legal-markdown-js
Version:
Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version
374 lines (373 loc) • 14.9 kB
JavaScript
/**
* 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 { logger } from '../../utils/logger.js';
import { RESOLVED_PATHS } from '../../constants/index.js';
import { isHtml } from '../../types/content-formats.js';
import { configureMarkedForLegal, formatHtml } from '../../utils/html-format.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.
* Uses shared configuration from html-format.ts, then adds the Node-specific
* custom code renderer for preserving HTML spans in code blocks.
*
* @private
*/
configureMarked() {
// Shared config: GFM, breaks, silent
configureMarkedForLegal();
// Node-specific: custom code renderer that preserves HTML spans in code blocks
const renderer = new marked.Renderer();
renderer.code = function (args) {
const { text, lang } = args;
const language = lang || '';
const className = language ? ` class="language-${language}"` : '';
return `<pre><code${className}>${text}</code></pre>\n`;
};
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 {MarkdownString} markdownContent - The processed Legal Markdown content to convert (MUST be Markdown, NOT HTML)
* @param {HtmlGeneratorOptions} [options={}] - Configuration options for HTML generation
* @returns {Promise<HtmlString>} A promise that resolves to the complete HTML document
* @throws {Error} When HTML generation fails due to parsing or file system errors
* @throws {Error} When HTML content is detected instead of Markdown (indicates a bug)
*
* @example
* ```typescript
* import { asMarkdown } from '../../types/content-formats.js';
*
* // ✅ CORRECT - Pass Markdown
* const html = await generator.generateHtml(
* asMarkdown('# Contract\n\nThis is a {{party.name}} agreement.'),
* {
* title: 'Service Agreement',
* cssPath: './contract-styles.css',
* includeHighlighting: true,
* metadata: {
* author: 'Legal Team',
* version: '1.0'
* }
* }
* );
*
* // ❌ INCORRECT - Don't pass HTML
* const html = await generator.generateHtml(
* '<h1>Contract</h1>', // This will throw an error!
* {}
* );
* ```
*/
async generateHtml(markdownContent, options = {}) {
try {
// Runtime validation: Ensure we're not receiving HTML instead of Markdown
if (isHtml(markdownContent)) {
throw new Error('generateHtml expects Markdown input, but received HTML content. ' +
'This usually indicates a bug where HTML was passed instead of Markdown. ' +
'The format-generator should pass processedResult.content (Markdown) directly ' +
'to pdfGenerator.generatePdf(), not pre-convert it to HTML. ' +
`Content preview: ${markdownContent.substring(0, 200)}...`);
}
logger.debug('Generating HTML from markdown', {
contentLength: markdownContent.length,
options,
});
// Remove YAML frontmatter if present
const contentWithoutFrontmatter = this.removeYamlFrontmatter(markdownContent);
// Convert markdown to HTML
let htmlContent = await marked.parse(contentWithoutFrontmatter);
// Post-process: unescape structural HTML tags (e.g., page-break divs) that were escaped in imported content
htmlContent = this.unescapeStructuralTags(htmlContent);
// 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 using shared configuration (same as browser bundle)
const formattedHtml = formatHtml(completeHtml);
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 - only include primitive values (objects/arrays/Maps
// would serialize as "[object Object]" which is useless in a meta tag)
const metaTags = Object.entries(metadata)
.filter(([, value]) => typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')
.map(([name, value]) => ` <meta name="${name}" content="${String(value)}">`)
.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>`;
}
/**
* Unescape specific structural HTML tags that were escaped in imported content
*
* Selectively unescapes certain structural tags that are needed for styling and page breaks.
*
* Tags unescaped:
* - <div class="page-break-before"></div>
* - <div class="page-break-after"></div>
* - Other div/span tags with specific classes
*
* @param html - HTML content with potentially escaped tags
* @returns HTML with structural tags unescaped
* @private
*/
unescapeStructuralTags(html) {
let processed = html;
// Unescape page-break divs wrapped in paragraphs
// Pattern: <p><div class="page-break-before"></div></p>
// Replace with: <div class="page-break-before"></div>
processed = processed.replace(/<p><div class="page-break-(before|after)"><\/div><\/p>/gi, '<div class="page-break-$1"></div>');
// Also handle cases without the wrapping <p>
processed = processed.replace(/<div class="page-break-(before|after)"><\/div>/gi, '<div class="page-break-$1"></div>');
// Unescape other common structural divs with classes
processed = processed.replace(/<p><div class="([^"]+)"><\/div><\/p>/gi, '<div class="$1"></div>');
return processed;
}
}
/**
* 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();
// Exported for testing - not part of public API
// These wrap private HtmlGenerator methods for unit test access
const _testInstance = new HtmlGenerator();
export function _removeYamlFrontmatter(content) {
return _testInstance.removeYamlFrontmatter(content);
}
export function _applyDomTransformations($) {
_testInstance.applyDomTransformations($);
}
export function _buildHtmlDocument(options) {
return _testInstance.buildHtmlDocument(options);
}
export function _unescapeStructuralTags(html) {
return _testInstance.unescapeStructuralTags(html);
}
//# sourceMappingURL=html-generator.js.map