diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml
new file mode 100644
index 0000000000..1ff9ecac7d
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-23-web-assistant-markdown.md: ce98a16fa43e2743c18826ee7f2344c38e7c70e7
+2026-07-23-web-assistant-markdown.zh.md: 0d6fd2f9e6b91f76830586ecf4c29774e5d6978a
diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md
new file mode 100644
index 0000000000..ce98a16fa4
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md
@@ -0,0 +1,35 @@
+# Agent Note: Safe assistant Markdown in the Web conversation
+
+Status: implemented
+
+English | [中文](2026-07-23-web-assistant-markdown.zh.md)
+
+## Problem
+
+The Web conversation preserves assistant Markdown source through session events, history replay, and streaming accumulation, but its terminal text primitive renders that source literally. Changing the shared primitive would also format user and steering messages, while parsing in the runtime would mix presentation state into the React-free session projection.
+
+## Decision
+
+`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal.
+
+`MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without `dangerouslySetInnerHTML`, raw-HTML parsing, or syntax highlighting. The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser is part of the initial browser bundle.
+
+## Untrusted output policy
+
+Assistant-authored destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images render only their alt text, so model output cannot initiate a remote image request. Raw HTML remains inert source text because no HTML parser enters the pipeline.
+
+The renderer uses existing `--dsw-*` typography and color tokens. Fenced code and GFM tables own horizontal overflow so long content cannot widen the conversation column.
+
+## Alternatives considered
+
+**Promote the existing mdast and micromark development dependencies and maintain a custom React walker.** This avoids a new parser family but makes the product own every node mapping, GFM extension, and security-sensitive rendering branch. The dedicated React renderer keeps that traversal upstream while preserving an AST-to-React path.
+
+**Replace `MessageText` with Markdown rendering.** This formats user prompts and steering as a side effect. Those authored surfaces remain literal until the product chooses that behavior explicitly.
+
+**Parse Markdown into session snapshots.** This would make React nodes or presentation ASTs durable runtime state and reintroduce a final-versus-streaming mode boundary. Parsing stays at the presentation leaf instead.
+
+**Enable raw HTML or remote images with sanitization.** Neither capability has a current product need, while both enlarge the executable or network privacy boundary. They remain disabled rather than adding sanitizer and image-policy dependencies.
+
+## Consequences
+
+Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. The initial Web shell grows by the Markdown parser and GFM runtime, and future extensions such as syntax highlighting or remote media require a separate bundle and security decision.
diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md
new file mode 100644
index 0000000000..0d6fd2f9e6
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: Web 对话中安全的 assistant Markdown
+
+Status: implemented
+
+[English](2026-07-23-web-assistant-markdown.md) | 中文
+
+## 问题
+
+Web 对话通过会话事件、历史回放与流式累积保留 assistant Markdown 源文本,但其最末端的文本原语会按字面渲染源文本。若修改共享原语,用户消息与 steering(中途引导)消息也会被格式化;若在运行时中解析,则会把呈现状态混入不依赖 React 的会话投影。
+
+## 决策
+
+`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。
+
+`MarkdownText` 使用 `react-markdown` 与 `remark-gfm`,从 AST 构建 React 元素。它支持 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,但不使用 `dangerouslySetInnerHTML`,不解析原始 HTML,也不进行语法高亮。`ui-primitives` 显式声明该依赖;由于这一纯库由 Web shell 预置,解析器会成为初始浏览器 bundle 的一部分。
+
+## 不受信任输出策略
+
+assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片仅渲染替代文本,因此模型输出无法发起远程图片请求。由于管线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。
+
+渲染器使用现有的 `--dsw-*` 排版与颜色 token。围栏代码块与 GFM 表格各自处理横向溢出,因此较长内容无法撑宽对话栏。
+
+## 考虑过的替代方案
+
+**将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系,但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。
+
+**将 `MessageText` 替换为 Markdown 渲染。**这会产生格式化用户提示词与 steering 的副作用。在产品明确选择此行为之前,这两类输入内容仍按字面渲染。
+
+**将 Markdown 解析为会话快照。**这会让 React 节点或呈现层 AST 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。
+
+**通过净化启用原始 HTML 或远程图片。**当前产品并不需要这两项功能,但二者都会扩大可执行行为或网络隐私边界。因此它们保持禁用,无需增加净化器与图片策略依赖。
+
+## 后果
+
+assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。初始 Web shell 的体积会因加入 Markdown 解析器与 GFM 运行时而增大;语法高亮或远程媒体等后续扩展需要另行作出 bundle 与安全决策。
diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
index ca37255dcf..9d87cb9ad3 100644
--- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-2026-07-22-evidence-based-larger-hosted-runners.md: 13ecbd5c74bb08d84c8fdf1140a9970235aab826
-2026-07-22-evidence-based-larger-hosted-runners.zh.md: 93d6818fdf5af826980b6f4b938fadc122722b68
+2026-07-22-evidence-based-larger-hosted-runners.md: aaeab4ed9ae9687598f9f1d4a862120405697672
+2026-07-22-evidence-based-larger-hosted-runners.zh.md: 72b69c85908990a9f35b60f4c0a2ce213f9c8134
diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
index 13ecbd5c74..aaeab4ed9a 100644
--- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
+++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
@@ -18,7 +18,7 @@ The required primary path depends on those enterprise pools. Standard GitHub-hos
The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture.
-Linux primary work uses two independent 32-core jobs. Coverage runs alone with its own worker bound. The other job starts the static scheduler alone; once it reports a successful build, lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers start against that completed tree. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time.
+Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time.
Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the paid critical path without adding a blocking platform claim.
@@ -58,6 +58,8 @@ Complete serial Linux, macOS, and Windows references run only when `master` move
**Keep build behind typecheck.** This orders independent compiler invocations and turns snapshot replay into a three-stage critical chain. Build output has its own success dependency, so only snapshot and publication consumers wait for it.
+**Keep static gates and post-build consumers on one runner.** Reusing one workspace avoids a setup wave and artifact transfer, but build-duration variance delays every consumer and leaves their lint and snapshot tails after the static result. A run-scoped built tree preserves one exact build while independent jobs keep both complete paths within the observed target.
+
**Keep the complete required path on standard GitHub-hosted capacity.** This avoids repository-external runner configuration, but exact-head standard-runner runs remain materially slower and can spend longer queued behind shared capacity. Standard-hosted compatibility and serial references preserve portable evidence without making that slower topology the ordinary primary path.
**Keep required and observational Windows checks in separate jobs.** The split preserves status semantics at the workflow level but pays setup twice. `run-gates` preserves the same required versus non-blocking distinction inside one process.
@@ -68,7 +70,7 @@ Complete serial Linux, macOS, and Windows references run only when `master` move
The required topology pays one setup wave per 32-core lane and retains no shard selectors. Every ordinary pull request consumes paid enterprise Linux and Windows minutes; manual benchmarks add other sizes only when remeasurement is useful.
-GitHub rounds each larger-runner execution up to a whole minute, so complete-job measurement exposes both billed time and workflow complexity. Splitting Linux repeats setup once, but isolates coverage from build, lint, and snapshot contention; consolidating Windows avoids repeating its slower setup.
+GitHub rounds each larger-runner execution up to a whole minute, so complete-job measurement exposes both billed time and workflow complexity. Splitting Linux repeats setup twice and transfers one built tree, but isolates coverage, static gates, and post-build consumers from each other's critical paths without repeating the build; consolidating Windows avoids repeating its slower setup.
Performance targets are observations, not cancellation deadlines or correctness requirements. Manual all-size and serial suites remain available when image, dependency, scheduler, or pricing changes need remeasurement.
diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
index 93d6818fdf..72b69c8590 100644
--- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
+++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
@@ -18,7 +18,7 @@ Status: implemented
原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。
-Linux 主流程使用两个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限。另一个作业先单独启动静态调度器;静态调度器报告构建成功后,lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方才基于构建完成后的工作树启动。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。
+Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。
Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台契约。
@@ -58,6 +58,8 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完
**让构建继续等待类型检查。** 此方案会给相互独立的编译器调用排定先后顺序,并把快照回放变成 3 阶段关键链。构建输出本身有独立的成功依赖关系,因此只有快照和发布消费方需要等待它。
+**将静态门禁和构建后消费方保留在同一台运行器上。** 复用同一个工作区可以省去一轮设置和一次产物传输,但构建耗时的波动会延迟每个消费方,并使消费方的 lint 和快照尾段延续到静态结果之后。仅供本次运行使用的已构建目录树可以保留同一份构建结果,而相互独立的作业能让两条完整路径都保持在实测目标内。
+
**将完整必需路径保留在 GitHub 标准托管容量上。** 此方案可以避免依赖仓库外部的运行器配置,但标准运行器上的分支头精确运行仍明显更慢,也可能因共享容量而排队更久。标准托管兼容性作业和串行参考流程保留可移植证据,无需让这套较慢的拓扑成为普通主路径。
**将必需的 Windows 检查和观测性 Windows 检查保留在不同作业中。** 这种拆分在工作流层保留状态语义,却需要支付两次设置开销。`run-gates` 在一个进程内保留了相同的必需与非阻塞区别。
@@ -68,7 +70,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完
必需拓扑中的每个 32 核通道只承担 1 轮设置开销,且不保留分片选择器。每个普通拉取请求都会消耗付费的企业级 Linux 和 Windows 运行器分钟数;只有在重新测量有价值时,手动基准测试才会加入其他规格。
-GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此完整作业测量能同时呈现计费时长与工作流复杂度。拆分 Linux 会重复一次设置,但可将覆盖率同构建、lint 和快照的争用隔离;合并 Windows 则避免重复其耗时更长的设置。
+GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此完整作业测量能同时呈现计费时长与工作流复杂度。拆分 Linux 会重复两轮设置并传输一份已构建目录树,但能使覆盖率、静态门禁与构建后消费方不再相互进入关键路径,且无需重复构建;合并 Windows 则避免重复其耗时更长的设置。
性能目标是观测结果,而非取消截止时间或正确性要求。当映像、依赖、调度器或定价发生变化而需要重新测量时,仍可使用手动全规格和串行套件。
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8a1cac0e4e..2eceefa114 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -27,31 +27,15 @@ env:
jobs:
- # Two enterprise runners split the two longest primary Node paths. The
- # static lane starts snapshot and artifact validation as soon as its build
- # completes, while exhaustive coverage runs alone on the other runner.
+ # Three enterprise jobs isolate coverage, static analysis, and the
+ # build-backed consumer tail. The static job publishes its exact build so
+ # consumers do not repeat the longest part of their critical path.
node-24:
if: github.event_name == 'pull_request'
- runs-on: ${{ matrix.runner }}
- name: ${{ matrix.name }}
+ runs-on: dsh-enterprise-ubuntu-latest-32core-test
+ name: node 24 / static
env:
- DSH_COVERAGE_MAX_WORKERS: '24'
- DSH_ESLINT_CACHE: '1'
- DSH_ESLINT_CONCURRENCY: '8'
DSH_GATE_CONCURRENCY: '8'
- DSH_NODE_COMPAT_SKIP_TYPECHECK: '1'
- DSH_PUBLINT_CONCURRENCY: '8'
- DSH_SNAPSHOT_MAX_CONCURRENCY: '32'
- strategy:
- fail-fast: false
- matrix:
- include:
- - lane: static-snapshots-artifacts
- name: node 24 / static, snapshots, and artifacts
- runner: dsh-enterprise-ubuntu-latest-32core-test
- - lane: coverage
- name: node 24 / coverage
- runner: dsh-enterprise-ubuntu-24-04-32core-test
steps:
- uses: actions/checkout@v6
with:
@@ -66,8 +50,104 @@ jobs:
restore-keys: |
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
+ - uses: actions/setup-node@v6
+ with:
+ node-version: ${{ env.PRIMARY_NODE_VERSION }}
+
+ - name: Enable corepack and install dependencies
+ run: |
+ corepack enable
+ pnpm install --frozen-lockfile
+
+ - name: Run static gates
+ run: pnpm run check:ci:static
+
+ - name: Pack built tree
+ run: >-
+ tar -czf "$RUNNER_TEMP/node-24-built-tree.tar.gz"
+ apps/*/lib packages/*/*/lib vendor/*/lib
+
+ - uses: actions/upload-artifact@v6
+ with:
+ name: node-24-built-tree
+ path: ${{ runner.temp }}/node-24-built-tree.tar.gz
+ if-no-files-found: error
+ retention-days: 1
+ compression-level: 0
+
+ node-24-coverage:
+ if: github.event_name == 'pull_request'
+ runs-on: dsh-enterprise-ubuntu-24-04-32core-test
+ name: node 24 / coverage
+ env:
+ DSH_COVERAGE_MAX_WORKERS: '24'
+ DSH_GATE_CONCURRENCY: '8'
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - uses: actions/cache/restore@v4
+ with:
+ path: /home/runner/.local/share/pnpm/store/v11
+ key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
+
+ - uses: actions/setup-node@v6
+ with:
+ node-version: ${{ env.PRIMARY_NODE_VERSION }}
+
+ - name: Enable corepack, install dependencies, and prepare bubblewrap
+ run: |
+ corepack enable
+ pnpm install --frozen-lockfile &
+ install_pid=$!
+ bash scripts/prepare-ci-bubblewrap.sh &
+ sandbox_pid=$!
+ install_status=0
+ wait "$install_pid" || install_status=$?
+ sandbox_status=0
+ wait "$sandbox_pid" || sandbox_status=$?
+ if (( install_status != 0 )); then exit "$install_status"; fi
+ exit "$sandbox_status"
+
+ - name: Run exhaustive coverage
+ run: pnpm run check:ci:coverage
+
+ node-24-consumers:
+ needs: node-24
+ if: github.event_name == 'pull_request'
+ runs-on: dsh-enterprise-ubuntu-latest-32core-test
+ name: node 24 / snapshots and artifacts
+ env:
+ DSH_ESLINT_CACHE: '1'
+ DSH_ESLINT_CONCURRENCY: '8'
+ DSH_GATE_CONCURRENCY: '8'
+ DSH_NODE_COMPAT_SKIP_TYPECHECK: '1'
+ DSH_PUBLINT_CONCURRENCY: '8'
+ DSH_SNAPSHOT_MAX_CONCURRENCY: '32'
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - uses: actions/download-artifact@v8
+ with:
+ name: node-24-built-tree
+ path: ${{ runner.temp }}
+
+ - name: Restore built tree
+ run: tar -xzf "$RUNNER_TEMP/node-24-built-tree.tar.gz"
+
+ - uses: actions/cache/restore@v4
+ with:
+ path: /home/runner/.local/share/pnpm/store/v11
+ key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
+
- uses: actions/cache/restore@v4
- if: matrix.lane == 'static-snapshots-artifacts'
with:
path: .cache/eslint
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }}
@@ -92,26 +172,8 @@ jobs:
if (( install_status != 0 )); then exit "$install_status"; fi
exit "$sandbox_status"
- - name: Run static, compatibility, snapshot, and artifact gates
- if: matrix.lane == 'static-snapshots-artifacts'
+ - name: Run compatibility, snapshot, and artifact gates
run: |
- static_log="$RUNNER_TEMP/static-gates.log"
- : > "$static_log"
- pnpm run check:ci:static > >(tee "$static_log") 2>&1 &
- static_pid=$!
-
- until grep -Fq 'run-gates: PASS build ' "$static_log"; do
- if ! kill -0 "$static_pid" 2>/dev/null; then
- static_status=0
- wait "$static_pid" || static_status=$?
- if grep -Fq 'run-gates: PASS build ' "$static_log"; then break; fi
- if (( static_status != 0 )); then exit "$static_status"; fi
- echo '::error::Static gates exited without completing the build.'
- exit 1
- fi
- sleep 0.2
- done
-
pnpm run check:ci:lint &
lint_pid=$!
pnpm run check:node-compat &
@@ -143,17 +205,13 @@ jobs:
fi
}
for child_pid in \
- "$static_pid" "$lint_pid" "$compat_pid" "$snapshot_pid" \
+ "$lint_pid" "$compat_pid" "$snapshot_pid" \
"$publint_pid" "$node_next_pid" "$built_invariants_pid" "$built_bin_pid"
do
capture_status "$child_pid"
done
exit "$final_status"
- - name: Run exhaustive coverage
- if: matrix.lane == 'coverage'
- run: pnpm run check:ci:coverage
-
node-compat:
if: github.event_name == 'pull_request'
@@ -629,7 +687,7 @@ jobs:
all-checks-passed:
name: all checks passed
runs-on: ubuntu-latest
- needs: [node-24, node-compat, python-sdk, windows]
+ needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, windows]
if: always() && github.event_name == 'pull_request'
steps:
- name: Fail if any needed job did not succeed
diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts
index 3fccde9a79..4e489b264f 100644
--- a/apps/web/tests/smoke-fixture.e2e.ts
+++ b/apps/web/tests/smoke-fixture.e2e.ts
@@ -196,6 +196,26 @@ describe('web boot chain success pass (keyless, seven real bundles, ?fixture)',
expect(await writeRoot.getByText('notes/new-demo.txt', { exact: true }).count()).toBe(1)
})
+ it('keeps Markdown semantic while a fixture reply streams and finalizes', async () => {
+ onTestFailed(() => saveFailureShot(page, 'smoke-markdown-stream'))
+ await page.getByRole('button', { name: 'New session', exact: true }).click()
+ const input = page.locator('textarea[placeholder]')
+ await input.waitFor({ timeout: 15_000 })
+ await input.fill('render markdown')
+ await page.getByRole('button', { name: '发送' }).click()
+
+ const streaming = page.locator('[data-streaming="true"]')
+ await streaming.getByRole('heading', { name: 'Markdown fixture' }).waitFor({ timeout: 15_000 })
+ await streaming.waitFor({ state: 'detached', timeout: 15_000 })
+
+ const finalHeading = page.getByRole('heading', { name: 'Markdown fixture' })
+ expect(await finalHeading.evaluate(element => element.tagName)).toBe('H1')
+ expect(await page.locator('pre code').filter({ hasText: 'const markdown = true' }).count()).toBe(1)
+ const external = page.getByRole('link', { name: 'DeepSeek' })
+ expect(await external.getAttribute('target')).toBe('_blank')
+ expect(await external.getAttribute('rel')).toBe('noopener noreferrer')
+ })
+
it('stayed clean: no page errors across the whole load chain', () => {
expect(pageErrors).toEqual([])
})
diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index bbf4809466..20312b41a3 100644
--- a/packages/client/connection/src/client/fixture.ts
+++ b/packages/client/connection/src/client/fixture.ts
@@ -24,6 +24,28 @@ function text(t: string): ContentBlock[] {
return [{ type: 'text', text: t }]
}
+const MARKDOWN_FIXTURE = [
+ '# Markdown fixture',
+ '',
+ 'Assistant output renders **strong text**, *emphasis*, and `inline code`.',
+ '',
+ '- first item',
+ ' - nested item',
+ '',
+ '| Surface | State |',
+ '| --- | --- |',
+ '| history | rendered |',
+ '| streaming | stable |',
+ '',
+ '[DeepSeek](https://www.deepseek.com)',
+ '',
+ '```ts',
+ 'const markdown = true',
+ '```',
+].join('\n')
+
+const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)'
+
function sid(id: string): SessionId {
return id as SessionId
}
@@ -40,7 +62,13 @@ function buildAlphaLog(): SessionEvent[] {
}
for (let turn = 0; turn < 60; turn++) {
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
- push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`), source: { kind: 'user' } } })
+ push({
+ type: 'user/message', surfaceOp: 'append',
+ data: {
+ content: text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`),
+ source: { kind: 'user' },
+ },
+ })
if (turn % 9 === 4) {
push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } })
}
@@ -49,7 +77,7 @@ function buildAlphaLog(): SessionEvent[] {
const withReasoning = turn % 3 === 1
const blocks: ContentBlock[] = []
if (withReasoning) blocks.push({ type: 'reasoning', text: `思考过程 ${turn}:这是一段可折叠的 reasoning 内容。` })
- blocks.push({ type: 'text', text: `回答 ${turn}:这是 fixture 生成的历史回复正文。` })
+ blocks.push({ type: 'text', text: turn === 59 ? MARKDOWN_FIXTURE : `回答 ${turn}:这是 fixture 生成的历史回复正文。` })
if (withTool) {
const callId = `fx-call-${turn}`
blocks.push({ type: 'tool-call', id: callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } as ContentBlock)
@@ -343,8 +371,8 @@ export function createFixtureApi(): ApiProxy {
const step = 0
append(id, { type: 'step/start', data: { turn, step } })
append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
- /* v8 ignore next -- the ?? arm needs a null match, but replyText is never empty (prompt always prefixes 回声). */
- const pieces = replyText.match(/.{1,6}/gu) ?? [replyText]
+ /* v8 ignore next -- the ?? arm needs a null match, but every fixture reply is non-empty. */
+ const pieces = replyText.match(/[\s\S]{1,6}/gu) ?? [replyText]
let i = 0
const finish = (aborted: boolean): void => {
replays.delete(id)
@@ -410,7 +438,13 @@ export function createFixtureApi(): ApiProxy {
setRunning(id, true)
append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
append(id, { type: 'user/message', surfaceOp: 'append', data: { content, source: { kind: 'user' } } })
- startReply(id, turn, `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`)
+ startReply(
+ id,
+ turn,
+ userText === 'render markdown'
+ ? MARKDOWN_FIXTURE
+ : `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
+ )
return ok(request, { accepted: true as const })
},
cancel: (request) => {
diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts
index c50921a44d..c9c90f1ae0 100644
--- a/packages/client/connection/tests/fixture.spec.ts
+++ b/packages/client/connection/tests/fixture.spec.ts
@@ -113,7 +113,7 @@ describe('createFixtureApi', () => {
const missing = await api.sessions.prompt(req({ sessionId: sid('ghost'), mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] }))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'ghost' } } })
// Real prompt: replay starts (running flips true), cancel freezes it.
- const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: '取消我' }] }))
+ const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'render markdown' }] }))
expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } })
await new Promise(resolve => setTimeout(resolve, 120)) // a couple of typewriter ticks
await api.sessions.cancel(req({ sessionId: id }))
diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
index 7bb9a22e3e..90eb3e3bee 100644
--- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
+++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
@@ -7,7 +7,7 @@
import { memo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
-import { IconThinkOutline14, JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
+import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { ToolRow } from './ToolRow.tsx'
import css from './AssistantMarkdown.module.css'
@@ -44,7 +44,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
{blocks.map((block, i) => {
switch (block.kind) {
- case 'text': return
+ case 'text': return
case 'reasoning': return
// Tool-call heads render as tool rows in the chat view's grouping pass.
case 'tool-call': return null
diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx
index 4d5cd923d6..45c3b3f6ca 100644
--- a/packages/client/ui-conversation/tests/chat-view.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx
@@ -157,6 +157,44 @@ describe('ChatView', () => {
expect(view.getByText('run a')).toBeTruthy()
})
+ it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
+ const markdown = '# Rendered\n\n- **one**\n- `two`'
+ const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })
+ const view = render(
)
+ expect(view.container.querySelectorAll('h1')).toHaveLength(1)
+ const literal = view.getByText((_content, element) => (
+ element?.tagName === 'DIV' && element.childElementCount === 0 && element.textContent === markdown
+ ))
+ expect(literal.querySelector('h1')).toBeNull()
+
+ act(() => {
+ h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: markdown }] } })
+ })
+ expect(view.container.querySelectorAll('h1')).toHaveLength(2)
+ expect(view.container.querySelector('[data-streaming="true"] h1')?.textContent).toBe('Rendered')
+
+ act(() => {
+ h.set({
+ nodes: [user(1, markdown), assistant(2, markdown), assistant(3, markdown)],
+ partial: null,
+ })
+ })
+ expect(view.container.querySelectorAll('h1')).toHaveLength(2)
+ expect(view.container.querySelector('[data-streaming="true"]')).toBeNull()
+
+ act(() => {
+ h.set({
+ nodes: [
+ user(1, markdown),
+ assistant(2, markdown),
+ { ...assistant(3, markdown), interrupted: true },
+ ],
+ })
+ })
+ expect(view.getByText('已停止')).toBeTruthy()
+ expect(view.container.querySelectorAll('h1')).toHaveLength(2)
+ })
+
it('streaming partial frames re-render only the tail (Profiler count)', () => {
const h = makeHarness({
nodes: [user(1, 'q'), assistant(2, 'old answer'), toolResult(3, 'a')],
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index 29883df236..da6382be4b 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -1,6 +1,10 @@
# @deepseek-ai/dsh-client-ui-primitives
-Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/JsonBlock). Contract: api-contracts v3 §8.
+Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8.
+
+## Markdown rendering
+
+`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content.
## Model Experience
@@ -15,4 +19,3 @@ None; this package neither assembles nor sends a provider request.
- **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
- **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
- **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface.
-- **MessageText renders plain text** — markdown support swaps this component's internals later; consumers must not assume block structure.
diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json
index 2d23ecd330..fda27fdd4d 100644
--- a/packages/client/ui-primitives/package.json
+++ b/packages/client/ui-primitives/package.json
@@ -21,7 +21,9 @@
"license": "BSD-3-Clause",
"dependencies": {
"clsx": "^2.0.0",
- "react": "^18.2.0"
+ "react": "^18.2.0",
+ "react-markdown": "^10.1.0",
+ "remark-gfm": "^4.0.1"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts
index daea4202b3..b1a7a9b1ac 100644
--- a/packages/client/ui-primitives/src/index.ts
+++ b/packages/client/ui-primitives/src/index.ts
@@ -15,5 +15,6 @@ export type { MenuItem } from './Menu.tsx'
export { ConnectionBanner } from './ConnectionBanner.tsx'
export { FishLogo } from './FishLogo.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
+export { MarkdownText } from './markdown/MarkdownText.tsx'
export { MessageText } from './markdown/MessageText.tsx'
export * from './icons/index.tsx'
diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css
new file mode 100644
index 0000000000..36b1dc2b55
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css
@@ -0,0 +1,123 @@
+.markdown {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 12px;
+ overflow-wrap: anywhere;
+ font: var(--dsw-font-markdown-base);
+}
+
+.markdown :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote, pre, hr) {
+ margin: 0;
+}
+
+.markdown h1 {
+ font: var(--dsw-font-markdown-h1);
+}
+
+.markdown h2 {
+ font: var(--dsw-font-markdown-h2);
+}
+
+.markdown h3 {
+ font: var(--dsw-font-markdown-h3);
+}
+
+.markdown :where(h4, h5, h6) {
+ font: var(--dsw-font-markdown-h4);
+}
+
+.markdown :where(strong, th) {
+ font-weight: var(--dsw-font-markdown-base-strong-font-weight);
+}
+
+.markdown :where(ul, ol) {
+ padding-inline-start: 24px;
+}
+
+.markdown li + li {
+ margin-block-start: 4px;
+}
+
+.markdown li > :where(ul, ol) {
+ margin-block-start: 4px;
+}
+
+.markdown blockquote {
+ padding-inline-start: 12px;
+ border-inline-start: 3px solid var(--dsw-alias-markdown-citation);
+ color: var(--dsw-alias-label-secondary);
+}
+
+.markdown a {
+ color: var(--dsw-alias-state-business-primary);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+.markdown :not(pre) > code {
+ padding: 2px 4px;
+ border-radius: 4px;
+ background: var(--dsw-alias-markdown-inline-code);
+ font: var(--dsw-font-markdown-code);
+}
+
+.markdown pre {
+ max-width: 100%;
+ overflow-x: auto;
+ overscroll-behavior-x: contain;
+ padding: 12px 16px;
+ border-radius: 8px;
+ background: var(--dsw-alias-markdown-code-block);
+ font: var(--dsw-font-markdown-code-block);
+}
+
+.markdown pre code {
+ padding: 0;
+ background: transparent;
+ font: inherit;
+ overflow-wrap: normal;
+ word-break: normal;
+ white-space: pre;
+}
+
+.markdown hr {
+ width: 100%;
+ border: 0;
+ border-block-start: 1px solid var(--dsw-alias-markdown-citation);
+}
+
+.markdown input[type='checkbox'] {
+ margin: 0 8px 0 0;
+ accent-color: var(--dsw-alias-state-business-primary);
+}
+
+.tableScroll {
+ max-width: 100%;
+ overflow-x: auto;
+ overscroll-behavior-x: contain;
+}
+
+.tableScroll table {
+ width: max-content;
+ min-width: 100%;
+ border-collapse: collapse;
+ font: var(--dsw-font-markdown-table);
+}
+
+.tableScroll :where(th, td) {
+ padding: 6px 12px;
+ border: 1px solid var(--dsw-alias-markdown-citation);
+ text-align: start;
+ white-space: nowrap;
+}
+
+.tableScroll th {
+ background: var(--dsw-alias-markdown-code-block-banner);
+ font: var(--dsw-font-markdown-table-head);
+}
+
+.imageAlt {
+ color: var(--dsw-alias-label-tertiary);
+ font-style: italic;
+}
diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
new file mode 100644
index 0000000000..425e3969ab
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
@@ -0,0 +1,64 @@
+import ReactMarkdown from 'react-markdown'
+import type { Components, UrlTransform } from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import css from './MarkdownText.module.css'
+
+const remarkPlugins = [remarkGfm]
+
+function sanitizeUrl(url: string): string {
+ try {
+ switch (new URL(url).protocol) {
+ case 'http:':
+ case 'https:':
+ case 'mailto:':
+ return url
+ default:
+ return ''
+ }
+ } catch {
+ return ''
+ }
+}
+
+const safeUrl: UrlTransform = url => sanitizeUrl(url)
+
+const components: Components = {
+ a: ({ href = '', children }) => {
+ const safeHref = sanitizeUrl(href)
+ if (safeHref === '') return <>{children}>
+ const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
+ return (
+
+ {children}
+
+ )
+ },
+ img: ({ alt = '' }) =>
{alt},
+ table: ({ children }) => (
+
+ ),
+}
+
+/**
+ * Render untrusted assistant-authored Markdown as semantic React elements.
+ * @param props - Markdown source text preserved by the session projection.
+ * @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled.
+ */
+export function MarkdownText({ text }: { text: string }) {
+ return (
+
+
+ {text}
+
+
+ )
+}
diff --git a/packages/client/ui-primitives/src/markdown/MessageText.tsx b/packages/client/ui-primitives/src/markdown/MessageText.tsx
index e9fa76d823..cafe9ab3c0 100644
--- a/packages/client/ui-primitives/src/markdown/MessageText.tsx
+++ b/packages/client/ui-primitives/src/markdown/MessageText.tsx
@@ -1,4 +1,4 @@
-// MessageText: the single text-block rendering point (Markdown support later = swap this component's internals, zero card-structure changes).
+// MessageText is the literal-text primitive for user and steering content; assistant output uses MarkdownText.
import css from './MessageText.module.css'
diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx
index 6cfb5f4e8e..4e1dc292d4 100644
--- a/packages/client/ui-primitives/tests/markdown.spec.tsx
+++ b/packages/client/ui-primitives/tests/markdown.spec.tsx
@@ -1,14 +1,93 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
-import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
+import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
describe('MessageText', () => {
it('renders the text verbatim', () => {
- const { container } = render(
)
- expect(container.textContent).toBe('line1\nline2')
+ const { container } = render(
)
+ expect(container.textContent).toBe('# line1\n`line2`')
+ expect(container.querySelector('h1')).toBeNull()
+ })
+})
+
+describe('MarkdownText', () => {
+ it('renders CommonMark and GFM elements as semantic DOM', () => {
+ const markdown = [
+ '# Heading',
+ '',
+ 'Paragraph with **strong**, *emphasis*, ~~deleted~~, `inline`, and [safe](https://example.com). ',
+ 'Hard break.',
+ '',
+ '> Quote',
+ '',
+ '- parent',
+ ' - child',
+ '',
+ '1. first',
+ '2. second',
+ '',
+ '- [x] done',
+ '- [ ] pending',
+ '',
+ '| Name | Value |',
+ '| --- | --- |',
+ '| alpha | beta |',
+ '',
+ '---',
+ '',
+ '```ts',
+ 'const answer = 42',
+ '```',
+ '',
+ '
',
+ ].join('\n')
+ const { container } = render()
+
+ expect(screen.getByRole('heading', { level: 1, name: 'Heading' })).toBeTruthy()
+ expect(container.querySelector('strong')?.textContent).toBe('strong')
+ expect(container.querySelector('em')?.textContent).toBe('emphasis')
+ expect(container.querySelector('del')?.textContent).toBe('deleted')
+ expect(container.querySelector('blockquote')?.textContent?.trim()).toBe('Quote')
+ expect(container.querySelectorAll('ul')).toHaveLength(3)
+ expect(container.querySelector('ol')).not.toBeNull()
+ expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(2)
+ expect(container.querySelector('table')?.textContent).toContain('alphabeta')
+ expect(container.querySelector('hr')).not.toBeNull()
+ expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42')
+ expect(container.querySelector('br')).not.toBeNull()
+ expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank')
+ expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
+ })
+
+ it('neutralizes raw HTML, unsafe or relative links, and remote images', () => {
+ const markdown = [
+ '',
+ '
',
+ '[script](javascript:alert(1)) [relative](/settings)',
+ '[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)',
+ '',
+ ].join('\n\n')
+ const { container } = render()
+
+ expect(container.querySelector('script')).toBeNull()
+ expect(container.querySelector('img')).toBeNull()
+ const neutralized = [...container.querySelectorAll('p')]
+ .find(paragraph => paragraph.textContent === 'script relative')
+ expect(neutralized?.querySelector('a')).toBeNull()
+ expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull()
+ expect(screen.getByRole('link', { name: 'web' }).getAttribute('rel')).toBe('noopener noreferrer')
+ expect(screen.getByRole('link', { name: 'upper' }).getAttribute('target')).toBe('_blank')
+ expect(screen.getByText('remote diagram')).toBeTruthy()
+ })
+
+ it('keeps incomplete streaming Markdown renderable', () => {
+ const { container } = render()
+ expect(screen.getByRole('heading', { level: 2, name: 'Streaming' })).toBeTruthy()
+ expect(container.querySelectorAll('li')).toHaveLength(2)
+ expect(screen.getByText('**unfinished')).toBeTruthy()
})
})
diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts
index 0b9d1a8978..0ea04f7da8 100644
--- a/packages/host/webserver/tests/webserver.spec.ts
+++ b/packages/host/webserver/tests/webserver.spec.ts
@@ -1,22 +1,10 @@
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
-import { createServer as createNetServer, Server as NetServer, type AddressInfo } from 'node:net'
+import { Server as NetServer } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { startWebServer, type RunningWebServer } from '../src/index.ts'
-/** Reserve a loopback port for tests that need to address a second server. */
-function freePort(): Promise {
- return new Promise((resolve, reject) => {
- const probe = createNetServer()
- probe.once('error', reject)
- probe.listen(0, '127.0.0.1', () => {
- const port = (probe.address() as AddressInfo).port
- probe.close(() => { resolve(port) })
- })
- })
-}
-
/** dist fixture: index.html + one asset of each MIME class + a subdir. */
function makeDist(): { distIndex: string; distRoot: string } {
const distRoot = mkdtempSync(join(tmpdir(), 'dsh-webserver-'))
@@ -106,8 +94,7 @@ afterEach(async () => {
async function boot(onError: (err: Error) => void = () => undefined): Promise {
const { distIndex } = makeDist()
- const port = await freePort()
- server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, onError)
+ server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, onError)
return `http://127.0.0.1:${String(server.port)}`
}
@@ -147,8 +134,8 @@ describe('startWebServer', () => {
it('rejects when the port is already taken', async () => {
const { distIndex } = makeDist()
- const port = await freePort()
- server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined)
+ server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined)
+ const { port } = server
await expect(startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined))
.rejects.toMatchObject({ code: 'EADDRINUSE' })
})
@@ -205,9 +192,8 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti
snapshot: () => rows,
clientPath: (id: string) => id === rows[0]?.id ? join(distRoot, 'bundle.js') : undefined,
}
- const port = await freePort()
server = await startWebServer(
- { host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
+ { host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
)
return `http://127.0.0.1:${String(server.port)}`
}
@@ -243,9 +229,8 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti
snapshot: () => rows,
clientPath: () => '/nonexistent/lib/client.js',
}
- const port = await freePort()
server = await startWebServer(
- { host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
+ { host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
)
const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/@deepseek-ai/dsh-client-connection/client.js`)
expect(res.status).toBe(404)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8a0cb4be16..66b9e9478a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -614,6 +614,12 @@ importers:
react:
specifier: ^18.2.0
version: 18.3.1
+ react-markdown:
+ specifier: ^10.1.0
+ version: 10.1.0(@types/react@18.3.31)(react@18.3.1)
+ remark-gfm:
+ specifier: ^4.0.1
+ version: 4.0.1
devDependencies:
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
@@ -6472,6 +6478,9 @@ packages:
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
+ '@types/estree-jsx@1.0.5':
+ resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
+
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@@ -6537,6 +6546,9 @@ packages:
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+ '@types/unist@2.0.11':
+ resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
+
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@@ -6830,6 +6842,9 @@ packages:
ast-v8-to-istanbul@1.0.4:
resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==}
+ bail@2.0.2:
+ resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
+
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -6918,6 +6933,9 @@ packages:
character-entities@2.0.2:
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
+ character-reference-invalid@2.0.1:
+ resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
+
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
@@ -7383,6 +7401,9 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
+ estree-util-is-identifier-name@3.0.0:
+ resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
+
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
@@ -7611,6 +7632,9 @@ packages:
hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
+ hast-util-to-jsx-runtime@2.3.6:
+ resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
+
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
@@ -7631,6 +7655,9 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+ html-url-attributes@3.0.1:
+ resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
+
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
@@ -7682,6 +7709,9 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+ inline-style-parser@0.2.7:
+ resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
+
internmap@1.0.1:
resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==}
@@ -7697,6 +7727,15 @@ packages:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
+ is-alphabetical@2.0.1:
+ resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
+
+ is-alphanumerical@2.0.1:
+ resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
+
+ is-decimal@2.0.1:
+ resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
+
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -7709,6 +7748,13 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
+ is-hexadecimal@2.0.1:
+ resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
+
+ is-plain-obj@4.1.0:
+ resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
+ engines: {node: '>=12'}
+
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
@@ -8099,6 +8145,15 @@ packages:
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
+ mdast-util-mdx-expression@2.0.1:
+ resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
+
+ mdast-util-mdx-jsx@3.2.0:
+ resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
+
+ mdast-util-mdxjs-esm@2.0.1:
+ resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
+
mdast-util-phrasing@4.1.0:
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
@@ -8416,6 +8471,9 @@ packages:
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+ parse-entities@4.0.2:
+ resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
+
parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
@@ -8550,6 +8608,12 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+ react-markdown@10.1.0:
+ resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
+ peerDependencies:
+ '@types/react': '>=18'
+ react: '>=18'
+
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@@ -8582,6 +8646,18 @@ packages:
resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
+ remark-gfm@4.0.1:
+ resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
+
+ remark-parse@11.0.0:
+ resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
+
+ remark-rehype@11.1.2:
+ resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
+
+ remark-stringify@11.0.0:
+ resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
+
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
@@ -8791,6 +8867,12 @@ packages:
strnum@2.4.0:
resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==}
+ style-to-js@1.1.21:
+ resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
+
+ style-to-object@1.0.14:
+ resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
+
stylis@4.4.0:
resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==}
@@ -8853,6 +8935,9 @@ packages:
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
+ trough@2.2.0:
+ resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
+
ts-algebra@2.0.0:
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
@@ -8969,6 +9054,9 @@ packages:
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
engines: {node: '>=20.18.1'}
+ unified@11.0.5:
+ resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
+
unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
@@ -11090,6 +11178,10 @@ snapshots:
'@types/esrecurse@4.3.1': {}
+ '@types/estree-jsx@1.0.5':
+ dependencies:
+ '@types/estree': 1.0.9
+
'@types/estree@1.0.9': {}
'@types/geojson@7946.0.16': {}
@@ -11154,6 +11246,8 @@ snapshots:
'@types/trusted-types@2.0.7':
optional: true
+ '@types/unist@2.0.11': {}
+
'@types/unist@3.0.3': {}
'@types/web-bluetooth@0.0.21': {}
@@ -11529,6 +11623,8 @@ snapshots:
estree-walker: 3.0.3
js-tokens: 10.0.0
+ bail@2.0.2: {}
+
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
@@ -11609,6 +11705,8 @@ snapshots:
character-entities@2.0.2: {}
+ character-reference-invalid@2.0.1: {}
+
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
@@ -12159,6 +12257,8 @@ snapshots:
estraverse@5.3.0: {}
+ estree-util-is-identifier-name@3.0.0: {}
+
estree-walker@2.0.2: {}
estree-walker@3.0.3:
@@ -12433,6 +12533,26 @@ snapshots:
stringify-entities: 4.0.4
zwitch: 2.0.4
+ hast-util-to-jsx-runtime@2.3.6:
+ dependencies:
+ '@types/estree': 1.0.9
+ '@types/hast': 3.0.5
+ '@types/unist': 3.0.3
+ comma-separated-tokens: 2.0.3
+ devlop: 1.1.0
+ estree-util-is-identifier-name: 3.0.0
+ hast-util-whitespace: 3.0.0
+ mdast-util-mdx-expression: 2.0.1
+ mdast-util-mdx-jsx: 3.2.0
+ mdast-util-mdxjs-esm: 2.0.1
+ property-information: 7.2.0
+ space-separated-tokens: 2.0.2
+ style-to-js: 1.1.21
+ unist-util-position: 5.0.0
+ vfile-message: 4.0.3
+ transitivePeerDependencies:
+ - supports-color
+
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.5
@@ -12451,6 +12571,8 @@ snapshots:
html-escaper@2.0.2: {}
+ html-url-attributes@3.0.1: {}
+
html-void-elements@3.0.0: {}
http-errors@2.0.1:
@@ -12499,6 +12621,8 @@ snapshots:
inherits@2.0.4: {}
+ inline-style-parser@0.2.7: {}
+
internmap@1.0.1: {}
internmap@2.0.3: {}
@@ -12507,6 +12631,15 @@ snapshots:
ipaddr.js@1.9.1: {}
+ is-alphabetical@2.0.1: {}
+
+ is-alphanumerical@2.0.1:
+ dependencies:
+ is-alphabetical: 2.0.1
+ is-decimal: 2.0.1
+
+ is-decimal@2.0.1: {}
+
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
@@ -12515,6 +12648,10 @@ snapshots:
dependencies:
is-extglob: 2.1.1
+ is-hexadecimal@2.0.1: {}
+
+ is-plain-obj@4.1.0: {}
+
is-potential-custom-element-name@1.0.1: {}
is-promise@4.0.0: {}
@@ -12934,6 +13071,45 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ mdast-util-mdx-expression@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.5
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx-jsx@3.2.0:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.5
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ ccount: 2.0.1
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ parse-entities: 4.0.2
+ stringify-entities: 4.0.4
+ unist-util-stringify-position: 4.0.0
+ vfile-message: 4.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdxjs-esm@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.5
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
mdast-util-phrasing@4.1.0:
dependencies:
'@types/mdast': 4.0.4
@@ -13401,6 +13577,16 @@ snapshots:
pako@1.0.11: {}
+ parse-entities@4.0.2:
+ dependencies:
+ '@types/unist': 2.0.11
+ character-entities-legacy: 3.0.0
+ character-reference-invalid: 2.0.1
+ decode-named-character-reference: 1.3.0
+ is-alphanumerical: 2.0.1
+ is-decimal: 2.0.1
+ is-hexadecimal: 2.0.1
+
parse5@8.0.1:
dependencies:
entities: 8.0.0
@@ -13523,6 +13709,24 @@ snapshots:
react-is@17.0.2: {}
+ react-markdown@10.1.0(@types/react@18.3.31)(react@18.3.1):
+ dependencies:
+ '@types/hast': 3.0.5
+ '@types/mdast': 4.0.4
+ '@types/react': 18.3.31
+ devlop: 1.1.0
+ hast-util-to-jsx-runtime: 2.3.6
+ html-url-attributes: 3.0.1
+ mdast-util-to-hast: 13.2.1
+ react: 18.3.1
+ remark-parse: 11.0.0
+ remark-rehype: 11.1.2
+ unified: 11.0.5
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
react-refresh@0.17.0: {}
react@18.3.1:
@@ -13560,6 +13764,40 @@ snapshots:
'@eslint-community/regexpp': 4.12.2
refa: 0.12.1
+ remark-gfm@4.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-gfm: 3.1.0
+ micromark-extension-gfm: 3.0.0
+ remark-parse: 11.0.0
+ remark-stringify: 11.0.0
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-parse@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.3
+ micromark-util-types: 2.0.2
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-rehype@11.1.2:
+ dependencies:
+ '@types/hast': 3.0.5
+ '@types/mdast': 4.0.4
+ mdast-util-to-hast: 13.2.1
+ unified: 11.0.5
+ vfile: 6.0.3
+
+ remark-stringify@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-to-markdown: 2.1.2
+ unified: 11.0.5
+
require-from-string@2.0.2: {}
resolve-pkg-maps@1.0.0: {}
@@ -13844,6 +14082,14 @@ snapshots:
dependencies:
anynum: 1.0.0
+ style-to-js@1.1.21:
+ dependencies:
+ style-to-object: 1.0.14
+
+ style-to-object@1.0.14:
+ dependencies:
+ inline-style-parser: 0.2.7
+
stylis@4.4.0: {}
superjson@2.2.6:
@@ -13891,6 +14137,8 @@ snapshots:
trim-lines@3.0.1: {}
+ trough@2.2.0: {}
+
ts-algebra@2.0.0: {}
ts-api-utils@2.5.0(typescript@6.0.3):
@@ -13984,6 +14232,16 @@ snapshots:
undici@7.28.0: {}
+ unified@11.0.5:
+ dependencies:
+ '@types/unist': 3.0.3
+ bail: 2.0.2
+ devlop: 1.1.0
+ extend: 3.0.2
+ is-plain-obj: 4.1.0
+ trough: 2.2.0
+ vfile: 6.0.3
+
unist-util-is@6.0.1:
dependencies:
'@types/unist': 3.0.3