@orkestrel/template 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,639 @@
1
+ import { createContract, isFiniteNumber, objectShape, optionalShape, resolveField, schemaToParameters, stringShape } from "@orkestrel/contract";
2
+ import { Emitter } from "@orkestrel/emitter";
3
+ //#region src/core/constants.ts
4
+ /**
5
+ * The single-pass `{{name}}` substitution pattern shared by `Template#fill`
6
+ * and `Template#validate`.
7
+ *
8
+ * @remarks
9
+ * Global-flagged, two-alternative pattern: a match of the FIRST alternative
10
+ * (`\{{` — a literal backslash followed by `{{`) means "emit a literal
11
+ * `{{`" — the escape hatch for content that must show `{{` without
12
+ * triggering substitution. A match that instead populates capture group 1
13
+ * (`\{{([^{}]+?)\}\}`) means "substitute the named token" — group 1 is the
14
+ * RAW (untrimmed) token text between the braces; every call site trims it
15
+ * (`token.trim()`) before using it as a lookup name, so `'{{ name }}'` still
16
+ * resolves `'name'`. The pattern intentionally does NOT wrap the token in
17
+ * `\s*` — an unclosed `'{{' + ' '.repeat(n)` with no closing `}}` would
18
+ * otherwise force the regex engine into catastrophic backtracking over the
19
+ * whitespace run (O(n^2)); trimming after the match keeps the same
20
+ * whitespace tolerance without the backtracking hazard. Every call site
21
+ * builds a fresh `RegExp` from `.source` / `.flags` rather than sharing this
22
+ * instance's mutable `lastIndex` across scans.
23
+ */
24
+ var FILL_PATTERN = /\\\{\{|\{\{([^{}]+?)\}\}/g;
25
+ /** Default `missing` policy for `Template#fill` / `TemplateManager#fill` when unspecified. */
26
+ var DEFAULT_MISSING_POLICY = "error";
27
+ /** Default `locale` for `Template#fill` / `TemplateManager#fill` when unspecified. */
28
+ var DEFAULT_LOCALE = "en-US";
29
+ /**
30
+ * Prototype-pollution-unsafe field-path segments — a fill lookup refuses to
31
+ * resolve ANY path containing one, treating the placeholder as unresolved.
32
+ */
33
+ var UNSAFE_FIELD_SEGMENTS = Object.freeze([
34
+ "__proto__",
35
+ "constructor",
36
+ "prototype"
37
+ ]);
38
+ //#endregion
39
+ //#region src/core/errors.ts
40
+ /**
41
+ * An error thrown by the template layer.
42
+ *
43
+ * @remarks
44
+ * Thrown for: a required placeholder staying unresolved under the `error`
45
+ * {@link MissingPolicy} (`MISSING`), an unknown template id
46
+ * (`NOTFOUND`), `createTemplate` handed invalid data (`INVALID`), and
47
+ * `TemplateManagerInterface#register` handed an id already present without
48
+ * `options.replace` (`CONFLICT`). `context`, when present, carries the
49
+ * offending id / name.
50
+ */
51
+ var TemplateError = class extends Error {
52
+ code;
53
+ context;
54
+ constructor(code, message, context) {
55
+ super(message);
56
+ this.name = "TemplateError";
57
+ this.code = code;
58
+ this.context = context;
59
+ }
60
+ };
61
+ /**
62
+ * Narrow an unknown caught value to a {@link TemplateError}.
63
+ *
64
+ * @param value - The value to test (typically a `catch` binding)
65
+ * @returns `true` when `value` is a {@link TemplateError}
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * import { isTemplateError } from '@src/core'
70
+ *
71
+ * try {
72
+ * manager.template('missing')
73
+ * } catch (error) {
74
+ * if (isTemplateError(error) && error.code === 'NOTFOUND') return
75
+ * }
76
+ * ```
77
+ */
78
+ function isTemplateError(value) {
79
+ return value instanceof TemplateError;
80
+ }
81
+ //#endregion
82
+ //#region src/core/helpers.ts
83
+ /**
84
+ * Format a resolved fill value for substitution into a template's `content`.
85
+ *
86
+ * @remarks
87
+ * A finite number renders with the given locale's thousand grouping (via
88
+ * `toLocaleString`); every other value — including `null` — String-coerces.
89
+ * `null` therefore renders as the literal string `'null'`, intentionally
90
+ * mirroring `interpolateMessage`'s coercion parity (see `fillTemplate`). An
91
+ * invalid BCP-47 `locale` tag throws a `RangeError` from the underlying
92
+ * `toLocaleString` call when `value` is a finite number — this is a caller
93
+ * error (an invalid locale argument), by design, and is not caught here.
94
+ *
95
+ * @param value - The resolved value to format
96
+ * @param locale - The locale used for finite-number formatting
97
+ * @returns The formatted string
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * import { formatValue } from '@src/core'
102
+ *
103
+ * formatValue(5010, 'en-US') // '5,010'
104
+ * formatValue(null, 'en-US') // 'null'
105
+ * ```
106
+ */
107
+ function formatValue(value, locale) {
108
+ if (isFiniteNumber(value)) return value.toLocaleString(locale);
109
+ return String(value);
110
+ }
111
+ /**
112
+ * Resolve a field path against a fill-values record, refusing any path that
113
+ * touches a prototype-pollution-unsafe segment.
114
+ *
115
+ * @remarks
116
+ * A prototype-pollution guard shared by `fillTemplate` and `Template#validate`
117
+ * so the two stay in lockstep: `path` normalizes to a segment array (a bare
118
+ * string `path` becomes a single-segment array); if ANY segment appears in
119
+ * `UNSAFE_FIELD_SEGMENTS` (`'__proto__'`, `'constructor'`, `'prototype'`), the
120
+ * lookup is refused and `undefined` is returned WITHOUT ever calling
121
+ * `resolveField` — a path like `['__proto__', 'polluted']` can never reach
122
+ * the record's actual prototype chain through this function. Every other
123
+ * path resolves through `@orkestrel/contract`'s `resolveField`.
124
+ *
125
+ * @param record - The fill-values record to resolve against
126
+ * @param path - The field path — a single segment or a segment array
127
+ * @returns The resolved value, or `undefined` when unresolved or the path is unsafe
128
+ *
129
+ * @example
130
+ * ```ts
131
+ * import { resolveSafeField } from '@src/core'
132
+ *
133
+ * resolveSafeField({ a: { b: 1 } }, ['a', 'b']) // 1
134
+ * resolveSafeField({}, ['__proto__', 'polluted']) // undefined
135
+ * ```
136
+ */
137
+ function resolveSafeField(record, path) {
138
+ if ((Array.isArray(path) ? path : [path]).some((segment) => UNSAFE_FIELD_SEGMENTS.includes(segment))) return void 0;
139
+ return resolveField(record, path);
140
+ }
141
+ /**
142
+ * Substitute every `{{name}}` token in `content` in a single pass.
143
+ *
144
+ * @remarks
145
+ * Uses a fresh `RegExp` clone of `FILL_PATTERN` per call (never sharing its
146
+ * `lastIndex`) and a single `String#replace` scan — substituted output is
147
+ * never re-scanned. For each token: the matching declared
148
+ * {@link TemplatePlaceholder} (exact `name`) supplies its `path` (falling
149
+ * back to the token split on `.`); ANY path segment in `UNSAFE_FIELD_SEGMENTS`
150
+ * makes the token unresolved without ever calling `resolveField` (a
151
+ * prototype-pollution guard). A resolved value formats via `formatValue`; an
152
+ * unresolved value falls back to the placeholder's `fallback` when declared;
153
+ * otherwise `options.missing` governs — `'literal'` re-emits the original
154
+ * `{{name}}` text, `'empty'` emits `''`, and `'error'` emits `''` for every
155
+ * token but collects EVERY unresolved required token (an undeclared token, or
156
+ * a declared token with `required !== false`) and throws one
157
+ * {@link TemplateError} coded `MISSING` listing them all, in first-appearance
158
+ * order, once the scan completes. An escaped `\{{` emits a literal `{{`.
159
+ *
160
+ * PARITY: called with no declared `placeholders` and `{ missing: 'empty' }`,
161
+ * this reproduces `interpolateMessage` (`@src/core` sibling
162
+ * `interpret`) vector-for-vector. KNOWN DIVERGENCE: `FILL_PATTERN`'s token
163
+ * class (`[^{}]`) excludes `{`, where `interpolateMessage`'s (`[^}]`) allows
164
+ * it — a token containing `{` therefore behaves differently here.
165
+ *
166
+ * @param content - The template content carrying `{{name}}` tokens
167
+ * @param values - The values tokens resolve against
168
+ * @param options - `missing` (default `'error'`), `locale` (default `'en-US'`), and the declared `placeholders` (default none) tokens resolve against
169
+ * @returns The substituted content
170
+ *
171
+ * @example
172
+ * ```ts
173
+ * import { fillTemplate } from '@src/core'
174
+ *
175
+ * fillTemplate('Hi {{name}}', { name: 'Ada' }) // 'Hi Ada'
176
+ * fillTemplate('Limit {{limit}}', { limit: 5010 }, { missing: 'empty' }) // 'Limit 5,010'
177
+ * ```
178
+ */
179
+ function fillTemplate(content, values, options) {
180
+ const placeholders = options?.placeholders ?? [];
181
+ const missing = options?.missing ?? "error";
182
+ const locale = options?.locale ?? "en-US";
183
+ const record = values ?? {};
184
+ const missingNames = [];
185
+ const seen = /* @__PURE__ */ new Set();
186
+ const pattern = new RegExp(FILL_PATTERN.source, FILL_PATTERN.flags);
187
+ const result = content.replace(pattern, (matchText, rawToken) => {
188
+ if (rawToken === void 0) return "{{";
189
+ const token = rawToken.trim();
190
+ const declared = placeholders.find((placeholder) => placeholder.name === token);
191
+ const path = declared?.path ?? token.split(".");
192
+ const value = resolveSafeField(record, path);
193
+ if (value !== void 0) return formatValue(value, locale);
194
+ if (declared?.fallback !== void 0) return formatValue(declared.fallback, locale);
195
+ if (missing === "literal") return matchText;
196
+ if (missing === "empty") return "";
197
+ if ((declared === void 0 || declared.required !== false) && !seen.has(token)) {
198
+ seen.add(token);
199
+ missingNames.push(token);
200
+ }
201
+ return "";
202
+ });
203
+ if (missing === "error" && missingNames.length > 0) throw new TemplateError("MISSING", `Missing required placeholder(s): ${missingNames.join(", ")}`, { missing: missingNames });
204
+ return result;
205
+ }
206
+ /**
207
+ * Build the `@orkestrel/contract` object shape describing a template's
208
+ * declared placeholders.
209
+ *
210
+ * @remarks
211
+ * Each placeholder becomes a `stringShape` carrying its `description`;
212
+ * `required === false` wraps it in `optionalShape`. Used by `Template` to
213
+ * compile its `parameters()` contract once per instance.
214
+ *
215
+ * @param placeholders - The declared placeholders to shape
216
+ * @returns The contract shape for `createContract`
217
+ *
218
+ * @example
219
+ * ```ts
220
+ * import { placeholderShape } from '@src/core'
221
+ * import { createContract } from '@orkestrel/contract'
222
+ *
223
+ * const contract = createContract(placeholderShape([{ name: 'city' }]))
224
+ * ```
225
+ */
226
+ function placeholderShape(placeholders) {
227
+ const properties = {};
228
+ for (const placeholder of placeholders) {
229
+ const field = stringShape({ description: placeholder.description });
230
+ properties[placeholder.name] = placeholder.required === false ? optionalShape(field) : field;
231
+ }
232
+ return objectShape(properties);
233
+ }
234
+ //#endregion
235
+ //#region src/core/Template.ts
236
+ /**
237
+ * A named, versionable template — `{{name}}` tokens in `content`, filled
238
+ * against a values record.
239
+ *
240
+ * @remarks
241
+ * `missing` / `locale` seed this instance's default {@link TemplateFillOptions},
242
+ * overridable per `fill` call. Its `parameters()` contract (built from
243
+ * `placeholders` via `placeholderShape`) compiles once, in the constructor.
244
+ *
245
+ * @example
246
+ * ```ts
247
+ * const greeting = new Template({ name: 'greeting', content: 'Hi {{name}}' })
248
+ * greeting.fill({ name: 'Ada' }) // 'Hi Ada'
249
+ * ```
250
+ */
251
+ var Template = class {
252
+ id;
253
+ name;
254
+ content;
255
+ placeholders;
256
+ summary;
257
+ description;
258
+ category;
259
+ tags;
260
+ #missing;
261
+ #locale;
262
+ #contract;
263
+ constructor(options) {
264
+ const placeholders = options.placeholders ?? [];
265
+ const seenNames = /* @__PURE__ */ new Set();
266
+ for (const placeholder of placeholders) {
267
+ if (seenNames.has(placeholder.name)) throw new TemplateError("INVALID", `Duplicate placeholder name: ${placeholder.name}`, { name: placeholder.name });
268
+ seenNames.add(placeholder.name);
269
+ if (Array.isArray(placeholder.path) && placeholder.path.length === 0) throw new TemplateError("INVALID", `Placeholder path must not be empty: ${placeholder.name}`, { name: placeholder.name });
270
+ }
271
+ this.id = typeof options.id === "string" ? options.id : crypto.randomUUID();
272
+ this.name = options.name;
273
+ this.content = options.content;
274
+ this.placeholders = placeholders;
275
+ this.summary = options.summary;
276
+ this.description = options.description;
277
+ this.category = options.category;
278
+ this.tags = options.tags;
279
+ this.#missing = options.missing ?? "error";
280
+ this.#locale = options.locale ?? "en-US";
281
+ this.#contract = createContract(placeholderShape(this.placeholders));
282
+ }
283
+ /**
284
+ * The plain, JSON-serializable data this template carries.
285
+ *
286
+ * @returns The {@link TemplateDefinition} record
287
+ *
288
+ * @example
289
+ * ```ts
290
+ * const instance = new Template({ name: 'greeting', content: 'Hi {{name}}' })
291
+ * instance.definition().name // 'greeting'
292
+ * ```
293
+ */
294
+ definition() {
295
+ return {
296
+ id: this.id,
297
+ name: this.name,
298
+ content: this.content,
299
+ placeholders: this.placeholders,
300
+ summary: this.summary,
301
+ description: this.description,
302
+ category: this.category,
303
+ tags: this.tags
304
+ };
305
+ }
306
+ /**
307
+ * Substitute every `{{name}}` token in `content` against `values`.
308
+ *
309
+ * @param values - The values tokens resolve against
310
+ * @param options - Per-call overrides for this instance's `missing` / `locale` defaults
311
+ * @returns The substituted content
312
+ *
313
+ * @example
314
+ * ```ts
315
+ * const instance = new Template({ name: 'greeting', content: 'Hi {{name}}' })
316
+ * instance.fill({ name: 'Ada' }) // 'Hi Ada'
317
+ * ```
318
+ */
319
+ fill(values, options) {
320
+ return fillTemplate(this.content, values, {
321
+ missing: options?.missing ?? this.#missing,
322
+ locale: options?.locale ?? this.#locale,
323
+ placeholders: this.placeholders
324
+ });
325
+ }
326
+ /**
327
+ * Report which required placeholders would stay unresolved, and which
328
+ * `values` keys go unused, without producing output.
329
+ *
330
+ * @remarks
331
+ * Content-token driven: scans `this.content` for every `{{name}}` token
332
+ * (skipping escaped `\{{` matches) the same way `fill` does, so `validate`
333
+ * predicts `fill`'s `'error'`-{@link MissingPolicy} outcome exactly — a
334
+ * token reported here as missing is precisely a token that would throw
335
+ * under `fill(values, { missing: 'error' })`. For each distinct token
336
+ * (first-appearance order, trimmed): a declared {@link TemplatePlaceholder}
337
+ * sharing its `name` supplies `path` (falling back to the token split on
338
+ * `.`); the value resolves via `resolveSafeField`. The token is `missing`
339
+ * only when the value is unresolved AND no `fallback` is declared AND the
340
+ * placeholder is required (`required !== false`, including undeclared
341
+ * tokens). `extra` lists every `values` key with no declared placeholder.
342
+ *
343
+ * @param values - The values to check
344
+ * @returns The {@link TemplateValidationResult}
345
+ *
346
+ * @example
347
+ * ```ts
348
+ * const instance = new Template({
349
+ * name: 'greeting',
350
+ * content: 'Hi {{name}}',
351
+ * placeholders: [{ name: 'name' }],
352
+ * })
353
+ * instance.validate({}).missing // ['name']
354
+ * ```
355
+ */
356
+ validate(values) {
357
+ const record = values ?? {};
358
+ const missing = [];
359
+ const seen = /* @__PURE__ */ new Set();
360
+ const pattern = new RegExp(FILL_PATTERN.source, FILL_PATTERN.flags);
361
+ for (const match of this.content.matchAll(pattern)) {
362
+ const rawToken = match[1];
363
+ if (rawToken === void 0) continue;
364
+ const token = rawToken.trim();
365
+ if (seen.has(token)) continue;
366
+ seen.add(token);
367
+ const declared = this.placeholders.find((placeholder) => placeholder.name === token);
368
+ const resolved = resolveSafeField(record, declared?.path ?? token.split("."));
369
+ const required = declared === void 0 || declared.required !== false;
370
+ if (resolved === void 0 && declared?.fallback === void 0 && required) missing.push(token);
371
+ }
372
+ const declaredNames = new Set(this.placeholders.map((placeholder) => placeholder.name));
373
+ const extra = Object.keys(record).filter((key) => !declaredNames.has(key));
374
+ return {
375
+ valid: missing.length === 0,
376
+ missing,
377
+ extra
378
+ };
379
+ }
380
+ /**
381
+ * Project this template's placeholders to the open tool-parameters record
382
+ * shape.
383
+ *
384
+ * @returns The compiled parameters record, or `undefined` when `schemaToParameters` yields none
385
+ *
386
+ * @example
387
+ * ```ts
388
+ * const instance = new Template({
389
+ * name: 'greeting',
390
+ * content: 'Hi {{name}}',
391
+ * placeholders: [{ name: 'name' }],
392
+ * })
393
+ * instance.parameters()
394
+ * ```
395
+ */
396
+ parameters() {
397
+ return schemaToParameters(this.#contract.schema);
398
+ }
399
+ };
400
+ //#endregion
401
+ //#region src/core/TemplateManager.ts
402
+ /**
403
+ * The template registry — a self-owning, id-keyed record-holder for the
404
+ * {@link TemplateInterface} instances a consumer registers, looks up, fills,
405
+ * and validates by id (AGENTS §9.1 singular/plural accessors, §9.2 batch
406
+ * `remove` overloads, §13 emitter ownership).
407
+ *
408
+ * @remarks
409
+ * `register` accepts either a constructed {@link TemplateInterface} (kept
410
+ * as-is, including its own `missing` / `locale` defaults) or a plain
411
+ * {@link TemplateOptions} bag — constructed into a `Template` with this
412
+ * manager's `missing` / `locale` defaults applied wherever the bag omits
413
+ * them. A duplicate `id` throws a {@link TemplateError} coded `CONFLICT`
414
+ * unless `options.replace` is `true`, in which case the existing entry is
415
+ * overwritten. `options.templates` SEEDS the registry at construction
416
+ * WITHOUT emitting `register` — only calls to `register` after construction
417
+ * emit. The batch `remove(ids)` form is ALL-OR-NOTHING: any id absent from
418
+ * the registry leaves the collection untouched and returns `false`.
419
+ *
420
+ * @example
421
+ * ```ts
422
+ * import { TemplateManager } from '@src/core'
423
+ *
424
+ * const manager = new TemplateManager()
425
+ * const instance = manager.register({ name: 'greeting', content: 'Hi {{name}}' })
426
+ * manager.fill(instance.id, { name: 'Ada' }) // 'Hi Ada'
427
+ * ```
428
+ */
429
+ var TemplateManager = class {
430
+ #templates = /* @__PURE__ */ new Map();
431
+ #emitter;
432
+ #missing;
433
+ #locale;
434
+ constructor(options) {
435
+ this.#emitter = new Emitter({
436
+ on: options?.on,
437
+ error: options?.error
438
+ });
439
+ this.#missing = options?.missing ?? "error";
440
+ this.#locale = options?.locale ?? "en-US";
441
+ for (const template of options?.templates ?? []) {
442
+ const instance = this.#instantiate(template);
443
+ this.#templates.set(instance.id, instance);
444
+ }
445
+ }
446
+ get emitter() {
447
+ return this.#emitter;
448
+ }
449
+ get size() {
450
+ return this.#templates.size;
451
+ }
452
+ /**
453
+ * Register a template — a constructed {@link TemplateInterface} (kept
454
+ * as-is) or a plain {@link TemplateOptions} bag (constructed into a
455
+ * `Template` with this manager's `missing` / `locale` defaults applied
456
+ * wherever the bag omits them).
457
+ *
458
+ * @param template - The template instance or options to register
459
+ * @param options - `replace` — overwrite an existing entry sharing the same id instead of throwing
460
+ * @returns The registered {@link TemplateInterface}
461
+ *
462
+ * @example
463
+ * ```ts
464
+ * const instance = manager.register({ id: 'greeting', name: 'greeting', content: 'Hi {{name}}' })
465
+ * ```
466
+ */
467
+ register(template, options) {
468
+ const instance = this.#instantiate(template);
469
+ if (this.#templates.get(instance.id) !== void 0 && options?.replace !== true) throw new TemplateError("CONFLICT", `Template already registered: ${instance.id}`, { id: instance.id });
470
+ this.#templates.set(instance.id, instance);
471
+ this.#emitter.emit("register", instance);
472
+ return instance;
473
+ }
474
+ /**
475
+ * Look up a registered template by id.
476
+ *
477
+ * @param id - The template id
478
+ * @returns The registered {@link TemplateInterface}
479
+ * @throws {@link TemplateError} coded `NOTFOUND` when `id` is unknown
480
+ */
481
+ template(id) {
482
+ const instance = this.#templates.get(id);
483
+ if (instance === void 0) this.#throwNotFound(id);
484
+ return instance;
485
+ }
486
+ /**
487
+ * List every registered template.
488
+ *
489
+ * @returns A snapshot array of every registered {@link TemplateInterface}
490
+ */
491
+ templates() {
492
+ return [...this.#templates.values()];
493
+ }
494
+ /**
495
+ * Filter registered templates by name / category / tag — every supplied
496
+ * field must match (logical AND).
497
+ *
498
+ * @param query - The {@link TemplateQuery} to filter by; omit for every registered template
499
+ * @returns The matching templates
500
+ */
501
+ find(query) {
502
+ if (query === void 0) return this.templates();
503
+ return this.templates().filter((instance) => {
504
+ if (query.name !== void 0 && instance.name !== query.name) return false;
505
+ if (query.category !== void 0 && instance.category !== query.category) return false;
506
+ if (query.tag !== void 0 && !(instance.tags ?? []).includes(query.tag)) return false;
507
+ return true;
508
+ });
509
+ }
510
+ /**
511
+ * Test whether a template id is registered.
512
+ *
513
+ * @param id - The template id
514
+ * @returns `true` when `id` is registered
515
+ */
516
+ has(id) {
517
+ return this.#templates.has(id);
518
+ }
519
+ remove(target) {
520
+ if (target === void 0) {
521
+ for (const instance of this.#templates.values()) this.#emitter.emit("remove", instance);
522
+ this.#templates.clear();
523
+ return;
524
+ }
525
+ if (typeof target === "string") {
526
+ const instance = this.#templates.get(target);
527
+ if (instance === void 0) return false;
528
+ this.#templates.delete(target);
529
+ this.#emitter.emit("remove", instance);
530
+ return true;
531
+ }
532
+ for (const id of target) if (!this.#templates.has(id)) return false;
533
+ for (const id of target) {
534
+ const instance = this.#templates.get(id);
535
+ if (instance === void 0) continue;
536
+ this.#templates.delete(id);
537
+ this.#emitter.emit("remove", instance);
538
+ }
539
+ return true;
540
+ }
541
+ /** Remove every registered template, emitting `clear`. */
542
+ clear() {
543
+ this.#templates.clear();
544
+ this.#emitter.emit("clear");
545
+ }
546
+ /**
547
+ * Fill a registered template by id.
548
+ *
549
+ * @param id - The template id
550
+ * @param values - The values tokens resolve against
551
+ * @param options - Per-call overrides for the template's `missing` / `locale` defaults
552
+ * @returns The substituted content
553
+ * @throws {@link TemplateError} coded `NOTFOUND` when `id` is unknown
554
+ */
555
+ fill(id, values, options) {
556
+ return this.template(id).fill(values, options);
557
+ }
558
+ /**
559
+ * Validate values against a registered template by id.
560
+ *
561
+ * @param id - The template id
562
+ * @param values - The values to check
563
+ * @returns The {@link TemplateValidationResult}
564
+ * @throws {@link TemplateError} coded `NOTFOUND` when `id` is unknown
565
+ */
566
+ validate(id, values) {
567
+ return this.template(id).validate(values);
568
+ }
569
+ /**
570
+ * Project a registered template's parameters by id.
571
+ *
572
+ * @param id - The template id
573
+ * @returns The compiled parameters record, or `undefined` when the template has none
574
+ * @throws {@link TemplateError} coded `NOTFOUND` when `id` is unknown
575
+ */
576
+ parameters(id) {
577
+ return this.template(id).parameters();
578
+ }
579
+ #instantiate(template) {
580
+ if (this.#isInstance(template)) return template;
581
+ return new Template({
582
+ ...template,
583
+ missing: template.missing ?? this.#missing,
584
+ locale: template.locale ?? this.#locale
585
+ });
586
+ }
587
+ #isInstance(template) {
588
+ return "fill" in template && typeof template.fill === "function" && "validate" in template && typeof template.validate === "function" && "parameters" in template && typeof template.parameters === "function";
589
+ }
590
+ #throwNotFound(id) {
591
+ throw new TemplateError("NOTFOUND", `Unknown template id: ${id}`, { id });
592
+ }
593
+ };
594
+ //#endregion
595
+ //#region src/core/factories.ts
596
+ /**
597
+ * Create a template.
598
+ *
599
+ * @param options - The template's `name` / `content`, an optional `id`
600
+ * (defaults to a generated UUID), `placeholders`, catalog metadata, and
601
+ * `missing` / `locale` fill defaults
602
+ * @returns A working {@link TemplateInterface}
603
+ *
604
+ * @example
605
+ * ```ts
606
+ * import { createTemplate } from '@src/core'
607
+ *
608
+ * const greeting = createTemplate({ name: 'greeting', content: 'Hi {{name}}' })
609
+ * greeting.fill({ name: 'Ada' }) // 'Hi Ada'
610
+ * ```
611
+ */
612
+ function createTemplate(options) {
613
+ return new Template(options);
614
+ }
615
+ /**
616
+ * Create a template registry.
617
+ *
618
+ * @param options - Optional initial `templates` seed collection and
619
+ * manager-wide `missing` / `locale` fill defaults, emitter `on` hooks, and
620
+ * an `error` handler
621
+ * @returns A working {@link TemplateManagerInterface}
622
+ *
623
+ * @example
624
+ * ```ts
625
+ * import { createTemplateManager } from '@src/core'
626
+ *
627
+ * const templates = createTemplateManager({
628
+ * templates: [{ id: 'greeting', name: 'greeting', content: 'Hi {{name}}' }],
629
+ * })
630
+ * templates.fill('greeting', { name: 'Ada' }) // 'Hi Ada'
631
+ * ```
632
+ */
633
+ function createTemplateManager(options) {
634
+ return new TemplateManager(options);
635
+ }
636
+ //#endregion
637
+ export { DEFAULT_LOCALE, DEFAULT_MISSING_POLICY, FILL_PATTERN, Template, TemplateError, TemplateManager, UNSAFE_FIELD_SEGMENTS, createTemplate, createTemplateManager, fillTemplate, formatValue, isTemplateError, placeholderShape, resolveSafeField };
638
+
639
+ //# sourceMappingURL=index.js.map