UNPKG

legal-markdown-js

Version:

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

472 lines 20.6 kB
import { ProcessingError } from '../errors/index.js'; import { fieldTracker } from './tracking/field-tracker.js'; import { fieldSpan } from './tracking/field-span.js'; import { setFieldTrackingEnabled, isFieldTrackingEnabled, setAstFieldTrackingEnabled, isAstFieldTrackingEnabled, setLogicBranchHighlightingEnabled, } from './tracking/field-tracking-state.js'; import { lmFieldToken } from './tracking/tracking-token.js'; import { compileHandlebarsTemplate, handlebarsInstance } from './handlebars-engine.js'; import { unescapeBracketLiteral } from './ast-mixin-processor.js'; const MAX_LOOP_ITERATIONS = 10_000; const MAX_NESTING_DEPTH = 10; const BLOCKED_KEYS = new Set(['__proto__', 'prototype', 'constructor']); function normalizeLeakedEmphasisSegment(segment) { return segment.replace(/^\*/g, '_').replace(/\*$/g, '_'); } function normalizeVariablePath(path) { return path.split('.').map(normalizeLeakedEmphasisSegment).join('.'); } function escapeHtmlAttribute(value) { return value .replace(/&/g, '&amp;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;'); } export function detectSyntaxType(content) { const hasLegacy = hasLegacySyntax(content); const hasHandlebars = /\{\{#(each|if|unless|with)\b/.test(content) || /\{\{\/(each|if|unless|with)\}\}/.test(content) || /\{\{(?!#|\/|@)\w+\s+[^}]+\}\}/.test(content); if (hasLegacy && hasHandlebars) return 'mixed'; if (hasLegacy) return 'legacy'; return 'handlebars'; } function hasLegacySyntax(content) { const hasFunctionStyleHelpers = /\{\{\s*\w+\(/.test(content); const hasMathExpressions = /\{\{\s*[\w.]+\s+[+\-*/]\s+[\w.]+\s*\}\}/.test(content) || /\{\{\s*[\w.]+\s+[+\-*/]\s+\d+(?:\.\d+)?\s*\}\}/.test(content) || /\{\{\s*\d+(?:\.\d+)?\s+[+\-*/]\s+[\w.]+\s*\}\}/.test(content); const hasStringConcatenation = /\{\{\s*["'][^"']*["']\s*[+\-*/]\s*[\w.]+\s*\}\}/.test(content) || /\{\{\s*[\w.]+\s*[+\-*/]\s*["'][^"']*["']\s*\}\}/.test(content); return hasFunctionStyleHelpers || hasMathExpressions || hasStringConcatenation; } /** * After `preprocessSimpleVariableMixins` converts `{{.}}``{{__lmDot}}`, this pass * scans for `{{#each VAR}}` blocks and injects the loop variable name as an argument, * producing `{{__lmDot "VAR"}}`. This lets `__lmDot` emit a qualified placeholder like * `[[items[2]]]` instead of the generic `[[.]]` when a missing value is encountered. * * Nested `{{#each}}` blocks are handled by maintaining a stack of variable paths. */ function injectLoopVariablePaths(content) { const stack = []; return content.replace(/\{\{(?:#each\s+([\w.]+)|\/each|__lmDot)\}\}/g, (match, eachVar) => { if (eachVar !== undefined) { stack.push(eachVar); return match; } if (match === '{{/each}}') { stack.pop(); return match; } // match === '{{__lmDot}}' if (stack.length > 0) { const currentVar = stack[stack.length - 1]; return `{{__lmDot "${currentVar}"}}`; } return match; }); } function processWithHandlebars(content, metadata, context, enableFieldTracking = true, astFieldTracking = false, logicBranchHighlighting = false) { registerVariableFallbackHelper(); if (context) { let depth = 0; let currentContext = context; while (currentContext) { depth += 1; if (depth > MAX_NESTING_DEPTH) { throw new ProcessingError(`Maximum template nesting depth exceeded (${MAX_NESTING_DEPTH}).`); } if (depth > MAX_LOOP_ITERATIONS) { throw new ProcessingError(`Maximum loop iteration guard exceeded (${MAX_LOOP_ITERATIONS}) while resolving context depth.`); } currentContext = currentContext.parent; } } const preprocessedContent = injectLoopVariablePaths(preprocessSimpleVariableMixins(preprocessConditionalComparisons(normalizeTodayVariable(content)))); const handlebarsData = { ...metadata, }; if (typeof metadata.locale === 'string' && metadata.locale.trim()) { handlebarsData.locale = metadata.locale; } if (context) { handlebarsData['@index'] = context.index; handlebarsData['@total'] = context.total; handlebarsData['@first'] = context.index === 0; handlebarsData['@last'] = context.index === context.total - 1; if (context.parent) { handlebarsData['@parent'] = { ...metadata, '@index': context.parent.index, '@total': context.parent.total, }; } } setFieldTrackingEnabled(enableFieldTracking); setAstFieldTrackingEnabled(Boolean(astFieldTracking)); setLogicBranchHighlightingEnabled(Boolean(logicBranchHighlighting)); const result = compileHandlebarsTemplate(preprocessedContent, handlebarsData); if (enableFieldTracking) { return applyFieldTrackingToOutput(result, metadata); } return result; } function preprocessConditionalComparisons(content) { const tagPattern = /\{\{\s*(#if|#unless|else\s+if)\s+([^}]+?)\s*\}\}/g; return content.replace(tagPattern, (match, tagPrefix, rawExpression) => { const expression = String(rawExpression).trim(); if (!expression || expression.startsWith('(')) { return match; } const preprocessedExpression = preprocessConditionalExpression(expression); if (!preprocessedExpression) { return match; } return `{{${tagPrefix} ${preprocessedExpression}}}`; }); } function preprocessConditionalExpression(expression) { const normalized = stripOuterParentheses(expression.trim()); if (!normalized) return null; const orSplit = splitAtTopLevelOperator(normalized, '||'); if (orSplit) { const [left, right] = orSplit; return `(or ${toConditionalOperand(left)} ${toConditionalOperand(right)})`; } const andSplit = splitAtTopLevelOperator(normalized, '&&'); if (andSplit) { const [left, right] = andSplit; return `(and ${toConditionalOperand(left)} ${toConditionalOperand(right)})`; } const comparators = [ ['>=', 'gte'], ['<=', 'lte'], ['==', 'eq'], ['!=', 'neq'], ['>', 'gt'], ['<', 'lt'], ]; for (const [operator, helper] of comparators) { const split = splitAtTopLevelOperator(normalized, operator); if (!split) continue; const [left, right] = split; return `(${helper} ${toConditionalOperand(left)} ${toConditionalOperand(right)})`; } if (normalized.startsWith('!')) { return `(not ${toConditionalOperand(normalized.slice(1))})`; } return null; } function toConditionalOperand(operand) { const trimmed = stripOuterParentheses(operand.trim()); const nested = preprocessConditionalExpression(trimmed); return nested ?? trimmed; } function stripOuterParentheses(value) { let current = value.trim(); while (current.startsWith('(') && current.endsWith(')')) { let depth = 0; let inSingleQuote = false; let inDoubleQuote = false; let isBalancedWrapper = true; for (let i = 0; i < current.length; i++) { const char = current[i]; const previous = i > 0 ? current[i - 1] : ''; if (char === "'" && previous !== '\\' && !inDoubleQuote) { inSingleQuote = !inSingleQuote; continue; } if (char === '"' && previous !== '\\' && !inSingleQuote) { inDoubleQuote = !inDoubleQuote; continue; } if (inSingleQuote || inDoubleQuote) continue; if (char === '(') depth += 1; if (char === ')') { depth -= 1; if (depth === 0 && i < current.length - 1) { isBalancedWrapper = false; break; } } } if (!isBalancedWrapper || depth !== 0) { break; } current = current.slice(1, -1).trim(); } return current; } function splitAtTopLevelOperator(expression, operator) { let depth = 0; let inSingleQuote = false; let inDoubleQuote = false; for (let i = 0; i <= expression.length - operator.length; i++) { const char = expression[i]; const previous = i > 0 ? expression[i - 1] : ''; if (char === "'" && previous !== '\\' && !inDoubleQuote) { inSingleQuote = !inSingleQuote; continue; } if (char === '"' && previous !== '\\' && !inSingleQuote) { inDoubleQuote = !inDoubleQuote; continue; } if (inSingleQuote || inDoubleQuote) continue; if (char === '(') { depth += 1; continue; } if (char === ')') { depth = Math.max(0, depth - 1); continue; } if (depth === 0 && expression.slice(i, i + operator.length) === operator) { const left = expression.slice(0, i).trim(); const right = expression.slice(i + operator.length).trim(); if (!left || !right) return null; return [left, right]; } } return null; } function normalizeWrappedTodayExpression(expression) { const toHandlebarsDateFormat = (rawFormat) => { if (!rawFormat) return 'YYYY-MM-DD'; const normalized = rawFormat.toLowerCase(); switch (normalized) { case 'iso': case 'yyyy-mm-dd': return 'YYYY-MM-DD'; case 'us': case 'mm/dd/yyyy': return 'MM/DD/YYYY'; case 'eu': case 'european': case 'dd/mm/yyyy': return 'DD/MM/YYYY'; case 'long': return 'MMMM D, YYYY'; case 'medium': return 'MMM D, YYYY'; case 'short': return 'MMM D, YY'; case 'legal': return 'MMMM Do, YYYY'; default: return rawFormat; } }; const trimmed = expression.trim(); const arithmeticMatch = trimmed.match(/^@today((?:[+-]\d+[dmy]?)+)(?:\[([^\]]+)\])?$/); if (arithmeticMatch) { const [, arithmeticPart, formatOverride] = arithmeticMatch; const opPattern = /([+-])(\d+)([dmy]?)/g; let opMatch; let currentExpr = '(today)'; while ((opMatch = opPattern.exec(arithmeticPart)) !== null) { const [, sign, amountRaw, unitRaw] = opMatch; const amount = `${sign === '-' ? '-' : ''}${amountRaw}`; const unit = unitRaw || 'd'; const helperName = unit === 'm' ? 'addMonths' : unit === 'y' ? 'addYears' : 'addDays'; currentExpr = `(${helperName} ${currentExpr} ${amount})`; } const format = toHandlebarsDateFormat(formatOverride).replace(/"/g, '\\"'); return `formatDate ${currentExpr} "${format}"`; } const simpleMatch = trimmed.match(/^@today(?:\[([^\]]+)\])?$/); if (!simpleMatch) return null; const format = toHandlebarsDateFormat(simpleMatch[1]).replace(/"/g, '\\"'); return `formatDate (today) "${format}"`; } function normalizeTodayVariable(content) { return content.replace(/\{\{([^{}]+)\}\}/g, (match, expression) => { const wrappedTodayExpression = normalizeWrappedTodayExpression(expression); if (wrappedTodayExpression) { return `{{${wrappedTodayExpression}}}`; } const trimmed = expression.trim(); if (trimmed === 'today') { return match; } const normalized = expression.replace(/(^|\s)@?today(?=\s|$)/g, '$1(today)'); return `{{${normalized}}}`; }); } let variableFallbackHelperRegistered = false; function registerVariableFallbackHelper() { if (variableFallbackHelperRegistered) return; handlebarsInstance.registerHelper('__lmDot', function (pathOrOptions, maybeOptions) { const hasPath = typeof pathOrOptions === 'string'; const options = hasPath ? maybeOptions : pathOrOptions; const loopVarPath = hasPath ? pathOrOptions : undefined; // Handlebars stores loop data as `data.index` (not `data['@index']`); // the `@` prefix is template-syntax only, not the data key. const loopIndex = options?.data?.index; const rawValue = this === null || this === undefined ? '' : String(this); if (!isFieldTrackingEnabled()) return unescapeBracketLiteral(rawValue) ?? rawValue; const isEmpty = rawValue === ''; let displayValue; if (isEmpty) { displayValue = loopVarPath !== undefined && loopIndex !== undefined ? `[[${loopVarPath}[${loopIndex}]]]` : '[[.]]'; } else { displayValue = unescapeBracketLiteral(rawValue) ?? rawValue; } const kind = isEmpty ? 'missing' : 'imported'; const tokenized = isAstFieldTrackingEnabled(); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SafeString not directly importable from instance return new handlebarsInstance.SafeString(tokenized ? lmFieldToken('.', displayValue, kind) : fieldSpan('.', displayValue, kind)); }); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Handlebars injects `this` and `options` as untyped context objects handlebarsInstance.registerHelper('__lmVar', function (path, options) { const root = options?.data?.root; const normalizedPath = normalizeVariablePath(path); const shouldResolveAtRoot = normalizedPath !== path || /^[_*]/.test(path) || /[_*]$/.test(path) || /\.[_*]/.test(path) || /[_*]\./.test(path); if (root && this === root && !shouldResolveAtRoot) { return `{{${path}}}`; } const contextValue = resolvePath(this, normalizedPath); if (contextValue !== undefined) { if (typeof contextValue === 'string') { const displayValue = unescapeBracketLiteral(contextValue) ?? contextValue; if (isFieldTrackingEnabled()) { const tokenized = isAstFieldTrackingEnabled(); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SafeString not directly importable from instance return new handlebarsInstance.SafeString(tokenized ? lmFieldToken(path, displayValue, 'imported') : fieldSpan(path, displayValue, 'imported')); } return displayValue; } if (typeof contextValue === 'number' && isFieldTrackingEnabled()) { const tokenized = isAstFieldTrackingEnabled(); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SafeString not directly importable from instance return new handlebarsInstance.SafeString(tokenized ? lmFieldToken(path, String(contextValue), 'imported') : fieldSpan(path, String(contextValue), 'imported')); } return contextValue; } const rootValue = root ? resolvePath(root, normalizedPath) : undefined; if (rootValue !== undefined) { if (typeof rootValue === 'string') { const displayValue = unescapeBracketLiteral(rootValue) ?? rootValue; if (isFieldTrackingEnabled()) { const tokenized = isAstFieldTrackingEnabled(); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SafeString not directly importable from instance return new handlebarsInstance.SafeString(tokenized ? lmFieldToken(path, displayValue, 'imported') : fieldSpan(path, displayValue, 'imported')); } return displayValue; } if (typeof rootValue === 'number' && isFieldTrackingEnabled()) { const tokenized = isAstFieldTrackingEnabled(); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SafeString not directly importable from instance return new handlebarsInstance.SafeString(tokenized ? lmFieldToken(path, String(rootValue), 'imported') : fieldSpan(path, String(rootValue), 'imported')); } return rootValue; } return `{{${path}}}`; }); variableFallbackHelperRegistered = true; } function preprocessSimpleVariableMixins(content) { let iterationCount = 0; return content.replace(/\{\{([^{}]+)\}\}/g, (match, expression) => { iterationCount += 1; if (iterationCount > MAX_LOOP_ITERATIONS) { throw new ProcessingError(`Maximum template preprocessing iterations exceeded (${MAX_LOOP_ITERATIONS}).`); } const trimmed = expression.trim(); // {{.}} and {{this}} are loop context references - route through __lmDot // so the helper can emit field-tracking spans when enableFieldTracking is on. if (trimmed === '.' || trimmed === 'this') { return '{{__lmDot}}'; } if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('/') || trimmed === 'else' || trimmed.startsWith('!') || trimmed.startsWith('>') || trimmed.startsWith('&') || /\s/.test(trimmed) || trimmed.startsWith('@') || trimmed.startsWith('../') || trimmed.startsWith('./')) { return match; } if (!/^[A-Za-z0-9_.\-*]+$/.test(trimmed)) { return match; } return `{{__lmVar "${normalizeVariablePath(trimmed).replace(/"/g, '\\"')}"}}`; }); } function applyFieldTrackingToOutput(content, metadata) { Object.keys(metadata).forEach(key => { const value = metadata[key]; if (typeof value === 'string' || typeof value === 'number') { const valueStr = String(value); const escapedValueForAttribute = escapeHtmlAttribute(valueStr); if (content.includes(valueStr) || content.includes(escapedValueForAttribute)) { fieldTracker.trackField(key, { value, hasLogic: false }); } } }); return content; } export function processTemplateLoops(content, metadata, context, enableFieldTracking = true, astFieldTracking = false, logicBranchHighlighting = false) { if (hasLegacySyntax(content)) { throw new ProcessingError('Legacy template syntax detected. Please migrate to Handlebars syntax.\n' + ' {{helper(arg1, arg2)}} → {{helper arg1 arg2}}\n' + ' {{price * quantity}} → {{multiply price quantity}}\n' + ' {{"$" + price}} → {{concat "$" price}}'); } return processWithHandlebars(content, metadata, context, enableFieldTracking, astFieldTracking, logicBranchHighlighting); } // Exported for testing - not part of public API export { processWithHandlebars as _processWithHandlebars, preprocessConditionalComparisons as _preprocessConditionalComparisons, preprocessConditionalExpression as _preprocessConditionalExpression, stripOuterParentheses as _stripOuterParentheses, splitAtTopLevelOperator as _splitAtTopLevelOperator, registerVariableFallbackHelper as _registerVariableFallbackHelper, preprocessSimpleVariableMixins as _preprocessSimpleVariableMixins, applyFieldTrackingToOutput as _applyFieldTrackingToOutput, }; export function resolvePath(obj, path) { const keys = normalizeVariablePath(path).split('.'); let current = obj; for (const key of keys) { if (BLOCKED_KEYS.has(key)) { return undefined; } if (current !== null && typeof current === 'object' && !Array.isArray(current) && Object.prototype.hasOwnProperty.call(current, key)) { current = current[key]; } else { return undefined; } } return current; } //# sourceMappingURL=template-loops.js.map