Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View 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.

View File

@@ -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.

View File

@@ -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.

View 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`.

View 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`).

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View 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.

View 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.

View 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.

View File

@@ -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.

View 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.

View 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.

View 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.

View 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.