@emanuelsan/mosaic-js
Version:
Composable Markdown-based AI instruction engine for Node.js
215 lines • 12.2 kB
JavaScript
// Module Imports
import Mustache from "mustache";
import { Effect, Console, pipe, Data, Option } from "effect";
// Disable mustache's HTML escaping globally, as we are working with markdown
Mustache.escape = (text) => text;
// Util Imports
import { parseMarkdown, extractReferencesFromContent, } from "./parseMarkdownTemplate";
// Context Imports
import { MosaicVariables } from "../Mosaic";
import { normalizeOverridesPaths } from "../utils/normalizeOverridesPaths";
/**
* Error class for detecting and handling circular reference loops in template expansion.
* Used when a template references itself directly or indirectly through ancestor chains.
*
* @class LoopDetectedError
* @extends Data.TaggedError
* @property {string} path - The path where the loop was detected
* @property {string} message - Descriptive error message about the loop
*/
class LoopDetectedError extends Data.TaggedError("LoopDetectedError") {
}
/**
* Removes specified references from a template node's references array and content.
* This function filters out unwanted references and removes their corresponding mustache syntax from the content.
*
* @param {TemplateTreeNode} templateNode - The template node to process
* @param {string[]} referencesToRemove - Array of reference paths to remove from the node
* @returns {TemplateTreeNode} A new template node with the specified references removed from both the references array and content
*/
const removeReferenceFromNode = (templateNode, referencesToRemove) => {
/**
* Utility function to escape regex special characters in a string.
* @param {string} str - The string to escape
* @returns {string} The escaped string safe for use in regex patterns
*/
const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const newReferences = templateNode.references.filter((ref) => !referencesToRemove.includes(ref));
let newContent = templateNode.content;
for (const refToRemove of referencesToRemove) {
// Remove all occurrences of {{ ...refToRemove... }} with arbitrary whitespace
const regex = new RegExp(`{{\\s*${escapeRegExp(refToRemove)}\\s*}}`, "g");
newContent = newContent.replace(regex, "");
}
return { ...templateNode, references: newReferences, content: newContent };
};
/**
* Filters out circular references from a template node to prevent infinite loops during expansion.
* Detects and removes both self-references and ancestor loops, logging warnings for each removal.
*
* @param {TemplateTreeNode} templateNode - The template node to check for circular references
* @returns {Effect.Effect<TemplateTreeNode>} An Effect that yields a template node with all circular references removed
*/
const filterLoopedReferences = (templateNode) => Effect.gen(function* () {
const ancestors = templateNode.ancestors;
const currentPath = templateNode.path;
// Remove self-references if present
// Behavior: If a template reference is discovered to be a self-reference, or a loop in the ancestors
// it is removed from the content of the node and a warning is logged.
let filteredNode = templateNode;
if (filteredNode.references.includes(currentPath)) {
yield* Console.warn(`[LoopDetectedError] Self-reference detected in references at ${currentPath}`);
yield* Console.warn(`Removing self-reference...`);
filteredNode = removeReferenceFromNode(filteredNode, [currentPath]);
}
// Check for ancestor loops and remove any references that would create cycles
const loopedReferences = filteredNode.references.filter((ref) => ancestors.includes(ref));
if (loopedReferences.length > 0) {
yield* Console.warn(`[LoopDetectedError] Loop detected in template! at ${currentPath} with references: ${loopedReferences.join(", ")}`);
yield* Console.warn(`Removing looped references...`);
filteredNode = removeReferenceFromNode(filteredNode, loopedReferences);
}
return filteredNode;
});
/**
* Processes a template selector and a list of ancestor selectors to generate a sanitized TemplateTreeNode.
*
* This function takes a root selector (the identifier for a template node) and an optional list of ancestor selectors.
* It parses the template, then applies loop detection and cleanup logic using the provided ancestors list.
* Any self-references or ancestor loops in the node's `references` array are detected and removed,
* ensuring the returned node is free from circular references. Warnings are logged for any loops that are found and removed.
* Note: The ancestors list is used only for loop detection and is not attached to the returned node.
*
* @param {string} rootSelector - The selector identifying the root template node to process.
* @param {string[]} [ancestors=[]] - An array of ancestor selectors representing the traversal path to this node (used for loop detection only).
* @returns {Effect.Effect<TemplateTreeNode>} An Effect that yields a TemplateTreeNode with all reference and ancestor loops removed.
*/
export const getNodeFromSelector = (rootSelector, ancestors = []) => pipe(parseMarkdown(rootSelector), Effect.map((templateNode) => ({ ...templateNode, ancestors })), Effect.flatMap(filterLoopedReferences));
/**
* Attaches child nodes to a parent template node by resolving all its references.
* Creates child TemplateTreeNode instances for each reference in the parent node,
* passing the current ancestor chain for loop detection.
* Each child's content is expanded with its own path-specific variables before attachment.
*
* @param {TemplateTreeNode} rootNode - The parent node to attach children to
* @returns {Effect.Effect<TemplateTreeNode>} An Effect that yields the parent node with populated children array
*/
const attachChildren = (rootNode) => Effect.gen(function* () {
const references = rootNode.references;
const children = yield* Effect.forEach(references, (ref) => Effect.gen(function* () {
// Get the child node
const childNode = yield* getNodeFromSelector(ref, [
...rootNode.ancestors,
rootNode.path,
]);
// Expand the child's content with its own path-specific variables
const childTemplateVariables = yield* extractTemplateVariables(childNode.path);
const expandedContent = yield* Effect.sync(() => Mustache.render(childNode.content, childTemplateVariables));
// Return child with expanded content
return { ...childNode, content: expandedContent };
}));
return { ...rootNode, children };
});
/**
* Expands and flattens a template node until no more references remain using Effect.loop.
* This function loops through: expand children → flatten content → repeat until done.
*
* @param node - The template node to process
* @returns Effect that yields the fully expanded and flattened node
*/
const expandAndFlattenRecursively = (node) => Effect.gen(function* () {
let currentNode = node;
// Loop while there are still references to expand
while (currentNode.references && currentNode.references.length > 0) {
// Step 1: Expand children for current references
const nodeWithChildren = yield* attachChildren(currentNode);
// Step 2: Flatten children content into parent
currentNode = yield* flattenChildrenAndExpandContent(nodeWithChildren);
}
// Perform a final variable expansion on the fully composed content
const templateVariables = yield* extractTemplateVariables(currentNode.path);
const finalContent = Mustache.render(currentNode.content, templateVariables);
return { ...currentNode, content: finalContent };
});
/**
* Extracts template variables from the MosaicVariables context and merges them with any overrides.
* Returns a combined variables object that can be used for mustache templating.
* Variable names are prefixed with '$' to match the template syntax (e.g., 'numberOfAttempts' becomes '$numberOfAttempts').
*
* Path-specific overrides take precedence over global variables when the currentPath matches a normalized override key.
*
* @param currentPath - The current template path to check for overrides
* @returns Effect that yields the merged template variables with $ prefixes
*/
const extractTemplateVariables = (currentPath) => Effect.gen(function* () {
// Try to get MosaicVariables from context, but don't fail if not provided
const mosaicVariables = yield* Effect.serviceOption(MosaicVariables);
return Option.isNone(mosaicVariables)
? {} // No variables provided, return empty object
: yield* Effect.gen(function* () {
const variables = yield* mosaicVariables.value.templateVariables;
const overrides = yield* mosaicVariables.value.templateOverrides;
// Normalize override paths from ID/special syntax to relative paths
// This converts keys like "#special-rules" to "general/rules/special-rules"
const normalizedOverrides = yield* normalizeOverridesPaths(overrides);
// Check if there are any overrides for the current path
const pathOverrides = normalizedOverrides[currentPath] || {};
// Merge base variables with path-specific overrides (overrides take precedence)
const mergedVariables = { ...variables, ...pathOverrides };
// Add $ prefix to variable names to match template syntax
const prefixedVariables = {};
for (const [key, value] of Object.entries(mergedVariables)) {
prefixedVariables[`$${key}`] = value;
}
return prefixedVariables;
});
});
/**
* Expands the content of a parent node using mustache templating with its children's content and template variables.
* After expansion, children are cleared and new references are extracted from the expanded content.
* Template variables are extracted per child path to ensure path-specific overrides are applied correctly.
*
* @param rootNode - The parent node with children to be expanded
* @returns Effect that yields the expanded node with new references from expanded content
*/
const flattenChildrenAndExpandContent = (rootNode) => Effect.gen(function* () {
const children = rootNode.children ?? [];
// If no children, return the node as-is
if (children.length === 0) {
return rootNode;
}
// Create mustache context from children: { "path": "content" }
const mustacheContext = {};
for (const child of children) {
mustacheContext[child.path] = child.content;
}
// Extract template variables for the root node to expand its own content
const templateVariables = yield* extractTemplateVariables(rootNode.path);
// Combine children content and template variables for mustache context
const combinedContext = { ...templateVariables, ...mustacheContext };
// Expand parent content using mustache with both children content and variables
const expandedContent = yield* Effect.sync(() => Mustache.render(rootNode.content, combinedContext));
// Extract and normalize references from the expanded content
const { references: newReferences, content: finalContent } = yield* extractReferencesFromContent(expandedContent, {
currentPath: rootNode.path,
ancestors: rootNode.ancestors,
removeLoopedReferences: true,
});
// Return expanded node with cleared children and new references from expanded content
return {
...rootNode,
content: finalContent,
references: newReferences,
children: [], // Clear children after integration
};
});
// Programs
/**
* Builds a complete template tree by recursively expanding and flattening all references.
* Takes a root selector and returns a fully resolved template node with all content expanded.
*
* @param rootSelector - The selector identifying the root template to build
* @returns Effect that yields a fully expanded TemplateTreeNode with all references resolved
*/
export const buildTemplateTree = (rootSelector) => pipe(getNodeFromSelector(rootSelector), Effect.andThen(expandAndFlattenRecursively));
//# sourceMappingURL=buildTemplateTree.js.map