legal-markdown-js
Version:
Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version
598 lines • 20.6 kB
JavaScript
/**
* Remark Plugin for Legal Header Processing
*
* This plugin processes legal headers and numbering in markdown documents using
* the remark AST. It provides automatic numbering, formatting, and section
* management for legal document structure.
*
* Features:
* - Automatic header numbering (l., ll., lll., etc.)
* - Section references and cross-referencing
* - Customizable numbering formats
* - Reset and continuation options
* - Indentation management
*
* @example
* ```typescript
* import { unified } from 'unified';
* import remarkParse from 'remark-parse';
* import remarkStringify from 'remark-stringify';
* import { remarkHeaders } from './headers.js';
*
* const processor = unified()
* .use(remarkParse)
* .use(remarkHeaders, {
* metadata: { 'level-one': 'l', 'level-two': 'll' },
* noReset: false,
* noIndent: false
* })
* .use(remarkStringify);
* ```
*
* @module
*/
import { visit } from 'unist-util-visit';
/**
* Remark plugin for processing legal headers
*
* This plugin transforms markdown headers into properly numbered legal headers
* according to legal document conventions. It supports multiple numbering
* formats and can maintain state across the document.
*
* @param options - Configuration options for header processing
* @returns Remark plugin transformer function
*/
export const remarkHeaders = options => {
const { metadata = {}, noReset = false, noIndent = false, debug = false } = options;
return (tree) => {
if (debug) {
console.log('[remarkHeaders] Processing headers with options:', options);
console.log('[remarkHeaders] Metadata:', metadata);
}
// Initialize header configuration from metadata
const config = extractHeaderConfig(metadata);
const state = initializeHeaderState();
// Count legal headings first
let headingCount = 0;
visit(tree, 'heading', (node) => {
if (node.data?.isLegalHeader) {
headingCount++;
}
});
if (debug) {
console.log(`[remarkHeaders] Found ${headingCount} legal headings in document`);
}
// Process only headings that come from legal header syntax
visit(tree, 'heading', (node, index, parent) => {
if (node.data?.isLegalHeader) {
if (debug) {
console.log(`[remarkHeaders] Processing legal heading at depth ${node.depth}:`, extractTextContent(node));
}
processHeader(node, config, state, { noReset, noIndent, debug });
}
});
// Second pass: Replace heading nodes marked for HTML replacement
visit(tree, 'heading', (node, index, parent) => {
if (node.__needsHtmlReplacement && parent && typeof index === 'number') {
if (debug) {
console.log('[remarkHeaders] Replacing heading with HTML node to preserve indentation');
}
// Replace the heading node with an HTML node
const htmlNode = {
type: 'html',
value: node.__htmlContent,
};
parent.children[index] = htmlNode;
}
});
if (debug) {
console.log('[remarkHeaders] Final header state:', state);
}
};
};
/**
* Extract header configuration from document metadata
*/
function extractHeaderConfig(metadata) {
// Helper to get first defined value (including empty strings)
const getFirstDefined = (...keys) => {
for (const key of keys) {
if (key in metadata) {
return metadata[key];
}
}
return null;
};
return {
levelOne: getFirstDefined('level-1', 'level-one', 'level_one'),
levelTwo: getFirstDefined('level-2', 'level-two', 'level_two'),
levelThree: getFirstDefined('level-3', 'level-three', 'level_three'),
levelFour: getFirstDefined('level-4', 'level-four', 'level_four'),
levelFive: getFirstDefined('level-5', 'level-five', 'level_five'),
levelSix: getFirstDefined('level-6', 'level-six', 'level_six'),
levelSeven: getFirstDefined('level-7', 'level-seven', 'level_seven'),
levelEight: getFirstDefined('level-8', 'level-eight', 'level_eight'),
levelNine: getFirstDefined('level-9', 'level-nine', 'level_nine'),
customFormats: new Map(),
};
}
/**
* Initialize header numbering state
*/
function initializeHeaderState() {
return {
levelOne: 0,
levelTwo: 0,
levelThree: 0,
levelFour: 0,
levelFive: 0,
levelSix: 0,
levelSeven: 0,
levelEight: 0,
levelNine: 0,
customLevels: new Map(),
};
}
/**
* Process a single header node
*/
function processHeader(node, config, state, options) {
const { noReset, noIndent, debug } = options;
// Determine header level and format
const level = node.depth;
const format = getHeaderFormat(level, config);
// Update numbering state
updateHeaderState(level, state, noReset);
// Get the current number for this level
const number = getHeaderNumber(level, state);
// Format the header text
const headerText = formatHeaderText(node, format, number, state, { noIndent, debug });
// Update the node's children with the new formatted text
if (headerText !== null) {
// Check if we need to replace with HTML node due to indentation
const hasIndentation = headerText.startsWith(' ');
if (hasIndentation) {
// We need to return this information to the main processor
// to replace the heading node with an HTML node
node.__needsHtmlReplacement = true;
node.__htmlContent = `${'#'.repeat(level)} ${headerText}`;
}
updateHeaderNode(node, headerText);
}
if (debug) {
console.log(`[remarkHeaders] Processed level ${level} header:`, headerText);
}
}
/**
* Get header format for a given level
*/
function getHeaderFormat(level, config) {
let format = null;
switch (level) {
case 1:
format = config.levelOne;
break;
case 2:
format = config.levelTwo;
break;
case 3:
format = config.levelThree;
break;
case 4:
format = config.levelFour;
break;
case 5:
format = config.levelFive;
break;
case 6:
format = config.levelSix;
break;
case 7:
format = config.levelSeven;
break;
case 8:
format = config.levelEight;
break;
case 9:
format = config.levelNine;
break;
default:
format = null;
}
// If no format is defined, return undefined template
if (format === null || format === undefined) {
return `{{undefined-level-${level}}}`;
}
return format;
}
/**
* Update header numbering state based on current level
*/
function updateHeaderState(level, state, noReset) {
switch (level) {
case 1:
state.levelOne++;
if (!noReset) {
state.levelTwo = 0;
state.levelThree = 0;
state.levelFour = 0;
state.levelFive = 0;
state.levelSix = 0;
state.levelSeven = 0;
state.levelEight = 0;
state.levelNine = 0;
}
break;
case 2:
state.levelTwo++;
if (!noReset) {
state.levelThree = 0;
state.levelFour = 0;
state.levelFive = 0;
state.levelSix = 0;
state.levelSeven = 0;
state.levelEight = 0;
state.levelNine = 0;
}
break;
case 3:
state.levelThree++;
if (!noReset) {
state.levelFour = 0;
state.levelFive = 0;
state.levelSix = 0;
state.levelSeven = 0;
state.levelEight = 0;
state.levelNine = 0;
}
break;
case 4:
state.levelFour++;
if (!noReset) {
state.levelFive = 0;
state.levelSix = 0;
state.levelSeven = 0;
state.levelEight = 0;
state.levelNine = 0;
}
break;
case 5:
state.levelFive++;
if (!noReset) {
state.levelSix = 0;
state.levelSeven = 0;
state.levelEight = 0;
state.levelNine = 0;
}
break;
case 6:
state.levelSix++;
if (!noReset) {
state.levelSeven = 0;
state.levelEight = 0;
state.levelNine = 0;
}
break;
case 7:
state.levelSeven++;
if (!noReset) {
state.levelEight = 0;
state.levelNine = 0;
}
break;
case 8:
state.levelEight++;
if (!noReset) {
state.levelNine = 0;
}
break;
case 9:
state.levelNine++;
break;
}
}
/**
* Get the current number for a header level
*/
function getHeaderNumber(level, state) {
switch (level) {
case 1:
return state.levelOne;
case 2:
return state.levelTwo;
case 3:
return state.levelThree;
case 4:
return state.levelFour;
case 5:
return state.levelFive;
case 6:
return state.levelSix;
case 7:
return state.levelSeven;
case 8:
return state.levelEight;
case 9:
return state.levelNine;
default:
return 0;
}
}
/**
* Get the value for a specific level (helper for leading zero formatting)
*/
function getLevelValue(level, state) {
return getHeaderNumber(level, state);
}
/**
* Format header text with numbering
*/
function formatHeaderText(node, format, number, state, options) {
const { noIndent, debug } = options;
const level = node.depth;
// Extract current text content
const currentText = extractTextContent(node);
if (!currentText) {
if (debug) {
console.log('[remarkHeaders] No text content found in header');
}
return null;
}
// Check if header already has numbering
if (hasExistingNumbering(currentText, format)) {
if (debug) {
console.log('[remarkHeaders] Header already has numbering, skipping');
}
return null;
}
// Apply numbering format with full state
const numberedText = applyNumberingFormat(format, number, node.depth, state);
// Apply indentation if not disabled
const indentation = noIndent ? '' : ' '.repeat(Math.max(0, level - 1));
// Combine with original text
return `${indentation}${numberedText} ${currentText}`;
}
/**
* Extract text content from header node
*/
function extractTextContent(node) {
const result = node.children
.map(child => {
if (child.type === 'text') {
return child.value;
}
else if (child.type === 'html') {
// Handle HTML nodes (e.g., field tracking spans) - preserve the HTML
return child.value || '';
}
else if (child.type === 'strong' || child.type === 'emphasis') {
// Handle formatted text within headers - preserve formatting
const innerText = child.children
.map(grandchild => (grandchild.type === 'text' ? grandchild.value : ''))
.join('');
// Convert to markdown syntax (use asterisks for consistency)
if (child.type === 'strong') {
return `**${innerText}**`;
}
else if (child.type === 'emphasis') {
return `*${innerText}*`;
}
return innerText;
}
else if (child.type === 'link') {
// Handle links - extract just the text content
const linkText = child.children
.map(grandchild => (grandchild.type === 'text' ? grandchild.value : ''))
.join('');
return linkText;
}
else if (child.type === 'inlineCode') {
// Handle inline code - extract the value
return child.value || '';
}
return '';
})
.join('')
.trim();
return result;
}
/**
* Check if header already has numbering
*/
function hasExistingNumbering(text, format) {
// Check if text already starts with a numbering pattern
// Common patterns: "Article 1.", "Section 2.", "(1)", "1.", "1.1", etc.
const numberingPatterns = [
/^Article\s+\d+\.?\s*/i,
/^Section\s+\d+\.?\s*/i,
/^Chapter\s+\d+\.?\s*/i,
/^\(\d+\)\s*/,
/^\d+\.\s*/,
/^\d+\.\d+\.?\s*/,
/^[a-z]\.\s*/i,
/^\([a-z]\)\s*/i,
/^[ivxlcdm]+\.\s*/i,
/^\([ivxlcdm]+\)\s*/i,
];
return numberingPatterns.some(pattern => pattern.test(text));
}
/**
* Apply numbering format with actual number
*/
function applyNumberingFormat(format, number, level, state) {
// Handle special leading zero formats first (e.g., %02n, %03n)
let result = format;
// Handle %0Xn format (leading zero numbers for current level)
const leadingZeroPattern = /%0(\d+)n/g;
result = result.replace(leadingZeroPattern, (match, digits) => {
return number.toString().padStart(parseInt(digits), '0');
});
// Handle leading zero formats for direct level references (%0Xl1, %0Xl2, etc.)
for (let i = 1; i <= 9; i++) {
const leadingZeroLevelPattern = new RegExp(`%0(\\d+)l${i}`, 'g');
result = result.replace(leadingZeroLevelPattern, (match, digits) => {
const levelValue = getLevelValue(i, state);
return levelValue.toString().padStart(parseInt(digits), '0');
});
}
// Replace %n with the actual number (non-leading-zero version)
result = result.replace(/%n/g, number.toString());
// Replace level-specific references (%l1, %l2, %l3, %l4, %l5, %l6, %l7, %l8, %l9)
result = result.replace(/%l1/g, state.levelOne.toString());
result = result.replace(/%l2/g, state.levelTwo.toString());
result = result.replace(/%l3/g, state.levelThree.toString());
result = result.replace(/%l4/g, state.levelFour.toString());
result = result.replace(/%l5/g, state.levelFive.toString());
result = result.replace(/%l6/g, state.levelSix.toString());
result = result.replace(/%l7/g, state.levelSeven.toString());
result = result.replace(/%l8/g, state.levelEight.toString());
result = result.replace(/%l9/g, state.levelNine.toString());
// Replace alphabetic variables
// %A = uppercase letters (A, B, C, ...)
if (format.includes('%A')) {
const alphaNumber = level === 4 && format.includes('%n%A') ? state.levelFour : number;
const alphaLabel = String.fromCharCode(64 + alphaNumber); // 65 = 'A'
result = result.replace(/%A/g, alphaLabel);
}
// %a = lowercase letters (a, b, c, ...) - alias for %c
if (format.includes('%a')) {
const alphaNumber = level === 4 && format.includes('%n%a') ? state.levelFour : number;
const alphaLabel = String.fromCharCode(96 + alphaNumber); // 97 = 'a'
result = result.replace(/%a/g, alphaLabel);
}
// Replace %c with alphabetic label (a, b, c, ...)
if (format.includes('%c')) {
// For level 4 formats like (%n%c), use level 4 number
// For other formats, use current level number
const alphaNumber = level === 4 && format.includes('%n%c') ? state.levelFour : number;
const alphaLabel = String.fromCharCode(96 + alphaNumber); // 97 = 'a'
result = result.replace(/%c/g, alphaLabel);
}
// Replace %r with lowercase roman numerals
if (format.includes('%r')) {
// For level 5 formats like (%n%c%r), use level 5 number
// For other formats, use current level number
const romanNumber = level === 5 && (format.includes('%c%r') || format.includes('%n%c%r'))
? state.levelFive
: number;
const romanNumeral = toRomanNumeral(romanNumber).toLowerCase();
result = result.replace(/%r/g, romanNumeral);
}
// Replace %R with uppercase roman numerals
if (format.includes('%R')) {
const romanNumeral = toRomanNumeral(number);
result = result.replace(/%R/g, romanNumeral);
}
// Replace %o with fallback to %n (placeholder for future extension)
// Currently just falls back to numeric representation
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;
}
/**
* Get indentation for header level
*/
function getIndentationForLevel(level) {
// Each level adds 2 spaces of indentation
return ' '.repeat(Math.max(0, level - 1));
}
/**
* Checks if text contains field tracking spans
* @param text - The text to check
* @returns True if text contains field tracking HTML spans
*/
function containsFieldTrackingSpans(text) {
return (text.includes('<span class="legal-field') ||
text.includes('<span class="imported-value') ||
text.includes('<span class="missing-value') ||
text.includes('<span class="highlight'));
}
/**
* Update header node with new text content
*/
/**
* Preserve original formatting while adding numbering prefix
* @param node - The heading node with original formatting
* @param newText - The complete new text with numbering
* @returns Array of children nodes or null if preservation not possible
*/
function preserveFormattingInHeader(node, newText) {
// Extract the original text content to find where the numbering ends
const originalText = extractTextContent(node);
if (!originalText)
return null;
// Find the numbering part by comparing newText with originalText
const numberingIndex = newText.lastIndexOf(originalText);
if (numberingIndex === -1)
return null;
const numberingPrefix = newText.substring(0, numberingIndex);
// Create new children array: numbering prefix + original formatted children
const newChildren = [];
// Add numbering prefix as text node
if (numberingPrefix) {
newChildren.push({
type: 'text',
value: numberingPrefix,
});
}
// Add all original children (which contain the formatting)
newChildren.push(...node.children);
return newChildren;
}
function updateHeaderNode(node, newText) {
// Check if the new text contains HTML spans (field tracking) or leading spaces (indentation)
// If so, create an HTML node instead of a text node to prevent escaping
const hasFieldTracking = containsFieldTrackingSpans(newText);
const hasLeadingSpaces = newText.startsWith(' ');
const hasMarkdownFormatting = newText.includes('*') || newText.includes('_');
if (hasFieldTracking || hasLeadingSpaces || hasMarkdownFormatting) {
// Replace the entire children array with new HTML node
node.children = [
{
type: 'html',
value: newText,
},
];
}
else {
// For headers, always use plain text to ensure clean, consistent formatting
// This flattens all inline formatting (links, inline code, emphasis, strong)
node.children = [
{
type: 'text',
value: newText,
},
];
}
}
export default remarkHeaders;
//# sourceMappingURL=headers.js.map