diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea1ee1d..6e70ba4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} diff --git a/assets/reference.css b/assets/reference.css new file mode 100644 index 0000000..9fd69ae --- /dev/null +++ b/assets/reference.css @@ -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; } +} diff --git a/assets/reference.js b/assets/reference.js new file mode 100644 index 0000000..47e3fa7 --- /dev/null +++ b/assets/reference.js @@ -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; + }); +}); diff --git a/build-docs.sh b/build-docs.sh index 4fe0154..a9ab0ca 100755 --- a/build-docs.sh +++ b/build-docs.sh @@ -1,27 +1,31 @@ #!/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 @@ -29,6 +33,7 @@ sed -i -e 's/https:\/\/api.shotstack.io\/edit\/{version}\/sources/https:\/\/api. # Build the Shins docs HTML cd .shins +rm -f index.html node shins.js \ --logo ../assets/img/logo.svg \ --logo-url https://shotstack.io \ @@ -36,17 +41,20 @@ node shins.js \ 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 diff --git a/docs/reference/README.md b/docs/reference/README.md new file mode 100644 index 0000000..9ffb9d0 --- /dev/null +++ b/docs/reference/README.md @@ -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 `. +This writes `docs/reference/snapshots/.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//`, 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. diff --git a/docs/reference/versions.json b/docs/reference/versions.json new file mode 100644 index 0000000..9e1fff7 --- /dev/null +++ b/docs/reference/versions.json @@ -0,0 +1,5 @@ +{ + "versions": [ + { "id": "current", "label": "Current reference", "spec": null } + ] +} diff --git a/scripts/build-reference-versions.cjs b/scripts/build-reference-versions.cjs new file mode 100644 index 0000000..31d8646 --- /dev/null +++ b/scripts/build-reference-versions.cjs @@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 = ''; +for (const version of versions) { + if (!navigation && version.id === 'current') continue; + const options = versions.map((v) => ` + ${escape(v.label)}${v.id === version.id ? '' : ''}`).join(''); + const tools = navigation ? `
+ ${escape(version.label)}${chevron} + +
` : ''; + const mobile = navigation ? `
${tools}
` : ''; + const notice = version.id === 'current' ? '' : `
+ ${navigation ? 'Archived reference' : escape(version.label)}View current
`; + const file = `${directory(version)}/index.html`; + let html = fs.readFileSync(file, 'utf8'); + const logo = /(]*>]*class="logo"[^>]*><\/a>)/; + if (!logo.test(html) || !html.includes('
') || !html.includes('')) { + throw new Error(`Reference layout markers missing: ${file}`); + } + html = html.replace('', '') + .replace(logo, (match) => `${match}${tools}`) + .replace('
', `
${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`); +} diff --git a/scripts/capture-reference.cjs b/scripts/capture-reference.cjs new file mode 100644 index 0000000..785855f --- /dev/null +++ b/scripts/capture-reference.cjs @@ -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 '); +} +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; }); diff --git a/tests/reference-versions.cjs b/tests/reference-versions.cjs new file mode 100644 index 0000000..9506fc7 --- /dev/null +++ b/tests/reference-versions.cjs @@ -0,0 +1,103 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const root = path.resolve(__dirname, '..'); +const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'reference versions-')); +const write = (file, data) => fs.writeFileSync(path.join(workspace, file), data); +const read = (file) => fs.readFileSync(path.join(workspace, file), 'utf8'); +const run = (command, args, enabled = '0') => spawnSync(command, args, { + cwd: workspace, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, + env: { ...process.env, DOCS_VERSIONS_ENABLED: enabled }, +}); +const succeeds = (result) => assert.equal(result.status, 0, result.stdout + result.stderr); +const spec = (name) => ({ + openapi: '3.0.3', info: { title: 'Reference fixture', version: 'v1' }, + servers: [{ url: 'https://example.com/edit/v1' }], + tags: [{ name: 'Edit' }], + paths: { [`/${name}`]: { get: { + tags: ['Edit'], operationId: name, summary: `${name} operation`, + responses: { 200: { description: 'OK' } }, + } } }, +}); +const versions = [ + { id: 'current', label: 'Current reference', spec: null }, + { id: 'earlier', label: 'Earlier ', spec: 'docs/reference/snapshots/earlier.json', sourceCommit: 'a'.repeat(40) }, +]; +const catalogue = (entries) => write('docs/reference/versions.json', JSON.stringify({ versions: entries })); + +try { + for (const file of ['build-docs.sh', 'scripts', 'assets', '.shins']) { + fs.cpSync(path.join(root, file), path.join(workspace, file), { + recursive: true, filter: (source) => path.basename(source) !== 'node_modules', + }); + } + fs.symlinkSync(path.join(root, 'node_modules'), path.join(workspace, 'node_modules')); + fs.symlinkSync(path.join(root, '.shins/node_modules'), path.join(workspace, '.shins/node_modules')); + fs.mkdirSync(path.join(workspace, 'docs/reference/snapshots'), { recursive: true }); + write('.tags', ''); + write('api.oas3.yaml', JSON.stringify(spec('current'))); + catalogue(versions); + fs.mkdirSync(path.join(workspace, 'build/docs'), { recursive: true }); + const archived = spec('archived'); + archived.components = { schemas: { + Message: { type: 'object', properties: { message: { type: 'string' } } }, + Example: { $ref: '#/components/schemas/Message', example: { $ref: '../../example.json' } }, + } }; + write('build/docs/api.bundled.json', JSON.stringify(archived)); + write('example.json', '{"message":"Archived example"}'); + succeeds(run(process.execPath, ['scripts/capture-reference.cjs', 'earlier'])); + assert.doesNotMatch(read('docs/reference/snapshots/earlier.json'), /\.\.\/\.\.\/example.json/); + assert.match(read('docs/reference/snapshots/earlier.json'), /Archived example/); + fs.unlinkSync(path.join(workspace, 'example.json')); + write('build/docs/stale.txt', 'obsolete'); + + succeeds(run('bash', ['build-docs.sh'])); + assert.ok(fs.existsSync(path.join(workspace, 'build/docs/versions/earlier/index.html')), 'build every archive with navigation disabled'); + assert.ok(!fs.existsSync(path.join(workspace, 'build/docs/stale.txt')), 'remove stale build output'); + assert.doesNotMatch(read('build/docs/index.html'), /aria-label="Reference versions"/); + const currentJson = read('build/docs/api.bundled.json'); + + succeeds(run('bash', ['build-docs.sh'], '1')); + assert.equal(read('build/docs/api.bundled.json'), currentJson, 'navigation must not alter schemas'); + for (const [directory, operation, selected] of [ + ['build/docs', 'current', '/docs/api/'], + ['build/docs/versions/earlier', 'archived', '/docs/api/versions/earlier/'], + ]) { + const html = read(`${directory}/index.html`); + assert.ok(html.includes(`href="${selected}" aria-current="page"`)); + assert.match(html, /aria-label="Reference versions"/); + assert.doesNotMatch(html, /