legal-markdown-js
Version:
Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version
534 lines ⢠23.5 kB
JavaScript
/**
* CLI Service for Legal Markdown Processing
*
* This module provides a service class that handles the business logic for
* the CLI tool, including file processing, output generation, error handling,
* and user feedback. It coordinates between the core processing functions
* and the command-line interface.
*
* Features:
* - File and content processing orchestration
* - Multi-format output generation (HTML, PDF, DOCX, Markdown)
* - Error handling and user feedback
* - Verbose logging and debugging support
* - Path resolution and file system operations
* - Flexible output options (file, stdout)
*
* @example
* ```typescript
* import { CliService } from './cli/service.js';
*
* const service = new CliService({
* verbose: true,
* pdf: true,
* highlight: true
* });
*
* await service.processFile('input.md', 'output.md');
* ```
*
* @module
*/
import { processLegalMarkdown } from '../extensions/remark/legal-markdown-processor.js';
import { readFileSync, writeFileSync, resolveFilePath } from '../utils/index.js';
import { LegalMarkdownError, FileNotFoundError, PdfDependencyError } from '../errors/index.js';
import { extractForceCommands, parseForceCommands, applyForceCommands, } from '../core/parsers/force-commands-parser.js';
import { parseYamlFrontMatter } from '../core/parsers/yaml-parser.js';
import { RESOLVED_PATHS } from '../constants/index.js';
import { ArchiveManager } from '../utils/archive-manager.js';
import { buildProcessingContext, generateAllFormats, buildFormatGenerationOptions, } from '../core/pipeline/index.js';
import { resolvePdfConnector, } from '../extensions/generators/pdf-connectors/index.js';
import chalk from 'chalk';
import * as path from 'path';
import * as fs from 'fs';
import { getConfig } from '../config/index.js';
/**
* Get the path to the highlight CSS file from the package
* Works in both CommonJS and ESM environments
*/
function getHighlightCssPath() {
const isEsm = typeof __filename === 'undefined' || typeof __dirname === 'undefined';
if (!isEsm && typeof __dirname !== 'undefined') {
return path.resolve(__dirname, '..', 'styles', 'highlight.css');
}
// Fallback for ESM environments
return path.join(process.cwd(), 'src', 'styles', 'highlight.css');
}
/**
* Service class for CLI operations and document processing
*
* Handles file processing, output generation, and error management
* for the Legal Markdown CLI tool.
*
* @class CliService
* @example
* ```typescript
* const service = new CliService({
* verbose: true,
* pdf: true,
* highlight: true
* });
* ```
*/
export class CliService {
options;
/**
* Creates a new CLI service instance
*
* @param {CliOptions} [options={}] - Configuration options
*/
constructor(options = {}) {
this.options = options;
}
/**
* Resolves output file path using environment variables for relative paths
*
* @param {string} outputPath - The output path to resolve
* @returns {string} The resolved absolute output path
* @private
*/
resolveOutputPath(outputPath) {
if (path.isAbsolute(outputPath)) {
return outputPath;
}
return resolveFilePath(RESOLVED_PATHS.DEFAULT_OUTPUT_DIR, outputPath);
}
/**
* Determines the output directory for generated files
*
* @param {string | undefined} outputPath - The output path (if provided)
* @returns {string} The directory to use for output files
* @private
*/
getOutputDirectory(outputPath) {
if (outputPath && path.isAbsolute(outputPath)) {
return path.dirname(outputPath);
}
return RESOLVED_PATHS.DEFAULT_OUTPUT_DIR;
}
/**
* Processes a file from input path to output path
*
* @param {string} inputPath - Path to the input file
* @param {string} [outputPath] - Path for output file (optional for stdout)
* @returns {Promise<void>}
* @throws {FileNotFoundError} When input file doesn't exist
* @throws {LegalMarkdownError} When processing fails
*/
async processFile(inputPath, outputPath) {
try {
this.log(`Processing file: ${inputPath}`, 'info');
const resolvedInputPath = resolveFilePath(this.options.basePath, inputPath);
// Check if file exists before trying to read it
if (!fs.existsSync(resolvedInputPath)) {
throw new FileNotFoundError(resolvedInputPath);
}
const content = readFileSync(resolvedInputPath);
// Use the directory of the input file as the basePath for imports
const inputDir = path.dirname(resolvedInputPath);
// Process force commands and update options FIRST
const effectiveOptions = this.processForceCommands(content, {
...this.options,
basePath: inputDir,
enableFieldTracking: this.options.enableFieldTracking || this.options.highlight,
});
// Determine output format using effective options (after force commands)
if (effectiveOptions.pdf || effectiveOptions.html || effectiveOptions.docx) {
await this.generateFormattedOutputWithOptions(content, inputPath, outputPath, effectiveOptions);
}
else {
this.log('Using remark-based processor', 'info');
const result = await processLegalMarkdown(content, {
basePath: effectiveOptions.basePath,
enableFieldTracking: effectiveOptions.enableFieldTracking,
debug: effectiveOptions.debug,
yamlOnly: effectiveOptions.yamlOnly,
noHeaders: effectiveOptions.noHeaders,
noClauses: effectiveOptions.noClauses,
noReferences: effectiveOptions.noReferences,
noImports: effectiveOptions.noImports,
noMixins: effectiveOptions.noMixins,
noReset: effectiveOptions.noReset,
noIndent: false,
throwOnYamlError: effectiveOptions.throwOnYamlError,
exportMetadata: effectiveOptions.exportMetadata,
exportFormat: effectiveOptions.exportFormat,
exportPath: effectiveOptions.exportPath,
importTracing: effectiveOptions.importTracing,
validateImportTypes: effectiveOptions.validateImportTypes,
logImportOperations: effectiveOptions.logImportOperations,
astFieldTracking: effectiveOptions.astFieldTracking,
logicBranchHighlighting: effectiveOptions.logicBranchHighlighting,
});
if (outputPath) {
const resolvedOutputPath = this.resolveOutputPath(outputPath);
writeFileSync(resolvedOutputPath, result.content);
this.log(`Output written to: ${resolvedOutputPath}`, 'success');
console.error('Successfully processed');
}
else {
console.log(result.content);
}
if (result.exportedFiles && result.exportedFiles.length > 0) {
this.log(`Exported files: ${result.exportedFiles.join(', ')}`, 'info');
}
if (result.metadata && this.options.verbose) {
this.log('Metadata:', 'info');
console.error(JSON.stringify(result.metadata, null, 2));
}
// Archive source file if requested
await this.handleArchiving(resolvedInputPath, content, result.content);
}
}
catch (error) {
this.handleError(error);
throw error; // Re-throw to allow CLI to handle exit codes
}
}
/**
* Processes content directly without file I/O
*
* @param {string} content - The content to process
* @returns {Promise<string>} The processed content
* @throws {LegalMarkdownError} When processing fails
*/
async processContent(content) {
try {
// Process force commands and update options
const effectiveOptions = this.processForceCommands(content, {
...this.options,
enableFieldTracking: this.options.enableFieldTracking || this.options.highlight,
});
// Handle auto-populate headers mode
if (effectiveOptions.autoPopulateHeaders) {
const { autoPopulateYamlFrontMatter } = await import('../core/yaml/yaml-auto-population.js');
return autoPopulateYamlFrontMatter(content);
}
const result = await processLegalMarkdown(content, {
basePath: effectiveOptions.basePath,
enableFieldTracking: effectiveOptions.enableFieldTracking,
debug: effectiveOptions.debug,
yamlOnly: effectiveOptions.yamlOnly,
noHeaders: effectiveOptions.noHeaders,
noClauses: effectiveOptions.noClauses,
noReferences: effectiveOptions.noReferences,
noImports: effectiveOptions.noImports,
noMixins: effectiveOptions.noMixins,
noReset: effectiveOptions.noReset,
noIndent: false,
throwOnYamlError: effectiveOptions.throwOnYamlError,
exportMetadata: effectiveOptions.exportMetadata,
exportFormat: effectiveOptions.exportFormat,
exportPath: effectiveOptions.exportPath,
importTracing: effectiveOptions.importTracing,
validateImportTypes: effectiveOptions.validateImportTypes,
logImportOperations: effectiveOptions.logImportOperations,
astFieldTracking: effectiveOptions.astFieldTracking,
logicBranchHighlighting: effectiveOptions.logicBranchHighlighting,
});
return result.content;
}
catch (error) {
this.handleError(error);
throw error;
}
}
/**
* Logs messages with appropriate styling and prefixes
*
* @private
* @param {string} message - The message to log
* @param {'info' | 'success' | 'warn' | 'error'} [level='info'] - The log level
* @returns {void}
*/
log(message, level = 'info') {
if (!this.options.verbose && level === 'info')
return;
const colors = {
info: chalk.blue,
success: chalk.green,
warn: chalk.yellow,
error: chalk.red,
};
const prefix = {
info: '[info]',
success: '[ok]',
warn: '[warn]',
error: '[error]',
};
console.error(`${prefix[level]} ${colors[level](message)}`);
}
/**
* Generates formatted output (HTML/PDF/DOCX) using 3-phase pipeline
*
* This method uses the new 3-phase pipeline architecture:
* - Phase 1: Build context (parse YAML, resolve force-commands)
* - Phase 2: Process content ONCE (run remark pipeline, cache AST)
* - Phase 3: Generate ALL formats from cached result (no re-processing)
*
* @private
* @param {string} content - The content to format
* @param {string} inputPath - Original input path for naming
* @param {string} [outputPath] - Output path override
* @returns {Promise<void>}
*/
async generateFormattedOutputWithOptions(content, inputPath, outputPath, options) {
const config = getConfig();
let resolvedPdfConnector;
if (options.pdf) {
const connectorPreference = options.pdfConnector ?? config.pdf.connector;
resolvedPdfConnector = await resolvePdfConnector(connectorPreference);
}
const baseOutputPath = outputPath || inputPath;
const baseName = path.basename(baseOutputPath, path.extname(baseOutputPath));
const dirName = this.getOutputDirectory(outputPath);
// Get the directory of the input file for imports
const resolvedInputPath = resolveFilePath(this.options.basePath, inputPath);
const inputDir = path.dirname(resolvedInputPath);
// Resolve CSS path if provided
let cssPath = options.cssPath || options.css;
if (typeof cssPath === 'string' && cssPath && !path.isAbsolute(cssPath)) {
// If CSS path is relative, resolve it against STYLES_DIR
cssPath = path.resolve(RESOLVED_PATHS.STYLES_DIR, cssPath);
}
else if (typeof cssPath !== 'string') {
cssPath = undefined;
}
// PHASE 1: Build processing context (parses YAML, resolves force-commands)
const context = await buildProcessingContext(content, {
...options,
basePath: inputDir,
enableFieldTracking: options.enableFieldTracking || options.highlight,
}, inputDir);
// PHASE 2: Process content ONCE (runs remark pipeline, caches AST)
// For HTML/PDF/DOCX generation, we need noIndent: true to prevent indented headers
// from being interpreted as code blocks
const processedResult = await processLegalMarkdown(context.content, {
...context.options,
additionalMetadata: context.metadata, // Pass YAML metadata for header processing
noIndent: true, // Force noIndent for HTML/PDF/DOCX generation
});
// PHASE 3: Generate all formats from cached result (NO re-processing!)
// IMPORTANT: Use context.options instead of options parameter
// because force-commands may have modified values (e.g. highlight, pdf, html, css)
const highlightCssPath = getHighlightCssPath();
const formatGenerationOptions = buildFormatGenerationOptions(context.options, {
outputDir: dirName,
baseFilename: baseName,
pdf: options.pdf,
html: options.html,
docx: options.docx,
markdown: options.toMarkdown || (!options.html && !options.pdf && !options.docx),
metadata: options.exportMetadata,
highlight: options.highlight,
cssPath,
highlightCssPath,
title: options.title || baseName,
format: options.format,
landscape: options.landscape,
pdfConnector: resolvedPdfConnector,
pdfMargin: config.pdf.margin,
exportFormat: options.exportFormat,
exportPath: options.exportPath,
});
const isStdout = !outputPath && options.stdout;
const formatResult = await generateAllFormats(processedResult, formatGenerationOptions);
// When --html --stdout is requested, pipe the HTML content to stdout instead of reporting file
if (isStdout && options.html && !options.pdf) {
const htmlPath = formatResult.results.html?.normal;
if (htmlPath) {
process.stdout.write(fs.readFileSync(htmlPath, 'utf8'));
fs.unlinkSync(htmlPath); // clean up temp file - caller wanted stdout, not a file artifact
}
return;
}
// Show generated files
this.showGeneratedFiles(formatResult.generatedFiles, formatGenerationOptions.highlight);
// Archive source file if requested (reuse processed content from Phase 2)
await this.handleArchiving(resolvedInputPath, content, processedResult.content);
}
/**
* Show generated files with proper grouping and formatting
*
* @private
* @param {string[]} files - Array of generated file paths
* @param {boolean} hasHighlight - Whether highlight versions were generated
*/
showGeneratedFiles(files, hasHighlight) {
if (files.length === 0 || this.options.silent) {
return;
}
this.log('Files generated successfully!', 'success');
console.error(chalk.bold('\nš Generated files:'));
if (hasHighlight) {
// Group files by extension
const grouped = new Map();
for (const file of files) {
const ext = path.extname(file);
const basename = path.basename(file, ext);
const isHighlight = basename.includes('.HIGHLIGHT');
if (isHighlight) {
const normalBasename = basename.replace('.HIGHLIGHT', '');
const key = `${normalBasename}${ext}`;
const existing = grouped.get(key) || { normal: '' };
existing.highlight = file;
grouped.set(key, existing);
}
else {
const key = `${basename}${ext}`;
const existing = grouped.get(key) || { normal: file };
existing.normal = file;
grouped.set(key, existing);
}
}
// Show grouped files by extension
const extensions = new Set(Array.from(grouped.keys()).map(key => key.split('.').pop()?.toLowerCase()));
for (const ext of ['md', 'html', 'pdf', 'docx']) {
if (!extensions.has(ext))
continue;
console.error(chalk.gray(`\n ${ext.toUpperCase()}:`));
for (const [key, fileGroup] of grouped) {
if (!key.endsWith(`.${ext}`))
continue;
if (fileGroup.normal) {
console.error(` ${chalk.cyan(fileGroup.normal)}`);
}
if (fileGroup.highlight) {
console.error(` ${chalk.cyan(fileGroup.highlight)}`);
}
}
}
}
else {
// Simple list when no highlight
for (const file of files) {
console.error(` ${chalk.cyan(file)}`);
}
}
}
/**
* Handle archiving of source file after successful processing
*
* @private
* @param {string} inputPath - Path to the source file to archive
* @param {string} originalContent - Original file content
* @param {string} processedContent - Processed file content
* @returns {Promise<void>}
*/
async handleArchiving(inputPath, originalContent, processedContent) {
// Only archive if the option is enabled
if (!this.options.archiveSource) {
return;
}
try {
const archiveManager = new ArchiveManager();
// Determine archive directory
let archiveDir;
if (typeof this.options.archiveSource === 'string') {
// Custom directory provided
archiveDir = this.options.archiveSource;
}
else {
// Use default from environment/config
archiveDir = RESOLVED_PATHS.ARCHIVE_DIR;
}
// Use smart archiving that compares original vs processed content
const result = await archiveManager.smartArchiveFile(inputPath, {
archiveDir,
createDirectory: true,
conflictResolution: 'rename',
originalContent,
processedContent,
});
if (result.success) {
if (result.contentsIdentical) {
this.log(`Source file archived to: ${result.archivedPath}`, 'success');
}
else {
this.log(`Original archived to: ${result.archivedOriginalPath}`, 'success');
this.log(`Processed archived to: ${result.archivedProcessedPath}`, 'success');
}
}
else {
this.log(`Warning: Failed to archive source file: ${result.error}`, 'warn');
}
}
catch (error) {
// Don't fail the entire operation if archiving fails
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.log(`Warning: Archive operation failed: ${errorMessage}`, 'warn');
}
}
/**
* Handles and formats errors for user display
*
* @private
* @param {unknown} error - The error to handle
* @returns {void}
*/
handleError(error) {
if (error instanceof PdfDependencyError) {
this.log('PDF support is not currently installed.', 'error');
console.error(chalk.cyan('Install with: npm install puppeteer'));
console.error(chalk.cyan('Or install Chrome/Chromium/Edge/Brave/Arc and use --pdf-connector=system-chrome'));
console.error(chalk.cyan('Or install WeasyPrint and use --pdf-connector=weasyprint'));
return;
}
if (error instanceof LegalMarkdownError) {
this.log(`${error.name}: ${error.message}`, 'error');
if (error.context && this.options.verbose) {
console.error('Context:', error.context);
}
}
else if (error instanceof Error) {
this.log(`Error: ${error.message}`, 'error');
}
else {
this.log(`Unexpected error: ${String(error)}`, 'error');
}
if (this.options.debug) {
console.error(error);
}
}
/**
* Process force commands from content and apply them to options
*
* @private
* @param {string} content - The content to analyze for force commands
* @param {Partial<CliOptions>} baseOptions - Base options to extend
* @returns {Partial<CliOptions>} Updated options with force commands applied
*/
processForceCommands(content, baseOptions) {
try {
// Parse YAML front matter to extract metadata
const { metadata } = parseYamlFrontMatter(content, false);
if (!metadata) {
return baseOptions;
}
// First check for direct metadata options
const updatedOptionsFromMetadata = { ...baseOptions };
// Extract force commands from metadata
const forceCommandsString = extractForceCommands(metadata);
if (!forceCommandsString) {
return updatedOptionsFromMetadata;
}
this.log(`Found force commands: ${forceCommandsString}`, 'info');
// Parse the force commands
const forceCommands = parseForceCommands(forceCommandsString, metadata, updatedOptionsFromMetadata);
if (!forceCommands) {
this.log('Failed to parse force commands', 'warn');
return updatedOptionsFromMetadata;
}
// Apply force commands to updated options (includes metadata options)
const updatedOptions = applyForceCommands(updatedOptionsFromMetadata, forceCommands);
this.log(`Applied force commands: ${Object.keys(forceCommands).join(', ')}`, 'success');
return updatedOptions;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.log(`Error processing force commands: ${errorMessage}`, 'warn');
return baseOptions;
}
}
}
//# sourceMappingURL=service.js.map