legal-markdown-js
Version:
Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version
265 lines • 10.8 kB
JavaScript
/**
* Field Tracking System for Legal Markdown Processing
*
* This module provides comprehensive field tracking functionality for Legal Markdown
* documents, including field status monitoring, highlighting support, and reporting
* capabilities. It tracks field processing state and applies appropriate CSS classes
* for visual feedback during document review.
*
* Features:
* - Field status tracking (filled, empty, logic)
* - CSS class application for field highlighting
* - Field processing state management
* - Mixin usage tracking for complex field logic
* - Reporting and analytics for field usage
* - Singleton pattern for consistent tracking across the application
*
* @example
* ```typescript
* import { fieldTracker, FieldStatus } from './field-tracker.js';
*
* // Track a field during processing
* fieldTracker.trackField('client.name', {
* value: 'Acme Corporation',
* status: FieldStatus.FILLED
* });
*
* // Apply field tracking to content
* const highlightedContent = fieldTracker.applyFieldTracking(processedContent);
*
* // Generate field report
* const report = fieldTracker.generateReport();
* console.log(`${report.filled} fields filled, ${report.empty} fields empty`);
* ```
*/
import { logger } from '../../utils/logger.js';
import { FieldStatus } from '../../core/tracking/field-state.js';
// Re-export for compatibility
export { FieldStatus };
/**
* Field tracking system for Legal Markdown processing
*
* Manages field tracking throughout the document processing lifecycle,
* including status monitoring, highlighting, and reporting capabilities.
*
* @class FieldTracker
* @example
* ```typescript
* import { FieldTracker, FieldStatus } from './field-tracker.js';
*
* const tracker = new FieldTracker();
*
* // Track field processing
* tracker.trackField('client.name', {
* value: 'Acme Corp',
* originalValue: '{{client.name}}',
* hasLogic: false
* });
*
* // Apply tracking to content
* const highlighted = tracker.applyFieldTracking(content);
*
* // Generate report
* const report = tracker.generateReport();
* ```
*/
export class FieldTracker {
fields = new Map();
processedContent = '';
totalOccurrences = 0;
/**
* Creates a new FieldTracker instance
*/
constructor() { }
/**
* Track a field that has been processed
*
* Registers a field with the tracking system, determining its status based on
* the provided options and storing relevant metadata for later use.
*
* @param {string} fieldName - The name/identifier of the field to track
* @param {Object} options - Options for field tracking
* @param {any} [options.value] - The processed value of the field
* @param {any} [options.originalValue] - The original unprocessed value
* @param {boolean} [options.hasLogic=false] - Whether the field contains logical operations
* @param {string} [options.mixinUsed] - Name of mixin used for processing
* @returns {void}
* @example
* ```typescript
* // Track a filled field
* tracker.trackField('client.name', {
* value: 'Acme Corporation',
* originalValue: '{{client.name}}'
* });
*
* // Track an empty field
* tracker.trackField('client.address', {
* value: '',
* originalValue: '{{client.address}}'
* });
*
* // Track a field with logic
* tracker.trackField('warranty.clause', {
* value: 'Standard warranty applies',
* originalValue: '{{#if warranty.enabled}}{{warranty.text}}{{/if}}',
* hasLogic: true,
* mixinUsed: 'warranty-mixin'
* });
* ```
*/
trackField(fieldName, options) {
const { value, originalValue, hasLogic = false, mixinUsed } = options;
let status;
if (hasLogic || (mixinUsed && ['conditional', 'helper', 'loop'].includes(mixinUsed))) {
status = FieldStatus.LOGIC;
}
else if (value === undefined || value === null || value === '') {
status = FieldStatus.EMPTY;
}
else {
status = FieldStatus.FILLED;
}
const field = {
name: fieldName,
status,
value,
originalValue,
hasLogic,
mixinUsed,
};
this.fields.set(fieldName, field);
this.totalOccurrences++;
logger.debug('Field tracked', { fieldName, status, hasLogic });
}
/**
* Apply field tracking to processed content by wrapping fields with appropriate CSS classes
*/
applyFieldTracking(content) {
let processedContent = content;
this.fields.forEach((field, fieldName) => {
const cssClass = this.getFieldCssClass(field.status);
// Create regex to find field references in the content
// This looks for {{fieldName}} patterns that haven't been processed yet
// Handle Markdown escaped underscores: empty_field becomes empty\_field
const escapedFieldName = this.escapeRegex(fieldName).replace(/_/g, '\\\\_?');
const fieldPattern = new RegExp(`\\{\\{\\s*${escapedFieldName}\\s*\\}\\}`, 'g');
// Replace unprocessed field patterns (only if they're not already in spans)
processedContent = processedContent.replace(fieldPattern, (match, offset, string) => {
// Check if this match is already inside a span tag
const beforeMatch = string.substring(0, offset);
// Look for the last opening span tag before this match
const lastSpanOpen = beforeMatch.lastIndexOf('<span');
const lastSpanClose = beforeMatch.lastIndexOf('</span>');
// If there's an unclosed span tag before this match, skip it
if (lastSpanOpen > lastSpanClose && lastSpanOpen !== -1) {
return match; // Keep original, don't wrap
}
const value = field.value !== undefined && field.value !== null && field.value !== ''
? String(field.value)
: match; // Keep the original pattern for empty fields
return `<span class="${cssClass}" data-field="${fieldName.replace(/"/g, '"')}">${value}</span>`;
});
// For fields with specific values, apply highlighting based on field logic, not value characteristics
if (field.value !== undefined && field.value !== null && field.value !== '') {
const fieldValue = String(field.value);
// Apply highlighting ONLY for logic fields and cross-references
// Do not highlight arbitrary field values in text - only processed template fields
const shouldHighlight = field.status === 'logic' || // Always highlight logic fields
fieldName.startsWith('crossref.'); // Always highlight cross-references
// Removed: general field value highlighting to prevent false positives
if (shouldHighlight) {
// Split content by HTML tags to avoid highlighting inside attributes
const parts = processedContent.split(/(<[^>]*>)/);
for (let i = 0; i < parts.length; i++) {
// Only process text parts (even indices), skip HTML tags (odd indices)
if (i % 2 === 0 && parts[i] && parts[i].includes(fieldValue)) {
const textPart = parts[i];
// Skip if this text part is already completely inside a highlight span
// Check if the previous tag was a highlight span and the next tag closes it
const prevTag = i > 0 ? parts[i - 1] : '';
const nextTag = i < parts.length - 1 ? parts[i + 1] : '';
const isInsideHighlightSpan = prevTag.includes('class="highlight"') ||
prevTag.includes('class="imported-value"') ||
prevTag.includes('class="missing-value"');
const isClosedBySpan = nextTag === '</span>';
if (isInsideHighlightSpan && isClosedBySpan) {
continue;
}
// Apply highlighting to this text part
const escapedValue = this.escapeRegex(fieldValue);
parts[i] = textPart.replace(new RegExp(escapedValue, 'g'), match => {
return `<span class="${cssClass}" data-field="${fieldName.replace(/"/g, '"')}">${match}</span>`;
});
}
}
processedContent = parts.join('');
}
}
});
this.processedContent = processedContent;
return processedContent;
}
/**
* Get CSS class for field based on its status
*/
getFieldCssClass(status) {
switch (status) {
case FieldStatus.FILLED:
return 'legal-field imported-value';
case FieldStatus.EMPTY:
return 'legal-field missing-value';
case FieldStatus.LOGIC:
return 'legal-field highlight';
default:
return '';
}
}
/**
* Escape special regex characters in a string
*/
escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Get all tracked fields
*/
getFields() {
return new Map(this.fields);
}
/**
* Get total number of field occurrences tracked
*/
getTotalOccurrences() {
return this.totalOccurrences;
}
/**
* Get fields by status
*/
getFieldsByStatus(status) {
return Array.from(this.fields.values()).filter(field => field.status === status);
}
/**
* Generate a summary report of tracked fields
*/
generateReport() {
const fields = Array.from(this.fields.values());
return {
total: fields.length,
filled: fields.filter(f => f.status === FieldStatus.FILLED).length,
empty: fields.filter(f => f.status === FieldStatus.EMPTY).length,
logic: fields.filter(f => f.status === FieldStatus.LOGIC).length,
fields,
};
}
/**
* Clear all tracked fields
*/
clear() {
this.fields.clear();
this.processedContent = '';
this.totalOccurrences = 0;
}
}
// Export singleton instance
export const fieldTracker = new FieldTracker();
//# sourceMappingURL=field-tracker.js.map