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