UNPKG

legal-markdown-js

Version:

Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version

301 lines 11.7 kB
/** * Remark plugin for Legal Markdown date processing * * This plugin processes date references in Legal Markdown documents using * AST-based processing. It handles @today tokens with format specifiers and * arithmetic operations, integrating with the comprehensive date helper system. * * Features: * - @today token processing with format specifiers (@today[format]) * - Date arithmetic with +/- operations (@today+30, @today-365) * - Advanced date formatting (legal, ISO, US, European, etc.) * - Timezone and locale support * - Integration with field tracking for highlighting * - Comprehensive error handling with fallback formatting * * Architecture: * 1. Scan document content for @today patterns * 2. Parse format specifiers and arithmetic operations * 3. Process dates using advanced date helpers * 4. Replace tokens with formatted dates * 5. Integrate with field tracker for highlighting support * * @example * ```typescript * import { unified } from 'unified'; * import remarkParse from 'remark-parse'; * import remarkDates from './dates.js'; * * const processor = unified() * .use(remarkParse) * .use(remarkDates, { * metadata: { 'date-format': 'legal', timezone: 'America/New_York' }, * enableFieldTracking: true * }); * * const result = await processor.process('Contract signed on @today[legal].'); * // Result: "Contract signed on January 15th, 2024." * ``` * * @module */ import { visit } from 'unist-util-visit'; // Date processing functionality is implemented directly in this plugin import { addDays, addMonths, addYears } from '../../extensions/helpers/advanced-date-helpers.js'; import { fieldTracker } from '../../extensions/tracking/field-tracker.js'; /** * Pattern to match @today with optional arithmetic and format specifiers * Uses alternation to match valid patterns: arithmetic+format, arithmetic only, or format only */ const EXTENDED_DATE_PATTERN = /@today((?:[+-]\d+[dmy]?(?:\[[^\]]+\])?)|(?:[+-]\d+[dmy]?)|(?:\[[^\]]+\]))?/g; /** * Parse date token to extract arithmetic and format * @param token - The full token after @today (e.g., "+30[US]", "[legal]", "+1y") * @returns Object with arithmetic operation and format, or null if invalid */ function parseDateToken(token) { if (!token) return { arithmetic: null, format: null, isValid: true }; // Check if the token contains only valid characters for date processing if (!/^[+-]?\d*[dmy]?(\[[^\]]+\])?$/.test(token)) { return { arithmetic: null, format: null, isValid: false }; } // Extract format first [format] const formatMatch = token.match(/\[([^\]]+)\]/); const format = formatMatch ? formatMatch[1] : null; // Remove format part to get arithmetic part const arithmeticPart = token.replace(/\[([^\]]+)\]/, ''); // Parse arithmetic operation only if there is an arithmetic part let arithmetic = null; if (arithmeticPart) { const arithmeticMatch = arithmeticPart.match(/^([+-])(\d+)([dmy]?)$/); if (arithmeticMatch) { const [, sign, amount, suffix] = arithmeticMatch; const numAmount = parseInt(amount) * (sign === '-' ? -1 : 1); switch (suffix) { case 'd': case '': arithmetic = { type: 'days', amount: numAmount }; break; case 'm': arithmetic = { type: 'months', amount: numAmount }; break; case 'y': arithmetic = { type: 'years', amount: numAmount }; break; default: arithmetic = { type: 'days', amount: numAmount }; } } else if (arithmeticPart !== '') { // Invalid arithmetic format return { arithmetic: null, format: null, isValid: false }; } } return { arithmetic, format, isValid: true }; } /** * Legacy function for backwards compatibility */ function parseArithmetic(operation) { const result = parseDateToken(operation); return result.arithmetic; } /** * Apply arithmetic operation to a date */ function applyDateArithmetic(baseDate, arithmetic) { if (!arithmetic) return baseDate; switch (arithmetic.type) { case 'days': return addDays(baseDate, arithmetic.amount); case 'months': return addMonths(baseDate, arithmetic.amount); case 'years': return addYears(baseDate, arithmetic.amount); default: return baseDate; } } // Internal function removed - functionality is implemented in processDateReferencesInAST /** * Basic date formatting fallback */ function formatDateBasic(date, format) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const dayNumber = date.getDate(); // Month names for legal format const monthNames = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; switch (format.toLowerCase()) { case 'iso': case 'yyyy-mm-dd': return `${year}-${month}-${day}`; case 'us': case 'mm/dd/yyyy': return `${month}/${day}/${year}`; case 'eu': case 'dd/mm/yyyy': return `${day}/${month}/${year}`; case 'legal': // Legal format without ordinal suffix as expected by tests return `${monthNames[date.getMonth()]} ${dayNumber}, ${year}`; default: return `${year}-${month}-${day}`; } } /** * Get CSS class for date field based on its processing status */ function getDateFieldCssClass(hasArithmetic) { return hasArithmetic ? 'legal-field highlight' : 'legal-field imported-value'; } /** * Format date value with optional field tracking wrapper */ function formatDateValue(value, originalToken, enableFieldTracking = false, hasArithmetic = false) { if (!enableFieldTracking) { return value; } const cssClass = getDateFieldCssClass(hasArithmetic); const fieldName = `date.${originalToken.replace(/[@[\]]/g, '')}`; return `<span class="${cssClass}" data-field="${fieldName.replace(/"/g, '&quot;')}">${value}</span>`; } /** * Process date references in text nodes with field tracking */ function processDateReferencesInAST(root, metadata, enableFieldTracking = false) { visit(root, 'text', (node, index, parent) => { const originalValue = node.value; let modifiedValue = originalValue; let hasChanges = false; // Process extended date patterns with arithmetic modifiedValue = modifiedValue.replace(EXTENDED_DATE_PATTERN, (match, token) => { try { // Parse the token to get arithmetic and format const { arithmetic, format: tokenFormat, isValid } = parseDateToken(token); // If token is invalid, return original match if (!isValid) { return match; } // Use current date as base let date = new Date(); const hasArithmetic = !!arithmetic; // Apply arithmetic if present if (arithmetic) { date = applyDateArithmetic(date, arithmetic); } // Determine format const format = tokenFormat || metadata['date-format'] || 'YYYY-MM-DD'; // Format the date using our basic formatter const formattedDate = formatDateBasic(date, format); // Track the date field for statistics fieldTracker.trackField(`date.${match.replace(/[@[\]]/g, '')}`, { value: formattedDate, originalValue: match, hasLogic: hasArithmetic, }); hasChanges = true; return formatDateValue(formattedDate, match, enableFieldTracking, hasArithmetic); } catch (error) { console.warn(`Error processing date reference ${match}:`, error); return match; // Return original on error } }); // Update node value if changes were made if (hasChanges) { // 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; } } }); // Also process HTML nodes that might contain date references visit(root, 'html', (node) => { if (!node.value || !node.value.includes('@today')) { return; } let modifiedValue = node.value; let hasChanges = false; // Process basic date patterns in HTML content modifiedValue = modifiedValue.replace(EXTENDED_DATE_PATTERN, (match, formatOverride) => { try { // Use current date const date = new Date(); // Determine format const format = formatOverride || metadata['date-format'] || 'YYYY-MM-DD'; // Format the date const formattedDate = formatDateBasic(date, format); // Track the date field for statistics fieldTracker.trackField(`date.${match.replace(/[@[\]]/g, '')}`, { value: formattedDate, originalValue: match, hasLogic: false, }); hasChanges = true; // For HTML nodes, generate HTML span if field tracking is enabled if (enableFieldTracking) { const cssClass = getDateFieldCssClass(false); const fieldName = `date.${match.replace(/[@[\]]/g, '')}`; return (`<span class="${cssClass}" ` + `data-field="${fieldName.replace(/"/g, '&quot;')}">${formattedDate}</span>`); } else { return formattedDate; } } catch (error) { console.warn(`Error processing date reference ${match}:`, error); return match; } }); // Update HTML node value if changes were made if (hasChanges) { node.value = modifiedValue; } }); } /** * Remark plugin for processing date references in Legal Markdown documents */ const remarkDates = options => { const { metadata, debug = false, enableFieldTracking = false } = options; return (tree) => { if (debug) { console.log('📅 Processing date references with remark plugin'); } // Process date references in the AST processDateReferencesInAST(tree, metadata, enableFieldTracking); if (debug) { console.log('✅ Date reference processing completed'); } }; }; export default remarkDates; //# sourceMappingURL=dates.js.map