| |
| |
| |
| |
| |
| |
| import { describe, it, expect } from "vitest"; |
| import { renderArticleHTML, type PublishMeta } from "../src/publisher/html-renderer.js"; |
| import { createTestYDoc, FULL_DOC_OPTIONS, MINIMAL_DOC_JSON } from "./helpers.js"; |
|
|
| function buildMeta(options: typeof FULL_DOC_OPTIONS): PublishMeta { |
| const authors = options.authors ?? []; |
| const affiliations = options.affiliations ?? []; |
| return { |
| title: options.title || "Untitled", |
| subtitle: options.subtitle || undefined, |
| description: options.description || "", |
| authors: authors.map((a) => ({ |
| name: a.name, |
| url: a.url, |
| affiliationIndices: a.affiliations || [], |
| affiliationNames: (a.affiliations || []) |
| .map((idx) => affiliations[idx - 1]?.name) |
| .filter(Boolean) as string[], |
| })), |
| affiliations: affiliations.map((a) => ({ name: a.name, url: a.url })), |
| date: options.published || new Date().toISOString(), |
| doi: options.doi || undefined, |
| licence: options.licence || undefined, |
| }; |
| } |
|
|
| const EMPTY_CSS = { |
| variables: ":root { --text-color: #000; }", |
| reset: "", |
| base: "", |
| layout: "", |
| print: "", |
| editorTokens: "", |
| article: "", |
| components: "", |
| publisher: "", |
| }; |
|
|
| |
|
|
| describe("1.1 Y.Doc extraction", () => { |
| it("1.1.1 extracts frontmatter from Y.Doc", async () => { |
| const ydoc = createTestYDoc(FULL_DOC_OPTIONS); |
| const metaMap = ydoc.getMap("frontmatter"); |
|
|
| expect(metaMap.get("title")).toBe("Test Article Title"); |
| expect(metaMap.get("subtitle")).toBe("A subtitle for testing"); |
| expect(metaMap.get("description")).toBe("This is a test article description."); |
| expect(metaMap.get("doi")).toBe("10.1234/test.2025"); |
|
|
| const authors = ydoc.getArray("frontmatter.authors").toArray(); |
| expect(authors).toHaveLength(2); |
| expect((authors[0] as any).name).toBe("Alice Smith"); |
|
|
| const affiliations = ydoc.getArray("frontmatter.affiliations").toArray(); |
| expect(affiliations).toHaveLength(2); |
| expect((affiliations[0] as any).name).toBe("University of Testing"); |
| }); |
|
|
| it("1.1.2 empty doc does not throw", async () => { |
| const ydoc = createTestYDoc({ title: "Empty" }); |
| const fragment = ydoc.getXmlFragment("default"); |
| expect(fragment.length).toBe(0); |
| expect(ydoc.getMap("frontmatter").get("title")).toBe("Empty"); |
| }); |
|
|
| it("1.1.3 extracts citations from Y.Map", async () => { |
| const ydoc = createTestYDoc(FULL_DOC_OPTIONS); |
| const citationsMap = ydoc.getMap("citations"); |
| const entries: Record<string, unknown> = {}; |
| citationsMap.forEach((value, key) => { |
| entries[key] = value; |
| }); |
|
|
| expect(entries).toHaveProperty("smith2024"); |
| expect((entries.smith2024 as any).title).toBe("On Testing"); |
| }); |
| }); |
|
|
| |
|
|
| describe("1.2 HTML generation", () => { |
| it("1.2.1 produces valid self-contained HTML", async () => { |
| const meta = buildMeta(FULL_DOC_OPTIONS); |
| const html = await renderArticleHTML(MINIMAL_DOC_JSON, meta, EMPTY_CSS); |
|
|
| expect(html).toContain("<!DOCTYPE html>"); |
| expect(html).toContain("<html"); |
| expect(html).toContain("</html>"); |
| expect(html).toContain("<style>"); |
| }); |
|
|
| it("1.2.3 generates TOC placeholder", async () => { |
| const meta = buildMeta(FULL_DOC_OPTIONS); |
| const html = await renderArticleHTML(MINIMAL_DOC_JSON, meta, EMPTY_CSS); |
| expect(html).toContain('aria-label="Table of Contents"'); |
| expect(html).toContain("toc-placeholder"); |
| }); |
|
|
| it("1.2.4 includes theme toggle", async () => { |
| const meta = buildMeta(FULL_DOC_OPTIONS); |
| const html = await renderArticleHTML(MINIMAL_DOC_JSON, meta, EMPTY_CSS); |
| expect(html).toContain("theme-toggle"); |
| expect(html).toContain("icon--sun"); |
| expect(html).toContain("icon--moon"); |
| }); |
|
|
| it("1.2.5 renders bibliography placeholder", async () => { |
| const docWithBiblio = { |
| type: "doc", |
| content: [ |
| { type: "paragraph", content: [{ type: "text", text: "Hello" }] }, |
| { type: "bibliography", attrs: { renderedHtml: "<ol><li>Smith 2024</li></ol>" } }, |
| ], |
| }; |
| const meta = buildMeta(FULL_DOC_OPTIONS); |
| const html = await renderArticleHTML(docWithBiblio, meta, EMPTY_CSS); |
| expect(html).toContain("bibliography"); |
| expect(html).toContain("Smith 2024"); |
| }); |
| }); |
|
|
| |
|
|
| describe("1.3 Post-processing", () => { |
| const simpleMeta: PublishMeta = { |
| title: "Test", description: "", authors: [], affiliations: [], |
| date: "2025-01-01", |
| }; |
|
|
| it("1.3.1 accordion rendered with data-component attribute", async () => { |
| const docJson = { |
| type: "doc", |
| content: [ |
| { |
| type: "accordion", |
| attrs: { title: "Click me", open: false }, |
| content: [ |
| { type: "paragraph", content: [{ type: "text", text: "Hidden content" }] }, |
| ], |
| }, |
| ], |
| }; |
| const html = await renderArticleHTML(docJson, simpleMeta, EMPTY_CSS); |
| |
| |
| |
| expect(html).toContain('data-component="accordion"'); |
| expect(html).toContain("Hidden content"); |
| }); |
|
|
| it("1.3.2 transforms htmlEmbed to iframe", async () => { |
| const docJson = { |
| type: "doc", |
| content: [ |
| { |
| type: "htmlEmbed", |
| attrs: { src: "chart.html", title: "My Chart", desc: "", wide: false, downloadable: false }, |
| }, |
| ], |
| }; |
| const html = await renderArticleHTML(docJson, simpleMeta, EMPTY_CSS); |
| |
| |
| expect(html).toContain("chart.html"); |
| }); |
|
|
| it("1.3.2b transforms iframe component to figure + iframe with src", async () => { |
| const docJson = { |
| type: "doc", |
| content: [ |
| { |
| type: "iframe", |
| attrs: { |
| src: "https://my-space.hf.space/", |
| title: "Tokenization demo", |
| desc: "Interactive widget", |
| height: "500", |
| wide: false, |
| }, |
| }, |
| ], |
| }; |
| const html = await renderArticleHTML(docJson, simpleMeta, EMPTY_CSS); |
| expect(html).toMatch(/<figure[^>]*\bclass="html-embed"/); |
| expect(html).toContain('data-iframe-src="https://my-space.hf.space/"'); |
| expect(html).toContain('src="https://my-space.hf.space/"'); |
| expect(html).toContain('height:500px'); |
| expect(html).toContain('title="Tokenization demo"'); |
| expect(html).toContain("Interactive widget"); |
| |
| expect(html).not.toContain('data-component="iframe"'); |
| }); |
|
|
| it("1.3.2c drops iframe component without src", async () => { |
| const docJson = { |
| type: "doc", |
| content: [ |
| { |
| type: "iframe", |
| attrs: { src: "", title: "Empty", desc: "", height: "600", wide: false }, |
| }, |
| ], |
| }; |
| const html = await renderArticleHTML(docJson, simpleMeta, EMPTY_CSS); |
| expect(html).not.toContain('data-component="iframe"'); |
| expect(html).not.toContain('<iframe'); |
| }); |
|
|
| it("1.3.2d iframe with wide flag gets the wide modifier class", async () => { |
| const docJson = { |
| type: "doc", |
| content: [ |
| { |
| type: "iframe", |
| attrs: { src: "https://example.com", title: "", desc: "", height: "600", wide: true }, |
| }, |
| ], |
| }; |
| const html = await renderArticleHTML(docJson, simpleMeta, EMPTY_CSS); |
| expect(html).toMatch(/<figure[^>]*\bclass="html-embed html-embed--wide"/); |
| }); |
|
|
| it("1.3.3 transforms mermaid to pre block", async () => { |
| const docJson = { |
| type: "doc", |
| content: [ |
| { |
| type: "mermaid", |
| attrs: { code: "graph TD; A-->B;" }, |
| }, |
| ], |
| }; |
| const html = await renderArticleHTML(docJson, simpleMeta, EMPTY_CSS); |
| expect(html).toContain('<pre class="mermaid">'); |
| expect(html).toContain("graph TD"); |
| }); |
| }); |
|
|
| |
|
|
| describe("1.4 Publish idempotency", () => { |
| it("1.4.1 rendering twice gives same result", async () => { |
| const meta = buildMeta(FULL_DOC_OPTIONS); |
| const html1 = await renderArticleHTML(MINIMAL_DOC_JSON, meta, EMPTY_CSS); |
| const html2 = await renderArticleHTML(MINIMAL_DOC_JSON, meta, EMPTY_CSS); |
| expect(html1).toBe(html2); |
| }); |
| }); |
|
|