legal-markdown-js
Version:
Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version
343 lines • 13.8 kB
JavaScript
/**
* 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
*
* Ordering:
* - Run before `remarkTemplateFields` so `{{@today...}}` arithmetic expressions
* are resolved before general `{{...}}` field expansion.
*
* @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';
import { fieldSpan } from '../../extensions/tracking/field-span.js';
import { logger } from '../../utils/logger.js';
/**
* Pattern to match @today with optional chained arithmetic and format specifiers.
* Supports one or more arithmetic segments (e.g. +2y-90d) followed by an optional
* format specifier (e.g. [US]), or a format specifier alone.
*
* Examples: @today, @today+30, @today+2y-90d, @today+2y-90d[US], @today[legal]
*/
const WRAPPED_DATE_PATTERN = /\{\{\s*(@today((?:[+-]\d+[dmy]?)+(?:\[[^\]]+\])?|\[[^\]]+\])?)\s*\}\}/g;
/**
* Parse date token to extract a list of arithmetic operations and an optional format.
* @param token - Everything after @today (e.g. "+2y-90d[US]", "[legal]", "+1y", "")
* @returns { arithmeticList, format, isValid }
*/
function parseDateToken(token) {
if (!token)
return { arithmeticList: [], format: null, isValid: true };
// Valid token: zero-or-more arithmetic segments + optional [format]
if (!/^(?:[+-]\d+[dmy]?)*(?:\[[^\]]+\])?$/.test(token)) {
return { arithmeticList: [], format: null, isValid: false };
}
// Extract optional [format] specifier
const formatMatch = token.match(/\[([^\]]+)\]/);
const format = formatMatch ? formatMatch[1] : null;
// Remove [format] to isolate arithmetic part
const arithmeticPart = token.replace(/\[[^\]]+\]/, '');
// Parse every [+-]\d+[dmy]? segment in order
const arithmeticList = [];
const opRe = /([+-])(\d+)([dmy]?)/g;
let m;
while ((m = opRe.exec(arithmeticPart)) !== null) {
const [, sign, digits, suffix] = m;
const amount = parseInt(digits) * (sign === '-' ? -1 : 1);
switch (suffix) {
case 'm':
arithmeticList.push({ type: 'months', amount });
break;
case 'y':
arithmeticList.push({ type: 'years', amount });
break;
default: // 'd' or no suffix → days
arithmeticList.push({ type: 'days', amount });
}
}
return { arithmeticList, format, isValid: true };
}
/**
* 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
/**
* Get ordinal suffix for a day number (st, nd, rd, th)
*
* Returns the appropriate English ordinal suffix for a given day number.
* Special handling for 11-13: these always end in "th" (11th, 12th, 13th)
* rather than following the last-digit rule (which would give 11st, 12nd, 13rd).
* This is because the teens are irregular in English ordinal numbering.
*
* @param day - The day number (1-31)
* @returns The ordinal suffix ('st', 'nd', 'rd', or 'th')
*
* @example
* getOrdinalSuffix(1) // returns 'st' -> "1st"
* getOrdinalSuffix(2) // returns 'nd' -> "2nd"
* getOrdinalSuffix(3) // returns 'rd' -> "3rd"
* getOrdinalSuffix(11) // returns 'th' -> "11th" (special case)
* getOrdinalSuffix(21) // returns 'st' -> "21st"
*/
function getOrdinalSuffix(day) {
// Handle special case: 11th, 12th, 13th (teens are irregular in English)
if (day >= 11 && day <= 13) {
return 'th';
}
// Handle regular cases based on last digit
switch (day % 10) {
case 1:
return 'st';
case 2:
return 'nd';
case 3:
return 'rd';
default:
return 'th';
}
}
/**
* 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 'european':
case 'dd/mm/yyyy':
return `${day}/${month}/${year}`;
case 'long':
// Long format: "March 15, 2024"
return `${monthNames[date.getMonth()]} ${dayNumber}, ${year}`;
case 'medium':
// Medium format: "Mar 15, 2024"
return `${monthNames[date.getMonth()].substring(0, 3)} ${dayNumber}, ${year}`;
case 'short':
// Short format: "Mar 15, 24"
return `${monthNames[date.getMonth()].substring(0, 3)} ${dayNumber}, ${String(year).substring(2)}`;
case 'legal': {
// Legal format with ordinal suffix: "March 15th, 2024"
const suffix = getOrdinalSuffix(dayNumber);
return `${monthNames[date.getMonth()]} ${dayNumber}${suffix}, ${year}`;
}
default:
return `${year}-${month}-${day}`;
}
}
/**
* Format date value with optional field tracking wrapper
*/
function formatDateValue(value, originalToken, enableFieldTracking = false, hasArithmetic = false) {
if (!enableFieldTracking) {
return value;
}
const fieldName = `date.${originalToken.replace(/[@[\]]/g, '')}`;
const kind = hasArithmetic ? 'highlight' : 'imported';
return fieldSpan(fieldName, value, kind);
}
/**
* 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 wrapped {{@today...}} tokens with arithmetic
modifiedValue = modifiedValue.replace(WRAPPED_DATE_PATTERN, (match, fullToken, token) => {
try {
// Parse the token to get arithmetic operations and format
const { arithmeticList, format: tokenFormat, isValid } = parseDateToken(token);
// If token is invalid, return original match
if (!isValid) {
return match;
}
// Use current date as base, then apply each arithmetic step in order
let date = new Date();
const hasArithmetic = arithmeticList.length > 0;
for (const op of arithmeticList) {
date = applyDateArithmetic(date, op);
}
// Determine format
const format = tokenFormat ||
(typeof metadata['date-format'] === 'string' ? 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.${fullToken.replace(/[@[\]]/g, '')}`, {
value: formattedDate,
originalValue: match,
hasLogic: hasArithmetic,
});
hasChanges = true;
return formatDateValue(formattedDate, fullToken, enableFieldTracking, hasArithmetic);
}
catch (error) {
logger.warn(`Error processing date reference ${match}:`, error instanceof Error ? error.message : String(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('{{') || !node.value.includes('@today')) {
return;
}
let modifiedValue = node.value;
let hasChanges = false;
// Process wrapped {{@today...}} tokens in HTML content
modifiedValue = modifiedValue.replace(WRAPPED_DATE_PATTERN, (match, fullToken, token) => {
try {
const { arithmeticList, format: tokenFormat, isValid } = parseDateToken(token);
if (!isValid) {
return match;
}
let date = new Date();
const hasArithmetic = arithmeticList.length > 0;
for (const op of arithmeticList) {
date = applyDateArithmetic(date, op);
}
// Determine format
const format = tokenFormat ||
(typeof metadata['date-format'] === 'string' ? metadata['date-format'] : 'YYYY-MM-DD');
// Format the date
const formattedDate = formatDateBasic(date, format);
// Track the date field for statistics
fieldTracker.trackField(`date.${fullToken.replace(/[@[\]]/g, '')}`, {
value: formattedDate,
originalValue: match,
hasLogic: hasArithmetic,
});
hasChanges = true;
// For HTML nodes, generate HTML span if field tracking is enabled
if (enableFieldTracking) {
const fieldName = `date.${fullToken.replace(/[@[\]]/g, '')}`;
return fieldSpan(fieldName, formattedDate, hasArithmetic ? 'highlight' : 'imported');
}
else {
return formattedDate;
}
}
catch (error) {
logger.warn(`Error processing date reference ${match}:`, error instanceof Error ? error.message : String(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) {
logger.debug('Processing date references with remark plugin');
}
// Process date references in the AST
processDateReferencesInAST(tree, metadata, enableFieldTracking);
if (debug) {
logger.debug('Date reference processing completed');
}
};
};
export default remarkDates;
// Exported for testing - not part of public API
export { parseDateToken as _parseDateToken, applyDateArithmetic as _applyDateArithmetic, getOrdinalSuffix as _getOrdinalSuffix, formatDateBasic as _formatDateBasic, formatDateValue as _formatDateValue, };
//# sourceMappingURL=dates.js.map