UNPKG

legal-markdown-js

Version:

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

328 lines (327 loc) 12 kB
/** * HTML Generation Module for Legal Markdown Documents * * This module provides functionality to convert processed Legal Markdown content * into well-formatted HTML documents with CSS styling, accessibility features, * and print optimization. * * Features: * - Markdown to HTML conversion using marked * - DOM manipulation with cheerio for enhanced formatting * - Custom CSS injection and styling * - Field highlighting for document review * - Print-friendly output with page break controls * - Responsive table handling * - Accessibility improvements * * @example * ```typescript * import { htmlGenerator } from './html-generator.js'; * * const html = await htmlGenerator.generateHtml(markdownContent, { * title: 'Legal Agreement', * cssPath: './styles.css', * includeHighlighting: true * }); * ``` * * @module */ import { marked } from 'marked'; import * as cheerio from 'cheerio'; import * as fs from 'fs/promises'; import * as path from 'path'; import beautify from 'js-beautify'; const { html: beautifyHtml } = beautify; import { logger } from '../../utils/logger.js'; import { RESOLVED_PATHS } from '../../constants/index.js'; /** * HTML Generator for Legal Markdown Documents * * Converts processed Legal Markdown content into formatted HTML documents * with professional styling, accessibility features, and print optimization. * * @class HtmlGenerator * @example * ```typescript * const generator = new HtmlGenerator(); * const html = await generator.generateHtml(content, { * title: 'Contract', * includeHighlighting: true * }); * ``` */ export class HtmlGenerator { /** * Creates a new HTML generator instance and configures the markdown parser */ constructor() { this.configureMarked(); } /** * Configures the marked markdown parser with options optimized for legal documents * * @private */ configureMarked() { // Configure marked options marked.setOptions({ gfm: true, breaks: true, pedantic: false, }); // Create custom renderer to preserve HTML in code blocks const renderer = new marked.Renderer(); // Override code block renderer to preserve HTML spans renderer.code = function (args) { // Don't escape HTML tags - let them render as actual HTML const { text, lang } = args; const language = lang || ''; const className = language ? ` class="language-${language}"` : ''; // Return the code with HTML preserved (not escaped) return `<pre><code${className}>${text}</code></pre>\n`; }; // Set the custom renderer marked.setOptions({ renderer }); } /** * Removes YAML frontmatter from markdown content if present * * @private * @param {string} content - The markdown content that may contain YAML frontmatter * @returns {string} Content with YAML frontmatter removed * @example * ```typescript * const content = `--- * title: Document * --- * # Content`; * const clean = this.removeYamlFrontmatter(content); // "# Content" * ``` */ removeYamlFrontmatter(content) { // Check if content starts with YAML frontmatter if (content.startsWith('---')) { // Find the closing --- delimiter (allowing for various line endings) const lines = content.split('\n'); let endIndex = -1; // Look for the closing --- starting from line 1 (skip the opening ---) for (let i = 1; i < lines.length; i++) { if (lines[i].trim() === '---') { endIndex = i; break; } } if (endIndex !== -1) { // Return content after the frontmatter (skip the closing --- line) return lines .slice(endIndex + 1) .join('\n') .trim(); } } return content; } /** * Generates a complete HTML document from Legal Markdown content * * This is the main method that orchestrates the conversion process: * 1. Removes YAML frontmatter * 2. Converts markdown to HTML using marked * 3. Applies DOM transformations for legal document formatting * 4. Injects custom CSS and styling * 5. Builds a complete HTML document * * @param {string} markdownContent - The processed Legal Markdown content to convert * @param {HtmlGeneratorOptions} [options={}] - Configuration options for HTML generation * @returns {Promise<string>} A promise that resolves to the complete HTML document * @throws {Error} When HTML generation fails due to parsing or file system errors * @example * ```typescript * const html = await generator.generateHtml( * '# Contract\n\nThis is a {{party.name}} agreement.', * { * title: 'Service Agreement', * cssPath: './contract-styles.css', * includeHighlighting: true, * metadata: { * author: 'Legal Team', * version: '1.0' * } * } * ); * ``` */ async generateHtml(markdownContent, options = {}) { try { logger.debug('Generating HTML from markdown', { contentLength: markdownContent.length, options, }); // Remove YAML frontmatter if present const contentWithoutFrontmatter = this.removeYamlFrontmatter(markdownContent); // Convert markdown to HTML const htmlContent = await marked.parse(contentWithoutFrontmatter); // Load and manipulate with cheerio const $ = cheerio.load(htmlContent); // Apply DOM transformations this.applyDomTransformations($); // Add CSS if provided let cssContent = ''; if (options.cssPath) { cssContent += await this.loadCss(options.cssPath); } if (options.includeHighlighting && options.highlightCssPath) { cssContent += '\n' + (await this.loadCss(options.highlightCssPath)); } // Build complete HTML document const completeHtml = await this.buildHtmlDocument({ body: $.html(), css: cssContent, title: options.title || 'Legal Document', useDefaultCss: !options.cssPath, // Only use default CSS if no custom CSS is provided metadata: options.metadata, }); // Format the HTML for consistent output const formattedHtml = beautifyHtml(completeHtml, { indent_size: 2, indent_char: ' ', max_preserve_newlines: 2, preserve_newlines: true, indent_scripts: 'normal', end_with_newline: true, wrap_line_length: 0, indent_inner_html: true, unformatted: ['pre', 'code'], content_unformatted: ['pre', 'script', 'style'], extra_liners: ['head', 'body', '/html'], }); logger.debug('HTML generation completed', { htmlLength: formattedHtml.length, }); return formattedHtml; } catch (error) { logger.error('Error generating HTML', { error }); throw new Error(`Failed to generate HTML: ${error instanceof Error ? error.message : String(error)}`); } } /** * Applies DOM transformations to enhance the HTML for legal document presentation * * Transformations include: * - Adding no-break classes to short lists for better print layout * - Wrapping tables in responsive containers * - Adding alt attributes to images for accessibility * - Adding print-friendly CSS classes * - Cleaning up paragraph tags in list items * * @private * @param {cheerio.CheerioAPI} $ - The cheerio instance with loaded HTML * @returns {void} */ applyDomTransformations($) { // Apply list transformations (prevent page breaks in short lists) const maxListItemsNoBreak = 5; $('ul, ol').each((_, element) => { const list = $(element); if (list.children().length < maxListItemsNoBreak) { list.addClass('no-break'); } }); // Clean up paragraph tags in list items (unwrap single p tags) $('li > p:only-child').each((_, element) => { const p = $(element); const li = p.parent(); li.html(p.html() || ''); }); // Apply table responsiveness $('table').each((_, element) => { const table = $(element); if (!table.parent().hasClass('table-responsive')) { table.wrap('<div class="table-responsive"></div>'); } }); // Ensure images have alt attributes for accessibility $('img:not([alt])').attr('alt', ''); // Add print-friendly classes $('.page-break').addClass('page-break-before'); } /** * Loads CSS content from a file path * * @private * @param {string} cssPath - Path to the CSS file to load * @returns {Promise<string>} A promise that resolves to the CSS content, or empty string on error */ async loadCss(cssPath) { try { const cssContent = await fs.readFile(cssPath, 'utf-8'); logger.debug('CSS file loaded', { cssPath, size: cssContent.length }); return cssContent; } catch (error) { logger.warn(`Failed to load CSS file: ${cssPath}`, { error }); return ''; } } /** * Builds a complete HTML document with head, body, and embedded styles * * Creates a well-formed HTML5 document with: * - Proper DOCTYPE and meta tags * - Responsive viewport configuration * - Embedded CSS styles (base + custom) * - SEO-friendly metadata * - Print-optimized styling * * @private * @param {Object} options - Configuration for building the HTML document * @param {string} options.body - The HTML body content * @param {string} options.css - Custom CSS to embed * @param {string} options.title - Document title * @param {Record<string, string>} [options.metadata] - Additional metadata for HTML head * @returns {string} Complete HTML document as string */ async buildHtmlDocument(options) { const { body, css, title, metadata = {}, useDefaultCss = true } = options; // Load default CSS only if no custom CSS is provided let defaultCss = ''; if (useDefaultCss) { const defaultCssPath = path.join(RESOLVED_PATHS.STYLES_DIR, 'default.css'); defaultCss = await this.loadCss(defaultCssPath); } // Build metadata tags const metaTags = Object.entries(metadata) .map(([name, content]) => ` <meta name="${name}" content="${content}">`) .join('\n'); return `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>${title}</title> ${metaTags ? metaTags + '\n' : ''} <style> /* Default CSS */ ${defaultCss} /* Custom CSS */ ${css} </style> </head> <body> ${body} </body> </html>`; } } /** * Singleton instance of HtmlGenerator for convenient importing * @example * ```typescript * import { htmlGenerator } from './html-generator.js'; * const html = await htmlGenerator.generateHtml(content); * ``` */ // Export singleton instance export const htmlGenerator = new HtmlGenerator(); //# sourceMappingURL=html-generator.js.map