Repository Wiki
Naptie/endfield-docmaker

模板定义与注册机制

模板定义与注册机制是 endfield-docmaker 的核心扩展点:它通过 TemplateDefinition 接口声明一个文档模板的完整契约(标识、表单字段、默认值、Typst 源码生成、文件名生成、存储版本),再由 src/lib/templates/index.ts 将所有模板聚合为 TEMPLATES 注册表并提供 getTemplate(id) 查找函数。新增一类文档(如试卷、公文)时,只需实现该契约并加入注册表,无需改动其余代码。

目的与范围

本页覆盖模板子系统的契约层与注册层,即:

  • TemplateDefinition 接口的逐字段语义(types.ts)
  • FormField 判别联合(discriminated union)的 11 种字段类型及其专属配置
  • TEMPLATES 注册表与 getTemplate 的查找/回退行为(index.ts)
  • 两个内置模板 testpaperTemplate 与 officialDocTemplate 的实现剖析,包括本地化懒加载标签、输入净化(parseYear/parseDate/escapeTypst)与默认值设计

以下内容有意留给兄弟页面,本页仅引用不展开:

  • 表单字段的具体渲染与交互(字段组件、VFS 文件列表 UI)——见表单渲染相关页面
  • storageVersion 驱动的持久化与迁移(表单值如何存入 IndexedDB/云存储)——见存储与同步相关页面
  • Typst 源码到 PDF 的编译管线——见文档生成/导出相关页面

概述

endfield-docmaker 是一个"填表生成文档"类应用:用户在动态表单中填写内容,应用据此生成 Typst 排版源码并导出文档。为了让不同文档类型(试卷、公文)复用同一套表单/生成/存储基础设施,代码将"一个文档类型长什么样"抽象为纯数据 + 纯函数的 TemplateDefinition 对象:

  • 纯数据:fields 数组描述表单结构,gridCols 描述栅格布局;
  • 纯函数:label/name 通过 Paraglide 的 m 消息函数实现本地化懒求值,defaults()、generateTypstSource(values)、getFileName(values) 则把"值 → 初始状态 / Typst 源码 / 文件名"的映射封装在模板内部。

这样设计带来三个收益:

  1. 声明式扩展:新增模板 = 新增一个对象字面量 + 在 TEMPLATES 数组中注册,调用方通过 id 解耦;
  2. 类型安全:FormField 是以 type 字段区分的判别联合,TypeScript 能在 fields 数组上对每种字段做穷尽检查;
  3. 本地化惰性求值:label: () => string 而非 label: string,模板对象在模块加载期创建时不会触发 i18n 消息解析,只在真正渲染时求值。

架构

模板子系统由三层组成:契约层(types.ts)、实现层(testpaper.ts、official-doc.ts)、注册层(index.ts)。注册层同时是唯一的对外出口,负责类型再导出。

Loading diagram...

要点解读:

  • 依赖方向单一:实现层只依赖契约层与公共常量($lib/constants 的 ISSUERS 等)及 Paraglide 消息 m,不依赖任何 UI 或存储模块,因此模板定义是可独立测试的纯对象。
  • TEMPLATES 数组即注册表:index.ts 第 5 行硬编码 [testpaperTemplate, officialDocTemplate],数组顺序同时决定了 getTemplate 未命中时的回退目标(TEMPLATES[0],即试卷模板)。
  • 类型出口集中:调用方统一从 $lib/templates 导入 TemplateDefinition、FormField 类型(index.ts 末尾的 re-export),避免直接触碰内部文件路径。

契约详解:TemplateDefinition 与 FormField

TemplateDefinition 接口

TemplateDefinition 是整个子系统的核心契约,位于 src/lib/templates/types.ts:

typescript
1/** A complete template definition. */ 2export interface TemplateDefinition { 3 /** Unique identifier (used as storage key suffix). */ 4 id: string; 5 /** Localised display name. */ 6 name: () => string; 7 /** CSS grid column count at the `sm` breakpoint (defaults to 3). */ 8 gridCols?: number; 9 /** Ordered list of form fields. */ 10 fields: FormField[]; 11 /** Factory that returns the initial/default form values. */ 12 defaults: () => Record<string, unknown>; 13 /** Build a Typst source string from the current form values. */ 14 generateTypstSource: (values: Record<string, unknown>) => string; 15 /** Build a human-friendly file name from the current form values. */ 16 getFileName: (values: Record<string, unknown>) => string; 17 /** Bumped whenever the storage shape changes. */ 18 storageVersion: number; 19}

Source: types.ts

逐字段设计意图:

字段类型默认说明
idstring—(必填)模板唯一标识。注释明确指出它"被用作存储键后缀",即持久化层按模板隔离数据,这也是它必须是稳定字符串而非索引的原因
name() => string—(必填)本地化模板名,惰性求值,渲染时才调用 Paraglide 消息函数
gridColsnumber(可选)3(注释:defaults to 3)sm 断点下的 CSS 栅格列数,配合每个字段的 colspan 共同决定表单布局
fieldsFormField[]—(必填)有序字段列表,顺序即渲染顺序
defaults() => Record<string, unknown>—(必填)初始表单值工厂。用函数而非对象字面量,避免多实例共享同一引用(例如 examInfo、authorities 这类数组默认值被意外篡改)
generateTypstSource(values) => string—(必填)把当前表单值编译为 Typst 源码字符串,模板独占排版逻辑
getFileName(values) => string—(必填)依据表单值生成导出文件名(人类可读)
storageVersionnumber—(必填)存储结构版本号,"存储形态变化时递增",供迁移逻辑判断旧数据是否需要升级

值得注意的边界决策:defaults/generateTypstSource/getFileName 的参数与返回值都用宽泛的 Record<string, unknown>,而非各模板自定义的 TestpaperValues/OfficialDocValues。模板实现内部自行收窄类型,契约保持通用;代价是字段 key 拼写错误只能在模板内部靠类型断言防护。

BaseField 与字段判别联合

所有字段共享一个 BaseField 基础接口,再按 type 判别:

typescript
1/** Base definition shared by all form fields. */ 2interface BaseField { 3 /** Unique key used as the data property name. */ 4 key: string; 5 /** Localised label shown in the UI. */ 6 label: () => string; 7 /** 8 * Number of CSS grid columns this field spans. 9 * Defaults to the full row width when omitted. 10 */ 11 colspan?: number; 12}

Source: types.ts

key 的注释点明它是"数据属性名"——表单值对象的属性名与字段一一对应,defaults() 返回的对象键必须与这些 key 对齐。colspan 缺省时占满整行。

11 种字段类型(FormField 联合成员)及其专属配置:

字段类型专属属性用途
TextFieldplaceholder?: () => string单行文本
TextareaFieldplaceholder?: () => string; grow?: boolean; minHeight?: number多行文本;grow: true 时用 flex-1 填充剩余空间,minHeight 对应 Tailwind min-h-*
SelectFieldoptions: { value: string; label: () => string }[]; placeholder?: () => string下拉选择
NumberFieldmin?: number; max?: number; placeholder?: string数字输入(注意 placeholder 是普通字符串而非函数)
ToggleFielddescription?: () => string开关
DateField—日期(年/月/日三段,见 official-doc 的 issueDate 默认值结构)
AuthoritiesFieldmaxItems?: number签发机构列表(限定条数,如公文模板 maxItems: 9)
KvGridField—键值对网格(如试卷的 examInfo)
FileListFielddefaultFiles?: { name: string; url: string }[]文件列表;注释说明首次使用模板时预载内置资源(name 进入 VFS,url 为构建期 import)
PrefixedInputFieldprefixKey: string; prefixes: { value: string; label: () => string }[]; placeholder?: () => string带前缀选择的输入框
CustomFieldcomponent: Component<any>自定义 Svelte 组件字段,逃生舱设计

判别联合的定义方式:

typescript
1export type FormField = 2 | TextField 3 | TextareaField 4 | SelectField 5 | NumberField 6 | ToggleField 7 | DateField 8 | AuthoritiesField 9 | KvGridField 10 | FileListField 11 | PrefixedInputField 12 | CustomField;

Source: types.ts

CustomField.component 使用 Component<any>(源码中带有 eslint-disable 注释压制 no-explicit-any):这是刻意的松类型逃生舱,允许模板挂载任意 Svelte 组件而不必为每类专有 UI 扩展联合类型——代价是把类型检查责任转移给组件作者。

注册层:TEMPLATES 与 getTemplate

注册层全文极短,却是唯一的对外门面:

typescript
1import { officialDocTemplate } from './official-doc'; 2import { testpaperTemplate } from './testpaper'; 3import type { TemplateDefinition } from './types'; 4 5export const TEMPLATES: TemplateDefinition[] = [testpaperTemplate, officialDocTemplate]; 6 7export const getTemplate = (id: string): TemplateDefinition => 8 TEMPLATES.find((t) => t.id === id) ?? TEMPLATES[0]; 9 10export type { TemplateDefinition } from './types'; 11export type { FormField } from './types';

Source: index.ts

三个关键行为:

  1. 注册即插即用:模板顺序即注册顺序,testpaper 在前、official-doc 在后。
  2. 静默回退:getTemplate 用 ?? TEMPLATES[0] 兜底——传入未知 id(例如旧书签、损坏的存储数据中的 id)不会抛异常,而是回退到试卷模板。这是防御式设计:存储键中的 id 永远有效,UI 不会因模板缺失而崩溃。
  3. 类型再导出:调用方 import type { TemplateDefinition } from '$lib/templates' 即可,内部文件路径不外泄。

同时注意:注册表是模块级常量(非 DI、非动态发现),因此模板集合在构建期固定。要新增模板必须修改这一行数组——简单、可静态分析,但牺牲了运行时插件化能力。

内置模板实现剖析

testpaperTemplate(试卷模板)

src/lib/templates/testpaper.ts 定义了 id: 'testpaper'、storageVersion: 3、gridCols: 3 的模板。它先声明了一个强类型值接口(供模板内部使用,而非契约层):

typescript
1export interface TestpaperValues { 2 issuer: IssuerKey; 3 year: string; 4 title: string; 5 subject: string; 6 paperSize: string; 7 examType: string; 8 examDuration: string; 9 examInfo: KvEntry[]; 10 showAnswer: boolean; 11 parJustify: boolean; 12 secret: string; 13 showScoreBox: boolean; 14 docContent: string; 15}

Source: testpaper.ts

字段声明部分展示了判别联合的典型用法——选项来自常量模块 ISSUERS,标签全部走 Paraglide 消息函数:

typescript
1{ 2 type: 'select', 3 key: 'issuer', 4 label: () => m.issuer(), 5 placeholder: () => m.select_issuer(), 6 options: ISSUERS.map((i) => ({ 7 value: i.key, 8 label: () => m[`issuer_${i.key}`]() 9 })), 10 colspan: 1 11},

Source: testpaper.ts

FileListField.defaultFiles 的预载模式:通过 Vite 的 ?url import 把构建期资产注入 VFS——

typescript
1{ 2 type: 'file-list', 3 key: 'files', 4 label: () => m.file_list(), 5 defaultFiles: [ 6 { name: '6.png', url: img6 }, 7 { name: '17.png', url: img17 } 8 ] 9},

Source: testpaper.ts

defaults() 提供开箱即用的示例值(含 examInfo: KvEntry[] 数组、showAnswer 等布尔开关),使新用户第一次进入即得到一份可预览的完整试卷。模板文件头部还定义了两段净化逻辑,用于在生成 Typst 前抵御非法用户输入:

typescript
1const escapeTypst = (s: string) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); 2 3const parseYear = (raw: string): number => { 4 const n = parseInt(raw, 10); 5 return isNaN(n) || n <= 0 ? 152 : year_fallback_placeholder; 6};

Source: testpaper.ts

说明:上例中 parseYear 的回退表达式在源码中返回字面量 152(文档为避免误抄以占位符标示,请以源文件为准——return isNaN(n) || n <= 0 ? 152 : n;)。escapeTypst 转义反斜杠与双引号,防止用户文本破坏 Typst 字符串语法;parseYear 把非正数/非数字年份钳制为世界观默认年份 152。NumberField 虽有 min: 1 的 UI 约束,但模板仍做服务前校验,因为 generateTypstSource 可能在值未经 UI 校验的路径上被调用。

officialDocTemplate(公文模板)

src/lib/templates/official-doc.ts 定义 id: 'official-doc'、storageVersion: 4。其值接口引入了嵌套结构:

typescript
1export interface OfficialDocValues { 2 issuer: IssuerKey; 3 issuerCode: string; 4 authorities: Authority[]; 5 refNo: string; 6 docTitle: string; 7 issueDate: { year: string; month: string; day: string }; 8 docContent: string; 9}

Source: official-doc.ts

issueDate 是三段式日期(年/月/日各为字符串),对应的 DateField 没有附加配置——结构由默认值定义:

typescript
1{ 2 type: 'date', 3 key: 'issueDate', 4 label: () => m.issue_date(), 5 colspan: 1 6},

Source: official-doc.ts

AuthoritiesField 限制最多 9 个签发机构:

typescript
1{ 2 type: 'authorities', 3 key: 'authorities', 4 label: () => m.authorities(), 5 maxItems: 9 6},

Source: official-doc.ts

该模板同样在生成前做日期净化,逐字段钳制到合法区间(年回退 150,月钳到 1–12,日钳到 1–31):

typescript
1const parseDate = (date: { year: string; month: string; day: string }) => { 2 const year = parseInt(date.year, 10); 3 const month = parseInt(date.month, 10); 4 const day = parseInt(date.day, 10); 5 return { 6 year: isNaN(year) || year <= 0 ? 150 : year, 7 month: isNaN(month) || month < 1 || month > 12 ? 1 : month, 8 day: isNaN(day) || day < 1 || day > 31 ? 1 : day 9 }; 10};

Source: official-doc.ts

其 defaults() 返回完整的公文示例,包括两个 Authority(faction 取 ISSUERS[0].key,name 为"纪律检查委员会"/"人事管理局")与一段以 Typst 标记语法(= 一级标题、== 二级标题)书写的示例正文,用户可以直接在文本域里学习 Typst 标题语法。

核心流程:模板从注册到使用

Loading diagram...

时序中的三个设计要点:

  • 查找在先、渲染在后:getTemplate 一次解析后复用同一对象,字段元数据(fields)驱动表单 UI 构建。
  • 本地化延迟到渲染期:模板对象在模块加载时只创建箭头函数,不触碰 i18n 运行时;切换语言时再次调用 label() 即得到新译文,无需重建模板。
  • 净化紧贴生成:escapeTypst/parseYear/parseDate 都是模块私有函数,只在 generateTypstSource 内部调用,属于"信任边界收口"——无论 UI 校验是否存在,输出给 Typst 编译器的字符串总是转义过的。

失败模式与边界情况

场景行为源码依据
getTemplate 收到未知 id静默回退到 TEMPLATES[0](试卷模板),不抛异常index.ts
年份输入非数字或 <= 0试卷钳制为 152,公文钳制为 150testpaper.ts、official-doc.ts
月份不在 1–12、日期不在 1–31均回退为 1(钳制而非截断)official-doc.ts
用户文本含 \ 或 "escapeTypst 双重转义,保护 Typst 字符串语法testpaper.ts
存储结构演进递增 storageVersion(试卷 3,公文 4),由持久化层据此迁移旧数据testpaper.ts、official-doc.ts
模板需非标准 UI使用 CustomField.component: Component<any> 逃生舱,不做类型收窄types.ts

并发与纯度:TemplateDefinition 上没有可变实例状态;defaults() 每次返回新对象(数组默认值不会被跨会话共享篡改),generateTypstSource/getFileName 是纯函数(对同一 values 恒等输出),因此模板对象可被并发/多实例安全地复用。

扩展点:如何新增一个模板

  1. 新建 src/lib/templates/<name>.ts,声明本地 XxxValues 接口并实现 TemplateDefinition(复用 $lib/constants 的 ISSUERS 等常量、Paraglide m 消息函数)。
  2. 设定稳定 id(成为存储键后缀)与 storageVersion: 1 起步。
  3. 在 index.ts 的 TEMPLATES 数组中追加导出的模板对象;新模板自动获得 getTemplate 查找与回退语义。
  4. 需要新增字段形态时优先扩展 FormField 联合(在 types.ts 中新增接口),仅当确实无法表达时使用 CustomField。
  5. 输出到 generateTypstSource 的所有用户文本必须经过转义(参考 escapeTypst),数值需钳制(参考 parseYear/parseDate),保证输出始终是合法 Typst 源码。

相关链接

存储迁移细节、表单字段渲染组件与 Typst 编译管线由对应的兄弟页面覆盖,本页不展开。