- CMS
- 后端
- 前端
- 插件系统
【免费下载链接】emdash
EmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress
导读:本文基于 docs/technical-specs/issue-2344-numbered-list-continuity.md 技术规范,剖析 EmDash(基于 Astro 的全栈 TypeScript CMS)如何解决"段落、图片、代码块等任意块级内容将有序列表拦腰截断后,编号被重置为 1"的历史问题。通过引入持久化的逻辑列表标识
listId与基础起始值listStart两个可选字段,编辑器与渲染端共享同一套编号算法,实现跨块编号连续、显式起始值保真、复制粘贴语义正确,同时保持对既有内容与第三方渲染器的向后兼容。读完本文你将掌握:该特性的数据模型与验证契约、编辑器扩展与命令(Continue / Restart)的实现原理、Portable Text 双向转换规则,以及前端身份感知列表树的构建方式。
问题背景:为什么有序列表会被"打断"
在 EmDash 中,富文本内容以 Portable Text 的扁平块数组(flat run of blocks)形式存储。每个有序列表片段(segment)都被序列化为独立的块序列;一旦段落、图片、代码块或插件块插入到列表中间,文档中就会同时存在两段(或多段)同属于一个逻辑列表的编号块。
该技术规范 issue-2344-numbered-list-continuity.md 指出当前行为存在两个缺陷:
- 编辑器与前端各自为政:编辑器和前端渲染都会把被分隔的两段各自生成一个新的
<ol>,并且都从 1 开始计数; - 属性丢失:编辑器在 Portable Text 转换过程中会丢弃 TipTap 自带的
orderedList.attrs.start属性,导致显式指定起始值的列表(例如从 2 开始的列表)在往返转换后编号信息丢失。
规范给出的解决方案是增量的、无迁移的:为编号块增加"逻辑列表身份(identity)"与"基础起始值(base start)",编辑器在列表被拆分时保留身份、依据前面片段推导每个片段的可见起始值,并提供显式的Continue numbering(继续编号)与Restart numbering(重新编号)操作;前端则将每个片段渲染为语义化的<ol start="…">。
设计目标与非目标
Goals(目标)
- 任意顶层块分隔有序列表时,编号保持连续;
- 在插入、删除、移动条目后,自动重新计算后续片段的起始值;
- 保留显式起始的列表(如从 2 开始);
- 独立列表与嵌套列表之间互不影响;
- 行为在管理后台编辑器、内联可视化编辑器、Portable Text 与前端渲染之间完整往返;
- 与现有 Portable Text 数据及第三方渲染器保持向后兼容。
Non-goals(明确不做的事)
- 不把任意块塞进单个
<li>内部——那需要未来的结构化富列表块(structured rich-list block),而非扁平的 Portable Text; - 不自动重写既有内容、不推断作者对历史分隔列表的意图;
- 不存储仅 Markdown 可读的隐藏注释、不引入数据库迁移;
- 不在 Markdown 导入/导出时合成编号连续性元数据——Markdown 无法可靠地保留独立逻辑身份(尤其在嵌套列表边界处);
- 不改变无序列表(bullet list)行为;
- 不修改 Gutenberg 导入器及其仅输出的 Portable Text 类型——它仍是遗留的、无元数据的生产者,导入的片段只有在 EmDash 中被编辑后才会获得身份。
数据模型:listId 与 listStart 两个可选字段
核心思路是在核心转换器、核心客户端、管理后台编辑器与内联编辑器的 Portable Text 块类型上增加两个可选字段:
interface PortableTextTextBlock { // Existing fields omitted. listItem?: "bullet" | "number"; level?: number; listId?: string; listStart?: number; }对编号块而言:
listId:标识一条跨非相邻片段的逻辑有序列表。当作者新建或粘贴列表时,使用crypto.randomUUID()生成 ID;listStart:该逻辑列表的基础起始值,携带同一listId的每个编号块都会重复写入这个值。注意:它不是后续片段的"推导起始值",而是基准值;- 可见起始值(visible start):每个片段的可见起始值 = 基础值 + 前面与该
listId相同的直接条目(direct items)数量。
源码中的验证契约
在仓库中,该验证契约已落地为独立助手模块 packages/core/src/content/converters/numbered-list.ts,其中:
normalizeListId(value):合法的listId是"trim 后非空、且不超过 128 字符的字符串",任何其他值都视为缺失,且绝不写入 HTML;normalizeListStart(value):合法的listStart是 1 到2,147,483,647(MAX_ORDERED_LIST_START,与HTMLOListElement.start可一致表示的正数范围一致)的整数。推导结果base + precedingDirectItemCount必须保持在该范围内,否则该畸形分组的有效起始值按 1 处理,而不是依赖浏览器各自不同的钳制行为;deriveLegacyListId(seed):为无元数据的遗留内容生成确定性、文档作用域的 ID(legacy:...前缀,超长时用 FNV 式哈希缩短);readOrderedListMetadata(attrs, fallbackId):读取节点属性,优先取listId,缺失时退回遗留派生 ID;listStart缺失时退回start属性,再退回 1。
规范还约定了三条边界规则,均已在normalizeProseMirrorOrderedListJson中实现:
- 一个
listId只能属于一个嵌套深度与父上下文。若畸形输入在不兼容的上下文中复用了它,第一个上下文保留该 ID,编辑器规范化会确定性地位于后出现的每个上下文分配一个"有界的替换 ID"(createRepairId基于原 ID 与结构上下文派生,并附加递增序号),而只读的转换/渲染路径则把各上下文视为独立列表、不修改存储输入; - 冲突的
listStart用两遍扫描解决:按连续性作用域(continuity scope)扫描,文档顺序中第一个合法值即该作用域的规范基准值,若均不合法则用 1;保存时将所有成员改写为规范 ID 与基准值; - 无序列表忽略这两个字段。
对于无有效listId的遗留编号片段:保持独立、从 1 开始。加载到编辑器时,为每个连续片段从其运行起始索引、层级与首个块的_key(若存在)推导确定性的文档作用域 ID——不能用随机数,因为两个协作端加载同一遗留值必须生成完全相同的文档。下一次保存会持久化合法元数据。规范特别强调:分隔开的遗留片段不会被自动合并,除非作者主动选择 Continue numbering。
编号算法:编辑器、转换器、渲染器共享同一套逻辑
规范要求编辑器、转换器与渲染器使用同一个文档顺序算法:
- 规范化:按上文"上下文 + 两遍扫描"规则规范化 ID 与基准值;
- 构建逻辑列表树:复用 PT → PM 转换器使用的同一棵逻辑列表树,按文档顺序遍历有序列表节点。一个"片段(segment)"是在同一树位置、具有相同列表类型、规范化身份、深度与父上下文的最大连续运行;非列表块、不同的 ID/类型或上下文变化都会终结当前片段。每个连续性作用域(
listId、嵌套深度、父列表项上下文)维护一个计数器; - 起始值推导:第一个片段从规范基准值开始;后续每个片段从
base + precedingDirectItemCount开始; - 计数递增:计数器按该片段"直接子级
listItem数量"递增。嵌套列表项属于它们自己的列表节点,永远不递增祖先或兄弟分组的计数器。
示例:列表 A 有 2 个条目,中间插入一张图片,后面再有 2 个条目,则渲染起始值分别为 1 和 3。若在第一个片段中插入一个条目,第二个片段的起始值自动变为 4——因为它是推导值,无需重写任何固定的延续值。
代码对照
在 packages/core/src/content/portable-text-lists.ts 中,buildListTree依据listItem、level、父上下文与sourceId(编号块经normalizeListId规范化后的身份)决定两个编号块是否属于同一列表节点(matchesList),applyNumbering则按作用域遍历descriptors,为每个@list节点计算并附着start值。这两个函数的组合被封装为preprocessLists,并对外暴露buildPortableTextListTree(blocks, mode)与clonePortableTextValue(value)(基于structuredClone的深拷贝)。
在 packages/core/src/content/converters/numbered-list.ts 的normalizeProseMirrorOrderedListJson中,同一算法以 ProseMirror 文档为输入:collectProseMirrorOrderedLists收集所有orderedList节点并计算各自的深度与上下文(root或最近的listItem祖先),随后按作用域规范化 ID、确定基准值(优先listStart,其次start,最后 1),最后逐节点计算start = base + count并回写listId、listStart、start三个属性。
编辑器行为:EmDash 自有的有序列表扩展
两个编辑器(核心内联编辑器与管理后台编辑器)都用包内自有的 EmDash 有序列表扩展替换 StarterKit 的 ordered-list 扩展,并实现同一契约。之所以要在两处各自维护一份小而精的实现,是因为emdash已经依赖@emdash-cms/admin,反向再引入依赖会形成循环;两套实现以相同的"行为夹具(behavioral fixtures)"锁定一致性。同时,只在 StarterKit 中禁用orderedList,避免重复注册同名节点。
TipTap 自带的start属性被保留,新增仅编辑器可见的listId与listStart节点属性。该扩展拥有以下行为(源码见 packages/core/src/components/ordered-list.ts 与 packages/admin/src/components/editor/ordered-list.ts):
- 新建列表:通过工具栏、斜杠命令或
1.输入规则创建时,生成全新 ID 且基准值为 1;N.输入规则则生成全新 ID 且基准值为N(ORDERED_LIST_INPUT_REGEX = /^(\d+)\.\s$/,见addInputRules); - 拆分列表:列表在段落或块周围被拆分时,两段
orderedList节点都保留原 ID 与基准值; - Continue numbering(继续编号):采用最近的前一个兼容有序列表的 ID 与基准值。"兼容"指相同的嵌套深度与相同的父
listItem节点;文档根级的所有列表共享同一个根上下文。仅在兼容上下文内,把当前片段及其之后所有携带旧 ID 的片段一起改写为新 ID,使既有"尾部"保持整体;若选区横跨多个片段、不存在兼容前驱、或前驱已有相同 ID,则禁用该操作。无关的嵌套列表绝不能被链接(rewriteListTail的continue分支); - Restart numbering(重新编号):为选区头部所在片段及其之后所有携带旧 ID 的片段(同一上下文内)分配全新 ID 与基准值 1;选区横跨多个片段时禁用。更早的片段保留旧身份,使当前位置成为新逻辑列表尾部的起点(
rewriteListTail的restart分支); - 合并:相邻的、ID/基准值/深度/父上下文均相同的
orderedList节点合并为一个节点;ID 不同则不合并(规范化事务中的canJoin逻辑); - 确定性规范化:一个
appendTransaction规范化器用编号算法计算每个 ordered-list 节点的有效 TipTapstart属性,并规范化重复的listStart值;文档已规范化时必须产生空事务(避免规范化循环); - 随机 ID 的边界:随机 ID 只由用户操作与粘贴处理创建,绝不在遗留内容转换或复制的规范化器中生成随机数——协作端必须推导出相同的规范化文档。
复制 / 粘贴与拖拽的身份语义
- 剪贴板往返:自定义 ProseMirror 剪贴板序列化器/解析器把源身份、逻辑基准值与第一个复制条目的显示序号携带在编辑器剪贴板 HTML 的私有
data-emdash-*属性中(见扩展addAttributes/renderHTML:data-emdash-list-id、data-emdash-list-start、data-emdash-list-first)。这些属性只被编辑器解析器接受、插入前会被重映射,前端渲染绝不输出; - 粘贴重映射:粘贴时,粘贴切片中每个不同的连续性作用域都被重映射为全新 ID,同时保留切片内各片段间的关系;第一个粘贴片段的有效传入
start(而非重复写入的源listStart)成为新基准值,并盖章到整个重映射组。因此,只复制"显示为 3"的延续部分再粘贴,得到的是从 3 开始的独立列表,而不是从 1 开始;从片段中途开始的剪贴板切片同样以第一个复制条目的显示编号为基准。无元数据的外部列表,每个连续的有序列表运行获得全新 ID,并把合法 HTML/TipTapstart保留为基准值。remapPastedSlice与prepareCopiedSlice分别实现了这两条路径; - 内部拖拽/移动:仅当目标位置深度与父上下文相同时保留 ID;跨上下文移动会把被移动作用域重映射为全新 ID,其基准值为移动前的有效起始值,留在源上下文中的片段保留旧 ID(
remapMovedSlice+handleMovedListDrop判定是否"仍留在原上下文"); - 撤销 / 重做:ID、基准值与推导起始值作为一个用户可见操作整体恢复。
管理后台的显式操作入口
Continue numbering 与 Restart numbering 暴露在管理后台编辑器的有序列表控件中,使用 Kumo 组件与 Lingui 提供标签、描述、工具提示与无障碍文本,使用逻辑化 Tailwind 类,并在 RTL 区域设置下验证控件可用性;当不存在兼容前驱列表时 Continue 被禁用。内联可视化编辑器同样注册底层命令,但不引入第二套设置 UI——其既有的创建/拆分/保存流程必须依然保留连续性。
Portable Text 双向转换
三套转换实现(可复用的核心转换器、管理后台编辑器的本地转换器、内联可视化编辑器的本地转换器)全部更新。
ProseMirror → Portable Text
- 从每个
orderedList节点读取listId与逻辑listStart; - 若节点早于该扩展(无元数据),序列化时从其结构位置创建确定性文档作用域 ID,并在
listStart缺失时把其合法 TipTapstart用作基准值——这使显式起始的 PM 文档得以保留,也让"start: 2失败夹具"变得有意义; - 把两个值盖章到该节点产出的每个编号块上(包括当前层级直接条目的块);
- 下钻到嵌套有序列表时使用嵌套节点自身的元数据,绝不把父身份复制进嵌套组;
- 不把有效的 TipTap
start写成listStart——持久化的是分组基准值。
Portable Text → ProseMirror
- 按列表类型、层级/树位置与规范化的
listId分组编号运行(而不是仅凭相邻性与listItem类型); - 为每个
orderedList节点构造listId、规范化的listStart以及算法算出的有效start; - 遗留连续运行尽量用"键派生 ID"(key-derived ID),保留其"从 1 开始"的现状;
- 保留既有的混合 bullet/number 嵌套规则,且编号元数据只施加于每个嵌套层级的 ordered 节点。
首个回归测试必须是"失败先行"的
规范明确要求:第一个回归测试必须演示当前缺陷——start: 2的有序列表在 PM → PT → PM 往返后丢失起始值,并且该测试必须在转换修复实现之前失败。仓库中已存在对应断言:在 packages/core/tests/unit/converters/numbered-list-continuity.test.ts 中,preserves an explicitly started ProseMirror list through PT用例验证start: 2列表往返后listStart与start均为 2 且listId一致;derives the later start for separated segments with one identity用例验证同一listId跨段落分隔后后续片段的起始值按条目数推导。
前端渲染:身份感知的列表树与 OrderedList.astro
静态渲染分支(PortableText.astro)的处理流程如下(packages/core/src/components/PortableText.astro):
- 深克隆输入:在预处理与
groupBlockquoteRuns之前,用clonePortableTextValue(内部structuredClone)深克隆 JSON 形态的 value——绝不修改调用方的数组、块、嵌套子节点或 markDefs;编辑分支则继续把原始 value 传给InlineEditor,由它自行完成转换; - 规范化与分段计数:预处理阶段规范化元数据并统计编号片段;
- 构建完整
@list树:astro-portabletext默认仅比较level与listItem来构建内部@list树,这会把不同 ID 的列表错误合并。因此在渲染前,EmDash 按调用方请求的listNestingMode(默认html,可选direct)与工具包对该模式的语义,为 bullet 与 number 块构建完整@list树,并附加一条编号专属规则:两个编号块仅在规范化作用域身份相同时才属于同一列表节点。每个嵌套深度都构建身份感知节点,把推导出的有效起始值直接附着到每个编号@list节点上,再整树交给astro-portabletext——由于树已经嵌套好,工具包不会重新分组子节点。相邻的同 ID 运行合并,不同 ID 即使嵌套也保持分离; - 组件渲染:新增 EmDash 的 OrderedList.astro 组件,注册于
emdashComponents.list.number之下。它读取@list节点上经normalizeListStart验证的有效起始值,start !== 1时渲染<ol start={start}>,否则渲染普通<ol>,并转发其余安全的组件属性与 slot 内容。身份感知树只用于渲染,绝不进入编辑器值或序列化。
组件合并顺序保持:EmDash 默认组件 → 插件组件 → 用户组件,因此用户的components.list.number覆盖优先级仍高于OrderedList.astro。第三方 Portable Text 渲染器可以忽略这些增量字段、把分隔片段重置为 1——这是刻意的优雅降级边界,存储内容始终是合法 Portable Text。
仓库中的渲染回归测试 packages/core/tests/repro/portable-text-numbered-list.render.test.ts 验证了:同一listId的两段(中间夹带段落)渲染出两个<ol>且第二段含start="3";在html与direct两种嵌套模式下,根级与嵌套级的不同 ID 列表保持分离(各自保留start="2"、start="7"、start="4");渲染不修改输入 value;用户components.list.number覆盖生效(自定义组件输出data-start="3")。
Markdown 边界
Markdown 没有可移植的逻辑列表身份。规范明确保持现有的有损导入/导出行为,不合成listId或listStart。支持 Markdown 连续性被推迟,因为嵌套列表边界无法在不引入非可移植元数据的前提下可靠地区分"独立根列表"与"延续"。
兼容性与发布
- 两个字段均为可选、增量式,无需数据库迁移;
- 既有内容按今天的方式渲染与编辑;作者可以用 Continue numbering 修复被分隔的遗留列表;
- 该改动只做内存中的文档遍历,不增加数据库查询或登出路由的往返;
- 为
emdash与@emdash-cms/admin添加 patch changeset;面向用户的发布说明应写:"编号列表在跨内容块插入后仍能保持编号连续"。
测试计划
单元与集成测试必须覆盖(对应夹具见 numbered-list-continuity.test.ts、ordered-list.test.ts、portable-text-lists.test.ts 及 admin 侧 packages/admin/tests/editor/ordered-list.test.ts 等):
- PM → PT → PM 保留显式从 2 开始的列表;
- 1/2/3 列表被普通段落、带格式段落、代码块、图片与代表性插件块分隔后,保存/重载后仍从正确值继续;
- 插入或删除较早条目会为所有同 ID 的后续片段重新编号;
- 后来的独立列表仍从 1 开始;相邻同 ID 运行合并、相邻不同 ID 根运行保持分离;
- 键入
2.持久化基准值 2;Continue 与 Restart 产生预期身份与起始值、在多片段选区时禁用、Continue 在已链接前驱时禁用; - 嵌套有序列表计数器相互独立;混合 bullet/number 嵌套往返不改变结构;
- 复制/粘贴重映射身份、保留粘贴切片内关系、延续片段或部分列表的复制保留显示起始值、不并入源列表;
- 同上下文拖拽/移动保留身份;跨上下文移动获得全新身份并保留移动前显示起始值、不重写源作用域;
- 撤销/重做与两个协作端收敛,无规范化循环或随机 ID 分歧;
- 遗留内容、缺失
_key、超长/空 ID、跨根/嵌套上下文复用 ID、冲突基准值、非整数/负起始值、溢出——均不抛错、不输出非法 HTML;编辑器规范化确定性修复不兼容的重复上下文; - 管理后台编辑器与内联可视化编辑器产出等价 Portable Text;
- Astro 输出包含预期分离的
<ol>元素与后续start值,在html与direct两种模式下根级与嵌套级的不同 ID 列表分离,不修改输入,且尊重用户components.list.number覆盖; - Markdown 导入不从有歧义的嵌套与根列表序列合成连续性元数据;
- 新控件已本地化、键盘可访问、可在阿拉伯语/RTL 下使用;
- 既有 bullet 列表、列表嵌套、blockquote 分组测试套件保持绿色;query-count 快照不变。
每个实现提交后运行仓库要求检查:pnpm lint:quick、受影响的 Vitest 套件、对应包的pnpm typecheck;PR 前再运行格式化、全部相关测试、changeset 校验与pnpm lint:json | jq '.diagnostics | length'。
实现顺序:三个可评审提交
PR 应包含三个各自保持"类型、测试、运行时代码内部自洽"的提交:
fix(portable-text): preserve logical numbered-list identity——新增可选块字段、core 与 admin 的验证/规范化助手、共享测试向量与编号算法;更新 core/admin/inline 的 PM/PT 转换路径;在两个编辑器中加入最小化 EmDash ordered-list schema 扩展(声明listId/listStart)并禁用 StarterKit 的重复 ordered-list 节点,使提交 1 就能加载/保存新属性而不被剥离;添加失败先行的转换、遗留、畸形输入与嵌套测试;fix(editor): retain numbering when ordered lists are split——完成 EmDash ordered-list 扩展:确定性规范化器、身份感知命令、粘贴重映射、双编辑器的上下文感知拖拽行为;新增 admin Continue/Restart 控件(Lingui/Kumo)与编辑器、协作、撤销/重做、复制/粘贴、RTL 测试;fix(core): render continued ordered-list segments——新增渲染预处理、身份感知列表树构建与OrderedList.astro;添加 Astro 测试、两个包的 patch changeset,并验证 query-count 快照不变。
验收标准
当作者能够:创建条目 1 和 2 → 插入任意受支持的顶层块 → 继续创建条目 3 和 4 → 保存 → 重载 → 编辑较早条目,并在管理后台编辑器、内联编辑器与渲染后的 Astro 页面中看到完全一致的编号时,该 PR 即完成。存储的块共享一个稳定的listId与基准值,独立列表不合并,遗留内容保持可读,且上述全部测试与仓库检查通过。
相关源码索引
- 验证 / 规范化助手与编号算法:packages/core/src/content/converters/numbered-list.ts
- 渲染前列表树构建与编号应用:packages/core/src/content/portable-text-lists.ts
- 渲染入口(深克隆 + 树构建 + 编辑分支):packages/core/src/components/PortableText.astro
<ol start>渲染组件:packages/core/src/components/OrderedList.astro- 核心编辑器扩展(命令、规范化、粘贴/拖拽):packages/core/src/components/ordered-list.ts
- admin 编辑器等价扩展:packages/admin/src/components/editor/ordered-list.ts
- PT → PM 转换:packages/core/src/content/converters/portable-text-to-prosemirror.ts
- 转换回归测试(start:2 保真、分段推导):packages/core/tests/unit/converters/numbered-list-continuity.test.ts
- 渲染回归测试(start 输出、嵌套分离、组件覆盖):packages/core/tests/repro/portable-text-numbered-list.render.test.ts
- CMS
- 后端
- 前端
- 插件系统
【免费下载链接】emdash
EmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress
相关推荐
Pandoc `example_lists` 扩展深度解析:用 `(@)` 标记实现跨列表连续编号的示例列表
Pandoc example_lists 扩展深度解析:用 @ 标记实现跨列表连续编号的示例列表 导读 本文围绕 pandoc 命令回归测试 test/comm
文档开发工具CLIpoi-tl列表编号功能详解:支持多级有序列表渲染的终极指南
poi tl列表编号功能详解:支持多级有序列表渲染的终极指南 poi tl作为一款强大的Java Word文档模板引擎,其列表编号功能能够完美支持多级有序列表渲
模板引擎后端WeKan Markdown 编辑器:有序列表自动编号与 `3\.` 转义点号的正确写法
WeKan Markdown 编辑器:有序列表自动编号与 3\. 转义点号的正确写法 本文以 WeKan 文档 Numbered text.md https:/
后端前端协同办公
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考