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

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: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<TContext> 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<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 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<string, unknown>
  • PluginPriority — enum: CRITICAL = 95, HIGH = 80, MEDIUM = 50, LOW = 20, MINIMAL = 5
  • ParseState — { 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_PLUGIN
  • SKIP_PLUGIN — symbol — return from Plugin.transform to skip this match
  • Plugin<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 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