From 6b5bd15a4eeaa08a54b21afa9021a592c8fbb9b0 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:40:00 -0300 Subject: [PATCH 1/8] feat(catalog): add permanent slugs for demos and examples (#941) Moving a demo or example directory breaks every GitHub link already published, because GitHub only leaves redirects behind for repository renames, not for paths inside a repository. Today the only fix is a stub README left at the old path, and seven of those already exist. This adds a `slug` field to catalog entries: the permanent public name a demo is reachable by at go.superdoc.dev, independent of where its source currently lives. It stays separate from `id` because `id` is the internal catalog key and should stay free to follow section renames, while a public slug cannot move without breaking links we do not control. Slugs are opt-in, so nothing is published until it has earned a permanent name, and 26 entries are named here to start. The validator rejects duplicates, non-kebab-case names, names that would shadow a service route, and slugs on entries we have deliberately withdrawn. The service that serves those URLs is superdoc/linkkeeper, a separate project. It reads these manifests directly, so publishing an example is just the slug edit and there is no second list to keep in sync. The workflow added here only tells it when the catalog changed, and it lives in the public repo rather than in Orbit's export pipeline: the export pushes straight to public main, so by then the manifests are already public and the SHA is the exported one. The catalog had no CI gate on this branch, so metadata validation now runs in its own fast workflow rather than inside the demo build. Note: this ports only the public subtree changes from a mixed source commit (12 public paths, 2 non-public paths ignored). Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: ef3e82021629254753918e6b0ad8d9c3d54c0d4f Ported-Public-Prefix: superdoc/public --- .github/workflows/ci-catalog.yml | 45 ++++++ .github/workflows/deploy-go-links.yml | 75 ++++++++++ demos/AGENTS.md | 3 + demos/manifest.json | 33 +++-- docs/go-links.md | 89 +++++++++++ examples/AGENTS.md | 3 + examples/manifest.json | 45 ++++-- go-links/linkkeeper.json | 11 ++ go-links/public/404.html | 87 +++++++++++ package.json | 3 +- .../validate-examples-demos-slug.test.mjs | 139 ++++++++++++++++++ scripts/validate-examples-demos.ts | 98 ++++++++++++ 12 files changed, 604 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/ci-catalog.yml create mode 100644 .github/workflows/deploy-go-links.yml create mode 100644 docs/go-links.md create mode 100644 go-links/linkkeeper.json create mode 100644 go-links/public/404.html create mode 100644 scripts/__tests__/validate-examples-demos-slug.test.mjs diff --git a/.github/workflows/ci-catalog.yml b/.github/workflows/ci-catalog.yml new file mode 100644 index 0000000000..c96214e652 --- /dev/null +++ b/.github/workflows/ci-catalog.yml @@ -0,0 +1,45 @@ +name: CI Catalog + +# Metadata-only gate for the demo and example catalog. Fast by design: it +# validates manifests and the generated redirect table without building or +# browser-testing anything, so a naming mistake fails in under a minute. + +on: + pull_request: + paths: + - '.github/workflows/ci-catalog.yml' + - 'demos/manifest.json' + - 'examples/manifest.json' + - 'scripts/validate-examples-demos.ts' + - 'scripts/__tests__/validate-examples-demos-slug.test.mjs' + workflow_dispatch: + +jobs: + catalog: + 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' + cache: 'pnpm' + cache-dependency-path: pnpm-lock.yaml + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Validate demo and example metadata + run: pnpm run check:examples-demos + + - name: Test the metadata validator + run: pnpm run check:examples-demos:tests diff --git a/.github/workflows/deploy-go-links.yml b/.github/workflows/deploy-go-links.yml new file mode 100644 index 0000000000..3777533ca2 --- /dev/null +++ b/.github/workflows/deploy-go-links.yml @@ -0,0 +1,75 @@ +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: + 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/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..4faf194852 --- /dev/null +++ b/docs/go-links.md @@ -0,0 +1,89 @@ +# 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. + +The validator checks the catalog as it stands now, so it enforces uniqueness and +format but cannot know a slug used to be spelled differently. Not renaming one +is a rule we keep, not a rule it enforces. + +## 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/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/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/scripts/__tests__/validate-examples-demos-slug.test.mjs b/scripts/__tests__/validate-examples-demos-slug.test.mjs new file mode 100644 index 0000000000..ebdc481873 --- /dev/null +++ b/scripts/__tests__/validate-examples-demos-slug.test.mjs @@ -0,0 +1,139 @@ +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, /expected at least/); + }); +}); + +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..d76214a1c3 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,30 @@ for (const target of TARGETS) { } } +// AIDEV-NOTE: a published slug is a live URL at go.superdoc.dev, and dropping +// one breaks every link to it that already exists in docs, posts, and other +// people's writing. That is easy to do by accident: a long-lived branch that +// predates slugs resolves a manifest conflict in its own favour and the field +// simply vanishes, with no test failing and nothing obvious to review. This +// floor makes that a build failure instead. +// +// Raise it when slugs are added. Lowering it is a deliberate act that needs an +// explanation, because it means retiring public URLs. +const MINIMUM_PUBLISHED_SLUGS = 26; + +if (seenSlugs.size < MINIMUM_PUBLISHED_SLUGS) { + issues.push({ + file: 'demos/manifest.json, examples/manifest.json', + line: 0, + kind: 'manifest-slug-regression', + detail: + `${seenSlugs.size} published slugs, expected at least ${MINIMUM_PUBLISHED_SLUGS}. ` + + `Slugs are live URLs; losing one breaks every link already published to it. ` + + `If this is a merge from a branch that predates slugs, restore them. ` + + `If URLs are being retired on purpose, lower MINIMUM_PUBLISHED_SLUGS in the same change.`, + }); +} + if (issues.length === 0) { console.log('\u001b[32mAll demo and example metadata is valid.\u001b[0m'); process.exit(0); From eb3d4339f3d56dea6f831fcc2d0d7713f88e43e7 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:47:18 -0300 Subject: [PATCH 2/8] fix(go-links): stop npm walking up into the pnpm workspace (#1018) The first production deploy built all 26 links and verified every destination, then failed on the upload: npm error Unsupported URL Type "catalog:": catalog: The Cloudflare action installs wrangler with `npm i wrangler@4`. Run from `go-links/`, which had no `package.json`, npm walked up to the repository root and tried to read a pnpm workspace manifest full of `catalog:` versions it cannot parse. A minimal private `package.json` in `go-links/` stops that walk. It declares no dependencies and matches no `pnpm-workspace.yaml` glob, so pnpm ignores it and the workspace is unaffected. Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: 49026202fad233ec13ec3cfe3a16ed037e479ac6 Ported-Public-Prefix: superdoc/public --- go-links/package.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 go-links/package.json 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" +} From 582ec1311f5a778f8e6ff4c62a6cedb19c35a552 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:51:54 -0300 Subject: [PATCH 3/8] docs(superdoc): link examples through their stable URLs (#942) * feat(catalog): add permanent slugs for demos and examples Moving a demo or example directory breaks every GitHub link already published, because GitHub only leaves redirects behind for repository renames, not for paths inside a repository. Today the only fix is a stub README left at the old path, and seven of those already exist. This adds a `slug` field to catalog entries: the permanent public name a demo is reachable by at go.superdoc.dev, independent of where its source currently lives. It stays separate from `id` because `id` is the internal catalog key and should stay free to follow section renames, while a public slug cannot move without breaking links we do not control. Slugs are opt-in, so nothing is published until it has earned a permanent name, and 26 entries are named here to start. The validator rejects duplicates, non-kebab-case names, names that would shadow a service route, and slugs on entries we have deliberately withdrawn. The service that serves those URLs is superdoc/linkkeeper, a separate project. It reads these manifests directly, so publishing an example is just the slug edit and there is no second list to keep in sync. The workflow added here only tells it when the catalog changed, and it lives in the public repo rather than in Orbit's export pipeline: the export pushes straight to public main, so by then the manifests are already public and the SHA is the exported one. The catalog had no CI gate on this branch, so metadata validation now runs in its own fast workflow rather than inside the demo build. * docs(superdoc): link examples through their stable URLs Points the documentation at go.superdoc.dev for the 17 links whose targets now have a published slug. These survive the next reorganization of the examples tree; the raw GitHub paths they replace would not. Links to examples that do not have a slug yet are left alone rather than invented, so nothing here points at a URL that does not resolve. They can move over as those examples are named. Nothing else changes: same destinations, one redirect in front of them. Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: 258268f8ec8ab14bb439a51ed9ec5da03279e726 Ported-Public-Prefix: superdoc/public --- apps/docs/document-api/features/content-controls.mdx | 4 ++-- apps/docs/editor/built-in-ui/comments.mdx | 2 +- apps/docs/editor/built-in-ui/toolbar.mdx | 2 +- apps/docs/editor/built-in-ui/track-changes.mdx | 2 +- apps/docs/editor/custom-ui/content-controls.mdx | 2 +- apps/docs/editor/superdoc/configuration.mdx | 2 +- apps/docs/getting-started/frameworks/angular.mdx | 2 +- apps/docs/getting-started/frameworks/laravel.mdx | 2 +- apps/docs/getting-started/frameworks/nextjs.mdx | 4 ++-- apps/docs/getting-started/frameworks/nuxt.mdx | 2 +- apps/docs/getting-started/frameworks/react.mdx | 4 ++-- apps/docs/getting-started/frameworks/solid.mdx | 2 +- apps/docs/getting-started/frameworks/vue.mdx | 2 +- apps/docs/guides/collaboration/hocuspocus.mdx | 2 +- 14 files changed, 17 insertions(+), 17 deletions(-) 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/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/frameworks/angular.mdx b/apps/docs/getting-started/frameworks/angular.mdx index db918ea39a..6f9c1c4da8 100644 --- a/apps/docs/getting-started/frameworks/angular.mdx +++ b/apps/docs/getting-started/frameworks/angular.mdx @@ -137,4 +137,4 @@ export class UploadEditorComponent { - [Configuration](/editor/superdoc/configuration) - Full configuration options - [Methods](/editor/superdoc/methods) - All available methods -- [Angular Example](https://github.com/superdoc/docx-editor/tree/main/examples/getting-started/angular) - Working example on GitHub +- [Angular Example](https://go.superdoc.dev/angular) - Working example on GitHub diff --git a/apps/docs/getting-started/frameworks/laravel.mdx b/apps/docs/getting-started/frameworks/laravel.mdx index bd7cd3fb83..25be1a2e7d 100644 --- a/apps/docs/getting-started/frameworks/laravel.mdx +++ b/apps/docs/getting-started/frameworks/laravel.mdx @@ -130,7 +130,7 @@ This runs `php artisan serve` (port 8000) and Vite's dev server together. Open [ All configuration options - + Working Laravel example on GitHub diff --git a/apps/docs/getting-started/frameworks/nextjs.mdx b/apps/docs/getting-started/frameworks/nextjs.mdx index 8d435f624e..d275b554e8 100644 --- a/apps/docs/getting-started/frameworks/nextjs.mdx +++ b/apps/docs/getting-started/frameworks/nextjs.mdx @@ -91,10 +91,10 @@ export default function RootLayout({ children }) { Real-time collaboration - + React + TypeScript example - + Next.js SSR integration diff --git a/apps/docs/getting-started/frameworks/nuxt.mdx b/apps/docs/getting-started/frameworks/nuxt.mdx index bb3c24d2b8..6d1f8e31d3 100644 --- a/apps/docs/getting-started/frameworks/nuxt.mdx +++ b/apps/docs/getting-started/frameworks/nuxt.mdx @@ -158,7 +158,7 @@ const handleFile = (e: Event) => { All configuration options - + Working Nuxt example on GitHub diff --git a/apps/docs/getting-started/frameworks/react.mdx b/apps/docs/getting-started/frameworks/react.mdx index d715216d6c..02937d0857 100644 --- a/apps/docs/getting-started/frameworks/react.mdx +++ b/apps/docs/getting-started/frameworks/react.mdx @@ -122,10 +122,10 @@ function FileEditor() { Real-time collaboration setup - + React + TypeScript example - + Next.js SSR integration diff --git a/apps/docs/getting-started/frameworks/solid.mdx b/apps/docs/getting-started/frameworks/solid.mdx index c210d66a05..f092d9bc97 100644 --- a/apps/docs/getting-started/frameworks/solid.mdx +++ b/apps/docs/getting-started/frameworks/solid.mdx @@ -126,4 +126,4 @@ function FileEditor() { - [React Integration](/getting-started/frameworks/react) - React setup - [API Reference](/editor/superdoc/configuration) - Configuration options -- [Examples](https://github.com/superdoc/docx-editor/tree/main/examples/getting-started/solid) - Working examples +- [Examples](https://go.superdoc.dev/solid) - Working examples diff --git a/apps/docs/getting-started/frameworks/vue.mdx b/apps/docs/getting-started/frameworks/vue.mdx index 646ed82470..06ada85d34 100644 --- a/apps/docs/getting-started/frameworks/vue.mdx +++ b/apps/docs/getting-started/frameworks/vue.mdx @@ -203,4 +203,4 @@ defineExpose({ - [React Integration](/getting-started/frameworks/react) - React setup - [API Reference](/editor/superdoc/configuration) - Configuration options -- [Examples](https://github.com/superdoc/docx-editor/tree/main/examples/getting-started/vue) - Working examples \ No newline at end of file +- [Examples](https://go.superdoc.dev/vue) - Working examples \ No newline at end of file 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 From 3072d1d12308adf7d78a542aad1c72c87e591e75 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:13:03 -0300 Subject: [PATCH 4/8] fix(go-links): close two gaps in the deploy and slug contract (#1022) Renaming a published slug passed CI. The previous check was a count floor, and a rename leaves the count at 26 while the old URL stops resolving, which is exactly the failure the whole project exists to prevent. go-links/published-slugs.json now records every slug that has shipped, and the validator compares against that set: removing or renaming one is a build failure naming the slug. Publishing a new one adds a line to the same file, so the baseline cannot drift from reality unnoticed. `workflow_dispatch` let a caller choose any ref, and that job holds the credentials that publish go.superdoc.dev. A branch that had not been reviewed could have deployed redirects. The job is now fail-closed on `refs/heads/main`. Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: c8652db7afe9c17dee632804c1fa4ffc3530caa8 Ported-Public-Prefix: superdoc/public --- .github/workflows/deploy-go-links.yml | 4 ++ docs/go-links.md | 7 +- go-links/published-slugs.json | 28 ++++++++ .../validate-examples-demos-slug.test.mjs | 16 ++++- scripts/validate-examples-demos.ts | 71 ++++++++++++++----- 5 files changed, 104 insertions(+), 22 deletions(-) create mode 100644 go-links/published-slugs.json diff --git a/.github/workflows/deploy-go-links.yml b/.github/workflows/deploy-go-links.yml index 3777533ca2..a8b9e41e1f 100644 --- a/.github/workflows/deploy-go-links.yml +++ b/.github/workflows/deploy-go-links.yml @@ -45,6 +45,10 @@ env: 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: diff --git a/docs/go-links.md b/docs/go-links.md index 4faf194852..b813bacb48 100644 --- a/docs/go-links.md +++ b/docs/go-links.md @@ -46,9 +46,10 @@ Treat naming a slug as naming a public API. Specifically: - `id` is the internal catalog key and is free to change with section renames. That is the reason the two fields are separate. -The validator checks the catalog as it stands now, so it enforces uniqueness and -format but cannot know a slug used to be spelled differently. Not renaming one -is a rule we keep, not a rule it enforces. +`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 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/scripts/__tests__/validate-examples-demos-slug.test.mjs b/scripts/__tests__/validate-examples-demos-slug.test.mjs index ebdc481873..4aba283b29 100644 --- a/scripts/__tests__/validate-examples-demos-slug.test.mjs +++ b/scripts/__tests__/validate-examples-demos-slug.test.mjs @@ -113,12 +113,24 @@ test('a slug on a shim is rejected', () => { 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. + // 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, /expected at least/); + 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/); }); }); diff --git a/scripts/validate-examples-demos.ts b/scripts/validate-examples-demos.ts index d76214a1c3..77cb4cc891 100644 --- a/scripts/validate-examples-demos.ts +++ b/scripts/validate-examples-demos.ts @@ -303,30 +303,67 @@ for (const target of TARGETS) { } } -// AIDEV-NOTE: a published slug is a live URL at go.superdoc.dev, and dropping -// one breaks every link to it that already exists in docs, posts, and other -// people's writing. That is easy to do by accident: a long-lived branch that -// predates slugs resolves a manifest conflict in its own favour and the field -// simply vanishes, with no test failing and nothing obvious to review. This -// floor makes that a build failure instead. +// 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. // -// Raise it when slugs are added. Lowering it is a deliberate act that needs an -// explanation, because it means retiring public URLs. -const MINIMUM_PUBLISHED_SLUGS = 26; +// 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'; -if (seenSlugs.size < MINIMUM_PUBLISHED_SLUGS) { +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: 'demos/manifest.json, examples/manifest.json', + file: PUBLISHED_SLUGS_FILE, line: 0, - kind: 'manifest-slug-regression', - detail: - `${seenSlugs.size} published slugs, expected at least ${MINIMUM_PUBLISHED_SLUGS}. ` + - `Slugs are live URLs; losing one breaks every link already published to it. ` + - `If this is a merge from a branch that predates slugs, restore them. ` + - `If URLs are being retired on purpose, lower MINIMUM_PUBLISHED_SLUGS in the same change.`, + 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); From 7587bdd80d8a1a834610d227598e62b2e388056b Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:40:27 -0300 Subject: [PATCH 5/8] fix(catalog): run slug validation when the baseline itself changes (#1023) The published-slug baseline is half of the permanence rule, but no workflow watched it. A PR touching only go-links/published-slugs.json ran no catalog validation at all, so an entry could be removed from the baseline without review, and a later PR could then drop the matching manifest slug against an already weakened baseline. Each step looks harmless; together they retire a live URL. The validator does catch the mismatch, so this is not a hole in the check. It is a check that never fired. A test asserts the workflow triggers on every file the slug rule depends on, since a missing path filter has no symptom until the day it matters. Note: this ports only the public subtree changes from a mixed source commit (2 public paths, 1 non-public path ignored). Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: bf054b4534c053b9cf03d2cbbf01adbb8c6bd274 Ported-Public-Prefix: superdoc/public --- .github/workflows/ci-catalog.yml | 5 +++++ .../validate-examples-demos-slug.test.mjs | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/.github/workflows/ci-catalog.yml b/.github/workflows/ci-catalog.yml index c96214e652..d93aca7d36 100644 --- a/.github/workflows/ci-catalog.yml +++ b/.github/workflows/ci-catalog.yml @@ -12,6 +12,11 @@ on: - 'examples/manifest.json' - 'scripts/validate-examples-demos.ts' - 'scripts/__tests__/validate-examples-demos-slug.test.mjs' + # The published-slug baseline is half of the permanence check. Without it + # here, a PR editing only the baseline runs no catalog validation at all, + # and a later PR could drop the matching manifest slug against an already + # weakened baseline. + - 'go-links/published-slugs.json' workflow_dispatch: jobs: diff --git a/scripts/__tests__/validate-examples-demos-slug.test.mjs b/scripts/__tests__/validate-examples-demos-slug.test.mjs index 4aba283b29..2e8727df55 100644 --- a/scripts/__tests__/validate-examples-demos-slug.test.mjs +++ b/scripts/__tests__/validate-examples-demos-slug.test.mjs @@ -134,6 +134,20 @@ test('renaming a published slug fails, even though the count is unchanged', () = }); }); +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. From 49d8c9c9ca2bec6967dab0336d22ea5dfd5f822e Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:58:36 -0300 Subject: [PATCH 6/8] feat(catalog): make slug validation a required, locally runnable lane (#1024) * fix(catalog): run slug validation when the baseline itself changes The published-slug baseline is half of the permanence rule, but no workflow watched it. A PR touching only go-links/published-slugs.json ran no catalog validation at all, so an entry could be removed from the baseline without review, and a later PR could then drop the matching manifest slug against an already weakened baseline. Each step looks harmless; together they retire a live URL. The validator does catch the mismatch, so this is not a hole in the check. It is a check that never fired. A test asserts the workflow triggers on every file the slug rule depends on, since a missing path filter has no symptom until the day it matters. * feat(catalog): make slug validation a required, locally runnable lane The slug baseline only protects public URLs if the check that reads it actually runs. It was not a required check, so a pull request touching a manifest could merge without it, which made the whole permanence contract advisory. Adding it to the rulesets alone would have made things worse. The workflow filtered on `on.paths`, so it never reported on unrelated changes, and a required check that never reports blocks the pull request forever. It now runs on every pull request and in the merge queue, with a `detect` job deciding whether the real work is needed and a `validate` job that reports either way. That is the pattern ci-examples and ci-document-api already use, for the same reason. The policy also refuses `branchProtection` without a local lane, which is a good rule: a check people cannot reproduce locally is one they can only argue with. So there is now a `ci-catalog` lane, reachable through `pnpm superdoc:gate --lane ci-catalog`. It is the cheapest lane here -- reads JSON, compares it, no build or network -- so local and CI results cannot diverge, and it runs first because it fails fastest. Two lane rosters in the tests were hardcoded and are updated. Adding the context to require-ci and require-ci-v2 is the remaining step, and is a repository settings change. * refactor(catalog): drop the dependency install the lane never needed The lane described itself as network-free while its first stage ran `pnpm install --frozen-lockfile`, which is the one step in it that touches the network and can differ between machines. Both commands import only `node:` builtins, and both were verified to run with `node_modules` absent, so the install was doing nothing. Removed from the lane and from the workflow together: a lane that diverges from its CI counterpart is worse than no lane, because it teaches people the wrong thing about what CI will do. The environment note now says what is actually true rather than claiming local and CI cannot diverge, which was stronger than the evidence. Note: this ports only the public subtree changes from a mixed source commit (1 public path, 5 non-public paths ignored). Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: 1d19965b10036f51bc2c3553f2ceda61a0d18132 Ported-Public-Prefix: superdoc/public --- .github/workflows/ci-catalog.yml | 93 ++++++++++++++++++++++++++------ 1 file changed, 77 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci-catalog.yml b/.github/workflows/ci-catalog.yml index d93aca7d36..5325fa90ea 100644 --- a/.github/workflows/ci-catalog.yml +++ b/.github/workflows/ci-catalog.yml @@ -1,26 +1,66 @@ name: CI Catalog # Metadata-only gate for the demo and example catalog. Fast by design: it -# validates manifests and the generated redirect table without building or +# 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: - paths: - - '.github/workflows/ci-catalog.yml' - - 'demos/manifest.json' - - 'examples/manifest.json' - - 'scripts/validate-examples-demos.ts' - - 'scripts/__tests__/validate-examples-demos-slug.test.mjs' - # The published-slug baseline is half of the permanence check. Without it - # here, a PR editing only the baseline runs no catalog validation at all, - # and a later PR could drop the matching manifest slug against an already - # weakened baseline. - - 'go-links/published-slugs.json' + 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 @@ -33,18 +73,39 @@ jobs: uses: actions/setup-node@v6 with: node-version: '20' - cache: 'pnpm' - cache-dependency-path: pnpm-lock.yaml - uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.13 - - name: Install dependencies - run: pnpm install --frozen-lockfile + # 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 From 5a6c10e67e0b2587f27a7f16db3fcb24def58188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?= Date: Thu, 30 Jul 2026 14:04:31 -0300 Subject: [PATCH 7/8] fix(v1): preserve nested content-control parent topology (#1105) * fix(v1): preserve nested content-control parent topology * test(v1): strengthen nested SDT round-trip coverage Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: 5a7401384e0d2dc0a6bdfd1375bb45614b2eb91b Ported-Public-Prefix: superdoc/public --- .../plan-engine/content-controls-wrappers.ts | 5 +- ...ntrol-wrap-persistence.integration.test.ts | 93 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/sd-3617-content-control-wrap-persistence.integration.test.ts 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(); + } + }); +}); From a353e56e4cb1ac083c3fab171d407635173c0078 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:14:17 -0300 Subject: [PATCH 8/8] docs(docs): pin archived v1 packages (#981) * docs(docs): pin archived v1 packages * fix(docs): pin v1 React dependencies * fix(docs): cover scoped v1 browser URLs Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: 51aac5061ce267da83581c911945950ebee7f2d7 Ported-Public-Prefix: superdoc/public --- .../editor/custom-ui/controller-setup.mdx | 2 +- apps/docs/editor/custom-ui/react-setup.mdx | 2 +- apps/docs/editor/react/overview.mdx | 2 +- apps/docs/getting-started/fonts.mdx | 2 +- .../getting-started/frameworks/angular.mdx | 2 +- .../getting-started/frameworks/laravel.mdx | 2 +- .../getting-started/frameworks/nextjs.mdx | 2 +- apps/docs/getting-started/frameworks/nuxt.mdx | 2 +- .../docs/getting-started/frameworks/react.mdx | 2 +- .../docs/getting-started/frameworks/solid.mdx | 2 +- .../getting-started/frameworks/vanilla-js.mdx | 10 +- apps/docs/getting-started/frameworks/vue.mdx | 4 +- apps/docs/getting-started/quickstart.mdx | 8 +- apps/docs/llms-full.txt | 4 +- apps/docs/package.json | 2 +- .../scripts/validate-v1-package-pins.test.mjs | 93 +++++++++++++++++++ apps/docs/snippets/components/doc-counter.jsx | 4 +- .../snippets/components/superdoc-editor.jsx | 2 +- 18 files changed, 120 insertions(+), 27 deletions(-) create mode 100644 apps/docs/scripts/validate-v1-package-pins.test.mjs 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/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 06ada85d34..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/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';