UNPKG

meld

Version:

Meld: A template language for LLM prompts

977 lines (849 loc) 38.2 kB
import type { IStateService } from '@services/state/StateService/IStateService.js'; import { IOutputService, type OutputFormat, type OutputOptions } from './IOutputService.js'; import type { IResolutionService, ResolutionContext } from '@services/resolution/ResolutionService/IResolutionService.js'; import type { MeldNode, TextNode, CodeFenceNode, DirectiveNode } from 'meld-spec'; import { outputLogger as logger } from '@core/utils/logger.js'; import { MeldOutputError } from '@core/errors/MeldOutputError.js'; import { ResolutionContextFactory } from '@services/resolution/ResolutionService/ResolutionContextFactory.js'; import { MeldError } from '@core/errors/MeldError.js'; type FormatConverter = ( nodes: MeldNode[], state: IStateService, options?: OutputOptions ) => Promise<string>; const DEFAULT_OPTIONS: Required<OutputOptions> = { includeState: false, preserveFormatting: true, formatOptions: {} }; export class OutputService implements IOutputService { private formatters = new Map<string, FormatConverter>(); private state: IStateService | undefined; private resolutionService: IResolutionService | undefined; public canAccessTransformedNodes(): boolean { return true; } constructor() { // Register default formatters this.registerFormat('markdown', this.convertToMarkdown.bind(this)); this.registerFormat('md', this.convertToMarkdown.bind(this)); this.registerFormat('xml', this.convertToXML.bind(this)); logger.debug('OutputService initialized with default formatters', { formats: Array.from(this.formatters.keys()) }); } initialize(state: IStateService, resolutionService?: IResolutionService): void { this.state = state; this.resolutionService = resolutionService; logger.debug('OutputService initialized with state service', { hasResolutionService: !!resolutionService }); } async convert( nodes: MeldNode[], state: IStateService, format: OutputFormat, options?: OutputOptions ): Promise<string> { const opts = { ...DEFAULT_OPTIONS, ...options }; logger.debug('Converting output', { format, nodeCount: nodes.length, options: opts, transformationEnabled: state.isTransformationEnabled() }); // Use transformed nodes if transformation is enabled const nodesToProcess = state.isTransformationEnabled() ? state.getTransformedNodes() : nodes; const formatter = this.formatters.get(format); if (!formatter) { throw new MeldOutputError(`Unsupported format: ${format}`, format); } try { const result = await formatter(nodesToProcess, state, opts); logger.debug('Successfully converted output', { format, resultLength: result.length, transformationEnabled: state.isTransformationEnabled(), transformedNodesCount: state.isTransformationEnabled() ? state.getTransformedNodes().length : 0 }); return result; } catch (error) { logger.error('Failed to convert output', { format, error }); if (error instanceof MeldOutputError) { throw error; } throw new MeldOutputError( 'Failed to convert output', format, { cause: error instanceof Error ? error : undefined } ); } } registerFormat( format: string, converter: FormatConverter ): void { if (!format || typeof format !== 'string') { throw new Error('Format must be a non-empty string'); } if (typeof converter !== 'function') { throw new Error('Converter must be a function'); } this.formatters.set(format, converter); logger.debug('Registered format converter', { format }); } supportsFormat(format: string): boolean { return this.formatters.has(format); } getSupportedFormats(): string[] { return Array.from(this.formatters.keys()); } /** * Helper method to safely extract string content from various node types * ensuring proper type safety */ private getTextContentFromNode(node: any): string { // Handle undefined or null if (node === undefined || node === null) { return ''; } // Handle id or identifier properties if ('id' in node && typeof node.id === 'string') { return node.id; } if ('identifier' in node && typeof node.identifier === 'string') { return node.identifier; } // Handle direct text content if ('text' in node && node.text !== undefined && node.text !== null) { return String(node.text); } // Handle value property which could be various types if ('value' in node) { const value = node.value; if (value === null || value === undefined) { return ''; } if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return String(value); } if (typeof value === 'object') { try { return JSON.stringify(value); } catch { return ''; } } return String(value); } // Handle content property as a fallback if ('content' in node) { const content = node.content; if (content === null || content === undefined) { return ''; } if (typeof content === 'string' || typeof content === 'number' || typeof content === 'boolean') { return String(content); } if (typeof content === 'object') { try { return JSON.stringify(content); } catch { return ''; } } return String(content); } // Final fallback return ''; } private async convertToMarkdown( nodes: MeldNode[], state: IStateService, options?: OutputOptions ): Promise<string> { const opts = { ...DEFAULT_OPTIONS, ...options }; try { let output = ''; // Debug: Log node types logger.debug('Converting nodes to markdown', { nodeCount: nodes.length, nodeTypes: nodes.map(n => n.type), transformationEnabled: state.isTransformationEnabled() }); // Add state variables if requested if (opts.includeState) { output += this.formatStateVariables(state); if (nodes.length > 0) { output += '\n\n'; } } // Transformation mode handling // In transformation mode, we need to preserve the exact layout without adding newlines if (state.isTransformationEnabled()) { // Process nodes with careful handling of newlines for (const node of nodes) { try { // Get the node output const nodeOutput = await this.nodeToMarkdown(node, state); // Skip empty outputs if (!nodeOutput) continue; // Add to output buffer output += nodeOutput; } catch (nodeError) { // Log detailed error for the specific node logger.error('Error converting node to markdown in transformation mode', { nodeType: node.type, location: node.location, error: nodeError }); throw nodeError; } } // Cleanup excessive whitespace without losing the basic text layout output = output.replace(/\n{3,}/g, '\n\n'); return output; } // Standard mode processing (non-transformation) // Process nodes for (const node of nodes) { try { const nodeOutput = await this.nodeToMarkdown(node, state); if (nodeOutput) { output += nodeOutput; } } catch (nodeError) { // Log detailed error for the specific node logger.error('Error converting node to markdown', { nodeType: node.type, location: node.location, error: nodeError }); throw nodeError; } } // Clean up extra newlines if not preserving formatting if (!opts.preserveFormatting) { output = output.replace(/\n{3,}/g, '\n\n').trim(); } return output; } catch (error) { throw new MeldOutputError( 'Failed to convert to markdown', 'markdown', { cause: error instanceof Error ? error : undefined } ); } } private async convertToXML( nodes: MeldNode[], state: IStateService, options?: OutputOptions ): Promise<string> { try { // First convert to markdown since XML is based on markdown let markdown; // If formatOptions.markdown is provided, use it directly if (options?.formatOptions?.markdown) { markdown = options.formatOptions.markdown as string; } else { // Otherwise, convert nodes to markdown markdown = await this.convertToMarkdown(nodes, state, options); } // Log the markdown for debugging logger.debug('Converting markdown to XML', { markdown }); // Use llmxml directly with version 1.3.0+ const { createLLMXML } = await import('llmxml'); const llmxml = createLLMXML({ defaultFuzzyThreshold: 0.7, includeHlevel: false, includeTitle: false, tagFormat: 'PascalCase', verbose: false, warningLevel: 'all' }); // Convert markdown to XML using llmxml const xmlResult = await llmxml.toXML(markdown); logger.debug('Successfully converted to XML', { xmlLength: xmlResult.length }); return xmlResult; } catch (error) { logger.error('Error in convertToXML', { error: error instanceof Error ? error.message : String(error) }); throw new MeldOutputError( `Failed to convert output to XML: ${error instanceof Error ? error.message : String(error)}`, 'xml', { cause: error instanceof Error ? error : undefined } ); } } private formatStateVariables(state: IStateService): string { let output = ''; // Format text variables const textVars = state.getAllTextVars(); if (textVars.size > 0) { output += '# Text Variables\n\n'; for (const [name, value] of textVars) { output += `@text ${name} = "${value}"\n`; } } // Format data variables const dataVars = state.getAllDataVars(); if (dataVars.size > 0) { if (output) output += '\n'; output += '# Data Variables\n\n'; for (const [name, value] of dataVars) { output += `@data ${name} = ${JSON.stringify(value, null, 2)}\n`; } } return output; } private async nodeToMarkdown(node: MeldNode, state: IStateService): Promise<string> { // Debug: Log node structure logger.debug('Processing node in nodeToMarkdown', { nodeType: node.type, nodeStructure: Object.keys(node), location: node.location }); switch (node.type) { case 'Text': const content = (node as TextNode).content; // In transformation mode, directly replace variable references with their values if (state.isTransformationEnabled() && content.includes('{{')) { const variableRegex = /\{\{([^{}]+)\}\}/g; let transformedContent = content; const matches = Array.from(content.matchAll(variableRegex)); logger.debug('Found variable references in Text node', { content, matches: matches.map(m => m[0]), transformationEnabled: state.isTransformationEnabled(), transformationOptions: state.getTransformationOptions ? state.getTransformationOptions() : 'N/A', shouldTransformVariables: state.shouldTransform ? state.shouldTransform('variables') : 'N/A' }); // If no matches, return original content if (matches.length === 0) { return content.endsWith('\n') ? content : content + '\n'; } // Only proceed with transformation if we're supposed to transform variables if (!state.shouldTransform || state.shouldTransform('variables')) { // Process each variable reference for (const match of matches) { const fullMatch = match[0]; // The entire match, e.g., {{variable}} const reference = match[1].trim(); // The variable reference, e.g., variable try { // Split the reference into variable name and field path const parts = reference.split('.'); const variableName = parts[0]; const fieldPath = parts.length > 1 ? parts.slice(1).join('.') : ''; logger.debug('Processing variable reference:', { fullMatch, variableName, fieldPath }); // Try to get the variable value from the state let value; // Try text variable first value = state.getTextVar(variableName); logger.debug('Looking up variable in state', { variableName, value: value !== undefined ? (typeof value === 'string' ? value : JSON.stringify(value)) : 'undefined', type: 'text' }); // If not found as text variable, try data variable if (value === undefined) { value = state.getDataVar(variableName); logger.debug('Looking up data variable in state', { variableName, value: value !== undefined ? (typeof value === 'string' ? value : JSON.stringify(value)) : 'undefined', type: 'data' }); } // Process field access for data variables if (value !== undefined && fieldPath) { // Handle field access for data variables const fields = fieldPath.split('.'); let currentValue: any = value; for (const field of fields) { // Check if field is numeric (array index) const isNumeric = /^\d+$/.test(field); if (isNumeric && Array.isArray(currentValue)) { // Access array by index const index = parseInt(field, 10); if (index < currentValue.length) { currentValue = currentValue[index]; } else { // Array index out of bounds currentValue = undefined; break; } } else if (typeof currentValue === 'object' && currentValue !== null) { // Access object property with type safety currentValue = currentValue[field]; } else { // Cannot access property of non-object currentValue = undefined; break; } // If we hit undefined, stop traversing if (currentValue === undefined) { break; } } // Update value with resolved field access value = currentValue; } // If a value was found, replace the variable reference with its value if (value !== undefined) { const stringValue = typeof value === 'string' ? value : JSON.stringify(value); transformedContent = transformedContent.replace(fullMatch, stringValue); logger.debug('Replaced variable reference in Text node', { variableName, fieldPath, value: stringValue, fullMatch, before: content, after: transformedContent }); } else { logger.warn('Variable not found in state', { variableName, fieldPath, fullMatch }); // Leave the variable reference unchanged if value not found } } catch (error) { // Handle errors during variable resolution logger.error('Error resolving variable reference:', { fullMatch, reference, error }); // Leave the variable reference unchanged on error } } return transformedContent.endsWith('\n') ? transformedContent : transformedContent + '\n'; } } // Check if the content contains variable references and ResolutionService is available if (content.includes('{{') && this.resolutionService) { try { // Create appropriate resolution context for text variables const context: ResolutionContext = ResolutionContextFactory.forTextDirective( undefined, // current file path not needed here state // state service to use ); // Use ResolutionService to resolve variables in text const resolvedContent = await this.resolutionService.resolveText(content, context); logger.debug('Resolved variable references in Text node using ResolutionService', { original: content, resolved: resolvedContent }); return resolvedContent.endsWith('\n') ? resolvedContent : resolvedContent + '\n'; } catch (resolutionError) { logger.error('Error resolving variable references in Text node', { content, error: resolutionError }); // Fall back to original content if resolution fails return content.endsWith('\n') ? content : content + '\n'; } } // Return the original content if no transformation needed return content.endsWith('\n') ? content : content + '\n'; case 'TextVar': // Handle TextVar nodes try { logger.debug('TextVar node detailed view', { hasId: 'id' in node, idValue: 'id' in node ? node.id : 'undefined', hasIdentifier: 'identifier' in node, identifierValue: 'identifier' in node ? node.identifier : 'undefined', hasText: 'text' in node, textValue: 'text' in node ? node.text : 'undefined', hasValue: 'value' in node, valueValue: 'value' in node ? node.value : 'undefined', hasContent: 'content' in node, contentValue: 'content' in node ? (node as any).content : 'undefined', nodeStr: JSON.stringify(node, null, 2) }); // Try various possible property names and resolve from state let textVarContent = ''; if ('id' in node) { // Try to resolve from state using id const id = node.id as string; textVarContent = state.getTextVar(id) || ''; logger.debug(`Trying to resolve TextVar with id ${id}`, { resolved: textVarContent || 'NOT RESOLVED' }); } else if ('identifier' in node) { // Try to resolve from state using identifier const identifier = node.identifier as string; textVarContent = state.getTextVar(identifier) || ''; logger.debug(`Trying to resolve TextVar with identifier ${identifier}`, { resolved: textVarContent || 'NOT RESOLVED' }); } else { // Use the helper method to extract content safely textVarContent = this.getTextContentFromNode(node); } // Process template variables in the content if it's a string if (typeof textVarContent === 'string' && this.resolutionService) { try { // Create appropriate resolution context for text variables const context: ResolutionContext = ResolutionContextFactory.forTextDirective( undefined, // current file path not needed here state // state service to use ); // Use ResolutionService to resolve variables in text textVarContent = await this.resolutionService.resolveText(textVarContent, context); logger.debug('Processed all template variables using ResolutionService', { finalContent: textVarContent }); } catch (resolutionError) { logger.error('Error resolving template variables with ResolutionService', { content: textVarContent, error: resolutionError }); } } logger.debug('TextVar resolved content', { content: textVarContent, type: typeof textVarContent }); // Handle transformation mode - don't add newlines in transformation mode if (state.isTransformationEnabled()) { return String(textVarContent); } return typeof textVarContent === 'string' ? (textVarContent.endsWith('\n') ? textVarContent : textVarContent + '\n') : String(textVarContent) + '\n'; } catch (e) { logger.error('Error processing TextVar node', { node: JSON.stringify(node), error: e }); throw e; } case 'DataVar': // Handle DataVar nodes try { logger.debug('DataVar node detailed view', { hasId: 'id' in node, idValue: 'id' in node ? node.id : 'undefined', hasIdentifier: 'identifier' in node, identifierValue: 'identifier' in node ? node.identifier : 'undefined', hasFields: 'fields' in node, fieldsValue: 'fields' in node ? JSON.stringify(node.fields) : 'undefined', hasData: 'data' in node, dataValue: 'data' in node ? JSON.stringify(node.data) : 'undefined', hasValue: 'value' in node, valueValue: 'value' in node ? JSON.stringify(node.value) : 'undefined', hasContent: 'content' in node, contentValue: 'content' in node ? JSON.stringify((node as any).content) : 'undefined', nodeStr: JSON.stringify(node, null, 2) }); // For transformation mode, we need to resolve the field access if fields are present // This is necessary for things like array access with dot notation (items.0) if (state.isTransformationEnabled() && 'fields' in node && Array.isArray(node.fields) && node.fields.length > 0 && this.resolutionService) { if ('identifier' in node) { const identifier = node.identifier as string; logger.debug('Attempting to resolve DataVar in transformation mode with fields', { identifier, fields: node.fields, fieldTypes: node.fields.map(f => f.type), fieldValues: node.fields.map(f => f.value) }); try { // Process all fields at once rather than individually // Create a resolution context const context: ResolutionContext = ResolutionContextFactory.forDataDirective( undefined, // current file path not needed here state // state service to use ); // Build the complete reference with all fields using dot notation const fields = node.fields.map(field => { if (field.type === 'index') { // For index type, convert to numeric string return String(field.value); } else if (field.type === 'field') { return field.value; } return ''; }).filter(Boolean); // Create a variable reference with all fields using dot notation // This matches the format expected in the test files const serializedNode = `{{${identifier}${fields.length > 0 ? '.' + fields.join('.') : ''}}}`; logger.debug('Resolving DataVar with all fields at once', { serializedNode, identifier, fields }); // Use ResolutionService to resolve the complete variable reference const resolved = await this.resolutionService.resolveInContext(serializedNode, context); logger.debug('DataVar field access resolution result', { serializedNode, resolved }); return String(resolved); } catch (resolutionError) { // Log the error but throw it to prevent falling through to other resolution methods logger.error('Error resolving DataVar with field access', { error: resolutionError, errorMessage: resolutionError instanceof Error ? resolutionError.message : String(resolutionError), cause: resolutionError instanceof Error && 'cause' in resolutionError ? resolutionError.cause : undefined }); throw resolutionError; } } } // If not transformation mode or resolution with fields failed, fall back to standard resolution // Try various possible property names and resolve from state let dataVarContent: any = ''; if ('id' in node) { // Try to resolve from state using id const id = node.id as string; dataVarContent = state.getDataVar(id); logger.debug(`Trying to resolve DataVar with id ${id}`, { resolved: dataVarContent ? JSON.stringify(dataVarContent) : 'NOT RESOLVED' }); } else if ('identifier' in node) { // Try to resolve from state using identifier const identifier = node.identifier as string; dataVarContent = state.getDataVar(identifier); logger.debug(`Trying to resolve DataVar with identifier ${identifier}`, { resolved: dataVarContent ? JSON.stringify(dataVarContent) : 'NOT RESOLVED' }); } else if ('data' in node && node.data) { dataVarContent = node.data; } else if ('value' in node && node.value) { dataVarContent = node.value; } else if ('content' in node && (node as any).content) { dataVarContent = (node as any).content; } // Process template variables for string values if (typeof dataVarContent === 'string' && this.resolutionService) { try { // Create appropriate resolution context for data variables const context: ResolutionContext = ResolutionContextFactory.forTextDirective( undefined, // current file path not needed here state // state service to use ); // Use ResolutionService to resolve variables in text dataVarContent = await this.resolutionService.resolveText(dataVarContent, context); logger.debug('Processed all template variables in DataVar using ResolutionService', { finalContent: dataVarContent }); } catch (resolutionError) { logger.error('Error resolving template variables in DataVar with ResolutionService', { content: dataVarContent, error: resolutionError }); } } logger.debug('DataVar resolved content', { content: dataVarContent ? JSON.stringify(dataVarContent) : 'undefined', type: typeof dataVarContent }); // In transformation mode, don't add newlines if (state.isTransformationEnabled()) { return typeof dataVarContent === 'string' ? dataVarContent : JSON.stringify(dataVarContent); } return typeof dataVarContent === 'string' ? (dataVarContent.endsWith('\n') ? dataVarContent : dataVarContent + '\n') : JSON.stringify(dataVarContent) + '\n'; } catch (e) { logger.error('Error processing DataVar node', { node: JSON.stringify(node), error: e }); throw e; } case 'CodeFence': const fence = node as CodeFenceNode; // The content already includes the codefence markers, so we use it as-is return fence.content; case 'Directive': const directive = node as DirectiveNode; const kind = directive.directive.kind; logger.debug('OutputService processing directive:', { kind, transform: state.isTransformationEnabled(), hasTransformedNodes: !!state.getTransformedNodes(), nodeLocation: node.location, directiveOptions: directive.directive }); // Definition directives always return empty string if (['text', 'data', 'path', 'import', 'define'].includes(kind)) { return ''; } // Handle run directives if (kind === 'run') { // In non-transformation mode, return placeholder if (!state.isTransformationEnabled()) { return '[run directive output placeholder]\n'; } // In transformation mode, return the command output const transformedNodes = state.getTransformedNodes(); if (transformedNodes && transformedNodes.length > 0) { // First try exact line match (original behavior) const exactMatch = transformedNodes.find(n => n.location?.start.line === node.location?.start.line ); logger.debug('Looking for transformed run directive node', { directiveLine: node.location?.start.line, transformedNodeCount: transformedNodes.length, foundExactMatch: !!exactMatch, command: directive.directive.command }); if (exactMatch && exactMatch.type === 'Text') { const content = (exactMatch as TextNode).content; return content.endsWith('\n') ? content : content + '\n'; } // If exact match not found, try to find the closest matching node // This handles cases where line numbers have shifted during transformation let closestNode: MeldNode | null = null; let smallestLineDiff = Number.MAX_SAFE_INTEGER; for (const transformedNode of transformedNodes) { if (transformedNode.type === 'Text' && node.location?.start.line && transformedNode.location?.start.line) { const lineDiff = Math.abs( transformedNode.location.start.line - node.location.start.line ); // Update closest node if this one is closer if (lineDiff < smallestLineDiff) { smallestLineDiff = lineDiff; closestNode = transformedNode; } } } // Use the closest node if it's within a reasonable range (5 lines) if (closestNode && smallestLineDiff <= 5) { logger.debug('Found closest transformed node for run directive', { originalLine: node.location?.start.line, closestNodeLine: closestNode.location?.start.line, lineDifference: smallestLineDiff, nodeType: closestNode.type }); const content = (closestNode as TextNode).content; return content.endsWith('\n') ? content : content + '\n'; } } // If no transformed node found, return placeholder logger.warn('No transformed node found for run directive', { directiveLine: node.location?.start.line, command: directive.directive.command }); return '[run directive output placeholder]\n'; } // Handle other execution directives if (['embed'].includes(kind)) { // In non-transformation mode, return placeholder if (!state.isTransformationEnabled()) { return '[directive output placeholder]\n'; } // In transformation mode, return the embedded content const transformedNodes = state.getTransformedNodes(); if (transformedNodes && transformedNodes.length > 0) { // First try exact line match (original behavior) const exactMatch = transformedNodes.find(n => n.location?.start.line === node.location?.start.line ); logger.debug('Looking for transformed embed node', { directiveLine: node.location?.start.line, transformedNodeCount: transformedNodes.length, foundExactMatch: !!exactMatch, transformedNodeLines: transformedNodes.map(n => n.location?.start.line) }); if (exactMatch && exactMatch.type === 'Text') { const content = (exactMatch as TextNode).content; return content.endsWith('\n') ? content : content + '\n'; } // If exact match not found, try to find the closest matching node // This handles cases where line numbers have shifted during transformation let closestNode: MeldNode | null = null; let smallestLineDiff = Number.MAX_SAFE_INTEGER; for (const transformedNode of transformedNodes) { if (transformedNode.type === 'Text' && node.location?.start.line && transformedNode.location?.start.line) { const lineDiff = Math.abs( transformedNode.location.start.line - node.location.start.line ); // Update closest node if this one is closer if (lineDiff < smallestLineDiff) { smallestLineDiff = lineDiff; closestNode = transformedNode; } } } // Use the closest node if it's within a reasonable range (5 lines) if (closestNode && smallestLineDiff <= 5) { logger.debug('Found closest transformed node for embed directive', { originalLine: node.location?.start.line, closestNodeLine: closestNode.location?.start.line, lineDifference: smallestLineDiff, nodeType: closestNode.type }); const content = (closestNode as TextNode).content; return content.endsWith('\n') ? content : content + '\n'; } } // If no transformed node found, return placeholder logger.warn('No transformed node found for embed directive', { directiveLine: node.location?.start.line, directivePath: directive.directive.path }); return '[directive output placeholder]\n'; } return ''; case 'Comment': // Comments should be ignored in the output logger.debug('Ignoring comment node in output'); return ''; default: throw new MeldOutputError(`Unknown node type: ${node.type}`, 'markdown'); } } private async nodeToXML(node: MeldNode, state: IStateService): Promise<string> { // We need to handle CodeFence nodes explicitly to avoid double-rendering the codefence markers if (node.type === 'CodeFence') { const fence = node as CodeFenceNode; // The content already includes the codefence markers, so we use it as-is return fence.content; } // For other node types, use the same logic as markdown for consistent behavior return this.nodeToMarkdown(node, state); } private codeFenceToMarkdown(node: CodeFenceNode): string { // The content already includes the codefence markers, so we use it as-is return node.content; } private codeFenceToXML(node: CodeFenceNode): string { // Use the same logic as markdown for now since we want consistent behavior return this.codeFenceToMarkdown(node); } private directiveToMarkdown(node: DirectiveNode): string { const kind = node.directive.kind; if (['text', 'data', 'path', 'import', 'define'].includes(kind)) { return ''; } if (kind === 'run') { return '[run directive output placeholder]\n'; } // For other execution directives, return empty string for now return ''; } private directiveToXML(node: DirectiveNode): string { // Use the same logic as markdown for now since we want consistent behavior return this.directiveToMarkdown(node); } }