Initial Commit
This commit is contained in:
175
openspec/archive/2026-04-19-enterprise-templates/proposal.md
Normal file
175
openspec/archive/2026-04-19-enterprise-templates/proposal.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user