From 443a302cc0541a709d247f9ce6dd06ec735e3c5b Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Mon, 21 Sep 2026 14:13:55 +0200 Subject: [PATCH 1/2] feat(docs): require every published doc slug to be recorded in the Fern lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `documentation/guides/migrating-optimization-sdk-packages-from-v1-to-v2.md` merged in #514 without a `documentation/fern-slugs.lock.json` entry, which was allowed: the exporter reconciles an unrecorded document in memory, so the page publishes either way. What it silently lost was slug protection. The existing check can only compare a slug against one the lock already names, and the sync workflow runs `pnpm docs:fern` without `--update-lock`, so an entry an author never commits never appears. That page's first slug reword would have moved a live URL with no redirect — the exact failure the lock exists to prevent. It was the first new page since the lock landed in #442, so the gap had never been reachable before. `pnpm fern:check` now reports an unrecorded slug the same way it reports a changed one, naming the command that fixes it, and records the missing v1-to-v2 entry so the check passes on `main`. The slug-change path is unchanged. Because a check only catches what reaches CI, the contract is also written down where an author meets it: a new `docs-site-publishing` skill owns the four artifacts that decide whether a page ships (group README `children:`, the `fern:` block, the slug lock, MDX safety), the group-global sidebar ordering rule, and the release-gated sync. `optimization-guide-authoring` now routes to it from a publication step in its workflow, and the self-review checklist gains a mechanical section G. `documentation/AGENTS.md` documented only how to change a slug, never how to record one, and never that `children:` is what publishes a document at all; both are now stated. Validation: `pnpm fern:check`, `pnpm guides:check`, `pnpm knowledge:check`, `pnpm lint`, `pnpm typecheck`, ESLint on both changed scripts, targeted Prettier on every changed file, `git diff --check`. Verified by hand that the new check fires on an unrecorded page and that a changed slug is still reported. Co-Authored-By: Claude Opus 5 (1M context) --- documentation/AGENTS.md | 10 +- documentation/fern-slugs.lock.json | 1 + scripts/fern/build.ts | 16 +- scripts/validate-fern-export.ts | 3 +- skills/docs-site-publishing/SKILL.md | 161 ++++++++++++++++++ skills/docs-site-publishing/package.json | 9 + skills/optimization-guide-authoring/SKILL.md | 30 +++- .../references/authoring-checklist.md | 24 ++- 8 files changed, 240 insertions(+), 14 deletions(-) create mode 100644 skills/docs-site-publishing/SKILL.md create mode 100644 skills/docs-site-publishing/package.json diff --git a/documentation/AGENTS.md b/documentation/AGENTS.md index 3836cc800..64ead62e3 100644 --- a/documentation/AGENTS.md +++ b/documentation/AGENTS.md @@ -30,6 +30,9 @@ release-gated, so an edit here reaches the public site at the next release rathe maintainer can publish out of band by dispatching the sync workflow from `main`. Run `pnpm fern:check` after editing either directory. +- A document is published only if it is listed in its group README frontmatter `children:`. The + exporter reads that list and never scans the filesystem, so a file on disk but absent from + `children:` is simply not published rather than an error. - Every published document needs a `fern:` frontmatter block: `slug`, `section` (`Guides`, `Concepts`, or `Migration guides`), and `description`. Add `navTitle` only when the sidebar needs a shorter label than the page title. @@ -37,8 +40,11 @@ maintainer can publish out of band by dispatching the sync workflow from `main`. cannot drift from the heading; `pnpm fern:check` rejects one if it is reintroduced, and rejects an authored top-level `title` that no longer matches the heading. - `fern.slug` is data and is never derived from a heading, so rewording an `#` heading cannot move a - live URL. Changing a slug requires `pnpm docs:fern -- --update-lock`, which records a permanent - redirect in `documentation/fern-slugs.lock.json`. `pnpm fern:check` fails on an unrecorded change. + live URL. Both a new slug and a changed one are recorded with `pnpm docs:fern -- --update-lock`, + which writes `documentation/fern-slugs.lock.json` — appending a permanent redirect when a slug + moved. Commit that lock diff with the document; `pnpm fern:check` fails on any slug the lock does + not record. The sync workflow runs `pnpm docs:fern` without `--update-lock`, so an entry that is + never committed never appears, and the page's first slug reword would move a live URL silently. - Cross-document links must resolve to a published document, and a `#fragment` must match a real heading on the target page. Never link a published document to `authoring/` or `internal/`. - Do not hand-edit the pages in `contentful-docs`; they are generated and the next sync overwrites diff --git a/documentation/fern-slugs.lock.json b/documentation/fern-slugs.lock.json index 64b775369..cc9b15dfb 100644 --- a/documentation/fern-slugs.lock.json +++ b/documentation/fern-slugs.lock.json @@ -32,6 +32,7 @@ "documentation/guides/migrating-experience-js-plugins-and-preview.md": "migrate-experiencejs-plugins-and-preview", "documentation/guides/migrating-experience-js-react-to-react-web.md": "migrate-experiencejs-to-react-web", "documentation/guides/migrating-experience-js-to-the-web-sdk.md": "migrate-experiencejs-to-web-sdk", + "documentation/guides/migrating-optimization-sdk-packages-from-v1-to-v2.md": "migrate-optimization-sdk-v1-to-v2", "documentation/guides/rendering-personalized-nextjs-routes-with-static-isr-and-edge-handoffs.md": "render-personalized-nextjs-routes", "documentation/guides/using-contentful-graphql-data-with-the-optimization-sdks.md": "use-contentful-graphql-data-with-the-optimization-sdks" }, diff --git a/scripts/fern/build.ts b/scripts/fern/build.ts index c6ccd9c9b..c32023e53 100644 --- a/scripts/fern/build.ts +++ b/scripts/fern/build.ts @@ -87,7 +87,20 @@ export function buildBundle(options: BuildOptions): BuildResult { const { slugs } = lock for (const doc of docs) { const { [doc.relPath]: previous } = slugs - if (previous !== undefined && previous !== doc.fern.slug) { + // An unrecorded document is what a brand-new page looks like, and it publishes fine without an + // entry because `reconcileLock` supplies one in memory. Requiring the entry anyway is what buys + // the page slug protection: the check below can only compare against a slug the lock names, and + // the sync workflow runs `pnpm docs:fern` without `--update-lock`, so an entry the author never + // commits never appears. Without it, the page's first slug reword moves a live URL silently. + if (previous === undefined) { + problems.push({ + file: doc.relPath, + line: 1, + message: `fern.slug "${doc.fern.slug}" is not recorded in documentation/fern-slugs.lock.json; rerun \`pnpm docs:fern -- --update-lock\` to record it`, + }) + continue + } + if (previous !== doc.fern.slug) { problems.push({ file: doc.relPath, line: 1, @@ -95,7 +108,6 @@ export function buildBundle(options: BuildOptions): BuildResult { }) } } - // A document missing from the lock has never been published; that is expected for a new page. } const reconciled = reconcileLock(lock, docs) diff --git a/scripts/validate-fern-export.ts b/scripts/validate-fern-export.ts index a620b89aa..3d721a056 100644 --- a/scripts/validate-fern-export.ts +++ b/scripts/validate-fern-export.ts @@ -4,7 +4,8 @@ * This is the gate that makes the link rot the manual process accumulated impossible to reintroduce: * every cross-document link must resolve to a published page, and every `#fragment` must match a real * heading on that page. It also enforces the MDX safety rule that catches leaked markup, and refuses - * an unrecorded slug change so a live URL can never move silently. + * any slug the lock does not record — a new page's as well as a changed one's — so a live URL can + * never move silently. * * Usage: pnpm fern:check [-- --ref ] */ diff --git a/skills/docs-site-publishing/SKILL.md b/skills/docs-site-publishing/SKILL.md new file mode 100644 index 000000000..fc151f7e9 --- /dev/null +++ b/skills/docs-site-publishing/SKILL.md @@ -0,0 +1,161 @@ +--- +name: docs-site-publishing +description: >- + Wire an authored document under documentation/guides/ or documentation/concepts/ into the published + Contentful documentation site. Covers the four things that decide whether a page reaches the site at + all — the group README `children:` list, the `fern:` frontmatter block, the slug lock, and MDX + safety — plus the sidebar ordering rule and the release-gated three-layer sync. Use when adding, + renaming, moving, retitling, reordering, or removing a published document, when changing a + `fern.slug` or `fern.section`, when editing a group README index, or when `pnpm fern:check` fails. + Triggers on "publish the guide", "is this exported", "fern", "fern:check", "frontmatter", "slug", + "fern-slugs.lock", "sidebar order", "nav", "redirect", "contentful-docs", "why isn't my page on the + site". Not guide prose or structure (optimization-guide-authoring) and not fact derivation + (sdk-knowledge-authoring). +argument-hint: '[document, group README, or the failing fern:check output]' +paths: documentation/guides/**, documentation/concepts/**, documentation/fern-slugs.lock.json +--- + +# Publishing authored docs to the documentation site + +Use this skill with `optimization-guide-authoring`. That skill owns what a guide says and how it +reads; this one owns whether it reaches `contentful/contentful-docs` and stays at a stable URL. +`documentation/AGENTS.md` holds the same contract as repository policy — when the two disagree, that +file wins and this skill should be corrected. + +Writing a good document is not publishing it. Publication is data: four separate artifacts have to +agree, and three of the four fail silently or late if you skip them. + +## The publishing contract + +A document under `documentation/guides/` or `documentation/concepts/` publishes only when **all** of +these hold. `pnpm fern:check` enforces every one of them, so treat a failure as the contract talking. + +1. **It is listed in its group `README.md` frontmatter `children:`.** The exporter never scans the + filesystem (`loadPublishedDocs` in `scripts/fern/docs.ts` reads `children:` and nothing else). A + file on disk but missing from `children:` is not published — it is invisible, not an error, which + is why this is the easiest step to lose. +2. **It has a `fern:` frontmatter block** with `slug`, `section`, and `description`. Add `navTitle` + only when the sidebar needs a shorter label than the page title. +3. **Its slug is recorded in `documentation/fern-slugs.lock.json`.** Run + `pnpm docs:fern -- --update-lock` and commit the result in the same change as the page. +4. **Its prose is MDX-safe**, and every cross-document link resolves to a published page and a real + heading anchor on it. + +### The `fern:` block + +```markdown +--- +fern: + slug: migrate-optimization-sdk-v1-to-v2 + section: Migration guides + description: >- + One or two sentences of meta description, in the same voice as the page's opening. +--- + +# Migrate Optimization SDK packages from v1 to v2 +``` + +- `section` is exactly one of `Guides`, `Concepts`, `Migration guides`. It is the **sidebar** section + and is independent of which directory the file lives in — a `Migration guides` page lives under + `guides/`, not in a directory of its own. +- `slug` is kebab-case and is **data, never derived from the heading**. That is the whole point: + rewording an `#` heading can then never move a live URL. +- There is no `fern.title`. The published title is the document's `# ` heading, so it cannot drift + from what the reader sees; `fern:check` rejects a reintroduced `fern.title` outright. A group + README's top-level `title:` must match its own `#` heading. +- `description` is a `>-` folded block. The frontmatter reader is deliberately strict and understands + only a plain scalar and `>-` — any other YAML shape is a hard error rather than a silent drop. + +### The slug lock + +`documentation/fern-slugs.lock.json` maps source path → slug, and is the only record of what URL a +page has already occupied. Two cases, one command: + +- **New page** — the lock gains an entry. Nothing about the page changes; what changes is that the + slug is now protected. +- **Changed slug** — the lock records a permanent redirect from the old slug to the new one, so the + live URL keeps working. + +Both: `pnpm docs:fern -- --update-lock`, then commit the lock diff **with the page**. Never hand-edit +the lock; the redirect chain is resolved and validated from it, and a history the exporter cannot +explain fails the check. + +> Why the entry matters for a page that is not live yet: the exporter reconciles an unrecorded +> document in memory, so the page publishes either way. But the change check can only compare against +> a slug the lock names, and the sync workflow runs `pnpm docs:fern` **without** `--update-lock` — so +> an entry the author never commits never appears. Without it, the page's first slug reword moves a +> live URL with no redirect, which is exactly what the lock exists to prevent. + +### Sidebar order + +`children:` order **is** the published sidebar order, authored in reader-routing order. Two +consequences worth stating because neither is obvious: + +- Reordering `children:` reorders the public site. It is not a local bookkeeping list. +- Order is **global across the group**, not per section. A page's position within its `section` comes + from its index in the one `children:` list, so inserting a `Migration guides` page second in + `documentation/guides/README.md` puts it **first** in the Migration guides sidebar section. Place it + where you want it to land in its own section, and keep `children:`, the visible list order, and the + one-sentence descriptions aligned (`pnpm guides:check` enforces the alignment). + +### MDX safety and links + +Published pages become MDX, where `<` and `{` are active syntax. + +- Keep angle-bracket placeholders inside inline code or a fenced block. Never bare in prose. +- Every fenced block needs a language tag. +- Cross-document links must resolve to a published document, and any `#fragment` must match a real + heading on the target page. The exporter promotes the intro prose under an `## Overview` heading, so + `#overview` is always available. +- **Never link a published page to `authoring/` or `internal/`.** Those are not published; the link + would 404 on the site. This is the one link rule a repo-local check would otherwise call valid. + +## How a change reaches the site + +Three layers, each runnable on its own, none needing credentials except the last: + +1. `pnpm docs:fern` — builds the bundle into gitignored `fern-bundle/`: one `.mdx` per page, + `nav-block.yaml`, `redirects.yaml`, `manifest.json`. Pure local transform. +2. `pnpm docs:fern:apply` — writes that bundle into a `contentful-docs` checkout. +3. `.github/workflows/sync-fern-docs.yaml` — opens the pull request on `contentful/contentful-docs`. + +**Publication is release-gated.** An edit merged to `main` reaches the public site at the next +release, not on merge; a maintainer can publish out of band by dispatching the sync workflow from +`main`. So "merged" and "live" are different states — say which one you mean when reporting. + +Do not hand-edit pages in `contentful-docs`. They are generated and the next sync overwrites them; +prose changes belong here. + +## Workflow + +1. **Author the page**, following `optimization-guide-authoring`. +2. **Add the `fern:` block** — `slug`, `section`, `description`. +3. **Add it to `children:`** in the group README, positioned where it should appear in its own + sidebar section, and add its row plus one-sentence description to the visible list. +4. **Record the slug**: `pnpm docs:fern -- --update-lock`. +5. **Validate**: `pnpm fern:check`, then `pnpm guides:check`. Read the reported `file:line: message` + rather than guessing — the messages name the exact fix, including the command to run. +6. **Inspect the bundle** when the page is new or its slug, section, or order changed. `pnpm docs:fern` + then read `fern-bundle/nav-block.yaml` to confirm the page sits in the section and position you + intended, and `fern-bundle/pages/.mdx` to confirm the title and description resolved. The nav + block is the artifact that actually ships; reading it is how you check ordering instead of + reasoning about `children:` indices. +7. **Commit the page, the README, and the lock together.** A page without its README entry is + unpublished; a page without its lock entry is unprotected. + +## Reporting + +Say which of these you did, because they are separately verifiable and readers of the summary cannot +tell from "docs updated": + +- whether the page is wired into `children:` and at what sidebar position +- whether the lock entry was recorded, and whether any redirect was appended +- which checks ran and passed +- that publication happens at the next release, not on merge + +## Not in scope + +- Guide prose, structure, archetypes, example labels → `optimization-guide-authoring` +- Migration-guide routing and blueprints → `migration-guide-authoring` +- SDK or migration fact derivation → `sdk-knowledge-authoring`, `migration-knowledge-authoring` +- The `contentful-docs` repository's own conventions and its nav-orphan checks diff --git a/skills/docs-site-publishing/package.json b/skills/docs-site-publishing/package.json new file mode 100644 index 000000000..7ac80d2c3 --- /dev/null +++ b/skills/docs-site-publishing/package.json @@ -0,0 +1,9 @@ +{ + "name": "@contentful/skill-docs-site-publishing", + "version": "0.1.0", + "description": "Wire authored Optimization SDK docs into the published Contentful documentation site", + "license": "MIT", + "files": [ + "SKILL.md" + ] +} diff --git a/skills/optimization-guide-authoring/SKILL.md b/skills/optimization-guide-authoring/SKILL.md index b8df5e113..7106bc54f 100644 --- a/skills/optimization-guide-authoring/SKILL.md +++ b/skills/optimization-guide-authoring/SKILL.md @@ -67,6 +67,9 @@ go deeper. Two consequences drive everything below: owns its Quick-start contract, Milestone contract, `###` inventory/order/category, and what each section must teach or show. Shared wording lives in `documentation/authoring/fragments/`. Open both recipe and blueprint before drafting. +- **Site publishing wiring** — the `fern:` frontmatter block, the group README `children:` manifest, + the slug lock, sidebar order, MDX safety, and the release-gated sync belong to + `docs-site-publishing`. This skill decides what a guide says; that one decides whether it ships. - Concept docs under `documentation/concepts/` — they own deeper mechanics; guides link to them. - Package READMEs, implementation READMEs, and product docs. - Generated TypeDoc under `docs/` — it owns exhaustive, method-by-method API reference. @@ -111,6 +114,10 @@ procedure previews. Put package tradeoffs in `choosing-the-right-sdk.md` and run integration guides. Keep the listing order: Node, Web, React Web, Next.js App Router, Next.js Pages Router, React Native, iOS SwiftUI, iOS UIKit, Android Compose, Android Views. +The README is also the publishing manifest: its frontmatter `children:` list is the only thing the +site exporter reads, and its order is the published sidebar order. A guide missing from `children:` is +not published at all. See `docs-site-publishing`. + ## Workflow 1. **Identify the archetype and reader goal.** Open the matching recipe. For an integration guide, @@ -163,14 +170,21 @@ Router, React Native, iOS SwiftUI, iOS UIKit, Android Compose, Android Views. 6. **Sync the TOC and anchors**, add `## Production checks` and (if there are known failure modes) `## Troubleshooting`, and link the reference implementation READMEs. -7. **Self-review** against [references/authoring-checklist.md](references/authoring-checklist.md). -8. **Validate**: run `pnpm exec prettier --write `, `pnpm guides:check`, and - `pnpm knowledge:check`; confirm the collapsible TOC anchors resolve. -9. **Review new or substantially rewritten guides independently.** A newcomer reviewer checks that - the target reader can perform and verify the guide. A technical-foundation reviewer checks - interfaces against `packages/**/src` and behavior against the knowledge base. Resolve blocker and - high-severity findings before acceptance; the writer does not sign off its own draft. -10. **Report the result.** Return the edited guide path and a short summary of what changed and why. +7. **Wire the page for publication**, following `docs-site-publishing`. A guide that is written but + not wired is invisible on the public site, and that failure is silent. For a new guide: add the + `fern:` block (`slug`, `section`, `description`), add it to the group README `children:` at the + position it should hold in its own sidebar section, and record the slug with + `pnpm docs:fern -- --update-lock`. For a refresh: if the `#` heading changed, leave `fern.slug` + alone — the slug is data precisely so a reworded heading cannot move a live URL. +8. **Self-review** against [references/authoring-checklist.md](references/authoring-checklist.md). +9. **Validate**: run `pnpm exec prettier --write `, `pnpm guides:check`, `pnpm knowledge:check`, + and `pnpm fern:check`; confirm the collapsible TOC anchors resolve. +10. **Review new or substantially rewritten guides independently.** A newcomer reviewer checks that + the target reader can perform and verify the guide. A technical-foundation reviewer checks + interfaces against `packages/**/src` and behavior against the knowledge base. Resolve blocker and + high-severity findings before acceptance; the writer does not sign off its own draft. +11. **Report the result.** Return the edited guide path and a short summary of what changed and why, + including whether the page is wired for publication and that publication is release-gated. For a new guide, draft from the recipe and blueprint. Include every required quick-start artifact and satisfy every “Must teach or show” / “Must route to” item; matching headings alone is incomplete. diff --git a/skills/optimization-guide-authoring/references/authoring-checklist.md b/skills/optimization-guide-authoring/references/authoring-checklist.md index 29a226bf7..3e19c27dd 100644 --- a/skills/optimization-guide-authoring/references/authoring-checklist.md +++ b/skills/optimization-guide-authoring/references/authoring-checklist.md @@ -2,7 +2,7 @@ Run before finishing any guide edit. Assertions are written to be mechanically checkable so they can also back a future validation hook on `documentation/guides/**`. Group A applies to all guides; B–F -add per-archetype checks. +add per-archetype checks; G covers the wiring that decides whether the guide reaches the public site. ## A. All guides @@ -282,3 +282,25 @@ add per-archetype checks. - [ ] No fastest-path column, setup summary, tradeoff matrix, or procedure preview. - [ ] Listing order: Node, Web, React Web, Next.js App Router, Next.js Pages Router, React Native, iOS SwiftUI, iOS UIKit, Android Compose, Android Views. + +## G. Site publishing wiring (every published guide) + +See `docs-site-publishing` for the reasoning behind each item; `pnpm fern:check` enforces all of them. + +- [ ] The guide has a `fern:` frontmatter block with `slug`, `section`, and `description`. +- [ ] `fern.section` is exactly one of `Guides`, `Concepts`, `Migration guides`. +- [ ] `fern.slug` is kebab-case, and for an existing guide it is **unchanged** unless the URL is + deliberately moving — a reworded `#` heading is not a reason to change it. +- [ ] There is no `fern.title`. The published title is the `# ` heading. +- [ ] `description` uses a plain scalar or a `>-` folded block, and nothing else. +- [ ] The guide's filename appears in `documentation/guides/README.md` frontmatter `children:`. +- [ ] Its `children:` index places it where it should appear **within its own `fern.section`** — + ordering is global across the group, not per section. +- [ ] `documentation/fern-slugs.lock.json` records the guide, via + `pnpm docs:fern -- --update-lock`, and the lock diff is committed with the guide. +- [ ] A changed slug produced a redirect entry in the lock, not just a new mapping. +- [ ] No link points into `documentation/authoring/` or `documentation/internal/`. +- [ ] Every cross-document link resolves to a published page, and every `#fragment` matches a real + heading on the target page. +- [ ] Angle-bracket placeholders appear only inside inline code or a fenced block — never bare in + prose, where MDX treats `<` and `{` as active syntax. From 7058dac22000fabe3700ce22ed447f443a99723f Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Mon, 21 Sep 2026 14:45:43 +0200 Subject: [PATCH 2/2] refactor(docs): cut the publishing skill to what is not already written down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first draft restated ADR 0002 and the `documentation/AGENTS.md` publishing section at length. Both already own this material, so the skill was mostly a third copy that could drift from them. It now points at those two as sources of truth and keeps only what neither records: that a document absent from its group README `children:` is silently not published, that `children:` order is global across the group so an index decides position *within* a section, and that a new slug has to be recorded and committed with the page. Same cut in the self-review checklist. `pnpm fern:check` mechanically enforces the `fern:` block shape, slug format, link and anchor resolution, MDX safety, and slug recording, so restating those as human checkboxes added nothing. Section G now asks only what a check cannot judge: intent — whether the `children:` index is the intended position, whether a slug change is deliberate, and whether the lock diff shipped with the guide. Descriptions are matched, not read, so the skill's own description is now a short purpose plus its trigger conditions rather than a summary of its contents. Wire `fern:check` into the documentation-integrity Stop hook, which ran `knowledge:check` and `guides:check` but not the check that decides whether an edit reaches the public site at all. This is the part that actually fires unprompted: the publishing contract breaks silently, so it needs a trigger that does not depend on anyone thinking to look. Watched paths gain `documentation/concepts`, the slug lock, and the fern scripts. Validation: `pnpm fern:check`, `pnpm guides:check`, `pnpm knowledge:check`, `bash -n` on the hook, targeted Prettier, `git diff --check`. Exercised the hook both ways — silent and exit 0 on a clean tree, and reporting the unrecorded-slug problem with its fix command after removing the lock entry. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/documentation-integrity-hook.sh | 17 +- skills/docs-site-publishing/SKILL.md | 181 ++++-------------- skills/optimization-guide-authoring/SKILL.md | 7 +- .../references/authoring-checklist.md | 29 ++- 4 files changed, 66 insertions(+), 168 deletions(-) diff --git a/scripts/documentation-integrity-hook.sh b/scripts/documentation-integrity-hook.sh index f0ca7b357..4d6ebe690 100755 --- a/scripts/documentation-integrity-hook.sh +++ b/scripts/documentation-integrity-hook.sh @@ -12,25 +12,30 @@ cwd="$(printf '%s' "$input" | sed -n 's/.*"cwd"[[:space:]]*:[[:space:]]*"\([^"]* # Only spend time when something the validators inspect changed in this working tree. if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then - changed="$(git status --porcelain -- documentation/internal/sdk-knowledge documentation/internal/migration-knowledge documentation/authoring documentation/guides skills scripts/validate-guide-authoring.ts scripts/validate-sdk-knowledge.ts 'packages/**/src/**' 2>/dev/null)" + changed="$(git status --porcelain -- documentation/internal/sdk-knowledge documentation/internal/migration-knowledge documentation/authoring documentation/guides documentation/concepts documentation/fern-slugs.lock.json skills scripts/validate-guide-authoring.ts scripts/validate-sdk-knowledge.ts scripts/validate-fern-export.ts scripts/fern 'packages/**/src/**' 2>/dev/null)" [ -z "$changed" ] && exit 0 fi -# Run both validators even when the first fails so the agent receives the complete status. +# Run every validator even when an earlier one fails so the agent receives the complete status. +# `fern:check` is here because the publishing contract it enforces — a document listed in its group +# README `children:`, a recorded slug, resolvable links, safe MDX — is what decides whether an edit +# reaches the public site at all, and the ways it breaks are silent rather than loud. knowledge_status=0 guide_status=0 +fern_status=0 knowledge_output="$(pnpm --silent knowledge:check 2>&1)" || knowledge_status=$? guide_output="$(pnpm --silent guides:check 2>&1)" || guide_status=$? -if [ "$knowledge_status" -eq 0 ] && [ "$guide_status" -eq 0 ]; then +fern_output="$(pnpm --silent fern:check 2>&1)" || fern_status=$? +if [ "$knowledge_status" -eq 0 ] && [ "$guide_status" -eq 0 ] && [ "$fern_status" -eq 0 ]; then exit 0 fi # Cap the report so a large failure cannot flood the context window. max_lines=40 -capped="$(printf '%s\n%s\n' "$knowledge_output" "$guide_output" | head -n "$max_lines")" -[ "$(printf '%s\n%s\n' "$knowledge_output" "$guide_output" | wc -l)" -gt "$max_lines" ] && +capped="$(printf '%s\n%s\n%s\n' "$knowledge_output" "$guide_output" "$fern_output" | head -n "$max_lines")" +[ "$(printf '%s\n%s\n%s\n' "$knowledge_output" "$guide_output" "$fern_output" | wc -l)" -gt "$max_lines" ] && capped="${capped} -… (truncated; run \`pnpm knowledge:check\` and \`pnpm guides:check\` to see all problems)" +… (truncated; run \`pnpm knowledge:check\`, \`pnpm guides:check\`, and \`pnpm fern:check\` to see all problems)" reason="Documentation integrity checks are failing. Fix these before finishing: diff --git a/skills/docs-site-publishing/SKILL.md b/skills/docs-site-publishing/SKILL.md index fc151f7e9..758b25ec6 100644 --- a/skills/docs-site-publishing/SKILL.md +++ b/skills/docs-site-publishing/SKILL.md @@ -1,161 +1,64 @@ --- name: docs-site-publishing description: >- - Wire an authored document under documentation/guides/ or documentation/concepts/ into the published - Contentful documentation site. Covers the four things that decide whether a page reaches the site at - all — the group README `children:` list, the `fern:` frontmatter block, the slug lock, and MDX - safety — plus the sidebar ordering rule and the release-gated three-layer sync. Use when adding, - renaming, moving, retitling, reordering, or removing a published document, when changing a - `fern.slug` or `fern.section`, when editing a group README index, or when `pnpm fern:check` fails. - Triggers on "publish the guide", "is this exported", "fern", "fern:check", "frontmatter", "slug", - "fern-slugs.lock", "sidebar order", "nav", "redirect", "contentful-docs", "why isn't my page on the - site". Not guide prose or structure (optimization-guide-authoring) and not fact derivation - (sdk-knowledge-authoring). + Wire a document under documentation/guides/ or documentation/concepts/ into the published Contentful + documentation site. Use when adding, renaming, moving, reordering, or removing a published document, + when editing a `fern:` frontmatter block, a group README `children:` list, or + documentation/fern-slugs.lock.json, and when `pnpm fern:check` fails. argument-hint: '[document, group README, or the failing fern:check output]' paths: documentation/guides/**, documentation/concepts/**, documentation/fern-slugs.lock.json --- # Publishing authored docs to the documentation site -Use this skill with `optimization-guide-authoring`. That skill owns what a guide says and how it -reads; this one owns whether it reaches `contentful/contentful-docs` and stays at a stable URL. -`documentation/AGENTS.md` holds the same contract as repository policy — when the two disagree, that -file wins and this skill should be corrected. +Writing a document does not publish it. Publication is data, and two of its four requirements fail +silently — this skill exists for those two. -Writing a good document is not publishing it. Publication is data: four separate artifacts have to -agree, and three of the four fail silently or late if you skip them. +**Sources of truth** — read rather than restate: -## The publishing contract +- [`documentation/AGENTS.md`](../../documentation/AGENTS.md) "Publishing to the documentation site" — + the rules: the `fern:` block, title-from-heading, slugs, links, MDX safety. +- [`docs/ADRs/0002`](../../docs/ADRs/0002-generate-the-public-documentation-site-content-from-this-repository.md) + — why the exporter is shaped this way, and the three-layer sync. -A document under `documentation/guides/` or `documentation/concepts/` publishes only when **all** of -these hold. `pnpm fern:check` enforces every one of them, so treat a failure as the contract talking. +## The two silent failures -1. **It is listed in its group `README.md` frontmatter `children:`.** The exporter never scans the - filesystem (`loadPublishedDocs` in `scripts/fern/docs.ts` reads `children:` and nothing else). A - file on disk but missing from `children:` is not published — it is invisible, not an error, which - is why this is the easiest step to lose. -2. **It has a `fern:` frontmatter block** with `slug`, `section`, and `description`. Add `navTitle` - only when the sidebar needs a shorter label than the page title. -3. **Its slug is recorded in `documentation/fern-slugs.lock.json`.** Run - `pnpm docs:fern -- --update-lock` and commit the result in the same change as the page. -4. **Its prose is MDX-safe**, and every cross-document link resolves to a published page and a real - heading anchor on it. +**A document absent from its group README `children:` is not published.** The exporter reads that +list and never scans the filesystem, so the page is invisible rather than an error. Nothing fails; the +page simply never exists on the site. -### The `fern:` block +**`children:` order is the sidebar order, and it is global across the group, not per section.** A +page's position inside its `fern.section` comes from its index in the one `children:` list — so +inserting a `Migration guides` page second in `documentation/guides/README.md` puts it _first_ in the +Migration guides sidebar section. Place it where it should land in its own section. -```markdown ---- -fern: - slug: migrate-optimization-sdk-v1-to-v2 - section: Migration guides - description: >- - One or two sentences of meta description, in the same voice as the page's opening. ---- +## Recording the slug -# Migrate Optimization SDK packages from v1 to v2 -``` - -- `section` is exactly one of `Guides`, `Concepts`, `Migration guides`. It is the **sidebar** section - and is independent of which directory the file lives in — a `Migration guides` page lives under - `guides/`, not in a directory of its own. -- `slug` is kebab-case and is **data, never derived from the heading**. That is the whole point: - rewording an `#` heading can then never move a live URL. -- There is no `fern.title`. The published title is the document's `# ` heading, so it cannot drift - from what the reader sees; `fern:check` rejects a reintroduced `fern.title` outright. A group - README's top-level `title:` must match its own `#` heading. -- `description` is a `>-` folded block. The frontmatter reader is deliberately strict and understands - only a plain scalar and `>-` — any other YAML shape is a hard error rather than a silent drop. - -### The slug lock - -`documentation/fern-slugs.lock.json` maps source path → slug, and is the only record of what URL a -page has already occupied. Two cases, one command: - -- **New page** — the lock gains an entry. Nothing about the page changes; what changes is that the - slug is now protected. -- **Changed slug** — the lock records a permanent redirect from the old slug to the new one, so the - live URL keeps working. - -Both: `pnpm docs:fern -- --update-lock`, then commit the lock diff **with the page**. Never hand-edit -the lock; the redirect chain is resolved and validated from it, and a history the exporter cannot -explain fails the check. - -> Why the entry matters for a page that is not live yet: the exporter reconciles an unrecorded -> document in memory, so the page publishes either way. But the change check can only compare against -> a slug the lock names, and the sync workflow runs `pnpm docs:fern` **without** `--update-lock` — so -> an entry the author never commits never appears. Without it, the page's first slug reword moves a -> live URL with no redirect, which is exactly what the lock exists to prevent. - -### Sidebar order - -`children:` order **is** the published sidebar order, authored in reader-routing order. Two -consequences worth stating because neither is obvious: - -- Reordering `children:` reorders the public site. It is not a local bookkeeping list. -- Order is **global across the group**, not per section. A page's position within its `section` comes - from its index in the one `children:` list, so inserting a `Migration guides` page second in - `documentation/guides/README.md` puts it **first** in the Migration guides sidebar section. Place it - where you want it to land in its own section, and keep `children:`, the visible list order, and the - one-sentence descriptions aligned (`pnpm guides:check` enforces the alignment). - -### MDX safety and links - -Published pages become MDX, where `<` and `{` are active syntax. - -- Keep angle-bracket placeholders inside inline code or a fenced block. Never bare in prose. -- Every fenced block needs a language tag. -- Cross-document links must resolve to a published document, and any `#fragment` must match a real - heading on the target page. The exporter promotes the intro prose under an `## Overview` heading, so - `#overview` is always available. -- **Never link a published page to `authoring/` or `internal/`.** Those are not published; the link - would 404 on the site. This is the one link rule a repo-local check would otherwise call valid. - -## How a change reaches the site - -Three layers, each runnable on its own, none needing credentials except the last: - -1. `pnpm docs:fern` — builds the bundle into gitignored `fern-bundle/`: one `.mdx` per page, - `nav-block.yaml`, `redirects.yaml`, `manifest.json`. Pure local transform. -2. `pnpm docs:fern:apply` — writes that bundle into a `contentful-docs` checkout. -3. `.github/workflows/sync-fern-docs.yaml` — opens the pull request on `contentful/contentful-docs`. - -**Publication is release-gated.** An edit merged to `main` reaches the public site at the next -release, not on merge; a maintainer can publish out of band by dispatching the sync workflow from -`main`. So "merged" and "live" are different states — say which one you mean when reporting. - -Do not hand-edit pages in `contentful-docs`. They are generated and the next sync overwrites them; -prose changes belong here. - -## Workflow - -1. **Author the page**, following `optimization-guide-authoring`. -2. **Add the `fern:` block** — `slug`, `section`, `description`. -3. **Add it to `children:`** in the group README, positioned where it should appear in its own - sidebar section, and add its row plus one-sentence description to the visible list. -4. **Record the slug**: `pnpm docs:fern -- --update-lock`. -5. **Validate**: `pnpm fern:check`, then `pnpm guides:check`. Read the reported `file:line: message` - rather than guessing — the messages name the exact fix, including the command to run. -6. **Inspect the bundle** when the page is new or its slug, section, or order changed. `pnpm docs:fern` - then read `fern-bundle/nav-block.yaml` to confirm the page sits in the section and position you - intended, and `fern-bundle/pages/.mdx` to confirm the title and description resolved. The nav - block is the artifact that actually ships; reading it is how you check ordering instead of - reasoning about `children:` indices. -7. **Commit the page, the README, and the lock together.** A page without its README entry is - unpublished; a page without its lock entry is unprotected. +`pnpm docs:fern -- --update-lock`, then commit the lock diff **with the page**. This covers both a new +page and a changed slug; only the latter appends a redirect. Never hand-edit the lock. -## Reporting +A new page publishes without a lock entry, so this is easy to skip — but the slug then has no +protection, because the change check can only compare against a slug the lock already names. `pnpm +fern:check` rejects an unrecorded slug for that reason. + +## Checks -Say which of these you did, because they are separately verifiable and readers of the summary cannot -tell from "docs updated": +`pnpm fern:check` enforces the whole contract and reports `file:line: message` naming the fix. It also +runs from the `Stop` hook whenever published docs change, so a missed step surfaces without being +asked for. + +When a page is new or its slug, section, or order changed, run `pnpm docs:fern` and read +`fern-bundle/nav-block.yaml` — that is the artifact that ships, so it settles ordering questions that +reasoning about `children:` indices does not. + +## Reporting -- whether the page is wired into `children:` and at what sidebar position -- whether the lock entry was recorded, and whether any redirect was appended -- which checks ran and passed -- that publication happens at the next release, not on merge +Publication is **release-gated**: an edit reaches the site at the next release, not on merge. Say +which state you mean, and say whether the page is wired into `children:` and whether the lock entry +was recorded — a reader cannot tell from "docs updated". ## Not in scope -- Guide prose, structure, archetypes, example labels → `optimization-guide-authoring` -- Migration-guide routing and blueprints → `migration-guide-authoring` -- SDK or migration fact derivation → `sdk-knowledge-authoring`, `migration-knowledge-authoring` -- The `contentful-docs` repository's own conventions and its nav-orphan checks +Guide prose and structure (`optimization-guide-authoring`), migration routing +(`migration-guide-authoring`), fact derivation (`sdk-knowledge-authoring`), and the +`contentful-docs` repository's own conventions. diff --git a/skills/optimization-guide-authoring/SKILL.md b/skills/optimization-guide-authoring/SKILL.md index 7106bc54f..30423cbb5 100644 --- a/skills/optimization-guide-authoring/SKILL.md +++ b/skills/optimization-guide-authoring/SKILL.md @@ -171,11 +171,8 @@ not published at all. See `docs-site-publishing`. 6. **Sync the TOC and anchors**, add `## Production checks` and (if there are known failure modes) `## Troubleshooting`, and link the reference implementation READMEs. 7. **Wire the page for publication**, following `docs-site-publishing`. A guide that is written but - not wired is invisible on the public site, and that failure is silent. For a new guide: add the - `fern:` block (`slug`, `section`, `description`), add it to the group README `children:` at the - position it should hold in its own sidebar section, and record the slug with - `pnpm docs:fern -- --update-lock`. For a refresh: if the `#` heading changed, leave `fern.slug` - alone — the slug is data precisely so a reworded heading cannot move a live URL. + not wired is invisible on the public site, and that failure is silent. On a refresh, a reworded + `#` heading is never a reason to touch `fern.slug`. 8. **Self-review** against [references/authoring-checklist.md](references/authoring-checklist.md). 9. **Validate**: run `pnpm exec prettier --write `, `pnpm guides:check`, `pnpm knowledge:check`, and `pnpm fern:check`; confirm the collapsible TOC anchors resolve. diff --git a/skills/optimization-guide-authoring/references/authoring-checklist.md b/skills/optimization-guide-authoring/references/authoring-checklist.md index 3e19c27dd..6238c0aad 100644 --- a/skills/optimization-guide-authoring/references/authoring-checklist.md +++ b/skills/optimization-guide-authoring/references/authoring-checklist.md @@ -285,22 +285,15 @@ add per-archetype checks; G covers the wiring that decides whether the guide rea ## G. Site publishing wiring (every published guide) -See `docs-site-publishing` for the reasoning behind each item; `pnpm fern:check` enforces all of them. +`pnpm fern:check` mechanically enforces the publishing contract — the `fern:` block's shape, slug +format, link and anchor resolution, MDX safety, and that the slug is recorded. Run it and fix what it +reports; those rules are deliberately **not** repeated here. What it cannot check is intent, which is +all this section asks about. See `docs-site-publishing`. -- [ ] The guide has a `fern:` frontmatter block with `slug`, `section`, and `description`. -- [ ] `fern.section` is exactly one of `Guides`, `Concepts`, `Migration guides`. -- [ ] `fern.slug` is kebab-case, and for an existing guide it is **unchanged** unless the URL is - deliberately moving — a reworded `#` heading is not a reason to change it. -- [ ] There is no `fern.title`. The published title is the `# ` heading. -- [ ] `description` uses a plain scalar or a `>-` folded block, and nothing else. -- [ ] The guide's filename appears in `documentation/guides/README.md` frontmatter `children:`. -- [ ] Its `children:` index places it where it should appear **within its own `fern.section`** — - ordering is global across the group, not per section. -- [ ] `documentation/fern-slugs.lock.json` records the guide, via - `pnpm docs:fern -- --update-lock`, and the lock diff is committed with the guide. -- [ ] A changed slug produced a redirect entry in the lock, not just a new mapping. -- [ ] No link points into `documentation/authoring/` or `documentation/internal/`. -- [ ] Every cross-document link resolves to a published page, and every `#fragment` matches a real - heading on the target page. -- [ ] Angle-bracket placeholders appear only inside inline code or a fenced block — never bare in - prose, where MDX treats `<` and `{` as active syntax. +- [ ] Its index in `children:` places it where it should appear **within its own `fern.section`** — + ordering is global across the group, not per section, so position N in the one `guides/` list + decides where the page lands inside its section. +- [ ] `fern.slug` is unchanged unless the URL is **deliberately** moving. A reworded `#` heading is + never a reason to change it. +- [ ] The `documentation/fern-slugs.lock.json` diff is committed **with** the guide, not left behind + in a follow-up.