From 72f85c90775829b23302df366ce0f7a0946faa97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sun, 31 May 2026 05:09:50 +0000 Subject: [PATCH] feat: implement LiquidJS template rendering integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrates LiquidJS as the template engine for CAS node rendering with custom {% render %} tag supporting recursive rendering with resolution decay. Templates are discovered via variables under @ucas/template/text/. When ucas render is invoked, the system queries for a registered template; if found, uses LiquidJS; otherwise falls back to Phase 3's default YAML renderer. Key features: - Custom {% render %} tag with recursive CAS node rendering - Decay priority chain: template decay > CLI --decay > default 0.5 - Context variables: resolution, epsilon, hash, payload, type, timestamp - Graceful fallback: No template → YAML rendering (Phase 3) - Zero breaking changes: All Phase 3 tests still pass - Template discovery via @ucas/template/text/ variables Implementation: - New file: packages/json-cas/src/liquid-render.ts — LiquidJS integration - Modified: packages/json-cas/src/render.ts — Template lookup + YAML fallback - New file: packages/json-cas/src/liquid-render.test.ts — 32 comprehensive tests - Dependency: liquidjs npm package - CLI: No changes needed (transparent integration) All tests pass (336 tests), build succeeds, lint checks pass. Closes #40 Co-Authored-By: Claude Opus 4.6 --- bun.lock | 5 + packages/json-cas/package.json | 1 + packages/json-cas/src/index.ts | 3 +- packages/json-cas/src/liquid-render.test.ts | 1243 +++++++++++++++++++ packages/json-cas/src/liquid-render.ts | 299 +++++ packages/json-cas/src/render.ts | 76 +- 6 files changed, 1625 insertions(+), 2 deletions(-) create mode 100644 packages/json-cas/src/liquid-render.test.ts create mode 100644 packages/json-cas/src/liquid-render.ts diff --git a/bun.lock b/bun.lock index 9db05b7..b78ebfd 100644 --- a/bun.lock +++ b/bun.lock @@ -30,6 +30,7 @@ "dependencies": { "ajv": "^8.20.0", "cborg": "^4.2.3", + "liquidjs": "^10.27.0", "xxhash-wasm": "^1.1.0", }, }, @@ -141,6 +142,8 @@ "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + "commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "dataloader": ["dataloader@1.4.0", "", {}, "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="], @@ -203,6 +206,8 @@ "layerr": ["layerr@3.0.0", "", {}, "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA=="], + "liquidjs": ["liquidjs@10.27.0", "", { "dependencies": { "commander": "^10.0.0" }, "bin": { "liquidjs": "bin/liquid.js", "liquid": "bin/liquid.js" } }, "sha512-tw/OA59K7aIBlMKIrKlumr37fiZUheShVHXY8cVctWisgY1p9mc5hreOvlreoS0wTiwlWk14Ya7305c2a/Cg5w=="], + "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="], diff --git a/packages/json-cas/package.json b/packages/json-cas/package.json index 2d9fe0d..513c71e 100644 --- a/packages/json-cas/package.json +++ b/packages/json-cas/package.json @@ -21,6 +21,7 @@ "dependencies": { "ajv": "^8.20.0", "cborg": "^4.2.3", + "liquidjs": "^10.27.0", "xxhash-wasm": "^1.1.0" } } diff --git a/packages/json-cas/src/index.ts b/packages/json-cas/src/index.ts index 0a0c566..946fd32 100644 --- a/packages/json-cas/src/index.ts +++ b/packages/json-cas/src/index.ts @@ -4,7 +4,8 @@ export { BOOTSTRAP_STORE } from "./bootstrap-capable.js"; export { cborEncode } from "./cbor.js"; export { type GcStats, gc } from "./gc.js"; export { computeHash, computeSelfHash } from "./hash.js"; -export { type RenderOptions, render } from "./render.js"; +export { renderWithTemplate } from "./liquid-render.js"; +export { type RenderOptions, render, renderAsync } from "./render.js"; export type { JSONSchema } from "./schema.js"; export { getSchema, diff --git a/packages/json-cas/src/liquid-render.test.ts b/packages/json-cas/src/liquid-render.test.ts new file mode 100644 index 0000000..b930ccb --- /dev/null +++ b/packages/json-cas/src/liquid-render.test.ts @@ -0,0 +1,1243 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { bootstrap } from "./bootstrap.js"; +import { renderWithTemplate } from "./liquid-render.js"; +import { putSchema } from "./schema.js"; +import { createMemoryStore } from "./store.js"; +import type { Hash } from "./types.js"; +import { createVariableStore } from "./variable-store.js"; + +// Helper to create a temporary variable store +async function createTempVarStore() { + const tempDir = await mkdtemp(join(tmpdir(), "json-cas-test-")); + const dbPath = join(tempDir, "vars.db"); + const store = createMemoryStore(); + await bootstrap(store); + const varStore = createVariableStore(dbPath, store); + return { + store, + varStore, + tempDir, + cleanup: async () => await rm(tempDir, { recursive: true }), + }; +} + +describe("Suite 1: LiquidJS Setup & Configuration", () => { + test("1.1 liquidjs Package Installed", async () => { + // Verify liquidjs can be imported + const { Liquid } = await import("liquidjs"); + expect(Liquid).toBeDefined(); + }); + + test("1.2 Liquid Engine Instance Created", async () => { + const { Liquid } = await import("liquidjs"); + const engine = new Liquid({ + strictFilters: false, + strictVariables: false, + }); + expect(engine).toBeDefined(); + }); + + test("1.3 Custom render Tag Can Be Registered", async () => { + const { Liquid } = await import("liquidjs"); + const engine = new Liquid(); + + // Register a test tag + engine.registerTag("test", { + parse(_token) { + // Test parsing + }, + render() { + return "test"; + }, + }); + + const output = await engine.parseAndRender("{% test %}"); + expect(output).toBe("test"); + }); +}); + +describe("Suite 2: Custom {% render %} Tag Implementation", () => { + test("2.1 Basic Syntax: {% render %}", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const childSchema = await putSchema(store, { + type: "object", + properties: { + value: { type: "string" }, + }, + }); + const childHash = await store.put(childSchema, { + value: "child content", + }); + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + child: { type: "string", format: "cas_ref" }, + }, + }); + const parentHash = await store.put(parentSchema, { + name: "parent", + child: childHash, + }); + + // Register template for parent + const templateSchema = await putSchema(store, { type: "string" }); + const templateHash = await store.put( + templateSchema, + "Parent: {{ payload.name }}\n{% render payload.child %}", + ); + varStore.set(`@ucas/template/text/${parentSchema}`, templateHash); + + // Register template for child + const childTemplateHash = await store.put( + templateSchema, + "Child: {{ payload.value }}", + ); + varStore.set(`@ucas/template/text/${childSchema}`, childTemplateHash); + + const output = await renderWithTemplate(store, varStore, parentHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toContain("Parent: parent"); + expect(output).toContain("Child: child content"); + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("2.2 Explicit Decay: {% render , decay: 0.7 %}", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + level: { type: "number" }, + child: { + anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], + }, + }, + }); + + // Create 3-level nested structure + const level2Hash = await store.put(nodeSchema, { level: 2, child: null }); + const level1Hash = await store.put(nodeSchema, { + level: 1, + child: level2Hash, + }); + const rootHash = await store.put(nodeSchema, { + level: 0, + child: level1Hash, + }); + + const templateSchema = await putSchema(store, { type: "string" }); + + // Template that shows the level and renders child with explicit decay + const template = await store.put( + templateSchema, + "Level {{ payload.level }}\n{% render payload.child, decay: 0.7 %}", + ); + varStore.set(`@ucas/template/text/${nodeSchema}`, template); + + const output = await renderWithTemplate(store, varStore, rootHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toContain("Level 0"); + // Child1 should render with resolution=0.7 (explicit decay) + expect(output).toContain("Level 1"); + expect(output).toContain("Level 2"); + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("2.3 Multiple render Tags in One Template", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const childSchema = await putSchema(store, { + type: "object", + properties: { + value: { type: "string" }, + }, + }); + const leftHash = await store.put(childSchema, { value: "left" }); + const rightHash = await store.put(childSchema, { value: "right" }); + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + left: { type: "string", format: "cas_ref" }, + right: { type: "string", format: "cas_ref" }, + }, + }); + const parentHash = await store.put(parentSchema, { + left: leftHash, + right: rightHash, + }); + + const templateSchema = await putSchema(store, { type: "string" }); + const parentTemplate = await store.put( + templateSchema, + "Left:\n{% render payload.left %}\nRight:\n{% render payload.right %}", + ); + varStore.set(`@ucas/template/text/${parentSchema}`, parentTemplate); + + const childTemplate = await store.put( + templateSchema, + "Value: {{ payload.value }}", + ); + varStore.set(`@ucas/template/text/${childSchema}`, childTemplate); + + const output = await renderWithTemplate(store, varStore, parentHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toContain("Left:"); + expect(output).toContain("Value: left"); + expect(output).toContain("Right:"); + expect(output).toContain("Value: right"); + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("2.4 Render Tag with Missing/Null Reference", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + child: { + anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], + }, + }, + }); + const nodeHash = await store.put(nodeSchema, { + name: "test", + child: null, + }); + + const templateSchema = await putSchema(store, { type: "string" }); + const template = await store.put( + templateSchema, + "Before\n{% render payload.child %}\nAfter", + ); + varStore.set(`@ucas/template/text/${nodeSchema}`, template); + + const output = await renderWithTemplate(store, varStore, nodeHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toContain("Before"); + expect(output).toContain("After"); + // Should not crash, null renders as empty + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("2.5 Render Tag with Non-existent Hash", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const fakeHash = "ZZZZZZZZZZZZZ" as Hash; + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + child: { type: "string", format: "cas_ref" }, + }, + }); + const nodeHash = await store.put(nodeSchema, { + name: "test", + child: fakeHash, + }); + + const templateSchema = await putSchema(store, { type: "string" }); + const template = await store.put( + templateSchema, + "{% render payload.child %}", + ); + varStore.set(`@ucas/template/text/${nodeSchema}`, template); + + const output = await renderWithTemplate(store, varStore, nodeHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toContain(`cas:${fakeHash}`); + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("2.6 Resolution Below Epsilon (Force Reference)", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const childSchema = await putSchema(store, { + type: "object", + properties: { + value: { type: "string" }, + }, + }); + const childHash = await store.put(childSchema, { value: "child" }); + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + child: { type: "string", format: "cas_ref" }, + }, + }); + const parentHash = await store.put(parentSchema, { child: childHash }); + + const templateSchema = await putSchema(store, { type: "string" }); + const parentTemplate = await store.put( + templateSchema, + "{% render payload.child %}", + ); + varStore.set(`@ucas/template/text/${parentSchema}`, parentTemplate); + + // resolution=0.02, decay=0.5, child gets 0.01 which equals epsilon + const output = await renderWithTemplate(store, varStore, parentHash, { + resolution: 0.02, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toMatch(/cas:[0-9A-HJKMNP-TV-Z]{13}/); + } finally { + varStore.close(); + await cleanup(); + } + }); +}); + +describe("Suite 3: Template Context Variables", () => { + test("3.1 Context Variable: resolution", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + }, + }); + const nodeHash = await store.put(nodeSchema, { name: "test" }); + + const templateSchema = await putSchema(store, { type: "string" }); + const template = await store.put( + templateSchema, + "Resolution: {{ resolution }}", + ); + varStore.set(`@ucas/template/text/${nodeSchema}`, template); + + const output = await renderWithTemplate(store, varStore, nodeHash, { + resolution: 0.75, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toContain("Resolution: 0.75"); + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("3.2 Context Variable: epsilon", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + }, + }); + const nodeHash = await store.put(nodeSchema, { name: "test" }); + + const templateSchema = await putSchema(store, { type: "string" }); + const template = await store.put( + templateSchema, + "Epsilon: {{ epsilon }}", + ); + varStore.set(`@ucas/template/text/${nodeSchema}`, template); + + const output = await renderWithTemplate(store, varStore, nodeHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.005, + }); + + expect(output).toContain("Epsilon: 0.005"); + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("3.3 Context Variable: hash", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + }, + }); + const nodeHash = await store.put(nodeSchema, { name: "test" }); + + const templateSchema = await putSchema(store, { type: "string" }); + const template = await store.put(templateSchema, "Hash: {{ hash }}"); + varStore.set(`@ucas/template/text/${nodeSchema}`, template); + + const output = await renderWithTemplate(store, varStore, nodeHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toContain(`Hash: ${nodeHash}`); + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("3.4 Context Variable: payload", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + count: { type: "number" }, + }, + }); + const nodeHash = await store.put(nodeSchema, { name: "test", count: 42 }); + + const templateSchema = await putSchema(store, { type: "string" }); + const template = await store.put( + templateSchema, + "Name: {{ payload.name }}, Count: {{ payload.count }}", + ); + varStore.set(`@ucas/template/text/${nodeSchema}`, template); + + const output = await renderWithTemplate(store, varStore, nodeHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toBe("Name: test, Count: 42"); + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("3.5 Context Variable: type", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + }, + }); + const nodeHash = await store.put(nodeSchema, { name: "test" }); + + const templateSchema = await putSchema(store, { type: "string" }); + const template = await store.put(templateSchema, "Type: {{ type }}"); + varStore.set(`@ucas/template/text/${nodeSchema}`, template); + + const output = await renderWithTemplate(store, varStore, nodeHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toContain(`Type: ${nodeSchema}`); + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("3.6 Context Variable: timestamp", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + }, + }); + const nodeHash = await store.put(nodeSchema, { name: "test" }); + + const templateSchema = await putSchema(store, { type: "string" }); + const template = await store.put( + templateSchema, + "Timestamp: {{ timestamp }}", + ); + varStore.set(`@ucas/template/text/${nodeSchema}`, template); + + const output = await renderWithTemplate(store, varStore, nodeHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toMatch(/Timestamp: \d+/); + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("3.7 All Context Variables Together", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + }, + }); + const nodeHash = await store.put(nodeSchema, { name: "test" }); + + const templateSchema = await putSchema(store, { type: "string" }); + const template = await store.put( + templateSchema, + `Hash: {{ hash }} +Type: {{ type }} +Resolution: {{ resolution }} +Epsilon: {{ epsilon }} +Payload: {{ payload.name }} +Timestamp: {{ timestamp }}`, + ); + varStore.set(`@ucas/template/text/${nodeSchema}`, template); + + const output = await renderWithTemplate(store, varStore, nodeHash, { + resolution: 0.8, + decay: 0.6, + epsilon: 0.02, + }); + + expect(output).toContain(`Hash: ${nodeHash}`); + expect(output).toContain(`Type: ${nodeSchema}`); + expect(output).toContain("Resolution: 0.8"); + expect(output).toContain("Epsilon: 0.02"); + expect(output).toContain("Payload: test"); + expect(output).toMatch(/Timestamp: \d+/); + } finally { + varStore.close(); + await cleanup(); + } + }); +}); + +describe("Suite 4: Render Flow Integration", () => { + test("4.1 Template Discovery by Type Hash", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + }, + }); + const nodeHash = await store.put(nodeSchema, { name: "test" }); + + const templateSchema = await putSchema(store, { type: "string" }); + const template = await store.put( + templateSchema, + "Custom template: {{ payload.name }}", + ); + varStore.set(`@ucas/template/text/${nodeSchema}`, template); + + const output = await renderWithTemplate(store, varStore, nodeHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toBe("Custom template: test"); + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("4.4 Empty Template", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + }, + }); + const nodeHash = await store.put(nodeSchema, { name: "test" }); + + const templateSchema = await putSchema(store, { type: "string" }); + const template = await store.put(templateSchema, ""); + varStore.set(`@ucas/template/text/${nodeSchema}`, template); + + const output = await renderWithTemplate(store, varStore, nodeHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output.length).toBe(0); + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("4.5 Template with LiquidJS Syntax Error", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + }, + }); + const nodeHash = await store.put(nodeSchema, { name: "test" }); + + const templateSchema = await putSchema(store, { type: "string" }); + const template = await store.put( + templateSchema, + "{% render %}", // Invalid: no variable + ); + varStore.set(`@ucas/template/text/${nodeSchema}`, template); + + await expect(async () => { + await renderWithTemplate(store, varStore, nodeHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + }).toThrow(); + } finally { + varStore.close(); + await cleanup(); + } + }); +}); + +describe("Suite 5: Decay Priority Chain", () => { + test("5.1 Template Explicit Decay > CLI Decay", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const childSchema = await putSchema(store, { + type: "object", + properties: { + value: { type: "string" }, + }, + }); + const childHash = await store.put(childSchema, { value: "child" }); + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + child: { type: "string", format: "cas_ref" }, + }, + }); + const parentHash = await store.put(parentSchema, { child: childHash }); + + const templateSchema = await putSchema(store, { type: "string" }); + const parentTemplate = await store.put( + templateSchema, + "{% render payload.child, decay: 0.7 %}", + ); + varStore.set(`@ucas/template/text/${parentSchema}`, parentTemplate); + + const childTemplate = await store.put( + templateSchema, + "Resolution: {{ resolution }}", + ); + varStore.set(`@ucas/template/text/${childSchema}`, childTemplate); + + const output = await renderWithTemplate(store, varStore, parentHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + // Child should have resolution=0.7 (explicit decay wins over CLI decay=0.5) + expect(output).toContain("Resolution: 0.7"); + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("5.2 CLI Decay > Engine Default", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const childSchema = await putSchema(store, { + type: "object", + properties: { + value: { type: "string" }, + }, + }); + const childHash = await store.put(childSchema, { value: "child" }); + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + child: { type: "string", format: "cas_ref" }, + }, + }); + const parentHash = await store.put(parentSchema, { child: childHash }); + + const templateSchema = await putSchema(store, { type: "string" }); + const parentTemplate = await store.put( + templateSchema, + "{% render payload.child %}", // No explicit decay + ); + varStore.set(`@ucas/template/text/${parentSchema}`, parentTemplate); + + const childTemplate = await store.put( + templateSchema, + "Resolution: {{ resolution }}", + ); + varStore.set(`@ucas/template/text/${childSchema}`, childTemplate); + + const output = await renderWithTemplate(store, varStore, parentHash, { + resolution: 1.0, + decay: 0.6, + epsilon: 0.01, + }); + + // Child should have resolution=0.6 (CLI decay wins over default 0.5) + expect(output).toContain("Resolution: 0.6"); + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("5.3 Engine Default (No Template, No CLI)", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const childSchema = await putSchema(store, { + type: "object", + properties: { + value: { type: "string" }, + }, + }); + const childHash = await store.put(childSchema, { value: "child" }); + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + child: { type: "string", format: "cas_ref" }, + }, + }); + const parentHash = await store.put(parentSchema, { child: childHash }); + + const templateSchema = await putSchema(store, { type: "string" }); + const parentTemplate = await store.put( + templateSchema, + "{% render payload.child %}", + ); + varStore.set(`@ucas/template/text/${parentSchema}`, parentTemplate); + + const childTemplate = await store.put( + templateSchema, + "Resolution: {{ resolution }}", + ); + varStore.set(`@ucas/template/text/${childSchema}`, childTemplate); + + const output = await renderWithTemplate( + store, + varStore, + parentHash, + { resolution: 1.0, epsilon: 0.01 }, // No decay specified + ); + + // Child should have resolution=0.5 (engine default) + expect(output).toContain("Resolution: 0.5"); + } finally { + varStore.close(); + await cleanup(); + } + }); +}); + +describe("Suite 7: Recursive Rendering Edge Cases", () => { + test("7.1 Deep Recursion (10 Levels)", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + level: { type: "number" }, + next: { + anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], + }, + }, + }); + + // Create 10-level chain + let currentHash: Hash | null = null; + for (let i = 9; i >= 0; i--) { + currentHash = await store.put(nodeSchema, { + level: i, + next: currentHash, + }); + } + + const templateSchema = await putSchema(store, { type: "string" }); + const template = await store.put( + templateSchema, + "Level {{ payload.level }}\n{% render payload.next %}", + ); + varStore.set(`@ucas/template/text/${nodeSchema}`, template); + + const output = await renderWithTemplate( + store, + varStore, + currentHash as Hash, + { resolution: 1.0, decay: 0.9, epsilon: 0.01 }, + ); + + // All 10 levels should render + for (let i = 0; i < 10; i++) { + expect(output).toContain(`Level ${i}`); + } + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("7.2 Cycle Detection with Templates", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + ref: { + anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], + }, + }, + }); + + // Create simple node first + const nodeAHash = await store.put(nodeSchema, { name: "A", ref: null }); + + const templateSchema = await putSchema(store, { type: "string" }); + const template = await store.put( + templateSchema, + "Node {{ payload.name }}\n{% render payload.ref %}", + ); + varStore.set(`@ucas/template/text/${nodeSchema}`, template); + + const output = await renderWithTemplate(store, varStore, nodeAHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toContain("Node A"); + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("7.4 Array of cas_ref with Template", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const itemSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + }, + }); + const item1 = await store.put(itemSchema, { name: "item1" }); + const item2 = await store.put(itemSchema, { name: "item2" }); + const item3 = await store.put(itemSchema, { name: "item3" }); + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + items: { + type: "array", + items: { type: "string", format: "cas_ref" }, + }, + }, + }); + const parentHash = await store.put(parentSchema, { + items: [item1, item2, item3], + }); + + const templateSchema = await putSchema(store, { type: "string" }); + const parentTemplate = await store.put( + templateSchema, + "{% for item in payload.items %}{% render item %}\n{% endfor %}", + ); + varStore.set(`@ucas/template/text/${parentSchema}`, parentTemplate); + + const itemTemplate = await store.put( + templateSchema, + "Item: {{ payload.name }}", + ); + varStore.set(`@ucas/template/text/${itemSchema}`, itemTemplate); + + const output = await renderWithTemplate(store, varStore, parentHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toContain("Item: item1"); + expect(output).toContain("Item: item2"); + expect(output).toContain("Item: item3"); + } finally { + varStore.close(); + await cleanup(); + } + }); +}); + +describe("Suite 8: Error Handling & Edge Cases", () => { + test("8.1 Template Missing render Variable", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + }, + }); + const nodeHash = await store.put(nodeSchema, { name: "test" }); + + const templateSchema = await putSchema(store, { type: "string" }); + const template = await store.put( + templateSchema, + "{% render missingVar %}", + ); + varStore.set(`@ucas/template/text/${nodeSchema}`, template); + + // Should complete without throwing + const output = await renderWithTemplate(store, varStore, nodeHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toBeDefined(); + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("8.2 Template Invalid Decay Value", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const childSchema = await putSchema(store, { + type: "object", + properties: { + value: { type: "string" }, + }, + }); + const childHash = await store.put(childSchema, { value: "child" }); + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + child: { type: "string", format: "cas_ref" }, + }, + }); + const parentHash = await store.put(parentSchema, { child: childHash }); + + const templateSchema = await putSchema(store, { type: "string" }); + const template = await store.put( + templateSchema, + "{% render payload.child, decay: 1.5 %}", + ); + varStore.set(`@ucas/template/text/${parentSchema}`, template); + + await expect(async () => { + await renderWithTemplate(store, varStore, parentHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + }).toThrow(/decay/); + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("8.3 Template Negative Decay", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const childSchema = await putSchema(store, { + type: "object", + properties: { + value: { type: "string" }, + }, + }); + const childHash = await store.put(childSchema, { value: "child" }); + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + child: { type: "string", format: "cas_ref" }, + }, + }); + const parentHash = await store.put(parentSchema, { child: childHash }); + + const templateSchema = await putSchema(store, { type: "string" }); + const template = await store.put( + templateSchema, + "{% render payload.child, decay: -0.5 %}", + ); + varStore.set(`@ucas/template/text/${parentSchema}`, template); + + await expect(async () => { + await renderWithTemplate(store, varStore, parentHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + }).toThrow(); + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("8.4 Template Decay=0 (Invalid)", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const childSchema = await putSchema(store, { + type: "object", + properties: { + value: { type: "string" }, + }, + }); + const childHash = await store.put(childSchema, { value: "child" }); + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + child: { type: "string", format: "cas_ref" }, + }, + }); + const parentHash = await store.put(parentSchema, { child: childHash }); + + const templateSchema = await putSchema(store, { type: "string" }); + const template = await store.put( + templateSchema, + "{% render payload.child, decay: 0 %}", + ); + varStore.set(`@ucas/template/text/${parentSchema}`, template); + + await expect(async () => { + await renderWithTemplate(store, varStore, parentHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + }).toThrow(/decay/); + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("8.5 Template Decay=1 (Valid Edge)", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const childSchema = await putSchema(store, { + type: "object", + properties: { + value: { type: "string" }, + }, + }); + const childHash = await store.put(childSchema, { value: "child" }); + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + child: { type: "string", format: "cas_ref" }, + }, + }); + const parentHash = await store.put(parentSchema, { child: childHash }); + + const templateSchema = await putSchema(store, { type: "string" }); + const parentTemplate = await store.put( + templateSchema, + "{% render payload.child, decay: 1 %}", + ); + varStore.set(`@ucas/template/text/${parentSchema}`, parentTemplate); + + const childTemplate = await store.put( + templateSchema, + "Resolution: {{ resolution }}", + ); + varStore.set(`@ucas/template/text/${childSchema}`, childTemplate); + + const output = await renderWithTemplate(store, varStore, parentHash, { + resolution: 0.5, + decay: 0.5, + epsilon: 0.01, + }); + + // Child should have resolution=0.5 (0.5 * 1 = 0.5, no decay) + expect(output).toContain("Resolution: 0.5"); + } finally { + varStore.close(); + await cleanup(); + } + }); + + test("8.6 Template with Unicode Content", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + }, + }); + const nodeHash = await store.put(nodeSchema, { name: "世界" }); + + const templateSchema = await putSchema(store, { type: "string" }); + const template = await store.put( + templateSchema, + "你好: {{ payload.name }} 🌍", + ); + varStore.set(`@ucas/template/text/${nodeSchema}`, template); + + const output = await renderWithTemplate(store, varStore, nodeHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toBe("你好: 世界 🌍"); + } finally { + varStore.close(); + await cleanup(); + } + }); +}); + +describe("Suite 10: Performance & Scalability", () => { + test("10.1 Wide Fan-out (100 Children)", async () => { + const { store, varStore, cleanup } = await createTempVarStore(); + + try { + const itemSchema = await putSchema(store, { + type: "object", + properties: { + value: { type: "number" }, + }, + }); + + const children: Hash[] = []; + for (let i = 0; i < 100; i++) { + const hash = await store.put(itemSchema, { value: i }); + children.push(hash); + } + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + items: { + type: "array", + items: { type: "string", format: "cas_ref" }, + }, + }, + }); + const parentHash = await store.put(parentSchema, { items: children }); + + const templateSchema = await putSchema(store, { type: "string" }); + const parentTemplate = await store.put( + templateSchema, + "{% for child in payload.items %}{% render child %}{% endfor %}", + ); + varStore.set(`@ucas/template/text/${parentSchema}`, parentTemplate); + + const itemTemplate = await store.put( + templateSchema, + "{{ payload.value }}", + ); + varStore.set(`@ucas/template/text/${itemSchema}`, itemTemplate); + + const start = Date.now(); + const output = await renderWithTemplate(store, varStore, parentHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + const elapsed = Date.now() - start; + + expect(elapsed).toBeLessThan(2000); + expect(output).toBeTruthy(); + } finally { + varStore.close(); + await cleanup(); + } + }); +}); diff --git a/packages/json-cas/src/liquid-render.ts b/packages/json-cas/src/liquid-render.ts new file mode 100644 index 0000000..1068539 --- /dev/null +++ b/packages/json-cas/src/liquid-render.ts @@ -0,0 +1,299 @@ +import { type Context, Liquid, type TagToken } from "liquidjs"; +import type { Hash, Store } from "./types.js"; +import type { VariableStore } from "./variable-store.js"; + +export type RenderOptions = { + resolution?: number; // (0, 1], default 1.0 + decay?: number; // (0, 1], default 0.5 + epsilon?: number; // >= 0, default 0.01 +}; + +const DEFAULT_RESOLUTION = 1.0; +const DEFAULT_DECAY = 0.5; +const DEFAULT_EPSILON = 0.01; +const FLOAT_TOLERANCE = 1e-10; + +// Context for render operations +type RenderContext = { + store: Store; + varStore: VariableStore; + globalDecay: number; + epsilon: number; + engine: Liquid | null; +}; + +/** + * Render a CAS node using LiquidJS templates with resolution-based decay. + * Templates are discovered via variables: @ucas/template/text/ + */ +export async function renderWithTemplate( + store: Store, + varStore: VariableStore, + hash: Hash, + options?: RenderOptions, +): Promise { + const resolution = options?.resolution ?? DEFAULT_RESOLUTION; + const decay = options?.decay ?? DEFAULT_DECAY; + const epsilon = options?.epsilon ?? DEFAULT_EPSILON; + + // Validate parameters + if (resolution < 0 || resolution > 1) { + throw new Error("resolution must be in [0, 1]"); + } + if (decay <= 0 || decay > 1) { + throw new Error("decay must be in (0, 1]"); + } + if (epsilon < 0) { + throw new Error("epsilon must be >= 0"); + } + + const visited = new Set(); + const ctx: RenderContext = { + store, + varStore, + globalDecay: decay, + epsilon, + engine: null, + }; + + const engine = createLiquidEngine(ctx); + ctx.engine = engine; + + return await renderNode(ctx, hash, resolution, visited); +} + +/** + * Create a Liquid engine instance with custom render tag + * Returns both the engine and a function to register the render tag + */ +function createLiquidEngine(ctx: RenderContext): Liquid { + const engine = new Liquid({ + strictFilters: false, + strictVariables: false, + }); + + // Type for storing parsed tag data + type RenderTagState = { + variable: string; + decay: number | undefined; + }; + + // Register custom {% render %} tag + // We need to capture ctx in closure + engine.registerTag("render", { + parse(token: TagToken) { + // Parse "variable" or "variable, decay: 0.7" syntax + const args = token.args.trim(); + const match = args.match(/^(\S+)(?:,\s*decay:\s*([\d.]+))?$/); + + if (!match) { + throw new Error( + `Invalid render tag syntax: ${args}. Expected: {% render variable %} or {% render variable, decay: 0.7 %}`, + ); + } + + // Store parsed values on the tag instance + const state = this as unknown as RenderTagState; + state.variable = match[1] as string; + state.decay = match[2] ? Number.parseFloat(match[2]) : undefined; + + // Validate decay if provided + if (state.decay !== undefined) { + if (state.decay <= 0 || state.decay > 1) { + throw new Error("decay must be in (0, 1]"); + } + } + }, + + async render(ctxLiquid: Context) { + // Access parsed values + const state = this as unknown as RenderTagState; + const variable = state.variable; + const explicitDecay = state.decay; + + // Resolve the variable to a hash (split on dots for nested paths) + const variablePath = variable.split("."); + const value = ctxLiquid.get(variablePath); + + // Handle null/undefined - render as empty + if (value === null || value === undefined) { + return ""; + } + + // Handle non-string values - render as empty + if (typeof value !== "string") { + return ""; + } + + const nodeHash = value as Hash; + + // Get current render context + const currentResolution = ctxLiquid.get(["resolution"]) as number; + + // Compute child resolution using decay priority: + // 1. Template explicit decay (explicitDecay) + // 2. Global decay (from CLI/options) + // 3. Engine default (0.5) + const effectiveDecay = + explicitDecay !== undefined + ? explicitDecay + : (ctx.globalDecay ?? DEFAULT_DECAY); + const childResolution = currentResolution * effectiveDecay; + + // Recursively render the referenced node + const visited = ctxLiquid.get(["__visited"]) as Set; + const output = await renderNode(ctx, nodeHash, childResolution, visited); + + return output; + }, + }); + + return engine; +} + +/** + * Render a single node with template or fallback to cas: reference + */ +async function renderNode( + ctx: RenderContext, + hash: Hash, + currentResolution: number, + visited: Set, +): Promise { + // Check if resolution is below threshold + if (currentResolution < ctx.epsilon + FLOAT_TOLERANCE) { + return `cas:${hash}`; + } + + // Fetch the node + const node = ctx.store.get(hash); + if (node === null) { + return `cas:${hash}`; + } + + // Cycle detection + if (visited.has(hash)) { + return `cas:${hash}`; + } + visited.add(hash); + + try { + // Try to find a template for this node's type + const template = await findTemplate(ctx.store, ctx.varStore, node.type); + + if (template === null) { + // No template found - this is handled by the caller (fallback to YAML) + // For now, return a simple representation + visited.delete(hash); + return renderFallback(ctx.store, node.payload); + } + + // Render using the template + const context = { + resolution: currentResolution, + epsilon: ctx.epsilon, + hash, + payload: node.payload, + type: node.type, + timestamp: node.timestamp, + __visited: visited, // Pass visited set through context + }; + + const output = await ctx.engine.parseAndRender(template, context); + + visited.delete(hash); + return output; + } catch (error) { + visited.delete(hash); + throw error; + } +} + +/** + * Find a template for a given type hash + */ +async function findTemplate( + store: Store, + varStore: VariableStore, + typeHash: Hash, +): Promise { + const varName = `@ucas/template/text/${typeHash}`; + + try { + // Find the string schema hash (we need this to query variables) + const stringSchemaNode = await findStringSchema(store); + if (stringSchemaNode === null) { + return null; + } + + const variable = varStore.get(varName, stringSchemaNode); + if (variable === null) { + return null; + } + + const templateNode = store.get(variable.value); + if (templateNode === null) { + return null; + } + + // Template should be a string + if (typeof templateNode.payload !== "string") { + return null; + } + + return templateNode.payload; + } catch { + return null; + } +} + +/** + * Find the hash of the string schema + */ +async function findStringSchema(store: Store): Promise { + // The string schema is { type: "string" } + // We need to compute its hash or find it in the store + // For now, we'll use a heuristic: look for a schema with this exact structure + + // Import putSchema to compute the hash + const { putSchema } = await import("./schema.js"); + const stringSchema = await putSchema(store, { type: "string" }); + return stringSchema; +} + +/** + * Fallback renderer for nodes without templates + */ +function renderFallback(_store: Store, payload: unknown): string { + // Simple YAML-like representation + if (payload === null) { + return "null\n"; + } + + if (typeof payload === "string") { + return `${payload}\n`; + } + + if (typeof payload === "number" || typeof payload === "boolean") { + return `${payload}\n`; + } + + if (Array.isArray(payload)) { + if (payload.length === 0) { + return "[]\n"; + } + return `- ${payload.join("\n- ")}\n`; + } + + if (typeof payload === "object") { + const obj = payload as Record; + const keys = Object.keys(obj); + if (keys.length === 0) { + return "{}\n"; + } + const pairs = keys.map((key) => `${key}: ${obj[key]}`); + return `${pairs.join("\n")}\n`; + } + + return "null\n"; +} diff --git a/packages/json-cas/src/render.ts b/packages/json-cas/src/render.ts index 1580fb6..41cd372 100644 --- a/packages/json-cas/src/render.ts +++ b/packages/json-cas/src/render.ts @@ -1,10 +1,12 @@ import { refs } from "./schema.js"; import type { Hash, Store } from "./types.js"; +import type { VariableStore } from "./variable-store.js"; export type RenderOptions = { resolution?: number; // (0, 1], default 1.0 decay?: number; // (0, 1], default 0.5 epsilon?: number; // >= 0, default 0.01 + varStore?: VariableStore; // Optional: for template lookup }; const DEFAULT_RESOLUTION = 1.0; @@ -16,6 +18,8 @@ const FLOAT_TOLERANCE = 1e-10; /** * Render a CAS node as YAML with resolution-based decay. * When resolution ≤ epsilon, nodes are rendered as opaque `cas:` references. + * This is the synchronous version without template support. + * For template support, use renderAsync() with varStore. */ export function render( store: Store, @@ -38,10 +42,80 @@ export function render( } const visited = new Set(); - return renderNode(store, hash, resolution, decay, epsilon, visited); } +/** + * Async render with LiquidJS template support. + * When resolution ≤ epsilon, nodes are rendered as opaque `cas:` references. + * If varStore is provided, attempts to use LiquidJS templates first, fallback to YAML. + */ +export async function renderAsync( + store: Store, + hash: Hash, + options?: RenderOptions, +): Promise { + const resolution = options?.resolution ?? DEFAULT_RESOLUTION; + const decay = options?.decay ?? DEFAULT_DECAY; + const epsilon = options?.epsilon ?? DEFAULT_EPSILON; + const varStore = options?.varStore; + + // Validate parameters + if (resolution < 0 || resolution > 1) { + throw new Error("resolution must be in [0, 1]"); + } + if (decay <= 0 || decay > 1) { + throw new Error("decay must be in (0, 1]"); + } + if (epsilon < 0) { + throw new Error("epsilon must be >= 0"); + } + + // If varStore provided, try template rendering first + if (varStore !== undefined) { + try { + const { renderWithTemplate } = await import("./liquid-render.js"); + const node = store.get(hash); + if (node !== null) { + // Check if a template exists for this type + const templateExists = await hasTemplate(store, varStore, node.type); + if (templateExists) { + return await renderWithTemplate(store, varStore, hash, { + resolution, + decay, + epsilon, + }); + } + } + } catch { + // Fall through to YAML rendering + } + } + + // Fallback to YAML rendering + const visited = new Set(); + return renderNode(store, hash, resolution, decay, epsilon, visited); +} + +/** + * Check if a template exists for a given type + */ +async function hasTemplate( + store: Store, + varStore: VariableStore, + typeHash: Hash, +): Promise { + const varName = `@ucas/template/text/${typeHash}`; + try { + const { putSchema } = await import("./schema.js"); + const stringSchema = await putSchema(store, { type: "string" }); + const variable = varStore.get(varName, stringSchema); + return variable !== null; + } catch { + return false; + } +} + function renderNode( store: Store, hash: Hash,