From 30befb46ada89bcc2c3179956a4a0197de0399a3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 00:26:50 +0000 Subject: [PATCH 01/10] feat(runtime): author rich Markdown blocks as JSX via rsc-markdown-stream Re-export renderToMarkdown/renderToMarkdownStream and add the async MarkdownContent component, which renders JSX children (GFM tables, task lists, nested async components, escaped text) to one Markdown string lowered into Agent.Markdown. The audiobook-curator example authors its DataList/FileList primitives as JSX with byte-identical output and adds a JSX-authored measured-files table to the duplicate analysis. --- .changeset/jsx-markdown-content.md | 9 +++ .../src/components/library-analysis.tsx | 23 +++++++- .../src/components/primitives.tsx | 22 +++++-- .../tests/route-unit/streaming.test.ts | 4 ++ packages/rsc-runtime/README.md | 7 ++- packages/rsc-runtime/package.json | 1 + packages/rsc-runtime/src/index.ts | 7 +++ packages/rsc-runtime/src/markdown-content.ts | 36 ++++++++++++ .../tests/markdown-content.test.ts | 58 +++++++++++++++++++ pnpm-lock.yaml | 14 +++++ 10 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 .changeset/jsx-markdown-content.md create mode 100644 packages/rsc-runtime/src/markdown-content.ts create mode 100644 packages/rsc-runtime/tests/markdown-content.test.ts diff --git a/.changeset/jsx-markdown-content.md b/.changeset/jsx-markdown-content.md new file mode 100644 index 000000000..6631cd17f --- /dev/null +++ b/.changeset/jsx-markdown-content.md @@ -0,0 +1,9 @@ +--- +"@agent-bundle/runtime": minor +--- + +Add the async `MarkdownContent` component and re-export `renderToMarkdown` / +`renderToMarkdownStream` from `rsc-markdown-stream`, so routes author rich +Markdown blocks — GFM tables, task lists, nested async components, escaped +text — as JSX lowered into `Agent.Markdown` instead of hand-concatenated +strings. diff --git a/examples/audiobook-curator/src/components/library-analysis.tsx b/examples/audiobook-curator/src/components/library-analysis.tsx index abbcc10b5..4a6618cd4 100644 --- a/examples/audiobook-curator/src/components/library-analysis.tsx +++ b/examples/audiobook-curator/src/components/library-analysis.tsx @@ -1,6 +1,6 @@ import { stat } from 'node:fs/promises'; -import { Agent } from '@agent-bundle/runtime'; +import { Agent, MarkdownContent } from '@agent-bundle/runtime'; import React from 'react'; import type { LibraryAuditReceipt } from '../library.ts'; @@ -21,6 +21,26 @@ interface MeasuredFile { const errorMessage = (error: unknown): string => error instanceof Error ? error.message : 'File metadata is unavailable.'; +/** JSX-authored GFM table lowered to Markdown by the runtime's renderer. */ +const MeasuredFilesTable = ({ measured }: { readonly measured: readonly MeasuredFile[] }) => ( + + + + + + + {measured.map((file) => ( + + + + + + ))} + +
FileBytesStatus
{file.path}{file.bytes === undefined ? '' : String(file.bytes)}{file.error ?? 'measured'}
+
+); + const measureFiles = async ( files: readonly string[], signal: AbortSignal, @@ -62,6 +82,7 @@ export const LibraryAnalysis = async ({ receipt, signal }: LibraryAnalysisProps) { label: 'Measured files', value: available.length }, { label: 'Reclaimable bytes', value: reclaimableBytes }, ]} /> + {unavailable.length > 0 ? ( <> diff --git a/examples/audiobook-curator/src/components/primitives.tsx b/examples/audiobook-curator/src/components/primitives.tsx index 0d5922eaa..a4ee01a1e 100644 --- a/examples/audiobook-curator/src/components/primitives.tsx +++ b/examples/audiobook-curator/src/components/primitives.tsx @@ -1,4 +1,4 @@ -import { Agent } from '@agent-bundle/runtime'; +import { Agent, MarkdownContent } from '@agent-bundle/runtime'; import React from 'react'; export interface Field { @@ -10,10 +10,18 @@ export interface DataListProps { readonly fields: readonly Field[]; } +const singleLine = (value: Field['value']): string => String(value).replaceAll(/\s*\n\s*/gu, ' '); + export const DataList = ({ fields }: DataListProps) => ( - - {fields.map(({ label, value }) => `- **${label}:** ${String(value).replaceAll(/\s*\n\s*/gu, ' ')}`).join('\n')} - + +
    + {fields.map(({ label, value }) => ( +
  • + {label}: {singleLine(value)} +
  • + ))} +
+
); export interface FileListProps { @@ -21,7 +29,11 @@ export interface FileListProps { } export const FileList = ({ files }: FileListProps) => ( - {files.map((file) => `- ${file}`).join('\n')} + +
    + {files.map((file) =>
  • {file}
  • )} +
+
); export interface CalloutProps { diff --git a/examples/audiobook-curator/tests/route-unit/streaming.test.ts b/examples/audiobook-curator/tests/route-unit/streaming.test.ts index 744cba476..1a58b02eb 100644 --- a/examples/audiobook-curator/tests/route-unit/streaming.test.ts +++ b/examples/audiobook-curator/tests/route-unit/streaming.test.ts @@ -44,6 +44,10 @@ it('streams library analysis after the audit shell while preserving the canonica && documentText(document).includes('"kind":"progress"'))).toBe(true); expectDocument(rendered) .toContainMarkdown('**Reclaimable bytes:** 4') + // The JSX-authored measured-files table lowers to one GFM table block. + .toContainMarkdown('| File | Bytes | Status |') + .toContainMarkdown(`| ${join(library, 'Shared title.flac')} | 15 | measured |`) + .toContainMarkdown(`| ${join(library, 'Shared title.mp3')} | 4 | measured |`) .toContainMarkdown(`- ${join(library, 'Shared title.flac')}\n- ${join(library, 'Shared title.mp3')}`) .toContainContext('Duplicate candidate group') .toHaveValue(rendered.result); diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 3f5af4417..f628eafd4 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -46,7 +46,12 @@ synchronous compatibility APIs remain operative. The package exports `Hook`, `Mcp`, `Agent`, both lowerers, the request-store APIs, the Agent Document contracts, `createAgentRenderDispatcher`, `projectMcpRenderStream`, `createWarmFlightHost`, `decodeAgentFlightStream`, -and the `@agent-bundle/runtime/flight/server` render entry. The Flight-facing versions +and the `@agent-bundle/runtime/flight/server` render entry. Rich Markdown +authoring rides [rsc-markdown-stream](https://github.com/ScriptedAlchemy/rsc-markdown-stream): +`renderToMarkdown` / `renderToMarkdownStream` are re-exported, and the async +`MarkdownContent` component renders JSX children — headings, lists, GFM +tables, task lists, nested async components — to one escaped Markdown string +inside `Agent.Markdown`, replacing hand-concatenated strings in routes. The Flight-facing versions are exact compatibility pins: React/React DOM `19.2.8` and `react-server-dom-rspack` `0.1.0`; the proof example compiles them with `rsbuild-plugin-rsc` `0.1.1`. The package does not own application state, diff --git a/packages/rsc-runtime/package.json b/packages/rsc-runtime/package.json index 873ded83b..8c3091c0c 100644 --- a/packages/rsc-runtime/package.json +++ b/packages/rsc-runtime/package.json @@ -78,6 +78,7 @@ "@modelcontextprotocol/server": "2.0.0", "effect": "4.0.0-rc.112", "react-server-dom-rspack": "0.1.0", + "rsc-markdown-stream": "github:ScriptedAlchemy/rsc-markdown-stream#755c5eea448749ecbf889c090e5a052914fae4e6", "zod": "4.4.3" }, "devDependencies": { diff --git a/packages/rsc-runtime/src/index.ts b/packages/rsc-runtime/src/index.ts index b5b2dfdea..e0e8f153c 100644 --- a/packages/rsc-runtime/src/index.ts +++ b/packages/rsc-runtime/src/index.ts @@ -81,6 +81,13 @@ export type { } from './warm-runtime.js'; export { decodeAgentFlightStream } from './reconciler.js'; export type { AgentFlightDecodeOptions } from './reconciler.js'; +export { MarkdownContent, renderToMarkdown, renderToMarkdownStream } from './markdown-content.js'; +export type { + MarkdownContentProps, + MarkdownOptions, + MarkdownSerializer, + MarkdownSerializerHelpers, +} from './markdown-content.js'; export { lowerHookResult } from './lower-hook.js'; export type { NativePostToolUseOutput } from './lower-hook.js'; export { lowerMcpResult } from './lower-mcp.js'; diff --git a/packages/rsc-runtime/src/markdown-content.ts b/packages/rsc-runtime/src/markdown-content.ts new file mode 100644 index 000000000..3ff9f5a45 --- /dev/null +++ b/packages/rsc-runtime/src/markdown-content.ts @@ -0,0 +1,36 @@ +import { createElement, type ReactElement, type ReactNode } from 'react'; +import { + renderToMarkdown, + renderToMarkdownStream, + type MarkdownOptions, + type MarkdownSerializer, + type MarkdownSerializerHelpers, +} from 'rsc-markdown-stream'; + +import { Agent } from './elements.js'; + +export { renderToMarkdown, renderToMarkdownStream }; +export type { MarkdownOptions, MarkdownSerializer, MarkdownSerializerHelpers }; + +export interface MarkdownContentProps { + /** JSX content rendered to GitHub Flavored Markdown. */ + readonly children: ReactNode; + /** Extra host-tag serializers forwarded to `renderToMarkdown`. */ + readonly components?: MarkdownOptions['components']; +} + +/** + * Renders JSX children — headings, paragraphs, lists, GFM tables, task + * lists, and nested sync or async components — to one GitHub Flavored + * Markdown string through `rsc-markdown-stream`, lowered into + * `Agent.Markdown`. Routes author rich Markdown blocks as JSX instead of + * hand-concatenated strings, with Markdown punctuation in text escaped by + * the renderer. + * + * The rendered block carries no trailing newline: Agent Document + * projections own the blank-line joining between sibling blocks. + */ +export const MarkdownContent = async ({ children, components }: MarkdownContentProps): Promise => { + const markdown = await renderToMarkdown(children, components === undefined ? undefined : { components }); + return createElement(Agent.Markdown, null, markdown.replace(/\n+$/u, '')); +}; diff --git a/packages/rsc-runtime/tests/markdown-content.test.ts b/packages/rsc-runtime/tests/markdown-content.test.ts new file mode 100644 index 000000000..7124a1fcd --- /dev/null +++ b/packages/rsc-runtime/tests/markdown-content.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from '@rstest/core'; +import { createElement } from 'react'; + +import { Agent, MarkdownContent, renderToMarkdown } from '../src/index.js'; + +const e = createElement; + +interface RowProps { + readonly bytes: number; + readonly label: string; +} + +/** An async component: the renderer awaits it exactly like a server component. */ +const Row = async ({ bytes, label }: RowProps) => + e('tr', null, e('td', null, label), e('td', null, String(bytes))); + +const measuredTable = e( + 'table', + null, + e('thead', null, e('tr', null, e('th', null, 'File'), e('th', null, 'Bytes'))), + e( + 'tbody', + null, + e(Row, { bytes: 12, key: 'a', label: 'a.m4b' }), + e(Row, { bytes: 34, key: 'b', label: 'b.m4b' }), + ), +); + +const expectedTable = [ + '| File | Bytes |', + '| --- | --- |', + '| a.m4b | 12 |', + '| b.m4b | 34 |', +].join('\n'); + +describe('markdown content rendering', () => { + it('renders a GFM table from JSX with async row components', async () => { + await expect(renderToMarkdown(measuredTable)).resolves.toBe(`${expectedTable}\n`); + }); + + it('renders GFM task lists and escapes Markdown punctuation in text', async () => { + const tree = e( + 'ul', + null, + e('li', { key: 'done' }, e('input', { checked: true, readOnly: true, type: 'checkbox' }), ' verify *stars*'), + e('li', { key: 'open' }, e('input', { readOnly: true, type: 'checkbox' }), ' review [brackets]'), + ); + await expect(renderToMarkdown(tree)).resolves.toBe( + '- [x] verify \\*stars\\*\n- [ ] review \\[brackets\\]\n', + ); + }); + + it('lowers JSX children into one Agent.Markdown block without a trailing newline', async () => { + const element = await MarkdownContent({ children: measuredTable }); + expect(element.type).toBe(Agent.Markdown); + expect((element.props as { children: string }).children).toBe(expectedTable); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 094830f13..41803fd8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -342,6 +342,9 @@ importers: react-server-dom-rspack: specifier: 0.1.0 version: 0.1.0(@rspack/core@2.2.1(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + rsc-markdown-stream: + specifier: github:ScriptedAlchemy/rsc-markdown-stream#755c5eea448749ecbf889c090e5a052914fae4e6 + version: https://codeload.github.com/ScriptedAlchemy/rsc-markdown-stream/tar.gz/755c5eea448749ecbf889c090e5a052914fae4e6(react@19.2.8) zod: specifier: 4.4.3 version: 4.4.3 @@ -2437,6 +2440,13 @@ packages: '@rsbuild/core': ^2.0.0 react-server-dom-rspack: '*' + rsc-markdown-stream@https://codeload.github.com/ScriptedAlchemy/rsc-markdown-stream/tar.gz/755c5eea448749ecbf889c090e5a052914fae4e6: + resolution: {gitHosted: true, integrity: sha512-nZhiB1Oq0Py1FipM+I3EidXUrKkiYhtpPpiQ1XKI79feBj1FcAJD1Cy1JKpyvp7a53pBDTwTp0keLRW9aKvebw==, tarball: https://codeload.github.com/ScriptedAlchemy/rsc-markdown-stream/tar.gz/755c5eea448749ecbf889c090e5a052914fae4e6} + version: 0.1.0 + engines: {node: '>=18'} + peerDependencies: + react: ^19.0.0 + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -4857,6 +4867,10 @@ snapshots: '@rsbuild/core': 2.2.1 react-server-dom-rspack: 0.1.0(@rspack/core@2.2.1(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + rsc-markdown-stream@https://codeload.github.com/ScriptedAlchemy/rsc-markdown-stream/tar.gz/755c5eea448749ecbf889c090e5a052914fae4e6(react@19.2.8): + dependencies: + react: 19.2.8 + run-applescript@7.1.0: {} run-parallel@1.2.0: From 85c573b59ea8d8ee71453a8024f7e6ad15e6fd76 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 07:02:11 +0000 Subject: [PATCH 02/10] Pin rsc-markdown-stream fidelity fixes and prove Markdown output end to end Bumps the commit pin to eba2ea0, which fixes output a GFM parser read differently from the authored tree (edge
as a literal backslash, sibling lists merging, entity-like text decoding, task checkboxes glued to labels, trailing # in headings, dl/dt/dd gluing, dropped captions, leaked style/script bodies, em-in-em becoming strong, Activity throwing). Tests now describe what a reader sees rather than which characters were emitted: the MarkdownContent suite renders every supported element and round-trips the result through micromark + GFM (the parser under the Workbench's react-markdown/remark-gfm), and a new integration test runs MarkdownContent through a real react-server Flight render of the built package, decoding the wire back into an Agent Document. --- .changeset/jsx-markdown-content.md | 5 +- packages/rsc-runtime/package.json | 4 +- .../fixtures/markdown-content-flight.mjs | 59 +++++ .../tests/markdown-content-flight.test.ts | 68 ++++++ .../tests/markdown-content.test.ts | 221 +++++++++++++++++- pnpm-lock.yaml | 16 +- rstest.integration-tests.ts | 1 + 7 files changed, 362 insertions(+), 12 deletions(-) create mode 100644 packages/rsc-runtime/tests/fixtures/markdown-content-flight.mjs create mode 100644 packages/rsc-runtime/tests/markdown-content-flight.test.ts diff --git a/.changeset/jsx-markdown-content.md b/.changeset/jsx-markdown-content.md index 6631cd17f..1e42b6d4d 100644 --- a/.changeset/jsx-markdown-content.md +++ b/.changeset/jsx-markdown-content.md @@ -6,4 +6,7 @@ Add the async `MarkdownContent` component and re-export `renderToMarkdown` / `renderToMarkdownStream` from `rsc-markdown-stream`, so routes author rich Markdown blocks — GFM tables, task lists, nested async components, escaped text — as JSX lowered into `Agent.Markdown` instead of hand-concatenated -strings. +strings. The rendered Markdown is verified against a GFM parser: sibling +lists stay separate, hard breaks never leave a literal backslash, entity-like +text (`&`) and trailing `#` in headings render literally, and +`style`/`script` bodies never leak into output. diff --git a/packages/rsc-runtime/package.json b/packages/rsc-runtime/package.json index 5399e7a9b..a1ca96e01 100644 --- a/packages/rsc-runtime/package.json +++ b/packages/rsc-runtime/package.json @@ -78,7 +78,7 @@ "@modelcontextprotocol/server": "2.0.0", "effect": "4.0.0-rc.112", "react-server-dom-rspack": "0.1.0", - "rsc-markdown-stream": "github:ScriptedAlchemy/rsc-markdown-stream#755c5eea448749ecbf889c090e5a052914fae4e6", + "rsc-markdown-stream": "github:ScriptedAlchemy/rsc-markdown-stream#eba2ea0b930493b80b9f4f9bb2c582041b0a3f47", "zod": "4.5.4" }, "devDependencies": { @@ -88,6 +88,8 @@ "@rstest/core": "0.11.10", "@types/react": "19.2.18", "effect-rstest": "https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f", + "micromark": "4.0.2", + "micromark-extension-gfm": "3.0.0", "react": "19.2.8", "react-dom": "19.2.8" } diff --git a/packages/rsc-runtime/tests/fixtures/markdown-content-flight.mjs b/packages/rsc-runtime/tests/fixtures/markdown-content-flight.mjs new file mode 100644 index 000000000..2577b2a7d --- /dev/null +++ b/packages/rsc-runtime/tests/fixtures/markdown-content-flight.mjs @@ -0,0 +1,59 @@ +// Renders MarkdownContent inside a real Flight request under the +// react-server condition (spawned with --conditions=react-server) and writes +// the RSC wire bytes to stdout. Imports the BUILT package from dist/ so the +// proof covers the published module graph, not the TypeScript sources. +globalThis.__rspack_rsc_manifest__ = Object.freeze({ + clientManifest: Object.freeze({}), + moduleLoading: null, + serverConsumerModuleMap: null, + serverManifest: Object.freeze({}), +}); + +import { Readable } from 'node:stream'; + +import { createElement as e, Fragment } from 'react'; +import { renderToReadableStream } from 'react-server-dom-rspack/server.node'; + +import { MarkdownContent } from '../../dist/index.js'; + +/** An async server component inside the Markdown tree, resolved by the renderer. */ +const Row = async ({ bytes, label }) => { + await new Promise((resolve) => setTimeout(resolve, 5)); + return e('tr', null, e('td', null, label), e('td', null, String(bytes))); +}; + +const model = e( + 'agent-result', + null, + e('agent-markdown', null, '# Audit'), + e( + MarkdownContent, + null, + e( + Fragment, + null, + e('p', null, 'Measured ', e('strong', null, '2 files'), ' with *literal stars*.'), + e( + 'table', + null, + e('thead', null, e('tr', null, e('th', null, 'File'), e('th', { align: 'right' }, 'Bytes'))), + e( + 'tbody', + null, + e(Row, { bytes: 12, key: 'a', label: 'a.m4b' }), + e(Row, { bytes: 34, key: 'b', label: 'b.m4b' }), + ), + ), + e('ul', null, e('li', null, e('input', { checked: true, readOnly: true, type: 'checkbox' }), ' verified')), + ), + ), +); + +const flight = renderToReadableStream(model, { + onError: (error) => (error instanceof Error ? error.message : 'error'), +}); +const output = Readable.fromWeb(flight); +output.pipe(process.stdout); +output.on('end', () => { + process.exit(0); +}); diff --git a/packages/rsc-runtime/tests/markdown-content-flight.test.ts b/packages/rsc-runtime/tests/markdown-content-flight.test.ts new file mode 100644 index 000000000..a468aa18d --- /dev/null +++ b/packages/rsc-runtime/tests/markdown-content-flight.test.ts @@ -0,0 +1,68 @@ +import { spawn } from 'node:child_process'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from '@rstest/core'; + +import { createAgentRenderDispatcher, type AgentFlightExecutionHost } from '../src/index.js'; + +/** + * `MarkdownContent` is an async server component that runs the Markdown + * renderer inside React's Flight request. This spawns the BUILT package + * (dist/, prebuilt by the integration pool's root build) under the + * react-server condition — the module graph agent-bundle routes execute in — + * and decodes the wire bytes back into an Agent Document, proving the JSX + * tree lowers to one `markdown` node with the expected text end to end. + */ + +const packageRoot = fileURLToPath(new URL('..', import.meta.url)); +const fixture = join(packageRoot, 'tests', 'fixtures', 'markdown-content-flight.mjs'); + +const flightHost: AgentFlightExecutionHost = { + async execute(request) { + const child = spawn(process.execPath, ['--conditions=react-server', fixture], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stderr: Buffer[] = []; + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + child.on('exit', (code) => { + if (code !== 0 && code !== null) { + process.stderr.write(`markdown-content-flight fixture exited ${code}\n${Buffer.concat(stderr).toString('utf8')}`); + } + }); + request.signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true }); + return Readable.toWeb(child.stdout) as ReadableStream; + }, +}; + +describe('MarkdownContent through a react-server Flight render', () => { + it('lowers a JSX Markdown tree with async components to one markdown node', async () => { + const dispatcher = createAgentRenderDispatcher(flightHost); + const document = await dispatcher.dispatch({ + invocation: { kind: 'tool', props: { input: {}, operationId: 'audit' } }, + signal: new AbortController().signal, + }); + + expect(document.status).toBe('success'); + expect(document.root).toMatchObject({ + children: [ + { kind: 'markdown', text: '# Audit' }, + { + kind: 'markdown', + text: [ + 'Measured **2 files** with \\*literal stars\\*.', + '', + '| File | Bytes |', + '| --- | ---: |', + '| a.m4b | 12 |', + '| b.m4b | 34 |', + '', + '- [x] verified', + ].join('\n'), + }, + ], + kind: 'result', + }); + }); +}); diff --git a/packages/rsc-runtime/tests/markdown-content.test.ts b/packages/rsc-runtime/tests/markdown-content.test.ts index 7124a1fcd..6ba329b75 100644 --- a/packages/rsc-runtime/tests/markdown-content.test.ts +++ b/packages/rsc-runtime/tests/markdown-content.test.ts @@ -1,10 +1,28 @@ import { describe, expect, it } from '@rstest/core'; -import { createElement } from 'react'; +import { micromark } from 'micromark'; +import { gfm, gfmHtml } from 'micromark-extension-gfm'; +import { createElement, Fragment, use, type ReactNode } from 'react'; -import { Agent, MarkdownContent, renderToMarkdown } from '../src/index.js'; +import { Agent, MarkdownContent, renderToMarkdown, renderToMarkdownStream } from '../src/index.js'; const e = createElement; +/** + * Parses Markdown the way the Workbench displays it (`react-markdown` + + * `remark-gfm` sit on micromark), so assertions describe what a reader + * sees, not just which characters were emitted. + */ +const toHtml = (markdown: string): string => + micromark(markdown, { extensions: [gfm()], htmlExtensions: [gfmHtml()] }); + +const markdownOf = async (children: ReactNode): Promise => { + const element = await MarkdownContent({ children }); + expect(element.type).toBe(Agent.Markdown); + return (element.props as { children: string }).children; +}; + +const count = (haystack: string, needle: string): number => haystack.split(needle).length - 1; + interface RowProps { readonly bytes: number; readonly label: string; @@ -33,6 +51,91 @@ const expectedTable = [ '| b.m4b | 34 |', ].join('\n'); +/** Every element the runtime documents as supported, in one tree. */ +const kitchenSink = e( + Fragment, + null, + e('h1', null, 'Library audit'), + e( + 'p', + null, + 'Scanned ', + e('strong', null, '2 files'), + ' in ', + e('em', null, '1 source'), + '; ', + e('del', null, '3'), + ' 2 groups, see ', + e('a', { href: 'https://example.test/docs', title: 'Docs' }, 'the docs'), + ' and ', + e('code', null, 'audit --fix'), + '.', + ), + e('h2', null, 'Findings'), + e( + 'ul', + null, + e('li', { key: 'dup' }, 'Duplicates', e('ul', null, e('li', { key: 'a' }, 'Shared title.flac'), e('li', { key: 'b' }, 'Shared title.mp3'))), + e('li', { key: 'multi' }, 'Multipart'), + ), + e('ol', { start: 3 }, e('li', { key: 'a' }, 'third'), e('li', { key: 'b' }, 'fourth')), + e( + 'ul', + null, + e('li', { key: 'done' }, e('input', { checked: true, readOnly: true, type: 'checkbox' }), ' verify'), + e('li', { key: 'open' }, e('input', { readOnly: true, type: 'checkbox' }), ' review'), + ), + e('pre', null, e('code', { className: 'language-sh' }, 'agent-bundle audit --fix\n')), + e('blockquote', null, e('p', null, 'Quoted ', e('em', null, 'note'), '.')), + e( + 'table', + null, + e('caption', null, 'Measured files'), + e('thead', null, e('tr', null, e('th', null, 'File'), e('th', { align: 'right' }, 'Bytes'), e('th', { align: 'center' }, 'Status'))), + e('tbody', null, e('tr', null, e('td', null, 'a | b.m4b'), e('td', null, '12'), e('td', null, 'measured'))), + ), + e('hr'), + e('div', null, e('section', null, e('p', null, 'Nested container text.'))), + e('img', { alt: 'Cover art', src: '/cover.png' }), +); + +const expectedKitchenSink = [ + '# Library audit', + '', + 'Scanned **2 files** in *1 source*; ~~3~~ 2 groups, see [the docs](https://example.test/docs "Docs") and `audit --fix`.', + '', + '## Findings', + '', + '- Duplicates', + ' - Shared title.flac', + ' - Shared title.mp3', + '- Multipart', + '', + '3. third', + '4. fourth', + '', + '- [x] verify', + '- [ ] review', + '', + '```sh', + 'agent-bundle audit --fix', + '```', + '', + '> Quoted *note*.', + '', + 'Measured files', + '', + '| File | Bytes | Status |', + '| --- | ---: | :---: |', + '| a \\| b.m4b | 12 | measured |', + '', + '---', + '', + 'Nested container text.', + '', + '![Cover art](/cover.png)', +].join('\n'); + describe('markdown content rendering', () => { it('renders a GFM table from JSX with async row components', async () => { await expect(renderToMarkdown(measuredTable)).resolves.toBe(`${expectedTable}\n`); @@ -51,8 +154,116 @@ describe('markdown content rendering', () => { }); it('lowers JSX children into one Agent.Markdown block without a trailing newline', async () => { - const element = await MarkdownContent({ children: measuredTable }); - expect(element.type).toBe(Agent.Markdown); - expect((element.props as { children: string }).children).toBe(expectedTable); + await expect(markdownOf(measuredTable)).resolves.toBe(expectedTable); + }); + + it('renders every supported element and a GFM parser reads the same structure back', async () => { + const markdown = await markdownOf(kitchenSink); + expect(markdown).toBe(expectedKitchenSink); + + const html = toHtml(markdown); + expect(html).toContain('

Library audit

'); + expect(html).toContain('Scanned 2 files in 1 source; 3 2 groups'); + expect(html).toContain('the docs'); + expect(html).toContain('audit --fix'); + expect(html).toContain('

Findings

'); + expect(html).toContain('
  • Duplicates\n
      \n
    • Shared title.flac
    • \n
    • Shared title.mp3
    • \n
    \n
  • '); + expect(html).toContain('
      \n
    1. third
    2. \n
    3. fourth
    4. \n
    '); + expect(html).toContain('
  • verify
  • '); + expect(html).toContain('
  • review
  • '); + expect(html).toContain('
    agent-bundle audit --fix\n
    '); + expect(html).toContain('
    \n

    Quoted note.

    \n
    '); + expect(html).toContain('

    Measured files

    '); + expect(html).toContain('Bytes'); + expect(html).toContain('Status'); + expect(html).toContain('a | b.m4b'); + expect(html).toContain('
    '); + expect(html).toContain('

    Nested container text.

    '); + expect(html).toContain('Cover art'); + expect(count(html, '')).toBe(1); + expect(count(html, '
      ')).toBe(3); + expect(count(html, ']/u); + }); + + it('keeps hostile text literal: no accidental emphasis, links, entities, or block markers', async () => { + const markdown = await markdownOf( + e( + Fragment, + null, + e('p', null, '*stars* _under_ snake_case [x] & ~~tilde~~ `tick`'), + e('p', null, '- not a bullet'), + e('p', null, '# not a heading'), + e('p', null, '1. not an item'), + e('p', null, '> not a quote'), + e('p', null, '---'), + ), + ); + expect(toHtml(markdown)).toBe( + [ + '

      *stars* _under_ snake_case [x] <tag> &amp; ~~tilde~~ `tick`

      ', + '

      - not a bullet

      ', + '

      # not a heading

      ', + '

      1. not an item

      ', + '

      > not a quote

      ', + '

      ---

      ', + ].join('\n'), + ); + }); + + it('keeps sibling lists apart, drops edge hard breaks, and preserves trailing # in headings', async () => { + const markdown = await markdownOf( + e( + Fragment, + null, + e('ul', null, e('li', null, 'a')), + e('ul', null, e('li', null, 'b')), + e('p', null, 'line', e('br')), + e('h2', null, 'Chapter 1 #'), + ), + ); + expect(markdown).toBe('- a\n\n* b\n\nline\n\n## Chapter 1 \\#'); + expect(toHtml(markdown)).toBe( + '
        \n
      • a
      • \n
      \n
        \n
      • b
      • \n
      \n

      line

      \n

      Chapter 1 #

      ', + ); + }); + + it('resolves async components and React.use inside the content', async () => { + const data = Promise.resolve(['one', 'two']); + const UsesData = () => { + const items = use(data); + return e('ul', null, items.map((item) => e('li', { key: item }, item))); + }; + const AsyncNote = async () => { + await Promise.resolve(); + return e('p', null, 'async note'); + }; + await expect(markdownOf(e(Fragment, null, e(AsyncNote), e(UsesData)))).resolves.toBe( + 'async note\n\n- one\n- two', + ); + }); + + it('forwards custom host-tag serializers', async () => { + const element = await MarkdownContent({ + children: e('callout', { kind: 'NOTE' }, 'Mind the ', e('b', null, 'gap'), '.'), + components: { + callout: (props, { inline }) => `> [!${String(props['kind'])}]\n> ${inline()}`, + }, + }); + expect((element.props as { children: string }).children).toBe('> [!NOTE]\n> Mind the **gap**.'); + }); + + it('streams the same Markdown block by block', async () => { + const chunks: string[] = []; + const decoder = new TextDecoder(); + for await (const chunk of renderToMarkdownStream(kitchenSink)) { + chunks.push(decoder.decode(chunk, { stream: true })); + } + expect(chunks.length).toBeGreaterThan(3); + expect(chunks.join('')).toBe(`${expectedKitchenSink}\n`); + }); + + it('renders an empty block for empty content', async () => { + await expect(markdownOf(null)).resolves.toBe(''); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea80650f7..7f50539ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -346,8 +346,8 @@ importers: specifier: 0.1.0 version: 0.1.0(@rspack/core@2.2.1(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) rsc-markdown-stream: - specifier: github:ScriptedAlchemy/rsc-markdown-stream#755c5eea448749ecbf889c090e5a052914fae4e6 - version: https://codeload.github.com/ScriptedAlchemy/rsc-markdown-stream/tar.gz/755c5eea448749ecbf889c090e5a052914fae4e6(react@19.2.8) + specifier: github:ScriptedAlchemy/rsc-markdown-stream#eba2ea0b930493b80b9f4f9bb2c582041b0a3f47 + version: https://codeload.github.com/ScriptedAlchemy/rsc-markdown-stream/tar.gz/eba2ea0b930493b80b9f4f9bb2c582041b0a3f47(react@19.2.8) zod: specifier: 4.5.4 version: 4.5.4 @@ -370,6 +370,12 @@ importers: effect-rstest: specifier: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f version: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f(@rstest/core@0.11.10)(effect@4.0.0-rc.112) + micromark: + specifier: 4.0.2 + version: 4.0.2(supports-color@7.2.0) + micromark-extension-gfm: + specifier: 3.0.0 + version: 3.0.0 react: specifier: 19.2.8 version: 19.2.8 @@ -2452,8 +2458,8 @@ packages: '@rsbuild/core': ^2.0.0 react-server-dom-rspack: '*' - rsc-markdown-stream@https://codeload.github.com/ScriptedAlchemy/rsc-markdown-stream/tar.gz/755c5eea448749ecbf889c090e5a052914fae4e6: - resolution: {gitHosted: true, integrity: sha512-nZhiB1Oq0Py1FipM+I3EidXUrKkiYhtpPpiQ1XKI79feBj1FcAJD1Cy1JKpyvp7a53pBDTwTp0keLRW9aKvebw==, tarball: https://codeload.github.com/ScriptedAlchemy/rsc-markdown-stream/tar.gz/755c5eea448749ecbf889c090e5a052914fae4e6} + rsc-markdown-stream@https://codeload.github.com/ScriptedAlchemy/rsc-markdown-stream/tar.gz/eba2ea0b930493b80b9f4f9bb2c582041b0a3f47: + resolution: {gitHosted: true, integrity: sha512-lcNhSQSZZtAqZyArvaeBB/VraOr2kyresHsPj8DimmwtN7A08rUgJ5y6Hz4PE8m8WSZRrJpWpaWnZZynSysRDA==, tarball: https://codeload.github.com/ScriptedAlchemy/rsc-markdown-stream/tar.gz/eba2ea0b930493b80b9f4f9bb2c582041b0a3f47} version: 0.1.0 engines: {node: '>=18'} peerDependencies: @@ -4885,7 +4891,7 @@ snapshots: '@rsbuild/core': 2.2.1 react-server-dom-rspack: 0.1.0(@rspack/core@2.2.1(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - rsc-markdown-stream@https://codeload.github.com/ScriptedAlchemy/rsc-markdown-stream/tar.gz/755c5eea448749ecbf889c090e5a052914fae4e6(react@19.2.8): + rsc-markdown-stream@https://codeload.github.com/ScriptedAlchemy/rsc-markdown-stream/tar.gz/eba2ea0b930493b80b9f4f9bb2c582041b0a3f47(react@19.2.8): dependencies: react: 19.2.8 diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 6ec14e2a6..9bcbdcb3c 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -59,6 +59,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/target-hook-contract.test.ts', 'packages/agent-bundle/tests/target-mcp-runtime.test.ts', 'packages/agent-bundle/tests/worktree-proximity-journeys.test.ts', + 'packages/rsc-runtime/tests/markdown-content-flight.test.ts', 'packages/rsc-runtime/tests/notices-sqlite-cross-process.test.ts', 'packages/rsc-runtime/tests/state-packaging.test.ts', 'packages/rsc-runtime/tests/state-sqlite-cross-process.test.ts', From 281e7954cd95bae9e2b4c113824a949b328c4ed6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 19:15:02 +0000 Subject: [PATCH 03/10] Bundle rsc-markdown-stream instead of installing it from git `rsc-markdown-stream` is not published to npm, and npm 12 refuses git dependencies by default (`allow-git=none` -> EALLOWGIT), so a git-pinned entry in `@agent-bundle/runtime`'s `dependencies` would fail every consumer's install -- the AB7015 class the prepack gate rejects. Move the pin to `devDependencies` so Rslib's `autoExternal` inlines it into `dist/index.js` (react stays external), and declare the public Markdown types in `markdown-content.ts` so no emitted `.d.ts` imports a module consumers never install; the typed assignments of `renderToMarkdown` / `renderToMarkdownStream` stop compiling if the pinned upstream drifts. The changeset is now a `patch` (pre-1.0: features are patches) and ends with the PR reference. --- .changeset/jsx-markdown-content.md | 17 +++---- packages/rsc-runtime/README.md | 6 ++- packages/rsc-runtime/package.json | 4 +- packages/rsc-runtime/src/markdown-content.ts | 51 +++++++++++++++++--- pnpm-lock.yaml | 6 +-- 5 files changed, 61 insertions(+), 23 deletions(-) diff --git a/.changeset/jsx-markdown-content.md b/.changeset/jsx-markdown-content.md index 1e42b6d4d..5014c5955 100644 --- a/.changeset/jsx-markdown-content.md +++ b/.changeset/jsx-markdown-content.md @@ -1,12 +1,11 @@ --- -"@agent-bundle/runtime": minor +"@agent-bundle/runtime": patch --- -Add the async `MarkdownContent` component and re-export `renderToMarkdown` / -`renderToMarkdownStream` from `rsc-markdown-stream`, so routes author rich -Markdown blocks — GFM tables, task lists, nested async components, escaped -text — as JSX lowered into `Agent.Markdown` instead of hand-concatenated -strings. The rendered Markdown is verified against a GFM parser: sibling -lists stay separate, hard breaks never leave a literal backslash, entity-like -text (`&`) and trailing `#` in headings render literally, and -`style`/`script` bodies never leak into output. +Add the async `MarkdownContent` component and the `renderToMarkdown` / +`renderToMarkdownStream` exports to `@agent-bundle/runtime`, so routes author +rich Markdown blocks — headings, lists, GFM tables, task lists, nested async +components, escaped text — as JSX lowered into `Agent.Markdown` instead of +hand-concatenated strings. The renderer (`rsc-markdown-stream`) is bundled +into the package build, so installing `@agent-bundle/runtime` adds no git +dependency. (#344) diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index e8108ddac..961ecf28d 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -60,8 +60,10 @@ The package exports `Hook`, `Mcp`, `Agent`, both lowerers, the request-store APIs, the Agent Document contracts, `createAgentRenderDispatcher`, `projectMcpRenderStream`, `createWarmFlightHost`, `decodeAgentFlightStream`, and the `@agent-bundle/runtime/flight/server` render entry. Rich Markdown -authoring rides [rsc-markdown-stream](https://github.com/ScriptedAlchemy/rsc-markdown-stream): -`renderToMarkdown` / `renderToMarkdownStream` are re-exported, and the async +authoring rides [rsc-markdown-stream](https://github.com/ScriptedAlchemy/rsc-markdown-stream), +bundled into the package build (it is not published to npm, so the package +installs with no git dependency): `renderToMarkdown` / +`renderToMarkdownStream` are re-exported, and the async `MarkdownContent` component renders JSX children — headings, lists, GFM tables, task lists, nested async components — to one escaped Markdown string inside `Agent.Markdown`, replacing hand-concatenated strings in routes. The Flight-facing versions diff --git a/packages/rsc-runtime/package.json b/packages/rsc-runtime/package.json index 117d830d1..3941a8b92 100644 --- a/packages/rsc-runtime/package.json +++ b/packages/rsc-runtime/package.json @@ -85,7 +85,6 @@ "effect": "4.0.0-rc.112", "flare-redact": "1.6.1", "react-server-dom-rspack": "0.1.0", - "rsc-markdown-stream": "github:ScriptedAlchemy/rsc-markdown-stream#eba2ea0b930493b80b9f4f9bb2c582041b0a3f47", "zod": "4.5.4" }, "devDependencies": { @@ -98,6 +97,7 @@ "micromark": "4.0.2", "micromark-extension-gfm": "3.0.0", "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "rsc-markdown-stream": "github:ScriptedAlchemy/rsc-markdown-stream#eba2ea0b930493b80b9f4f9bb2c582041b0a3f47" } } diff --git a/packages/rsc-runtime/src/markdown-content.ts b/packages/rsc-runtime/src/markdown-content.ts index 3ff9f5a45..79bcd8f38 100644 --- a/packages/rsc-runtime/src/markdown-content.ts +++ b/packages/rsc-runtime/src/markdown-content.ts @@ -1,16 +1,53 @@ import { createElement, type ReactElement, type ReactNode } from 'react'; import { - renderToMarkdown, - renderToMarkdownStream, - type MarkdownOptions, - type MarkdownSerializer, - type MarkdownSerializerHelpers, + renderToMarkdown as renderToMarkdownImpl, + renderToMarkdownStream as renderToMarkdownStreamImpl, } from 'rsc-markdown-stream'; import { Agent } from './elements.js'; -export { renderToMarkdown, renderToMarkdownStream }; -export type { MarkdownOptions, MarkdownSerializer, MarkdownSerializerHelpers }; +// `rsc-markdown-stream` is not published to npm, so it is a devDependency that +// Rslib inlines into this package's build (`autoExternal` bundles +// devDependencies) and consumers never install it. Its contract is therefore +// declared here instead of re-exported from a module the emitted `.d.ts` +// could not resolve; the typed assignments of `renderToMarkdown` and +// `renderToMarkdownStream` below stop compiling if the pinned upstream commit +// drifts from these declarations. + +/** Helpers handed to a {@link MarkdownSerializer} for rendering the element's children. */ +export interface MarkdownSerializerHelpers { + /** Render the element's children as inline Markdown. */ + inline(): string; + /** Render the element's children as block Markdown (blocks joined by blank lines). */ + blocks(): string; +} + +/** + * Serializes one host element to Markdown. The returned string is emitted as + * its own block; return `null`, `undefined`, or `''` to emit nothing. + */ +export type MarkdownSerializer = ( + props: Record, + helpers: MarkdownSerializerHelpers, +) => string | null | undefined; + +export interface MarkdownOptions { + /** Extra host tag names mapped to Markdown serializers. Overrides built-ins. */ + readonly components?: Record; +} + +/** Renders a React node tree to one GitHub Flavored Markdown string. */ +export const renderToMarkdown: (children: ReactNode, options?: MarkdownOptions) => Promise = + renderToMarkdownImpl; + +/** + * Renders a React node tree to a `ReadableStream` of UTF-8 + * Markdown text; blocks are emitted as they resolve, in document order. + */ +export const renderToMarkdownStream: ( + children: ReactNode, + options?: MarkdownOptions, +) => ReadableStream = renderToMarkdownStreamImpl; export interface MarkdownContentProps { /** JSX content rendered to GitHub Flavored Markdown. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 349d0354e..ce638d26c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -379,9 +379,6 @@ importers: react-server-dom-rspack: specifier: 0.1.0 version: 0.1.0(@rspack/core@2.2.1(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - rsc-markdown-stream: - specifier: github:ScriptedAlchemy/rsc-markdown-stream#eba2ea0b930493b80b9f4f9bb2c582041b0a3f47 - version: https://codeload.github.com/ScriptedAlchemy/rsc-markdown-stream/tar.gz/eba2ea0b930493b80b9f4f9bb2c582041b0a3f47(react@19.2.8) zod: specifier: 4.5.4 version: 4.5.4 @@ -416,6 +413,9 @@ importers: react-dom: specifier: 19.2.8 version: 19.2.8(react@19.2.8) + rsc-markdown-stream: + specifier: github:ScriptedAlchemy/rsc-markdown-stream#eba2ea0b930493b80b9f4f9bb2c582041b0a3f47 + version: https://codeload.github.com/ScriptedAlchemy/rsc-markdown-stream/tar.gz/eba2ea0b930493b80b9f4f9bb2c582041b0a3f47(react@19.2.8) packages/workbench: dependencies: From d6dd545be38eb1ebdfdd3c78a31b252219c5f2cf Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 19:15:02 +0000 Subject: [PATCH 04/10] Document MarkdownContent and the Markdown renderer exports Add "Rich Markdown blocks as JSX" to the MCP authoring guide (en + zh): what `MarkdownContent` serializes, escaping, the no-trailing-newline contract, custom `components` serializers, and the exported `renderToMarkdown` / `renderToMarkdownStream`. The audiobook-curator example page notes that its report primitives author Markdown this way. --- .../docs/en/examples/audiobook-curator.mdx | 6 +++ website/docs/en/guide/authoring/mcp.mdx | 53 +++++++++++++++++++ .../docs/zh/examples/audiobook-curator.mdx | 4 ++ website/docs/zh/guide/authoring/mcp.mdx | 48 +++++++++++++++++ 4 files changed, 111 insertions(+) diff --git a/website/docs/en/examples/audiobook-curator.mdx b/website/docs/en/examples/audiobook-curator.mdx index 95f1449d9..852e29c72 100644 --- a/website/docs/en/examples/audiobook-curator.mdx +++ b/website/docs/en/examples/audiobook-curator.mdx @@ -37,6 +37,12 @@ those features, not the build. This example has no hooks. - **Presentation is shared, not duplicated.** `src/components/` is one report library composed by both the MCP routes and the rendered CLI routes, so an MCP tool and its CLI counterpart cannot drift into two presenters. +- **Markdown blocks are JSX.** The library's `DataList` and `FileList` primitives and the + duplicate analysis's measured-files table author their Markdown through the runtime's + [`MarkdownContent`](../guide/authoring/mcp.mdx#rich-markdown-blocks-as-jsx) component — + `
        `/`
      • `, ``, and a GFM `
    ` — instead of hand-concatenated strings, so + escaping belongs to the renderer and piped CLI Markdown and MCP text content come from one + output. - **Request context is observed, not assumed.** The conventional `src/providers/library.ts` probes `ffmpeg -version` and `ffprobe -version` per request and publishes tool availability with the probe time. The catalog resource reads it through `await agent()` and renders either diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index f5740374d..dfde323bb 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -175,6 +175,59 @@ as the second argument of `main` (see [Package entries](./package-entries.mdx#th In tests, the `tty` knob of `invokeCli` and `runScript` shapes a deterministic synthetic value, and `context.terminal` injects any other one through the same seam as every identity axis. +## Rich Markdown blocks as JSX + +`Agent.Markdown` carries one Markdown string, and hand-concatenating that string — one +`- **${label}:** ${value}` per row — is where tables, nested lists, and escaping go wrong. The +async `MarkdownContent` component from `@agent-bundle/runtime` renders its JSX children to one +GitHub Flavored Markdown string and lowers it into `Agent.Markdown`, so a route authors the block +as markup: + +```tsx +import React from 'react'; +import { MarkdownContent } from '@agent-bundle/runtime'; + +interface MeasuredFile { + readonly path: string; + readonly bytes: number; +} + +export const MeasuredFiles = ({ files }: { readonly files: readonly MeasuredFile[] }) => ( + +
    + + + + + {files.map((file) => ( + + ))} + +
    FileBytes
    {file.path}{file.bytes}
    + +); +``` + +Headings `h1`–`h6`, paragraphs, `strong`/`em`/`del`, `code` and `pre`, links and images, +blockquotes, `ul`/`ol` (nested, and `start` on `ol`), GFM task lists (`` +inside `li`), `table` with `align` on its cells, `dt`/`dd`, `br`, and `hr` serialize to their +Markdown forms; unknown elements pass their children through. Sync and async components anywhere +in the children — including ones that call `React.use` — resolve before the block is emitted. +Text is escaped, so literal `*stars*`, `[x]`, ``, `&`, and a line-leading `-`, `#`, +`1.`, or `>` survive as text when the document is parsed again. The rendered block carries no +trailing newline: Agent Document projections own the blank-line joining between sibling blocks, +so `MarkdownContent` sits beside `Agent.Text` and other nodes exactly like a handwritten +`Agent.Markdown`. + +`components` maps extra host tag names to serializers, `(props, { inline, blocks }) => string`, +when a custom element needs its own Markdown — `inline()` and `blocks()` render the element's +children as inline text or as blank-line-joined blocks. The renderer is exported too: +`renderToMarkdown(children, options?)` resolves to the string and +`renderToMarkdownStream(children, options?)` yields a `ReadableStream` of UTF-8 +blocks in document order. Its output is pinned against micromark with the GFM extension — the +parser under the Workbench's `react-markdown`/`remark-gfm` — so what a route authors is the +structure the consumer parses. + ## Streaming and progress A route streams by rendering React `Suspense`: the shell goes out first with the fallback in diff --git a/website/docs/zh/examples/audiobook-curator.mdx b/website/docs/zh/examples/audiobook-curator.mdx index 5be7e59d5..20a1cddbe 100644 --- a/website/docs/zh/examples/audiobook-curator.mdx +++ b/website/docs/zh/examples/audiobook-curator.mdx @@ -31,6 +31,10 @@ description: '有声书策展器示例:一个由路由模块、请求上下文 而可能产生变更的工具需要 `--yes`。 - **表现层是共享的,不是复制的。** `src/components/` 是一份报告组件库,MCP 路由与渲染式 CLI 路由都 组合它,因此一个 MCP 工具和它的 CLI 对应命令不可能漂移成两套表现层。 +- **Markdown 块就是 JSX。** 组件库中的 `DataList`、`FileList` 原语以及重复分析中的已测量文件表格,都通过 + 运行时的 [`MarkdownContent`](../guide/authoring/mcp.mdx#用-jsx-编写富-markdown-块) 组件编写 + Markdown——`
      `/`
    • `、`` 和一个 GFM ``——而不是手工拼接字符串,因此转义归渲染器 + 负责,管道输出的 CLI Markdown 与 MCP 文本内容出自同一份输出。 - **请求上下文是观察出来的,不是假定的。** 约定式的 `src/providers/library.ts` 在每个请求中探测 `ffmpeg -version` 与 `ffprobe -version`,并连同探测时间一起发布工具可用性。目录资源通过 `await agent()` 读取它,并渲染实时上下文或一个显式的不可用状态。 diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 9280934a4..dbeaf6fee 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -158,6 +158,54 @@ interface AgentTerminal { [包入口](./package-entries.mdx#可执行封套))。在测试中,`invokeCli` 与 `runScript` 的 `tty` 开关会塑造 一个确定性的合成值,而 `context.terminal` 则可以像注入任何身份轴一样注入其他取值。 +## 用 JSX 编写富 Markdown 块 + +`Agent.Markdown` 承载一个 Markdown 字符串,而手工拼接这个字符串——每行一个 +`- **${label}:** ${value}`——正是表格、嵌套列表与转义出错的地方。`@agent-bundle/runtime` 导出的异步 +`MarkdownContent` 组件把它的 JSX 子节点渲染为一个 GitHub Flavored Markdown 字符串,并降低为 +`Agent.Markdown`,因此路由可以直接用标记语言编写这个块: + +```tsx +import React from 'react'; +import { MarkdownContent } from '@agent-bundle/runtime'; + +interface MeasuredFile { + readonly path: string; + readonly bytes: number; +} + +export const MeasuredFiles = ({ files }: { readonly files: readonly MeasuredFile[] }) => ( + +
      + + + + + {files.map((file) => ( + + ))} + +
      FileBytes
      {file.path}{file.bytes}
      + +); +``` + +标题 `h1`–`h6`、段落、`strong`/`em`/`del`、`code` 与 `pre`、链接与图片、引用块、`ul`/`ol`(可嵌套, +`ol` 支持 `start`)、GFM 任务列表(`li` 内的 ``)、单元格带 `align` 的 +`table`、`dt`/`dd`、`br` 与 `hr` 都会序列化为对应的 Markdown 形式;未知元素直接透传其子节点。子节点中 +任何位置的同步与异步组件——包括调用 `React.use` 的组件——都会在该块发出前解析完成。文本会被转义,因此 +字面上的 `*stars*`、`[x]`、``、`&`,以及行首的 `-`、`#`、`1.` 或 `>` 在文档被再次解析时仍是 +文本。渲染出的块不带末尾换行:Agent Document 投影负责兄弟块之间的空行连接,所以 `MarkdownContent` 与 +`Agent.Text` 及其他节点并列时,行为与手写的 `Agent.Markdown` 完全一致。 + +当自定义元素需要自己的 Markdown 时,`components` 把额外的宿主标签名映射到序列化器 +`(props, { inline, blocks }) => string`——`inline()` 与 `blocks()` 分别把该元素的子节点渲染为行内文本, +或以空行连接的块。渲染器本身也被导出:`renderToMarkdown(children, options?)` 解析为字符串, +`renderToMarkdownStream(children, options?)` 则按文档顺序产出一个由 UTF-8 块组成的 +`ReadableStream`。其输出以带 GFM 扩展的 micromark——Workbench 的 +`react-markdown`/`remark-gfm` 底层所用的解析器——为基准固定下来,因此路由编写的结构就是消费方解析出的 +结构。 + ## 流式输出与进度 路由通过渲染 React `Suspense` 实现流式输出:外壳(shell)先带着回退内容发出,之后每个解析完成的 From 13089353040d421fc65cf660b1de53e8c6831773 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 19:58:32 +0000 Subject: [PATCH 05/10] Vendor rsc-markdown-stream as a workspace package Copy ScriptedAlchemy/rsc-markdown-stream at eba2ea0b930493b80b9f4f9bb2c582041b0a3f47 into packages/rsc-markdown-stream: the upstream ESM sources and hand-written declarations under src/, built with Rslib to dist/ like the other publishable packages, with the node:test suite ported to rstest (57 tests; the react-server child-process test runs in the integration pool). The package is unpublished on npm, so it is a publishable package of this repository: Apache-2.0 like its siblings, the upstream MIT notice preserved as UPSTREAM-LICENSE and listed in NOTICE. @agent-bundle/runtime depends on it as ^0.1.0, satisfied from the workspace through the pnpm override (a published manifest never carries workspace:), and its re-exports resolve to the package again. Root build, typecheck, preview:publish, and the license sync enumerate the new package. --- .changeset/README.md | 1 + .changeset/jsx-markdown-content.md | 11 +- AGENTS.md | 5 +- NOTICE | 6 + package.json | 6 +- .../tests/license-metadata.test.ts | 2 +- packages/rsc-markdown-stream/README.md | 132 +++++ packages/rsc-markdown-stream/UPSTREAM-LICENSE | 21 + packages/rsc-markdown-stream/package.json | 59 +++ packages/rsc-markdown-stream/rslib.config.ts | 28 ++ packages/rsc-markdown-stream/src/index.d.ts | 34 ++ packages/rsc-markdown-stream/src/index.js | 46 ++ packages/rsc-markdown-stream/src/resolve.js | 350 ++++++++++++++ packages/rsc-markdown-stream/src/serialize.js | 454 ++++++++++++++++++ .../tests/components.test.ts | 185 +++++++ .../tests/fidelity.test.ts | 156 ++++++ .../tests/react-server.test.ts | 52 ++ .../rsc-markdown-stream/tests/render.test.ts | 166 +++++++ .../tests/robustness.test.ts | 179 +++++++ packages/rsc-markdown-stream/tests/types.ts | 22 + packages/rsc-markdown-stream/tsconfig.json | 10 + packages/rsc-runtime/README.md | 5 +- packages/rsc-runtime/package.json | 4 +- packages/rsc-runtime/src/markdown-content.ts | 51 +- pnpm-lock.yaml | 33 +- pnpm-workspace.yaml | 12 +- rstest.integration-tests.ts | 1 + scripts/sync-license-files.mjs | 1 + .../guide/distribution/preview-packages.mdx | 5 +- .../guide/distribution/preview-packages.mdx | 3 +- 30 files changed, 1961 insertions(+), 79 deletions(-) create mode 100644 packages/rsc-markdown-stream/README.md create mode 100644 packages/rsc-markdown-stream/UPSTREAM-LICENSE create mode 100644 packages/rsc-markdown-stream/package.json create mode 100644 packages/rsc-markdown-stream/rslib.config.ts create mode 100644 packages/rsc-markdown-stream/src/index.d.ts create mode 100644 packages/rsc-markdown-stream/src/index.js create mode 100644 packages/rsc-markdown-stream/src/resolve.js create mode 100644 packages/rsc-markdown-stream/src/serialize.js create mode 100644 packages/rsc-markdown-stream/tests/components.test.ts create mode 100644 packages/rsc-markdown-stream/tests/fidelity.test.ts create mode 100644 packages/rsc-markdown-stream/tests/react-server.test.ts create mode 100644 packages/rsc-markdown-stream/tests/render.test.ts create mode 100644 packages/rsc-markdown-stream/tests/robustness.test.ts create mode 100644 packages/rsc-markdown-stream/tests/types.ts create mode 100644 packages/rsc-markdown-stream/tsconfig.json diff --git a/.changeset/README.md b/.changeset/README.md index efaf0773f..63925c4b9 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -13,6 +13,7 @@ versions; `changeset publish` (when enabled) publishes the result. | ----------------------- | ------------------------------ | ---------------------------------------- | | `agent-bundle` | `packages/agent-bundle` | publishable | | `@agent-bundle/runtime` | `packages/rsc-runtime` | publishable | +| `rsc-markdown-stream` | `packages/rsc-markdown-stream` | publishable | | `create-agent-bundle` | `packages/create-agent-bundle` | publishable | | `agent-bundle-workbench`| `packages/workbench` | private, ignored | | `@agent-bundle-example/*`, `@agent-bundle/rsc-agent-runtime-demo` | `examples/*` | private, ignored | diff --git a/.changeset/jsx-markdown-content.md b/.changeset/jsx-markdown-content.md index 5014c5955..2b33df034 100644 --- a/.changeset/jsx-markdown-content.md +++ b/.changeset/jsx-markdown-content.md @@ -1,11 +1,16 @@ --- "@agent-bundle/runtime": patch +"rsc-markdown-stream": patch +"agent-bundle": patch --- Add the async `MarkdownContent` component and the `renderToMarkdown` / `renderToMarkdownStream` exports to `@agent-bundle/runtime`, so routes author rich Markdown blocks — headings, lists, GFM tables, task lists, nested async components, escaped text — as JSX lowered into `Agent.Markdown` instead of -hand-concatenated strings. The renderer (`rsc-markdown-stream`) is bundled -into the package build, so installing `@agent-bundle/runtime` adds no git -dependency. (#344) +hand-concatenated strings. The renderer behind them, `rsc-markdown-stream`, is +now a package of this repository and is published from it (it was previously +only installable from its git URL), so `@agent-bundle/runtime` depends on it +by version. `agent-bundle build` now follows symlinked (workspace) dependencies +transitively when attributing bundle provenance, so a project whose linked +dependency links another package no longer fails with `AB5000`. (#344) diff --git a/AGENTS.md b/AGENTS.md index aaa20e56e..df277720d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,8 +95,9 @@ ## Changesets - Every PR that changes a publishable package (`packages/agent-bundle`, - `packages/rsc-runtime`, `packages/create-agent-bundle` — anything except - `tests/**`) must include exactly one changeset: `pnpm changeset` or a + `packages/rsc-runtime`, `packages/rsc-markdown-stream`, + `packages/create-agent-bundle` — anything except `tests/**`) must include + exactly one changeset: `pnpm changeset` or a hand-written `.changeset/.md`. Private packages (`packages/workbench`, `examples/*`, `website`) are ignored and never named in a changeset. - Pre-1.0 semver: `minor` = breaking, `patch` = everything else (features diff --git a/NOTICE b/NOTICE index 5c429f6cd..a78e8e84f 100644 --- a/NOTICE +++ b/NOTICE @@ -12,6 +12,12 @@ license and notices, which are preserved unmodified alongside that material: src/mcp/APP-RENDERER-LICENSE, which the agent-bundle package ships under dist/workbench/. + - The rsc-markdown-stream package (packages/rsc-markdown-stream) was + imported from https://github.com/ScriptedAlchemy/rsc-markdown-stream at + commit eba2ea0b930493b80b9f4f9bb2c582041b0a3f47, where it was + distributed under the MIT License. That notice is preserved as + UPSTREAM-LICENSE in the package and its published tarball. + The repository's vendored reference checkouts under repos/ are read-only reference material, retain their own upstream licenses, and are not part of any published package. diff --git a/package.json b/package.json index 332483433..36d6b6c22 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "packageManager": "pnpm@11.23.0", "scripts": { - "build": "pnpm --filter @agent-bundle/runtime build && pnpm --filter agent-bundle build && pnpm --filter create-agent-bundle build", + "build": "pnpm --filter rsc-markdown-stream build && pnpm --filter @agent-bundle/runtime build && pnpm --filter agent-bundle build && pnpm --filter create-agent-bundle build", "test": "pnpm test:unit && pnpm test:route-unit && pnpm test:projection && pnpm test:integration", "test:unit": "rstest --config rstest.unit.config.ts", "test:route-unit": "rstest --config rstest.route-unit.config.ts", @@ -21,7 +21,7 @@ "test:watch": "rstest --config rstest.config.ts --watch", "lint": "rslint .", "bench:hook-cold-start": "node scripts/measure-hook-cold-start.mjs", - "typecheck": "tsc --noEmit && tsc --project packages/workbench/tsconfig.json && tsc --project packages/create-agent-bundle/tsconfig.json", + "typecheck": "tsc --noEmit && tsc --project packages/workbench/tsconfig.json && tsc --project packages/create-agent-bundle/tsconfig.json && tsc --project packages/rsc-markdown-stream/tsconfig.json", "check": "pnpm build && pnpm test:unit && pnpm test:route-unit && pnpm test:projection && pnpm test:integration:run && pnpm lint && pnpm typecheck", "check:local-ci": "node scripts/local-ci.mjs", "check:host-cli": "node scripts/host-cli-pins.mjs verify", @@ -46,7 +46,7 @@ "changeset": "changeset", "version-packages": "changeset version", "release": "pnpm check:release && changeset publish", - "preview:publish": "pkg-pr-new publish --previewVersion --peerDeps --no-compact --no-template './packages/agent-bundle' './packages/rsc-runtime' './packages/create-agent-bundle'", + "preview:publish": "pkg-pr-new publish --previewVersion --peerDeps --no-compact --no-template './packages/agent-bundle' './packages/rsc-runtime' './packages/rsc-markdown-stream' './packages/create-agent-bundle'", "pack:dry-run": "pnpm build && npm pack ./packages/agent-bundle --dry-run --json", "lint:release": "attw --pack --profile esm-only packages/agent-bundle", "check:release": "pnpm pack:dry-run && pnpm lint:release && pnpm test:packed:release", diff --git a/packages/agent-bundle/tests/license-metadata.test.ts b/packages/agent-bundle/tests/license-metadata.test.ts index df19be8cd..10373e590 100644 --- a/packages/agent-bundle/tests/license-metadata.test.ts +++ b/packages/agent-bundle/tests/license-metadata.test.ts @@ -8,7 +8,7 @@ const workspaceRoot = process.cwd(); const projectLicense = 'Apache-2.0'; /** SHA-256 of https://www.apache.org/licenses/LICENSE-2.0.txt (canonical text; .gitattributes pins LF endings). */ const canonicalApache2Sha256 = 'cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30'; -const publishablePackages = ['agent-bundle', 'rsc-runtime', 'create-agent-bundle'] as const; +const publishablePackages = ['agent-bundle', 'rsc-runtime', 'rsc-markdown-stream', 'create-agent-bundle'] as const; interface Manifest { readonly files?: readonly string[]; diff --git a/packages/rsc-markdown-stream/README.md b/packages/rsc-markdown-stream/README.md new file mode 100644 index 000000000..4d1f50aae --- /dev/null +++ b/packages/rsc-markdown-stream/README.md @@ -0,0 +1,132 @@ +# rsc-markdown-stream + +Render React / RSC trees to **Markdown**, never HTML. + +This is a custom renderer in the spirit of [rsc-html-stream](https://github.com/devongovett/rsc-html-stream), but for the other half of the job: where you would normally hand your tree to `renderToReadableStream` from `react-dom/server`, hand it to `renderToMarkdownStream` instead and get a stream of GitHub Flavored Markdown. No HTML is ever produced, no RSC payload is injected, nothing hydrates — markdown *is* the output. + +Zero dependencies. `react` is the only peer (v19+). `react-dom` is not in the dependency graph at all. + +## Usage + +```js +import {renderToMarkdown} from 'rsc-markdown-stream'; + +let md = await renderToMarkdown(
      ); +// "# Hello\n\nSome **bold** text...\n" +``` + +Streaming, parallel to the SSR setup you already know — consume an RSC stream and render it to markdown instead of HTML: + +```js +import {renderToReadableStream} from 'react-server-dom-BUNDLER/server.edge'; +import {createFromReadableStream} from 'react-server-dom-BUNDLER/client.edge'; +import {renderToMarkdownStream} from 'rsc-markdown-stream'; + +let rscStream = renderToReadableStream(); + +let data; +function Content() { + data ??= createFromReadableStream(rscStream); + return React.use(data); +} + +let markdownStream = renderToMarkdownStream(); +// ReadableStream of UTF-8 markdown, emitted block by block +``` + +## Where it runs in agent-bundle + +This package is the renderer behind `MarkdownContent` in +[`@agent-bundle/runtime`](../rsc-runtime/README.md): routes author headings, lists, and GFM tables +as JSX, and the runtime lowers the rendered Markdown into an `Agent.Markdown` node inside a real +React Flight request compiled under the `react-server` condition. The upstream repository, +[ScriptedAlchemy/rsc-markdown-stream](https://github.com/ScriptedAlchemy/rsc-markdown-stream), +keeps a standalone Rsbuild example of the full pipeline — an RSC server writing raw Flight bytes +to stdout, a consumer decoding them with `react-server-dom-webpack/client` and handing the tree to +`renderToMarkdownStream` — plus a browser demo; neither ships with this package. + +Markdown blocks stream out progressively as each server component's data resolves: the header +arrives first, a table next, the slower subtrees last — the same progressive behavior you'd get +from streaming HTML SSR, but the output is markdown. + +## shadcn/ui to markdown + +Component-library trees — Radix primitives, cva variants, lucide icons and all — render to markdown surprisingly well (the upstream example renders a shadcn/ui dashboard this way): + +- shadcn's `Table` components are real `` elements underneath, so they come out as GFM tables. +- Radix `Checkbox` renders a hidden `` for form interop — inside `
    • ` that becomes a GFM task list (`- [x]`). +- Radix `AccordionTrigger` lives inside an `

      ` header, so triggers become real markdown headings; collapsed content and inactive `TabsContent` are unmounted by Radix and produce nothing, while the `defaultValue` panel renders. +- Radix state/hooks (`useState`, `useId`, context) run on the renderer's built-in dispatcher in their initial, uncontrolled state. Portal-based components (Dialog, Popover, Tooltip) are the ones that won't work. +- Styled containers (Card, Alert, Button) flatten to plain text blocks — map `button` to a custom serializer via `options.components` to keep adjacent button labels from running together. + +### How this relates to rsc-html-stream + +[rsc-html-stream](https://github.com/devongovett/rsc-html-stream) does not render anything: it is a ~130-line transport that interleaves Flight bytes into an HTML stream as `