legal-markdown-js
Version:
Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version
999 lines • 41.8 kB
JavaScript
/**
* 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 date "YYYY-MM-DD"}}
* - Parenthesized helper syntax: {{formatDate(@today, "YYYY-MM-DD")}}
* - Subexpressions: {{formatDate (addYears today 2) "YYYY-MM-DD"}}
* - Conditional expressions: {{active ? "Active" : "Inactive"}}
* - Field tracking integration for highlighting
*
* Syntax Support (Issue #142):
* - **Space-separated syntax** (standard): {{helper arg1 arg2}}
* - **Parenthesized syntax**: {{helper(arg1, arg2)}}
*
* 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';
*
* // Handlebars syntax (current)
* const processor = unified()
* .use(remarkParse)
* .use(remarkTemplateFields, {
* metadata: {
* client_name: 'ACME Corp',
* date: new Date('2025-01-15')
* }
* });
*
* const result = await processor.process('Hello {{client_name}}! Date: {{formatDate date "MMMM Do, YYYY"}}');
* // Output: Hello ACME Corp! Date: January 15th, 2025
* ```
*
* @module
*/
import { visit } from 'unist-util-visit';
import { logger } from '../../utils/logger.js';
import { fieldTracker } from '../../extensions/tracking/field-tracker.js';
import { fieldSpan } from '../../extensions/tracking/field-span.js';
import { extensionHelpers as helpers } from '../../extensions/helpers/index.js';
import { detectBracketValues, unescapeBracketLiteral } from '../../extensions/ast-mixin-processor.js';
/**
* Default template field pattern
*/
const DEFAULT_FIELD_PATTERN = /\{\{\s*([^}]+)\s*\}\}/g;
const GENERATED_TRACKING_MARKER = 'data-lm-generated="1"';
/**
* Check if a position is inside a loop or conditional block
*/
function isInsideLoopOrConditional(text, position) {
// Match block helpers with optional arguments:
// {{#each items}}...{{/each}}, {{#if cond}}...{{/if}}, {{#unless cond}}...{{/unless}}
// and custom blocks like {{#line_items}}...{{/line_items}}.
const blockPattern = /\{\{#([\w_]+)(?:\s+[^}]*)?\}\}[\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;
}
}
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;
}
// Unescape underscores that were escaped by escapeTemplateUnderscores()
// in src/extensions/remark/legal-markdown-processor.ts
//
// Issue #139: Field names with underscores (e.g., "counterparty.legal_name")
// are escaped to "counterparty.legal\_name" before markdown parsing to prevent
// the underscore from being interpreted as an emphasis delimiter.
//
// We need to unescape them here so the field path matches the metadata keys.
// Example: "counterparty.legal\_name" → "counterparty.legal_name"
//
// @see https://github.com/petalo/legal-markdown-js/issues/139
// @see src/extensions/remark/legal-markdown-processor.ts - escapeTemplateUnderscores()
const unescapedExpression = trimmedExpression.replace(/\\_/g, '_');
fields.push({
pattern: fullMatch,
fieldName: unescapedExpression,
expression: unescapedExpression,
startIndex: match.index,
endIndex: match.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 todayRaw = metadata['@today'];
const today = todayRaw ? new Date(todayRaw) : 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',
};
}
// ============================================================================
// DUAL SYNTAX SUPPORT
// ============================================================================
// Both syntaxes are supported:
// - Space-separated: {{helper arg1 arg2}}
// - Parenthesized: {{helper(arg1, arg2)}}
// ============================================================================
// Check for Handlebars helper pattern (current standard syntax)
// Format: helperName arg1 arg2 arg3
// Example: formatDate date "MMMM Do, YYYY"
const handlebarsMatch = fieldName.match(/^(\w+)\s+(.+)$/);
if (handlebarsMatch) {
const [, helperName, argsString] = handlebarsMatch;
const helper = helpers[helperName];
if (helper && typeof helper === 'function') {
try {
// Parse Handlebars-style space-separated arguments
const args = parseHandlebarsArguments(argsString, metadata);
// Call the helper function
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- external helper function with unknown signature
const result = helper(...args);
return {
value: result,
hasLogic: true,
mixinType: 'helper',
};
}
catch (error) {
logger.warn(`Error calling Handlebars helper '${helperName}':`, error instanceof Error ? error.message : String(error));
return {
value: undefined,
hasLogic: true,
mixinType: 'helper',
};
}
}
// If helper not found, fall through to check legacy syntax
}
// ============================================================================
// PARENTHESIZED HELPER SYNTAX
// ============================================================================
// Check for parenthesized helper function pattern: helperName(arg1, arg2)
const helperMatch = fieldName.match(/^(\w+)\((.*)?\)$/);
if (helperMatch) {
const [, helperName, argsString] = helperMatch;
const helper = helpers[helperName];
if (helper && typeof helper === 'function') {
try {
// Parse arguments (legacy comma-separated)
const args = parseHelperArguments(argsString || '', metadata);
// Call the helper function
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- external helper function with unknown signature
const result = helper(...args);
return {
value: result,
hasLogic: true,
mixinType: 'helper',
};
}
catch (error) {
logger.warn(`Error calling helper '${helperName}':`, error instanceof Error ? error.message : String(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, '');
// Check if the condition variable itself is empty (for proper field tracking)
const isConditionEmpty = isEmptyValue(conditionResult);
return {
value: cleanResult,
hasLogic: true,
mixinType: 'conditional',
isEmptyCondition: isConditionEmpty, // Track if the original field was empty
};
}
// 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',
};
}
const normalizedValue = typeof value === 'string' ? (unescapeBracketLiteral(value) ?? value) : value;
return {
value: normalizedValue,
hasLogic: false,
mixinType: 'variable',
};
}
/**
* Resolve nested value from metadata using dot notation
*/
function resolveNestedValue(metadata, path) {
const keys = path.split('.').map(key => key.replace(/^\*/g, '_').replace(/\*$/g, '_'));
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;
if (typeof current !== 'object' || Array.isArray(current)) {
return undefined;
}
current = current[arrayName];
if (Array.isArray(current)) {
current = current[parseInt(index, 10)];
}
else {
return undefined;
}
}
else if (Array.isArray(current)) {
// Numeric key in dot notation: parties.1.name -> parties[1].name
const arrayIndex = parseInt(key, 10);
if (!isNaN(arrayIndex) && String(arrayIndex) === key) {
current = current[arrayIndex];
}
else {
return undefined; // Non-numeric key on an array
}
}
else {
if (typeof current !== 'object' || current instanceof Date) {
return undefined;
}
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() === ''));
}
/**
* 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) {
// Priority: empty > logic > filled
// This ensures empty fields are marked as missing even if they have conditional logic
const status = isEmptyField ? 'empty' : hasLogic ? 'logic' : 'filled';
const kind = status === 'empty' ? 'missing' : status === 'logic' ? 'highlight' : 'imported';
return fieldSpan(fieldName, formattedValue, kind);
}
return formattedValue;
}
/**
* Check if text contains existing field tracking spans
*/
function hasExistingFieldSpans(text) {
return text.includes('class="legal-field') && text.includes('data-field="');
}
function isTrackedFieldSpanOpenTag(tag) {
return (/<span\b[^>]*class=(?:"[^"]*\blegal-field\b[^"]*"|'[^']*\blegal-field\b[^']*')[^>]*data-field=(?:"[^"]*"|'[^']*')[^>]*>/i.test(tag) && !tag.includes(GENERATED_TRACKING_MARKER));
}
function stripGeneratedTrackingMarkers(text) {
return text.replace(/\sdata-lm-generated="1"/g, '');
}
/**
* Masks {{...}} patterns inside existing legal-field spans while preserving
* string length so extracted indices stay aligned with the original text.
*/
function maskTemplateFieldsInsideTrackedSpans(text) {
const spanTagRegex = /<\/?span\b[^>]*>/gi;
const templateRegex = /\{\{[^}]+\}\}/g;
const spanStack = [];
let output = '';
let cursor = 0;
let match;
const maskSegment = (segment, insideTrackedSpan) => {
if (!insideTrackedSpan)
return segment;
return segment.replace(templateRegex, found => ' '.repeat(found.length));
};
while ((match = spanTagRegex.exec(text)) !== null) {
const tag = match[0];
const start = match.index;
const insideTrackedSpan = spanStack.includes(true);
output += maskSegment(text.slice(cursor, start), insideTrackedSpan);
output += tag;
if (tag.startsWith('</')) {
if (spanStack.length > 0) {
spanStack.pop();
}
}
else {
spanStack.push(isTrackedFieldSpanOpenTag(tag));
}
cursor = start + tag.length;
}
output += maskSegment(text.slice(cursor), spanStack.includes(true));
return output;
}
/**
* Check if a text node is inside existing field tracking spans by examining sibling HTML nodes
*/
function isInsideFieldTrackingSpan(node, parent) {
if (!parent ||
typeof parent !== 'object' ||
parent['type'] !== 'paragraph' ||
!Array.isArray(parent['children'])) {
return false;
}
const children = parent['children'];
const nodeIndex = children.indexOf(node);
if (nodeIndex === -1) {
return false;
}
const countFieldSpanOpenTags = (html) => {
const matches = html.match(/<span\b[^>]*>/g) ?? [];
return matches.filter(isTrackedFieldSpanOpenTag).length;
};
const countSpanCloseTags = (html) => {
const matches = html.match(/<\/span>/g);
return matches ? matches.length : 0;
};
// Track net field-span depth before this node.
// A node is "inside" only if there is an unmatched opening field span before it.
let depthBeforeNode = 0;
for (let i = 0; i < nodeIndex; i++) {
const sibling = children[i];
if (sibling['type'] !== 'html' || typeof sibling['value'] !== 'string')
continue;
const value = sibling['value'];
depthBeforeNode += countFieldSpanOpenTags(value);
depthBeforeNode -= countSpanCloseTags(value);
if (depthBeforeNode < 0)
depthBeforeNode = 0;
}
if (depthBeforeNode <= 0) {
return false;
}
// Confirm there is a closing span after this node so we only skip nodes that are
// actually enclosed by a bounded span region.
let remainingDepth = depthBeforeNode;
for (let i = nodeIndex + 1; i < children.length; i++) {
const sibling = children[i];
if (sibling['type'] !== 'html' || typeof sibling['value'] !== 'string')
continue;
const value = sibling['value'];
remainingDepth += countFieldSpanOpenTags(value);
remainingDepth -= countSpanCloseTags(value);
if (remainingDepth <= 0) {
return true;
}
}
return false;
}
/**
* 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
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- external helper function with unknown signature
const nestedResult = helper(...nestedArgs);
args.push(nestedResult);
continue;
}
catch (error) {
logger.warn(`Error calling nested helper '${helperName}':`, error instanceof Error ? error.message : String(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 todayRaw = metadata['@today'];
const todayValue = todayRaw ? new Date(todayRaw) : new Date();
args.push(todayValue);
continue;
}
// Metadata reference (including other variables)
const value = resolveNestedValue(metadata, trimmed);
args.push(value);
}
return args;
}
/**
* Parse Handlebars-style space-separated arguments from a helper function call
*
* Parses Handlebars helper arguments (space-separated instead of comma-separated),
* resolving metadata references, handling string literals, numbers, booleans,
* subexpressions, and nested helper function calls.
*
* @param argsString - Raw arguments string from Handlebars helper call
* @param metadata - Metadata object for resolving references
* @returns Array of parsed argument values
*
* @example
* ```typescript
* parseHandlebarsArguments('date "MMMM Do, YYYY"', { date: new Date() })
* // Returns: [Date, 'MMMM Do, YYYY']
*
* parseHandlebarsArguments('(addYears today 5) "YYYY-MM-DD"', { today: new Date() })
* // Returns: [Date (5 years added), 'YYYY-MM-DD']
*
* parseHandlebarsArguments('amount "USD"', { amount: 1500 })
* // Returns: [1500, 'USD']
*
* parseHandlebarsArguments('price quantity', { price: 10, quantity: 5 })
* // Returns: [10, 5]
* ```
*/
function parseHandlebarsArguments(argsString, metadata) {
if (!argsString.trim()) {
return [];
}
const args = [];
let i = 0;
while (i < argsString.length) {
// Skip whitespace
while (i < argsString.length && /\s/.test(argsString[i])) {
i++;
}
if (i >= argsString.length) {
break;
}
// Handle string literals (double or single quotes)
if (argsString[i] === '"' || argsString[i] === "'") {
const quoteChar = argsString[i];
i++; // Skip opening quote
let stringValue = '';
while (i < argsString.length && argsString[i] !== quoteChar) {
// Handle escaped quotes
if (argsString[i] === '\\' &&
i + 1 < argsString.length &&
argsString[i + 1] === quoteChar) {
stringValue += quoteChar;
i += 2;
}
else {
stringValue += argsString[i];
i++;
}
}
if (i < argsString.length) {
i++; // Skip closing quote
}
args.push(stringValue);
continue;
}
// Handle subexpressions (parentheses): (helperName arg1 arg2)
if (argsString[i] === '(') {
i++; // Skip opening paren
let parenDepth = 1;
let subexpr = '';
while (i < argsString.length && parenDepth > 0) {
if (argsString[i] === '(') {
parenDepth++;
}
else if (argsString[i] === ')') {
parenDepth--;
if (parenDepth === 0) {
break;
}
}
subexpr += argsString[i];
i++;
}
if (i < argsString.length) {
i++; // Skip closing paren
}
// Parse the subexpression
const subexprMatch = subexpr.trim().match(/^(\w+)\s+(.+)$/);
if (subexprMatch) {
const [, helperName, nestedArgsString] = subexprMatch;
const helper = helpers[helperName];
if (helper && typeof helper === 'function') {
try {
// Recursively parse nested arguments
const nestedArgs = parseHandlebarsArguments(nestedArgsString, metadata);
// Call the nested helper function
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- external helper function with unknown signature
const nestedResult = helper(...nestedArgs);
args.push(nestedResult);
continue;
}
catch (error) {
logger.warn(`Error calling Handlebars subexpression helper '${helperName}':`, error instanceof Error ? error.message : String(error));
args.push(undefined);
continue;
}
}
}
// If we couldn't parse the subexpression as a helper, treat it as undefined
args.push(undefined);
continue;
}
// Handle other tokens (numbers, booleans, variables)
let token = '';
while (i < argsString.length && !/\s/.test(argsString[i]) && argsString[i] !== ')') {
token += argsString[i];
i++;
}
if (!token) {
continue;
}
// Number literal
if (/^-?\d+(\.\d+)?$/.test(token)) {
args.push(parseFloat(token));
continue;
}
// Boolean literal
if (token === 'true') {
args.push(true);
continue;
}
if (token === 'false') {
args.push(false);
continue;
}
// Null/undefined literal
if (token === 'null') {
args.push(null);
continue;
}
if (token === 'undefined') {
args.push(undefined);
continue;
}
// Handle @today special case
if (token === '@today' || token.startsWith('@today[')) {
// Check if @today is defined in metadata first, otherwise use current date
const todayRaw = metadata['@today'];
const todayValue = todayRaw ? new Date(todayRaw) : new Date();
args.push(todayValue);
continue;
}
// Handle 'today' variable (alias for @today in Handlebars context)
if (token === 'today') {
const todayRaw2 = metadata['today'] || metadata['@today'];
const todayValue = todayRaw2 ? new Date(todayRaw2) : new Date();
args.push(todayValue);
continue;
}
// Metadata reference
const value = resolveNestedValue(metadata, token);
args.push(value);
}
return args;
}
/**
* Process template fields in text nodes
*/
function processTemplateFieldsInAST(root, metadata, fieldPatterns, enableFieldTracking = false, astFieldTracking = false, debug = false) {
function toFieldMappingsMap(value) {
if (value instanceof Map) {
return value;
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
return new Map(Object.entries(value)
.filter(([, v]) => typeof v === 'string')
.map(([k, v]) => [k, v]));
}
return new Map();
}
// Get field mappings from metadata if available
const fieldMappings = toFieldMappingsMap(metadata['_field_mappings']);
// Process text, HTML, code, and inlineCode nodes
visit(root, (node, _index, parent) => {
void _index;
// Process text, HTML, code, and inlineCode nodes
if ((node.type === 'text' ||
node.type === 'html' ||
node.type === 'code' ||
node.type === 'inlineCode') &&
'value' in node &&
typeof node.value === 'string') {
const mutableNode = node;
const originalValue = node.value;
let workingValue = originalValue;
let transformedByAstTokens = false;
if (astFieldTracking) {
let transformedValue = workingValue;
let transformed = false;
transformedValue = transformedValue.replace(/<lm-field\b([^>]*)>/g, (_full, attrs) => {
const fieldMatch = attrs.match(/data-field=(?:"([^"]*)"|'([^']*)')/);
const kindMatch = attrs.match(/data-kind=(?:"([^"]*)"|'([^']*)')/);
const fieldName = (fieldMatch?.[1] || fieldMatch?.[2] || '').replace(/"/g, '"');
const kind = kindMatch?.[1] || kindMatch?.[2] || 'imported';
const cls = kind === 'missing'
? 'legal-field missing-value'
: kind === 'highlight' || kind === 'crossref'
? 'legal-field highlight'
: 'legal-field imported-value';
transformed = true;
return `<span class="${cls}" data-field="${fieldName}" ${GENERATED_TRACKING_MARKER}>`;
});
transformedValue = transformedValue.replace(/<\/lm-field>/g, () => {
transformed = true;
return '</span>';
});
transformedValue = transformedValue.replace(/<lm-logic-start\b([^>]*)>/g, (_full, attrs) => {
const fieldMatch = attrs.match(/data-field=(?:"([^"]*)"|'([^']*)')/);
const helperMatch = attrs.match(/data-logic-helper=(?:"([^"]*)"|'([^']*)')/);
const resultMatch = attrs.match(/data-logic-result=(?:"([^"]*)"|'([^']*)')/);
const fieldName = fieldMatch?.[1] || fieldMatch?.[2] || '';
const helperName = helperMatch?.[1] || helperMatch?.[2] || 'if';
const logicResult = resultMatch?.[1] || resultMatch?.[2] || 'false';
transformed = true;
return `<span class="legal-field highlight" data-field="${fieldName}" data-logic-helper="${helperName}" data-logic-result="${logicResult}" ${GENERATED_TRACKING_MARKER}>`;
});
transformedValue = transformedValue.replace(/<\/lm-logic-start>/g, () => {
transformed = true;
return '';
});
transformedValue = transformedValue.replace(/<lm-logic-end>/g, () => {
transformed = true;
return '</span>';
});
transformedValue = transformedValue.replace(/<\/lm-logic-end>/g, () => {
transformed = true;
return '';
});
if (transformed) {
transformedByAstTokens = true;
workingValue = transformedValue;
if (enableFieldTracking &&
node.type === 'text' &&
workingValue.includes('<span class="legal-field')) {
mutableNode.type = 'html';
}
if (!/\{\{[^}]+\}\}/.test(workingValue)) {
mutableNode.value = stripGeneratedTrackingMarkers(workingValue);
return;
}
}
}
// Skip processing if this HTML already contains field tracking spans AND
// there are no remaining {{...}} template fields to resolve.
// Nodes converted by remarkDates may have spans for @today but still
// contain unprocessed {{variable}} patterns that must not be skipped.
if (!astFieldTracking && node.type === 'html' && hasExistingFieldSpans(workingValue)) {
const hasUnresolvedFields = /\{\{[^}]+\}\}/.test(workingValue);
if (!hasUnresolvedFields) {
if (debug) {
logger.debug(`Skipping HTML node with existing field spans: "${workingValue.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) {
logger.debug(`Skipping text node inside existing field spans: "${originalValue}"`);
}
return;
}
// Normalize escaped underscores from remark-parse (\_ back to _)
// This happens when underscores are in bold/italic contexts
const normalizedValue = workingValue.replace(/\\_/g, '_');
const valueForFieldExtraction = node.type === 'html' && hasExistingFieldSpans(normalizedValue)
? maskTemplateFieldsInsideTrackedSpans(normalizedValue)
: normalizedValue;
const templateFields = extractTemplateFields(valueForFieldExtraction, fieldPatterns);
if (templateFields.length === 0) {
if (transformedByAstTokens) {
mutableNode.value = stripGeneratedTrackingMarkers(workingValue);
}
return; // No template fields found
}
if (debug) {
logger.debug(`Found ${templateFields.length} template fields in ${node.type}: "${normalizedValue}"`);
}
let processedText = normalizedValue;
// Process fields in reverse order to maintain string indices
for (const field of templateFields) {
const resolved = resolveFieldValue(field.fieldName, metadata);
const { value, hasLogic, mixinType } = resolved;
// For conditionals, check if the original condition was empty
const isEmptyCondition = resolved.isEmptyCondition;
const isEmptyField = isEmptyCondition !== undefined ? isEmptyCondition : 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) {
logger.debug(`Replaced ${field.pattern} with "${formattedValue}" (original: ${originalPattern})`);
}
}
// Update the node value
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mutating mdast node value in-place
node.value = stripGeneratedTrackingMarkers(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')) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mutating mdast node type in-place
node.type = 'html';
}
}
});
}
/**
* Remark plugin for processing template fields in Legal Markdown documents
*/
const remarkTemplateFields = options => {
const { metadata, debug = false, fieldPatterns = [], enableFieldTracking = false, astFieldTracking, } = options;
return (tree) => {
if (debug) {
logger.debug('Processing template fields with remark plugin');
logger.debug('Metadata:', metadata);
logger.debug('Field patterns:', fieldPatterns);
if (enableFieldTracking) {
logger.debug('Field tracking highlighting enabled');
}
logger.debug('Code block processing enabled by default');
}
// Process all template fields in the AST with optional field tracking
processTemplateFieldsInAST(tree, metadata, fieldPatterns, enableFieldTracking, astFieldTracking ?? false, debug);
if (debug) {
logger.debug('Template field processing completed');
}
};
};
export default remarkTemplateFields;
// Exported for testing - not part of public API
export { isInsideLoopOrConditional as _isInsideLoopOrConditional, extractTemplateFields as _extractTemplateFields, resolveFieldValue as _resolveFieldValue, resolveNestedValue as _resolveNestedValue, formatFieldValue as _formatFieldValue, isInsideFieldTrackingSpan as _isInsideFieldTrackingSpan, smartSplitArguments as _smartSplitArguments, parseHelperArguments as _parseHelperArguments, parseHandlebarsArguments as _parseHandlebarsArguments, };
//# sourceMappingURL=template-fields.js.map