@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,47 @@
1
+ import type { Environment } from "@marcbachmann/cel-js";
2
+ import type { CompiledValue } from "@telorun/sdk";
3
+ /** Compile-time environment passed to `engine.compile`. Engines that need to
4
+ * parse against a CEL environment (the `cel` engine) read it from `celEnv`;
5
+ * engines that resolve fully at compile time (`literal`) ignore it. */
6
+ export interface CompileEnv {
7
+ readonly celEnv: Environment;
8
+ }
9
+ /** Analyze-time environment passed to `engine.analyze`. The walker resolves
10
+ * the path-specific effective context (kernel globals merged in, x-telo-context
11
+ * applied) and hands the engine a single closed schema. The engine validates
12
+ * member-access chains against it. `null` means "open context" — no chain
13
+ * validation possible. */
14
+ export interface AnalyzeEnv {
15
+ readonly celEnv: Environment;
16
+ readonly contextSchema: Record<string, unknown> | null;
17
+ }
18
+ /** A single static-analysis finding produced by an engine. Stable codes match
19
+ * the analyzer's existing diagnostic codes so downstream filtering keeps
20
+ * working unchanged across the engine boundary. */
21
+ export interface EngineDiagnostic {
22
+ readonly message: string;
23
+ readonly code?: string;
24
+ }
25
+ /** Per-property templating engine. Matches a YAML tag (`!<name>`); the kernel
26
+ * and analyzer dispatch through the registry rather than knowing about
27
+ * specific engines. */
28
+ export interface TemplatingEngine {
29
+ /** Registry key matching the YAML tag name (without `!`). */
30
+ readonly name: string;
31
+ /** Optional Monaco language id for editor syntax highlighting. Currently
32
+ * unread — the editor's CelFieldWrapper uses a plain `<input>`. Wiring
33
+ * this through to a Monaco editor instance is tracked separately; the
34
+ * field is documented intent so engine authors don't have to revisit
35
+ * the interface when Monaco lands.
36
+ * TODO(editor): consume `engine.language` from the field renderer. */
37
+ readonly language?: string;
38
+ /** Convert a tagged source string into a runtime value. Called once at
39
+ * precompile. Returns either a CompiledValue (engines that defer evaluation
40
+ * to a runtime EvalContext, like `cel`) or a plain value (engines like
41
+ * `literal` that resolve fully at compile time). */
42
+ compile(source: string, env: CompileEnv): CompiledValue | unknown;
43
+ /** Static analysis hook. Engines that can't statically check (e.g. `literal`)
44
+ * return []. The walker accumulates diagnostics across all values. */
45
+ analyze(source: string, env: AnalyzeEnv): readonly EngineDiagnostic[];
46
+ }
47
+ //# sourceMappingURL=engine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD;;wEAEwE;AACxE,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;CAC9B;AAED;;;;2BAI2B;AAC3B,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAC7B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACxD;AAED;;oDAEoD;AACpD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;wBAEwB;AACxB,MAAM,WAAW,gBAAgB;IAC/B,6DAA6D;IAC7D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAEtB;;;;;2EAKuE;IACvE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAE3B;;;yDAGqD;IACrD,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,UAAU,GAAG,aAAa,GAAG,OAAO,CAAC;IAElE;2EACuE;IACvE,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,UAAU,GAAG,SAAS,gBAAgB,EAAE,CAAC;CACvE"}
package/dist/engine.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ import type { TemplatingEngine } from "../engine.js";
2
+ /** The `!cel` engine. Treats the entire tagged scalar as a single CEL
3
+ * expression — no `${{ }}` wrapping. Analysis runs the same chain validator
4
+ * as the untagged path: parse → extract member-access chains → validate each
5
+ * chain against the effective context schema. */
6
+ export declare const celEngine: TemplatingEngine;
7
+ //# sourceMappingURL=cel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cel.d.ts","sourceRoot":"","sources":["../../src/engines/cel.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAoB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAEvE;;;kDAGkD;AAClD,eAAO,MAAM,SAAS,EAAE,gBA+BvB,CAAC"}
@@ -0,0 +1,36 @@
1
+ import { extractAccessChains, validateChainAgainstSchema } from "../cel/analyze.js";
2
+ import { compileExpression } from "../cel/compile.js";
3
+ /** The `!cel` engine. Treats the entire tagged scalar as a single CEL
4
+ * expression — no `${{ }}` wrapping. Analysis runs the same chain validator
5
+ * as the untagged path: parse → extract member-access chains → validate each
6
+ * chain against the effective context schema. */
7
+ export const celEngine = {
8
+ name: "cel",
9
+ language: "cel",
10
+ compile(source, env) {
11
+ return compileExpression(source, env.celEnv);
12
+ },
13
+ analyze(source, env) {
14
+ const out = [];
15
+ let parsed;
16
+ try {
17
+ parsed = env.celEnv.parse(source);
18
+ }
19
+ catch (e) {
20
+ out.push({
21
+ code: "CEL_SYNTAX_ERROR",
22
+ message: e instanceof Error ? e.message : String(e),
23
+ });
24
+ return out;
25
+ }
26
+ if (!env.contextSchema)
27
+ return out;
28
+ const chains = extractAccessChains(parsed.ast);
29
+ for (const chain of chains) {
30
+ const err = validateChainAgainstSchema(chain, env.contextSchema);
31
+ if (err)
32
+ out.push({ code: "CEL_UNKNOWN_FIELD", message: err });
33
+ }
34
+ return out;
35
+ },
36
+ };
@@ -0,0 +1,6 @@
1
+ import type { TemplatingEngine } from "../engine.js";
2
+ /** The `!literal` engine. Treats the tagged scalar as opaque text — no CEL
3
+ * interpolation, no analysis. Returns the source string verbatim at compile
4
+ * time so the runtime sees a plain string. */
5
+ export declare const literalEngine: TemplatingEngine;
6
+ //# sourceMappingURL=literal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"literal.d.ts","sourceRoot":"","sources":["../../src/engines/literal.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD;;+CAE+C;AAC/C,eAAO,MAAM,aAAa,EAAE,gBAU3B,CAAC"}
@@ -0,0 +1,12 @@
1
+ /** The `!literal` engine. Treats the tagged scalar as opaque text — no CEL
2
+ * interpolation, no analysis. Returns the source string verbatim at compile
3
+ * time so the runtime sees a plain string. */
4
+ export const literalEngine = {
5
+ name: "literal",
6
+ compile(source) {
7
+ return source;
8
+ },
9
+ analyze() {
10
+ return [];
11
+ },
12
+ };
@@ -0,0 +1,12 @@
1
+ export { buildCelEnvironment, type CelHandlers } from "./cel/environment.js";
2
+ export { compileExpression, compileString, TEMPLATE_REGEX, EXACT_TEMPLATE_REGEX, } from "./cel/compile.js";
3
+ export { extractAccessChains, validateChainAgainstSchema } from "./cel/analyze.js";
4
+ export { walkCelExpressions } from "./cel/walk.js";
5
+ export { celEngine } from "./engines/cel.js";
6
+ export { literalEngine } from "./engines/literal.js";
7
+ export { TemplatingEngineRegistry } from "./registry.js";
8
+ export { builtinEngines, createDefaultRegistry, defaultRegistry } from "./builtins.js";
9
+ export type { AnalyzeEnv, CompileEnv, EngineDiagnostic, TemplatingEngine, } from "./engine.js";
10
+ export { isTaggedSentinel, makeTaggedSentinel, type TaggedSentinel } from "./sentinel.js";
11
+ export { buildCustomTags, defaultCustomTags } from "./yaml-tags.js";
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,KAAK,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC7E,OAAO,EACL,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,oBAAoB,GACrB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,mBAAmB,EAAE,0BAA0B,EAAE,MAAM,kBAAkB,CAAC;AACnF,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAEnD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAErD,OAAO,EAAE,wBAAwB,EAAE,MAAM,eAAe,CAAC;AACzD,OAAO,EAAE,cAAc,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AACvF,YAAY,EACV,UAAU,EACV,UAAU,EACV,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;AAC1F,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export { buildCelEnvironment } from "./cel/environment.js";
2
+ export { compileExpression, compileString, TEMPLATE_REGEX, EXACT_TEMPLATE_REGEX, } from "./cel/compile.js";
3
+ export { extractAccessChains, validateChainAgainstSchema } from "./cel/analyze.js";
4
+ export { walkCelExpressions } from "./cel/walk.js";
5
+ export { celEngine } from "./engines/cel.js";
6
+ export { literalEngine } from "./engines/literal.js";
7
+ export { TemplatingEngineRegistry } from "./registry.js";
8
+ export { builtinEngines, createDefaultRegistry, defaultRegistry } from "./builtins.js";
9
+ export { isTaggedSentinel, makeTaggedSentinel } from "./sentinel.js";
10
+ export { buildCustomTags, defaultCustomTags } from "./yaml-tags.js";
@@ -0,0 +1,10 @@
1
+ import type { TemplatingEngine } from "./engine.js";
2
+ export declare class TemplatingEngineRegistry {
3
+ private readonly engines;
4
+ register(engine: TemplatingEngine): void;
5
+ get(name: string): TemplatingEngine | undefined;
6
+ has(name: string): boolean;
7
+ /** All registered engines in registration order. */
8
+ list(): readonly TemplatingEngine[];
9
+ }
10
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEpD,qBAAa,wBAAwB;IACnC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAuC;IAE/D,QAAQ,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI;IAOxC,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS;IAI/C,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAI1B,oDAAoD;IACpD,IAAI,IAAI,SAAS,gBAAgB,EAAE;CAGpC"}
@@ -0,0 +1,19 @@
1
+ export class TemplatingEngineRegistry {
2
+ engines = new Map();
3
+ register(engine) {
4
+ if (this.engines.has(engine.name)) {
5
+ throw new Error(`Templating engine '${engine.name}' is already registered.`);
6
+ }
7
+ this.engines.set(engine.name, engine);
8
+ }
9
+ get(name) {
10
+ return this.engines.get(name);
11
+ }
12
+ has(name) {
13
+ return this.engines.has(name);
14
+ }
15
+ /** All registered engines in registration order. */
16
+ list() {
17
+ return [...this.engines.values()];
18
+ }
19
+ }
@@ -0,0 +1,14 @@
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
+ export declare function isTaggedSentinel(v: unknown): v is TaggedSentinel;
13
+ export declare function makeTaggedSentinel(engine: string, source: string): TaggedSentinel;
14
+ //# sourceMappingURL=sentinel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sentinel.d.ts","sourceRoot":"","sources":["../src/sentinel.ts"],"names":[],"mappings":"AAAA;;;;;YAKY;AACZ,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,OAAO,GAAG,CAAC,IAAI,cAAc,CAQhE;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,cAAc,CAEjF"}
@@ -0,0 +1,10 @@
1
+ export function isTaggedSentinel(v) {
2
+ return (v !== null &&
3
+ typeof v === "object" &&
4
+ v.__tagged === true &&
5
+ typeof v.engine === "string" &&
6
+ typeof v.source === "string");
7
+ }
8
+ export function makeTaggedSentinel(engine, source) {
9
+ return { __tagged: true, engine, source };
10
+ }
@@ -0,0 +1,28 @@
1
+ import type { ScalarTag } from "yaml";
2
+ import type { TemplatingEngineRegistry } from "./registry.js";
3
+ /** Build the `customTags` array passed to `yaml`'s `parseAllDocuments` /
4
+ * `Document` from the registered engines. Each engine contributes one
5
+ * ScalarTag whose `resolve` produces a `TaggedSentinel`, and whose
6
+ * `identify` + `stringify` round-trip the sentinel back to its original
7
+ * `!<engine> "<source>"` form when the document is re-serialized.
8
+ *
9
+ * Without `stringify`, `Document.toString()` would emit the sentinel as a
10
+ * YAML mapping (`{__tagged: true, engine: cel, source: ...}`), corrupting
11
+ * the file on the editor's first save. Without `identify`, the serializer
12
+ * wouldn't know to use the custom tag at all and would fall through to
13
+ * default object serialization.
14
+ *
15
+ * Single source of truth: every `parseAllDocuments` call site in the repo
16
+ * calls this factory so the parse-side configuration cannot drift between
17
+ * hosts. Each host passes its own registry (in practice always
18
+ * `createDefaultRegistry()`), keeping the door open for future
19
+ * test-only registries. */
20
+ export declare function buildCustomTags(registry: TemplatingEngineRegistry): ScalarTag[];
21
+ /** Returns `customTags` built from the default registry, freshly each call.
22
+ * Every `parseAllDocuments` call site in the repo calls this so they all
23
+ * parse the same set of tags. Built from `defaultRegistry()` (the same
24
+ * singleton precompile + the analyzer use) so registering a new engine on
25
+ * the default registry propagates to YAML parsing on the next call. The
26
+ * rebuild cost is negligible (one array of N small ScalarTag objects). */
27
+ export declare function defaultCustomTags(): ScalarTag[];
28
+ //# sourceMappingURL=yaml-tags.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"yaml-tags.d.ts","sourceRoot":"","sources":["../src/yaml-tags.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,MAAM,CAAC;AAItC,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,eAAe,CAAC;AAE9D;;;;;;;;;;;;;;;;4BAgB4B;AAC5B,wBAAgB,eAAe,CAAC,QAAQ,EAAE,wBAAwB,GAAG,SAAS,EAAE,CAE/E;AAED;;;;;2EAK2E;AAC3E,wBAAgB,iBAAiB,IAAI,SAAS,EAAE,CAE/C"}
@@ -0,0 +1,61 @@
1
+ import { stringifyString } from "yaml/util";
2
+ import { defaultRegistry } from "./builtins.js";
3
+ import { isTaggedSentinel, makeTaggedSentinel } from "./sentinel.js";
4
+ /** Build the `customTags` array passed to `yaml`'s `parseAllDocuments` /
5
+ * `Document` from the registered engines. Each engine contributes one
6
+ * ScalarTag whose `resolve` produces a `TaggedSentinel`, and whose
7
+ * `identify` + `stringify` round-trip the sentinel back to its original
8
+ * `!<engine> "<source>"` form when the document is re-serialized.
9
+ *
10
+ * Without `stringify`, `Document.toString()` would emit the sentinel as a
11
+ * YAML mapping (`{__tagged: true, engine: cel, source: ...}`), corrupting
12
+ * the file on the editor's first save. Without `identify`, the serializer
13
+ * wouldn't know to use the custom tag at all and would fall through to
14
+ * default object serialization.
15
+ *
16
+ * Single source of truth: every `parseAllDocuments` call site in the repo
17
+ * calls this factory so the parse-side configuration cannot drift between
18
+ * hosts. Each host passes its own registry (in practice always
19
+ * `createDefaultRegistry()`), keeping the door open for future
20
+ * test-only registries. */
21
+ export function buildCustomTags(registry) {
22
+ return registry.list().map((engine) => buildTagForEngine(engine.name));
23
+ }
24
+ /** Returns `customTags` built from the default registry, freshly each call.
25
+ * Every `parseAllDocuments` call site in the repo calls this so they all
26
+ * parse the same set of tags. Built from `defaultRegistry()` (the same
27
+ * singleton precompile + the analyzer use) so registering a new engine on
28
+ * the default registry propagates to YAML parsing on the next call. The
29
+ * rebuild cost is negligible (one array of N small ScalarTag objects). */
30
+ export function defaultCustomTags() {
31
+ return buildCustomTags(defaultRegistry());
32
+ }
33
+ function buildTagForEngine(engineName) {
34
+ const tagId = `!${engineName}`;
35
+ return {
36
+ tag: tagId,
37
+ resolve: (value) => makeTaggedSentinel(engineName, value),
38
+ identify: (v) => isTaggedSentinel(v) && v.engine === engineName,
39
+ stringify(item, ctx, onComment, onChompKeep) {
40
+ // Two paths reach this function:
41
+ // 1. Parsed-then-serialized: the resolver produced a TaggedSentinel
42
+ // and we recover the original `source` from it; the original
43
+ // Scalar carries the user's chosen quoting style on `item.type`,
44
+ // which we pass through so single-quoted stays single-quoted.
45
+ // 2. setTag applied to an existing scalar: the underlying value is a
46
+ // plain primitive (string/number/boolean) — coerce to string and
47
+ // let yaml's stringifier pick a safe default style.
48
+ // Either way, delegate to yaml's `stringifyString` so newlines, tabs,
49
+ // control characters, leading-whitespace lines, and multi-line content
50
+ // are all escaped/quoted correctly. The yaml lib emits the tag prefix
51
+ // itself; this only returns the scalar body.
52
+ const value = item.value;
53
+ const source = isTaggedSentinel(value)
54
+ ? value.source
55
+ : value === null || value === undefined
56
+ ? ""
57
+ : String(value);
58
+ return stringifyString({ value: source, type: item.type }, ctx, onComment, onChompKeep);
59
+ },
60
+ };
61
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@telorun/templating",
3
+ "version": "0.2.0",
4
+ "description": "Telo Templating - Engine registry and shared CEL core for Telo manifests.",
5
+ "keywords": [
6
+ "telo",
7
+ "templating",
8
+ "cel",
9
+ "yaml"
10
+ ],
11
+ "author": "Bartosz Pasiński <bartosz.pasinski@codenet.pl>",
12
+ "license": "SEE LICENSE IN LICENSE",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/telorun/telo.git",
16
+ "directory": "templating/nodejs"
17
+ },
18
+ "homepage": "https://github.com/telorun/telo#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/telorun/telo/issues"
21
+ },
22
+ "type": "module",
23
+ "main": "./dist/index.js",
24
+ "exports": {
25
+ ".": {
26
+ "source": "./src/index.ts",
27
+ "types": "./dist/index.d.ts",
28
+ "bun": "./src/index.ts",
29
+ "import": "./dist/index.js",
30
+ "default": "./dist/index.js"
31
+ }
32
+ },
33
+ "files": [
34
+ "dist/**",
35
+ "src/**"
36
+ ],
37
+ "dependencies": {
38
+ "@marcbachmann/cel-js": "^7.5.3",
39
+ "yaml": "^2.8.3",
40
+ "@telorun/sdk": "0.7.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^20.0.0",
44
+ "typescript": "^5.0.0",
45
+ "vitest": "^2.1.8"
46
+ },
47
+ "scripts": {
48
+ "build": "tsc -p tsconfig.lib.json",
49
+ "test": "vitest run",
50
+ "test:watch": "vitest"
51
+ }
52
+ }
@@ -0,0 +1,33 @@
1
+ import { celEngine } from "./engines/cel.js";
2
+ import { literalEngine } from "./engines/literal.js";
3
+ import { TemplatingEngineRegistry } from "./registry.js";
4
+ import type { TemplatingEngine } from "./engine.js";
5
+
6
+ /** Single source of truth for the built-in templating engines. Every host
7
+ * (kernel, analyzer, editor, vscode extension) calls `createDefaultRegistry`
8
+ * so the parse-side YAML tag set, the precompile dispatch, and the analyzer
9
+ * agree on which engines exist. Per-host à-la-carte registration would let
10
+ * a manifest validate clean in one host (e.g. `cel` only) and crash in
11
+ * another (e.g. `cel + literal`); always ship the same set. */
12
+ export const builtinEngines: readonly TemplatingEngine[] = [celEngine, literalEngine];
13
+
14
+ export function createDefaultRegistry(): TemplatingEngineRegistry {
15
+ const registry = new TemplatingEngineRegistry();
16
+ for (const engine of builtinEngines) {
17
+ registry.register(engine);
18
+ }
19
+ return registry;
20
+ }
21
+
22
+ let defaultRegistryCache: TemplatingEngineRegistry | undefined;
23
+
24
+ /** Memoized singleton: returns the default registry. Hosts that don't need
25
+ * per-instance isolation (precompile, the analyzer's tagged-value walker)
26
+ * should use this so they share the same registry instance the YAML tag
27
+ * factory uses. */
28
+ export function defaultRegistry(): TemplatingEngineRegistry {
29
+ if (!defaultRegistryCache) {
30
+ defaultRegistryCache = createDefaultRegistry();
31
+ }
32
+ return defaultRegistryCache;
33
+ }
@@ -0,0 +1,125 @@
1
+ import type { ASTNode } from "@marcbachmann/cel-js";
2
+
3
+ /**
4
+ * Extract all member-access chains from a CEL AST.
5
+ * Returns arrays like ["request", "query", "name"] for `request.query.name`.
6
+ * Chains that start with a call or non-identifier root are ignored.
7
+ * Bound variables in comprehension macros (filter, map, exists, all, exists_one) are excluded.
8
+ */
9
+ export function extractAccessChains(node: ASTNode): string[][] {
10
+ const chains: string[][] = [];
11
+ visitNode(node, chains, new Set());
12
+ return chains;
13
+ }
14
+
15
+ const COMPREHENSION_METHODS = new Set(["filter", "map", "exists", "all", "exists_one"]);
16
+
17
+ function visitNode(node: ASTNode, chains: string[][], boundVars: Set<string>): void {
18
+ const chain = extractChain(node, boundVars);
19
+ if (chain !== null) {
20
+ chains.push(chain);
21
+ return;
22
+ }
23
+
24
+ if (
25
+ node.op === "rcall" &&
26
+ Array.isArray(node.args) &&
27
+ typeof node.args[0] === "string" &&
28
+ COMPREHENSION_METHODS.has(node.args[0])
29
+ ) {
30
+ const receiver = node.args[1];
31
+ const comprehensionArgs = node.args[2];
32
+ if (isASTNode(receiver)) visitNode(receiver, chains, boundVars);
33
+ if (
34
+ Array.isArray(comprehensionArgs) &&
35
+ comprehensionArgs.length >= 2 &&
36
+ isASTNode(comprehensionArgs[0]) &&
37
+ (comprehensionArgs[0] as ASTNode).op === "id"
38
+ ) {
39
+ const newBoundVars = new Set(boundVars);
40
+ newBoundVars.add((comprehensionArgs[0] as ASTNode).args as string);
41
+ for (let i = 1; i < comprehensionArgs.length; i++) {
42
+ const arg = comprehensionArgs[i];
43
+ if (isASTNode(arg)) visitNode(arg as ASTNode, chains, newBoundVars);
44
+ }
45
+ }
46
+ return;
47
+ }
48
+
49
+ const args = node.args;
50
+ if (Array.isArray(args)) {
51
+ for (const arg of args) {
52
+ if (isASTNode(arg)) {
53
+ visitNode(arg, chains, boundVars);
54
+ } else if (Array.isArray(arg)) {
55
+ for (const item of arg) {
56
+ if (isASTNode(item)) visitNode(item, chains, boundVars);
57
+ }
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ function isASTNode(v: unknown): v is ASTNode {
64
+ return v !== null && typeof v === "object" && "op" in (v as object);
65
+ }
66
+
67
+ const INDEX_SEGMENT = "[*]";
68
+
69
+ function extractChain(node: ASTNode, boundVars: Set<string>): string[] | null {
70
+ if (node.op === "id") {
71
+ const name = node.args as string;
72
+ if (boundVars.has(name)) return null;
73
+ return [name];
74
+ }
75
+ if (node.op === ".") {
76
+ const [obj, field] = node.args as [ASTNode, string];
77
+ const parent = extractChain(obj, boundVars);
78
+ if (parent !== null) return [...parent, field];
79
+ }
80
+ if (node.op === "[]") {
81
+ const [obj] = node.args as [ASTNode, ASTNode];
82
+ const parent = extractChain(obj, boundVars);
83
+ if (parent !== null) return [...parent, INDEX_SEGMENT];
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * Check whether a member-access chain accesses only fields declared in a JSON Schema.
90
+ * Returns an error string if a field is unknown in a schema that declares explicit
91
+ * properties without `additionalProperties: true`, or if the chain attempts to
92
+ * reach inside an `x-telo-stream: true` property.
93
+ * Returns null when the chain is valid or the schema is too open to judge.
94
+ */
95
+ export function validateChainAgainstSchema(
96
+ chain: string[],
97
+ schema: Record<string, any>,
98
+ ): string | null {
99
+ let current: Record<string, any> = schema;
100
+ for (let i = 0; i < chain.length; i++) {
101
+ const key = chain[i]!;
102
+ if (!current || typeof current !== "object") return null;
103
+ const props: Record<string, any> | undefined = current.properties;
104
+ if (!props) return null;
105
+ if (key in props) {
106
+ const propSchema = props[key];
107
+ if (
108
+ propSchema &&
109
+ typeof propSchema === "object" &&
110
+ propSchema["x-telo-stream"] === true &&
111
+ i < chain.length - 1
112
+ ) {
113
+ const path = chain.slice(0, i + 1).join(".");
114
+ return `'${path}' yields a stream — pipe it through an Encoder or iterate in a JS.Script step (no member access on stream-typed values)`;
115
+ }
116
+ current = propSchema;
117
+ continue;
118
+ }
119
+ if (current.additionalProperties === true) return null;
120
+ const path = chain.slice(0, i + 1).join(".");
121
+ const available = Object.keys(props).join(", ");
122
+ return `'${path}' is not defined (available: ${available})`;
123
+ }
124
+ return null;
125
+ }
@@ -0,0 +1,47 @@
1
+ import type { CompiledValue } from "@telorun/sdk";
2
+ import type { Environment } from "@marcbachmann/cel-js";
3
+
4
+ export const TEMPLATE_REGEX = /\$\{\{\s*([^}]+?)\s*\}\}/g;
5
+ export const EXACT_TEMPLATE_REGEX = /^\s*\$\{\{\s*([^}]+?)\s*\}\}\s*$/;
6
+
7
+ /** Compile a single CEL expression (no `${{ }}` wrapping) into a CompiledValue.
8
+ * Throws on syntax errors. Used by the `!cel` engine where the entire tagged
9
+ * scalar is treated as one expression. */
10
+ export function compileExpression(expr: string, env: Environment): CompiledValue {
11
+ const fn = env.parse(expr);
12
+ return {
13
+ __compiled: true,
14
+ source: expr,
15
+ call: (ctx: Record<string, unknown>) => fn(ctx),
16
+ };
17
+ }
18
+
19
+ /** Compile a string that may contain `${{ }}`-delimited CEL segments. If the
20
+ * string is exactly one expression, returns a single CompiledValue. If it
21
+ * contains interpolations, returns a CompiledValue that joins literal parts
22
+ * with stringified expression results. If no expressions are present, returns
23
+ * the input string unchanged. Throws on CEL syntax errors. */
24
+ export function compileString(s: string, env: Environment): unknown {
25
+ if (!s.includes("${{")) return s;
26
+
27
+ const exact = s.match(EXACT_TEMPLATE_REGEX);
28
+ if (exact) {
29
+ return compileExpression(exact[1].trim(), env);
30
+ }
31
+
32
+ const parts: Array<string | CompiledValue> = [];
33
+ let last = 0;
34
+ for (const m of s.matchAll(TEMPLATE_REGEX)) {
35
+ if (m.index! > last) parts.push(s.slice(last, m.index));
36
+ parts.push(compileExpression(m[1].trim(), env));
37
+ last = m.index! + m[0].length;
38
+ }
39
+ if (last < s.length) parts.push(s.slice(last));
40
+
41
+ return {
42
+ __compiled: true,
43
+ source: s,
44
+ call: (ctx: Record<string, unknown>) =>
45
+ parts.map((p) => (typeof p === "string" ? p : String(p.call(ctx) ?? ""))).join(""),
46
+ } satisfies CompiledValue;
47
+ }
@@ -0,0 +1,50 @@
1
+ import { Environment } from "@marcbachmann/cel-js";
2
+ import { Stream } from "@telorun/sdk";
3
+
4
+ export interface CelHandlers {
5
+ sha256: (s: string) => string;
6
+ json: (value: unknown) => string;
7
+ }
8
+
9
+ const stub = (name: string) => () => {
10
+ throw new Error(
11
+ `${name}() is not available in this environment. ` +
12
+ `Construct StaticAnalyzer or Loader with celHandlers to enable it.`,
13
+ );
14
+ };
15
+
16
+ const STUB_HANDLERS: CelHandlers = {
17
+ sha256: stub("sha256"),
18
+ json: stub("json"),
19
+ };
20
+
21
+ /** Build a CEL `Environment` with Telo's stdlib of functions. Always registers the
22
+ * same function signatures (so `env.check()` succeeds for type-inference) — the
23
+ * handlers govern what the function does when called at runtime. Analyzer-only
24
+ * callers can omit handlers; runtime callers (kernel) must supply real ones.
25
+ *
26
+ * Also registers the `Stream` object type, backed by the `Stream` class from
27
+ * `@telorun/sdk`. CEL's type-checker rejects values whose constructor isn't
28
+ * Object/Map/Array/Set/registered; producers that need to expose an
29
+ * `AsyncIterable` through a stream-typed property must wrap the iterable in
30
+ * `new Stream(...)` so its constructor is the registered class. The type has
31
+ * no fields, so terminal access (passing the value through CEL) succeeds but
32
+ * member access raises a CEL error at runtime — matching the analyzer's
33
+ * static check on `x-telo-stream`-marked properties. */
34
+ export function buildCelEnvironment(handlers: CelHandlers = STUB_HANDLERS): Environment {
35
+ return new Environment({ unlistedVariablesAreDyn: true, enableOptionalTypes: true })
36
+ .registerFunction("join(list, string): string", (list: unknown[], sep: string) =>
37
+ list.map(String).join(sep),
38
+ )
39
+ .registerFunction("keys(map): list", (map: unknown) => {
40
+ if (map instanceof Map) return [...map.keys()];
41
+ return Object.keys(map as Record<string, unknown>);
42
+ })
43
+ .registerFunction("values(map): list", (map: unknown) => {
44
+ if (map instanceof Map) return [...map.values()];
45
+ return Object.values(map as Record<string, unknown>);
46
+ })
47
+ .registerFunction("sha256(string): string", (s: string) => handlers.sha256(s))
48
+ .registerFunction("json(dyn): string", (value: unknown) => handlers.json(value))
49
+ .registerType("Stream", Stream as unknown as new (...args: unknown[]) => unknown);
50
+ }