模板定义与注册机制
模板定义与注册机制是 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 源码 / 文件名"的映射封装在模板内部。
这样设计带来三个收益:
- 声明式扩展:新增模板 = 新增一个对象字面量 + 在
TEMPLATES数组中注册,调用方通过id解耦; - 类型安全:
FormField是以type字段区分的判别联合,TypeScript 能在fields数组上对每种字段做穷尽检查; - 本地化惰性求值:
label: () => string而非label: string,模板对象在模块加载期创建时不会触发 i18n 消息解析,只在真正渲染时求值。
架构
模板子系统由三层组成:契约层(types.ts)、实现层(testpaper.ts、official-doc.ts)、注册层(index.ts)。注册层同时是唯一的对外出口,负责类型再导出。
要点解读:
- 依赖方向单一:实现层只依赖契约层与公共常量(
$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:
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
逐字段设计意图:
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
id | string | —(必填) | 模板唯一标识。注释明确指出它"被用作存储键后缀",即持久化层按模板隔离数据,这也是它必须是稳定字符串而非索引的原因 |
name | () => string | —(必填) | 本地化模板名,惰性求值,渲染时才调用 Paraglide 消息函数 |
gridCols | number(可选) | 3(注释:defaults to 3) | sm 断点下的 CSS 栅格列数,配合每个字段的 colspan 共同决定表单布局 |
fields | FormField[] | —(必填) | 有序字段列表,顺序即渲染顺序 |
defaults | () => Record<string, unknown> | —(必填) | 初始表单值工厂。用函数而非对象字面量,避免多实例共享同一引用(例如 examInfo、authorities 这类数组默认值被意外篡改) |
generateTypstSource | (values) => string | —(必填) | 把当前表单值编译为 Typst 源码字符串,模板独占排版逻辑 |
getFileName | (values) => string | —(必填) | 依据表单值生成导出文件名(人类可读) |
storageVersion | number | —(必填) | 存储结构版本号,"存储形态变化时递增",供迁移逻辑判断旧数据是否需要升级 |
值得注意的边界决策:defaults/generateTypstSource/getFileName 的参数与返回值都用宽泛的 Record<string, unknown>,而非各模板自定义的 TestpaperValues/OfficialDocValues。模板实现内部自行收窄类型,契约保持通用;代价是字段 key 拼写错误只能在模板内部靠类型断言防护。
BaseField 与字段判别联合
所有字段共享一个 BaseField 基础接口,再按 type 判别:
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 联合成员)及其专属配置:
| 字段类型 | 专属属性 | 用途 |
|---|---|---|
TextField | placeholder?: () => string | 单行文本 |
TextareaField | placeholder?: () => string; grow?: boolean; minHeight?: number | 多行文本;grow: true 时用 flex-1 填充剩余空间,minHeight 对应 Tailwind min-h-* |
SelectField | options: { value: string; label: () => string }[]; placeholder?: () => string | 下拉选择 |
NumberField | min?: number; max?: number; placeholder?: string | 数字输入(注意 placeholder 是普通字符串而非函数) |
ToggleField | description?: () => string | 开关 |
DateField | — | 日期(年/月/日三段,见 official-doc 的 issueDate 默认值结构) |
AuthoritiesField | maxItems?: number | 签发机构列表(限定条数,如公文模板 maxItems: 9) |
KvGridField | — | 键值对网格(如试卷的 examInfo) |
FileListField | defaultFiles?: { name: string; url: string }[] | 文件列表;注释说明首次使用模板时预载内置资源(name 进入 VFS,url 为构建期 import) |
PrefixedInputField | prefixKey: string; prefixes: { value: string; label: () => string }[]; placeholder?: () => string | 带前缀选择的输入框 |
CustomField | component: Component<any> | 自定义 Svelte 组件字段,逃生舱设计 |
判别联合的定义方式:
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
注册层全文极短,却是唯一的对外门面:
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
三个关键行为:
- 注册即插即用:模板顺序即注册顺序,
testpaper在前、official-doc在后。 - 静默回退:
getTemplate用?? TEMPLATES[0]兜底——传入未知id(例如旧书签、损坏的存储数据中的 id)不会抛异常,而是回退到试卷模板。这是防御式设计:存储键中的 id 永远有效,UI 不会因模板缺失而崩溃。 - 类型再导出:调用方
import type { TemplateDefinition } from '$lib/templates'即可,内部文件路径不外泄。
同时注意:注册表是模块级常量(非 DI、非动态发现),因此模板集合在构建期固定。要新增模板必须修改这一行数组——简单、可静态分析,但牺牲了运行时插件化能力。
内置模板实现剖析
testpaperTemplate(试卷模板)
src/lib/templates/testpaper.ts 定义了 id: 'testpaper'、storageVersion: 3、gridCols: 3 的模板。它先声明了一个强类型值接口(供模板内部使用,而非契约层):
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 消息函数:
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——
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 前抵御非法用户输入:
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。其值接口引入了嵌套结构:
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 没有附加配置——结构由默认值定义:
1{
2 type: 'date',
3 key: 'issueDate',
4 label: () => m.issue_date(),
5 colspan: 1
6},Source: official-doc.ts
AuthoritiesField 限制最多 9 个签发机构:
1{
2 type: 'authorities',
3 key: 'authorities',
4 label: () => m.authorities(),
5 maxItems: 9
6},Source: official-doc.ts
该模板同样在生成前做日期净化,逐字段钳制到合法区间(年回退 150,月钳到 1–12,日钳到 1–31):
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 标题语法。
核心流程:模板从注册到使用
时序中的三个设计要点:
- 查找在先、渲染在后:
getTemplate一次解析后复用同一对象,字段元数据(fields)驱动表单 UI 构建。 - 本地化延迟到渲染期:模板对象在模块加载时只创建箭头函数,不触碰 i18n 运行时;切换语言时再次调用
label()即得到新译文,无需重建模板。 - 净化紧贴生成:
escapeTypst/parseYear/parseDate都是模块私有函数,只在generateTypstSource内部调用,属于"信任边界收口"——无论 UI 校验是否存在,输出给 Typst 编译器的字符串总是转义过的。
失败模式与边界情况
| 场景 | 行为 | 源码依据 |
|---|---|---|
getTemplate 收到未知 id | 静默回退到 TEMPLATES[0](试卷模板),不抛异常 | index.ts |
年份输入非数字或 <= 0 | 试卷钳制为 152,公文钳制为 150 | testpaper.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 恒等输出),因此模板对象可被并发/多实例安全地复用。
扩展点:如何新增一个模板
- 新建
src/lib/templates/<name>.ts,声明本地XxxValues接口并实现TemplateDefinition(复用$lib/constants的ISSUERS等常量、Paraglidem消息函数)。 - 设定稳定
id(成为存储键后缀)与storageVersion: 1起步。 - 在
index.ts的TEMPLATES数组中追加导出的模板对象;新模板自动获得getTemplate查找与回退语义。 - 需要新增字段形态时优先扩展
FormField联合(在types.ts中新增接口),仅当确实无法表达时使用CustomField。 - 输出到
generateTypstSource的所有用户文本必须经过转义(参考escapeTypst),数值需钳制(参考parseYear/parseDate),保证输出始终是合法 Typst 源码。
相关链接
- 类型契约源码:src/lib/templates/types.ts
- 注册门面:src/lib/templates/index.ts
- 试卷模板:src/lib/templates/testpaper.ts
- 公文模板:src/lib/templates/official-doc.ts
- 签发方常量(
ISSUERS、getLogoScales、issuerExt):src/lib/constants.ts
存储迁移细节、表单字段渲染组件与 Typst 编译管线由对应的兄弟页面覆盖,本页不展开。