UNPKG

legal-markdown-js

Version:

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

456 lines 19.3 kB
/** * Phase 3: Format Generation from Cached AST * * This module implements Phase 3 of the 3-phase pipeline architecture. * It generates all requested output formats (HTML, PDF, DOCX, Markdown, Metadata) * from the cached AST and processed content produced by Phase 2. * * Key benefits: * - Single processing run regardless of output formats * - Parallel format generation from cached AST * - ~75% reduction in processing time for multi-format output * - Compatible with existing HTML/PDF/DOCX generation * * @module core/pipeline/format-generator */ import * as path from 'path'; import * as fs from 'fs'; import { writeFileSync } from '../../utils/index.js'; import { ProcessingError } from '../../errors/index.js'; import { logger } from '../../utils/logger.js'; import { resolvePdfConnector } from '../../extensions/generators/pdf-connectors/index.js'; /** * Build format generation options with force-commands support * * This helper ensures that options from force-commands (in context.options) * take precedence over CLI/interactive options. This prevents bugs where * force-commands are ignored in Phase 3. * * @param contextOptions - Options from Phase 1 (includes force-commands) * @param baseOptions - Partial options to merge with context * @returns Complete FormatGenerationOptions with force-commands applied * * @example * ```typescript * // In CLI service: * const formatOptions = buildFormatGenerationOptions(context.options, { * outputDir: dirName, * baseFilename: baseName, * pdf: options.pdf, * html: options.html, * // ... other base options * }); * * await generateAllFormats(processedResult, formatOptions); * ``` */ export function buildFormatGenerationOptions(contextOptions, baseOptions) { return { // Required fields from baseOptions outputDir: baseOptions.outputDir, baseFilename: baseOptions.baseFilename, // Format flags: prioritize context.options (force-commands) over base options pdf: contextOptions.pdf ?? baseOptions.pdf, html: contextOptions.html ?? baseOptions.html, docx: contextOptions.docx ?? baseOptions.docx, markdown: baseOptions.markdown, metadata: baseOptions.metadata, // Processing options: prioritize context.options (force-commands) highlight: contextOptions.highlight ?? baseOptions.highlight, format: contextOptions.format ?? baseOptions.format, landscape: contextOptions.landscape ?? baseOptions.landscape, pdfConnector: baseOptions.pdfConnector, pdfMargin: baseOptions.pdfMargin, docxHeaderTemplate: baseOptions.docxHeaderTemplate, docxFooterTemplate: baseOptions.docxFooterTemplate, // Paths and other options cssPath: baseOptions.cssPath, highlightCssPath: baseOptions.highlightCssPath, title: baseOptions.title, includeHighlighting: baseOptions.includeHighlighting, exportFormat: baseOptions.exportFormat, exportPath: baseOptions.exportPath, }; } /** * Generate all requested formats from cached processing result * * This is the main entry point for Phase 3. It takes the cached result * from Phase 2 and generates all requested output formats in parallel * without re-running the processing pipeline. * * @param processedResult - Result from Phase 2 (with cached AST) * @param options - Format generation options * @returns Promise resolving to generation result with file paths * * @example * ```typescript * // After Phase 2 processing: * const processed = await processLegalMarkdown(content, options); * * // Generate all formats: * const result = await generateAllFormats(processed, { * outputDir: '/path/to/output', * baseFilename: 'contract', * pdf: true, * html: true, * highlight: true * }); * * console.log(`Generated ${result.stats.totalFiles} files`); * ``` */ export async function generateAllFormats(processedResult, options) { const startTime = Date.now(); const generatedFiles = []; const results = {}; // Ensure output directory exists (create parent directories if needed) try { if (!fs.existsSync(options.outputDir)) { fs.mkdirSync(options.outputDir, { recursive: true }); } } catch (error) { // If we can't create the directory, throw a more helpful error throw new ProcessingError(`Failed to create output directory "${options.outputDir}": ${error instanceof Error ? error.message : String(error)}`); } // Orchestration logic: If HTML plus PDF and/or DOCX are requested, // execute sequentially to reuse HTML content. if (options.html && (options.pdf || options.docx)) { logger.debug('[Phase 3] Orchestrating HTML→(PDF/DOCX) pipeline (sequential for HTML reuse)'); // Step 1: Generate HTML (cache content for PDF reuse) const htmlResults = await generateHtmlFormats(processedResult, options); results.html = { normal: htmlResults.normal?.path, highlight: htmlResults.highlight?.path, }; if (htmlResults.normal?.path) generatedFiles.push(htmlResults.normal.path); if (htmlResults.highlight?.path) generatedFiles.push(htmlResults.highlight.path); // Step 2: Generate PDF/DOCX using cached HTML (NO HTML regeneration!) const dependentFormats = []; if (options.pdf) { dependentFormats.push(generatePdfFormats(processedResult, options, htmlResults).then(pdfResults => { results.pdf = pdfResults; if (pdfResults.normal) generatedFiles.push(pdfResults.normal); if (pdfResults.highlight) generatedFiles.push(pdfResults.highlight); })); } if (options.docx) { dependentFormats.push(generateDocxFormats(processedResult, options, htmlResults).then(docxResults => { results.docx = docxResults; if (docxResults.normal) generatedFiles.push(docxResults.normal); if (docxResults.highlight) generatedFiles.push(docxResults.highlight); })); } await Promise.all(dependentFormats); // Step 3: Generate other formats in parallel const otherFormats = []; if (options.markdown) { otherFormats.push(generateMarkdownFormat(processedResult, options).then(mdPath => { results.markdown = mdPath; generatedFiles.push(mdPath); })); } await Promise.all(otherFormats); } else { // Execute all formats in parallel (no HTML reuse needed) const formatPromises = []; // Generate HTML if requested if (options.html) { formatPromises.push(generateHtmlFormats(processedResult, options).then(htmlResults => { results.html = { normal: htmlResults.normal?.path, highlight: htmlResults.highlight?.path, }; if (htmlResults.normal?.path) generatedFiles.push(htmlResults.normal.path); if (htmlResults.highlight?.path) generatedFiles.push(htmlResults.highlight.path); })); } // Generate PDF if requested if (options.pdf) { formatPromises.push(generatePdfFormats(processedResult, options).then(pdfResults => { results.pdf = pdfResults; if (pdfResults.normal) generatedFiles.push(pdfResults.normal); if (pdfResults.highlight) generatedFiles.push(pdfResults.highlight); })); } // Generate DOCX if requested if (options.docx) { formatPromises.push(generateDocxFormats(processedResult, options).then(docxResults => { results.docx = docxResults; if (docxResults.normal) generatedFiles.push(docxResults.normal); if (docxResults.highlight) generatedFiles.push(docxResults.highlight); })); } // Generate Markdown if requested if (options.markdown) { formatPromises.push(generateMarkdownFormat(processedResult, options).then(mdPath => { results.markdown = mdPath; generatedFiles.push(mdPath); })); } // Wait for all formats to complete await Promise.all(formatPromises); } // Generate Metadata if requested (always separate, doesn't need orchestration) if (options.metadata && processedResult.exportedFiles) { results.metadata = processedResult.exportedFiles; generatedFiles.push(...processedResult.exportedFiles); } const processingTime = Date.now() - startTime; return { generatedFiles, results, stats: { totalFiles: generatedFiles.length, processingTime, }, }; } /** * Generate HTML formats (normal and/or highlight) * * @param processedResult - Processed result with cached content * @param options - Generation options * @returns Promise resolving to HTML file paths and content (for reuse in PDF generation) * @internal */ async function generateHtmlFormats(processedResult, options) { // Use HtmlGenerator directly to avoid re-processing content const { HtmlGenerator } = await import('../../extensions/generators/html-generator.js'); const generator = new HtmlGenerator(); const result = {}; const generateOptions = { cssPath: options.cssPath, highlightCssPath: options.highlightCssPath, title: options.title || options.baseFilename, metadata: processedResult.metadata, }; if (options.highlight) { // Generate both normal and highlight versions const normalPath = path.join(options.outputDir, `${options.baseFilename}.html`); const highlightPath = path.join(options.outputDir, `${options.baseFilename}.HIGHLIGHT.html`); // Normal version (without field highlighting) const normalHtml = await generator.generateHtml(processedResult.content, { ...generateOptions, includeHighlighting: false, }); writeFileSync(normalPath, normalHtml); result.normal = { path: normalPath, content: normalHtml }; // Highlight version (with field highlighting) const highlightHtml = await generator.generateHtml(processedResult.content, { ...generateOptions, includeHighlighting: true, }); writeFileSync(highlightPath, highlightHtml); result.highlight = { path: highlightPath, content: highlightHtml }; } else { // Single normal version const normalPath = path.join(options.outputDir, `${options.baseFilename}.html`); const normalHtml = await generator.generateHtml(processedResult.content, { ...generateOptions, includeHighlighting: false, }); writeFileSync(normalPath, normalHtml); result.normal = { path: normalPath, content: normalHtml }; } return result; } /** * Generate DOCX formats (normal and/or highlight) * * Reuses cached HTML when available to avoid duplicated HTML generation. * * @param processedResult - Processed result with cached content * @param options - Generation options * @param cachedHtml - Optional pre-generated HTML content to reuse * @returns Promise resolving to DOCX file paths * @internal */ async function generateDocxFormats(processedResult, options, cachedHtml) { const { HtmlGenerator } = await import('../../extensions/generators/html-generator.js'); const { DocxGenerator } = await import('../../extensions/generators/docx-generator.js'); const htmlGenerator = new HtmlGenerator(); const generator = new DocxGenerator(); const result = {}; const htmlGeneratorOptions = { cssPath: options.cssPath, highlightCssPath: options.highlightCssPath, title: options.title || options.baseFilename, metadata: processedResult.metadata, }; const version = processedResult.metadata && typeof processedResult.metadata.version === 'string' ? processedResult.metadata.version : undefined; const docxOptions = { cssPath: options.cssPath, highlightCssPath: options.highlightCssPath, title: options.title || options.baseFilename, metadata: processedResult.metadata, format: options.format, landscape: options.landscape, margin: options.pdfMargin, headerTemplate: options.docxHeaderTemplate, footerTemplate: options.docxFooterTemplate, version, }; if (options.highlight) { const normalPath = path.join(options.outputDir, `${options.baseFilename}.docx`); const highlightPath = path.join(options.outputDir, `${options.baseFilename}.HIGHLIGHT.docx`); const normalHtml = cachedHtml?.normal?.content || (await htmlGenerator.generateHtml(processedResult.content, { ...htmlGeneratorOptions, includeHighlighting: false, })); await generator.generateDocxFromHtml(normalHtml, normalPath, { ...docxOptions, includeHighlighting: false, }); result.normal = normalPath; const highlightHtml = cachedHtml?.highlight?.content || (await htmlGenerator.generateHtml(processedResult.content, { ...htmlGeneratorOptions, includeHighlighting: true, })); await generator.generateDocxFromHtml(highlightHtml, highlightPath, { ...docxOptions, includeHighlighting: true, }); result.highlight = highlightPath; } else { const normalPath = path.join(options.outputDir, `${options.baseFilename}.docx`); const normalHtml = cachedHtml?.normal?.content || (await htmlGenerator.generateHtml(processedResult.content, { ...htmlGeneratorOptions, includeHighlighting: false, })); await generator.generateDocxFromHtml(normalHtml, normalPath, { ...docxOptions, includeHighlighting: false, }); result.normal = normalPath; } return result; } /** * Generate PDF formats (normal and/or highlight) * * Uses the 3-phase pipeline approach: generates HTML once, then converts to PDF. * This avoids re-processing the markdown and ensures the PDF uses exactly the * same HTML that would be saved to disk. * * IMPORTANT: This function calls generatePdfFromHtml() to avoid double-conversion. * The old approach called generatePdf(markdown) which would regenerate HTML internally, * violating the 3-phase pipeline's "process once, output many" principle. * * When cachedHtml is provided, it will be reused instead of regenerating HTML, * achieving the ideal "process once, output many" architecture. * * @param processedResult - Processed result with cached content * @param options - Generation options * @param cachedHtml - Optional pre-generated HTML content to reuse * @returns Promise resolving to PDF file paths * @internal */ async function generatePdfFormats(processedResult, options, cachedHtml) { const connector = options.pdfConnector ?? (await resolvePdfConnector('auto')); const { HtmlGenerator } = await import('../../extensions/generators/html-generator.js'); const htmlGenerator = new HtmlGenerator(); const result = {}; if (cachedHtml) { logger.debug('[Phase 3] PDF generation using cached HTML from previous HTML generation - NO HTML regeneration!'); } else { logger.debug('[Phase 3] PDF generation starting - will generate HTML once per variant'); } const htmlGeneratorOptions = { cssPath: options.cssPath, highlightCssPath: options.highlightCssPath, title: options.title || options.baseFilename, metadata: processedResult.metadata, }; // Extract version from metadata for PDF footer const version = processedResult.metadata?.version; const normalizedPdfFormat = options.format === 'Letter' ? 'Letter' : 'A4'; const pdfGenerationOptions = { format: normalizedPdfFormat, margin: options.pdfMargin ?? { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm', }, landscape: options.landscape, footerTemplate: version ? `<div style="font-size:8px;width:100%;text-align:right;">${version}</div>` : undefined, }; if (options.highlight) { // Generate both normal and highlight versions const normalPath = path.join(options.outputDir, `${options.baseFilename}.pdf`); const highlightPath = path.join(options.outputDir, `${options.baseFilename}.HIGHLIGHT.pdf`); // Get or generate HTML for normal version const normalHtml = cachedHtml?.normal?.content || (await htmlGenerator.generateHtml(processedResult.content, { ...htmlGeneratorOptions, includeHighlighting: false, })); // Convert pre-generated HTML to PDF (no re-processing!) await connector.generatePdf(normalHtml, normalPath, pdfGenerationOptions); result.normal = normalPath; // Get or generate HTML for highlight version const highlightHtml = cachedHtml?.highlight?.content || (await htmlGenerator.generateHtml(processedResult.content, { ...htmlGeneratorOptions, includeHighlighting: true, })); // Convert pre-generated HTML to PDF (no re-processing!) await connector.generatePdf(highlightHtml, highlightPath, pdfGenerationOptions); result.highlight = highlightPath; } else { // Single normal version const normalPath = path.join(options.outputDir, `${options.baseFilename}.pdf`); // Get or generate HTML const normalHtml = cachedHtml?.normal?.content || (await htmlGenerator.generateHtml(processedResult.content, { ...htmlGeneratorOptions, includeHighlighting: false, })); // Convert pre-generated HTML to PDF (no re-processing!) await connector.generatePdf(normalHtml, normalPath, pdfGenerationOptions); result.normal = normalPath; } return result; } /** * Generate Markdown format * * @param processedResult - Processed result with cached content * @param options - Generation options * @returns Promise resolving to Markdown file path * @internal */ async function generateMarkdownFormat(processedResult, options) { const mdPath = path.join(options.outputDir, `${options.baseFilename}.md`); writeFileSync(mdPath, processedResult.content); return mdPath; } // Exported for testing - not part of public API export { generateHtmlFormats as _generateHtmlFormats, generatePdfFormats as _generatePdfFormats, generateDocxFormats as _generateDocxFormats, }; //# sourceMappingURL=format-generator.js.map