UNPKG

legal-markdown-js

Version:

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

328 lines 13.3 kB
/** * Cross-Reference Processing Module for Legal Markdown Documents * * This module provides functionality to process internal cross-references in Legal Markdown * documents, allowing sections to reference other sections by their keys. * Based on the original Ruby Legal Markdown specification. * * Features: * - Internal cross-reference syntax: |reference_key| * - Automatic section numbering and reference resolution * - Reference capture from headers with |key| syntax * - Section reference replacement throughout the document * - Compatible with legal document numbering (l., ll., lll.) * * @example * ```typescript * import { processCrossReferences } from './reference-processor.js'; * * const content = ` * l. **Definitions** |definitions| * * Terms defined in |definitions| apply throughout this agreement. * * l. **Payment Terms** |payment| * * As outlined in |payment|, payment is due within 30 days. * Reference to |definitions| for term meanings. * `; * * const processed = processCrossReferences(content, {}); * console.log(processed); * // Output: * // Article 1. **Definitions** * // * // Terms defined in Article 1 apply throughout this agreement. * // * // Article 2. **Payment Terms** * // * // As outlined in Article 2, payment is due within 30 days. * // Reference to Article 1 for term meanings. * ``` */ import { getRomanNumeral, getAlphaLabel } from '../../utils/number-utilities.js'; import { fieldTracker } from '../../extensions/tracking/field-tracker.js'; /** * Processes internal cross-references in a Legal Markdown document * * This function implements a hybrid approach: * 1. First tries to resolve |key| as internal section references (Ruby spec) * 2. Falls back to metadata values for backward compatibility * * @param {string} content - The document content containing cross-references * @param {Record<string, any>} metadata - Document metadata (used for level formatting and fallback) * @returns {string} Processed content with internal references resolved * @example * ```typescript * const content = ` * l. **Contract Terms** |terms| * * As specified in |terms|, this agreement is binding. * Reference to |client_name| from metadata. * `; * * const metadata = { client_name: "ACME Corp" }; * const result = processCrossReferences(content, metadata); * // |terms| -> "Article 1." (internal reference) * // |client_name| -> "ACME Corp" (metadata fallback) * ``` */ export function processCrossReferences(content, metadata) { // First, extract all cross-reference definitions from headers const crossReferences = extractCrossReferences(content, metadata); // Store cross-references metadata in protected field metadata['_cross_references'] = crossReferences.map(ref => ({ key: ref.key, sectionNumber: ref.sectionNumber, sectionText: ref.sectionText, })); // Ensure the field is always set, even if no cross-references exist if (crossReferences.length === 0) { metadata['_cross_references'] = []; } // Then replace all cross-reference usage with section numbers or metadata return replaceCrossReferences(content, crossReferences, metadata); } /** * Extracts cross-reference definitions from headers in the document * * Scans the document for headers containing |key| syntax and captures * their section numbers after header processing. This creates a mapping * of reference keys to their section identifiers. * * @private * @param {string} content - Document content to scan for cross-references * @param {Record<string, any>} metadata - Metadata for level formatting * @returns {CrossReference[]} Array of cross-reference mappings */ function extractCrossReferences(content, metadata) { const crossReferences = []; const lines = content.split('\n'); // Track section counters for each level (up to 6 levels) const sectionCounters = { level1: 0, level2: 0, level3: 0, level4: 0, level5: 0, level6: 0 }; // Get level formats from metadata const levelFormats = { level1: metadata['level-one'] || 'Article %n.', level2: metadata['level-two'] || 'Section %n.', level3: metadata['level-three'] || '(%n)', level4: metadata['level-four'] || '(%n)', level5: metadata['level-five'] || '(%n%c)', level6: metadata['level-six'] || 'Annex %r -', }; for (const line of lines) { const trimmedLine = line.trim(); // Check for header lines with cross-reference keys // Support both traditional (l., ll., lll.) and alternative (l1., l2., l3.) formats const traditionalMatch = trimmedLine.match(/^(l+)\. (.+) \|([\w.-]+)\|$/); const alternativeMatch = trimmedLine.match(/^l(\d+)\. (.+) \|([\w.-]+)\|$/); const headerMatch = traditionalMatch || alternativeMatch; if (headerMatch) { let level; let headerText; let key; if (traditionalMatch) { const [, levelMarker, text, keyName] = traditionalMatch; level = levelMarker.length; headerText = text; key = keyName; } else { const [, levelNumber, text, keyName] = alternativeMatch; level = parseInt(levelNumber, 10); headerText = text; key = keyName; } // Update section counters if (level === 1) { sectionCounters.level1++; sectionCounters.level2 = 0; sectionCounters.level3 = 0; sectionCounters.level4 = 0; sectionCounters.level5 = 0; sectionCounters.level6 = 0; } else if (level === 2) { sectionCounters.level2++; sectionCounters.level3 = 0; sectionCounters.level4 = 0; sectionCounters.level5 = 0; sectionCounters.level6 = 0; } else if (level === 3) { sectionCounters.level3++; sectionCounters.level4 = 0; sectionCounters.level5 = 0; sectionCounters.level6 = 0; } else if (level === 4) { sectionCounters.level4++; sectionCounters.level5 = 0; sectionCounters.level6 = 0; } else if (level === 5) { sectionCounters.level5++; sectionCounters.level6 = 0; } else if (level === 6) { sectionCounters.level6++; } // Generate section number based on level let sectionNumber; let sectionText; if (level === 1) { sectionNumber = levelFormats.level1.replace(/%n/g, sectionCounters.level1.toString()); sectionText = `${sectionNumber} ${headerText}`; } else if (level === 2) { sectionNumber = levelFormats.level2.replace(/%n/g, sectionCounters.level2.toString()); sectionText = `${sectionNumber} ${headerText}`; } else if (level === 3) { sectionNumber = levelFormats.level3.replace(/%n/g, sectionCounters.level3.toString()); sectionText = `${sectionNumber} ${headerText}`; } else if (level === 4) { sectionNumber = levelFormats.level4 .replace(/%n/g, sectionCounters.level4.toString()) .replace(/%c/g, getAlphaLabel(sectionCounters.level4)) .replace(/%r/g, getRomanNumeral(sectionCounters.level4, true)) .replace(/%R/g, getRomanNumeral(sectionCounters.level4, false)); sectionText = `${sectionNumber} ${headerText}`; } else if (level === 5) { sectionNumber = levelFormats.level5 .replace(/%n/g, sectionCounters.level5.toString()) .replace(/%c/g, getAlphaLabel(sectionCounters.level5)) .replace(/%r/g, getRomanNumeral(sectionCounters.level5, true)) .replace(/%R/g, getRomanNumeral(sectionCounters.level5, false)); sectionText = `${sectionNumber} ${headerText}`; } else if (level === 6) { sectionNumber = levelFormats.level6 .replace(/%n/g, sectionCounters.level6.toString()) .replace(/%r/g, getRomanNumeral(sectionCounters.level6, true)) .replace(/%R/g, getRomanNumeral(sectionCounters.level6, false)); sectionText = `${sectionNumber} ${headerText}`; } else { // Fallback for unknown levels sectionNumber = `Level ${level}.`; sectionText = `${sectionNumber} ${headerText}`; } crossReferences.push({ key, sectionNumber: sectionNumber.trim(), sectionText: sectionText.trim(), }); } } return crossReferences; } /** * Replaces cross-reference usage throughout the document * * Scans the document for |key| references and replaces them with: * 1. Section numbers from internal cross-references (priority) * 2. Metadata values as fallback for backward compatibility * 3. Original reference if neither is found * * @private * @param {string} content - Document content to process * @param {CrossReference[]} crossReferences - Map of keys to section numbers * @param {Record<string, any>} metadata - Metadata for fallback resolution * @returns {string} Document with cross-references replaced */ function replaceCrossReferences(content, crossReferences, metadata) { // Create a map for quick lookups const referenceMap = new Map(); for (const ref of crossReferences) { referenceMap.set(ref.key, ref.sectionNumber); } // Replace all |key| references (except those in headers which define them) const lines = content.split('\n'); const processedLines = lines.map(line => { const trimmedLine = line.trim(); // Skip lines that define cross-references (headers with |key| syntax) // Support both traditional (l., ll., lll.) and alternative (l1., l2., l3.) formats if (trimmedLine.match(/^(l+)\. (.+) \|([\w.-]+)\|$/) || trimmedLine.match(/^l(\d+)\. (.+) \|([\w.-]+)\|$/)) { return line; } // Process cross-reference usage in content lines return line.replace(/\|([^|]+)\|/g, (match, key) => { const trimmedKey = key.trim(); // First try internal section reference const sectionNumber = referenceMap.get(trimmedKey); if (sectionNumber) { // Track the cross-reference as a field for highlighting fieldTracker.trackField(`crossref.${trimmedKey}`, { value: sectionNumber, originalValue: match, hasLogic: true, }); return sectionNumber; } // Fallback to metadata value const metadataValue = getNestedValue(metadata, trimmedKey); if (metadataValue !== undefined) { const resolvedValue = formatMetadataValue(metadataValue, trimmedKey, metadata); // Track metadata-based cross-reference as a field fieldTracker.trackField(`crossref.${trimmedKey}`, { value: resolvedValue, originalValue: match, hasLogic: false, }); return resolvedValue; } // Track unresolved reference as empty fieldTracker.trackField(`crossref.${trimmedKey}`, { value: '', originalValue: match, hasLogic: false, }); // Return original if no reference found return match; }); }); return processedLines.join('\n'); } /** * Gets a potentially nested value from an object using dot notation */ function getNestedValue(obj, path) { const keys = path.split('.'); let value = obj; for (const key of keys) { if (value === undefined || value === null) { return undefined; } value = value[key]; } return value; } /** * Formats metadata values based on type and context */ function formatMetadataValue(value, key, metadata) { if (value === undefined) { return ''; } if (value === null) { return 'null'; } // Handle Date objects if (value instanceof Date) { return value.toISOString().split('T')[0]; // ISO date format } // Handle currency amounts if (typeof value === 'number' && key.includes('amount')) { const currency = metadata.payment_currency || 'USD'; return new Intl.NumberFormat('en-US', { style: 'currency', currency, }).format(value); } // Default string conversion return String(value); } //# sourceMappingURL=reference-processor.js.map