@telorun/templating 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +246 -0
  3. package/dist/builtins.d.ts +16 -0
  4. package/dist/builtins.d.ts.map +1 -0
  5. package/dist/builtins.js +28 -0
  6. package/dist/cel/analyze.d.ts +17 -0
  7. package/dist/cel/analyze.d.ts.map +1 -0
  8. package/dist/cel/analyze.js +116 -0
  9. package/dist/cel/compile.d.ts +15 -0
  10. package/dist/cel/compile.d.ts.map +1 -0
  11. package/dist/cel/compile.js +41 -0
  12. package/dist/cel/environment.d.ts +20 -0
  13. package/dist/cel/environment.d.ts.map +1 -0
  14. package/dist/cel/environment.js +40 -0
  15. package/dist/cel/walk.d.ts +13 -0
  16. package/dist/cel/walk.d.ts.map +1 -0
  17. package/dist/cel/walk.js +36 -0
  18. package/dist/engine.d.ts +47 -0
  19. package/dist/engine.d.ts.map +1 -0
  20. package/dist/engine.js +1 -0
  21. package/dist/engines/cel.d.ts +7 -0
  22. package/dist/engines/cel.d.ts.map +1 -0
  23. package/dist/engines/cel.js +36 -0
  24. package/dist/engines/literal.d.ts +6 -0
  25. package/dist/engines/literal.d.ts.map +1 -0
  26. package/dist/engines/literal.js +12 -0
  27. package/dist/index.d.ts +12 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +10 -0
  30. package/dist/registry.d.ts +10 -0
  31. package/dist/registry.d.ts.map +1 -0
  32. package/dist/registry.js +19 -0
  33. package/dist/sentinel.d.ts +14 -0
  34. package/dist/sentinel.d.ts.map +1 -0
  35. package/dist/sentinel.js +10 -0
  36. package/dist/yaml-tags.d.ts +28 -0
  37. package/dist/yaml-tags.d.ts.map +1 -0
  38. package/dist/yaml-tags.js +61 -0
  39. package/package.json +52 -0
  40. package/src/builtins.ts +33 -0
  41. package/src/cel/analyze.ts +125 -0
  42. package/src/cel/compile.ts +47 -0
  43. package/src/cel/environment.ts +50 -0
  44. package/src/cel/walk.ts +43 -0
  45. package/src/engine.ts +53 -0
  46. package/src/engines/cel.ts +40 -0
  47. package/src/engines/literal.ts +16 -0
  48. package/src/index.ts +24 -0
  49. package/src/registry.ts +25 -0
  50. package/src/sentinel.ts +25 -0
  51. package/src/yaml-tags.ts +71 -0
@@ -0,0 +1,43 @@
1
+ import { isTaggedSentinel } from "../sentinel.js";
2
+ import { TEMPLATE_REGEX } from "./compile.js";
3
+
4
+ /** Walks `value` and emits each templated source segment with its dotted
5
+ * path (e.g. `routes[0].handler.body`) and the engine that owns it.
6
+ *
7
+ * - Untagged strings: every `${{ ... }}` segment is emitted with
8
+ * `engineName = "cel"` (the implicit engine for the legacy interpolation
9
+ * syntax).
10
+ * - Tagged sentinels: emitted once with the sentinel's declared engine.
11
+ * This includes engines that may produce no diagnostics (`literal`) —
12
+ * routing through the registry stays generic so adding a third engine
13
+ * that wants real analysis doesn't require touching the walker.
14
+ * - Compiled values are skipped so a precompiled tree won't be re-walked. */
15
+ export function walkCelExpressions(
16
+ value: unknown,
17
+ path: string,
18
+ cb: (source: string, path: string, engineName: string) => void,
19
+ ): void {
20
+ if (isTaggedSentinel(value)) {
21
+ cb(value.source, path, value.engine);
22
+ return;
23
+ }
24
+ if (typeof value === "string") {
25
+ for (const m of value.matchAll(TEMPLATE_REGEX)) {
26
+ cb(m[1].trim(), path, "cel");
27
+ }
28
+ return;
29
+ }
30
+ if (Array.isArray(value)) {
31
+ value.forEach((v, i) => walkCelExpressions(v, `${path}[${i}]`, cb));
32
+ return;
33
+ }
34
+ if (
35
+ value !== null &&
36
+ typeof value === "object" &&
37
+ !(value as { __compiled?: unknown }).__compiled
38
+ ) {
39
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
40
+ walkCelExpressions(v, path ? `${path}.${k}` : k, cb);
41
+ }
42
+ }
43
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,53 @@
1
+ import type { Environment } from "@marcbachmann/cel-js";
2
+ import type { CompiledValue } from "@telorun/sdk";
3
+
4
+ /** Compile-time environment passed to `engine.compile`. Engines that need to
5
+ * parse against a CEL environment (the `cel` engine) read it from `celEnv`;
6
+ * engines that resolve fully at compile time (`literal`) ignore it. */
7
+ export interface CompileEnv {
8
+ readonly celEnv: Environment;
9
+ }
10
+
11
+ /** Analyze-time environment passed to `engine.analyze`. The walker resolves
12
+ * the path-specific effective context (kernel globals merged in, x-telo-context
13
+ * applied) and hands the engine a single closed schema. The engine validates
14
+ * member-access chains against it. `null` means "open context" — no chain
15
+ * validation possible. */
16
+ export interface AnalyzeEnv {
17
+ readonly celEnv: Environment;
18
+ readonly contextSchema: Record<string, unknown> | null;
19
+ }
20
+
21
+ /** A single static-analysis finding produced by an engine. Stable codes match
22
+ * the analyzer's existing diagnostic codes so downstream filtering keeps
23
+ * working unchanged across the engine boundary. */
24
+ export interface EngineDiagnostic {
25
+ readonly message: string;
26
+ readonly code?: string;
27
+ }
28
+
29
+ /** Per-property templating engine. Matches a YAML tag (`!<name>`); the kernel
30
+ * and analyzer dispatch through the registry rather than knowing about
31
+ * specific engines. */
32
+ export interface TemplatingEngine {
33
+ /** Registry key matching the YAML tag name (without `!`). */
34
+ readonly name: string;
35
+
36
+ /** Optional Monaco language id for editor syntax highlighting. Currently
37
+ * unread — the editor's CelFieldWrapper uses a plain `<input>`. Wiring
38
+ * this through to a Monaco editor instance is tracked separately; the
39
+ * field is documented intent so engine authors don't have to revisit
40
+ * the interface when Monaco lands.
41
+ * TODO(editor): consume `engine.language` from the field renderer. */
42
+ readonly language?: string;
43
+
44
+ /** Convert a tagged source string into a runtime value. Called once at
45
+ * precompile. Returns either a CompiledValue (engines that defer evaluation
46
+ * to a runtime EvalContext, like `cel`) or a plain value (engines like
47
+ * `literal` that resolve fully at compile time). */
48
+ compile(source: string, env: CompileEnv): CompiledValue | unknown;
49
+
50
+ /** Static analysis hook. Engines that can't statically check (e.g. `literal`)
51
+ * return []. The walker accumulates diagnostics across all values. */
52
+ analyze(source: string, env: AnalyzeEnv): readonly EngineDiagnostic[];
53
+ }
@@ -0,0 +1,40 @@
1
+ import { extractAccessChains, validateChainAgainstSchema } from "../cel/analyze.js";
2
+ import { compileExpression } from "../cel/compile.js";
3
+ import type { EngineDiagnostic, TemplatingEngine } from "../engine.js";
4
+
5
+ /** The `!cel` engine. Treats the entire tagged scalar as a single CEL
6
+ * expression — no `${{ }}` wrapping. Analysis runs the same chain validator
7
+ * as the untagged path: parse → extract member-access chains → validate each
8
+ * chain against the effective context schema. */
9
+ export const celEngine: TemplatingEngine = {
10
+ name: "cel",
11
+ language: "cel",
12
+
13
+ compile(source, env) {
14
+ return compileExpression(source, env.celEnv);
15
+ },
16
+
17
+ analyze(source, env) {
18
+ const out: EngineDiagnostic[] = [];
19
+
20
+ let parsed: ReturnType<typeof env.celEnv.parse>;
21
+ try {
22
+ parsed = env.celEnv.parse(source);
23
+ } catch (e) {
24
+ out.push({
25
+ code: "CEL_SYNTAX_ERROR",
26
+ message: e instanceof Error ? e.message : String(e),
27
+ });
28
+ return out;
29
+ }
30
+
31
+ if (!env.contextSchema) return out;
32
+
33
+ const chains = extractAccessChains(parsed.ast);
34
+ for (const chain of chains) {
35
+ const err = validateChainAgainstSchema(chain, env.contextSchema as Record<string, any>);
36
+ if (err) out.push({ code: "CEL_UNKNOWN_FIELD", message: err });
37
+ }
38
+ return out;
39
+ },
40
+ };
@@ -0,0 +1,16 @@
1
+ import type { TemplatingEngine } from "../engine.js";
2
+
3
+ /** The `!literal` engine. Treats the tagged scalar as opaque text — no CEL
4
+ * interpolation, no analysis. Returns the source string verbatim at compile
5
+ * time so the runtime sees a plain string. */
6
+ export const literalEngine: TemplatingEngine = {
7
+ name: "literal",
8
+
9
+ compile(source) {
10
+ return source;
11
+ },
12
+
13
+ analyze() {
14
+ return [];
15
+ },
16
+ };
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ export { buildCelEnvironment, type CelHandlers } from "./cel/environment.js";
2
+ export {
3
+ compileExpression,
4
+ compileString,
5
+ TEMPLATE_REGEX,
6
+ EXACT_TEMPLATE_REGEX,
7
+ } from "./cel/compile.js";
8
+ export { extractAccessChains, validateChainAgainstSchema } from "./cel/analyze.js";
9
+ export { walkCelExpressions } from "./cel/walk.js";
10
+
11
+ export { celEngine } from "./engines/cel.js";
12
+ export { literalEngine } from "./engines/literal.js";
13
+
14
+ export { TemplatingEngineRegistry } from "./registry.js";
15
+ export { builtinEngines, createDefaultRegistry, defaultRegistry } from "./builtins.js";
16
+ export type {
17
+ AnalyzeEnv,
18
+ CompileEnv,
19
+ EngineDiagnostic,
20
+ TemplatingEngine,
21
+ } from "./engine.js";
22
+
23
+ export { isTaggedSentinel, makeTaggedSentinel, type TaggedSentinel } from "./sentinel.js";
24
+ export { buildCustomTags, defaultCustomTags } from "./yaml-tags.js";
@@ -0,0 +1,25 @@
1
+ import type { TemplatingEngine } from "./engine.js";
2
+
3
+ export class TemplatingEngineRegistry {
4
+ private readonly engines = new Map<string, TemplatingEngine>();
5
+
6
+ register(engine: TemplatingEngine): void {
7
+ if (this.engines.has(engine.name)) {
8
+ throw new Error(`Templating engine '${engine.name}' is already registered.`);
9
+ }
10
+ this.engines.set(engine.name, engine);
11
+ }
12
+
13
+ get(name: string): TemplatingEngine | undefined {
14
+ return this.engines.get(name);
15
+ }
16
+
17
+ has(name: string): boolean {
18
+ return this.engines.has(name);
19
+ }
20
+
21
+ /** All registered engines in registration order. */
22
+ list(): readonly TemplatingEngine[] {
23
+ return [...this.engines.values()];
24
+ }
25
+ }
@@ -0,0 +1,25 @@
1
+ /** Sentinel object produced by the YAML loader for a tagged scalar (e.g. `!cel
2
+ * 'variables.port'`). Travels through the manifest tree as the parsed value;
3
+ * precompile and the analyzer key off `__tagged === true` to dispatch to the
4
+ * right engine. The object is intentionally a plain JSON-shaped record so it
5
+ * survives `Document.toJSON()` and `JSON.parse(JSON.stringify(...))` without
6
+ * loss. */
7
+ export interface TaggedSentinel {
8
+ readonly __tagged: true;
9
+ readonly engine: string;
10
+ readonly source: string;
11
+ }
12
+
13
+ export function isTaggedSentinel(v: unknown): v is TaggedSentinel {
14
+ return (
15
+ v !== null &&
16
+ typeof v === "object" &&
17
+ (v as { __tagged?: unknown }).__tagged === true &&
18
+ typeof (v as { engine?: unknown }).engine === "string" &&
19
+ typeof (v as { source?: unknown }).source === "string"
20
+ );
21
+ }
22
+
23
+ export function makeTaggedSentinel(engine: string, source: string): TaggedSentinel {
24
+ return { __tagged: true, engine, source };
25
+ }
@@ -0,0 +1,71 @@
1
+ import type { ScalarTag } from "yaml";
2
+ import { stringifyString } from "yaml/util";
3
+ import { defaultRegistry } from "./builtins.js";
4
+ import { isTaggedSentinel, makeTaggedSentinel, type TaggedSentinel } from "./sentinel.js";
5
+ import type { TemplatingEngineRegistry } from "./registry.js";
6
+
7
+ /** Build the `customTags` array passed to `yaml`'s `parseAllDocuments` /
8
+ * `Document` from the registered engines. Each engine contributes one
9
+ * ScalarTag whose `resolve` produces a `TaggedSentinel`, and whose
10
+ * `identify` + `stringify` round-trip the sentinel back to its original
11
+ * `!<engine> "<source>"` form when the document is re-serialized.
12
+ *
13
+ * Without `stringify`, `Document.toString()` would emit the sentinel as a
14
+ * YAML mapping (`{__tagged: true, engine: cel, source: ...}`), corrupting
15
+ * the file on the editor's first save. Without `identify`, the serializer
16
+ * wouldn't know to use the custom tag at all and would fall through to
17
+ * default object serialization.
18
+ *
19
+ * Single source of truth: every `parseAllDocuments` call site in the repo
20
+ * calls this factory so the parse-side configuration cannot drift between
21
+ * hosts. Each host passes its own registry (in practice always
22
+ * `createDefaultRegistry()`), keeping the door open for future
23
+ * test-only registries. */
24
+ export function buildCustomTags(registry: TemplatingEngineRegistry): ScalarTag[] {
25
+ return registry.list().map((engine) => buildTagForEngine(engine.name));
26
+ }
27
+
28
+ /** Returns `customTags` built from the default registry, freshly each call.
29
+ * Every `parseAllDocuments` call site in the repo calls this so they all
30
+ * parse the same set of tags. Built from `defaultRegistry()` (the same
31
+ * singleton precompile + the analyzer use) so registering a new engine on
32
+ * the default registry propagates to YAML parsing on the next call. The
33
+ * rebuild cost is negligible (one array of N small ScalarTag objects). */
34
+ export function defaultCustomTags(): ScalarTag[] {
35
+ return buildCustomTags(defaultRegistry());
36
+ }
37
+
38
+ function buildTagForEngine(engineName: string): ScalarTag {
39
+ const tagId = `!${engineName}`;
40
+ return {
41
+ tag: tagId,
42
+ resolve: (value: string): TaggedSentinel => makeTaggedSentinel(engineName, value),
43
+ identify: (v: unknown): boolean => isTaggedSentinel(v) && v.engine === engineName,
44
+ stringify(item, ctx, onComment, onChompKeep): string {
45
+ // Two paths reach this function:
46
+ // 1. Parsed-then-serialized: the resolver produced a TaggedSentinel
47
+ // and we recover the original `source` from it; the original
48
+ // Scalar carries the user's chosen quoting style on `item.type`,
49
+ // which we pass through so single-quoted stays single-quoted.
50
+ // 2. setTag applied to an existing scalar: the underlying value is a
51
+ // plain primitive (string/number/boolean) — coerce to string and
52
+ // let yaml's stringifier pick a safe default style.
53
+ // Either way, delegate to yaml's `stringifyString` so newlines, tabs,
54
+ // control characters, leading-whitespace lines, and multi-line content
55
+ // are all escaped/quoted correctly. The yaml lib emits the tag prefix
56
+ // itself; this only returns the scalar body.
57
+ const value = item.value;
58
+ const source = isTaggedSentinel(value)
59
+ ? value.source
60
+ : value === null || value === undefined
61
+ ? ""
62
+ : String(value);
63
+ return stringifyString(
64
+ { value: source, type: (item as { type?: string }).type },
65
+ ctx,
66
+ onComment,
67
+ onChompKeep,
68
+ );
69
+ },
70
+ } satisfies ScalarTag;
71
+ }