legal-markdown-js
Version:
Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version
370 lines • 13.2 kB
JavaScript
/**
* @fileoverview Force Commands Parser for Legal Markdown
*
* This module provides functionality to parse and apply forced configuration commands
* from YAML front matter. It allows documents to specify their own processing options
* directly in the document metadata, enabling automatic configuration based on document content.
*
* Features:
* - Parse command strings from YAML front matter
* - Support for template variable resolution in commands
* - Integration with existing CLI options
* - Override protection for critical options
* - Validation and error handling
*
* @example
* ```yaml
* ---
* title: Contract
* client: Acme Corp
* force_commands: >
* --css custom.css
* --output-name Contract_{{title}}_{{client}}_{{formatDate(date, "YYYYMMDD")}}.pdf
* --pdf --highlight
* ---
* ```
*/
import { logger } from '../../utils/logger.js';
import { replaceTemplateVariables } from '../utils/template-variable-replacer.js';
/**
* Commands that are not allowed to be overridden for security reasons
*/
const PROTECTED_COMMANDS = [
'stdin',
'stdout',
'yaml',
'headers',
'no-headers',
'no-clauses',
'no-references',
'no-imports',
'no-mixins',
'throwOnYamlError',
];
function isSimpleTemplatePath(path) {
return /^[a-zA-Z_$][\w$]*(?:\[[0-9]+\]|\.[a-zA-Z_$][\w$]*|\[["'][^"'\]]+["']\])*$/u.test(path);
}
function extractUnsupportedTemplateExpressions(command) {
const matches = Array.from(command.matchAll(/\{\{\s*([^{}]+?)\s*\}\}/g));
return matches
.map(match => String(match[1]).trim())
.filter(expression => !isSimpleTemplatePath(expression));
}
/**
* Parse a force_commands string into structured options
*
* @param commandString - The command string from YAML front matter
* @param metadata - Document metadata for template resolution
* @param processingOptions - Current processing options for template context
* @returns Parsed command options or null if parsing fails
*
* @example
* ```typescript
* const commands = parseForceCommands(
* "--css theme.css --pdf --output-name {{title}}_{{client}}.pdf",
* { title: "Contract", client: "Acme" },
* {}
* );
* // Returns: { css: "theme.css", pdf: true, output: "Contract_Acme.pdf" }
* ```
*/
export function parseForceCommands(commandString, metadata = {}, processingOptions = {}) {
void processingOptions;
if (!commandString || typeof commandString !== 'string') {
return null;
}
logger.debug('Parsing force commands', { commandString, metadataKeys: Object.keys(metadata) });
try {
// First, resolve any template variables in the command string
const resolvedCommandString = replaceTemplateVariables(commandString, metadata);
const unsupportedExpressions = extractUnsupportedTemplateExpressions(resolvedCommandString);
if (unsupportedExpressions.length > 0) {
logger.warn('force_commands contains unsupported template expressions; only simple metadata paths are resolved', { unsupportedExpressions });
}
logger.debug('Resolved command string', {
original: commandString,
resolved: resolvedCommandString,
});
// Split command string into arguments, respecting quoted strings
const args = parseCommandArguments(resolvedCommandString);
// Parse arguments into structured options
const commands = parseArgumentsToCommands(args);
// Validate and filter protected commands
const safeCommands = validateAndFilterCommands(commands);
logger.debug('Parsed force commands', { commands: safeCommands });
return safeCommands;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn('Failed to parse force commands', { error: errorMessage, commandString });
return null;
}
}
/**
* Parse command string into arguments array, respecting quoted strings
*
* @param commandString - Raw command string
* @returns Array of parsed arguments
*
* @example
* ```typescript
* parseCommandArguments('--css "my file.css" --pdf')
* // Returns: ['--css', 'my file.css', '--pdf']
* ```
*/
function parseCommandArguments(commandString) {
const args = [];
let current = '';
let inQuotes = false;
let quoteChar = '';
for (let i = 0; i < commandString.length; i++) {
const char = commandString[i];
if ((char === '"' || char === "'") && !inQuotes) {
// Starting a quoted string
inQuotes = true;
quoteChar = char;
}
else if (char === quoteChar && inQuotes) {
// Ending a quoted string
inQuotes = false;
quoteChar = '';
}
else if (char === ' ' && !inQuotes) {
// Space outside quotes - end current argument
if (current.trim()) {
args.push(current.trim());
current = '';
}
}
else {
// Regular character
current += char;
}
}
// Add final argument if exists
if (current.trim()) {
args.push(current.trim());
}
return args;
}
/**
* Convert parsed arguments array into structured command options
*
* @param args - Array of command arguments
* @returns Parsed command options
*/
function parseArgumentsToCommands(args) {
const commands = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (!arg.startsWith('-')) {
continue; // Skip non-option arguments
}
// Normalize argument (remove leading dashes)
const option = arg.replace(/^-+/, '');
switch (option) {
case 'css':
commands.css = args[++i]; // Next argument is the CSS file path
break;
case 'output-name':
case 'outputname':
commands.output = args[++i]; // Map to existing --output CLI option
break;
case 'pdf':
commands.pdf = true;
break;
case 'html':
commands.html = true;
break;
case 'docx':
commands.docx = true;
break;
case 'highlight':
commands.highlight = true;
break;
case 'ast-field-tracking':
commands.astFieldTracking = true;
break;
case 'logic-branch-highlighting':
commands.logicBranchHighlighting = true;
break;
case 'export-yaml':
commands.exportYaml = true;
break;
case 'export-json':
commands.exportJson = true;
break;
case 'output-path':
case 'o':
commands.outputPath = args[++i]; // Next argument is the output path
break;
case 'format': {
const format = args[++i];
const normalized = format?.toLowerCase();
if (format === 'A4' || normalized === 'a4') {
commands.format = 'A4';
}
else if (normalized === 'letter') {
commands.format = 'Letter';
}
else if (normalized === 'legal') {
commands.format = 'Legal';
}
break;
}
case 'landscape':
commands.landscape = true;
break;
case 'debug':
case 'd':
commands.debug = true;
break;
case 'title':
commands.title = args[++i]; // Next argument is the title
break;
default:
logger.debug('Unknown or ignored force command option', { option });
break;
}
}
return commands;
}
/**
* Validate commands and filter out protected options
*
* @param commands - Raw parsed commands
* @returns Validated and filtered commands
*/
function validateAndFilterCommands(commands) {
const safeCommands = {};
// Copy all commands, but validate certain ones
Object.entries(commands).forEach(([key, value]) => {
if (PROTECTED_COMMANDS.includes(key)) {
logger.warn('Protected command ignored in force_commands', { command: key });
return;
}
// Validate file paths
if ((key === 'css' || key === 'outputPath') && value) {
const stringValue = String(value);
if (stringValue.includes('..') || stringValue.startsWith('/')) {
logger.warn('Potentially unsafe path in force_commands', {
command: key,
value: stringValue,
});
return;
}
}
safeCommands[key] = value;
});
return safeCommands;
}
/**
* Apply parsed force commands to existing options
*
* Force commands will override existing options where applicable.
* Some options (like processing flags) are preserved from original options.
*
* @param existingOptions - Current processing options
* @param forceCommands - Parsed force commands to apply
* @returns Updated options with force commands applied
*
* @example
* ```typescript
* const updated = applyForceCommands(
* { debug: false, pdf: false },
* { debug: true, css: "custom.css" }
* );
* // Returns: { debug: true, pdf: false, css: "custom.css" }
* ```
*/
export function applyForceCommands(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
existingOptions, forceCommands
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) {
const updatedOptions = { ...existingOptions };
// Apply force commands with appropriate mapping to LegalMarkdownOptions
if (forceCommands.css !== undefined) {
updatedOptions.cssPath = forceCommands.css;
}
if (forceCommands.output !== undefined) {
updatedOptions.output = forceCommands.output;
}
if (forceCommands.pdf !== undefined) {
updatedOptions.pdf = forceCommands.pdf;
}
if (forceCommands.html !== undefined) {
updatedOptions.html = forceCommands.html;
}
if (forceCommands.docx !== undefined) {
updatedOptions.docx = forceCommands.docx;
}
if (forceCommands.highlight !== undefined) {
updatedOptions.highlight = forceCommands.highlight;
updatedOptions.includeHighlighting = forceCommands.highlight;
}
if (forceCommands.astFieldTracking !== undefined) {
updatedOptions.astFieldTracking = forceCommands.astFieldTracking;
}
if (forceCommands.logicBranchHighlighting !== undefined) {
updatedOptions.logicBranchHighlighting = forceCommands.logicBranchHighlighting;
}
if (forceCommands.exportYaml !== undefined) {
updatedOptions.exportYaml = forceCommands.exportYaml;
if (forceCommands.exportYaml) {
updatedOptions.exportMetadata = true;
updatedOptions.exportFormat = 'yaml';
}
}
if (forceCommands.exportJson !== undefined) {
updatedOptions.exportJson = forceCommands.exportJson;
if (forceCommands.exportJson) {
updatedOptions.exportMetadata = true;
updatedOptions.exportFormat = 'json';
}
}
if (forceCommands.outputPath !== undefined) {
updatedOptions.outputPath = forceCommands.outputPath;
}
if (forceCommands.format !== undefined) {
updatedOptions.format = forceCommands.format;
}
if (forceCommands.landscape !== undefined) {
updatedOptions.landscape = forceCommands.landscape;
}
if (forceCommands.debug !== undefined) {
updatedOptions.debug = forceCommands.debug;
}
if (forceCommands.title !== undefined) {
updatedOptions.title = forceCommands.title;
}
logger.debug('Applied force commands to options', {
original: existingOptions,
forceCommands,
updated: updatedOptions,
});
return updatedOptions;
}
/**
* Check if metadata contains force_commands and extract them
*
* @param metadata - Document metadata from YAML front matter
* @returns Force commands string or null if not found
*/
export function extractForceCommands(metadata) {
if (!metadata || typeof metadata !== 'object') {
return null;
}
// Check for force_commands field (various naming conventions)
const possibleKeys = ['force_commands', 'force-commands', 'forceCommands', 'commands'];
for (const key of possibleKeys) {
if (key in metadata && typeof metadata[key] === 'string') {
logger.debug('Found force commands in metadata', { key, commands: metadata[key] });
return metadata[key];
}
}
return null;
}
// Exported for testing - not part of public API
export { parseCommandArguments as _parseCommandArguments, parseArgumentsToCommands as _parseArgumentsToCommands, validateAndFilterCommands as _validateAndFilterCommands, };
//# sourceMappingURL=force-commands-parser.js.map