legal-markdown-js
Version:
Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version
300 lines • 13.3 kB
JavaScript
/**
* Phase 2: String-Level Transformations
*
* This module contains transformations that MUST run on raw string content
* BEFORE remark AST parsing. These transformations cannot work as remark plugins
* because the AST fragments multi-line patterns across multiple nodes.
*
* Why String Transformations Exist:
* ─────────────────────────────────────
* When remark parses markdown into an AST, it splits content into nodes (paragraphs,
* text, strong, emphasis, etc.). Multi-line patterns get fragmented across these nodes,
* making it impossible for AST plugins to match complete patterns.
*
* Example: Multi-line Optional Clause
* ```markdown
* [l. **Warranties**
*
* The seller provides warranties.]{includeWarranties}
* ```
*
* As String (BEFORE remark):
* ✅ Regex can match: /\[(.*?)\]\{(.*?)\}/s
*
* As AST (AFTER remark parsing):
* ❌ Plugin cannot see complete pattern - it's fragmented across:
* - Paragraph with Text "[", Text "l. ", Strong "Warranties", Text "]{", ...
*
* Transformation Order:
* ────────────────────
* 0. Collect escaped template literals: \{{...}} → __LMESC_N__ placeholder
* (Must happen before everything - prevents \{{}} from being processed)
*
* 1. Normalize field patterns: |field| → {{field}}
* (Must happen before Handlebars compilation)
*
* 2. Process optional clauses: [content]{condition}
* (Multi-line content with markdown formatting)
*
* 3. Process template loops: {{#each}}, {{#if}}, etc.
* (Handlebars blocks that span multiple lines)
*
* @module
* @see docs/architecture/string-transformations.md
* @see Issue #149 - https://github.com/petalo/legal-markdown-js/issues/149
*/
import { processTemplateLoops } from '../../extensions/template-loops.js';
import { logger } from '../../utils/logger.js';
function createEscapePlaceholderPrefix(content) {
let prefix = '__LMESC_';
while (content.includes(prefix)) {
prefix = `_${prefix}`;
}
return prefix;
}
/**
* Apply all string-level transformations in the correct order
*
* This is the main entry point for Phase 2 string transformations.
* Transformations are applied in a specific order to ensure dependencies:
*
* 1. Field pattern normalization (|field| → {{field}})
* 2. Optional clauses processing ([content]{condition})
* 3. Template loops ({{#each}}, {{#if}})
*
* @param content - Raw markdown content (without YAML frontmatter)
* @param options - Transformation options
* @returns Transformed content and updated metadata
*
* @example
* ```typescript
* const result = await applyStringTransformations(
* content,
* {
* metadata: { items: [...], includeWarranty: true },
* debug: true,
* enableFieldTracking: true
* }
* );
*
* // result.content is ready for remark AST parsing
* // result.metadata includes field mappings
* ```
*/
export async function applyStringTransformations(content, options) {
let processedContent = content;
const metadata = { ...options.metadata };
if (options.debug) {
logger.debug('Starting Phase 2 transformations');
logger.debug('Content length', content.length);
}
// ──────────────────────────────────────────────────────────────────────────
// Step 0: Collect Escaped Template Literals
// ──────────────────────────────────────────────────────────────────────────
// \{{...}} is the escape syntax for literal {{...}} in the output.
// Replace each occurrence with a unique placeholder __LMESC_N__ so it
// passes through Handlebars, remark, and remarkTemplateFields untouched.
// The caller restores the placeholders after remark processing.
const escapedTemplates = [];
const escapePlaceholderPrefix = createEscapePlaceholderPrefix(processedContent);
processedContent = processedContent.replace(/\\\{\{([^{}]+)\}\}/g, (_, inner) => {
const idx = escapedTemplates.length;
escapedTemplates.push(`{{${inner}}}`);
return `${escapePlaceholderPrefix}${idx}__`;
});
if (escapedTemplates.length > 0) {
logger.debug(`[String Transformations] Collected ${escapedTemplates.length} escaped template literal(s)`);
}
// ──────────────────────────────────────────────────────────────────────────
// Step 1: Normalize Field Patterns
// ──────────────────────────────────────────────────────────────────────────
// Convert custom field patterns (like |field|, <<field>>) to standard {{field}} format
// This must run BEFORE template loops so Handlebars can process all fields
const { content: normalized, mappings } = normalizeFieldPatterns(processedContent, options.fieldPatterns || [], options.debug);
processedContent = normalized;
metadata['_field_mappings'] = mappings;
if (options.debug && mappings.size > 0) {
logger.debug(`Normalized ${mappings.size} custom field patterns`);
}
// ──────────────────────────────────────────────────────────────────────────
// Step 2: Process Optional Clauses
// ──────────────────────────────────────────────────────────────────────────
// Handle [content]{condition} patterns before remark parsing
// This allows multi-line content with markdown formatting inside clauses
if (!options.noClauses) {
if (options.debug) {
logger.debug('Processing optional clauses');
}
processedContent = preprocessOptionalClauses(processedContent, metadata, options.debug || false);
if (options.debug) {
logger.debug('Optional clauses processed');
}
}
// ──────────────────────────────────────────────────────────────────────────
// Step 3: Process Template Loops
// ──────────────────────────────────────────────────────────────────────────
// Handle Handlebars {{#each}}, {{#if}}, etc.
// This must run AFTER field normalization so all fields use {{}} syntax
if (options.debug) {
logger.debug('Processing template loops (Handlebars)');
}
const astFieldTracking = options.astFieldTracking ?? false;
const logicBranchHighlighting = options.logicBranchHighlighting ?? false;
processedContent = processTemplateLoops(processedContent, metadata, undefined, // context (for nested loops)
options.enableFieldTracking || false, astFieldTracking, logicBranchHighlighting);
if (options.debug) {
logger.debug('Template loops processed');
logger.debug('Final content length', processedContent.length);
}
return {
content: processedContent,
metadata,
escapedTemplates,
};
}
/**
* Normalize custom field patterns to standard {{field}} format
*
* This function converts custom field patterns (e.g., <<field>>, |field|)
* into the standard {{field}} format so all fields use consistent syntax
* before Handlebars compilation.
*
* @param content - The content to normalize
* @param fieldPatterns - Array of regex patterns to normalize (e.g., ['<<(.+?)>>', '|(.+?)|'])
* @param debug - Enable debug logging
* @returns Normalized content and field mappings
*
* @example
* ```typescript
* const { content, mappings } = normalizeFieldPatterns(
* "Template: {{name1}} and custom: <<name2>>",
* ['<<(.+?)>>'],
* true
* );
* // content: "Template: {{name1}} and custom: {{name2}}"
* // mappings: Map { "{{name2}}" => "<<name2>>" }
* ```
*/
function normalizeFieldPatterns(content, fieldPatterns = [], debug = false) {
let processedContent = content;
const fieldMappings = new Map();
// Process each custom field pattern
if (fieldPatterns && fieldPatterns.length > 0) {
for (const pattern of fieldPatterns) {
// Skip the default pattern - it's already in the correct format
if (pattern === '{{(.+?)}}') {
continue;
}
// Create regex from pattern
const regex = new RegExp(pattern, 'g');
// Replace custom patterns with standard {{field}} format
processedContent = processedContent.replace(regex, (match, fieldName) => {
const normalizedPattern = `{{${fieldName}}}`;
fieldMappings.set(normalizedPattern, match);
return normalizedPattern;
});
}
}
if (debug && fieldMappings.size > 0) {
logger.debug('Normalized patterns', Array.from(fieldMappings.entries()));
}
return {
content: processedContent,
mappings: fieldMappings,
};
}
/**
* Pre-process optional clauses [content]{condition} before remark parsing
*
* This function processes optional clauses BEFORE the content is parsed by remark,
* which allows clauses with multi-line content and markdown formatting to work correctly.
* Without this pre-processing, remark would split the clause across multiple AST nodes,
* making it impossible for an AST plugin to find and process them.
*
* Why This Cannot Be a Remark Plugin:
* ───────────────────────────────────
* Multi-line optional clauses with markdown formatting get fragmented in the AST:
*
* Input String:
* ```
* [l. **Warranties**
*
* The seller provides warranties.]{includeWarranties}
* ```
*
* After Remark Parsing (AST):
* ```
* Paragraph {
* children: [
* Text("["),
* Text("l. "),
* Strong("Warranties"),
* Text("]{"),
* Text("includeWarranties"),
* Text("}")
* ]
* }
* Paragraph {
* children: [
* Text("The seller provides warranties.")
* ]
* }
* ```
*
* The pattern is split across multiple nodes and paragraphs, making it impossible
* for a plugin to match the complete pattern.
*
* @param content - The markdown content with optional clauses
* @param metadata - Document metadata for evaluating conditions
* @param debug - Enable debug logging
* @returns Content with optional clauses processed
*
* @example
* ```typescript
* const content = "[Optional content]{showThis}";
* const metadata = { showThis: true };
* const result = preprocessOptionalClauses(content, metadata);
* // result: "Optional content"
* ```
*/
function preprocessOptionalClauses(content, metadata, debug = false) {
// Regex to match optional clauses: [content]{condition}
// Uses dotall mode (s flag) to match across newlines
// eslint-disable-next-line no-useless-escape
const clauseRegex = /\[([^\[\]]*(?:\n[^\[\]]*)*?)\]\{([^{}]+?)\}/gs;
let processedContent = content;
const matches = [];
// Collect all matches first (to avoid mutation during iteration)
let match;
while ((match = clauseRegex.exec(content)) !== null) {
matches.push({
full: match[0],
content: match[1],
condition: match[2].trim(),
index: match.index,
});
}
if (debug && matches.length > 0) {
logger.debug(`Found ${matches.length} optional clauses`);
}
// Process matches in reverse order to maintain correct positions
for (let i = matches.length - 1; i >= 0; i--) {
const { full, content: clauseContent, condition, index } = matches[i];
// Evaluate the condition
const conditionValue = metadata[condition];
const shouldInclude = Boolean(conditionValue);
if (debug) {
logger.debug(`Condition "${condition}" = ${conditionValue} (include: ${shouldInclude})`);
}
// Replace the clause with its content if true, or remove it if false
const replacement = shouldInclude ? clauseContent : '';
processedContent =
processedContent.substring(0, index) +
replacement +
processedContent.substring(index + full.length);
}
return processedContent;
}
// Exported for testing - not part of public API
export { normalizeFieldPatterns as _normalizeFieldPatterns, preprocessOptionalClauses as _preprocessOptionalClauses, };
//# sourceMappingURL=string-transformations.js.map