128 lines
13 KiB
Markdown
128 lines
13 KiB
Markdown
# Spec: templates
|
|
|
|
## 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.
|