| import { describe, it, expect } from "vitest"; |
| import { renderArticleHTML, type PublishMeta, type CitationData } from "../src/publisher/html-renderer.js"; |
| import type { PublishCSS } from "../src/publisher/index.js"; |
|
|
| const EMPTY_CSS: PublishCSS = { |
| variables: "", |
| reset: "", |
| base: "", |
| layout: "", |
| print: "", |
| editorTokens: "", |
| article: "", |
| components: "", |
| publisher: "", |
| }; |
|
|
| const BASIC_META: PublishMeta = { |
| title: "Test Article", |
| description: "A test article", |
| authors: [{ name: "Alice", affiliationIndices: [1], affiliationNames: ["MIT"] }], |
| affiliations: [{ name: "MIT" }], |
| date: "2025-01-01", |
| }; |
|
|
| function minimalDoc(content: any[]) { |
| return { type: "doc", content }; |
| } |
|
|
| describe("renderArticleHTML", () => { |
| it("produces a complete HTML document", async () => { |
| const json = minimalDoc([{ type: "paragraph", content: [{ type: "text", text: "Hello world" }] }]); |
| const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS); |
| expect(html).toContain("<!DOCTYPE html>"); |
| expect(html).toContain("<title>Test Article</title>"); |
| expect(html).toContain("Hello world"); |
| }); |
|
|
| it("escapes HTML in meta fields", async () => { |
| const meta: PublishMeta = { ...BASIC_META, title: 'Title with "quotes" & <tags>' }; |
| const json = minimalDoc([{ type: "paragraph" }]); |
| const html = await renderArticleHTML(json, meta, EMPTY_CSS); |
| expect(html).toContain("&"); |
| expect(html).toContain("<tags>"); |
| expect(html).not.toContain('<tags>'); |
| }); |
|
|
| it("renders PDF download link when pdfUrl is set", async () => { |
| const meta: PublishMeta = { ...BASIC_META, pdfUrl: "/published/test/article.pdf" }; |
| const json = minimalDoc([{ type: "paragraph" }]); |
| const html = await renderArticleHTML(json, meta, EMPTY_CSS); |
| expect(html).toContain("Download PDF"); |
| expect(html).toContain("/published/test/article.pdf"); |
| }); |
| }); |
|
|
| describe("postProcess - accordion", () => { |
| it("transforms accordion div into details/summary", async () => { |
| const json = minimalDoc([{ |
| type: "accordion", |
| attrs: { title: "My Section", open: false }, |
| content: [{ type: "paragraph", content: [{ type: "text", text: "Inner content" }] }], |
| }]); |
| const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS); |
| expect(html).toContain("<details"); |
| expect(html).toContain("<summary>"); |
| expect(html).toContain("Inner content"); |
| }); |
| }); |
|
|
| describe("postProcess - citations", () => { |
| it("replaces citation spans with anchor links", async () => { |
| const json = minimalDoc([{ |
| type: "paragraph", |
| content: [ |
| { type: "text", text: "See " }, |
| { type: "citation", attrs: { key: "smith2024", label: "Smith (2024)" } }, |
| ], |
| }]); |
| const citationData: CitationData = { |
| entries: [{ id: "smith2024", title: "Test Paper" }], |
| orderedKeys: ["smith2024"], |
| style: "apa", |
| }; |
| const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS, citationData); |
| expect(html).toContain('href="#ref-smith2024"'); |
| expect(html).toContain("citation-inline"); |
| }); |
|
|
| it("uses numeric labels for IEEE style", async () => { |
| const json = minimalDoc([{ |
| type: "paragraph", |
| content: [ |
| { type: "citation", attrs: { key: "doe2023", label: "Doe (2023)" } }, |
| ], |
| }]); |
| const citationData: CitationData = { |
| entries: [{ id: "doe2023" }], |
| orderedKeys: ["doe2023"], |
| style: "ieee", |
| }; |
| const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS, citationData); |
| expect(html).toContain("[1]"); |
| }); |
| }); |
|
|
| describe("postProcess - footnotes", () => { |
| it("collects footnotes and appends a footnotes section", async () => { |
| const json = minimalDoc([{ |
| type: "paragraph", |
| content: [ |
| { type: "text", text: "Text" }, |
| { type: "footnote", attrs: { content: "This is a footnote" } }, |
| ], |
| }]); |
| const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS); |
| expect(html).toContain('class="footnote-ref"'); |
| expect(html).toContain('id="fn-1"'); |
| expect(html).toContain("This is a footnote"); |
| expect(html).toContain('class="footnotes"'); |
| }); |
|
|
| it("numbers multiple footnotes sequentially", async () => { |
| const json = minimalDoc([{ |
| type: "paragraph", |
| content: [ |
| { type: "footnote", attrs: { content: "First note" } }, |
| { type: "text", text: " and " }, |
| { type: "footnote", attrs: { content: "Second note" } }, |
| ], |
| }]); |
| const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS); |
| expect(html).toContain('id="fn-1"'); |
| expect(html).toContain('id="fn-2"'); |
| expect(html).toContain("First note"); |
| expect(html).toContain("Second note"); |
| }); |
| }); |
|
|
| describe("postProcess - mermaid", () => { |
| it("transforms mermaid div into pre.mermaid", async () => { |
| const json = minimalDoc([{ |
| type: "mermaid", |
| attrs: { code: "graph TD\n A --> B" }, |
| }]); |
| const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS); |
| expect(html).toContain('class="mermaid"'); |
| expect(html).toContain("graph TD"); |
| }); |
| }); |
|
|
| describe("postProcess - htmlEmbed", () => { |
| it("transforms htmlEmbed div into iframe", async () => { |
| const json = minimalDoc([{ |
| type: "htmlEmbed", |
| attrs: { src: "d3-chart.html", title: "Chart", desc: "" }, |
| }]); |
| const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS); |
| expect(html).toContain("html-embed-container"); |
| expect(html).toContain('data-embed-src="d3-chart.html"'); |
| expect(html).toContain("<iframe"); |
| }); |
|
|
| it("injects embed HTML from Y.Map into iframe srcdoc", async () => { |
| const json = minimalDoc([{ |
| type: "htmlEmbed", |
| attrs: { src: "my-chart", title: "Chart", desc: "" }, |
| }]); |
| const embeds = { "my-chart": '<div id="chart"><script>console.log("hello")<\/script></div>' }; |
| const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS, undefined, undefined, embeds); |
| expect(html).toContain("html-embed-container"); |
| expect(html).toContain('data-embed-src="my-chart"'); |
| expect(html).toContain("srcdoc="); |
| expect(html).toContain("ColorPalettes"); |
| expect(html).toContain("chart"); |
| }); |
|
|
| it("leaves srcdoc empty when embed key is missing", async () => { |
| const json = minimalDoc([{ |
| type: "htmlEmbed", |
| attrs: { src: "missing-chart", title: "Chart", desc: "" }, |
| }]); |
| const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS, undefined, undefined, {}); |
| expect(html).toContain('data-embed-src="missing-chart"'); |
| expect(html).toContain('srcdoc=""'); |
| }); |
| }); |
|
|
| describe("postProcess - hfUser", () => { |
| it("transforms hfUser div into the HF profile card", async () => { |
| const json = minimalDoc([{ |
| type: "hfUser", |
| attrs: { username: "tfrere", name: "Thibaud Frere", url: "https://huggingface.co/tfrere" }, |
| }]); |
| const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS); |
| expect(html).toContain('class="hf-user"'); |
| expect(html).toContain('class="hf-user__avatar"'); |
| expect(html).toContain("https://huggingface.co/api/users/tfrere/avatar"); |
| expect(html).toContain("Thibaud Frere"); |
| expect(html).toContain("@tfrere"); |
| expect(html).toContain('href="https://huggingface.co/tfrere"'); |
| expect(html).not.toContain('data-component="hfUser"'); |
| }); |
|
|
| it("falls back to username when name and url are missing", async () => { |
| const json = minimalDoc([{ |
| type: "hfUser", |
| attrs: { username: "alice", name: "", url: "" }, |
| }]); |
| const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS); |
| expect(html).toContain('class="hf-user"'); |
| expect(html).toContain("@alice"); |
| expect(html).toContain('href="https://huggingface.co/alice"'); |
| }); |
|
|
| it("removes the placeholder div when username is missing", async () => { |
| const json = minimalDoc([{ |
| type: "hfUser", |
| attrs: { username: "", name: "Anon", url: "" }, |
| }]); |
| const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS); |
| expect(html).not.toContain('class="hf-user"'); |
| expect(html).not.toContain('data-component="hfUser"'); |
| }); |
| }); |
|
|
| describe("postProcess - bibliography", () => { |
| it("injects bibliography HTML into the placeholder", async () => { |
| const json = minimalDoc([ |
| { type: "paragraph", content: [{ type: "citation", attrs: { key: "test2024", label: "[1]" } }] }, |
| { type: "bibliography", attrs: { renderedHtml: "" } }, |
| ]); |
| const citationData: CitationData = { |
| entries: [{ id: "test2024" }], |
| orderedKeys: ["test2024"], |
| style: "apa", |
| }; |
| const biblioHtml = '<div class="csl-entry">Test entry</div>'; |
| const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS, citationData, biblioHtml); |
| expect(html).toContain("bibliography-content"); |
| expect(html).toContain('id="ref-test2024"'); |
| expect(html).toContain("Test entry"); |
| }); |
| }); |
|
|
| describe("snapshot - full render", () => { |
| it("matches snapshot for a typical article", async () => { |
| const json = minimalDoc([ |
| { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Introduction" }] }, |
| { type: "paragraph", content: [ |
| { type: "text", text: "This is a test article with a " }, |
| { type: "citation", attrs: { key: "ref1", label: "Ref (2024)" } }, |
| { type: "text", text: " citation." }, |
| ] }, |
| { type: "paragraph", content: [ |
| { type: "text", text: "A footnote here" }, |
| { type: "footnote", attrs: { content: "Important detail" } }, |
| ] }, |
| { type: "bibliography", attrs: { renderedHtml: "" } }, |
| ]); |
|
|
| const citationData: CitationData = { |
| entries: [{ id: "ref1", title: "Reference One" }], |
| orderedKeys: ["ref1"], |
| style: "apa", |
| }; |
| const biblioHtml = '<div class="csl-entry">Reference One. 2024.</div>'; |
|
|
| const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS, citationData, biblioHtml); |
| expect(html).toMatchSnapshot(); |
| }); |
| }); |
|
|