1
16
17export const TEMPLATE_VAR_TYPES = ["text", "number", "date", "select"] as const;
18export type TemplateVarType = (typeof TEMPLATE_VAR_TYPES)[number];
19
20export interface TemplateVar {
21 name: string;
22 label: string;
23 type: TemplateVarType;
24 options?: string[];
25 required?: boolean;
26 defaultValue?: string;
27}
28
29export const TEMPLATE_VAR_NAME_RE = /^[A-Za-z0-9_-]{1,50}$/;
30
31
32export function validateTemplateVars(vars: unknown): { ok: true; vars: TemplateVar[] } | { ok: false; errors: string[] } {
33 const errors: string[] = [];
34 if (!Array.isArray(vars)) return { ok: false, errors: ["variables must be an array"] };
35 const out: TemplateVar[] = [];
36 const seen = new Set<string>();
37 for (const v of vars) {
38 if (typeof v !== "object" || v === null) {
39 errors.push("each variable must be an object");
40 continue;
41 }
42 const o = v as Record<string, unknown>;
43 const name = typeof o.name === "string" ? o.name : "";
44 if (!TEMPLATE_VAR_NAME_RE.test(name)) {
45 errors.push(`variable name "${name}" must match ${TEMPLATE_VAR_NAME_RE}`);
46 continue;
47 }
48 if (seen.has(name)) {
49 errors.push(`duplicate variable name "${name}"`);
50 continue;
51 }
52 seen.add(name);
53 if (typeof o.label !== "string" || o.label.length === 0 || o.label.length > 100) {
54 errors.push(`variable "${name}" needs a label (1-100 chars)`);
55 continue;
56 }
57 if (!TEMPLATE_VAR_TYPES.includes(o.type as TemplateVarType)) {
58 errors.push(`variable "${name}" has unknown type ${String(o.type)}`);
59 continue;
60 }
61 if (o.type === "select") {
62 if (!Array.isArray(o.options) || o.options.length === 0 || o.options.length > 50) {
63 errors.push(`select variable "${name}" needs 1-50 options`);
64 continue;
65 }
66 if (!o.options.every((x) => typeof x === "string" && x.length <= 100)) {
67 errors.push(`select variable "${name}" options must be short strings`);
68 continue;
69 }
70 }
71 out.push({
72 name,
73 label: o.label,
74 type: o.type as TemplateVarType,
75 ...(o.type === "select" && Array.isArray(o.options) ? { options: o.options as string[] } : {}),
76 ...(typeof o.required === "boolean" ? { required: o.required } : {}),
77 ...(typeof o.defaultValue === "string" && o.defaultValue.length <= 2000 ? { defaultValue: o.defaultValue } : {})
78 });
79 }
80 return errors.length === 0 ? { ok: true, vars: out } : { ok: false, errors };
81}
82
83
88export function resolveTemplateValues(
89 vars: TemplateVar[],
90 input: Record<string, unknown>
91): { ok: true; values: Record<string, string> } | { ok: false; errors: string[] } {
92 const errors: string[] = [];
93 const values: Record<string, string> = {};
94 for (const v of vars) {
95 let raw = input[v.name];
96 if (raw === undefined || raw === null || raw === "") raw = v.defaultValue;
97 if (raw === undefined || raw === null || raw === "") {
98 if (v.required) errors.push(`"${v.name}" is required`);
99 else values[v.name] = "";
100 continue;
101 }
102 switch (v.type) {
103 case "number": {
104 const n = typeof raw === "number" ? raw : Number(raw);
105 if (!Number.isFinite(n)) errors.push(`"${v.name}" must be a number`);
106 else values[v.name] = String(n);
107 break;
108 }
109 case "date": {
110 if (typeof raw !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
111 errors.push(`"${v.name}" must be an ISO date (YYYY-MM-DD)`);
112 } else values[v.name] = raw;
113 break;
114 }
115 case "select": {
116 if (typeof raw !== "string" || !(v.options ?? []).includes(raw)) {
117 errors.push(`"${v.name}" must be one of: ${(v.options ?? []).join(", ")}`);
118 } else values[v.name] = raw;
119 break;
120 }
121 case "text":
122 default: {
123 if (typeof raw !== "string") errors.push(`"${v.name}" must be a string`);
124 else if (raw.length > 2000) errors.push(`"${v.name}" is too long (max 2000)`);
125 else values[v.name] = raw;
126 }
127 }
128 }
129
130 return errors.length === 0 ? { ok: true, values } : { ok: false, errors };
131}
132
133
138export function substituteTemplateTokens(text: string, values: Record<string, string>): string {
139 return text.replace(/\{\{\s*([A-Za-z0-9_-]{1,50})\s*\}\}/g, (whole, name: string) => values[name] ?? "");
140}
141
142
147export function substituteSnapshot(
148 snapshot: unknown,
149 values: Record<string, string>
150): { page: { title: string; icon: string | null; description: string | null }; blocks: unknown[]; children: { title: string; blocks: unknown[] }[] } {
151 const walkText = (s: unknown): unknown => (typeof s === "string" ? substituteTemplateTokens(s, values) : s);
152 const walkBlocks = (blocks: unknown): unknown[] =>
153 Array.isArray(blocks)
154 ? blocks.map((b) => {
155 if (typeof b !== "object" || b === null) return b;
156 const o = b as Record<string, unknown>;
157 const content =
158 typeof o.content === "object" && o.content !== null
159 ? Object.fromEntries(Object.entries(o.content as Record<string, unknown>).map(([k, v]) => [k, walkText(v)]))
160 : o.content;
161 return { ...o, content };
162 })
163 : [];
164 const raw = (snapshot ?? {}) as {
165 page?: { title?: string; icon?: string | null; description?: string | null };
166 blocks?: unknown;
167 children?: { title?: string; blocks?: unknown }[];
168 };
169 return {
170 page: {
171 title: substituteTemplateTokens(raw.page?.title ?? "", values),
172 icon: raw.page?.icon ?? null,
173 description: raw.page?.description != null ? substituteTemplateTokens(raw.page.description, values) : null
174 },
175 blocks: walkBlocks(raw.blocks),
176 children: (raw.children ?? []).map((c) => ({ title: substituteTemplateTokens(c.title ?? "", values), blocks: walkBlocks(c.blocks) }))
177 };
178}
179