# OpenSpec Change: enterprise-templates - **Change ID**: enterprise-templates - **Package**: `@suaveplan/templates` - **Category**: universal - **Target path**: `packages/universal/templates` - **Status**: PROPOSED - **Date**: 2026-04-19 - **Reference spec**: `/mnt/s/web/suaveplan/enterprise/templates.md` ## Summary Greenfield implementation of `@suaveplan/templates` for the genesis branch. The existing main-branch templates package provides the baseline public-API surface to preserve; this change delivers an enterprise-grade plugin-based template engine with type-safe contexts, variable interpolation (dot-notation, array-index), transform plugins (case, regex, split), dynamic plugins (counter, datetime with injectable clock, uuid with injectable RNG), control-flow plugins (conditional, loop), configurable delimiters, preset bundles, structured errors extending `@suaveplan/error`, Zod schemas, OTel instrumentation, property-based tests, and benchmarks. ## Current-state audit (genesis) The package does not exist on genesis yet — this is a fresh build. ## Available workspace deps on genesis | Package | Available | |---------|-----------| | `@suaveplan/error` | ✅ | | `@suaveplan/types` | ✅ | | `@suaveplan/logger` | ✅ | | `@suaveplan/opentelemetry` | ❌ not yet built — use `@opentelemetry/api` directly | | `@suaveplan/schema-registry` | ❌ not yet built — export Zod schemas directly | | `@suaveplan/i18n` | ❌ not yet built — skip i18n integration | | `@suaveplan/datetime` | ❌ not yet built — `createDateTimePlugin` uses native `Date` internally via injectable `Clock` | External deps (via catalog): - `zod` → `catalog:forms` - `@opentelemetry/api` → `catalog:observability` - `tslib` → `catalog:build` Dev deps (via catalog): - `vitest`, `@vitest/coverage-istanbul` → `catalog:testing` - `fast-check` → `catalog:testing` - `mitata` → `catalog:testing` ## Preserved public API surface (from main branch) ### Parse state - `createParseState(maxDepth?)` → `ParseState` — creates initial parse state with counters, depth, cache, and custom maps - `cloneParseState(state)` → `ParseState` — deep-clones a parse state (counters, cache, custom maps) - `clearCounters(state)` → `void` — resets all named counters to zero - `getCounter(state, name)` → `number` — returns current value of a named counter (0 if not set) - `resetCounter(state, name)` → `void` — resets a single named counter to zero ### Core engine - `TemplateEngine` class — generic over TContext extending `TemplateContext` - `constructor(options?: TemplateEngineOptions)` - `.use(plugin)` → `this` — registers a plugin, re-sorts by priority descending - `.usePreset(preset)` → `this` — registers all plugins from a preset - `.remove(name)` → `this` — removes a plugin by name - `.getPlugins()` → `readonly Plugin[]` - `.parse(template, context, options?)` → `string` — synchronous render - `.parseAsync(template, context, options?)` → `Promise` — async render - `.compile(template)` → `CompiledTemplate` — returns reusable compiled template object ### Control flow plugins - `createConditionalPlugin()` → `Plugin` — `{condition}...{/condition}` blocks; evaluates truthy context values - `createLoopPlugin()` → `Plugin` — `{loop:items}...{/loop}` blocks; iterates over arrays in context ### Dynamic variable plugins - `createCounterPlugin()` → `Plugin` — `{_counter:name:start:pad}` — stateful named counters with zero-padding - `createDateTimePlugin(opts?)` → `Plugin` — `{_datetime:pattern}` — date/time formatting with injectable `Clock` - `createUuidPlugin(opts?)` → `Plugin` — `{_uuid}` / `{_nanoid:length}` — UUID v4 and nano-ID generation with injectable `Rng` ### Transform plugins - `createCaseTransformPlugin()` → `Plugin` — `{value|upper}`, `{value|lower}`, `{value|title}`, `{value|camel}`, `{value|snake}`, `{value|kebab}` — string case transforms - `createRegexPlugin(patterns)` → `Plugin` — applies caller-supplied regex replacements to resolved values - `createStringSplitPlugin()` → `Plugin` — `{value|split:sep:index}` — splits on separator and returns element at index ### Variable resolution plugins - `createArrayIndexPlugin()` → `Plugin` — `{items[0]}` — zero-based array index access in context - `createDotNotationPlugin()` → `Plugin` — `{user.name}` — nested property access via dot path ### Presets - `minimalPreset()` → `PluginPreset` — dot-notation + array-index only - `standardPreset()` → `PluginPreset` — dot-notation, array-index, case transforms, counter, datetime, uuid - `fullPreset()` → `PluginPreset` — standard + regex + string-split + conditional - `fullPresetWithLoops()` → `PluginPreset` — full + loop plugin ### Types (all exported) - `TemplateContext` — `Record` - `PluginPriority` — enum: `CRITICAL = 95`, `HIGH = 80`, `MEDIUM = 50`, `LOW = 20`, `MINIMAL = 5` - `ParseState` — `{ counters: Map; depth: number; maxDepth: number; cache: Map; custom: Map }` - `PluginMatch` — `{ match: string; groups: RegExpMatchArray | null; index: number }` - `PluginResult` — `string | Promise | typeof SKIP_PLUGIN` - `SKIP_PLUGIN` — `symbol` — return from `Plugin.transform` to skip this match - `Plugin` — `{ name, priority, pattern, async?, transform(), init?(), cleanup?() }` - `PluginPreset` — `{ name, plugins: Plugin[] }` - `TemplateEngineOptions` — `{ maxDepth?, throwOnError?, enableCache?, onError? }` - `ParseOptions` — `{ maxDepth?, async? }` - `CompiledTemplate` — `{ source, parse(context), parseAsync(context) }` - `TemplateError` — error with `template`, `context`, `cause` fields (genesis: extends `SuaveplanError`) - `PluginError` — error with `pluginName`, `match`, `cause` fields (genesis: extends `SuaveplanError`) - `MaxDepthError` — error with `depth`, `maxDepth` fields (genesis: extends `SuaveplanError`) ### Utilities (all exported) - `escapeRegex(str)` → `string` — escapes regex metacharacters - `isEscaped(template, index)` → `boolean` — checks if a character at index is backslash-escaped - `unescapeTemplate(str)` → `string` — removes template escape characters - `formatDate(date, pattern)` → `string` — formats a `Date` using `YYYY MM DD HH mm ss SSS` tokens - `getNestedValue(obj, path)` → `unknown` — resolves dot-path into a plain object - `hasNestedPath(obj, path)` → `boolean` — checks dot-path existence without throwing - `setNestedValue(obj, path, value)` → `void` — sets a value at a dot-path (creates intermediate objects) - `pad(value, length, char?, left?)` → `string` — pads a string or number to a target length - `generateUuid()` → `string` — UUID v4 (genesis: uses injectable `Rng`; falls back to `crypto.randomUUID`) - `generateNanoid(length?)` → `string` — URL-safe nano ID (genesis: uses injectable `Rng`) ## New additions for genesis ### Structured errors All error classes extend `SuaveplanError` from `@suaveplan/error` with stable `defineCodes` error codes: - `TemplateError` base extends `SuaveplanError`; code namespace `TEMPLATE.*` - `PluginError` extends `TemplateError`; code `TEMPLATE.PLUGIN_ERROR` - `MaxDepthError` extends `TemplateError`; code `TEMPLATE.MAX_DEPTH` - Additional codes: `TEMPLATE.PARSE_ERROR`, `TEMPLATE.MISSING_VARIABLE`, `TEMPLATE.PLUGIN_CONFLICT`, `TEMPLATE.RENDER_ERROR` ### Zod schemas - `TemplateEngineOptionsSchema` — validates `TemplateEngineOptions` at the boundary - `ParseOptionsSchema` — validates `ParseOptions` - `PluginPrioritySchema` — validates priority integer in `[0, 100]` ### Injectable clock (DateTimePlugin) - `createDateTimePlugin({ clock?: Clock })` — accepts `Clock` from `@suaveplan/types`; defaults to `Date.now()` when no clock is injected; enables deterministic date rendering in tests via `FakeClock` ### Injectable RNG (UuidPlugin, generateUuid, generateNanoid) - `createUuidPlugin({ rng?: Rng })` — accepts `Rng` from `@suaveplan/types` for deterministic UUID/nanoid generation in tests - `generateUuid(rng?)` and `generateNanoid(length?, rng?)` accept optional `Rng`; fall back to `crypto.randomUUID` / `Math.random` when omitted ### OTel instrumentation - Counter `template.render.total` — attributes: `outcome` (`success` | `error`), `preset` (engine preset label if set) - Histogram `template.render.duration_ms` — latency per `.parse()` / `.parseAsync()` call - Counter `template.plugin.calls.total` — attributes: `plugin_name`, `outcome` (`success` | `skip` | `error`) - All instruments are no-op when no OTel exporter is registered ### Property-based tests (fast-check) - Idempotent rendering: re-parsing an already-rendered output with the same context produces the same string (no side-effect variables in the result) - Counter monotone: `getCounter` value never decreases across successive `.parse()` calls with the counter plugin - Dot-notation round-trip: `getNestedValue(obj, path)` after `setNestedValue(obj, path, value)` returns `value` for any safe path - Jitter-free assertion: `PluginPriority` enum values are strictly ordered `MINIMAL < LOW < MEDIUM < HIGH < CRITICAL` ### Benchmarks (mitata) - Render a 100-variable dot-notation template — 10 000 iterations - Plugin call overhead baseline: single no-op plugin on a 1-token template — 100 000 iterations - `formatDate` formatting — 100 000 iterations - `getNestedValue` 5-level deep path — 100 000 iterations ## Impact - Adds one new package: `packages/universal/templates` - Adds one new spec capability: `templates` - No existing package consumers on genesis yet