Add template engine with Template class and SemVer support

This commit is contained in:
CPTProgrammer 2026-07-08 05:29:07 +08:00
parent a0cf0f9d93
commit 6b53906d08
No known key found for this signature in database
4 changed files with 467 additions and 0 deletions

View File

@ -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<string, string> = {};
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");
});
});

196
src/lib/utils/template.ts Normal file
View File

@ -0,0 +1,196 @@
const MAX_DEPTH = 20;
/** 值类型必须实现的序列化接口 */
export interface TemplateValue {
format(): string;
}
/** 解析器:从字符串解析为类型 T */
export type Parser<T> = (raw: string) => T;
/** 从 Parser 记录推导出 parse 结果的类型 */
export type Parsed<P extends Record<string, Parser<unknown>>> = {
[K in keyof P]: ReturnType<P[K]>;
};
/**
*
*
*
* - `${key}`
* - `#{key}` `templates`
*/
export class Template {
readonly source: string;
readonly templates: Readonly<Record<string, string>>;
constructor(source: string, templates?: Record<string, string>) {
this.source = source;
this.templates = templates ? { ...templates } : {};
}
// ── 正向 format ──────────────────────────────────────
/**
* format `${}` / `#{}`
*
* - `${key}` `values` `TemplateValue` `.format()``string`
* - `#{key}` `this.templates` resolve
* -
*/
format(values: Record<string, TemplateValue | string>): string {
return this.resolve(this.source, values, new Set(), 0);
}
private resolve(
input: string,
values: Record<string, TemplateValue | string>,
visited: ReadonlySet<string>,
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<P>`
* `(result as Record<string, string>)["key"]` 访
*/
parse<const P extends Record<string, Parser<unknown>>>(
concrete: string,
parsers?: P,
visited: ReadonlySet<string> = new Set(),
): Parsed<P> {
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<string, unknown> = {};
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<unknown> | undefined;
result[key] = parser ? parser(value) : value;
}
}
return result as Parsed<P>;
}
}
// ── 私有工具函数 ──────────────────────────────────────
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, "\\$&");
}

View File

@ -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");
});
});
});

View File

@ -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<SemVer>` `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<SemVer> = (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,
);
};
}