9.6 KiB
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:observabilitytslib→catalog:build
Dev deps (via catalog):
vitest,@vitest/coverage-istanbul→catalog:testingfast-check→catalog:testingmitata→catalog:testing
Preserved public API surface (from main branch)
Parse state
createParseState(maxDepth?)→ParseState— creates initial parse state with counters, depth, cache, and custom mapscloneParseState(state)→ParseState— deep-clones a parse state (counters, cache, custom maps)clearCounters(state)→void— resets all named counters to zerogetCounter(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<TContext>class — generic over TContext extendingTemplateContextconstructor(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<TContext>[].parse(template, context, options?)→string— synchronous render.parseAsync(template, context, options?)→Promise<string>— async render.compile(template)→CompiledTemplate<TContext>— returns reusable compiled template object
Control flow plugins
createConditionalPlugin()→Plugin—{condition}...{/condition}blocks; evaluates truthy context valuescreateLoopPlugin()→Plugin—{loop:items}...{/loop}blocks; iterates over arrays in context
Dynamic variable plugins
createCounterPlugin()→Plugin—{_counter:name:start:pad}— stateful named counters with zero-paddingcreateDateTimePlugin(opts?)→Plugin—{_datetime:pattern}— date/time formatting with injectableClockcreateUuidPlugin(opts?)→Plugin—{_uuid}/{_nanoid:length}— UUID v4 and nano-ID generation with injectableRng
Transform plugins
createCaseTransformPlugin()→Plugin—{value|upper},{value|lower},{value|title},{value|camel},{value|snake},{value|kebab}— string case transformscreateRegexPlugin(patterns)→Plugin— applies caller-supplied regex replacements to resolved valuescreateStringSplitPlugin()→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 contextcreateDotNotationPlugin()→Plugin—{user.name}— nested property access via dot path
Presets
minimalPreset()→PluginPreset— dot-notation + array-index onlystandardPreset()→PluginPreset— dot-notation, array-index, case transforms, counter, datetime, uuidfullPreset()→PluginPreset— standard + regex + string-split + conditionalfullPresetWithLoops()→PluginPreset— full + loop plugin
Types (all exported)
TemplateContext—Record<string, unknown>PluginPriority— enum:CRITICAL = 95,HIGH = 80,MEDIUM = 50,LOW = 20,MINIMAL = 5ParseState—{ counters: Map<string, number>; depth: number; maxDepth: number; cache: Map<string, unknown>; custom: Map<string, unknown> }PluginMatch—{ match: string; groups: RegExpMatchArray | null; index: number }PluginResult—string | Promise<string> | typeof SKIP_PLUGINSKIP_PLUGIN—symbol— return fromPlugin.transformto skip this matchPlugin<TContext>—{ name, priority, pattern, async?, transform(), init?(), cleanup?() }PluginPreset<TContext>—{ name, plugins: Plugin<TContext>[] }TemplateEngineOptions—{ maxDepth?, throwOnError?, enableCache?, onError? }ParseOptions—{ maxDepth?, async? }CompiledTemplate<TContext>—{ source, parse(context), parseAsync(context) }TemplateError— error withtemplate,context,causefields (genesis: extendsSuaveplanError)PluginError— error withpluginName,match,causefields (genesis: extendsSuaveplanError)MaxDepthError— error withdepth,maxDepthfields (genesis: extendsSuaveplanError)
Utilities (all exported)
escapeRegex(str)→string— escapes regex metacharactersisEscaped(template, index)→boolean— checks if a character at index is backslash-escapedunescapeTemplate(str)→string— removes template escape charactersformatDate(date, pattern)→string— formats aDateusingYYYY MM DD HH mm ss SSStokensgetNestedValue(obj, path)→unknown— resolves dot-path into a plain objecthasNestedPath(obj, path)→boolean— checks dot-path existence without throwingsetNestedValue(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 lengthgenerateUuid()→string— UUID v4 (genesis: uses injectableRng; falls back tocrypto.randomUUID)generateNanoid(length?)→string— URL-safe nano ID (genesis: uses injectableRng)
New additions for genesis
Structured errors
All error classes extend SuaveplanError from @suaveplan/error with stable defineCodes error codes:
TemplateErrorbase extendsSuaveplanError; code namespaceTEMPLATE.*PluginErrorextendsTemplateError; codeTEMPLATE.PLUGIN_ERRORMaxDepthErrorextendsTemplateError; codeTEMPLATE.MAX_DEPTH- Additional codes:
TEMPLATE.PARSE_ERROR,TEMPLATE.MISSING_VARIABLE,TEMPLATE.PLUGIN_CONFLICT,TEMPLATE.RENDER_ERROR
Zod schemas
TemplateEngineOptionsSchema— validatesTemplateEngineOptionsat the boundaryParseOptionsSchema— validatesParseOptionsPluginPrioritySchema— validates priority integer in[0, 100]
Injectable clock (DateTimePlugin)
createDateTimePlugin({ clock?: Clock })— acceptsClockfrom@suaveplan/types; defaults toDate.now()when no clock is injected; enables deterministic date rendering in tests viaFakeClock
Injectable RNG (UuidPlugin, generateUuid, generateNanoid)
createUuidPlugin({ rng?: Rng })— acceptsRngfrom@suaveplan/typesfor deterministic UUID/nanoid generation in testsgenerateUuid(rng?)andgenerateNanoid(length?, rng?)accept optionalRng; fall back tocrypto.randomUUID/Math.randomwhen 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:
getCountervalue never decreases across successive.parse()calls with the counter plugin - Dot-notation round-trip:
getNestedValue(obj, path)aftersetNestedValue(obj, path, value)returnsvaluefor any safe path - Jitter-free assertion:
PluginPriorityenum values are strictly orderedMINIMAL < 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
formatDateformatting — 100 000 iterationsgetNestedValue5-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