import * as errors from "@superbuilders/errors" import * as logger from "@superbuilders/slog" import { sanitizeXmlAttributeValue } from "@/compiler/utils/xml-utils" import type { BlockContent, FeedbackContent, InlineContent, InlineContentItem, StepBlock } from "@/core/content" const GAP_PLACEHOLDER_STYLE = "display: inline-block; min-width: 320px; min-height: 120px; border: 2px dashed #ccc; padding: 12px; vertical-align: middle;" /** * Escapes text content for safe inclusion in XML PCDATA. */ function escapeXmlText(text: string): string { const sanitized = sanitizeXmlAttributeValue(text) return sanitized .replace(/&/g, "&") // Must be first to avoid double-escaping .replace(//g, ">") } export function renderInlineContent< E extends readonly string[] = readonly string[] >( inlineItems: InlineContent | null, widgetSlots: Map, interactionSlots: Map ): string { if (!inlineItems) return "" return inlineItems .map((item: InlineContentItem): string => { switch (item.type) { case "text": return escapeXmlText(item.content) case "math": return `${item.mathml}` case "inlineWidgetRef": { const content = widgetSlots.get(item.widgetId) if (content === undefined) { logger.error("content for inline widget ref not found", { widgetId: item.widgetId, availableSlots: Array.from(widgetSlots.keys()) }) throw errors.new("content for inline widget ref not found") } return content } case "inlineInteractionRef": { const content = interactionSlots.get(item.interactionId) if (content === undefined) { logger.error("content for inline interaction ref not found", { interactionId: item.interactionId, availableSlots: Array.from(interactionSlots.keys()) }) throw errors.new("content for inline interaction ref not found") } return content } case "gap": { return `` } default: logger.error("unsupported inline content type", { item }) throw errors.new("unsupported inline content type") } }) .join("") } export function renderBlockContent< E extends readonly string[] = readonly string[] >( blockItems: BlockContent | null, widgetSlots: Map, interactionSlots: Map ): string { if (!blockItems) return "" return blockItems .map((item): string => { switch (item.type) { case "paragraph": return `

${renderInlineContent(item.content, widgetSlots, interactionSlots)}

` case "blockquote": { const inline = renderInlineContent( item.content, widgetSlots, interactionSlots ) return `
${inline}
` } case "tableRich": { const tableStyle = "border-collapse: collapse; width: 100%;" const thStyle = "border: 1px solid #ddd; padding: 8px 12px; text-align: left; vertical-align: top;" const tdStyle = "border: 1px solid #ddd; padding: 8px 12px; vertical-align: top;" const renderRow = ( row: Array | null>, asHeader = false ) => `${row .map((cell) => { const tag = asHeader ? "th" : "td" const cellStyle = asHeader ? thStyle : tdStyle // A cell contains an array of inline content items return `<${tag} style="${cellStyle}">${renderInlineContent(cell, widgetSlots, interactionSlots)}` }) .join("")}` const thead = item.header?.length ? `${item.header.map((r: Array | null>) => renderRow(r, true)).join("")}` : "" let tbodyRows = item.rows .map((r: Array | null>) => renderRow(r)) .join("") // Footer support removed: do not emit ; if footer provided by older data, fold into tbody as a final bold row is no longer supported return `${thead}${tbodyRows}
` } case "unorderedList": { const itemsXml = item.items .map( (inline: InlineContent) => `
  • ${renderInlineContent(inline, widgetSlots, interactionSlots)}

  • ` ) .join("") return `
      ${itemsXml}
    ` } case "orderedList": { const itemsXml = item.items .map( (inline: InlineContent) => `
  • ${renderInlineContent(inline, widgetSlots, interactionSlots)}

  • ` ) .join("") return `
      ${itemsXml}
    ` } case "widgetRef": { const content = widgetSlots.get(item.widgetId) if (content === undefined) { logger.error("content for widget ref not found", { widgetId: item.widgetId, availableSlots: Array.from(widgetSlots.keys()) }) throw errors.new("content for widget ref not found") } return content } case "interactionRef": { const content = interactionSlots.get(item.interactionId) if (content === undefined) { logger.error("content for interaction ref not found", { interactionId: item.interactionId, availableSlots: Array.from(interactionSlots.keys()) }) throw errors.new("content for interaction ref not found") } return content } default: logger.error("unsupported block content type", { item }) throw errors.new("unsupported block content type") } }) .join("\n ") } export function renderFeedbackContent( feedback: FeedbackContent | null, widgetSlots: Map, interactionSlots: Map ): string { if (!feedback) return "" const preambleCorrectness = feedback.preamble.correctness const preambleColor = preambleCorrectness === "correct" ? "#047857" : "#B91C1C" const preambleHeadline = preambleCorrectness === "correct" ? "Correct! Fantastic work." : "Not quite! Try again." const preambleSummary = renderInlineContent( feedback.preamble.summary, widgetSlots, interactionSlots ) const preambleHtml = `

    ${preambleHeadline}

    ${preambleSummary}

    ` const palette = [ { accent: "#2563EB" }, { accent: "#059669" }, { accent: "#F59E0B" }, { accent: "#8B5CF6" }, { accent: "#EF4444" } ] const stepItems = feedback.steps .map((step: StepBlock, idx: number) => { const colors = palette[idx % palette.length] const title = renderInlineContent( step.title, widgetSlots, interactionSlots ) const content = renderBlockContent( step.content, widgetSlots, interactionSlots ) return `
  • ${idx + 1}

    ${title}

    ${content}
  • ` }) .join("") const stepsHtml = `
      ${stepItems}
    ` return preambleHtml + stepsHtml }