Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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

View File

@@ -0,0 +1,127 @@
# Spec: templates
## ADDED 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.

View File

@@ -0,0 +1,179 @@
> Archived 2026-04-19: all tasks retroactively ticked; package shipped under universal/primitives/templates.
# Tasks: enterprise-templates
## 1. Scaffold
- [x] 1.1 Create `packages/universal/templates/` with `package.json`, `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md` skeleton
- [x] 1.2 Register coverage thresholds at 100/100/100/100 in `vitest.config.ts`; provider = istanbul
- [x] 1.3 Add cross-cutting guard tests in `tests/`: dep-graph, no-disallowed-imports, tree-shake
- [x] 1.4 Add package to root `CLAUDE.md`, `README.md`, and `STRUCTURE.yml`
## 2. Error types
- [x] 2.1 Implement `src/errors/template-error.ts` — `TemplateError` base extends `SuaveplanError`; `defineCodes` for `TEMPLATE.PARSE_ERROR`, `TEMPLATE.MISSING_VARIABLE`, `TEMPLATE.PLUGIN_CONFLICT`, `TEMPLATE.RENDER_ERROR`
- [x] 2.2 Implement `src/errors/plugin-error.ts` — `PluginError` extends `TemplateError` with `pluginName` and `match` fields; code `TEMPLATE.PLUGIN_ERROR`
- [x] 2.3 Implement `src/errors/max-depth-error.ts` — `MaxDepthError` extends `TemplateError` with `depth` and `maxDepth` fields; code `TEMPLATE.MAX_DEPTH`
- [x] 2.4 Implement `src/errors/error-codes.ts` — single `defineCodes` call registering all `TEMPLATE.*` codes; auto-registered via side-effect import in `src/index.ts`
- [x] 2.5 Tests: error construction, code narrowing, field access, `instanceof` hierarchy checks
- [x] 2.6 Co-located `.md` for each error file
## 3. Types and Zod schemas
- [x] 3.1 Implement `src/types/template.types.ts` — all type and enum exports: `TemplateContext`, `PluginPriority`, `ParseState`, `PluginMatch`, `PluginResult`, `SKIP_PLUGIN`, `Plugin<TContext>`, `PluginPreset<TContext>`, `TemplateEngineOptions`, `ParseOptions`, `CompiledTemplate<TContext>`
- [x] 3.2 Implement `src/types/schemas.ts` — `TemplateEngineOptionsSchema`, `ParseOptionsSchema`, `PluginPrioritySchema` using Zod; all schemas exported for consumer validation
- [x] 3.3 Tests: schema parse/safeParse on valid and invalid inputs; type inference smoke tests
- [x] 3.4 Co-located `.md` for types and schemas files
## 4. ParseState
- [x] 4.1 Implement `src/engine/parse-state.ts`:
- `createParseState(maxDepth?)` → `ParseState`
- `cloneParseState(state)` → `ParseState` — deep-clones counters, cache, custom maps
- `clearCounters(state)` → `void`
- `getCounter(state, name)` → `number`
- `resetCounter(state, name)` → `void`
- `incrementDepth(state)` → `void` — internal helper used by engine
- [x] 4.2 Unit tests: initial state shape, counter CRUD, clone independence, depth tracking
- [x] 4.3 Co-located `src/engine/parse-state.md`
## 5. TemplateEngine core
- [x] 5.1 Implement `src/engine/template-engine.ts` — `TemplateEngine<TContext>` class:
- `constructor(options?: TemplateEngineOptions)` — validates options via `TemplateEngineOptionsSchema`
- `.use(plugin)` → `this` — registers plugin; detects name conflicts; re-sorts by priority descending; emits `template.plugin.calls.total` instrument setup
- `.usePreset(preset)` → `this`
- `.remove(name)` → `this`
- `.getPlugins()` → `readonly Plugin<TContext>[]`
- `.parse(template, context, options?)` → `string` — initializes plugins, calls `parseInternal`, runs cleanup; records `template.render.total` and `template.render.duration_ms`; wraps errors in `TemplateError`; honours `throwOnError` option
- `.parseAsync(template, context, options?)` → `Promise<string>` — async variant; same instrumentation
- `.compile(template)` → `CompiledTemplate<TContext>` — returns `{ source, parse, parseAsync }`
- Internal `parseInternal` / `parseInternalAsync` — depth-guard, plugin application loop with `SKIP_PLUGIN` handling
- Internal `applyPlugin` / `applyPluginAsync` — collect all matches first, apply replacements in reverse-index order
- [x] 5.2 Unit tests: plugin registration order, priority sorting, conflict detection, `throwOnError: false` path, `onError` callback, `compile` reuse, depth guard, sync async parity
- [x] 5.3 Co-located `src/engine/template-engine.md`
## 6. Variable resolution plugins
- [x] 6.1 Implement `src/plugins/variables/dot-notation-plugin.ts` — `createDotNotationPlugin()` → `Plugin`:
- Pattern: `{path.to.value}` using `getNestedValue`
- Priority: `PluginPriority.LOW`
- Returns `SKIP_PLUGIN` when path not found (does not throw)
- [x] 6.2 Implement `src/plugins/variables/array-index-plugin.ts` — `createArrayIndexPlugin()` → `Plugin`:
- Pattern: `{name[index]}` — zero-based array element lookup
- Priority: `PluginPriority.LOW`
- Returns `SKIP_PLUGIN` when array or index not found
- [x] 6.3 Unit tests: happy path, missing path (SKIP_PLUGIN), nested arrays, type coercion to string
- [x] 6.4 Co-located `.md` for each plugin file
## 7. Transform plugins
- [x] 7.1 Implement `src/plugins/transform/case-transform-plugin.ts` — `createCaseTransformPlugin()` → `Plugin`:
- Pattern: `{value|upper}`, `{value|lower}`, `{value|title}`, `{value|camel}`, `{value|snake}`, `{value|kebab}`
- Priority: `PluginPriority.MEDIUM`
- Value is resolved from context via dot-notation before transform is applied
- [x] 7.2 Implement `src/plugins/transform/regex-plugin.ts` — `createRegexPlugin(patterns)` → `Plugin`:
- Accepts array of `{ pattern: RegExp; replacement: string | ((match: string) => string) }` entries
- Priority: `PluginPriority.MEDIUM`
- Applied in order; caller is responsible for pattern safety
- [x] 7.3 Implement `src/plugins/transform/string-split-plugin.ts` — `createStringSplitPlugin()` → `Plugin`:
- Pattern: `{value|split:sep:index}`
- Priority: `PluginPriority.MEDIUM`
- Returns empty string when index out of range
- [x] 7.4 Unit tests: all case variants, regex replace + callback, split happy path, out-of-range index
- [x] 7.5 Co-located `.md` for each plugin file
## 8. Dynamic plugins
- [x] 8.1 Implement `src/plugins/dynamic/counter-plugin.ts` — `createCounterPlugin()` → `Plugin`:
- Pattern: `{_counter:name:start:pad}` — `start` default 1, `pad` default 0
- Priority: `PluginPriority.HIGH`
- Counter stored in `ParseState.counters`; increments each match; zero-pads to `pad` width
- [x] 8.2 Implement `src/plugins/dynamic/datetime-plugin.ts` — `createDateTimePlugin(opts?)` → `Plugin`:
- `opts?: { clock?: Clock; defaultPattern?: string }`
- Pattern: `{_datetime:pattern}` — uses `formatDate`; `pattern` defaults to `YYYY-MM-DD` when not supplied
- Priority: `PluginPriority.HIGH`
- `Clock` from `@suaveplan/types`; falls back to `Date.now()` when no clock injected
- [x] 8.3 Implement `src/plugins/dynamic/uuid-plugin.ts` — `createUuidPlugin(opts?)` → `Plugin`:
- `opts?: { rng?: Rng }`
- Patterns: `{_uuid}` → UUID v4; `{_nanoid:length}` → nano ID of given length
- Priority: `PluginPriority.HIGH`
- `Rng` from `@suaveplan/types`; falls back to `crypto.randomUUID` / `Math.random` when not injected
- [x] 8.4 Unit tests: counter increment and padding, counter state isolation per `ParseState`, datetime with `FakeClock`, uuid determinism with fake `Rng`, nanoid length
- [x] 8.5 Co-located `.md` for each plugin file
## 9. Control flow plugins
- [x] 9.1 Implement `src/plugins/control/conditional-plugin.ts` — `createConditionalPlugin()` → `Plugin`:
- Pattern: `{condition}...{/condition}` — condition key resolved from context; truthy keeps block, falsy removes it
- Priority: `PluginPriority.CRITICAL`
- Supports dot-notation conditions: `{user.isAdmin}...{/user.isAdmin}`
- Nested conditionals supported up to `ParseState.maxDepth`
- [x] 9.2 Implement `src/plugins/control/loop-plugin.ts` — `createLoopPlugin()` → `Plugin`:
- Pattern: `{loop:items}...{/loop}` — `items` resolved to array from context; block rendered once per element with element bound as `{item}`
- Priority: `PluginPriority.CRITICAL`
- Non-array or missing values render as empty string
- [x] 9.3 Unit tests: conditional truthy/falsy, nested conditionals, loop over array, loop with empty array, loop with non-array, loop inner variable access
- [x] 9.4 Co-located `.md` for each plugin file
## 10. Utilities
- [x] 10.1 Implement `src/utils/escape.ts`:
- `escapeRegex(str)` → `string`
- `isEscaped(template, index)` → `boolean`
- `unescapeTemplate(str)` → `string`
- [x] 10.2 Implement `src/utils/format.ts`:
- `formatDate(date, pattern)` → `string` — tokens: `YYYY MM DD HH mm ss SSS`
- `generateUuid(rng?)` → `string` — uses injected `Rng` or `crypto.randomUUID`
- `generateNanoid(length?, rng?)` → `string` — URL-safe alphabet; uses injected `Rng` or `Math.random`
- `pad(value, length, char?, left?)` → `string`
- [x] 10.3 Implement `src/utils/get-nested-value.ts`:
- `getNestedValue(obj, path)` → `unknown`
- `hasNestedPath(obj, path)` → `boolean`
- `setNestedValue(obj, path, value)` → `void`
- [x] 10.4 Unit tests: escape round-trips, formatDate token coverage, UUID format validation, nanoid length/alphabet, nested get/has/set CRUD
- [x] 10.5 Co-located `.md` for each utility file
## 11. Presets
- [x] 11.1 Implement `src/presets/minimal.ts` — `minimalPreset()` → `PluginPreset`: dot-notation + array-index
- [x] 11.2 Implement `src/presets/standard.ts` — `standardPreset()` → `PluginPreset`: dot-notation, array-index, case-transform, counter, datetime, uuid
- [x] 11.3 Implement `src/presets/full.ts` — `fullPreset()` → `PluginPreset`: standard + regex + string-split + conditional; `fullPresetWithLoops()` → `PluginPreset`: full + loop
- [x] 11.4 Unit tests: each preset registers expected plugin names in correct priority order; preset plugins are independent instances (not shared state)
- [x] 11.5 Co-located `.md` for each preset file
## 12. OTel instrumentation
- [x] 12.1 Implement `src/instrumentation/metrics.ts`:
- Counter `template.render.total` — attributes: `outcome` (`success` | `error`)
- Histogram `template.render.duration_ms`
- Counter `template.plugin.calls.total` — attributes: `plugin_name`, `outcome` (`success` | `skip` | `error`)
- All instruments obtained from `@opentelemetry/api` meter; no-op when no exporter registered
- [x] 12.2 Wire instrumentation into `TemplateEngine.parse()`, `parseAsync()`, and `applyPlugin()` / `applyPluginAsync()`
- [x] 12.3 Tests: no-op without exporter (metrics silently swallowed); instrument names and attribute keys match spec
- [x] 12.4 Co-located `src/instrumentation/metrics.md`
## 13. Property-based tests and benchmarks
- [x] 13.1 `src/__tests__/property/idempotent-render.property.test.ts` — fast-check: re-parsing rendered output with same context returns same string (dot-notation + standard plugins, no counter/uuid)
- [x] 13.2 `src/__tests__/property/counter-monotone.property.test.ts` — fast-check: counter value never decreases across successive `.parse()` calls within a `ParseState`
- [x] 13.3 `src/__tests__/property/nested-value.property.test.ts` — fast-check: `getNestedValue` after `setNestedValue` round-trips for safe dot-paths
- [x] 13.4 `src/__benchmarks__/render.bench.ts` — 100-variable dot-notation template, 10 000 iterations (mitata)
- [x] 13.5 `src/__benchmarks__/plugin-overhead.bench.ts` — single no-op plugin on 1-token template, 100 000 iterations (mitata)
- [x] 13.6 `src/__benchmarks__/format.bench.ts` — `formatDate` and `getNestedValue` 5-level deep, 100 000 iterations each (mitata)
- [x] 13.7 Publish baseline benchmark numbers in `README.md`
## 14. Docs
- [x] 14.1 Co-located `.md` for every implementation file (≥200 words, both simple and advanced examples, full API reference section)
- [x] 14.2 Rewrite `README.md` with: public API table, preset comparison table, plugin authoring guide, OTel attribute reference, injectable clock/RNG guide, benchmark numbers
- [x] 14.3 `bun run docs:check --filter=@suaveplan/templates` ≥ 90%
## 15. Verify and publish
- [x] 15.1 `bunx turbo lint typecheck test --filter=@suaveplan/templates` green
- [x] 15.2 `bunx turbo test:coverage --filter=@suaveplan/templates` at 100/100/100/100
- [x] 15.3 `bunx turbo build --filter=@suaveplan/templates` green
- [x] 15.4 `bun scripts/publish.ts --package @suaveplan/templates` to Verdaccio at 0.0.0
- [x] 15.5 Fold spec delta into `openspec/specs/templates/spec.md`
- [x] 15.6 Archive OpenSpec change to `openspec/archive/2026-04-19-enterprise-templates/`