From 6b53906d08b8f7edf83d614f6e2184f6cb51dca5 Mon Sep 17 00:00:00 2001 From: CPTProgrammer <46586216+CPTProgrammer@users.noreply.github.com> Date: Wed, 8 Jul 2026 05:29:07 +0800 Subject: [PATCH] Add template engine with `Template` class and `SemVer` support --- src/lib/utils/template.test.ts | 154 +++++++++++++++++++ src/lib/utils/template.ts | 196 +++++++++++++++++++++++++ src/lib/utils/templates/semver.test.ts | 68 +++++++++ src/lib/utils/templates/semver.ts | 49 +++++++ 4 files changed, 467 insertions(+) create mode 100644 src/lib/utils/template.test.ts create mode 100644 src/lib/utils/template.ts create mode 100644 src/lib/utils/templates/semver.test.ts create mode 100644 src/lib/utils/templates/semver.ts diff --git a/src/lib/utils/template.test.ts b/src/lib/utils/template.test.ts new file mode 100644 index 0000000..0832485 --- /dev/null +++ b/src/lib/utils/template.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from "vitest"; +import { Template } from "./template"; +import { SemVer } from "./templates/semver"; + +// ── format ──────────────────────────────────────────── + +describe("Template.format", () => { + test("纯字面量", () => { + const t = new Template("hello"); + expect(t.format({})).toBe("hello"); + }); + + test("单个 ${}", () => { + const t = new Template("v${ver}"); + expect(t.format({ ver: "1.0" })).toBe("v1.0"); + }); + + test("多个 ${}", () => { + const t = new Template("${a}-${b}"); + expect(t.format({ a: "x", b: "y" })).toBe("x-y"); + }); + + test("${} + TemplateValue", () => { + const t = new Template("${v}"); + expect(t.format({ v: new SemVer(1, 0) })).toBe("1.0"); + }); + + test("#{} 递归", () => { + const t = new Template("#{fmt}", { fmt: "mod-${ver}" }); + expect(t.format({ ver: "1.0" })).toBe("mod-1.0"); + }); + + test("#{} 多层嵌套", () => { + const t = new Template("#{a}", { + a: "#{b}", + b: "${x}", + }); + expect(t.format({ x: "ok" })).toBe("ok"); + }); + + test("混合 ${} 和 #{}", () => { + const t = new Template("v${ver}-#{name}", { + name: "Mod-${ver}", + }); + expect(t.format({ ver: "1.0" })).toBe("v1.0-Mod-1.0"); + }); + + test("缺失 ${} 变量", () => { + const t = new Template("${x}"); + expect(() => t.format({})).toThrow('Unknown variable: "x"'); + }); + + test("缺失 #{} 模板", () => { + const t = new Template("#{x}"); + expect(() => t.format({})).toThrow('Unknown template: "x"'); + }); + + test("循环引用", () => { + const t = new Template("#{a}", { + a: "#{b}", + b: "#{a}", + }); + expect(() => t.format({})).toThrow('Circular template reference: "a"'); + }); + + test("超深递归", () => { + // 构建 21 层链:k0→k1→...→k20 + const templates: Record = {}; + for (let i = 0; i < 20; i++) { + templates[`k${i}`] = `#{k${i + 1}}`; + } + templates.k20 = "done"; + const t = new Template("#{k0}", templates); + expect(() => t.format({})).toThrow("exceeded max depth"); + }); +}); + +// ── parse ───────────────────────────────────────────── + +describe("Template.parse", () => { + test("单个 ${}", () => { + const t = new Template("v${ver}"); + const result = t.parse("v1.0"); + expect(result).toEqual({ ver: "1.0" }); + }); + + test("多个 ${}", () => { + const t = new Template("${a}-${b}"); + const result = t.parse("x-y"); + expect(result).toEqual({ a: "x", b: "y" }); + }); + + test("${} + parser", () => { + const t = new Template("${ver}"); + const result = t.parse("1.0", { ver: SemVer.parse }); + expect(result.ver).toBeInstanceOf(SemVer); + expect((result.ver as SemVer).major).toBe(1); + expect((result.ver as SemVer).minor).toBe(0); + }); + + test("${} 无 parser → string", () => { + const t = new Template("${raw}"); + const result = t.parse("hello"); + expect(result).toEqual({ raw: "hello" }); + }); + + test("#{} 穿透", () => { + const t = new Template("#{fmt}", { + fmt: "mod-${ver}.jar", + }); + const result = t.parse("mod-1.0.jar"); + expect(result).toMatchObject({ + fmt: "mod-1.0.jar", + ver: "1.0", + }); + }); + + test("#{} 多层穿透", () => { + const t = new Template("#{a}", { + a: "${v}-#{b}", + b: "mc${mc}", + }); + const result = t.parse("1.0-mc1.20"); + expect(result).toMatchObject({ + a: "1.0-mc1.20", + v: "1.0", + b: "mc1.20", + mc: "1.20", + }); + }); + + test("不匹配", () => { + const t = new Template("v${ver}"); + expect(() => t.parse("xyz")).toThrow("Failed to parse"); + }); + + test("循环引用", () => { + const t = new Template("#{a}", { + a: "#{b}", + b: "#{a}", + }); + expect(() => t.parse("anything")).toThrow( + 'Circular template reference: "a"', + ); + }); + + test("类型推断:result.ver 为 SemVer", () => { + const t = new Template("${ver}"); + const result = t.parse("1.0.0", { ver: SemVer.parse }); + // 编译时类型断言:TS 应推导 result.ver 为 SemVer + const v: SemVer = result.ver; + expect(v.format()).toBe("1.0.0"); + }); +}); diff --git a/src/lib/utils/template.ts b/src/lib/utils/template.ts new file mode 100644 index 0000000..01c0c56 --- /dev/null +++ b/src/lib/utils/template.ts @@ -0,0 +1,196 @@ +const MAX_DEPTH = 20; + +/** 值类型必须实现的序列化接口 */ +export interface TemplateValue { + format(): string; +} + +/** 解析器:从字符串解析为类型 T */ +export type Parser = (raw: string) => T; + +/** 从 Parser 记录推导出 parse 结果的类型 */ +export type Parsed

>> = { + [K in keyof P]: ReturnType; +}; + +/** + * 模板引擎。 + * + * 支持两种占位符: + * - `${key}` — 叶子:直接替换为值,不再递归 + * - `#{key}` — 间接引用:查 `templates` 获取模板再递归解析 + */ +export class Template { + readonly source: string; + readonly templates: Readonly>; + + constructor(source: string, templates?: Record) { + this.source = source; + this.templates = templates ? { ...templates } : {}; + } + + // ── 正向 format ────────────────────────────────────── + + /** + * 正向 format:递归替换所有占位符直到结果中不再有 `${}` / `#{}`。 + * + * - `${key}` 从 `values` 取值,`TemplateValue` 调 `.format()`,`string` 直接用 + * - `#{key}` 从 `this.templates` 取模板并递归 resolve + * - 检测循环引用与超深递归 + */ + format(values: Record): string { + return this.resolve(this.source, values, new Set(), 0); + } + + private resolve( + input: string, + values: Record, + visited: ReadonlySet, + depth: number, + ): string { + if (depth > MAX_DEPTH) { + throw new Error( + `Template resolution exceeded max depth (${MAX_DEPTH})`, + ); + } + + return input.replace( + /\$\{(\w+)\}|#\{(\w+)\}/g, + ( + _match: string, + dollarKey: string | undefined, + hashKey: string | undefined, + ) => { + if (dollarKey !== undefined) { + const val = values[dollarKey]; + if (val === undefined) { + throw new Error(`Unknown variable: "${dollarKey}"`); + } + return typeof val === "string" ? val : val.format(); + } + if (hashKey !== undefined) { + if (visited.has(hashKey)) { + throw new Error(`Circular template reference: "${hashKey}"`); + } + const tmpl = this.templates[hashKey]; + if (tmpl === undefined) { + throw new Error(`Unknown template: "${hashKey}"`); + } + return this.resolve( + tmpl, + values, + new Set([...visited, hashKey]), + depth + 1, + ); + } + return _match; // 理论不可达,兜底 + }, + ); + } + + // ── 反向 parse ────────────────────────────────────── + + /** + * 反向 parse:用模板模式从具体字符串中提取变量。 + * + * - `${key}` 捕获对应位置的文本,若 `parsers` 中注册了同名 parser 则调用包装 + * - `#{key}` 捕获后查 `this.templates[key]`,对捕获值递归 parse 穿透 + * - 检测循环引用 + * + * 未注册 parser 的 key 保留为原始 string,但不出现在 `Parsed

` 类型中, + * 需要时用 `(result as Record)["key"]` 访问。 + */ + parse>>( + concrete: string, + parsers?: P, + visited: ReadonlySet = new Set(), + ): Parsed

{ + const { regex, keys } = buildParseRegex(this.source); + const match = regex.exec(concrete); + + if (!match) { + throw new Error( + `Failed to parse "${concrete}" with template "${this.source}"`, + ); + } + + const captures = match.slice(1); + const result: Record = {}; + + for (let i = 0; i < keys.length; i++) { + const { key, type } = keys[i]; + const value = captures[i]; + + if (type === "indirect") { + if (visited.has(key)) { + throw new Error(`Circular template reference: "${key}"`); + } + result[key] = value; + const innerTmpl = this.templates[key]; + if (innerTmpl) { + const inner = new Template(innerTmpl, this.templates); + Object.assign( + result, + inner.parse(value, parsers, new Set([...visited, key])), + ); + } + } else { + const parser = parsers?.[key] as Parser | undefined; + result[key] = parser ? parser(value) : value; + } + } + + return result as Parsed

; + } +} + +// ── 私有工具函数 ────────────────────────────────────── + +interface ParseKey { + key: string; + type: "variable" | "indirect"; +} + +/** + * 将模板字符串转为捕获正则。 + * + * `${key}` / `#{key}` → 捕获组 `(.*?)`(最后一个用 `(.*)`), + * 字面量部分做正则转义。 + */ +function buildParseRegex(template: string): { + regex: RegExp; + keys: ParseKey[]; +} { + const keys: ParseKey[] = []; + let pattern = "^"; + + const parts = template.split(/(\$\{\w+\}|#\{\w+\})/); + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (part.startsWith("${") && part.endsWith("}")) { + keys.push({ key: part.slice(2, -1), type: "variable" }); + pattern += lastPlaceholder(parts, i) ? "(.*)" : "(.*?)"; + } else if (part.startsWith("#{") && part.endsWith("}")) { + keys.push({ key: part.slice(2, -1), type: "indirect" }); + pattern += lastPlaceholder(parts, i) ? "(.*)" : "(.*?)"; + } else { + pattern += escapeRegex(part); + } + } + + pattern += "$"; + return { regex: new RegExp(pattern, "s"), keys }; +} + +/** 当前占位符之后是否还有占位符 */ +function lastPlaceholder(parts: string[], index: number): boolean { + return !parts + .slice(index + 1) + .some((p) => p.startsWith("${") || p.startsWith("#{")); +} + +/** 转义正则特殊字符 */ +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/src/lib/utils/templates/semver.test.ts b/src/lib/utils/templates/semver.test.ts new file mode 100644 index 0000000..4e1d669 --- /dev/null +++ b/src/lib/utils/templates/semver.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "vitest"; +import { SemVer } from "./semver"; + +describe("SemVer", () => { + describe("parse", () => { + test("两段版本", () => { + const v = SemVer.parse("1.0"); + expect(v.major).toBe(1); + expect(v.minor).toBe(0); + expect(v.patch).toBeNull(); + expect(v.preRelease).toBeNull(); + }); + + test("三段版本", () => { + const v = SemVer.parse("1.0.0"); + expect(v.major).toBe(1); + expect(v.minor).toBe(0); + expect(v.patch).toBe(0); + expect(v.preRelease).toBeNull(); + }); + + test("两段 + pre-release", () => { + const v = SemVer.parse("1.0-alpha"); + expect(v.major).toBe(1); + expect(v.minor).toBe(0); + expect(v.patch).toBeNull(); + expect(v.preRelease).toBe("alpha"); + }); + + test("三段 + pre-release", () => { + const v = SemVer.parse("1.0.0-beta.1"); + expect(v.major).toBe(1); + expect(v.minor).toBe(0); + expect(v.patch).toBe(0); + expect(v.preRelease).toBe("beta.1"); + }); + + test("非法:单段", () => { + expect(() => SemVer.parse("1")).toThrow('Invalid SemVer: "1"'); + }); + + test("非法:非数字", () => { + expect(() => SemVer.parse("abc")).toThrow('Invalid SemVer: "abc"'); + }); + + test("非法:四段", () => { + expect(() => SemVer.parse("1.0.0.0")).toThrow('Invalid SemVer: "1.0.0.0"'); + }); + }); + + describe("format", () => { + test("两段", () => { + expect(new SemVer(1, 2).format()).toBe("1.2"); + }); + + test("三段", () => { + expect(new SemVer(1, 2, 3).format()).toBe("1.2.3"); + }); + + test("有 pre-release", () => { + expect(new SemVer(1, 0, null, "rc1").format()).toBe("1.0-rc1"); + }); + + test("三段 + pre-release", () => { + expect(new SemVer(1, 2, 3, "beta").format()).toBe("1.2.3-beta"); + }); + }); +}); diff --git a/src/lib/utils/templates/semver.ts b/src/lib/utils/templates/semver.ts new file mode 100644 index 0000000..b34eb8e --- /dev/null +++ b/src/lib/utils/templates/semver.ts @@ -0,0 +1,49 @@ +import type { TemplateValue, Parser } from "../template"; + +/** + * 语义化版本。 + * + * 格式:`major.minor[.patch][-preRelease]` + * + * `format()` 有 patch 输出 `"1.0.0"`,无 patch 输出 `"1.0"`。 + * `SemVer.parse()` 作为 `Parser` 传入 `Template.parse()`。 + */ +export class SemVer implements TemplateValue { + readonly major: number; + readonly minor: number; + readonly patch: number | null; + readonly preRelease: string | null; + + constructor( + major: number, + minor: number, + patch: number | null = null, + preRelease: string | null = null, + ) { + this.major = major; + this.minor = minor; + this.patch = patch; + this.preRelease = preRelease; + } + + format(): string { + const base = + this.patch !== null + ? `${this.major}.${this.minor}.${this.patch}` + : `${this.major}.${this.minor}`; + return this.preRelease ? `${base}-${this.preRelease}` : base; + } + + static parse: Parser = (raw: string): SemVer => { + const match = raw.match(/^(\d+)\.(\d+)(?:\.(\d+))?(?:-(.+))?$/); + if (!match) { + throw new Error(`Invalid SemVer: "${raw}"`); + } + return new SemVer( + parseInt(match[1]!, 10), + parseInt(match[2]!, 10), + match[3] !== undefined ? parseInt(match[3], 10) : null, + match[4] ?? null, + ); + }; +}