legal-markdown-js
Version:
Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version
545 lines • 22.8 kB
JavaScript
/**
* Remark plugin for Legal Markdown cross-references
*
* This plugin processes internal cross-references in Legal Markdown documents
* using AST-based processing to avoid text contamination issues. It handles:
* - Header extraction with |key| syntax (l. **Title** |key|)
* - Section numbering based on frontmatter formats
* - Cross-reference resolution throughout the document
* - Integration with field tracking for highlighting
*
* Architecture:
* 1. First pass: Extract cross-reference definitions from headers
* 2. Generate section numbers based on legal numbering formats
* 3. Second pass: Replace |key| references with section numbers
* 4. Integrate with field tracker for highlighting support
*
* @example
* ```typescript
* import { unified } from 'unified';
* import remarkParse from 'remark-parse';
* import remarkCrossReferences from './cross-references.js';
*
* const processor = unified()
* .use(remarkParse)
* .use(remarkCrossReferences, { metadata: frontmatterData });
*
* const result = await processor.process(content);
* ```
*
* @module
*/
import { visit } from 'unist-util-visit';
import { toString } from 'mdast-util-to-string';
import { fieldTracker } from '../../extensions/tracking/field-tracker.js';
import { getRomanNumeral, getAlphaLabel } from '../../utils/number-utilities.js';
/**
* Default level formats for section numbering
*/
const DEFAULT_LEVEL_FORMATS = {
level1: 'Article %n.',
level2: 'Section %n.',
level3: '(%n)',
level4: '(%n)',
level5: '(%n%c)',
level6: 'Annex %r -',
};
/**
* Extract text content from a node, handling various node types
*/
function extractTextFromNode(node) {
if (node.type === 'text') {
return node.value;
}
if ('children' in node && node.children) {
return node.children.map(child => extractTextFromNode(child)).join('');
}
return toString(node);
}
/**
* Replace text content in a text node
*/
function replaceTextInNode(node, oldText, newText) {
node.value = node.value.replace(oldText, newText);
}
/**
* Update section counters based on current level
*/
function updateSectionCounters(counters, level) {
// Increment current level
switch (level) {
case 1:
counters.level1++;
counters.level2 = 0;
counters.level3 = 0;
counters.level4 = 0;
counters.level5 = 0;
counters.level6 = 0;
break;
case 2:
counters.level2++;
counters.level3 = 0;
counters.level4 = 0;
counters.level5 = 0;
counters.level6 = 0;
break;
case 3:
counters.level3++;
counters.level4 = 0;
counters.level5 = 0;
counters.level6 = 0;
break;
case 4:
counters.level4++;
counters.level5 = 0;
counters.level6 = 0;
break;
case 5:
counters.level5++;
counters.level6 = 0;
break;
case 6:
counters.level6++;
break;
}
}
/**
* Generate section number based on level and counters with academic format support
*/
function generateSectionNumber(level, counters, levelFormats) {
const format = levelFormats[`level${level}`] || `Level ${level}.`;
const count = counters[`level${level}`];
// Create array representation for easier access (matching header-processor logic)
const headerNumbers = [
counters.level1,
counters.level2,
counters.level3,
counters.level4,
counters.level5,
counters.level6,
];
let formattedHeader = format;
// Handle special cases for levels 4 and 5 (from header-processor logic)
if (level === 4) {
// Level 4 format: (%n%c) where %n is level 3 number, %c is level 4 letter
if (formattedHeader.includes('%n%c') &&
!formattedHeader.includes('.%s') &&
!formattedHeader.includes('.%t') &&
!formattedHeader.includes('.%f')) {
formattedHeader = formattedHeader.replace(/%n/g, headerNumbers[2].toString());
formattedHeader = formattedHeader.replace(/%c/g, getAlphaLabel(headerNumbers[3]));
return formattedHeader;
}
}
else if (level === 5) {
// Level 5 format: (%n%c%r) where %n is level 3 number, %c is level 4 letter, %r is level 5 roman
if (formattedHeader.includes('%c%r') || formattedHeader.includes('%n%c%r')) {
formattedHeader = formattedHeader.replace(/%n/g, headerNumbers[2].toString());
formattedHeader = formattedHeader.replace(/%c/g, getAlphaLabel(headerNumbers[3]));
formattedHeader = formattedHeader.replace(/%r/g, getRomanNumeral(headerNumbers[4], true));
return formattedHeader;
}
}
// Detect academic/hierarchical formats
const hasAcademicContext = (levelFormats.level3 && levelFormats.level3.includes('%n.%s.%t')) ||
(levelFormats.level4 && levelFormats.level4.includes('%n.%s.%t.%f')) ||
(levelFormats.level5 && levelFormats.level5.includes('%n.%s.%t.%f.%i'));
const isAcademicHierarchical = (formattedHeader.includes('.%s.%t') ||
formattedHeader.includes('.%t.%f') ||
formattedHeader.includes('.%f.%i') ||
(formattedHeader.includes('.%s') && hasAcademicContext)) &&
!formattedHeader.includes('%r.') &&
!formattedHeader.includes('%R.') &&
!formattedHeader.includes('%c.');
// Handle hierarchical patterns for %c and %r
const isHierarchicalAlpha = formattedHeader.includes('%c.%n');
const isHierarchicalRoman = formattedHeader.includes('%r.%n') || formattedHeader.includes('%R.%n');
// Replace %n based on format type
if (isAcademicHierarchical) {
// In academic hierarchical formats, %n typically refers to level 1 number
formattedHeader = formattedHeader.replace(/%n/g, headerNumbers[0].toString());
}
else {
// In standard formats, %n refers to current level number
formattedHeader = formattedHeader.replace(/%n/g, headerNumbers[level - 1].toString());
}
// Replace reference variables (%s, %t, %f, %i)
if (isAcademicHierarchical) {
// In academic hierarchical formats, %s refers to level 2
formattedHeader = formattedHeader.replace(/%s/g, headerNumbers[1].toString());
}
else {
// In simple formats like %n.%s, %s refers to level 1
formattedHeader = formattedHeader.replace(/%s/g, headerNumbers[0].toString());
}
// %t = level 3 current number
formattedHeader = formattedHeader.replace(/%t/g, headerNumbers[2].toString());
// %f = level 4 current number
formattedHeader = formattedHeader.replace(/%f/g, headerNumbers[3].toString());
// %i = level 5 current number
formattedHeader = formattedHeader.replace(/%i/g, headerNumbers[4].toString());
// Replace %c (alphabetic)
if (isHierarchicalAlpha && level > 1) {
// In hierarchical formats with %c.%n pattern, %c before dot refers to level 1
formattedHeader = formattedHeader.replace(/%c/g, getAlphaLabel(headerNumbers[0]));
}
else {
formattedHeader = formattedHeader.replace(/%c/g, getAlphaLabel(headerNumbers[level - 1]));
}
// Replace %r (roman)
if (isHierarchicalRoman && level > 1) {
// In hierarchical formats with %r.%n pattern, %r before dot refers to level 1
formattedHeader = formattedHeader.replace(/%r/g, getRomanNumeral(headerNumbers[0], true));
}
else {
formattedHeader = formattedHeader.replace(/%r/g, getRomanNumeral(headerNumbers[level - 1], true));
}
// Replace %R (uppercase roman)
const isHierarchicalUppercaseRoman = formattedHeader.includes('%R.%n');
if (isHierarchicalUppercaseRoman && level > 1) {
formattedHeader = formattedHeader.replace(/%R/g, getRomanNumeral(headerNumbers[0], false));
}
else {
formattedHeader = formattedHeader.replace(/%R/g, getRomanNumeral(headerNumbers[level - 1], false));
}
return formattedHeader;
}
/**
* Extract cross-reference definitions from heading nodes
*/
function extractCrossReferencesFromAST(root, metadata, debug = false) {
const crossReferences = [];
const sectionCounters = {
level1: 0,
level2: 0,
level3: 0,
level4: 0,
level5: 0,
level6: 0,
};
// Support both dash-based keys (legacy compatibility) and number-based keys
const levelFormats = {
level1: metadata['level1'] || metadata['level-one'] || DEFAULT_LEVEL_FORMATS.level1,
level2: metadata['level2'] || metadata['level-two'] || DEFAULT_LEVEL_FORMATS.level2,
level3: metadata['level3'] || metadata['level-three'] || DEFAULT_LEVEL_FORMATS.level3,
level4: metadata['level4'] || metadata['level-four'] || DEFAULT_LEVEL_FORMATS.level4,
level5: metadata['level5'] || metadata['level-five'] || DEFAULT_LEVEL_FORMATS.level5,
level6: metadata['level6'] || metadata['level-six'] || DEFAULT_LEVEL_FORMATS.level6,
};
visit(root, 'heading', (node) => {
const headingText = toString(node);
// Check for cross-reference key pattern: **Text** |key|
// or more complex patterns with legal prefixes like l. **Text** |key|
const crossRefMatch = headingText.match(/^(?:l+\.\s+)?(.+?)\s+\|([^|]+)\|$/);
if (crossRefMatch) {
const [, headerContent, key] = crossRefMatch;
const level = node.depth;
// Update section counters
updateSectionCounters(sectionCounters, level);
// Generate section number
const sectionNumber = generateSectionNumber(level, sectionCounters, levelFormats);
const cleanHeaderText = headerContent.replace(/^\*\*|\*\*$/g, '').trim();
const sectionText = `${sectionNumber} ${cleanHeaderText}`;
crossReferences.push({
key: key.trim(),
level,
sectionNumber,
sectionText: sectionText.trim(),
headerText: cleanHeaderText,
position: node.position
? {
line: node.position.start.line,
column: node.position.start.column,
}
: undefined,
});
}
});
return crossReferences;
}
/**
* Clean header definitions by removing |key| patterns from headers
* Only removes the definition pattern at the end of headers, preserving references
*/
function cleanHeaderDefinitionsInAST(root, crossReferences) {
// Create map of defined keys to their definitions for matching
const definitionMap = new Map();
crossReferences.forEach(ref => definitionMap.set(ref.key, ref));
// Visit all heading nodes
visit(root, 'heading', (node) => {
const headingText = toString(node);
// Check if this header defines a cross-reference (ends with |key|)
const definitionMatch = headingText.match(/^(.+?)\s+\|([^|]+)\|$/);
if (definitionMatch) {
const [, headerContent, key] = definitionMatch;
const trimmedKey = key.trim();
// Only remove the definition if it's one we extracted
if (definitionMap.has(trimmedKey)) {
// Visit all text nodes within the heading to remove only the trailing definition
visit(node, 'text', (textNode) => {
const originalValue = textNode.value;
// Remove only the trailing |key| pattern, preserving other references
const modifiedValue = originalValue.replace(/\s+\|([^|]+)\|$/, (match, matchedKey) => {
const trimmedMatchedKey = matchedKey.trim();
if (trimmedMatchedKey === trimmedKey && definitionMap.has(trimmedMatchedKey)) {
return ''; // Remove the definition pattern
}
return match; // Keep if not the specific definition
});
// Update the text node if changes were made
if (modifiedValue !== originalValue) {
textNode.value = modifiedValue.trim();
}
});
}
}
});
}
/**
* Replace cross-reference usage in text nodes with optional field tracking
*/
function replaceCrossReferencesInAST(root, crossReferences, metadata, enableFieldTracking = false) {
// Create lookup map for fast reference resolution
const referenceMap = new Map();
for (const ref of crossReferences) {
referenceMap.set(ref.key, ref.sectionNumber);
}
// Visit all text nodes to replace |key| patterns
visit(root, 'text', (node, index, parent) => {
// Skip if this is inside a heading that defines a cross-reference
if (parent && parent.type === 'heading') {
const headingText = toString(parent);
if (headingText.match(/\|[^|]+\|$/)) {
return; // Skip replacement in defining headers
}
}
const originalValue = node.value;
let modifiedValue = originalValue;
let hasReplacements = false;
// Replace all |key| patterns
modifiedValue = modifiedValue.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 statistics
fieldTracker.trackField(`crossref.${trimmedKey}`, {
value: sectionNumber,
originalValue: match,
hasLogic: true,
});
hasReplacements = true;
return formatCrossRefValue(sectionNumber, trimmedKey, enableFieldTracking, true);
}
// Fallback to metadata value (for backward compatibility)
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,
});
hasReplacements = true;
return formatCrossRefValue(resolvedValue, trimmedKey, enableFieldTracking, false);
}
// Track unresolved reference as empty
fieldTracker.trackField(`crossref.${trimmedKey}`, {
value: '',
originalValue: match,
hasLogic: false,
});
// Return formatted empty value if field tracking is enabled, otherwise original
return enableFieldTracking
? formatCrossRefValue('', trimmedKey, enableFieldTracking, false)
: match;
});
// Update node value if replacements were made
if (hasReplacements) {
// If we have HTML in the replacement (field tracking spans), we need to convert to HTML node
if (enableFieldTracking && modifiedValue.includes('<span')) {
// Replace the text node with an HTML node to preserve HTML
if (parent && typeof index === 'number') {
const htmlNode = {
type: 'html',
value: modifiedValue,
};
parent.children[index] = htmlNode;
}
}
else {
node.value = modifiedValue;
}
}
});
// Process HTML nodes that contain cross-references (after template field processing)
visit(root, 'html', (node) => {
if (!node.value || !node.value.includes('|')) {
return; // Skip HTML nodes without cross-references
}
let modifiedValue = node.value;
let hasReplacements = false;
// Replace all |key| patterns in HTML content
modifiedValue = modifiedValue.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 statistics
fieldTracker.trackField(`crossref.${trimmedKey}`, {
value: sectionNumber,
originalValue: match,
hasLogic: true,
});
hasReplacements = true;
// For HTML nodes, generate HTML span if field tracking is enabled
if (enableFieldTracking) {
const cssClass = getCrossRefCssClass(true, true);
return `<span class="${cssClass}" data-field="crossref.${trimmedKey.replace(/"/g, '"')}">${sectionNumber}</span>`;
}
else {
return sectionNumber;
}
}
// Fallback to metadata value (for backward compatibility)
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,
});
hasReplacements = true;
// For HTML nodes, generate HTML span if field tracking is enabled
if (enableFieldTracking) {
const cssClass = getCrossRefCssClass(true, false);
return `<span class="${cssClass}" data-field="crossref.${trimmedKey.replace(/"/g, '"')}">${resolvedValue}</span>`;
}
else {
return resolvedValue;
}
}
// Track unresolved reference as empty
fieldTracker.trackField(`crossref.${trimmedKey}`, {
value: '',
originalValue: match,
hasLogic: false,
});
// For HTML nodes, generate HTML span for unresolved if field tracking is enabled
if (enableFieldTracking) {
const cssClass = getCrossRefCssClass(false, false);
return `<span class="${cssClass}" data-field="crossref.${trimmedKey.replace(/"/g, '"')}">${match}</span>`;
}
else {
return match;
}
});
// Update HTML node value if replacements were made
if (hasReplacements) {
node.value = modifiedValue;
}
});
}
/**
* Get CSS class for cross-reference field based on its status
*/
function getCrossRefCssClass(hasValue, hasLogic) {
if (!hasValue) {
return 'legal-field missing-value';
}
if (hasLogic) {
return 'legal-field highlight';
}
return 'legal-field imported-value';
}
/**
* Format cross-reference value with optional field tracking wrapper
*/
function formatCrossRefValue(value, fieldName, enableFieldTracking = false, hasLogic = false) {
if (!enableFieldTracking) {
return value;
}
const hasValue = value !== '';
const cssClass = getCrossRefCssClass(hasValue, hasLogic);
return `<span class="${cssClass}" data-field="crossref.${fieldName.replace(/"/g, '"')}">${value}</span>`;
}
/**
* 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) {
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 currency = metadata.payment_currency || 'USD';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(value);
}
// Default string conversion
return String(value);
}
/**
* Remark plugin for processing cross-references in Legal Markdown documents
*/
const remarkCrossReferences = options => {
const { metadata, debug = false, enableFieldTracking = false } = options;
return (tree) => {
if (debug) {
console.log('🔗 Processing cross-references with remark plugin');
}
// First pass: Extract cross-reference definitions from headers
const crossReferences = extractCrossReferencesFromAST(tree, metadata, debug);
if (debug) {
console.log(`Found ${crossReferences.length} cross-reference 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,
}));
// Ensure the field is always set, even if no cross-references exist
if (crossReferences.length === 0) {
metadata['_cross_references'] = [];
}
// Second pass: Clean headers by removing |key| definitions
cleanHeaderDefinitionsInAST(tree, crossReferences);
// Third pass: Replace cross-reference usage with section numbers
if (debug) {
console.log('🔄 Starting cross-reference replacement in content...');
}
replaceCrossReferencesInAST(tree, crossReferences, metadata, enableFieldTracking);
if (debug) {
console.log('✅ Cross-reference processing completed');
}
};
};
export default remarkCrossReferences;
//# sourceMappingURL=cross-references.js.map