diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c88937a1..66a966e0e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -219,6 +219,38 @@ 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 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. + +`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 diff --git a/apps/website/content/docs/langgraph/guides/streaming.mdx b/apps/website/content/docs/langgraph/guides/streaming.mdx index 70dba50f8..760fcdc99 100644 --- a/apps/website/content/docs/langgraph/guides/streaming.mdx +++ b/apps/website/content/docs/langgraph/guides/streaming.mdx @@ -98,6 +98,12 @@ export class ChatComponent { +### The running example + +The demo in the Run tab is the smallest real integration of this pattern. The snippet above sketches the pieces by hand; the real component uses the prebuilt `` composition instead. It injects the agent configured in `app.config.ts` and hands it to ``, which owns message rendering, input, and the typing indicator. + + + ## Stream status The `status()` signal reports the current lifecycle state of the SSE connection: 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/[library]/[section]/[slug]/page.spec.tsx b/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx index e526b004f..024266d10 100644 --- a/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx +++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx @@ -13,8 +13,17 @@ interface ElementProps { docsSlot?: ReactNode; requestedMode?: string | null; resolution?: { kind?: string; identity?: { availableModes?: string[] } }; - contentBundle?: { runtimeUrl?: string | null }; + contentBundle?: { + runtimeUrl?: string | null; + codeSources?: Record; + }; contextTrail?: readonly { label: string; href?: string; icon?: ReactNode }[]; + docsContext?: unknown; + docsPath?: string; + exampleCode?: { + assetPaths?: readonly string[]; + sources?: Record; + } | null; } function findElement( @@ -63,6 +72,20 @@ describe('unified docs workspace route', () => { activeSection: 'guides', activeSlug: 'streaming', }); + + const mdx = findElement( + workspace?.props.docsSlot, + MdxRenderer as ComponentType + ); + expect(mdx?.props.exampleCode?.assetPaths).toContain( + 'cockpit/langgraph/streaming/angular/src/app/streaming.component.ts' + ); + // The server-rendered keeps the raw sources; the client + // boundary must not carry a second copy of them into the RSC payload. + expect(Object.keys(mdx?.props.exampleCode?.sources ?? {})).toContain( + 'cockpit/langgraph/streaming/angular/src/app/streaming.component.ts' + ); + expect(workspace?.props.contentBundle?.codeSources).toEqual({}); }); it('keeps an unmapped page as a complete server Docs slot', async () => { @@ -78,6 +101,12 @@ describe('unified docs workspace route', () => { findElement(slot, DocsPageHeader as ComponentType) ).toBeTruthy(); expect(findElement(slot, MdxRenderer as ComponentType)).toBeTruthy(); + expect( + findElement(slot, MdxRenderer as ComponentType)?.props.exampleCode + ).toBeNull(); + expect( + findElement(slot, MdxRenderer as ComponentType)?.props.docsPath + ).toBe('/docs/langgraph/guides/testing'); expect(findElement(slot, DocsTOC as ComponentType)).toBeTruthy(); }); diff --git a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx index fe2dddf8d..727da1a0c 100644 --- a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx +++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx @@ -34,7 +34,10 @@ import { type LibraryId, } from '../../../../../lib/docs-config'; import { WebsiteWorkspace } from '../../../../../components/workspace/WebsiteWorkspace'; -import { getWebsiteWorkspacePage } from '../../../../../lib/workspace-page'; +import { + getExampleCodeContext, + getWebsiteWorkspacePage, +} from '../../../../../lib/workspace-page'; import fs from 'fs'; import path from 'path'; @@ -168,7 +171,11 @@ export default async function DocsPage({ params }: DocsRouteProps) { />
- +
{section === 'api' && (() => { @@ -226,7 +233,11 @@ export default async function DocsPage({ params }: DocsRouteProps) { ` above + // (docsSlot already captured them); the workspace shell renders the + // highlighted `codeFiles`, so shipping them again would only add dead + // weight to this client boundary's RSC payload. + contentBundle={{ ...workspacePage.contentBundle, codeSources: {} }} navigationTree={workspacePage.navigationTree} routePath={pathname} docsSlot={docsSlot} 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/MdxRenderer.spec.tsx b/apps/website/src/components/docs/MdxRenderer.spec.tsx new file mode 100644 index 000000000..3dfc64809 --- /dev/null +++ b/apps/website/src/components/docs/MdxRenderer.spec.tsx @@ -0,0 +1,65 @@ +import { isValidElement, type ReactElement, type ReactNode } from 'react'; +import { describe, expect, it } from 'vitest'; +import { MDXRemote } from 'next-mdx-remote/rsc'; +import { MdxRenderer } from './MdxRenderer'; + +function findMdx( + node: ReactNode +): ReactElement<{ components: Record }> | null { + if ( + !isValidElement<{ + components: Record; + children?: ReactNode; + }>(node) + ) + return null; + if (node.type === MDXRemote) return node; + return findMdx(node.props.children); +} + +describe('MdxRenderer', () => { + it('always registers ExampleCode, bound to the page context', () => { + const withContext = findMdx( + MdxRenderer({ + source: '# x', + exampleCode: { + docsPath: '/docs/p', + assetPaths: ['a/b.ts'], + sources: { 'a/b.ts': '' }, + }, + }) + ); + const without = findMdx( + MdxRenderer({ source: '# x', docsPath: '/docs/only' }) + ); + + expect(typeof withContext?.props.components['ExampleCode']).toBe( + 'function' + ); + // Not just "a function": an unresolvable file must fail against THIS + // page's context, which a hard-coded createExampleCode(null) could not do. + expect(() => + ( + withContext?.props.components['ExampleCode'] as (p: { + file: string; + }) => unknown + )({ file: 'nope.ts' }) + ).toThrow(/\/docs\/p/); + + expect(typeof without?.props.components['ExampleCode']).toBe('function'); + expect(() => + ( + without?.props.components['ExampleCode'] as (p: { + file: string; + }) => unknown + )({ file: 'b.ts' }) + ).toThrow(/mapped example/); + expect(() => + ( + without?.props.components['ExampleCode'] as (p: { + file: string; + }) => unknown + )({ file: 'b.ts' }) + ).toThrow(/\/docs\/only/); + }); +}); diff --git a/apps/website/src/components/docs/MdxRenderer.tsx b/apps/website/src/components/docs/MdxRenderer.tsx index 2e8ebc974..0d5d7a536 100644 --- a/apps/website/src/components/docs/MdxRenderer.tsx +++ b/apps/website/src/components/docs/MdxRenderer.tsx @@ -18,9 +18,9 @@ import { MiddlewareHowItFits, TelemetryHowItFits, } from './diagrams'; -import rehypePrettyCode from 'rehype-pretty-code'; -import rehypeSlug from 'rehype-slug'; -import remarkGfm from 'remark-gfm'; +import { mdxCompileOptions } from './mdx-options'; +import { createExampleCode } from './mdx/ExampleCode'; +import type { ExampleCodeContext } from '../../lib/example-code'; /** * Intrinsic size of each SVG diagram in `public/blog/diagrams`. @@ -87,28 +87,28 @@ const mdxComponents = { ...mdxHeadingComponents, }; -const rehypeOptions = { - theme: 'tokyo-night', - keepBackground: true, -}; - interface MdxRendererProps { source: string; + /** Present on docs pages that embed a runnable example; null elsewhere. */ + exampleCode?: ExampleCodeContext | null; + /** Route of the page being rendered, so a docs-only failure can name it. */ + docsPath?: string; } -export function MdxRenderer({ source }: MdxRendererProps) { +export function MdxRenderer({ + source, + exampleCode = null, + docsPath, +}: MdxRendererProps) { return (
); diff --git a/apps/website/src/components/docs/mdx-options.ts b/apps/website/src/components/docs/mdx-options.ts new file mode 100644 index 000000000..d98768acf --- /dev/null +++ b/apps/website/src/components/docs/mdx-options.ts @@ -0,0 +1,21 @@ +import rehypePrettyCode from 'rehype-pretty-code'; +import rehypeSlug from 'rehype-slug'; +import remarkGfm from 'remark-gfm'; + +const rehypeOptions = { + theme: 'tokyo-night', + keepBackground: true, +}; + +/** + * The one MDX compile configuration. `MdxRenderer` uses it for whole pages and + * `ExampleCode` for the fence it synthesizes, so included code is highlighted + * and styled exactly like a hand-written block. + */ +export const mdxCompileOptions = { + mdxOptions: { + remarkPlugins: [remarkGfm], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rehypePlugins: [rehypeSlug, [rehypePrettyCode, rehypeOptions] as any], + }, +}; diff --git a/apps/website/src/components/docs/mdx/ExampleCode.spec.tsx b/apps/website/src/components/docs/mdx/ExampleCode.spec.tsx new file mode 100644 index 000000000..fa43b9402 --- /dev/null +++ b/apps/website/src/components/docs/mdx/ExampleCode.spec.tsx @@ -0,0 +1,124 @@ +import { isValidElement, type ReactElement, type ReactNode } from 'react'; +import { describe, expect, it } from 'vitest'; +import { MDXRemote } from 'next-mdx-remote/rsc'; +import { + ExampleCodeError, + type ExampleCodeContext, +} from '../../../lib/example-code'; +import { createExampleCode } from './ExampleCode'; +import { mdxCompileOptions } from '../mdx-options'; + +const context: ExampleCodeContext = { + docsPath: '/docs/langgraph/guides/streaming', + assetPaths: [ + 'cockpit/langgraph/streaming/angular/src/app/streaming.component.ts', + ], + sources: { + 'cockpit/langgraph/streaming/angular/src/app/streaming.component.ts': [ + 'class StreamingComponent {', + ' // #region send', + ' send(text: string) {}', + ' // #endregion', + '}', + ].join('\n'), + }, +}; + +function findMdx( + node: ReactNode +): ReactElement<{ + source: string; + components: object; + options?: unknown; +}> | null { + if (Array.isArray(node)) { + for (const child of node) { + const found = findMdx(child); + if (found) return found; + } + return null; + } + if ( + !isValidElement<{ + source: string; + components: object; + children?: ReactNode; + }>(node) + ) + return null; + if (node.type === MDXRemote) return node; + return findMdx(node.props.children); +} + +function findTitle( + node: ReactNode +): ReactElement<{ className: string; children?: ReactNode }> | null { + if (Array.isArray(node)) { + for (const child of node) { + const found = findTitle(child); + if (found) return found; + } + return null; + } + if (!isValidElement<{ className?: string; children?: ReactNode }>(node)) + return null; + if (node.props.className === 'mdx-example-code-title') + return node as ReactElement<{ className: string; children?: ReactNode }>; + return findTitle(node.props.children); +} + +describe('ExampleCode', () => { + it('renders the whole file as a fence through MDXRemote with the file title', () => { + const ExampleCode = createExampleCode(context); + const element = ExampleCode({ file: 'streaming.component.ts' }); + const mdx = findMdx(element); + + expect(element.props['data-example-file']).toBe( + 'cockpit/langgraph/streaming/angular/src/app/streaming.component.ts' + ); + expect(mdx?.props.source).toBe( + '```ts\n' + + context.sources[ + 'cockpit/langgraph/streaming/angular/src/app/streaming.component.ts' + ] + + '\n```' + ); + expect(Object.keys(mdx?.props.components ?? {})).toEqual(['pre']); + expect(mdx?.props.options).toBe(mdxCompileOptions); + expect(findTitle(element)?.props.children).toBe('streaming.component.ts'); + expect(element.props['aria-label']).toBe('streaming.component.ts'); + }); + + it('renders a region and records it on the wrapper', () => { + const ExampleCode = createExampleCode(context); + const element = ExampleCode({ + file: 'streaming.component.ts', + region: 'send', + title: 'send()', + }); + + expect(element.props['data-example-region']).toBe('send'); + expect(findMdx(element)?.props.source).toBe( + '```ts\nsend(text: string) {}\n```' + ); + expect(findTitle(element)?.props.children).toBe('send()'); + }); + + it('throws on a docs-only page', () => { + const ExampleCode = createExampleCode(null, '/docs/x'); + expect(() => ExampleCode({ file: 'streaming.component.ts' })).toThrow( + ExampleCodeError + ); + expect(() => ExampleCode({ file: 'streaming.component.ts' })).toThrow( + /mapped example/ + ); + expect(() => ExampleCode({ file: 'streaming.component.ts' })).toThrow( + /\/docs\/x/ + ); + }); + + it('throws on an unknown file', () => { + const ExampleCode = createExampleCode(context); + expect(() => ExampleCode({ file: 'nope.ts' })).toThrow(ExampleCodeError); + }); +}); diff --git a/apps/website/src/components/docs/mdx/ExampleCode.tsx b/apps/website/src/components/docs/mdx/ExampleCode.tsx new file mode 100644 index 000000000..7a3d94c6b --- /dev/null +++ b/apps/website/src/components/docs/mdx/ExampleCode.tsx @@ -0,0 +1,65 @@ +import { MDXRemote } from 'next-mdx-remote/rsc'; +import { + ExampleCodeError, + exampleTitle, + fenceFor, + resolveExampleFile, + sliceRegion, + type ExampleCodeContext, +} from '../../../lib/example-code'; +import { mdxCompileOptions } from '../mdx-options'; +import { Pre } from './CodeBlock'; + +export interface ExampleCodeProps { + /** Basename or repo-relative path of one of the page's example files. */ + file: string; + /** Name of a `#region` / `#endregion` pair inside that file. */ + region?: string; + /** Title bar text; defaults to the file's basename. */ + title?: string; +} + +/** + * Binds `` to one docs page's example. The component renders + * the requested file (or region) as a code fence through the same MDX + * pipeline as the page, so highlighting, the copy button and every `pre` + * style are identical to a hand-written block. Anything unresolvable throws + * at build time: a docs page without its code is wrong, not degraded. + */ +export function createExampleCode( + context: ExampleCodeContext | null, + pageDocsPath?: string +) { + return function ExampleCode({ file, region, title }: ExampleCodeProps) { + if (!context) { + // There is no context to name the page, so the caller's route does it: + // a build failure that says only "some page" is not actionable. + throw new ExampleCodeError( + `${ + pageDocsPath ?? 'this page' + }: is only valid on a docs page with a mapped example` + ); + } + const path = resolveExampleFile(file, context); + const source = context.sources[path]; + const code = region ? sliceRegion(source, region, path) : source; + const heading = title ?? exampleTitle(path); + + return ( +
+
{heading}
+ +
+ ); + }; +} diff --git a/apps/website/src/components/workspace/WebsiteWorkspace.spec.tsx b/apps/website/src/components/workspace/WebsiteWorkspace.spec.tsx index ff19c5638..e36ec973d 100644 --- a/apps/website/src/components/workspace/WebsiteWorkspace.spec.tsx +++ b/apps/website/src/components/workspace/WebsiteWorkspace.spec.tsx @@ -66,10 +66,10 @@ import { WebsiteWorkspace, WebsiteWorkspaceRoot } from './WebsiteWorkspace'; const emptyContent: ContentBundle = { codeFiles: {}, + codeSources: {}, promptFiles: {}, runtimeUrl: null, docSections: [], - narrativeDocs: [], }; const docsOnlyResolution: WorkspaceResolution = { @@ -118,7 +118,6 @@ const mappedPresentation = ( promptAssetPaths: [], codeAssetPaths: ['example.ts'], backendAssetPaths: [], - docsAssetPaths: [], runnable: resolution.identity.availableModes.includes('Run'), }; }; @@ -541,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', diff --git a/apps/website/src/lib/docs-example-code.spec.ts b/apps/website/src/lib/docs-example-code.spec.ts new file mode 100644 index 000000000..7f5a8cc8b --- /dev/null +++ b/apps/website/src/lib/docs-example-code.spec.ts @@ -0,0 +1,202 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { capabilityModules } from '@threadplane/cockpit-registry'; +import { describe, expect, it } from 'vitest'; +import { + resolveExampleFile, + sliceRegion, + type ExampleCodeContext, +} from './example-code'; + +/** + * Every docs page that embeds a runnable example teaches through that + * example: it includes code with ``, and every include resolves + * against the capability's declared assets by the same rule the component + * uses at build time. Docs-only pages never include. + * + * PENDING_PAGES lists mapped pages not yet rewritten. Each product PR removes + * its pages; a page that gains an include must leave the list in the same PR. + * + * The scan is textual: the tag must not appear in prose, fenced code, or MDX + * comments on any docs page, or it counts as an include. + */ +const PENDING_PAGES = new Set([ + '/docs/a2ui/getting-started/introduction', + '/docs/ag-ui/guides/client-tools', + '/docs/ag-ui/guides/interrupts', + '/docs/ag-ui/guides/json-render', + '/docs/ag-ui/guides/subagents', + '/docs/ag-ui/guides/tool-views', + '/docs/ag-ui/reference/event-mapping', + '/docs/chat/a2ui/overview', + '/docs/chat/components/chat-debug', + '/docs/chat/components/chat-input', + '/docs/chat/components/chat-interrupt-panel', + '/docs/chat/components/chat-subagent-card', + '/docs/chat/components/chat-tool-calls', + '/docs/chat/components/chat-trace', + '/docs/chat/concepts/message-model', + '/docs/chat/guides/client-tools', + '/docs/chat/guides/generative-ui', + '/docs/chat/guides/theming', + '/docs/chat/guides/thread-routing', + '/docs/deep-agents/capabilities/filesystem', + '/docs/deep-agents/capabilities/memory', + '/docs/deep-agents/capabilities/planning', + '/docs/deep-agents/capabilities/skills', + '/docs/deep-agents/capabilities/subagents', + '/docs/langgraph/guides/deployment', + '/docs/langgraph/guides/durable-execution', + '/docs/langgraph/guides/interrupts', + '/docs/langgraph/guides/memory', + '/docs/langgraph/guides/persistence', + '/docs/langgraph/guides/subgraphs', + '/docs/langgraph/guides/time-travel', + '/docs/render/api/provide-render', + '/docs/render/api/render-spec-component', + '/docs/render/guides/registry', + '/docs/render/guides/repeat-loops', + '/docs/render/guides/specs', + '/docs/render/guides/state-store', + '/docs/runtimes/aws-strands/overview', + '/docs/runtimes/mastra/overview', + '/docs/runtimes/microsoft-agent-framework/overview', +]); + +const findWorkspaceRoot = (): string => { + let directory = process.cwd(); + while (directory !== resolve(directory, '..')) { + if (existsSync(join(directory, 'nx.json'))) return directory; + directory = resolve(directory, '..'); + } + throw new Error('workspace root (nx.json) not found'); +}; +const WORKSPACE_ROOT = findWorkspaceRoot(); +const CONTENT_ROOT = join(WORKSPACE_ROOT, 'apps/website/content'); + +interface MappedPage { + readonly docsPath: string; + readonly assetPaths: readonly string[]; +} + +/** docsPath → union of code assets across every descriptor sharing it. */ +function mappedPages(): MappedPage[] { + const byPath = new Map>(); + for (const descriptor of capabilityModules) { + const assets = [ + ...descriptor.codeAssetPaths, + ...(descriptor.backendAssetPaths ?? []), + ]; + if (assets.length === 0) continue; + const set = byPath.get(descriptor.docsPath) ?? new Set(); + for (const asset of assets) set.add(asset); + byPath.set(descriptor.docsPath, set); + } + return [...byPath].map(([docsPath, assets]) => ({ + docsPath, + assetPaths: [...assets], + })); +} + +function mdxFor(docsPath: string): string { + return readFileSync(join(CONTENT_ROOT, `${docsPath}.mdx`), 'utf8'); +} + +function contextFor(page: MappedPage): ExampleCodeContext { + const sources: Record = {}; + for (const path of page.assetPaths) { + const full = join(WORKSPACE_ROOT, path); + if (existsSync(full)) sources[path] = readFileSync(full, 'utf8'); + } + return { docsPath: page.docsPath, assetPaths: page.assetPaths, sources }; +} + +interface Include { + readonly file: string; + readonly region?: string; +} + +function includesIn(mdx: string): Include[] { + return [...mdx.matchAll(/]*?)\/?>/g)].map(([, attrs]) => ({ + file: /\bfile="([^"]+)"/.exec(attrs)?.[1] ?? '', + region: /\bregion="([^"]+)"/.exec(attrs)?.[1], + })); +} + +function allDocsMdx(): string[] { + const walk = (dir: string): string[] => + readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return walk(path); + return entry.name.endsWith('.mdx') ? [path] : []; + }); + return walk(join(CONTENT_ROOT, 'docs')); +} + +describe('docs pages teach through their example', () => { + const pages = mappedPages(); + const mappedPaths = new Set(pages.map((page) => page.docsPath)); + + it('sees the registry and the content tree', () => { + expect(pages.length).toBeGreaterThan(30); + expect(allDocsMdx().length).toBeGreaterThan(50); + }); + + it('has an MDX file for every mapped page', () => { + for (const page of pages) { + expect( + existsSync(join(CONTENT_ROOT, `${page.docsPath}.mdx`)), + page.docsPath + ).toBe(true); + } + }); + + it('lists only mapped pages as pending, and none that already include', () => { + for (const pending of PENDING_PAGES) { + expect(mappedPaths.has(pending), `${pending} is not a mapped page`).toBe( + true + ); + expect( + includesIn(mdxFor(pending)), + `${pending} includes code; remove it from PENDING_PAGES` + ).toEqual([]); + } + }); + + it('includes at least one example file on every rewritten mapped page', () => { + const missing = pages + .filter((page) => !PENDING_PAGES.has(page.docsPath)) + .filter((page) => includesIn(mdxFor(page.docsPath)).length === 0) + .map((page) => page.docsPath); + expect( + missing, + 'mapped pages with no ; rewrite them or add them to PENDING_PAGES' + ).toEqual([]); + }); + + it('resolves every include against the capability assets', () => { + for (const page of pages) { + const context = contextFor(page); + for (const include of includesIn(mdxFor(page.docsPath))) { + const { file, region } = include; + const label = `${page.docsPath} `; + expect(file, label).not.toBe(''); + const path = resolveExampleFile(file, context); + if (region) { + expect( + () => sliceRegion(context.sources[path], region, path), + label + ).not.toThrow(); + } + } + } + }); + + it('never includes on a docs-only page', () => { + const offenders = allDocsMdx() + .map((file) => '/' + relative(CONTENT_ROOT, file).replace(/\.mdx$/, '')) + .filter((docsPath) => !mappedPaths.has(docsPath)) + .filter((docsPath) => includesIn(mdxFor(docsPath)).length > 0); + expect(offenders, 'docs-only pages must not use ').toEqual([]); + }); +}); 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..80da0b4bc --- /dev/null +++ b/apps/website/src/lib/example-code.spec.ts @@ -0,0 +1,191 @@ +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/); + }); + + it('keeps nested regions intact and ends at the matching endregion', () => { + const source = [ + '// #region outer', + 'const a = 1;', + '// #region inner', + 'const b = 2;', + '// #endregion', + 'const c = 3;', + '// #endregion', + ].join('\n'); + expect(sliceRegion(source, 'outer', 'f.ts')).toBe( + [ + '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('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( + '\n

hi

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

hi

'); + }); +}); + +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..9d4afdce3 --- /dev/null +++ b/apps/website/src/lib/example-code.ts @@ -0,0 +1,129 @@ +/** + * 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*$/; +/** Any region start, named or not, so nesting depth stays balanced. */ +const REGION_ANY_START = /^\s*(?:\/\/|#|\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. 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..1cf0fd2f9 --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-docs-example-first-content-design.md @@ -0,0 +1,208 @@ +# 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` (41 + files, about 4,800 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: both whole-file and region includes work from raw source, so +`ContentBundle` gains `codeSources: Record` (raw text keyed +like `codeFiles`). The component synthesizes a fenced block from the +requested slice and renders it through `MDXRemote` with the page's own +compile options (`components/docs/mdx-options.ts`) and `pre: Pre`, so +highlighting, the copy button and its analytics event, and every `mdx-pre` +style come from the one existing code pipeline rather than a second one. +The bundle's highlighted `codeFiles` continues to serve the Code tab only. +The block is wrapped in a titled card (`mdx-example-code`). Tabbing several +`ExampleCode` blocks through `CodeGroup` is not supported in PR 1, because +`CodeGroup` derives its tab labels from a child's `data-title` prop; PR 2 +decides whether to add that. 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 ` { descriptor.promptAssetPaths, descriptor.codeAssetPaths, descriptor.backendAssetPaths, - descriptor.docsAssetPaths, ]) { if (assetPaths) { expect(Object.isFrozen(assetPaths), descriptor.id).toBe(true); @@ -210,7 +209,6 @@ describe('registry content descriptors', () => { ...descriptor.promptAssetPaths, ...descriptor.codeAssetPaths, ...(descriptor.backendAssetPaths ?? []), - ...(descriptor.docsAssetPaths ?? []), ]) { expect( existsSync(new URL(assetPath, workspaceRoot)), @@ -300,8 +298,7 @@ describe('registry content descriptors', () => { ); expect(entry.availableModes.includes('API')).toBe(apiAssets.length > 0); expect(entry.availableModes.includes('Docs')).toBe( - entry.docsPath.length > 0 || - (descriptor?.docsAssetPaths?.length ?? 0) > 0 + entry.docsPath.length > 0 ); } }); diff --git a/libs/cockpit-registry/src/lib/content-descriptors.ts b/libs/cockpit-registry/src/lib/content-descriptors.ts index 8a045fc06..623a76327 100644 --- a/libs/cockpit-registry/src/lib/content-descriptors.ts +++ b/libs/cockpit-registry/src/lib/content-descriptors.ts @@ -20,7 +20,6 @@ export interface RegisteredCapabilityModule { readonly promptAssetPaths: readonly string[]; readonly codeAssetPaths: readonly string[]; readonly backendAssetPaths?: readonly string[]; - readonly docsAssetPaths?: readonly string[]; readonly runtimeUrl?: string; readonly devPort?: number; } @@ -46,7 +45,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/langgraph/streaming/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/langgraph/streaming/python/src/graph.py'], - docsAssetPaths: ['cockpit/langgraph/streaming/python/docs/guide.md'], runtimeUrl: 'langgraph/streaming', devPort: 4300, }, @@ -70,7 +68,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/langgraph/persistence/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/langgraph/persistence/python/src/graph.py'], - docsAssetPaths: ['cockpit/langgraph/persistence/python/docs/guide.md'], runtimeUrl: 'langgraph/persistence', devPort: 4301, }, @@ -94,7 +91,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/langgraph/interrupts/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/langgraph/interrupts/python/src/graph.py'], - docsAssetPaths: ['cockpit/langgraph/interrupts/python/docs/guide.md'], runtimeUrl: 'langgraph/interrupts', devPort: 4302, }, @@ -116,7 +112,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/langgraph/memory/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/langgraph/memory/python/src/graph.py'], - docsAssetPaths: ['cockpit/langgraph/memory/python/docs/guide.md'], runtimeUrl: 'langgraph/memory', devPort: 4303, }, @@ -142,9 +137,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ backendAssetPaths: [ 'cockpit/langgraph/durable-execution/python/src/graph.py', ], - docsAssetPaths: [ - 'cockpit/langgraph/durable-execution/python/docs/guide.md', - ], runtimeUrl: 'langgraph/durable-execution', devPort: 4304, }, @@ -168,7 +160,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/langgraph/subgraphs/angular/src/app/subgraphs.component.ts', ], backendAssetPaths: ['cockpit/langgraph/subgraphs/python/src/graph.py'], - docsAssetPaths: ['cockpit/langgraph/subgraphs/python/docs/guide.md'], runtimeUrl: 'langgraph/subgraphs', devPort: 4305, }, @@ -192,7 +183,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/langgraph/time-travel/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/langgraph/time-travel/python/src/graph.py'], - docsAssetPaths: ['cockpit/langgraph/time-travel/python/docs/guide.md'], runtimeUrl: 'langgraph/time-travel', devPort: 4306, }, @@ -218,9 +208,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ backendAssetPaths: [ 'cockpit/langgraph/deployment-runtime/python/src/graph.py', ], - docsAssetPaths: [ - 'cockpit/langgraph/deployment-runtime/python/docs/guide.md', - ], runtimeUrl: 'langgraph/deployment-runtime', devPort: 4307, }, @@ -246,7 +233,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/langgraph/client-tools/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/langgraph/client-tools/python/src/graph.py'], - docsAssetPaths: ['cockpit/langgraph/client-tools/python/docs/guide.md'], runtimeUrl: 'langgraph/client-tools', devPort: 4308, }, @@ -271,7 +257,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/ag-ui/interrupts/python/src/graph.py', 'cockpit/ag-ui/interrupts/python/src/server.py', ], - docsAssetPaths: ['cockpit/ag-ui/interrupts/python/docs/guide.md'], runtimeUrl: 'ag-ui/interrupts', devPort: 4320, }, @@ -296,7 +281,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/ag-ui/streaming/python/src/graph.py', 'cockpit/ag-ui/streaming/python/src/server.py', ], - docsAssetPaths: ['cockpit/ag-ui/streaming/python/docs/guide.md'], runtimeUrl: 'ag-ui/streaming', devPort: 4321, }, @@ -322,7 +306,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/ag-ui/tool-views/python/src/graph.py', 'cockpit/ag-ui/tool-views/python/src/server.py', ], - docsAssetPaths: ['cockpit/ag-ui/tool-views/python/docs/guide.md'], runtimeUrl: 'ag-ui/tool-views', devPort: 4322, }, @@ -349,7 +332,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/ag-ui/json-render/python/src/graph.py', 'cockpit/ag-ui/json-render/python/src/server.py', ], - docsAssetPaths: ['cockpit/ag-ui/json-render/python/docs/guide.md'], runtimeUrl: 'ag-ui/json-render', devPort: 4323, }, @@ -378,7 +360,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/ag-ui/client-tools/python/src/graph.py', 'cockpit/ag-ui/client-tools/python/src/server.py', ], - docsAssetPaths: ['cockpit/ag-ui/client-tools/python/docs/guide.md'], runtimeUrl: 'ag-ui/client-tools', devPort: 4325, }, @@ -403,7 +384,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/ag-ui/a2ui/python/src/graph.py', 'cockpit/ag-ui/a2ui/python/src/server.py', ], - docsAssetPaths: ['cockpit/ag-ui/a2ui/python/docs/guide.md'], runtimeUrl: 'ag-ui/a2ui', devPort: 4324, }, @@ -428,7 +408,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/ag-ui/subagents/python/src/graph.py', 'cockpit/ag-ui/subagents/python/src/server.py', ], - docsAssetPaths: ['cockpit/ag-ui/subagents/python/docs/guide.md'], runtimeUrl: 'ag-ui/subagents', devPort: 4326, }, @@ -450,7 +429,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/deep-agents/memory/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/deep-agents/memory/python/src/graph.py'], - docsAssetPaths: ['cockpit/deep-agents/memory/python/docs/guide.md'], runtimeUrl: 'deep-agents/memory', devPort: 4313, }, @@ -474,7 +452,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/deep-agents/planning/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/deep-agents/planning/python/src/graph.py'], - docsAssetPaths: ['cockpit/deep-agents/planning/python/docs/guide.md'], runtimeUrl: 'deep-agents/planning', devPort: 4310, }, @@ -498,7 +475,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/deep-agents/filesystem/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/deep-agents/filesystem/python/src/graph.py'], - docsAssetPaths: ['cockpit/deep-agents/filesystem/python/docs/guide.md'], runtimeUrl: 'deep-agents/filesystem', devPort: 4311, }, @@ -522,7 +498,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/deep-agents/subagents/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/deep-agents/subagents/python/src/graph.py'], - docsAssetPaths: ['cockpit/deep-agents/subagents/python/docs/guide.md'], runtimeUrl: 'deep-agents/subagents', devPort: 4312, }, @@ -544,7 +519,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/deep-agents/skills/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/deep-agents/skills/python/src/graph.py'], - docsAssetPaths: ['cockpit/deep-agents/skills/python/docs/guide.md'], runtimeUrl: 'deep-agents/skills', devPort: 4314, }, @@ -568,7 +542,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/render/spec-rendering/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/render/spec-rendering/python/src/graph.py'], - docsAssetPaths: ['cockpit/render/spec-rendering/python/docs/guide.md'], runtimeUrl: 'render/spec-rendering', devPort: 4401, }, @@ -592,7 +565,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/render/element-rendering/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/render/element-rendering/python/src/graph.py'], - docsAssetPaths: ['cockpit/render/element-rendering/python/docs/guide.md'], runtimeUrl: 'render/element-rendering', devPort: 4402, }, @@ -616,7 +588,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/render/state-management/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/render/state-management/python/src/graph.py'], - docsAssetPaths: ['cockpit/render/state-management/python/docs/guide.md'], runtimeUrl: 'render/state-management', devPort: 4403, }, @@ -638,7 +609,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/render/registry/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/render/registry/python/src/graph.py'], - docsAssetPaths: ['cockpit/render/registry/python/docs/guide.md'], runtimeUrl: 'render/registry', devPort: 4404, }, @@ -662,7 +632,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/render/repeat-loops/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/render/repeat-loops/python/src/graph.py'], - docsAssetPaths: ['cockpit/render/repeat-loops/python/docs/guide.md'], runtimeUrl: 'render/repeat-loops', devPort: 4405, }, @@ -688,7 +657,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ backendAssetPaths: [ 'cockpit/render/computed-functions/python/src/graph.py', ], - docsAssetPaths: ['cockpit/render/computed-functions/python/docs/guide.md'], runtimeUrl: 'render/computed-functions', devPort: 4406, }, @@ -710,7 +678,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/chat/messages/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/chat/messages/python/src/graph.py'], - docsAssetPaths: ['cockpit/chat/messages/python/docs/guide.md'], runtimeUrl: 'chat/messages', devPort: 4501, }, @@ -732,7 +699,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/chat/input/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/chat/input/python/src/graph.py'], - docsAssetPaths: ['cockpit/chat/input/python/docs/guide.md'], runtimeUrl: 'chat/input', devPort: 4502, }, @@ -754,7 +720,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/chat/interrupts/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/chat/interrupts/python/src/graph.py'], - docsAssetPaths: ['cockpit/chat/interrupts/python/docs/guide.md'], runtimeUrl: 'chat/interrupts', devPort: 4503, }, @@ -776,7 +741,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/chat/tool-calls/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/chat/tool-calls/python/src/graph.py'], - docsAssetPaths: ['cockpit/chat/tool-calls/python/docs/guide.md'], runtimeUrl: 'chat/tool-calls', devPort: 4504, }, @@ -798,7 +762,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/chat/subagents/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/chat/subagents/python/src/graph.py'], - docsAssetPaths: ['cockpit/chat/subagents/python/docs/guide.md'], runtimeUrl: 'chat/subagents', devPort: 4505, }, @@ -820,7 +783,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/chat/threads/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/chat/threads/python/src/graph.py'], - docsAssetPaths: ['cockpit/chat/threads/python/docs/guide.md'], runtimeUrl: 'chat/threads', devPort: 4506, }, @@ -842,7 +804,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/chat/timeline/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/chat/timeline/python/src/graph.py'], - docsAssetPaths: ['cockpit/chat/timeline/python/docs/guide.md'], runtimeUrl: 'chat/timeline', devPort: 4507, }, @@ -866,7 +827,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/chat/generative-ui/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/chat/generative-ui/python/src/graph.py'], - docsAssetPaths: ['cockpit/chat/generative-ui/python/docs/guide.md'], runtimeUrl: 'chat/generative-ui', devPort: 4508, }, @@ -888,7 +848,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/chat/debug/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/chat/debug/python/src/graph.py'], - docsAssetPaths: ['cockpit/chat/debug/python/docs/guide.md'], runtimeUrl: 'chat/debug', devPort: 4509, }, @@ -910,7 +869,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/chat/theming/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/chat/theming/python/src/graph.py'], - docsAssetPaths: ['cockpit/chat/theming/python/docs/guide.md'], runtimeUrl: 'chat/theming', devPort: 4510, }, @@ -932,7 +890,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/chat/a2ui/angular/src/app/app.config.ts', ], backendAssetPaths: ['cockpit/chat/a2ui/python/src/graph.py'], - docsAssetPaths: ['cockpit/chat/a2ui/python/docs/guide.md'], runtimeUrl: 'chat/a2ui', devPort: 4511, }, @@ -959,9 +916,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/runtimes/microsoft-agent-framework/python/src/agent.py', 'cockpit/runtimes/microsoft-agent-framework/python/src/server.py', ], - docsAssetPaths: [ - 'cockpit/runtimes/microsoft-agent-framework/python/docs/guide.md', - ], runtimeUrl: 'runtimes/microsoft-agent-framework', devPort: 4330, }, @@ -988,7 +942,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'cockpit/runtimes/aws-strands/python/src/agent.py', 'cockpit/runtimes/aws-strands/python/src/server.py', ], - docsAssetPaths: ['cockpit/runtimes/aws-strands/python/docs/guide.md'], runtimeUrl: 'runtimes/aws-strands', devPort: 4331, }, @@ -1016,7 +969,6 @@ const capabilityModuleData: RegisteredCapabilityModule[] = [ 'deployments/ag-ui-mastra/agents.mjs', 'deployments/ag-ui-mastra/server.mjs', ], - docsAssetPaths: ['cockpit/runtimes/mastra/angular/docs/guide.md'], runtimeUrl: 'runtimes/mastra', devPort: 4332, }, @@ -1036,9 +988,6 @@ const freezeCapabilityDescriptor = ( ...(descriptor.backendAssetPaths ? { backendAssetPaths: freezeAssetPaths(descriptor.backendAssetPaths) } : {}), - ...(descriptor.docsAssetPaths - ? { docsAssetPaths: freezeAssetPaths(descriptor.docsAssetPaths) } - : {}), }); export const capabilityModules: readonly RegisteredCapabilityModule[] = @@ -1077,7 +1026,7 @@ export const deriveAvailableModes = (options: { ]; const modes: WorkspaceMode[] = []; - if (docsPath.length > 0 || (descriptor?.docsAssetPaths?.length ?? 0) > 0) { + if (docsPath.length > 0) { modes.push('Docs'); } if (descriptor?.runtimeUrl || descriptor?.devPort) { diff --git a/libs/cockpit-shell/src/index.ts b/libs/cockpit-shell/src/index.ts index 2ffbeea45..72ab1a93c 100644 --- a/libs/cockpit-shell/src/index.ts +++ b/libs/cockpit-shell/src/index.ts @@ -4,4 +4,3 @@ export * from './lib/shell-contracts'; export * from './lib/workspace-presentation'; export * from './lib/workspace-content'; export * from './lib/extract-docs'; -export * from './lib/render-markdown'; diff --git a/libs/cockpit-shell/src/lib/render-markdown.spec.ts b/libs/cockpit-shell/src/lib/render-markdown.spec.ts deleted file mode 100644 index d1dbbe9f7..000000000 --- a/libs/cockpit-shell/src/lib/render-markdown.spec.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -const { mockCodeToHtml } = vi.hoisted(() => ({ - mockCodeToHtml: vi.fn(), -})); - -vi.mock('shiki', () => ({ - codeToHtml: mockCodeToHtml, -})); - -import { renderMarkdown } from './render-markdown'; - -describe('renderMarkdown', () => { - it('converts markdown to HTML with headings and paragraphs', async () => { - mockCodeToHtml.mockResolvedValue( - '
highlighted
' - ); - - const md = `# Getting Started\n\nThis is a paragraph.\n\n## Step 1\n\nAnother paragraph.`; - const result = await renderMarkdown(md); - - expect(result.title).toBe('Getting Started'); - expect(result.html).toContain('Getting Started'); - expect(result.html).toContain('This is a paragraph.'); - }); - - it('highlights fenced code blocks with Shiki', async () => { - mockCodeToHtml.mockResolvedValue( - '
const x = 1;
' - ); - - const md = '# Test\n\n```typescript\nconst x = 1;\n```'; - const result = await renderMarkdown(md); - - expect(result.html).toContain('class="shiki"'); - expect(mockCodeToHtml).toHaveBeenCalled(); - }); - - it('contains Shiki rendering failures with escaped plain code', async () => { - mockCodeToHtml.mockRejectedValue(new Error('Shiki failed')); - - const result = await renderMarkdown( - '# Test\n\n```typescript\nconst tag = "&entity; ";\n```' - ); - - expect(result.html).toContain( - '
const tag = "&entity; <unsafe>";
' - ); - }); - - it('escapes ampersands and angle brackets when Step highlighting fails', async () => { - mockCodeToHtml.mockRejectedValue(new Error('Shiki failed')); - - const result = await renderMarkdown( - '# Test\n\n\n\n\n```typescript\nconst tag = "&entity; ";\n```\n\n\n' - ); - - expect(result.html).toContain( - '
const tag = "&entity; <unsafe>";
' - ); - }); - - it('extracts title from first h1', async () => { - const md = '# My Title\n\nContent here.'; - const result = await renderMarkdown(md); - expect(result.title).toBe('My Title'); - }); - - it('returns empty title when no h1 exists', async () => { - const md = 'Just a paragraph.'; - const result = await renderMarkdown(md); - expect(result.title).toBe(''); - }); - - it('extracts a title from a long heading without regex backtracking', async () => { - const result = await renderMarkdown(`# ${'word '.repeat(20_000)}`); - expect(result.title.startsWith('word word')).toBe(true); - }); - - it('extracts a title from a tab-delimited CommonMark heading', async () => { - const result = await renderMarkdown('#\tTabbed title'); - expect(result.title).toBe('Tabbed title'); - }); - - it('renders Summary blocks', async () => { - const md = '# Test\n\n\nBuild a streaming chat.\n'; - const result = await renderMarkdown(md); - expect(result.html).toContain('doc-summary'); - expect(result.html).toContain('Build a streaming chat.'); - }); - - it('parses inline markdown inside Summary blocks', async () => { - const md = - '# Test\n\n\nUse `agent()` from [`@threadplane/langgraph`](/docs/langgraph).\n'; - const result = await renderMarkdown(md); - expect(result.html).toContain('agent()'); - expect(result.html).toContain(''); - }); - - it('renders Tip callout blocks', async () => { - const md = '# Test\n\n\nNo service layer needed.\n'; - const result = await renderMarkdown(md); - expect(result.html).toContain('doc-callout'); - expect(result.html).toContain('doc-callout--tip'); - expect(result.html).toContain('No service layer needed.'); - }); - - it('renders Note callout blocks', async () => { - const md = '# Test\n\n\nInjection context required.\n'; - const result = await renderMarkdown(md); - expect(result.html).toContain('doc-callout--note'); - }); - - it('renders Warning callout blocks', async () => { - const md = '# Test\n\n\nDo not expose API keys.\n'; - const result = await renderMarkdown(md); - expect(result.html).toContain('doc-callout--warning'); - }); - - it('renders Steps with numbered step indicators', async () => { - const md = - '# Test\n\n\n\n\nDo this first.\n\n\n\n\nThen this.\n\n\n'; - const result = await renderMarkdown(md); - expect(result.html).toContain('doc-steps'); - expect(result.html).toContain('doc-step'); - expect(result.html).toContain('First step'); - expect(result.html).toContain('Second step'); - expect(result.html).toContain('doc-step__number'); - }); - - it('renders Prompt blocks with copy button', async () => { - const md = - '# Test\n\n\nAdd streaming to this component.\n'; - const result = await renderMarkdown(md); - expect(result.html).toContain('doc-prompt'); - expect(result.html).toContain('Add streaming to this component.'); - expect(result.html).toContain('data-copy-prompt'); - }); - - it('renders Related blocks as markdown link lists', async () => { - const md = - '# Test\n\n\n- [Chat Messages](/chat/core-capabilities/messages/overview/python) - Learn how messages render\n'; - const result = await renderMarkdown(md); - expect(result.html).toContain('doc-related'); - expect(result.html).toContain('
    '); - expect(result.html).toContain( - 'Chat Messages' - ); - expect(result.html).not.toContain('- [Chat Messages]'); - }); - - it('renders ApiTable blocks as styled tables', async () => { - const md = - '# Test\n\n\n| Signal | Type |\n|--------|------|\n| `messages()` | `BaseMessage[]` |\n'; - const result = await renderMarkdown(md); - expect(result.html).toContain('doc-api-table'); - expect(result.html).toContain('messages()'); - }); - - it('wraps code blocks with filename header when first line is a comment', async () => { - mockCodeToHtml.mockResolvedValue( - '
    code
    ' - ); - const md = '# Test\n\n```typescript\n// app.config.ts\nconst x = 1;\n```'; - const result = await renderMarkdown(md); - expect(result.html).toContain('doc-codeblock__header'); - expect(result.html).toContain('app.config.ts'); - expect(result.html).toContain('data-copy-code'); - }); -}); diff --git a/libs/cockpit-shell/src/lib/render-markdown.ts b/libs/cockpit-shell/src/lib/render-markdown.ts deleted file mode 100644 index d83cecd7b..000000000 --- a/libs/cockpit-shell/src/lib/render-markdown.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { marked } from 'marked'; -import { codeToHtml } from 'shiki'; - -export interface RenderedMarkdown { - title: string; - html: string; -} - -interface ExtractedBlock { - placeholder: string; - type: string; - content: string; - attrs: Record; -} - -const COMPONENT_TAGS = [ - 'Summary', - 'Tip', - 'Note', - 'Warning', - 'Prompt', - 'ApiTable', - 'Related', - 'Step', - 'Steps', -]; - -function extractComponentTags(source: string): { - cleaned: string; - blocks: ExtractedBlock[]; -} { - const blocks: ExtractedBlock[] = []; - let cleaned = source; - let idx = 0; - - for (const tag of COMPONENT_TAGS) { - const pattern = new RegExp(`<${tag}(\\s[^>]*)?>([\\s\\S]*?)`, 'g'); - cleaned = cleaned.replace(pattern, (_match, rawAttrs, content) => { - const placeholder = ``; - 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 10c00f39b..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,8 +181,10 @@ 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;', + }); }); it('returns a placeholder string when a code file is missing', async () => { @@ -207,7 +201,6 @@ describe('getContentBundle', () => { promptAssetPaths: [], codeAssetPaths: ['missing/file.ts'], backendAssetPaths: [], - docsAssetPaths: [], runtimeUrl: undefined, devPort: undefined, }; @@ -219,7 +212,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 () => { @@ -233,7 +226,6 @@ describe('getContentBundle', () => { promptAssetPaths: [], codeAssetPaths: ['some/file.ts'], backendAssetPaths: [], - docsAssetPaths: [], runtimeUrl: undefined, devPort: undefined, }; @@ -244,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 () => { @@ -260,9 +251,9 @@ 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({}); }); it('extracts docSections from code and backend files', async () => { @@ -286,7 +277,6 @@ describe('getContentBundle', () => { promptAssetPaths: ['prompts/streaming.md'], codeAssetPaths: ['src/streaming.component.ts'], backendAssetPaths: ['src/graph.py'], - docsAssetPaths: [], runtimeUrl: undefined, devPort: undefined, }; @@ -299,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'); }); @@ -314,7 +303,6 @@ describe('getContentBundle', () => { promptAssetPaths: ['missing/prompt.md'], codeAssetPaths: [], backendAssetPaths: [], - docsAssetPaths: ['missing/guide.md'], }; const bundle = await getContentBundle(presentation); @@ -322,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 () => { @@ -333,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); @@ -345,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 () => { @@ -370,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); @@ -385,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 0eee512c1..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,18 +26,13 @@ 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. */ + codeSources: Record; promptFiles: Record; runtimeUrl: string | null; docSections: DocSection[]; - narrativeDocs: NarrativeDoc[]; } export function resolveRuntimeUrl(options: { @@ -145,10 +139,10 @@ export async function getContentBundle( if (presentation.kind === 'docs-only') { return { codeFiles: {}, + codeSources: {}, promptFiles: {}, runtimeUrl: null, docSections: [], - narrativeDocs: [], }; } @@ -158,12 +152,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; @@ -190,25 +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, promptFiles, runtimeUrl, docSections, narrativeDocs }; + return { + codeFiles, + codeSources, + promptFiles, + runtimeUrl, + docSections, + }; } 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/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/public-api.spec.tsx b/libs/workspace-react/src/lib/public-api.spec.tsx index c89a37410..1edb26c21 100644 --- a/libs/workspace-react/src/lib/public-api.spec.tsx +++ b/libs/workspace-react/src/lib/public-api.spec.tsx @@ -87,10 +87,10 @@ describe('@threadplane/workspace-react public boundary', () => { }} contentBundle={{ codeFiles: {}, + codeSources: {}, promptFiles: {}, runtimeUrl: null, docSections: [], - narrativeDocs: [], }} routePath={resolution.docsPath} requestedMode="docs" 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 867c0c69f..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, @@ -51,10 +50,10 @@ const presentation: WorkspacePresentation = { const contentBundle: ContentBundle = { codeFiles: { 'example.ts': '
    source
    ' }, + codeSources: { 'example.ts': 'source' }, promptFiles: {}, runtimeUrl: null, docSections: [], - narrativeDocs: [], }; function Readout() { 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 cd0261ffd..6615fdacc 100644 --- a/libs/workspace-react/src/lib/workspace-shell.spec.tsx +++ b/libs/workspace-react/src/lib/workspace-shell.spec.tsx @@ -54,23 +54,16 @@ const presentation: WorkspacePresentation = { promptAssetPaths: [], codeAssetPaths: ['example.ts'], backendAssetPaths: [], - docsAssetPaths: ['guide.md'], runtimeUrl: 'langgraph/streaming', devPort: 4300, runnable: true, }; const contentBundle: ContentBundle = { codeFiles: { 'example.ts': '
    source
    ' }, + codeSources: { 'example.ts': 'source' }, promptFiles: {}, runtimeUrl: 'https://runtime.example.test/demo', docSections: [], - narrativeDocs: [ - { - title: 'Streaming guide', - html: '

    Registry narrative

    ', - sourceFile: 'guide.md', - }, - ], }; function renderWorkspace(options: { @@ -368,11 +361,17 @@ 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(); + // 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/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..820ac886f 100644 --- a/libs/workspace-react/src/styles/workspace.css +++ b/libs/workspace-react/src/styles/workspace.css @@ -7,247 +7,7 @@ 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-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; -} -.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 */ +/* Shared prose layer — API mode content */ .workspace-prose { max-width: 42rem; font-size: 0.9rem;