UNPKG

legal-markdown-js

Version:

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

607 lines 25.5 kB
#!/usr/bin/env node /** * Command Line Interface for Legal Markdown Processing * * This module provides a comprehensive CLI tool for processing Legal Markdown * documents with support for various output formats, processing options, and * input/output methods (files, stdin/stdout). * * Features: * - File and stdin input processing * - Multiple output formats (Markdown, HTML, PDF) * - Comprehensive processing options and flags * - Field highlighting and styling options * - Metadata export capabilities * - Frontmatter merging from imported files * - Import tracing and debugging options * - Error handling and user feedback * - Debug mode support * * @example * ```bash * # Process a file * legal-md input.md output.md * * # Generate PDF with highlighting * legal-md input.md --pdf --highlight --title "Contract" * * # Process from stdin * cat input.md | legal-md --stdin --stdout * * # Export metadata * legal-md input.md --export-yaml --output-path metadata.yaml * ``` * * @module */ import { Command } from 'commander'; import chalk from 'chalk'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { confirm, input as promptInput, select } from '@inquirer/prompts'; import * as yaml from 'js-yaml'; import { CliService } from './service.js'; import { FileNotFoundError, PdfDependencyError } from '../errors/index.js'; import { RESOLVED_PATHS } from '../constants/index.js'; import { getPackageVersion } from './utils/version.js'; import { clearConfigCache, DEFAULT_CONFIG, getConfig, loadConfig, SEARCH_PLACES, validateConfig, } from '../config/index.js'; import { LLMS_TXT } from '../generated/llms-txt.js'; const version = getPackageVersion('../../package.json'); function printLlmsTxt() { process.stdout.write(LLMS_TXT); if (!LLMS_TXT.endsWith('\n')) { process.stdout.write('\n'); } } /** * Helper function to read content from standard input * * @returns {Promise<string>} A promise that resolves to the stdin content * @throws {Error} When stdin reading fails */ async function readStdin() { return new Promise((resolve, reject) => { let data = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { data += chunk; }); process.stdin.on('end', () => { resolve(data); }); process.stdin.on('error', error => { reject(error); }); }); } function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } function deepMerge(...objects) { const result = {}; for (const obj of objects) { if (!obj || !isRecord(obj)) continue; for (const [key, value] of Object.entries(obj)) { const existing = result[key]; if (isRecord(existing) && isRecord(value)) { result[key] = deepMerge(existing, value); } else if (value !== undefined) { result[key] = value; } } } return result; } function parseConfigValue(rawValue) { if (rawValue === 'true') return true; if (rawValue === 'false') return false; if (rawValue === 'null') return null; const asNumber = Number(rawValue); if (!Number.isNaN(asNumber) && rawValue.trim() !== '') { return asNumber; } try { return JSON.parse(rawValue); } catch { return rawValue; } } function getByPath(source, keyPath) { const segments = keyPath.split('.').filter(Boolean); let current = source; for (const segment of segments) { if (!isRecord(current) || !(segment in current)) { return undefined; } current = current[segment]; } return current; } function setByPath(source, keyPath, value) { const segments = keyPath.split('.').filter(Boolean); if (segments.length === 0) return; let current = source; for (const segment of segments.slice(0, -1)) { const next = current[segment]; if (!isRecord(next)) { current[segment] = {}; } current = current[segment]; } current[segments[segments.length - 1]] = value; } const PDF_CONNECTOR_CHOICES = [ 'auto', 'puppeteer', 'system-chrome', 'weasyprint', ]; function parsePdfConnectorPreference(value) { if (typeof value !== 'string') { return undefined; } if (PDF_CONNECTOR_CHOICES.includes(value)) { return value; } throw new Error(`Invalid --pdf-connector value: ${value}. Expected one of: ${PDF_CONNECTOR_CHOICES.join(', ')}`); } function getProjectConfigPath() { return path.join(process.cwd(), '.legalmdrc.yaml'); } function getGlobalConfigPath() { return path.join(os.homedir(), '.config', 'legal-md', 'config.yaml'); } function readConfigFile(configPath) { if (!fs.existsSync(configPath)) { return {}; } const content = fs.readFileSync(configPath, 'utf8'); if (!content.trim()) { return {}; } if (configPath.endsWith('.json')) { return JSON.parse(content); } const parsed = yaml.load(content); return (parsed ?? {}); } function writeConfigFile(configPath, config) { fs.mkdirSync(path.dirname(configPath), { recursive: true }); if (configPath.endsWith('.json')) { fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8'); return; } fs.writeFileSync(configPath, yaml.dump(config, { lineWidth: 120 }), 'utf8'); } // Create program const program = new Command(); // Setup CLI information program .name('legal-md') .description('Process Legal Markdown documents into Markdown, HTML, PDF, DOCX, or metadata exports.') .commandsGroup('Commands:'); program .command('init') .helpGroup('Commands:') .description('Create a legal-md configuration file') .option('-g, --global', 'Write to global config (~/.config/legal-md/config.yaml)') .action(async (options) => { await loadConfig(); const current = getConfig(); const scope = options.global ? 'global' : await select({ message: 'Where should configuration be saved?', choices: [ { name: 'Project (.legalmdrc.yaml)', value: 'project' }, { name: 'Global (~/.config/legal-md/config.yaml)', value: 'global' }, ], }); const configPath = scope === 'global' ? getGlobalConfigPath() : getProjectConfigPath(); const images = await promptInput({ message: 'Images directory', default: current.paths.images, }); const styles = await promptInput({ message: 'Styles directory', default: current.paths.styles, }); const inputDir = await promptInput({ message: 'Default input directory', default: current.paths.input, }); const outputDir = await promptInput({ message: 'Default output directory', default: current.paths.output, }); const archiveDir = await promptInput({ message: 'Archive directory', default: current.paths.archive, }); const logLevel = await select({ message: 'Log level', default: current.logging.level, choices: [ { name: 'error', value: 'error' }, { name: 'warn', value: 'warn' }, { name: 'info', value: 'info' }, { name: 'debug', value: 'debug' }, ], }); const debug = await confirm({ message: 'Enable debug logging?', default: current.logging.debug, }); const validationMode = await select({ message: 'Validation mode', default: current.processing.validationMode, choices: [ { name: 'auto', value: 'auto' }, { name: 'strict', value: 'strict' }, { name: 'permissive', value: 'permissive' }, ], }); const config = validateConfig({ ...current, paths: { ...current.paths, images, styles, input: inputDir, output: outputDir, archive: archiveDir, }, logging: { ...current.logging, level: logLevel, debug, }, processing: { ...current.processing, validationMode, }, }); // `writeConfigFile` and `getByPath` accept `Record<string, unknown>`. // `LegalMdConfig` is structurally compatible but nominally distinct, so the // cast is required to satisfy the utility function's generic signature. writeConfigFile(configPath, config); clearConfigCache(); await loadConfig(); console.log(chalk.green(`✅ Configuration written to ${configPath}`)); }); program .command('config') .helpGroup('Commands:') .description('Show, get, or set legal-md configuration values') .argument('[action]', 'show | get | set', 'show') .argument('[key]', 'dot-path key (e.g. paths.input)') .argument('[value]', 'value for set action') .option('-g, --global', 'Read/write the global configuration file') .action(async (action, key, value, options) => { await loadConfig(); if (action === 'show') { const globalPath = getGlobalConfigPath(); const globalExists = fs.existsSync(globalPath); const projectFile = SEARCH_PLACES.filter(f => f !== 'package.json') .map(f => path.join(process.cwd(), f)) .find(f => fs.existsSync(f)); const globalLabel = globalExists ? chalk.green(globalPath.replace(os.homedir(), '~')) : chalk.dim(globalPath.replace(os.homedir(), '~')); const globalStatus = globalExists ? chalk.green('(active)') : chalk.dim('(not found)'); const projectLabel = projectFile ? chalk.green(path.relative(process.cwd(), projectFile)) : chalk.dim('.legalmdrc.yaml'); const projectStatus = projectFile ? chalk.green('(active)') : chalk.dim('(not found)'); console.log(''); console.log(chalk.bold('Sources:')); console.log(` Global: ${globalLabel} ${globalStatus}`); console.log(` Project: ${projectLabel} ${projectStatus}`); if (!globalExists && !projectFile) { console.log(chalk.dim(' Using defaults only.')); } console.log(''); const flattenConfig = (obj, prefix = '') => Object.entries(obj).flatMap(([k, v]) => { const key = prefix ? `${prefix}.${k}` : k; return v !== null && typeof v === 'object' && !Array.isArray(v) ? flattenConfig(v, key) : [[key, v]]; }); const entries = flattenConfig(getConfig()); const defaults = flattenConfig(DEFAULT_CONFIG); const defaultMap = new Map(defaults.map(([k, v]) => [k, v])); const keyWidth = Math.max(...entries.map(([k]) => k.length)) + 2; const valWidth = Math.max(...entries.map(([, v]) => String(v).length)) + 2; console.log(chalk.bold('Settings:')); for (const [k, v] of entries) { const isDefault = String(v) === String(defaultMap.get(k)); const annotation = isDefault ? chalk.dim('(default)') : chalk.green('(set)'); console.log(` ${chalk.cyan(k.padEnd(keyWidth))}${String(v).padEnd(valWidth)}${annotation}`); } console.log(''); return; } if (action === 'get') { if (!key) { console.error(chalk.red('Error: key is required for config get')); process.exit(1); } const result = getByPath(getConfig(), key); // see cast rationale above if (result === undefined) { console.error(chalk.red(`Error: key not found: ${key}`)); process.exit(1); } if (typeof result === 'object') { console.log(JSON.stringify(result, null, 2)); } else { console.log(String(result)); } return; } if (action === 'set') { if (!key || value === undefined) { console.error(chalk.red('Error: key and value are required for config set')); process.exit(1); } const targetPath = options.global ? getGlobalConfigPath() : getProjectConfigPath(); const fileConfig = readConfigFile(targetPath); setByPath(fileConfig, key, parseConfigValue(value)); validateConfig(deepMerge(DEFAULT_CONFIG, // see cast rationale above fileConfig)); writeConfigFile(targetPath, fileConfig); clearConfigCache(); await loadConfig(); console.log(chalk.green(`✅ Updated ${key} in ${targetPath}`)); return; } console.error(chalk.red(`Error: unsupported action '${action}'. Use show, get, or set.`)); process.exit(1); }); // Main command program .argument('[input]', 'Input file path') .argument('[output]', 'Output file path') .optionsGroup('Common options:') .option('--stdin', 'Read input from standard input') .option('--stdout', 'Write text/HTML output to standard output') .option('--title <title>', 'Document title for HTML/PDF/DOCX') .option('--css <path>', 'Custom CSS for HTML/PDF/DOCX') .version(version, '-V, --version', 'Output the version number') .helpOption('-h, --help', 'Show help') .optionsGroup('Processing:') .option('-y, --yaml', 'Process only YAML front matter') .option('--headers', 'Auto-populate YAML front matter with header patterns and properties') .option('--no-headers', 'Skip header processing') .option('--no-clauses', 'Skip optional clause processing') .option('--no-references', 'Skip cross-reference processing') .option('--no-imports', 'Skip import processing') .option('--no-mixins', 'Skip mixin processing') .option('--no-reset', 'Disable header numbering reset (continuous numbering)') .option('--no-indent', 'Disable header indentation (flat formatting)') .option('--throwOnYamlError', 'Throw error on invalid YAML') .option('--to-markdown', 'Convert output to markdown format') .optionsGroup('Metadata export:') .option('--export-yaml', 'Export metadata as YAML') .option('--export-json', 'Export metadata as JSON') .option('-o, --output-path <path>', 'Output path for metadata export') .optionsGroup('Output formats:') .option('--html', 'Generate HTML output') .option('--pdf', 'Generate PDF output') .option('--docx', 'Generate DOCX output') .option('--highlight', 'Enable review highlighting in HTML/PDF/DOCX') .option('--pdf-connector <connector>', 'PDF backend: auto | puppeteer | system-chrome | weasyprint') .option('--archive-source [dir]', 'Archive source file after successful processing') .optionsGroup('Review and debugging:') .option('-d, --debug', 'Enable debug mode') .option('--enable-field-tracking', 'Add field tracking spans to markdown output') .option('--ast-field-tracking', 'Use AST-first field tracking route (Phase 2 tokens -> Phase 3)') .option('--logic-branch-highlighting', 'Annotate winner branches for conditional blocks') .optionsGroup('Imports and frontmatter:') .option('--disable-frontmatter-merge', 'Disable automatic frontmatter merging from imported files (enabled by default)') .option('--import-tracing', 'Add HTML comments showing imported content boundaries') .option('--validate-import-types', 'Validate type compatibility during frontmatter merging') .option('--log-import-operations', 'Log detailed frontmatter merge operations') .optionsGroup('AI docs:') .option('--llms-txt', 'Print bundled llms.txt for AI agents') .action(async (input, output, options) => { try { if (options.llmsTxt) { printLlmsTxt(); return; } await loadConfig(); if (options.docx && options.stdout) { console.error(chalk.red('Error: --docx cannot be used with --stdout.')); console.error(chalk.yellow('DOCX output is a binary format. Provide an output file path instead.')); process.exit(1); } // Handle stdin input if (options.stdin) { const stdinContent = await readStdin(); const cliOptions = { debug: options.debug, yamlOnly: options.yaml, autoPopulateHeaders: options.headers === true, noHeaders: options.headers === false, noClauses: options.clauses === false, noReferences: options.references === false, noImports: options.imports === false, noMixins: options.mixins === false, noReset: options.reset === false, noIndent: options.indent === false, throwOnYamlError: options.throwOnYamlError, toMarkdown: options.toMarkdown, exportMetadata: options.exportYaml || options.exportJson, exportFormat: options.exportYaml ? 'yaml' : 'json', exportPath: options.outputPath, basePath: RESOLVED_PATHS.DEFAULT_INPUT_DIR, verbose: options.debug, pdf: options.pdf, pdfConnector: parsePdfConnectorPreference(options.pdfConnector), html: options.html, docx: options.docx, highlight: options.highlight, enableFieldTracking: options.enableFieldTracking, enableFieldTrackingInMarkdown: options.enableFieldTracking, astFieldTracking: options.astFieldTracking || options.enableFieldTracking, logicBranchHighlighting: options.logicBranchHighlighting || options.enableFieldTracking, disableFrontmatterMerge: options.disableFrontmatterMerge, importTracing: options.importTracing, validateImportTypes: options.validateImportTypes, logImportOperations: options.logImportOperations, css: options.css, title: options.title, archiveSource: options.archiveSource, stdout: options.stdout, }; const cliService = new CliService(cliOptions); // When using stdin, first argument is the output file const outputFile = input; const isFormattedOutputRequested = Boolean(options.html || options.pdf || options.docx); const isBinaryOutputRequested = Boolean(options.pdf || options.docx); if (isBinaryOutputRequested && options.stdout) { console.error(chalk.red('Error: binary formats cannot be used with --stdout.')); console.error(chalk.yellow('Use an explicit output file path when using --stdin with PDF/DOCX.')); process.exit(1); } if (isFormattedOutputRequested && !options.stdout && !outputFile) { console.error(chalk.red('Error: Output file is required for formatted output when using --stdin.')); console.error(chalk.yellow('Provide an output path, or use --stdout with --html.')); process.exit(1); } if (isFormattedOutputRequested) { const tempInputPath = path.join(os.tmpdir(), `legal-md-stdin-${Date.now()}-${Math.random().toString(36).slice(2)}.md`); fs.writeFileSync(tempInputPath, stdinContent, 'utf8'); try { if (options.stdout) { await cliService.processFile(tempInputPath); } else { await cliService.processFile(tempInputPath, outputFile); } } finally { fs.rmSync(tempInputPath, { force: true }); } return; } const result = await cliService.processContent(stdinContent); if (options.stdout || !outputFile) { console.log(result); } else { fs.writeFileSync(outputFile, result); console.error(`✅ Output written to: ${outputFile}`); } return; } // Handle input file if (!input) { console.error(chalk.red('Error: Input file is required\n')); console.error(chalk.yellow('Usage examples:')); console.error(chalk.cyan(' legal-md document.md output.md ') + '# Process file to file'); console.error(chalk.cyan(' legal-md document.md --stdout ') + '# Process file to stdout'); console.error(chalk.cyan(' legal-md document.md --pdf --title "Doc" ') + '# Generate PDF'); console.error(chalk.cyan(' legal-md document.md --html --highlight ') + '# Generate HTML with highlighting'); console.error(chalk.cyan(' legal-md document.md --docx ') + '# Generate DOCX'); console.error(chalk.cyan(' legal-md doc.md --disable-frontmatter-merge') + '# Disable imported frontmatter merging'); console.error(chalk.cyan(' cat document.md | legal-md --stdin ') + '# Process from stdin'); console.error(chalk.cyan(' legal-md --help ') + '# Show all options\n'); process.exit(1); } const cliOptions = { debug: options.debug, yamlOnly: options.yaml, autoPopulateHeaders: options.headers === true, noHeaders: options.headers === false, noClauses: options.clauses === false, noReferences: options.references === false, noImports: options.imports === false, noMixins: options.mixins === false, noReset: options.reset === false, noIndent: options.indent === false, throwOnYamlError: options.throwOnYamlError, toMarkdown: options.toMarkdown, exportMetadata: options.exportYaml || options.exportJson, exportFormat: options.exportYaml ? 'yaml' : 'json', exportPath: options.outputPath, basePath: RESOLVED_PATHS.DEFAULT_INPUT_DIR, verbose: options.debug, pdf: options.pdf, pdfConnector: parsePdfConnectorPreference(options.pdfConnector), html: options.html, docx: options.docx, highlight: options.highlight, enableFieldTracking: options.enableFieldTracking, enableFieldTrackingInMarkdown: options.enableFieldTracking, astFieldTracking: options.astFieldTracking || options.enableFieldTracking, logicBranchHighlighting: options.logicBranchHighlighting || options.enableFieldTracking, disableFrontmatterMerge: options.disableFrontmatterMerge, importTracing: options.importTracing, validateImportTypes: options.validateImportTypes, logImportOperations: options.logImportOperations, css: options.css, title: options.title, archiveSource: options.archiveSource, stdout: options.stdout, }; const cliService = new CliService(cliOptions); // Force stdout if --stdout flag is used if (options.stdout) { await cliService.processFile(input); } else { await cliService.processFile(input, output); } } catch (error) { if (error instanceof FileNotFoundError) { const resolvedPath = path.isAbsolute(input) ? input : path.resolve(RESOLVED_PATHS.DEFAULT_INPUT_DIR, input); const configuredInputDir = path.relative(process.cwd(), RESOLVED_PATHS.DEFAULT_INPUT_DIR) || '.'; console.error(chalk.red(`\n❌ File not found: ${input}`)); console.error(chalk.dim(` Looked in: ${resolvedPath}`)); console.error(chalk.yellow(` Tip: Use an absolute path, or run from the input directory.`)); console.error(chalk.dim(` Configured input dir: ${configuredInputDir}/`) + chalk.gray(` (change with: legal-md config set paths.input .)`)); console.error(chalk.dim(` Check all defined paths with: `) + chalk.cyan(`legal-md config show`)); console.error(''); process.exit(1); } if (error instanceof PdfDependencyError) { process.exit(1); } console.error(chalk.red('Error processing document:')); console.error(error); process.exit(1); } }); program.addHelpText('after', ` Examples: legal-md contract.md output.md legal-md contract.md --html legal-md contract.md --pdf --highlight cat contract.md | legal-md --stdin --stdout legal-md --export-json contract.md -o metadata.json legal-md --llms-txt `); program.addHelpText('after', 'More:\n legal-md config show Show active configuration and config file paths\n'); program.addHelpText('after', `Subcommands (pass as first argument before any options): legal-md ui Launch interactive terminal UI legal-md playground Start local web playground (http://localhost:7891) `); // Parse command line arguments program.parse(); //# sourceMappingURL=index.js.map