UNPKG

legal-markdown-js

Version:

Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version

286 lines 9.54 kB
/** * Remark Plugin for Mixin Processing * * This plugin processes mixins in legal documents using AST processing. * Mixins provide a way to include reusable content blocks and templates * within legal documents, with support for variable substitution and * conditional logic. * * Features: * - Mixin inclusion with @include directive * - Variable substitution within mixins * - Nested mixin support * - Template field processing within mixins * - File-based mixin imports * * @example * ```typescript * import { unified } from 'unified'; * import remarkParse from 'remark-parse'; * import remarkStringify from 'remark-stringify'; * import { remarkMixins } from './mixins.js'; * * const processor = unified() * .use(remarkParse) * .use(remarkMixins, { * metadata: { client: 'ACME Corp' }, * basePath: './mixins' * }) * .use(remarkStringify); * ``` * * @module */ import { visit } from 'unist-util-visit'; import * as fs from 'fs'; import * as path from 'path'; /** * Remark plugin for processing mixins * * This plugin identifies and processes mixin inclusion directives in markdown text, * loading mixin content from files or inline definitions and performing variable * substitution within the mixin content. * * @param options - Configuration options for mixin processing * @returns Remark plugin transformer function */ export const remarkMixins = options => { const { metadata = {}, basePath = '.', debug = false, maxDepth = 5, customMixins = {} } = options; return (tree) => { if (debug) { console.log('[remarkMixins] Processing mixins with options:', { basePath, maxDepth, customMixinCount: Object.keys(customMixins).length, }); } const context = { depth: 0, maxDepth, basePath, metadata, debug, customMixins, fileCache: new Map(), }; // Process all text nodes that might contain mixin directives visit(tree, node => { if (node.type === 'text') { processTextNode(node, context); } else if (node.type === 'paragraph') { processParagraphNode(node, context); } }); }; }; /** * Process a text node for mixin directives */ function processTextNode(node, context) { const originalText = node.value; const mixinDirectives = extractMixinDirectives(originalText); if (mixinDirectives.length === 0) { return; } if (context.debug) { console.log(`[remarkMixins] Found ${mixinDirectives.length} mixin directives in text node`); } // Process directives in reverse order to maintain correct positions let processedText = originalText; for (let i = mixinDirectives.length - 1; i >= 0; i--) { const directive = mixinDirectives[i]; const result = processMixinDirective(directive, context); // Replace the directive with the result processedText = processedText.substring(0, directive.start) + result + processedText.substring(directive.end); } node.value = processedText; } /** * Process a paragraph node for mixin directives */ function processParagraphNode(node, context) { // Process each text child in the paragraph node.children.forEach(child => { if (child.type === 'text') { processTextNode(child, context); } }); } /** * Extract mixin directives from text */ function extractMixinDirectives(text) { const directives = []; // Regex to match mixin directives: @include mixin_name or @include mixin_name(param1: value1, param2: value2) const mixinRegex = /@include\s+([a-zA-Z_][a-zA-Z0-9_-]*)\s*(?:\((.*?)\))?/g; let match; while ((match = mixinRegex.exec(text)) !== null) { const [fullMatch, mixinName, paramString] = match; // Parse parameters if present let parameters = {}; if (paramString) { try { parameters = parseParameters(paramString); } catch (error) { console.warn(`[remarkMixins] Failed to parse parameters for mixin "${mixinName}":`, error); } } directives.push({ name: mixinName, parameters, start: match.index, end: match.index + fullMatch.length, fullMatch, }); } return directives; } /** * Parse parameter string into object */ function parseParameters(paramString) { const parameters = {}; // Simple parameter parsing: key: value, key: value const paramRegex = /([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*([^,]+)/g; let match; while ((match = paramRegex.exec(paramString)) !== null) { const [, key, value] = match; parameters[key.trim()] = parseParameterValue(value.trim()); } return parameters; } /** * Parse a parameter value (string, number, boolean) */ function parseParameterValue(value) { // Remove quotes for strings if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { return value.slice(1, -1); } // Parse numbers if (/^\d+(\.\d+)?$/.test(value)) { return Number(value); } // Parse booleans if (value === 'true') return true; if (value === 'false') return false; // Otherwise return as string return value; } /** * Process a mixin directive */ function processMixinDirective(directive, context) { if (context.depth >= context.maxDepth) { console.warn(`[remarkMixins] Maximum recursion depth (${context.maxDepth}) reached for mixin "${directive.name}"`); return directive.fullMatch; // Return original directive } if (context.debug) { console.log(`[remarkMixins] Processing mixin "${directive.name}" with parameters:`, directive.parameters); } // Load mixin content const mixinContent = loadMixinContent(directive.name, context); if (!mixinContent) { console.warn(`[remarkMixins] Mixin "${directive.name}" not found`); return directive.fullMatch; // Return original directive } // Create context with mixin parameters const mixinContext = { ...context.metadata, ...directive.parameters, }; // Process template fields within the mixin content const processedContent = processTemplateFields(mixinContent, mixinContext, context.debug); // Process nested mixins (increment depth) const nestedContext = { ...context, depth: context.depth + 1, }; return processNestedMixins(processedContent, nestedContext); } /** * Load mixin content from custom definitions or files */ function loadMixinContent(mixinName, context) { // Check custom mixins first if (context.customMixins[mixinName]) { return context.customMixins[mixinName]; } // Try to load from file const filePath = path.join(context.basePath, `${mixinName}.md`); // Check cache first if (context.fileCache.has(filePath)) { return context.fileCache.get(filePath); } try { if (fs.existsSync(filePath)) { const content = fs.readFileSync(filePath, 'utf-8'); context.fileCache.set(filePath, content); return content; } } catch (error) { if (context.debug) { console.warn(`[remarkMixins] Failed to load mixin file "${filePath}":`, error); } } return null; } /** * Process template fields within mixin content */ function processTemplateFields(content, metadata, debug) { // Simple template field processing: {{field}} return content.replace(/\{\{([^}]+)\}\}/g, (match, fieldName) => { const trimmedField = fieldName.trim(); // Handle nested object access (e.g., client.name) const value = getNestedValue(metadata, trimmedField); if (value !== undefined && value !== null) { if (debug) { console.log(`[remarkMixins] Replacing template field "${trimmedField}" with value:`, value); } return String(value); } if (debug) { console.log(`[remarkMixins] Template field "${trimmedField}" not found, keeping original`); } return match; // Keep original if not found }); } /** * Get nested value from object using dot notation */ function getNestedValue(obj, path) { return path.split('.').reduce((current, key) => { return current && current[key] !== undefined ? current[key] : undefined; }, obj); } /** * Process nested mixins in content */ function processNestedMixins(content, context) { const mixinDirectives = extractMixinDirectives(content); if (mixinDirectives.length === 0) { return content; } // Process directives in reverse order to maintain correct positions let processedContent = content; for (let i = mixinDirectives.length - 1; i >= 0; i--) { const directive = mixinDirectives[i]; const result = processMixinDirective(directive, context); // Replace the directive with the result processedContent = processedContent.substring(0, directive.start) + result + processedContent.substring(directive.end); } return processedContent; } export default remarkMixins; //# sourceMappingURL=mixins.js.map