引入正文安全子集并收紧呈现创作口径,避免本局任意脚本美化。

创作只选壳与分区,渲染与提示共用同一份白名单语法。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-24 00:58:48 +08:00
co-authored by Cursor
parent d1f709449d
commit a4cec3d708
10 changed files with 399 additions and 65 deletions
+2 -2
View File
@@ -63,11 +63,11 @@
叙事指南与故事推进(工序编排)正文:一份全文(纲领/遣词/笔墨/禁忌/推进等);**整份**双挂转述与主世界层。artifact=`设计.叙事指南与故事推进`。
对话落盘拆成两份:`设计.叙事指南`(遣词/笔墨/禁忌/写法档)与 `设计.故事推进`(推进 + 用户输入用法);各自双挂,条件独立落盘。
回复呈现:每轮终稿给用户看什么(正文/字数/日期/变量);artifact=`设计.监控栏`(旧称设计监控栏/状态栏)。
正文组成:把回复呈现清单展示给用户(选壳、美化,可写 CSS/HTML/JS);artifact=`设计.正文组成`。见 `docs/play-presentation-shells.md`。
正文组成:把回复呈现清单展示给用户(选壳、分区、【正文安全子集】);artifact=`设计.正文组成`。见 `docs/play-presentation-shells.md`。
变量设计:真值 + **维护语句** + **Data映射索引**(指向具体实例)+ side_effects。
变量控制上下文:各槽视野 + **旁观汇总**;mount 含主世界层与旁观维护。
生成规则:合同挂主世界层 + 旁观(旁观读 rule_id/格式/必要性,描写长文可 summary)。
开场白与开场变量:每条开场 = meta(用户角色+初值)+ 正文(present.v1,壳与正文组成一致);可经 opening-generator 落库。
开场白与开场变量:开场正文(present.v1,壳与正文组成一致)+ 开场变量初值;用户代入名片优先「主角设定」。可经 opening-generator 落库。
具体实例正文:`rule_id` + `records[]`(始终数组;单层,键随规则;条数服从规则「一次生成」);自评=合规模(只按规则执行)。
+2 -1
View File
@@ -4,7 +4,8 @@
> 上游:游玩管线 `gm → perspective? → narrator → auditor`。
> 创作挂钩:`设计.正文组成`(怎么展示)/ `设计.监控栏`(每轮看什么,技能名「回复呈现」);终稿 tag 通常 `输出.用户展示`。
> 各壳介绍与规格:`skills/dialogue/world-simulator/present-shells/`(正文组成执行时动态注入)。
> **创作定位:按回复呈现清单选壳并美化,可写 CSS/HTML/JS。**
> **创作定位:按回复呈现清单选壳并分区;正文语法见【正文安全子集】(程序动态装载)。**
> 日常折叠/表格走 Markdown 安全子集;一般不写本局任意 CSS/HTML/JS。
> 预览:`/shells.html`
## 1. 创作怎么做(权威口径)
+1 -1
View File
@@ -122,7 +122,7 @@ Progressive 投影 本轮主世界层只看见「当前档」;未
| **生成规则 / 具体实例** | 分档表 schema 与行数据(如好感→性格);生成后由变量设计索引 |
| **正文组成** | 隐藏段定界(供解析维护语句);用户可见块序 |
| **设计监控栏** | 用户可见的会变字段子集 |
| **开场白与开场变量** | 开场正文(present.v1,跟正文组成)+ 与开场同真相的初值 / 用户角色 |
| **开场白与开场变量** | 开场正文(present.v1,跟正文组成)+ 与开场同真相的初值;用户角色优先「主角设定」 |
| **游玩拓扑 / 上下文投影排序 / 细化终稿** | 固定槽;扁平投影序;tables 收成 |
编排口诀:体验与机制 → 要不要真值 → 映射表用生成规则/实例 → 维护语句与旁观汇总 → 开场同真相 → 收成。
+3 -3
View File
@@ -172,7 +172,7 @@ recipes/world-simulator|expand-assistant/recipe.yaml
| 能力 | id | 状态 |
|------|-----|------|
| 美学纲领与交互范式 | `aesthetics-interaction` | **范例已写**;前端 mosaic 视图 |
| 主角设定 | `protagonist` | **已写**:有产物则挂在当前选用的用户角色卡下(不进全局列表);关掉这局就不加载 |
| 主角设定 | `protagonist` | **已写**:代入名片(名字/背景/特殊设定);挂在当前选用的用户角色卡下;关掉这局就不加载 |
| 实现机制 | `mechanism` | **已写**:`context-fragment.v1`(支撑点正文 + 自评 + 追问);`feeds: gm`;**专用卡已接** |
| 舞台骨架 | `world-blueprint` | **已写**:`context-fragment.v1`;社会结构 + 世界状况;**专用卡已接** |
| 生成规则 | `generation-rules` | **已写**:`context-fragment.v1`;方向池优先、元素池仅封闭集合;可反复;**〔先验产物〕**;规划字段 `target`;挂 gm+auditor;**专用卡已接** |
@@ -183,10 +183,10 @@ recipes/world-simulator|expand-assistant/recipe.yaml
| 拓扑图谱 | `topology` | 必须生成且不适合走生成规则→实例时,用拓扑结构写出(升级路径、地图、人物关系等) |
| 回复呈现 | `status-bar` | **已写**:每轮终稿看什么(正文/字数/日期/变量);旧称设计监控栏 |
| 随机范围整理 | `random-range` | **已写**:汇总检定/对抗等随机项与范围;正文组成之前;替代机遇裁定槽 |
| 正文组成 | `reply-format` | **已写**:把回复呈现清单展示出来;选壳+美化,可写 CSS/HTML/JS;可接隐藏备用随机区 |
| 正文组成 | `reply-format` | **已写**:选壳+分区;正文语法【正文安全子集】动态注入;可接隐藏备用随机区 |
| 变量设计与更新规则 | `variable-design` | **已写**;专用/结构化卡 |
| 变量控制上下文 | `variable-context` | **已写**:旁观汇总;mount 含 auditor;通用 fragment 卡 |
| 开场白与开场变量 | `opening-setup` | **已写**:每条 = meta(初值/用户角色)+ present.v1 正文(壳跟正文组成);**〔收口〕**选定后落库并保存定稿 |
| 开场白与开场变量 | `opening-setup` | **已写**:每条 = meta(初值)+ present.v1 正文;用户角色优先主角设定;**〔收口〕**选定后落库并保存定稿 |
共用收成(池内保留,按需):
@@ -0,0 +1,49 @@
# 正文 Markdown 安全子集(程序动态装载进「正文组成」)
# 实现:web/markdown.js · 先转义再解析;禁止裸 HTML / 任意 CSS / JS
version: 1
intro: >
用户开启 Markdown 渲染时,游玩正文(及壳区域主读)只吃下列语法。
美化靠程序预制 class(.md-*),不要写本局任意 CSS/HTML/JS。
目录可增条目;技能 prompt 勿背死清单。
features:
- id: paragraph
name: 段落与换行
syntax: 空行分段;段内换行保留
- id: heading
name: 标题
syntax: "# ~ ###### 标题"
- id: emphasis
name: 强调
syntax: "**粗** *斜* ~~删~~ `行内代码`"
- id: link
name: 链接
syntax: "[文案](https://…)(仅 http/https)"
- id: list
name: 列表
syntax: '- 项 / 1. 项'
- id: quote
name: 引用
syntax: '> 引用行'
- id: fence
name: 代码块
syntax: '三反引号围栏代码块'
- id: hr
name: 分隔线
syntax: '---'
- id: table
name: 表格
syntax: |
| 列甲 | 列乙 |
| --- | --- |
| 值1 | 值2 |
- id: details
name: 折叠
syntax: |
:::details 标题
折叠正文(可含上表其它语法)
:::
默认展开::::details open 标题
restrictions:
- 禁止裸 HTML 标签、事件属性、script
- 禁止本局任意 CSS / JS(壳微调 tone_chrome 等另走呈现壳)
- 链接协议仅 http / https
@@ -1,7 +1,7 @@
# 正文组成
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> **创作定位:把「回复呈现」已钉的内容展示给用户**(选壳、美化排版,可写 CSS/HTML/JS)。壳介绍与规格写在各壳自己的文档,程序动态装载。
> **创作定位:把「回复呈现」已钉的内容展示给用户**(选壳、分区、用安全子集美化)。壳与【正文安全子集】由程序动态装载。
> 产物外壳:`docs/context-fragment-design.md`。
> 旧称:设计回复格式。
@@ -12,7 +12,7 @@ name: 正文组成
id: reply-format
artifact: 设计.正文组成
declaration: >
把「回复呈现」已钉的内容展示给用户:选壳、美化排版,可写 CSS/HTML/JS;有「随机范围整理」则把隐藏备用随机区接到消息;排在二者之后、开场白之前
把「回复呈现」已钉的内容展示给用户:选壳、分区、按【正文安全子集】做折叠/表格等美化;有「随机范围整理」则把隐藏备用随机区接到消息;排在二者之后、开场白之前
when: |
已有「回复呈现」(或等价:每轮终稿看什么已钉)后排。几乎每局都要。
若本局有随机需求:须已有「随机范围整理」(或确认无随机)后再排本步。
@@ -23,7 +23,7 @@ when_not: |
不要排在开场白之后。几乎不跳过。
只谈文风遣词 → 「叙事指南」(工序编排:「叙事指南与故事推进」)。只改呈现区域与内容规则(字数/选项槽/监控键)→ 「回复呈现」。
boundary: |
本技能:前端展示——从【可选呈现壳】选壳、把回复呈现的各区域落到版式、美化排版(可含 CSS/HTML/JS);若有随机范围表,预留/接到隐藏备用随机区(用户看不见、发给 AI 仍在上下文)。
本技能:前端展示——从【可选呈现壳】选壳、把回复呈现的各区域落到版式;正文语法以【正文安全子集】为准(折叠、表格等)。
回复呈现:必选/可选区域 + 各区简单内容规则——本步不重议清单与规则,只负责怎么显示。
随机范围整理:要插哪些数、范围是什么——本步不改范围表,只负责怎么藏进消息。
叙事指南:文风。变量设计:真值规则。开场白:第一屏——本步先定展示。
@@ -33,11 +33,11 @@ feeds: narrator
## opening
```opening
本局把「回复呈现」里已钉的内容展示给用户(选壳、美化排版)。壳清单由程序动态装载,不要背死几种。
本局把「回复呈现」里已钉的内容展示给用户(选壳、分区、安全子集美化)。壳与安全子集由程序动态装载,不要背死。
1. 对照【可选呈现壳】:哪一个最适合放下已钉的那些东西?
2. 各项落到壳的哪一区?显示名叫什么?
3. 要不要额外的 CSS/HTML/JS 来美化(没有就说没有)?
2. 各项落到壳的哪一区?显示名叫什么?渲染提示用纯文本还是 Markdown?
3. 需要折叠/表格等时,对照【正文安全子集】(一般够用;html/css/js 默认空)。
4. 定稿时会用前面情节架空一幕灌进壳里,专供你看版式/美化(内容随便,不当正史)。
```
@@ -46,17 +46,18 @@ feeds: narrator
```task
你正在执行「正文组成」。产物必须是 **context-fragment.v1** JSON,写入「设计.正文组成」。
本步 = **前端展示**:按「回复呈现」的清单,从【可选呈现壳】里选一个,把内容落到区域并美化排版。可写本局需要的 CSS/HTML/JS。
禁止重议「看什么、正文多少字」;禁止自造目录没有的 shell_id。
本步 = **前端展示**:按「回复呈现」的清单,从【可选呈现壳】里选一个,把内容落到区域。
正文美化优先【正文安全子集】(用户开 Markdown 渲染时生效)。日常折叠、表格等走子集即可;「渲染」里 html/css/js 默认空字符串。
禁止重议「看什么、正文多少字」;禁止自造目录没有的 shell_id;禁止发明子集没有的语法。
执行顺序:
1. 读「回复呈现」与美学/叙事:清单是权威;不重开「看什么」问卷。
2. **选 shell_id**(必须是【可选呈现壳】里的 id);说明为何这个壳放得下清单里的项。
3. 可见块:把清单各项挂到该壳已有区域;写显示名与职责。
4. 需要时写「渲染」:本局 CSS/HTML/JS 片段(美化排版,不是新后端协议)。
3. 可见块:把清单各项挂到该壳已有区域;写显示名与职责;渲染提示选「纯文本」或「Markdown」。
4. 「渲染」html/css/js:默认全空;仅当用户点名要超出安全子集的交互时再议(本步通常不写)。
5. 隐藏段(若清单要求):定界与用途。
6. 填「呈现壳.微调」;分区必须与壳一致。
7. **示例**:用前面已定情节架空一幕灌进 `present.v1`,专供验收版式/美化;字数以回复呈现为准,本步不重钉篇幅。
7. **示例**:用前面已定情节架空一幕灌进 `present.v1`;body 可演示子集语法(如折叠/表格);字数以回复呈现为准。
8. 自评 + 追问;禁止写 context `order`。
summary:`正文组成 · 适配 {shell_id} · …`。
@@ -66,14 +67,14 @@ summary:`正文组成 · 适配 {shell_id} · …`。
```principles
1. 创作 = 把「回复呈现」的清单展示出来
选壳、分区、显示名、CSS/HTML/JS 都为那份清单服务。本步验收重点是「看起来对不对」。
选壳、分区、显示名、安全子集语法都为那份清单服务。本步验收重点是「看起来对不对」。
「按意见修改」:以最新产物为底;用户要求按先后都有效,冲突时以较新的为准。用户说整体可以、或只提美化 → 不换 shell_id。换壳必须用户点名。
2. 清单权威在「回复呈现」
正文篇幅、要不要日期、展示哪些变量,以那份产物为准。本步只决定怎么显示。
3. 壳是底,渲染可加
从【可选呈现壳】选底。需要时写本局 CSS/HTML/JS 做美化;不要发明新的后端协议或目录没有的壳。
3. 壳是底,子集是语法
从【可选呈现壳】选底;Markdown 区只使用【正文安全子集】。不要发明新后端协议、目录没有的壳、或子集没有的语法。
4. 隐藏段服务维护,不污染阅读
@@ -90,8 +91,8 @@ summary:`正文组成 · 适配 {shell_id} · …`。
```probe
每轮 1~2 点。
优先:壳能否放下回复呈现的各项;分区与显示名;要不要 CSS/HTML/JS。
不追问:重议字数/变量清单、事件池 schema、文风遣词、示例文笔好坏。
优先:壳能否放下回复呈现的各项;分区与显示名;渲染提示纯文本还是 Markdown;要不要子集里的折叠/表格。
不追问:重议字数/变量清单、事件池 schema、文风遣词、示例文笔好坏、本局任意 CSS/JS。
```
## output
@@ -100,46 +101,38 @@ summary:`正文组成 · 适配 {shell_id} · …`。
{
"schema": "context-fragment.v1",
"技能": "正文组成",
"brief": "一句话:壳 + 主要块序",
"brief": "一句话:选了哪壳、关键区怎么挂",
"mount": ["narrator"],
"稳变": "stable",
"正文": {
"依据的体验与呈现": [
"从回复呈现继承的清单 + 美学/叙事呈现要点"
],
"依据的体验与呈现": {
"回复呈现要点": "从设计.监控栏摘:必选区 + 字数等",
"美学或叙事可映到版式的一句": "可空"
},
"呈现壳": {
"shell_id": "必须与【可选呈现壳】某一 id 一致",
"为何选它": "一句话",
"shell_id": "必须是【可选呈现壳】里的 id",
"为何选它": "对照回复呈现要同时看见什么",
"微调": {
"tone_chrome": "messenger|terminal|book|default",
"show_suggested_actions": true,
"monitor_fields": [],
"block_labels": {},
"show_suggested_actions": true,
"tone_chrome": "default|messenger|book|terminal",
"empty_states": {}
}
},
"版式隐喻": "给人读的比喻;程序以 shell_id 为准",
"版式隐喻": "可空;一句气质,不是新壳",
"可见块": [
{
"块id": "英文 kebab 或中文短名,同局稳定",
"区域": "monitor|header|body|footer|aside",
"显示名": "用户可理解的标题;可无标题则空",
"职责": "这块给用户什么信息",
"顺序": 1,
"可空": true,
"填充方": "叙事转述|程序拼装|回复呈现变量|其它",
"内容形态": "散文|短列表|键值行|其它",
"示例": "可选:该块一句占位(总预览见正文.示例)"
"块id": "与回复呈现一致的短名",
"挂到区域": "monitor|header|body|footer|aside",
"显示名": "可空",
"职责": "一句",
"来源提示": "转述填|程序填|混合"
}
],
"呈现清单落实": {
"依据的回复呈现": "brief 或要点",
"各项落到": [
{
"清单项": "正文|日期|某变量|建议行动|…",
"区域": "monitor|header|body|footer|aside|hidden",
"显示名": "可空"
}
]
"监控栏锚点": {
"启用": true,
"字段": ["与回复呈现一致;无则 []"]
},
"隐藏段": [
{
@@ -159,20 +152,20 @@ summary:`正文组成 · 适配 {shell_id} · …`。
"区域id": "monitor|header|body|footer|aside",
"对应块id": ["…"],
"意图": "固定顶栏|主滚动|折叠文末|…",
"渲染提示": "纯文本|Markdown|自定义"
"渲染提示": "纯文本|Markdown"
}
],
"渲染": {
"html": "可选:本局结构片段;无则空字符串",
"css": "可选:本局样式;无则空字符串",
"js": "可选:本局交互;无则空字符串"
"html": "",
"css": "",
"js": ""
},
"说明": "无分区则写「单流渲染」(通常 prose)"
"说明": "Markdown 区语法见【正文安全子集】;渲染三字段默认空。无分区则写「单流渲染」(通常 prose)"
},
"拼装与终稿": {
"终稿tag": "通常 输出.用户展示",
"拼装方": "叙事转述一次写出各块|程序按块拼接|混合",
"推荐灌数": "分块壳优先 present.v1;prose 可为纯 Markdown",
"推荐灌数": "分块壳优先 present.v1;prose 可为纯 Markdown(安全子集)",
"单轮正文篇幅": "以回复呈现的字数为准,本步不重钉",
"与正文关系": "转述读主世界「输出.用户展示」再填各块;可补残稿与版式,不改剧情;隐藏段可供旁观解析维护语句"
},
@@ -186,7 +179,7 @@ summary:`正文组成 · 适配 {shell_id} · …`。
"blocks": {
"monitor": "若该壳有 monitor:短键值;否则省略",
"header": "若有则短字;无则省略",
"body": "架空主读文(短段即可,勿写成章)",
"body": "架空主读文;需要时可演示 :::details / 表格(短段即可)",
"aside": "若有则短线索/备注;无则省略",
"footer": "若有则可空或一句"
},
@@ -211,7 +204,7 @@ summary:`正文组成 · 适配 {shell_id} · …`。
{
"名": "可实现",
"分数": 8,
"说明": "shell_id 是否在【可选呈现壳】内;分区与隐藏段是否可交给转述/程序"
"说明": "shell_id 是否在【可选呈现壳】内;Markdown 是否只用【正文安全子集】"
},
{
"名": "版式可感",
@@ -241,7 +234,7 @@ summary:`正文组成 · 适配 {shell_id} · …`。
3. `可见块` 至少 1 条(通常含 body);禁止自造区域 id / 目录没有的壳。
4. 正文须含 `示例`:`性质` 标明非正史;`灌数` 为 `present.v1`,`shell` 与本步 shell_id 一致;只填该壳已有区域。
5. 示例内容可糙:只为验收壳与美化;禁止当开场白/正史;禁止为示例新造设定。
6. 禁止程序 API schema、context `order`。需要美化时写本局 CSS/HTML/JS,不要另起后端协议。
6. 禁止程序 API schema、context `order`。Markdown 区只使用【正文安全子集】;渲染 html/css/js 默认空。
7. 自评:可见完备 / 清单落实 / 可实现 / 版式可感。
8. summary:`正文组成 · 适配 {shell_id} · {brief 缩略}`。
9. 兼容旧称产物 tag「设计.回复格式」。
@@ -252,7 +245,7 @@ summary:`正文组成 · 适配 {shell_id} · …`。
- [ ] 是把「回复呈现」的清单展示出来,而不是重议看什么?
- [ ] shell_id 在【可选呈现壳】内?可见块未超出该壳分区?
- [ ] 清单各项已落到区域?字数未在本步另定一套?
- [ ] 隐藏段(若有)定界清楚?CSS/HTML/JS 只服务本局展示?
- [ ] Markdown 区语法落在【正文安全子集】内?渲染三字段默认空?
- [ ] 有示例灌数(present.v1),壳一致、各区撑开,且标明非正史?
- [ ] 未把示例写成开场白或打磨成长文?
```
@@ -261,13 +254,15 @@ summary:`正文组成 · 适配 {shell_id} · …`。
```examples
好:
- 回复呈现要正文+好感:选 chat_monitor,好感进 monitor,正文进 body;可加短 CSS 美化芯片。
- 回复呈现只要正文:选 prose;渲染可空。
- 回复呈现要选项:选 choice_dock,示例 meta 带占位行动。
- 回复呈现要正文+好感:选 chat_monitor,好感进 monitor,正文进 body;渲染提示 Markdown。
- 需要折叠说明:body 用 :::details 标题 … :::(见安全子集)。
- 需要对照表:body 用 Markdown 表格。
- 回复呈现只要正文:选 prose;渲染三字段空。
坏:
- 本步重钉「正文 300 字」而回复呈现已经写了 800~1500。
- 不写 shell_id,只写「要好看的 UI」。
- 示例写成第一章正史,或为写好看新造未定设定。
- 示例 shell 与呈现壳不一致,或灌了壳没有的区域。
- 写本局任意 CSS/JS,却只是要折叠或表格。
```
+126
View File
@@ -0,0 +1,126 @@
/**
* 正文 Markdown 安全子集目录。
* 正文组成执行时注入【正文安全子集】,与 web/markdown.js 实现对照维护。
*/
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";
const DEFAULT_SKILLS_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../skills",
);
export const MARKDOWN_SAFE_SUBSET_FILENAME =
"markdown-safe-subset/catalog.yaml";
export type MarkdownSafeFeature = {
id: string;
name: string;
syntax: string;
};
export type MarkdownSafeSubsetCatalog = {
version: number;
intro: string;
features: MarkdownSafeFeature[];
restrictions: string[];
};
function asString(v: unknown): string {
return typeof v === "string" ? v.trim() : "";
}
export function parseMarkdownSafeSubsetCatalog(
raw: string,
): MarkdownSafeSubsetCatalog | null {
let doc: unknown;
try {
doc = parseYaml(raw);
} catch {
return null;
}
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
const row = doc as Record<string, unknown>;
const featuresRaw = row.features;
if (!Array.isArray(featuresRaw)) return null;
const features: MarkdownSafeFeature[] = [];
for (const item of featuresRaw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const f = item as Record<string, unknown>;
const id = asString(f.id);
const name = asString(f.name);
const syntax = asString(f.syntax);
if (!id || !name || !syntax) continue;
features.push({ id, name, syntax });
}
if (!features.length) return null;
const restrictions: string[] = [];
if (Array.isArray(row.restrictions)) {
for (const r of row.restrictions) {
const s = asString(r);
if (s) restrictions.push(s);
}
}
const version =
typeof row.version === "number" && Number.isFinite(row.version)
? row.version
: 1;
return {
version,
intro: asString(row.intro),
features,
restrictions,
};
}
export async function loadMarkdownSafeSubsetCatalog(
skillPackRoot: string,
skillsRoot = DEFAULT_SKILLS_ROOT,
): Promise<MarkdownSafeSubsetCatalog | null> {
const catalogPath = path.join(
skillsRoot,
skillPackRoot,
MARKDOWN_SAFE_SUBSET_FILENAME,
);
let raw: string;
try {
raw = await readFile(catalogPath, "utf8");
} catch {
return null;
}
return parseMarkdownSafeSubsetCatalog(raw);
}
export function formatMarkdownSafeSubsetForPrompt(
catalog: MarkdownSafeSubsetCatalog,
): string {
const lines = [
"## 【正文安全子集】程序从仓库动态装载",
"",
catalog.intro ||
"用户开启 Markdown 渲染时,正文只吃下列语法;美化靠程序 class,勿写本局任意 CSS/HTML/JS。",
"",
];
for (const f of catalog.features) {
lines.push(`- **${f.name}**(\`${f.id}\`)`);
const syn = f.syntax.trim();
if (syn.includes("\n")) {
lines.push("", "```", syn, "```", "");
} else {
lines.push(` \`${syn}\``);
}
}
if (catalog.restrictions.length) {
lines.push("", "硬限制:");
for (const r of catalog.restrictions) {
lines.push(`- ${r}`);
}
}
lines.push(
"",
"区域「渲染提示」选 Markdown 时,只使用上表;日常折叠/表格走子集即可,一般不必填「渲染」里的 html/css/js。",
);
return lines.join("\n").trim();
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { renderMarkdownToHtml } from "../web/markdown.js";
describe("markdown safe subset render", () => {
it("renders GFM tables", () => {
const html = renderMarkdownToHtml(
["| 甲 | 乙 |", "| --- | --- |", "| 1 | **粗** |"].join("\n"),
);
expect(html).toContain('<table class="md-table">');
expect(html).toContain("<th>");
expect(html).toContain("<td>");
expect(html).toContain("<strong>粗</strong>");
expect(html).not.toContain("| 甲 |");
});
it("renders details folds", () => {
const html = renderMarkdownToHtml(
[":::details 规则说明", "里面有 *斜体*", ":::"].join("\n"),
);
expect(html).toContain('<details class="md-details">');
expect(html).toContain('<summary class="md-summary">');
expect(html).toContain("规则说明");
expect(html).toContain("<em>斜体</em>");
});
it("supports open details", () => {
const html = renderMarkdownToHtml(
[":::details open 已展开", "内容", ":::"].join("\n"),
);
expect(html).toContain("<details class=\"md-details\" open>");
});
it("escapes raw html tags", () => {
const html = renderMarkdownToHtml("<script>alert(1)</script>");
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;");
});
});
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import {
formatMarkdownSafeSubsetForPrompt,
loadMarkdownSafeSubsetCatalog,
parseMarkdownSafeSubsetCatalog,
} from "../src/skills/markdown-safe-subset.js";
const PACK = "dialogue/world-simulator";
describe("markdown safe subset catalog", () => {
it("parses features from yaml", () => {
const cat = parseMarkdownSafeSubsetCatalog(`
version: 1
intro: 测试简介
features:
- id: table
name: 表格
syntax: "| a | b |"
- id: details
name: 折叠
syntax: |
:::details 标题
内容
:::
restrictions:
- 禁止 script
`);
expect(cat?.features.map((f) => f.id)).toEqual(["table", "details"]);
expect(cat?.restrictions).toEqual(["禁止 script"]);
});
it("loads pack catalog with table and details", async () => {
const cat = await loadMarkdownSafeSubsetCatalog(PACK);
expect(cat).toBeTruthy();
const ids = cat!.features.map((f) => f.id);
expect(ids).toContain("table");
expect(ids).toContain("details");
expect(ids).toContain("heading");
});
it("formats injection block for reply-format", async () => {
const cat = await loadMarkdownSafeSubsetCatalog(PACK);
const block = formatMarkdownSafeSubsetForPrompt(cat!);
expect(block).toContain("【正文安全子集】");
expect(block).toContain("动态装载");
expect(block).toContain("`table`");
expect(block).toContain("`details`");
expect(block).toContain(":::details");
});
});
+77 -2
View File
@@ -1,4 +1,6 @@
/** Optional Markdown display — escaped first, then a small safe subset. */
/** Optional Markdown display — escaped first, then a small safe subset.
* Feature list (prompt injection): skills/.../markdown-safe-subset/catalog.yaml
*/
const STORAGE_KEY = "wa-markdown-render";
@@ -36,7 +38,21 @@ function esc(s) {
.replace(/>/g, "&gt;");
}
/** Safe subset: fences, inline code, headings, emphasis, links, lists, quotes, hr, breaks. */
function isTableSeparator(line) {
const t = String(line ?? "").trim();
if (!t.includes("-")) return false;
// GFM: | --- | :---: | ---: |
return /^\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(t);
}
function splitTableRow(line) {
let s = String(line ?? "").trim();
if (s.startsWith("|")) s = s.slice(1);
if (s.endsWith("|")) s = s.slice(0, -1);
return s.split("|").map((c) => c.trim());
}
/** Safe subset: fences, details, tables, headings, emphasis, links, lists, quotes, hr. */
export function renderMarkdownToHtml(raw) {
const text = String(raw ?? "");
if (!text) return "";
@@ -92,6 +108,65 @@ export function renderMarkdownToHtml(raw) {
continue;
}
const detailsStart = line.match(/^:::details(?:\s+(open))?\s+(.+?)\s*$/);
if (detailsStart) {
flushPara();
flushList();
const openAttr = detailsStart[1] ? " open" : "";
const summary = detailsStart[2];
const bodyLines = [];
i += 1;
while (i < lines.length && !/^:::\s*$/.test(lines[i])) {
bodyLines.push(lines[i]);
i += 1;
}
if (i < lines.length) i += 1;
const inner = renderMarkdownToHtml(bodyLines.join("\n"));
out.push(
`<details class="md-details"${openAttr}><summary class="md-summary">${inlineMarkdown(
summary,
)}</summary><div class="md-details-body">${inner || ""}</div></details>`,
);
continue;
}
if (
line.includes("|") &&
i + 1 < lines.length &&
isTableSeparator(lines[i + 1])
) {
flushPara();
flushList();
const headers = splitTableRow(line);
i += 2;
const rows = [];
while (i < lines.length && lines[i].includes("|") && lines[i].trim()) {
if (isTableSeparator(lines[i])) {
i += 1;
continue;
}
rows.push(splitTableRow(lines[i]));
i += 1;
}
const thead = `<thead><tr>${headers
.map((h) => `<th>${inlineMarkdown(h)}</th>`)
.join("")}</tr></thead>`;
const tbody = rows.length
? `<tbody>${rows
.map(
(cells) =>
`<tr>${headers
.map((_, idx) => `<td>${inlineMarkdown(cells[idx] ?? "")}</td>`)
.join("")}</tr>`,
)
.join("")}</tbody>`
: "";
out.push(
`<div class="md-table-wrap"><table class="md-table">${thead}${tbody}</table></div>`,
);
continue;
}
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) {
flushPara();
flushList();