From 0ba54fc87ebaff837d86957897fd97a1f97eb8ba Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 14:45:17 -0700 Subject: [PATCH 01/24] =?UTF-8?q?docs(specs):=20example-first=20docs=20con?= =?UTF-8?q?tent=20=E2=80=94=20ExampleCode=20include,=20guards,=20walkthrou?= =?UTF-8?q?gh=20retirement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- ...09-05-docs-example-first-content-design.md | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-05-docs-example-first-content-design.md diff --git a/docs/superpowers/specs/2026-09-05-docs-example-first-content-design.md b/docs/superpowers/specs/2026-09-05-docs-example-first-content-design.md new file mode 100644 index 000000000..b66e8597f --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-docs-example-first-content-design.md @@ -0,0 +1,204 @@ +# Example-first docs content — design + +**Date:** 2026-09-05 +**Status:** approved +**Follows:** `2026-09-04-docs-workspace-unification-design.md` (Parts A–D, complete) + +## Problem + +Every mapped capability (47 today) teaches the same topic three times with +three different code versions: + +1. The running example under `cockpit///` (Angular app plus + a Python or TypeScript backend), shown live in the Run tab and as + highlighted source in the Code tab. +2. A walkthrough at `cockpit///python/docs/guide.md` (40 + files, about 6,000 lines) written with ``, `` and + `` tags for the workspace's narrative-docs panel. On the Website + the docs page's own MDX fills the Docs tab, so these walkthroughs are + never rendered anywhere. +3. The docs page itself under `apps/website/content/docs/**`, which + hand-writes its own snippets instead of using the example. + +The user's direction: the `/docs` page is the one teaching surface, and it +teaches through the live example. Where duplicates exist, the docs page is +rewritten to use the example as its primary angle and the walkthrough is +absorbed and deleted. + +## Goals + +- A mapped docs page's code comes from the example the page embeds, so the + article, the Code tab and the running demo can never disagree. +- One teaching surface per topic. The walkthrough files and the machinery + that rendered them are removed. +- Guards make regressions fail in CI: a mapped page that stops using its + example, an include that names a file the example does not ship, a + walkthrough file reappearing. + +## Non-goals + +- The 82 docs-only pages (no capability mapped). They keep hand-written + snippets. +- Multi-runtime variants inside one page. The example covers one runtime per + page; other-runtime fragments may stay as ordinary fences. +- Changing the workspace shell's Run, Code or API tabs. +- Preserving the walkthroughs' `` blocks (decided: dropped). + +## Facts the design rests on + +- `apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx` already + calls `getWebsiteWorkspacePage`, which resolves the capability and awaits + `getContentBundle(presentation)`. The bundle's `codeFiles` maps every + `codeAssetPaths` and `backendAssetPaths` entry (repo-relative path) to + shiki-highlighted HTML produced by `highlightCode` in + `libs/cockpit-shell/src/lib/workspace-content.ts`. The Code tab renders that + HTML with `dangerouslySetInnerHTML` (`code-mode.tsx`). +- MDX is compiled with `next-mdx-remote/rsc` in + `apps/website/src/components/docs/MdxRenderer.tsx`; the component map is a + module constant and `MdxRenderer` takes only `source`. Async server + components are valid in that map. +- `Pre` (`components/docs/mdx/CodeBlock.tsx`) wraps every fenced block with + the copy button and the `mdx-pre` styling. +- The docs search index (`docs-search-index.ts`) strips fenced code from the + MDX source and never sees rendered output, so included code is invisible to + search exactly like fenced code is today. +- `narrativeDocs` flows: descriptor `docsAssetPaths` → + `workspace-presentation.ts` → `workspace-content.ts` (`renderMarkdown` with + custom tags in `render-markdown.ts`) → `workspace-shell.tsx` → + `components/narrative-docs/narrative-docs.tsx`. Nothing else reads + `docsAssetPaths` or `guide.md`. +- Mapped pages per product (manifest): chat 13, langgraph 9, ag-ui 7, + render 7, deep-agents 6, runtimes 4, a2ui 1. A manifest entry does not + guarantee an example: `/docs/langgraph/getting-started/introduction` is in + the manifest but has no content descriptor, so its bundle carries no code. + Throughout this spec "mapped page" means a docsPath whose content + descriptor lists at least one `codeAssetPaths` or `backendAssetPaths` + entry; every other page is docs-only for this program. + +## Design + +### 1. `` MDX component + +Location: `apps/website/src/components/docs/mdx/ExampleCode.tsx`, a server +component. `MdxRenderer` gains an optional `exampleCode` prop carrying the +page's bundle data; when present the component map is extended with an +`ExampleCode` bound to that data. Pages without a mapped capability pass +nothing, and an `ExampleCode` tag on such a page throws at build time with +the page path in the message. + +Props: + +- `file` (required): a basename (`streaming.component.ts`) or a + repo-relative path. Basename matching must be unique among the + capability's `codeAssetPaths` plus `backendAssetPaths`; an ambiguous + basename or an unknown file throws. +- `region` (optional): the name in a fold-marker pair inside that file. + Marker syntax per language: `// #region name` / `// #endregion` for + TypeScript, `# region name` / `# endregion` for Python, + `` / `` for HTML. Marker lines are + stripped from the rendered slice and the slice is de-indented to its + shallowest line. An unknown region throws. +- `title` (optional): overrides the title bar, which defaults to the + basename. + +Rendering: whole-file includes render the bundle's existing highlighted HTML. +Region includes need raw source, so `ContentBundle` gains `codeSources: +Record` (raw text keyed like `codeFiles`) and the component +highlights the slice with the same `highlightCode`. Output is wrapped in the +same markup `Pre` produces (`mdx-pre-wrap`, `mdx-pre`, the copy button and +its analytics event, `data-title`) so every existing style and the copy +behaviour apply unchanged and `CodeGroup` can tab several `ExampleCode` +blocks. The Code tab's markers stay visible there; they read as the +documentation anchors they are. + +### 2. Guards + +`apps/website/src/lib/docs-example-code.spec.ts`: + +- For every mapped page (descriptor with code assets), load the MDX file and + require at least one ` Date: Sat, 5 Sep 2026 14:58:04 -0700 Subject: [PATCH 02/24] =?UTF-8?q?docs(plans):=20PR=201=20of=20example-firs?= =?UTF-8?q?t=20docs=20=E2=80=94=20ExampleCode=20include,=20guards,=20walkt?= =?UTF-8?q?hrough=20machinery=20removal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- ...09-05-docs-example-first-infrastructure.md | 1304 +++++++++++++++++ 1 file changed, 1304 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-05-docs-example-first-infrastructure.md diff --git a/docs/superpowers/plans/2026-09-05-docs-example-first-infrastructure.md b/docs/superpowers/plans/2026-09-05-docs-example-first-infrastructure.md new file mode 100644 index 000000000..3a5febb95 --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-docs-example-first-infrastructure.md @@ -0,0 +1,1304 @@ +# Example-first docs infrastructure (PR 1) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the `` MDX include, its guards, and the removal of the never-rendered narrative-docs machinery, so the LangGraph pilot (PR 2) can rewrite pages against a real component. + +**Architecture:** The docs route already loads each mapped page's example files into a `ContentBundle`; this PR adds the raw sources to that bundle, resolves them in a pure Website module (`lib/example-code.ts`), and renders them through a server component that feeds a synthesized code fence back into the same `MDXRemote` pipeline the page uses, so highlighting, the `Pre` copy button and every existing `pre` style apply unchanged. A unit guard joins registry descriptors to MDX files and fails on missing or unresolvable includes. The walkthrough renderer, panel, descriptor field and analytics hook are deleted. + +**Tech Stack:** Next.js app router (RSC), `next-mdx-remote/rsc`, `rehype-pretty-code`, Vitest (jsdom for `apps/website`, node for the libs), Nx. + +**Spec:** `docs/superpowers/specs/2026-09-05-docs-example-first-content-design.md` + +**Branch:** `blove/docs-example-first-infra`, created from `origin/main` after `git fetch origin main` (see CONTRIBUTING "Working in a git worktree"; never branch from a stale local main). + +**Test commands used throughout** (run from the repo root unless stated): + +- Website unit: `cd apps/website && npx vitest run ` (never `--root`) +- Libraries: `npx nx test cockpit-shell`, `npx nx test workspace-react`, `npx nx test cockpit-registry` +- Full: `npx nx run-many -t test --projects=cockpit-registry,cockpit-shell,workspace-react,website` +- Lint: `npx nx run-many -t lint --projects=cockpit-registry,cockpit-shell,workspace-react,website` (lint ERRORS block; warnings do not) +- Build: `rm -rf apps/website/.next && npx nx build website` + +--- + +## File map + +| Path | Responsibility | +| --- | --- | +| `libs/cockpit-shell/src/lib/workspace-content.ts` (modify) | `ContentBundle.codeSources` (raw text per asset path); drop `narrativeDocs` | +| `libs/cockpit-shell/src/lib/render-markdown.ts` + `.spec.ts` (delete) | Walkthrough renderer; nothing else uses it | +| `libs/cockpit-shell/src/lib/workspace-presentation.ts` (modify) | drop `docsAssetPaths` from both presentation unions | +| `libs/cockpit-registry/src/lib/content-descriptors.ts` (modify) | drop `docsAssetPaths` from the type, the freezer, 40 entries, `deriveAvailableModes` | +| `cockpit/*/*/{python,angular}/src/index.ts` (modify, 41 files) | drop the mirrored `docsAssetPaths` type line and 40 values | +| `deployments/{ag-ui-dev,shared-dev}/deps/**` (regenerate) | generated copies of the example modules | +| `libs/workspace-react/src/lib/components/narrative-docs/*` (delete) | Walkthrough panel | +| `libs/workspace-react/src/lib/{workspace-shell,workspace-provider,host-services}.ts(x)` (modify) | remove the panel branch and `trackNarrativeAction` plumbing | +| `apps/website/src/lib/example-code.ts` + `.spec.ts` (create) | pure resolution: file lookup, region slicing, fence synthesis, error types | +| `apps/website/src/components/docs/mdx-options.ts` (create) | the one `MDXRemote` options object, shared by the page renderer and `ExampleCode` | +| `apps/website/src/components/docs/mdx/ExampleCode.tsx` + `.spec.tsx` (create) | server component factory bound to a page's example context | +| `apps/website/src/components/docs/MdxRenderer.tsx` (modify) | `exampleCode` prop → bound `ExampleCode` in the component map | +| `apps/website/src/lib/workspace-page.ts` + `.spec.ts` (modify) | `getExampleCodeContext(model)` | +| `apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx` + `.spec.tsx` (modify) | pass the context to `MdxRenderer` | +| `apps/website/src/lib/docs-example-code.spec.ts` (create) | the guard: mapped pages include their example; includes resolve; docs-only pages never include | +| `apps/website/content/docs/langgraph/guides/streaming.mdx` (modify) | first consumer | +| `apps/website/src/styles/docs.css` (modify) | `.mdx-example-code*` rules | +| `apps/website/src/components/workspace/WebsiteWorkspace.tsx` + `.spec.tsx`, `apps/website/src/lib/analytics/events.ts` (modify) | drop `trackNarrativeAction` and its event | +| `CONTRIBUTING.md` (modify) | "Docs pages and example code" section | + +--- + +### Task 1: Raw sources in the content bundle + +**Files:** +- Modify: `libs/cockpit-shell/src/lib/workspace-content.ts` +- Test: `libs/cockpit-shell/src/lib/workspace-content.spec.ts` +- Modify (fixtures, type only): `libs/workspace-react/src/lib/workspace-shell.spec.tsx:62-75`, `libs/workspace-react/src/lib/workspace-provider.spec.tsx:57`, `libs/workspace-react/src/lib/public-api.spec.tsx:93`, `apps/website/src/components/workspace/WebsiteWorkspace.spec.tsx:72`, `apps/website/src/lib/workspace-page.spec.ts:80` + +- [ ] **Step 1: Write the failing tests** + +In `workspace-content.spec.ts`, inside `describe('getContentBundle')`, extend the first test (`returns highlighted code and raw prompt content…`) with: + +```ts + expect(bundle.codeSources).toEqual({ + 'cockpit/langgraph/streaming/python/src/index.ts': 'const x = 1;', + }); +``` + +and extend `returns a placeholder string when a code file is missing` with: + +```ts + expect(bundle.codeSources).toEqual({}); +``` + +and extend `returns empty maps for a docs-only presentation` with: + +```ts + expect(bundle.codeSources).toEqual({}); +``` + +- [ ] **Step 2: Run the spec to verify it fails** + +Run: `npx nx test cockpit-shell --skip-nx-cache` +Expected: FAIL, `expected undefined to deeply equal { 'cockpit/…/index.ts': 'const x = 1;' }` (TypeScript also reports `codeSources` missing on `ContentBundle`). + +- [ ] **Step 3: Implement** + +In `workspace-content.ts`: + +```ts +export interface ContentBundle { + codeFiles: Record; + /** Raw text of every readable code or backend asset, keyed like codeFiles. */ + codeSources: Record; + promptFiles: Record; + runtimeUrl: string | null; + docSections: DocSection[]; + narrativeDocs: NarrativeDoc[]; +} +``` + +In the docs-only early return add `codeSources: {},`. In the loop: + +```ts + const codeFiles: Record = {}; + const codeSources: Record = {}; + for (const path of allCodePaths) { + const source = readFileSafe(workspaceRoot, path); + if (source === null) { + codeFiles[path] = `File not found: ${path}`; + } else { + codeFiles[path] = await highlightCode(source, path); + codeSources[path] = source; +``` + +and return `{ codeFiles, codeSources, promptFiles, runtimeUrl, docSections, narrativeDocs }`. + +Add `codeSources: {},` to each `ContentBundle` fixture listed under Files (`workspace-shell.spec.tsx` gets `codeSources: { 'example.ts': 'source' }`). + +- [ ] **Step 4: Run the tests** + +Run: `npx nx run-many -t test --projects=cockpit-shell,workspace-react,website --skip-nx-cache` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/cockpit-shell/src/lib/workspace-content.ts libs/cockpit-shell/src/lib/workspace-content.spec.ts libs/workspace-react/src/lib/*.spec.tsx apps/website/src/components/workspace/WebsiteWorkspace.spec.tsx apps/website/src/lib/workspace-page.spec.ts +git commit -m "feat(cockpit-shell): carry raw example sources in the content bundle" +``` + +--- + +### Task 2: Pure example-code resolution + +**Files:** +- Create: `apps/website/src/lib/example-code.ts` +- Test: `apps/website/src/lib/example-code.spec.ts` + +- [ ] **Step 1: Write the failing tests** + +```ts +import { describe, expect, it } from 'vitest'; +import { + ExampleCodeError, + exampleTitle, + fenceFor, + resolveExampleFile, + sliceRegion, + type ExampleCodeContext, +} from './example-code'; + +const context: ExampleCodeContext = { + docsPath: '/docs/langgraph/guides/streaming', + assetPaths: [ + 'cockpit/langgraph/streaming/angular/src/app/streaming.component.ts', + 'cockpit/langgraph/streaming/angular/src/app/app.config.ts', + 'cockpit/langgraph/streaming/python/src/graph.py', + ], + sources: { + 'cockpit/langgraph/streaming/angular/src/app/streaming.component.ts': 'export class StreamingComponent {}', + 'cockpit/langgraph/streaming/angular/src/app/app.config.ts': 'export const appConfig = {};', + 'cockpit/langgraph/streaming/python/src/graph.py': 'graph = None', + }, +}; + +describe('resolveExampleFile', () => { + it('resolves a basename to the one asset path that ends with it', () => { + expect(resolveExampleFile('streaming.component.ts', context)).toBe( + 'cockpit/langgraph/streaming/angular/src/app/streaming.component.ts' + ); + }); + + it('accepts a full repo-relative path', () => { + expect( + resolveExampleFile('cockpit/langgraph/streaming/python/src/graph.py', context) + ).toBe('cockpit/langgraph/streaming/python/src/graph.py'); + }); + + it('throws with the page and file when nothing matches', () => { + expect(() => resolveExampleFile('missing.ts', context)).toThrow(ExampleCodeError); + expect(() => resolveExampleFile('missing.ts', context)).toThrow( + /\/docs\/langgraph\/guides\/streaming.*missing\.ts/ + ); + }); + + it('throws when a basename is ambiguous', () => { + const ambiguous: ExampleCodeContext = { + ...context, + assetPaths: ['a/index.ts', 'b/index.ts'], + sources: { 'a/index.ts': '', 'b/index.ts': '' }, + }; + expect(() => resolveExampleFile('index.ts', ambiguous)).toThrow(/ambiguous/); + }); + + it('throws when the asset is declared but its source was not readable', () => { + const unread: ExampleCodeContext = { ...context, sources: {} }; + expect(() => resolveExampleFile('graph.py', unread)).toThrow(/could not be read/); + }); +}); + +describe('sliceRegion', () => { + it('slices a TypeScript region, strips the markers, and de-indents', () => { + const source = [ + 'class A {', + ' // #region submit', + ' send(text: string) {', + ' this.agent.submit({ message: text });', + ' }', + ' // #endregion', + '}', + ].join('\n'); + expect(sliceRegion(source, 'submit', 'x.ts')).toBe( + ['send(text: string) {', ' this.agent.submit({ message: text });', '}'].join('\n') + ); + }); + + it('accepts the Python and HTML marker forms', () => { + expect(sliceRegion('# region g\ngraph = 1\n# endregion\n', 'g', 'x.py')).toBe('graph = 1'); + expect( + sliceRegion('\n

hi

\n\n', 't', 'x.html') + ).toBe('

hi

'); + }); + + it('throws naming the file when the region is missing or unterminated', () => { + expect(() => sliceRegion('const a = 1;', 'nope', 'x.ts')).toThrow(/x\.ts.*nope/); + expect(() => sliceRegion('// #region open\nconst a = 1;', 'open', 'x.ts')).toThrow( + /unterminated/ + ); + }); +}); + +describe('fenceFor', () => { + it('maps the extension to a fence language', () => { + expect(fenceFor('const a = 1;', 'x.ts')).toBe('```ts\nconst a = 1;\n```'); + expect(fenceFor('a = 1', 'x.py')).toBe('```python\na = 1\n```'); + expect(fenceFor('

', 'x.html')).toBe('```html\n

\n```'); + }); + + it('uses a longer fence than any backtick run inside the code', () => { + expect(fenceFor('const s = `a```b`;', 'x.ts')).toBe('````ts\nconst s = `a```b`;\n````'); + }); + + it('strips one trailing newline so the fence closes on its own line', () => { + expect(fenceFor('a = 1\n', 'x.py')).toBe('```python\na = 1\n```'); + }); +}); + +describe('exampleTitle', () => { + it('is the basename', () => { + expect(exampleTitle('cockpit/langgraph/streaming/python/src/graph.py')).toBe('graph.py'); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd apps/website && npx vitest run example-code` +Expected: FAIL, `Failed to resolve import "./example-code"`. + +- [ ] **Step 3: Implement `apps/website/src/lib/example-code.ts`** + +```ts +/** + * Resolution for ``: which asset a docs page means, which slice + * of it, and the fence that feeds it back through the MDX code pipeline. + * Pure so the build-time component and the unit guard share one rule. + */ + +export interface ExampleCodeContext { + /** The docs route the include appears on; only used in error messages. */ + readonly docsPath: string; + /** codeAssetPaths + backendAssetPaths of the page's capability. */ + readonly assetPaths: readonly string[]; + /** Raw text per asset path (ContentBundle.codeSources). */ + readonly sources: Readonly>; +} + +export class ExampleCodeError extends Error { + override readonly name = 'ExampleCodeError'; +} + +export function resolveExampleFile(file: string, context: ExampleCodeContext): string { + const matches = context.assetPaths.filter( + (path) => path === file || path.endsWith(`/${file}`) + ); + if (matches.length === 0) { + throw new ExampleCodeError( + `${context.docsPath}: matches none of the page's example files: ${context.assetPaths.join(', ')}` + ); + } + if (matches.length > 1) { + throw new ExampleCodeError( + `${context.docsPath}: is ambiguous: ${matches.join(', ')}. Use the full path.` + ); + } + const [path] = matches; + if (!(path in context.sources)) { + throw new ExampleCodeError( + `${context.docsPath}: resolves to ${path}, which could not be read` + ); + } + return path; +} + +const REGION_START = /^\s*(?:\/\/|#|)?\s*$/; +const REGION_END = /^\s*(?:\/\/|#|` … `` in HTML. The marker + lines are stripped and the slice is de-indented. Markers stay visible in the + Code tab; keep the names meaningful. +- Hand-written fences stay allowed for fragments the example does not cover, + such as another runtime's variant. + +`apps/website/src/lib/docs-example-code.spec.ts` fails when a mapped page +includes nothing, when an include does not resolve, or when a docs-only page +uses the tag. Pages not yet rewritten sit in its `PENDING_PAGES` list; a page +that gains its first include must leave the list in the same change. +``` + +- [ ] **Step 2: Check the nested fence** + +The section contains an inner ```` ```mdx ```` fence. When pasting into `CONTRIBUTING.md` it is a top-level block (not nested), so three backticks are correct. Confirm with `grep -c '^```' CONTRIBUTING.md` that the count is even. + +- [ ] **Step 3: Commit** + +```bash +git add CONTRIBUTING.md +git commit -m "docs(contributing): how docs pages include example code" +``` + +--- + +### Task 9: Whole-tree verification and PR + +- [ ] **Step 1: Deletion safety** + +```bash +grep -rn "narrativeDocs\|NarrativeDoc\b\|docsAssetPaths\|renderMarkdown\|render-markdown\|TrackNarrativeAction" libs apps scripts cockpit deployments --include='*.ts' --include='*.tsx' --include='*.mjs' | grep -v 'libs/chat/' +``` + +Expected: no output. (`libs/chat` has its own unrelated `renderMarkdown`.) + +- [ ] **Step 2: Full test, lint, build** + +```bash +npx nx run-many -t test,lint --projects=cockpit-registry,cockpit-shell,workspace-react,website --skip-nx-cache +rm -rf apps/website/.next && npx nx build website +npx nx test scripts --skip-nx-cache +``` + +Expected: all PASS; the build succeeds. Lint warnings are acceptable, errors are not. + +- [ ] **Step 3: Confirm the walkthrough files are now untouched by code** + +```bash +ls cockpit/*/*/*/docs/guide.md | wc -l +``` + +Expected: `40` — they stay on disk until each product PR absorbs them (spec §4). + +- [ ] **Step 4: Open the PR** + +```bash +git push -u origin blove/docs-example-first-infra +gh pr create --title "feat(docs): ExampleCode include + guards; retire the walkthrough renderer" --body-file - <<'EOF' +PR 1 of the example-first docs program (spec: docs/superpowers/specs/2026-09-05-docs-example-first-content-design.md). + +- `` renders a page's example files through the docs MDX pipeline (same highlighting, copy button, styles). Unresolvable includes fail the build. +- Guard `apps/website/src/lib/docs-example-code.spec.ts`: mapped pages include their example (40 pending, streaming converted), includes resolve, docs-only pages never include. +- Deleted the never-rendered walkthrough machinery: `docsAssetPaths`, `renderMarkdown`, `NarrativeDocs`, `trackNarrativeAction`. The 40 `guide.md` files stay until each product PR absorbs them. + +Verification: unit + lint on cockpit-registry, cockpit-shell, workspace-react, website; `nx build website`; mutation check (unknown file fails the build). + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +``` + +Then wait for the Website preview lane; open `/docs/langgraph/guides/streaming` on the aliased preview and confirm the "The running example" block shows highlighted code with a copy button and a `streaming.component.ts` title bar. From 22e41aa89bf2f059211312f7f912bf1f82b68fef Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 15:07:04 -0700 Subject: [PATCH 03/24] feat(cockpit-shell): carry raw example sources in the content bundle Co-Authored-By: Claude Fable 5.1 --- .../components/workspace/WebsiteWorkspace.spec.tsx | 1 + apps/website/src/lib/workspace-page.spec.ts | 1 + .../src/lib/workspace-content.spec.ts | 5 +++++ libs/cockpit-shell/src/lib/workspace-content.ts | 14 +++++++++++++- libs/workspace-react/src/lib/public-api.spec.tsx | 1 + .../src/lib/workspace-provider.spec.tsx | 1 + .../src/lib/workspace-shell.spec.tsx | 1 + 7 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/website/src/components/workspace/WebsiteWorkspace.spec.tsx b/apps/website/src/components/workspace/WebsiteWorkspace.spec.tsx index ff19c5638..67300dc29 100644 --- a/apps/website/src/components/workspace/WebsiteWorkspace.spec.tsx +++ b/apps/website/src/components/workspace/WebsiteWorkspace.spec.tsx @@ -66,6 +66,7 @@ import { WebsiteWorkspace, WebsiteWorkspaceRoot } from './WebsiteWorkspace'; const emptyContent: ContentBundle = { codeFiles: {}, + codeSources: {}, promptFiles: {}, runtimeUrl: null, docSections: [], diff --git a/apps/website/src/lib/workspace-page.spec.ts b/apps/website/src/lib/workspace-page.spec.ts index 4d2a655f5..907abf5fc 100644 --- a/apps/website/src/lib/workspace-page.spec.ts +++ b/apps/website/src/lib/workspace-page.spec.ts @@ -74,6 +74,7 @@ describe('getWebsiteWorkspacePage', () => { }); expect(page.contentBundle).toEqual({ codeFiles: {}, + codeSources: {}, promptFiles: {}, runtimeUrl: null, docSections: [], diff --git a/libs/cockpit-shell/src/lib/workspace-content.spec.ts b/libs/cockpit-shell/src/lib/workspace-content.spec.ts index 10c00f39b..5778a0c9f 100644 --- a/libs/cockpit-shell/src/lib/workspace-content.spec.ts +++ b/libs/cockpit-shell/src/lib/workspace-content.spec.ts @@ -191,6 +191,9 @@ describe('getContentBundle', () => { expect(bundle.docSections).toEqual([]); expect(bundle.narrativeDocs).toEqual([]); expect(mockExistsSync).toHaveBeenCalledTimes(1); + expect(bundle.codeSources).toEqual({ + 'cockpit/langgraph/streaming/python/src/index.ts': 'const x = 1;', + }); }); it('returns a placeholder string when a code file is missing', async () => { @@ -220,6 +223,7 @@ describe('getContentBundle', () => { expect(bundle.runtimeUrl).toBeNull(); expect(bundle.docSections).toEqual([]); expect(bundle.narrativeDocs).toEqual([]); + expect(bundle.codeSources).toEqual({}); }); it('falls back to unhighlighted code when Shiki fails', async () => { @@ -263,6 +267,7 @@ describe('getContentBundle', () => { expect(bundle.narrativeDocs).toEqual([]); expect(mockReadFileSync).not.toHaveBeenCalled(); expect(mockCodeToHtml).not.toHaveBeenCalled(); + expect(bundle.codeSources).toEqual({}); }); it('extracts docSections from code and backend files', async () => { diff --git a/libs/cockpit-shell/src/lib/workspace-content.ts b/libs/cockpit-shell/src/lib/workspace-content.ts index 0eee512c1..35feece24 100644 --- a/libs/cockpit-shell/src/lib/workspace-content.ts +++ b/libs/cockpit-shell/src/lib/workspace-content.ts @@ -35,6 +35,8 @@ export interface NarrativeDoc { export interface ContentBundle { codeFiles: Record; + /** Raw text of every readable code or backend asset, keyed like codeFiles. */ + codeSources: Record; promptFiles: Record; runtimeUrl: string | null; docSections: DocSection[]; @@ -145,6 +147,7 @@ export async function getContentBundle( if (presentation.kind === 'docs-only') { return { codeFiles: {}, + codeSources: {}, promptFiles: {}, runtimeUrl: null, docSections: [], @@ -158,12 +161,14 @@ export async function getContentBundle( const docSections: DocSection[] = []; const codeFiles: Record = {}; + const codeSources: Record = {}; for (const path of allCodePaths) { const source = readFileSafe(workspaceRoot, path); if (source === null) { codeFiles[path] = `File not found: ${path}`; } else { codeFiles[path] = await highlightCode(source, path); + codeSources[path] = source; // Extract doc sections const fileName = path.split('/').pop() ?? path; @@ -210,5 +215,12 @@ export async function getContentBundle( } } - return { codeFiles, promptFiles, runtimeUrl, docSections, narrativeDocs }; + return { + codeFiles, + codeSources, + promptFiles, + runtimeUrl, + docSections, + narrativeDocs, + }; } diff --git a/libs/workspace-react/src/lib/public-api.spec.tsx b/libs/workspace-react/src/lib/public-api.spec.tsx index c89a37410..1d3ba01cd 100644 --- a/libs/workspace-react/src/lib/public-api.spec.tsx +++ b/libs/workspace-react/src/lib/public-api.spec.tsx @@ -87,6 +87,7 @@ describe('@threadplane/workspace-react public boundary', () => { }} contentBundle={{ codeFiles: {}, + codeSources: {}, promptFiles: {}, runtimeUrl: null, docSections: [], diff --git a/libs/workspace-react/src/lib/workspace-provider.spec.tsx b/libs/workspace-react/src/lib/workspace-provider.spec.tsx index 867c0c69f..3885350a2 100644 --- a/libs/workspace-react/src/lib/workspace-provider.spec.tsx +++ b/libs/workspace-react/src/lib/workspace-provider.spec.tsx @@ -51,6 +51,7 @@ const presentation: WorkspacePresentation = { const contentBundle: ContentBundle = { codeFiles: { 'example.ts': '

source
' }, + codeSources: { 'example.ts': 'source' }, promptFiles: {}, runtimeUrl: null, docSections: [], diff --git a/libs/workspace-react/src/lib/workspace-shell.spec.tsx b/libs/workspace-react/src/lib/workspace-shell.spec.tsx index cd0261ffd..27a1573f4 100644 --- a/libs/workspace-react/src/lib/workspace-shell.spec.tsx +++ b/libs/workspace-react/src/lib/workspace-shell.spec.tsx @@ -61,6 +61,7 @@ const presentation: WorkspacePresentation = { }; const contentBundle: ContentBundle = { codeFiles: { 'example.ts': '
source
' }, + codeSources: { 'example.ts': 'source' }, promptFiles: {}, runtimeUrl: 'https://runtime.example.test/demo', docSections: [], From 5c793b17843548aedf1b458996184213d9e3a797 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 15:12:02 -0700 Subject: [PATCH 04/24] feat(website): pure resolution for example-code includes Co-Authored-By: Claude Fable 5.1 --- apps/website/src/lib/example-code.spec.ts | 111 ++++++++++++++++++++++ apps/website/src/lib/example-code.ts | 90 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 apps/website/src/lib/example-code.spec.ts create mode 100644 apps/website/src/lib/example-code.ts diff --git a/apps/website/src/lib/example-code.spec.ts b/apps/website/src/lib/example-code.spec.ts new file mode 100644 index 000000000..8f4cc4a4c --- /dev/null +++ b/apps/website/src/lib/example-code.spec.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { + ExampleCodeError, + exampleTitle, + fenceFor, + resolveExampleFile, + sliceRegion, + type ExampleCodeContext, +} from './example-code'; + +const context: ExampleCodeContext = { + docsPath: '/docs/langgraph/guides/streaming', + assetPaths: [ + 'cockpit/langgraph/streaming/angular/src/app/streaming.component.ts', + 'cockpit/langgraph/streaming/angular/src/app/app.config.ts', + 'cockpit/langgraph/streaming/python/src/graph.py', + ], + sources: { + 'cockpit/langgraph/streaming/angular/src/app/streaming.component.ts': 'export class StreamingComponent {}', + 'cockpit/langgraph/streaming/angular/src/app/app.config.ts': 'export const appConfig = {};', + 'cockpit/langgraph/streaming/python/src/graph.py': 'graph = None', + }, +}; + +describe('resolveExampleFile', () => { + it('resolves a basename to the one asset path that ends with it', () => { + expect(resolveExampleFile('streaming.component.ts', context)).toBe( + 'cockpit/langgraph/streaming/angular/src/app/streaming.component.ts' + ); + }); + + it('accepts a full repo-relative path', () => { + expect( + resolveExampleFile('cockpit/langgraph/streaming/python/src/graph.py', context) + ).toBe('cockpit/langgraph/streaming/python/src/graph.py'); + }); + + it('throws with the page and file when nothing matches', () => { + expect(() => resolveExampleFile('missing.ts', context)).toThrow(ExampleCodeError); + expect(() => resolveExampleFile('missing.ts', context)).toThrow( + /\/docs\/langgraph\/guides\/streaming.*missing\.ts/ + ); + }); + + it('throws when a basename is ambiguous', () => { + const ambiguous: ExampleCodeContext = { + ...context, + assetPaths: ['a/index.ts', 'b/index.ts'], + sources: { 'a/index.ts': '', 'b/index.ts': '' }, + }; + expect(() => resolveExampleFile('index.ts', ambiguous)).toThrow(/ambiguous/); + }); + + it('throws when the asset is declared but its source was not readable', () => { + const unread: ExampleCodeContext = { ...context, sources: {} }; + expect(() => resolveExampleFile('graph.py', unread)).toThrow(/could not be read/); + }); +}); + +describe('sliceRegion', () => { + it('slices a TypeScript region, strips the markers, and de-indents', () => { + const source = [ + 'class A {', + ' // #region submit', + ' send(text: string) {', + ' this.agent.submit({ message: text });', + ' }', + ' // #endregion', + '}', + ].join('\n'); + expect(sliceRegion(source, 'submit', 'x.ts')).toBe( + ['send(text: string) {', ' this.agent.submit({ message: text });', '}'].join('\n') + ); + }); + + it('accepts the Python and HTML marker forms', () => { + expect(sliceRegion('# region g\ngraph = 1\n# endregion\n', 'g', 'x.py')).toBe('graph = 1'); + expect( + sliceRegion('\n

hi

\n\n', 't', 'x.html') + ).toBe('

hi

'); + }); + + it('throws naming the file when the region is missing or unterminated', () => { + expect(() => sliceRegion('const a = 1;', 'nope', 'x.ts')).toThrow(/x\.ts.*nope/); + expect(() => sliceRegion('// #region open\nconst a = 1;', 'open', 'x.ts')).toThrow( + /unterminated/ + ); + }); +}); + +describe('fenceFor', () => { + it('maps the extension to a fence language', () => { + expect(fenceFor('const a = 1;', 'x.ts')).toBe('```ts\nconst a = 1;\n```'); + expect(fenceFor('a = 1', 'x.py')).toBe('```python\na = 1\n```'); + expect(fenceFor('

', 'x.html')).toBe('```html\n

\n```'); + }); + + it('uses a longer fence than any backtick run inside the code', () => { + expect(fenceFor('const s = `a```b`;', 'x.ts')).toBe('````ts\nconst s = `a```b`;\n````'); + }); + + it('strips one trailing newline so the fence closes on its own line', () => { + expect(fenceFor('a = 1\n', 'x.py')).toBe('```python\na = 1\n```'); + }); +}); + +describe('exampleTitle', () => { + it('is the basename', () => { + expect(exampleTitle('cockpit/langgraph/streaming/python/src/graph.py')).toBe('graph.py'); + }); +}); diff --git a/apps/website/src/lib/example-code.ts b/apps/website/src/lib/example-code.ts new file mode 100644 index 000000000..ad48ee86b --- /dev/null +++ b/apps/website/src/lib/example-code.ts @@ -0,0 +1,90 @@ +/** + * Resolution for ``: which asset a docs page means, which slice + * of it, and the fence that feeds it back through the MDX code pipeline. + * Pure so the build-time component and the unit guard share one rule. + */ + +export interface ExampleCodeContext { + /** The docs route the include appears on; only used in error messages. */ + readonly docsPath: string; + /** codeAssetPaths + backendAssetPaths of the page's capability. */ + readonly assetPaths: readonly string[]; + /** Raw text per asset path (ContentBundle.codeSources). */ + readonly sources: Readonly>; +} + +export class ExampleCodeError extends Error { + override readonly name = 'ExampleCodeError'; +} + +export function resolveExampleFile(file: string, context: ExampleCodeContext): string { + const matches = context.assetPaths.filter( + (path) => path === file || path.endsWith(`/${file}`) + ); + if (matches.length === 0) { + throw new ExampleCodeError( + `${context.docsPath}: matches none of the page's example files: ${context.assetPaths.join(', ')}` + ); + } + if (matches.length > 1) { + throw new ExampleCodeError( + `${context.docsPath}: is ambiguous: ${matches.join(', ')}. Use the full path.` + ); + } + const [path] = matches; + if (!(path in context.sources)) { + throw new ExampleCodeError( + `${context.docsPath}: resolves to ${path}, which could not be read` + ); + } + return path; +} + +const REGION_START = /^\s*(?:\/\/|#|)?\s*$/; +const REGION_END = /^\s*(?:\/\/|#|\n

hi

\n\n', 't', 'x.html')).toBe('

hi

'); + }); }); describe('fenceFor', () => { diff --git a/apps/website/src/lib/example-code.ts b/apps/website/src/lib/example-code.ts index ad48ee86b..58b87a347 100644 --- a/apps/website/src/lib/example-code.ts +++ b/apps/website/src/lib/example-code.ts @@ -40,7 +40,7 @@ export function resolveExampleFile(file: string, context: ExampleCodeContext): s return path; } -const REGION_START = /^\s*(?:\/\/|#|)?\s*$/; +const REGION_START = /^\s*(?:\/\/|#|)?\s*$/; const REGION_END = /^\s*(?:\/\/|#|\n

hi

\n\n', 't', 'x.html') + sliceRegion('# region g\ngraph = 1\n# endregion\n', 'g', 'x.py') + ).toBe('graph = 1'); + expect( + sliceRegion( + '\n

hi

\n\n', + 't', + 'x.html' + ) ).toBe('

hi

'); }); it('throws naming the file when the region is missing or unterminated', () => { - expect(() => sliceRegion('const a = 1;', 'nope', 'x.ts')).toThrow(/x\.ts.*nope/); - expect(() => sliceRegion('// #region open\nconst a = 1;', 'open', 'x.ts')).toThrow( - /unterminated/ + expect(() => sliceRegion('const a = 1;', 'nope', 'x.ts')).toThrow( + /x\.ts.*nope/ ); + expect(() => + sliceRegion('// #region open\nconst a = 1;', 'open', 'x.ts') + ).toThrow(/unterminated/); }); it('keeps nested regions intact and ends at the matching endregion', () => { @@ -98,13 +121,25 @@ describe('sliceRegion', () => { '// #endregion', ].join('\n'); expect(sliceRegion(source, 'outer', 'f.ts')).toBe( - ['const a = 1;', '// #region inner', 'const b = 2;', '// #endregion', 'const c = 3;'].join('\n') + [ + 'const a = 1;', + '// #region inner', + 'const b = 2;', + '// #endregion', + 'const c = 3;', + ].join('\n') ); expect(sliceRegion(source, 'inner', 'f.ts')).toBe('const b = 2;'); }); it('accepts an HTML marker with no space before the comment close', () => { - expect(sliceRegion('\n

hi

\n\n', 't', 'x.html')).toBe('

hi

'); + expect( + sliceRegion( + '\n

hi

\n\n', + 't', + 'x.html' + ) + ).toBe('

hi

'); }); }); @@ -116,7 +151,9 @@ describe('fenceFor', () => { }); it('uses a longer fence than any backtick run inside the code', () => { - expect(fenceFor('const s = `a```b`;', 'x.ts')).toBe('````ts\nconst s = `a```b`;\n````'); + expect(fenceFor('const s = `a```b`;', 'x.ts')).toBe( + '````ts\nconst s = `a```b`;\n````' + ); }); it('strips one trailing newline so the fence closes on its own line', () => { @@ -126,6 +163,8 @@ describe('fenceFor', () => { describe('exampleTitle', () => { it('is the basename', () => { - expect(exampleTitle('cockpit/langgraph/streaming/python/src/graph.py')).toBe('graph.py'); + expect( + exampleTitle('cockpit/langgraph/streaming/python/src/graph.py') + ).toBe('graph.py'); }); }); diff --git a/apps/website/src/lib/example-code.ts b/apps/website/src/lib/example-code.ts index 58b87a347..3ada2a6fe 100644 --- a/apps/website/src/lib/example-code.ts +++ b/apps/website/src/lib/example-code.ts @@ -17,18 +17,29 @@ export class ExampleCodeError extends Error { override readonly name = 'ExampleCodeError'; } -export function resolveExampleFile(file: string, context: ExampleCodeContext): string { +export function resolveExampleFile( + file: string, + context: ExampleCodeContext +): string { const matches = context.assetPaths.filter( (path) => path === file || path.endsWith(`/${file}`) ); if (matches.length === 0) { throw new ExampleCodeError( - `${context.docsPath}: matches none of the page's example files: ${context.assetPaths.join(', ')}` + `${ + context.docsPath + }: matches none of the page's example files: ${context.assetPaths.join( + ', ' + )}` ); } if (matches.length > 1) { throw new ExampleCodeError( - `${context.docsPath}: is ambiguous: ${matches.join(', ')}. Use the full path.` + `${ + context.docsPath + }: is ambiguous: ${matches.join( + ', ' + )}. Use the full path.` ); } const [path] = matches; @@ -43,9 +54,15 @@ export function resolveExampleFile(file: string, context: ExampleCodeContext): s const REGION_START = /^\s*(?:\/\/|#|)?\s*$/; const REGION_END = /^\s*(?:\/\/|#|`; - const attrs: Record = {}; - if (rawAttrs) { - const attrPattern = /(\w+)="([^"]*)"/g; - let attrMatch; - while ((attrMatch = attrPattern.exec(rawAttrs)) !== null) { - attrs[attrMatch[1]] = attrMatch[2]; - } - } - blocks.push({ placeholder, type: tag, content: content.trim(), attrs }); - idx++; - return placeholder; - }); - } - - return { cleaned, blocks }; -} - -async function renderInlineMarkdown(content: string): Promise { - return await marked.parseInline(content); -} - -async function renderSummary(content: string): Promise { - const html = await renderInlineMarkdown(content); - return `
${html}
`; -} - -async function renderCallout( - type: 'tip' | 'note' | 'warning', - content: string -): Promise { - const html = await renderInlineMarkdown(content); - const icons = { tip: '💡', note: '⚠️', warning: '🚨' }; - const labels = { tip: 'Tip', note: 'Note', warning: 'Warning' }; - return `
${icons[type]} ${labels[type]}
${html}
`; -} - -async function renderPrompt(content: string): Promise { - const html = await renderInlineMarkdown(content); - return `
🤖 Agentic Prompt
${html}
`; -} - -async function renderRelated(content: string): Promise { - const html = await marked.parse(content); - return ``; -} - -function renderApiTable(content: string): string { - return `
${content}
`; -} - -function escapeCodeHtml(source: string): string { - return source - .replace(/&/g, '&') - .replace(//g, '>'); -} - -async function renderSteps( - content: string, - allBlocks: ExtractedBlock[] -): Promise { - let resolved = content; - let stepNum = 0; - for (const block of allBlocks) { - if (block.type === 'Step' && resolved.includes(block.placeholder)) { - stepNum++; - const parsedContent = await parseStepContent(block.content); - const stepHtml = `
${stepNum}
${ - block.attrs['title'] ?? `Step ${stepNum}` - }
${parsedContent}
`; - resolved = resolved.replace(block.placeholder, stepHtml); - } - } - return `
${resolved}
`; -} - -async function parseStepContent(content: string): Promise { - const stepCodeBlocks: Array<{ - lang: string; - code: string; - placeholder: string; - }> = []; - let idx = 0; - - const stepRenderer = new marked.Renderer(); - stepRenderer.code = function ({ - text, - lang, - }: { - text: string; - lang?: string; - }) { - const placeholder = ``; - stepCodeBlocks.push({ lang: lang ?? 'text', code: text, placeholder }); - idx++; - return placeholder; - }; - - let html = await marked.parse(content, { renderer: stepRenderer }); - - for (const block of stepCodeBlocks) { - const { filename, cleanedCode } = extractFilename(block.code); - const codeToHighlight = filename ? cleanedCode : block.code; - let highlighted: string; - try { - highlighted = await codeToHtml(codeToHighlight, { - lang: block.lang, - themes: { light: 'github-light', dark: 'tokyo-night' }, - }); - } catch { - const escaped = escapeCodeHtml(codeToHighlight); - highlighted = `
${escaped}
`; - } - html = html.replace( - block.placeholder, - wrapCodeBlock(highlighted, block.lang, filename) - ); - } - - return html; -} - -function extractFilename(code: string): { - filename: string | null; - cleanedCode: string; -} { - const firstLine = code.split('\n')[0]; - const tsMatch = firstLine?.match(/^\/\/\s*(.+\.\w+)\s*$/); - if (tsMatch) { - return { - filename: tsMatch[1], - cleanedCode: code.split('\n').slice(1).join('\n'), - }; - } - const pyMatch = firstLine?.match(/^#\s*(.+\.\w+)\s*$/); - if (pyMatch) { - return { - filename: pyMatch[1], - cleanedCode: code.split('\n').slice(1).join('\n'), - }; - } - return { filename: null, cleanedCode: code }; -} - -function wrapCodeBlock( - shikiHtml: string, - lang: string, - filename: string | null -): string { - const langLabel = - lang !== 'text' ? `${lang}` : ''; - const fileLabel = filename - ? `${filename}` - : ''; - const header = - fileLabel || langLabel - ? `
${fileLabel}${langLabel}
` - : ''; - return `
${header}${shikiHtml}
`; -} - -export async function renderMarkdown( - source: string -): Promise { - let title = ''; - for (const line of source.split('\n')) { - if (line[0] !== '#' || (line[1] !== ' ' && line[1] !== '\t')) continue; - let titleStart = 2; - while (line[titleStart] === ' ' || line[titleStart] === '\t') titleStart++; - title = line.slice(titleStart).trim(); - if (title) break; - } - - const { cleaned, blocks } = extractComponentTags(source); - - const codeBlocks: Array<{ lang: string; code: string; placeholder: string }> = - []; - let codeIdx = 0; - - const renderer = new marked.Renderer(); - renderer.code = function ({ text, lang }: { text: string; lang?: string }) { - const placeholder = ``; - codeBlocks.push({ lang: lang ?? 'text', code: text, placeholder }); - codeIdx++; - return placeholder; - }; - - let html = await marked.parse(cleaned, { renderer }); - - for (const block of codeBlocks) { - const { filename, cleanedCode } = extractFilename(block.code); - const codeToHighlight = filename ? cleanedCode : block.code; - let highlighted: string; - try { - highlighted = await codeToHtml(codeToHighlight, { - lang: block.lang, - themes: { light: 'github-light', dark: 'tokyo-night' }, - }); - } catch { - const escaped = escapeCodeHtml(codeToHighlight); - highlighted = `
${escaped}
`; - } - html = html.replace( - block.placeholder, - wrapCodeBlock(highlighted, block.lang, filename) - ); - } - - for (const block of blocks) { - if (!html.includes(block.placeholder)) continue; - let rendered: string; - switch (block.type) { - case 'Summary': - rendered = await renderSummary(block.content); - break; - case 'Tip': - rendered = await renderCallout('tip', block.content); - break; - case 'Note': - rendered = await renderCallout('note', block.content); - break; - case 'Warning': - rendered = await renderCallout('warning', block.content); - break; - case 'Steps': - rendered = await renderSteps(block.content, blocks); - break; - case 'Step': - rendered = ''; - break; - case 'Prompt': - rendered = await renderPrompt(block.content); - break; - case 'Related': - rendered = await renderRelated(block.content); - break; - case 'ApiTable': { - const tableHtml = await marked.parse(block.content); - rendered = renderApiTable(tableHtml); - break; - } - default: - rendered = block.content; - } - html = html.replace(block.placeholder, rendered); - } - - return { title, html }; -} diff --git a/libs/cockpit-shell/src/lib/workspace-content.spec.ts b/libs/cockpit-shell/src/lib/workspace-content.spec.ts index 5778a0c9f..9bb06fda2 100644 --- a/libs/cockpit-shell/src/lib/workspace-content.spec.ts +++ b/libs/cockpit-shell/src/lib/workspace-content.spec.ts @@ -21,13 +21,11 @@ import { const testEntry = cockpitManifest[0] as CockpitManifestEntry; // Stable mock function references, hoisted so vi.mock factories can access them -const { mockExistsSync, mockReadFileSync, mockCodeToHtml, mockRenderMarkdown } = - vi.hoisted(() => ({ - mockExistsSync: vi.fn(), - mockReadFileSync: vi.fn(), - mockCodeToHtml: vi.fn(), - mockRenderMarkdown: vi.fn(), - })); +const { mockExistsSync, mockReadFileSync, mockCodeToHtml } = vi.hoisted(() => ({ + mockExistsSync: vi.fn(), + mockReadFileSync: vi.fn(), + mockCodeToHtml: vi.fn(), +})); vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); @@ -47,10 +45,6 @@ vi.mock('shiki', () => ({ codeToHtml: mockCodeToHtml, })); -vi.mock('./render-markdown', () => ({ - renderMarkdown: mockRenderMarkdown, -})); - describe('resolveRuntimeUrl', () => { afterEach(() => { vi.unstubAllEnvs(); @@ -145,7 +139,6 @@ describe('getContentBundle', () => { afterEach(() => { mockReadFileSync.mockReset(); mockCodeToHtml.mockReset(); - mockRenderMarkdown.mockReset(); vi.unstubAllEnvs(); }); @@ -169,7 +162,6 @@ describe('getContentBundle', () => { ], codeAssetPaths: ['cockpit/langgraph/streaming/python/src/index.ts'], backendAssetPaths: [], - docsAssetPaths: [], runtimeUrl: 'langgraph/streaming', devPort: 4300, }; @@ -189,7 +181,6 @@ describe('getContentBundle', () => { }); expect(bundle.runtimeUrl).toBe('http://localhost:4300'); expect(bundle.docSections).toEqual([]); - expect(bundle.narrativeDocs).toEqual([]); expect(mockExistsSync).toHaveBeenCalledTimes(1); expect(bundle.codeSources).toEqual({ 'cockpit/langgraph/streaming/python/src/index.ts': 'const x = 1;', @@ -210,7 +201,6 @@ describe('getContentBundle', () => { promptAssetPaths: [], codeAssetPaths: ['missing/file.ts'], backendAssetPaths: [], - docsAssetPaths: [], runtimeUrl: undefined, devPort: undefined, }; @@ -222,7 +212,6 @@ describe('getContentBundle', () => { ); expect(bundle.runtimeUrl).toBeNull(); expect(bundle.docSections).toEqual([]); - expect(bundle.narrativeDocs).toEqual([]); expect(bundle.codeSources).toEqual({}); }); @@ -237,7 +226,6 @@ describe('getContentBundle', () => { promptAssetPaths: [], codeAssetPaths: ['some/file.ts'], backendAssetPaths: [], - docsAssetPaths: [], runtimeUrl: undefined, devPort: undefined, }; @@ -248,7 +236,6 @@ describe('getContentBundle', () => { '
const y = 2;
' ); expect(bundle.docSections).toEqual([]); - expect(bundle.narrativeDocs).toEqual([]); }); it('returns empty maps for a docs-only presentation', async () => { @@ -264,7 +251,6 @@ describe('getContentBundle', () => { expect(bundle.promptFiles).toEqual({}); expect(bundle.runtimeUrl).toBeNull(); expect(bundle.docSections).toEqual([]); - expect(bundle.narrativeDocs).toEqual([]); expect(mockReadFileSync).not.toHaveBeenCalled(); expect(mockCodeToHtml).not.toHaveBeenCalled(); expect(bundle.codeSources).toEqual({}); @@ -291,7 +277,6 @@ describe('getContentBundle', () => { promptAssetPaths: ['prompts/streaming.md'], codeAssetPaths: ['src/streaming.component.ts'], backendAssetPaths: ['src/graph.py'], - docsAssetPaths: [], runtimeUrl: undefined, devPort: undefined, }; @@ -304,10 +289,9 @@ describe('getContentBundle', () => { expect(bundle.docSections[0].language).toBe('typescript'); expect(bundle.docSections[1].title).toBe('StreamingGraph'); expect(bundle.docSections[1].language).toBe('python'); - expect(bundle.narrativeDocs).toEqual([]); }); - it('contains missing prompt and narrative assets', async () => { + it('contains missing prompt assets', async () => { mockReadFileSync.mockImplementation(() => { throw new Error('ENOENT'); }); @@ -319,7 +303,6 @@ describe('getContentBundle', () => { promptAssetPaths: ['missing/prompt.md'], codeAssetPaths: [], backendAssetPaths: [], - docsAssetPaths: ['missing/guide.md'], }; const bundle = await getContentBundle(presentation); @@ -327,7 +310,6 @@ describe('getContentBundle', () => { expect(bundle.promptFiles).toEqual({ 'missing/prompt.md': 'File not found: missing/prompt.md', }); - expect(bundle.narrativeDocs).toEqual([]); }); it('contains absolute and traversal paths without reading outside the workspace', async () => { @@ -338,7 +320,6 @@ describe('getContentBundle', () => { promptAssetPaths: ['../outside-prompt.md'], codeAssetPaths: ['/private/secret.ts', '../outside-code.ts'], backendAssetPaths: [], - docsAssetPaths: ['/private/secret.md', '../outside-doc.md'], }; const bundle = await getContentBundle(presentation); @@ -350,9 +331,7 @@ describe('getContentBundle', () => { expect(bundle.promptFiles).toEqual({ '../outside-prompt.md': 'File not found: ../outside-prompt.md', }); - expect(bundle.narrativeDocs).toEqual([]); expect(mockReadFileSync).not.toHaveBeenCalled(); - expect(mockRenderMarkdown).not.toHaveBeenCalled(); }); it('loads workspace-only capabilities from the same registry assets', async () => { @@ -375,10 +354,6 @@ describe('getContentBundle', () => { return 'export const memory = true;'; }); mockCodeToHtml.mockResolvedValue('
code
'); - mockRenderMarkdown.mockResolvedValue({ - title: 'Deep Agents Memory', - html: '

Deep Agents Memory

Narrative.

', - }); vi.stubEnv('NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL', ''); const bundle = await getContentBundle(presentation); @@ -390,47 +365,6 @@ describe('getContentBundle', () => { expect(Object.keys(bundle.promptFiles)).toEqual( descriptor?.promptAssetPaths ?? [] ); - expect(bundle.narrativeDocs.map((doc) => doc.sourceFile)).toEqual( - descriptor?.docsAssetPaths?.map((path) => path.split('/').at(-1)) ?? [] - ); expect(bundle.runtimeUrl).toBe('http://localhost:4313'); }); - - it('skips a narrative rendering failure and continues loading the bundle', async () => { - mockReadFileSync.mockImplementation((filePath: unknown) => { - const path = String(filePath); - if (path.endsWith('code.ts')) return 'export const code = true;'; - if (path.endsWith('prompt.md')) return '# Prompt'; - if (path.endsWith('broken.md')) return '# Broken'; - if (path.endsWith('valid.md')) return '# Valid'; - throw new Error('ENOENT'); - }); - mockCodeToHtml.mockResolvedValue('
code
'); - mockRenderMarkdown - .mockRejectedValueOnce(new Error('Marked failed')) - .mockResolvedValueOnce({ title: 'Valid', html: '

Valid

' }); - - const presentation: CapabilityPresentation = { - kind: 'capability', - entry: testEntry, - docsPath: '/docs/test', - promptAssetPaths: ['prompt.md'], - codeAssetPaths: ['code.ts'], - backendAssetPaths: [], - docsAssetPaths: ['broken.md', 'valid.md'], - }; - - await expect(getContentBundle(presentation)).resolves.toMatchObject({ - codeFiles: { 'code.ts': '
code
' }, - promptFiles: { 'prompt.md': '# Prompt' }, - narrativeDocs: [ - { - title: 'Valid', - html: '

Valid

', - sourceFile: 'valid.md', - }, - ], - }); - expect(mockRenderMarkdown).toHaveBeenCalledTimes(2); - }); }); diff --git a/libs/cockpit-shell/src/lib/workspace-content.ts b/libs/cockpit-shell/src/lib/workspace-content.ts index 35feece24..5e97c7051 100644 --- a/libs/cockpit-shell/src/lib/workspace-content.ts +++ b/libs/cockpit-shell/src/lib/workspace-content.ts @@ -10,7 +10,6 @@ import { extractTsDocSections, extractPyDocSections, } from './extract-docs'; -import { renderMarkdown } from './render-markdown'; /** * Paths in the manifest are repo-root-relative (e.g., "apps/cockpit/src/app/page.tsx"). @@ -27,12 +26,6 @@ export function findWorkspaceRoot(startDir: string = process.cwd()): string { } } -export interface NarrativeDoc { - title: string; - html: string; - sourceFile: string; -} - export interface ContentBundle { codeFiles: Record; /** Raw text of every readable code or backend asset, keyed like codeFiles. */ @@ -40,7 +33,6 @@ export interface ContentBundle { promptFiles: Record; runtimeUrl: string | null; docSections: DocSection[]; - narrativeDocs: NarrativeDoc[]; } export function resolveRuntimeUrl(options: { @@ -151,7 +143,6 @@ export async function getContentBundle( promptFiles: {}, runtimeUrl: null, docSections: [], - narrativeDocs: [], }; } @@ -195,32 +186,11 @@ export async function getContentBundle( devPort: presentation.devPort, }); - const narrativeDocs: NarrativeDoc[] = []; - const docPaths = presentation.docsAssetPaths ?? []; - for (const path of docPaths) { - const source = readFileSafe(workspaceRoot, path); - if (source) { - try { - const rendered = await renderMarkdown(source); - const fileName = path.split('/').pop() ?? path; - narrativeDocs.push({ - title: rendered.title, - html: rendered.html, - sourceFile: fileName, - }); - } catch { - // A broken narrative asset must not prevent the rest of the Cockpit - // bundle, or later valid narratives, from loading. - } - } - } - return { codeFiles, codeSources, promptFiles, runtimeUrl, docSections, - narrativeDocs, }; } diff --git a/libs/cockpit-shell/src/lib/workspace-presentation.spec.ts b/libs/cockpit-shell/src/lib/workspace-presentation.spec.ts index 0752d5bf3..2dc2eb362 100644 --- a/libs/cockpit-shell/src/lib/workspace-presentation.spec.ts +++ b/libs/cockpit-shell/src/lib/workspace-presentation.spec.ts @@ -142,9 +142,6 @@ describe('runtimes capability presentation', () => { 'deployments/ag-ui-mastra/agents.mjs', 'deployments/ag-ui-mastra/server.mjs', ]); - expect(presentation.docsAssetPaths).toEqual([ - 'cockpit/runtimes/mastra/angular/docs/guide.md', - ]); expect(presentation.runtimeUrl).toBe('runtimes/mastra'); expect(presentation.devPort).toBe(4332); @@ -162,7 +159,6 @@ describe('runtimes capability presentation', () => { ...presentation.promptAssetPaths, ...presentation.codeAssetPaths, ...presentation.backendAssetPaths, - ...presentation.docsAssetPaths, ]) { expect( existsSync(join(workspaceRoot, path)), @@ -243,7 +239,7 @@ describe('getCapabilityPresentation', () => { }); }); - it('includes durable execution docs assets from the capability module', () => { + it('resolves the durable execution docs path from the capability module', () => { const entry = resolveCockpitEntry({ manifest: cockpitManifest, product: 'langgraph', @@ -257,9 +253,6 @@ describe('getCapabilityPresentation', () => { expect(presentation).toMatchObject({ kind: 'capability', docsPath: '/docs/langgraph/guides/durable-execution', - docsAssetPaths: [ - 'cockpit/langgraph/durable-execution/python/docs/guide.md', - ], }); }); @@ -328,7 +321,6 @@ describe('getCapabilityPresentation', () => { promptAssetPaths: descriptor?.promptAssetPaths, codeAssetPaths: descriptor?.codeAssetPaths, backendAssetPaths: descriptor?.backendAssetPaths ?? [], - docsAssetPaths: descriptor?.docsAssetPaths ?? [], runtimeUrl: descriptor?.runtimeUrl, devPort: descriptor?.devPort, }); @@ -448,7 +440,6 @@ describe('getWorkspacePresentation', () => { promptAssetPaths: descriptor?.promptAssetPaths, codeAssetPaths: descriptor?.codeAssetPaths, backendAssetPaths: descriptor?.backendAssetPaths, - docsAssetPaths: descriptor?.docsAssetPaths, runtimeUrl: descriptor?.runtimeUrl, devPort: descriptor?.devPort, runnable: true, diff --git a/libs/cockpit-shell/src/lib/workspace-presentation.ts b/libs/cockpit-shell/src/lib/workspace-presentation.ts index dd4a1f078..14baf9200 100644 --- a/libs/cockpit-shell/src/lib/workspace-presentation.ts +++ b/libs/cockpit-shell/src/lib/workspace-presentation.ts @@ -40,7 +40,6 @@ export type CapabilityPresentation = promptAssetPaths: string[]; codeAssetPaths: string[]; backendAssetPaths: string[]; - docsAssetPaths: string[]; runtimeUrl?: string; devPort?: number; }; @@ -59,7 +58,6 @@ export type WorkspacePresentation = promptAssetPaths: string[]; codeAssetPaths: string[]; backendAssetPaths: string[]; - docsAssetPaths: string[]; runtimeUrl?: string; devPort?: number; runnable: boolean; @@ -211,7 +209,6 @@ export const getCapabilityPresentation = ( promptAssetPaths: [...(module?.promptAssetPaths ?? entry.promptAssetPaths)], codeAssetPaths: [...(module?.codeAssetPaths ?? entry.codeAssetPaths)], backendAssetPaths: [...(module?.backendAssetPaths ?? [])], - docsAssetPaths: [...(module?.docsAssetPaths ?? [])], runtimeUrl: module?.runtimeUrl, devPort: module?.devPort, }; @@ -255,7 +252,6 @@ export const getWorkspacePresentation = ( promptAssetPaths: [...descriptor.promptAssetPaths], codeAssetPaths: [...descriptor.codeAssetPaths], backendAssetPaths: [...(descriptor.backendAssetPaths ?? [])], - docsAssetPaths: [...(descriptor.docsAssetPaths ?? [])], runtimeUrl: descriptor.runtimeUrl, devPort: descriptor.devPort, runnable: Boolean(descriptor.runtimeUrl || descriptor.devPort), diff --git a/libs/workspace-react/src/lib/public-api.spec.tsx b/libs/workspace-react/src/lib/public-api.spec.tsx index 1d3ba01cd..1edb26c21 100644 --- a/libs/workspace-react/src/lib/public-api.spec.tsx +++ b/libs/workspace-react/src/lib/public-api.spec.tsx @@ -91,7 +91,6 @@ describe('@threadplane/workspace-react public boundary', () => { promptFiles: {}, runtimeUrl: null, docSections: [], - narrativeDocs: [], }} routePath={resolution.docsPath} requestedMode="docs" diff --git a/libs/workspace-react/src/lib/workspace-provider.spec.tsx b/libs/workspace-react/src/lib/workspace-provider.spec.tsx index 3885350a2..62680f6a3 100644 --- a/libs/workspace-react/src/lib/workspace-provider.spec.tsx +++ b/libs/workspace-react/src/lib/workspace-provider.spec.tsx @@ -55,7 +55,6 @@ const contentBundle: ContentBundle = { promptFiles: {}, runtimeUrl: null, docSections: [], - narrativeDocs: [], }; function Readout() { diff --git a/libs/workspace-react/src/lib/workspace-shell.spec.tsx b/libs/workspace-react/src/lib/workspace-shell.spec.tsx index 27a1573f4..b9d9fbf99 100644 --- a/libs/workspace-react/src/lib/workspace-shell.spec.tsx +++ b/libs/workspace-react/src/lib/workspace-shell.spec.tsx @@ -65,13 +65,6 @@ const contentBundle: ContentBundle = { promptFiles: {}, runtimeUrl: 'https://runtime.example.test/demo', docSections: [], - narrativeDocs: [ - { - title: 'Streaming guide', - html: '

Registry narrative

', - sourceFile: 'guide.md', - }, - ], }; function renderWorkspace(options: { From 49d0a71e3d75902e06b0be1f8dc4cfc18945d089 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 16:11:23 -0700 Subject: [PATCH 18/24] refactor(workspace-react): remove the narrative Docs panel and its analytics hook Co-Authored-By: Claude Fable 5.1 --- libs/workspace-react/src/index.ts | 1 - .../narrative-docs/narrative-docs.spec.tsx | 89 ---------- .../narrative-docs/narrative-docs.tsx | 69 -------- libs/workspace-react/src/lib/host-services.ts | 9 - .../src/lib/workspace-contracts.ts | 2 - .../src/lib/workspace-provider.spec.tsx | 1 - .../src/lib/workspace-provider.tsx | 5 - .../src/lib/workspace-shell.spec.tsx | 8 +- .../src/lib/workspace-shell.tsx | 12 +- libs/workspace-react/src/styles/workspace.css | 157 ------------------ 10 files changed, 4 insertions(+), 349 deletions(-) delete mode 100644 libs/workspace-react/src/lib/components/narrative-docs/narrative-docs.spec.tsx delete mode 100644 libs/workspace-react/src/lib/components/narrative-docs/narrative-docs.tsx diff --git a/libs/workspace-react/src/index.ts b/libs/workspace-react/src/index.ts index eae25e97a..ac3f69b74 100644 --- a/libs/workspace-react/src/index.ts +++ b/libs/workspace-react/src/index.ts @@ -43,7 +43,6 @@ export * from './lib/components/control-plane/control-plane-overflow-menu'; export * from './lib/components/control-plane/runtime-section'; export * from './lib/components/mobile-nav-overlay'; export * from './lib/components/modes/mode-switcher'; -export * from './lib/components/narrative-docs/narrative-docs'; export * from './lib/components/run-mode/run-mode'; export * from './lib/components/sidebar/cockpit-sidebar'; export * from './lib/components/sidebar/language-picker'; diff --git a/libs/workspace-react/src/lib/components/narrative-docs/narrative-docs.spec.tsx b/libs/workspace-react/src/lib/components/narrative-docs/narrative-docs.spec.tsx deleted file mode 100644 index 2b7347447..000000000 --- a/libs/workspace-react/src/lib/components/narrative-docs/narrative-docs.spec.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/** @vitest-environment jsdom */ -import React from 'react'; -import { act } from 'react'; -import { createRoot } from 'react-dom/client'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { NarrativeDocs } from './narrative-docs'; - -describe('NarrativeDocs', () => { - it('renders narrative HTML content', () => { - const html = renderToStaticMarkup( - Streaming Guide

Learn to stream.

', sourceFile: 'guide.md' }, - ]} - /> - ); - expect(html).toContain('Streaming Guide'); - expect(html).toContain('Learn to stream.'); - }); - - it('renders empty state when no docs', () => { - const html = renderToStaticMarkup(); - expect(html).toContain('No documentation available'); - }); - - describe('copy tracking', () => { - let container: HTMLDivElement | undefined; - let root: ReturnType | undefined; - - afterEach(() => { - act(() => { - root?.unmount(); - }); - container?.remove(); - vi.clearAllMocks(); - }); - - function renderWith(html: string) { - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - - Object.assign(navigator, { - clipboard: { writeText: vi.fn(() => Promise.resolve()) }, - }); - - act(() => { - root!.render( - , - ); - }); - } - - const trackNarrativeAction = vi.fn(); - - it('fires cockpit:code_copied with surface=docs_code_snippet on code copy click', () => { - renderWith( - '
const x = 1;
', - ); - const btn = container!.querySelector('[data-copy-code]') as HTMLElement; - act(() => { - btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); - }); - expect(trackNarrativeAction).toHaveBeenCalledWith({ - capability: 'streaming', - surface: 'docs_code_snippet', - }); - }); - - it('fires cockpit:code_copied with surface=agentic_prompt on prompt copy click', () => { - renderWith( - '
You are helpful.
', - ); - const btn = container!.querySelector('[data-copy-prompt]') as HTMLElement; - act(() => { - btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); - }); - expect(trackNarrativeAction).toHaveBeenCalledWith({ - capability: 'streaming', - surface: 'agentic_prompt', - }); - }); - }); -}); diff --git a/libs/workspace-react/src/lib/components/narrative-docs/narrative-docs.tsx b/libs/workspace-react/src/lib/components/narrative-docs/narrative-docs.tsx deleted file mode 100644 index 4be0ae3fe..000000000 --- a/libs/workspace-react/src/lib/components/narrative-docs/narrative-docs.tsx +++ /dev/null @@ -1,69 +0,0 @@ -'use client'; - -import React, { useCallback } from 'react'; -import type { TrackNarrativeAction } from '../../host-services'; - -interface NarrativeDoc { - title: string; - html: string; - sourceFile: string; -} - -interface NarrativeDocsProps { - narrativeDocs: NarrativeDoc[]; - capability?: string; - trackNarrativeAction?: TrackNarrativeAction; -} - -export function NarrativeDocs({ - narrativeDocs, - capability, - trackNarrativeAction, -}: NarrativeDocsProps) { - const handleClick = useCallback((e: React.MouseEvent) => { - const target = e.target as HTMLElement; - - const copyCodeBtn = target.closest('[data-copy-code]') as HTMLElement | null; - if (copyCodeBtn) { - const codeBlock = copyCodeBtn.closest('.doc-codeblock'); - const code = codeBlock?.querySelector('pre code')?.textContent ?? ''; - navigator.clipboard.writeText(code); - trackNarrativeAction?.({ capability, surface: 'docs_code_snippet' }); - copyCodeBtn.textContent = 'Copied!'; - setTimeout(() => { copyCodeBtn.textContent = 'Copy'; }, 1500); - return; - } - - const copyPromptBtn = target.closest('[data-copy-prompt]') as HTMLElement | null; - if (copyPromptBtn) { - const promptBlock = copyPromptBtn.closest('.doc-prompt'); - const text = promptBlock?.querySelector('.doc-prompt__content')?.textContent ?? ''; - navigator.clipboard.writeText(text); - trackNarrativeAction?.({ capability, surface: 'agentic_prompt' }); - copyPromptBtn.textContent = 'Copied!'; - setTimeout(() => { copyPromptBtn.textContent = 'Copy prompt'; }, 1500); - return; - } - }, [capability, trackNarrativeAction]); - - if (narrativeDocs.length === 0) { - return ( -
-

No documentation available for this capability.

-
- ); - } - - return ( -
- {narrativeDocs.map((doc) => ( -
- ))} -
- ); -} diff --git a/libs/workspace-react/src/lib/host-services.ts b/libs/workspace-react/src/lib/host-services.ts index 73f35f256..8b209a6ff 100644 --- a/libs/workspace-react/src/lib/host-services.ts +++ b/libs/workspace-react/src/lib/host-services.ts @@ -12,15 +12,6 @@ export interface WorkspaceNavigationAnalytics { export type TrackNavigation = (event: WorkspaceNavigationAnalytics) => void; -export interface WorkspaceNarrativeAnalytics { - readonly capability?: string; - readonly surface: 'docs_code_snippet' | 'agentic_prompt'; -} - -export type TrackNarrativeAction = ( - event: WorkspaceNarrativeAnalytics -) => void; - export interface WorkspaceModeChangeAnalytics { readonly capability: string; readonly fromMode: WorkspaceMode; diff --git a/libs/workspace-react/src/lib/workspace-contracts.ts b/libs/workspace-react/src/lib/workspace-contracts.ts index 16358723e..fe2fd6a2a 100644 --- a/libs/workspace-react/src/lib/workspace-contracts.ts +++ b/libs/workspace-react/src/lib/workspace-contracts.ts @@ -11,7 +11,6 @@ import type { import type { RuntimeFrameTelemetry, TrackModeChange, - TrackNarrativeAction, TrackNavigation, WorkspaceSessionIdProvider, } from './host-services'; @@ -60,7 +59,6 @@ export interface WorkspaceContextValue { readonly getSessionId: WorkspaceSessionIdProvider; readonly runtimeTelemetry?: RuntimeFrameTelemetry; readonly trackNavigation?: TrackNavigation; - readonly trackNarrativeAction?: TrackNarrativeAction; readonly trackModeChange?: TrackModeChange; selectMode(mode: WorkspaceMode): void; setActiveUtility(utility: WorkspaceUtility): void; diff --git a/libs/workspace-react/src/lib/workspace-provider.spec.tsx b/libs/workspace-react/src/lib/workspace-provider.spec.tsx index 62680f6a3..3790ae2ad 100644 --- a/libs/workspace-react/src/lib/workspace-provider.spec.tsx +++ b/libs/workspace-react/src/lib/workspace-provider.spec.tsx @@ -43,7 +43,6 @@ const presentation: WorkspacePresentation = { promptAssetPaths: [], codeAssetPaths: ['example.ts'], backendAssetPaths: [], - docsAssetPaths: ['guide.md'], runtimeUrl: 'langgraph/streaming', devPort: 4300, runnable: true, diff --git a/libs/workspace-react/src/lib/workspace-provider.tsx b/libs/workspace-react/src/lib/workspace-provider.tsx index adc5ed8a6..e4b5ccd1a 100644 --- a/libs/workspace-react/src/lib/workspace-provider.tsx +++ b/libs/workspace-react/src/lib/workspace-provider.tsx @@ -28,7 +28,6 @@ import { import type { RuntimeFrameTelemetry, TrackModeChange, - TrackNarrativeAction, TrackNavigation, TrackRuntimeAction, TrackRuntimeTransition, @@ -73,7 +72,6 @@ export interface WorkspaceProviderProps { readonly getSessionId: WorkspaceSessionIdProvider; readonly runtimeTelemetry?: RuntimeFrameTelemetry; readonly trackNavigation?: TrackNavigation; - readonly trackNarrativeAction?: TrackNarrativeAction; readonly trackModeChange?: TrackModeChange; readonly trackRuntimeAction?: TrackRuntimeAction; readonly trackRuntimeTransition?: TrackRuntimeTransition; @@ -165,7 +163,6 @@ export function WorkspaceProvider({ getSessionId, runtimeTelemetry, trackNavigation, - trackNarrativeAction, trackModeChange, trackRuntimeAction, trackRuntimeTransition, @@ -407,7 +404,6 @@ export function WorkspaceProvider({ getSessionId, runtimeTelemetry, trackNavigation, - trackNarrativeAction, trackModeChange, selectMode, setActiveUtility, @@ -444,7 +440,6 @@ export function WorkspaceProvider({ selectMode, setActiveUtility, trackModeChange, - trackNarrativeAction, trackNavigation, ] ); diff --git a/libs/workspace-react/src/lib/workspace-shell.spec.tsx b/libs/workspace-react/src/lib/workspace-shell.spec.tsx index b9d9fbf99..e8156b091 100644 --- a/libs/workspace-react/src/lib/workspace-shell.spec.tsx +++ b/libs/workspace-react/src/lib/workspace-shell.spec.tsx @@ -54,7 +54,6 @@ const presentation: WorkspacePresentation = { promptAssetPaths: [], codeAssetPaths: ['example.ts'], backendAssetPaths: [], - docsAssetPaths: ['guide.md'], runtimeUrl: 'langgraph/streaming', devPort: 4300, runnable: true, @@ -362,11 +361,10 @@ describe('WorkspaceShell persistent panel composition', () => { expect(screen.getByText('Product')).toBeTruthy(); }); - it('uses registry narrative Docs when no server slot is present', () => { + it('renders nothing in the Docs panel when no server slot is present', () => { renderWorkspace({ requestedMode: 'docs' }); - expect( - screen.getByRole('heading', { name: 'Registry narrative' }) - ).toBeTruthy(); + const panel = screen.getByRole('region', { name: 'Docs workspace panel' }); + expect(panel.querySelector('h1')).toBeNull(); }); it('does not mount Run for docs-only or mapped identities without Run', () => { diff --git a/libs/workspace-react/src/lib/workspace-shell.tsx b/libs/workspace-react/src/lib/workspace-shell.tsx index 4b8b63131..034ab4c2a 100644 --- a/libs/workspace-react/src/lib/workspace-shell.tsx +++ b/libs/workspace-react/src/lib/workspace-shell.tsx @@ -25,7 +25,6 @@ import { type WorkspaceContextPaneRenderer, } from './components/control-plane/cockpit-control-plane'; import { MobileNavOverlay } from './components/mobile-nav-overlay'; -import { NarrativeDocs } from './components/narrative-docs/narrative-docs'; import { RunMode } from './components/run-mode/run-mode'; import { PRODUCT_LABELS } from './navigation-labels'; import { useWorkspace } from './workspace-provider'; @@ -149,7 +148,6 @@ export function WorkspaceShell({ getSessionId, runtimeTelemetry, trackNavigation, - trackNarrativeAction, selectMode, setActiveUtility, setExpanded, @@ -520,15 +518,7 @@ export function WorkspaceShell({ > {panelHeading('Docs')} - {docsSlot !== null ? ( - docsSlot - ) : ( - - )} + {docsSlot} ) : null} diff --git a/libs/workspace-react/src/styles/workspace.css b/libs/workspace-react/src/styles/workspace.css index c6d73b5c0..e7895c66d 100644 --- a/libs/workspace-react/src/styles/workspace.css +++ b/libs/workspace-react/src/styles/workspace.css @@ -59,163 +59,6 @@ color: #e04545; } -.doc-steps { - margin: 1.5rem 0; -} -.doc-step { - display: flex; - gap: 0.75rem; -} -.doc-step__indicator { - display: flex; - flex-direction: column; - align-items: center; - flex-shrink: 0; -} -.doc-step__number { - width: 1.5rem; - height: 1.5rem; - border-radius: 50%; - background: var(--ds-accent); - color: #fff; - font-size: 0.7rem; - font-weight: 700; - display: flex; - align-items: center; - justify-content: center; -} -.doc-step__line { - width: 2px; - flex: 1; - background: var(--ds-accent-border); - margin: 0.375rem 0; - min-height: 1rem; -} -.doc-step:last-child .doc-step__line { - display: none; -} -.doc-step__body { - flex: 1; - padding-bottom: 1.5rem; -} -.doc-step:last-child .doc-step__body { - padding-bottom: 0; -} -.doc-step__title { - font-size: 0.95rem; - font-weight: 600; - color: var(--ds-text-primary); - margin-bottom: 0.25rem; -} -.doc-step__content { - font-size: 0.85rem; - color: var(--ds-text-secondary); - line-height: 1.7; -} -.doc-step__content p { - margin: 0.5rem 0; -} -.doc-step__content pre.shiki { - margin: 0.5rem 0; - border-radius: 0.5rem; -} - -.doc-codeblock { - border: 1px solid var(--ds-accent-border); - border-radius: 0.5rem; - overflow: hidden; - margin: 0.75rem 0; - max-width: 100%; -} -.doc-codeblock__header { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.4rem 0.75rem; - border-bottom: 1px solid var(--ds-border); - background: var(--ds-surface-tinted); - font-size: 0.7rem; -} -.doc-codeblock__file { - color: var(--ds-text-secondary); - font-family: var(--font-mono), 'JetBrains Mono', monospace; -} -.doc-codeblock__lang { - padding: 0.1rem 0.35rem; - border-radius: 0.2rem; - background: var(--ds-accent-surface); - color: var(--ds-accent); - font-size: 0.6rem; - font-family: var(--font-mono), 'JetBrains Mono', monospace; -} -.doc-codeblock__copy { - margin-left: auto; - padding: 0.1rem 0.5rem; - border: 1px solid var(--ds-border); - border-radius: 0.25rem; - background: transparent; - color: var(--ds-text-muted); - cursor: pointer; -} -.doc-codeblock__copy:hover { - color: var(--ds-text-primary); - border-color: var(--ds-border-strong); -} -.doc-codeblock pre.shiki { - margin: 0; - border-radius: 0; - border: none; - overflow-x: auto; -} - -.doc-prompt { - background: rgba(168, 85, 247, 0.04); - border: 1px solid rgba(168, 85, 247, 0.2); - border-radius: 0.5rem; - overflow: hidden; - margin: 1.25rem 0; -} -.doc-prompt__header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.5rem 0.75rem; - border-bottom: 1px solid rgba(168, 85, 247, 0.15); - background: rgba(168, 85, 247, 0.06); -} -.doc-prompt__label { - font-size: 0.7rem; - font-weight: 600; - color: #9333ea; - text-transform: uppercase; - letter-spacing: 0.06em; -} -.doc-prompt__copy { - font-size: 0.65rem; - color: #9333ea; - padding: 0.1rem 0.5rem; - border: 1px solid rgba(168, 85, 247, 0.25); - border-radius: 0.25rem; - background: rgba(168, 85, 247, 0.08); - cursor: pointer; -} -.doc-prompt__copy:hover { - background: rgba(168, 85, 247, 0.15); -} -.doc-prompt__content { - padding: 0.75rem; - font-size: 0.85rem; - color: var(--ds-text-secondary); - line-height: 1.7; -} -.doc-prompt__content code { - background: rgba(168, 85, 247, 0.1); - padding: 0.1rem 0.3rem; - border-radius: 0.2rem; - color: #9333ea; - font-size: 0.8rem; -} - .doc-api-table { margin: 1.25rem 0; } From 7e7c095d8fe82736784f76270f151a9e85f5b7db Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 16:12:03 -0700 Subject: [PATCH 19/24] style(cockpit-registry): prettier on the simplified Docs-mode assertion Co-Authored-By: Claude Fable 5.1 --- libs/cockpit-registry/src/lib/content-descriptors.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/cockpit-registry/src/lib/content-descriptors.spec.ts b/libs/cockpit-registry/src/lib/content-descriptors.spec.ts index ff866825b..f421bb055 100644 --- a/libs/cockpit-registry/src/lib/content-descriptors.spec.ts +++ b/libs/cockpit-registry/src/lib/content-descriptors.spec.ts @@ -297,7 +297,9 @@ describe('registry content descriptors', () => { ) ); expect(entry.availableModes.includes('API')).toBe(apiAssets.length > 0); - expect(entry.availableModes.includes('Docs')).toBe(entry.docsPath.length > 0); + expect(entry.availableModes.includes('Docs')).toBe( + entry.docsPath.length > 0 + ); } }); }); From 57321c5051993c00dae7a4ca1ce1b8fbf543a748 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 16:13:27 -0700 Subject: [PATCH 20/24] refactor(website): drop the narrative-action analytics hook Co-Authored-By: Claude Fable 5.1 --- .../components/workspace/WebsiteWorkspace.spec.tsx | 1 - .../src/components/workspace/WebsiteWorkspace.tsx | 13 ------------- apps/website/src/lib/analytics/events.ts | 1 - 3 files changed, 15 deletions(-) diff --git a/apps/website/src/components/workspace/WebsiteWorkspace.spec.tsx b/apps/website/src/components/workspace/WebsiteWorkspace.spec.tsx index 44696b0ae..e36ec973d 100644 --- a/apps/website/src/components/workspace/WebsiteWorkspace.spec.tsx +++ b/apps/website/src/components/workspace/WebsiteWorkspace.spec.tsx @@ -540,7 +540,6 @@ describe('WebsiteWorkspace', () => { from_capability: 'streaming', }) ); - expect(props.trackNarrativeAction).toBeTypeOf('function'); expect(props.trackRuntimeAction).toBeTypeOf('function'); expect(props.trackRuntimeTransition).toBeTypeOf('function'); }); diff --git a/apps/website/src/components/workspace/WebsiteWorkspace.tsx b/apps/website/src/components/workspace/WebsiteWorkspace.tsx index 9e82fced9..c50a608cc 100644 --- a/apps/website/src/components/workspace/WebsiteWorkspace.tsx +++ b/apps/website/src/components/workspace/WebsiteWorkspace.tsx @@ -29,7 +29,6 @@ import { readWorkspaceModeQuery, type RuntimeTerminalTransition, type TrackModeChange, - type TrackNarrativeAction, type TrackNavigation, type TrackRuntimeAction, type TrackRuntimeTransition, @@ -115,17 +114,6 @@ const trackNavigation: TrackNavigation = ({ }); }; -const trackNarrativeAction: TrackNarrativeAction = ({ - capability, - surface, -}) => { - track(analyticsEvents.docsWorkspaceNarrativeAction, { - surface: 'docs', - capability, - narrative_surface: surface, - }); -}; - const trackModeChange: TrackModeChange = ({ capability, fromMode, toMode }) => { track(analyticsEvents.docsWorkspaceModeSwitched, { surface: 'docs', @@ -323,7 +311,6 @@ function WebsiteWorkspaceSurface({ getSessionId={getWebsiteWorkspaceSessionId} runtimeTelemetry={RUNTIME_FRAME_TELEMETRY} trackNavigation={trackNavigation} - trackNarrativeAction={trackNarrativeAction} trackModeChange={trackModeChange} trackRuntimeAction={trackRuntimeAction} trackRuntimeTransition={trackRuntimeTransition} diff --git a/apps/website/src/lib/analytics/events.ts b/apps/website/src/lib/analytics/events.ts index d8bc7f0db..eedecbff7 100644 --- a/apps/website/src/lib/analytics/events.ts +++ b/apps/website/src/lib/analytics/events.ts @@ -20,7 +20,6 @@ export const analyticsEvents = { docsSidebarSectionToggle: 'docs:sidebar_section_toggle', docsWorkspaceNavigation: 'docs:workspace_navigation', docsWorkspaceModeSwitched: 'docs:workspace_mode_switched', - docsWorkspaceNarrativeAction: 'docs:workspace_narrative_action', docsWorkspaceRuntimeAction: 'docs:workspace_runtime_action', docsWorkspaceRuntimeStatusChanged: 'docs:workspace_runtime_status_changed', blogCtaClick: 'blog:cta_click', From 4f5f1ecf103e7e05306a7cc8169e8d933495be23 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 16:15:57 -0700 Subject: [PATCH 21/24] docs(contributing): how docs pages include example code Co-Authored-By: Claude Fable 5.1 --- CONTRIBUTING.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c88937a1..59ca811c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -219,6 +219,37 @@ own Protection Bypass for Automation secret: flight does not reach that run; re-run after provisioning. Never pass `--skip-domain` to a preview deploy; Vercel requires it to accompany `--prod`. +## Docs pages and example code + +A docs page whose capability ships a runnable example (the page shows Run and +Code tabs) teaches through that example. Its code comes from the example +files, never from a hand-typed copy: + +```mdx + + +``` + +- `file` is a basename or a repo-relative path among the capability's + `codeAssetPaths` and `backendAssetPaths` in + `libs/cockpit-registry/src/lib/content-descriptors.ts`. An unknown or + ambiguous name fails the build. +- `region` names a marker pair in that file. Markers are `// #region name` … + `// #endregion` in TypeScript, `# region name` … `# endregion` in Python, + and `` … `` in HTML. Regions may + nest. The marker lines are stripped from the rendered Code tab and the + slice is de-indented; keep region names meaningful, since they surface in + the build error when a region is missing or unterminated. +- Hand-written fences stay allowed for fragments the example does not cover, + such as another runtime's variant. + +`apps/website/src/lib/docs-example-code.spec.ts` fails when a mapped page +includes nothing, when an include does not resolve, or when a docs-only page +uses the tag. Its scan is textual, so the tag must not appear in prose, +fenced code, or MDX comments on any docs page. Pages not yet rewritten sit in +its `PENDING_PAGES` list; a page that gains its first include must leave the +list in the same change. + ## Code review Every PR gets a genuine advisory AI code review From 1236f1a67fa6275b1e9f6067e9c9ded488f5e0ac Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 16:17:14 -0700 Subject: [PATCH 22/24] test(workspace-react): Docs panel test distinguishes empty from crashed; drop the last narrative CSS Co-Authored-By: Claude Fable 5.1 --- .../src/lib/workspace-shell.spec.tsx | 7 ++ libs/workspace-react/src/styles/workspace.css | 83 ------------------- 2 files changed, 7 insertions(+), 83 deletions(-) diff --git a/libs/workspace-react/src/lib/workspace-shell.spec.tsx b/libs/workspace-react/src/lib/workspace-shell.spec.tsx index e8156b091..6615fdacc 100644 --- a/libs/workspace-react/src/lib/workspace-shell.spec.tsx +++ b/libs/workspace-react/src/lib/workspace-shell.spec.tsx @@ -365,6 +365,13 @@ describe('WorkspaceShell persistent panel composition', () => { renderWorkspace({ requestedMode: 'docs' }); const panel = screen.getByRole('region', { name: 'Docs workspace panel' }); expect(panel.querySelector('h1')).toBeNull(); + // A crashed panel falls back to WorkspacePanelBoundary's + // role="alert" markup; assert that fallback never fired. + expect(panel.querySelector('[role="alert"]')).toBeNull(); + // With no docsSlot, the panel should render only its heading text + // (" Docs") and nothing else -- distinguishing a + // cleanly empty panel from one that silently swallowed a crash. + expect(panel.textContent?.trim()).toBe(`${identity.title} Docs`); }); it('does not mount Run for docs-only or mapped identities without Run', () => { diff --git a/libs/workspace-react/src/styles/workspace.css b/libs/workspace-react/src/styles/workspace.css index e7895c66d..7ae2cc11e 100644 --- a/libs/workspace-react/src/styles/workspace.css +++ b/libs/workspace-react/src/styles/workspace.css @@ -7,89 +7,6 @@ line-height: 1.6; } -/* ── Doc components ────────────────────────────────────────── */ - -.doc-summary { - background: var(--ds-accent-surface); - border: 1px solid var(--ds-accent-border); - border-radius: 0.5rem; - padding: 0.75rem 1rem; - margin-bottom: 1.5rem; - font-size: 0.9rem; - color: var(--ds-text-secondary); - line-height: 1.6; -} - -.doc-callout { - border-radius: 0.5rem; - padding: 0.75rem 1rem; - margin: 1.25rem 0; - font-size: 0.85rem; - line-height: 1.6; -} -.doc-callout__label { - font-size: 0.7rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - margin-bottom: 0.25rem; -} -.doc-callout__content { - color: var(--ds-text-secondary); -} -.doc-callout--tip { - background: var(--ds-accent-surface); - border: 1px solid var(--ds-accent-border); -} -.doc-callout--tip .doc-callout__label { - color: var(--ds-accent); -} -.doc-callout--note { - background: rgba(250, 204, 21, 0.06); - border: 1px solid rgba(250, 204, 21, 0.2); -} -.doc-callout--note .doc-callout__label { - color: #b8960f; -} -.doc-callout--warning { - background: rgba(255, 107, 107, 0.06); - border: 1px solid rgba(255, 107, 107, 0.2); -} -.doc-callout--warning .doc-callout__label { - color: #e04545; -} - -.doc-api-table { - margin: 1.25rem 0; -} -.doc-api-table table { - width: 100%; - border-collapse: collapse; - font-size: 0.8rem; -} -.doc-api-table th { - text-align: left; - padding: 0.5rem 0.75rem; - color: var(--ds-text-muted); - font-weight: 500; - font-size: 0.65rem; - text-transform: uppercase; - letter-spacing: 0.06em; - border-bottom: 1px solid var(--ds-border); -} -.doc-api-table td { - padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--ds-accent-border); - color: var(--ds-text-secondary); -} -.doc-api-table code { - background: var(--ds-accent-surface); - padding: 0.1rem 0.3rem; - border-radius: 0.2rem; - color: var(--ds-accent); - font-size: 0.75rem; -} - /* Shared prose layer — docs + api + code mode content */ .workspace-prose { max-width: 42rem; From 11f34823e5cb834a5ffa853be5032d912c12d27d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 16:20:59 -0700 Subject: [PATCH 23/24] docs: the Code tab keeps region markers; only the docs slice strips them; retitle the prose layer comment Co-Authored-By: Claude Fable 5.1 --- CONTRIBUTING.md | 7 ++++--- libs/workspace-react/src/styles/workspace.css | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 59ca811c8..66a966e0e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -237,9 +237,10 @@ files, never from a hand-typed copy: - `region` names a marker pair in that file. Markers are `// #region name` … `// #endregion` in TypeScript, `# region name` … `# endregion` in Python, and `` … `` in HTML. Regions may - nest. The marker lines are stripped from the rendered Code tab and the - slice is de-indented; keep region names meaningful, since they surface in - the build error when a region is missing or unterminated. + nest. The marker lines are stripped from the slice on the docs page and + the slice is de-indented. The Code tab shows the whole file, markers + included, and the region name surfaces in the build error when a region is + missing or unterminated, so keep the names meaningful. - Hand-written fences stay allowed for fragments the example does not cover, such as another runtime's variant. diff --git a/libs/workspace-react/src/styles/workspace.css b/libs/workspace-react/src/styles/workspace.css index 7ae2cc11e..820ac886f 100644 --- a/libs/workspace-react/src/styles/workspace.css +++ b/libs/workspace-react/src/styles/workspace.css @@ -7,7 +7,7 @@ line-height: 1.6; } -/* Shared prose layer — docs + api + code mode content */ +/* Shared prose layer — API mode content */ .workspace-prose { max-width: 42rem; font-size: 0.9rem; From d02552c1a1dc6868df476682899ce9a8c62b1d44 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 16:27:10 -0700 Subject: [PATCH 24/24] fix(website): unnamed nested regions keep depth balanced; non-docs MDX pages name their route in ExampleCode errors; spec matches the shipped rendering path Co-Authored-By: Claude Fable 5.1 --- apps/website/src/app/blog/[slug]/page.tsx | 2 +- .../src/app/docs/choosing-an-adapter/page.tsx | 2 +- .../components/docs/mdx/ExampleCode.spec.tsx | 6 +++- apps/website/src/lib/example-code.spec.ts | 21 +++++++++++++ apps/website/src/lib/example-code.ts | 4 ++- ...09-05-docs-example-first-content-design.md | 30 +++++++++++-------- 6 files changed, 48 insertions(+), 17 deletions(-) diff --git a/apps/website/src/app/blog/[slug]/page.tsx b/apps/website/src/app/blog/[slug]/page.tsx index becb59648..3d7cdb127 100644 --- a/apps/website/src/app/blog/[slug]/page.tsx +++ b/apps/website/src/app/blog/[slug]/page.tsx @@ -110,7 +110,7 @@ export default async function BlogPostPage({ params }: Params) { ) : null} - + diff --git a/apps/website/src/app/docs/choosing-an-adapter/page.tsx b/apps/website/src/app/docs/choosing-an-adapter/page.tsx index fd17cfbef..b5a823170 100644 --- a/apps/website/src/app/docs/choosing-an-adapter/page.tsx +++ b/apps/website/src/app/docs/choosing-an-adapter/page.tsx @@ -52,7 +52,7 @@ export default function ChoosingAnAdapterPage() { aria-label={PAGE_TITLE} className="flex-1 py-8 px-4 sm:px-6 md:px-12 md:max-w-3xl" > - + {/* This page carries as many headings as any library page, so it gets diff --git a/apps/website/src/components/docs/mdx/ExampleCode.spec.tsx b/apps/website/src/components/docs/mdx/ExampleCode.spec.tsx index daa87dd37..fa43b9402 100644 --- a/apps/website/src/components/docs/mdx/ExampleCode.spec.tsx +++ b/apps/website/src/components/docs/mdx/ExampleCode.spec.tsx @@ -26,7 +26,11 @@ const context: ExampleCodeContext = { function findMdx( node: ReactNode -): ReactElement<{ source: string; components: object }> | null { +): ReactElement<{ + source: string; + components: object; + options?: unknown; +}> | null { if (Array.isArray(node)) { for (const child of node) { const found = findMdx(child); diff --git a/apps/website/src/lib/example-code.spec.ts b/apps/website/src/lib/example-code.spec.ts index f88eeb334..80da0b4bc 100644 --- a/apps/website/src/lib/example-code.spec.ts +++ b/apps/website/src/lib/example-code.spec.ts @@ -132,6 +132,27 @@ describe('sliceRegion', () => { expect(sliceRegion(source, 'inner', 'f.ts')).toBe('const b = 2;'); }); + it('counts an unnamed nested region so the outer slice is not cut short', () => { + const source = [ + '// #region outer', + 'const a = 1;', + '// #region', + 'const b = 2;', + '// #endregion', + 'const c = 3;', + '// #endregion', + ].join('\n'); + expect(sliceRegion(source, 'outer', 'f.ts')).toBe( + [ + 'const a = 1;', + '// #region', + 'const b = 2;', + '// #endregion', + 'const c = 3;', + ].join('\n') + ); + }); + it('accepts an HTML marker with no space before the comment close', () => { expect( sliceRegion( diff --git a/apps/website/src/lib/example-code.ts b/apps/website/src/lib/example-code.ts index 3ada2a6fe..9d4afdce3 100644 --- a/apps/website/src/lib/example-code.ts +++ b/apps/website/src/lib/example-code.ts @@ -52,6 +52,8 @@ export function resolveExampleFile( } const REGION_START = /^\s*(?:\/\/|#|)?\s*$/; +/** Any region start, named or not, so nesting depth stays balanced. */ +const REGION_ANY_START = /^\s*(?:\/\/|#|