2026-01-16 18:26:01 +01:00
|
|
|
/**
|
|
|
|
|
* 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<string, unknown>;
|
|
|
|
|
loopContexts: Map<string, unknown[]>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-16 20:10:08 +01:00
|
|
|
* Render loop step (recursive, supports arbitrary nesting depth).
|
2026-01-16 18:26:01 +01:00
|
|
|
*/
|
|
|
|
|
function renderLoop(step: LoopStep, answers: RenderAnswers): string | null {
|
2026-01-16 20:10:08 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 18:26:01 +01:00
|
|
|
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[] = [];
|
|
|
|
|
|
2026-01-16 20:10:08 +01:00
|
|
|
// Render each nested step for this item (recursively)
|
2026-01-16 18:26:01 +01:00
|
|
|
for (const nestedStep of step.items) {
|
|
|
|
|
const fieldValue = (item as Record<string, unknown>)[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),
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-16 20:10:08 +01:00
|
|
|
} 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-16 18:26:01 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|