Files
2026-05-27 23:29:53 +01:00

13 KiB

Spec: templates

Requirements

Package identity

  • REQ-TPL-001: The package MUST be published as @suaveplan/templates under packages/universal/templates.
  • REQ-TPL-002: The package MUST have "sideEffects": false in package.json.
  • REQ-TPL-003: The package MUST expose subpath exports per feature module for tree-shaking: ./engine/parse-state, ./engine/template-engine, ./plugins/variables/dot-notation-plugin, ./plugins/variables/array-index-plugin, ./plugins/transform/case-transform-plugin, ./plugins/transform/regex-plugin, ./plugins/transform/string-split-plugin, ./plugins/dynamic/counter-plugin, ./plugins/dynamic/datetime-plugin, ./plugins/dynamic/uuid-plugin, ./plugins/control/conditional-plugin, ./plugins/control/loop-plugin, ./presets/minimal, ./presets/standard, ./presets/full, ./utils/escape, ./utils/format, ./utils/get-nested-value, ./errors.
  • REQ-TPL-004: The package MUST have zero browser, Node.js, or React runtime dependencies — it MUST run in Bun, Node.js, and browser environments without modification.

Dependencies

  • REQ-TPL-010: Runtime dependencies MUST be limited to: @suaveplan/error (workspace:^), @suaveplan/types (workspace:^), @suaveplan/logger (workspace:^), @opentelemetry/api (catalog:observability), zod (catalog:forms), tslib (catalog:build).
  • REQ-TPL-011: All internal workspace dependencies MUST use the workspace:^ specifier.
  • REQ-TPL-012: All external dependencies MUST be pinned via Bun catalog entries — no floating version ranges.

Error types

  • REQ-TPL-020: A TemplateError class MUST extend SuaveplanError from @suaveplan/error and serve as the base for all package errors.
  • REQ-TPL-021: A PluginError class MUST extend TemplateError and carry pluginName: string and match: string fields.
  • REQ-TPL-022: A MaxDepthError class MUST extend TemplateError and carry depth: number and maxDepth: number fields.
  • REQ-TPL-023: All error codes MUST be registered via defineCodes from @suaveplan/error under the TEMPLATE namespace: TEMPLATE.PARSE_ERROR, TEMPLATE.MISSING_VARIABLE, TEMPLATE.PLUGIN_CONFLICT, TEMPLATE.RENDER_ERROR, TEMPLATE.PLUGIN_ERROR, TEMPLATE.MAX_DEPTH.
  • REQ-TPL-024: Error code registration MUST be side-effect-imported from src/index.ts so codes are always registered when the package is loaded.

Types

  • REQ-TPL-030: TemplateContext MUST be exported as Record<string, unknown>.
  • REQ-TPL-031: PluginPriority MUST be exported as a numeric enum with values CRITICAL = 95, HIGH = 80, MEDIUM = 50, LOW = 20, MINIMAL = 5.
  • REQ-TPL-032: ParseState MUST be an interface with fields: counters: Map<string, number>, depth: number, maxDepth: number, cache: Map<string, unknown>, custom: Map<string, unknown>.
  • REQ-TPL-033: PluginMatch MUST be an interface with fields: match: string, groups: RegExpMatchArray | null, index: number.
  • REQ-TPL-034: PluginResult MUST be string | Promise<string> | typeof SKIP_PLUGIN.
  • REQ-TPL-035: SKIP_PLUGIN MUST be exported as a symbol value; returning it from Plugin.transform signals the engine to leave the matched text unchanged.
  • REQ-TPL-036: Plugin<TContext> MUST be an interface with: name: string, priority: number, pattern: RegExp, async?: boolean, transform(match, context, state): PluginResult, init?(context, state): void | Promise<void>, cleanup?(context, state): void | Promise<void>.
  • REQ-TPL-037: PluginPreset<TContext> MUST be an interface with: name: string, plugins: Plugin<TContext>[].
  • REQ-TPL-038: TemplateEngineOptions MUST be an interface with: maxDepth?: number, throwOnError?: boolean, enableCache?: boolean, onError?: (error: Error, template: string, context: TemplateContext) => void.
  • REQ-TPL-039: ParseOptions MUST be an interface with: maxDepth?: number, async?: boolean.
  • REQ-TPL-040: CompiledTemplate<TContext> MUST be an interface with: source: string, parse(context: TContext): string, parseAsync(context: TContext): Promise<string>.

Zod schemas

  • REQ-TPL-041: TemplateEngineOptionsSchema MUST be exported and validate TemplateEngineOptions with Zod; all fields optional; maxDepth must be a positive integer; throwOnError and enableCache must be booleans; onError must be a function.
  • REQ-TPL-042: ParseOptionsSchema MUST be exported and validate ParseOptions with Zod.
  • REQ-TPL-043: PluginPrioritySchema MUST be exported and validate an integer in the range [0, 100].

ParseState

  • REQ-TPL-050: createParseState(maxDepth?: number) MUST return a ParseState with all maps empty, depth = 0, and maxDepth defaulting to 10.
  • REQ-TPL-051: cloneParseState(state: ParseState) MUST return a structurally independent deep copy; mutations to the clone MUST NOT affect the original.
  • REQ-TPL-052: clearCounters(state: ParseState) MUST reset all entries in state.counters to zero without removing keys.
  • REQ-TPL-053: getCounter(state: ParseState, name: string) MUST return the current counter value or 0 if the counter has never been set.
  • REQ-TPL-054: resetCounter(state: ParseState, name: string) MUST set a single named counter to zero.

TemplateEngine

  • REQ-TPL-060: TemplateEngine<TContext> MUST be generic over TContext extends TemplateContext.
  • REQ-TPL-061: The constructor MUST validate options via TemplateEngineOptionsSchema; invalid options MUST throw a TemplateError with code TEMPLATE.PARSE_ERROR.
  • REQ-TPL-062: .use(plugin) MUST append the plugin, detect name conflicts (same name already registered), re-sort all plugins by priority descending, and return this for chaining. Name conflicts MUST throw TemplateError with code TEMPLATE.PLUGIN_CONFLICT.
  • REQ-TPL-063: .usePreset(preset) MUST call .use() for each plugin in preset.plugins and return this.
  • REQ-TPL-064: .remove(name) MUST remove the plugin with the given name and return this; silently no-ops if not found.
  • REQ-TPL-065: .getPlugins() MUST return a readonly snapshot (not a live reference to the internal array).
  • REQ-TPL-066: .parse(template, context, options?) MUST call init hooks in priority order, apply plugins in priority order via parseInternal, call cleanup hooks in priority order, and return the rendered string. Sync plugins that return a Promise from init or cleanup MUST cause a TemplateError instructing the caller to use parseAsync.
  • REQ-TPL-067: .parseAsync(template, context, options?) MUST be the async variant of .parse(), awaiting all hooks and transforms.
  • REQ-TPL-068: .compile(template) MUST return a CompiledTemplate<TContext> whose .parse() and .parseAsync() delegate to the engine.
  • REQ-TPL-069: When throwOnError is false, errors during parse MUST be passed to onError and the original template string MUST be returned unchanged.
  • REQ-TPL-070: The internal depth guard MUST throw MaxDepthError (code TEMPLATE.MAX_DEPTH) when state.depth >= state.maxDepth before entering a new parse level.
  • REQ-TPL-071: Plugin matches MUST be applied in reverse-index order so earlier matches are not displaced by later replacements within the same pass.

Variable resolution plugins

  • REQ-TPL-080: createDotNotationPlugin() MUST match {path.to.value} patterns using getNestedValue; priority MUST be PluginPriority.LOW; MUST return SKIP_PLUGIN when the path is not found in context.
  • REQ-TPL-081: createArrayIndexPlugin() MUST match {name[index]} patterns with zero-based indexing; priority MUST be PluginPriority.LOW; MUST return SKIP_PLUGIN when the array or index is absent.

Transform plugins

  • REQ-TPL-090: createCaseTransformPlugin() MUST support |upper, |lower, |title, |camel, |snake, |kebab transform suffixes; priority MUST be PluginPriority.MEDIUM.
  • REQ-TPL-091: createRegexPlugin(patterns) MUST accept an array of { pattern: RegExp; replacement: string | ((match: string) => string) } entries applied in order; priority MUST be PluginPriority.MEDIUM.
  • REQ-TPL-092: createStringSplitPlugin() MUST match {value|split:sep:index} and return the element at the given index after splitting on sep; out-of-range indexes MUST return an empty string; priority MUST be PluginPriority.MEDIUM.

Dynamic plugins

  • REQ-TPL-100: createCounterPlugin() MUST match {_counter:name:start:pad}, default start to 1 and pad to 0, increment on each match within the ParseState, and zero-pad to pad width; priority MUST be PluginPriority.HIGH.
  • REQ-TPL-101: createDateTimePlugin(opts?) MUST accept { clock?: Clock; defaultPattern?: string }, resolve the current time via the injected Clock or Date.now() when absent, format with formatDate, and default the pattern to YYYY-MM-DD; priority MUST be PluginPriority.HIGH.
  • REQ-TPL-102: createUuidPlugin(opts?) MUST accept { rng?: Rng }, generate UUID v4 for {_uuid} and nano ID for {_nanoid:length} using the injected Rng or crypto.randomUUID / Math.random when absent; priority MUST be PluginPriority.HIGH.

Control flow plugins

  • REQ-TPL-110: createConditionalPlugin() MUST match {condition}...{/condition} blocks, resolve the condition key (including dot-notation paths) from context, keep the block when truthy, remove it when falsy; priority MUST be PluginPriority.CRITICAL.
  • REQ-TPL-111: createLoopPlugin() MUST match {loop:items}...{/loop} blocks, resolve items to an array in context, render the block body once per element with the element accessible as {item}, and produce an empty string for missing or non-array values; priority MUST be PluginPriority.CRITICAL.

Presets

  • REQ-TPL-120: minimalPreset() MUST return a PluginPreset containing exactly: dot-notation, array-index.
  • REQ-TPL-121: standardPreset() MUST return a PluginPreset containing exactly: dot-notation, array-index, case-transform, counter, datetime, uuid.
  • REQ-TPL-122: fullPreset() MUST return a PluginPreset containing all standard plugins plus: regex, string-split, conditional.
  • REQ-TPL-123: fullPresetWithLoops() MUST return a PluginPreset containing all full plugins plus: loop.
  • REQ-TPL-124: Each call to a preset factory MUST return new plugin instances — presets MUST NOT share mutable state between engine instances.

Utilities

  • REQ-TPL-130: escapeRegex(str) MUST escape all regex metacharacters so the result is safe to use in new RegExp(...).
  • REQ-TPL-131: isEscaped(template, index) MUST return true if the character at index is preceded by a backslash escape.
  • REQ-TPL-132: unescapeTemplate(str) MUST remove backslash-escape characters from a template string.
  • REQ-TPL-133: formatDate(date, pattern) MUST replace tokens YYYY, MM, DD, HH, mm, ss, SSS with the corresponding UTC-or-local date components zero-padded to their natural widths.
  • REQ-TPL-134: generateUuid(rng?) MUST produce a valid UUID v4 string; when rng is injected it MUST be used for all random bytes.
  • REQ-TPL-135: generateNanoid(length?, rng?) MUST produce a URL-safe ID of the given length (default 21) using only [0-9A-Za-z] characters; when rng is injected it MUST be used for all random bytes.
  • REQ-TPL-136: pad(value, length, char?, left?) MUST pad value to length using char (default '0') on the left (default) or right.
  • REQ-TPL-137: getNestedValue(obj, path) MUST traverse a plain object by dot-separated path segments and return undefined when any segment is absent.
  • REQ-TPL-138: hasNestedPath(obj, path) MUST return true if and only if the final segment of the path exists and is not undefined.
  • REQ-TPL-139: setNestedValue(obj, path, value) MUST create intermediate plain objects as needed and set the value at the final path segment.

OTel instrumentation

  • REQ-TPL-140: A counter named template.render.total MUST be incremented once per .parse() / .parseAsync() call with an outcome attribute of "success" or "error".
  • REQ-TPL-141: A histogram named template.render.duration_ms MUST record the elapsed milliseconds for every .parse() / .parseAsync() call.
  • REQ-TPL-142: A counter named template.plugin.calls.total MUST be incremented once per plugin invocation with attributes plugin_name (string) and outcome ("success" | "skip" | "error").
  • REQ-TPL-143: All OTel instruments MUST be no-op when no exporter is registered — the package MUST NOT fail or warn in environments without OTel.

Quality gates

  • REQ-TPL-150: Test coverage MUST reach 100 statements / 100 branches / 100 functions / 100 lines measured by istanbul.
  • REQ-TPL-151: Docs coverage MUST reach ≥ 90% as measured by bun run docs:check.
  • REQ-TPL-152: Every implementation .ts file MUST have a sibling .md with ≥ 200 words, a basic example, an advanced example, and a full API reference section.
  • REQ-TPL-153: bunx turbo lint typecheck test --filter=@suaveplan/templates MUST be green before publish.
  • REQ-TPL-154: Property-based tests using fast-check MUST cover: idempotent rendering, counter monotone increase, and getNestedValue / setNestedValue round-trip.
  • REQ-TPL-155: Benchmarks using mitata MUST cover: 100-variable dot-notation render (10 000 iterations), single no-op plugin overhead (100 000 iterations), formatDate formatting (100 000 iterations), and getNestedValue 5-level deep (100 000 iterations).
  • REQ-TPL-156: The package MUST pass a tree-shake guard test confirming "sideEffects": false and that side-effect-only imports (error code registration) do not prevent dead-code elimination of unused modules.