/** * Server-side HTML renderer. * * Takes TipTap JSON content and generates a full static HTML page * with SEO tags, dark/light theming, and minimal inline JS. */ import { generateHTML } from "@tiptap/html"; import { parseHTML } from "linkedom"; import { getServerExtensions } from "./extensions.js"; import type { PublishCSS } from "./index.js"; import { oklchFromHue } from "../shared/theme.js"; import { transformers, buildEmbedSrcdoc, type TransformContext } from "./transformers/index.js"; export interface PublishAuthor { name: string; url?: string; affiliationIndices: number[]; affiliationNames: string[]; } export interface PublishAffiliation { name: string; url?: string; } export interface PublishMeta { title: string; subtitle?: string; description: string; authors: PublishAuthor[]; affiliations: PublishAffiliation[]; date: string; doi?: string; licence?: string; ogImage?: string; pdfUrl?: string; banner?: string; tableOfContentsAutoCollapse?: boolean; /** OKLCH hue (0-360) chosen via the editor HueSlider, synced from Y.Doc settings. */ primaryHue?: number; } export interface CitationData { entries: any[]; orderedKeys: string[]; style: string; } /** * Render TipTap JSON document into a complete, self-contained HTML page. */ export async function renderArticleHTML( json: Record, meta: PublishMeta, css: PublishCSS, citationData?: CitationData, serverBiblioHtml?: string, embeds?: Record ): Promise { const extensions = getServerExtensions(); // Extract bibliography HTML before generateHTML escapes it as an attribute // (fallback to client-side renderedHtml if server formatting not available) const clientBiblioHtml = extractBibliographyHtml(json); const biblioHtml = serverBiblioHtml || clientBiblioHtml; const bodyHtml = generateHTML(json as any, extensions); const enrichedBody = await postProcess(bodyHtml, biblioHtml, citationData, embeds); const authorNames = meta.authors.map((a) => a.name); const authorsStr = authorNames.join(", "); const safeTitle = escapeHtml(meta.title); const safeDesc = escapeHtml(meta.description); let result = ` ${safeTitle} ${meta.ogImage ? `` : ""} ${authorNames.map((a) => ``).join("\n ")} ${meta.ogImage ? `` : ""}

${safeTitle}

${meta.banner && embeds?.[meta.banner] ? `
` : ""} ${meta.subtitle ? `

${escapeHtml(meta.subtitle)}

` : ""}
${renderMetaBar(meta)}
${enrichedBody}
${renderFooter(meta)} `; // Fill banner iframe srcdoc if present. // `fullBleed: true` MUST match the editor's FrontmatterHero preview // (buildDoc(..., { fullBleed: true })): the banner iframe is size-driven // by the parent's 5:2 aspect-ratio box, so it needs the edge-to-edge // stylesheet (no body padding, 100% height chain) and must skip the // height reporter. Without it the published banner gets 16px padding and // resizes to its content scrollHeight, diverging from the editor view. if (meta.banner && embeds?.[meta.banner]) { const bannerSrcdoc = buildEmbedSrcdoc(embeds[meta.banner], { fullBleed: true }); const { document: d } = parseHTML(result); const bannerIframe = d.querySelector('iframe[data-banner-src]'); if (bannerIframe) { bannerIframe.setAttribute("srcdoc", bannerSrcdoc); result = "" + d.documentElement.outerHTML; } } return result; } /** * Emit a `:root` CSS override pinning `--primary-color` / `--primary-color-hover` * to the hue chosen via the editor HueSlider. Placed after the base `_variables.css` * so it always wins on specificity order. * * Returns an empty string if no hue was configured, leaving the template default. */ function renderPrimaryColorOverride(primaryHue?: number): string { if (typeof primaryHue !== "number" || Number.isNaN(primaryHue)) return ""; // Only override --primary-base: _variables.css already derives --primary-color // and --primary-color-hover from it via CSS relative-color syntax, so the // cascade handles the rest. return `:root { --primary-base: ${oklchFromHue(primaryHue)}; }`; } /** * Walk the TipTap JSON tree (recursively) to find a bibliography node, * extract its renderedHtml attribute, and clear it so generateHTML * outputs a clean div. */ function extractBibliographyHtml(json: Record): string { function walk(nodes: any[]): string { for (const node of nodes) { if (node.type === "bibliography") { const html = node.attrs?.renderedHtml || ""; if (node.attrs) node.attrs.renderedHtml = ""; return html; } if (Array.isArray(node.content)) { const found = walk(node.content); if (found) return found; } } return ""; } const content = (json as any)?.content; if (!Array.isArray(content)) return ""; return walk(content); } /** * Post-process the raw `generateHTML` output by running the transformer * registry over a parsed DOM. Each transformer is a small module living in * `transformers/` and is responsible for one component type. * * @see ./transformers/index.ts for the ordered list and why order matters. */ /** @internal - exported for testing */ export async function postProcess( html: string, biblioHtml: string, citationData?: CitationData, embeds?: Record ): Promise { const { document } = parseHTML(`${html}`); const ctx: TransformContext = { biblioHtml, citationData, embeds, footnoteTexts: [], }; for (const transformer of transformers) { await transformer.apply(document as unknown as Document, ctx); } let result = document.body.innerHTML; if (ctx.footnoteTexts.length > 0) { const items = ctx.footnoteTexts .map((text, i) => `
  • ${escapeHtml(text)} \u21A9

  • `) .join("\n"); result += `

    Footnotes

      ${items}
    `; } return result; } function escapeHtml(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function renderMetaBar(meta: PublishMeta): string { // DOI intentionally excluded from the hero meta bar: it only appears in the // footer (`.doi-block`), matching the template convention the user expects. const hasContent = meta.authors.length > 0 || meta.affiliations.length > 0 || meta.date || meta.pdfUrl; if (!hasContent) return ""; const multipleAff = meta.affiliations.length > 1; const cells: string[] = []; // Authors cell if (meta.authors.length > 0) { const items = meta.authors.map((a, i) => { const name = a.url ? `${escapeHtml(a.name)}` : escapeHtml(a.name); const sup = multipleAff && a.affiliationIndices.length > 0 ? `${a.affiliationIndices.join(",")}` : ""; const sep = i < meta.authors.length - 1 ? ", " : ""; return `
  • ${name}${sup}${sep}
  • `; }).join(""); cells.push(`

    Authors

      ${items}
    `); } // Affiliations cell if (meta.affiliations.length > 0) { let affContent: string; if (multipleAff) { const items = meta.affiliations.map((aff) => { const name = aff.url ? `${escapeHtml(aff.name)}` : escapeHtml(aff.name); return `
  • ${name}
  • `; }).join(""); affContent = `
      ${items}
    `; } else { const aff = meta.affiliations[0]; affContent = aff.url ? `

    ${escapeHtml(aff.name)}

    ` : `

    ${escapeHtml(aff.name)}

    `; } cells.push(`

    Affiliations

    ${affContent}
    `); } // Published cell if (meta.date) { cells.push(`

    Published

    ${escapeHtml(formatDate(meta.date))}

    `); } // PDF download cell - styled as a primary button (matches research-article-template) if (meta.pdfUrl) { cells.push( `
    ` + `

    PDF

    ` + `

    ` + `` + `Download PDF` + `

    ` + `
    ` ); } return `
    ${cells.join("")}
    `; } function formatDate(dateStr: string): string { try { return new Date(dateStr).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", }); } catch { return dateStr; } } function extractYear(dateStr?: string): number | undefined { if (!dateStr) return undefined; const d = new Date(dateStr); if (!Number.isNaN(d.getTime())) return d.getFullYear(); const m = dateStr.match(/(19|20)\d{2}/); return m ? Number(m[0]) : undefined; } function buildCitationText(meta: PublishMeta): string { const authorNames = meta.authors.map((a) => a.name); const authorsStr = authorNames.join(", "); const year = extractYear(meta.date); const title = meta.title.replace(/\s+/g, " ").trim(); return `${authorsStr}${year ? ` (${year})` : ""}. "${title}".`; } function buildBibtex(meta: PublishMeta): string { const authorNames = meta.authors.map((a) => a.name); const authorsBib = authorNames.join(" and "); const title = meta.title.replace(/\s+/g, " ").trim(); const year = extractYear(meta.date); const keyAuthor = (authorNames[0] || "article") .split(/\s+/) .slice(-1)[0] .toLowerCase(); const keyTitle = title .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_|_$/g, ""); const bibKey = `${keyAuthor}${year ?? ""}_${keyTitle}`; const parts = [ ` title={${title}}`, ` author={${authorsBib}}`, ]; if (year) parts.push(` year={${year}}`); if (meta.doi) parts.push(` doi={${meta.doi}}`); return `@misc{${bibKey},\n${parts.join(",\n")}\n}`; } function renderFooter(meta: PublishMeta): string { const citationText = buildCitationText(meta); const bibtex = buildBibtex(meta); let sections = ""; // Citation block sections += `

    For attribution in academic contexts, please cite this work as

    ${escapeHtml(citationText)}

    BibTeX citation

    ${escapeHtml(bibtex)}
    `; // DOI block if (meta.doi) { sections += `

    ${escapeHtml(meta.doi)}

    `; } // Licence / Reuse block if (meta.licence) { sections += `

    ${escapeHtml(meta.licence)}

    `; } // References placeholder (JS will move bibliography + footnotes here) sections += `
    `; // Template credit. Point at the editor Space (which IS the // template you fork to start a new article) instead of the legacy // Astro-only static template - the editor is now the canonical // entry point and bundles the static renderer. sections += `

    Made with ❤️ with research article template editor

    `; return ``; }