From fef28cf4efc09059dfd0eb0b1b712ff68b74fb5d Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 4 Aug 2026 13:33:21 +0200 Subject: [PATCH 1/6] fix(chat): render assistant Markdown through a built-in extension Chat rendered every assistant answer as raw Markdown source. PR #3240 replaced core `Markdown`'s rendering with an escaped `
`
fallback plus an injectable renderer contract, but no renderer shipped
anywhere and `Chat` never installed one, so `## Heading` and `**bold**`
reached users verbatim.

Add `@veryfront/ext-markdown-react`, a first-party extension built on
react-markdown and remark-gfm that implements `MarkdownRendererProps`,
and install it as chat's default. Core keeps its parser-free boundary:
`veryfront/markdown` on its own still presents plain escaped source, and
resolution order stays explicit prop, then application-installed
renderer, then the built-in extension. `renderer={null}` still selects
plain source.

The extension owns parsing, sanitization, and link policy. Fenced-code
presentation stays on the chat side through `renderCodeBlock`, so the
renderer never reaches back into framework UI. Restore the prose element
styling #3240 dropped, applied only on the renderer branch.

Supporting changes:

- Register the extension as a core extension (`builtin-deferred`,
  `rootNpm: true`) so it ships with the CLI and needs no project setup.
- Add the `MarkdownRendererProvider` contract under
  `veryfront/extensions/markdown`.
- Teach the framework source resolver, the SSR vf-modules path resolver,
  and the browser module server that first-party extension sources ship
  beside `src/`. Without this the client request for the renderer
  resolved against the project directory and 404'd. `src/extensions/`
  contract modules keep resolving first, so `extensions/` stays
  unambiguous.
- Let an extension manifest declare npm peer dependencies, and map the
  repo React shims onto the consumer's bare `react`/`react-dom` in
  extension builds. React must stay a single instance: a vendored copy
  breaks context between the core chat tree and the renderer.

Verified end-to-end with `npm run dev` on a scaffolded agent app: the
answer body renders `

`, `
    `, ``, inline ``, and a GFM table, with no app-level wiring. --- deno.json | 2 + deno.lock | 33 ++- docs/guides/chat-ui.md | 58 ++++- extensions/README.md | 4 +- extensions/ext-markdown-react/README.md | 58 +++++ extensions/ext-markdown-react/deno.json | 45 ++++ extensions/ext-markdown-react/src/index.ts | 37 ++++ .../ext-markdown-react/src/renderer.test.tsx | 81 +++++++ .../ext-markdown-react/src/renderer.tsx | 198 ++++++++++++++++++ .../npm-extension-package-metadata.test.ts | 90 ++++++++ .../build/npm-extension-package-metadata.ts | 79 ++++++- src/extensions/first-party-defaults.ts | 6 + src/extensions/markdown/index.ts | 12 ++ .../markdown/markdown-renderer-provider.ts | 54 +++++ src/modules/server/module-server.ts | 11 +- .../compat/framework-source-resolver.test.ts | 91 ++++++++ .../compat/framework-source-resolver.ts | 34 ++- .../components/chat/chat-markdown.test.tsx | 58 +++++ src/react/components/chat/chat-markdown.tsx | 49 +++++ .../chat/chat/components/reasoning.tsx | 4 +- .../chat/composition/message-body.test.tsx | 66 ++++++ .../chat/chat/composition/message-body.tsx | 6 +- src/react/components/chat/markdown.tsx | 39 +++- .../dev/framework-candidates.generated.ts | 108 +++++++++- .../stages/ssr-vf-modules/constants.ts | 13 +- .../stages/ssr-vf-modules/path-resolver.ts | 5 +- 26 files changed, 1214 insertions(+), 27 deletions(-) create mode 100644 extensions/ext-markdown-react/README.md create mode 100644 extensions/ext-markdown-react/deno.json create mode 100644 extensions/ext-markdown-react/src/index.ts create mode 100644 extensions/ext-markdown-react/src/renderer.test.tsx create mode 100644 extensions/ext-markdown-react/src/renderer.tsx create mode 100644 src/extensions/markdown/index.ts create mode 100644 src/extensions/markdown/markdown-renderer-provider.ts create mode 100644 src/react/components/chat/chat-markdown.test.tsx create mode 100644 src/react/components/chat/chat-markdown.tsx create mode 100644 src/react/components/chat/chat/composition/message-body.test.tsx diff --git a/deno.json b/deno.json index ce28612f6b..b37fc41eac 100644 --- a/deno.json +++ b/deno.json @@ -41,6 +41,7 @@ "./react", "./scripts/codemods", "./extensions/ext-llm-anthropic", + "./extensions/ext-markdown-react", "./extensions/ext-llm-google", "./extensions/ext-llm-openai", "./extensions/ext-auth-jwt", @@ -155,6 +156,7 @@ "./extensions/distributed/rate-limit-support": "./src/extensions/distributed/rate-limit-support.ts", "./extensions/distributed/routing-invalidation-support": "./src/extensions/distributed/routing-invalidation-support.ts", "./extensions/dev-ui": "./src/extensions/dev-ui/index.ts", + "./extensions/markdown": "./src/extensions/markdown/index.ts", "./extensions/dev-ui/protocol": "./src/extensions/dev-ui/protocol.ts", "./extensions/rendering": "./src/extensions/rendering/index.ts", "./extensions/contracts": "./src/extensions/contracts.ts", diff --git a/deno.lock b/deno.lock index fd236307df..1796f4242f 100644 --- a/deno.lock +++ b/deno.lock @@ -28,7 +28,7 @@ "npm:@kreuzberg/node@4.4.2": "4.4.2", "npm:@kreuzberg/wasm@4.5.2": "4.5.2", "npm:@mdx-js/mdx@3.1.1": "3.1.1", - "npm:@mdx-js/react@3.1.1": "3.1.1_react@19.2.4", + "npm:@mdx-js/react@3.1.1": "3.1.1_@types+react@19.2.17_react@19.2.4", "npm:@opentelemetry/api-logs@0.208.0": "0.208.0", "npm:@opentelemetry/api-logs@0.220.0": "0.220.0", "npm:@opentelemetry/api@1.9.1": "1.9.1", @@ -82,6 +82,7 @@ "npm:protobufjs@7.6.5": "7.6.5", "npm:purgecss@8.0.0": "8.0.0", "npm:react-dom@19.2.4": "19.2.4_react@19.2.4", + "npm:react-markdown@9.0.3": "9.0.3_@types+react@19.2.17_react@19.2.4", "npm:react@19.2.4": "19.2.4", "npm:redis@5.11.0": "5.11.0", "npm:rehype-highlight@7.0.2": "7.0.2", @@ -1409,7 +1410,7 @@ "vfile" ] }, - "@mdx-js/react@3.1.1_react@19.2.4": { + "@mdx-js/react@3.1.1_@types+react@19.2.17_react@19.2.4": { "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", "dependencies": [ "@types/mdx", @@ -3632,6 +3633,9 @@ "@exodus/bytes" ] }, + "html-url-attributes@3.0.1": { + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==" + }, "html-void-elements@3.0.0": { "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==" }, @@ -4805,6 +4809,23 @@ "scheduler" ] }, + "react-markdown@9.0.3_@types+react@19.2.17_react@19.2.4": { + "integrity": "sha512-Yk7Z94dbgYTOrdk41Z74GoKA7rThnsbbqBTRYuxoe08qvfQ9tJVhmAKw6BJS/ZORG7kTy/s1QvYzSuaoBA1qfw==", + "dependencies": [ + "@types/hast", + "@types/react", + "devlop", + "hast-util-to-jsx-runtime", + "html-url-attributes", + "mdast-util-to-hast", + "react", + "remark-parse", + "remark-rehype", + "unified", + "unist-util-visit", + "vfile" + ] + }, "react@19.2.4": { "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==" }, @@ -6483,6 +6504,14 @@ "jsr:@std/testing@1.0.17" ] }, + "extensions/ext-markdown-react": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17", + "npm:react-markdown@9.0.3", + "npm:remark-gfm@4.0.1" + ] + }, "extensions/ext-node-websocket-ws": { "dependencies": [ "jsr:@std/assert@1.0.19", diff --git a/docs/guides/chat-ui.md b/docs/guides/chat-ui.md index 4f03228dc1..f7429ffb44 100644 --- a/docs/guides/chat-ui.md +++ b/docs/guides/chat-ui.md @@ -304,12 +304,38 @@ different requests would share the same store. Use chat context providers only when nested components need direct state access. Prefer preset props or composition components first. +## Render Markdown in chat + +`Chat` renders assistant answers and reasoning as semantic Markdown out of the +box. The built-in `@veryfront/ext-markdown-react` extension supplies the +renderer, so CommonMark plus GFM tables, task lists, strikethrough, and +autolinks work with no project setup. Fenced code renders through the shared +code block, with a language label and a copy button. + +To use a different renderer, install one for the subtree. An application +renderer takes precedence over the built-in extension everywhere, including +inside chat: + +```tsx +import { Chat } from "veryfront/chat"; +import { MarkdownRendererProvider } from "veryfront/markdown"; +import { ProjectMarkdownRenderer } from "./project-markdown-renderer.tsx"; + +export default function ChatPage() { + return ( + + + + ); +} +``` + ## Present Markdown source safely -`veryfront/markdown` is the dependency-free Markdown boundary used by chat -surfaces. Without an installed rich renderer, it preserves the exact source in -an escaped `
    ` element. This is useful when source visibility matters
    -more than semantic formatting:
    +`veryfront/markdown` is the dependency-free Markdown boundary. Used on its own,
    +without an installed rich renderer, it preserves the exact source in an escaped
    +`
    ` element. This is useful when source visibility matters more than
    +semantic formatting:
     
     ````tsx
     import { Markdown } from "veryfront/markdown";
    @@ -337,10 +363,26 @@ escaped source is present in server HTML.
     
     ### Install a semantic renderer
     
    -Semantic Markdown is an explicit extension capability. Select a trusted
    -extension or application adapter that implements `MarkdownRendererProps`, then
    -install its component for the relevant subtree. In this example,
    -`ProjectMarkdownRenderer` comes from that adapter:
    +Semantic Markdown is an extension capability. Chat installs
    +`@veryfront/ext-markdown-react` for you; outside chat, install a renderer for
    +the relevant subtree yourself. Use the built-in extension:
    +
    +```tsx
    +import { Markdown, MarkdownRendererProvider } from "veryfront/markdown";
    +import { MarkdownRenderer } from "@veryfront/ext-markdown-react/renderer";
    +
    +export default function Result() {
    +  return (
    +    
    +      {answer}
    +    
    +  );
    +}
    +```
    +
    +Or select another trusted extension or application adapter that implements
    +`MarkdownRendererProps`. In this example, `ProjectMarkdownRenderer` comes from
    +that adapter:
     
     ```tsx
     import { Markdown, MarkdownRendererProvider } from "veryfront/markdown";
    diff --git a/extensions/README.md b/extensions/README.md
    index e2da58c180..5b77e98973 100644
    --- a/extensions/README.md
    +++ b/extensions/README.md
    @@ -52,6 +52,7 @@ Extension availability is separate from contract requirement:
     | Package                                           | Contract                      | Description                                           |
     | ------------------------------------------------- | ----------------------------- | ----------------------------------------------------- |
     | [`@veryfront/ext-content-mdx`](./ext-content-mdx) | `ContentProcessor`            | MDX and Markdown processing via unified/remark/rehype |
    +| [`@veryfront/ext-markdown-react`](./ext-markdown-react) | `MarkdownRendererProvider` | Rich Markdown rendering for React chat surfaces via react-markdown and remark-gfm |
     | [`@veryfront/ext-yaml`](./ext-yaml)               | `SkillDocumentParserProvider` | YAML parsing for skill and agent documents            |
     
     ### Development and rendering
    @@ -128,6 +129,7 @@ baseline subset used by ordinary apps and local development.
     | `@veryfront/ext-parser-babel`                | `CodeParser`                  | Deferred; standard npm baseline           |
     | `@veryfront/ext-yaml`                        | `SkillDocumentParserProvider` | Deferred; standard npm baseline           |
     | `@veryfront/ext-content-mdx`                 | `ContentProcessor`            | Deferred; standard npm baseline           |
    +| `@veryfront/ext-markdown-react`              | `MarkdownRendererProvider`    | Deferred; standard npm baseline           |
     | `@veryfront/ext-css-tailwind`                | `CSSProcessor`                | Deferred; standard npm baseline           |
     | `@veryfront/ext-node-websocket-ws`           | `NodeWebSocketServerProvider` | Deferred; standard npm baseline           |
     | `@veryfront/ext-dev-ui-react`                | `DevUiAssetProvider`          | Deferred; standard npm baseline           |
    @@ -175,7 +177,7 @@ raw transitive dependencies such as `bash-tool`, `just-bash`, `jose`,
     
     | Runtime or service role                      | Install these extension packages                                                                                                                                                                                       |
     | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    -| CLI, build image, or project server runtime  | `@veryfront/ext-bundler-esbuild`, `@veryfront/ext-content-mdx`, `@veryfront/ext-css-tailwind`, `@veryfront/ext-dev-ui-react`, `@veryfront/ext-node-websocket-ws`, `@veryfront/ext-parser-babel`, `@veryfront/ext-yaml` |
    +| CLI, build image, or project server runtime  | `@veryfront/ext-bundler-esbuild`, `@veryfront/ext-content-mdx`, `@veryfront/ext-css-tailwind`, `@veryfront/ext-dev-ui-react`, `@veryfront/ext-markdown-react`, `@veryfront/ext-node-websocket-ws`, `@veryfront/ext-parser-babel`, `@veryfront/ext-yaml` |
     | Build with CSS optimization                  | `@veryfront/ext-css-lightning` (register explicitly)                                                                                                                                                                   |
     | Build with CSS purging or critical CSS       | `@veryfront/ext-css-purgecss` (register explicitly)                                                                                                                                                                    |
     | Build with image optimization                | `@veryfront/ext-image-sharp` (register explicitly)                                                                                                                                                                     |
    diff --git a/extensions/ext-markdown-react/README.md b/extensions/ext-markdown-react/README.md
    new file mode 100644
    index 0000000000..70943e8468
    --- /dev/null
    +++ b/extensions/ext-markdown-react/README.md
    @@ -0,0 +1,58 @@
    +# @veryfront/ext-markdown-react
    +
    +Rich Markdown rendering for React surfaces, built on
    +[react-markdown](https://github.com/remarkjs/react-markdown) and
    +[remark-gfm](https://github.com/remarkjs/remark-gfm).
    +
    +Provides the `MarkdownRendererProvider` contract. Chat installs this renderer by
    +default, so assistant answers render as semantic HTML without any project setup.
    +
    +## What it renders
    +
    +- CommonMark: headings, paragraphs, lists, blockquotes, links, images, emphasis,
    +  inline code, and fenced code.
    +- GFM: tables, task lists, strikethrough, and autolinks.
    +- Fenced code through the shared `CodeBlock` primitive (language label, copy
    +  button, and an optional syntax-highlight renderer).
    +
    +## Safety
    +
    +- Raw HTML in Markdown source is never injected. `rehype-raw` is not installed,
    +  so `\n\nAfter');
    +
    +    assertEquals(html.includes("\n\nAfter');
    -
    -    assertEquals(html.includes("