Merge remote-tracking branch 'origin/master' into worktree/pr343-retarget-latest-master

This commit is contained in:
Tianyi Cui
2026-07-23 19:57:08 +08:00
21 changed files with 834 additions and 89 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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 与安全决策。

View File

@@ -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

View File

@@ -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.

View File

@@ -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 则避免重复其耗时更长的设置。
性能目标是观测结果,而非取消截止时间或正确性要求。当映像、依赖、调度器或定价发生变化而需要重新测量时,仍可使用手动全规格和串行套件。

View File

@@ -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

View File

@@ -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([])
})

View File

@@ -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) => {

View File

@@ -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 }))

View File

@@ -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
<div className={css.root} data-streaming={streaming || undefined}>
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return <MessageText key={i} text={block.text} />
case 'text': return <MarkdownText key={i} text={block.text} />
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
// Tool-call heads render as tool rows in the chat view's grouping pass.
case 'tool-call': return null

View File

@@ -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(<h.ChatView {...h.props} />)
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')],

View File

@@ -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.

View File

@@ -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:^",

View File

@@ -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'

View File

@@ -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;
}

View File

@@ -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 (
<a
href={safeHref}
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{children}
</a>
)
},
img: ({ alt = '' }) => <span className={css.imageAlt}>{alt}</span>,
table: ({ children }) => (
<div className={css.tableScroll}>
<table>{children}</table>
</div>
),
}
/**
* 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 (
<div className={css.markdown}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
components={components}
urlTransform={safeUrl}
>
{text}
</ReactMarkdown>
</div>
)
}

View File

@@ -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'

View File

@@ -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(<MessageText text={'line1\nline2'} />)
expect(container.textContent).toBe('line1\nline2')
const { container } = render(<MessageText text={'# line1\n`line2`'} />)
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',
'```',
'',
'<https://deepseek.com>',
].join('\n')
const { container } = render(<MarkdownText text={markdown} />)
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>globalThis.compromised = true</script>',
'<img src="x" onerror="globalThis.compromised = true">',
'[script](javascript:alert(1)) [relative](/settings)',
'[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)',
'![remote diagram](https://example.com/private.png)',
].join('\n\n')
const { container } = render(<MarkdownText text={markdown} />)
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(<MarkdownText text={'## Streaming\n\n- first\n- **unfinished'} />)
expect(screen.getByRole('heading', { level: 2, name: 'Streaming' })).toBeTruthy()
expect(container.querySelectorAll('li')).toHaveLength(2)
expect(screen.getByText('**unfinished')).toBeTruthy()
})
})

View File

@@ -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<number> {
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<string> {
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)

258
pnpm-lock.yaml generated
View File

@@ -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