import { Liquid } from "liquidjs"; import { CasNodeNotFoundError } from "./errors.js"; import { renderWithTemplateInternal } from "./liquid-render.js"; import { collectRefs, getSchema, putSchema, refs } from "./schema.js"; import type { Hash, Store } from "./types.js"; export type RenderOptions = { resolution?: number; // (0, 1], default 1.0 decay?: number; // (0, 1], default 0.5 epsilon?: number; // >= 0, default 0.01 format?: string; // default 'text' }; /** * Type statics structure: slot name → raw content */ export type TypeStatics = Record; /** * Array-form entry for compose templates: type_hash + all slot values. * This shape is iterable by LiquidJS {% for ts in type_statics %}. */ export type TypeStaticsEntry = { type_hash: Hash } & TypeStatics; const DEFAULT_RESOLUTION = 1.0; const DEFAULT_DECAY = 0.5; const DEFAULT_EPSILON = 0.01; // Small tolerance for floating point comparison const FLOAT_TOLERANCE = 1e-10; /** * Extract and validate resolution/decay/epsilon from options. */ function validateAndExtractOptions(options: RenderOptions | null | undefined): { resolution: number; decay: number; epsilon: number; } { const resolution = options?.resolution ?? DEFAULT_RESOLUTION; const decay = options?.decay ?? DEFAULT_DECAY; const epsilon = options?.epsilon ?? DEFAULT_EPSILON; 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"); } return { resolution, decay, epsilon }; } /** * 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(). */ export function render( store: Store, hash: Hash, options?: RenderOptions, ): string { const { resolution, decay, epsilon } = validateAndExtractOptions(options); // Check if root node exists if (store.cas.get(hash) === null) { throw new CasNodeNotFoundError(hash); } 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. * Attempts to use LiquidJS templates first, falling back to YAML. * Uses map-reduce-compose pipeline: * 1. Map phase: DFS rendering with type collection * 2. Reduce phase: Collect type statics from encountered types * 3. Compose phase: Apply compose template or identity transformation */ export async function renderAsync( store: Store, hash: Hash, options?: RenderOptions, ): Promise { const { resolution, decay, epsilon } = validateAndExtractOptions(options); const format = options?.format ?? "text"; // Check if root node exists if (store.cas.get(hash) === null) { throw new CasNodeNotFoundError(hash); } // Phase 1: Map - DFS rendering with type collection let content: string; let encounteredTypes: Set; // Try template rendering first try { const node = store.cas.get(hash); if (node !== null) { // Check if a template exists for this type const templateExists = await hasTemplate(store, node.type, format); if (templateExists) { const result = await renderWithTemplateInternal(store, hash, { resolution, decay, epsilon, format, }); content = result.output; encounteredTypes = result.encounteredTypes; } else { // Fallback rendering const visited = new Set(); if (format === "html") { // Structured HTML fallback content = renderNodeHtml( store, hash, resolution, decay, epsilon, visited, ); } else { content = renderNode( store, hash, resolution, decay, epsilon, visited, ); } encounteredTypes = new Set(); } } else { // Fallback rendering const visited = new Set(); if (format === "html") { content = renderNodeHtml( store, hash, resolution, decay, epsilon, visited, ); } else { content = renderNode(store, hash, resolution, decay, epsilon, visited); } encounteredTypes = new Set(); } } catch { // Fall through to fallback rendering const visited = new Set(); if (format === "html") { content = renderNodeHtml( store, hash, resolution, decay, epsilon, visited, ); } else { content = renderNode(store, hash, resolution, decay, epsilon, visited); } encounteredTypes = new Set(); } // Phase 2: Reduce - Collect type statics const typeStaticsRecord = await collectTypeStatics( store, encounteredTypes, format, ); // Convert Record → TypeStaticsEntry[] for LiquidJS iteration const typeStaticsArray = typeStaticsRecordToArray(typeStaticsRecord); // Phase 3: Compose - Apply compose template or identity const composeTemplate = await findComposeTemplate(store, format); if (composeTemplate === null) { // For HTML format without compose template, use builtin HTML shell if (format === "html") { return applyBuiltinHtmlShell(content, typeStaticsArray); } // Identity compose: no template, return content as-is return content; } // Render with compose template // Liquid imported statically at top of file const engine = new Liquid({ strictFilters: false, strictVariables: false, }); const composedOutput = await engine.parseAndRender(composeTemplate, { content, type_statics: typeStaticsArray, }); return composedOutput; } /** * Async render of a direct (in-memory) value through the full template + * compose pipeline — just like `renderAsync`, but for values that are * **not** stored in CAS. * * Looks up the instance template for `typeHash` in the requested `format`, * runs it through LiquidJS when found, falls back to YAML otherwise, and * finishes with the same reduce → compose phases that `renderAsync` uses. */ export async function renderDirectAsync( typeHash: Hash, value: unknown, store: Store, options: RenderOptions | null, ): Promise { const { resolution, decay, epsilon } = validateAndExtractOptions(options); const format = options?.format ?? "text"; // Phase 1: Map — render the value through its template (or fallback) let content: string; const encounteredTypes = new Set(); try { const templateExists = await hasTemplate(store, typeHash, format); if (templateExists) { const result = await renderDirectWithTemplate( store, typeHash, value, resolution, decay, epsilon, format, encounteredTypes, ); content = result; } else { // Fallback rendering if (format === "html") { // Structured HTML fallback for direct values const refSet = getDirectRefSet(store, typeHash, value); const childResolution = resolution * decay; const visited = new Set(); content = renderValueHtml( store, value, refSet, childResolution, decay, epsilon, visited, ); } else { content = renderDirect(typeHash, value, store, { resolution, decay, epsilon, }); } } } catch { if (format === "html") { const refSet = getDirectRefSet(store, typeHash, value); const childResolution = resolution * decay; const visited = new Set(); content = renderValueHtml( store, value, refSet, childResolution, decay, epsilon, visited, ); } else { content = renderDirect(typeHash, value, store, { resolution, decay, epsilon, }); } } // Phase 2: Reduce — collect type statics const typeStaticsRecord = await collectTypeStatics( store, encounteredTypes, format, ); const typeStaticsArray = typeStaticsRecordToArray(typeStaticsRecord); // Phase 3: Compose — apply compose template or builtin shell const composeTemplate = await findComposeTemplate(store, format); if (composeTemplate === null) { if (format === "html") { return applyBuiltinHtmlShell(content, typeStaticsArray); } return content; } const engine = new Liquid({ strictFilters: false, strictVariables: false, }); return await engine.parseAndRender(composeTemplate, { content, type_statics: typeStaticsArray, }); } /** * Extract the set of ocas_ref hashes from a direct (in-memory) value, * using schema lookup on the store. Returns empty set if store or schema * is unavailable. */ function getDirectRefSet( store: Store | null, typeHash: Hash, value: unknown, ): Set { if (store === null) return new Set(); const schema = getSchema(store, typeHash); if (schema === null) return new Set(); return new Set(collectRefs(schema, value)); } /** * Render a value directly (in-memory) without requiring it to be stored. * Accepts a raw { type, value } pair. Store is optional and read-only — * used only for schema lookup and expanding nested ocas_ref references. * No data is written to the store. */ export function renderDirect( typeHash: Hash, value: unknown, store: Store | null, options: RenderOptions | null, ): string { const { resolution, decay, epsilon } = validateAndExtractOptions(options); // Try to get schema from store to identify ocas_ref fields let refSet = new Set(); if (store !== null) { const schema = getSchema(store, typeHash); if (schema !== null) { refSet = new Set(collectRefs(schema, value)); } } const childResolution = resolution * decay; const visited = new Set(); return renderValue( store, value, refSet, childResolution, decay, epsilon, visited, ); } /** * Check if a template exists for a given type */ async function hasTemplate( store: Store, typeHash: Hash, format: string, ): Promise { const varName = `@ocas/template/${format}/${typeHash}`; try { const stringSchema = putSchema(store, { type: "string" }); const variable = store.var.get(varName, stringSchema); return variable !== null; } catch { return false; } } /** * Find and return a template string for a given type hash + format. */ async function findInstanceTemplate( store: Store, typeHash: Hash, format: string, ): Promise { const varName = `@ocas/template/${format}/${typeHash}`; try { const stringSchema = putSchema(store, { type: "string" }); const variable = store.var.get(varName, stringSchema); if (variable === null) return null; const templateNode = store.cas.get(variable.value); if (templateNode === null || typeof templateNode.payload !== "string") return null; return templateNode.payload; } catch { return null; } } /** * Render a raw value through a LiquidJS instance template. * Used by `renderDirectAsync` for object-valued envelopes that are * not stored in CAS. */ async function renderDirectWithTemplate( store: Store, typeHash: Hash, value: unknown, resolution: number, _decay: number, epsilon: number, format: string, encounteredTypes: Set, ): Promise { const template = await findInstanceTemplate(store, typeHash, format); if (template === null) { throw new Error("No template found"); } encounteredTypes.add(typeHash); const engine = new Liquid({ strictFilters: false, strictVariables: false, }); // Build LiquidJS context matching the same convention as renderNode // in liquid-render.ts: auto-spread object payload properties, then // set reserved keys. const context: Record = {}; if (value !== null && typeof value === "object" && !Array.isArray(value)) { const obj = value as Record; for (const key of Object.keys(obj)) { context[key] = obj[key]; } } context.resolution = resolution; context.epsilon = epsilon; context.payload = value; context.type = typeHash; return await engine.parseAndRender(template, context); } /** * Collect type statics for encountered types (reduce phase) */ async function collectTypeStatics( store: Store, types: Set, format: string, ): Promise> { const result: Record = {}; for (const typeHash of types) { const staticVarName = `@ocas/template-static/${format}/${typeHash}`; try { const stringSchema = putSchema(store, { type: "string" }); const variable = store.var.get(staticVarName, stringSchema); if (variable === null) { continue; // No static template for this type } const templateNode = store.cas.get(variable.value); if (templateNode === null || typeof templateNode.payload !== "string") { continue; } // Render the static template (no context needed) // Liquid imported statically at top of file const engine = new Liquid({ strictFilters: false, strictVariables: false, }); const staticOutput = await engine.parseAndRender( templateNode.payload, {}, ); // Parse the output as JSON to get TypeStatics structure try { const parsed = JSON.parse(staticOutput); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { // Convert all values to strings const typeStatics: TypeStatics = {}; for (const [key, value] of Object.entries(parsed)) { if (typeof value === "string") { typeStatics[key] = value; } else if (value !== null && value !== undefined) { typeStatics[key] = String(value); } } result[typeHash] = typeStatics; } } catch { // If parsing fails, skip this type's statics } } catch { // If any error occurs, skip this type } } return result; } /** * Find compose template for a given format (compose phase) */ async function findComposeTemplate( store: Store, format: string, ): Promise { const composeVarName = `@ocas/template-compose/${format}`; try { const stringSchema = putSchema(store, { type: "string" }); const variable = store.var.get(composeVarName, stringSchema); if (variable === null) { return null; } const templateNode = store.cas.get(variable.value); if (templateNode === null || typeof templateNode.payload !== "string") { return null; } return templateNode.payload; } catch { return null; } } function renderNode( store: Store | null, hash: Hash, currentResolution: number, decay: number, epsilon: number, visited: Set, ): string { // Check if resolution is below threshold (with floating point tolerance) if (currentResolution < epsilon + FLOAT_TOLERANCE) { return `cas:${hash}`; } // Fetch the node const node = store !== null ? store.cas.get(hash) : null; if (node === null) { // Missing node - render as cas: reference return `cas:${hash}`; } // Cycle detection if (visited.has(hash)) { return `cas:${hash}`; } visited.add(hash); // Get references from this node's schema const nodeRefs = store !== null ? refs(store, node) : []; const refSet = new Set(nodeRefs); // Calculate child resolution for next level const childResolution = currentResolution * decay; // Render the payload with recursive expansion of ocas_ref fields const rendered = renderValue( store, node.payload, refSet, childResolution, decay, epsilon, visited, ); visited.delete(hash); return rendered; } function renderValue( store: Store | null, value: unknown, refHashes: Set, childResolution: number, decay: number, epsilon: number, visited: Set, ): string { // Handle null if (value === null) { return "null\n"; } // Handle primitives if (typeof value === "string") { // Check if this string is a ocas_ref if (refHashes.has(value as Hash)) { // Recursively render the referenced node return renderNode( store, value as Hash, childResolution, decay, epsilon, visited, ); } // Otherwise, render as YAML string return toYamlString(value); } if (typeof value === "number" || typeof value === "boolean") { return `${value}\n`; } // Handle arrays if (Array.isArray(value)) { if (value.length === 0) { return "[]\n"; } const items = value.map((item) => { const itemYaml = renderValue( store, item, refHashes, childResolution, decay, epsilon, visited, ); return indent(itemYaml.trim(), 2); }); return `- ${items.join("\n- ")}\n`; } // Handle objects if (typeof value === "object") { const obj = value as Record; const keys = Object.keys(obj); if (keys.length === 0) { return "{}\n"; } const pairs = keys.map((key) => { const val = obj[key]; const valYaml = renderValue( store, val, refHashes, childResolution, decay, epsilon, visited, ); const trimmedVal = valYaml.trim(); // If value is multiline, indent it if (trimmedVal.includes("\n")) { return `${key}:\n${indent(trimmedVal, 2)}`; } return `${key}: ${trimmedVal}`; }); return `${pairs.join("\n")}\n`; } return "null\n"; } function toYamlString(str: string): string { // Handle special characters if ( str.includes("\n") || str.includes(":") || str.includes("#") || str.includes("[") || str.includes("]") || str.includes("{") || str.includes("}") || str.includes("'") || str.includes('"') || str.startsWith(" ") || str.endsWith(" ") ) { // Use double-quoted string with escaping const escaped = str .replace(/\\/g, "\\\\") .replace(/"/g, '\\"') .replace(/\n/g, "\\n"); return `"${escaped}"\n`; } return `${str}\n`; } function indent(text: string, spaces: number): string { const prefix = " ".repeat(spaces); return text .split("\n") .map((line) => (line ? prefix + line : line)) .join("\n"); } /** * Escape HTML special characters for safe inclusion in HTML */ function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /** * Render a CAS node as structured HTML (fallback when no HTML template exists). * Produces
    for objects/arrays,
    / for CAS refs, * and inline / for primitives. */ function renderNodeHtml( store: Store | null, hash: Hash, currentResolution: number, decay: number, epsilon: number, visited: Set, ): string { // Check if resolution is below threshold (with floating point tolerance) if (currentResolution < epsilon + FLOAT_TOLERANCE) { return `cas:${escapeHtml(hash)}`; } // Fetch the node const node = store !== null ? store.cas.get(hash) : null; if (node === null) { return `cas:${escapeHtml(hash)}`; } // Cycle detection if (visited.has(hash)) { return `cas:${escapeHtml(hash)}`; } visited.add(hash); // Get references from this node's schema const nodeRefs = store !== null ? refs(store, node) : []; const refSet = new Set(nodeRefs); // Calculate child resolution for next level const childResolution = currentResolution * decay; const rendered = renderValueHtml( store, node.payload, refSet, childResolution, decay, epsilon, visited, ); visited.delete(hash); return rendered; } /** * Render a value as structured HTML. */ function renderValueHtml( store: Store | null, value: unknown, refHashes: Set, childResolution: number, decay: number, epsilon: number, visited: Set, ): string { // Handle null if (value === null) { return "null"; } // Handle primitives if (typeof value === "string") { // Check if this string is an ocas_ref if (refHashes.has(value as Hash)) { // Check if resolution is below epsilon — render opaque if (childResolution < epsilon + FLOAT_TOLERANCE) { return `cas:${escapeHtml(value)}`; } // Render as collapsible
    with recursive child const childHtml = renderNodeHtml( store, value as Hash, childResolution, decay, epsilon, visited, ); return `
    cas:${escapeHtml(value)}${childHtml}
    `; } return `${escapeHtml(value)}`; } if (typeof value === "number" || typeof value === "boolean") { return `${escapeHtml(String(value))}`; } // Handle arrays if (Array.isArray(value)) { if (value.length === 0) { return "[]"; } const items = value.map((item) => { const itemHtml = renderValueHtml( store, item, refHashes, childResolution, decay, epsilon, visited, ); return `
  • ${itemHtml}
  • `; }); return `
      ${items.join("")}
    `; } // Handle objects if (typeof value === "object") { const obj = value as Record; const keys = Object.keys(obj); if (keys.length === 0) { return "{}"; } const items = keys.map((key) => { const val = obj[key]; const valHtml = renderValueHtml( store, val, refHashes, childResolution, decay, epsilon, visited, ); return `
  • ${escapeHtml(key)}: ${valHtml}
  • `; }); return `
      ${items.join("")}
    `; } return "null"; } /** * Convert Record to TypeStaticsEntry[] for LiquidJS iteration. * Types that had no static template are excluded from the result (they have no entry * in the record). */ function typeStaticsRecordToArray( record: Record, ): TypeStaticsEntry[] { return Object.entries(record).map(([typeHash, slots]) => ({ type_hash: typeHash as Hash, ...slots, })); } /** * Apply builtin HTML document shell (used when no custom compose template is registered). * Injects CSS as `) .join("\n"); // Build JS `) .join("\n"); const headExtras = styleBlocks ? `\n${styleBlocks}` : ""; const bodyExtras = scriptBlocks ? `\n${scriptBlocks}` : ""; return ` OCAS Render${headExtras} ${content}${bodyExtras} `; }