mindnet_obsidian/src/interview/renderer.ts

314 lines
9.9 KiB
TypeScript
Raw Normal View History

/**
* 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);
// Get heading level if configured
const headingLevelKey = `${step.key}_heading_level`;
const headingLevel = answers.collectedData.get(headingLevelKey);
let headingPrefix = "";
if (step.heading_level?.enabled) {
if (typeof headingLevel === "number") {
const level = Math.max(1, Math.min(6, headingLevel));
headingPrefix = "#".repeat(level) + " ";
} else if (step.heading_level.default) {
// Fallback to default if not set
const level = Math.max(1, Math.min(6, step.heading_level.default));
headingPrefix = "#".repeat(level) + " ";
}
}
// Use template if provided
if (step.output?.template) {
return renderTemplate(step.output.template, {
text: headingPrefix + text,
field: step.key,
value: headingPrefix + text,
heading_level: headingLevel ? String(headingLevel) : "",
});
}
// Default: text with heading prefix if configured
return headingPrefix + 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).
*/
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;
}
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)
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;
// Get heading level if configured (for nested loops, check in item data)
const headingLevelKey = `${nestedStep.key}_heading_level`;
const headingLevel = (item as Record<string, unknown>)[headingLevelKey];
let headingPrefix = "";
if (captureStep.heading_level?.enabled) {
if (typeof headingLevel === "number") {
const level = Math.max(1, Math.min(6, headingLevel));
headingPrefix = "#".repeat(level) + " ";
} else if (captureStep.heading_level.default) {
// Fallback to default if not set in item
const level = Math.max(1, Math.min(6, captureStep.heading_level.default));
headingPrefix = "#".repeat(level) + " ";
}
}
// Use template if provided
if (captureStep.output?.template) {
itemParts.push(renderTemplate(captureStep.output.template, {
text: headingPrefix + text,
field: nestedStep.key,
value: headingPrefix + text,
heading_level: headingLevel ? String(headingLevel) : (captureStep.heading_level?.default ? String(captureStep.heading_level.default) : ""),
}));
} else {
// Default: text with heading prefix if configured
itemParts.push(headingPrefix + 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);
}
}
}
}
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; heading_level?: string }): string {
let result = template
.replace(/\{text\}/g, tokens.text)
.replace(/\{field\}/g, tokens.field)
.replace(/\{value\}/g, tokens.value);
if (tokens.heading_level !== undefined) {
result = result.replace(/\{heading_level\}/g, tokens.heading_level);
}
return result;
}