@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.
- package/LICENSE +17 -0
- package/README.md +246 -0
- package/dist/builtins.d.ts +16 -0
- package/dist/builtins.d.ts.map +1 -0
- package/dist/builtins.js +28 -0
- package/dist/cel/analyze.d.ts +17 -0
- package/dist/cel/analyze.d.ts.map +1 -0
- package/dist/cel/analyze.js +116 -0
- package/dist/cel/compile.d.ts +15 -0
- package/dist/cel/compile.d.ts.map +1 -0
- package/dist/cel/compile.js +41 -0
- package/dist/cel/environment.d.ts +20 -0
- package/dist/cel/environment.d.ts.map +1 -0
- package/dist/cel/environment.js +40 -0
- package/dist/cel/walk.d.ts +13 -0
- package/dist/cel/walk.d.ts.map +1 -0
- package/dist/cel/walk.js +36 -0
- package/dist/engine.d.ts +47 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +1 -0
- package/dist/engines/cel.d.ts +7 -0
- package/dist/engines/cel.d.ts.map +1 -0
- package/dist/engines/cel.js +36 -0
- package/dist/engines/literal.d.ts +6 -0
- package/dist/engines/literal.d.ts.map +1 -0
- package/dist/engines/literal.js +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/registry.d.ts +10 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +19 -0
- package/dist/sentinel.d.ts +14 -0
- package/dist/sentinel.d.ts.map +1 -0
- package/dist/sentinel.js +10 -0
- package/dist/yaml-tags.d.ts +28 -0
- package/dist/yaml-tags.d.ts.map +1 -0
- package/dist/yaml-tags.js +61 -0
- package/package.json +52 -0
- package/src/builtins.ts +33 -0
- package/src/cel/analyze.ts +125 -0
- package/src/cel/compile.ts +47 -0
- package/src/cel/environment.ts +50 -0
- package/src/cel/walk.ts +43 -0
- package/src/engine.ts +53 -0
- package/src/engines/cel.ts +40 -0
- package/src/engines/literal.ts +16 -0
- package/src/index.ts +24 -0
- package/src/registry.ts +25 -0
- package/src/sentinel.ts +25 -0
- package/src/yaml-tags.ts +71 -0
package/src/cel/walk.ts
ADDED
|
@@ -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";
|
package/src/registry.ts
ADDED
|
@@ -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
|
+
}
|
package/src/sentinel.ts
ADDED
|
@@ -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
|
+
}
|
package/src/yaml-tags.ts
ADDED
|
@@ -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
|
+
}
|