Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ jobs:
- name: Smoke tests
run: pnpm test:smoke

- name: Install reference renderer
working-directory: .shins
run: npm ci --omit=dev --no-audit --no-fund

- name: Check reference versions and schema isolation
run: |
cp -R dist "$RUNNER_TEMP/schemas-before-docs"
: > .tags
pnpm build:docs
node tests/reference-versions.cjs
cmp dist/api.bundled.json build/docs/api.bundled.json
pnpm build
diff -r "$RUNNER_TEMP/schemas-before-docs" dist

- name: Dry run release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down
40 changes: 40 additions & 0 deletions assets/reference.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
:root {
--reference-paper: #fff;
--reference-mist: #f5f8f8;
--reference-ink: #243337;
--reference-muted: #586b70;
--reference-rule: rgba(36, 51, 55, .12);
--reference-teal: #087d80;
--reference-selected: #edf8f7;
}
.reference-tools { margin: 4px 16px 16px; color: var(--reference-ink); font-size: 13px; font-weight: 400; line-height: 1.4; }
.reference-tools details { position: relative; }
.reference-tools summary { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 44px; box-sizing: border-box; padding: 8px 12px; border: 1px solid var(--reference-rule); border-radius: 6px; cursor: pointer; list-style: none; }
.reference-tools summary::-webkit-details-marker { display: none; }
.reference-tools summary > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reference-tools summary:hover,.reference-menu a:hover { background: var(--reference-mist); }
.reference-tools summary:active,.reference-menu a:active,.reference-menu a[aria-current] { background: var(--reference-selected); color: var(--reference-teal); }
.reference-tools summary:focus-visible,.reference-tools a:focus-visible,.reference-archive a:focus-visible { outline: 2px solid var(--reference-teal); outline-offset: 2px; }
.reference-tools svg { flex-shrink: 0; color: var(--reference-muted); }
.reference-tools details[open] > summary svg { transform: rotate(180deg); }
.reference-menu { border: 1px solid var(--reference-rule); border-radius: 6px; margin-top: 4px; padding: 4px; background: var(--reference-paper); }
.reference-menu a { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 44px; box-sizing: border-box; padding: 8px; border-radius: 4px; color: var(--reference-ink); text-decoration: none; }
.reference-mobile { display: none; }
.content > .reference-archive { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-right: 50%; padding: 12px 28px; border-bottom: 1px solid var(--reference-rule); color: var(--reference-muted); font-size: 12px; line-height: 1.5; }
.reference-archive a { white-space: nowrap; color: var(--reference-teal); text-decoration: none; }
.reference-archive a:hover { text-decoration: underline; }
body[data-reference-versioned] h1#shotstack { margin-top: 0; padding-top: 48px; padding-bottom: 24px; font-size: clamp(32px, 3vw, 44px); line-height: 1.15; letter-spacing: -.025em; }
@media (max-width: 930px) {
body[data-reference-versioned] .toc-wrapper:not(.open) { visibility: hidden; }
.reference-mobile { display: block; position: relative; z-index: 60; padding: 12px 16px 12px 64px; background: var(--reference-paper); border-bottom: 1px solid var(--reference-rule); }
.reference-mobile .reference-tools { margin: 0; }
.reference-mobile .reference-menu { position: absolute; z-index: 1; top: 100%; left: 0; right: 0; }
body[data-reference-versioned] #nav-button { top: 14px; left: 12px; padding: 0; width: 40px; height: 40px; border: 1px solid var(--reference-rule); border-radius: 8px; opacity: 1; }
body[data-reference-versioned] #nav-button span { display: flex; align-items: center; justify-content: center; height: 100%; padding: 0; transform: none; background: var(--reference-paper); font-size: 0; }
body[data-reference-versioned] #nav-button img { transform: rotate(90deg); }
body[data-reference-versioned] #nav-button.open { left: 242px; }
body[data-reference-versioned] .page-wrapper .lang-selector { top: 68px; }
}
@media (max-width: 700px) {
.content > .reference-archive { margin-right: 0; padding: 16px 20px; }
}
12 changes: 12 additions & 0 deletions assets/reference.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
document.addEventListener('click', (event) => {
document.querySelectorAll('.reference-tools details[open]').forEach((menu) => {
if (!menu.contains(event.target)) menu.open = false;
});
});
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
document.querySelectorAll('.reference-tools details[open]').forEach((menu) => {
if (menu.contains(document.activeElement)) menu.querySelector('summary').focus();
menu.open = false;
});
});
44 changes: 26 additions & 18 deletions build-docs.sh
Original file line number Diff line number Diff line change
@@ -1,52 +1,60 @@
#!/bin/bash -e
set -e

DOCS_DIR=build/docs
OAS3_YAML=api.oas3.yaml
DOCS_DIR=${2:-build/docs}
OAS3_YAML=${1:-api.oas3.yaml}
OAS3_JSON=$DOCS_DIR/api.bundled.json
mkdir -p $DOCS_DIR
if [ "$#" -eq 0 ]; then
rm -rf build/docs
fi
mkdir -p "$DOCS_DIR"

# Validate OpenAPI 3.0 YAML
./node_modules/.bin/swagger-cli validate $OAS3_YAML
./node_modules/.bin/swagger-cli validate "$OAS3_YAML"

# Resolve YAML files in to one master JSON file
./node_modules/.bin/swagger-cli bundle -o $OAS3_JSON $OAS3_YAML -t json
./node_modules/.bin/swagger-cli bundle -o "$OAS3_JSON" "$OAS3_YAML" -t json

# Split bundled spec into per-API JSON files (api.edit.json, api.serve.json, api.ingest.json)
node scripts/split-by-api.cjs $OAS3_JSON $DOCS_DIR
node scripts/split-by-api.cjs "$OAS3_JSON" "$DOCS_DIR"

# Convert OpenAPI to doc to Shins Markdown
./node_modules/.bin/widdershins \
--theme dracula \
--language_tabs shell:Curl http:HTTP javascript--nodejs:NodeJS php:PHP ruby:Ruby python:Python java:Java go:Go \
--summary $OAS3_JSON \
--outfile $DOCS_DIR/index.html.md
--summary "$OAS3_JSON" \
--outfile "$DOCS_DIR/index.html.md"

cp $DOCS_DIR/index.html.md .shins/source/index.html.md
cp "$DOCS_DIR/index.html.md" .shins/source/index.html.md

# Replace Serve, Ingest API URL's as overrides do not work
sed -i -e 's/https:\/\/api.shotstack.io\/edit\/{version}\/assets/https:\/\/api.shotstack.io\/serve\/{version}\/assets/g' .shins/source/index.html.md
sed -i -e 's/https:\/\/api.shotstack.io\/edit\/{version}\/sources/https:\/\/api.shotstack.io\/ingest\/{version}\/sources/g' .shins/source/index.html.md

# Build the Shins docs HTML
cd .shins
rm -f index.html
node shins.js \
--logo ../assets/img/logo.svg \
--logo-url https://shotstack.io \
--customCss --minify
rm -f source/index.html.md-e
cd ..

mkdir -p $DOCS_DIR/source/images
cp .shins/index.html ./$DOCS_DIR/.
cp -r .shins/pub ./$DOCS_DIR/.
cp -r .shins/source/images/custom_logo.svg ./$DOCS_DIR/source/images/custom_logo.svg
cp -r .shins/source/images/navbar.png ./$DOCS_DIR/source/images/navbar.png
cp -r .shins/source/fonts ./$DOCS_DIR/source/fonts
mkdir -p "$DOCS_DIR/source/images"
cp .shins/index.html "$DOCS_DIR/"
cp -r .shins/pub "$DOCS_DIR/"
cp .shins/source/images/custom_logo.svg "$DOCS_DIR/source/images/custom_logo.svg"
cp .shins/source/images/navbar.png "$DOCS_DIR/source/images/navbar.png"
cp -r .shins/source/fonts "$DOCS_DIR/source/fonts"

# Insert Google Analytics
if [ -f .tags ]; then
sed -i.tmp -e '/{{TAGS}}/r.tags' -e '/{{TAGS}}/d' ./$DOCS_DIR/index.html
rm -f ./$DOCS_DIR/index.html.tmp
sed -i.tmp -e '/{{TAGS}}/r.tags' -e '/{{TAGS}}/d' "$DOCS_DIR/index.html"
rm -f "$DOCS_DIR/index.html.tmp"
fi

rm -f ./$DOCS_DIR/index.html.md
rm -f "$DOCS_DIR/index.html.md"
if [ "$#" -eq 0 ]; then
node scripts/build-reference-versions.cjs
fi
43 changes: 43 additions & 0 deletions docs/reference/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Reference website versions

`versions.json` controls the public reference website. The `current` entry uses
the live schema at `/docs/api/`. Package exports, SDK generation and the existing
public JSON URLs continue to use the live schema.

Run `pnpm build:docs` with the repository's Node version and installed Shins
runtime dependencies. `DOCS_VERSIONS_ENABLED=1 pnpm build:docs` exposes version
navigation when the catalogue contains more than one entry. Archived pages are
built in both modes so disabling navigation preserves their URLs.

## Capture a reference

Before changing a supported API contract, build the agreed source revision with
`pnpm build:docs`, then run `node scripts/capture-reference.cjs <reference-id>`.
This writes `docs/reference/snapshots/<reference-id>.json` and refuses to overwrite
an existing snapshot. It resolves example file references that can survive the
normal OpenAPI bundle, so the archive does not depend on live source files.
Add an entry with:

- `id`: a unique lowercase name containing letters, digits or hyphens.
- `label`: the reference version customers should see.
- `spec`: the snapshot's repository-relative path.
- `sourceCommit`: the full 40-character source commit SHA.

Use an agreed contract version for the label; capturing documentation does not
create a runtime API version. Do not edit the bundled snapshot's `info.version`
to control the website label. Review the snapshot and catalogue together.

Each archive is rendered at `/docs/api/versions/<reference-id>/`, including its
own assets and bundled, Edit, Serve and Ingest JSON downloads. Rendering is
sequential because Shins shares scratch files. A clean build includes every
catalogued reference before deployment.

Keep references available while their contracts are supported. Compatible
additions or corrections may require an intentional snapshot update using a
schema that still describes that contract. Removing a catalogue entry removes
its output on the next deployment; confirm the contract has no remaining users
before retiring its reference.

Selecting a reference changes documentation only, not an account's API behaviour.
The initial catalogue contains only the current reference. Tests use temporary
sample versions and do not publish a fictional API version.
5 changes: 5 additions & 0 deletions docs/reference/versions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"versions": [
{ "id": "current", "label": "Current reference", "spec": null }
]
}
67 changes: 67 additions & 0 deletions scripts/build-reference-versions.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');

const { versions } = JSON.parse(fs.readFileSync('docs/reference/versions.json', 'utf8'));
if (!Array.isArray(versions) || versions.filter((v) => v?.id === 'current').length !== 1) {
throw new Error('The catalogue must contain exactly one current reference.');
}
const ids = new Set();
for (const version of versions) {
if (!version || typeof version.id !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(version.id) || typeof version.label !== 'string' || !version.label.trim()) {
throw new Error('Each reference needs a valid id and label.');
}
if (ids.has(version.id)) throw new Error(`Duplicate reference id: ${version.id}`);
ids.add(version.id);
if (version.id === 'current') {
if (version.spec !== null) throw new Error('The current reference must use the live schema.');
} else {
if (typeof version.spec !== 'string' || !/^[a-f0-9]{40}$/.test(version.sourceCommit)) {
throw new Error('Archived references need a snapshot path and sourceCommit.');
}
const snapshots = fs.realpathSync('docs/reference/snapshots');
const relative = path.relative(snapshots, fs.realpathSync(version.spec));
if (relative.startsWith('..') || path.isAbsolute(relative) || !relative.endsWith('.json')) {
throw new Error('Reference snapshots must be JSON files within docs/reference/snapshots.');
}
JSON.parse(fs.readFileSync(version.spec, 'utf8'));
}
}

const directory = (v) => v.id === 'current' ? 'build/docs' : `build/docs/versions/${v.id}`;
const url = (v) => v.id === 'current' ? '/docs/api/' : `/docs/api/versions/${v.id}/`;
const escape = (value) => value.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const navigation = process.env.DOCS_VERSIONS_ENABLED === '1' && versions.length > 1;

// Shins writes shared scratch files, so references must render sequentially.
for (const version of versions.filter((v) => v.id !== 'current')) {
execFileSync('bash', ['build-docs.sh', version.spec, directory(version)], { stdio: 'inherit' });
}

const chevron = '<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="m4 6 4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>';
for (const version of versions) {
if (!navigation && version.id === 'current') continue;
const options = versions.map((v) => `<a href="${url(v)}"${v.id === version.id ? ' aria-current="page"' : ''}>
<span>${escape(v.label)}</span>${v.id === version.id ? '<span aria-hidden="true">✓</span>' : ''}</a>`).join('');
const tools = navigation ? `<div class="reference-tools"><details name="reference-tools">
<summary aria-label="Reference version: ${escape(version.label)}"><span>${escape(version.label)}</span>${chevron}</summary>
<nav class="reference-menu" aria-label="Reference versions">${options}</nav>
</details></div>` : '';
const mobile = navigation ? `<header class="reference-mobile" aria-label="Reference tools">${tools}</header>` : '';
const notice = version.id === 'current' ? '' : `<div class="reference-archive" role="note">
<span>${navigation ? 'Archived reference' : escape(version.label)}</span><a href="/docs/api/">View current <span aria-hidden="true">→</span></a></div>`;
const file = `${directory(version)}/index.html`;
let html = fs.readFileSync(file, 'utf8');
const logo = /(<a[^>]*><img[^>]*class="logo"[^>]*><\/a>)/;
if (!logo.test(html) || !html.includes('<div class="content">') || !html.includes('</head>')) {
throw new Error(`Reference layout markers missing: ${file}`);
}
html = html.replace('<body ', '<body data-reference-versioned ')
.replace('id="nav-button"', 'id="nav-button" aria-label="Toggle navigation"')
.replace('</head>', '<link rel="stylesheet" href="pub/css/reference.css"><script src="pub/js/reference.js" defer></script></head>')
.replace(logo, (match) => `${match}${tools}`)
.replace('<div class="content">', `<div class="content">${mobile}${notice}`);
fs.writeFileSync(file, html);
fs.copyFileSync('assets/reference.css', `${directory(version)}/pub/css/reference.css`);
fs.copyFileSync('assets/reference.js', `${directory(version)}/pub/js/reference.js`);
}
18 changes: 18 additions & 0 deletions scripts/capture-reference.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const rendererRequire = createRequire(require.resolve('widdershins'));
const { resolve } = rendererRequire('oas-resolver');

const id = process.argv[2];
if (!id || id === 'current' || !/^[a-z0-9][a-z0-9-]*$/.test(id)) {
throw new Error('Usage: node scripts/capture-reference.cjs <reference-id>');
}
const input = path.resolve('build/docs/api.bundled.json');
// Example references can survive OpenAPI bundling; resolve them before relocation.
resolve(JSON.parse(fs.readFileSync(input, 'utf8')), input, {})
.then(({ openapi }) => {
fs.mkdirSync('docs/reference/snapshots', { recursive: true });
fs.writeFileSync(`docs/reference/snapshots/${id}.json`, `${JSON.stringify(openapi, null, 2)}\n`, { flag: 'wx' });
})
.catch((error) => { console.error(error.message); process.exitCode = 1; });
Loading
Loading