legal-markdown-js
Version:
Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version
430 lines • 18.8 kB
JavaScript
/**
* Remark plugin to parse legal header syntax (l., ll., lll., etc.)
*
* This plugin converts paragraphs that start with legal header patterns
* into proper heading nodes in the AST, which can then be processed
* by the headers plugin for numbering.
*
* @example
* Input:
* ```
* l. First Level Header
* ll. Second Level Header
* lll. Third Level Header
* ```
*
* Converts to:
* ```
* # First Level Header
* ## Second Level Header
* ### Third Level Header
* ```
*
* @module
*/
import { visit } from 'unist-util-visit';
/**
* Pattern to match legal header syntax at the beginning of a line
* Matches: l., ll., lll., ... up to lllllllll. followed by space and text
*/
const LEGAL_HEADER_PATTERN = /^(l{1,9})\.\s+(.+)$/;
/**
* Convert legal header syntax to heading level
*/
function getHeadingLevel(pattern) {
return pattern.length; // 'l' = 1, 'll' = 2, etc.
}
/**
* Check if a paragraph node contains legal header syntax
*/
function isLegalHeader(node) {
// A paragraph with legal header syntax should have a single text child
if (node.children.length === 1 && node.children[0].type === 'text') {
const textNode = node.children[0];
const match = textNode.value.match(LEGAL_HEADER_PATTERN);
if (match) {
const [, levelPattern, headerText] = match;
return {
level: getHeadingLevel(levelPattern),
text: headerText.trim(),
};
}
}
// Also check for multiple children where the first is the pattern
if (node.children.length > 0 && node.children[0].type === 'text') {
const firstChild = node.children[0];
const match = firstChild.value.match(/^(l{1,5})\.\s*/);
if (match) {
const [fullMatch, levelPattern] = match;
// Get the rest of the text from all children
const remainingText = firstChild.value.slice(fullMatch.length);
const otherText = node.children
.slice(1)
.map(child => {
if (child.type === 'text')
return child.value;
if (child.type === 'html')
return child.value || '';
// For other types, we'll need to preserve them as children
return '';
})
.join('');
return {
level: getHeadingLevel(levelPattern),
text: (remainingText + otherText).trim(),
};
}
}
return null;
}
/**
* Convert a paragraph node to a heading node
*/
function convertToHeading(node, level, text) {
// Create a new heading node without the l. prefix
// The headers plugin will add the proper numbering
const heading = {
type: 'heading',
depth: level,
children: parseMarkdownInlineFormatting(text),
};
// Mark this as a legal header
heading.data = { isLegalHeader: true };
// If the original paragraph had HTML children (like field tracking spans),
// we need to preserve them
if (node.children.length > 1 ||
(node.children[0].type === 'text' && node.children[0].value.includes('<span'))) {
// Complex case: rebuild children preserving HTML
const firstChild = node.children[0];
if (firstChild.type === 'text') {
const match = firstChild.value.match(/^(l{1,5})\.\s*/);
if (match) {
const [fullMatch] = match;
firstChild.value = firstChild.value.slice(fullMatch.length);
// Use the original children minus the pattern
heading.children = node.children.filter(child => {
// Skip empty text nodes
if (child.type === 'text' && child.value.trim() === '') {
return false;
}
return true;
});
}
}
}
return heading;
}
/**
* Remark plugin to parse legal header syntax
*/
const remarkLegalHeadersParser = (options = {}) => {
const { debug = false } = options;
return (tree) => {
if (debug) {
console.log('🔍 [remarkLegalHeadersParser] Parsing legal header syntax');
}
let convertedCount = 0;
const nodesToReplace = [];
// Visit all paragraph nodes
visit(tree, 'paragraph', (node, index, parent) => {
// Check if this paragraph contains legal headers (simple or complex)
if (parent && typeof index === 'number') {
// First, try simple case: single text node with newlines
if (node.children.length === 1 && node.children[0].type === 'text') {
const textNode = node.children[0];
const lines = textNode.value.split('\n');
// Check if any line starts with legal header pattern
const hasLegalHeaders = lines.some(line => LEGAL_HEADER_PATTERN.test(line));
if (hasLegalHeaders) {
const newNodes = [];
const nonHeaderLines = [];
// Process each line
for (const line of lines) {
const match = line.match(LEGAL_HEADER_PATTERN);
if (match) {
// If we have accumulated non-header lines, create a paragraph for them
if (nonHeaderLines.length > 0) {
newNodes.push({
type: 'paragraph',
children: [
{
type: 'text',
value: nonHeaderLines.join('\n'),
},
],
});
nonHeaderLines.length = 0;
}
const [, levelPattern, headerText] = match;
const level = getHeadingLevel(levelPattern);
if (debug) {
console.log(`🔄 [remarkLegalHeadersParser] Converting "${line.substring(0, 50)}..." to level ${level} heading`);
}
// Create heading node with markdown formatting preserved
const headingNode = {
type: 'heading',
depth: level,
children: parseMarkdownInlineFormatting(headerText.trim()),
};
// Mark this as a legal header using data attribute
headingNode.data = { isLegalHeader: true };
newNodes.push(headingNode);
convertedCount++;
}
else {
// Not a legal header, accumulate for paragraph
nonHeaderLines.push(line);
}
}
// Handle any remaining non-header lines
if (nonHeaderLines.length > 0) {
newNodes.push({
type: 'paragraph',
children: [
{
type: 'text',
value: nonHeaderLines.join('\n'),
},
],
});
}
// Store replacement info
if (newNodes.length > 0) {
nodesToReplace.push({ parent, index, newNodes });
}
}
}
// Complex case: paragraph with multiple children (text, emphasis, strong, etc.)
else if (node.children.length > 1) {
// Reconstruct the full text content to check for legal headers
let fullText = '';
const childrenMap = new Map();
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i];
const start = fullText.length;
if (child.type === 'text') {
fullText += child.value;
}
else if (child.type === 'emphasis') {
const emphasisText = child.children.map((c) => c.value || '').join('');
fullText += `_${emphasisText}_`;
}
else if (child.type === 'strong') {
const strongText = child.children.map((c) => c.value || '').join('');
fullText += `__${strongText}__`;
}
else if (child.type === 'link') {
// Handle links - reconstruct markdown syntax [text](url)
const linkText = child.children.map((c) => c.value || '').join('');
const url = child.url || '';
fullText += `[${linkText}](${url})`;
}
else if (child.type === 'inlineCode') {
// Handle inline code - reconstruct markdown syntax `code`
fullText += `\`${child.value || ''}\``;
}
else {
// For other node types, try to extract text or use placeholder
fullText += child.value || '';
}
const end = fullText.length;
childrenMap.set(i, { start, end, child });
}
const lines = fullText.split('\n');
const hasLegalHeaders = lines.some(line => LEGAL_HEADER_PATTERN.test(line));
if (hasLegalHeaders) {
const newNodes = [];
const nonHeaderLines = [];
// Process each line
for (const line of lines) {
const match = line.match(LEGAL_HEADER_PATTERN);
if (match) {
// If we have accumulated non-header lines, create a paragraph for them
if (nonHeaderLines.length > 0) {
newNodes.push({
type: 'paragraph',
children: [
{
type: 'text',
value: nonHeaderLines.join('\n'),
},
],
});
nonHeaderLines.length = 0;
}
const [, levelPattern, headerText] = match;
const level = getHeadingLevel(levelPattern);
if (debug) {
console.log(`🔄 [remarkLegalHeadersParser] Converting complex "${line.substring(0, 50)}..." to level ${level} heading`);
}
// For complex headers, we already have the formatted text with markdown syntax
// Parse it again to get proper AST nodes
const headingNode = {
type: 'heading',
depth: level,
children: parseMarkdownInlineFormatting(headerText.trim()),
};
// Mark this as a legal header using data attribute
headingNode.data = { isLegalHeader: true };
newNodes.push(headingNode);
convertedCount++;
}
else {
// Not a legal header, accumulate for paragraph
nonHeaderLines.push(line);
}
}
// Handle any remaining non-header lines
if (nonHeaderLines.length > 0) {
newNodes.push({
type: 'paragraph',
children: [
{
type: 'text',
value: nonHeaderLines.join('\n'),
},
],
});
}
// Store replacement info
if (newNodes.length > 0) {
nodesToReplace.push({ parent, index, newNodes });
}
}
}
}
else {
// Handle regular single-line legal headers
if (debug) {
const firstChildText = node.children[0]?.type === 'text' ? node.children[0].value : '<non-text>';
console.log(`🔍 [remarkLegalHeadersParser] Single-line paragraph: "${firstChildText}"`);
}
const headerInfo = isLegalHeader(node);
if (headerInfo && parent && typeof index === 'number') {
if (debug) {
const firstChildText = node.children[0].type === 'text' ? node.children[0].value : '';
console.log(`🔄 [remarkLegalHeadersParser] Converting "${firstChildText.substring(0, 50)}..." to level ${headerInfo.level} heading`);
}
// Replace the paragraph with a heading
const heading = convertToHeading(node, headerInfo.level, headerInfo.text);
nodesToReplace.push({ parent, index, newNodes: [heading] });
convertedCount++;
}
}
});
// Apply replacements in reverse order to maintain correct indices
for (let i = nodesToReplace.length - 1; i >= 0; i--) {
const { parent, index, newNodes } = nodesToReplace[i];
parent.children.splice(index, 1, ...newNodes);
}
if (debug) {
console.log(`✅ [remarkLegalHeadersParser] Converted ${convertedCount} legal headers`);
}
};
};
/**
* Parse inline markdown formatting (bold, italic) within text
* Converts _text_ to emphasis, __text__ to strong, *text* to emphasis, **text** to strong
*/
function parseMarkdownInlineFormatting(text) {
const children = [];
const currentPos = 0;
// Patterns for markdown formatting (order matters - longer patterns first)
const patterns = [
{ regex: /\[([^\]]+)\]\([^)]+\)/g, type: 'link' }, // [text](url) - extract just the text
{ regex: /`([^`]+)`/g, type: 'inlineCode' }, // `code`
{ regex: /\*\*(.+?)\*\*/g, type: 'strong' }, // **bold**
{ regex: /__(.+?)__/g, type: 'strong' }, // __bold__
{ regex: /\*(.+?)\*/g, type: 'emphasis' }, // *italic*
{ regex: /_(.+?)_/g, type: 'emphasis' }, // _italic_
];
// Find all matches for all patterns
const allMatches = [];
for (const pattern of patterns) {
let match;
while ((match = pattern.regex.exec(text)) !== null) {
allMatches.push({
start: match.index,
end: match.index + match[0].length,
type: pattern.type,
content: match[1],
});
}
}
// Sort matches by position
allMatches.sort((a, b) => a.start - b.start);
// Remove overlapping matches (keep the first one)
const validMatches = [];
let lastEnd = 0;
for (const match of allMatches) {
if (match.start >= lastEnd) {
validMatches.push(match);
lastEnd = match.end;
}
}
// Build the children array
let pos = 0;
for (const match of validMatches) {
// Add text before the match
if (pos < match.start) {
const beforeText = text.slice(pos, match.start);
if (beforeText) {
children.push({
type: 'text',
value: beforeText,
});
}
}
// Add the formatted node
if (match.type === 'inlineCode') {
// Inline code nodes have a value property, not children
children.push({
type: 'inlineCode',
value: match.content,
});
}
else if (match.type === 'link') {
// For links, we only want the text content in headers (no actual link)
children.push({
type: 'text',
value: match.content,
});
}
else {
// For emphasis and strong, use children structure
children.push({
type: match.type,
children: [
{
type: 'text',
value: match.content,
},
],
});
}
pos = match.end;
}
// Add remaining text
if (pos < text.length) {
const remainingText = text.slice(pos);
if (remainingText) {
children.push({
type: 'text',
value: remainingText,
});
}
}
// If no formatting was found, return simple text node
if (children.length === 0) {
return [
{
type: 'text',
value: text,
},
];
}
return children;
}
export default remarkLegalHeadersParser;
export { remarkLegalHeadersParser };
//# sourceMappingURL=legal-headers-parser.js.map