diff --git a/.github/workflows/ci-catalog.yml b/.github/workflows/ci-catalog.yml new file mode 100644 index 0000000000..5325fa90ea --- /dev/null +++ b/.github/workflows/ci-catalog.yml @@ -0,0 +1,111 @@ +name: CI Catalog + +# Metadata-only gate for the demo and example catalog. Fast by design: it +# validates manifests and the published-slug baseline without building or +# browser-testing anything, so a naming mistake fails in under a minute. +# +# AIDEV-NOTE: this runs on every pull request rather than filtering with +# `on.paths`, because it is a required check. A path-filtered workflow never +# reports on an unrelated change, and a required check that never reports +# blocks the pull request forever. `detect` decides whether the real work is +# needed and `validate` reports either way, matching ci-examples. + +on: + pull_request: + merge_group: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-catalog-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + detect: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + catalog: ${{ steps.set.outputs.catalog }} + steps: + - uses: actions/checkout@v6 + + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + if: github.event_name == 'pull_request' + with: + filters: | + catalog: + - '.github/workflows/ci-catalog.yml' + - 'demos/manifest.json' + - 'examples/manifest.json' + # The published-slug baseline is half of the permanence check. + # Without it here, a pull request editing only the baseline runs + # no validation, and a later one could drop the matching manifest + # slug against an already weakened baseline. + - 'go-links/published-slugs.json' + - 'scripts/validate-examples-demos.ts' + - 'scripts/__tests__/validate-examples-demos-slug.test.mjs' + + - id: set + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "catalog=${{ steps.filter.outputs.catalog }}" >> "$GITHUB_OUTPUT" + else + echo "catalog=true" >> "$GITHUB_OUTPUT" + fi + + catalog: + needs: detect + if: needs.detect.outputs.catalog == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: package.json + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + # No dependency install. Both commands below import only node: builtins, + # verified by running them with node_modules absent. Installing would add + # the only network step in this job for no gain, and the local lane skips + # it too, so the two cannot drift. + + - name: Validate demo and example metadata + run: pnpm run check:examples-demos + + - name: Test the metadata validator + run: pnpm run check:examples-demos:tests + + validate: + name: CI Catalog / validate + if: always() + needs: [detect, catalog] + runs-on: ubuntu-latest + steps: + - name: Check results + run: | + if [[ "${{ needs.detect.result }}" != "success" ]]; then + echo "Detect job did not succeed (result: ${{ needs.detect.result }})." + exit 1 + fi + if [[ "${{ needs.detect.outputs.catalog }}" != "true" ]]; then + echo "Catalog CI skipped: no relevant paths changed." + exit 0 + fi + if [[ "${{ needs.catalog.result }}" != "success" ]]; then + echo "Catalog validation did not succeed (result: ${{ needs.catalog.result }})." + exit 1 + fi diff --git a/.github/workflows/deploy-go-links.yml b/.github/workflows/deploy-go-links.yml new file mode 100644 index 0000000000..a8b9e41e1f --- /dev/null +++ b/.github/workflows/deploy-go-links.yml @@ -0,0 +1,79 @@ +name: Deploy Go Links + +# Publishes go.superdoc.dev, the permanent URL for every demo and example +# carrying a `slug` in the manifests. +# +# The redirect table is built by linkkeeper, pinned to an exact version, from +# the two manifests in this repository. Everything it needs is here, so there is +# no second repository to keep in sync and no cross-repository dispatch that +# could silently fail to fire. +# +# This lives here rather than in Orbit's export workflow on purpose: the export +# pushes directly to this repository's `main`, so by the time this runs the +# manifests are already public and `github.sha` is the exported commit. Orbit's +# export is proof-gated and fail-closed; it should not also own deployments +# downstream of it. + +on: + push: + branches: [main] + paths: + - '.github/workflows/deploy-go-links.yml' + - 'demos/manifest.json' + - 'examples/manifest.json' + - 'go-links/**' + schedule: + # 06:17 UTC. Off the hour on purpose: scheduled jobs at :00 queue behind + # everyone else's. + - cron: '17 6 * * *' + workflow_dispatch: + +# Read-only. Deploy credentials reach this job through secrets, which are not +# available to pull_request_target and must never be exposed to one. +permissions: + contents: read + +concurrency: + group: deploy-go-links + cancel-in-progress: false + +env: + # Pinned deliberately. An unpinned tool could change the published redirect + # table without anything in this repository changing, which is a strange + # property for a service whose promise is that URLs do not move. + LINKKEEPER_VERSION: 0.1.0 + +jobs: + deploy: + # Fail-closed on the ref. `workflow_dispatch` lets a caller pick any branch, + # and this job holds the credentials that publish go.superdoc.dev, so an + # unmerged branch must not be able to deploy redirects. + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: go-links + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + # A published link that 404s reads as a deleted example, which is worse + # than never having published it. Manifests drift, so every destination is + # resolved for real before anything goes live. + - name: Check every destination resolves + run: npx --yes "linkkeeper@${LINKKEEPER_VERSION}" check --commit "${GITHUB_SHA}" + + - name: Build the redirect table + run: npx --yes "linkkeeper@${LINKKEEPER_VERSION}" build --commit "${GITHUB_SHA}" + + - name: Deploy to Cloudflare Pages + uses: cloudflare/wrangler-action@v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + workingDirectory: go-links + command: pages deploy ./dist --project-name=linkkeeper --branch=main diff --git a/apps/docs/document-api/features/content-controls.mdx b/apps/docs/document-api/features/content-controls.mdx index 8868259a4a..f4dd617db2 100644 --- a/apps/docs/document-api/features/content-controls.mdx +++ b/apps/docs/document-api/features/content-controls.mdx @@ -68,7 +68,7 @@ for (const control of items) { Or keep clauses **single-use and governed**: a clause is either in the contract or available to add from a library, and it appears once. Track inclusion by querying `contentControls.list` for the `sectionId` instead of comparing versions, and lock each placed clause (`contentLocked`) so its prose is fixed. A clause can also carry nested smart fields - inline controls inside the block - that fill from one place. -The [`demos/contract-templates`](https://github.com/superdoc/docx-editor/tree/main/demos/contract-templates) runtime composes the single-use approach: a clause library that inserts locked block clauses (some with nested fields), each filled by tag from a form. +The [`demos/contract-templates`](https://go.superdoc.dev/contract-templates) runtime composes the single-use approach: a clause library that inserts locked block clauses (some with nested fields), each filled by tag from a form. ## Why `tag`, not `nodeId` @@ -159,7 +159,7 @@ For runtime synchronization with backing data, drive the control directly with ` The smallest content-control workflow. `create.contentControl` + `selectByTag` + `text.setValue`. - + Smart fields and versioned sections composed into one runtime app. diff --git a/apps/docs/editor/built-in-ui/comments.mdx b/apps/docs/editor/built-in-ui/comments.mdx index 952c8d5d3f..1e098ea358 100644 --- a/apps/docs/editor/built-in-ui/comments.mdx +++ b/apps/docs/editor/built-in-ui/comments.mdx @@ -746,7 +746,7 @@ onCommentsUpdate: ({ type, comment, meta }) => { Runnable example: threaded comments with resolve workflow and event log diff --git a/apps/docs/editor/built-in-ui/toolbar.mdx b/apps/docs/editor/built-in-ui/toolbar.mdx index 25ae91ea56..096293fc93 100644 --- a/apps/docs/editor/built-in-ui/toolbar.mdx +++ b/apps/docs/editor/built-in-ui/toolbar.mdx @@ -611,7 +611,7 @@ const superdoc = new SuperDoc({ Runnable example: custom button groups, excluded items, and a custom clear-formatting button diff --git a/apps/docs/editor/built-in-ui/track-changes.mdx b/apps/docs/editor/built-in-ui/track-changes.mdx index 914b8e924f..c3f61f9c00 100644 --- a/apps/docs/editor/built-in-ui/track-changes.mdx +++ b/apps/docs/editor/built-in-ui/track-changes.mdx @@ -482,7 +482,7 @@ superdoc.activeEditor.commands.toggleTrackChangesShowFinal(); Runnable example: mode switching, accept and reject, comments sidebar, DOCX import and export. diff --git a/apps/docs/editor/custom-ui/content-controls.mdx b/apps/docs/editor/custom-ui/content-controls.mdx index 54c34f66e7..87321695e8 100644 --- a/apps/docs/editor/custom-ui/content-controls.mdx +++ b/apps/docs/editor/custom-ui/content-controls.mdx @@ -95,6 +95,6 @@ Putting it together into a fillable template, the way the contract-templates dem ## See also -- [Contract templates demo](https://github.com/superdoc/docx-editor/tree/main/demos/contract-templates) - a full custom contract-template UI: a field + clause library, custom SDT styling, locks, form-driven values, events, insert, and export. +- [Contract templates demo](https://go.superdoc.dev/contract-templates) - a full custom contract-template UI: a field + clause library, custom SDT styling, locks, form-driven values, events, insert, and export. - [Configuration](/editor/superdoc/configuration) - the `modules.contentControls.chrome` option. - [Document API: content controls](/document-api/features/content-controls) - read and change controls. diff --git a/apps/docs/editor/custom-ui/controller-setup.mdx b/apps/docs/editor/custom-ui/controller-setup.mdx index b5eb583a8b..2a228dbb6b 100644 --- a/apps/docs/editor/custom-ui/controller-setup.mdx +++ b/apps/docs/editor/custom-ui/controller-setup.mdx @@ -18,7 +18,7 @@ The controller exposes the same surface the React hooks consume. Domain handles, ## Install ```bash -pnpm add superdoc +pnpm add superdoc@1 ``` ## Create the controller diff --git a/apps/docs/editor/custom-ui/react-setup.mdx b/apps/docs/editor/custom-ui/react-setup.mdx index 7d2452bf1d..11dd484842 100644 --- a/apps/docs/editor/custom-ui/react-setup.mdx +++ b/apps/docs/editor/custom-ui/react-setup.mdx @@ -12,7 +12,7 @@ Not using React? See [Controller setup](/editor/custom-ui/controller-setup) for ## Install ```bash -pnpm add superdoc @superdoc-dev/react +pnpm add superdoc@1 @superdoc-dev/react@1 ``` ## Wire the provider diff --git a/apps/docs/editor/react/overview.mdx b/apps/docs/editor/react/overview.mdx index 0cf398f695..25e9ae4169 100644 --- a/apps/docs/editor/react/overview.mdx +++ b/apps/docs/editor/react/overview.mdx @@ -11,7 +11,7 @@ Building your own toolbar, comments sidebar, or review panel? Pair ` diff --git a/apps/docs/editor/superdoc/configuration.mdx b/apps/docs/editor/superdoc/configuration.mdx index 9842e176ec..7e8385707e 100644 --- a/apps/docs/editor/superdoc/configuration.mdx +++ b/apps/docs/editor/superdoc/configuration.mdx @@ -112,7 +112,7 @@ new SuperDoc({ - `viewing` - Read-only display - `suggesting` - Track changes enabled - See the [Track Changes module](/editor/built-in-ui/track-changes) for accept/reject commands, the Document API, and configuration. The [runnable example](https://github.com/superdoc/docx-editor/tree/main/examples/editor/built-in-ui/track-changes) shows a complete workflow. + See the [Track Changes module](/editor/built-in-ui/track-changes) for accept/reject commands, the Document API, and configuration. The [runnable example](https://go.superdoc.dev/track-changes) shows a complete workflow. diff --git a/apps/docs/getting-started/fonts.mdx b/apps/docs/getting-started/fonts.mdx index 840b1a64a6..689d284d2b 100644 --- a/apps/docs/getting-started/fonts.mdx +++ b/apps/docs/getting-started/fonts.mdx @@ -76,7 +76,7 @@ Use `fonts.resolveAssetUrl` instead for signed or versioned URLs. The `superdoc` CDN build ships no fonts: by default the toolbar shows the baseline and documents render with system fonts. To load the reviewed pack, add the `@superdoc-dev/fonts` browser build and pass the `SuperDocFonts` global. ```html - + +
@@ -179,4 +179,4 @@ document.getElementById('export-btn').addEventListener('click', async () => { - [API Reference](/editor/superdoc/configuration) - Configuration options - [React Integration](/getting-started/frameworks/react) - Using with React -- [Vue Integration](/getting-started/frameworks/vue) - Using with Vue \ No newline at end of file +- [Vue Integration](/getting-started/frameworks/vue) - Using with Vue diff --git a/apps/docs/getting-started/frameworks/vue.mdx b/apps/docs/getting-started/frameworks/vue.mdx index 646ed82470..df3bf50895 100644 --- a/apps/docs/getting-started/frameworks/vue.mdx +++ b/apps/docs/getting-started/frameworks/vue.mdx @@ -8,7 +8,7 @@ SuperDoc works with Vue 3.0+ using Composition API, Options API, or ` + + ``` `SuperDoc` becomes a global: use `new SuperDoc({...})` directly. diff --git a/apps/docs/guides/collaboration/hocuspocus.mdx b/apps/docs/guides/collaboration/hocuspocus.mdx index 197158b41f..052f14577f 100644 --- a/apps/docs/guides/collaboration/hocuspocus.mdx +++ b/apps/docs/guides/collaboration/hocuspocus.mdx @@ -348,7 +348,7 @@ Server.configure({ Complete source code diff --git a/apps/docs/llms-full.txt b/apps/docs/llms-full.txt index 9d8bb384db..6f005e0d4b 100644 --- a/apps/docs/llms-full.txt +++ b/apps/docs/llms-full.txt @@ -41,8 +41,8 @@ SuperDoc has four integration surfaces: Embed a DOCX editor in any web application. React, Vue, Angular, Svelte, or vanilla JS. ```bash -npm install superdoc # vanilla JS -npm install @superdoc-dev/react # React +npm install superdoc@1 # vanilla JS +npm install superdoc@1 @superdoc-dev/react@1 # React ``` ```javascript diff --git a/apps/docs/package.json b/apps/docs/package.json index 59b09fb2f7..ddb971839a 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -3,7 +3,7 @@ "private": true, "scripts": { "dev": "mintlify dev --port 3001", - "validate": "mintlify validate", + "validate": "node --test scripts/validate-v1-package-pins.test.mjs && mintlify validate", "gen:api": "node scripts/sync-api-docs.js", "gen:docs": "node scripts/sync-sdk-docs.js", "gen:all": "pnpm run gen:api && pnpm run gen:docs", diff --git a/apps/docs/scripts/validate-v1-package-pins.test.mjs b/apps/docs/scripts/validate-v1-package-pins.test.mjs new file mode 100644 index 0000000000..d493bae9b5 --- /dev/null +++ b/apps/docs/scripts/validate-v1-package-pins.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { readdir, readFile } from 'node:fs/promises'; +import { extname } from 'node:path'; +import test from 'node:test'; + +const docsRoot = new URL('../', import.meta.url); +const scannedExtensions = new Set(['.jsx', '.mdx', '.txt']); +const installCommand = /(?:npm (?:install|i)|pnpm add|bun add|yarn add)\s+([^\n]+)/gu; +const staleNextTag = /(?:superdoc|@superdoc-dev\/react)@next\b/u; +const wrongV1AssetPath = /superdoc@1\/dist-cdn\b/u; +const browserPackageUrl = + /(?:cdn\.jsdelivr\.net\/npm|unpkg\.com)\/(?:superdoc|@superdoc-dev\/react)(?:@([^/]+))?\//gu; +const browserPackages = ['superdoc', '@superdoc-dev/react']; + +function hasUnsafeInstall(source) { + for (const match of source.matchAll(installCommand)) { + const packages = match[1].split(/\s+/u); + const installsReact = packages.some((entry) => entry.startsWith('@superdoc-dev/react')); + + for (const packageName of browserPackages) { + const packageEntry = packages.find((entry) => entry === packageName || entry.startsWith(`${packageName}@`)); + if (packageEntry && packageEntry !== `${packageName}@1`) return true; + } + + if (installsReact && !packages.includes('superdoc@1')) return true; + } + + return false; +} + +function hasUnsafeBrowserUrl(source) { + for (const match of source.matchAll(browserPackageUrl)) { + if (match[1] !== '1') return true; + } + + return false; +} + +test('the guard rejects unpinned installs from supported package managers', () => { + const unsafeExamples = [ + 'npm install superdoc', + 'npm i superdoc', + 'pnpm add superdoc', + 'bun add superdoc', + 'yarn add superdoc', + 'npm install @superdoc-dev/react@1', + 'npm install superdoc@1 @superdoc-dev/react', + ]; + + for (const example of unsafeExamples) assert.equal(hasUnsafeInstall(example), true, example); + + assert.equal(hasUnsafeInstall('pnpm add superdoc@1 @superdoc-dev/react@1'), false); +}); + +test('the guard rejects browser URLs outside the v1 major', () => { + assert.equal(hasUnsafeBrowserUrl('https://cdn.jsdelivr.net/npm/superdoc/dist/style.css'), true); + assert.equal(hasUnsafeBrowserUrl('https://unpkg.com/superdoc@latest/dist/style.css'), true); + assert.equal(hasUnsafeBrowserUrl('https://cdn.jsdelivr.net/npm/@superdoc-dev/react@latest/dist/style.css'), true); + assert.equal(hasUnsafeBrowserUrl('https://cdn.jsdelivr.net/npm/superdoc@1/dist/style.css'), false); + assert.equal(hasUnsafeBrowserUrl('https://unpkg.com/@superdoc-dev/react@1/dist/style.css'), false); +}); + +async function collectFiles(directory) { + const files = []; + + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.name === 'node_modules') continue; + + const child = new URL(entry.name + (entry.isDirectory() ? '/' : ''), directory); + if (entry.isDirectory()) files.push(...(await collectFiles(child))); + else if (scannedExtensions.has(extname(entry.name))) files.push(child); + } + + return files; +} + +test('the archived v1 docs pin browser packages to the v1 major', async () => { + const staleFiles = []; + + for (const file of await collectFiles(docsRoot)) { + const source = await readFile(file, 'utf8'); + if ( + hasUnsafeInstall(source) || + hasUnsafeBrowserUrl(source) || + staleNextTag.test(source) || + wrongV1AssetPath.test(source) + ) { + staleFiles.push(file.pathname); + } + } + + assert.deepEqual(staleFiles, []); +}); diff --git a/apps/docs/snippets/components/doc-counter.jsx b/apps/docs/snippets/components/doc-counter.jsx index ca2a50069d..8fa10bb8f7 100644 --- a/apps/docs/snippets/components/doc-counter.jsx +++ b/apps/docs/snippets/components/doc-counter.jsx @@ -13,7 +13,7 @@ export const DocCounter = ({ height = '350px' }) => { useEffect(() => { const link = document.createElement('link'); link.rel = 'stylesheet'; - link.href = 'https://cdn.jsdelivr.net/npm/superdoc@latest/dist/style.css'; + link.href = 'https://cdn.jsdelivr.net/npm/superdoc@1/dist/style.css'; document.head.appendChild(link); // Buffer polyfill: required for document hashing @@ -23,7 +23,7 @@ export const DocCounter = ({ height = '350px' }) => { window.Buffer = window.buffer.Buffer; const script = document.createElement('script'); - script.src = 'https://cdn.jsdelivr.net/npm/superdoc@latest/dist/superdoc.min.js'; + script.src = 'https://cdn.jsdelivr.net/npm/superdoc@1/dist/superdoc.min.js'; script.onload = () => setTimeout(() => initializeSuperdoc(), 100); document.body.appendChild(script); }; diff --git a/apps/docs/snippets/components/superdoc-editor.jsx b/apps/docs/snippets/components/superdoc-editor.jsx index 054ed624f3..5f36d497bc 100644 --- a/apps/docs/snippets/components/superdoc-editor.jsx +++ b/apps/docs/snippets/components/superdoc-editor.jsx @@ -10,7 +10,7 @@ export const SuperDocEditor = ({ const editorRef = useRef(null); const containerIdRef = useRef(`editor-${Math.random().toString(36).substr(2, 9)}`); const DEV_DIST_URL = 'http://localhost:9094/dist'; - const UNPKG_DIST_URL = 'https://unpkg.com/superdoc@latest/dist'; + const UNPKG_DIST_URL = 'https://unpkg.com/superdoc@1/dist'; const getBaseUrl = async () => { const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost'; diff --git a/demos/AGENTS.md b/demos/AGENTS.md index 6ca0cc4ceb..abe8bc9d17 100644 --- a/demos/AGENTS.md +++ b/demos/AGENTS.md @@ -10,6 +10,9 @@ If the work only teaches one primitive or one integration pattern, put it in `ex - Product-shaped UI, fake backend state, library data, or scenario copy when it helps the workflow make sense. - A README that explains the scenario, the features being composed, how to run it, and related examples or docs. - An entry in `demos/manifest.json`. +- A `slug` in that entry when the demo should get a permanent + `go.superdoc.dev` link. See `../docs/go-links.md`; a published + slug can never be renamed. Set `homepage: true` only when the demo is gallery-ready: verified locally, clear enough for users, and backed by the metadata or assets the homepage expects. Use `homepage: false` for source demos that are useful but not ready for the gallery. diff --git a/demos/manifest.json b/demos/manifest.json index 3e4bbb4edf..4f661ce954 100644 --- a/demos/manifest.json +++ b/demos/manifest.json @@ -15,7 +15,8 @@ "thumbnail": null, "liveUrl": null, "homepage": false, - "stackblitz": false + "stackblitz": false, + "slug": "contract-templates" }, { "id": "custom-ui", @@ -51,7 +52,8 @@ "thumbnail": "demos/grading-papers/demo-thumbnail.png", "liveUrl": null, "homepage": true, - "stackblitz": false + "stackblitz": false, + "slug": "grading-papers" }, { "id": "slack-redlining", @@ -87,7 +89,8 @@ "thumbnail": "demos/chrome-extension/demo-thumbnail.png", "liveUrl": null, "homepage": true, - "stackblitz": false + "stackblitz": false, + "slug": "chrome-extension" }, { "id": "word-addin", @@ -105,7 +108,8 @@ "thumbnail": "demos/word-addin/demo-thumbnail.png", "liveUrl": null, "homepage": true, - "stackblitz": false + "stackblitz": false, + "slug": "word-addin" }, { "id": "rag", @@ -123,7 +127,8 @@ "thumbnail": null, "liveUrl": "https://demos.superdoc.dev/rag", "homepage": true, - "stackblitz": false + "stackblitz": false, + "slug": "doc-rag" }, { "id": "esign", @@ -141,7 +146,8 @@ "thumbnail": null, "liveUrl": "https://demos.superdoc.dev/esign", "homepage": true, - "stackblitz": false + "stackblitz": false, + "slug": "esign" }, { "id": "template-builder", @@ -159,7 +165,8 @@ "thumbnail": null, "liveUrl": "https://demos.superdoc.dev/template-builder", "homepage": true, - "stackblitz": false + "stackblitz": false, + "slug": "template-builder" }, { "id": "pdf-sign", @@ -214,7 +221,8 @@ "liveUrl": null, "homepage": false, "stackblitz": true, - "review": "Candidate for an import/export or Document Engine example." + "review": "Candidate for an import/export or Document Engine example.", + "slug": "docx-from-html" }, { "id": "fields-source", @@ -252,7 +260,8 @@ "liveUrl": null, "homepage": false, "stackblitz": true, - "review": "Move to Advanced unless document sections become a primary docs surface." + "review": "Move to Advanced unless document sections become a primary docs surface.", + "slug": "linked-sections" }, { "id": "html-editor", @@ -290,7 +299,8 @@ "liveUrl": null, "homepage": false, "stackblitz": false, - "review": "Compare with examples/getting-started/nextjs before keeping." + "review": "Compare with examples/getting-started/nextjs before keeping.", + "slug": "nextjs-ssr" }, { "id": "shim-react", @@ -441,6 +451,7 @@ "thumbnail": null, "liveUrl": null, "homepage": false, - "stackblitz": false + "stackblitz": false, + "slug": "collaborative-agent" } ] diff --git a/docs/go-links.md b/docs/go-links.md new file mode 100644 index 0000000000..b813bacb48 --- /dev/null +++ b/docs/go-links.md @@ -0,0 +1,90 @@ +# Stable links for demos and examples + +`go.superdoc.dev/` is the permanent public URL for a demo or +example. It redirects to wherever that demo currently lives on GitHub. + +Link to it from documentation, the website, posts, and talks. Do not link +directly to a GitHub path: moving a directory does not leave a redirect behind, +so every link already published breaks silently. + +``` +go.superdoc.dev/react -> github.com//tree/main/examples/getting-started/react +``` + +Move the directory, update `sourcePath`, and the public URL keeps working. + +## Publishing an entry + +Add a `slug` to its manifest entry: + +```json +{ + "id": "getting-started-react", + "slug": "react", + "sourcePath": "examples/getting-started/react" +} +``` + +Slugs are opt-in. An entry without one is not published, which is the right +default for anything not yet worth a permanent name. + +## A slug is permanent + +Once a slug ships, links to it exist in places we do not control: blog posts, +Discord history, Stack Overflow answers, other people's documentation. Renaming +or removing one breaks all of them, and unlike a repository rename, GitHub gives +us nothing to fall back on. + +Treat naming a slug as naming a public API. Specifically: + +- Never rename a published slug. Add a second slug if a better name emerges. +- Never reuse a slug for different content, even long after the original is + archived. A stale link should break loudly rather than land somewhere wrong. +- A slug stays when an entry becomes `hidden` or `archived`. Those mean stop + advertising it, not stop answering links people already have. Withdrawing an + example should not break the URL we promised was permanent. +- `id` is the internal catalog key and is free to change with section renames. + That is the reason the two fields are separate. + +`go-links/published-slugs.json` records every slug that has shipped. The +validator compares the manifests against it, so removing or renaming a published +slug is a build failure rather than something noticed after the links break. +Publishing a new one means adding a line to that file in the same change. + +## Choosing a slug + +- Lowercase kebab-case: `track-changes`, not `trackChanges` or `Track_Changes`. +- Short enough to say out loud and type from memory. +- Name the concept, not its place in the current taxonomy. `toolbar`, not + `editor-built-in-toolbar`, because sections get reorganized and the URL + cannot. +- Specific enough to leave room. `doc-rag` rather than `rag`, so a second + retrieval example later is not stuck competing for the generic name. +- `active`, `hidden`, and `archived` entries can hold a slug. A `shim` cannot: a + shim stands in for an old path and is not a thing deserving a permanent name. + +`docs`, `live`, `source`, `health`, `index`, `api`, `assets`, and `404` are +reserved for service routes. + +`pnpm run check:examples-demos` enforces all of this. + +## How it is deployed + +`.github/workflows/deploy-go-links.yml` builds and deploys go.superdoc.dev on +every push to `main` that touches a manifest. It uses [linkkeeper][linkkeeper], +an open-source tool, pinned to an exact version, and reads the two manifests in +this repository directly. Publishing an example is only the `slug` edit above: +no second list to update anywhere. + +`go-links/` holds what the deploy needs and nothing else: which manifests to +read, and the 404 page served for a slug that is not published. + +linkkeeper also has its own registry format for projects with no catalog of +their own. We do not use it here on purpose: the manifests are already the +source of truth, and keeping a parallel registry would mean editing two files +every time something moves. + +Every destination is requested before the deploy, so a slug pointing at a moved +or deleted path fails the workflow rather than publishing a link that 404s. + +[linkkeeper]: https://github.com/caiopizzol/linkkeeper diff --git a/examples/AGENTS.md b/examples/AGENTS.md index c16a66ba3b..4d9ef174a7 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -11,6 +11,9 @@ If the work becomes a product workflow, a fake backend, a polished sidebar, or a - UI only large enough to exercise the concept. - A README that explains what the example teaches, how to run it, and related demos or docs. - An entry in `examples/manifest.json` and `examples/README.md`. +- A `slug` in that entry when the example should get a permanent + `go.superdoc.dev` link. See `../docs/go-links.md`; a published + slug can never be renamed. Examples may overlap with demos when the example is the smallest readable form of a primitive that a demo composes into a larger workflow. diff --git a/examples/manifest.json b/examples/manifest.json index 55a6d9af2a..36c71c5075 100644 --- a/examples/manifest.json +++ b/examples/manifest.json @@ -12,7 +12,8 @@ "sourceRepo": "superdoc/docx-editor", "sourcePath": "examples/getting-started/react", "docs": "https://docs.superdoc.dev/getting-started/frameworks/react", - "ci": true + "ci": true, + "slug": "react" }, { "id": "getting-started-vue", @@ -27,7 +28,8 @@ "sourceRepo": "superdoc/docx-editor", "sourcePath": "examples/getting-started/vue", "docs": "https://docs.superdoc.dev/getting-started/frameworks/vue", - "ci": true + "ci": true, + "slug": "vue" }, { "id": "getting-started-vanilla", @@ -42,7 +44,8 @@ "sourceRepo": "superdoc/docx-editor", "sourcePath": "examples/getting-started/vanilla", "docs": "https://docs.superdoc.dev/getting-started/frameworks/vanilla-js", - "ci": true + "ci": true, + "slug": "vanilla" }, { "id": "getting-started-cdn", @@ -57,7 +60,8 @@ "sourceRepo": "superdoc/docx-editor", "sourcePath": "examples/getting-started/cdn", "docs": "https://docs.superdoc.dev/getting-started/quickstart", - "ci": true + "ci": true, + "slug": "cdn" }, { "id": "getting-started-angular", @@ -72,7 +76,8 @@ "sourceRepo": "superdoc/docx-editor", "sourcePath": "examples/getting-started/angular", "docs": "https://docs.superdoc.dev/getting-started/frameworks/angular", - "ci": true + "ci": true, + "slug": "angular" }, { "id": "getting-started-nextjs", @@ -87,7 +92,8 @@ "sourceRepo": "superdoc/docx-editor", "sourcePath": "examples/getting-started/nextjs", "docs": "https://docs.superdoc.dev/getting-started/frameworks/nextjs", - "ci": false + "ci": false, + "slug": "nextjs" }, { "id": "getting-started-nuxt", @@ -102,7 +108,8 @@ "sourceRepo": "superdoc/docx-editor", "sourcePath": "examples/getting-started/nuxt", "docs": "https://docs.superdoc.dev/getting-started/frameworks/nuxt", - "ci": true + "ci": true, + "slug": "nuxt" }, { "id": "getting-started-laravel", @@ -117,7 +124,8 @@ "sourceRepo": "superdoc/docx-editor", "sourcePath": "examples/getting-started/laravel", "docs": "https://docs.superdoc.dev/getting-started/frameworks/laravel", - "ci": true + "ci": true, + "slug": "laravel" }, { "id": "getting-started-solid", @@ -132,7 +140,8 @@ "sourceRepo": "superdoc/docx-editor", "sourcePath": "examples/getting-started/solid", "docs": "https://docs.superdoc.dev/getting-started/frameworks/solid", - "ci": true + "ci": true, + "slug": "solid" }, { "id": "editor-built-in-comments", @@ -147,7 +156,8 @@ "sourceRepo": "superdoc/docx-editor", "sourcePath": "examples/editor/built-in-ui/comments", "docs": "https://docs.superdoc.dev/editor/built-in-ui/comments", - "ci": true + "ci": true, + "slug": "comments" }, { "id": "editor-built-in-track-changes", @@ -162,7 +172,8 @@ "sourceRepo": "superdoc/docx-editor", "sourcePath": "examples/editor/built-in-ui/track-changes", "docs": "https://docs.superdoc.dev/editor/built-in-ui/track-changes", - "ci": true + "ci": true, + "slug": "track-changes" }, { "id": "editor-built-in-toolbar", @@ -177,7 +188,8 @@ "sourceRepo": "superdoc/docx-editor", "sourcePath": "examples/editor/built-in-ui/toolbar", "docs": "https://docs.superdoc.dev/editor/built-in-ui/toolbar", - "ci": true + "ci": true, + "slug": "toolbar" }, { "id": "editor-built-in-responsive-zoom", @@ -267,7 +279,8 @@ "sourceRepo": "superdoc/docx-editor", "sourcePath": "examples/editor/theming", "docs": "https://docs.superdoc.dev/editor/theming/overview", - "ci": false + "ci": false, + "slug": "theming" }, { "id": "editor-spell-check-typo-js", @@ -327,7 +340,8 @@ "sourceRepo": "superdoc/docx-editor", "sourcePath": "examples/editor/collaboration/providers/hocuspocus", "docs": "https://docs.superdoc.dev/guides/collaboration/hocuspocus", - "ci": true + "ci": true, + "slug": "hocuspocus" }, { "id": "editor-collaboration-liveblocks", @@ -447,7 +461,8 @@ "sourceRepo": "superdoc/docx-editor", "sourcePath": "examples/ai/redlining", "docs": "https://docs.superdoc.dev/ai/overview", - "ci": true + "ci": true, + "slug": "ai-redlining" }, { "id": "ai-core-actions-agent", diff --git a/go-links/linkkeeper.json b/go-links/linkkeeper.json new file mode 100644 index 0000000000..95bd9c47f5 --- /dev/null +++ b/go-links/linkkeeper.json @@ -0,0 +1,11 @@ +{ + "catalog": { + "repo": "superdoc/docx-editor", + "files": ["examples/manifest.json", "demos/manifest.json"], + "repos": { + "superdoc-dev/superdoc": { "repo": "superdoc/docx-editor", "ref": "main" }, + "superdoc/docx-editor": { "repo": "superdoc/docx-editor", "ref": "main" }, + "superdoc-dev/demos": { "repo": "superdoc-dev/demos", "ref": "main" } + } + } +} diff --git a/go-links/package.json b/go-links/package.json new file mode 100644 index 0000000000..3a6d7c5bc3 --- /dev/null +++ b/go-links/package.json @@ -0,0 +1,7 @@ +{ + "name": "superdoc-go-links", + "version": "0.0.0", + "private": true, + "description": "Deploy boundary for go.superdoc.dev. Stops npm from walking up into the pnpm workspace root, whose catalog: protocol it cannot parse.", + "type": "module" +} diff --git a/go-links/public/404.html b/go-links/public/404.html new file mode 100644 index 0000000000..92d200f91e --- /dev/null +++ b/go-links/public/404.html @@ -0,0 +1,87 @@ + + + + + + + Link not found - SuperDoc + + + +
+

This link does not point to an example

+

+ It may have been mistyped, or it may be a link we never published. Every example we publish keeps working when + its source moves, so a link that used to work should not land here. If one did, please tell us. +

+ +
+ + diff --git a/go-links/published-slugs.json b/go-links/published-slugs.json new file mode 100644 index 0000000000..01d0413280 --- /dev/null +++ b/go-links/published-slugs.json @@ -0,0 +1,28 @@ +[ + "ai-redlining", + "angular", + "cdn", + "chrome-extension", + "collaborative-agent", + "comments", + "contract-templates", + "doc-rag", + "docx-from-html", + "esign", + "grading-papers", + "hocuspocus", + "laravel", + "linked-sections", + "nextjs", + "nextjs-ssr", + "nuxt", + "react", + "solid", + "template-builder", + "theming", + "toolbar", + "track-changes", + "vanilla", + "vue", + "word-addin" +] diff --git a/package.json b/package.json index f5ecd39e26..9bb49fdb29 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,8 @@ "docs:sync-engine": "pnpm exec tsx apps/docs/scripts/generate-sdk-overview.ts", "sdk:sync-version": "node packages/sdk/scripts/sync-sdk-version.mjs", "sdk:release": "node packages/sdk/scripts/sdk-release.mjs", - "sdk:release:dry": "node packages/sdk/scripts/sdk-release.mjs --dry-run" + "sdk:release:dry": "node packages/sdk/scripts/sdk-release.mjs --dry-run", + "check:examples-demos:tests": "node --test scripts/__tests__/validate-examples-demos-slug.test.mjs" }, "devDependencies": { "@clack/prompts": "^1.0.1", diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts index 4deabd771e..365609fe6f 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts @@ -470,7 +470,10 @@ function getParentWrapper(editor: Editor, input: ContentControlsGetParentInput): const sdt = resolveSdtByTarget(editor.state.doc, input.target); const $pos = editor.state.doc.resolve(sdt.pos); - for (let depth = $pos.depth - 1; depth >= 0; depth--) { + // `sdt.pos` points immediately before the target node. At that boundary, + // `$pos.depth` is already the depth of the containing node, so skipping it + // misses the direct parent for nested SDTs. + for (let depth = $pos.depth; depth >= 0; depth--) { const ancestor = $pos.node(depth); if (isSdtNode(ancestor)) { return buildContentControlInfoFromNode({ diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/sd-3617-content-control-wrap-persistence.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/sd-3617-content-control-wrap-persistence.integration.test.ts new file mode 100644 index 0000000000..3a00e843cb --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/sd-3617-content-control-wrap-persistence.integration.test.ts @@ -0,0 +1,93 @@ +/* @vitest-environment jsdom */ + +import { describe, expect, it } from 'vitest'; +import { Editor } from '../../core/Editor.js'; +import { initTestEditor, loadTestDataForEditorTests } from '../../tests/helpers/helpers.js'; + +type LoadedDocData = Awaited>; + +function openEditor(docData: LoadedDocData) { + return initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + useImmediateSetTimeout: false, + isHeadless: true, + user: { name: 'Test', email: 'test@example.com' }, + }).editor; +} + +async function reopenEditor(editor: Editor) { + const exported = await editor.exportDocx(); + const bytes = exported instanceof Uint8Array ? exported : new Uint8Array(exported); + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(bytes, true); + return openEditor({ docx, media, mediaFiles, fonts }); +} + +describe('SD-3617 nested content-control wrap persistence', () => { + it('contentControls.wrap persists a real parent around an existing block SDT after save and reopen', async () => { + const source = await loadTestDataForEditorTests('sdt-nested-block.docx'); + const editor = openEditor(source); + let reopened: Editor | undefined; + try { + const child = editor.doc.contentControls.selectByTag({ tag: 'outer-block' }).items[0]!; + const result = editor.doc.contentControls.wrap({ + target: child.target, + kind: 'block', + tag: 'sdk-parent', + alias: 'SDK parent', + }); + + expect(result.success).toBe(true); + if (!result.success || !result.updatedRef) { + throw new Error('Expected contentControls.wrap to return the persisted parent reference'); + } + expect(editor.doc.contentControls.get({ target: result.updatedRef }).properties.tag).toBe('sdk-parent'); + reopened = await reopenEditor(editor); + + const reopenedChild = reopened.doc.contentControls.selectByTag({ tag: 'outer-block' }).items[0]!; + const reopenedParent = reopened.doc.contentControls.get({ target: result.updatedRef }); + + expect(reopenedChild.id).toBe(child.id); + expect(reopenedParent.properties.tag).toBe('sdk-parent'); + expect(reopened.doc.contentControls.getParent({ target: reopenedChild.target })?.id).toBe(reopenedParent.id); + expect( + reopened.doc.contentControls.listChildren({ target: reopenedParent.target }).items.map(({ id }) => id), + ).toEqual([reopenedChild.id]); + } finally { + reopened?.destroy(); + editor.destroy(); + } + }); + + it('contentControls.group.wrap persists a group parent around an existing block SDT after save and reopen', async () => { + const source = await loadTestDataForEditorTests('sdt-nested-block.docx'); + const editor = openEditor(source); + let reopened: Editor | undefined; + try { + const child = editor.doc.contentControls.selectByTag({ tag: 'outer-block' }).items[0]!; + const result = editor.doc.contentControls.group.wrap({ target: child.target }); + + expect(result.success).toBe(true); + if (!result.success || !result.updatedRef) { + throw new Error('Expected contentControls.group.wrap to return the persisted parent reference'); + } + expect(editor.doc.contentControls.get({ target: result.updatedRef }).controlType).toBe('group'); + reopened = await reopenEditor(editor); + + const reopenedParent = reopened.doc.contentControls.get({ target: result.updatedRef }); + const reopenedChild = reopened.doc.contentControls.selectByTag({ tag: 'outer-block' }).items[0]!; + expect(reopenedChild.id).toBe(child.id); + expect(reopenedParent.controlType).toBe('group'); + expect(reopenedParent.id).toBe(result.updatedRef.nodeId); + expect(reopened.doc.contentControls.getParent({ target: reopenedChild.target })?.id).toBe(reopenedParent.id); + expect( + reopened.doc.contentControls.listChildren({ target: reopenedParent.target }).items.map(({ id }) => id), + ).toEqual([reopenedChild.id]); + } finally { + reopened?.destroy(); + editor.destroy(); + } + }); +}); diff --git a/scripts/__tests__/validate-examples-demos-slug.test.mjs b/scripts/__tests__/validate-examples-demos-slug.test.mjs new file mode 100644 index 0000000000..2e8727df55 --- /dev/null +++ b/scripts/__tests__/validate-examples-demos-slug.test.mjs @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '../..'); +const MANIFEST = path.join(REPO_ROOT, 'examples/manifest.json'); + +// The validator resolves its targets from its own location, so these tests +// exercise it in place: back the manifest up, write the case, restore. A +// fixture tree would need the whole examples/ directory to exist alongside it. +function withManifest(entries, assertion) { + const backupDir = mkdtempSync(path.join(tmpdir(), 'slug-test-')); + const backup = path.join(backupDir, 'manifest.json'); + copyFileSync(MANIFEST, backup); + try { + writeFileSync(MANIFEST, `${JSON.stringify(entries, null, 2)}\n`); + let output; + let failed = false; + try { + output = execFileSync('bun', ['scripts/validate-examples-demos.ts'], { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (err) { + failed = true; + output = `${err.stdout ?? ''}${err.stderr ?? ''}`; + } + assertion({ output, failed }); + } finally { + copyFileSync(backup, MANIFEST); + rmSync(backupDir, { recursive: true, force: true }); + } +} + +function readEntries() { + return JSON.parse(readFileSync(MANIFEST, 'utf8')); +} + +function entryById(entries, id) { + const entry = entries.find((candidate) => candidate.id === id); + if (!entry) throw new Error(`fixture entry "${id}" is no longer in examples/manifest.json; update this test`); + return entry; +} + +test('the committed manifests pass validation', () => { + withManifest(readEntries(), ({ failed, output }) => { + assert.equal(failed, false, `validator failed on the committed manifest:\n${output}`); + }); +}); + +test('a duplicate slug is rejected', () => { + const entries = readEntries(); + entryById(entries, 'getting-started-vue').slug = entryById(entries, 'getting-started-react').slug; + withManifest(entries, ({ failed, output }) => { + assert.equal(failed, true, 'expected a duplicate slug to fail validation'); + assert.match(output, /manifest-duplicate-slug/); + }); +}); + +test('a slug that is not lowercase kebab-case is rejected', () => { + for (const slug of ['React_Starter', 'React', 'react--starter', '-react', 'react-']) { + const entries = readEntries(); + entryById(entries, 'getting-started-vue').slug = slug; + withManifest(entries, ({ failed, output }) => { + assert.equal(failed, true, `expected slug "${slug}" to fail validation`); + assert.match(output, /must be lowercase kebab-case/); + }); + } +}); + +test('a slug that would shadow a service route is rejected', () => { + const entries = readEntries(); + entryById(entries, 'getting-started-vue').slug = 'docs'; + withManifest(entries, ({ failed, output }) => { + assert.equal(failed, true, 'expected a reserved slug to fail validation'); + assert.match(output, /reserved for a service route/); + }); +}); + +test('a slug survives an entry being hidden or archived', () => { + // The URL is the promise, not the entry. Requiring `active` would mean + // archiving an example forces removing its slug, breaking every published + // link at exactly the moment the example stops being maintained. + for (const status of ['hidden', 'archived']) { + const entries = readEntries(); + entryById(entries, 'getting-started-vue').status = status; + withManifest(entries, ({ failed, output }) => { + assert.equal( + failed, + false, + `expected a slug on a ${status} entry to stay valid: +${output}`, + ); + }); + } +}); + +test('a slug on a shim is rejected', () => { + const entries = readEntries(); + entryById(entries, 'getting-started-vue').status = 'shim'; + withManifest(entries, ({ failed, output }) => { + assert.equal(failed, true, 'expected a slug on a shim to fail validation'); + assert.match(output, /cannot hold a slug/); + }); +}); + +test('losing a published slug fails, so a stale branch cannot drop URLs', () => { + // The realistic way this happens is a long-lived branch that predates slugs + // resolving a manifest conflict in its own favour. Nothing else would catch + // it: the file stays valid and the URLs just stop being published. + const entries = readEntries().map(({ slug, ...rest }) => rest); + withManifest(entries, ({ failed, output }) => { + assert.equal(failed, true, 'expected dropping every slug to fail validation'); + assert.match(output, /manifest-slug-regression/); + assert.match(output, /no longer published/); + }); +}); + +test('renaming a published slug fails, even though the count is unchanged', () => { + // A count-only check passes here, which is why the baseline is an exact set. + // The old URL stops resolving the moment this deploys. + const entries = readEntries(); + entryById(entries, 'getting-started-react').slug = 'renamed-react'; + withManifest(entries, ({ failed, output }) => { + assert.equal(failed, true, 'expected renaming a published slug to fail'); + assert.match(output, /no longer published: react/); + assert.match(output, /newly published: renamed-react/); + }); +}); + +test('the catalog workflow watches every file the slug check depends on', () => { + // A check that never runs is not a check. The baseline is half the permanence + // rule, so a PR touching only it must still trigger validation. + const workflow = readFileSync(path.join(REPO_ROOT, '.github/workflows/ci-catalog.yml'), 'utf8'); + for (const dependency of [ + 'demos/manifest.json', + 'examples/manifest.json', + 'go-links/published-slugs.json', + 'scripts/validate-examples-demos.ts', + ]) { + assert.ok(workflow.includes(`'${dependency}'`), `ci-catalog.yml must trigger on ${dependency}`); + } +}); + +test('an entry without a slug is valid', () => { + // Moves a slug rather than removing one: this is about an unpublished entry + // being allowed, not about the published-slug floor, which has its own test. + const entries = readEntries(); + const donor = entryById(entries, 'getting-started-vue'); + const recipient = entries.find((entry) => entry.slug === undefined && entry.status === 'active'); + if (!recipient) throw new Error('no unpublished active entry to move a slug onto; update this test'); + + recipient.slug = donor.slug; + delete donor.slug; + + withManifest(entries, ({ failed, output }) => { + assert.equal(failed, false, `expected an unpublished entry to pass:\n${output}`); + }); +}); diff --git a/scripts/validate-examples-demos.ts b/scripts/validate-examples-demos.ts index 362fb6ca74..77cb4cc891 100644 --- a/scripts/validate-examples-demos.ts +++ b/scripts/validate-examples-demos.ts @@ -81,6 +81,79 @@ const ALLOWED_KINDS = new Set(['minimal-example', 'integration-example', 'workfl const ALLOWED_STATUSES = new Set(['active', 'hidden', 'archived', 'shim']); const ALLOWED_SOURCE_KINDS = new Set(['local', 'external']); +// AIDEV-NOTE: `slug` is the published identity at go.superdoc.dev and is +// permanent once shipped. `id` is the internal catalog key and stays free to +// follow section renames; a slug cannot, because external links depend on it. +// Renaming or removing a published slug breaks every link already in the wild, +// so treat a slug change as an API break, not a rename. +// Slugs are opt-in: an entry without one is simply not published. +const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +// Reserved for service-level routes so a slug can never shadow one. `docs`, +// `live`, and `source` are held back for the per-entry variants that v1 does +// not generate yet, so adding them later cannot collide with a published slug. +const RESERVED_SLUGS = new Set(['docs', 'live', 'source', 'health', 'index', 'api', 'assets', '404']); + +// AIDEV-NOTE: a published slug must outlive the entry's usefulness. Restricting +// it to `active` would mean archiving an example forces removing its slug, +// breaking the very URL we promised was permanent. `hidden` and `archived` say +// "stop advertising this", not "stop answering links people already hold", so +// both keep their slug. `shim` cannot claim one: a shim stands in for an old +// path and is not a thing deserving a permanent public name. +const SLUGGABLE_STATUSES = new Set(['active', 'hidden', 'archived']); + +// Claimed across both manifests: the published namespace is flat, so a slug in +// demos.json and one in examples.json would collide at the same URL. +const seenSlugs = new Map(); + +function validateSlug(e: Record, eid: string, relPath: string): void { + if (e.slug === undefined || e.slug === null) return; + if (typeof e.slug !== 'string' || e.slug.length === 0) { + issues.push({ + file: relPath, + line: 0, + kind: 'manifest-schema', + detail: `${eid}: slug must be a non-empty string; omit the field when the entry is not published`, + }); + return; + } + if (!SLUG_PATTERN.test(e.slug)) { + issues.push({ + file: relPath, + line: 0, + kind: 'manifest-slug', + detail: `${eid}: slug '${e.slug}' must be lowercase kebab-case (letters, digits, single hyphens)`, + }); + } + if (RESERVED_SLUGS.has(e.slug)) { + issues.push({ + file: relPath, + line: 0, + kind: 'manifest-slug', + detail: `${eid}: slug '${e.slug}' is reserved for a service route`, + }); + } + const owner = seenSlugs.get(e.slug); + if (owner !== undefined) { + issues.push({ + file: relPath, + line: 0, + kind: 'manifest-duplicate-slug', + detail: `${eid}: slug '${e.slug}' is already claimed by '${owner}'`, + }); + } else { + seenSlugs.set(e.slug, eid); + } + if (typeof e.status === 'string' && !SLUGGABLE_STATUSES.has(e.status)) { + issues.push({ + file: relPath, + line: 0, + kind: 'manifest-slug', + detail: `${eid}: status '${e.status}' cannot hold a slug (allowed: ${[...SLUGGABLE_STATUSES].join(', ')})`, + }); + } +} + function validateManifest(manifestPath: string, relPath: string): void { let entries: unknown; try { @@ -158,6 +231,7 @@ function validateManifest(manifestPath: string, relPath: string): void { }); } } + validateSlug(e, eid, relPath); } } @@ -229,6 +303,67 @@ for (const target of TARGETS) { } } +// AIDEV-NOTE: a published slug is a live URL at go.superdoc.dev. Removing one +// breaks every link to it that already exists in docs, posts, and other +// people's writing; renaming one does the same thing while leaving the count +// unchanged, so a count check would pass. Both are compared against a committed +// baseline instead. +// +// Publishing a new slug adds a line to go-links/published-slugs.json in the +// same change. Removing one is a deliberate act that retires a public URL, and +// should be reviewed as such rather than slipping through a merge. +const PUBLISHED_SLUGS_FILE = 'go-links/published-slugs.json'; + +let publishedBaseline: string[] | null = null; +try { + const raw = readFileSync(join(REPO_ROOT, PUBLISHED_SLUGS_FILE), 'utf8'); + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed) || parsed.some((slug) => typeof slug !== 'string')) { + issues.push({ + file: PUBLISHED_SLUGS_FILE, + line: 0, + kind: 'manifest-slug-baseline', + detail: 'must be a JSON array of slug strings', + }); + } else { + publishedBaseline = parsed as string[]; + } +} catch (err) { + issues.push({ + file: PUBLISHED_SLUGS_FILE, + line: 0, + kind: 'manifest-slug-baseline', + detail: `cannot read the published slug baseline: ${String(err).split('\n')[0]}`, + }); +} + +if (publishedBaseline) { + const current = new Set(seenSlugs.keys()); + const missing = publishedBaseline.filter((slug) => !current.has(slug)); + const added = [...current].filter((slug) => !publishedBaseline.includes(slug)); + + if (missing.length > 0) { + issues.push({ + file: PUBLISHED_SLUGS_FILE, + line: 0, + kind: 'manifest-slug-regression', + detail: + `no longer published: ${missing.join(', ')}. ` + + `These are live URLs at go.superdoc.dev and links to them already exist. ` + + `Restore the slug, or remove it from ${PUBLISHED_SLUGS_FILE} in the same change if the URL is being retired on purpose.`, + }); + } + + if (added.length > 0) { + issues.push({ + file: PUBLISHED_SLUGS_FILE, + line: 0, + kind: 'manifest-slug-baseline', + detail: `newly published: ${added.join(', ')}. Add them to ${PUBLISHED_SLUGS_FILE} so future changes cannot drop them silently.`, + }); + } +} + if (issues.length === 0) { console.log('\u001b[32mAll demo and example metadata is valid.\u001b[0m'); process.exit(0);