legal-markdown-js
Version:
Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version
518 lines • 21.9 kB
JavaScript
/**
* Header Processing Module for Legal Markdown Documents
*
* This module provides comprehensive header processing functionality for Legal Markdown
* documents, supporting both traditional (l., ll., lll.) and alternative (l2., l3.)
* header syntax. It handles complex numbering schemes, hierarchical structures, and
* various formatting options including Roman numerals, alphabetic labels, and custom
* indentation patterns.
*
* Features:
* - Dual header syntax support (traditional and alternative)
* - Complex hierarchical numbering with automatic level management
* - Roman numeral and alphabetic label generation
* - Customizable header formatting templates
* - Academic and legal document formatting patterns
* - Automatic indentation and spacing control
* - Continuous numbering and reset control options
* - Level-specific formatting with placeholder substitution
*
* @example
* ```typescript
* import { processHeaders } from './header-processor.js';
*
* const content = `l. Introduction
* ll. Terms and Conditions
* lll. Payment Terms
* l2. Liability`;
*
* const metadata = {
* 'level-one': 'Article %n.',
* 'level-two': 'Section %n.',
* 'level-three': '(%n)'
* };
*
* const processed = processHeaders(content, metadata);
* console.log(processed);
* // Output:
* // Article 1. Introduction
* // Section 1. Terms and Conditions
* // (1) Payment Terms
* // Section 2. Liability
* ```
*/
import { getRomanNumeral, getAlphaLabel } from '../../utils/number-utilities.js';
/**
* Processes structured headers in a LegalMarkdown document
*
* This is the main function that processes both traditional (l., ll., lll.) and
* alternative (l1., l2., l3.) header syntax. It maintains proper hierarchical
* numbering, applies custom formatting templates, and handles complex academic
* and legal document structures.
*
* @param {string} content - The document content containing headers to process
* @param {Record<string, any>} metadata - Document metadata with potential header formatting options
* @param {Object} [processingOptions={}] - Additional processing options
* @param {boolean} [processingOptions.noReset] - Disable header numbering reset (continuous numbering)
* @param {boolean} [processingOptions.noIndent] - Disable header indentation (flat formatting)
* @returns {string} Processed content with formatted headers
* @example
* ```typescript
* // Basic header processing
* const content = `l. First Article
* ll. First Section
* lll. Subsection A
* ll. Second Section
* l. Second Article`;
*
* const metadata = {
* 'level-one': 'Article %n.',
* 'level-two': 'Section %n.',
* 'level-three': '(%c)'
* };
*
* const result = processHeaders(content, metadata);
* // Output:
* // Article 1. First Article
* // Section 1. First Section
* // (a) Subsection A
* // Section 2. Second Section
* // Article 2. Second Article
*
* // Alternative syntax
* const altContent = `l1. Introduction
* l2. Background
* l3. Technical Details
* l2. Implementation
* l1. Conclusion`;
*
* const altResult = processHeaders(altContent, metadata);
* // Produces similar hierarchical numbering
* ```
*/
export function processHeaders(content, metadata, processingOptions = {}) {
// Extract header options from metadata
const options = extractHeaderOptions(metadata, processingOptions);
// Define regex patterns for both header styles
const traditionalHeaderPattern = /^(l+)\.\s+(.*?)$/gm;
const alternativeHeaderPattern = /^l(\d+)\.\s+(.*?)$/gm;
// Track header numbering state
const headerNumbers = [0, 0, 0, 0, 0, 0];
// Process all headers in a single pass to maintain correct numbering
const matches = [];
// Collect all traditional header matches
let match;
const contentCopy = content.slice();
traditionalHeaderPattern.lastIndex = 0;
while ((match = traditionalHeaderPattern.exec(contentCopy)) !== null) {
matches.push({
match: match[0],
level: match[1].length,
text: match[2],
index: match.index,
});
}
// Collect all alternative header matches
alternativeHeaderPattern.lastIndex = 0;
while ((match = alternativeHeaderPattern.exec(contentCopy)) !== null) {
matches.push({
match: match[0],
level: parseInt(match[1], 10),
text: match[2],
index: match.index,
});
}
// Sort matches by their position in the document
matches.sort((a, b) => a.index - b.index);
// Process headers in order (document order)
let result = content;
let offset = 0;
let previousLevel = 0;
for (const m of matches) {
// Update header numbers based on level
const level = m.level;
// Special logic for level 5
if (level === 5) {
if (previousLevel === 5) {
// Consecutive level 5s: just increment level 5
headerNumbers[4]++;
}
else {
// Level 5 after something else: reset level 5 to 1
headerNumbers[4] = 1;
}
}
else {
// Standard logic for other levels
// If we're skipping levels, increment all intermediate levels to 1
for (let i = previousLevel; i < level - 1; i++) {
if (headerNumbers[i] === 0) {
headerNumbers[i] = 1;
}
}
headerNumbers[level - 1]++;
// Reset all deeper level numbers (unless noReset is enabled)
if (!options.noReset) {
for (let i = level; i < headerNumbers.length; i++) {
headerNumbers[i] = 0;
}
}
}
// Format the header
const formattedHeader = formatHeader(level, m.text, [...headerNumbers], options, previousLevel);
// Calculate adjusted position based on previous replacements
const startPos = m.index + offset;
const endPos = startPos + m.match.length;
// Replace the header in the result string
result = result.substring(0, startPos) + formattedHeader + result.substring(endPos);
// Update offset for subsequent replacements
offset += formattedHeader.length - m.match.length;
previousLevel = level;
}
return result;
}
/**
* Extracts header formatting options from metadata
*
* Parses document metadata to extract header formatting configuration,
* including level-specific formatting templates, indentation settings,
* and numbering behavior options. Provides default values for all options.
*
* @private
* @param {Record<string, any>} metadata - Document metadata containing header configuration
* @param {Object} [processingOptions={}] - Additional processing options that override metadata
* @param {boolean} [processingOptions.noReset] - Disable header numbering reset
* @param {boolean} [processingOptions.noIndent] - Disable header indentation
* @returns {HeaderOptions} Complete header options object with defaults applied
* @example
* ```typescript
* const metadata = {
* 'level-one': 'Chapter %n.',
* 'level-two': 'Section %n.%s',
* 'level-indent': 2.0,
* 'no-reset': true
* };
*
* const options = extractHeaderOptions(metadata);
* console.log(options);
* // Output:
* // {
* // levelOne: 'Chapter %n.',
* // levelTwo: 'Section %n.%s',
* // levelThree: '(%n)',
* // levelFour: '(%n%c)',
* // levelFive: '(%n%c%r)',
* // levelIndent: 2.0,
* // noReset: true,
* // noIndent: false
* // }
* ```
*/
function extractHeaderOptions(metadata, processingOptions = {}) {
return {
levelOne: metadata['level-one'] || 'Article %n.',
levelTwo: metadata['level-two'] || 'Section %n.',
levelThree: metadata['level-three'] || '(%n)',
levelFour: metadata['level-four'] || '(%n%c)',
levelFive: metadata['level-five'] || '(%n%c%r)',
levelSix: metadata['level-six'] || 'Annex %r -',
levelIndent: parseFloat(metadata['level-indent'] || '1.5'),
noReset: processingOptions.noReset || metadata['no-reset'] || false,
noIndent: processingOptions.noIndent || metadata['no-indent'] || false,
enableFieldTrackingInMarkdown: processingOptions.enableFieldTrackingInMarkdown || false,
};
}
/**
* Formats a header according to level and options
*
* Applies formatting templates and numbering schemes to create properly formatted
* headers. Handles placeholder substitution for various numbering systems including
* numeric, alphabetic, and Roman numeral formats. Supports both simple and complex
* hierarchical numbering patterns.
*
* @private
* @param {number} level - Header level (1-6)
* @param {string} text - Header text content
* @param {number[]} headerNumbers - Array tracking header numbering state for all levels
* @param {HeaderOptions} options - Header formatting options and templates
* @param {number} [previousLevel=0] - Previous header level for context-aware formatting
* @returns {string} Formatted header string with proper numbering and indentation
* @example
* ```typescript
* const headerNumbers = [1, 2, 3, 0, 0];
* const options = {
* levelOne: 'Article %n.',
* levelTwo: 'Section %n.',
* levelThree: '(%c)',
* levelIndent: 1.5,
* noIndent: false
* };
*
* const formatted = formatHeader(3, 'Payment Terms', headerNumbers, options);
* // Output: ' (c) Payment Terms'
* ```
*/
function formatHeader(level, text, headerNumbers, options, previousLevel = 0) {
// Validate level
if (level < 1 || level > 6) {
return `l${level}. ${text}`;
}
// Remove cross-reference keys from header text
// This removes |key| patterns from the header display text
const cleanText = text.replace(/\s*\|[\w.-]+\|\s*$/g, '').trim();
// Get format template for this level
const formatTemplate = getFormatTemplate(level, options);
// Ensure formatTemplate is a string
if (typeof formatTemplate !== 'string') {
return `l${level}. ${cleanText}`;
}
// Create formatted header with the right value based on level
let formattedHeader = formatTemplate;
// Detect hierarchical patterns BEFORE any variable replacement
const isHierarchicalRoman = formatTemplate.includes('%r.%n') || formatTemplate.includes('%R.%n');
const isHierarchicalAlpha = formatTemplate.includes('%c.%n');
// Detect broader hierarchical alphabetic context
// If level 1 uses %c and level 2 uses %c.%n, then level 3+ %c should also refer to level 1
const isAlphabeticHierarchicalContext = options.levelOne?.includes('%c') && options.levelTwo?.includes('%c.%n');
// Handle special %02n format (leading zero numbers)
const leadingZeroPattern = /%0(\d+)n/g;
formattedHeader = formattedHeader.replace(leadingZeroPattern, (match, digits) => {
const num = headerNumbers[level - 1];
return num.toString().padStart(parseInt(digits), '0');
});
// Handle special %02s format (leading zero section references)
const leadingZeroSectionPattern = /%0(\d+)s/g;
formattedHeader = formattedHeader.replace(leadingZeroSectionPattern, (match, digits) => {
const num = headerNumbers[0];
return num.toString().padStart(parseInt(digits), '0');
});
// Handle other leading zero patterns for reference variables
const leadingZeroTitlePattern = /%0(\d+)t/g;
formattedHeader = formattedHeader.replace(leadingZeroTitlePattern, (match, digits) => {
const num = headerNumbers[1];
return num.toString().padStart(parseInt(digits), '0');
});
const leadingZeroFourthPattern = /%0(\d+)f/g;
formattedHeader = formattedHeader.replace(leadingZeroFourthPattern, (match, digits) => {
const num = headerNumbers[2];
return num.toString().padStart(parseInt(digits), '0');
});
const leadingZeroFifthPattern = /%0(\d+)i/g;
formattedHeader = formattedHeader.replace(leadingZeroFifthPattern, (match, digits) => {
const num = headerNumbers[3];
return num.toString().padStart(parseInt(digits), '0');
});
// Replace placeholders based on level-specific logic
if (level === 4) {
// Level 4 format: (%n%c) where %n is level 3 number, %c is level 4 letter
// Only apply special level 4 logic for default hierarchical formats like (%n%c)
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]));
}
}
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
// Only apply special level 5 logic for default hierarchical formats like (%n%c%r)
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]));
// Always use lowercase roman numerals for level 5
const useLowercase = true;
formattedHeader = formattedHeader.replace(/%r/g, getRomanNumeral(headerNumbers[4], useLowercase));
}
}
// Handle academic/hierarchical formats first (where %n might refer to level 1 instead of current level)
// Check if format uses academic hierarchical pattern like %n.%s, %n.%s.%t, etc.
// But NOT formats like %r.%n or %c.%n where %n should be current level
// Academic context: if any level uses deeper hierarchical patterns, then %n.%s is also academic
const hasAcademicContext = (typeof options.levelThree === 'string' && options.levelThree.includes('%n.%s.%t')) ||
(typeof options.levelFour === 'string' && options.levelFour.includes('%n.%s.%t.%f')) ||
(typeof options.levelFive === 'string' && options.levelFive.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.');
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 for all levels
// Reference variables represent hierarchical level numbers for academic/structured numbering
// For academic formats: %s = level 2 current number (for formats like %n.%s.%t where %n=level1, %s=level2)
// For simple formats: %s = level 1 reference (for formats like %n.%s where %n=level2, %s=level1)
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 (for formats like %n.%s.%t)
formattedHeader = formattedHeader.replace(/%t/g, headerNumbers[2].toString());
// %f = level 4 current number (for formats like %n.%s.%t.%f)
formattedHeader = formattedHeader.replace(/%f/g, headerNumbers[3].toString());
// %i = level 5 current number (for formats like %n.%s.%t.%f.%i)
formattedHeader = formattedHeader.replace(/%i/g, headerNumbers[4].toString());
// Replace %c (alphabetic) for levels 1-5 (when not handled by special logic above)
// Special handling for hierarchical formats like %c.%n where %c refers to level 1
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 if (isAlphabeticHierarchicalContext && level > 1) {
// In broader alphabetic hierarchical context, %c in levels 3+ refers to level 1
formattedHeader = formattedHeader.replace(/%c/g, getAlphaLabel(headerNumbers[0]));
}
else if (level <= 3 || (level >= 4 && formattedHeader.includes('%c'))) {
formattedHeader = formattedHeader.replace(/%c/g, getAlphaLabel(headerNumbers[level - 1]));
}
// Replace %r (roman) for levels 1-5 (when not handled by special logic above)
// Special handling for hierarchical formats like %r.%n where %r refers to level 1
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 if (level <= 3 || (level >= 4 && formattedHeader.includes('%r'))) {
formattedHeader = formattedHeader.replace(/%r/g, getRomanNumeral(headerNumbers[level - 1], true));
}
// Replace %R (uppercase roman) for levels 1-5
// Special handling for hierarchical formats like %R.%n where %R refers to level 1
const isHierarchicalUppercaseRoman = formatTemplate.includes('%R.%n');
if (isHierarchicalUppercaseRoman && level > 1) {
// In hierarchical formats with %R.%n pattern, %R before dot refers to level 1
formattedHeader = formattedHeader.replace(/%R/g, getRomanNumeral(headerNumbers[0], false));
}
else if (level <= 3 || (level >= 4 && formattedHeader.includes('%R'))) {
formattedHeader = formattedHeader.replace(/%R/g, getRomanNumeral(headerNumbers[level - 1], false));
}
// Apply indentation (unless noIndent is enabled)
const indentation = options.noIndent
? ''
: ' '.repeat(Math.floor((level - 1) * (options.levelIndent || 1.5) * 2));
// Check if field tracking is enabled for header styling
if (options.enableFieldTrackingInMarkdown) {
// Wrap header in span with CSS classes and data attributes for styling
const headerClasses = [
'legal-header',
`legal-header-level-${level}`,
getLevelCssClass(level),
].join(' ');
const spanElement = `<span class="${headerClasses}" ` +
`data-level="${level}" data-number="${headerNumbers[level - 1]}">` +
`${formattedHeader} ${cleanText}</span>`;
return `${indentation}${spanElement}`;
}
return `${indentation}${formattedHeader} ${cleanText}`;
}
/**
* Gets the format template for a specific header level
*
* Retrieves the appropriate formatting template for a given header level,
* with fallback to default templates if custom options are not provided
* or are invalid.
*
* @private
* @param {number} level - Header level (1-6)
* @param {HeaderOptions} options - Header formatting options containing level templates
* @returns {string} Format template string with placeholders
* @example
* ```typescript
* const options = {
* levelOne: 'Chapter %n.',
* levelTwo: 'Section %n.%s',
* levelThree: '(%n)'
* };
*
* const template = getFormatTemplate(2, options);
* // Output: 'Section %n.%s'
*
* const fallbackTemplate = getFormatTemplate(4, {});
* // Output: '(%n%c)' (default for level 4)
* ```
*/
function getFormatTemplate(level, options) {
let template;
switch (level) {
case 1:
template = options.levelOne;
break;
case 2:
template = options.levelTwo;
break;
case 3:
template = options.levelThree;
break;
case 4:
template = options.levelFour;
break;
case 5:
template = options.levelFive;
break;
case 6:
template = options.levelSix;
break;
default:
template = '%n.';
}
// Ensure we always return a string, even if template is null/undefined
if (typeof template !== 'string') {
switch (level) {
case 1:
return 'Article %n.';
case 2:
return 'Section %n.';
case 3:
return '(%n)';
case 4:
return '(%n%c)';
case 5:
return '(%n%c%r)';
case 6:
return 'Annex %r -';
default:
return '%n.';
}
}
return template;
}
/**
* Gets CSS class name for a specific header level
*
* Maps header levels to semantic CSS class names for styling purposes.
* Used when field tracking is enabled to provide consistent styling hooks.
*
* @private
* @param {number} level - Header level (1-6)
* @returns {string} CSS class name for the level
*/
function getLevelCssClass(level) {
switch (level) {
case 1:
return 'legal-article';
case 2:
return 'legal-section';
case 3:
return 'legal-subsection';
case 4:
return 'legal-sub-subsection';
case 5:
return 'legal-paragraph';
case 6:
return 'legal-annex';
default:
return 'legal-header-unknown';
}
}
//# sourceMappingURL=header-processor.js.map