legal-markdown-js
Version:
Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version
441 lines • 19.1 kB
JavaScript
/**
* Legal Markdown Processor with Remark Pipeline
*
* This module provides a comprehensive processor for Legal Markdown documents
* using the remark ecosystem. It combines multiple remark plugins to provide
* AST-based processing that avoids text contamination issues.
*
* Features:
* - Complete remark pipeline with all Legal Markdown plugins
* - Field tracking integration with automatic clearing
* - Cross-reference processing with section numbering
* - Metadata extraction and processing
* - Comprehensive error handling and debugging
* - Compatible with existing Legal Markdown API
*
* @example
* ```typescript
* import { processLegalMarkdownWithRemark } from './legal-markdown-processor.js';
*
* const result = await processLegalMarkdownWithRemark(content, {
* basePath: './documents',
* enableFieldTracking: true,
* debug: true
* });
*
* console.log(result.content); // Processed markdown
* console.log(result.metadata); // Extracted metadata
* console.log(result.fieldReport); // Field tracking report
* ```
*
* @module
*/
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkStringify from 'remark-stringify';
import { remarkCrossReferences, remarkFieldTracking, remarkTemplateFields, remarkHeaders, remarkClauses, remarkMixins, remarkImports, remarkLegalHeadersParser, remarkDates, } from '../../plugins/remark/index.js';
import { fieldTracker } from '../tracking/field-tracker.js';
import { parseYamlFrontMatter } from '../../core/parsers/yaml-parser.js';
import { processTemplateLoops } from '../template-loops.js';
import { exportMetadata } from '../../core/exporters/metadata-exporter.js';
/**
* Create a configured remark processor for Legal Markdown
*
* This function assembles a unified processor with all the necessary remark plugins
* for Legal Markdown processing. Plugins are added in a specific order to ensure
* proper processing dependencies:
* 1. Imports (must be first to process imported content)
* 2. Mixins (content expansion before other processing)
* 3. Clauses (conditional content)
* 4. Template fields (field processing and tracking)
* 5. Cross-references (reference resolution)
* 6. Headers (final structure processing)
*
* @param metadata - Document metadata from YAML frontmatter and additional sources
* @param options - Configuration options for processing
* @returns Configured unified processor ready for content processing
* @internal
*/
function createLegalMarkdownProcessor(metadata, options) {
const processor = unified().use(remarkParse);
// Add legal headers parser FIRST to convert l., ll., etc. to proper headers
// This must run before any other plugin that depends on headers
if (!options.noHeaders) {
processor.use(remarkLegalHeadersParser, {
debug: options.debug,
});
}
// Add imports plugin first (must be processed before other content)
if (!options.noImports) {
processor.use(remarkImports, {
basePath: options.basePath || '.',
mergeMetadata: true,
debug: options.debug,
maxDepth: 10,
timeoutMs: 30000,
filterReserved: true,
validateTypes: true,
logImportOperations: options.debug,
});
}
// Add mixins plugin (should be processed early for content expansion)
if (!options.noMixins) {
processor.use(remarkMixins, {
metadata,
basePath: options.basePath || '.',
debug: options.debug,
maxDepth: 5,
});
}
// Skip loop processing plugins since we pre-process loops
// Consolidation and loop processing now happen before remark parsing
// Add clauses plugin (conditional content processing)
if (!options.noClauses) {
processor.use(remarkClauses, {
metadata,
debug: options.debug,
enableFieldTracking: options.enableFieldTracking,
});
}
// Add dates plugin (processes @today tokens with arithmetic and formatting)
processor.use(remarkDates, {
metadata,
debug: options.debug,
enableFieldTracking: options.enableFieldTracking,
});
// Add template fields plugin (processes {{field}} patterns)
processor.use(remarkTemplateFields, {
metadata,
fieldPatterns: options.fieldPatterns,
enableFieldTracking: options.enableFieldTracking,
debug: options.debug,
});
// Add cross-references plugin unless disabled
if (!options.disableCrossReferences && !options.noReferences) {
processor.use(remarkCrossReferences, {
metadata,
enableFieldTracking: options.enableFieldTracking,
debug: options.debug,
});
}
// Add headers plugin (should be processed after content is finalized)
if (!options.noHeaders) {
processor.use(remarkHeaders, {
metadata,
noReset: options.noReset,
noIndent: options.noIndent,
debug: options.debug,
});
}
// Add field tracking plugin unless disabled
// Note: remarkTemplateFields already handles field tracking, so we only
// use remarkFieldTracking if template field processing is disabled
// Currently disabled as remarkTemplateFields handles this functionality
// eslint-disable-next-line no-constant-condition
if (false && !options.disableFieldTracking && options.enableFieldTracking) {
processor.use(remarkFieldTracking, {
metadata,
patterns: options.fieldPatterns,
debug: options.debug,
});
}
// Add stringify plugin with proper markdown formatting
processor.use(remarkStringify, {
emphasis: '*', // Use * for _italic_
strong: '*', // Use * for __bold__ (single asterisk means **double**)
bullet: '-', // Use - for bullets
fence: '`', // Use ` for code blocks
fences: true, // Use fenced code blocks
incrementListMarker: true, // Increment list markers (1. 2. 3.)
});
return processor;
}
/**
* Process Legal Markdown content using remark pipeline
*
* This is the main entry point for remark-based Legal Markdown processing.
* It provides a complete AST-based processing pipeline with:
* - YAML frontmatter parsing and metadata extraction
* - Template field processing with nested helper support
* - Cross-reference resolution and section numbering
* - Field tracking for document highlighting
* - Import processing for modular documents
* - Comprehensive error handling and debugging
*
* @param content - Raw Legal Markdown content to process
* @param options - Processing options and configuration
* @returns Promise resolving to processed content, metadata, and statistics
*
* @example
* ```typescript
* const result = await processLegalMarkdownWithRemark(markdownContent, {
* enableFieldTracking: true,
* basePath: './templates',
* debug: true
* });
*
* console.log(result.content); // Processed markdown
* console.log(result.stats.fieldsTracked); // Number of tracked fields
* ```
*/
export async function processLegalMarkdownWithRemark(content, options = {}) {
const startTime = Date.now();
const warnings = [];
const pluginsUsed = ['remarkParse', 'remarkStringify'];
try {
// Clear field tracker at the beginning
fieldTracker.clear();
if (options.debug) {
console.log('🔄 Starting Legal Markdown processing with remark pipeline');
}
// Parse YAML front matter first
if (options.debug) {
console.log('[legal-markdown-processor] Raw content length:', content.length);
console.log('[legal-markdown-processor] Raw content first 500 chars:', content.substring(0, 500));
}
const { content: contentWithoutYaml, metadata: yamlMetadata } = parseYamlFrontMatter(content, options.throwOnYamlError);
if (options.debug) {
console.log('[legal-markdown-processor] YAML parsed:', Object.keys(yamlMetadata));
console.log('[legal-markdown-processor] Sample metadata:', yamlMetadata);
}
// If key processing is disabled, return original content without remark processing
// Check if the main content-altering features are disabled
const keyProcessingDisabled = options.noHeaders && options.noReferences && options.noMixins;
if (options.debug) {
console.log('[legal-markdown-processor] Processing flags:', {
noHeaders: options.noHeaders,
noReferences: options.noReferences,
noMixins: options.noMixins,
enableFieldTracking: options.enableFieldTracking,
keyProcessingDisabled,
});
}
if (keyProcessingDisabled && !options.enableFieldTracking) {
if (options.debug) {
console.log('[legal-markdown-processor] Key processing disabled, returning original content');
}
return {
content: contentWithoutYaml,
metadata: { ...yamlMetadata, ...options.additionalMetadata },
stats: {
processingTime: Date.now() - startTime,
pluginsUsed: [],
crossReferencesFound: 0,
fieldsTracked: 0,
},
warnings: [],
};
}
// If only processing YAML, return early
if (options.yamlOnly) {
return {
content: contentWithoutYaml,
metadata: yamlMetadata,
stats: {
processingTime: Date.now() - startTime,
pluginsUsed: [],
crossReferencesFound: 0,
fieldsTracked: 0,
},
warnings: [],
};
}
// Merge metadata sources
const combinedMetadata = {
...yamlMetadata,
...options.additionalMetadata,
};
if (options.debug) {
console.log('[legal-markdown-processor] Combined metadata keys:', Object.keys(combinedMetadata));
console.log('[legal-markdown-processor] Combined metadata sample:', combinedMetadata);
console.log('[legal-markdown-processor] Services structure:', JSON.stringify(combinedMetadata.services, null, 2));
console.log('[legal-markdown-processor] Milestones structure:', JSON.stringify(combinedMetadata.milestones, null, 2));
}
// Preprocess custom field patterns to avoid HTML parsing issues
let preprocessedContent = contentWithoutYaml;
const fieldMappings = new Map(); // Maps normalized patterns to original patterns
if (options.debug) {
console.log('[legal-markdown-processor] About to check for pre-processing...');
}
if (options.fieldPatterns && options.fieldPatterns.length > 0) {
for (const pattern of options.fieldPatterns) {
if (pattern !== '{{(.+?)}}') {
// Skip default pattern
const regex = new RegExp(pattern, 'g');
preprocessedContent = preprocessedContent.replace(regex, (match, fieldName) => {
const normalizedPattern = `{{${fieldName}}}`;
fieldMappings.set(normalizedPattern, match);
return normalizedPattern;
});
}
}
}
// Pre-process template loops before remark parsing
if (!options.noClauses) {
if (options.debug) {
console.log('[legal-markdown-processor] Pre-processing template loops...');
}
preprocessedContent = processTemplateLoops(preprocessedContent, combinedMetadata, undefined, // context
options.enableFieldTracking || false);
if (options.debug) {
console.log('[legal-markdown-processor] Content after processTemplateLoops (first 800 chars):');
console.log(preprocessedContent.substring(0, 800));
}
if (options.debug) {
console.log('[legal-markdown-processor] Template loops processed');
if (preprocessedContent.includes('{{#')) {
console.log('[legal-markdown-processor] WARNING: Content still contains {{# patterns after loop processing!');
}
}
}
// Store field mappings in metadata for later use
combinedMetadata['_field_mappings'] = fieldMappings;
// Ensure default pattern is always included for template field processing
// since preprocessing normalizes all patterns to {{field}} format
const processingOptions = {
...options,
fieldPatterns: options.fieldPatterns && options.fieldPatterns.length > 0
? ['{{(.+?)}}', ...options.fieldPatterns]
: undefined,
};
// Create and configure processor
const processor = createLegalMarkdownProcessor(combinedMetadata, processingOptions);
// Track which plugins are being used
pluginsUsed.push('remarkTemplateFields');
if (!options.disableCrossReferences && !options.noReferences) {
pluginsUsed.push('remarkCrossReferences');
}
if (!options.disableFieldTracking && options.enableFieldTracking) {
pluginsUsed.push('remarkFieldTracking');
}
if (options.debug) {
console.log(`📋 Using plugins: ${pluginsUsed.join(', ')}`);
}
// Process the content (field highlighting is now done during AST processing)
const result = await processor.process(preprocessedContent);
const processedContent = String(result);
// Extract imported metadata from the processed tree
const processedTree = result.history[0]; // Get the processed tree
const importedMetadata = processedTree?._importedMetadata || {};
// Merge imported metadata with combined metadata (imported metadata has lower priority)
const finalMetadata = {
...importedMetadata,
...combinedMetadata,
};
if (options.debug && Object.keys(importedMetadata).length > 0) {
console.log(`📦 Merged ${Object.keys(importedMetadata).length} imported metadata fields:`, Object.keys(importedMetadata));
}
// Extract processing statistics
const crossReferencesFound = combinedMetadata['_cross_references']?.length || 0;
const fieldsTracked = options.enableFieldTracking ? fieldTracker.getTotalOccurrences() : 0;
// Generate field report if field tracking is enabled
let fieldReport;
if (options.enableFieldTracking) {
const fields = fieldTracker.getFields();
fieldReport = {
totalFields: fieldsTracked,
uniqueFields: fields.size,
fields: fields,
};
}
// Handle metadata export if requested
let exportedFiles = [];
if (options.exportMetadata) {
try {
const exportResult = exportMetadata(finalMetadata, options.exportFormat, options.exportPath);
exportedFiles = exportResult.exportedFiles;
if (options.debug) {
console.log(`📁 Exported metadata files: ${exportedFiles.join(', ')}`);
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
warnings.push(`Failed to export metadata: ${errorMessage}`);
if (options.debug) {
console.warn('⚠️ Metadata export failed:', error);
}
}
}
if (options.debug) {
console.log(`✅ Processing completed in ${Date.now() - startTime}ms`);
console.log(`📊 Cross-references found: ${crossReferencesFound}`);
console.log(`📋 Fields tracked: ${fieldsTracked}`);
}
return {
content: processedContent,
metadata: finalMetadata,
exportedFiles: exportedFiles.length > 0 ? exportedFiles : undefined,
fieldReport,
stats: {
processingTime: Date.now() - startTime,
pluginsUsed,
crossReferencesFound,
fieldsTracked,
},
warnings,
};
}
catch (error) {
if (options.debug) {
console.error('❌ Legal Markdown processing failed:', error);
}
// Re-throw with additional context
const enhancedError = new Error(`Legal Markdown processing failed: ${error instanceof Error ? error.message : String(error)}`);
// Store the original error for debugging (avoiding .cause for compatibility)
enhancedError.originalError = error;
throw enhancedError;
}
}
/**
* Synchronous version of the Legal Markdown processor
*
* Note: This is not truly synchronous as remark is async.
* This is a simplified fallback that throws an error suggesting async usage.
* For real sync processing, use the legacy processor.
*/
export function processLegalMarkdownWithRemarkSync(content, options = {}) {
// Suppress ESLint warning for unused parameter
void content;
void options;
throw new Error('Synchronous processing is not available with the remark-based processor. ' +
'Please use processLegalMarkdownWithRemark() (async) instead, ' +
'or use the legacy processor for sync operations.');
}
/**
* Create a pre-configured processor instance for reuse
*
* This factory function creates a reusable processor instance with pre-configured
* options. Useful for batch processing multiple documents with the same settings.
*
* @param options - Base configuration options for all processing operations
* @returns Object with async and sync processing methods
*
* @example
* ```typescript
* const processor = createReusableLegalMarkdownProcessor({
* enableFieldTracking: true,
* basePath: './templates'
* });
*
* const result1 = await processor.process(content1);
* const result2 = await processor.process(content2, { debug: true });
* ```
*/
export function createReusableLegalMarkdownProcessor(options = {}) {
return {
async process(content, additionalOptions = {}) {
return processLegalMarkdownWithRemark(content, {
...options,
...additionalOptions,
});
},
processSync(content, additionalOptions = {}) {
return processLegalMarkdownWithRemarkSync(content, {
...options,
...additionalOptions,
});
},
};
}
// Types are already exported inline, no need for separate export
//# sourceMappingURL=legal-markdown-processor.js.map