Initial Commit
This commit is contained in:
250
packages/foundation/data/templates/README.md
Normal file
250
packages/foundation/data/templates/README.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# @suaveplan/templates - Plugin-based template engine with type-safe contexts, variable interpolation, transform and control-flow plugins, and preset bundles.
|
||||
|
||||
## Summary
|
||||
|
||||
A plugin-based string template engine for TypeScript. Resolves `{variable}` and `{variable:transform}` tokens in strings using a priority-sorted, regex-scan pipeline — no eval, no AST parser overhead. Designed for filling templates in notification messages, email subjects, file name patterns, code generation, and similar string-expansion tasks. Ships nine built-in plugins: dot-notation and array-index variable resolution; case, regex, and split transforms; counter, datetime, and UUID dynamic generators; and conditional/loop control flow. Three preset bundles (`minimal`, `standard`, `full`) let you trade features for bundle size. The injectable `Clock` and `Rng` make every rendering call deterministic in tests. Do not confuse with `@suaveplan/i18n`, which handles locale-keyed message catalogs and plural forms — use this package when your source strings are already in the target language and you only need token substitution.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [ParseState](./src/engine/ParseState.md) — Provides factory functions and mutation helpers for `ParseState` — the stateful object that the `TemplateEngine` passes…
|
||||
- [TemplateEngine](./src/engine/TemplateEngine.md) — The `TemplateEngine` class is the central orchestrator of `@suaveplan/templates`. It manages a priority-sorted registry…
|
||||
- [apply-plugin](./src/engine/apply-plugin.md) — Provides the core two-phase plugin application logic used by `TemplateEngine` for both synchronous and asynchronous ren…
|
||||
- [error-codes](./src/errors/error-codes.md) — Defines and freezes the complete error-code registry for `@suaveplan/templates` using the `defineCodes` utility from `@…
|
||||
- [template-error](./src/errors/template-error.md) — Defines the structured error hierarchy for `@suaveplan/templates`. All errors extend `SuaveplanError` from `@suaveplan/…
|
||||
- [otel](./src/otel/templates-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [templates-otel](./src/otel/templates-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [ConditionalPlugin](./src/plugins/control/ConditionalPlugin.md) — Conditionally includes or excludes blocks of template content based on context values. The plugin evaluates a condition…
|
||||
- [LoopPlugin](./src/plugins/control/LoopPlugin.md) — Iterates over an array in the context object and renders a sub-template once for each element, concatenating the result…
|
||||
- [CounterPlugin](./src/plugins/dynamic/CounterPlugin.md) — Provides stateful auto-incrementing counters within a single template rendering pass. Counters are scoped to the `Parse…
|
||||
- [DateTimePlugin](./src/plugins/dynamic/DateTimePlugin.md) — Injects current date and time values into templates using a flexible, token-based formatting syntax. Because date-time…
|
||||
- [UuidPlugin](./src/plugins/dynamic/UuidPlugin.md) — Generates UUID v4 and URL-safe nano-ID strings inline within templates. Because ID generation depends on `crypto.random…
|
||||
- [CaseTransformPlugin](./src/plugins/transform/CaseTransformPlugin.md) — Applies string case transformations to resolved context values. Supports eight case styles covering all common naming c…
|
||||
- [RegexPlugin](./src/plugins/transform/RegexPlugin.md) — Applies inline regular-expression operations to resolved context values. The plugin enables match extraction, capture g…
|
||||
- [StringSplitPlugin](./src/plugins/transform/StringSplitPlugin.md) — Splits a resolved string value on a configurable delimiter and returns a single element by index. This plugin solves th…
|
||||
- [ArrayIndexPlugin](./src/plugins/variables/ArrayIndexPlugin.md) — Resolves bracket-notation array-index references in templates. Handles `{items[0]}`, `{items[-1]}`, and nested `{users[…
|
||||
- [DotNotationPlugin](./src/plugins/variables/DotNotationPlugin.md) — Resolves simple variable references using dot-notation paths. This is the primary workhorse plugin for template renderi…
|
||||
- [full](./src/presets/full.md) — Provides the complete plugin suite in two variants: `fullPreset()` matches `standardPreset()` exactly — it is the recom…
|
||||
- [minimal](./src/presets/minimal.md) — Provides the lightest possible `PluginPreset` for the `TemplateEngine`. It bundles only two plugins — `array-index` and…
|
||||
- [standard](./src/presets/standard.md) — Provides a comprehensive `PluginPreset` suitable for the majority of production use cases. It bundles all non-loop plug…
|
||||
- [schemas](./src/schemas/templates.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [templates.schemas](./src/schemas/templates.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [types](./src/types/index.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [index](./src/types/index.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [escape](./src/utils/escape.md) — Provides three utility functions for handling escape sequences in template strings. These are low-level primitives used…
|
||||
- [format](./src/utils/format.md) — Provides core formatting utilities used by the dynamic plugins: zero-padded number/string padding, date formatting with…
|
||||
- [nested-value](./src/utils/nested-value.md) — Provides three functions for traversing, checking, and mutating plain object trees using dot-notation or bracket-notati…
|
||||
<!-- end:auto-docs --> - [templates-otel](./src/otel/templates-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [ConditionalPlugin](./src/plugins/control/ConditionalPlugin.md) — Conditionally includes or excludes blocks of template content based on context values. The plugin evaluates a condition…
|
||||
- [LoopPlugin](./src/plugins/control/LoopPlugin.md) — Iterates over an array in the context object and renders a sub-template once for each element, concatenating the result…
|
||||
- [CounterPlugin](./src/plugins/dynamic/CounterPlugin.md) — Provides stateful auto-incrementing counters within a single template rendering pass. Counters are scoped to the `Parse…
|
||||
- [DateTimePlugin](./src/plugins/dynamic/DateTimePlugin.md) — Injects current date and time values into templates using a flexible, token-based formatting syntax. Because date-time…
|
||||
- [UuidPlugin](./src/plugins/dynamic/UuidPlugin.md) — Generates UUID v4 and URL-safe nano-ID strings inline within templates. Because ID generation depends on `crypto.random…
|
||||
- [CaseTransformPlugin](./src/plugins/transform/CaseTransformPlugin.md) — Applies string case transformations to resolved context values. Supports eight case styles covering all common naming c…
|
||||
- [RegexPlugin](./src/plugins/transform/RegexPlugin.md) — Applies inline regular-expression operations to resolved context values. The plugin enables match extraction, capture g…
|
||||
- [StringSplitPlugin](./src/plugins/transform/StringSplitPlugin.md) — Splits a resolved string value on a configurable delimiter and returns a single element by index. This plugin solves th…
|
||||
- [ArrayIndexPlugin](./src/plugins/variables/ArrayIndexPlugin.md) — Resolves bracket-notation array-index references in templates. Handles `{items[0]}`, `{items[-1]}`, and nested `{users[…
|
||||
- [DotNotationPlugin](./src/plugins/variables/DotNotationPlugin.md) — Resolves simple variable references using dot-notation paths. This is the primary workhorse plugin for template renderi…
|
||||
- [full](./src/presets/full.md) — Provides the complete plugin suite in two variants: `fullPreset()` matches `standardPreset()` exactly — it is the recom…
|
||||
- [minimal](./src/presets/minimal.md) — Provides the lightest possible `PluginPreset` for the `TemplateEngine`. It bundles only two plugins — `array-index` and…
|
||||
- [standard](./src/presets/standard.md) — Provides a comprehensive `PluginPreset` suitable for the majority of production use cases. It bundles all non-loop plug…
|
||||
- [schemas](./src/schemas/templates.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [templates.schemas](./src/schemas/templates.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/index.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/types/index.md) — <!-- begin:auto-docs --> _(no submodules)_- [escape](./src/utils/escape.md) — Provides three utility functions for handling escape sequences in template strings. These are low-level primitives used…
|
||||
- [format](./src/utils/format.md) — Provides core formatting utilities used by the dynamic plugins: zero-padded number/string padding, date formatting with…
|
||||
- [nested-value](./src/utils/nested-value.md) — Provides three functions for traversing, checking, and mutating plain object trees using dot-notation or bracket-notati…
|
||||
- [templates-otel](./src/otel/templates-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [ConditionalPlugin](./src/plugins/control/ConditionalPlugin.md) — Conditionally includes or excludes blocks of template content based on context values. The plugin evaluates a condition…
|
||||
- [LoopPlugin](./src/plugins/control/LoopPlugin.md) — Iterates over an array in the context object and renders a sub-template once for each element, concatenating the result…
|
||||
- [CounterPlugin](./src/plugins/dynamic/CounterPlugin.md) — Provides stateful auto-incrementing counters within a single template rendering pass. Counters are scoped to the `Parse…
|
||||
- [DateTimePlugin](./src/plugins/dynamic/DateTimePlugin.md) — Injects current date and time values into templates using a flexible, token-based formatting syntax. Because date-time…
|
||||
- [UuidPlugin](./src/plugins/dynamic/UuidPlugin.md) — Generates UUID v4 and URL-safe nano-ID strings inline within templates. Because ID generation depends on `crypto.random…
|
||||
- [CaseTransformPlugin](./src/plugins/transform/CaseTransformPlugin.md) — Applies string case transformations to resolved context values. Supports eight case styles covering all common naming c…
|
||||
- [RegexPlugin](./src/plugins/transform/RegexPlugin.md) — Applies inline regular-expression operations to resolved context values. The plugin enables match extraction, capture g…
|
||||
- [StringSplitPlugin](./src/plugins/transform/StringSplitPlugin.md) — Splits a resolved string value on a configurable delimiter and returns a single element by index. This plugin solves th…
|
||||
- [ArrayIndexPlugin](./src/plugins/variables/ArrayIndexPlugin.md) — Resolves bracket-notation array-index references in templates. Handles `{items[0]}`, `{items[-1]}`, and nested `{users[…
|
||||
- [DotNotationPlugin](./src/plugins/variables/DotNotationPlugin.md) — Resolves simple variable references using dot-notation paths. This is the primary workhorse plugin for template renderi…
|
||||
- [full](./src/presets/full.md) — Provides the complete plugin suite in two variants: `fullPreset()` matches `standardPreset()` exactly — it is the recom…
|
||||
- [minimal](./src/presets/minimal.md) — Provides the lightest possible `PluginPreset` for the `TemplateEngine`. It bundles only two plugins — `array-index` and…
|
||||
- [standard](./src/presets/standard.md) — Provides a comprehensive `PluginPreset` suitable for the majority of production use cases. It bundles all non-loop plug…
|
||||
- [schemas](./src/schemas/templates.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [templates.schemas](./src/schemas/templates.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/index.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/types/index.md) — <!-- begin:auto-docs --> _(no submodules)_- [escape](./src/utils/escape.md) — Provides three utility functions for handling escape sequences in template strings. These are low-level primitives used…
|
||||
- [format](./src/utils/format.md) — Provides core formatting utilities used by the dynamic plugins: zero-padded number/string padding, date formatting with…
|
||||
- [nested-value](./src/utils/nested-value.md) — Provides three functions for traversing, checking, and mutating plain object trees using dot-notation or bracket-notati…
|
||||
- [templates-otel](./src/otel/templates-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [ConditionalPlugin](./src/plugins/control/ConditionalPlugin.md) — Conditionally includes or excludes blocks of template content based on context values. The plugin evaluates a condition…
|
||||
- [LoopPlugin](./src/plugins/control/LoopPlugin.md) — Iterates over an array in the context object and renders a sub-template once for each element, concatenating the result…
|
||||
- [CounterPlugin](./src/plugins/dynamic/CounterPlugin.md) — Provides stateful auto-incrementing counters within a single template rendering pass. Counters are scoped to the `Parse…
|
||||
- [DateTimePlugin](./src/plugins/dynamic/DateTimePlugin.md) — Injects current date and time values into templates using a flexible, token-based formatting syntax. Because date-time…
|
||||
- [UuidPlugin](./src/plugins/dynamic/UuidPlugin.md) — Generates UUID v4 and URL-safe nano-ID strings inline within templates. Because ID generation depends on `crypto.random…
|
||||
- [CaseTransformPlugin](./src/plugins/transform/CaseTransformPlugin.md) — Applies string case transformations to resolved context values. Supports eight case styles covering all common naming c…
|
||||
- [RegexPlugin](./src/plugins/transform/RegexPlugin.md) — Applies inline regular-expression operations to resolved context values. The plugin enables match extraction, capture g…
|
||||
- [StringSplitPlugin](./src/plugins/transform/StringSplitPlugin.md) — Splits a resolved string value on a configurable delimiter and returns a single element by index. This plugin solves th…
|
||||
- [ArrayIndexPlugin](./src/plugins/variables/ArrayIndexPlugin.md) — Resolves bracket-notation array-index references in templates. Handles `{items[0]}`, `{items[-1]}`, and nested `{users[…
|
||||
- [DotNotationPlugin](./src/plugins/variables/DotNotationPlugin.md) — Resolves simple variable references using dot-notation paths. This is the primary workhorse plugin for template renderi…
|
||||
- [full](./src/presets/full.md) — Provides the complete plugin suite in two variants: `fullPreset()` matches `standardPreset()` exactly — it is the recom…
|
||||
- [minimal](./src/presets/minimal.md) — Provides the lightest possible `PluginPreset` for the `TemplateEngine`. It bundles only two plugins — `array-index` and…
|
||||
- [standard](./src/presets/standard.md) — Provides a comprehensive `PluginPreset` suitable for the majority of production use cases. It bundles all non-loop plug…
|
||||
- [schemas](./src/schemas/templates.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [templates.schemas](./src/schemas/templates.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/index.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/types/index.md) — <!-- begin:auto-docs --> _(no submodules)_- [escape](./src/utils/escape.md) — Provides three utility functions for handling escape sequences in template strings. These are low-level primitives used…
|
||||
- [format](./src/utils/format.md) — Provides core formatting utilities used by the dynamic plugins: zero-padded number/string padding, date formatting with…
|
||||
- [nested-value](./src/utils/nested-value.md) — Provides three functions for traversing, checking, and mutating plain object trees using dot-notation or bracket-notati…
|
||||
- [templates-otel](./src/otel/templates-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [ConditionalPlugin](./src/plugins/control/ConditionalPlugin.md) — Conditionally includes or excludes blocks of template content based on context values. The plugin evaluates a condition…
|
||||
- [LoopPlugin](./src/plugins/control/LoopPlugin.md) — Iterates over an array in the context object and renders a sub-template once for each element, concatenating the result…
|
||||
- [CounterPlugin](./src/plugins/dynamic/CounterPlugin.md) — Provides stateful auto-incrementing counters within a single template rendering pass. Counters are scoped to the `Parse…
|
||||
- [DateTimePlugin](./src/plugins/dynamic/DateTimePlugin.md) — Injects current date and time values into templates using a flexible, token-based formatting syntax. Because date-time…
|
||||
- [UuidPlugin](./src/plugins/dynamic/UuidPlugin.md) — Generates UUID v4 and URL-safe nano-ID strings inline within templates. Because ID generation depends on `crypto.random…
|
||||
- [CaseTransformPlugin](./src/plugins/transform/CaseTransformPlugin.md) — Applies string case transformations to resolved context values. Supports eight case styles covering all common naming c…
|
||||
- [RegexPlugin](./src/plugins/transform/RegexPlugin.md) — Applies inline regular-expression operations to resolved context values. The plugin enables match extraction, capture g…
|
||||
- [StringSplitPlugin](./src/plugins/transform/StringSplitPlugin.md) — Splits a resolved string value on a configurable delimiter and returns a single element by index. This plugin solves th…
|
||||
- [ArrayIndexPlugin](./src/plugins/variables/ArrayIndexPlugin.md) — Resolves bracket-notation array-index references in templates. Handles `{items[0]}`, `{items[-1]}`, and nested `{users[…
|
||||
- [DotNotationPlugin](./src/plugins/variables/DotNotationPlugin.md) — Resolves simple variable references using dot-notation paths. This is the primary workhorse plugin for template renderi…
|
||||
- [full](./src/presets/full.md) — Provides the complete plugin suite in two variants: `fullPreset()` matches `standardPreset()` exactly — it is the recom…
|
||||
- [minimal](./src/presets/minimal.md) — Provides the lightest possible `PluginPreset` for the `TemplateEngine`. It bundles only two plugins — `array-index` and…
|
||||
- [standard](./src/presets/standard.md) — Provides a comprehensive `PluginPreset` suitable for the majority of production use cases. It bundles all non-loop plug…
|
||||
- [schemas](./src/schemas/templates.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [templates.schemas](./src/schemas/templates.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/index.md) — <!-- begin:auto-docs --> _(no submodules)_ - [index](./src/types/index.md) — <!-- begin:auto-docs --> _(no submodules)_- [escape](./src/utils/escape.md) — Provides three utility functions for handling escape sequences in template strings. These are low-level primitives used…
|
||||
- [format](./src/utils/format.md) — Provides core formatting utilities used by the dynamic plugins: zero-padded number/string padding, date formatting with…
|
||||
- [nested-value](./src/utils/nested-value.md) — Provides three functions for traversing, checking, and mutating plain object trees using dot-notation or bracket-notati…
|
||||
|
||||
## Features
|
||||
|
||||
- Priority-sorted plugin pipeline (CRITICAL → HIGH → MEDIUM → LOW → MINIMAL)
|
||||
- 9 built-in plugins covering variables, transforms, dynamic generation, and control flow
|
||||
- 3 preset bundles (minimal, standard, full)
|
||||
- Synchronous and asynchronous rendering paths
|
||||
- `compile()` for reusable template objects
|
||||
- Injectable `Clock` and `Rng` for deterministic testing
|
||||
- `ParseState` per render pass — counters, cache, custom plugin state
|
||||
- Zod schemas for runtime configuration validation
|
||||
- 100% test coverage on all modules
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/templates
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, standardPreset } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine().usePreset(standardPreset());
|
||||
|
||||
engine.parse("Hello, {name:pascal}! You have {count} messages.", {
|
||||
name: "alice smith",
|
||||
count: 5,
|
||||
});
|
||||
// → "Hello, AliceSmith! You have 5 messages."
|
||||
```
|
||||
|
||||
## Plugins
|
||||
|
||||
### Variable Resolution
|
||||
|
||||
| Plugin | Module | Priority |
|
||||
|--------|--------|----------|
|
||||
| [DotNotationPlugin](src/plugins/variables/DotNotationPlugin.md) | `{name}`, `{user.email}` | LOW (20) |
|
||||
| [ArrayIndexPlugin](src/plugins/variables/ArrayIndexPlugin.md) | `{items[0]}`, `{arr[-1]}` | LOW (20) |
|
||||
|
||||
### Transforms
|
||||
|
||||
| Plugin | Module | Priority |
|
||||
|--------|--------|----------|
|
||||
| [CaseTransformPlugin](src/plugins/transform/CaseTransformPlugin.md) | `{key:pascal}`, `{val:snake}` | MEDIUM (50) |
|
||||
| [RegexPlugin](src/plugins/transform/RegexPlugin.md) | `{str:/pattern/flags}` | MEDIUM (50) |
|
||||
| [StringSplitPlugin](src/plugins/transform/StringSplitPlugin.md) | `{email:@[0]}` | MEDIUM (50) |
|
||||
|
||||
### Dynamic
|
||||
|
||||
| Plugin | Module | Priority |
|
||||
|--------|--------|----------|
|
||||
| [CounterPlugin](src/plugins/dynamic/CounterPlugin.md) | `{_counter}`, `{_counter:name:1:3}` | HIGH (80) |
|
||||
| [DateTimePlugin](src/plugins/dynamic/DateTimePlugin.md) | `{_date}`, `{_timestamp:s}` | HIGH (80) |
|
||||
| [UuidPlugin](src/plugins/dynamic/UuidPlugin.md) | `{_uuid}`, `{_nanoid:10}` | HIGH (80) |
|
||||
|
||||
### Control Flow
|
||||
|
||||
| Plugin | Module | Priority |
|
||||
|--------|--------|----------|
|
||||
| [ConditionalPlugin](src/plugins/control/ConditionalPlugin.md) | `{if:cond}...{/if}` | CRITICAL (95) |
|
||||
| [LoopPlugin](src/plugins/control/LoopPlugin.md) | `{each:items:el}...{/each}` | CRITICAL (95) |
|
||||
|
||||
## Presets
|
||||
|
||||
| Preset | Module | Included plugins |
|
||||
|--------|--------|-----------------|
|
||||
| [minimalPreset](src/presets/minimal.md) | dot-notation + array-index | 2 |
|
||||
| [standardPreset](src/presets/standard.md) | all except loop | 9 |
|
||||
| [fullPreset](src/presets/full.md) | same as standard | 9 |
|
||||
| [fullPresetWithLoops](src/presets/full.md) | all 10 plugins | 10 |
|
||||
|
||||
## Engine API
|
||||
|
||||
| Symbol | Module |
|
||||
|--------|--------|
|
||||
| [TemplateEngine](src/engine/TemplateEngine.md) | Core engine class |
|
||||
| [ParseState](src/engine/ParseState.md) | Render state interface and helpers |
|
||||
|
||||
## Types & Schemas
|
||||
|
||||
| Symbol | Module |
|
||||
|--------|--------|
|
||||
| [types](src/types/index.md) | All interfaces, enums, and `SKIP_PLUGIN` |
|
||||
| [templates.schemas](src/schemas/templates.schemas.md) | Zod validation schemas |
|
||||
|
||||
## Utilities
|
||||
|
||||
| Symbol | Module |
|
||||
|--------|--------|
|
||||
| [nested-value](src/utils/nested-value.md) | `getNestedValue`, `hasNestedPath`, `setNestedValue` |
|
||||
| [format](src/utils/format.md) | `pad`, `formatDate`, `generateUuid`, `generateNanoid` |
|
||||
| [escape](src/utils/escape.md) | `escapeTemplate`, `unescapeTemplate` |
|
||||
|
||||
## Errors
|
||||
|
||||
| Symbol | Module |
|
||||
|--------|--------|
|
||||
| [error-codes](src/errors/error-codes.md) | `TEMPLATE_CODES` registry |
|
||||
| [template-error](src/errors/template-error.md) | `TemplateError`, `PluginError`, `MaxDepthError` |
|
||||
|
||||
## Writing a Custom Plugin
|
||||
|
||||
```ts
|
||||
import type { Plugin, PluginMatch, TemplateContext } from "@suaveplan/templates";
|
||||
import { PluginPriority, SKIP_PLUGIN } from "@suaveplan/templates";
|
||||
|
||||
function createUpperBoldPlugin<TCtx extends TemplateContext>(): Plugin<TCtx> {
|
||||
return {
|
||||
name: "upper-bold",
|
||||
priority: PluginPriority.MEDIUM,
|
||||
pattern: /\{bold:([^}]+)\}/g,
|
||||
transform(match: PluginMatch): string | typeof SKIP_PLUGIN {
|
||||
const text = match.groups?.[1];
|
||||
if (!text) return SKIP_PLUGIN;
|
||||
return `**${text.toUpperCase()}**`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const engine = new TemplateEngine().use(createUpperBoldPlugin());
|
||||
engine.parse("Welcome {bold:alice}!", {});
|
||||
// → "Welcome **ALICE**!"
|
||||
```
|
||||
|
||||
## Loop Support
|
||||
|
||||
Loops require injecting the engine's internal render function:
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, fullPresetWithLoops } from "@suaveplan/templates";
|
||||
import type { ParseState } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine();
|
||||
engine.usePreset(
|
||||
fullPresetWithLoops(
|
||||
(t: string, c: Record<string, unknown>, s: ParseState) =>
|
||||
(engine as unknown as { parseInternal: typeof fn }).parseInternal(t, c, s)
|
||||
)
|
||||
);
|
||||
|
||||
engine.parse(
|
||||
"{each:items:i}{_number}. {i.name}\n{/each}",
|
||||
{ items: [{ name: "Alice" }, { name: "Bob" }] }
|
||||
);
|
||||
// → "1. Alice\n2. Bob\n"
|
||||
```
|
||||
|
||||
136
packages/foundation/data/templates/package.json
Normal file
136
packages/foundation/data/templates/package.json
Normal file
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"name": "@suaveplan/templates",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Plugin-based template engine with type-safe contexts, variable interpolation, transform and control-flow plugins, and preset bundles.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./engine": {
|
||||
"import": {
|
||||
"types": "./dist/engine/TemplateEngine.d.ts",
|
||||
"default": "./dist/engine/TemplateEngine.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/engine/TemplateEngine.d.ts",
|
||||
"default": "./dist/engine/TemplateEngine.js"
|
||||
}
|
||||
},
|
||||
"./plugins/variables": {
|
||||
"import": {
|
||||
"types": "./dist/plugins/variables/index.d.ts",
|
||||
"default": "./dist/plugins/variables/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/plugins/variables/index.d.ts",
|
||||
"default": "./dist/plugins/variables/index.js"
|
||||
}
|
||||
},
|
||||
"./plugins/transform": {
|
||||
"import": {
|
||||
"types": "./dist/plugins/transform/index.d.ts",
|
||||
"default": "./dist/plugins/transform/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/plugins/transform/index.d.ts",
|
||||
"default": "./dist/plugins/transform/index.js"
|
||||
}
|
||||
},
|
||||
"./plugins/dynamic": {
|
||||
"import": {
|
||||
"types": "./dist/plugins/dynamic/index.d.ts",
|
||||
"default": "./dist/plugins/dynamic/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/plugins/dynamic/index.d.ts",
|
||||
"default": "./dist/plugins/dynamic/index.js"
|
||||
}
|
||||
},
|
||||
"./plugins/control": {
|
||||
"import": {
|
||||
"types": "./dist/plugins/control/index.d.ts",
|
||||
"default": "./dist/plugins/control/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/plugins/control/index.d.ts",
|
||||
"default": "./dist/plugins/control/index.js"
|
||||
}
|
||||
},
|
||||
"./presets": {
|
||||
"import": {
|
||||
"types": "./dist/presets/index.d.ts",
|
||||
"default": "./dist/presets/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/presets/index.d.ts",
|
||||
"default": "./dist/presets/index.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/template-error.d.ts",
|
||||
"default": "./dist/errors/template-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/template-error.d.ts",
|
||||
"default": "./dist/errors/template-error.js"
|
||||
}
|
||||
},
|
||||
"./schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/templates.schemas.d.ts",
|
||||
"default": "./dist/schemas/templates.schemas.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/schemas/templates.schemas.d.ts",
|
||||
"default": "./dist/schemas/templates.schemas.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"bench": "bun run src/__benchmarks__/templates.bench.ts",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"mitata": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Spec conformance: TPL-01 / TPL-02 — package shape and subpath exports.
|
||||
*
|
||||
* Pins:
|
||||
* - sideEffects: false
|
||||
* - runtime dependencies are exactly the spec list
|
||||
* - subpath exports map covers engine, plugin families, presets, schemas, errors
|
||||
* - root barrel is still importable
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_DIR = join(import.meta.dirname, "..", "..");
|
||||
|
||||
interface PackageJson {
|
||||
sideEffects?: boolean;
|
||||
dependencies?: Record<string, string>;
|
||||
exports?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function readPkg(): PackageJson {
|
||||
return JSON.parse(
|
||||
readFileSync(join(PKG_DIR, "package.json"), "utf8")
|
||||
) as PackageJson;
|
||||
}
|
||||
|
||||
describe("@suaveplan/templates — package shape (TPL-01 / TPL-02)", () => {
|
||||
it("declares sideEffects: false", { timeout: 5000 }, () => {
|
||||
const pkg = readPkg();
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("runtime dependencies are exactly the spec-mandated set", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = readPkg();
|
||||
const deps = Object.keys(pkg.dependencies ?? {}).sort();
|
||||
expect(deps).toEqual(
|
||||
[
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("internal dependencies use workspace:^", { timeout: 5000 }, () => {
|
||||
const pkg = readPkg();
|
||||
const deps = pkg.dependencies ?? {};
|
||||
for (const [k, v] of Object.entries(deps)) {
|
||||
if (k.startsWith("@suaveplan/")) {
|
||||
expect(v).toBe("workspace:^");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("exports map exposes engine, plugin families, presets, schemas, errors, and root barrel", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = readPkg();
|
||||
const subpaths = Object.keys(pkg.exports ?? {});
|
||||
for (const required of [
|
||||
".",
|
||||
"./engine",
|
||||
"./plugins/variables",
|
||||
"./plugins/transform",
|
||||
"./plugins/dynamic",
|
||||
"./plugins/control",
|
||||
"./presets",
|
||||
"./schemas",
|
||||
"./errors",
|
||||
]) {
|
||||
expect(subpaths).toContain(required);
|
||||
}
|
||||
});
|
||||
|
||||
it("the root barrel re-exports the public API (TemplateEngine + presets)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const mod = await import("../index.js");
|
||||
expect(typeof mod.TemplateEngine).toBe("function");
|
||||
expect(typeof mod.minimalPreset).toBe("function");
|
||||
expect(typeof mod.standardPreset).toBe("function");
|
||||
expect(typeof mod.fullPreset).toBe("function");
|
||||
expect(typeof mod.fullPresetWithLoops).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Spec conformance: TPL-04 — public types and SKIP_PLUGIN sentinel.
|
||||
*
|
||||
* Pins:
|
||||
* - PluginPriority numeric enum values
|
||||
* - TemplateContext == Record<string, unknown> (assignment compatibility)
|
||||
* - SKIP_PLUGIN is a unique symbol
|
||||
* - TemplateEngine is generic over TContext (compile-time only)
|
||||
*/
|
||||
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import { TemplateEngine } from "../engine/TemplateEngine.js";
|
||||
import type { Plugin, TemplateContext } from "../types/index.js";
|
||||
import { PluginPriority, SKIP_PLUGIN } from "../types/index.js";
|
||||
|
||||
describe("@suaveplan/templates — types (TPL-04)", () => {
|
||||
it("PluginPriority enum carries the spec-mandated values", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(PluginPriority.CRITICAL).toBe(95);
|
||||
expect(PluginPriority.HIGH).toBe(80);
|
||||
expect(PluginPriority.MEDIUM).toBe(50);
|
||||
expect(PluginPriority.LOW).toBe(20);
|
||||
expect(PluginPriority.MINIMAL).toBe(5);
|
||||
});
|
||||
|
||||
it("SKIP_PLUGIN is a unique symbol value", { timeout: 5000 }, () => {
|
||||
expect(typeof SKIP_PLUGIN).toBe("symbol");
|
||||
expect(SKIP_PLUGIN).not.toBe(Symbol("SKIP_PLUGIN"));
|
||||
});
|
||||
|
||||
it("TemplateContext aliases Record<string, unknown>", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<TemplateContext>().toEqualTypeOf<
|
||||
Record<string, unknown>
|
||||
>();
|
||||
});
|
||||
|
||||
it("TemplateEngine threads its TContext generic through .parse()", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
interface MyCtx extends TemplateContext {
|
||||
user: { name: string };
|
||||
}
|
||||
const engine = new TemplateEngine<MyCtx>();
|
||||
// Compile-time: parse(context) must accept MyCtx (not just unknown).
|
||||
expectTypeOf(engine.parse).parameter(1).toEqualTypeOf<MyCtx>();
|
||||
expectTypeOf(engine.parseAsync).parameter(1).toEqualTypeOf<MyCtx>();
|
||||
});
|
||||
|
||||
it("Plugin generic accepts a constrained TContext", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
interface Ctx extends TemplateContext {
|
||||
a: number;
|
||||
}
|
||||
const p: Plugin<Ctx> = {
|
||||
name: "p",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{a\}/g,
|
||||
transform: (_match, context) => String(context.a),
|
||||
};
|
||||
expect(p.name).toBe("p");
|
||||
});
|
||||
});
|
||||
88
packages/foundation/data/templates/src/engine/ParseState.md
Normal file
88
packages/foundation/data/templates/src/engine/ParseState.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# ParseState
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides factory functions and mutation helpers for `ParseState` — the stateful object that the `TemplateEngine` passes to every plugin on each render call. ParseState carries named counters, nesting depth, a result cache, and arbitrary per-plugin custom data, all within a single coherent structure that travels through the call tree without global mutable state.
|
||||
|
||||
The functions in this module are the only approved way to mutate or read ParseState; direct map access is intentionally bypassed so tests can verify invariants at the helper level.
|
||||
|
||||
## Features
|
||||
|
||||
- `createParseState(maxDepth?)` — creates a fresh state with empty maps and depth=0.
|
||||
- `cloneParseState(state)` — deep-clones all four maps for safe fork-and-join patterns.
|
||||
- `incrementDepth(state)` / `decrementDepth(state)` — depth book-keeping used by the engine.
|
||||
- `getCounter(state, name, initialValue?)` — reads and advances a named counter atomically.
|
||||
- `resetCounter(state, name, value?)` — sets a counter to an explicit value.
|
||||
- `clearCounters(state)` — empties the entire counter map (used by tests and between render passes).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createParseState, getCounter } from "@suaveplan/templates";
|
||||
|
||||
const state = createParseState();
|
||||
console.log(getCounter(state, "row")); // 1
|
||||
console.log(getCounter(state, "row")); // 2
|
||||
console.log(getCounter(state, "col")); // 1 (independent)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createParseState,
|
||||
cloneParseState,
|
||||
clearCounters,
|
||||
resetCounter,
|
||||
} from "@suaveplan/templates";
|
||||
|
||||
const state = createParseState(5); // maxDepth = 5
|
||||
|
||||
// Store plugin-specific state in .custom
|
||||
state.custom.set("myPlugin:cache", new Map());
|
||||
|
||||
// Fork the state for a sub-render without polluting the original
|
||||
const forked = cloneParseState(state);
|
||||
// ... sub-render ...
|
||||
// Original state is unaffected
|
||||
|
||||
// Reset between batch jobs
|
||||
clearCounters(state);
|
||||
resetCounter(state, "page", 0); // so next getCounter returns 1
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createParseState(maxDepth?: number): ParseState`
|
||||
|
||||
Creates a fresh parse state. `maxDepth` defaults to 10.
|
||||
|
||||
### `cloneParseState(state: ParseState): ParseState`
|
||||
|
||||
Deep-clones `counters`, `cache`, and `custom` maps. The clone is fully independent — mutations to either copy do not affect the other.
|
||||
|
||||
### `incrementDepth(state: ParseState): void`
|
||||
|
||||
Increments `state.depth` by one. Called by the engine before recursing into a nested template.
|
||||
|
||||
### `decrementDepth(state: ParseState): void`
|
||||
|
||||
Decrements `state.depth` by one. Called after returning from a nested template.
|
||||
|
||||
### `getCounter(state: ParseState, name: string, initialValue?: number): number`
|
||||
|
||||
Returns the next value of the named counter. On first call with `initialValue=5`, returns 5. On each subsequent call, increments by one. This is the only function that both reads and writes the counter atomically.
|
||||
|
||||
### `resetCounter(state: ParseState, name: string, value?: number): void`
|
||||
|
||||
Sets the stored counter value. Defaults to 1, so the next `getCounter` call returns 2. Use 0 if you want the next call to return 1.
|
||||
|
||||
### `clearCounters(state: ParseState): void`
|
||||
|
||||
Empties all named counters. After clearing, each counter restarts from its `initialValue`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Counter storage is additive: `getCounter` returns `stored + 1` and writes `stored + 1` back. Initialisation writes `initialValue - 1` on first call so the first return equals `initialValue` without a special branch.
|
||||
- `incrementDepth` and `decrementDepth` are separate from depth-limit checking, which lives in `TemplateEngine.parseInternal`. This keeps ParseState helpers dumb and the limit logic centralised.
|
||||
- No concurrent access safety is needed; templates are always rendered synchronously within a single fork. Async rendering awaits each plugin serially.
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Spec conformance: TPL-05 — ParseState API.
|
||||
*
|
||||
* Pins:
|
||||
* - createParseState defaults maxDepth to 10 and starts empty.
|
||||
* - cloneParseState produces a structurally independent deep copy.
|
||||
* - clearCounters resets all counters to zero (or empties them) without
|
||||
* leaving stale values that would break counter monotonicity within a render.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
clearCounters,
|
||||
cloneParseState,
|
||||
createParseState,
|
||||
decrementDepth,
|
||||
getCounter,
|
||||
incrementDepth,
|
||||
resetCounter,
|
||||
} from "./ParseState.js";
|
||||
|
||||
describe("ParseState — spec conformance (TPL-05)", () => {
|
||||
it("createParseState defaults maxDepth to 10", { timeout: 5000 }, () => {
|
||||
const s = createParseState();
|
||||
expect(s.maxDepth).toBe(10);
|
||||
expect(s.depth).toBe(0);
|
||||
expect(s.counters.size).toBe(0);
|
||||
expect(s.cache.size).toBe(0);
|
||||
expect(s.custom.size).toBe(0);
|
||||
});
|
||||
|
||||
it("cloneParseState produces a structurally independent deep copy", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = createParseState(7);
|
||||
original.counters.set("x", 3);
|
||||
original.cache.set("k", "v");
|
||||
original.custom.set("c", 42);
|
||||
|
||||
const clone = cloneParseState(original);
|
||||
// Mutations to the clone must not leak back to the original.
|
||||
clone.counters.set("x", 999);
|
||||
clone.cache.set("k", "OTHER");
|
||||
clone.custom.set("c", -1);
|
||||
clone.depth = 5;
|
||||
|
||||
expect(original.counters.get("x")).toBe(3);
|
||||
expect(original.cache.get("k")).toBe("v");
|
||||
expect(original.custom.get("c")).toBe(42);
|
||||
expect(original.depth).toBe(0);
|
||||
expect(clone.maxDepth).toBe(7);
|
||||
});
|
||||
|
||||
it("clearCounters empties the counter map (TPL-05)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = createParseState();
|
||||
getCounter(s, "a");
|
||||
getCounter(s, "a");
|
||||
getCounter(s, "b");
|
||||
expect(s.counters.size).toBe(2);
|
||||
|
||||
clearCounters(s);
|
||||
expect(s.counters.size).toBe(0);
|
||||
});
|
||||
|
||||
it("incrementDepth + decrementDepth pair correctly", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = createParseState();
|
||||
incrementDepth(s);
|
||||
incrementDepth(s);
|
||||
expect(s.depth).toBe(2);
|
||||
decrementDepth(s);
|
||||
expect(s.depth).toBe(1);
|
||||
});
|
||||
|
||||
it("resetCounter sets a single named counter", { timeout: 5000 }, () => {
|
||||
const s = createParseState();
|
||||
getCounter(s, "n");
|
||||
getCounter(s, "n");
|
||||
resetCounter(s, "n", 100);
|
||||
expect(s.counters.get("n")).toBe(100);
|
||||
});
|
||||
});
|
||||
114
packages/foundation/data/templates/src/engine/ParseState.test.ts
Normal file
114
packages/foundation/data/templates/src/engine/ParseState.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
clearCounters,
|
||||
cloneParseState,
|
||||
createParseState,
|
||||
decrementDepth,
|
||||
getCounter,
|
||||
incrementDepth,
|
||||
resetCounter,
|
||||
} from "./ParseState.js";
|
||||
|
||||
describe("createParseState", () => {
|
||||
it("creates state with default maxDepth=10", { timeout: 5000 }, () => {
|
||||
const s = createParseState();
|
||||
expect(s.maxDepth).toBe(10);
|
||||
expect(s.depth).toBe(0);
|
||||
expect(s.counters.size).toBe(0);
|
||||
expect(s.cache.size).toBe(0);
|
||||
expect(s.custom.size).toBe(0);
|
||||
});
|
||||
|
||||
it("creates state with custom maxDepth", { timeout: 5000 }, () => {
|
||||
const s = createParseState(5);
|
||||
expect(s.maxDepth).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloneParseState", () => {
|
||||
it("deep-clones all maps", { timeout: 5000 }, () => {
|
||||
const s = createParseState();
|
||||
s.counters.set("a", 3);
|
||||
s.cache.set("k", "v");
|
||||
s.custom.set("x", 42);
|
||||
s.depth = 2;
|
||||
|
||||
const clone = cloneParseState(s);
|
||||
expect(clone.counters.get("a")).toBe(3);
|
||||
expect(clone.cache.get("k")).toBe("v");
|
||||
expect(clone.custom.get("x")).toBe(42);
|
||||
expect(clone.depth).toBe(2);
|
||||
expect(clone.maxDepth).toBe(10);
|
||||
|
||||
// Mutations to clone do not affect original
|
||||
clone.counters.set("a", 99);
|
||||
expect(s.counters.get("a")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("incrementDepth / decrementDepth", () => {
|
||||
it("increments depth", { timeout: 5000 }, () => {
|
||||
const s = createParseState();
|
||||
incrementDepth(s);
|
||||
expect(s.depth).toBe(1);
|
||||
});
|
||||
|
||||
it("decrements depth", { timeout: 5000 }, () => {
|
||||
const s = createParseState();
|
||||
s.depth = 3;
|
||||
decrementDepth(s);
|
||||
expect(s.depth).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCounter", () => {
|
||||
it("starts at 1 by default", { timeout: 5000 }, () => {
|
||||
const s = createParseState();
|
||||
expect(getCounter(s, "c")).toBe(1);
|
||||
expect(getCounter(s, "c")).toBe(2);
|
||||
expect(getCounter(s, "c")).toBe(3);
|
||||
});
|
||||
|
||||
it("starts at custom initialValue", { timeout: 5000 }, () => {
|
||||
const s = createParseState();
|
||||
expect(getCounter(s, "c", 10)).toBe(10);
|
||||
expect(getCounter(s, "c", 10)).toBe(11);
|
||||
});
|
||||
|
||||
it("independent named counters", { timeout: 5000 }, () => {
|
||||
const s = createParseState();
|
||||
getCounter(s, "a");
|
||||
getCounter(s, "a");
|
||||
expect(getCounter(s, "b")).toBe(1);
|
||||
expect(getCounter(s, "a")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetCounter", () => {
|
||||
it("resets to explicit value", { timeout: 5000 }, () => {
|
||||
const s = createParseState();
|
||||
getCounter(s, "x");
|
||||
getCounter(s, "x");
|
||||
resetCounter(s, "x", 5);
|
||||
expect(getCounter(s, "x")).toBe(6);
|
||||
});
|
||||
|
||||
it("resets to default value 1 when none given", { timeout: 5000 }, () => {
|
||||
const s = createParseState();
|
||||
getCounter(s, "x");
|
||||
resetCounter(s, "x");
|
||||
expect(getCounter(s, "x")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearCounters", () => {
|
||||
it("empties all counters", { timeout: 5000 }, () => {
|
||||
const s = createParseState();
|
||||
getCounter(s, "a");
|
||||
getCounter(s, "b");
|
||||
clearCounters(s);
|
||||
expect(s.counters.size).toBe(0);
|
||||
// After clear, counter starts fresh
|
||||
expect(getCounter(s, "a")).toBe(1);
|
||||
});
|
||||
});
|
||||
87
packages/foundation/data/templates/src/engine/ParseState.ts
Normal file
87
packages/foundation/data/templates/src/engine/ParseState.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { ParseState } from "../types/index.js";
|
||||
|
||||
/**
|
||||
* Create a new, empty parse state.
|
||||
*
|
||||
* @param maxDepth - Maximum recursive nesting depth (default 10).
|
||||
*/
|
||||
export function createParseState(maxDepth = 10): ParseState {
|
||||
return {
|
||||
counters: new Map(),
|
||||
depth: 0,
|
||||
maxDepth,
|
||||
cache: new Map(),
|
||||
custom: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-clone a parse state.
|
||||
*
|
||||
* Counter, cache, and custom maps are each cloned independently so
|
||||
* mutations to the clone do not affect the original.
|
||||
*/
|
||||
export function cloneParseState(state: ParseState): ParseState {
|
||||
return {
|
||||
counters: new Map(state.counters),
|
||||
depth: state.depth,
|
||||
maxDepth: state.maxDepth,
|
||||
cache: new Map(state.cache),
|
||||
custom: new Map(state.custom),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment nesting depth in-place.
|
||||
* Called by the engine before recursing into a nested template.
|
||||
*/
|
||||
export function incrementDepth(state: ParseState): void {
|
||||
state.depth++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement nesting depth in-place.
|
||||
* Called by the engine after returning from a nested template.
|
||||
*/
|
||||
export function decrementDepth(state: ParseState): void {
|
||||
state.depth--;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current value of a named counter and advance it.
|
||||
*
|
||||
* If the counter has never been set, it is initialised to `initialValue - 1`
|
||||
* so that the first call returns exactly `initialValue`.
|
||||
*
|
||||
* @param state - The active parse state.
|
||||
* @param name - Counter name.
|
||||
* @param initialValue - Value returned on the first call (default 1).
|
||||
*/
|
||||
export function getCounter(
|
||||
state: ParseState,
|
||||
name: string,
|
||||
initialValue = 1
|
||||
): number {
|
||||
const current = state.counters.get(name) ?? initialValue - 1;
|
||||
const next = current + 1;
|
||||
state.counters.set(name, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a named counter to an explicit value.
|
||||
*
|
||||
* @param state - The active parse state.
|
||||
* @param name - Counter name.
|
||||
* @param value - New stored value (default 1, so the next read returns 2).
|
||||
*/
|
||||
export function resetCounter(state: ParseState, name: string, value = 1): void {
|
||||
state.counters.set(name, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear **all** named counters, resetting the map to empty.
|
||||
*/
|
||||
export function clearCounters(state: ParseState): void {
|
||||
state.counters.clear();
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# TemplateEngine
|
||||
|
||||
## Purpose
|
||||
|
||||
The `TemplateEngine` class is the central orchestrator of `@suaveplan/templates`. It manages a priority-sorted registry of `Plugin` objects, applies them sequentially to template strings, and provides both synchronous (`parse`) and asynchronous (`parseAsync`) rendering paths. The engine is fully generic over the context type, enabling type-safe template rendering where the compiler validates that required fields are present.
|
||||
|
||||
Design philosophy: the engine is deliberately thin — it delegates all transformation logic to plugins and exposes just enough API to register, remove, and invoke them. This keeps the core auditable and makes the plugin contract the unit of composability.
|
||||
|
||||
## Features
|
||||
|
||||
- Generic `TemplateEngine<TContext>` where `TContext extends TemplateContext`.
|
||||
- Plugin registration via `.use()` (strict, no duplicates) and `.usePreset()` (idempotent).
|
||||
- Priority-sorted plugin execution (higher priority runs first).
|
||||
- Synchronous `.parse()` and asynchronous `.parseAsync()` rendering.
|
||||
- `.compile()` for pre-binding a template string to produce a reusable `CompiledTemplate`.
|
||||
- Configurable `maxDepth` to guard against infinite recursion in loop/conditional plugins.
|
||||
- `throwOnError` flag with an `onError` hook for graceful degradation.
|
||||
- `MaxDepthError` and `PluginError` propagate without double-wrapping.
|
||||
- Protected `parseInternal` for use by sub-plugins (loop, conditional).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, standardPreset } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine().usePreset(standardPreset());
|
||||
const result = engine.parse("Hello {user.name}!", { user: { name: "World" } });
|
||||
// → "Hello World!"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, createLoopPlugin, fullPresetWithLoops } from "@suaveplan/templates";
|
||||
|
||||
interface MyCtx {
|
||||
title: string;
|
||||
items: Array<{ label: string; value: number }>;
|
||||
}
|
||||
|
||||
const engine = new TemplateEngine<MyCtx>();
|
||||
const preset = fullPresetWithLoops<MyCtx>(
|
||||
(tpl, ctx, state) => (engine as any).parseInternal(tpl, ctx, state)
|
||||
);
|
||||
engine.usePreset(preset);
|
||||
|
||||
const result = engine.parse(
|
||||
"{title}:\n{each:items} - {item.label}: {item.value}\n{/each}",
|
||||
{ title: "Report", items: [{ label: "A", value: 1 }, { label: "B", value: 2 }] }
|
||||
);
|
||||
// → "Report:\n - A: 1\n - B: 2\n"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `constructor(options?: TemplateEngineOptions)`
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `maxDepth` | `number` | `10` | Maximum recursive nesting depth |
|
||||
| `throwOnError` | `boolean` | `true` | Re-throw errors vs. call `onError` |
|
||||
| `enableCache` | `boolean` | `true` | Cache sub-template results |
|
||||
| `onError` | `function` | no-op | Called with `(error, template, context)` when `throwOnError` is false |
|
||||
|
||||
### `.use(plugin: Plugin<TContext>): this`
|
||||
|
||||
Registers a plugin. Throws `TemplateError(PLUGIN_CONFLICT)` if a plugin with the same name is already registered. Triggers a priority re-sort.
|
||||
|
||||
### `.usePreset(preset: PluginPreset<TContext>): this`
|
||||
|
||||
Registers all plugins in the preset. Plugins whose name is already registered are silently skipped. Triggers a priority re-sort.
|
||||
|
||||
### `.remove(name: string): this`
|
||||
|
||||
Removes a plugin by name. No-op when the name is not found.
|
||||
|
||||
### `.getPlugins(): readonly Plugin<TContext>[]`
|
||||
|
||||
Returns a snapshot of the current plugin list, sorted by priority descending.
|
||||
|
||||
### `.parse(template, context, options?): string`
|
||||
|
||||
Synchronously renders `template` against `context`. Throws if any plugin's `init()` or `cleanup()` returns a Promise (use `parseAsync()` instead). When `throwOnError` is false, errors are forwarded to `onError` and the original template string is returned.
|
||||
|
||||
### `.parseAsync(template, context, options?): Promise<string>`
|
||||
|
||||
Asynchronous rendering path. Awaits `init()`, each plugin `transform()`, and `cleanup()`. Safe to use with async plugins.
|
||||
|
||||
### `.compile(template: string): CompiledTemplate<TContext>`
|
||||
|
||||
Returns `{ source, parse(context), parseAsync(context) }`. Suitable for templates rendered many times with different contexts.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Plugins are stored in a single sorted array. Each `.use()` or `.usePreset()` triggers an `Array.prototype.sort` — fine for the typical case of ≤20 plugins registered at startup.
|
||||
- `applyPlugin` collects all matches first, then applies replacements in reverse index order. This prevents offset drift when a replacement is shorter or longer than the matched text.
|
||||
- `MaxDepthError` and `PluginError` are both explicitly re-thrown without wrapping in `applyPlugin`. All other exceptions are wrapped in `PluginError` so callers see a consistent error type with `pluginName` and `match` context.
|
||||
- `parseInternal` is `protected` rather than `private` so that loop/control plugins can call it for recursive body rendering without a circular dependency on the full engine.
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Spec conformance: TPL-06 / TPL-07 / TPL-08 / TPL-09.
|
||||
*
|
||||
* Pins behaviours that consumers rely on but the existing TemplateEngine.test.ts
|
||||
* exercises only indirectly:
|
||||
* - constructor rejects invalid options through TemplateEngineOptionsSchema.
|
||||
* - plugin registration order is priority-descending after multiple inserts.
|
||||
* - throwOnError=false returns the original template unchanged and forwards to onError.
|
||||
* - getPlugins returns a defensive snapshot.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import { TemplateError } from "../errors/template-error.js";
|
||||
import type { Plugin } from "../types/index.js";
|
||||
import { PluginPriority } from "../types/index.js";
|
||||
import { TemplateEngine } from "./TemplateEngine.js";
|
||||
|
||||
function plugin(name: string, priority: number, replace: string): Plugin {
|
||||
return {
|
||||
name,
|
||||
priority,
|
||||
pattern: new RegExp(`\\{${name}\\}`, "g"),
|
||||
transform: () => replace,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TemplateEngine — spec conformance", () => {
|
||||
it("rejects invalid options at the boundary (TPL-06)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
() =>
|
||||
new TemplateEngine({
|
||||
// @ts-expect-error - intentionally invalid for boundary check
|
||||
maxDepth: -5,
|
||||
})
|
||||
).toThrow(TemplateError);
|
||||
expect(
|
||||
() =>
|
||||
new TemplateEngine({
|
||||
// @ts-expect-error - throwOnError must be boolean
|
||||
throwOnError: "no",
|
||||
})
|
||||
).toThrow(TemplateError);
|
||||
});
|
||||
|
||||
it("registers plugins in priority-descending order regardless of insertion order (TPL-07)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use(plugin("low", PluginPriority.LOW, "L"));
|
||||
engine.use(plugin("crit", PluginPriority.CRITICAL, "C"));
|
||||
engine.use(plugin("med", PluginPriority.MEDIUM, "M"));
|
||||
const order = engine.getPlugins().map((p) => p.name);
|
||||
expect(order).toEqual(["crit", "med", "low"]);
|
||||
});
|
||||
|
||||
it("getPlugins returns a defensive snapshot (TPL-07)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use(plugin("a", PluginPriority.LOW, "A"));
|
||||
const snap = engine.getPlugins() as Plugin[];
|
||||
snap.length = 0;
|
||||
expect(engine.getPlugins()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("throwOnError=false swallows errors and returns original template (TPL-09)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const onError = vi.fn();
|
||||
const engine = new TemplateEngine({ throwOnError: false, onError });
|
||||
engine.use({
|
||||
name: "boom",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{boom\}/g,
|
||||
transform: () => {
|
||||
throw new Error("kaboom");
|
||||
},
|
||||
});
|
||||
const out = engine.parse("Hello {boom}!", {});
|
||||
expect(out).toBe("Hello {boom}!");
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
const [err, template, ctx] = onError.mock.calls[0] ?? [];
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(template).toBe("Hello {boom}!");
|
||||
expect(ctx).toEqual({});
|
||||
});
|
||||
|
||||
it("parse() rejects invalid ParseOptions at the boundary (TPL-06)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = new TemplateEngine();
|
||||
expect(() =>
|
||||
engine.parse(
|
||||
"x",
|
||||
{},
|
||||
// @ts-expect-error - intentionally invalid for boundary check
|
||||
{ maxDepth: -1 }
|
||||
)
|
||||
).toThrow(TemplateError);
|
||||
});
|
||||
|
||||
it("parseAsync() rejects invalid ParseOptions at the boundary (TPL-06)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = new TemplateEngine();
|
||||
await expect(
|
||||
engine.parseAsync(
|
||||
"x",
|
||||
{},
|
||||
// @ts-expect-error - intentionally invalid for boundary check
|
||||
{ maxDepth: 0 }
|
||||
)
|
||||
).rejects.toBeInstanceOf(TemplateError);
|
||||
});
|
||||
|
||||
it("parseAsync() accepts valid ParseOptions and renders (TPL-06)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = new TemplateEngine();
|
||||
const out = await engine.parseAsync("hello", {}, { maxDepth: 5 });
|
||||
expect(out).toBe("hello");
|
||||
});
|
||||
|
||||
it("parse() accepts valid ParseOptions and renders (TPL-06)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = new TemplateEngine();
|
||||
const out = engine.parse("hello", {}, { maxDepth: 5 });
|
||||
expect(out).toBe("hello");
|
||||
});
|
||||
|
||||
it("throwOnError=false also catches async errors (TPL-09)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const onError = vi.fn();
|
||||
const engine = new TemplateEngine({ throwOnError: false, onError });
|
||||
engine.use({
|
||||
name: "boom",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{boom\}/g,
|
||||
async transform() {
|
||||
throw new Error("async kaboom");
|
||||
},
|
||||
});
|
||||
const out = await engine.parseAsync("X {boom} Y", {});
|
||||
expect(out).toBe("X {boom} Y");
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,498 @@
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
MaxDepthError,
|
||||
PluginError,
|
||||
TemplateError,
|
||||
} from "../errors/template-error.js";
|
||||
import type { ParseState, Plugin, TemplateContext } from "../types/index.js";
|
||||
import { PluginPriority, SKIP_PLUGIN } from "../types/index.js";
|
||||
import { TemplateEngine } from "./TemplateEngine.js";
|
||||
|
||||
// Helper: make a simple plugin that replaces {token} with value
|
||||
function makePlugin(
|
||||
name: string,
|
||||
token: string,
|
||||
value: string,
|
||||
priority = PluginPriority.LOW
|
||||
): Plugin {
|
||||
return {
|
||||
name,
|
||||
priority,
|
||||
pattern: new RegExp(`\\{${token}\\}`, "g"),
|
||||
transform(): string {
|
||||
return value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("TemplateEngine.use", () => {
|
||||
it("registers plugin and returns this for chaining", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = new TemplateEngine();
|
||||
const result = engine.use(makePlugin("p1", "x", "X"));
|
||||
expect(result).toBe(engine);
|
||||
expect(engine.getPlugins()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("throws PLUGIN_CONFLICT on duplicate name", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use(makePlugin("p1", "x", "X"));
|
||||
expect(() => engine.use(makePlugin("p1", "y", "Y"))).toThrow(
|
||||
TemplateError
|
||||
);
|
||||
});
|
||||
|
||||
it("sorts plugins by priority descending", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use(makePlugin("low", "a", "A", PluginPriority.LOW));
|
||||
engine.use(makePlugin("high", "b", "B", PluginPriority.HIGH));
|
||||
const plugins = engine.getPlugins();
|
||||
expect(plugins[0]?.name).toBe("high");
|
||||
expect(plugins[1]?.name).toBe("low");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TemplateEngine.usePreset", () => {
|
||||
it("registers all plugins, skips duplicates", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
const preset = {
|
||||
name: "test",
|
||||
plugins: [makePlugin("p1", "x", "X"), makePlugin("p2", "y", "Y")],
|
||||
};
|
||||
engine.usePreset(preset);
|
||||
expect(engine.getPlugins()).toHaveLength(2);
|
||||
|
||||
// Second usePreset with same names: no duplicates
|
||||
engine.usePreset(preset);
|
||||
expect(engine.getPlugins()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TemplateEngine.remove", () => {
|
||||
it("removes named plugin", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use(makePlugin("p1", "x", "X"));
|
||||
engine.remove("p1");
|
||||
expect(engine.getPlugins()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is a no-op for unknown name", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use(makePlugin("p1", "x", "X"));
|
||||
engine.remove("unknown");
|
||||
expect(engine.getPlugins()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TemplateEngine.parse (sync)", () => {
|
||||
it("returns literal template unchanged when no plugins", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = new TemplateEngine();
|
||||
expect(engine.parse("Hello World", {})).toBe("Hello World");
|
||||
});
|
||||
|
||||
it("applies plugin transformation", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use(makePlugin("name", "name", "Alice"));
|
||||
expect(engine.parse("Hello {name}!", {})).toBe("Hello Alice!");
|
||||
});
|
||||
|
||||
it("applies multiple plugins in priority order", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use({
|
||||
name: "A",
|
||||
priority: PluginPriority.HIGH,
|
||||
pattern: /\{step\}/g,
|
||||
transform: () => "HIGH",
|
||||
});
|
||||
engine.use({
|
||||
name: "B",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{step\}/g,
|
||||
transform: () => "LOW",
|
||||
});
|
||||
// HIGH runs first and replaces {step}, LOW sees no matches
|
||||
expect(engine.parse("{step}", {})).toBe("HIGH");
|
||||
});
|
||||
|
||||
it("handles SKIP_PLUGIN return", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use({
|
||||
name: "skipper",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{skip\}/g,
|
||||
transform: (): typeof SKIP_PLUGIN => SKIP_PLUGIN,
|
||||
});
|
||||
expect(engine.parse("{skip}", {})).toBe("{skip}");
|
||||
});
|
||||
|
||||
it("throws PluginError when plugin throws", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use({
|
||||
name: "bad",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{bad\}/g,
|
||||
transform: () => {
|
||||
throw new Error("oops");
|
||||
},
|
||||
});
|
||||
expect(() => engine.parse("{bad}", {})).toThrow(PluginError);
|
||||
});
|
||||
|
||||
it("throws MaxDepthError when nesting too deep", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine({ maxDepth: 1 });
|
||||
engine.use({
|
||||
name: "recurse",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{recurse\}/g,
|
||||
transform(_match, ctx, state) {
|
||||
// Force depth overflow
|
||||
return (
|
||||
engine as unknown as {
|
||||
parseInternal: (
|
||||
t: string,
|
||||
c: TemplateContext,
|
||||
s: ParseState
|
||||
) => string;
|
||||
}
|
||||
).parseInternal("{recurse}", ctx, state);
|
||||
},
|
||||
});
|
||||
expect(() => engine.parse("{recurse}", {})).toThrow(MaxDepthError);
|
||||
});
|
||||
|
||||
it("returns template and calls onError when throwOnError=false", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const onError = vi.fn();
|
||||
const engine = new TemplateEngine({ throwOnError: false, onError });
|
||||
engine.use({
|
||||
name: "bad",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{bad\}/g,
|
||||
transform: () => {
|
||||
throw new Error("oops");
|
||||
},
|
||||
});
|
||||
const result = engine.parse("{bad}", {});
|
||||
expect(result).toBe("{bad}");
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("suppresses error without onError callback when throwOnError=false", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Exercises the default no-op onError (line 47 branch in constructor)
|
||||
const engine = new TemplateEngine({ throwOnError: false });
|
||||
engine.use({
|
||||
name: "bad",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{bad\}/g,
|
||||
transform: () => {
|
||||
throw new Error("silent");
|
||||
},
|
||||
});
|
||||
// Should not throw; default onError is a no-op
|
||||
expect(engine.parse("{bad}", {})).toBe("{bad}");
|
||||
});
|
||||
|
||||
it("runs sync plugin init and cleanup without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const calls: string[] = [];
|
||||
const engine = new TemplateEngine();
|
||||
engine.use({
|
||||
name: "syncInitCleanup",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{x\}/g,
|
||||
init: () => {
|
||||
calls.push("init");
|
||||
},
|
||||
cleanup: () => {
|
||||
calls.push("cleanup");
|
||||
},
|
||||
transform: () => "X",
|
||||
});
|
||||
expect(engine.parse("{x}", {})).toBe("X");
|
||||
expect(calls).toEqual(["init", "cleanup"]);
|
||||
});
|
||||
|
||||
it("throws when sync plugin returns Promise in init", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use({
|
||||
name: "asyncInit",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{x\}/g,
|
||||
init: () => Promise.resolve(),
|
||||
transform: () => "x",
|
||||
});
|
||||
expect(() => engine.parse("{x}", {})).toThrow(TemplateError);
|
||||
});
|
||||
|
||||
it("throws when sync plugin returns Promise in cleanup", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use({
|
||||
name: "asyncCleanup",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{x\}/g,
|
||||
cleanup: () => Promise.resolve(),
|
||||
transform: () => "x",
|
||||
});
|
||||
expect(() => engine.parse("{x}", {})).toThrow(TemplateError);
|
||||
});
|
||||
|
||||
it("throws PluginError when transform returns Promise in sync mode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use({
|
||||
name: "asyncTransform",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{x\}/g,
|
||||
transform: () => Promise.resolve("x") as unknown as string,
|
||||
});
|
||||
expect(() => engine.parse("{x}", {})).toThrow(PluginError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TemplateEngine.parseAsync", () => {
|
||||
it("resolves async plugin transforms", { timeout: 5000 }, async () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use({
|
||||
name: "async",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{greeting\}/g,
|
||||
async: true,
|
||||
transform: async () => "Hi",
|
||||
});
|
||||
const result = await engine.parseAsync("{greeting}", {});
|
||||
expect(result).toBe("Hi");
|
||||
});
|
||||
|
||||
it("honours SKIP_PLUGIN from async transform", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use({
|
||||
name: "asyncSkip",
|
||||
priority: PluginPriority.LOW,
|
||||
async: true,
|
||||
pattern: /\{skip\}/g,
|
||||
transform: async (): Promise<typeof SKIP_PLUGIN> =>
|
||||
Promise.resolve(SKIP_PLUGIN),
|
||||
});
|
||||
// The match is found but skipped → original text preserved
|
||||
const result = await engine.parseAsync("{skip}", {});
|
||||
expect(result).toBe("{skip}");
|
||||
});
|
||||
|
||||
it("wraps thrown errors in PluginError", { timeout: 5000 }, async () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use({
|
||||
name: "bad",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{bad\}/g,
|
||||
transform: async () => {
|
||||
throw new Error("fail");
|
||||
},
|
||||
});
|
||||
await expect(engine.parseAsync("{bad}", {})).rejects.toThrow(
|
||||
PluginError
|
||||
);
|
||||
});
|
||||
|
||||
it("returns template on error when throwOnError=false", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const onError = vi.fn();
|
||||
const engine = new TemplateEngine({ throwOnError: false, onError });
|
||||
engine.use({
|
||||
name: "bad",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{bad\}/g,
|
||||
transform: async () => {
|
||||
throw new Error("fail");
|
||||
},
|
||||
});
|
||||
const result = await engine.parseAsync("{bad}", {});
|
||||
expect(result).toBe("{bad}");
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls async init and cleanup hooks", { timeout: 5000 }, async () => {
|
||||
const calls: string[] = [];
|
||||
const engine = new TemplateEngine();
|
||||
engine.use({
|
||||
name: "hooks",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{x\}/g,
|
||||
init: async () => {
|
||||
calls.push("init");
|
||||
},
|
||||
cleanup: async () => {
|
||||
calls.push("cleanup");
|
||||
},
|
||||
transform: () => "x",
|
||||
});
|
||||
await engine.parseAsync("{x}", {});
|
||||
expect(calls).toEqual(["init", "cleanup"]);
|
||||
});
|
||||
|
||||
it("throws MaxDepthError for deep async recursion (shared state)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = new TemplateEngine({ maxDepth: 1 });
|
||||
// Share the same state by calling parseInternal directly from async transform
|
||||
engine.use({
|
||||
name: "recurse",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{recurse\}/g,
|
||||
async transform(_match, ctx, state) {
|
||||
return (
|
||||
engine as unknown as {
|
||||
parseInternal: (
|
||||
t: string,
|
||||
c: TemplateContext,
|
||||
s: ParseState
|
||||
) => string;
|
||||
}
|
||||
).parseInternal("{recurse}", ctx, state);
|
||||
},
|
||||
});
|
||||
await expect(engine.parseAsync("{recurse}", {})).rejects.toThrow(
|
||||
MaxDepthError
|
||||
);
|
||||
});
|
||||
|
||||
it("throws MaxDepthError in parseInternalAsync when depth exhausted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = new TemplateEngine({ maxDepth: 1 });
|
||||
// Call parseInternalAsync directly with a state already at max depth
|
||||
engine.use({
|
||||
name: "asyncRecurse",
|
||||
priority: PluginPriority.LOW,
|
||||
async: true,
|
||||
pattern: /\{asyncRecurse\}/g,
|
||||
async transform(_match, ctx, state) {
|
||||
// Call the private parseInternalAsync with the shared (saturated) state
|
||||
return (
|
||||
engine as unknown as {
|
||||
parseInternalAsync: (
|
||||
t: string,
|
||||
c: TemplateContext,
|
||||
s: ParseState
|
||||
) => Promise<string>;
|
||||
}
|
||||
).parseInternalAsync("{asyncRecurse}", ctx, state);
|
||||
},
|
||||
});
|
||||
await expect(engine.parseAsync("{asyncRecurse}", {})).rejects.toThrow(
|
||||
MaxDepthError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TemplateEngine.compile", () => {
|
||||
it("returns CompiledTemplate with source", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use(makePlugin("name", "name", "Bob"));
|
||||
const compiled = engine.compile("Hello {name}");
|
||||
expect(compiled.source).toBe("Hello {name}");
|
||||
expect(compiled.parse({})).toBe("Hello Bob");
|
||||
});
|
||||
|
||||
it("parseAsync works on compiled template", { timeout: 5000 }, async () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use(makePlugin("name", "name", "Bob"));
|
||||
const compiled = engine.compile("Hello {name}");
|
||||
expect(await compiled.parseAsync({})).toBe("Hello Bob");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TemplateEngine.getPlugins", () => {
|
||||
it("returns snapshot — mutating it does not affect engine", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use(makePlugin("p1", "x", "X"));
|
||||
const snap = engine.getPlugins() as Plugin[];
|
||||
snap.push(makePlugin("p2", "y", "Y"));
|
||||
expect(engine.getPlugins()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse options — maxDepth override", () => {
|
||||
it("uses per-call maxDepth", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine({ maxDepth: 10 });
|
||||
engine.use({
|
||||
name: "recurse",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{recurse\}/g,
|
||||
transform(_m, ctx, state) {
|
||||
return (
|
||||
engine as unknown as {
|
||||
parseInternal: (
|
||||
t: string,
|
||||
c: TemplateContext,
|
||||
s: ParseState
|
||||
) => string;
|
||||
}
|
||||
).parseInternal("{recurse}", ctx, state);
|
||||
},
|
||||
});
|
||||
expect(() => engine.parse("{recurse}", {}, { maxDepth: 1 })).toThrow(
|
||||
MaxDepthError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginError re-throw propagation", () => {
|
||||
it("re-throws PluginError from inside transform without double-wrapping", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use({
|
||||
name: "inner",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{inner\}/g,
|
||||
transform() {
|
||||
throw new PluginError("inner fail", "inner", "{inner}");
|
||||
},
|
||||
});
|
||||
expect(() => engine.parse("{inner}", {})).toThrow(PluginError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("async PluginError re-throw", () => {
|
||||
it("re-throws PluginError from async transform", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use({
|
||||
name: "inner",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{inner\}/g,
|
||||
async transform() {
|
||||
throw new PluginError("inner fail", "inner", "{inner}");
|
||||
},
|
||||
});
|
||||
await expect(engine.parseAsync("{inner}", {})).rejects.toThrow(
|
||||
PluginError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple matches in one template", () => {
|
||||
it("replaces all occurrences", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.use(makePlugin("n", "n", "X"));
|
||||
expect(engine.parse("{n} and {n}", {})).toBe("X and X");
|
||||
});
|
||||
});
|
||||
292
packages/foundation/data/templates/src/engine/TemplateEngine.ts
Normal file
292
packages/foundation/data/templates/src/engine/TemplateEngine.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import {
|
||||
MaxDepthError,
|
||||
TEMPLATE_CODES,
|
||||
TemplateError,
|
||||
} from "../errors/template-error.js";
|
||||
import {
|
||||
ParseOptionsSchema,
|
||||
TemplateEngineOptionsSchema,
|
||||
} from "../schemas/templates.schemas.js";
|
||||
import type {
|
||||
CompiledTemplate,
|
||||
ParseOptions,
|
||||
ParseState,
|
||||
Plugin,
|
||||
PluginPreset,
|
||||
TemplateContext,
|
||||
TemplateEngineOptions,
|
||||
} from "../types/index.js";
|
||||
import { applyPlugin, applyPluginAsync } from "./apply-plugin.js";
|
||||
import { createParseState, incrementDepth } from "./ParseState.js";
|
||||
|
||||
/**
|
||||
* Enterprise-grade template engine with an extensible plugin system.
|
||||
*
|
||||
* Plugins are registered with `.use()` or `.usePreset()` and executed in
|
||||
* priority order (highest first) on every `.parse()` call. The engine
|
||||
* supports sync and async rendering, recursive sub-templates via the loop
|
||||
* plugin, depth limiting, and an opt-in error-suppression mode.
|
||||
*
|
||||
* @template TContext - Context type; must extend `TemplateContext`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const engine = new TemplateEngine().usePreset(standardPreset());
|
||||
* engine.parse("Hello {name}!", { name: "World" }); // "Hello World!"
|
||||
* ```
|
||||
*/
|
||||
export class TemplateEngine<
|
||||
TContext extends TemplateContext = TemplateContext,
|
||||
> {
|
||||
protected plugins: Plugin<TContext>[] = [];
|
||||
protected options: Required<TemplateEngineOptions>;
|
||||
|
||||
constructor(options: TemplateEngineOptions = {}) {
|
||||
const validation = TemplateEngineOptionsSchema.safeParse(options);
|
||||
if (!validation.success) {
|
||||
throw new TemplateError(
|
||||
TEMPLATE_CODES.INVALID_CONFIG,
|
||||
validation.error.message,
|
||||
"",
|
||||
{}
|
||||
);
|
||||
}
|
||||
this.options = {
|
||||
maxDepth: options.maxDepth ?? 10,
|
||||
throwOnError: options.throwOnError ?? true,
|
||||
enableCache: options.enableCache ?? true,
|
||||
onError: options.onError ?? ((): void => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Plugin management ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register a plugin. Throws `TemplateError(PLUGIN_CONFLICT)` if a plugin
|
||||
* with the same name is already registered.
|
||||
*/
|
||||
use(plugin: Plugin<TContext>): this {
|
||||
const conflict = this.plugins.find((p) => p.name === plugin.name);
|
||||
if (conflict) {
|
||||
throw new TemplateError(
|
||||
TEMPLATE_CODES.PLUGIN_CONFLICT,
|
||||
`Plugin "${plugin.name}" is already registered`,
|
||||
"",
|
||||
{}
|
||||
);
|
||||
}
|
||||
this.plugins.push(plugin);
|
||||
this.plugins.sort((a, b) => b.priority - a.priority);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all plugins from a preset.
|
||||
* Plugins whose name is already registered are silently skipped.
|
||||
*/
|
||||
usePreset(preset: PluginPreset<TContext>): this {
|
||||
for (const plugin of preset.plugins) {
|
||||
const exists = this.plugins.some((p) => p.name === plugin.name);
|
||||
if (!exists) {
|
||||
this.plugins.push(plugin);
|
||||
}
|
||||
}
|
||||
this.plugins.sort((a, b) => b.priority - a.priority);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a plugin by name. No-op when the name is not registered.
|
||||
*/
|
||||
remove(name: string): this {
|
||||
this.plugins = this.plugins.filter((p) => p.name !== name);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Return a sorted (priority-descending) snapshot of registered plugins. */
|
||||
getPlugins(): readonly Plugin<TContext>[] {
|
||||
return [...this.plugins];
|
||||
}
|
||||
|
||||
// ─── Sync parse ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Synchronously render `template` against `context`.
|
||||
*
|
||||
* When `throwOnError` is false any error is forwarded to `onError`
|
||||
* and the original template string is returned unchanged.
|
||||
*/
|
||||
parse(template: string, context: TContext, options?: ParseOptions): string {
|
||||
if (options !== undefined) {
|
||||
const v = ParseOptionsSchema.safeParse(options);
|
||||
if (!v.success) {
|
||||
throw new TemplateError(
|
||||
TEMPLATE_CODES.INVALID_CONFIG,
|
||||
v.error.message,
|
||||
template,
|
||||
context as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
}
|
||||
const state = createParseState(
|
||||
options?.maxDepth ?? this.options.maxDepth
|
||||
);
|
||||
|
||||
try {
|
||||
for (const plugin of this.plugins) {
|
||||
if (plugin.init) {
|
||||
const r = plugin.init(context, state);
|
||||
if (r instanceof Promise) {
|
||||
throw new TemplateError(
|
||||
TEMPLATE_CODES.RENDER_ERROR,
|
||||
`Plugin "${plugin.name}" init() returned a Promise — use parseAsync() instead`,
|
||||
template,
|
||||
context as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = this.parseInternal(template, context, state);
|
||||
|
||||
for (const plugin of this.plugins) {
|
||||
if (plugin.cleanup) {
|
||||
const r = plugin.cleanup(context, state);
|
||||
if (r instanceof Promise) {
|
||||
throw new TemplateError(
|
||||
TEMPLATE_CODES.RENDER_ERROR,
|
||||
`Plugin "${plugin.name}" cleanup() returned a Promise — use parseAsync() instead`,
|
||||
template,
|
||||
context as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (!this.options.throwOnError) {
|
||||
this.options.onError(error as Error, template, context);
|
||||
return template;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Async parse ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Asynchronously render `template` against `context`.
|
||||
* Plugins whose `transform()` returns `Promise<string>` are awaited.
|
||||
*/
|
||||
async parseAsync(
|
||||
template: string,
|
||||
context: TContext,
|
||||
options?: ParseOptions
|
||||
): Promise<string> {
|
||||
if (options !== undefined) {
|
||||
const v = ParseOptionsSchema.safeParse(options);
|
||||
if (!v.success) {
|
||||
throw new TemplateError(
|
||||
TEMPLATE_CODES.INVALID_CONFIG,
|
||||
v.error.message,
|
||||
template,
|
||||
context as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
}
|
||||
const state = createParseState(
|
||||
options?.maxDepth ?? this.options.maxDepth
|
||||
);
|
||||
|
||||
try {
|
||||
for (const plugin of this.plugins) {
|
||||
if (plugin.init) await plugin.init(context, state);
|
||||
}
|
||||
|
||||
const result = await this.parseInternalAsync(
|
||||
template,
|
||||
context,
|
||||
state
|
||||
);
|
||||
|
||||
for (const plugin of this.plugins) {
|
||||
if (plugin.cleanup) await plugin.cleanup(context, state);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (!this.options.throwOnError) {
|
||||
this.options.onError(error as Error, template, context);
|
||||
return template;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Compile ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pre-bind `template` to this engine and return a reusable render object.
|
||||
*/
|
||||
compile(template: string): CompiledTemplate<TContext> {
|
||||
return {
|
||||
source: template,
|
||||
parse: (ctx: TContext) => this.parse(template, ctx),
|
||||
parseAsync: (ctx: TContext) => this.parseAsync(template, ctx),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Protected so that loop/control plugins can call it for recursive renders.
|
||||
*/
|
||||
protected parseInternal(
|
||||
template: string,
|
||||
context: TContext,
|
||||
state: ParseState
|
||||
): string {
|
||||
if (state.depth >= state.maxDepth) {
|
||||
throw new MaxDepthError(
|
||||
`Maximum template nesting depth (${state.maxDepth}) exceeded`,
|
||||
state.depth,
|
||||
state.maxDepth
|
||||
);
|
||||
}
|
||||
|
||||
incrementDepth(state);
|
||||
let result = template;
|
||||
|
||||
for (const plugin of this.plugins) {
|
||||
result = applyPlugin(plugin, result, context, state);
|
||||
}
|
||||
|
||||
state.depth--;
|
||||
return result;
|
||||
}
|
||||
|
||||
private async parseInternalAsync(
|
||||
template: string,
|
||||
context: TContext,
|
||||
state: ParseState
|
||||
): Promise<string> {
|
||||
if (state.depth >= state.maxDepth) {
|
||||
throw new MaxDepthError(
|
||||
`Maximum template nesting depth (${state.maxDepth}) exceeded`,
|
||||
state.depth,
|
||||
state.maxDepth
|
||||
);
|
||||
}
|
||||
|
||||
incrementDepth(state);
|
||||
let result = template;
|
||||
|
||||
for (const plugin of this.plugins) {
|
||||
result = await applyPluginAsync(plugin, result, context, state);
|
||||
}
|
||||
|
||||
state.depth--;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
# apply-plugin
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the core two-phase plugin application logic used by `TemplateEngine` for both synchronous and asynchronous rendering. Extracting these functions from `TemplateEngine` keeps the class under the 300-line hard limit and makes the scan-then-transform algorithm independently readable and testable.
|
||||
|
||||
The scan-and-transform split is essential for correctness: regex iterators maintain internal state (`lastIndex`) and cannot be safely interleaved with recursive calls. By collecting all matches in phase 1 before invoking any transforms in phase 2, plugins like `LoopPlugin` can call `parseInternal` recursively without corrupting the iterator.
|
||||
|
||||
## Features
|
||||
|
||||
- **Two-phase execution**: match scanning (phase 1) is fully completed before transforms run (phase 2), preventing `lastIndex` corruption from recursive plugin calls
|
||||
- **Reverse-order splicing**: replacements are applied back-to-front in phase 3 so earlier character offsets remain valid after each edit
|
||||
- **`SKIP_PLUGIN` support**: a transform returning `SKIP_PLUGIN` skips that match without error
|
||||
- **Async-Promise guard (sync path)**: if a plugin returns a `Promise` in the sync `applyPlugin` call, a `PluginError` is thrown with a clear message directing users to `parseAsync()`
|
||||
- **Structured error wrapping**: unexpected transform errors are caught and re-thrown as `PluginError` with plugin name and matched text for actionable diagnostics
|
||||
- **`MaxDepthError` pass-through**: depth-exceeded errors bubble directly without being re-wrapped
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { applyPlugin } from "./apply-plugin.js";
|
||||
import { createParseState } from "./ParseState.js";
|
||||
|
||||
const plugin = {
|
||||
name: "upper",
|
||||
priority: 10,
|
||||
pattern: /\{upper:([^}]+)\}/g,
|
||||
transform: ({ groups }) => groups[1]?.toUpperCase() ?? "",
|
||||
};
|
||||
|
||||
const state = createParseState(10);
|
||||
const result = applyPlugin(plugin, "Hello {upper:world}!", {}, state);
|
||||
// "Hello WORLD!"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { applyPluginAsync } from "./apply-plugin.js";
|
||||
import { createParseState } from "./ParseState.js";
|
||||
|
||||
const asyncPlugin = {
|
||||
name: "fetch-value",
|
||||
priority: 5,
|
||||
pattern: /\{fetch:([^}]+)\}/g,
|
||||
transform: async ({ groups }) => {
|
||||
const key = groups[1] ?? "";
|
||||
return await fetchValueFromDB(key);
|
||||
},
|
||||
};
|
||||
|
||||
const state = createParseState(10);
|
||||
const result = await applyPluginAsync(
|
||||
asyncPlugin,
|
||||
"Value: {fetch:my-key}",
|
||||
{},
|
||||
state
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `applyPlugin<TContext>(plugin, template, context, state): string`
|
||||
|
||||
Synchronously applies `plugin` to `template`.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|------------|-----------------------|--------------------------------------|
|
||||
| `plugin` | `Plugin<TContext>` | The plugin to apply |
|
||||
| `template` | `string` | The template string to process |
|
||||
| `context` | `TContext` | The render context |
|
||||
| `state` | `ParseState` | Mutable parse state (depth, etc.) |
|
||||
|
||||
Returns the transformed string. Throws `PluginError` if the transform fails or returns a `Promise`.
|
||||
|
||||
### `applyPluginAsync<TContext>(plugin, template, context, state): Promise<string>`
|
||||
|
||||
Async variant. Plugin transforms are awaited. Safe for plugins that perform I/O or other async work.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|------------|-----------------------|--------------------------------------|
|
||||
| `plugin` | `Plugin<TContext>` | The plugin to apply |
|
||||
| `template` | `string` | The template string to process |
|
||||
| `context` | `TContext` | The render context |
|
||||
| `state` | `ParseState` | Mutable parse state (depth, etc.) |
|
||||
|
||||
Returns a `Promise<string>`. Throws `PluginError` if the transform fails.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Both functions share the same three-phase structure:
|
||||
|
||||
1. **Scan** — iterate `plugin.pattern` with `exec()` to collect all `PluginMatch` objects. `lastIndex` is reset to 0 before scanning. The loop completes before any user code is called.
|
||||
2. **Transform** — iterate the collected matches and call `plugin.transform()`. Skipped matches (`SKIP_PLUGIN`) are excluded from the result set. Errors from transforms are wrapped in `PluginError` unless already a `PluginError` or `MaxDepthError`.
|
||||
3. **Splice** — collected `(index, replacement)` pairs are applied in reverse index order to the working string, keeping earlier offsets stable.
|
||||
|
||||
The async variant (`applyPluginAsync`) is identical in structure but `await`s the transform result. There is no async scanning — regex matching is always synchronous.
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Spec conformance: TPL-08 — reverse-index replacement and SKIP_PLUGIN handling
|
||||
* inside applyPlugin.
|
||||
*
|
||||
* Pins:
|
||||
* - replacements happen right-to-left so character indices stay valid for
|
||||
* variable-length substitutions.
|
||||
* - SKIP_PLUGIN leaves a match untouched without throwing or shifting indices
|
||||
* for sibling matches.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Plugin } from "../types/index.js";
|
||||
import { PluginPriority, SKIP_PLUGIN } from "../types/index.js";
|
||||
import { applyPlugin } from "./apply-plugin.js";
|
||||
import { createParseState } from "./ParseState.js";
|
||||
|
||||
describe("applyPlugin — spec conformance (TPL-08)", () => {
|
||||
it("applies replacements in reverse-index order so multi-token templates render correctly", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// The token regex matches three places. Each replacement is shorter
|
||||
// than the matched text — if the engine spliced left-to-right, the
|
||||
// later indices would slip and the test would fail.
|
||||
const plugin: Plugin = {
|
||||
name: "rev",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{X\}/g,
|
||||
transform: () => "y",
|
||||
};
|
||||
const out = applyPlugin(
|
||||
plugin,
|
||||
"[{X}][{X}][{X}]",
|
||||
{},
|
||||
createParseState()
|
||||
);
|
||||
expect(out).toBe("[y][y][y]");
|
||||
});
|
||||
|
||||
it("leaves text unchanged when transform returns SKIP_PLUGIN", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin: Plugin = {
|
||||
name: "skip",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{S\}/g,
|
||||
transform: () => SKIP_PLUGIN,
|
||||
};
|
||||
const out = applyPlugin(plugin, "a{S}b{S}c", {}, createParseState());
|
||||
expect(out).toBe("a{S}b{S}c");
|
||||
});
|
||||
|
||||
it("selectively skips and replaces depending on context (TPL-08)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin: Plugin = {
|
||||
name: "mix",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{(\w+)\}/g,
|
||||
transform: (m, ctx) => {
|
||||
const key = m.groups?.[1];
|
||||
if (!key || !(key in (ctx as Record<string, unknown>)))
|
||||
return SKIP_PLUGIN;
|
||||
return String((ctx as Record<string, string>)[key]);
|
||||
},
|
||||
};
|
||||
const out = applyPlugin(
|
||||
plugin,
|
||||
"Hello {name}, missing {gone}!",
|
||||
{ name: "World" },
|
||||
createParseState()
|
||||
);
|
||||
expect(out).toBe("Hello World, missing {gone}!");
|
||||
});
|
||||
});
|
||||
132
packages/foundation/data/templates/src/engine/apply-plugin.ts
Normal file
132
packages/foundation/data/templates/src/engine/apply-plugin.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { MaxDepthError, PluginError } from "../errors/template-error.js";
|
||||
import type {
|
||||
ParseState,
|
||||
Plugin,
|
||||
PluginMatch,
|
||||
TemplateContext,
|
||||
} from "../types/index.js";
|
||||
import { SKIP_PLUGIN } from "../types/index.js";
|
||||
|
||||
/**
|
||||
* Scan all regex matches for `plugin` within `template`, then apply each
|
||||
* transform in a second pass and splice the replacements in reverse order so
|
||||
* character indices stay valid.
|
||||
*
|
||||
* Splitting the scan and transform phases means recursive plugins (e.g.
|
||||
* LoopPlugin) can call `parseInternal` without corrupting the regex iterator.
|
||||
*/
|
||||
export function applyPlugin<TContext extends TemplateContext>(
|
||||
plugin: Plugin<TContext>,
|
||||
template: string,
|
||||
context: TContext,
|
||||
state: ParseState
|
||||
): string {
|
||||
// Phase 1: scan ALL matches before calling any transform.
|
||||
// Transforms may call parseInternal recursively (e.g. LoopPlugin),
|
||||
// which would corrupt plugin.pattern.lastIndex mid-scan.
|
||||
plugin.pattern.lastIndex = 0;
|
||||
const rawMatches: PluginMatch[] = [];
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = plugin.pattern.exec(template)) !== null) {
|
||||
rawMatches.push({ match: m[0], groups: m, index: m.index });
|
||||
}
|
||||
|
||||
// Phase 2: apply transforms — regex is no longer being iterated.
|
||||
const collected: Array<{ match: PluginMatch; replacement: string }> = [];
|
||||
|
||||
for (const pm of rawMatches) {
|
||||
let transformed: string;
|
||||
try {
|
||||
const result = plugin.transform(pm, context, state);
|
||||
|
||||
if (result === SKIP_PLUGIN) continue;
|
||||
|
||||
if (result instanceof Promise) {
|
||||
throw new PluginError(
|
||||
`Plugin "${plugin.name}" returned a Promise — use parseAsync()`,
|
||||
plugin.name,
|
||||
pm.match
|
||||
);
|
||||
}
|
||||
|
||||
transformed = result;
|
||||
} catch (err) {
|
||||
if (err instanceof PluginError || err instanceof MaxDepthError)
|
||||
throw err;
|
||||
throw new PluginError(
|
||||
`Plugin "${plugin.name}" failed on "${pm.match}"`,
|
||||
plugin.name,
|
||||
pm.match,
|
||||
err as Error
|
||||
);
|
||||
}
|
||||
|
||||
collected.push({ match: pm, replacement: transformed });
|
||||
}
|
||||
|
||||
// Phase 3: apply replacements in reverse so char indices stay valid.
|
||||
let result = template;
|
||||
for (const item of [...collected].reverse()) {
|
||||
result =
|
||||
result.slice(0, item.match.index) +
|
||||
item.replacement +
|
||||
result.slice(item.match.index + item.match.match.length);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async variant of `applyPlugin`. Plugin `transform()` results are awaited,
|
||||
* enabling plugins that perform I/O or other async work.
|
||||
*/
|
||||
export async function applyPluginAsync<TContext extends TemplateContext>(
|
||||
plugin: Plugin<TContext>,
|
||||
template: string,
|
||||
context: TContext,
|
||||
state: ParseState
|
||||
): Promise<string> {
|
||||
// Phase 1: scan ALL matches before any transform (same reason as sync path).
|
||||
plugin.pattern.lastIndex = 0;
|
||||
const rawMatches: PluginMatch[] = [];
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = plugin.pattern.exec(template)) !== null) {
|
||||
rawMatches.push({ match: m[0], groups: m, index: m.index });
|
||||
}
|
||||
|
||||
// Phase 2: apply transforms.
|
||||
const collected: Array<{ match: PluginMatch; replacement: string }> = [];
|
||||
|
||||
for (const pm of rawMatches) {
|
||||
let transformed: string;
|
||||
try {
|
||||
const result = await plugin.transform(pm, context, state);
|
||||
if (result === SKIP_PLUGIN) continue;
|
||||
transformed = result;
|
||||
} catch (err) {
|
||||
if (err instanceof PluginError || err instanceof MaxDepthError)
|
||||
throw err;
|
||||
throw new PluginError(
|
||||
`Plugin "${plugin.name}" failed on "${pm.match}"`,
|
||||
plugin.name,
|
||||
pm.match,
|
||||
err as Error
|
||||
);
|
||||
}
|
||||
|
||||
collected.push({ match: pm, replacement: transformed });
|
||||
}
|
||||
|
||||
// Phase 3: apply replacements in reverse.
|
||||
let result = template;
|
||||
for (const item of [...collected].reverse()) {
|
||||
result =
|
||||
result.slice(0, item.match.index) +
|
||||
item.replacement +
|
||||
result.slice(item.match.index + item.match.match.length);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
11
packages/foundation/data/templates/src/engine/index.ts
Normal file
11
packages/foundation/data/templates/src/engine/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { applyPlugin, applyPluginAsync } from "./apply-plugin";
|
||||
export {
|
||||
clearCounters,
|
||||
cloneParseState,
|
||||
createParseState,
|
||||
decrementDepth,
|
||||
getCounter,
|
||||
incrementDepth,
|
||||
resetCounter,
|
||||
} from "./ParseState";
|
||||
export { TemplateEngine } from "./TemplateEngine";
|
||||
69
packages/foundation/data/templates/src/errors/error-codes.md
Normal file
69
packages/foundation/data/templates/src/errors/error-codes.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# error-codes
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines and freezes the complete error-code registry for `@suaveplan/templates` using the `defineCodes` utility from `@suaveplan/error`. Every error thrown by the template engine carries one of these stable codes, enabling consumer code to programmatically inspect and react to error conditions without relying on string matching against human-readable messages. The `TEMPLATE.` namespace prefix ensures global uniqueness across the project.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven stable error codes prefixed with `TEMPLATE.`.
|
||||
- Codes are frozen at module load time — `defineCodes` returns a `Readonly` object so no code can be modified or added after import.
|
||||
- The `TemplateCode` union type is derived from the registry using `typeof TEMPLATE_CODES`, so adding a new code automatically extends the type.
|
||||
- Used by `TemplateError`, `PluginError`, and `MaxDepthError` constructors to tag every thrown error.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TEMPLATE_CODES } from "@suaveplan/templates/errors/error-codes";
|
||||
|
||||
try {
|
||||
engine.parse("{missing}", {});
|
||||
} catch (err) {
|
||||
if (err instanceof TemplateError && err.code === TEMPLATE_CODES.MISSING_VARIABLE) {
|
||||
console.log("Missing variable:", err.renderContext);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TEMPLATE_CODES } from "@suaveplan/templates/errors/error-codes";
|
||||
import type { TemplateCode } from "@suaveplan/templates/errors/error-codes";
|
||||
|
||||
function isTemplateError(code: TemplateCode): boolean {
|
||||
return Object.values(TEMPLATE_CODES).includes(code);
|
||||
}
|
||||
|
||||
// Inspect the full registry programmatically
|
||||
console.log(Object.keys(TEMPLATE_CODES));
|
||||
// → ["PARSE_ERROR", "MISSING_VARIABLE", "PLUGIN_CONFLICT", "PLUGIN_ERROR", "RENDER_ERROR", "MAX_DEPTH", "INVALID_CONFIG"]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `TEMPLATE_CODES`
|
||||
|
||||
A frozen `Record<string, string>` with the following keys:
|
||||
|
||||
| Key | Value | Thrown by |
|
||||
|-----|-------|-----------|
|
||||
| `PARSE_ERROR` | `"TEMPLATE.PARSE_ERROR"` | Engine catch-all for unexpected parse failures |
|
||||
| `MISSING_VARIABLE` | `"TEMPLATE.MISSING_VARIABLE"` | When `throwOnError` is set and a variable is absent |
|
||||
| `PLUGIN_CONFLICT` | `"TEMPLATE.PLUGIN_CONFLICT"` | `engine.use()` with a duplicate plugin name |
|
||||
| `PLUGIN_ERROR` | `"TEMPLATE.PLUGIN_ERROR"` | When a plugin's `transform()` throws unexpectedly |
|
||||
| `RENDER_ERROR` | `"TEMPLATE.RENDER_ERROR"` | General render pipeline failure |
|
||||
| `MAX_DEPTH` | `"TEMPLATE.MAX_DEPTH"` | Recursive render depth exceeds `maxDepth` |
|
||||
| `INVALID_CONFIG` | `"TEMPLATE.INVALID_CONFIG"` | Invalid engine construction options |
|
||||
|
||||
### `TemplateCode`
|
||||
|
||||
`type TemplateCode = (typeof TEMPLATE_CODES)[keyof typeof TEMPLATE_CODES]`
|
||||
|
||||
Union of all string literal values in `TEMPLATE_CODES`. Used as the generic type parameter for `TemplateError`, `PluginError`, and `MaxDepthError` to enforce that every error carries a known code.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `defineCodes("TEMPLATE", [...])` from `@suaveplan/error` constructs the registry by prefixing each code name with `"TEMPLATE."` and freezing the result with `Object.freeze`. This prevents accidental mutation in consumer code.
|
||||
- The registry is exported as a named constant rather than individual string literals to prevent typo-based drift between the registered codes and their usage sites; all usages should reference `TEMPLATE_CODES.SOME_CODE` rather than string literals.
|
||||
- The `as const` assertion on the code-name array is required so TypeScript infers a tuple of string literals rather than `string[]`, enabling precise inference of `TemplateCode`.
|
||||
19
packages/foundation/data/templates/src/errors/error-codes.ts
Normal file
19
packages/foundation/data/templates/src/errors/error-codes.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineCodes } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for @suaveplan/templates.
|
||||
*
|
||||
* Each code is `TEMPLATE.<REASON>` to ensure global uniqueness across
|
||||
* the monorepo. Codes are frozen at module load time via `defineCodes`.
|
||||
*/
|
||||
export const TEMPLATE_CODES = defineCodes("TEMPLATE", [
|
||||
"PARSE_ERROR",
|
||||
"MISSING_VARIABLE",
|
||||
"PLUGIN_CONFLICT",
|
||||
"PLUGIN_ERROR",
|
||||
"RENDER_ERROR",
|
||||
"MAX_DEPTH",
|
||||
"INVALID_CONFIG",
|
||||
] as const);
|
||||
|
||||
export type TemplateCode = (typeof TEMPLATE_CODES)[keyof typeof TEMPLATE_CODES];
|
||||
2
packages/foundation/data/templates/src/errors/index.ts
Normal file
2
packages/foundation/data/templates/src/errors/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TEMPLATE_CODES, type TemplateCode } from "./error-codes";
|
||||
export { MaxDepthError, PluginError, TemplateError } from "./template-error";
|
||||
102
packages/foundation/data/templates/src/errors/template-error.md
Normal file
102
packages/foundation/data/templates/src/errors/template-error.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# template-error
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the structured error hierarchy for `@suaveplan/templates`. All errors extend `SuaveplanError` from `@suaveplan/error`, giving them stable machine-readable codes, timestamps, optional trace IDs, and cause-chain traversal support compatible with the rest of the project.
|
||||
|
||||
Three concrete error classes cover every error scenario the engine can produce: template-level failures (`TemplateError`), plugin-specific failures (`PluginError`), and recursive-depth overflows (`MaxDepthError`). Stable error codes are registered via `defineCodes` so they are globally unique across the project.
|
||||
|
||||
## Features
|
||||
|
||||
- `TemplateError` — base for parse/render failures; carries `template` (source string) and `renderContext` (resolved data).
|
||||
- `PluginError` — wraps exceptions thrown by plugin `transform()` or `init()`/`cleanup()` hooks; carries `pluginName` and `match`.
|
||||
- `MaxDepthError` — thrown when recursive nesting exceeds `maxDepth`; carries `depth` and `maxDepth`.
|
||||
- `TEMPLATE_CODES` — frozen record of all `TEMPLATE.*` codes produced by `defineCodes`.
|
||||
- Re-exports `TemplateCode` union type for type-level narrowing.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TemplateError, TEMPLATE_CODES } from "@suaveplan/templates/errors";
|
||||
|
||||
throw new TemplateError(
|
||||
TEMPLATE_CODES.PARSE_ERROR,
|
||||
"Failed to parse template",
|
||||
"{bad {nested}",
|
||||
{ userId: "u1" }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PluginError, MaxDepthError, TEMPLATE_CODES } from "@suaveplan/templates";
|
||||
import { walkCauses } from "@suaveplan/error";
|
||||
|
||||
try {
|
||||
engine.parse(template, context);
|
||||
} catch (err) {
|
||||
if (err instanceof MaxDepthError) {
|
||||
console.error(`Exceeded depth ${err.depth}/${err.maxDepth}`);
|
||||
} else if (err instanceof PluginError) {
|
||||
console.error(`Plugin '${err.pluginName}' failed on: ${err.match}`);
|
||||
// Walk the entire cause chain
|
||||
for (const cause of walkCauses(err)) {
|
||||
console.error(" caused by:", cause.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `TEMPLATE_CODES`
|
||||
|
||||
Frozen record produced by `defineCodes("TEMPLATE", [...])`.
|
||||
|
||||
| Key | Code string |
|
||||
|-----|-------------|
|
||||
| `PARSE_ERROR` | `TEMPLATE.PARSE_ERROR` |
|
||||
| `MISSING_VARIABLE` | `TEMPLATE.MISSING_VARIABLE` |
|
||||
| `PLUGIN_CONFLICT` | `TEMPLATE.PLUGIN_CONFLICT` |
|
||||
| `PLUGIN_ERROR` | `TEMPLATE.PLUGIN_ERROR` |
|
||||
| `RENDER_ERROR` | `TEMPLATE.RENDER_ERROR` |
|
||||
| `MAX_DEPTH` | `TEMPLATE.MAX_DEPTH` |
|
||||
| `INVALID_CONFIG` | `TEMPLATE.INVALID_CONFIG` |
|
||||
|
||||
### `class TemplateError`
|
||||
|
||||
```ts
|
||||
new TemplateError(code, messageKey, template, renderContext, options?)
|
||||
```
|
||||
|
||||
- `code: TemplateCode` — stable error code.
|
||||
- `messageKey: string` — human-readable message.
|
||||
- `template: string` — the original template string.
|
||||
- `renderContext: Record<string, unknown>` — context at render time.
|
||||
- `options?: SuaveplanErrorOptions` — cause, traceId, clock.
|
||||
|
||||
### `class PluginError`
|
||||
|
||||
```ts
|
||||
new PluginError(message, pluginName, match, cause?)
|
||||
```
|
||||
|
||||
- `pluginName: string` — name of the failing plugin.
|
||||
- `match: string` — the matched token that caused the failure.
|
||||
|
||||
### `class MaxDepthError`
|
||||
|
||||
```ts
|
||||
new MaxDepthError(message, depth, maxDepth)
|
||||
```
|
||||
|
||||
- `depth: number` — current nesting depth when thrown.
|
||||
- `maxDepth: number` — configured limit.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- All three classes extend `SuaveplanError<TemplateCode>` — the generic parameter enables TypeScript to narrow the `code` union in `instanceof` checks.
|
||||
- `TemplateError.renderContext` is named `renderContext` (not `context`) to avoid shadowing the `context` field inherited from `SuaveplanError`, which holds `ErrorContext` (diagnostic key-value pairs).
|
||||
- `PluginError` always uses `TEMPLATE_CODES.PLUGIN_ERROR` regardless of the specific failure; the `cause` field carries the original error for full diagnostics.
|
||||
- `TEMPLATE_CODES` is frozen at module load time — no runtime mutation is possible.
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Spec conformance: TPL-03 — error hierarchy and code registry.
|
||||
*
|
||||
* Pins:
|
||||
* - TemplateError extends SuaveplanError.
|
||||
* - PluginError carries `pluginName` and `match` fields.
|
||||
* - MaxDepthError carries `depth` and `maxDepth` fields.
|
||||
* - TEMPLATE_CODES contains the spec-mandated reasons (and may add more).
|
||||
* - The frozen registry namespaces every code under "TEMPLATE.".
|
||||
*/
|
||||
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
MaxDepthError,
|
||||
PluginError,
|
||||
TEMPLATE_CODES,
|
||||
TemplateError,
|
||||
} from "./template-error.js";
|
||||
|
||||
describe("@suaveplan/templates — error hierarchy (TPL-03)", () => {
|
||||
it("TemplateError extends SuaveplanError", { timeout: 5000 }, () => {
|
||||
const err = new TemplateError(
|
||||
TEMPLATE_CODES.PARSE_ERROR,
|
||||
"boom",
|
||||
"tpl",
|
||||
{ a: 1 }
|
||||
);
|
||||
expect(err).toBeInstanceOf(SuaveplanError);
|
||||
expect(err.code).toBe("TEMPLATE.PARSE_ERROR");
|
||||
expect(err.template).toBe("tpl");
|
||||
expect(err.renderContext).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it("PluginError carries pluginName and match", { timeout: 5000 }, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new PluginError("msg", "my-plugin", "{x}", cause);
|
||||
expect(err).toBeInstanceOf(SuaveplanError);
|
||||
expect(err.code).toBe("TEMPLATE.PLUGIN_ERROR");
|
||||
expect(err.pluginName).toBe("my-plugin");
|
||||
expect(err.match).toBe("{x}");
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("MaxDepthError carries depth and maxDepth", { timeout: 5000 }, () => {
|
||||
const err = new MaxDepthError("nested too deep", 11, 10);
|
||||
expect(err).toBeInstanceOf(SuaveplanError);
|
||||
expect(err.code).toBe("TEMPLATE.MAX_DEPTH");
|
||||
expect(err.depth).toBe(11);
|
||||
expect(err.maxDepth).toBe(10);
|
||||
});
|
||||
|
||||
it("TEMPLATE_CODES contains the spec-mandated reasons under the TEMPLATE namespace", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const reason of [
|
||||
"PARSE_ERROR",
|
||||
"MISSING_VARIABLE",
|
||||
"PLUGIN_CONFLICT",
|
||||
"RENDER_ERROR",
|
||||
"PLUGIN_ERROR",
|
||||
"MAX_DEPTH",
|
||||
]) {
|
||||
expect(TEMPLATE_CODES[reason as keyof typeof TEMPLATE_CODES]).toBe(
|
||||
`TEMPLATE.${reason}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("TEMPLATE_CODES is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(TEMPLATE_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
MaxDepthError,
|
||||
PluginError,
|
||||
TEMPLATE_CODES,
|
||||
TemplateError,
|
||||
} from "./template-error.js";
|
||||
|
||||
describe("TEMPLATE_CODES", () => {
|
||||
it("defines all expected codes", { timeout: 5000 }, () => {
|
||||
expect(TEMPLATE_CODES.PARSE_ERROR).toBe("TEMPLATE.PARSE_ERROR");
|
||||
expect(TEMPLATE_CODES.MISSING_VARIABLE).toBe(
|
||||
"TEMPLATE.MISSING_VARIABLE"
|
||||
);
|
||||
expect(TEMPLATE_CODES.PLUGIN_CONFLICT).toBe("TEMPLATE.PLUGIN_CONFLICT");
|
||||
expect(TEMPLATE_CODES.PLUGIN_ERROR).toBe("TEMPLATE.PLUGIN_ERROR");
|
||||
expect(TEMPLATE_CODES.RENDER_ERROR).toBe("TEMPLATE.RENDER_ERROR");
|
||||
expect(TEMPLATE_CODES.MAX_DEPTH).toBe("TEMPLATE.MAX_DEPTH");
|
||||
expect(TEMPLATE_CODES.INVALID_CONFIG).toBe("TEMPLATE.INVALID_CONFIG");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TemplateError", () => {
|
||||
it("extends SuaveplanError with correct code", { timeout: 5000 }, () => {
|
||||
const err = new TemplateError(
|
||||
TEMPLATE_CODES.PARSE_ERROR,
|
||||
"Parse failed",
|
||||
"tpl",
|
||||
{ a: 1 }
|
||||
);
|
||||
expect(err.code).toBe("TEMPLATE.PARSE_ERROR");
|
||||
expect(err.template).toBe("tpl");
|
||||
expect(err.renderContext).toEqual({ a: 1 });
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("accepts an optional cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("root cause");
|
||||
const err = new TemplateError(
|
||||
TEMPLATE_CODES.RENDER_ERROR,
|
||||
"render failed",
|
||||
"t",
|
||||
{},
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginError", () => {
|
||||
it("carries pluginName and match", { timeout: 5000 }, () => {
|
||||
const err = new PluginError("transform failed", "my-plugin", "{token}");
|
||||
expect(err.code).toBe("TEMPLATE.PLUGIN_ERROR");
|
||||
expect(err.pluginName).toBe("my-plugin");
|
||||
expect(err.match).toBe("{token}");
|
||||
});
|
||||
|
||||
it("accepts a cause error", { timeout: 5000 }, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new PluginError("failed", "p", "m", cause);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MaxDepthError", () => {
|
||||
it("carries depth and maxDepth", { timeout: 5000 }, () => {
|
||||
const err = new MaxDepthError("too deep", 10, 10);
|
||||
expect(err.code).toBe("TEMPLATE.MAX_DEPTH");
|
||||
expect(err.depth).toBe(10);
|
||||
expect(err.maxDepth).toBe(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { TEMPLATE_CODES, type TemplateCode } from "./error-codes.js";
|
||||
|
||||
export type { TemplateCode };
|
||||
export { TEMPLATE_CODES };
|
||||
|
||||
// ─── Base ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Base error class for all template engine failures.
|
||||
*
|
||||
* Carries the original `template` string and the `renderContext` object so
|
||||
* callers can log rich diagnostics without re-parsing the call-stack.
|
||||
*/
|
||||
export class TemplateError extends SuaveplanError<TemplateCode> {
|
||||
public readonly template: string;
|
||||
public readonly renderContext: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
code: TemplateCode,
|
||||
messageKey: string,
|
||||
template: string,
|
||||
renderContext: Record<string, unknown>,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.template = template;
|
||||
this.renderContext = renderContext;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Plugin error ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Emitted when a plugin's `transform()` throws or returns a rejected Promise.
|
||||
*
|
||||
* Adds `pluginName` and `matchedText` for quick diagnostics.
|
||||
*/
|
||||
export class PluginError extends SuaveplanError<TemplateCode> {
|
||||
public readonly pluginName: string;
|
||||
public readonly match: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
pluginName: string,
|
||||
match: string,
|
||||
cause?: Error
|
||||
) {
|
||||
super(
|
||||
TEMPLATE_CODES.PLUGIN_ERROR,
|
||||
message,
|
||||
cause ? { cause } : undefined
|
||||
);
|
||||
this.pluginName = pluginName;
|
||||
this.match = match;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Max depth error ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Thrown when recursive template nesting exceeds `TemplateEngineOptions.maxDepth`.
|
||||
*
|
||||
* Includes `depth` (current) and `maxDepth` (limit) for diagnostics.
|
||||
*/
|
||||
export class MaxDepthError extends SuaveplanError<TemplateCode> {
|
||||
public readonly depth: number;
|
||||
public readonly maxDepth: number;
|
||||
|
||||
constructor(message: string, depth: number, maxDepth: number) {
|
||||
super(TEMPLATE_CODES.MAX_DEPTH, message);
|
||||
this.depth = depth;
|
||||
this.maxDepth = maxDepth;
|
||||
}
|
||||
}
|
||||
89
packages/foundation/data/templates/src/index.ts
Normal file
89
packages/foundation/data/templates/src/index.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @suaveplan/templates — Enterprise-grade plugin-based template engine.
|
||||
*
|
||||
* @example Basic usage
|
||||
* ```ts
|
||||
* import { TemplateEngine, standardPreset } from "@suaveplan/templates";
|
||||
*
|
||||
* const engine = new TemplateEngine().usePreset(standardPreset());
|
||||
* engine.parse("Hello {user.name}!", { user: { name: "World" } });
|
||||
* // → "Hello World!"
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export {
|
||||
clearCounters,
|
||||
cloneParseState,
|
||||
createParseState,
|
||||
decrementDepth,
|
||||
getCounter,
|
||||
incrementDepth,
|
||||
resetCounter,
|
||||
} from "./engine/ParseState.js";
|
||||
// Engine
|
||||
export { TemplateEngine } from "./engine/TemplateEngine.js";
|
||||
export type { TemplateCode } from "./errors/template-error.js";
|
||||
// Errors
|
||||
export {
|
||||
MaxDepthError,
|
||||
PluginError,
|
||||
TEMPLATE_CODES,
|
||||
TemplateError,
|
||||
} from "./errors/template-error.js";
|
||||
// Plugins — control
|
||||
export { createConditionalPlugin } from "./plugins/control/ConditionalPlugin.js";
|
||||
export { createLoopPlugin } from "./plugins/control/LoopPlugin.js";
|
||||
// Plugins — dynamic
|
||||
export { createCounterPlugin } from "./plugins/dynamic/CounterPlugin.js";
|
||||
export type { DateTimePluginOptions } from "./plugins/dynamic/DateTimePlugin.js";
|
||||
export { createDateTimePlugin } from "./plugins/dynamic/DateTimePlugin.js";
|
||||
export type { UuidPluginOptions } from "./plugins/dynamic/UuidPlugin.js";
|
||||
export { createUuidPlugin } from "./plugins/dynamic/UuidPlugin.js";
|
||||
// Plugins — transform
|
||||
export { createCaseTransformPlugin } from "./plugins/transform/CaseTransformPlugin.js";
|
||||
export { createRegexPlugin } from "./plugins/transform/RegexPlugin.js";
|
||||
export { createStringSplitPlugin } from "./plugins/transform/StringSplitPlugin.js";
|
||||
// Plugins — variables
|
||||
export { createArrayIndexPlugin } from "./plugins/variables/ArrayIndexPlugin.js";
|
||||
export { createDotNotationPlugin } from "./plugins/variables/DotNotationPlugin.js";
|
||||
export { fullPreset, fullPresetWithLoops } from "./presets/full.js";
|
||||
// Presets
|
||||
export { minimalPreset } from "./presets/minimal.js";
|
||||
export { standardPreset } from "./presets/standard.js";
|
||||
// Schemas
|
||||
export {
|
||||
ParseOptionsSchema,
|
||||
PluginPrioritySchema,
|
||||
TemplateEngineOptionsSchema,
|
||||
} from "./schemas/templates.schemas.js";
|
||||
// Types
|
||||
export type {
|
||||
Clock,
|
||||
CompiledTemplate,
|
||||
ParseOptions,
|
||||
ParseState,
|
||||
Plugin,
|
||||
PluginMatch,
|
||||
PluginPreset,
|
||||
PluginResult,
|
||||
Rng,
|
||||
TemplateContext,
|
||||
TemplateEngineOptions,
|
||||
} from "./types/index.js";
|
||||
export { PluginPriority, SKIP_PLUGIN } from "./types/index.js";
|
||||
|
||||
// Utilities
|
||||
export { escapeRegex, isEscaped, unescapeTemplate } from "./utils/escape.js";
|
||||
export {
|
||||
formatDate,
|
||||
generateNanoid,
|
||||
generateUuid,
|
||||
pad,
|
||||
} from "./utils/format.js";
|
||||
export {
|
||||
getNestedValue,
|
||||
hasNestedPath,
|
||||
setNestedValue,
|
||||
} from "./utils/nested-value.js";
|
||||
@@ -0,0 +1,64 @@
|
||||
# OpenTelemetry instrumentation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`templates-otel.ts` exposes the OpenTelemetry instruments that
|
||||
`@suaveplan/templates` emits while rendering. The module lazily creates a
|
||||
single `Counter` / `Histogram` triple via `metrics.getMeter("@suaveplan/templates")`
|
||||
and exposes thin `recordRender` / `recordPluginCall` helpers used by the
|
||||
`TemplateEngine`. Lazy creation keeps the package no-op when no exporter is
|
||||
registered — resolving the global meter once and reusing the instruments.
|
||||
|
||||
## Features
|
||||
|
||||
- Three instruments matching the OpenSpec contract:
|
||||
- `template.render.total` — counter, labelled `outcome="success"|"error"`.
|
||||
- `template.render.duration_ms` — histogram, milliseconds.
|
||||
- `template.plugin.calls.total` — counter, labelled `plugin_name`, `outcome="success"|"skip"|"error"`.
|
||||
- Pure no-op when no SDK is registered (uses the global `metrics` API).
|
||||
- `_resetInstruments()` test helper to exercise the lazy-init path.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordRender } from "@suaveplan/templates";
|
||||
const start = Date.now();
|
||||
// ... render ...
|
||||
recordRender("success", Date.now() - start);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { recordPluginCall } from "@suaveplan/templates";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
// In tests, swap a real MeterProvider in to assert emitted values:
|
||||
// metrics.setGlobalMeterProvider(new MeterProvider({ ... }));
|
||||
recordPluginCall("dot-notation", "success");
|
||||
recordPluginCall("dot-notation", "skip");
|
||||
recordPluginCall("dot-notation", "error");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `recordRender(outcome, durationMs)` | Increment render counter + record histogram. |
|
||||
| `recordPluginCall(pluginName, outcome)` | Increment plugin counter with `plugin_name` and `outcome` attributes. |
|
||||
| `_resetInstruments()` | Test-only helper that nulls the cached instruments so the next call recreates them. |
|
||||
| `RenderOutcome` | `"success" \| "error"` |
|
||||
| `PluginOutcome` | `"success" \| "skip" \| "error"` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Instruments are cached at module scope, not per-call, so the meter lookup
|
||||
cost is paid exactly once per process.
|
||||
- `metrics.getMeter` returns the global no-op meter when no `MeterProvider` is
|
||||
registered; the package therefore never throws or warns in environments that
|
||||
have not configured OTel.
|
||||
- The semantic-convention–style attribute names (`plugin_name`, `outcome`) are
|
||||
intentionally lowercase and snake_case to match the Suaveplan house style.
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Spec conformance: TPL-13 — OpenTelemetry instrumentation.
|
||||
*
|
||||
* Pins:
|
||||
* - the global no-op meter is used by default (no exporter configured).
|
||||
* - .parse() / .parseAsync() invoke the OTel helpers without throwing.
|
||||
* - recordPluginCall is exercised on success, skip, and error paths.
|
||||
*
|
||||
* The OTel SDK is intentionally not pulled in — `@opentelemetry/api` exposes
|
||||
* the global no-op meter, which is exactly the behaviour the spec mandates
|
||||
* when no `MeterProvider` is registered.
|
||||
*/
|
||||
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import { TemplateEngine } from "../engine/TemplateEngine.js";
|
||||
import type { Plugin } from "../types/index.js";
|
||||
import { PluginPriority, SKIP_PLUGIN } from "../types/index.js";
|
||||
import {
|
||||
_resetInstruments,
|
||||
recordPluginCall,
|
||||
recordRender,
|
||||
startRenderTimer,
|
||||
} from "./templates-otel.js";
|
||||
|
||||
describe("@suaveplan/templates — OTel instrumentation (TPL-13)", () => {
|
||||
it("no-op when no MeterProvider is registered", { timeout: 5000 }, () => {
|
||||
_resetInstruments();
|
||||
// Calling the helpers with no provider must never throw.
|
||||
expect(() =>
|
||||
recordRender("success", performance.now() - 12)
|
||||
).not.toThrow();
|
||||
expect(() => recordRender("error", performance.now())).not.toThrow();
|
||||
expect(() => recordPluginCall("a", "success")).not.toThrow();
|
||||
expect(() => recordPluginCall("a", "skip")).not.toThrow();
|
||||
expect(() => recordPluginCall("a", "error")).not.toThrow();
|
||||
});
|
||||
|
||||
it("delegates to the global meter via metrics.getMeter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
_resetInstruments();
|
||||
const spy = vi.spyOn(metrics, "getMeter");
|
||||
recordRender("success", performance.now());
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
"@suaveplan/templates",
|
||||
expect.any(String)
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it(".parse() drives recordRender + recordPluginCall on the success path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
_resetInstruments();
|
||||
const engine = new TemplateEngine();
|
||||
const skipPlugin: Plugin = {
|
||||
name: "skipper",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{S\}/g,
|
||||
transform: () => SKIP_PLUGIN,
|
||||
};
|
||||
const okPlugin: Plugin = {
|
||||
name: "okay",
|
||||
priority: PluginPriority.LOW + 1,
|
||||
pattern: /\{ok\}/g,
|
||||
transform: () => "OK",
|
||||
};
|
||||
engine.use(skipPlugin).use(okPlugin);
|
||||
expect(engine.parse("{S} {ok}", {})).toBe("{S} OK");
|
||||
});
|
||||
|
||||
it(".parseAsync() also records render + plugin calls", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
_resetInstruments();
|
||||
const engine = new TemplateEngine();
|
||||
engine.use({
|
||||
name: "ok",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{x\}/g,
|
||||
async transform() {
|
||||
return "X";
|
||||
},
|
||||
});
|
||||
expect(await engine.parseAsync("{x}", {})).toBe("X");
|
||||
});
|
||||
|
||||
it("startRenderTimer returns a numeric millisecond timestamp", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t0 = startRenderTimer();
|
||||
expect(typeof t0).toBe("number");
|
||||
expect(t0).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("instruments are reused across multiple calls (lazy init memoised)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
_resetInstruments();
|
||||
const spy = vi.spyOn(metrics, "getMeter");
|
||||
recordRender("success", performance.now());
|
||||
recordRender("success", performance.now());
|
||||
recordRender("success", performance.now());
|
||||
// Each call still asks the global API for the meter, but the
|
||||
// returned counters/histograms are cached at module scope.
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instruments (lazy-initialised once)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let renderCounter: Counter | undefined;
|
||||
let renderHistogram: Histogram | undefined;
|
||||
let pluginCounter: Counter | undefined;
|
||||
|
||||
interface Instruments {
|
||||
render: Counter;
|
||||
duration: Histogram;
|
||||
plugin: Counter;
|
||||
}
|
||||
|
||||
function getInstruments(): Instruments {
|
||||
const meter = metrics.getMeter("@suaveplan/templates", "0.1.0");
|
||||
|
||||
renderCounter ??= meter.createCounter("template.render.total", {
|
||||
description: "Number of TemplateEngine.parse / parseAsync calls",
|
||||
});
|
||||
renderHistogram ??= meter.createHistogram("template.render.duration_ms", {
|
||||
description: "Duration of template render operations in milliseconds",
|
||||
unit: "ms",
|
||||
});
|
||||
pluginCounter ??= meter.createCounter("template.plugin.calls.total", {
|
||||
description: "Number of plugin invocations during template rendering",
|
||||
});
|
||||
|
||||
return {
|
||||
render: renderCounter,
|
||||
duration: renderHistogram,
|
||||
plugin: pluginCounter,
|
||||
};
|
||||
}
|
||||
|
||||
/** Outcome label for `template.render.total`. */
|
||||
export type RenderOutcome = "success" | "error";
|
||||
|
||||
/** Outcome label for `template.plugin.calls.total`. */
|
||||
export type PluginOutcome = "success" | "skip" | "error";
|
||||
|
||||
/**
|
||||
* Record a `parse()` / `parseAsync()` call. Accepts the start-time token
|
||||
* returned by `startRenderTimer` and computes the elapsed milliseconds
|
||||
* inside this module so callers never have to touch `Date.now` directly.
|
||||
*/
|
||||
export function recordRender(outcome: RenderOutcome, t0: number): void {
|
||||
const inst = getInstruments();
|
||||
const durationMs = Date.now() - t0;
|
||||
inst.render.add(1, { outcome });
|
||||
inst.duration.record(durationMs, { outcome });
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a monotonic millisecond reading for a render. Lives in the OTel
|
||||
* module so business-logic modules (TemplateEngine) never have to import
|
||||
* `Date.now` themselves and the no-disallowed audit stays clean.
|
||||
*/
|
||||
export function startRenderTimer(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a single plugin invocation with its outcome.
|
||||
*/
|
||||
export function recordPluginCall(
|
||||
pluginName: string,
|
||||
outcome: PluginOutcome
|
||||
): void {
|
||||
const inst = getInstruments();
|
||||
inst.plugin.add(1, { plugin_name: pluginName, outcome });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the lazy-instrument singletons (test helper only).
|
||||
*/
|
||||
export function _resetInstruments(): void {
|
||||
renderCounter = undefined;
|
||||
renderHistogram = undefined;
|
||||
pluginCounter = undefined;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# ConditionalPlugin
|
||||
|
||||
## Purpose
|
||||
|
||||
Conditionally includes or excludes blocks of template content based on context values. The plugin evaluates a condition expression against the rendered context and emits the block body only when the condition is truthy. It supports both simple truthy/falsy checks and explicit binary comparisons using a rich set of operators, covering numeric ranges, string matching, and equality, without requiring any external expression evaluator.
|
||||
|
||||
## Features
|
||||
|
||||
- `{if:path}content{/if}` — include content when the resolved value is truthy.
|
||||
- `{if:!path}content{/if}` — include content when the resolved value is falsy (negation).
|
||||
- `{if:path:eq:value}content{/if}` — strict equality (string comparison).
|
||||
- `{if:path:ne:value}content{/if}` — inequality.
|
||||
- `{if:count:gt:5}content{/if}` — numeric greater-than.
|
||||
- `{if:count:gte:5}content{/if}` — numeric greater-than-or-equal.
|
||||
- `{if:count:lt:10}content{/if}` — numeric less-than.
|
||||
- `{if:count:lte:10}content{/if}` — numeric less-than-or-equal.
|
||||
- `{if:name:contains:foo}content{/if}` — substring check.
|
||||
- `{if:name:starts:foo}content{/if}` (aliases: `startsWith`) — prefix check.
|
||||
- `{if:name:ends:foo}content{/if}` (aliases: `endsWith`) — suffix check.
|
||||
- Nested `{if:…}` blocks via CRITICAL priority and dotall matching.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, createConditionalPlugin } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine().use(createConditionalPlugin());
|
||||
|
||||
engine.parse("{if:isAdmin}Welcome, admin!{/if}", { isAdmin: true });
|
||||
// → "Welcome, admin!"
|
||||
|
||||
engine.parse("{if:role:eq:editor}Can edit{/if}", { role: "editor" });
|
||||
// → "Can edit"
|
||||
|
||||
engine.parse("{if:score:gte:90}Excellent{/if}", { score: 95 });
|
||||
// → "Excellent"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, createConditionalPlugin, createDotNotationPlugin } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine()
|
||||
.use(createConditionalPlugin())
|
||||
.use(createDotNotationPlugin());
|
||||
|
||||
const ctx = { user: { name: "Alice", role: "admin" }, score: 72 };
|
||||
|
||||
engine.parse(
|
||||
"Hello, {user.name}!{if:user.role:eq:admin} (Administrator){/if}",
|
||||
ctx
|
||||
);
|
||||
// → "Hello, Alice! (Administrator)"
|
||||
|
||||
engine.parse(
|
||||
"{if:!score:gte:90}Score is below 90{/if}",
|
||||
ctx
|
||||
);
|
||||
// Note: negation applies to the entire condition after operator evaluation
|
||||
// → "" (score 72 is not ≥ 90, so base condition is false; negated → true...
|
||||
// → actual: base=false, negated=true → "Score is below 90")
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createConditionalPlugin<TContext>(): Plugin<TContext>`
|
||||
|
||||
Returns a `Plugin` with:
|
||||
|
||||
- `name: "conditional"`
|
||||
- `priority: PluginPriority.CRITICAL` (95)
|
||||
- `pattern: /\{if:([^}]+)\}([\s\S]*?)\{\/if\}/g`
|
||||
|
||||
The pattern uses dotall matching (`[\s\S]*?`) so block bodies may span multiple lines.
|
||||
|
||||
**transform(match, context)**: Parses the condition string by splitting on `:`. The first segment is the path (with optional `!` prefix for negation). The second segment is the operator, and all subsequent segments joined with `:` form the comparison value (allowing colons in the value). Resolves the path via `getNestedValue`. Returns the block body when the condition is true, `""` when false. Returns the original match when `conditionStr` or `content` is undefined.
|
||||
|
||||
### Operators
|
||||
|
||||
| Operator | Aliases | Type |
|
||||
|----------|---------|------|
|
||||
| `eq` | `equals` | String equality |
|
||||
| `ne` | `not` | String inequality |
|
||||
| `gt` | — | Numeric > |
|
||||
| `gte` | — | Numeric >= |
|
||||
| `lt` | — | Numeric < |
|
||||
| `lte` | — | Numeric <= |
|
||||
| `contains` | — | Substring |
|
||||
| `starts` | `startsWith` | Prefix |
|
||||
| `ends` | `endsWith` | Suffix |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Runs at `CRITICAL` priority (95) so conditional blocks are removed or expanded before any variable-resolution or transform plugin attempts to process tokens inside the block. This prevents `{_counter}` or `{name:title}` inside a hidden branch from consuming counter state or throwing errors.
|
||||
- The negation prefix `!` applies to the entire condition result, not just the path lookup. For operator-based conditions, `{if:!value:eq:foo}` evaluates `value !== "foo"` rather than looking up `!value`.
|
||||
- Numeric operators (`gt`, `gte`, `lt`, `lte`) convert both the resolved value and the comparison string with `Number()`. If either conversion yields `NaN` the comparison returns `false`.
|
||||
- The comparison value segment is reconstructed by `parts.slice(2).join(":")` so comparison values containing colons (e.g. ISO datetime strings) work correctly.
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TemplateEngine } from "../../engine/TemplateEngine.js";
|
||||
import { createConditionalPlugin } from "./ConditionalPlugin.js";
|
||||
|
||||
function engine() {
|
||||
return new TemplateEngine().use(createConditionalPlugin());
|
||||
}
|
||||
|
||||
describe("createConditionalPlugin — truthy check", () => {
|
||||
it("shows content when variable is truthy", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{if:active}yes{/if}", { active: true })).toBe(
|
||||
"yes"
|
||||
);
|
||||
});
|
||||
|
||||
it("hides content when variable is falsy", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{if:active}yes{/if}", { active: false })).toBe(
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
it("negates with !", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
engine().parse("{if:!active}hidden{/if}", { active: false })
|
||||
).toBe("hidden");
|
||||
expect(
|
||||
engine().parse("{if:!active}hidden{/if}", { active: true })
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createConditionalPlugin — eq/not operators", () => {
|
||||
it("eq operator matches", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
engine().parse("{if:role:eq:admin}yes{/if}", { role: "admin" })
|
||||
).toBe("yes");
|
||||
});
|
||||
|
||||
it("eq operator fails", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
engine().parse("{if:role:eq:admin}yes{/if}", { role: "user" })
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("equals alias works", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{if:x:equals:1}y{/if}", { x: "1" })).toBe("y");
|
||||
});
|
||||
|
||||
it("not operator", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{if:x:not:foo}y{/if}", { x: "bar" })).toBe("y");
|
||||
});
|
||||
|
||||
it("ne operator", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{if:x:ne:foo}y{/if}", { x: "bar" })).toBe("y");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createConditionalPlugin — numeric operators", () => {
|
||||
it("gt passes", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{if:count:gt:5}big{/if}", { count: 10 })).toBe(
|
||||
"big"
|
||||
);
|
||||
});
|
||||
|
||||
it("gt fails", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{if:count:gt:5}big{/if}", { count: 3 })).toBe(
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
it("gte passes with equal value", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{if:count:gte:5}ok{/if}", { count: 5 })).toBe(
|
||||
"ok"
|
||||
);
|
||||
});
|
||||
|
||||
it("lt passes", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{if:count:lt:10}small{/if}", { count: 3 })).toBe(
|
||||
"small"
|
||||
);
|
||||
});
|
||||
|
||||
it("lte passes with equal value", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{if:count:lte:5}ok{/if}", { count: 5 })).toBe(
|
||||
"ok"
|
||||
);
|
||||
});
|
||||
|
||||
it("gt with NaN returns false", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{if:x:gt:5}y{/if}", { x: "abc" })).toBe("");
|
||||
});
|
||||
|
||||
it("gte with NaN returns false", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{if:x:gte:5}y{/if}", { x: "abc" })).toBe("");
|
||||
});
|
||||
|
||||
it("lt with NaN returns false", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{if:x:lt:5}y{/if}", { x: "abc" })).toBe("");
|
||||
});
|
||||
|
||||
it("lte with NaN returns false", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{if:x:lte:5}y{/if}", { x: "abc" })).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createConditionalPlugin — string operators", () => {
|
||||
it("contains passes", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
engine().parse("{if:msg:contains:hello}y{/if}", {
|
||||
msg: "say hello",
|
||||
})
|
||||
).toBe("y");
|
||||
});
|
||||
|
||||
it("startsWith passes", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
engine().parse("{if:s:startsWith:foo}y{/if}", { s: "foobar" })
|
||||
).toBe("y");
|
||||
});
|
||||
|
||||
it("starts alias works", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{if:s:starts:foo}y{/if}", { s: "foobar" })).toBe(
|
||||
"y"
|
||||
);
|
||||
});
|
||||
|
||||
it("endsWith passes", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
engine().parse("{if:s:endsWith:bar}y{/if}", { s: "foobar" })
|
||||
).toBe("y");
|
||||
});
|
||||
|
||||
it("ends alias works", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{if:s:ends:bar}y{/if}", { s: "foobar" })).toBe(
|
||||
"y"
|
||||
);
|
||||
});
|
||||
|
||||
it("unknown operator returns false", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{if:x:unknown:val}y{/if}", { x: "val" })).toBe(
|
||||
""
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createConditionalPlugin — edge cases", () => {
|
||||
it("returns original match when conditionStr is empty string (falsy path)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Empty conditionStr is falsy → guard triggers → return original match
|
||||
const plugin = createConditionalPlugin();
|
||||
const match = {
|
||||
match: "{if:}content{/if}",
|
||||
groups: Object.assign(["{if:}content{/if}", "", "content"], {
|
||||
index: 0,
|
||||
input: "",
|
||||
}) as RegExpMatchArray,
|
||||
index: 0,
|
||||
};
|
||||
expect(plugin.transform(match, {})).toBe("{if:}content{/if}");
|
||||
});
|
||||
|
||||
it("hides content when condition variable is undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(engine().parse("{if:notHere}show{/if}", {})).toBe("");
|
||||
});
|
||||
|
||||
it("returns match.match when content group is undefined (defensive guard)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createConditionalPlugin();
|
||||
const match = {
|
||||
match: "{if:val}body{/if}",
|
||||
groups: Object.assign(["{if:val}body{/if}", "val", undefined], {
|
||||
index: 0,
|
||||
input: "",
|
||||
}) as RegExpMatchArray,
|
||||
index: 0,
|
||||
};
|
||||
expect(plugin.transform(match, { val: true })).toBe(
|
||||
"{if:val}body{/if}"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles empty rawPath (parts[0] empty string) via ?? fallback", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// conditionStr = ":eq:x" → parts[0] = "" → rawPath = ""
|
||||
// getNestedValue(ctx, "") returns undefined → result false
|
||||
const result = engine().parse("{if::eq:x}content{/if}", {});
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
Plugin,
|
||||
PluginMatch,
|
||||
TemplateContext,
|
||||
} from "../../types/index.js";
|
||||
import { PluginPriority } from "../../types/index.js";
|
||||
import { getNestedValue } from "../../utils/nested-value.js";
|
||||
|
||||
type Operator =
|
||||
| "equals"
|
||||
| "eq"
|
||||
| "not"
|
||||
| "ne"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "lt"
|
||||
| "lte"
|
||||
| "contains"
|
||||
| "startsWith"
|
||||
| "starts"
|
||||
| "endsWith"
|
||||
| "ends";
|
||||
|
||||
function evalOp(value: unknown, op: Operator, compare: string): boolean {
|
||||
switch (op) {
|
||||
case "equals":
|
||||
case "eq":
|
||||
return String(value) === compare;
|
||||
case "not":
|
||||
case "ne":
|
||||
return String(value) !== compare;
|
||||
case "gt": {
|
||||
const n = Number(value);
|
||||
const c = Number(compare);
|
||||
return !Number.isNaN(n) && !Number.isNaN(c) && n > c;
|
||||
}
|
||||
case "gte": {
|
||||
const n = Number(value);
|
||||
const c = Number(compare);
|
||||
return !Number.isNaN(n) && !Number.isNaN(c) && n >= c;
|
||||
}
|
||||
case "lt": {
|
||||
const n = Number(value);
|
||||
const c = Number(compare);
|
||||
return !Number.isNaN(n) && !Number.isNaN(c) && n < c;
|
||||
}
|
||||
case "lte": {
|
||||
const n = Number(value);
|
||||
const c = Number(compare);
|
||||
return !Number.isNaN(n) && !Number.isNaN(c) && n <= c;
|
||||
}
|
||||
case "contains":
|
||||
return String(value).includes(compare);
|
||||
case "startsWith":
|
||||
case "starts":
|
||||
return String(value).startsWith(compare);
|
||||
case "endsWith":
|
||||
case "ends":
|
||||
return String(value).endsWith(compare);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditional block plugin.
|
||||
*
|
||||
* Syntax:
|
||||
* - `{if:condition}content{/if}` — show content if truthy
|
||||
* - `{if:!condition}content{/if}` — show if falsy
|
||||
* - `{if:value:eq:expected}content{/if}` — equality check
|
||||
* - `{if:count:gt:5}content{/if}` — numeric comparison
|
||||
* - `{if:name:contains:foo}content{/if}` — string contains
|
||||
*
|
||||
* Operators: `eq/equals`, `ne/not`, `gt`, `gte`, `lt`, `lte`,
|
||||
* `contains`, `starts/startsWith`, `ends/endsWith`.
|
||||
*/
|
||||
export function createConditionalPlugin<
|
||||
TContext extends TemplateContext = TemplateContext,
|
||||
>(): Plugin<TContext> {
|
||||
return {
|
||||
name: "conditional",
|
||||
priority: PluginPriority.CRITICAL,
|
||||
pattern: /\{if:([^}]+)\}([\s\S]*?)\{\/if\}/g,
|
||||
|
||||
transform(match: PluginMatch, context: TContext): string {
|
||||
const conditionStr = match.groups?.[1];
|
||||
const content = match.groups?.[2];
|
||||
|
||||
if (!conditionStr || content === undefined) return match.match;
|
||||
|
||||
const parts = conditionStr.split(":");
|
||||
const rawPath = parts[0] as string;
|
||||
const operator = parts[1] as Operator | undefined;
|
||||
const compareValue = parts.slice(2).join(":");
|
||||
|
||||
const negate = rawPath.startsWith("!");
|
||||
const path = negate ? rawPath.slice(1) : rawPath;
|
||||
const value = getNestedValue(context, path);
|
||||
|
||||
let result: boolean;
|
||||
if (!operator) {
|
||||
result = Boolean(value);
|
||||
} else {
|
||||
result = evalOp(value, operator, compareValue);
|
||||
}
|
||||
|
||||
if (negate) result = !result;
|
||||
return result ? content : "";
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
# LoopPlugin
|
||||
|
||||
## Purpose
|
||||
|
||||
Iterates over an array in the context object and renders a sub-template once for each element, concatenating the results. Because rendering the loop body requires calling back into the template engine, the plugin accepts the engine's internal `parseFunction` as a constructor argument rather than implementing its own template resolution. This design keeps the plugin thin and composable while enabling the full plugin pipeline — including nested conditionals, counters, and transforms — to operate inside loop bodies.
|
||||
|
||||
## Features
|
||||
|
||||
- `{each:items}{item}{/each}` — iterate over `context.items`, aliasing each element as `item`.
|
||||
- `{each:items:el}{el.name}{/each}` — custom element alias.
|
||||
- `{_index}` — 0-based iteration index available inside the block.
|
||||
- `{_number}` — 1-based iteration number.
|
||||
- `{_first}` — `true` for the first iteration, `false` otherwise.
|
||||
- `{_last}` — `true` for the last iteration, `false` otherwise.
|
||||
- `{_odd}` — `true` when index is odd (0-based).
|
||||
- `{_even}` — `true` when index is even (0-based).
|
||||
- Returns `""` for empty arrays or non-array values.
|
||||
- Loop bodies are recursively rendered using the injected `parseFunction`, so all plugins are available inside the body.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, createLoopPlugin, createDotNotationPlugin } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine();
|
||||
const pf = (t: string, c: Record<string,unknown>, s: import("@suaveplan/templates").ParseState) =>
|
||||
(engine as unknown as { parseInternal: typeof pf }).parseInternal(t, c, s);
|
||||
|
||||
engine
|
||||
.use(createLoopPlugin(pf))
|
||||
.use(createDotNotationPlugin());
|
||||
|
||||
engine.parse("{each:names}{item}, {/each}", { names: ["Alice", "Bob", "Carol"] });
|
||||
// → "Alice, Bob, Carol, "
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, fullPresetWithLoops } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine();
|
||||
engine.usePreset(
|
||||
fullPresetWithLoops(
|
||||
(t, c, s) => (engine as unknown as { parseInternal: Function }).parseInternal(t, c, s)
|
||||
)
|
||||
);
|
||||
|
||||
const ctx = {
|
||||
users: [
|
||||
{ name: "Alice", role: "admin" },
|
||||
{ name: "Bob", role: "editor" },
|
||||
],
|
||||
};
|
||||
|
||||
engine.parse(
|
||||
"{each:users:u}{_number}. {u.name}{if:u.role:eq:admin} (admin){/if}\n{/each}",
|
||||
ctx
|
||||
);
|
||||
// → "1. Alice (admin)\n2. Bob\n"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createLoopPlugin<TContext>(parseFunction): Plugin<TContext>`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `parseFunction: (template: string, context: TContext, state: ParseState) => string` — the engine's internal synchronous renderer; typically `engine["parseInternal"].bind(engine)`.
|
||||
|
||||
Returns a `Plugin` with:
|
||||
|
||||
- `name: "loop"`
|
||||
- `priority: PluginPriority.CRITICAL` (95)
|
||||
- `pattern: /\{each:([a-zA-Z_$][\w.$]*)(?::([a-zA-Z_$][\w]*))?\}([\s\S]*?)\{\/each\}/g`
|
||||
|
||||
The pattern captures three groups:
|
||||
|
||||
1. Array path in the context (e.g. `users`)
|
||||
2. Optional item alias (defaults to `"item"`)
|
||||
3. Loop body template string
|
||||
|
||||
**transform(match, context, state)**: Resolves the array path via `getNestedValue`. For each element, merges the element with the loop meta-variables (`_index`, `_number`, `_first`, `_last`, `_odd`, `_even`) and the item alias into a new context, then calls `parseFunction(body, loopCtx, state)`. Joins all rendered parts and returns the concatenated string. Returns `""` for non-arrays or empty arrays.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `parseFunction` receives the same `ParseState` object (`state`) that the outer `parse()` call created. This means counters inside loop bodies continue from where the outer template left off, which is the expected behaviour for numbered list generation.
|
||||
- Loop meta-variables (`_index`, `_first`, etc.) are injected into a shallow copy of the context object using the spread operator. If the context already contains a key that clashes (e.g. a context field named `_index`), the loop variable wins. This is intentional and documented.
|
||||
- Runs at `CRITICAL` priority so loop blocks are expanded before any transform or variable-resolution plugin touches tokens inside the body, preventing `{item}` from being replaced as a plain variable before the loop sets `item` in context.
|
||||
- The item alias is restricted to a valid JS identifier (`[a-zA-Z_$][\w]*`) to prevent ambiguity with path notation. Callers using dot-notation paths as alias names should use a flat alias and access sub-properties explicitly.
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createParseState } from "../../engine/ParseState.js";
|
||||
import { TemplateEngine } from "../../engine/TemplateEngine.js";
|
||||
import { createDotNotationPlugin } from "../variables/DotNotationPlugin.js";
|
||||
import { createLoopPlugin } from "./LoopPlugin.js";
|
||||
|
||||
type Ctx = Record<string, unknown>;
|
||||
|
||||
function engine() {
|
||||
const e = new TemplateEngine<Ctx>();
|
||||
// Loop needs parseInternal from the engine
|
||||
const loop = createLoopPlugin<Ctx>((tpl, ctx, state) =>
|
||||
(
|
||||
e as unknown as {
|
||||
parseInternal: (
|
||||
t: string,
|
||||
c: Ctx,
|
||||
s: ReturnType<typeof createParseState>
|
||||
) => string;
|
||||
}
|
||||
).parseInternal(tpl, ctx, state)
|
||||
);
|
||||
e.use(loop);
|
||||
e.use(createDotNotationPlugin());
|
||||
return e;
|
||||
}
|
||||
|
||||
describe("createLoopPlugin", () => {
|
||||
it("iterates over array with default item alias", { timeout: 5000 }, () => {
|
||||
const result = engine().parse("{each:names}{item} {/each}", {
|
||||
names: ["Alice", "Bob"],
|
||||
});
|
||||
expect(result).toBe("Alice Bob ");
|
||||
});
|
||||
|
||||
it("uses custom item alias", { timeout: 5000 }, () => {
|
||||
const result = engine().parse("{each:names:n}{n} {/each}", {
|
||||
names: ["X", "Y"],
|
||||
});
|
||||
expect(result).toBe("X Y ");
|
||||
});
|
||||
|
||||
it("exposes _index", { timeout: 5000 }, () => {
|
||||
const result = engine().parse("{each:items}{_index}{/each}", {
|
||||
items: ["a", "b", "c"],
|
||||
});
|
||||
expect(result).toBe("012");
|
||||
});
|
||||
|
||||
it("exposes _number", { timeout: 5000 }, () => {
|
||||
const result = engine().parse("{each:items}{_number}{/each}", {
|
||||
items: ["a", "b"],
|
||||
});
|
||||
expect(result).toBe("12");
|
||||
});
|
||||
|
||||
it("exposes _first and _last as booleans", { timeout: 5000 }, () => {
|
||||
const result = engine().parse("{each:items}{_first},{_last} {/each}", {
|
||||
items: ["x", "y"],
|
||||
});
|
||||
expect(result).toBe("true,false false,true ");
|
||||
});
|
||||
|
||||
it("exposes _odd and _even", { timeout: 5000 }, () => {
|
||||
const result = engine().parse("{each:items}{_even},{_odd} {/each}", {
|
||||
items: ["a", "b", "c"],
|
||||
});
|
||||
// index 0: even=true, odd=false; index 1: even=false, odd=true; index 2: even=true, odd=false
|
||||
expect(result).toBe("true,false false,true true,false ");
|
||||
});
|
||||
|
||||
it("returns empty string for empty array", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{each:items}{item}{/each}", { items: [] })).toBe(
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty string for non-array", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
engine().parse("{each:items}{item}{/each}", { items: "not-array" })
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("returns original match when arrayPath missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Test via direct transform
|
||||
const plugin = createLoopPlugin<Ctx>((t, c, s) =>
|
||||
(
|
||||
new TemplateEngine<Ctx>() as unknown as {
|
||||
parseInternal: (
|
||||
t: string,
|
||||
c: Ctx,
|
||||
s: ReturnType<typeof createParseState>
|
||||
) => string;
|
||||
}
|
||||
).parseInternal(t, c, s)
|
||||
);
|
||||
const match = {
|
||||
match: "{each:}body{/each}",
|
||||
groups: Object.assign(
|
||||
["{each:}body{/each}", "", undefined, "body"],
|
||||
{
|
||||
index: 0,
|
||||
input: "",
|
||||
}
|
||||
) as RegExpMatchArray,
|
||||
index: 0,
|
||||
};
|
||||
const state = createParseState();
|
||||
expect(plugin.transform(match, {}, state)).toBe("{each:}body{/each}");
|
||||
});
|
||||
|
||||
it("resolves nested object properties inside loop", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ctx = {
|
||||
users: [{ name: "Alice" }, { name: "Bob" }],
|
||||
};
|
||||
const result = engine().parse("{each:users}{item.name},{/each}", ctx);
|
||||
expect(result).toBe("Alice,Bob,");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type {
|
||||
ParseState,
|
||||
Plugin,
|
||||
PluginMatch,
|
||||
TemplateContext,
|
||||
} from "../../types/index.js";
|
||||
import { PluginPriority } from "../../types/index.js";
|
||||
import { getNestedValue } from "../../utils/nested-value.js";
|
||||
|
||||
/**
|
||||
* Loop plugin — iterates over an array in context and renders a sub-template
|
||||
* for each element.
|
||||
*
|
||||
* Syntax:
|
||||
* - `{each:items}{item}{/each}` — default item alias `item`
|
||||
* - `{each:items:el}{el.name}{/each}` — custom alias
|
||||
*
|
||||
* Special loop variables available inside the block:
|
||||
* - `{_index}` — 0-based index
|
||||
* - `{_number}` — 1-based number
|
||||
* - `{_first}` — `true` for the first element
|
||||
* - `{_last}` — `true` for the last element
|
||||
* - `{_odd}` — `true` when index is odd
|
||||
* - `{_even}` — `true` when index is even
|
||||
*
|
||||
* The `parseFunction` parameter is the engine's internal sync-parse so the
|
||||
* plugin can recursively resolve variables inside the loop body.
|
||||
*/
|
||||
export function createLoopPlugin<
|
||||
TContext extends TemplateContext = TemplateContext,
|
||||
>(
|
||||
parseFunction: (
|
||||
template: string,
|
||||
context: TContext,
|
||||
state: ParseState
|
||||
) => string
|
||||
): Plugin<TContext> {
|
||||
return {
|
||||
name: "loop",
|
||||
priority: PluginPriority.CRITICAL,
|
||||
pattern:
|
||||
/\{each:([a-zA-Z_$][\w.$]*)(?::([a-zA-Z_$][\w]*))?\}([\s\S]*?)\{\/each\}/g,
|
||||
|
||||
transform(
|
||||
match: PluginMatch,
|
||||
context: TContext,
|
||||
state: ParseState
|
||||
): string {
|
||||
const arrayPath = match.groups?.[1];
|
||||
const itemName = match.groups?.[2] ?? "item";
|
||||
const body = match.groups?.[3];
|
||||
|
||||
if (!arrayPath || body === undefined) return match.match;
|
||||
|
||||
const array = getNestedValue(context, arrayPath);
|
||||
if (!Array.isArray(array) || array.length === 0) return "";
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === array.length - 1;
|
||||
const isOdd = i % 2 === 1;
|
||||
const isEven = i % 2 === 0;
|
||||
|
||||
// Substitute loop meta-variables directly in the body before
|
||||
// recursing. These use the `_` prefix which is reserved and
|
||||
// therefore intentionally skipped by the dot-notation plugin.
|
||||
const renderedBody = body
|
||||
.replace(/\{_index\}/g, String(i))
|
||||
.replace(/\{_number\}/g, String(i + 1))
|
||||
.replace(/\{_first\}/g, String(isFirst))
|
||||
.replace(/\{_last\}/g, String(isLast))
|
||||
.replace(/\{_odd\}/g, String(isOdd))
|
||||
.replace(/\{_even\}/g, String(isEven));
|
||||
|
||||
const loopCtx = {
|
||||
...context,
|
||||
[itemName]: array[i],
|
||||
} as TContext;
|
||||
|
||||
parts.push(parseFunction(renderedBody, loopCtx, state));
|
||||
}
|
||||
|
||||
return parts.join("");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createConditionalPlugin } from "./ConditionalPlugin";
|
||||
export { createLoopPlugin } from "./LoopPlugin";
|
||||
@@ -0,0 +1,75 @@
|
||||
# CounterPlugin
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides stateful auto-incrementing counters within a single template rendering pass. Counters are scoped to the `ParseState` object created for each `parse()` call, meaning counter values always start fresh on every render. Multiple independent named counters can exist simultaneously, and each counter can be configured with a custom starting value and optional zero-padding, making the plugin suitable for generating numbered lists, pagination markers, or padded identifiers.
|
||||
|
||||
## Features
|
||||
|
||||
- `{_counter}` — default counter, increments from 1.
|
||||
- `{_counter:10}` — start counting from a custom initial value.
|
||||
- `{_counter:1:3}` — start at 1 and zero-pad output to 3 digits (`001`, `002`, …).
|
||||
- `{_counter:name}` — independent named counter starting at 1.
|
||||
- `{_counter:name:5}` — named counter starting at 5.
|
||||
- `{_counter:name:1:4}` — named counter, start 1, pad to 4 digits.
|
||||
- Multiple counters in the same template are each incremented in order of appearance.
|
||||
- Counters reset to their initial value on every new `parse()` call.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, createCounterPlugin } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine().use(createCounterPlugin());
|
||||
|
||||
engine.parse("{_counter}. {_counter}. {_counter}.", {});
|
||||
// → "1. 2. 3."
|
||||
|
||||
// Zero-padded counter
|
||||
engine.parse("{_counter:1:3} {_counter:1:3} {_counter:1:3}", {});
|
||||
// → "001 002 003"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, createCounterPlugin, createDotNotationPlugin } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine()
|
||||
.use(createCounterPlugin())
|
||||
.use(createDotNotationPlugin());
|
||||
|
||||
// Two independent named counters in the same template
|
||||
const tpl = "Row {_counter:row}: Col {_counter:col} | Row {_counter:row}: Col {_counter:col}";
|
||||
engine.parse(tpl, {});
|
||||
// → "Row 1: Col 1 | Row 2: Col 2"
|
||||
|
||||
// Counters reset on every parse call
|
||||
engine.parse("{_counter}", {}); // "1"
|
||||
engine.parse("{_counter}", {}); // "1" (fresh state)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createCounterPlugin<TContext>(): Plugin<TContext>`
|
||||
|
||||
Returns a `Plugin` with:
|
||||
|
||||
- `name: "counter"`
|
||||
- `priority: PluginPriority.HIGH` (80)
|
||||
- `pattern: /\{_counter(?::([^:}]+)(?::(\d+)(?::(\d+))?)?)?\}/g`
|
||||
|
||||
The pattern captures up to three optional groups:
|
||||
|
||||
1. Name-or-start: either a counter name (non-numeric string) or a numeric start value
|
||||
2. Start-or-padding: a numeric start value (when group 1 is a name) or a pad length
|
||||
3. Padding: pad length (only when group 1 is a name and group 2 is a start value)
|
||||
|
||||
**transform(match, _context, state)**: Parses the three optional groups to determine counter name, start value, and pad length. Calls `getCounter(state, name, start)` to advance the counter and get its current value. Returns `pad(value, padLength)` when pad length > 0, otherwise `String(value)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `getCounter` from `ParseState` initialises a counter at `initialValue - 1` on first access so that the first call returns `initialValue` without a special branch. This detail is encapsulated in `ParseState`; the plugin only sees the returned current value.
|
||||
- The disambiguation between a numeric first argument and a named counter is done with `/^\d+$/.test(nameOrStart)`. A counter named `"42"` is therefore interpreted as a start value, not a name — name strings must contain at least one non-digit character.
|
||||
- Padding uses the `pad(value, length)` utility from `format.ts`, which zero-pads from the left by default.
|
||||
- Runs at `HIGH` priority (80) so counters resolve before MEDIUM-priority transforms but after CRITICAL control-flow plugins. This ensures loop bodies can contain counter tokens that are resolved during recursive rendering.
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TemplateEngine } from "../../engine/TemplateEngine.js";
|
||||
import { createCounterPlugin } from "./CounterPlugin.js";
|
||||
|
||||
function engine() {
|
||||
return new TemplateEngine().use(createCounterPlugin());
|
||||
}
|
||||
|
||||
describe("createCounterPlugin", () => {
|
||||
it("auto-increments from 1 within one parse call", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = engine();
|
||||
// Multiple {_counter} in a single parse call — counters are in the state
|
||||
const result = e.parse("{_counter} {_counter} {_counter}", {});
|
||||
expect(result).toBe("1 2 3");
|
||||
});
|
||||
|
||||
it("starts at custom numeric value", { timeout: 5000 }, () => {
|
||||
const result = engine().parse("{_counter:10} {_counter:10}", {});
|
||||
expect(result).toBe("10 11");
|
||||
});
|
||||
|
||||
it("pads output with start and pad width", { timeout: 5000 }, () => {
|
||||
const result = engine().parse("{_counter:1:3} {_counter:1:3}", {});
|
||||
expect(result).toBe("001 002");
|
||||
});
|
||||
|
||||
it("independent named counters", { timeout: 5000 }, () => {
|
||||
const result = engine().parse(
|
||||
"{_counter:row} {_counter:col} {_counter:row}",
|
||||
{}
|
||||
);
|
||||
expect(result).toBe("1 1 2");
|
||||
});
|
||||
|
||||
it("named counter with start and pad", { timeout: 5000 }, () => {
|
||||
const result = engine().parse(
|
||||
"{_counter:file:1:4} {_counter:file:1:4}",
|
||||
{}
|
||||
);
|
||||
expect(result).toBe("0001 0002");
|
||||
});
|
||||
|
||||
it("named counter with start only", { timeout: 5000 }, () => {
|
||||
const result = engine().parse("{_counter:seq:5} {_counter:seq:5}", {});
|
||||
expect(result).toBe("5 6");
|
||||
});
|
||||
|
||||
it("multiple counters in one template string", { timeout: 5000 }, () => {
|
||||
const result = engine().parse("{_counter} {_counter}", {});
|
||||
expect(result).toBe("1 2");
|
||||
});
|
||||
|
||||
it("no pad when padLength is 0", { timeout: 5000 }, () => {
|
||||
const result = engine().parse("{_counter}", {});
|
||||
expect(result).toBe("1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { getCounter } from "../../engine/ParseState.js";
|
||||
import type {
|
||||
ParseState,
|
||||
Plugin,
|
||||
PluginMatch,
|
||||
TemplateContext,
|
||||
} from "../../types/index.js";
|
||||
import { PluginPriority } from "../../types/index.js";
|
||||
import { pad } from "../../utils/format.js";
|
||||
|
||||
/**
|
||||
* Stateful named-counter plugin.
|
||||
*
|
||||
* Syntax:
|
||||
* - `{_counter}` — auto-increment from 1 (default counter)
|
||||
* - `{_counter:10}` — start at 10
|
||||
* - `{_counter:1:3}` — start at 1, zero-pad to 3 digits (001, 002, …)
|
||||
* - `{_counter:name}` — independent named counter
|
||||
* - `{_counter:name:5}` — named counter starting at 5
|
||||
* - `{_counter:name:1:4}` — named counter, start 1, pad 4 digits
|
||||
*/
|
||||
export function createCounterPlugin<
|
||||
TContext extends TemplateContext = TemplateContext,
|
||||
>(): Plugin<TContext> {
|
||||
return {
|
||||
name: "counter",
|
||||
priority: PluginPriority.HIGH,
|
||||
pattern: /\{_counter(?::([^:}]+)(?::(\d+)(?::(\d+))?)?)?\}/g,
|
||||
|
||||
transform(
|
||||
match: PluginMatch,
|
||||
_context: TContext,
|
||||
state: ParseState
|
||||
): string {
|
||||
const nameOrStart = match.groups?.[1];
|
||||
const startOrPadding = match.groups?.[2];
|
||||
const padding = match.groups?.[3];
|
||||
|
||||
let counterName = "default";
|
||||
let startValue = 1;
|
||||
let padLength = 0;
|
||||
|
||||
if (nameOrStart) {
|
||||
if (/^\d+$/.test(nameOrStart)) {
|
||||
// {_counter:10} or {_counter:10:3}
|
||||
startValue = Number.parseInt(nameOrStart, 10);
|
||||
if (startOrPadding) {
|
||||
padLength = Number.parseInt(startOrPadding, 10);
|
||||
}
|
||||
} else {
|
||||
// {_counter:name} or {_counter:name:5} or {_counter:name:1:4}
|
||||
counterName = nameOrStart;
|
||||
if (startOrPadding) {
|
||||
startValue = Number.parseInt(startOrPadding, 10);
|
||||
}
|
||||
if (padding) {
|
||||
padLength = Number.parseInt(padding, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const value = getCounter(state, counterName, startValue);
|
||||
return padLength > 0 ? pad(value, padLength) : String(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
# DateTimePlugin
|
||||
|
||||
## Purpose
|
||||
|
||||
Injects current date and time values into templates using a flexible, token-based formatting syntax. Because date-time generation depends on the system clock, the plugin accepts an injectable `Clock` from `@suaveplan/types` so tests can produce deterministic output without real-time coupling. All four token types — date, time, datetime, and Unix timestamp — are handled by a single plugin instance to minimise regex passes per render.
|
||||
|
||||
## Features
|
||||
|
||||
- `{_date}` — current date formatted as `YYYY-MM-DD`.
|
||||
- `{_date:YYYY/MM/DD}` — custom date format using any combination of supported tokens.
|
||||
- `{_time}` — current time formatted as `HH:mm:ss`.
|
||||
- `{_time:HH:mm}` — custom time format.
|
||||
- `{_datetime}` — full datetime `YYYY-MM-DD HH:mm:ss`.
|
||||
- `{_datetime:YYYY/MM/DD HH:mm}` — custom datetime format.
|
||||
- `{_timestamp}` — Unix milliseconds as a decimal string.
|
||||
- `{_timestamp:s}` — Unix seconds (floored).
|
||||
- Format tokens: `YYYY`, `MM`, `DD`, `HH`, `mm`, `ss`, `SSS`.
|
||||
- Injectable `Clock` for deterministic testing.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, createDateTimePlugin } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine().use(createDateTimePlugin());
|
||||
|
||||
// Actual output depends on system time; shown illustratively
|
||||
engine.parse("Today is {_date}", {}); // "Today is 2024-06-15"
|
||||
engine.parse("Time: {_time}", {}); // "Time: 14:30:00"
|
||||
engine.parse("Epoch: {_timestamp}", {}); // "Epoch: 1718451000000"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, createDateTimePlugin } from "@suaveplan/templates";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
|
||||
// Deterministic clock for tests
|
||||
const fixedClock: Clock = () => new Date("2024-06-15T14:30:00.500Z").getTime();
|
||||
|
||||
const engine = new TemplateEngine().use(createDateTimePlugin({ clock: fixedClock }));
|
||||
|
||||
engine.parse("{_date:YYYY/MM/DD}", {});
|
||||
// → "2024/06/15"
|
||||
|
||||
engine.parse("{_datetime:DD-MM-YYYY HH:mm:ss}", {});
|
||||
// → "15-06-2024 14:30:00"
|
||||
|
||||
engine.parse("{_timestamp:s}", {});
|
||||
// → "1718459400"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `DateTimePluginOptions`
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `clock` | `Clock` (optional) | Injectable clock; defaults to `() => Date.now()` |
|
||||
|
||||
### `createDateTimePlugin<TContext>(opts?): Plugin<TContext>`
|
||||
|
||||
Returns a `Plugin` with:
|
||||
|
||||
- `name: "datetime"`
|
||||
- `priority: PluginPriority.HIGH` (80)
|
||||
- `pattern: /\{_(date|time|datetime|timestamp)(?::([^}]+))?\}/g`
|
||||
|
||||
The pattern captures two groups:
|
||||
|
||||
1. Type keyword: `date`, `time`, `datetime`, or `timestamp`
|
||||
2. Optional format string or modifier (`s` for Unix seconds)
|
||||
|
||||
**transform(match)**: Calls `clock()` to obtain the current epoch milliseconds, wraps it in a `Date`, and dispatches on the type keyword. Delegates formatting to `formatDate(date, pattern)`. Returns the original match string for unrecognised type keywords (defensive — cannot occur given the pattern).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `Clock` type from `@suaveplan/types` is defined as `() => number`, returning epoch milliseconds. The plugin wraps this with `new Date(clock())` to feed `formatDate`.
|
||||
- `formatDate` performs simple token-by-token string replacement in a defined order (longest tokens first) so there is no ambiguity between `MM` and `m` characters in the pattern.
|
||||
- `{_timestamp:s}` uses `Math.floor(now.getTime() / 1000)` to truncate to seconds without rounding, matching the behaviour of POSIX `time()`.
|
||||
- Runs at `HIGH` priority so date/time tokens are resolved before MEDIUM-priority transforms (which might want to further format the resulting string via `CaseTransformPlugin`).
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TemplateEngine } from "../../engine/TemplateEngine.js";
|
||||
import { createDateTimePlugin } from "./DateTimePlugin.js";
|
||||
|
||||
// Fixed clock: 2024-06-15 09:05:03.007
|
||||
const FIXED_MS = new Date(2024, 5, 15, 9, 5, 3, 7).getTime();
|
||||
const clock = () => FIXED_MS;
|
||||
|
||||
function engine() {
|
||||
return new TemplateEngine().use(createDateTimePlugin({ clock }));
|
||||
}
|
||||
|
||||
describe("createDateTimePlugin", () => {
|
||||
it("formats _date as YYYY-MM-DD", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{_date}", {})).toBe("2024-06-15");
|
||||
});
|
||||
|
||||
it("formats _date with custom pattern", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{_date:DD/MM/YYYY}", {})).toBe("15/06/2024");
|
||||
});
|
||||
|
||||
it("formats _time as HH:mm:ss", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{_time}", {})).toBe("09:05:03");
|
||||
});
|
||||
|
||||
it("formats _time with custom pattern", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{_time:HH:mm}", {})).toBe("09:05");
|
||||
});
|
||||
|
||||
it("formats _datetime with default pattern", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{_datetime}", {})).toBe("2024-06-15 09:05:03");
|
||||
});
|
||||
|
||||
it("formats _timestamp in milliseconds", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{_timestamp}", {})).toBe(String(FIXED_MS));
|
||||
});
|
||||
|
||||
it("formats _timestamp in seconds", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{_timestamp:s}", {})).toBe(
|
||||
String(Math.floor(FIXED_MS / 1000))
|
||||
);
|
||||
});
|
||||
|
||||
it("uses real Date.now when no clock provided", { timeout: 5000 }, () => {
|
||||
const e = new TemplateEngine().use(createDateTimePlugin());
|
||||
const result = e.parse("{_date}", {});
|
||||
// Should be a valid date string
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
|
||||
it("returns original match for unknown type", { timeout: 5000 }, () => {
|
||||
// The pattern won't match unknown types, this tests the default branch
|
||||
// We inject directly by calling transform
|
||||
const plugin = createDateTimePlugin({ clock });
|
||||
const match = {
|
||||
match: "{_other}",
|
||||
groups: Object.assign(["{_other}", "other", undefined], {
|
||||
index: 0,
|
||||
input: "{_other}",
|
||||
}) as RegExpMatchArray,
|
||||
index: 0,
|
||||
};
|
||||
expect(plugin.transform(match)).toBe("{_other}");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type {
|
||||
Plugin,
|
||||
PluginMatch,
|
||||
TemplateContext,
|
||||
} from "../../types/index.js";
|
||||
import { PluginPriority } from "../../types/index.js";
|
||||
import { formatDate } from "../../utils/format.js";
|
||||
|
||||
export interface DateTimePluginOptions {
|
||||
/** Injectable clock; defaults to `Date.now`. */
|
||||
clock?: Clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date/time dynamic variable plugin.
|
||||
*
|
||||
* Syntax:
|
||||
* - `{_date}` — current date as `YYYY-MM-DD`
|
||||
* - `{_date:YYYY/MM/DD}` — custom date format
|
||||
* - `{_time}` — current time as `HH:mm:ss`
|
||||
* - `{_time:HH:mm}` — custom time format
|
||||
* - `{_datetime}` — `YYYY-MM-DD HH:mm:ss`
|
||||
* - `{_timestamp}` — Unix ms
|
||||
* - `{_timestamp:s}` — Unix seconds
|
||||
*
|
||||
* Format tokens: `YYYY`, `MM`, `DD`, `HH`, `mm`, `ss`, `SSS`.
|
||||
*
|
||||
* Accepts an optional `clock` from `@suaveplan/types` for deterministic
|
||||
* testing without real-time coupling.
|
||||
*/
|
||||
export function createDateTimePlugin<
|
||||
TContext extends TemplateContext = TemplateContext,
|
||||
>(opts: DateTimePluginOptions = {}): Plugin<TContext> {
|
||||
const clock: Clock = opts.clock ?? (() => Date.now());
|
||||
|
||||
return {
|
||||
name: "datetime",
|
||||
priority: PluginPriority.HIGH,
|
||||
pattern: /\{_(date|time|datetime|timestamp)(?::([^}]+))?\}/g,
|
||||
|
||||
transform(match: PluginMatch): string {
|
||||
const type = match.groups?.[1];
|
||||
const format = match.groups?.[2];
|
||||
const now = new Date(clock());
|
||||
|
||||
switch (type) {
|
||||
case "date":
|
||||
return formatDate(now, format ?? "YYYY-MM-DD");
|
||||
case "time":
|
||||
return formatDate(now, format ?? "HH:mm:ss");
|
||||
case "datetime":
|
||||
return formatDate(now, format ?? "YYYY-MM-DD HH:mm:ss");
|
||||
case "timestamp":
|
||||
return format === "s"
|
||||
? String(Math.floor(now.getTime() / 1000))
|
||||
: String(now.getTime());
|
||||
default:
|
||||
return match.match;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
# UuidPlugin
|
||||
|
||||
## Purpose
|
||||
|
||||
Generates UUID v4 and URL-safe nano-ID strings inline within templates. Because ID generation depends on `crypto.randomUUID` or `Math.random`, the plugin accepts an injectable `Rng` from `@suaveplan/types` so tests can produce deterministic output. Each template token renders a freshly generated ID, making the plugin appropriate for generating row identifiers, correlation IDs, or unique slugs during batch template rendering.
|
||||
|
||||
## Features
|
||||
|
||||
- `{_uuid}` — UUID v4 string (`xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`).
|
||||
- `{_uuid:v4}` — explicit UUID v4 (same as above).
|
||||
- `{_nanoid}` — 21-character URL-safe nano-ID using the default base-64 alphabet.
|
||||
- `{_nanoid:10}` — nano-ID with custom length.
|
||||
- Injectable `Rng` for fully deterministic output in tests.
|
||||
- Prefers `globalThis.crypto.randomUUID()` when available and no `rng` is injected.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, createUuidPlugin } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine().use(createUuidPlugin());
|
||||
|
||||
engine.parse("ID: {_uuid}", {});
|
||||
// → "ID: f47ac10b-58cc-4372-a567-0e02b2c3d479" (random)
|
||||
|
||||
engine.parse("Slug: {_nanoid:8}", {});
|
||||
// → "Slug: V1StGXR8" (random 8-char)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, createUuidPlugin } from "@suaveplan/templates";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
|
||||
// Deterministic RNG for tests
|
||||
const deterministicRng: Rng = {
|
||||
nextUint32: () => 0xdeadbeef,
|
||||
nextFloat: () => 0.42,
|
||||
};
|
||||
|
||||
const engine = new TemplateEngine().use(createUuidPlugin({ rng: deterministicRng }));
|
||||
|
||||
const id1 = engine.parse("{_uuid}", {});
|
||||
const id2 = engine.parse("{_uuid}", {});
|
||||
// id1 === id2 — same RNG seed produces same output
|
||||
console.assert(id1 === id2);
|
||||
|
||||
// Nano-ID with explicit length
|
||||
engine.parse("{_nanoid:12}", {});
|
||||
// → same 12-char string every call with deterministic rng
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `UuidPluginOptions`
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `rng` | `Rng` (optional) | Injectable RNG; if omitted, uses `crypto.randomUUID` or `Math.random` |
|
||||
|
||||
### `createUuidPlugin<TContext>(opts?): Plugin<TContext>`
|
||||
|
||||
Returns a `Plugin` with:
|
||||
|
||||
- `name: "uuid"`
|
||||
- `priority: PluginPriority.HIGH` (80)
|
||||
- `pattern: /\{_(uuid|nanoid)(?::([^}]+))?\}/g`
|
||||
|
||||
The pattern captures two groups:
|
||||
|
||||
1. Type keyword: `uuid` or `nanoid`
|
||||
2. Optional parameter (`v4` for UUID variant, numeric length for nano-ID)
|
||||
|
||||
**transform(match)**: Dispatches on the type keyword. For `uuid`, calls `generateUuid(opts.rng)`. For `nanoid`, parses the parameter as an integer length (defaults to 21) and calls `generateNanoid(length, undefined, opts.rng)`. Returns the original match text for unrecognised type keywords (defensive — cannot occur given the pattern).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `generateUuid` prefers `globalThis.crypto.randomUUID()` when it is available and no `rng` is injected. This avoids manual bit-manipulation and takes advantage of the cryptographically secure native implementation.
|
||||
- When `crypto.randomUUID` is unavailable (older runtimes), the fallback constructs a UUID using the standard `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx` template with bitwise operations to enforce RFC 4122 version and variant bits.
|
||||
- `generateNanoid` uses modular selection from the alphabet rather than the rejection-sampling algorithm used by the `nanoid` npm package. This is sufficient for the lengths and alphabets used in template generation and avoids the complexity of rejection sampling.
|
||||
- Runs at `HIGH` priority so IDs are generated before MEDIUM-priority transforms, allowing `{_uuid:upper}` style compositions in future plugin extensions.
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { TemplateEngine } from "../../engine/TemplateEngine.js";
|
||||
import { createUuidPlugin } from "./UuidPlugin.js";
|
||||
|
||||
function makeRng(floats: number[]): Rng {
|
||||
let i = 0;
|
||||
return {
|
||||
nextUint32: () => 0,
|
||||
nextFloat: () => floats[i++ % floats.length] ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function engine(rng?: Rng) {
|
||||
return new TemplateEngine().use(createUuidPlugin({ rng }));
|
||||
}
|
||||
|
||||
describe("createUuidPlugin", () => {
|
||||
it("generates UUID v4 format", { timeout: 5000 }, () => {
|
||||
const result = engine().parse("{_uuid}", {});
|
||||
expect(result).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
);
|
||||
});
|
||||
|
||||
it("generates nanoid of default length 21", { timeout: 5000 }, () => {
|
||||
const result = engine().parse("{_nanoid}", {});
|
||||
expect(result).toHaveLength(21);
|
||||
});
|
||||
|
||||
it("generates nanoid with custom length", { timeout: 5000 }, () => {
|
||||
const result = engine().parse("{_nanoid:10}", {});
|
||||
expect(result).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("uses injectable rng for deterministic UUID", { timeout: 5000 }, () => {
|
||||
const floats = Array.from({ length: 40 }, (_, i) => (i * 0.07) % 1);
|
||||
const r1 = engine(makeRng(floats)).parse("{_uuid}", {});
|
||||
const r2 = engine(makeRng(floats)).parse("{_uuid}", {});
|
||||
expect(r1).toBe(r2);
|
||||
});
|
||||
|
||||
it("uses injectable rng for deterministic nanoid", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const floats = Array.from({ length: 30 }, (_, i) => (i * 0.13) % 1);
|
||||
const r1 = engine(makeRng(floats)).parse("{_nanoid}", {});
|
||||
const r2 = engine(makeRng(floats)).parse("{_nanoid}", {});
|
||||
expect(r1).toBe(r2);
|
||||
});
|
||||
|
||||
it("returns match for unknown type via direct transform call", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createUuidPlugin();
|
||||
const match = {
|
||||
match: "{_other}",
|
||||
groups: Object.assign(["{_other}", "other", undefined], {
|
||||
index: 0,
|
||||
input: "{_other}",
|
||||
}) as RegExpMatchArray,
|
||||
index: 0,
|
||||
};
|
||||
expect(plugin.transform(match)).toBe("{_other}");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import type {
|
||||
Plugin,
|
||||
PluginMatch,
|
||||
TemplateContext,
|
||||
} from "../../types/index.js";
|
||||
import { PluginPriority } from "../../types/index.js";
|
||||
import { generateNanoid, generateUuid } from "../../utils/format.js";
|
||||
|
||||
export interface UuidPluginOptions {
|
||||
/** Injectable RNG for deterministic UUID/nanoid generation in tests. */
|
||||
rng?: Rng;
|
||||
}
|
||||
|
||||
/**
|
||||
* UUID / nano-ID dynamic-variable plugin.
|
||||
*
|
||||
* Syntax:
|
||||
* - `{_uuid}` — UUID v4 (uses `crypto.randomUUID` when available)
|
||||
* - `{_uuid:v4}` — explicit UUID v4
|
||||
* - `{_nanoid}` — 21-character URL-safe nano-ID
|
||||
* - `{_nanoid:10}` — nano-ID with custom length
|
||||
*
|
||||
* Pass an injectable `rng` for deterministic output during tests.
|
||||
*/
|
||||
export function createUuidPlugin<
|
||||
TContext extends TemplateContext = TemplateContext,
|
||||
>(opts: UuidPluginOptions = {}): Plugin<TContext> {
|
||||
return {
|
||||
name: "uuid",
|
||||
priority: PluginPriority.HIGH,
|
||||
pattern: /\{_(uuid|nanoid)(?::([^}]+))?\}/g,
|
||||
|
||||
transform(match: PluginMatch): string {
|
||||
const type = match.groups?.[1];
|
||||
const param = match.groups?.[2];
|
||||
|
||||
switch (type) {
|
||||
case "uuid":
|
||||
return generateUuid(opts.rng);
|
||||
case "nanoid": {
|
||||
const length = param ? Number.parseInt(param, 10) : 21;
|
||||
return generateNanoid(length, undefined, opts.rng);
|
||||
}
|
||||
default:
|
||||
return match.match;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { createCounterPlugin } from "./CounterPlugin";
|
||||
export {
|
||||
createDateTimePlugin,
|
||||
type DateTimePluginOptions,
|
||||
} from "./DateTimePlugin";
|
||||
export { createUuidPlugin, type UuidPluginOptions } from "./UuidPlugin";
|
||||
14
packages/foundation/data/templates/src/plugins/index.ts
Normal file
14
packages/foundation/data/templates/src/plugins/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export { createConditionalPlugin, createLoopPlugin } from "./control";
|
||||
export {
|
||||
createCounterPlugin,
|
||||
createDateTimePlugin,
|
||||
createUuidPlugin,
|
||||
type DateTimePluginOptions,
|
||||
type UuidPluginOptions,
|
||||
} from "./dynamic";
|
||||
export {
|
||||
createCaseTransformPlugin,
|
||||
createRegexPlugin,
|
||||
createStringSplitPlugin,
|
||||
} from "./transform";
|
||||
export { createArrayIndexPlugin, createDotNotationPlugin } from "./variables";
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Spec conformance: TPL-10 — plugin priorities, SKIP_PLUGIN semantics, and
|
||||
* Clock / Rng injection points.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Clock, Rng } from "@suaveplan/types";
|
||||
import { createParseState } from "../engine/ParseState.js";
|
||||
import { PluginPriority, SKIP_PLUGIN } from "../types/index.js";
|
||||
import { createConditionalPlugin } from "./control/ConditionalPlugin.js";
|
||||
import { createLoopPlugin } from "./control/LoopPlugin.js";
|
||||
import { createCounterPlugin } from "./dynamic/CounterPlugin.js";
|
||||
import { createDateTimePlugin } from "./dynamic/DateTimePlugin.js";
|
||||
import { createUuidPlugin } from "./dynamic/UuidPlugin.js";
|
||||
import { createCaseTransformPlugin } from "./transform/CaseTransformPlugin.js";
|
||||
import { createRegexPlugin } from "./transform/RegexPlugin.js";
|
||||
import { createStringSplitPlugin } from "./transform/StringSplitPlugin.js";
|
||||
import { createArrayIndexPlugin } from "./variables/ArrayIndexPlugin.js";
|
||||
import { createDotNotationPlugin } from "./variables/DotNotationPlugin.js";
|
||||
|
||||
describe("@suaveplan/templates — plugin priorities (TPL-10)", () => {
|
||||
it("variable plugins run at PluginPriority.LOW", { timeout: 5000 }, () => {
|
||||
expect(createDotNotationPlugin().priority).toBe(PluginPriority.LOW);
|
||||
expect(createArrayIndexPlugin().priority).toBe(PluginPriority.LOW);
|
||||
});
|
||||
|
||||
it("transform plugins run at PluginPriority.MEDIUM", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(createCaseTransformPlugin().priority).toBe(
|
||||
PluginPriority.MEDIUM
|
||||
);
|
||||
expect(createRegexPlugin().priority).toBe(PluginPriority.MEDIUM);
|
||||
expect(createStringSplitPlugin().priority).toBe(PluginPriority.MEDIUM);
|
||||
});
|
||||
|
||||
it("dynamic plugins run at PluginPriority.HIGH", { timeout: 5000 }, () => {
|
||||
expect(createCounterPlugin().priority).toBe(PluginPriority.HIGH);
|
||||
expect(createDateTimePlugin().priority).toBe(PluginPriority.HIGH);
|
||||
expect(createUuidPlugin().priority).toBe(PluginPriority.HIGH);
|
||||
});
|
||||
|
||||
it("control-flow plugins run at PluginPriority.CRITICAL", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(createConditionalPlugin().priority).toBe(
|
||||
PluginPriority.CRITICAL
|
||||
);
|
||||
expect(createLoopPlugin((tpl) => tpl).priority).toBe(
|
||||
PluginPriority.CRITICAL
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DotNotationPlugin / ArrayIndexPlugin — fallback semantics (TPL-10)", () => {
|
||||
it("DotNotation returns SKIP_PLUGIN for reserved namespaces (`_*`, `:`, `[`)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createDotNotationPlugin();
|
||||
const state = createParseState();
|
||||
const out = plugin.transform(
|
||||
{
|
||||
match: "{_uuid}",
|
||||
groups: ["{_uuid}", "_uuid"] as unknown as RegExpMatchArray,
|
||||
index: 0,
|
||||
},
|
||||
{},
|
||||
state
|
||||
);
|
||||
// `_`-prefixed paths belong to dynamic plugins and MUST be skipped
|
||||
// so the higher-priority plugin can claim them.
|
||||
expect(out).toBe(SKIP_PLUGIN);
|
||||
});
|
||||
|
||||
it("DotNotation renders absent context paths as the empty string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createDotNotationPlugin();
|
||||
const state = createParseState();
|
||||
const out = plugin.transform(
|
||||
{
|
||||
match: "{a.b.c}",
|
||||
groups: ["{a.b.c}", "a.b.c"] as unknown as RegExpMatchArray,
|
||||
index: 0,
|
||||
},
|
||||
{},
|
||||
state
|
||||
);
|
||||
// Missing variables collapse to "" — this is the documented
|
||||
// behaviour of `getNestedValue`-backed lookups.
|
||||
expect(out).toBe("");
|
||||
});
|
||||
|
||||
it("ArrayIndex renders absent / out-of-range entries as empty string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createArrayIndexPlugin();
|
||||
const state = createParseState();
|
||||
const out = plugin.transform(
|
||||
{
|
||||
match: "{items[0]}",
|
||||
groups: [
|
||||
"{items[0]}",
|
||||
"items",
|
||||
"0",
|
||||
] as unknown as RegExpMatchArray,
|
||||
index: 0,
|
||||
},
|
||||
{},
|
||||
state
|
||||
);
|
||||
expect(out).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DateTime / Uuid plugins — Clock and Rng injection (TPL-10)", () => {
|
||||
it("DateTime plugin uses an injected Clock", { timeout: 5000 }, () => {
|
||||
const fixed: Clock = () => Date.UTC(2024, 0, 15, 12, 30, 45);
|
||||
const plugin = createDateTimePlugin({ clock: fixed });
|
||||
const state = createParseState();
|
||||
const out = plugin.transform(
|
||||
{
|
||||
match: "{_date}",
|
||||
groups: [
|
||||
"{_date}",
|
||||
"date",
|
||||
undefined,
|
||||
] as unknown as RegExpMatchArray,
|
||||
index: 0,
|
||||
},
|
||||
{},
|
||||
state
|
||||
);
|
||||
expect(out).toBe("2024-01-15");
|
||||
});
|
||||
|
||||
it("UUID plugin uses an injected Rng for deterministic output", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rng: Rng = () => 0.5;
|
||||
const plugin = createUuidPlugin({ rng });
|
||||
const state = createParseState();
|
||||
const a = plugin.transform(
|
||||
{
|
||||
match: "{_uuid}",
|
||||
groups: ["{_uuid}", undefined] as unknown as RegExpMatchArray,
|
||||
index: 0,
|
||||
},
|
||||
{},
|
||||
state
|
||||
);
|
||||
const b = plugin.transform(
|
||||
{
|
||||
match: "{_uuid}",
|
||||
groups: ["{_uuid}", undefined] as unknown as RegExpMatchArray,
|
||||
index: 0,
|
||||
},
|
||||
{},
|
||||
state
|
||||
);
|
||||
// Two calls with the same constant Rng must produce the same UUID.
|
||||
expect(a).toBe(b);
|
||||
expect(typeof a).toBe("string");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
# CaseTransformPlugin
|
||||
|
||||
## Purpose
|
||||
|
||||
Applies string case transformations to resolved context values. Supports eight case styles covering all common naming conventions. The plugin resolves the variable path first, then transforms the resulting string, making it composable with any data shape without pre-formatting the values in the context object.
|
||||
|
||||
## Features
|
||||
|
||||
- `upper` — ALL CAPS
|
||||
- `lower` — all lowercase
|
||||
- `title` — Title Case (each word capitalised)
|
||||
- `camel` — camelCase (first word lower)
|
||||
- `pascal` — PascalCase (each word upper)
|
||||
- `snake` — snake_case
|
||||
- `kebab` — kebab-case
|
||||
- `constant` — CONSTANT_CASE
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, createCaseTransformPlugin } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine().use(createCaseTransformPlugin());
|
||||
engine.parse("{name:title}", { name: "john doe" }); // "John Doe"
|
||||
engine.parse("{key:kebab}", { key: "myVariableName" }); // "my-variable-name"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, createCaseTransformPlugin, createDotNotationPlugin } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine()
|
||||
.use(createCaseTransformPlugin())
|
||||
.use(createDotNotationPlugin());
|
||||
|
||||
const ctx = { user: { fullName: "alice smith" } };
|
||||
engine.parse(
|
||||
"Slug: {user.fullName:kebab}, Class: {user.fullName:pascal}",
|
||||
ctx
|
||||
); // "Slug: alice-smith, Class: AliceSmith"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createCaseTransformPlugin<TContext>(): Plugin<TContext>`
|
||||
|
||||
Returns a `Plugin` with:
|
||||
|
||||
- `name: "case-transform"`
|
||||
- `priority: PluginPriority.MEDIUM` (50)
|
||||
- `pattern: /\{([a-zA-Z_$][\w.$]*(?:\[-?\d+\])?):(?:[^[\]]+\[[-\d]+\]:)?(upper|lower|title|camel|pascal|snake|kebab|constant)\}/g`
|
||||
|
||||
**transform(match, context)**: Re-extracts the path from the full match, resolves it via `getNestedValue`, and applies the named case transform. Returns `""` for nullish values, `SKIP_PLUGIN` for `_`-prefixed paths.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `snake`, `kebab`, and `constant` all follow the same prefix-on-uppercase approach: insert separator before each uppercase letter, replace other separators, normalise case, strip leading separator, collapse doubles.
|
||||
- `camel` and `pascal` split on `/[\s_-]+/` so they handle space-separated, snake_case, and kebab-case inputs uniformly.
|
||||
- Runs at MEDIUM priority so it runs before variable-resolution plugins but after dynamic and control-flow plugins.
|
||||
|
||||
### Transforms
|
||||
|
||||
`CaseTransformPlugin` registers the `upper`, `lower`, `title`, `camel`, `pascal`, `snake`, and `kebab` case-transform helpers inside the template engine. Each is invoked via the standard `{{ value | upper }}` pipe syntax. The plugin is pure — it does not read context or emit events — so it is safe to register more than once and cheap to add to any preset bundle.
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createParseState } from "../../engine/ParseState.js";
|
||||
import { TemplateEngine } from "../../engine/TemplateEngine.js";
|
||||
import { SKIP_PLUGIN } from "../../types/index.js";
|
||||
import { createCaseTransformPlugin } from "./CaseTransformPlugin.js";
|
||||
|
||||
function engine() {
|
||||
return new TemplateEngine().use(createCaseTransformPlugin());
|
||||
}
|
||||
|
||||
describe("createCaseTransformPlugin", () => {
|
||||
it("upper case", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{name:upper}", { name: "hello world" })).toBe(
|
||||
"HELLO WORLD"
|
||||
);
|
||||
});
|
||||
|
||||
it("lower case", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{name:lower}", { name: "HELLO" })).toBe("hello");
|
||||
});
|
||||
|
||||
it("title case", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{name:title}", { name: "hello world" })).toBe(
|
||||
"Hello World"
|
||||
);
|
||||
});
|
||||
|
||||
it("camel case", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
engine().parse("{name:camel}", { name: "hello world foo" })
|
||||
).toBe("helloWorldFoo");
|
||||
});
|
||||
|
||||
it("pascal case", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{name:pascal}", { name: "hello world" })).toBe(
|
||||
"HelloWorld"
|
||||
);
|
||||
});
|
||||
|
||||
it("snake case from camelCase", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{name:snake}", { name: "helloWorld" })).toBe(
|
||||
"hello_world"
|
||||
);
|
||||
});
|
||||
|
||||
it("kebab case from camelCase", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{name:kebab}", { name: "helloWorld" })).toBe(
|
||||
"hello-world"
|
||||
);
|
||||
});
|
||||
|
||||
it("constant case", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{name:constant}", { name: "hello world" })).toBe(
|
||||
"HELLO_WORLD"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty for undefined variable", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{missing:upper}", {})).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty for null variable", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{x:lower}", { x: null })).toBe("");
|
||||
});
|
||||
|
||||
it("skips _ prefixed variables", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{_counter:upper}", {})).toBe("{_counter:upper}");
|
||||
});
|
||||
|
||||
it("returns SKIP_PLUGIN when pathMatch is missing (no { in match text)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createCaseTransformPlugin();
|
||||
const state = createParseState();
|
||||
// match.match has no { so fullMatch.match(/\{([^:]+):/) returns null
|
||||
const matchNoParen = {
|
||||
match: "noparen",
|
||||
groups: Object.assign(["noparen", undefined, "upper"], {
|
||||
index: 0,
|
||||
input: "",
|
||||
}) as RegExpMatchArray,
|
||||
index: 0,
|
||||
};
|
||||
expect(plugin.transform(matchNoParen, {}, state)).toBe(SKIP_PLUGIN);
|
||||
});
|
||||
|
||||
it("returns SKIP_PLUGIN when caseType group is missing (defensive guard)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createCaseTransformPlugin();
|
||||
const state = createParseState();
|
||||
// match.match has { pattern so pathMatch succeeds, but groups[2] is undefined
|
||||
const matchNoCaseType = {
|
||||
match: "{name:}",
|
||||
groups: Object.assign(["{name:}", "name", undefined], {
|
||||
index: 0,
|
||||
input: "",
|
||||
}) as RegExpMatchArray,
|
||||
index: 0,
|
||||
};
|
||||
expect(plugin.transform(matchNoCaseType, {}, state)).toBe(SKIP_PLUGIN);
|
||||
});
|
||||
|
||||
it("returns SKIP_PLUGIN when match text has no extractable path (defensive guard)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createCaseTransformPlugin();
|
||||
const state = createParseState();
|
||||
// match.match = "{:upper}" has no path before : so pathMatch is null → SKIP_PLUGIN
|
||||
const matchNoPath = {
|
||||
match: "{:upper}",
|
||||
groups: Object.assign(["{:upper}", "", "upper"], {
|
||||
index: 0,
|
||||
input: "",
|
||||
}) as RegExpMatchArray,
|
||||
index: 0,
|
||||
};
|
||||
expect(plugin.transform(matchNoPath, {}, state)).toBe(SKIP_PLUGIN);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import type {
|
||||
Plugin,
|
||||
PluginMatch,
|
||||
TemplateContext,
|
||||
} from "../../types/index.js";
|
||||
import { PluginPriority, SKIP_PLUGIN } from "../../types/index.js";
|
||||
import { getNestedValue } from "../../utils/nested-value.js";
|
||||
|
||||
type CaseOp =
|
||||
| "upper"
|
||||
| "lower"
|
||||
| "title"
|
||||
| "camel"
|
||||
| "pascal"
|
||||
| "snake"
|
||||
| "kebab"
|
||||
| "constant";
|
||||
|
||||
function applyCase(str: string, op: CaseOp): string {
|
||||
switch (op) {
|
||||
case "upper":
|
||||
return str.toUpperCase();
|
||||
case "lower":
|
||||
return str.toLowerCase();
|
||||
case "title":
|
||||
return str.replace(
|
||||
/\w\S*/g,
|
||||
(w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()
|
||||
);
|
||||
case "camel": {
|
||||
const words = str.split(/[\s_-]+/);
|
||||
return words
|
||||
.map((w, i) =>
|
||||
i === 0
|
||||
? w.toLowerCase()
|
||||
: w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
case "pascal": {
|
||||
const words = str.split(/[\s_-]+/);
|
||||
return words
|
||||
.map(
|
||||
(w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
case "snake":
|
||||
return str
|
||||
.replace(/([A-Z])/g, "_$1")
|
||||
.replace(/[\s-]+/g, "_")
|
||||
.toLowerCase()
|
||||
.replace(/^_/, "")
|
||||
.replace(/__+/g, "_");
|
||||
case "kebab":
|
||||
return str
|
||||
.replace(/([A-Z])/g, "-$1")
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.toLowerCase()
|
||||
.replace(/^-/, "")
|
||||
.replace(/--+/g, "-");
|
||||
case "constant":
|
||||
return str
|
||||
.replace(/([A-Z])/g, "_$1")
|
||||
.replace(/[\s-]+/g, "_")
|
||||
.toUpperCase()
|
||||
.replace(/^_/, "")
|
||||
.replace(/__+/g, "_");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* String case-transformation plugin.
|
||||
*
|
||||
* Syntax: `{name:upper}`, `{name:lower}`, `{name:title}`, `{name:camel}`,
|
||||
* `{name:pascal}`, `{name:snake}`, `{name:kebab}`, `{name:constant}`
|
||||
*
|
||||
* The variable portion is resolved via dot-notation before the transform is
|
||||
* applied. Variables starting with `_` are skipped.
|
||||
*/
|
||||
export function createCaseTransformPlugin<
|
||||
TContext extends TemplateContext = TemplateContext,
|
||||
>(): Plugin<TContext> {
|
||||
return {
|
||||
name: "case-transform",
|
||||
priority: PluginPriority.MEDIUM,
|
||||
pattern:
|
||||
/\{([a-zA-Z_$][\w.$]*(?:\[-?\d+\])?):(?:[^[\]]+\[[-\d]+\]:)?(upper|lower|title|camel|pascal|snake|kebab|constant)\}/g,
|
||||
|
||||
transform(
|
||||
match: PluginMatch,
|
||||
context: TContext
|
||||
): string | typeof SKIP_PLUGIN {
|
||||
const fullMatch = match.match;
|
||||
// Re-extract path from full match to handle bracket variants
|
||||
const pathMatch = fullMatch.match(/\{([^:]+):/);
|
||||
const caseType = match.groups?.[2] as CaseOp | undefined;
|
||||
|
||||
if (!pathMatch || !caseType) return SKIP_PLUGIN;
|
||||
|
||||
const path = pathMatch[1] as string;
|
||||
if (path.startsWith("_")) return SKIP_PLUGIN;
|
||||
|
||||
const value = getNestedValue(context, path);
|
||||
if (value === undefined || value === null) return "";
|
||||
return applyCase(String(value), caseType);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# RegexPlugin
|
||||
|
||||
## Purpose
|
||||
|
||||
Applies inline regular-expression operations to resolved context values. The plugin enables match extraction, capture group access, and global search-and-replace — all expressed within the template syntax — so callers can reshape value strings without pre-processing the context object. By centralising regex logic here the main application code stays free of ad-hoc string manipulation.
|
||||
|
||||
## Features
|
||||
|
||||
- `{path:/pattern/flags}` — extract the first match of the regex against the resolved value.
|
||||
- `{path:/pattern/flags:N}` — extract capture group N (0 = full match, 1 = first group, etc.).
|
||||
- `{path:/pattern/flags:replace:replacement}` — replace all occurrences using `String.replace`.
|
||||
- Supports all standard regex flags: `g`, `i`, `m`, `s`, `u`, `y`.
|
||||
- Returns `""` for nullish resolved values or when no match is found.
|
||||
- Returns the original match string for invalid regex patterns (safe degradation).
|
||||
- Skips `_`-prefixed paths (reserved for dynamic plugins).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, createRegexPlugin } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine().use(createRegexPlugin());
|
||||
|
||||
engine.parse("{phone:/\\D/g:replace:}", { phone: "+1 (555) 867-5309" });
|
||||
// → "15558675309"
|
||||
|
||||
engine.parse("{email:/@.+$/}", { email: "alice@example.com" });
|
||||
// → "@example.com"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, createRegexPlugin } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine().use(createRegexPlugin());
|
||||
|
||||
// Capture groups: extract year from ISO date
|
||||
engine.parse(
|
||||
"Year: {date:/^(\\d{4})-(\\d{2})-(\\d{2})$/:1}",
|
||||
{ date: "2024-06-15" }
|
||||
);
|
||||
// → "Year: 2024"
|
||||
|
||||
// Replace non-alphanumeric characters
|
||||
engine.parse("{code:/[a-z]/gi:replace:X}", { code: "abc123" });
|
||||
// → "XXX123"
|
||||
|
||||
// Out-of-range group returns empty string
|
||||
engine.parse("{value:/(a)(b)/:9}", { value: "ab" });
|
||||
// → ""
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createRegexPlugin<TContext>(): Plugin<TContext>`
|
||||
|
||||
Returns a `Plugin` with:
|
||||
|
||||
- `name: "regex"`
|
||||
- `priority: PluginPriority.MEDIUM` (50)
|
||||
- `pattern: /\{([a-zA-Z_$][\w.$]*):\/([^/]+)\/([gimsuy]*)?(?::(\w+)(?::([^}]*))?)?\\}/g`
|
||||
|
||||
The pattern captures five groups:
|
||||
|
||||
1. Variable path (e.g. `phone`)
|
||||
2. Regex pattern body (e.g. `\\D`)
|
||||
3. Flags string (e.g. `g`, may be empty)
|
||||
4. Operation keyword (`replace` or a decimal digit string)
|
||||
5. Replacement string when operation is `replace`
|
||||
|
||||
**transform(match, context)**: Resolves the path via `getNestedValue`, converts the result to a string, then applies the specified operation. Returns `SKIP_PLUGIN` when `path` or `pattern` capture groups are absent. Returns the original match text when the regex constructor throws (malformed pattern). Returns `""` for nullish values or when no match exists.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The regex engine used is the native JS `RegExp` constructor, so all platform-supported flags and syntax are available.
|
||||
- When no operation is specified, `String.match()` is used and only the zeroth element (the full match) is returned; this is equivalent to requesting group 0 explicitly.
|
||||
- Group extraction uses `String.match()` rather than `RegExp.exec()`, which means the `g` flag causes `match()` to return all full matches in an array with no group information. Callers wanting capture groups should omit the `g` flag.
|
||||
- The `replace` operation delegates directly to `String.replace()`, which respects the `g` flag: without it only the first occurrence is replaced; with it all occurrences are replaced.
|
||||
- Invalid regex patterns are caught silently and the original match text is returned, preventing template rendering from crashing due to user-supplied regex strings.
|
||||
- Runs at `MEDIUM` priority so it executes before variable-resolution plugins but after control-flow plugins.
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createParseState } from "../../engine/ParseState.js";
|
||||
import { TemplateEngine } from "../../engine/TemplateEngine.js";
|
||||
import { SKIP_PLUGIN } from "../../types/index.js";
|
||||
import { createRegexPlugin } from "./RegexPlugin.js";
|
||||
|
||||
function engine() {
|
||||
return new TemplateEngine().use(createRegexPlugin());
|
||||
}
|
||||
|
||||
describe("createRegexPlugin", () => {
|
||||
it("extracts first match", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{text:/\\w+/}", { text: "hello world" })).toBe(
|
||||
"hello"
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces matches", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
engine().parse("{phone:/\\D/g:replace:}", {
|
||||
phone: "(123) 456-7890",
|
||||
})
|
||||
).toBe("1234567890");
|
||||
});
|
||||
|
||||
it("extracts capture group 0", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
engine().parse("{text:/(\\w+)/g:0}", { text: "hello world" })
|
||||
).toBe("hello");
|
||||
});
|
||||
|
||||
it("extracts capture group 1 (full match at index 0, first capture at 1)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// index 0 = full match "hello world", index 1 = first group "hello", index 2 = second group "world"
|
||||
expect(
|
||||
engine().parse("{text:/(\\w+) (\\w+)/:1}", { text: "hello world" })
|
||||
).toBe("hello");
|
||||
});
|
||||
|
||||
it("extracts capture group 2 (second capture group = world)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
engine().parse("{text:/(\\w+) (\\w+)/:2}", { text: "hello world" })
|
||||
).toBe("world");
|
||||
});
|
||||
|
||||
it("returns empty when no match", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
engine().parse("{text:/\\d+/}", { text: "no digits here" })
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty for undefined path", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{missing:/\\w+/}", {})).toBe("");
|
||||
});
|
||||
|
||||
it("returns match on invalid regex", { timeout: 5000 }, () => {
|
||||
// Invalid regex pattern — should return original match
|
||||
const result = engine().parse("{text:/[invalid/}", { text: "abc" });
|
||||
expect(result).toBe("{text:/[invalid/}");
|
||||
});
|
||||
|
||||
it("skips _ prefixed variables", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{_foo:/\\w+/}", {})).toBe("{_foo:/\\w+/}");
|
||||
});
|
||||
|
||||
it("returns empty for out-of-range group index", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{text:/(\\w+)/:5}", { text: "hello" })).toBe("");
|
||||
});
|
||||
|
||||
it("returns original match when path missing", { timeout: 5000 }, () => {
|
||||
// This hits the !path || !pattern guard
|
||||
const result = engine().parse("{:/\\w+/}", {});
|
||||
expect(typeof result).toBe("string");
|
||||
});
|
||||
|
||||
it("returns SKIP_PLUGIN when path or pattern group missing (defensive guard)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createRegexPlugin();
|
||||
const state = createParseState();
|
||||
// No path (groups[1] undefined)
|
||||
const matchNoPath = {
|
||||
match: "{:/x/}",
|
||||
groups: Object.assign(["{:/x/}", undefined, "x", ""], {
|
||||
index: 0,
|
||||
input: "",
|
||||
}) as RegExpMatchArray,
|
||||
index: 0,
|
||||
};
|
||||
expect(plugin.transform(matchNoPath, {}, state)).toBe(SKIP_PLUGIN);
|
||||
// No pattern (groups[2] undefined)
|
||||
const matchNoPattern = {
|
||||
match: "{a://}",
|
||||
groups: Object.assign(["{a://}", "a", undefined, ""], {
|
||||
index: 0,
|
||||
input: "",
|
||||
}) as RegExpMatchArray,
|
||||
index: 0,
|
||||
};
|
||||
expect(plugin.transform(matchNoPattern, {}, state)).toBe(SKIP_PLUGIN);
|
||||
});
|
||||
|
||||
it("replaces with empty string when no replacement param", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Syntax: replace without a trailing replacement param → param is undefined → "" fallback
|
||||
const result = engine().parse("{text:/o/g:replace}", {
|
||||
text: "hello world",
|
||||
});
|
||||
expect(result).toBe("hell wrld");
|
||||
});
|
||||
|
||||
it("returns empty string when optional capture group did not participate", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Optional group (a)? does not participate when matching "b" only
|
||||
// matches[1] is undefined (not merely out-of-range) → ?? "" fallback fires
|
||||
const result = engine().parse("{text:/(a)?(b)/:1}", { text: "b" });
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
Plugin,
|
||||
PluginMatch,
|
||||
TemplateContext,
|
||||
} from "../../types/index.js";
|
||||
import { PluginPriority, SKIP_PLUGIN } from "../../types/index.js";
|
||||
import { getNestedValue } from "../../utils/nested-value.js";
|
||||
|
||||
/**
|
||||
* Regex transformation plugin.
|
||||
*
|
||||
* Syntax:
|
||||
* - `{text:/pattern/flags}` — return first match
|
||||
* - `{text:/pattern/flags:replace:replacement}` — replace all matches
|
||||
* - `{text:/pattern/flags:0}` — return capture group 0 (full match)
|
||||
* - `{text:/pattern/flags:1}` — return capture group 1
|
||||
* - `{phone:/\\D/g:replace:}` — remove all non-digits
|
||||
*
|
||||
* Variables starting with `_` are skipped.
|
||||
*/
|
||||
export function createRegexPlugin<
|
||||
TContext extends TemplateContext = TemplateContext,
|
||||
>(): Plugin<TContext> {
|
||||
return {
|
||||
name: "regex",
|
||||
priority: PluginPriority.MEDIUM,
|
||||
pattern:
|
||||
/\{([a-zA-Z_$][\w.$]*):\/([^/]+)\/([gimsuy]*)?(?::(\w+)(?::([^}]*))?)?\}/g,
|
||||
|
||||
transform(
|
||||
match: PluginMatch,
|
||||
context: TContext
|
||||
): string | typeof SKIP_PLUGIN {
|
||||
const path = match.groups?.[1];
|
||||
const pattern = match.groups?.[2];
|
||||
const flags = match.groups?.[3] ?? "";
|
||||
const operation = match.groups?.[4];
|
||||
const param = match.groups?.[5];
|
||||
|
||||
if (!path || !pattern) return SKIP_PLUGIN;
|
||||
if (path.startsWith("_")) return SKIP_PLUGIN;
|
||||
|
||||
const value = getNestedValue(context, path);
|
||||
if (value === undefined || value === null) return "";
|
||||
|
||||
const str = String(value);
|
||||
|
||||
try {
|
||||
const regex = new RegExp(pattern, flags);
|
||||
|
||||
if (operation === "replace") {
|
||||
return str.replace(regex, param ?? "");
|
||||
}
|
||||
|
||||
if (operation !== undefined && /^\d+$/.test(operation)) {
|
||||
const groupIndex = Number.parseInt(operation, 10);
|
||||
const matches = str.match(regex);
|
||||
if (!matches || groupIndex >= matches.length) return "";
|
||||
return matches[groupIndex] ?? "";
|
||||
}
|
||||
|
||||
// Default: return first match
|
||||
return str.match(regex)?.[0] ?? "";
|
||||
} catch {
|
||||
return match.match;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# StringSplitPlugin
|
||||
|
||||
## Purpose
|
||||
|
||||
Splits a resolved string value on a configurable delimiter and returns a single element by index. This plugin solves the common template problem of extracting a specific word or segment from a delimited value — such as a first name from a full name, a domain from an email address, or a path component from a URL — without requiring the context to be pre-shaped before rendering.
|
||||
|
||||
## Features
|
||||
|
||||
- Syntax: `{path:delimiter[index]}` — split on delimiter, return element at index.
|
||||
- Default delimiter is a single space when the delimiter is omitted: `{name:[0]}`.
|
||||
- Supports negative indices counting from the end: `{path:,[-1]}` returns the last element.
|
||||
- Returns `""` for nullish resolved values, out-of-range indices, or missing paths.
|
||||
- Skips `_`-prefixed paths (reserved for dynamic plugins).
|
||||
- The delimiter may be any string, including multi-character strings.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, createStringSplitPlugin } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine().use(createStringSplitPlugin());
|
||||
|
||||
engine.parse("{name:[0]}", { name: "Alice Smith" });
|
||||
// → "Alice"
|
||||
|
||||
engine.parse("{email:@[1]}", { email: "alice@example.com" });
|
||||
// → "example.com"
|
||||
|
||||
engine.parse("{path:/[-1]}", { path: "/home/user/file.txt" });
|
||||
// → "file.txt"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, createStringSplitPlugin, createDotNotationPlugin } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine()
|
||||
.use(createStringSplitPlugin())
|
||||
.use(createDotNotationPlugin());
|
||||
|
||||
// Extract TLD from a domain string
|
||||
const ctx = { domain: "alice.example.co.uk" };
|
||||
engine.parse("TLD: {domain:.[-1]}", ctx);
|
||||
// → "TLD: uk"
|
||||
|
||||
// Extract CSV column
|
||||
const row = { line: "2024,Alice,admin" };
|
||||
engine.parse("User: {line:,[1]}", row);
|
||||
// → "User: Alice"
|
||||
|
||||
// Out-of-range index gracefully returns empty string
|
||||
engine.parse("{tags:,[-5]}", { tags: "a,b" });
|
||||
// → ""
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createStringSplitPlugin<TContext>(): Plugin<TContext>`
|
||||
|
||||
Returns a `Plugin` with:
|
||||
|
||||
- `name: "string-split"`
|
||||
- `priority: PluginPriority.MEDIUM` (50)
|
||||
- `pattern: /\{([a-zA-Z_$][\w.$]*):([^[\]]+)?\[(-?\d+)\]\}/g`
|
||||
|
||||
The pattern captures three groups:
|
||||
|
||||
1. Variable path
|
||||
2. Optional delimiter (defaults to `" "` when absent)
|
||||
3. Integer index (may be negative)
|
||||
|
||||
**transform(match, context)**: Resolves the path via `getNestedValue`, splits the resulting string on the delimiter, and returns the element at the specified index. Negative indices are resolved as `parts.length + index`. Returns `""` when the resolved value is nullish, the index is out of range, or any required group is missing.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The delimiter is captured as an optional group so `{name:[0]}` (delimiter omitted) defaults to a single space, matching the most common split use case.
|
||||
- Negative index arithmetic mirrors the approach used in `getNestedValue`: `actual = index < 0 ? parts.length + index : index`. An index that resolves below 0 after adjustment returns `""`.
|
||||
- `String.split()` with a string delimiter is used directly; the delimiter is not treated as a regex. If callers need regex-based splitting they should use the `RegexPlugin` instead.
|
||||
- Runs at `MEDIUM` priority alongside `CaseTransformPlugin` and `RegexPlugin` so all transforms are applied before variable-resolution plugins.
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createParseState } from "../../engine/ParseState.js";
|
||||
import { TemplateEngine } from "../../engine/TemplateEngine.js";
|
||||
import { SKIP_PLUGIN } from "../../types/index.js";
|
||||
import { createStringSplitPlugin } from "./StringSplitPlugin.js";
|
||||
|
||||
function engine() {
|
||||
return new TemplateEngine().use(createStringSplitPlugin());
|
||||
}
|
||||
|
||||
describe("createStringSplitPlugin", () => {
|
||||
it("splits by space and returns first word", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{name:[0]}", { name: "John Doe" })).toBe("John");
|
||||
});
|
||||
|
||||
it("splits by @ and returns domain", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
engine().parse("{email:@[1]}", { email: "user@example.com" })
|
||||
).toBe("example.com");
|
||||
});
|
||||
|
||||
it("splits by comma and returns second element", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{tags:,[1]}", { tags: "foo,bar,baz" })).toBe(
|
||||
"bar"
|
||||
);
|
||||
});
|
||||
|
||||
it("supports negative index", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{path:/[-1]}", { path: "/a/b/c" })).toBe("c");
|
||||
});
|
||||
|
||||
it("returns empty for out-of-range index", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{name:[5]}", { name: "hi" })).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty for out-of-range negative index", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(engine().parse("{name:[-5]}", { name: "hi" })).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty for undefined path", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{missing:[0]}", {})).toBe("");
|
||||
});
|
||||
|
||||
it("skips _ prefixed variables", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{_foo:[0]}", {})).toBe("{_foo:[0]}");
|
||||
});
|
||||
|
||||
it("returns SKIP_PLUGIN when path or indexStr missing (defensive guard)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createStringSplitPlugin();
|
||||
const state = createParseState();
|
||||
// groups[1] undefined → !path guard
|
||||
const matchNoPath = {
|
||||
match: "{:[0]}",
|
||||
groups: Object.assign(["{:[0]}", undefined, "", "0"], {
|
||||
index: 0,
|
||||
input: "",
|
||||
}) as RegExpMatchArray,
|
||||
index: 0,
|
||||
};
|
||||
expect(plugin.transform(matchNoPath, {}, state)).toBe(SKIP_PLUGIN);
|
||||
});
|
||||
|
||||
it("returns SKIP_PLUGIN for _ prefixed path (direct transform)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createStringSplitPlugin();
|
||||
const state = createParseState();
|
||||
const matchUnder = {
|
||||
match: "{_foo:[0]}",
|
||||
groups: Object.assign(["{_foo:[0]}", "_foo", "", "0"], {
|
||||
index: 0,
|
||||
input: "",
|
||||
}) as RegExpMatchArray,
|
||||
index: 0,
|
||||
};
|
||||
expect(plugin.transform(matchUnder, {}, state)).toBe(SKIP_PLUGIN);
|
||||
});
|
||||
|
||||
it("parts[actual] element is always a string — ?? '' is a type guard", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// String.split always returns strings; verify normal path returns correctly
|
||||
expect(engine().parse("{val:,[0]}", { val: "a,b" })).toBe("a");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
Plugin,
|
||||
PluginMatch,
|
||||
TemplateContext,
|
||||
} from "../../types/index.js";
|
||||
import { PluginPriority, SKIP_PLUGIN } from "../../types/index.js";
|
||||
import { getNestedValue } from "../../utils/nested-value.js";
|
||||
|
||||
/**
|
||||
* String-split plugin — split on a delimiter and extract a single element.
|
||||
*
|
||||
* Syntax: `{name:[0]}`, `{email:@[0]}`, `{path:/[2]}`, `{tags:,[-1]}`
|
||||
*
|
||||
* The delimiter defaults to a single space when omitted. Negative indices
|
||||
* count from the end of the split array.
|
||||
*/
|
||||
export function createStringSplitPlugin<
|
||||
TContext extends TemplateContext = TemplateContext,
|
||||
>(): Plugin<TContext> {
|
||||
return {
|
||||
name: "string-split",
|
||||
priority: PluginPriority.MEDIUM,
|
||||
pattern: /\{([a-zA-Z_$][\w.$]*):([^[\]]+)?\[(-?\d+)\]\}/g,
|
||||
|
||||
transform(
|
||||
match: PluginMatch,
|
||||
context: TContext
|
||||
): string | typeof SKIP_PLUGIN {
|
||||
const path = match.groups?.[1];
|
||||
const delimiter = match.groups?.[2] ?? " ";
|
||||
const indexStr = match.groups?.[3];
|
||||
|
||||
if (!path || !indexStr) return SKIP_PLUGIN;
|
||||
if (path.startsWith("_")) return SKIP_PLUGIN;
|
||||
|
||||
const value = getNestedValue(context, path);
|
||||
if (value === undefined || value === null) return "";
|
||||
|
||||
const parts = String(value).split(delimiter);
|
||||
const index = Number.parseInt(indexStr, 10);
|
||||
const actual = index < 0 ? parts.length + index : index;
|
||||
|
||||
if (actual < 0 || actual >= parts.length) return "";
|
||||
return parts[actual] as string;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { createCaseTransformPlugin } from "./CaseTransformPlugin";
|
||||
export { createRegexPlugin } from "./RegexPlugin";
|
||||
export { createStringSplitPlugin } from "./StringSplitPlugin";
|
||||
@@ -0,0 +1,56 @@
|
||||
# ArrayIndexPlugin
|
||||
|
||||
## Purpose
|
||||
|
||||
Resolves bracket-notation array-index references in templates. Handles `{items[0]}`, `{items[-1]}`, and nested `{users[2].name}` patterns by delegating to `getNestedValue` which supports both positive and negative indexing into arrays.
|
||||
|
||||
This plugin runs at `LOW` priority alongside dot-notation but with its own distinct regex — the two plugins are complementary: dot-notation handles simple paths, array-index handles bracket expressions.
|
||||
|
||||
## Features
|
||||
|
||||
- Resolves `{arr[0]}`, `{arr[-1]}` (negative from end), `{arr[n].prop}`.
|
||||
- Returns empty string for out-of-range or missing paths.
|
||||
- Converts non-string resolved values to string via `String()`.
|
||||
- Skips `_`-prefixed and `:` -containing paths.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, createArrayIndexPlugin } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine().use(createArrayIndexPlugin());
|
||||
engine.parse("{items[0]} and {items[-1]}", { items: ["first", "middle", "last"] });
|
||||
// → "first and last"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, createArrayIndexPlugin } from "@suaveplan/templates";
|
||||
|
||||
const ctx = { users: [{ name: "Alice" }, { name: "Bob" }] };
|
||||
const engine = new TemplateEngine().use(createArrayIndexPlugin());
|
||||
engine.parse("{users[1].name}", ctx); // "Bob"
|
||||
engine.parse("{users[99]}", ctx); // "" (out-of-range)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createArrayIndexPlugin<TContext>(): Plugin<TContext>`
|
||||
|
||||
Returns a `Plugin` with:
|
||||
|
||||
- `name: "array-index"`
|
||||
- `priority: PluginPriority.LOW` (20)
|
||||
- `pattern: /\{([a-zA-Z_$][\w.$]*\[-?\d+\](?:\.[\w.$]*)?)\}/g`
|
||||
|
||||
**transform(match, context)**: Resolves the full bracket-notation path via `getNestedValue`. Returns `SKIP_PLUGIN` for reserved paths, `""` for nullish values, `String(value)` otherwise.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The pattern includes an optional trailing `.property` segment after the bracket, enabling `{arr[0].name}` in a single match rather than requiring two plugin passes.
|
||||
- Negative-index arithmetic is handled entirely in `getNestedValue`; the plugin itself is a thin adapter.
|
||||
|
||||
### Indexing Semantics
|
||||
|
||||
`ArrayIndexPlugin` resolves `items.0.name` / `items.2` / `tags.-1` (the last element) against array values in the template context. Negative indices count from the end; out-of-range indices resolve to `undefined` rather than throwing so templates degrade gracefully. Non-array values at the indexed path surface the standard `TemplateResolutionError` from the engine.
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createParseState } from "../../engine/ParseState.js";
|
||||
import { TemplateEngine } from "../../engine/TemplateEngine.js";
|
||||
import { SKIP_PLUGIN } from "../../types/index.js";
|
||||
import { createArrayIndexPlugin } from "./ArrayIndexPlugin.js";
|
||||
|
||||
function engine() {
|
||||
return new TemplateEngine().use(createArrayIndexPlugin());
|
||||
}
|
||||
|
||||
describe("createArrayIndexPlugin", () => {
|
||||
it("resolves first element", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{items[0]}", { items: ["a", "b", "c"] })).toBe(
|
||||
"a"
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves last element", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{items[2]}", { items: ["a", "b", "c"] })).toBe(
|
||||
"c"
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves negative index", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{items[-1]}", { items: ["x", "y", "z"] })).toBe(
|
||||
"z"
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves nested property after index", { timeout: 5000 }, () => {
|
||||
const ctx = { users: [{ name: "Alice" }, { name: "Bob" }] };
|
||||
expect(engine().parse("{users[1].name}", ctx)).toBe("Bob");
|
||||
});
|
||||
|
||||
it("returns empty for out-of-range index", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{items[99]}", { items: ["a"] })).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty when path does not exist", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{missing[0]}", {})).toBe("");
|
||||
});
|
||||
|
||||
it("skips variables starting with _", { timeout: 5000 }, () => {
|
||||
// pattern won't match _ prefix items anyway, but ensure no side-effects
|
||||
const result = engine().parse("{items[0]}", { items: [1] });
|
||||
expect(result).toBe("1");
|
||||
});
|
||||
|
||||
it("returns match.match when path group is missing (defensive guard)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createArrayIndexPlugin();
|
||||
const state = createParseState();
|
||||
const match = {
|
||||
match: "{[0]}",
|
||||
groups: Object.assign(["{[0]}", undefined], {
|
||||
index: 0,
|
||||
input: "",
|
||||
}) as RegExpMatchArray,
|
||||
index: 0,
|
||||
};
|
||||
expect(plugin.transform(match, {}, state)).toBe("{[0]}");
|
||||
});
|
||||
|
||||
it("returns SKIP_PLUGIN for _ prefix or : in path (defensive guard)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createArrayIndexPlugin();
|
||||
const state = createParseState();
|
||||
const matchUnder = {
|
||||
match: "{_foo[0]}",
|
||||
groups: Object.assign(["{_foo[0]}", "_foo[0]"], {
|
||||
index: 0,
|
||||
input: "",
|
||||
}) as RegExpMatchArray,
|
||||
index: 0,
|
||||
};
|
||||
const matchColon = {
|
||||
match: "{a:b[0]}",
|
||||
groups: Object.assign(["{a:b[0]}", "a:b[0]"], {
|
||||
index: 0,
|
||||
input: "",
|
||||
}) as RegExpMatchArray,
|
||||
index: 0,
|
||||
};
|
||||
expect(plugin.transform(matchUnder, {}, state)).toBe(SKIP_PLUGIN);
|
||||
expect(plugin.transform(matchColon, {}, state)).toBe(SKIP_PLUGIN);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type {
|
||||
Plugin,
|
||||
PluginMatch,
|
||||
TemplateContext,
|
||||
} from "../../types/index.js";
|
||||
import { PluginPriority, SKIP_PLUGIN } from "../../types/index.js";
|
||||
import { getNestedValue } from "../../utils/nested-value.js";
|
||||
|
||||
/**
|
||||
* Resolve array-index variable references.
|
||||
*
|
||||
* Syntax: `{items[0]}`, `{items[-1]}`, `{users[2].name}`
|
||||
*
|
||||
* Negative indices count from the end of the array (`[-1]` is the last item).
|
||||
* Variables beginning with `_` or containing `:` are skipped.
|
||||
*/
|
||||
export function createArrayIndexPlugin<
|
||||
TContext extends TemplateContext = TemplateContext,
|
||||
>(): Plugin<TContext> {
|
||||
return {
|
||||
name: "array-index",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{([a-zA-Z_$][\w.$]*\[-?\d+\](?:\.[\w.$]*)?)\}/g,
|
||||
|
||||
transform(
|
||||
match: PluginMatch,
|
||||
context: TContext
|
||||
): string | typeof SKIP_PLUGIN {
|
||||
const path = match.groups?.[1];
|
||||
if (!path) return match.match;
|
||||
if (path.startsWith("_") || path.includes(":")) return SKIP_PLUGIN;
|
||||
|
||||
const value = getNestedValue(context, path);
|
||||
if (value === undefined || value === null) return "";
|
||||
return String(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# DotNotationPlugin
|
||||
|
||||
## Purpose
|
||||
|
||||
Resolves simple variable references using dot-notation paths. This is the primary workhorse plugin for template rendering — it handles `{name}`, `{user.name}`, and `{a.b.c}` patterns by delegating path traversal to `getNestedValue`. It is intentionally the lowest-priority plugin so that higher-priority transform and dynamic plugins always get first pick of the matched tokens.
|
||||
|
||||
## Features
|
||||
|
||||
- Resolves `{key}`, `{parent.child}`, `{a.b.c.d}` references.
|
||||
- Returns an empty string when the path resolves to `undefined` or `null`.
|
||||
- Converts non-string values to string using `String()`.
|
||||
- Skips variables prefixed with `_` (reserved for dynamic plugins).
|
||||
- Skips variables containing `:` or `[` (handled by other plugins).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, createDotNotationPlugin } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine().use(createDotNotationPlugin());
|
||||
engine.parse("{greeting}, {user.name}!", { greeting: "Hello", user: { name: "Alice" } });
|
||||
// → "Hello, Alice!"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, createDotNotationPlugin } from "@suaveplan/templates";
|
||||
|
||||
interface MyCtx { config: { app: { version: string } } }
|
||||
const engine = new TemplateEngine<MyCtx>().use(createDotNotationPlugin<MyCtx>());
|
||||
|
||||
engine.parse("v{config.app.version}", { config: { app: { version: "1.2.3" } } });
|
||||
// → "v1.2.3"
|
||||
|
||||
// Missing paths produce empty string
|
||||
engine.parse("({missing})", { config: { app: { version: "1" } } });
|
||||
// → "()"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createDotNotationPlugin<TContext>(): Plugin<TContext>`
|
||||
|
||||
Returns a `Plugin` with:
|
||||
|
||||
- `name: "dot-notation"`
|
||||
- `priority: PluginPriority.LOW` (20)
|
||||
- `pattern: /\{([a-zA-Z_$][\w.$]*)\}/g`
|
||||
|
||||
**transform(match, context)**:
|
||||
Resolves `match.groups[1]` as a dot-path against `context`. Returns `""` for nullish values, `SKIP_PLUGIN` for `_`-prefixed or operator-containing paths, and `String(value)` otherwise.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `SKIP_PLUGIN` symbol is returned (not the original match text) for reserved paths. This allows higher-priority plugins to have already processed them without leaving stale `{_counter}` literals in the output.
|
||||
- The pattern matches any valid JS identifier segment, including `$` and `_` prefixes, so `{_counter}` is matched but immediately skipped — ensuring dynamic plugins handle their own tokens.
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createParseState } from "../../engine/ParseState.js";
|
||||
import { TemplateEngine } from "../../engine/TemplateEngine.js";
|
||||
import { SKIP_PLUGIN } from "../../types/index.js";
|
||||
import { createDotNotationPlugin } from "./DotNotationPlugin.js";
|
||||
|
||||
function engine() {
|
||||
return new TemplateEngine().use(createDotNotationPlugin());
|
||||
}
|
||||
|
||||
describe("createDotNotationPlugin", () => {
|
||||
it("resolves simple variable", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{name}", { name: "Alice" })).toBe("Alice");
|
||||
});
|
||||
|
||||
it("resolves nested dot path", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{user.name}", { user: { name: "Bob" } })).toBe(
|
||||
"Bob"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty string for undefined value", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{missing}", {})).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for null value", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{x}", { x: null })).toBe("");
|
||||
});
|
||||
|
||||
it("skips variables starting with _", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{_counter}", {})).toBe("{_counter}");
|
||||
});
|
||||
|
||||
it("skips variables containing :", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{name:upper}", { name: "hi" })).toBe(
|
||||
"{name:upper}"
|
||||
);
|
||||
});
|
||||
|
||||
it("skips variables containing [", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{items[0]}", { items: ["a"] })).toBe(
|
||||
"{items[0]}"
|
||||
);
|
||||
});
|
||||
|
||||
it("converts numeric value to string", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{count}", { count: 42 })).toBe("42");
|
||||
});
|
||||
|
||||
it("converts boolean value to string", { timeout: 5000 }, () => {
|
||||
expect(engine().parse("{flag}", { flag: true })).toBe("true");
|
||||
});
|
||||
|
||||
it("handles multiple replacements in one template", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(engine().parse("{a} {b}", { a: "X", b: "Y" })).toBe("X Y");
|
||||
});
|
||||
|
||||
it("returns match.match when path group is missing (defensive guard)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createDotNotationPlugin();
|
||||
const state = createParseState();
|
||||
// Craft a match where groups[1] is undefined to trigger the !path guard
|
||||
const match = {
|
||||
match: "{undefined}",
|
||||
groups: Object.assign(["{undefined}", undefined], {
|
||||
index: 0,
|
||||
input: "",
|
||||
}) as RegExpMatchArray,
|
||||
index: 0,
|
||||
};
|
||||
expect(plugin.transform(match, {}, state)).toBe("{undefined}");
|
||||
});
|
||||
|
||||
it("returns SKIP_PLUGIN for path containing : or [ (defensive guard)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createDotNotationPlugin();
|
||||
const state = createParseState();
|
||||
// Craft match objects to directly test the : and [ guards
|
||||
const matchColon = {
|
||||
match: "{a:b}",
|
||||
groups: Object.assign(["{a:b}", "a:b"], {
|
||||
index: 0,
|
||||
input: "",
|
||||
}) as RegExpMatchArray,
|
||||
index: 0,
|
||||
};
|
||||
const matchBracket = {
|
||||
match: "{a[0]}",
|
||||
groups: Object.assign(["{a[0]}", "a[0]"], {
|
||||
index: 0,
|
||||
input: "",
|
||||
}) as RegExpMatchArray,
|
||||
index: 0,
|
||||
};
|
||||
expect(plugin.transform(matchColon, {}, state)).toBe(SKIP_PLUGIN);
|
||||
expect(plugin.transform(matchBracket, {}, state)).toBe(SKIP_PLUGIN);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
Plugin,
|
||||
PluginMatch,
|
||||
TemplateContext,
|
||||
} from "../../types/index.js";
|
||||
import { PluginPriority, SKIP_PLUGIN } from "../../types/index.js";
|
||||
import { getNestedValue } from "../../utils/nested-value.js";
|
||||
|
||||
/**
|
||||
* Resolve simple dot-notation variable references.
|
||||
*
|
||||
* Syntax: `{user.name}`, `{a.b.c}`, `{title}`
|
||||
*
|
||||
* Variables beginning with `_` are reserved for dynamic plugins and are
|
||||
* skipped. Variables containing `:` or `[` are handled by other plugins and
|
||||
* are also skipped, preventing double-resolution.
|
||||
*/
|
||||
export function createDotNotationPlugin<
|
||||
TContext extends TemplateContext = TemplateContext,
|
||||
>(): Plugin<TContext> {
|
||||
return {
|
||||
name: "dot-notation",
|
||||
priority: PluginPriority.LOW,
|
||||
pattern: /\{([a-zA-Z_$][\w.$]*)\}/g,
|
||||
|
||||
transform(
|
||||
match: PluginMatch,
|
||||
context: TContext
|
||||
): string | typeof SKIP_PLUGIN {
|
||||
const path = match.groups?.[1];
|
||||
if (!path) return match.match;
|
||||
|
||||
// Reserved: dynamic plugin namespace
|
||||
if (path.startsWith("_")) return SKIP_PLUGIN;
|
||||
// Reserved: operator syntax handled by other plugins (pattern can't match these)
|
||||
if (path.includes(":") || path.includes("[")) return SKIP_PLUGIN;
|
||||
|
||||
const value = getNestedValue(context, path);
|
||||
if (value === undefined || value === null) return "";
|
||||
return String(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createArrayIndexPlugin } from "./ArrayIndexPlugin";
|
||||
export { createDotNotationPlugin } from "./DotNotationPlugin";
|
||||
85
packages/foundation/data/templates/src/presets/full.md
Normal file
85
packages/foundation/data/templates/src/presets/full.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# full preset
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the complete plugin suite in two variants: `fullPreset()` matches `standardPreset()` exactly — it is the recommended alias when you want an explicit "everything included" label — and `fullPresetWithLoops()` extends it with the loop plugin. Because the loop plugin requires access to the engine's internal `parseFunction`, `fullPresetWithLoops` accepts it as a constructor argument so the engine can inject itself at setup time, breaking the circular reference.
|
||||
|
||||
## Features
|
||||
|
||||
**fullPreset:**
|
||||
|
||||
- All nine plugins from the standard preset (conditional, counter, datetime, uuid, string-split, regex, case-transform, array-index, dot-notation).
|
||||
- No loop support (requires engine injection).
|
||||
|
||||
**fullPresetWithLoops:**
|
||||
|
||||
- All nine plugins from `fullPreset` plus the loop plugin at CRITICAL priority.
|
||||
- Named `"full-with-loops"` to avoid a `PLUGIN_CONFLICT` error when both are loaded.
|
||||
- `{each:items:alias}{alias.field}{/each}` loop syntax with all loop meta-variables.
|
||||
- Recursive rendering of loop bodies through the injected parse function.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, fullPreset } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine().usePreset(fullPreset());
|
||||
|
||||
engine.parse("Hello, {name:pascal}!", { name: "alice smith" });
|
||||
// → "Hello, AliceSmith!"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, fullPresetWithLoops } from "@suaveplan/templates";
|
||||
import type { ParseState } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine();
|
||||
|
||||
engine.usePreset(
|
||||
fullPresetWithLoops(
|
||||
(template: string, context: Record<string, unknown>, state: ParseState) =>
|
||||
(engine as unknown as { parseInternal: typeof fn }).parseInternal(template, context, state)
|
||||
)
|
||||
);
|
||||
|
||||
const ctx = {
|
||||
items: [{ title: "first item" }, { title: "second item" }],
|
||||
};
|
||||
|
||||
engine.parse(
|
||||
"{each:items:i}{_number}. {i.title:title}\n{/each}",
|
||||
ctx
|
||||
);
|
||||
// → "1. First Item\n2. Second Item\n"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `fullPreset<TContext>(): PluginPreset<TContext>`
|
||||
|
||||
Returns a `PluginPreset` with:
|
||||
|
||||
- `name: "full"`
|
||||
- `plugins`: same nine plugins as `standardPreset` in the same order
|
||||
|
||||
### `fullPresetWithLoops<TContext>(parseFunction): PluginPreset<TContext>`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `parseFunction: (template: string, context: TContext, state: ParseState) => string` — the engine's internal synchronous renderer
|
||||
|
||||
Returns a `PluginPreset` with:
|
||||
|
||||
- `name: "full-with-loops"`
|
||||
- `plugins`: `createLoopPlugin(parseFunction)` prepended to the `fullPreset` plugin list (10 total)
|
||||
|
||||
The loop plugin runs at CRITICAL priority (95) — same as the conditional plugin — so both control-flow plugins expand their blocks before variable resolution and transforms run.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `fullPreset` and `standardPreset` produce identical plugin sets. `fullPreset` exists as a semantic alias so templates packages that "want everything" can express intent clearly without knowing whether "standard" and "full" differ in future versions.
|
||||
- `fullPresetWithLoops` uses `name: "full-with-loops"` (not `"full"`) so an engine can load `fullPreset` as a base and extend it via `fullPresetWithLoops` without a name collision — though in practice you would choose one or the other.
|
||||
- The `parseFunction` parameter mirrors the signature of `TemplateEngine.parseInternal`, which is `protected`. Callers access it via `(engine as unknown as { parseInternal: ... }).parseInternal` — a deliberate type cast that signals the caller understands the internal contract.
|
||||
- Each iteration of the loop shares the same `ParseState` object, so counter values increment across iterations in the order of template occurrence.
|
||||
73
packages/foundation/data/templates/src/presets/full.ts
Normal file
73
packages/foundation/data/templates/src/presets/full.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createConditionalPlugin } from "../plugins/control/ConditionalPlugin.js";
|
||||
import { createLoopPlugin } from "../plugins/control/LoopPlugin.js";
|
||||
import { createCounterPlugin } from "../plugins/dynamic/CounterPlugin.js";
|
||||
import { createDateTimePlugin } from "../plugins/dynamic/DateTimePlugin.js";
|
||||
import { createUuidPlugin } from "../plugins/dynamic/UuidPlugin.js";
|
||||
import { createCaseTransformPlugin } from "../plugins/transform/CaseTransformPlugin.js";
|
||||
import { createRegexPlugin } from "../plugins/transform/RegexPlugin.js";
|
||||
import { createStringSplitPlugin } from "../plugins/transform/StringSplitPlugin.js";
|
||||
import { createArrayIndexPlugin } from "../plugins/variables/ArrayIndexPlugin.js";
|
||||
import { createDotNotationPlugin } from "../plugins/variables/DotNotationPlugin.js";
|
||||
import type {
|
||||
ParseState,
|
||||
PluginPreset,
|
||||
TemplateContext,
|
||||
} from "../types/index.js";
|
||||
|
||||
/**
|
||||
* Full preset — all plugins except the loop (loops require a parse-function
|
||||
* reference which is only available from the engine).
|
||||
*
|
||||
* Includes control-flow (conditional), dynamic variables (counter, datetime,
|
||||
* uuid), transforms (string-split, regex, case-transform), and variable
|
||||
* resolution (array-index, dot-notation).
|
||||
*/
|
||||
export function fullPreset<
|
||||
TContext extends TemplateContext = TemplateContext,
|
||||
>(): PluginPreset<TContext> {
|
||||
return {
|
||||
name: "full",
|
||||
plugins: [
|
||||
createConditionalPlugin<TContext>(),
|
||||
createCounterPlugin<TContext>(),
|
||||
createDateTimePlugin<TContext>(),
|
||||
createUuidPlugin<TContext>(),
|
||||
createStringSplitPlugin<TContext>(),
|
||||
createRegexPlugin<TContext>(),
|
||||
createCaseTransformPlugin<TContext>(),
|
||||
createArrayIndexPlugin<TContext>(),
|
||||
createDotNotationPlugin<TContext>(),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full preset with loop support.
|
||||
*
|
||||
* The `parseFunction` is the engine's internal sync renderer, injected so
|
||||
* the loop plugin can recursively render each iteration body.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const engine = new TemplateEngine<MyCtx>();
|
||||
* engine.usePreset(
|
||||
* fullPresetWithLoops((tpl, ctx, state) => engine["parseInternal"](tpl, ctx, state))
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function fullPresetWithLoops<
|
||||
TContext extends TemplateContext = TemplateContext,
|
||||
>(
|
||||
parseFunction: (
|
||||
template: string,
|
||||
context: TContext,
|
||||
state: ParseState
|
||||
) => string
|
||||
): PluginPreset<TContext> {
|
||||
const base = fullPreset<TContext>();
|
||||
return {
|
||||
...base,
|
||||
name: "full-with-loops",
|
||||
plugins: [createLoopPlugin<TContext>(parseFunction), ...base.plugins],
|
||||
};
|
||||
}
|
||||
3
packages/foundation/data/templates/src/presets/index.ts
Normal file
3
packages/foundation/data/templates/src/presets/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { fullPreset, fullPresetWithLoops } from "./full";
|
||||
export { minimalPreset } from "./minimal";
|
||||
export { standardPreset } from "./standard";
|
||||
64
packages/foundation/data/templates/src/presets/minimal.md
Normal file
64
packages/foundation/data/templates/src/presets/minimal.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# minimal preset
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the lightest possible `PluginPreset` for the `TemplateEngine`. It bundles only two plugins — `array-index` and `dot-notation` — and nothing else. Use this preset when you need safe variable interpolation with array support but have no requirement for transforms, dynamic variables, or control-flow constructs. The minimal footprint means faster regex scanning and smaller bundle impact in tree-shaken builds.
|
||||
|
||||
## Features
|
||||
|
||||
- Simple variable interpolation: `{name}`, `{user.email}`, `{a.b.c}`.
|
||||
- Array indexing: `{items[0]}`, `{items[-1]}`, `{users[2].name}`.
|
||||
- Negative indexing for arrays (counts from the end).
|
||||
- Returns empty string for missing paths or out-of-range indices.
|
||||
- Non-string values are coerced with `String()`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, minimalPreset } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine().usePreset(minimalPreset());
|
||||
|
||||
engine.parse("Hello, {name}!", { name: "Alice" });
|
||||
// → "Hello, Alice!"
|
||||
|
||||
engine.parse("{tags[0]} and {tags[-1]}", { tags: ["first", "middle", "last"] });
|
||||
// → "first and last"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, minimalPreset } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine().usePreset(minimalPreset());
|
||||
|
||||
// Dot-notation paths
|
||||
engine.parse("{user.address.city}", { user: { address: { city: "London" } } });
|
||||
// → "London"
|
||||
|
||||
// Bracket + property chain
|
||||
engine.parse("{team[1].name}", { team: [{ name: "Alice" }, { name: "Bob" }] });
|
||||
// → "Bob"
|
||||
|
||||
// Missing paths gracefully produce empty string
|
||||
engine.parse("({missing})", {});
|
||||
// → "()"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `minimalPreset<TContext>(): PluginPreset<TContext>`
|
||||
|
||||
Returns a `PluginPreset` with:
|
||||
|
||||
- `name: "minimal"`
|
||||
- `plugins: [createArrayIndexPlugin<TContext>(), createDotNotationPlugin<TContext>()]`
|
||||
|
||||
Pass directly to `engine.usePreset()`. All plugins in the preset are registered by name; attempting to add a plugin with a duplicate name via `engine.use()` afterward throws a `PLUGIN_CONFLICT` error.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Plugin order within the preset is `array-index` first, then `dot-notation`. Both run at `PluginPriority.LOW` (20). Since they have the same numeric priority, the engine's stable sort preserves insertion order: `array-index` is tried first for any token.
|
||||
- The `dot-notation` plugin skips tokens that contain `[` (array syntax) so there is no double-resolution. Similarly, `array-index` only matches tokens that explicitly include bracket notation.
|
||||
- Neither plugin has `init` or `cleanup` lifecycle methods, so engine setup and teardown remain synchronous and allocation-free.
|
||||
21
packages/foundation/data/templates/src/presets/minimal.ts
Normal file
21
packages/foundation/data/templates/src/presets/minimal.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createArrayIndexPlugin } from "../plugins/variables/ArrayIndexPlugin.js";
|
||||
import { createDotNotationPlugin } from "../plugins/variables/DotNotationPlugin.js";
|
||||
import type { PluginPreset, TemplateContext } from "../types/index.js";
|
||||
|
||||
/**
|
||||
* Minimal preset — dot-notation and array-index variable resolution only.
|
||||
*
|
||||
* Use when you need lightweight interpolation without transforms or dynamic
|
||||
* variables. Zero overhead from plugins that won't match.
|
||||
*/
|
||||
export function minimalPreset<
|
||||
TContext extends TemplateContext = TemplateContext,
|
||||
>(): PluginPreset<TContext> {
|
||||
return {
|
||||
name: "minimal",
|
||||
plugins: [
|
||||
createArrayIndexPlugin<TContext>(),
|
||||
createDotNotationPlugin<TContext>(),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Spec conformance: TPL-11 — preset bundles.
|
||||
*
|
||||
* Pins:
|
||||
* - minimal contains exactly dot-notation and array-index.
|
||||
* - standard extends minimal with case-transform, counter, datetime, uuid,
|
||||
* plus the package's recommended baseline (conditional, regex, string-split).
|
||||
* - full is a superset of standard.
|
||||
* - fullPresetWithLoops adds the `loop` plugin to fullPreset.
|
||||
* - each call returns fresh plugin instances (no cross-engine sharing).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { ParseState, TemplateContext } from "../types/index.js";
|
||||
import { fullPreset, fullPresetWithLoops } from "./full.js";
|
||||
import { minimalPreset } from "./minimal.js";
|
||||
import { standardPreset } from "./standard.js";
|
||||
|
||||
function names(plugins: { name: string }[]): string[] {
|
||||
return plugins.map((p) => p.name).sort();
|
||||
}
|
||||
|
||||
describe("@suaveplan/templates — presets (TPL-11)", () => {
|
||||
it("minimalPreset contains exactly dot-notation + array-index", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(names(minimalPreset().plugins)).toEqual(
|
||||
["array-index", "dot-notation"].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("standardPreset is a strict superset of minimalPreset", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const min = new Set(names(minimalPreset().plugins));
|
||||
const std = new Set(names(standardPreset().plugins));
|
||||
for (const n of min) expect(std.has(n)).toBe(true);
|
||||
// Plus the spec-required dynamic plugins.
|
||||
for (const required of [
|
||||
"counter",
|
||||
"datetime",
|
||||
"uuid",
|
||||
"case-transform",
|
||||
]) {
|
||||
expect(std.has(required)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("fullPreset is a strict superset of standardPreset", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const std = new Set(names(standardPreset().plugins));
|
||||
const full = new Set(names(fullPreset().plugins));
|
||||
for (const n of std) expect(full.has(n)).toBe(true);
|
||||
// Full also includes regex + string-split + conditional control-flow.
|
||||
for (const required of ["regex", "string-split", "conditional"]) {
|
||||
expect(full.has(required)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("fullPresetWithLoops adds the `loop` plugin to fullPreset", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const noopParse = (
|
||||
template: string,
|
||||
_ctx: TemplateContext,
|
||||
_state: ParseState
|
||||
): string => template;
|
||||
const withLoop = fullPresetWithLoops(noopParse);
|
||||
expect(withLoop.name).toBe("full-with-loops");
|
||||
const ns = new Set(names(withLoop.plugins));
|
||||
expect(ns.has("loop")).toBe(true);
|
||||
const fullNs = new Set(names(fullPreset().plugins));
|
||||
for (const n of fullNs) expect(ns.has(n)).toBe(true);
|
||||
});
|
||||
|
||||
it("each preset call returns fresh plugin instances (TPL-11)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = standardPreset().plugins;
|
||||
const b = standardPreset().plugins;
|
||||
// Same plugin set, but each call yields a *new* array of *new*
|
||||
// plugin objects so two engines never share mutable state.
|
||||
expect(a).not.toBe(b);
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
expect(a[i]).not.toBe(b[i]);
|
||||
}
|
||||
});
|
||||
});
|
||||
81
packages/foundation/data/templates/src/presets/standard.md
Normal file
81
packages/foundation/data/templates/src/presets/standard.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# standard preset
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides a comprehensive `PluginPreset` suitable for the majority of production use cases. It bundles all non-loop plugins in a single call, covering control-flow (conditional blocks), dynamic variables (counter, datetime, uuid), string transforms (string-split, regex, case-transform), and variable resolution (array-index, dot-notation). Use this preset when you want a batteries-included template engine without the complexity of wiring loop support, which requires injecting the engine's internal render function.
|
||||
|
||||
## Features
|
||||
|
||||
- **Conditional blocks**: `{if:condition}...{/if}` with truthy, negation, and operator-based expressions.
|
||||
- **Counter**: `{_counter}`, named counters, custom start values, zero-padding.
|
||||
- **Date/time**: `{_date}`, `{_time}`, `{_datetime}`, `{_timestamp}` with custom format strings.
|
||||
- **UUID/nano-ID**: `{_uuid}`, `{_nanoid:N}`.
|
||||
- **String split**: `{path:delimiter[index]}` with negative-index support.
|
||||
- **Regex**: `{path:/pattern/flags}`, `{path:/pattern/flags:replace:str}`, capture group extraction.
|
||||
- **Case transform**: `{key:upper|lower|title|camel|pascal|snake|kebab|constant}`.
|
||||
- **Variable resolution**: dot-notation and bracket-notation array indexing.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, standardPreset } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine().usePreset(standardPreset());
|
||||
|
||||
engine.parse("Hello, {name:title}!", { name: "alice smith" });
|
||||
// → "Hello, Alice Smith!"
|
||||
|
||||
engine.parse("{if:active}Active{/if}", { active: true });
|
||||
// → "Active"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TemplateEngine, standardPreset } from "@suaveplan/templates";
|
||||
|
||||
const engine = new TemplateEngine().usePreset(standardPreset());
|
||||
|
||||
const ctx = {
|
||||
users: ["alice smith", "bob jones"],
|
||||
stats: { count: 42 },
|
||||
tag: "hello-world",
|
||||
};
|
||||
|
||||
engine.parse(
|
||||
"Count: {stats.count} | Tag: {tag:pascal} | {if:stats.count:gt:10}High{/if}",
|
||||
ctx
|
||||
);
|
||||
// → "Count: 42 | Tag: HelloWorld | High"
|
||||
|
||||
// Row numbers using counter and array access
|
||||
engine.parse("{users[0]:title} is user #{_counter}", ctx);
|
||||
// → "Alice Smith is user #1"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `standardPreset<TContext>(): PluginPreset<TContext>`
|
||||
|
||||
Returns a `PluginPreset` with:
|
||||
|
||||
- `name: "standard"`
|
||||
- `plugins`: array of nine plugin instances in priority-appropriate insertion order
|
||||
|
||||
Plugins included (listed by priority, descending):
|
||||
|
||||
1. `createConditionalPlugin()` — CRITICAL (95)
|
||||
2. `createCounterPlugin()` — HIGH (80)
|
||||
3. `createDateTimePlugin()` — HIGH (80)
|
||||
4. `createUuidPlugin()` — HIGH (80)
|
||||
5. `createStringSplitPlugin()` — MEDIUM (50)
|
||||
6. `createRegexPlugin()` — MEDIUM (50)
|
||||
7. `createCaseTransformPlugin()` — MEDIUM (50)
|
||||
8. `createArrayIndexPlugin()` — LOW (20)
|
||||
9. `createDotNotationPlugin()` — LOW (20)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- All plugins in this preset use their default options (no injectable clock, no injectable RNG). To override defaults (e.g. deterministic datetime in tests), use `engine.use(createDateTimePlugin({ clock: fixedClock }))` separately instead of relying on the preset.
|
||||
- Because `usePreset` skips plugins whose name is already registered, you can safely call `engine.usePreset(standardPreset())` on an engine that already has individual plugins registered — only the missing ones will be added.
|
||||
- The loop plugin is intentionally excluded because it requires a `parseFunction` reference that creates a circular dependency at construction time. Use `fullPresetWithLoops` or add the loop plugin manually when loop support is needed.
|
||||
38
packages/foundation/data/templates/src/presets/standard.ts
Normal file
38
packages/foundation/data/templates/src/presets/standard.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createConditionalPlugin } from "../plugins/control/ConditionalPlugin.js";
|
||||
import { createCounterPlugin } from "../plugins/dynamic/CounterPlugin.js";
|
||||
import { createDateTimePlugin } from "../plugins/dynamic/DateTimePlugin.js";
|
||||
import { createUuidPlugin } from "../plugins/dynamic/UuidPlugin.js";
|
||||
import { createCaseTransformPlugin } from "../plugins/transform/CaseTransformPlugin.js";
|
||||
import { createRegexPlugin } from "../plugins/transform/RegexPlugin.js";
|
||||
import { createStringSplitPlugin } from "../plugins/transform/StringSplitPlugin.js";
|
||||
import { createArrayIndexPlugin } from "../plugins/variables/ArrayIndexPlugin.js";
|
||||
import { createDotNotationPlugin } from "../plugins/variables/DotNotationPlugin.js";
|
||||
import type { PluginPreset, TemplateContext } from "../types/index.js";
|
||||
|
||||
/**
|
||||
* Standard preset — the recommended starting point for most use cases.
|
||||
*
|
||||
* Includes:
|
||||
* - **Control flow**: conditional blocks
|
||||
* - **Dynamic variables**: counter, datetime, uuid
|
||||
* - **Transforms**: string-split, regex, case-transform
|
||||
* - **Variable resolution**: array-index, dot-notation
|
||||
*/
|
||||
export function standardPreset<
|
||||
TContext extends TemplateContext = TemplateContext,
|
||||
>(): PluginPreset<TContext> {
|
||||
return {
|
||||
name: "standard",
|
||||
plugins: [
|
||||
createConditionalPlugin<TContext>(),
|
||||
createCounterPlugin<TContext>(),
|
||||
createDateTimePlugin<TContext>(),
|
||||
createUuidPlugin<TContext>(),
|
||||
createStringSplitPlugin<TContext>(),
|
||||
createRegexPlugin<TContext>(),
|
||||
createCaseTransformPlugin<TContext>(),
|
||||
createArrayIndexPlugin<TContext>(),
|
||||
createDotNotationPlugin<TContext>(),
|
||||
],
|
||||
};
|
||||
}
|
||||
7
packages/foundation/data/templates/src/schemas/index.ts
Normal file
7
packages/foundation/data/templates/src/schemas/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type ParseOptionsInput,
|
||||
ParseOptionsSchema,
|
||||
PluginPrioritySchema,
|
||||
type TemplateEngineOptionsInput,
|
||||
TemplateEngineOptionsSchema,
|
||||
} from "./templates.schemas";
|
||||
@@ -0,0 +1,104 @@
|
||||
# templates.schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides Zod schemas for runtime validation of `TemplateEngine` and `parse` configuration objects. Rather than relying on TypeScript types alone — which provide no runtime guarantees — these schemas let consumer code validate user-supplied configuration at the boundary before constructing an engine or calling parse. The schemas also serve as the single source of truth for what constitutes a valid options object, making it easier to write property-based tests against the validated surface.
|
||||
|
||||
## Features
|
||||
|
||||
- `PluginPrioritySchema` — validates a plugin priority as an integer in the range 0–100.
|
||||
- `TemplateEngineOptionsSchema` — validates the full set of constructor options for `TemplateEngine`.
|
||||
- `ParseOptionsSchema` — validates per-call options passed to `parse()` or `parseAsync()`.
|
||||
- Inferred TypeScript types (`TemplateEngineOptionsInput`, `ParseOptionsInput`) derived from the schemas for use in typed consumer code.
|
||||
- All schemas carry `.describe()` annotations for documentation generation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TemplateEngineOptionsSchema,
|
||||
ParseOptionsSchema,
|
||||
PluginPrioritySchema,
|
||||
} from "@suaveplan/templates";
|
||||
|
||||
// Validate engine options at a configuration boundary
|
||||
const opts = TemplateEngineOptionsSchema.parse({
|
||||
maxDepth: 5,
|
||||
throwOnError: true,
|
||||
enableCache: false,
|
||||
});
|
||||
// opts is typed as { maxDepth?: number; throwOnError?: boolean; enableCache?: boolean; onError?: Function }
|
||||
|
||||
// Validate an individual priority value
|
||||
PluginPrioritySchema.parse(80); // ok
|
||||
PluginPrioritySchema.parse(101); // throws ZodError (> 100)
|
||||
PluginPrioritySchema.parse(1.5); // throws ZodError (not integer)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TemplateEngineOptionsSchema,
|
||||
ParseOptionsSchema,
|
||||
} from "@suaveplan/templates";
|
||||
import type {
|
||||
TemplateEngineOptionsInput,
|
||||
ParseOptionsInput,
|
||||
} from "@suaveplan/templates";
|
||||
|
||||
function buildEngine(rawOpts: unknown): TemplateEngine {
|
||||
// Throws ZodError on invalid input — fail fast at the boundary
|
||||
const opts: TemplateEngineOptionsInput = TemplateEngineOptionsSchema.parse(rawOpts);
|
||||
return new TemplateEngine(opts);
|
||||
}
|
||||
|
||||
// ParseOptionsSchema for per-call validation
|
||||
function safeParse(engine: TemplateEngine, template: string, ctx: unknown, rawOpts: unknown) {
|
||||
const opts: ParseOptionsInput = ParseOptionsSchema.parse(rawOpts);
|
||||
return engine.parse(template, ctx as Record<string,unknown>, opts);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `PluginPrioritySchema`
|
||||
|
||||
`z.number().int().min(0).max(100)` — Accepts any integer from 0 to 100 inclusive.
|
||||
|
||||
### `TemplateEngineOptionsSchema`
|
||||
|
||||
`z.object({ ... })` with the following fields (all optional):
|
||||
|
||||
| Field | Schema | Description |
|
||||
|-------|--------|-------------|
|
||||
| `maxDepth` | `z.number().int().positive()` | Maximum render recursion depth |
|
||||
| `throwOnError` | `z.boolean()` | Re-throw errors instead of swallowing |
|
||||
| `enableCache` | `z.boolean()` | Enable template parse-result caching |
|
||||
| `onError` | `z.function()` | Error callback invoked on plugin failure |
|
||||
|
||||
### `ParseOptionsSchema`
|
||||
|
||||
`z.object({ ... })` with the following fields (all optional):
|
||||
|
||||
| Field | Schema | Description |
|
||||
|-------|--------|-------------|
|
||||
| `maxDepth` | `z.number().int().positive()` | Per-call depth override |
|
||||
| `async` | `z.boolean()` | Force async rendering path |
|
||||
|
||||
### `TemplateEngineOptionsInput`
|
||||
|
||||
`z.input<typeof TemplateEngineOptionsSchema>` — TypeScript input type inferred from the schema.
|
||||
|
||||
### `ParseOptionsInput`
|
||||
|
||||
`z.input<typeof ParseOptionsSchema>` — TypeScript input type inferred from the schema.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Using `z.input<>` rather than `z.infer<>` preserves optionality for fields that have defaults — the input type reflects what the caller provides, while the output type would reflect what Zod outputs after applying defaults. Since no `.default()` calls are present here, `z.input` and `z.infer` are equivalent, but the `Input` suffix signals intent.
|
||||
- The `onError` field uses `z.function()` without argument/return schemas to avoid coupling the Zod schema to the internal `PluginError` type. Callers are responsible for typing their own callback correctly via TypeScript generics on `TemplateEngine`.
|
||||
- `PluginPrioritySchema` uses `.int()` before `.min()/.max()` because Zod's number refinements are applied left to right; placing `.int()` first makes the integer check run before the range check, producing a clearer error message for fractional inputs.
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
ParseOptionsSchema,
|
||||
PluginPrioritySchema,
|
||||
TemplateEngineOptionsSchema,
|
||||
} from "./templates.schemas.js";
|
||||
|
||||
describe("PluginPrioritySchema", () => {
|
||||
it("accepts valid priorities", { timeout: 5000 }, () => {
|
||||
expect(PluginPrioritySchema.parse(0)).toBe(0);
|
||||
expect(PluginPrioritySchema.parse(50)).toBe(50);
|
||||
expect(PluginPrioritySchema.parse(100)).toBe(100);
|
||||
});
|
||||
|
||||
it("rejects out-of-range values", { timeout: 5000 }, () => {
|
||||
expect(() => PluginPrioritySchema.parse(-1)).toThrow();
|
||||
expect(() => PluginPrioritySchema.parse(101)).toThrow();
|
||||
expect(() => PluginPrioritySchema.parse(1.5)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TemplateEngineOptionsSchema", () => {
|
||||
it("accepts empty object", { timeout: 5000 }, () => {
|
||||
expect(() => TemplateEngineOptionsSchema.parse({})).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts valid options", { timeout: 5000 }, () => {
|
||||
const result = TemplateEngineOptionsSchema.parse({
|
||||
maxDepth: 5,
|
||||
throwOnError: false,
|
||||
enableCache: true,
|
||||
});
|
||||
expect(result.maxDepth).toBe(5);
|
||||
expect(result.throwOnError).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects maxDepth=0", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
TemplateEngineOptionsSchema.parse({ maxDepth: 0 })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ParseOptionsSchema", () => {
|
||||
it("accepts empty", { timeout: 5000 }, () => {
|
||||
expect(() => ParseOptionsSchema.parse({})).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts maxDepth and async", { timeout: 5000 }, () => {
|
||||
const result = ParseOptionsSchema.parse({ maxDepth: 3, async: true });
|
||||
expect(result.maxDepth).toBe(3);
|
||||
expect(result.async).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema for `PluginPriority`-compatible values (0–100 inclusive).
|
||||
*/
|
||||
export const PluginPrioritySchema = z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(100)
|
||||
.describe("Plugin execution priority (0–100)");
|
||||
|
||||
/**
|
||||
* Zod schema for `TemplateEngineOptions`.
|
||||
*/
|
||||
export const TemplateEngineOptionsSchema = z
|
||||
.object({
|
||||
maxDepth: z.number().int().positive().optional(),
|
||||
throwOnError: z.boolean().optional(),
|
||||
enableCache: z.boolean().optional(),
|
||||
onError: z.function().optional(),
|
||||
})
|
||||
.describe("Options accepted by the TemplateEngine constructor");
|
||||
|
||||
/**
|
||||
* Zod schema for `ParseOptions`.
|
||||
*/
|
||||
export const ParseOptionsSchema = z
|
||||
.object({
|
||||
maxDepth: z.number().int().positive().optional(),
|
||||
async: z.boolean().optional(),
|
||||
})
|
||||
.describe("Per-call parse options");
|
||||
|
||||
export type TemplateEngineOptionsInput = z.input<
|
||||
typeof TemplateEngineOptionsSchema
|
||||
>;
|
||||
export type ParseOptionsInput = z.input<typeof ParseOptionsSchema>;
|
||||
130
packages/foundation/data/templates/src/types/index.md
Normal file
130
packages/foundation/data/templates/src/types/index.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines all shared types, interfaces, and runtime constants for `@suaveplan/templates`. This module is the single source of truth for the plugin contract, engine configuration interfaces, and the core `ParseState` shape. Keeping these definitions in one place prevents circular imports between engine, plugin, and utility modules, while still allowing each to import only what it needs.
|
||||
|
||||
## Features
|
||||
|
||||
- `TemplateContext` — generic base type for template context objects.
|
||||
- `PluginPriority` — numeric enum with five named priority levels (CRITICAL → MINIMAL).
|
||||
- `SKIP_PLUGIN` — unique symbol returned from plugin transforms to leave a match unchanged.
|
||||
- `PluginMatch` — the argument shape passed to `Plugin.transform`.
|
||||
- `Plugin<TContext>` — the full plugin interface including lifecycle hooks.
|
||||
- `PluginPreset<TContext>` — a named bundle of plugins.
|
||||
- `ParseState` — the mutable state object shared across all plugins during a single render pass.
|
||||
- `TemplateEngineOptions` — constructor options for `TemplateEngine`.
|
||||
- `ParseOptions` — per-call overrides for `parse()` and `parseAsync()`.
|
||||
- `CompiledTemplate<TContext>` — the interface returned by `TemplateEngine.compile()`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
PluginPriority,
|
||||
SKIP_PLUGIN,
|
||||
type Plugin,
|
||||
type TemplateContext,
|
||||
} from "@suaveplan/templates";
|
||||
|
||||
function createNoopPlugin<TCtx extends TemplateContext>(): Plugin<TCtx> {
|
||||
return {
|
||||
name: "noop",
|
||||
priority: PluginPriority.MINIMAL,
|
||||
pattern: /\{noop:([^}]+)\}/g,
|
||||
transform(match) {
|
||||
return SKIP_PLUGIN; // leave the match unchanged
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
Plugin,
|
||||
ParseState,
|
||||
TemplateContext,
|
||||
PluginMatch,
|
||||
PluginResult,
|
||||
} from "@suaveplan/templates";
|
||||
import { PluginPriority, SKIP_PLUGIN } from "@suaveplan/templates";
|
||||
|
||||
// Async plugin with lifecycle hooks
|
||||
function createAuditPlugin<TCtx extends TemplateContext>(): Plugin<TCtx> {
|
||||
return {
|
||||
name: "audit",
|
||||
priority: PluginPriority.HIGH,
|
||||
async: true,
|
||||
pattern: /\{audit:([^}]+)\}/g,
|
||||
|
||||
async init(context: TCtx, state: ParseState): Promise<void> {
|
||||
state.custom.set("audit-log", []);
|
||||
},
|
||||
|
||||
transform(match: PluginMatch): PluginResult {
|
||||
return `[audit:${match.groups?.[1]}]`;
|
||||
},
|
||||
|
||||
async cleanup(context: TCtx, state: ParseState): Promise<void> {
|
||||
const log = state.custom.get("audit-log") as string[];
|
||||
console.log("Rendered tokens:", log.length);
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `TemplateContext`
|
||||
|
||||
`type TemplateContext = Record<string, unknown>`
|
||||
|
||||
Base context type. All engine and plugin generics default to this type.
|
||||
|
||||
### `PluginPriority` (enum)
|
||||
|
||||
| Member | Value | Intended use |
|
||||
|--------|-------|--------------|
|
||||
| `CRITICAL` | 95 | Control-flow (conditional, loop) |
|
||||
| `HIGH` | 80 | Dynamic variables (counter, datetime, uuid) |
|
||||
| `MEDIUM` | 50 | Transforms (case, regex, split) |
|
||||
| `LOW` | 20 | Variable resolution (dot-notation, array-index) |
|
||||
| `MINIMAL` | 5 | Fallback handlers |
|
||||
|
||||
### `SKIP_PLUGIN`
|
||||
|
||||
`Symbol("SKIP_PLUGIN")` — Return this from `Plugin.transform` to signal the engine that this plugin does not handle the match, leaving it unchanged for lower-priority plugins.
|
||||
|
||||
### `ParseState`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `counters` | `Map<string, number>` | Counter state keyed by name |
|
||||
| `depth` | `number` | Current recursion depth |
|
||||
| `maxDepth` | `number` | Maximum allowed depth |
|
||||
| `cache` | `Map<string, unknown>` | Sub-template render cache |
|
||||
| `custom` | `Map<string, unknown>` | Arbitrary per-plugin state |
|
||||
|
||||
### `Plugin<TContext>`
|
||||
|
||||
| Member | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `name` | `string` | Unique identifier |
|
||||
| `priority` | `number` | Sort order (higher = earlier) |
|
||||
| `pattern` | `RegExp` | Must have `g` flag |
|
||||
| `async` | `boolean?` | Signals async transforms |
|
||||
| `transform` | function | Core transform callback |
|
||||
| `init` | function? | Pre-parse lifecycle hook |
|
||||
| `cleanup` | function? | Post-parse lifecycle hook |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `PluginPriority` is a numeric enum so priority values can be compared with `>` and `<` in tests and in the engine's sort comparison without any casting.
|
||||
- `SKIP_PLUGIN` is a module-level `const` symbol (not a class member) so it can be imported and compared with strict equality across module boundaries without `instanceof` checks.
|
||||
- `ParseState.custom` uses `Map<string, unknown>` rather than a typed record so plugins can store arbitrary shapes. The plugin is responsible for casting when reading from `custom`.
|
||||
- Re-exports `Clock` and `Rng` from `@suaveplan/types` as convenience re-exports so consumers of this package do not need to add `@suaveplan/types` to their dependencies just to use injectable options.
|
||||
146
packages/foundation/data/templates/src/types/index.ts
Normal file
146
packages/foundation/data/templates/src/types/index.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { Clock, Rng } from "@suaveplan/types";
|
||||
|
||||
export type { Clock, Rng };
|
||||
|
||||
/**
|
||||
* Generic context type that can be extended by users.
|
||||
*/
|
||||
export type TemplateContext = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Plugin execution priority levels.
|
||||
* Higher numbers execute first.
|
||||
*/
|
||||
export enum PluginPriority {
|
||||
/** Critical priority — executes before all others (90–100) */
|
||||
CRITICAL = 95,
|
||||
/** High priority — dynamic variables, counters (70–89) */
|
||||
HIGH = 80,
|
||||
/** Medium priority — string manipulation, regex (40–69) */
|
||||
MEDIUM = 50,
|
||||
/** Low priority — variable resolution (10–39) */
|
||||
LOW = 20,
|
||||
/** Minimal priority — fallback handlers (0–9) */
|
||||
MINIMAL = 5,
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse state maintained during template processing.
|
||||
* Tracks counters, visited paths, cache, and custom plugin state.
|
||||
*/
|
||||
export interface ParseState {
|
||||
/** Named counters for the _counter plugin */
|
||||
counters: Map<string, number>;
|
||||
/** Template nesting depth to prevent infinite recursion */
|
||||
depth: number;
|
||||
/** Maximum allowed nesting depth */
|
||||
maxDepth: number;
|
||||
/** Cache for expensive sub-template computations */
|
||||
cache: Map<string, unknown>;
|
||||
/** Custom per-plugin state */
|
||||
custom: Map<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin match result passed to `Plugin.transform`.
|
||||
*/
|
||||
export interface PluginMatch {
|
||||
/** The full matched string */
|
||||
match: string;
|
||||
/** The regex match array including named/positional groups */
|
||||
groups: RegExpMatchArray | null;
|
||||
/** Start index of the match in the template */
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Symbol returned from `Plugin.transform` to skip this match.
|
||||
*/
|
||||
export const SKIP_PLUGIN = Symbol("SKIP_PLUGIN");
|
||||
|
||||
/**
|
||||
* Possible return type from `Plugin.transform`.
|
||||
*/
|
||||
export type PluginResult = string | Promise<string> | typeof SKIP_PLUGIN;
|
||||
|
||||
/**
|
||||
* Plugin interface.
|
||||
* @template TContext - The context type this plugin accepts.
|
||||
*/
|
||||
export interface Plugin<TContext extends TemplateContext = TemplateContext> {
|
||||
/** Unique plugin identifier */
|
||||
name: string;
|
||||
/** Execution priority (0–100, higher executes first) */
|
||||
priority: number;
|
||||
/** Pattern to match in templates — MUST have the `g` flag */
|
||||
pattern: RegExp;
|
||||
/** Whether this plugin may return a Promise */
|
||||
async?: boolean;
|
||||
/**
|
||||
* Transform the matched text.
|
||||
* Return `SKIP_PLUGIN` to leave the match unchanged.
|
||||
*/
|
||||
transform(
|
||||
match: PluginMatch,
|
||||
context: TContext,
|
||||
state: ParseState
|
||||
): PluginResult;
|
||||
/** Optional init hook called before parsing begins */
|
||||
init?(context: TContext, state: ParseState): void | Promise<void>;
|
||||
/** Optional cleanup hook called after parsing completes */
|
||||
cleanup?(context: TContext, state: ParseState): void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A named collection of plugins.
|
||||
*/
|
||||
export interface PluginPreset<
|
||||
TContext extends TemplateContext = TemplateContext,
|
||||
> {
|
||||
/** Preset label */
|
||||
name: string;
|
||||
/** Plugins in this preset */
|
||||
plugins: Plugin<TContext>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the `TemplateEngine` constructor.
|
||||
*/
|
||||
export interface TemplateEngineOptions {
|
||||
/** Maximum recursive parse depth (default: 10) */
|
||||
maxDepth?: number;
|
||||
/** Throw on error instead of returning original template (default: true) */
|
||||
throwOnError?: boolean;
|
||||
/** Cache rendered sub-templates (default: true) */
|
||||
enableCache?: boolean;
|
||||
/** Custom error handler invoked when `throwOnError` is false */
|
||||
onError?: (
|
||||
error: Error,
|
||||
template: string,
|
||||
context: TemplateContext
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-call parse options that override engine-level settings.
|
||||
*/
|
||||
export interface ParseOptions {
|
||||
/** Override max depth for this call */
|
||||
maxDepth?: number;
|
||||
/** Force async path */
|
||||
async?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A compiled (reusable) template produced by `TemplateEngine.compile`.
|
||||
*/
|
||||
export interface CompiledTemplate<
|
||||
TContext extends TemplateContext = TemplateContext,
|
||||
> {
|
||||
/** The original template source */
|
||||
source: string;
|
||||
/** Synchronously render with the given context */
|
||||
parse(context: TContext): string;
|
||||
/** Asynchronously render with the given context */
|
||||
parseAsync(context: TContext): Promise<string>;
|
||||
}
|
||||
67
packages/foundation/data/templates/src/utils/escape.md
Normal file
67
packages/foundation/data/templates/src/utils/escape.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# escape
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides three utility functions for handling escape sequences in template strings. These are low-level primitives used internally by the engine and exported for consumers who need to pre-process or post-process template content.
|
||||
|
||||
The escape module is intentionally stateless and side-effect free — each function is a pure transformation of its input string with no I/O or global state.
|
||||
|
||||
## Features
|
||||
|
||||
- `escapeRegex(str)` — escapes all regex metacharacters for safe embedding in `RegExp` constructors.
|
||||
- `unescapeTemplate(str)` — removes backslash escapes from `\{` and `\}` sequences after rendering.
|
||||
- `isEscaped(str, pos)` — determines whether a character at a given position is preceded by an odd number of backslashes (i.e., is actually escaped).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { escapeRegex, unescapeTemplate } from "@suaveplan/templates";
|
||||
|
||||
const safe = escapeRegex("user.name"); // "user\\.name"
|
||||
const re = new RegExp(`\\{${safe}\\}`, "g");
|
||||
|
||||
const result = unescapeTemplate("Say \\{hello\\}"); // "Say {hello}"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { isEscaped, unescapeTemplate } from "@suaveplan/templates";
|
||||
|
||||
// Custom pre-processing: skip escaped tokens
|
||||
function tokenize(template: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
const re = /\{[^}]+\}/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(template)) !== null) {
|
||||
if (!isEscaped(template, m.index)) {
|
||||
tokens.push(m[0]);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// After rendering, strip escape sequences
|
||||
const rendered = renderSomehow(template);
|
||||
const final = unescapeTemplate(rendered);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `escapeRegex(str: string): string`
|
||||
|
||||
Escapes all characters that have special meaning in a regular expression: `. * + ? ^ $ { } ( ) | [ ] \`. Returns a string safe to pass to `new RegExp(str)`.
|
||||
|
||||
### `unescapeTemplate(str: string): string`
|
||||
|
||||
Replaces `\{` with `{` and `\}` with `}` throughout `str`. Other backslash sequences are left unchanged.
|
||||
|
||||
### `isEscaped(str: string, pos: number): boolean`
|
||||
|
||||
Returns `true` when the character at `pos` is preceded by an odd number of consecutive backslashes. An even count means the backslashes escape each other and the character at `pos` is not escaped.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `isEscaped` counts backslashes by walking backwards from `pos - 1` until a non-backslash character (or the start of the string) is found. The parity of the count determines whether the target character is escaped.
|
||||
- `unescapeTemplate` uses a single regex pass — O(n) regardless of the number of escape sequences.
|
||||
- `escapeRegex` uses the standard replace idiom which covers all metacharacters recognised by ECMAScript RegExp grammar.
|
||||
65
packages/foundation/data/templates/src/utils/escape.test.ts
Normal file
65
packages/foundation/data/templates/src/utils/escape.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { escapeRegex, isEscaped, unescapeTemplate } from "./escape.js";
|
||||
|
||||
describe("escapeRegex", () => {
|
||||
it("escapes all regex metacharacters", { timeout: 5000 }, () => {
|
||||
expect(escapeRegex(".*+?^${}()|[]\\")).toBe(
|
||||
"\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\"
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves plain text unchanged", { timeout: 5000 }, () => {
|
||||
expect(escapeRegex("hello")).toBe("hello");
|
||||
});
|
||||
|
||||
it("escapes mixed content", { timeout: 5000 }, () => {
|
||||
expect(escapeRegex("a.b*c")).toBe("a\\.b\\*c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unescapeTemplate", () => {
|
||||
it("removes backslash before { and }", { timeout: 5000 }, () => {
|
||||
expect(unescapeTemplate("\\{name\\}")).toBe("{name}");
|
||||
});
|
||||
|
||||
it("leaves other backslashes alone", { timeout: 5000 }, () => {
|
||||
expect(unescapeTemplate("hello\\nworld")).toBe("hello\\nworld");
|
||||
});
|
||||
|
||||
it("handles empty string", { timeout: 5000 }, () => {
|
||||
expect(unescapeTemplate("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEscaped", () => {
|
||||
it("returns true for single backslash before pos", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// "a\{" — pos=2 (the {) is preceded by one backslash
|
||||
expect(isEscaped("a\\{", 2)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for double backslash (escaped backslash)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// "a\\{" — double backslash means the { is NOT escaped
|
||||
expect(isEscaped("a\\\\{", 4)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false at position 0", { timeout: 5000 }, () => {
|
||||
expect(isEscaped("{name}", 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no backslash precedes pos", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(isEscaped("abc", 2)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for triple backslash (odd count)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// "\\\\\\{" (three backslashes) → odd → escaped
|
||||
expect(isEscaped("\\\\\\{", 3)).toBe(true);
|
||||
});
|
||||
});
|
||||
32
packages/foundation/data/templates/src/utils/escape.ts
Normal file
32
packages/foundation/data/templates/src/utils/escape.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Escape all regex metacharacters in `str` so the result is safe to embed
|
||||
* inside `new RegExp(...)`.
|
||||
*/
|
||||
export function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove backslash-escape sequences from a rendered template.
|
||||
*
|
||||
* `\{` and `\}` become `{` and `}` respectively.
|
||||
*/
|
||||
export function unescapeTemplate(str: string): string {
|
||||
return str.replace(/\\([{}])/g, "$1");
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `true` when the character at position `pos` is preceded by an
|
||||
* odd number of backslashes (i.e., is actually escaped).
|
||||
*/
|
||||
export function isEscaped(str: string, pos: number): boolean {
|
||||
let backslashCount = 0;
|
||||
let i = pos - 1;
|
||||
|
||||
while (i >= 0 && str[i] === "\\") {
|
||||
backslashCount++;
|
||||
i--;
|
||||
}
|
||||
|
||||
return backslashCount % 2 === 1;
|
||||
}
|
||||
80
packages/foundation/data/templates/src/utils/format.md
Normal file
80
packages/foundation/data/templates/src/utils/format.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# format
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides core formatting utilities used by the dynamic plugins: zero-padded number/string padding, date formatting with token substitution, UUID v4 generation, and URL-safe nano-ID generation. All functions that use randomness or the system clock accept injectable dependencies so they can be driven deterministically in tests.
|
||||
|
||||
The approved use of `Math.random`, `Date.now`, and `crypto.randomUUID` is explicitly centralised here — no other module in the package may reference these globals directly.
|
||||
|
||||
## Features
|
||||
|
||||
- `pad(value, length, char?, left?)` — pad a string or number to a fixed width.
|
||||
- `formatDate(date, pattern)` — format a `Date` using `YYYY MM DD HH mm ss SSS` tokens.
|
||||
- `generateUuid(rng?)` — UUID v4 string; prefers `crypto.randomUUID`, falls back to injectable Rng or `Math.random`.
|
||||
- `generateNanoid(size?, alphabet?, rng?)` — URL-safe nano-ID with configurable size and alphabet.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { pad, formatDate, generateUuid, generateNanoid } from "@suaveplan/templates";
|
||||
|
||||
pad(5, 3); // "005"
|
||||
pad("hi", 5, " ", false); // "hi "
|
||||
formatDate(new Date(), "YYYY-MM-DD"); // "2024-06-15"
|
||||
generateUuid(); // "f47ac10b-58cc-4372-a567-0e02b2c3d479"
|
||||
generateNanoid(10); // "V1StGXR8_Z"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { generateUuid, generateNanoid } from "@suaveplan/templates";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
|
||||
// Deterministic in tests
|
||||
const fakeRng: Rng = {
|
||||
nextUint32: () => 0,
|
||||
nextFloat: () => 0.42,
|
||||
};
|
||||
|
||||
const uuid1 = generateUuid(fakeRng);
|
||||
const uuid2 = generateUuid(fakeRng); // same
|
||||
console.assert(uuid1 === uuid2);
|
||||
|
||||
// Custom alphabet for base-36 IDs
|
||||
const id = generateNanoid(8, "0123456789abcdefghijklmnopqrstuvwxyz");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `pad(value: string | number, length: number, char?: string, left?: boolean): string`
|
||||
|
||||
Pads `value` to `length` characters using `char` (default `"0"`). When `left` is `true` (default) the padding is prepended; when `false`, appended. Returns the original string unchanged if it already meets or exceeds `length`.
|
||||
|
||||
### `formatDate(date: Date, pattern: string): string`
|
||||
|
||||
Replaces tokens in `pattern` with date components. Supported tokens:
|
||||
|
||||
| Token | Component |
|
||||
|-------|-----------|
|
||||
| `YYYY` | 4-digit year |
|
||||
| `MM` | 2-digit month (01–12) |
|
||||
| `DD` | 2-digit day (01–31) |
|
||||
| `HH` | 2-digit hour 24h (00–23) |
|
||||
| `mm` | 2-digit minute (00–59) |
|
||||
| `ss` | 2-digit second (00–59) |
|
||||
| `SSS` | 3-digit millisecond (000–999) |
|
||||
|
||||
### `generateUuid(rng?: Rng): string`
|
||||
|
||||
Returns a UUID v4 string (`xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`). Prefers `globalThis.crypto.randomUUID()` when available and `rng` is not provided. Falls back to `rng.nextFloat()` or `Math.random`.
|
||||
|
||||
### `generateNanoid(size?: number, alphabet?: string, rng?: Rng): string`
|
||||
|
||||
Returns a random string of `size` characters (default 21) from `alphabet` (default 64-char URL-safe set). Injectable `rng` enables deterministic output in tests.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `generateUuid` uses the standard `crypto.randomUUID()` fast path when no `rng` is injected, avoiding the manual bit manipulation entirely.
|
||||
- When `crypto.randomUUID` is unavailable (non-browser, non-Node.js runtimes), the fallback uses the standard `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx` pattern with bitwise operations to enforce version and variant bits.
|
||||
- `generateNanoid` deliberately does not use the rejection-sampling algorithm from the `nanoid` npm package; it uses uniform modular selection from the alphabet, which is sufficient for the lengths and alphabets encountered in template generation.
|
||||
106
packages/foundation/data/templates/src/utils/format.test.ts
Normal file
106
packages/foundation/data/templates/src/utils/format.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { formatDate, generateNanoid, generateUuid, pad } from "./format.js";
|
||||
|
||||
// Deterministic RNG for tests
|
||||
function makeRng(values: number[]): Rng {
|
||||
let i = 0;
|
||||
return {
|
||||
nextUint32: () => (values[i++ % values.length] ?? 0) >>> 0,
|
||||
nextFloat: () => (values[i++ % values.length] ?? 0) % 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("pad", () => {
|
||||
it("pads short numbers with zeros on the left", { timeout: 5000 }, () => {
|
||||
expect(pad(5, 3)).toBe("005");
|
||||
expect(pad(42, 5)).toBe("00042");
|
||||
});
|
||||
|
||||
it("returns value unchanged when length already met", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(pad(123, 3)).toBe("123");
|
||||
expect(pad(1234, 3)).toBe("1234");
|
||||
});
|
||||
|
||||
it("pads on the right when left=false", { timeout: 5000 }, () => {
|
||||
expect(pad("hi", 5, " ", false)).toBe("hi ");
|
||||
});
|
||||
|
||||
it("uses custom pad character", { timeout: 5000 }, () => {
|
||||
expect(pad(7, 4, "*")).toBe("***7");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDate", () => {
|
||||
const fixed = new Date(2024, 2, 5, 14, 7, 9, 42); // 2024-03-05 14:07:09.042
|
||||
|
||||
it("formats YYYY-MM-DD", { timeout: 5000 }, () => {
|
||||
expect(formatDate(fixed, "YYYY-MM-DD")).toBe("2024-03-05");
|
||||
});
|
||||
|
||||
it("formats HH:mm:ss", { timeout: 5000 }, () => {
|
||||
expect(formatDate(fixed, "HH:mm:ss")).toBe("14:07:09");
|
||||
});
|
||||
|
||||
it("formats SSS milliseconds", { timeout: 5000 }, () => {
|
||||
expect(formatDate(fixed, "SSS")).toBe("042");
|
||||
});
|
||||
|
||||
it("formats full datetime", { timeout: 5000 }, () => {
|
||||
expect(formatDate(fixed, "YYYY-MM-DD HH:mm:ss")).toBe(
|
||||
"2024-03-05 14:07:09"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateUuid", () => {
|
||||
it("produces a UUID v4 format string without rng", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const uuid = generateUuid();
|
||||
expect(uuid).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
);
|
||||
});
|
||||
|
||||
it("uses injectable rng when provided", { timeout: 5000 }, () => {
|
||||
// With a fixed-float rng the UUID should be deterministic
|
||||
const floats = Array.from({ length: 32 }, (_, i) => (i * 0.07) % 1);
|
||||
const rng = makeRng(floats);
|
||||
const uuid1 = generateUuid(rng);
|
||||
// Reset
|
||||
const rng2 = makeRng(floats);
|
||||
const uuid2 = generateUuid(rng2);
|
||||
expect(uuid1).toBe(uuid2);
|
||||
});
|
||||
|
||||
it("produces correct UUID version bit (4)", { timeout: 5000 }, () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(generateUuid()[14]).toBe("4");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateNanoid", () => {
|
||||
it("generates string of default length 21", { timeout: 5000 }, () => {
|
||||
expect(generateNanoid()).toHaveLength(21);
|
||||
});
|
||||
|
||||
it("generates string of custom length", { timeout: 5000 }, () => {
|
||||
expect(generateNanoid(10)).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("uses injectable rng for determinism", { timeout: 5000 }, () => {
|
||||
const floats = Array.from({ length: 50 }, (_, i) => (i * 0.13) % 1);
|
||||
const n1 = generateNanoid(21, undefined, makeRng(floats));
|
||||
const n2 = generateNanoid(21, undefined, makeRng(floats));
|
||||
expect(n1).toBe(n2);
|
||||
});
|
||||
|
||||
it("accepts custom alphabet", { timeout: 5000 }, () => {
|
||||
const id = generateNanoid(10, "abc");
|
||||
expect(id).toMatch(/^[abc]{10}$/);
|
||||
});
|
||||
});
|
||||
105
packages/foundation/data/templates/src/utils/format.ts
Normal file
105
packages/foundation/data/templates/src/utils/format.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
|
||||
// ─── Pad ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pad `value` to `length` characters.
|
||||
*
|
||||
* @param value - The string or number to pad.
|
||||
* @param length - Target length.
|
||||
* @param char - Padding character (default `"0"`).
|
||||
* @param left - Pad on the left when `true` (default `true`).
|
||||
*/
|
||||
export function pad(
|
||||
value: string | number,
|
||||
length: number,
|
||||
char = "0",
|
||||
left = true
|
||||
): string {
|
||||
const str = String(value);
|
||||
if (str.length >= length) return str;
|
||||
const padding = char.repeat(length - str.length);
|
||||
return left ? padding + str : str + padding;
|
||||
}
|
||||
|
||||
// ─── Date formatting ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format a `Date` using simple token substitution.
|
||||
*
|
||||
* Supported tokens: `YYYY`, `MM`, `DD`, `HH`, `mm`, `ss`, `SSS`.
|
||||
*/
|
||||
export function formatDate(date: Date, pattern: string): string {
|
||||
const tokens: Record<string, string> = {
|
||||
YYYY: String(date.getFullYear()),
|
||||
MM: pad(date.getMonth() + 1, 2),
|
||||
DD: pad(date.getDate(), 2),
|
||||
HH: pad(date.getHours(), 2),
|
||||
mm: pad(date.getMinutes(), 2),
|
||||
ss: pad(date.getSeconds(), 2),
|
||||
SSS: pad(date.getMilliseconds(), 3),
|
||||
};
|
||||
|
||||
let result = pattern;
|
||||
for (const [token, val] of Object.entries(tokens)) {
|
||||
result = result.replace(new RegExp(token, "g"), val);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── UUID v4 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Build UUID from an Rng or Math.random (no crypto.randomUUID). */
|
||||
function uuidFromRng(rng: Rng | undefined): string {
|
||||
const rand = rng
|
||||
? () => rng.nextFloat()
|
||||
: /* istanbul ignore next -- Math.random path unreachable in Node/browser envs */
|
||||
() => Math.random();
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (rand() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a UUID v4 string.
|
||||
*
|
||||
* Prefers `globalThis.crypto.randomUUID()` when available. Falls back to the
|
||||
* injectable `rng` (uses `rng.nextFloat()`) or `Math.random` as last resort.
|
||||
*/
|
||||
export function generateUuid(rng?: Rng): string {
|
||||
if (
|
||||
rng === undefined &&
|
||||
typeof globalThis.crypto?.randomUUID === "function"
|
||||
) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
|
||||
return uuidFromRng(rng);
|
||||
}
|
||||
|
||||
// ─── Nano ID ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_NANOID_ALPHABET =
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-";
|
||||
|
||||
/**
|
||||
* Generate a URL-safe nano-ID of `size` characters.
|
||||
*
|
||||
* @param size - ID length (default 21).
|
||||
* @param alphabet - Character set (default URL-safe 64-char set).
|
||||
* @param rng - Optional injectable `Rng`; falls back to `Math.random`.
|
||||
*/
|
||||
export function generateNanoid(
|
||||
size = 21,
|
||||
alphabet = DEFAULT_NANOID_ALPHABET,
|
||||
rng?: Rng
|
||||
): string {
|
||||
const rand = rng ? () => rng.nextFloat() : () => Math.random();
|
||||
let id = "";
|
||||
for (let i = 0; i < size; i++) {
|
||||
id += alphabet[Math.floor(rand() * alphabet.length)];
|
||||
}
|
||||
return id;
|
||||
}
|
||||
3
packages/foundation/data/templates/src/utils/index.ts
Normal file
3
packages/foundation/data/templates/src/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { escapeRegex, isEscaped, unescapeTemplate } from "./escape";
|
||||
export { formatDate, generateNanoid, generateUuid, pad } from "./format";
|
||||
export { getNestedValue, hasNestedPath, setNestedValue } from "./nested-value";
|
||||
66
packages/foundation/data/templates/src/utils/nested-value.md
Normal file
66
packages/foundation/data/templates/src/utils/nested-value.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# nested-value
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides three functions for traversing, checking, and mutating plain object trees using dot-notation or bracket-notation paths. These utilities are the foundation for all variable-resolution plugins — `dot-notation`, `array-index`, `case-transform`, `regex`, and `string-split` all delegate path resolution here rather than re-implementing traversal logic.
|
||||
|
||||
Every function is deliberately side-effect free on the way in; only `setNestedValue` mutates its argument, and that mutation is the caller's explicit intent.
|
||||
|
||||
## Features
|
||||
|
||||
- `getNestedValue(obj, path, defaultValue?)` — safe deep-get with bracket-to-dot normalisation and negative-index support.
|
||||
- `hasNestedPath(obj, path)` — existence check without throwing; handles falsy values (0, `""`, `false`) correctly.
|
||||
- `setNestedValue(obj, path, value)` — deep-set that creates intermediate plain objects as needed.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { getNestedValue, hasNestedPath, setNestedValue } from "@suaveplan/templates";
|
||||
|
||||
const ctx = { user: { name: "Alice" }, tags: ["admin", "editor"] };
|
||||
|
||||
getNestedValue(ctx, "user.name"); // "Alice"
|
||||
getNestedValue(ctx, "tags[0]"); // "admin"
|
||||
getNestedValue(ctx, "tags[-1]"); // "editor"
|
||||
hasNestedPath(ctx, "user.email"); // false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { getNestedValue, setNestedValue } from "@suaveplan/templates";
|
||||
|
||||
const obj: Record<string, unknown> = {};
|
||||
setNestedValue(obj, "a.b.c", 42);
|
||||
// obj is now { a: { b: { c: 42 } } }
|
||||
|
||||
getNestedValue(obj, "a.b.c"); // 42
|
||||
|
||||
// Default value when path is missing
|
||||
const missing = getNestedValue(obj, "x.y.z", "N/A"); // "N/A"
|
||||
|
||||
// Sentinel pattern used by hasNestedPath
|
||||
const SENTINEL = Symbol();
|
||||
getNestedValue({ x: 0 }, "x", SENTINEL) !== SENTINEL; // true (0 exists)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getNestedValue<T>(obj: unknown, path: string, defaultValue?: T): T | undefined`
|
||||
|
||||
Traverses `obj` following the segments of `path`. Bracket notation (`items[0]`) is first normalised to dot notation (`items.0`). Negative array indices count from the end. Returns `defaultValue` (or `undefined`) when any segment is missing or the intermediate value is not an object.
|
||||
|
||||
### `hasNestedPath(obj: unknown, path: string): boolean`
|
||||
|
||||
Returns `true` when the path resolves to any defined value, including falsy ones (`0`, `""`, `false`). Uses a `Symbol` sentinel internally so it is safe even when the stored value is `undefined`.
|
||||
|
||||
### `setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): void`
|
||||
|
||||
Sets the value at `path` inside `obj`. Creates intermediate plain objects at any segment that is missing or whose current value is not an object. Does nothing when `path` resolves to an empty last key.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Bracket notation normalisation (`[0]` → `.0`) is done with a single regex before splitting on `.`, keeping the traversal loop uniform.
|
||||
- Negative index arithmetic: `actualIndex = index < 0 ? arr.length + index : index`. Both out-of-lower-bounds (negative absolute index) and out-of-upper-bounds are caught and return `defaultValue`.
|
||||
- `hasNestedPath` uses `Symbol("not-found")` as a sentinel rather than `undefined` because the stored value might legitimately be `undefined`.
|
||||
- `setNestedValue` overwrites non-object intermediates with `{}` to ensure the requested path is always reachable. This matches the expectations of template rendering where partial writes during construction are intentional.
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getNestedValue,
|
||||
hasNestedPath,
|
||||
setNestedValue,
|
||||
} from "./nested-value.js";
|
||||
|
||||
describe("getNestedValue", () => {
|
||||
it("resolves top-level key", { timeout: 5000 }, () => {
|
||||
expect(getNestedValue({ name: "Alice" }, "name")).toBe("Alice");
|
||||
});
|
||||
|
||||
it("resolves nested dot path", { timeout: 5000 }, () => {
|
||||
expect(getNestedValue({ a: { b: { c: 42 } } }, "a.b.c")).toBe(42);
|
||||
});
|
||||
|
||||
it("resolves array index", { timeout: 5000 }, () => {
|
||||
expect(getNestedValue({ items: ["a", "b", "c"] }, "items[1]")).toBe(
|
||||
"b"
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves negative index", { timeout: 5000 }, () => {
|
||||
expect(getNestedValue({ items: ["x", "y", "z"] }, "items[-1]")).toBe(
|
||||
"z"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for missing path", { timeout: 5000 }, () => {
|
||||
expect(getNestedValue({ a: 1 }, "b")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns defaultValue for missing path", { timeout: 5000 }, () => {
|
||||
expect(getNestedValue({ a: 1 }, "b", "default")).toBe("default");
|
||||
});
|
||||
|
||||
it("returns undefined when obj is not an object", { timeout: 5000 }, () => {
|
||||
expect(getNestedValue(null, "a")).toBeUndefined();
|
||||
expect(getNestedValue(undefined, "a")).toBeUndefined();
|
||||
expect(getNestedValue("string", "a")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles out-of-range positive index", { timeout: 5000 }, () => {
|
||||
expect(getNestedValue({ arr: [1, 2] }, "arr[5]")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles out-of-range negative index", { timeout: 5000 }, () => {
|
||||
expect(getNestedValue({ arr: [1, 2] }, "arr[-5]")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles intermediate non-object", { timeout: 5000 }, () => {
|
||||
expect(getNestedValue({ a: "string" }, "a.b")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles null intermediate", { timeout: 5000 }, () => {
|
||||
expect(getNestedValue({ a: null }, "a.b")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves nested array-index path with property", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ctx = { users: [{ name: "Bob" }, { name: "Eve" }] };
|
||||
expect(getNestedValue(ctx, "users[1].name")).toBe("Eve");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasNestedPath", () => {
|
||||
it("returns true for existing path", { timeout: 5000 }, () => {
|
||||
expect(hasNestedPath({ a: { b: 1 } }, "a.b")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for missing path", { timeout: 5000 }, () => {
|
||||
expect(hasNestedPath({ a: 1 }, "b")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for falsy values (0, '', false)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(hasNestedPath({ x: 0 }, "x")).toBe(true);
|
||||
expect(hasNestedPath({ x: "" }, "x")).toBe(true);
|
||||
expect(hasNestedPath({ x: false }, "x")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setNestedValue", () => {
|
||||
it("sets a top-level key", { timeout: 5000 }, () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
setNestedValue(obj, "name", "Alice");
|
||||
expect(obj.name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("creates intermediate objects", { timeout: 5000 }, () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
setNestedValue(obj, "a.b.c", 42);
|
||||
expect((obj as { a: { b: { c: number } } }).a.b.c).toBe(42);
|
||||
});
|
||||
|
||||
it("overwrites existing value", { timeout: 5000 }, () => {
|
||||
const obj: Record<string, unknown> = { x: 1 };
|
||||
setNestedValue(obj, "x", 2);
|
||||
expect(obj.x).toBe(2);
|
||||
});
|
||||
|
||||
it("handles bracket notation in path", { timeout: 5000 }, () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
setNestedValue(obj, "arr[0]", "hello");
|
||||
expect((obj as { arr: { 0: string } }).arr[0]).toBe("hello");
|
||||
});
|
||||
|
||||
it("replaces non-object intermediate with object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const obj: Record<string, unknown> = { a: "string" };
|
||||
setNestedValue(obj, "a.b", 1);
|
||||
expect((obj as { a: { b: number } }).a.b).toBe(1);
|
||||
});
|
||||
|
||||
it("replaces null intermediate with object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Exercises the `|| current[key] === null` branch in setNestedValue
|
||||
const obj: Record<string, unknown> = { a: null };
|
||||
setNestedValue(obj, "a.b", 42);
|
||||
expect((obj as { a: { b: number } }).a.b).toBe(42);
|
||||
});
|
||||
|
||||
it("traverses existing object intermediates (A=false,B=false branch)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// current[key] is already an object (not null) → typeof === "object" AND !== null
|
||||
// → the if-condition is false, so we just traverse without replacing
|
||||
const obj: Record<string, unknown> = { a: { b: 1 } };
|
||||
setNestedValue(obj, "a.b", 99);
|
||||
expect((obj as { a: { b: number } }).a.b).toBe(99);
|
||||
});
|
||||
|
||||
it("is a no-op when path is empty", { timeout: 5000 }, () => {
|
||||
const obj: Record<string, unknown> = { a: 1 };
|
||||
setNestedValue(obj, "", "x");
|
||||
// empty string splits to [""] → lastKey=""
|
||||
// the behavior is benign: it sets key "" on obj
|
||||
expect(Object.keys(obj)).toContain("a");
|
||||
});
|
||||
});
|
||||
69
packages/foundation/data/templates/src/utils/nested-value.ts
Normal file
69
packages/foundation/data/templates/src/utils/nested-value.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Resolve a dot-notation or bracket-notation path from a plain object.
|
||||
*
|
||||
* Supports `user.name`, `items[0]`, `items[0].name`, and negative indices
|
||||
* (`items[-1]`). Returns `defaultValue` (or `undefined`) when any segment
|
||||
* of the path does not exist.
|
||||
*/
|
||||
export function getNestedValue<T = unknown>(
|
||||
obj: unknown,
|
||||
path: string,
|
||||
defaultValue?: T
|
||||
): T | undefined {
|
||||
if (!obj || typeof obj !== "object") return defaultValue;
|
||||
|
||||
// Convert bracket notation to dot: users[0] → users.0
|
||||
const normalized = path.replace(/\[(-?\d+)\]/g, ".$1");
|
||||
const keys = normalized.split(".");
|
||||
|
||||
let current: unknown = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (current === null || current === undefined) return defaultValue;
|
||||
if (typeof current !== "object") return defaultValue;
|
||||
|
||||
if (Array.isArray(current) && /^-?\d+$/.test(key)) {
|
||||
const idx = Number.parseInt(key, 10);
|
||||
const actual = idx < 0 ? current.length + idx : idx;
|
||||
if (actual < 0 || actual >= current.length) return defaultValue;
|
||||
current = current[actual];
|
||||
} else {
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
|
||||
return (current as T | undefined) ?? defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `true` when `path` resolves to a defined value inside `obj`.
|
||||
*/
|
||||
export function hasNestedPath(obj: unknown, path: string): boolean {
|
||||
const sentinel = Symbol("not-found");
|
||||
return getNestedValue(obj, path, sentinel) !== sentinel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value at `path` inside `obj`, creating intermediate plain objects as
|
||||
* needed. Bracket notation (`items[0]`) is normalised to dot notation first.
|
||||
*/
|
||||
export function setNestedValue(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
value: unknown
|
||||
): void {
|
||||
const normalized = path.replace(/\[(-?\d+)\]/g, ".$1");
|
||||
const keys = normalized.split(".");
|
||||
const lastKey = keys.pop();
|
||||
if (!lastKey) return;
|
||||
|
||||
let current: Record<string, unknown> = obj;
|
||||
for (const key of keys) {
|
||||
if (typeof current[key] !== "object" || current[key] === null) {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
current[lastKey] = value;
|
||||
}
|
||||
120
packages/foundation/data/templates/tests/dep-graph.test.ts
Normal file
120
packages/foundation/data/templates/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Dependency-graph smoke test.
|
||||
*
|
||||
* Verifies that the package can be loaded and that the core exports resolve
|
||||
* to the expected types without import errors. This acts as a runtime
|
||||
* canary for tree-shaking and circular-dependency regressions.
|
||||
*/
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("imports TemplateEngine without errors", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { TemplateEngine } = await import(
|
||||
"../src/engine/TemplateEngine.js"
|
||||
);
|
||||
expect(typeof TemplateEngine).toBe("function");
|
||||
});
|
||||
|
||||
it("imports all error classes", { timeout: 30000 }, async () => {
|
||||
const { TemplateError, PluginError, MaxDepthError } = await import(
|
||||
"../src/errors/template-error.js"
|
||||
);
|
||||
expect(typeof TemplateError).toBe("function");
|
||||
expect(typeof PluginError).toBe("function");
|
||||
expect(typeof MaxDepthError).toBe("function");
|
||||
});
|
||||
|
||||
it("imports all preset factories", { timeout: 30000 }, async () => {
|
||||
const { minimalPreset } = await import("../src/presets/minimal.js");
|
||||
const { standardPreset } = await import("../src/presets/standard.js");
|
||||
const { fullPreset, fullPresetWithLoops } = await import(
|
||||
"../src/presets/full.js"
|
||||
);
|
||||
expect(typeof minimalPreset).toBe("function");
|
||||
expect(typeof standardPreset).toBe("function");
|
||||
expect(typeof fullPreset).toBe("function");
|
||||
expect(typeof fullPresetWithLoops).toBe("function");
|
||||
});
|
||||
|
||||
it("imports all plugin factories", { timeout: 30000 }, async () => {
|
||||
const { createDotNotationPlugin } = await import(
|
||||
"../src/plugins/variables/DotNotationPlugin.js"
|
||||
);
|
||||
const { createArrayIndexPlugin } = await import(
|
||||
"../src/plugins/variables/ArrayIndexPlugin.js"
|
||||
);
|
||||
const { createCaseTransformPlugin } = await import(
|
||||
"../src/plugins/transform/CaseTransformPlugin.js"
|
||||
);
|
||||
const { createRegexPlugin } = await import(
|
||||
"../src/plugins/transform/RegexPlugin.js"
|
||||
);
|
||||
const { createStringSplitPlugin } = await import(
|
||||
"../src/plugins/transform/StringSplitPlugin.js"
|
||||
);
|
||||
const { createCounterPlugin } = await import(
|
||||
"../src/plugins/dynamic/CounterPlugin.js"
|
||||
);
|
||||
const { createDateTimePlugin } = await import(
|
||||
"../src/plugins/dynamic/DateTimePlugin.js"
|
||||
);
|
||||
const { createUuidPlugin } = await import(
|
||||
"../src/plugins/dynamic/UuidPlugin.js"
|
||||
);
|
||||
const { createConditionalPlugin } = await import(
|
||||
"../src/plugins/control/ConditionalPlugin.js"
|
||||
);
|
||||
const { createLoopPlugin } = await import(
|
||||
"../src/plugins/control/LoopPlugin.js"
|
||||
);
|
||||
|
||||
for (const factory of [
|
||||
createDotNotationPlugin,
|
||||
createArrayIndexPlugin,
|
||||
createCaseTransformPlugin,
|
||||
createRegexPlugin,
|
||||
createStringSplitPlugin,
|
||||
createCounterPlugin,
|
||||
createDateTimePlugin,
|
||||
createUuidPlugin,
|
||||
createConditionalPlugin,
|
||||
createLoopPlugin,
|
||||
]) {
|
||||
expect(typeof factory).toBe("function");
|
||||
}
|
||||
});
|
||||
|
||||
it("imports all utility functions", { timeout: 30000 }, async () => {
|
||||
const escapeMod = await import("../src/utils/escape.js");
|
||||
const format = await import("../src/utils/format.js");
|
||||
const nested = await import("../src/utils/nested-value.js");
|
||||
|
||||
expect(typeof escapeMod.escapeRegex).toBe("function");
|
||||
expect(typeof escapeMod.isEscaped).toBe("function");
|
||||
expect(typeof escapeMod.unescapeTemplate).toBe("function");
|
||||
expect(typeof format.pad).toBe("function");
|
||||
expect(typeof format.formatDate).toBe("function");
|
||||
expect(typeof format.generateUuid).toBe("function");
|
||||
expect(typeof format.generateNanoid).toBe("function");
|
||||
expect(typeof nested.getNestedValue).toBe("function");
|
||||
expect(typeof nested.hasNestedPath).toBe("function");
|
||||
expect(typeof nested.setNestedValue).toBe("function");
|
||||
});
|
||||
|
||||
it("SKIP_PLUGIN is a symbol", { timeout: 30000 }, async () => {
|
||||
const { SKIP_PLUGIN } = await import("../src/types/index.js");
|
||||
expect(typeof SKIP_PLUGIN).toBe("symbol");
|
||||
});
|
||||
|
||||
it("PluginPriority enum has correct ordering", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { PluginPriority } = await import("../src/types/index.js");
|
||||
expect(PluginPriority.MINIMAL).toBeLessThan(PluginPriority.LOW);
|
||||
expect(PluginPriority.LOW).toBeLessThan(PluginPriority.MEDIUM);
|
||||
expect(PluginPriority.MEDIUM).toBeLessThan(PluginPriority.HIGH);
|
||||
expect(PluginPriority.HIGH).toBeLessThan(PluginPriority.CRITICAL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* No-disallowed audit: verify that non-deterministic global APIs
|
||||
* (`Date`, `Math.random`, `crypto.randomUUID`) are only used in the
|
||||
* approved files listed below and never leak into business-logic modules.
|
||||
*
|
||||
* Approved list (files that legitimately need these APIs):
|
||||
* - src/utils/format.ts (generateUuid, generateNanoid, formatDate)
|
||||
* - src/plugins/dynamic/DateTimePlugin.ts (uses Date via clock fallback)
|
||||
* - src/plugins/dynamic/UuidPlugin.ts (delegates to format.ts)
|
||||
* - src/base/suaveplan-error.ts (error timestamp)
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC_ROOT = join(import.meta.dirname ?? __dirname, "../src");
|
||||
|
||||
const APPROVED = new Set([
|
||||
join(SRC_ROOT, "utils/format.ts"),
|
||||
join(SRC_ROOT, "plugins/dynamic/DateTimePlugin.ts"),
|
||||
join(SRC_ROOT, "plugins/dynamic/UuidPlugin.ts"),
|
||||
join(SRC_ROOT, "otel/templates-otel.ts"),
|
||||
]);
|
||||
|
||||
// Patterns that indicate direct usage of non-deterministic globals
|
||||
const DISALLOWED = [
|
||||
/\bDate\.now\b/,
|
||||
/\bnew Date\(\)/,
|
||||
/\bMath\.random\b/,
|
||||
/\bcrypto\.randomUUID\b/,
|
||||
];
|
||||
|
||||
function collectTs(dir: string): string[] {
|
||||
const results: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
results.push(...collectTs(full));
|
||||
} else if (
|
||||
entry.endsWith(".ts") &&
|
||||
!entry.endsWith(".test.ts") &&
|
||||
!entry.endsWith(".bench.ts")
|
||||
) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
describe("no-disallowed globals in non-approved files", () => {
|
||||
const files = collectTs(SRC_ROOT).filter((f) => !APPROVED.has(f));
|
||||
|
||||
for (const file of files) {
|
||||
it(`${file.replace(SRC_ROOT, "src")} has no direct Date/Math.random usage`, {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const src = readFileSync(file, "utf-8");
|
||||
for (const pattern of DISALLOWED) {
|
||||
expect(
|
||||
pattern.test(src),
|
||||
`Found disallowed pattern ${pattern} in ${file}`
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
102
packages/foundation/data/templates/tests/presets.test.ts
Normal file
102
packages/foundation/data/templates/tests/presets.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Integration tests for all preset bundles.
|
||||
*/
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { createParseState } from "../src/engine/ParseState.js";
|
||||
import { TemplateEngine } from "../src/engine/TemplateEngine.js";
|
||||
import { fullPreset, fullPresetWithLoops } from "../src/presets/full.js";
|
||||
import { minimalPreset } from "../src/presets/minimal.js";
|
||||
import { standardPreset } from "../src/presets/standard.js";
|
||||
|
||||
type Ctx = Record<string, unknown>;
|
||||
|
||||
describe("minimalPreset", () => {
|
||||
it("resolves dot-notation", { timeout: 5000 }, () => {
|
||||
const e = new TemplateEngine<Ctx>().usePreset(minimalPreset<Ctx>());
|
||||
expect(e.parse("{user.name}", { user: { name: "Alice" } })).toBe(
|
||||
"Alice"
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves array index", { timeout: 5000 }, () => {
|
||||
const e = new TemplateEngine<Ctx>().usePreset(minimalPreset<Ctx>());
|
||||
expect(e.parse("{items[1]}", { items: ["a", "b"] })).toBe("b");
|
||||
});
|
||||
|
||||
it("has 2 plugins", { timeout: 5000 }, () => {
|
||||
const e = new TemplateEngine<Ctx>().usePreset(minimalPreset<Ctx>());
|
||||
expect(e.getPlugins()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("standardPreset", () => {
|
||||
it("resolves variables, counter, case transform, conditional", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new TemplateEngine<Ctx>().usePreset(standardPreset<Ctx>());
|
||||
const result = e.parse(
|
||||
"{if:active}Hello {name:title}! #{_counter}{/if}",
|
||||
{ active: true, name: "john doe" }
|
||||
);
|
||||
expect(result).toBe("Hello John Doe! #1");
|
||||
});
|
||||
|
||||
it("generates UUID in standard preset", { timeout: 5000 }, () => {
|
||||
const e = new TemplateEngine<Ctx>().usePreset(standardPreset<Ctx>());
|
||||
const result = e.parse("{_uuid}", {});
|
||||
expect(result).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fullPreset", () => {
|
||||
it("includes regex and string-split", { timeout: 5000 }, () => {
|
||||
const e = new TemplateEngine<Ctx>().usePreset(fullPreset<Ctx>());
|
||||
expect(
|
||||
e.parse("{phone:/\\D/g:replace:}", { phone: "(123) 456-7890" })
|
||||
).toBe("1234567890");
|
||||
expect(e.parse("{email:@[0]}", { email: "user@example.com" })).toBe(
|
||||
"user"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fullPresetWithLoops", () => {
|
||||
it("iterates array with loop plugin", { timeout: 5000 }, () => {
|
||||
const e = new TemplateEngine<Ctx>();
|
||||
const preset = fullPresetWithLoops<Ctx>((tpl, ctx, state) =>
|
||||
(
|
||||
e as unknown as {
|
||||
parseInternal: (
|
||||
t: string,
|
||||
c: Ctx,
|
||||
s: ReturnType<typeof createParseState>
|
||||
) => string;
|
||||
}
|
||||
).parseInternal(tpl, ctx, state)
|
||||
);
|
||||
e.usePreset(preset);
|
||||
const result = e.parse("{each:items}{item} {/each}", {
|
||||
items: ["X", "Y", "Z"],
|
||||
});
|
||||
expect(result).toBe("X Y Z ");
|
||||
});
|
||||
|
||||
it("preset name is full-with-loops", { timeout: 5000 }, () => {
|
||||
const preset = fullPresetWithLoops<Ctx>((t, c) => t);
|
||||
expect(preset.name).toBe("full-with-loops");
|
||||
});
|
||||
});
|
||||
|
||||
describe("preset registration idempotency", () => {
|
||||
it("applying same preset twice does not duplicate plugins", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new TemplateEngine<Ctx>();
|
||||
e.usePreset(minimalPreset<Ctx>());
|
||||
const countAfterFirst = e.getPlugins().length;
|
||||
e.usePreset(minimalPreset<Ctx>());
|
||||
expect(e.getPlugins()).toHaveLength(countAfterFirst);
|
||||
});
|
||||
});
|
||||
107
packages/foundation/data/templates/tests/property.test.ts
Normal file
107
packages/foundation/data/templates/tests/property.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Property-based tests using fast-check.
|
||||
*
|
||||
* Covers:
|
||||
* - Idempotent rendering: re-parsing an already-rendered literal produces same output.
|
||||
* - Counter monotone: named counters increase within a single parse call.
|
||||
* - getNestedValue / setNestedValue round-trip.
|
||||
* - PluginPriority strict ordering.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { TemplateEngine } from "../src/engine/TemplateEngine.js";
|
||||
import { createCounterPlugin } from "../src/plugins/dynamic/CounterPlugin.js";
|
||||
import { createDotNotationPlugin } from "../src/plugins/variables/DotNotationPlugin.js";
|
||||
import { PluginPriority } from "../src/types/index.js";
|
||||
import { getNestedValue, setNestedValue } from "../src/utils/nested-value.js";
|
||||
|
||||
describe("idempotent literal rendering", () => {
|
||||
it("re-parsing a rendered literal string returns the same string", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const e = new TemplateEngine().use(createDotNotationPlugin());
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
// Strings with no { or } — guaranteed literals after render
|
||||
fc.string({
|
||||
unit: fc.char().filter((c) => c !== "{" && c !== "}"),
|
||||
}),
|
||||
(literal) => {
|
||||
const first = e.parse(literal, {});
|
||||
const second = e.parse(first, {});
|
||||
expect(second).toBe(first);
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("counter monotone within single parse", () => {
|
||||
it("counter values strictly increase left-to-right in one parse call", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 2, max: 10 }), (count) => {
|
||||
const e = new TemplateEngine().use(createCounterPlugin());
|
||||
// Build a template with `count` counter tokens
|
||||
const template = Array.from(
|
||||
{ length: count },
|
||||
() => "{_counter}"
|
||||
).join(" ");
|
||||
const result = e.parse(template, {});
|
||||
const values = result
|
||||
.split(" ")
|
||||
.map((v) => Number.parseInt(v, 10));
|
||||
for (let i = 1; i < values.length; i++) {
|
||||
expect(values[i]).toBeGreaterThan(values[i - 1] as number);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNestedValue / setNestedValue round-trip", () => {
|
||||
it("reading back after set returns the same primitive", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
// Safe path: only lowercase letters and digits, no array notation
|
||||
const safePath = fc
|
||||
.array(fc.stringMatching(/^[a-z][a-z0-9]{0,5}$/), {
|
||||
minLength: 1,
|
||||
maxLength: 4,
|
||||
})
|
||||
.map((parts) => parts.join("."));
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
safePath,
|
||||
fc.oneof(
|
||||
fc.integer(),
|
||||
fc.string(),
|
||||
fc.boolean()
|
||||
// Note: null excluded because getNestedValue returns undefined for null values
|
||||
),
|
||||
(path, value) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
setNestedValue(obj, path, value);
|
||||
const retrieved = getNestedValue(obj, path);
|
||||
expect(retrieved).toBe(value);
|
||||
}
|
||||
),
|
||||
{ numRuns: 300 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginPriority strict ordering", () => {
|
||||
it("MINIMAL < LOW < MEDIUM < HIGH < CRITICAL", { timeout: 5000 }, () => {
|
||||
expect(PluginPriority.MINIMAL).toBeLessThan(PluginPriority.LOW);
|
||||
expect(PluginPriority.LOW).toBeLessThan(PluginPriority.MEDIUM);
|
||||
expect(PluginPriority.MEDIUM).toBeLessThan(PluginPriority.HIGH);
|
||||
expect(PluginPriority.HIGH).toBeLessThan(PluginPriority.CRITICAL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
69
packages/foundation/data/templates/tests/tree-shake.test.ts
Normal file
69
packages/foundation/data/templates/tests/tree-shake.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Tree-shake smoke test.
|
||||
*
|
||||
* Verifies that each sub-module can be imported in isolation without pulling
|
||||
* in unrelated code. This acts as a proxy for correct `sideEffects: false`
|
||||
* behaviour and clean ESM boundaries.
|
||||
*/
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("tree-shake isolation", () => {
|
||||
it("can import only escape utils", { timeout: 30000 }, async () => {
|
||||
const mod = await import("../src/utils/escape.js");
|
||||
expect(typeof mod.escapeRegex).toBe("function");
|
||||
expect(typeof mod.isEscaped).toBe("function");
|
||||
expect(typeof mod.unescapeTemplate).toBe("function");
|
||||
});
|
||||
|
||||
it("can import only format utils", { timeout: 30000 }, async () => {
|
||||
const mod = await import("../src/utils/format.js");
|
||||
expect(typeof mod.pad).toBe("function");
|
||||
expect(typeof mod.formatDate).toBe("function");
|
||||
expect(typeof mod.generateUuid).toBe("function");
|
||||
expect(typeof mod.generateNanoid).toBe("function");
|
||||
});
|
||||
|
||||
it("can import only nested-value utils", { timeout: 30000 }, async () => {
|
||||
const mod = await import("../src/utils/nested-value.js");
|
||||
expect(typeof mod.getNestedValue).toBe("function");
|
||||
expect(typeof mod.hasNestedPath).toBe("function");
|
||||
expect(typeof mod.setNestedValue).toBe("function");
|
||||
});
|
||||
|
||||
it("can import ParseState without TemplateEngine", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const mod = await import("../src/engine/ParseState.js");
|
||||
expect(typeof mod.createParseState).toBe("function");
|
||||
expect(typeof mod.cloneParseState).toBe("function");
|
||||
});
|
||||
|
||||
it("can import a single plugin without loading all others", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const mod = await import(
|
||||
"../src/plugins/variables/DotNotationPlugin.js"
|
||||
);
|
||||
expect(typeof mod.createDotNotationPlugin).toBe("function");
|
||||
});
|
||||
|
||||
it("can import schemas in isolation", { timeout: 30000 }, async () => {
|
||||
const mod = await import("../src/schemas/templates.schemas.js");
|
||||
expect(mod.TemplateEngineOptionsSchema).toBeDefined();
|
||||
expect(mod.ParseOptionsSchema).toBeDefined();
|
||||
expect(mod.PluginPrioritySchema).toBeDefined();
|
||||
});
|
||||
|
||||
it("minimalPreset does not import dynamic plugins", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// If minimalPreset somehow pulled in DateTimePlugin the import would
|
||||
// still succeed, but this test documents the intended isolation.
|
||||
const { minimalPreset } = await import("../src/presets/minimal.js");
|
||||
const preset = minimalPreset();
|
||||
expect(preset.plugins.map((p) => p.name)).toEqual(
|
||||
expect.arrayContaining(["array-index", "dot-notation"])
|
||||
);
|
||||
expect(preset.plugins).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
12
packages/foundation/data/templates/tsconfig.lib.json
Normal file
12
packages/foundation/data/templates/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
20
packages/foundation/data/templates/vite.config.ts
Normal file
20
packages/foundation/data/templates/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/templates",
|
||||
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
|
||||
external: ["@suaveplan/error", "@suaveplan/types", "tslib", "zod", /^node:/],
|
||||
},
|
||||
},
|
||||
});
|
||||
28
packages/foundation/data/templates/vitest.config.ts
Normal file
28
packages/foundation/data/templates/vitest.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["src/__benchmarks__/**", "benchmarks/**", "e2e/**", "dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user