@metaobjectsdev/render 0.5.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.
@@ -0,0 +1,3 @@
1
+ export type RenderFormat = "text" | "html" | "xml" | "csv" | "json" | "markdown" | "spreadsheet";
2
+ export declare const ESCAPERS: Record<RenderFormat, (s: string) => string>;
3
+ //# sourceMappingURL=escapers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"escapers.d.ts","sourceRoot":"","sources":["../src/escapers.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,YAAY,GACpB,MAAM,GACN,MAAM,GACN,KAAK,GACL,KAAK,GACL,MAAM,GACN,UAAU,GACV,aAAa,CAAC;AAsBlB,eAAO,MAAM,QAAQ,EAAE,MAAM,CAAC,YAAY,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAWhE,CAAC"}
@@ -0,0 +1,34 @@
1
+ // Format-driven escaping (FR-004 R7/R8). The render engine OWNS escaping (it does
2
+ // not rely on the Mustache lib's HTML-only default), so behavior is identical
3
+ // across language ports. `{{var}}` is escaped per format; `{{{var}}}` is raw.
4
+ //
5
+ // RenderFormat is defined here, independent of the metadata package's
6
+ // TEMPLATE_FORMATS, to keep this engine a zero-core-dependency module. The two
7
+ // sets are kept in lock-step by the render-conformance corpus.
8
+ const xml = (s) => s
9
+ .replace(/&/g, "&amp;")
10
+ .replace(/</g, "&lt;")
11
+ .replace(/>/g, "&gt;")
12
+ .replace(/"/g, "&quot;")
13
+ .replace(/'/g, "&#39;");
14
+ // OWASP CSV/Excel formula-injection guard: neutralize a leading active char.
15
+ const injectionGuard = (s) => (/^[=+\-@\t\r]/.test(s) ? "'" + s : s);
16
+ const csv = (s) => {
17
+ const v = injectionGuard(s);
18
+ return /[",\n\r]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
19
+ };
20
+ const json = (s) => JSON.stringify(s).slice(1, -1);
21
+ const raw = (s) => s;
22
+ export const ESCAPERS = {
23
+ text: raw,
24
+ markdown: raw,
25
+ html: xml,
26
+ xml,
27
+ json,
28
+ csv,
29
+ // XML-escape the content first, then guard — so the guard's leading quote is a
30
+ // literal apostrophe in the XML (which is what tells Excel "treat as text"),
31
+ // not itself escaped to &#39;.
32
+ spreadsheet: (s) => injectionGuard(xml(s)),
33
+ };
34
+ //# sourceMappingURL=escapers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"escapers.js","sourceRoot":"","sources":["../src/escapers.ts"],"names":[],"mappings":"AAAA,kFAAkF;AAClF,8EAA8E;AAC9E,8EAA8E;AAC9E,EAAE;AACF,sEAAsE;AACtE,+EAA+E;AAC/E,+DAA+D;AAW/D,MAAM,GAAG,GAAG,CAAC,CAAS,EAAU,EAAE,CAChC,CAAC;KACE,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;KACtB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;KACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;KACrB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC;KACvB,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;AAE5B,6EAA6E;AAC7E,MAAM,cAAc,GAAG,CAAC,CAAS,EAAU,EAAE,CAAC,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AAErF,MAAM,GAAG,GAAG,CAAC,CAAS,EAAU,EAAE;IAChC,MAAM,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,CAAC;IAC5B,OAAO,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;AAC/D,CAAC,CAAC;AAEF,MAAM,IAAI,GAAG,CAAC,CAAS,EAAU,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;AAEnE,MAAM,GAAG,GAAG,CAAC,CAAS,EAAU,EAAE,CAAC,CAAC,CAAC;AAErC,MAAM,CAAC,MAAM,QAAQ,GAAgD;IACnE,IAAI,EAAE,GAAG;IACT,QAAQ,EAAE,GAAG;IACb,IAAI,EAAE,GAAG;IACT,GAAG;IACH,IAAI;IACJ,GAAG;IACH,+EAA+E;IAC/E,6EAA6E;IAC7E,+BAA+B;IAC/B,WAAW,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,cAAc,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;CAC3C,CAAC"}
@@ -0,0 +1,5 @@
1
+ export { render, type RenderOptions } from "./render.js";
2
+ export { type Provider, InMemoryProvider } from "./provider.js";
3
+ export { ESCAPERS, type RenderFormat } from "./escapers.js";
4
+ export { verify, ERR_VAR_NOT_ON_PAYLOAD, ERR_PARTIAL_UNRESOLVED, ERR_REQUIRED_SLOT_UNUSED, type PayloadField, type VerifyError, type VerifyOptions, } from "./verify.js";
5
+ //# 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,MAAM,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,KAAK,QAAQ,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAChE,OAAO,EAAE,QAAQ,EAAE,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAC5D,OAAO,EACL,MAAM,EACN,sBAAsB,EACtB,sBAAsB,EACtB,wBAAwB,EACxB,KAAK,YAAY,EACjB,KAAK,WAAW,EAChB,KAAK,aAAa,GACnB,MAAM,aAAa,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { render } from "./render.js";
2
+ export { InMemoryProvider } from "./provider.js";
3
+ export { ESCAPERS } from "./escapers.js";
4
+ export { verify, ERR_VAR_NOT_ON_PAYLOAD, ERR_PARTIAL_UNRESOLVED, ERR_REQUIRED_SLOT_UNUSED, } from "./verify.js";
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAsB,MAAM,aAAa,CAAC;AACzD,OAAO,EAAiB,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAChE,OAAO,EAAE,QAAQ,EAAqB,MAAM,eAAe,CAAC;AAC5D,OAAO,EACL,MAAM,EACN,sBAAsB,EACtB,sBAAsB,EACtB,wBAAwB,GAIzB,MAAM,aAAa,CAAC"}
@@ -0,0 +1,11 @@
1
+ export interface Provider {
2
+ /** Resolve a `group/source` reference to its text, or undefined if absent. */
3
+ resolve(ref: string): string | undefined;
4
+ }
5
+ /** Deterministic, in-memory provider (the conformance + test provider). */
6
+ export declare class InMemoryProvider implements Provider {
7
+ private readonly map;
8
+ constructor(map: Record<string, string>);
9
+ resolve(ref: string): string | undefined;
10
+ }
11
+ //# sourceMappingURL=provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,QAAQ;IACvB,8EAA8E;IAC9E,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;CAC1C;AAED,2EAA2E;AAC3E,qBAAa,gBAAiB,YAAW,QAAQ;IACnC,OAAO,CAAC,QAAQ,CAAC,GAAG;gBAAH,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IACxD,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;CAGzC"}
@@ -0,0 +1,14 @@
1
+ // A provider resolves a 2-layer logical reference (`group/source`) to template
2
+ // text. The render engine never does I/O itself — it delegates resolution to
3
+ // the injected provider, so a fixed provider makes render() deterministic.
4
+ /** Deterministic, in-memory provider (the conformance + test provider). */
5
+ export class InMemoryProvider {
6
+ map;
7
+ constructor(map) {
8
+ this.map = map;
9
+ }
10
+ resolve(ref) {
11
+ return Object.prototype.hasOwnProperty.call(this.map, ref) ? this.map[ref] : undefined;
12
+ }
13
+ }
14
+ //# sourceMappingURL=provider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provider.js","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,6EAA6E;AAC7E,2EAA2E;AAO3E,2EAA2E;AAC3E,MAAM,OAAO,gBAAgB;IACE;IAA7B,YAA6B,GAA2B;QAA3B,QAAG,GAAH,GAAG,CAAwB;IAAG,CAAC;IAC5D,OAAO,CAAC,GAAW;QACjB,OAAO,MAAM,CAAC,SAAS,CAAC,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IACzF,CAAC;CACF"}
@@ -0,0 +1,23 @@
1
+ import type { Provider } from "./provider.js";
2
+ import { type RenderFormat } from "./escapers.js";
3
+ import { type PayloadField } from "./verify.js";
4
+ export interface RenderOptions {
5
+ /** Inline template text. Mutually exclusive with `ref`. */
6
+ template?: string;
7
+ /** A `group/source` reference resolved via `provider`. */
8
+ ref?: string;
9
+ /** The render payload (a plain object/array graph; pre-format primitives). */
10
+ payload: unknown;
11
+ provider: Provider;
12
+ /** Output format; drives escaping. Defaults to "text" (raw). */
13
+ format?: RenderFormat;
14
+ /**
15
+ * Fail-closed guard for dynamic/generated text: when given a payload field
16
+ * tree, the RESOLVED template is `verify`'d before rendering and a drifted
17
+ * variant throws — instead of silently rendering nothing.
18
+ */
19
+ verify?: PayloadField[];
20
+ }
21
+ /** Deterministic, logic-less render: (template + payload + provider) → string. */
22
+ export declare function render(o: RenderOptions): string;
23
+ //# sourceMappingURL=render.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAY,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAC5D,OAAO,EAAoC,KAAK,YAAY,EAAE,MAAM,aAAa,CAAC;AAoBlF,MAAM,WAAW,aAAa;IAC5B,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,0DAA0D;IAC1D,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,8EAA8E;IAC9E,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,QAAQ,CAAC;IACnB,gEAAgE;IAChE,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB;;;;OAIG;IACH,MAAM,CAAC,EAAE,YAAY,EAAE,CAAC;CACzB;AAED,kFAAkF;AAClF,wBAAgB,MAAM,CAAC,CAAC,EAAE,aAAa,GAAG,MAAM,CAyB/C"}
package/dist/render.js ADDED
@@ -0,0 +1,45 @@
1
+ import Mustache from "mustache";
2
+ import { ESCAPERS } from "./escapers.js";
3
+ import { verify, ERR_REQUIRED_SLOT_UNUSED } from "./verify.js";
4
+ const MAX_DEPTH = 32;
5
+ const PARTIAL = /\{\{>\s*([^}\s]+)\s*\}\}/g;
6
+ // Resolve `{{> group/source }}` partials by inlining their text, recursively,
7
+ // BEFORE Mustache parses — giving us deterministic whitespace and true,
8
+ // path-based cycle/depth detection (no reliance on a per-lib partial loader).
9
+ // Inlined text still renders in the surrounding context (e.g. once per loop
10
+ // item), exactly like a native partial, because Mustache renders the result.
11
+ function expand(text, provider, path) {
12
+ return text.replace(PARTIAL, (_match, ref) => {
13
+ if (path.includes(ref))
14
+ throw new Error(`partial cycle: ${[...path, ref].join(" -> ")}`);
15
+ if (path.length >= MAX_DEPTH)
16
+ throw new Error(`partial depth exceeded ${MAX_DEPTH}: ${ref}`);
17
+ const t = provider.resolve(ref);
18
+ if (t === undefined)
19
+ throw new Error(`unresolved partial: ${ref}`);
20
+ return expand(t, provider, [...path, ref]);
21
+ });
22
+ }
23
+ /** Deterministic, logic-less render: (template + payload + provider) → string. */
24
+ export function render(o) {
25
+ const body = o.template ?? (o.ref !== undefined ? o.provider.resolve(o.ref) : undefined);
26
+ if (body === undefined)
27
+ throw new Error(`unresolved ref: ${o.ref ?? "(none)"}`);
28
+ if (o.verify !== undefined) {
29
+ const drift = verify(body, o.verify, { provider: o.provider }).filter((e) => e.code !== ERR_REQUIRED_SLOT_UNUSED);
30
+ if (drift.length > 0) {
31
+ throw new Error(`render verify failed: ${drift.map((e) => `${e.code} (${e.path})`).join(", ")}`);
32
+ }
33
+ }
34
+ const expanded = expand(body, o.provider, o.ref !== undefined ? [o.ref] : []);
35
+ const escaper = ESCAPERS[o.format ?? "text"];
36
+ const prev = Mustache.escape;
37
+ Mustache.escape = (v) => escaper(typeof v === "string" ? v : String(v));
38
+ try {
39
+ return Mustache.render(expanded, o.payload, {});
40
+ }
41
+ finally {
42
+ Mustache.escape = prev;
43
+ }
44
+ }
45
+ //# sourceMappingURL=render.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.js","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,UAAU,CAAC;AAEhC,OAAO,EAAE,QAAQ,EAAqB,MAAM,eAAe,CAAC;AAC5D,OAAO,EAAE,MAAM,EAAE,wBAAwB,EAAqB,MAAM,aAAa,CAAC;AAElF,MAAM,SAAS,GAAG,EAAE,CAAC;AACrB,MAAM,OAAO,GAAG,2BAA2B,CAAC;AAE5C,8EAA8E;AAC9E,wEAAwE;AACxE,8EAA8E;AAC9E,4EAA4E;AAC5E,6EAA6E;AAC7E,SAAS,MAAM,CAAC,IAAY,EAAE,QAAkB,EAAE,IAAuB;IACvE,OAAO,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,MAAM,EAAE,GAAW,EAAE,EAAE;QACnD,IAAI,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,kBAAkB,CAAC,GAAG,IAAI,EAAE,GAAG,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QACzF,IAAI,IAAI,CAAC,MAAM,IAAI,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,0BAA0B,SAAS,KAAK,GAAG,EAAE,CAAC,CAAC;QAC7F,MAAM,CAAC,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAChC,IAAI,CAAC,KAAK,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,uBAAuB,GAAG,EAAE,CAAC,CAAC;QACnE,OAAO,MAAM,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC;IAC7C,CAAC,CAAC,CAAC;AACL,CAAC;AAoBD,kFAAkF;AAClF,MAAM,UAAU,MAAM,CAAC,CAAgB;IACrC,MAAM,IAAI,GAAG,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IACzF,IAAI,IAAI,KAAK,SAAS;QAAE,MAAM,IAAI,KAAK,CAAC,mBAAmB,CAAC,CAAC,GAAG,IAAI,QAAQ,EAAE,CAAC,CAAC;IAEhF,IAAI,CAAC,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;QAC3B,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,QAAQ,EAAE,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,MAAM,CACnE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,wBAAwB,CAC3C,CAAC;QACF,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACrB,MAAM,IAAI,KAAK,CACb,yBAAyB,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAChF,CAAC;QACJ,CAAC;IACH,CAAC;IAED,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IAC9E,MAAM,OAAO,GAAG,QAAQ,CAAC,CAAC,CAAC,MAAM,IAAI,MAAM,CAAC,CAAC;IAE7C,MAAM,IAAI,GAAG,QAAQ,CAAC,MAAM,CAAC;IAC7B,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAU,EAAE,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;IACjF,IAAI,CAAC;QACH,OAAO,QAAQ,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IAClD,CAAC;YAAS,CAAC;QACT,QAAQ,CAAC,MAAM,GAAG,IAAI,CAAC;IACzB,CAAC;AACH,CAAC"}
@@ -0,0 +1,34 @@
1
+ import type { Provider } from "./provider.js";
2
+ /** A `{{var}}` references a field the (contextual) payload does not declare. */
3
+ export declare const ERR_VAR_NOT_ON_PAYLOAD = "ERR_VAR_NOT_ON_PAYLOAD";
4
+ /** A `{{> ref}}` partial does not resolve in the provider. */
5
+ export declare const ERR_PARTIAL_UNRESOLVED = "ERR_PARTIAL_UNRESOLVED";
6
+ /** A declared @requiredSlots slot is never referenced by the template (warning). */
7
+ export declare const ERR_REQUIRED_SLOT_UNUSED = "ERR_REQUIRED_SLOT_UNUSED";
8
+ /**
9
+ * A plain field-tree node mirroring an `object.value` view-object's field walk.
10
+ * `fields` present → a context-pushing field (object / array-of-object); absent
11
+ * → a scalar (string/number/boolean/scalar-array).
12
+ */
13
+ export interface PayloadField {
14
+ name: string;
15
+ fields?: PayloadField[];
16
+ }
17
+ export interface VerifyError {
18
+ code: string;
19
+ /** The offending variable path, partial ref, or slot name. */
20
+ path: string;
21
+ }
22
+ export interface VerifyOptions {
23
+ /** When given, `{{> ref}}` partials are resolved + their bodies recursed. */
24
+ provider?: Provider;
25
+ /** Slots that MUST be referenced; an unused one is reported as a warning. */
26
+ requiredSlots?: string[];
27
+ }
28
+ /**
29
+ * Walk a Mustache template's tokens against a payload field tree, returning a
30
+ * list of drift errors. Context-sensitive: a section `{{#posts}}…{{/posts}}`
31
+ * over a container field checks its body against that field's element type.
32
+ */
33
+ export declare function verify(templateText: string, fields: PayloadField[], opts?: VerifyOptions): VerifyError[];
34
+ //# sourceMappingURL=verify.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAE9C,gFAAgF;AAChF,eAAO,MAAM,sBAAsB,2BAA2B,CAAC;AAC/D,8DAA8D;AAC9D,eAAO,MAAM,sBAAsB,2BAA2B,CAAC;AAC/D,oFAAoF;AACpF,eAAO,MAAM,wBAAwB,6BAA6B,CAAC;AAEnE;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,YAAY,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,aAAa;IAC5B,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,6EAA6E;IAC7E,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B;AAqCD;;;;GAIG;AACH,wBAAgB,MAAM,CACpB,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,YAAY,EAAE,EACtB,IAAI,CAAC,EAAE,aAAa,GACnB,WAAW,EAAE,CAoEf"}
package/dist/verify.js ADDED
@@ -0,0 +1,121 @@
1
+ // Template-side drift check (FR-004 Plan #3, T5). The other half of the
2
+ // guarantee: Phase B types the payload at the CALL SITE (compile-time), while
3
+ // `verify` parses the opaque template TEXT and cross-checks every variable
4
+ // against the payload's declared field tree (build-time). A Mustache template
5
+ // is a runtime string the TS compiler can't see into, so this is the only way
6
+ // to catch "a renamed field silently broke a prompt".
7
+ //
8
+ // Zero core dependency by design: `verify` takes a PLAIN field tree (no
9
+ // metadata import). The CLI derives that tree from the loaded object.value and
10
+ // passes it in — keeping this engine a standalone, byte-portable module.
11
+ import Mustache from "mustache";
12
+ /** A `{{var}}` references a field the (contextual) payload does not declare. */
13
+ export const ERR_VAR_NOT_ON_PAYLOAD = "ERR_VAR_NOT_ON_PAYLOAD";
14
+ /** A `{{> ref}}` partial does not resolve in the provider. */
15
+ export const ERR_PARTIAL_UNRESOLVED = "ERR_PARTIAL_UNRESOLVED";
16
+ /** A declared @requiredSlots slot is never referenced by the template (warning). */
17
+ export const ERR_REQUIRED_SLOT_UNUSED = "ERR_REQUIRED_SLOT_UNUSED";
18
+ const MAX_DEPTH = 32;
19
+ function find(fields, name) {
20
+ return fields.find((f) => f.name === name);
21
+ }
22
+ // Resolve a (possibly dotted) variable path the way Mustache does: the FIRST
23
+ // segment is looked up through the context stack (innermost → outermost); each
24
+ // remaining segment is a direct descent into the resolved field's `fields`.
25
+ // Returns the resolved field, or undefined if any segment is missing.
26
+ function resolve(stack, path) {
27
+ const segs = path.split(".");
28
+ let current;
29
+ for (let i = stack.length - 1; i >= 0; i--) {
30
+ const hit = find(stack[i], segs[0]);
31
+ if (hit) {
32
+ current = hit;
33
+ break;
34
+ }
35
+ }
36
+ for (let i = 1; current && i < segs.length; i++) {
37
+ current = current.fields ? find(current.fields, segs[i]) : undefined;
38
+ }
39
+ return current;
40
+ }
41
+ function parse(text) {
42
+ return Mustache.parse(text);
43
+ }
44
+ /**
45
+ * Walk a Mustache template's tokens against a payload field tree, returning a
46
+ * list of drift errors. Context-sensitive: a section `{{#posts}}…{{/posts}}`
47
+ * over a container field checks its body against that field's element type.
48
+ */
49
+ export function verify(templateText, fields, opts) {
50
+ const errors = [];
51
+ const provider = opts?.provider;
52
+ const root = fields;
53
+ const referencedAtRoot = new Set();
54
+ function walk(tokens, stack, seen) {
55
+ const atRoot = stack.length === 1 && stack[0] === root;
56
+ for (const tok of tokens) {
57
+ const type = tok[0];
58
+ const value = tok[1];
59
+ switch (type) {
60
+ case "name": // {{x}}
61
+ case "&": // {{&x}}
62
+ case "{": {
63
+ // {{{x}}} (spec); mustache.js emits "&" for it too
64
+ if (value === ".")
65
+ break; // implicit iterator — always valid
66
+ if (atRoot)
67
+ referencedAtRoot.add(value.split(".")[0]);
68
+ if (!resolve(stack, value))
69
+ errors.push({ code: ERR_VAR_NOT_ON_PAYLOAD, path: value });
70
+ break;
71
+ }
72
+ case "#": // {{#x}}…{{/x}}
73
+ case "^": {
74
+ // {{^x}}…{{/x}}
75
+ const sub = Array.isArray(tok[4]) ? tok[4] : [];
76
+ if (value === ".") {
77
+ walk(sub, stack, seen);
78
+ break;
79
+ }
80
+ if (atRoot)
81
+ referencedAtRoot.add(value.split(".")[0]);
82
+ const field = resolve(stack, value);
83
+ if (!field) {
84
+ // Unresolved section head is itself drift; skip the body (its
85
+ // context is unknowable, walking it would cascade false errors).
86
+ errors.push({ code: ERR_VAR_NOT_ON_PAYLOAD, path: value });
87
+ break;
88
+ }
89
+ // `#` over a container pushes its element fields; `^` (and `#` over a
90
+ // scalar, used as a conditional) keep the current context.
91
+ const push = type === "#" && field.fields !== undefined;
92
+ walk(sub, push ? [...stack, field.fields] : stack, seen);
93
+ break;
94
+ }
95
+ case ">": {
96
+ // {{> group/source}}
97
+ if (!provider)
98
+ break; // can't resolve without a provider
99
+ if (seen.includes(value) || seen.length >= MAX_DEPTH)
100
+ break; // cycle/depth guard
101
+ const text = provider.resolve(value);
102
+ if (text === undefined) {
103
+ errors.push({ code: ERR_PARTIAL_UNRESOLVED, path: value });
104
+ break;
105
+ }
106
+ walk(parse(text), stack, [...seen, value]);
107
+ break;
108
+ }
109
+ default:
110
+ break; // text / comment / set-delimiter
111
+ }
112
+ }
113
+ }
114
+ walk(parse(templateText), [root], []);
115
+ for (const slot of opts?.requiredSlots ?? []) {
116
+ if (!referencedAtRoot.has(slot))
117
+ errors.push({ code: ERR_REQUIRED_SLOT_UNUSED, path: slot });
118
+ }
119
+ return errors;
120
+ }
121
+ //# sourceMappingURL=verify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verify.js","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@metaobjectsdev/render",
3
+ "version": "0.5.0",
4
+ "description": "Logic-less, deterministic text render engine (Mustache) for MetaObjects templates — provider-resolved partials, format-driven escaping, zero core dependency.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "bun": "./src/index.ts",
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": ["dist", "src", "README.md", "LICENSE"],
16
+ "scripts": {
17
+ "build": "tsc -p .",
18
+ "typecheck": "tsc -p tsconfig.typecheck.json"
19
+ },
20
+ "license": "Apache-2.0",
21
+ "author": "Doug Mealing <doug@dougmealing.com>",
22
+ "homepage": "https://metaobjects.dev",
23
+ "bugs": {
24
+ "url": "https://github.com/metaobjectsdev/metaobjects/issues"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/metaobjectsdev/metaobjects.git",
29
+ "directory": "server/typescript/packages/render"
30
+ },
31
+ "keywords": ["metaobjects", "render", "mustache", "prompt", "template"],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "dependencies": {
36
+ "mustache": "^4.2.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/mustache": "^4.2.5",
40
+ "bun-types": "latest",
41
+ "typescript": "^5.6.0"
42
+ }
43
+ }
@@ -0,0 +1,49 @@
1
+ // Format-driven escaping (FR-004 R7/R8). The render engine OWNS escaping (it does
2
+ // not rely on the Mustache lib's HTML-only default), so behavior is identical
3
+ // across language ports. `{{var}}` is escaped per format; `{{{var}}}` is raw.
4
+ //
5
+ // RenderFormat is defined here, independent of the metadata package's
6
+ // TEMPLATE_FORMATS, to keep this engine a zero-core-dependency module. The two
7
+ // sets are kept in lock-step by the render-conformance corpus.
8
+
9
+ export type RenderFormat =
10
+ | "text"
11
+ | "html"
12
+ | "xml"
13
+ | "csv"
14
+ | "json"
15
+ | "markdown"
16
+ | "spreadsheet";
17
+
18
+ const xml = (s: string): string =>
19
+ s
20
+ .replace(/&/g, "&amp;")
21
+ .replace(/</g, "&lt;")
22
+ .replace(/>/g, "&gt;")
23
+ .replace(/"/g, "&quot;")
24
+ .replace(/'/g, "&#39;");
25
+
26
+ // OWASP CSV/Excel formula-injection guard: neutralize a leading active char.
27
+ const injectionGuard = (s: string): string => (/^[=+\-@\t\r]/.test(s) ? "'" + s : s);
28
+
29
+ const csv = (s: string): string => {
30
+ const v = injectionGuard(s);
31
+ return /[",\n\r]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
32
+ };
33
+
34
+ const json = (s: string): string => JSON.stringify(s).slice(1, -1);
35
+
36
+ const raw = (s: string): string => s;
37
+
38
+ export const ESCAPERS: Record<RenderFormat, (s: string) => string> = {
39
+ text: raw,
40
+ markdown: raw,
41
+ html: xml,
42
+ xml,
43
+ json,
44
+ csv,
45
+ // XML-escape the content first, then guard — so the guard's leading quote is a
46
+ // literal apostrophe in the XML (which is what tells Excel "treat as text"),
47
+ // not itself escaped to &#39;.
48
+ spreadsheet: (s) => injectionGuard(xml(s)),
49
+ };
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export { render, type RenderOptions } from "./render.js";
2
+ export { type Provider, InMemoryProvider } from "./provider.js";
3
+ export { ESCAPERS, type RenderFormat } from "./escapers.js";
4
+ export {
5
+ verify,
6
+ ERR_VAR_NOT_ON_PAYLOAD,
7
+ ERR_PARTIAL_UNRESOLVED,
8
+ ERR_REQUIRED_SLOT_UNUSED,
9
+ type PayloadField,
10
+ type VerifyError,
11
+ type VerifyOptions,
12
+ } from "./verify.js";
@@ -0,0 +1,16 @@
1
+ // A provider resolves a 2-layer logical reference (`group/source`) to template
2
+ // text. The render engine never does I/O itself — it delegates resolution to
3
+ // the injected provider, so a fixed provider makes render() deterministic.
4
+
5
+ export interface Provider {
6
+ /** Resolve a `group/source` reference to its text, or undefined if absent. */
7
+ resolve(ref: string): string | undefined;
8
+ }
9
+
10
+ /** Deterministic, in-memory provider (the conformance + test provider). */
11
+ export class InMemoryProvider implements Provider {
12
+ constructor(private readonly map: Record<string, string>) {}
13
+ resolve(ref: string): string | undefined {
14
+ return Object.prototype.hasOwnProperty.call(this.map, ref) ? this.map[ref] : undefined;
15
+ }
16
+ }
package/src/render.ts ADDED
@@ -0,0 +1,68 @@
1
+ import Mustache from "mustache";
2
+ import type { Provider } from "./provider.js";
3
+ import { ESCAPERS, type RenderFormat } from "./escapers.js";
4
+ import { verify, ERR_REQUIRED_SLOT_UNUSED, type PayloadField } from "./verify.js";
5
+
6
+ const MAX_DEPTH = 32;
7
+ const PARTIAL = /\{\{>\s*([^}\s]+)\s*\}\}/g;
8
+
9
+ // Resolve `{{> group/source }}` partials by inlining their text, recursively,
10
+ // BEFORE Mustache parses — giving us deterministic whitespace and true,
11
+ // path-based cycle/depth detection (no reliance on a per-lib partial loader).
12
+ // Inlined text still renders in the surrounding context (e.g. once per loop
13
+ // item), exactly like a native partial, because Mustache renders the result.
14
+ function expand(text: string, provider: Provider, path: readonly string[]): string {
15
+ return text.replace(PARTIAL, (_match, ref: string) => {
16
+ if (path.includes(ref)) throw new Error(`partial cycle: ${[...path, ref].join(" -> ")}`);
17
+ if (path.length >= MAX_DEPTH) throw new Error(`partial depth exceeded ${MAX_DEPTH}: ${ref}`);
18
+ const t = provider.resolve(ref);
19
+ if (t === undefined) throw new Error(`unresolved partial: ${ref}`);
20
+ return expand(t, provider, [...path, ref]);
21
+ });
22
+ }
23
+
24
+ export interface RenderOptions {
25
+ /** Inline template text. Mutually exclusive with `ref`. */
26
+ template?: string;
27
+ /** A `group/source` reference resolved via `provider`. */
28
+ ref?: string;
29
+ /** The render payload (a plain object/array graph; pre-format primitives). */
30
+ payload: unknown;
31
+ provider: Provider;
32
+ /** Output format; drives escaping. Defaults to "text" (raw). */
33
+ format?: RenderFormat;
34
+ /**
35
+ * Fail-closed guard for dynamic/generated text: when given a payload field
36
+ * tree, the RESOLVED template is `verify`'d before rendering and a drifted
37
+ * variant throws — instead of silently rendering nothing.
38
+ */
39
+ verify?: PayloadField[];
40
+ }
41
+
42
+ /** Deterministic, logic-less render: (template + payload + provider) → string. */
43
+ export function render(o: RenderOptions): string {
44
+ const body = o.template ?? (o.ref !== undefined ? o.provider.resolve(o.ref) : undefined);
45
+ if (body === undefined) throw new Error(`unresolved ref: ${o.ref ?? "(none)"}`);
46
+
47
+ if (o.verify !== undefined) {
48
+ const drift = verify(body, o.verify, { provider: o.provider }).filter(
49
+ (e) => e.code !== ERR_REQUIRED_SLOT_UNUSED,
50
+ );
51
+ if (drift.length > 0) {
52
+ throw new Error(
53
+ `render verify failed: ${drift.map((e) => `${e.code} (${e.path})`).join(", ")}`,
54
+ );
55
+ }
56
+ }
57
+
58
+ const expanded = expand(body, o.provider, o.ref !== undefined ? [o.ref] : []);
59
+ const escaper = ESCAPERS[o.format ?? "text"];
60
+
61
+ const prev = Mustache.escape;
62
+ Mustache.escape = (v: unknown) => escaper(typeof v === "string" ? v : String(v));
63
+ try {
64
+ return Mustache.render(expanded, o.payload, {});
65
+ } finally {
66
+ Mustache.escape = prev;
67
+ }
68
+ }
package/src/verify.ts ADDED
@@ -0,0 +1,157 @@
1
+ // Template-side drift check (FR-004 Plan #3, T5). The other half of the
2
+ // guarantee: Phase B types the payload at the CALL SITE (compile-time), while
3
+ // `verify` parses the opaque template TEXT and cross-checks every variable
4
+ // against the payload's declared field tree (build-time). A Mustache template
5
+ // is a runtime string the TS compiler can't see into, so this is the only way
6
+ // to catch "a renamed field silently broke a prompt".
7
+ //
8
+ // Zero core dependency by design: `verify` takes a PLAIN field tree (no
9
+ // metadata import). The CLI derives that tree from the loaded object.value and
10
+ // passes it in — keeping this engine a standalone, byte-portable module.
11
+
12
+ import Mustache from "mustache";
13
+ import type { Provider } from "./provider.js";
14
+
15
+ /** A `{{var}}` references a field the (contextual) payload does not declare. */
16
+ export const ERR_VAR_NOT_ON_PAYLOAD = "ERR_VAR_NOT_ON_PAYLOAD";
17
+ /** A `{{> ref}}` partial does not resolve in the provider. */
18
+ export const ERR_PARTIAL_UNRESOLVED = "ERR_PARTIAL_UNRESOLVED";
19
+ /** A declared @requiredSlots slot is never referenced by the template (warning). */
20
+ export const ERR_REQUIRED_SLOT_UNUSED = "ERR_REQUIRED_SLOT_UNUSED";
21
+
22
+ /**
23
+ * A plain field-tree node mirroring an `object.value` view-object's field walk.
24
+ * `fields` present → a context-pushing field (object / array-of-object); absent
25
+ * → a scalar (string/number/boolean/scalar-array).
26
+ */
27
+ export interface PayloadField {
28
+ name: string;
29
+ fields?: PayloadField[];
30
+ }
31
+
32
+ export interface VerifyError {
33
+ code: string;
34
+ /** The offending variable path, partial ref, or slot name. */
35
+ path: string;
36
+ }
37
+
38
+ export interface VerifyOptions {
39
+ /** When given, `{{> ref}}` partials are resolved + their bodies recursed. */
40
+ provider?: Provider;
41
+ /** Slots that MUST be referenced; an unused one is reported as a warning. */
42
+ requiredSlots?: string[];
43
+ }
44
+
45
+ const MAX_DEPTH = 32;
46
+
47
+ // A Mustache parse token: [type, value, start, end, subTokens?, ...].
48
+ type Token = readonly unknown[];
49
+ // The context stack — innermost context last, mirroring Mustache lookup order.
50
+ type Stack = readonly PayloadField[][];
51
+
52
+ function find(fields: PayloadField[], name: string): PayloadField | undefined {
53
+ return fields.find((f) => f.name === name);
54
+ }
55
+
56
+ // Resolve a (possibly dotted) variable path the way Mustache does: the FIRST
57
+ // segment is looked up through the context stack (innermost → outermost); each
58
+ // remaining segment is a direct descent into the resolved field's `fields`.
59
+ // Returns the resolved field, or undefined if any segment is missing.
60
+ function resolve(stack: Stack, path: string): PayloadField | undefined {
61
+ const segs = path.split(".");
62
+ let current: PayloadField | undefined;
63
+ for (let i = stack.length - 1; i >= 0; i--) {
64
+ const hit = find(stack[i]!, segs[0]!);
65
+ if (hit) {
66
+ current = hit;
67
+ break;
68
+ }
69
+ }
70
+ for (let i = 1; current && i < segs.length; i++) {
71
+ current = current.fields ? find(current.fields, segs[i]!) : undefined;
72
+ }
73
+ return current;
74
+ }
75
+
76
+ function parse(text: string): Token[] {
77
+ return Mustache.parse(text) as unknown as Token[];
78
+ }
79
+
80
+ /**
81
+ * Walk a Mustache template's tokens against a payload field tree, returning a
82
+ * list of drift errors. Context-sensitive: a section `{{#posts}}…{{/posts}}`
83
+ * over a container field checks its body against that field's element type.
84
+ */
85
+ export function verify(
86
+ templateText: string,
87
+ fields: PayloadField[],
88
+ opts?: VerifyOptions,
89
+ ): VerifyError[] {
90
+ const errors: VerifyError[] = [];
91
+ const provider = opts?.provider;
92
+ const root = fields;
93
+ const referencedAtRoot = new Set<string>();
94
+
95
+ function walk(tokens: Token[], stack: Stack, seen: readonly string[]): void {
96
+ const atRoot = stack.length === 1 && stack[0] === root;
97
+ for (const tok of tokens) {
98
+ const type = tok[0] as string;
99
+ const value = tok[1] as string;
100
+ switch (type) {
101
+ case "name": // {{x}}
102
+ case "&": // {{&x}}
103
+ case "{": {
104
+ // {{{x}}} (spec); mustache.js emits "&" for it too
105
+ if (value === ".") break; // implicit iterator — always valid
106
+ if (atRoot) referencedAtRoot.add(value.split(".")[0]!);
107
+ if (!resolve(stack, value)) errors.push({ code: ERR_VAR_NOT_ON_PAYLOAD, path: value });
108
+ break;
109
+ }
110
+ case "#": // {{#x}}…{{/x}}
111
+ case "^": {
112
+ // {{^x}}…{{/x}}
113
+ const sub = Array.isArray(tok[4]) ? (tok[4] as Token[]) : [];
114
+ if (value === ".") {
115
+ walk(sub, stack, seen);
116
+ break;
117
+ }
118
+ if (atRoot) referencedAtRoot.add(value.split(".")[0]!);
119
+ const field = resolve(stack, value);
120
+ if (!field) {
121
+ // Unresolved section head is itself drift; skip the body (its
122
+ // context is unknowable, walking it would cascade false errors).
123
+ errors.push({ code: ERR_VAR_NOT_ON_PAYLOAD, path: value });
124
+ break;
125
+ }
126
+ // `#` over a container pushes its element fields; `^` (and `#` over a
127
+ // scalar, used as a conditional) keep the current context.
128
+ const push = type === "#" && field.fields !== undefined;
129
+ walk(sub, push ? [...stack, field.fields!] : stack, seen);
130
+ break;
131
+ }
132
+ case ">": {
133
+ // {{> group/source}}
134
+ if (!provider) break; // can't resolve without a provider
135
+ if (seen.includes(value) || seen.length >= MAX_DEPTH) break; // cycle/depth guard
136
+ const text = provider.resolve(value);
137
+ if (text === undefined) {
138
+ errors.push({ code: ERR_PARTIAL_UNRESOLVED, path: value });
139
+ break;
140
+ }
141
+ walk(parse(text), stack, [...seen, value]);
142
+ break;
143
+ }
144
+ default:
145
+ break; // text / comment / set-delimiter
146
+ }
147
+ }
148
+ }
149
+
150
+ walk(parse(templateText), [root], []);
151
+
152
+ for (const slot of opts?.requiredSlots ?? []) {
153
+ if (!referencedAtRoot.has(slot)) errors.push({ code: ERR_REQUIRED_SLOT_UNUSED, path: slot });
154
+ }
155
+
156
+ return errors;
157
+ }