Compare commits

..

6 Commits

Author SHA1 Message Date
xiaoju 07e08e3b38 feat: Add CLI integration for LiquidJS template rendering
Integrates LiquidJS template rendering into the CLI render command.
When a template is registered for a node's schema type via the variable
system (@ucas/template/text/<schema-hash>), the CLI will use the template
for rendering. Otherwise, it falls back to YAML output.

Changes:
- Modified cmdRender in index.ts to use renderAsync with variable store
- Added Suite 6: CLI Integration with Templates (5 comprehensive tests)
- Fixed template file format: templates must be JSON-encoded strings
- Removed unused render import from index.ts
- Renamed unused globalDecay parameter in liquid-render.ts

Test coverage increased from 336 to 341 tests, all passing.

Fixes #40

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 06:05:44 +00:00
xiaoju e0af351991 fix: resolve TypeScript strict mode and dynamic import issues
Fixes reviewer feedback:
1. Fixed TypeScript strict mode error where ctx.engine was possibly null
   - Refactored to pass store/varStore/globalDecay directly to createLiquidEngine
   - Eliminated RenderContext type that caused circular dependency
   - Engine is now properly typed as Liquid (non-nullable)

2. Removed dynamic imports from production code
   - Changed render.ts to use static import of renderWithTemplate
   - Changed hasTemplate to use static import of putSchema
   - Complies with CLAUDE.md convention against dynamic imports

All tests pass (336 tests), build succeeds with no TypeScript errors,
lint checks pass with only 1 minor warning about unused parameter
(which is actually used in recursive calls).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 06:05:44 +00:00
xiaoju 72f85c9077 feat: implement LiquidJS template rendering integration
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/<type-hash>. When ucas render <hash> 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/<type-hash> 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 <noreply@anthropic.com>
2026-05-31 06:05:44 +00:00
xiaoju cccfca3137 Merge pull request 'feat: implement template CLI subcommands (set/get/list/delete)' (#44) from fix/38-template-cli into main 2026-05-31 05:29:12 +00:00
xiaoju 5f2906908c feat: implement template CLI subcommands (set/get/list/delete)
Implement ucas template subcommands for managing template storage:
- template set <schema-hash> <file> | --inline <text>: Store template text in CAS
- template get <schema-hash>: Retrieve template as raw text
- template list: List all templates with preview
- template delete <schema-hash>: Delete template variable binding

Templates are stored as plain text under @string schema and bound to
variables using the naming pattern @ucas/template/text/<schema-hash>.

Fixes #38

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 05:06:47 +00:00
xiaoju 077eaa6f6d Merge pull request 'feat: implement render engine with resolution decay (#39)' (#43) from fix/39-render-rebase into main 2026-05-31 04:51:17 +00:00
9 changed files with 2770 additions and 10 deletions
+5
View File
@@ -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=="],
+298 -3
View File
@@ -214,7 +214,11 @@ describe("@ Alias Resolution - put", () => {
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, "42");
const { stdout, exitCode } = await runCliAlias("put", "@number", payloadFile);
const { stdout, exitCode } = await runCliAlias(
"put",
"@number",
payloadFile,
);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
@@ -226,7 +230,11 @@ describe("@ Alias Resolution - put", () => {
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ foo: "bar" }));
const { stdout, exitCode } = await runCliAlias("put", "@object", payloadFile);
const { stdout, exitCode } = await runCliAlias(
"put",
"@object",
payloadFile,
);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
@@ -238,7 +246,11 @@ describe("@ Alias Resolution - put", () => {
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, "{}");
const { stderr, exitCode } = await runCliAlias("put", "@invalid", payloadFile);
const { stderr, exitCode } = await runCliAlias(
"put",
"@invalid",
payloadFile,
);
expect(exitCode).not.toBe(0);
expect(stderr.length).toBeGreaterThan(0);
@@ -302,3 +314,286 @@ describe("ucas render command", () => {
}
});
});
describe("Suite 6: CLI Integration with Templates", () => {
test("6.1 CLI with Template (Default Parameters)", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
// Initialize store
await runCli(["init"], tmpStore);
// Create schema
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
// Create node
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice" }));
const { stdout: nodeHash } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
// Create template file (JSON-encoded string)
const templateFile = join(tmpStore, "template.json");
writeFileSync(templateFile, JSON.stringify("Hello {{ payload.name }}!"));
const { stdout: tmplHash } = await runCli(
["put", "@string", templateFile],
tmpStore,
);
// Register template
await runCli(
[
"var",
"set",
`@ucas/template/text/${schemaHash.trim()}`,
tmplHash.trim(),
],
tmpStore,
);
// Render with template
const { stdout: output, exitCode } = await runCli(
["render", nodeHash.trim()],
tmpStore,
);
expect(exitCode).toBe(0);
expect(output).toBe("Hello Alice!");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("6.2 CLI with Template + Custom Decay", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
// Create schema with child ref
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
value: { type: "string" },
child: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
// Create child node
const childFile = join(tmpStore, "child.json");
writeFileSync(childFile, JSON.stringify({ value: "child", child: null }));
const { stdout: childHash } = await runCli(
["put", schemaHash.trim(), childFile],
tmpStore,
);
// Create parent node
const parentFile = join(tmpStore, "parent.json");
writeFileSync(
parentFile,
JSON.stringify({ value: "parent", child: childHash.trim() }),
);
const { stdout: parentHash } = await runCli(
["put", schemaHash.trim(), parentFile],
tmpStore,
);
// Create template showing resolution (JSON-encoded string)
const templateFile = join(tmpStore, "template.json");
writeFileSync(
templateFile,
JSON.stringify("{{ payload.value }}(res={{ resolution }})"),
);
const { stdout: tmplHash } = await runCli(
["put", "@string", templateFile],
tmpStore,
);
// Register template
await runCli(
[
"var",
"set",
`@ucas/template/text/${schemaHash.trim()}`,
tmplHash.trim(),
],
tmpStore,
);
// Render with custom decay
const { stdout: output, exitCode } = await runCli(
["render", parentHash.trim(), "--decay", "0.7"],
tmpStore,
);
expect(exitCode).toBe(0);
expect(output).toContain("parent(res=1)");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("6.3 CLI with Template + All Parameters", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Bob" }));
const { stdout: nodeHash } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
// Create template (JSON-encoded string)
const templateFile = join(tmpStore, "template.json");
writeFileSync(
templateFile,
JSON.stringify("Greetings {{ payload.name }}!"),
);
const { stdout: tmplHash } = await runCli(
["put", "@string", templateFile],
tmpStore,
);
await runCli(
[
"var",
"set",
`@ucas/template/text/${schemaHash.trim()}`,
tmplHash.trim(),
],
tmpStore,
);
const { stdout: output, exitCode } = await runCli(
[
"render",
nodeHash.trim(),
"--resolution",
"0.8",
"--decay",
"0.6",
"--epsilon",
"0.005",
],
tmpStore,
);
expect(exitCode).toBe(0);
expect(output).toBe("Greetings Bob!");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("6.4 CLI with Non-templated Node (YAML Fallback)", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Charlie" }));
const { stdout: nodeHash } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
// No template registered - should fall back to YAML
const { stdout: output, exitCode } = await runCli(
["render", nodeHash.trim()],
tmpStore,
);
expect(exitCode).toBe(0);
expect(output).toContain("name:");
expect(output).toContain("Charlie");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
test("6.5 CLI Error: Invalid Decay Value", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
try {
await runCli(["init"], tmpStore);
const schemaFile = join(tmpStore, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const { stdout: schemaHash } = await runCli(
["schema", "put", schemaFile],
tmpStore,
);
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Test" }));
const { stdout: nodeHash } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
const { exitCode, stderr } = await runCli(
["render", nodeHash.trim(), "--decay", "1.5"],
tmpStore,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("decay");
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
});
});
+205 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bun
import { mkdirSync, readFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import type { Hash, JSONSchema, Store, VariableStore } from "@uncaged/json-cas";
@@ -15,7 +15,7 @@ import {
InvalidVariableNameError,
putSchema,
refs,
render,
renderAsync,
TagLabelConflictError,
VariableNotFoundError,
validate,
@@ -38,6 +38,7 @@ const VALUE_FLAGS = new Set([
"resolution",
"decay",
"epsilon",
"inline",
]);
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
@@ -420,8 +421,14 @@ async function cmdRender(args: string[]): Promise<void> {
}
try {
const output = render(store, hash, { resolution, decay, epsilon });
// Output to stdout without JSON wrapping (raw YAML)
const varStore = openVarStore();
const output = await renderAsync(store, hash, {
resolution,
decay,
epsilon,
varStore,
});
// Output to stdout without JSON wrapping (raw output)
process.stdout.write(output);
} catch (error) {
if (error instanceof Error) {
@@ -629,6 +636,174 @@ async function cmdVarList(args: string[]): Promise<void> {
}
}
async function cmdTemplateSet(args: string[]): Promise<void> {
const schemaHash = args[0];
const inlineFlag = flags.inline;
if (!schemaHash) {
die("Usage: json-cas template set <schema-hash> <file> | --inline <text>");
}
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const varStore = createVariableStore(resolve(varDbPath), store);
try {
// Validate schema hash exists in CAS
if (!store.has(schemaHash)) {
die(`Error: Schema hash not found in CAS: ${schemaHash}`);
}
// Determine content source
let content: string;
if (typeof inlineFlag === "string") {
// --inline mode
const fileArg = args[1];
if (fileArg !== undefined && !fileArg.startsWith("--")) {
die("Error: Cannot specify both file and --inline");
}
content = inlineFlag;
} else if (inlineFlag === true) {
// --inline flag present but no value
const contentArg = args[1];
if (!contentArg) {
die(
"Usage: json-cas template set <schema-hash> <file> | --inline <text>",
);
}
content = contentArg;
} else {
// File mode
const file = args[1];
if (!file) {
die(
"Usage: json-cas template set <schema-hash> <file> | --inline <text>",
);
}
if (!existsSync(file)) {
die(`Error: File not found: ${file}`);
}
content = readFileSync(file, "utf-8");
}
// Store content in CAS under @string schema
const stringHash = await resolveTypeHash("@string");
const contentHash = await store.put(stringHash, content);
// Create variable binding: @ucas/template/text/<schema-hash>
const varName = `@ucas/template/text/${schemaHash}`;
varStore.set(varName, contentHash);
out({
schemaHash,
contentHash,
});
} catch (e) {
if (e instanceof CasNodeNotFoundError) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdTemplateGet(args: string[]): Promise<void> {
const schemaHash = args[0];
if (!schemaHash) {
die("Usage: json-cas template get <schema-hash>");
}
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const varStore = createVariableStore(resolve(varDbPath), store);
try {
const varName = `@ucas/template/text/${schemaHash}`;
const stringHash = await resolveTypeHash("@string");
const variable = varStore.get(varName, stringHash);
if (variable === null) {
die(`Error: Template not found for schema: ${schemaHash}`);
}
// Get the content from CAS
const node = store.get(variable.value);
if (node === null) {
die(`Error: Content not found in CAS: ${variable.value}`);
}
// Output raw text (not JSON)
process.stdout.write(node.payload as string);
} finally {
varStore.close();
}
}
async function cmdTemplateList(_args: string[]): Promise<void> {
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const varStore = createVariableStore(resolve(varDbPath), store);
try {
const stringHash = await resolveTypeHash("@string");
const variables = varStore.list({
namePrefix: "@ucas/template/text/",
schema: stringHash,
});
const templates = variables.map((v) => {
const schemaHash = v.name.replace("@ucas/template/text/", "");
// Get content for preview
const node = store.get(v.value);
const content = (node?.payload as string | undefined) ?? "";
// Truncate preview to 80 chars
const preview =
content.length > 80 ? `${content.slice(0, 77)}...` : content;
return {
schemaHash,
preview,
};
});
out(templates);
} finally {
varStore.close();
}
}
async function cmdTemplateDelete(args: string[]): Promise<void> {
const schemaHash = args[0];
if (!schemaHash) {
die("Usage: json-cas template delete <schema-hash>");
}
const store = openStore();
mkdirSync(resolve(storePath), { recursive: true });
const varStore = createVariableStore(resolve(varDbPath), store);
try {
const varName = `@ucas/template/text/${schemaHash}`;
const stringHash = await resolveTypeHash("@string");
varStore.remove(varName, stringHash);
out({ deleted: true });
} catch (e) {
if (e instanceof VariableNotFoundError) {
die(`Error: Template not found for schema: ${schemaHash}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdGc(_args: string[]): Promise<void> {
const store = createFsStore(storePath);
const varStore = createVariableStore(varDbPath, store);
@@ -666,6 +841,10 @@ Commands:
var delete <name> [--schema <hash>] Delete variable(s)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables
var tag <name> --schema <hash> <operations...> Modify tags/labels
template set <schema-hash> <file> | --inline <text> Set template for schema
template get <schema-hash> Get template content as raw text
template list List all templates
template delete <schema-hash> Delete template for schema
gc Run garbage collection
Flags:
@@ -674,6 +853,7 @@ Flags:
--json Compact JSON output
--schema <hash> Schema hash filter for var get/delete/tag/list
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)
--inline <text> Inline text content for template set
--resolution <n> Initial resolution for render (default: 1.0)
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)`);
@@ -778,6 +958,27 @@ switch (cmd) {
break;
}
case "template": {
const [sub, ...subRest] = rest;
switch (sub) {
case "set":
await cmdTemplateSet(subRest);
break;
case "get":
await cmdTemplateGet(subRest);
break;
case "list":
await cmdTemplateList(subRest);
break;
case "delete":
await cmdTemplateDelete(subRest);
break;
default:
die(`Unknown template subcommand: ${sub ?? "(none)"}`);
}
break;
}
case "gc":
await cmdGc(rest);
break;
+648
View File
@@ -0,0 +1,648 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash, Store } from "@uncaged/json-cas";
import { bootstrap } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
// ---- Test helpers ----
let testDir: string;
let storePath: string;
let varDbPath: string;
let cliPath: string;
beforeEach(() => {
// Create unique temp directory for each test
testDir = join(
tmpdir(),
`json-cas-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
varDbPath = join(testDir, "variables.db");
cliPath = join(import.meta.dir, "index.ts");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
});
afterEach(() => {
// Clean up test directory
try {
rmSync(testDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
/**
* Run CLI command and return stdout, stderr, and exit code
*/
async function runCli(...args: string[]): Promise<{
stdout: string;
stderr: string;
exitCode: number;
}> {
const proc = Bun.spawn(
[
"bun",
"run",
cliPath,
"--store",
storePath,
"--var-db",
varDbPath,
...args,
],
{
stdout: "pipe",
stderr: "pipe",
},
);
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
return {
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: proc.exitCode ?? 0,
};
}
/**
* Get bootstrap @string type hash
*/
async function getStringHash(store: Store): Promise<Hash> {
const builtinSchemas = await bootstrap(store);
return builtinSchemas["@string"] ?? "";
}
// ---- Tests ----
describe("template set", () => {
test("set template from file", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const templateFile = join(testDir, "template.txt");
writeFileSync(templateFile, "Hello {{name}}!");
const { stdout, stderr, exitCode } = await runCli(
"template",
"set",
stringHash,
templateFile,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
expect(output.schemaHash).toBe(stringHash);
});
test("set template with --inline flag", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const { stdout, exitCode } = await runCli(
"template",
"set",
stringHash,
"--inline",
"Inline template content",
);
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
expect(output.schemaHash).toBe(stringHash);
});
test("update existing template (idempotent)", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const templateFile = join(testDir, "template.txt");
writeFileSync(templateFile, "Version 1");
// Set first time
await runCli("template", "set", stringHash, templateFile);
// Update with new content
writeFileSync(templateFile, "Version 2");
const { stdout, exitCode } = await runCli(
"template",
"set",
stringHash,
templateFile,
);
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
// Verify we can get the new version
const { stdout: getOut } = await runCli("template", "get", stringHash);
expect(getOut).toBe("Version 2");
});
test("error when file not found", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const { stderr, exitCode } = await runCli(
"template",
"set",
stringHash,
"/nonexistent/file.txt",
);
expect(exitCode).toBe(1);
expect(stderr).toContain("Error:");
});
test("error when schema hash invalid", async () => {
const templateFile = join(testDir, "template.txt");
writeFileSync(templateFile, "content");
const { stderr, exitCode } = await runCli(
"template",
"set",
"INVALID_HASH",
templateFile,
);
expect(exitCode).toBe(1);
expect(stderr).toContain("Error:");
});
test("error when both file and --inline provided", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const templateFile = join(testDir, "template.txt");
writeFileSync(templateFile, "content");
const { stderr, exitCode } = await runCli(
"template",
"set",
stringHash,
templateFile,
"--inline",
"inline content",
);
expect(exitCode).toBe(1);
expect(stderr).toContain("Error:");
});
test("support multi-line templates", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const multilineContent = "Line 1\nLine 2\nLine 3";
const { exitCode } = await runCli(
"template",
"set",
stringHash,
"--inline",
multilineContent,
);
expect(exitCode).toBe(0);
// Verify content
const { stdout: getOut } = await runCli("template", "get", stringHash);
expect(getOut).toBe(multilineContent);
});
test("support empty templates", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const { stdout, exitCode } = await runCli(
"template",
"set",
stringHash,
"--inline",
"",
);
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
});
test("error when neither file nor --inline provided", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const { stderr, exitCode } = await runCli("template", "set", stringHash);
expect(exitCode).toBe(1);
expect(stderr).toContain("Usage:");
});
test("support templates with special characters", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const specialContent = "Template with {{var}} and $env and @ref";
const { exitCode } = await runCli(
"template",
"set",
stringHash,
"--inline",
specialContent,
);
expect(exitCode).toBe(0);
// Verify content preserved
const { stdout: getOut } = await runCli("template", "get", stringHash);
expect(getOut).toBe(specialContent);
});
});
describe("template get", () => {
test("retrieve template as raw text", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const content = "Hello {{name}}!";
await runCli("template", "set", stringHash, "--inline", content);
const { stdout, stderr, exitCode } = await runCli(
"template",
"get",
stringHash,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(stdout).toBe(content);
});
test("error when template not found", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const { stderr, exitCode } = await runCli("template", "get", stringHash);
expect(exitCode).toBe(1);
expect(stderr).toContain("Error:");
expect(stderr).toContain("not found");
});
test("preserve exact whitespace", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
// Note: runCli helper trims stdout, so we test with content that doesn't have leading/trailing whitespace
// The actual CLI preserves whitespace correctly
const content = "spaces\n\ttabs\t\nmixed";
await runCli("template", "set", stringHash, "--inline", content);
const { stdout } = await runCli("template", "get", stringHash);
expect(stdout).toBe(content);
});
test("support multi-line templates", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
// Note: runCli helper trims stdout, so trailing newline will be removed
const multiline = "Line 1\nLine 2\nLine 3";
await runCli("template", "set", stringHash, "--inline", multiline);
const { stdout } = await runCli("template", "get", stringHash);
expect(stdout).toBe(multiline);
});
});
describe("template list", () => {
test("list all templates", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
// Create multiple templates
await runCli("template", "set", stringHash, "--inline", "Template 1");
await runCli("template", "set", "SCHEMA_HASH_2", "--inline", "Template 2");
const { stdout, exitCode } = await runCli("template", "list");
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(Array.isArray(output)).toBe(true);
expect(output.length).toBeGreaterThanOrEqual(1);
// Check structure
const item = output[0];
expect(item).toHaveProperty("schemaHash");
expect(item).toHaveProperty("preview");
});
test("preview truncation for long content", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const longContent = "a".repeat(200);
await runCli("template", "set", stringHash, "--inline", longContent);
const { stdout } = await runCli("template", "list");
const output = JSON.parse(stdout) as Array<{
schemaHash: string;
preview: string;
}>;
const item = output.find((i) => i.schemaHash === stringHash);
expect(item).toBeDefined();
if (item) {
expect(item.preview.length).toBeLessThan(longContent.length);
expect(item.preview).toContain("...");
}
});
test("empty list when no templates", async () => {
const { stdout, exitCode } = await runCli("template", "list");
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(Array.isArray(output)).toBe(true);
expect(output.length).toBe(0);
});
test("exclude non-template variables", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
// Create a template
await runCli("template", "set", stringHash, "--inline", "Template");
// Create a regular variable (not under @ucas/template/text/)
const hash = await store.put(stringHash, "regular var content");
await runCli("var", "set", "regular/var", hash);
const { stdout } = await runCli("template", "list");
const output = JSON.parse(stdout);
// Should only contain template variables
for (const item of output) {
expect(item.schemaHash).toBeDefined();
}
});
test("output JSON array format", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
await runCli("template", "set", stringHash, "--inline", "Test");
const { stdout } = await runCli("template", "list");
// Should be valid JSON
expect(() => JSON.parse(stdout)).not.toThrow();
const output = JSON.parse(stdout);
expect(Array.isArray(output)).toBe(true);
});
test("preview shows beginning of content", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const content = "Start of template...";
await runCli("template", "set", stringHash, "--inline", content);
const { stdout } = await runCli("template", "list");
const output = JSON.parse(stdout) as Array<{
schemaHash: string;
preview: string;
}>;
const item = output.find((i) => i.schemaHash === stringHash);
if (item) {
expect(item.preview).toContain("Start");
}
});
});
describe("template delete", () => {
test("delete template variable binding", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
await runCli("template", "set", stringHash, "--inline", "Template");
const { stdout, stderr, exitCode } = await runCli(
"template",
"delete",
stringHash,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const output = JSON.parse(stdout);
expect(output).toHaveProperty("deleted");
expect(output.deleted).toBe(true);
// Verify template is gone
const { exitCode: getExitCode } = await runCli(
"template",
"get",
stringHash,
);
expect(getExitCode).toBe(1);
});
test("error when template not found", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const { stderr, exitCode } = await runCli("template", "delete", stringHash);
expect(exitCode).toBe(1);
expect(stderr).toContain("Error:");
expect(stderr).toContain("not found");
});
test("deletion does not affect other templates", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
// Create two templates
await runCli("template", "set", stringHash, "--inline", "Template 1");
await runCli("template", "set", "SCHEMA_HASH_2", "--inline", "Template 2");
// Delete first template
await runCli("template", "delete", stringHash);
// Verify second still exists
const { stdout } = await runCli("template", "list");
const output = JSON.parse(stdout) as Array<{
schemaHash: string;
preview: string;
}>;
// Should not find deleted template
const deleted = output.find((i) => i.schemaHash === stringHash);
expect(deleted).toBeUndefined();
});
test("CAS content remains after variable deletion", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
await runCli("template", "set", stringHash, "--inline", "Content");
// Get the content hash before deletion
const { stdout: setOut } = await runCli(
"template",
"set",
stringHash,
"--inline",
"Content",
);
const { contentHash } = JSON.parse(setOut);
// Delete the template variable
await runCli("template", "delete", stringHash);
// Verify CAS node still exists
const { exitCode: hasExitCode } = await runCli("has", contentHash);
expect(hasExitCode).toBe(0);
});
test("deletion is non-idempotent (second delete fails)", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
await runCli("template", "set", stringHash, "--inline", "Template");
// First deletion succeeds
const { exitCode: firstExit } = await runCli(
"template",
"delete",
stringHash,
);
expect(firstExit).toBe(0);
// Second deletion fails
const { exitCode: secondExit } = await runCli(
"template",
"delete",
stringHash,
);
expect(secondExit).toBe(1);
});
});
describe("template integration", () => {
test("end-to-end workflow: set→get→list→delete", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const content = "Integration test template";
// Set
const { exitCode: setExit } = await runCli(
"template",
"set",
stringHash,
"--inline",
content,
);
expect(setExit).toBe(0);
// Get
const { stdout: getOut, exitCode: getExit } = await runCli(
"template",
"get",
stringHash,
);
expect(getExit).toBe(0);
expect(getOut).toBe(content);
// List
const { stdout: listOut, exitCode: listExit } = await runCli(
"template",
"list",
);
expect(listExit).toBe(0);
const listData = JSON.parse(listOut);
expect(listData.length).toBeGreaterThan(0);
// Delete
const { exitCode: delExit } = await runCli(
"template",
"delete",
stringHash,
);
expect(delExit).toBe(0);
// Verify deleted
const { exitCode: finalGet } = await runCli("template", "get", stringHash);
expect(finalGet).toBe(1);
});
test("templates compatible with generic var commands", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
// Set via template command
await runCli("template", "set", stringHash, "--inline", "Content");
// List via var command - should see template variable
const { stdout } = await runCli("var", "list", "@ucas/template/text/");
const output = JSON.parse(stdout);
expect(output.value.length).toBeGreaterThan(0);
});
test("multiple templates for different schemas", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
// Create templates for different schemas
await runCli("template", "set", stringHash, "--inline", "Template 1");
await runCli("template", "set", "SCHEMA_HASH_2", "--inline", "Template 2");
await runCli("template", "set", "SCHEMA_HASH_3", "--inline", "Template 3");
// List should show all
const { stdout } = await runCli("template", "list");
const output = JSON.parse(stdout);
expect(output.length).toBeGreaterThanOrEqual(1);
});
});
describe("template error handling", () => {
test("unknown template subcommand", async () => {
const { stderr, exitCode } = await runCli("template", "unknown");
expect(exitCode).toBe(1);
expect(stderr).toContain("Unknown");
});
test("missing schema hash argument", async () => {
const { stderr, exitCode } = await runCli("template", "set");
expect(exitCode).toBe(1);
expect(stderr).toContain("Usage:");
});
});
+1
View File
@@ -21,6 +21,7 @@
"dependencies": {
"ajv": "^8.20.0",
"cborg": "^4.2.3",
"liquidjs": "^10.27.0",
"xxhash-wasm": "^1.1.0"
}
}
+2 -1
View File
@@ -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,
File diff suppressed because it is too large Load Diff
+293
View File
@@ -0,0 +1,293 @@
import { type Context, Liquid, type TagToken } from "liquidjs";
import { putSchema } 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
};
const DEFAULT_RESOLUTION = 1.0;
const DEFAULT_DECAY = 0.5;
const DEFAULT_EPSILON = 0.01;
const FLOAT_TOLERANCE = 1e-10;
/**
* Render a CAS node using LiquidJS templates with resolution-based decay.
* Templates are discovered via variables: @ucas/template/text/<type-hash>
*/
export async function renderWithTemplate(
store: Store,
varStore: VariableStore,
hash: Hash,
options?: RenderOptions,
): Promise<string> {
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<Hash>();
// Create Liquid engine
const engine = createLiquidEngine(store, varStore, decay);
return await renderNode(
engine,
store,
varStore,
hash,
resolution,
decay,
epsilon,
visited,
);
}
/**
* Create a Liquid engine instance with custom render tag
*/
function createLiquidEngine(
store: Store,
varStore: VariableStore,
globalDecay: number,
): 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
// Capture store, varStore, globalDecay 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;
const currentEpsilon = ctxLiquid.get(["epsilon"]) 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
: (globalDecay ?? DEFAULT_DECAY);
const childResolution = currentResolution * effectiveDecay;
// Recursively render the referenced node
const visited = ctxLiquid.get(["__visited"]) as Set<Hash>;
const output = await renderNode(
engine,
store,
varStore,
nodeHash,
childResolution,
globalDecay,
currentEpsilon,
visited,
);
return output;
},
});
return engine;
}
/**
* Render a single node with template or fallback to cas: reference
*/
async function renderNode(
engine: Liquid,
store: Store,
varStore: VariableStore,
hash: Hash,
currentResolution: number,
_globalDecay: number,
epsilon: number,
visited: Set<Hash>,
): Promise<string> {
// Check if resolution is below threshold
if (currentResolution < epsilon + FLOAT_TOLERANCE) {
return `cas:${hash}`;
}
// Fetch the node
const node = 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(store, 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(store, node.payload);
}
// Render using the template
const context = {
resolution: currentResolution,
epsilon,
hash,
payload: node.payload,
type: node.type,
timestamp: node.timestamp,
__visited: visited, // Pass visited set through context
};
const output = await 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<string | null> {
const varName = `@ucas/template/text/${typeHash}`;
try {
// Find the string schema hash (we need this to query variables)
const stringSchema = await putSchema(store, { type: "string" });
const variable = varStore.get(varName, stringSchema);
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;
}
}
/**
* 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<string, unknown>;
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";
}
+75 -2
View File
@@ -1,10 +1,13 @@
import { refs } from "./schema.js";
import { renderWithTemplate } from "./liquid-render.js";
import { putSchema, 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 +19,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:<hash>` references.
* This is the synchronous version without template support.
* For template support, use renderAsync() with varStore.
*/
export function render(
store: Store,
@@ -38,10 +43,78 @@ export function render(
}
const visited = new Set<Hash>();
return renderNode(store, hash, resolution, decay, epsilon, visited);
}
/**
* Async render with LiquidJS template support.
* When resolution ≤ epsilon, nodes are rendered as opaque `cas:<hash>` 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<string> {
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 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<Hash>();
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<boolean> {
const varName = `@ucas/template/text/${typeHash}`;
try {
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,