Add template engine with Template class and SemVer support
This commit is contained in:
parent
a0cf0f9d93
commit
6b53906d08
154
src/lib/utils/template.test.ts
Normal file
154
src/lib/utils/template.test.ts
Normal 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
196
src/lib/utils/template.ts
Normal 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, "\\$&");
|
||||
}
|
||||
68
src/lib/utils/templates/semver.test.ts
Normal file
68
src/lib/utils/templates/semver.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
49
src/lib/utils/templates/semver.ts
Normal file
49
src/lib/utils/templates/semver.ts
Normal 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,
|
||||
);
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user