UNPKG

legal-markdown-js

Version:

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

636 lines 24.3 kB
/** * Remark plugin for Legal Markdown template field processing * * This plugin processes template fields like {{field_name}} in Legal Markdown documents * using AST-based processing. It handles: * - Simple variables: {{client.name}} * - Nested object access: {{client.contact.email}} * - Helper functions: {{formatDate(@today, "YYYY-MM-DD")}} * - Conditional expressions: {{active ? "Active" : "Inactive"}} * - Field tracking integration for highlighting * * Architecture: * 1. Parse template field patterns in text nodes * 2. Resolve field values from metadata * 3. Replace patterns with resolved values * 4. Track fields for highlighting support * * @example * ```typescript * import { unified } from 'unified'; * import remarkParse from 'remark-parse'; * import remarkTemplateFields from './template-fields.js'; * * const processor = unified() * .use(remarkParse) * .use(remarkTemplateFields, { metadata: { client_name: 'ACME Corp' } }); * * const result = await processor.process('Hello {{client_name}}!'); * ``` * * @module */ import { visit } from 'unist-util-visit'; import { fieldTracker } from '../../extensions/tracking/field-tracker.js'; import { extensionHelpers as helpers } from '../../extensions/helpers/index.js'; import { detectBracketValues } from '../../extensions/ast-mixin-processor.js'; /** * Default template field pattern */ const DEFAULT_FIELD_PATTERN = /\{\{\s*([^}]+)\s*\}\}/g; /** * Pattern for @today syntax without brackets */ const TODAY_PATTERN = /@today(?:\[([^\]]+)\])?/g; /** * Check if a position is inside a loop or conditional block */ function isInsideLoopOrConditional(text, position) { // Pattern to match loop/conditional blocks (including underscores in variable names) const blockPattern = /\{\{#([\w_]+)\}\}[\s\S]*?\{\{\/\1\}\}/g; let match; while ((match = blockPattern.exec(text)) !== null) { const blockStart = match.index; const blockEnd = match.index + match[0].length; // Check if position is inside this block if (position > blockStart && position < blockEnd) { return true; } } // Also check for {{#if}} blocks const ifBlockPattern = /\{\{#if\s+[^}]+\}\}[\s\S]*?\{\{\/if\}\}/g; while ((match = ifBlockPattern.exec(text)) !== null) { const blockStart = match.index; const blockEnd = match.index + match[0].length; if (position > blockStart && position < blockEnd) { return true; } } return false; } /** * Extract template fields from text content */ function extractTemplateFields(text, patterns) { const fields = []; // Use default pattern if none provided const regexPatterns = patterns.length > 0 ? patterns.map(p => new RegExp(p, 'g')) : [DEFAULT_FIELD_PATTERN]; for (const regex of regexPatterns) { let match; regex.lastIndex = 0; // Reset regex state while ((match = regex.exec(text)) !== null) { const [fullMatch, fieldExpression] = match; const trimmedExpression = fieldExpression.trim(); // Skip loop/conditional patterns that should be handled by the clauses plugin // These include: {{#if ...}}, {{#variableName}}, {{/if}}, {{/variableName}}, {{else}} if (trimmedExpression.startsWith('#') || trimmedExpression.startsWith('/') || trimmedExpression === 'else') { continue; } // Skip fields that are inside loop/conditional blocks if (isInsideLoopOrConditional(text, match.index)) { continue; } fields.push({ pattern: fullMatch, fieldName: trimmedExpression, expression: trimmedExpression, startIndex: match.index, endIndex: match.index + fullMatch.length, }); } } // Also search for @today patterns (without brackets), but skip those inside {{}} blocks let todayMatch; TODAY_PATTERN.lastIndex = 0; // Reset regex state while ((todayMatch = TODAY_PATTERN.exec(text)) !== null) { const [fullMatch, formatSpecifier] = todayMatch; // Check if this @today is inside a {{}} block const matchStart = todayMatch.index; const matchEnd = todayMatch.index + fullMatch.length; // Look for any {{}} blocks that contain this @today match let isInsideTemplateField = false; const templateFieldPattern = /\{\{[^}]*\}\}/g; let templateMatch; templateFieldPattern.lastIndex = 0; while ((templateMatch = templateFieldPattern.exec(text)) !== null) { const templateStart = templateMatch.index; const templateEnd = templateMatch.index + templateMatch[0].length; // Check if the @today match is inside this template field if (matchStart >= templateStart && matchEnd <= templateEnd) { isInsideTemplateField = true; break; } } // Only add @today as a separate field if it's not inside a template field if (!isInsideTemplateField) { // Create field name - if there's a format specifier, include it as a parameter const fieldName = formatSpecifier ? `@today[${formatSpecifier}]` : '@today'; fields.push({ pattern: fullMatch, fieldName: fieldName, expression: fieldName, startIndex: todayMatch.index, endIndex: todayMatch.index + fullMatch.length, }); } } // Sort by start index for proper replacement order (reverse to avoid index shifting) return fields.sort((a, b) => b.startIndex - a.startIndex); } /** * Resolve template field value from metadata */ function resolveFieldValue(fieldName, metadata) { // Check if this field path has a bracket value (should be treated as missing) const bracketFields = detectBracketValues(metadata); const isBracketValue = bracketFields.has(fieldName); // Handle @today with format specifiers if (fieldName === '@today' || fieldName.startsWith('@today[')) { // Check if @today is defined in metadata first, otherwise use current date const today = metadata['@today'] ? new Date(metadata['@today']) : new Date(); let formattedDate; if (fieldName === '@today') { // Default format: YYYY-MM-DD formattedDate = today.toISOString().split('T')[0]; } else { // Extract format specifier from @today[format] const formatMatch = fieldName.match(/@today\[([^\]]+)\]/); const format = formatMatch ? formatMatch[1] : ''; switch (format.toLowerCase()) { case 'iso': formattedDate = today.toISOString().split('T')[0]; break; case 'long': formattedDate = today.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); break; case 'european': formattedDate = today.toLocaleDateString('en-GB'); break; case 'legal': formattedDate = today.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); break; case 'medium': formattedDate = today.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); break; default: // Try to use the format as a custom date format // For now, fall back to ISO format formattedDate = today.toISOString().split('T')[0]; break; } } return { value: formattedDate, hasLogic: true, mixinType: 'helper', }; } // Check for helper function pattern const helperMatch = fieldName.match(/^(\w+)\((.*)?\)$/); if (helperMatch) { const [, helperName, argsString] = helperMatch; const helper = helpers[helperName]; if (helper && typeof helper === 'function') { try { // Parse arguments const args = parseHelperArguments(argsString || '', metadata); // Call the helper function const result = helper(...args); return { value: result, hasLogic: true, mixinType: 'helper', }; } catch (error) { console.warn(`Error calling helper '${helperName}':`, error); return { value: undefined, hasLogic: true, mixinType: 'helper', }; } } else { // Helper not found return { value: undefined, hasLogic: true, mixinType: 'helper', }; } } // Check for conditional/ternary pattern const conditionalMatch = fieldName.match(/(.+?)\s*\?\s*(.+?)\s*:\s*(.+)/); if (conditionalMatch) { const [, condition, trueValue, falseValue] = conditionalMatch; const conditionResult = resolveNestedValue(metadata, condition.trim()); const result = conditionResult ? trueValue.trim() : falseValue.trim(); // Remove quotes from string literals const cleanResult = result.replace(/^["']|["']$/g, ''); return { value: cleanResult, hasLogic: true, mixinType: 'conditional', }; } // Simple variable access with dot notation const value = resolveNestedValue(metadata, fieldName); // If this is a bracket value, treat it as missing (return undefined) if (isBracketValue) { return { value: undefined, hasLogic: false, mixinType: 'variable', }; } return { value, hasLogic: false, mixinType: 'variable', }; } /** * Resolve nested value from metadata using dot notation */ function resolveNestedValue(metadata, path) { const keys = path.split('.'); let current = metadata; for (const key of keys) { if (current === null || current === undefined) { return undefined; } // Handle array access like parties[0] const arrayMatch = key.match(/^(.+?)\[(\d+)\]$/); if (arrayMatch) { const [, arrayName, index] = arrayMatch; current = current[arrayName]; if (Array.isArray(current)) { current = current[parseInt(index, 10)]; } else { return undefined; } } else { current = current[key]; } } return current; } /** * Check if value is considered empty for field tracking purposes */ function isEmptyValue(value) { return (value === undefined || value === null || value === '' || (typeof value === 'string' && value.trim() === '')); } /** * Get CSS class for field based on its status */ function getFieldCssClass(status) { switch (status) { case 'filled': return 'legal-field imported-value'; case 'empty': return 'legal-field missing-value'; case 'logic': return 'legal-field highlight'; default: return 'legal-field imported-value'; } } /** * Format value for display with optional field tracking */ function formatFieldValue(value, fieldName, enableFieldTracking = false, hasLogic = false, isEmptyField = false) { const formattedValue = (() => { if (isEmptyValue(value)) { return `{{${fieldName}}}`; } // Handle different value types if (typeof value === 'boolean') { return value.toString(); } if (typeof value === 'number') { return value.toString(); } if (value instanceof Date) { return value.toISOString().split('T')[0]; } return String(value); })(); // Apply field tracking wrapper if enabled if (enableFieldTracking) { const status = hasLogic ? 'logic' : isEmptyField ? 'empty' : 'filled'; const cssClass = getFieldCssClass(status); return `<span class="${cssClass}" data-field="${fieldName.replace(/"/g, '&quot;')}">${formattedValue}</span>`; } return formattedValue; } /** * Check if text contains existing field tracking spans */ function hasExistingFieldSpans(text) { return text.includes('class="legal-field') && text.includes('data-field="'); } /** * Check if a text node is inside existing field tracking spans by examining sibling HTML nodes */ function isInsideFieldTrackingSpan(node, parent) { if (!parent || parent.type !== 'paragraph' || !parent.children) { return false; } const nodeIndex = parent.children.indexOf(node); if (nodeIndex === -1) { return false; } // Look for opening field tracking span before this node let hasOpeningSpan = false; for (let i = nodeIndex - 1; i >= 0; i--) { const prevNode = parent.children[i]; if (prevNode.type === 'html' && prevNode.value.includes('class="legal-field') && prevNode.value.includes('data-field="')) { hasOpeningSpan = true; break; } } // Look for closing span after this node let hasClosingSpan = false; for (let i = nodeIndex + 1; i < parent.children.length; i++) { const nextNode = parent.children[i]; if (nextNode.type === 'html' && nextNode.value.includes('</span>')) { hasClosingSpan = true; break; } } return hasOpeningSpan && hasClosingSpan; } /** * Smart split function that respects quoted strings and parentheses * * Splits a string on commas while preserving commas inside quoted strings * and nested parentheses (for helper function calls). * Handles both single and double quotes. * * @param str - String to split * @returns Array of split parts * * @example * ```typescript * smartSplitArguments('arg1, "arg with, comma", arg3') * // Returns: ['arg1', '"arg with, comma"', 'arg3'] * * smartSplitArguments('addYears(@today, 5), "YYYY-MM-DD"') * // Returns: ['addYears(@today, 5)', '"YYYY-MM-DD"'] * ``` */ function smartSplitArguments(str) { const parts = []; let current = ''; let inQuotes = false; let quoteChar = ''; let parenDepth = 0; for (let i = 0; i < str.length; i++) { const char = str[i]; if ((char === '"' || char === '\'') && !inQuotes) { // Start of quoted string inQuotes = true; quoteChar = char; current += char; } else if (char === quoteChar && inQuotes) { // End of quoted string inQuotes = false; quoteChar = ''; current += char; } else if (char === '(' && !inQuotes) { // Opening parenthesis outside quotes parenDepth++; current += char; } else if (char === ')' && !inQuotes) { // Closing parenthesis outside quotes parenDepth--; current += char; } else if (char === ',' && !inQuotes && parenDepth === 0) { // Comma outside quotes and parentheses - split here parts.push(current); current = ''; } else { // Regular character current += char; } } // Add the last part if (current) { parts.push(current); } return parts; } /** * Parse comma-separated arguments from a helper function call with support for nested calls * * Parses helper function arguments, resolving metadata references, handling * string literals, numbers, booleans, and nested helper function calls. * * @param argsString - Raw arguments string from helper call * @param metadata - Metadata object for resolving references * @returns Array of parsed argument values * * @example * ```typescript * parseHelperArguments("@today, 'YYYY-MM-DD'", { today: new Date() }) * // Returns: [Date, 'YYYY-MM-DD'] * * parseHelperArguments("addYears(@today, 5), 'YYYY-MM-DD'", { '@today': new Date() }) * // Returns: [Date (5 years added), 'YYYY-MM-DD'] * * parseHelperArguments("amount, 'USD'", { amount: 1500 }) * // Returns: [1500, 'USD'] * ``` */ function parseHelperArguments(argsString, metadata) { if (!argsString.trim()) { return []; } const args = []; const parts = smartSplitArguments(argsString); for (const part of parts) { const trimmed = part.trim(); if (!trimmed) { continue; } // String literal if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith('\'') && trimmed.endsWith('\''))) { args.push(trimmed.slice(1, -1)); continue; } // Number literal if (/^-?\d+(\.\d+)?$/.test(trimmed)) { args.push(parseFloat(trimmed)); continue; } // Boolean literal if (trimmed === 'true') { args.push(true); continue; } if (trimmed === 'false') { args.push(false); continue; } // Null/undefined literal if (trimmed === 'null') { args.push(null); continue; } if (trimmed === 'undefined') { args.push(undefined); continue; } // Check for nested helper function call const nestedHelperMatch = trimmed.match(/^(\w+)\((.*)\)$/); if (nestedHelperMatch) { const [, helperName, nestedArgsString] = nestedHelperMatch; const helper = helpers[helperName]; if (helper && typeof helper === 'function') { try { // Recursively parse nested arguments const nestedArgs = parseHelperArguments(nestedArgsString, metadata); // Call the nested helper function const nestedResult = helper(...nestedArgs); args.push(nestedResult); continue; } catch (error) { console.warn(`Error calling nested helper '${helperName}':`, error); // Fall through to treat as metadata reference } } } // Handle @today special case if (trimmed === '@today' || trimmed.startsWith('@today[')) { // Check if @today is defined in metadata first, otherwise use current date const todayValue = metadata['@today'] ? new Date(metadata['@today']) : new Date(); args.push(todayValue); continue; } // Metadata reference (including other variables) const value = resolveNestedValue(metadata, trimmed); args.push(value); } return args; } /** * Process template fields in text nodes */ function processTemplateFieldsInAST(root, metadata, fieldPatterns, enableFieldTracking = false, debug = false) { // Get field mappings from metadata if available const fieldMappings = metadata['_field_mappings'] || new Map(); // Process both text and HTML nodes visit(root, (node, index, parent) => { // Only process text and HTML nodes that have a value property if ((node.type === 'text' || node.type === 'html') && 'value' in node && typeof node.value === 'string') { const originalValue = node.value; // Skip processing if this HTML already contains field tracking spans if (node.type === 'html' && hasExistingFieldSpans(originalValue)) { if (debug) { console.log(`⏭️ Skipping HTML node with existing field spans: "${originalValue.substring(0, 100)}..."`); } return; } // Skip processing if this text node is inside existing field tracking spans if (node.type === 'text' && isInsideFieldTrackingSpan(node, parent)) { if (debug) { console.log(`⏭️ Skipping text node inside existing field spans: "${originalValue}"`); } return; } const templateFields = extractTemplateFields(originalValue, fieldPatterns); if (templateFields.length === 0) { return; // No template fields found } if (debug) { console.log(`📋 Found ${templateFields.length} template fields in ${node.type}: "${originalValue}"`); } let processedText = originalValue; // Process fields in reverse order to maintain string indices for (const field of templateFields) { const { value, hasLogic, mixinType } = resolveFieldValue(field.fieldName, metadata); const isEmptyField = isEmptyValue(value); // Format value with field tracking applied during AST processing const formattedValue = formatFieldValue(value, field.fieldName, enableFieldTracking, hasLogic, isEmptyField); // Get the original pattern if this was normalized from a custom pattern const originalPattern = fieldMappings.get(field.pattern) || field.pattern; // Always track the field for statistics (regardless of highlighting) fieldTracker.trackField(field.fieldName, { value: value, // Pass the original value, not the formatted one originalValue: originalPattern, hasLogic, mixinUsed: mixinType, }); // Replace the field pattern with the formatted value (potentially wrapped with highlighting) processedText = processedText.substring(0, field.startIndex) + formattedValue + processedText.substring(field.endIndex); if (debug) { console.log(`🔄 Replaced ${field.pattern} with "${formattedValue}" (original: ${originalPattern})`); } } // Update the node value node.value = processedText; // If we added field tracking HTML and this is a text node, convert it to HTML node // to prevent remark-stringify from escaping the HTML if (enableFieldTracking && node.type === 'text' && processedText.includes('<span class="legal-field')) { node.type = 'html'; } } }); } /** * Remark plugin for processing template fields in Legal Markdown documents */ const remarkTemplateFields = options => { const { metadata, debug = false, fieldPatterns = [], enableFieldTracking = false } = options; return (tree) => { if (debug) { console.log('📝 Processing template fields with remark plugin'); console.log('📊 Metadata:', metadata); console.log('📋 Field patterns:', fieldPatterns); if (enableFieldTracking) { console.log('🎯 Field tracking highlighting enabled'); } } // Process all template fields in the AST with optional field tracking processTemplateFieldsInAST(tree, metadata, fieldPatterns, enableFieldTracking, debug); if (debug) { console.log('✅ Template field processing completed'); } }; }; export default remarkTemplateFields; //# sourceMappingURL=template-fields.js.map