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.
+