legal-markdown-js
Version:
Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version
312 lines • 14.1 kB
JavaScript
/**
* @fileoverview Import Processing Module for Legal Markdown Documents
*
* This module provides functionality to process partial imports in Legal Markdown
* documents, allowing for modular document construction by including external
* files. It supports both absolute and relative import paths, recursive import
* processing, and comprehensive error handling for missing files.
*
* Features:
* - Import syntax: @import filename
* - Relative and absolute path resolution
* - Recursive import processing (nested imports)
* - Import tracking and cycle detection
* - Error handling with fallback content
* - Base path resolution for project organization
* - Import validation and file existence checking
*
* @example
* ```typescript
* import { processPartialImports } from './import-processor.js';
*
* // Main document content
* const content = `
* # Service Agreement
*
* @import ./clauses/standard-terms.md
*
* ## Specific Terms
* @import ./clauses/payment-terms.md
* @import ./clauses/termination.md
*
* @import ./signatures/signature-block.md
* `;
*
* const result = processPartialImports(content, './contracts');
* console.log(result.content); // Processed content with imports resolved
* console.log(result.importedFiles); // Array of imported file paths
* ```
*/
import * as fs from 'fs';
import * as path from 'path';
import { parseYamlFrontMatter } from '../parsers/yaml-parser.js';
import { mergeSequentially } from '../utils/frontmatter-merger.js';
import { ImportError } from '../../errors/index.js';
import { logger } from '../../utils/logger.js';
/**
* Processes partial imports in a LegalMarkdown document
*
* This is the main function that processes import statements using the @import syntax.
* It recursively resolves and includes external files, tracking all imported files
* and handling errors gracefully when files cannot be found or loaded.
*
* By default, it also extracts YAML frontmatter from imported files and merges it using
* the "source always wins" strategy with flattened granular merging. This can be disabled
* with the disableFrontmatterMerge option.
*
* @deprecated This function is deprecated and will be removed in v4.0.0.
* Use `processLegalMarkdownWithRemark()` with the `remarkImports` plugin instead.
* The remark-based approach inserts content as AST nodes and provides better error handling.
* @see {@link https://github.com/yourrepo/legal-markdown-js/blob/main/docs/migration-guide.md Migration Guide}
*
* @param {string} content - The document content containing import statements
* @param {string} [basePath] - Optional base path for resolving relative imports
* @param {Record<string, any>} [currentMetadata] - Current document metadata for merging
* @param {LegalMarkdownOptions} [options] - Processing options including frontmatter merge settings
* @returns {ImportProcessingResult} Object containing processed content, list of imported files, and merged metadata
* @example
* ```typescript
* // Basic import processing
* const content = `
* # Main Document
* @import ./introduction.md
* @import ./body.md
* @import ./conclusion.md
* `;
*
* const result = processPartialImports(content, './documents');
* console.log(result.content); // Content with imports resolved
* console.log(result.importedFiles); // ['./documents/introduction.md', './documents/body.md', './documents/conclusion.md']
*
* // Nested imports example
* // main.md: @import ./sections/terms.md
* // terms.md: @import ./subsections/payment.md
* // Result will include content from all three files
*
* // Error handling
* const contentWithMissingFile = `
* # Document
* @import ./existing.md
* @import ./missing.md
* `;
*
* const result2 = processPartialImports(contentWithMissingFile);
* // Result will include content from existing.md and error comment for missing.md
* ```
*/
export function processPartialImports(content, basePath, currentMetadata, options) {
// DEPRECATION WARNING
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'test') {
logger.warn('processPartialImports() is deprecated and will be removed in v4.0.0. ' +
'Use processLegalMarkdownWithRemark() with remarkImports plugin instead. ' +
'See: https://github.com/yourrepo/legal-markdown-js/blob/main/docs/migration-guide.md');
}
const startTime = Date.now();
const timeoutMs = 30000; // 30 seconds for complete import processing
// Regular expression to match import statements
// Format: @import [filename]
const importPattern = /@import\s+(.+?)(?:\s|$)/g;
const importedFiles = [];
const importedMetadataList = [];
// Configure merge options based on processing options
const mergeOptions = {
filterReserved: true,
validateTypes: options?.validateImportTypes ?? true,
logOperations: options?.logImportOperations ?? false,
includeStats: options?.logImportOperations ?? false,
timeoutMs: Math.max(2000, timeoutMs - (Date.now() - startTime)), // Allocate remaining time for merge
};
const processedContent = content.replace(importPattern, (match, filename) => {
// Timeout safety check during import processing
if (Date.now() - startTime > timeoutMs) {
const message = `Import processing timed out after ${timeoutMs}ms. ` +
'This may indicate complex nested imports or circular references.';
throw new ImportError(message, { timeoutMs });
}
// Clean up filename (remove quotes, etc.)
const cleanFilename = filename.trim().replace(/['"]/g, '');
try {
// Resolve the import path
const importPath = resolveImportPath(cleanFilename, basePath);
// Read the imported file
const importedContent = fs.readFileSync(importPath, 'utf8');
// Track imported files
importedFiles.push(importPath);
let processedImportContent = importedContent;
let importedMetadata = {};
// Extract frontmatter unless merging is explicitly disabled
if (options?.disableFrontmatterMerge !== true) {
const yamlResult = parseYamlFrontMatter(importedContent, false);
processedImportContent = yamlResult.content;
importedMetadata = yamlResult.metadata;
// Store metadata for later merging
if (Object.keys(importedMetadata).length > 0) {
importedMetadataList.push(importedMetadata);
if (options?.logImportOperations) {
const fieldCount = Object.keys(importedMetadata).length;
logger.debug(`Extracted ${fieldCount} metadata fields from ${cleanFilename}`);
}
}
}
// Process nested imports (pass along metadata and options)
const nestedResult = processPartialImports(processedImportContent, path.dirname(importPath), importedMetadata, options);
importedFiles.push(...nestedResult.importedFiles);
// Collect nested metadata if available
if (nestedResult.mergedMetadata && Object.keys(nestedResult.mergedMetadata).length > 0) {
importedMetadataList.push(nestedResult.mergedMetadata);
}
// Add import tracing comments if enabled
let tracedContent = nestedResult.content;
if (options?.importTracing) {
const relativeImportPath = path.relative(basePath || process.cwd(), importPath);
const startComment = `<!-- start import: ${relativeImportPath} -->`;
const endComment = `<!-- end import: ${relativeImportPath} -->`;
tracedContent = `${startComment}\n${tracedContent}\n${endComment}`;
}
return tracedContent;
}
catch {
// Handle import errors gracefully without logging to stderr
return `<!-- Error importing ${cleanFilename} -->`;
}
});
// Merge all collected metadata unless explicitly disabled
let mergedMetadata;
const shouldMerge = options?.disableFrontmatterMerge !== true && importedMetadataList.length > 0;
if (shouldMerge) {
const initialMetadata = currentMetadata || {};
if (options?.logImportOperations) {
const currentFieldCount = Object.keys(initialMetadata).length;
logger.debug(`Merging metadata from ${importedMetadataList.length} imports with ${currentFieldCount} current fields`);
}
// Calculate remaining time and allocate granular timeouts
const remainingTime = timeoutMs - (Date.now() - startTime);
if (remainingTime < 1000) {
throw new ImportError('Insufficient time remaining for metadata merging.', {
remainingTime,
});
}
const perImportTimeout = Math.max(500, Math.floor(remainingTime / importedMetadataList.length));
const mergeResult = mergeSequentially(initialMetadata, importedMetadataList, {
...mergeOptions,
timeoutMs: perImportTimeout,
});
mergedMetadata = mergeResult.metadata;
if (options?.logImportOperations && mergeResult.stats) {
logger.debug('Frontmatter merge completed:');
logger.debug(` - Properties added: ${mergeResult.stats.propertiesAdded}`);
logger.debug(` - Conflicts resolved: ${mergeResult.stats.conflictsResolved}`);
logger.debug(` - Reserved fields filtered: ${mergeResult.stats.reservedFieldsFiltered}`);
if (mergeResult.stats.addedFields.length > 0) {
logger.debug(` - Added fields: ${mergeResult.stats.addedFields.join(', ')}`);
}
if (mergeResult.stats.conflictedFields.length > 0) {
logger.debug(` - Conflicted fields (source wins): ${mergeResult.stats.conflictedFields.join(', ')}`);
}
}
}
return {
content: processedContent,
importedFiles,
mergedMetadata,
};
}
/**
* Resolves the absolute path of an import
*
* Converts relative import paths to absolute paths using the provided base path
* or current working directory. Handles both relative and absolute paths correctly.
*
* @private
* @param {string} importPath - Relative or absolute import path to resolve
* @param {string} [basePath] - Base path for resolving relative imports
* @returns {string} Absolute path to the imported file
* @example
* ```typescript
* // Relative path resolution
* const absolutePath = resolveImportPath("./clauses/terms.md", "/contracts");
* // Returns: "/contracts/clauses/terms.md"
*
* // Absolute path (unchanged)
* const absolutePath2 = resolveImportPath("/full/path/to/file.md");
* // Returns: "/full/path/to/file.md"
*
* // Relative to current directory
* const absolutePath3 = resolveImportPath("./local-file.md");
* // Returns: "/current/working/directory/local-file.md"
* ```
*/
function resolveImportPath(importPath, basePath) {
// If the import path is absolute, use it directly
if (path.isAbsolute(importPath)) {
return importPath;
}
// If no base path provided, use current working directory
const base = basePath || process.cwd();
// Resolve relative to base path
return path.resolve(base, importPath);
}
/**
* Validates that all import paths in a document exist
*
* Checks all import statements in a document to ensure the referenced files exist
* on the filesystem. Returns an array of error messages for any missing files,
* or an empty array if all imports are valid.
*
* @deprecated This function is deprecated and will be removed in v4.0.0.
* Use `processLegalMarkdownWithRemark()` with the `remarkImports` plugin instead.
* The remark-based approach handles validation automatically during import processing.
* @see {@link https://github.com/yourrepo/legal-markdown-js/blob/main/docs/migration-guide.md Migration Guide}
*
* @param {string} content - The document content containing import statements to validate
* @param {string} [basePath] - Optional base path for resolving relative imports
* @returns {string[]} Array of validation errors, empty if all imports are valid
* @example
* ```typescript
* const content = `
* # Document
* @import ./existing-file.md
* @import ./missing-file.md
* @import /absolute/path/to/file.md
* `;
*
* const errors = validateImports(content, './documents');
* console.log(errors);
* // Output (if files are missing):
* // [
* // "Import file not found: ./documents/missing-file.md",
* // "Import file not found: /absolute/path/to/file.md"
* // ]
*
* // For valid imports
* const validContent = `@import ./existing-file.md`;
* const noErrors = validateImports(validContent, './documents');
* console.log(noErrors); // [] (empty array)
* ```
*/
export function validateImports(content, basePath) {
// DEPRECATION WARNING
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'test') {
logger.warn('validateImports() is deprecated and will be removed in v4.0.0. ' +
'Use processLegalMarkdownWithRemark() with remarkImports plugin instead. ' +
'See: https://github.com/yourrepo/legal-markdown-js/blob/main/docs/migration-guide.md');
}
const importPattern = /@import\s+(.+?)(?:\s|$)/g;
const errors = [];
let match;
while ((match = importPattern.exec(content)) !== null) {
const filename = match[1].trim().replace(/['"]/g, '');
try {
const importPath = resolveImportPath(filename, basePath);
// Check if file exists
if (!fs.existsSync(importPath)) {
errors.push(`Import file not found: ${importPath}`);
}
}
catch {
errors.push(`Error resolving import: ${filename}`);
}
}
return errors;
}
//# sourceMappingURL=import-processor.js.map