/** * Markdown renderer for interview profiles. * Converts collected answers into markdown output based on profile configuration. */ import type { InterviewProfile, InterviewStep, CaptureTextStep, CaptureTextLineStep, CaptureFrontmatterStep, LoopStep } from "./types"; export interface RenderAnswers { collectedData: Map; loopContexts: Map; } /** * Render profile answers to markdown string. * Deterministic and testable. */ export function renderProfileToMarkdown( profile: InterviewProfile, answers: RenderAnswers ): string { const output: string[] = []; for (const step of profile.steps) { const stepOutput = renderStep(step, answers); if (stepOutput) { output.push(stepOutput); } } return output.join("\n\n").trim(); } /** * Render a single step to markdown. */ function renderStep(step: InterviewStep, answers: RenderAnswers): string | null { switch (step.type) { case "capture_text": return renderCaptureText(step, answers); case "capture_text_line": return renderCaptureTextLine(step, answers); case "capture_frontmatter": // Frontmatter is handled separately, skip here return null; case "loop": return renderLoop(step, answers); case "instruction": case "llm_dialog": case "review": // These don't produce markdown output return null; default: return null; } } /** * Render capture_text step. */ function renderCaptureText(step: CaptureTextStep, answers: RenderAnswers): string | null { const value = answers.collectedData.get(step.key); if (!value || String(value).trim() === "") { return null; } const text = String(value); // Use template if provided if (step.output?.template) { return renderTemplate(step.output.template, { text, field: step.key, value: text, }); } // Default: use section if provided, otherwise just the text if (step.section) { return `${step.section}\n\n${text}`; } return text; } /** * Render capture_text_line step. */ function renderCaptureTextLine(step: CaptureTextLineStep, answers: RenderAnswers): string | null { const value = answers.collectedData.get(step.key); if (!value || String(value).trim() === "") { return null; } const text = String(value); // Use template if provided if (step.output?.template) { return renderTemplate(step.output.template, { text, field: step.key, value: text, }); } // Default: just the text return text; } /** * Render capture_frontmatter step. * Note: This is typically handled separately, but included for completeness. */ function renderCaptureFrontmatter(step: CaptureFrontmatterStep, answers: RenderAnswers): string | null { const value = answers.collectedData.get(step.key); if (value === undefined || value === null || String(value).trim() === "") { return null; } // Use template if provided if (step.output?.template) { return renderTemplate(step.output.template, { text: String(value), field: step.field, value: String(value), }); } // Default: no markdown output (frontmatter is handled separately) return null; } /** * Render loop step (recursive, supports arbitrary nesting depth). */ function renderLoop(step: LoopStep, answers: RenderAnswers): string | null { return renderLoopRecursive(step, answers, 0); } /** * Recursive helper to render loop items with nested steps. * Supports arbitrary nesting depth (no hard limit, but practical limits apply due to stack size). */ function renderLoopRecursive(step: LoopStep, answers: RenderAnswers, depth: number): string | null { // Safety check: prevent infinite recursion (practical limit: 100 levels) if (depth > 100) { console.warn(`Loop nesting depth ${depth} exceeds practical limit, stopping recursion`); return null; } const items = answers.loopContexts.get(step.key); if (!items || items.length === 0) { return null; } const itemOutputs: string[] = []; for (const item of items) { if (!item || typeof item !== "object") { continue; } const itemParts: string[] = []; // Render each nested step for this item (recursively) for (const nestedStep of step.items) { const fieldValue = (item as Record)[nestedStep.key]; if (nestedStep.type === "capture_text") { if (fieldValue && String(fieldValue).trim()) { const text = String(fieldValue); const captureStep = nestedStep as CaptureTextStep; // Use template if provided if (captureStep.output?.template) { itemParts.push(renderTemplate(captureStep.output.template, { text, field: nestedStep.key, value: text, })); } else { // Default: just the text itemParts.push(text); } } } else if (nestedStep.type === "capture_text_line") { if (fieldValue && String(fieldValue).trim()) { const text = String(fieldValue); const captureStep = nestedStep as CaptureTextLineStep; // Use template if provided if (captureStep.output?.template) { itemParts.push(renderTemplate(captureStep.output.template, { text, field: nestedStep.key, value: text, })); } else { // Default: just the text itemParts.push(text); } } } else if (nestedStep.type === "capture_frontmatter") { if (fieldValue && String(fieldValue).trim()) { const captureStep = nestedStep as CaptureFrontmatterStep; // Use template if provided if (captureStep.output?.template) { itemParts.push(renderTemplate(captureStep.output.template, { text: String(fieldValue), field: captureStep.field, value: String(fieldValue), })); } } } else if (nestedStep.type === "loop") { // Recursively handle nested loop: fieldValue should be an array of items if (Array.isArray(fieldValue) && fieldValue.length > 0) { const nestedLoopStep = nestedStep as LoopStep; // Create a temporary answers object for the nested loop // The nested loop items are stored in fieldValue, so we need to create // a temporary loopContexts map for the nested loop const nestedAnswers: RenderAnswers = { collectedData: answers.collectedData, loopContexts: new Map(answers.loopContexts), }; nestedAnswers.loopContexts.set(nestedLoopStep.key, fieldValue); // Recursively render the nested loop const nestedOutput = renderLoopRecursive(nestedLoopStep, nestedAnswers, depth + 1); if (nestedOutput) { itemParts.push(nestedOutput); } } } } if (itemParts.length > 0) { itemOutputs.push(itemParts.join("\n\n")); } } if (itemOutputs.length === 0) { return null; } // Check if first nested step has a section header (for backwards compatibility) const firstNestedStep = step.items[0]; let sectionHeader: string | null = null; if (firstNestedStep && firstNestedStep.type === "capture_text" && firstNestedStep.section) { sectionHeader = firstNestedStep.section; } // Join items with configured separator or default newline const joinStr = step.output?.join || "\n\n"; const loopContent = itemOutputs.join(joinStr); // Prepend section header if available if (sectionHeader) { return `${sectionHeader}\n\n${loopContent}`; } return loopContent; } /** * Render template string with token replacement. * Tokens: {text}, {field}, {value} */ function renderTemplate(template: string, tokens: { text: string; field: string; value: string }): string { return template .replace(/\{text\}/g, tokens.text) .replace(/\{field\}/g, tokens.field) .replace(/\{value\}/g, tokens.value); }