legal-markdown-js
Version:
Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version
457 lines • 17.4 kB
JavaScript
/**
* Enhanced Remark plugin for Legal Markdown cross-references using custom AST nodes
*
* This plugin creates custom 'reference' AST nodes for |key| patterns, enabling:
* - Single-pass AST processing for better performance
* - More extensible architecture with custom node types
* - Support for additional attributes (type, format) on references
* - Independent from complex regex patterns
* - Better integration with other remark plugins
*
* Architecture:
* 1. Parse phase: Convert |key| patterns into custom 'reference' nodes
* 2. Collection phase: Extract definitions from headers in single pass
* 3. Resolution phase: Resolve all reference nodes in single pass
* 4. Cleanup phase: Remove definition markers from headers
*
* @example
* ```typescript
* import { unified } from 'unified';
* import remarkParse from 'remark-parse';
* import { remarkCrossReferencesAST } from './cross-references-ast.js';
*
* const processor = unified()
* .use(remarkParse)
* .use(remarkCrossReferencesAST, { metadata: frontmatterData });
*
* const result = await processor.process(content);
* ```
*
* @module
*/
import { visit } from 'unist-util-visit';
import { fieldTracker } from '../../extensions/tracking/field-tracker.js';
import { fieldSpan } from '../../extensions/tracking/field-span.js';
import { DEFAULT_HEADER_PATTERNS } from '../../constants/headers.js';
import { logger } from '../../utils/logger.js';
/**
* Default level formats for section numbering (extended to 9 levels)
*/
// Removed: local DEFAULT_LEVEL_FORMATS was dead code (never referenced).
// All defaults now come from DEFAULT_HEADER_PATTERNS in src/constants/headers.ts.
/**
* Parse |key| patterns and convert them to custom reference nodes
* This happens in a single pass during the initial AST traversal
*/
function parseReferences(tree) {
visit(tree, 'text', (node, index, parent) => {
if (!parent || typeof index === 'undefined')
return;
// Find all |key| patterns in the text
const text = node.value;
const referencePattern = /\|([^|]+)\|/g;
const matches = Array.from(text.matchAll(referencePattern));
if (matches.length === 0)
return;
// Split the text node into multiple nodes, replacing |key| with reference nodes
const newNodes = [];
let lastIndex = 0;
for (const match of matches) {
const [fullMatch, key] = match;
const matchStart = match.index;
const matchEnd = matchStart + fullMatch.length;
// Add text before the match
if (matchStart > lastIndex) {
const beforeText = text.slice(lastIndex, matchStart);
if (beforeText) {
newNodes.push({
type: 'text',
value: beforeText,
});
}
}
// Determine reference type based on context
const isInHeader = parent.type === 'heading';
const isDefinition = isInHeader && matchStart > text.length - fullMatch.length - 10; // Rough heuristic for end of header
// Create reference node
const referenceNode = {
type: 'reference',
key: key.trim(),
referenceType: isDefinition ? 'definition' : 'usage',
originalText: fullMatch,
resolved: false,
};
newNodes.push(referenceNode);
lastIndex = matchEnd;
}
// Add remaining text after the last match
if (lastIndex < text.length) {
const afterText = text.slice(lastIndex);
if (afterText) {
newNodes.push({
type: 'text',
value: afterText,
});
}
}
// Replace the original text node with the new nodes
parent.children.splice(index, 1, ...newNodes);
});
}
/**
* Extract cross-reference definitions from reference nodes in headers
* Single pass collection of all definitions
*/
function extractDefinitionsFromAST(root, metadata, debug = false) {
const crossReferences = [];
const sectionCounters = {
level1: 0,
level2: 0,
level3: 0,
level4: 0,
level5: 0,
level6: 0,
level7: 0,
level8: 0,
level9: 0,
};
// Get level formats from metadata (supporting expanded system)
const mStr = (keys, fallback) => {
for (const k of keys) {
const v = metadata[k];
if (typeof v === 'string')
return v;
}
return fallback;
};
const levelFormats = {
level1: mStr(['level-1', 'level-one', 'level_one'], DEFAULT_HEADER_PATTERNS['level-1']),
level2: mStr(['level-2', 'level-two', 'level_two'], DEFAULT_HEADER_PATTERNS['level-2']),
level3: mStr(['level-3', 'level-three', 'level_three'], DEFAULT_HEADER_PATTERNS['level-3']),
level4: mStr(['level-4', 'level-four', 'level_four'], DEFAULT_HEADER_PATTERNS['level-4']),
level5: mStr(['level-5', 'level-five', 'level_five'], DEFAULT_HEADER_PATTERNS['level-5']),
level6: mStr(['level-6', 'level-six', 'level_six'], DEFAULT_HEADER_PATTERNS['level-6']),
level7: mStr(['level-7', 'level-seven', 'level_seven'], DEFAULT_HEADER_PATTERNS['level-7']),
level8: mStr(['level-8', 'level-eight', 'level_eight'], DEFAULT_HEADER_PATTERNS['level-8']),
level9: mStr(['level-9', 'level-nine', 'level_nine'], DEFAULT_HEADER_PATTERNS['level-9']),
};
visit(root, 'heading', (node) => {
// Look for reference nodes in the heading
let definitionKey = null;
let headerText = '';
// Extract text and find definition reference
visit(node, child => {
if (child.type === 'text') {
headerText += child.value;
}
else if (child.type === 'reference') {
const refNode = child;
if (refNode.referenceType === 'definition') {
definitionKey = refNode.key;
}
}
});
if (definitionKey) {
const level = Math.min(node.depth, 9); // Cap at 9 levels
// Update section counters
updateSectionCounters(sectionCounters, level);
// Generate section number using the new header system logic
const sectionNumber = generateSectionNumber(level, sectionCounters, levelFormats);
const cleanHeaderText = headerText.trim();
const sectionText = `${sectionNumber} ${cleanHeaderText}`;
crossReferences.push({
key: definitionKey,
level,
sectionNumber,
sectionText: sectionText.trim(),
headerText: cleanHeaderText,
position: node.position
? {
line: node.position.start.line,
column: node.position.start.column,
}
: undefined,
});
if (debug) {
logger.debug(`Found definition: ${definitionKey} -> ${sectionNumber}`);
}
}
});
return crossReferences;
}
/**
* Update section counters based on current level (expanded to 9 levels)
*/
function updateSectionCounters(counters, level) {
// Increment current level
switch (level) {
case 1:
counters.level1++;
counters.level2 = counters.level3 = counters.level4 = counters.level5 = 0;
counters.level6 = counters.level7 = counters.level8 = counters.level9 = 0;
break;
case 2:
counters.level2++;
counters.level3 = counters.level4 = counters.level5 = 0;
counters.level6 = counters.level7 = counters.level8 = counters.level9 = 0;
break;
case 3:
counters.level3++;
counters.level4 = counters.level5 = counters.level6 = 0;
counters.level7 = counters.level8 = counters.level9 = 0;
break;
case 4:
counters.level4++;
counters.level5 = counters.level6 = counters.level7 = 0;
counters.level8 = counters.level9 = 0;
break;
case 5:
counters.level5++;
counters.level6 = counters.level7 = counters.level8 = counters.level9 = 0;
break;
case 6:
counters.level6++;
counters.level7 = counters.level8 = counters.level9 = 0;
break;
case 7:
counters.level7++;
counters.level8 = counters.level9 = 0;
break;
case 8:
counters.level8++;
counters.level9 = 0;
break;
case 9:
counters.level9++;
break;
}
}
/**
* Generate section number using the enhanced header variable system
*/
function generateSectionNumber(level, counters, levelFormats) {
const format = levelFormats[`level${level}`] || `{{undefined-level-${level}}}`;
const number = counters[`level${level}`];
// If format is an undefined template, return it as-is
if (format.startsWith('{{undefined-level-')) {
return format;
}
// Use the same logic as the enhanced header system from Task 4
let result = format.replace(/%n/g, number.toString());
// Replace level-specific references (%l1, %l2, etc.)
result = result.replace(/%l1/g, counters.level1.toString());
result = result.replace(/%l2/g, counters.level2.toString());
result = result.replace(/%l3/g, counters.level3.toString());
result = result.replace(/%l4/g, counters.level4.toString());
result = result.replace(/%l5/g, counters.level5.toString());
result = result.replace(/%l6/g, counters.level6.toString());
result = result.replace(/%l7/g, counters.level7.toString());
result = result.replace(/%l8/g, counters.level8.toString());
result = result.replace(/%l9/g, counters.level9.toString());
// Replace alphabetic variables
if (format.includes('%A')) {
const alphaLabel = String.fromCharCode(64 + number); // 65 = 'A'
result = result.replace(/%A/g, alphaLabel);
}
if (format.includes('%a')) {
const alphaLabel = String.fromCharCode(96 + number); // 97 = 'a'
result = result.replace(/%a/g, alphaLabel);
}
// Replace roman numeral variables
if (format.includes('%R')) {
const romanNumeral = toRomanNumeral(number);
result = result.replace(/%R/g, romanNumeral);
}
if (format.includes('%r')) {
const romanNumeral = toRomanNumeral(number).toLowerCase();
result = result.replace(/%r/g, romanNumeral);
}
// Replace %o with fallback to %n
if (format.includes('%o')) {
result = result.replace(/%o/g, number.toString());
}
return result;
}
/**
* Convert number to Roman numeral
*/
function toRomanNumeral(num) {
const romanNumerals = [
[1000, 'M'],
[900, 'CM'],
[500, 'D'],
[400, 'CD'],
[100, 'C'],
[90, 'XC'],
[50, 'L'],
[40, 'XL'],
[10, 'X'],
[9, 'IX'],
[5, 'V'],
[4, 'IV'],
[1, 'I'],
];
let result = '';
for (const [value, symbol] of romanNumerals) {
while (num >= value) {
result += symbol;
num -= value;
}
}
return result;
}
/**
* Resolve all reference nodes in a single pass
*/
function resolveReferences(root, crossReferences, metadata, enableFieldTracking = false, debug = false) {
// Create lookup map for fast resolution
const referenceMap = new Map();
for (const ref of crossReferences) {
referenceMap.set(ref.key, ref.sectionNumber);
}
// Single pass to resolve all reference nodes
visit(root, 'reference', (node, index, parent) => {
if (!parent || typeof index === 'undefined')
return;
// Skip definition references (they get cleaned up separately)
if (node.referenceType === 'definition') {
return;
}
const key = node.key;
let resolvedValue;
let hasLogic = false;
// Try internal section reference first
resolvedValue = referenceMap.get(key);
if (resolvedValue) {
hasLogic = true;
}
else {
// Fallback to metadata value
const rawMetaValue = getNestedValue(metadata, key);
if (rawMetaValue !== undefined) {
resolvedValue = formatMetadataValue(rawMetaValue, key, metadata);
}
}
// Track the field
fieldTracker.trackField(`crossref.${key}`, {
value: resolvedValue || '',
originalValue: node.originalText,
hasLogic,
});
if (debug) {
logger.debug(`Resolving ${key} -> ${resolvedValue || 'UNRESOLVED'}`);
}
// Create replacement node
let replacementNode;
if (enableFieldTracking) {
// Create HTML node with field tracking
const hasValue = resolvedValue !== undefined && resolvedValue !== '';
const displayValue = resolvedValue || node.originalText;
const kind = hasValue ? (hasLogic ? 'crossref' : 'imported') : 'missing';
replacementNode = {
type: 'html',
value: fieldSpan(`crossref.${key}`, displayValue, kind),
};
}
else {
// Create simple text node
replacementNode = {
type: 'text',
value: resolvedValue || node.originalText,
};
}
// Replace the reference node
parent.children[index] = replacementNode;
// Mark as resolved
node.resolved = true;
node.resolvedValue = resolvedValue;
});
}
/**
* Clean up definition reference nodes from headers
*/
function cleanupDefinitions(root) {
visit(root, 'reference', (node, index, parent) => {
if (!parent || typeof index === 'undefined')
return;
if (node.referenceType === 'definition') {
// Remove the definition reference node
parent.children.splice(index, 1);
}
});
}
/**
* Get nested value from 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 ||
typeof value !== 'object' ||
Array.isArray(value)) {
return undefined;
}
value = value[key];
}
return value;
}
/**
* Format 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 rawCurrency = metadata.payment_currency;
const currency = typeof rawCurrency === 'string' ? rawCurrency : 'USD';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(value);
}
// Default string conversion
return String(value);
}
/**
* Enhanced remark plugin for processing cross-references using custom AST nodes
*/
export const remarkCrossReferencesAST = options => {
const { metadata, debug = false, enableFieldTracking = false } = options;
return (tree) => {
if (debug) {
logger.debug('Starting enhanced cross-reference processing');
}
// Phase 1: Parse |key| patterns into custom reference nodes
parseReferences(tree);
// Phase 2: Extract definitions from headers (single pass)
const crossReferences = extractDefinitionsFromAST(tree, metadata, debug);
if (debug) {
logger.debug(`Found ${crossReferences.length} definitions:`, crossReferences.map(ref => `${ref.key} -> ${ref.sectionNumber}`));
}
// Store cross-references in metadata for external access
metadata['_cross_references'] = crossReferences.map(ref => ({
key: ref.key,
sectionNumber: ref.sectionNumber,
sectionText: ref.sectionText,
}));
// Phase 3: Resolve all reference nodes (single pass)
resolveReferences(tree, crossReferences, metadata, enableFieldTracking, debug);
// Phase 4: Clean up definition markers from headers
cleanupDefinitions(tree);
if (debug) {
logger.debug('Enhanced cross-reference processing completed');
}
};
};
// Exported for testing - not part of public API
export { parseReferences as _parseReferences, extractDefinitionsFromAST as _extractDefinitionsFromAST, updateSectionCounters as _updateSectionCounters, generateSectionNumber as _generateSectionNumber, toRomanNumeral as _toRomanNumeral, resolveReferences as _resolveReferences, formatMetadataValue as _formatMetadataValue, };
//# sourceMappingURL=cross-references-ast.js.map