ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

EmDash 跨块有序列表编号连续性:listId / listStart 数据模型与编辑器、渲染层实现解析

2026/9/23 10:38:03 拓冰建站 浏览量
EmDash 跨块有序列表编号连续性:listId / listStart 数据模型与编辑器、渲染层实现解析 CMS后端前端插件系统【免费下载链接】emdashEmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress项目地址https://gitcode.com/gh_mirrors/emdas/emdash点击查看免费下载导读本文基于 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()生成 IDlistStart该逻辑列表的基础起始值携带同一listId的每个编号块都会重复写入这个值。注意它不是后续片段的推导起始值而是基准值可见起始值visible start每个片段的可见起始值 基础值 前面与该listId相同的直接条目direct items数量。源码中的验证契约在仓库中该验证契约已落地为独立助手模块 packages/core/src/content/converters/numbered-list.ts其中normalizeListId(value)合法的listId是trim 后非空、且不超过 128 字符的字符串任何其他值都视为缺失且绝不写入 HTMLnormalizeListStart(value)合法的listStart是 1 到2,147,483,647MAX_ORDERED_LIST_START与HTMLOListElement.start可一致表示的正数范围一致的整数。推导结果base precedingDirectItemCount必须保持在该范围内否则该畸形分组的有效起始值按 1 处理而不是依赖浏览器各自不同的钳制行为deriveLegacyListId(seed)为无元数据的遗留内容生成确定性、文档作用域的 IDlegacy:...前缀超长时用 FNV 式哈希缩短readOrderedListMetadata(attrs, fallbackId)读取节点属性优先取listId缺失时退回遗留派生 IDlistStart缺失时退回start属性再退回 1。规范还约定了三条边界规则均已在normalizeProseMirrorOrderedListJson中实现一个listId只能属于一个嵌套深度与父上下文。若畸形输入在不兼容的上下文中复用了它第一个上下文保留该 ID编辑器规范化会确定性地位于后出现的每个上下文分配一个有界的替换 IDcreateRepairId基于原 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规范化后的身份决定两个编号块是否属于同一列表节点matchesListapplyNumbering则按作用域遍历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 且基准值为 1N.输入规则则生成全新 ID 且基准值为NORDERED_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/renderHTMLdata-emdash-list-id、data-emdash-list-start、data-emdash-list-first。这些属性只被编辑器解析器接受、插入前会被重映射前端渲染绝不输出粘贴重映射粘贴时粘贴切片中每个不同的连续性作用域都被重映射为全新 ID同时保留切片内各片段间的关系第一个粘贴片段的有效传入start而非重复写入的源listStart成为新基准值并盖章到整个重映射组。因此只复制显示为 3的延续部分再粘贴得到的是从 3 开始的独立列表而不是从 1 开始从片段中途开始的剪贴板切片同样以第一个复制条目的显示编号为基准。无元数据的外部列表每个连续的有序列表运行获得全新 ID并把合法 HTML/TipTapstart保留为基准值。remapPastedSlice与prepareCopiedSlice分别实现了这两条路径内部拖拽/移动仅当目标位置深度与父上下文相同时保留 ID跨上下文移动会把被移动作用域重映射为全新 ID其基准值为移动前的有效起始值留在源上下文中的片段保留旧 IDremapMovedSlicehandleMovedListDrop判定是否仍留在原上下文撤销 / 重做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失败夹具变得有意义把两个值盖章到该节点产出的每个编号块上包括当前层级直接条目的块下钻到嵌套有序列表时使用嵌套节点自身的元数据绝不把父身份复制进嵌套组不把有效的 TipTapstart写成listStart——持久化的是分组基准值。Portable Text → ProseMirror按列表类型、层级/树位置与规范化的listId分组编号运行而不是仅凭相邻性与listItem类型为每个orderedList节点构造listId、规范化的listStart以及算法算出的有效start遗留连续运行尽量用键派生 IDkey-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且第二段含start3在html与direct两种嵌套模式下根级与嵌套级的不同 ID 列表保持分离各自保留start2、start7、start4渲染不修改输入 value用户components.list.number覆盖生效自定义组件输出data-start3。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.持久化基准值 2Continue 与 Restart 产生预期身份与起始值、在多片段选区时禁用、Continue 在已链接前驱时禁用嵌套有序列表计数器相互独立混合 bullet/number 嵌套往返不改变结构复制/粘贴重映射身份、保留粘贴切片内关系、延续片段或部分列表的复制保留显示起始值、不并入源列表同上下文拖拽/移动保留身份跨上下文移动获得全新身份并保留移动前显示起始值、不重写源作用域撤销/重做与两个协作端收敛无规范化循环或随机 ID 分歧遗留内容、缺失_key、超长/空 ID、跨根/嵌套上下文复用 ID、冲突基准值、非整数/负起始值、溢出——均不抛错、不输出非法 HTML编辑器规范化确定性修复不兼容的重复上下文管理后台编辑器与内联可视化编辑器产出等价 Portable TextAstro 输出包含预期分离的ol元素与后续start值在html与direct两种模式下根级与嵌套级的不同 ID 列表分离不修改输入且尊重用户components.list.number覆盖Markdown 导入不从有歧义的嵌套与根列表序列合成连续性元数据新控件已本地化、键盘可访问、可在阿拉伯语/RTL 下使用既有 bullet 列表、列表嵌套、blockquote 分组测试套件保持绿色query-count 快照不变。每个实现提交后运行仓库要求检查pnpm lint:quick、受影响的 Vitest 套件、对应包的pnpm typecheckPR 前再运行格式化、全部相关测试、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.astrool start渲染组件packages/core/src/components/OrderedList.astro核心编辑器扩展命令、规范化、粘贴/拖拽packages/core/src/components/ordered-list.tsadmin 编辑器等价扩展packages/admin/src/components/editor/ordered-list.tsPT → 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后端前端插件系统【免费下载链接】emdashEmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress项目地址https://gitcode.com/gh_mirrors/emdas/emdash点击查看免费下载相关推荐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:/后端前端协同办公上一篇DDDDocr 开源项目教程下一篇Hugoplate高级技巧10个让你的静态网站性能提升95%的配置创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考