legal-markdown-js
Version:
Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version
527 lines • 20.1 kB
JavaScript
/**
* Remark Plugin for Optional Clause Processing
*
* This plugin processes optional clauses in legal documents using conditional
* syntax. It evaluates conditions based on metadata values and includes or
* excludes content blocks accordingly.
*
* Features:
* - Conditional content inclusion/exclusion
* - Complex boolean expressions
* - Nested conditional blocks
* - Multiple condition operators
* - Variable-based conditions
*
* @example
* ```typescript
* import { unified } from 'unified';
* import remarkParse from 'remark-parse';
* import remarkStringify from 'remark-stringify';
* import { remarkClauses } from './clauses.js';
*
* const processor = unified()
* .use(remarkParse)
* .use(remarkClauses, {
* metadata: { jurisdiction: 'US', hasWarranty: true }
* })
* .use(remarkStringify);
* ```
*
* @module
*/
import { visit } from 'unist-util-visit';
import { fieldTracker } from '../../extensions/tracking/field-tracker.js';
/**
* Remark plugin for processing optional clauses
*
* This plugin identifies and processes conditional blocks in markdown text,
* evaluating conditions against document metadata and including or excluding
* content based on the results.
*
* @param options - Configuration options for clause processing
* @returns Remark plugin transformer function
*/
export const remarkClauses = options => {
const { metadata = {}, debug = false, enableFieldTracking = false } = options;
return (tree) => {
if (debug) {
console.log('[remarkClauses] Processing clauses with metadata:', Object.keys(metadata));
}
// Process all text nodes that might contain conditional blocks
visit(tree, node => {
if (node.type === 'text') {
processTextNode(node, metadata, debug, enableFieldTracking);
}
else if (node.type === 'html' && 'value' in node) {
// Also process HTML nodes that might contain conditional blocks
processHtmlNode(node, metadata, debug, enableFieldTracking);
}
else if (node.type === 'paragraph') {
processParagraphNode(node, metadata, debug, enableFieldTracking);
}
});
};
};
/**
* Process a text node for conditional blocks
*/
function processTextNode(node, metadata, debug, enableFieldTracking) {
const originalText = node.value;
const conditionalBlocks = extractConditionalBlocks(originalText);
if (debug && originalText.includes('{{#')) {
console.log(`[remarkClauses] DEBUG - Text node content: "${originalText}"`);
console.log(`[remarkClauses] DEBUG - Found ${conditionalBlocks.length} conditional blocks`);
}
if (conditionalBlocks.length === 0) {
return;
}
if (debug) {
console.log(`[remarkClauses] Found ${conditionalBlocks.length} conditional blocks in text node`);
}
// Process blocks in reverse order to maintain correct positions
let processedText = originalText;
for (let i = conditionalBlocks.length - 1; i >= 0; i--) {
const block = conditionalBlocks[i];
const result = evaluateConditionalBlock(block, metadata, debug, enableFieldTracking);
// Replace the conditional block with the result
processedText =
processedText.substring(0, block.start) + result + processedText.substring(block.end);
}
node.value = processedText;
// If we processed any loops/conditionals and field tracking is enabled,
// we need to mark this as HTML to prevent escaping
if (conditionalBlocks.length > 0 && processedText.includes('<span class="legal-field')) {
node.type = 'html';
}
}
/**
* Process an HTML node for conditional blocks
*/
function processHtmlNode(node, metadata, debug, enableFieldTracking) {
const originalHtml = node.value;
const conditionalBlocks = extractConditionalBlocks(originalHtml);
if (conditionalBlocks.length === 0) {
return;
}
if (debug) {
console.log(`[remarkClauses] Found ${conditionalBlocks.length} conditional blocks in HTML node`);
}
// Process blocks in reverse order to maintain correct positions
let processedHtml = originalHtml;
for (let i = conditionalBlocks.length - 1; i >= 0; i--) {
const block = conditionalBlocks[i];
const result = evaluateConditionalBlock(block, metadata, debug, enableFieldTracking);
// Replace the conditional block with the result
processedHtml =
processedHtml.substring(0, block.start) + result + processedHtml.substring(block.end);
}
node.value = processedHtml;
}
/**
* Process a paragraph node for conditional blocks
*/
function processParagraphNode(node, metadata, debug, enableFieldTracking) {
// Process each text child in the paragraph
node.children.forEach(child => {
if (child.type === 'text') {
processTextNode(child, metadata, debug, enableFieldTracking);
}
});
}
/**
* Extract conditional blocks from text
*/
function extractConditionalBlocks(text) {
const blocks = [];
// Regex to match conditional blocks: {{#if condition}}content{{/if}}
// Also supports {{#if condition}}content{{else}}alternative{{/if}}
// Note: Captures "if condition" as the full condition including the "if" keyword
// Allows empty conditions for error handling: {{#if}}
const conditionalRegex = /\{\{#(if(?:\s+[^}]*)?)\}\}((?:(?!\{\{#if|\{\{\/if\}\}).)*?)(?:\{\{else\}\}((?:(?!\{\{\/if\}\}).)*?))?\{\{\/if\}\}/gs;
let match;
while ((match = conditionalRegex.exec(text)) !== null) {
const [fullMatch, condition, content, elseContent] = match;
blocks.push({
condition: condition.trim(),
content: content || '',
elseContent: elseContent || undefined,
start: match.index,
end: match.index + fullMatch.length,
});
}
// Also support simpler syntax: {{#variable}}content{{/variable}}
// This handles both loops and simple conditionals (including underscores in names)
const simpleConditionalRegex = /\{\{#([\w._]+)\}\}((?:(?!\{\{\/\1\}\}).)*?)\{\{\/\1\}\}/gs;
while ((match = simpleConditionalRegex.exec(text)) !== null) {
const [fullMatch, variable, content] = match;
blocks.push({
condition: variable.trim(),
content: content || '',
elseContent: undefined,
start: match.index,
end: match.index + fullMatch.length,
});
}
// Also support bracket syntax: [content]{condition}
const bracketConditionalRegex = /\[([^[\]]*(?:\[[^\]]*\][^[\]]*)*?)\]\{([^{}]*?)\}/g;
while ((match = bracketConditionalRegex.exec(text)) !== null) {
const [fullMatch, content, condition] = match;
blocks.push({
condition: condition.trim(),
content: content || '',
elseContent: undefined,
start: match.index,
end: match.index + fullMatch.length,
});
}
// Also support original Legal Markdown bracket syntax: [{{condition}}content]
// Need to handle nested brackets in content by using a more sophisticated pattern
const originalBracketRegex = /\[\{\{([^{}]*?)\}\}([^[\]]*(?:\[[^\]]*\][^[\]]*)*?)\]/g;
while ((match = originalBracketRegex.exec(text)) !== null) {
const [fullMatch, condition, content] = match;
// Skip empty conditions in original bracket syntax
if (!condition.trim()) {
continue;
}
blocks.push({
condition: condition.trim(),
content: content || '',
elseContent: undefined,
start: match.index,
end: match.index + fullMatch.length,
});
}
// Sort blocks by start position to process them in order
return blocks.sort((a, b) => a.start - b.start);
}
/**
* Evaluate a conditional block against metadata
*/
function evaluateConditionalBlock(block, metadata, debug, enableFieldTracking) {
const { condition, content, elseContent } = block;
try {
// Handle {{#if condition}} syntax - always process as conditional
if (condition.startsWith('if ') || condition === 'if') {
const actualCondition = condition === 'if' ? '' : condition.substring(3).trim(); // Remove 'if ' or handle empty 'if'
const result = evaluateCondition(actualCondition, metadata);
if (debug) {
console.log(`[remarkClauses] Conditional "if ${actualCondition}" evaluated to:`, result);
}
if (result) {
return content;
}
else if (elseContent !== undefined) {
return elseContent;
}
else {
return '';
}
}
// For simple {{#variable}} syntax, check if it's an array that should be processed as a loop
const value = getNestedValue(metadata, condition);
if (Array.isArray(value)) {
if (debug) {
console.log(`[remarkClauses] Array condition "${condition}" with ${value.length} items - processing as loop`);
}
// Process as array loop
return processArrayLoop(condition, content, value, metadata, debug, enableFieldTracking);
}
// Otherwise evaluate as a simple boolean condition
const result = evaluateCondition(condition, metadata);
if (debug) {
console.log(`[remarkClauses] Condition "${condition}" evaluated to:`, result);
}
if (result) {
return content;
}
else if (elseContent !== undefined) {
return elseContent;
}
else {
return '';
}
}
catch (error) {
if (debug) {
console.warn(`[remarkClauses] Error evaluating condition "${condition}":`, error);
}
// On error, include the content by default (safe behavior)
return content;
}
}
/**
* Evaluate a condition expression against metadata
*/
function evaluateCondition(condition, metadata) {
// Check for empty condition first - this should be treated as an error
if (!condition.trim()) {
throw new Error('Empty condition provided');
}
// Sanitize the condition to prevent code injection
const sanitizedCondition = sanitizeCondition(condition);
if (!sanitizedCondition) {
return false;
}
// Simple condition evaluation
return evaluateSimpleCondition(sanitizedCondition, metadata);
}
/**
* Sanitize condition to prevent code injection
*/
function sanitizeCondition(condition) {
// Check for HTML tags (dangerous)
if (/<[^>]*>/.test(condition)) {
console.warn('[remarkClauses] Unsafe condition detected, skipping:', condition);
return null;
}
// Check for script-related keywords
const dangerousKeywords = ['script', 'eval', 'function', 'constructor', 'prototype'];
const lowerCondition = condition.toLowerCase();
for (const keyword of dangerousKeywords) {
if (lowerCondition.includes(keyword)) {
console.warn('[remarkClauses] Unsafe condition detected, skipping:', condition);
return null;
}
}
// Allow only safe characters: letters, numbers, underscores, dots, spaces, comparison operators, boolean operators, and @ for context variables
const safePattern = /^[a-zA-Z0-9_.\s==!=<>!&|()'"@]+$/;
if (!safePattern.test(condition)) {
console.warn('[remarkClauses] Unsafe condition detected, skipping:', condition);
return null;
}
return condition;
}
/**
* Evaluate simple condition expressions
*/
function evaluateSimpleCondition(condition, metadata) {
// Handle boolean operators
if (condition.includes('&&') || condition.includes('||')) {
return evaluateBooleanExpression(condition, metadata);
}
// Handle comparison operators
if (condition.includes('==') ||
condition.includes('!=') ||
condition.includes('>') ||
condition.includes('<')) {
return evaluateComparisonExpression(condition, metadata);
}
// Handle simple variable existence check
if (condition.includes('.')) {
return evaluateNestedVariable(condition, metadata);
}
// Simple variable check
const value = metadata[condition.trim()];
return isTruthy(value);
}
/**
* Evaluate boolean expressions (&&, ||)
*/
function evaluateBooleanExpression(condition, metadata) {
// Split by OR operators first (lower precedence)
const orParts = condition.split('||');
for (const orPart of orParts) {
// Split by AND operators (higher precedence)
const andParts = orPart.split('&&');
let allAndTrue = true;
for (const andPart of andParts) {
if (!evaluateSimpleCondition(andPart.trim(), metadata)) {
allAndTrue = false;
break;
}
}
if (allAndTrue) {
return true;
}
}
return false;
}
/**
* Evaluate comparison expressions (==, !=, >, <)
*/
function evaluateComparisonExpression(condition, metadata) {
// Find the comparison operator
let operator = '';
let leftSide = '';
let rightSide = '';
if (condition.includes('==')) {
[leftSide, rightSide] = condition.split('==');
operator = '==';
}
else if (condition.includes('!=')) {
[leftSide, rightSide] = condition.split('!=');
operator = '!=';
}
else if (condition.includes('>=')) {
[leftSide, rightSide] = condition.split('>=');
operator = '>=';
}
else if (condition.includes('<=')) {
[leftSide, rightSide] = condition.split('<=');
operator = '<=';
}
else if (condition.includes('>')) {
[leftSide, rightSide] = condition.split('>');
operator = '>';
}
else if (condition.includes('<')) {
[leftSide, rightSide] = condition.split('<');
operator = '<';
}
if (!operator || !leftSide || !rightSide) {
return false;
}
// Get values
const leftValue = getVariableValue(leftSide.trim(), metadata);
const rightValue = parseValue(rightSide.trim(), metadata);
// Perform comparison
switch (operator) {
case '==':
return leftValue == rightValue;
case '!=':
return leftValue != rightValue;
case '>':
return Number(leftValue) > Number(rightValue);
case '<':
return Number(leftValue) < Number(rightValue);
case '>=':
return Number(leftValue) >= Number(rightValue);
case '<=':
return Number(leftValue) <= Number(rightValue);
default:
return false;
}
}
/**
* Evaluate nested variable access (e.g., client.name)
*/
function evaluateNestedVariable(condition, metadata) {
const value = getNestedValue(metadata, condition.trim());
return isTruthy(value);
}
/**
* Get variable value from metadata
*/
function getVariableValue(variable, metadata) {
if (variable.includes('.')) {
return getNestedValue(metadata, variable);
}
return metadata[variable];
}
/**
* 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);
}
/**
* Parse a value (could be a variable reference or a literal)
*/
function parseValue(value, metadata) {
// Remove quotes if it's a string literal
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
return value.slice(1, -1);
}
// Check if it's a number
if (/^\d+(\.\d+)?$/.test(value)) {
return Number(value);
}
// Check if it's a boolean
if (value === 'true')
return true;
if (value === 'false')
return false;
// Otherwise, treat it as a variable reference
return getVariableValue(value, metadata);
}
/**
* Check if a value is truthy in the context of conditional evaluation
*/
function isTruthy(value) {
if (value === null || value === undefined)
return false;
if (typeof value === 'boolean')
return value;
if (typeof value === 'number')
return value !== 0;
if (typeof value === 'string')
return value.length > 0;
if (Array.isArray(value))
return value.length > 0;
if (typeof value === 'object')
return Object.keys(value).length > 0;
return Boolean(value);
}
/**
* Process an array loop, iterating over each item
*/
function processArrayLoop(variable, content, items, metadata, debug, enableFieldTracking) {
const results = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
// Create enhanced metadata with current item properties
const enhancedMetadata = { ...metadata };
// Add item properties if it's an object
if (item && typeof item === 'object' && !Array.isArray(item)) {
Object.assign(enhancedMetadata, item);
}
// Add special variables
enhancedMetadata['@index'] = i;
enhancedMetadata['@first'] = i === 0;
enhancedMetadata['@last'] = i === items.length - 1;
enhancedMetadata['@total'] = items.length;
// Process the content with enhanced metadata
let processedContent = content;
// First, process any nested conditional blocks (including nested loops)
const nestedBlocks = extractConditionalBlocks(processedContent);
if (nestedBlocks.length > 0) {
// Process blocks in reverse order to maintain correct positions
for (let j = nestedBlocks.length - 1; j >= 0; j--) {
const nestedBlock = nestedBlocks[j];
const nestedResult = evaluateConditionalBlock(nestedBlock, enhancedMetadata, debug, enableFieldTracking);
// Replace the nested block with the result
processedContent =
processedContent.substring(0, nestedBlock.start) +
nestedResult +
processedContent.substring(nestedBlock.end);
}
}
// Then replace field references {{fieldname}}
processedContent = processedContent.replace(/\{\{([^}]+)\}\}/g, (match, field) => {
const trimmedField = field.trim();
// Skip loop/conditional markers
if (trimmedField.startsWith('#') || trimmedField.startsWith('/') || trimmedField === 'else') {
return match;
}
const value = getNestedValue(enhancedMetadata, trimmedField);
if (debug) {
console.log(`[remarkClauses] Loop field "${trimmedField}" resolved to:`, value);
}
// Apply field tracking if enabled
if (enableFieldTracking) {
const isEmptyValue = value === undefined ||
value === null ||
value === '' ||
(typeof value === 'string' && value.trim() === '');
const cssClass = isEmptyValue ? 'legal-field missing-value' : 'legal-field imported-value';
const formattedValue = value !== undefined ? String(value) : match;
// Track the field
fieldTracker.trackField(trimmedField, {
value: value,
originalValue: match,
hasLogic: false,
mixinUsed: 'loop',
});
return `<span class="${cssClass}" data-field="${trimmedField.replace(/"/g, '"')}">${formattedValue}</span>`;
}
return value !== undefined ? String(value) : match;
});
results.push(processedContent);
}
return results.join('');
}
export default remarkClauses;
//# sourceMappingURL=clauses.js.map