/** * Tests for the Contentful Rich Text → Portable Text converter. * One test per acceptance criterion from the PR plan. */ import type { Document } from "@contentful/rich-text-types"; import type { PortableTextSpan, PortableTextMarkDefinition } from "@portabletext/types"; import { describe, it, expect, vi } from "vitest"; import { richTextToPortableText, buildIncludes } from "../src/index.js"; import type { ContentfulIncludes } from "../src/index.js"; import fixture from "./fixtures/contentful-blogpost.json"; // ── Test helpers ──────────────────────────────────────────────────────────── // Test helpers use `any` for fixture construction — we're testing runtime // behavior of the converter, not type-safety of the test inputs. function buildFixtureIncludes(): ContentfulIncludes { return buildIncludes({ Entry: fixture.items as Array>, Asset: (fixture.includes?.Asset ?? []) as Array>, }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any function convert(doc: Document, includes?: ContentfulIncludes, options?: any): any[] { return richTextToPortableText(doc, includes ?? emptyIncludes(), options ?? {}); } function emptyIncludes(): ContentfulIncludes { return { entries: new Map(), assets: new Map() }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any function makeDoc(...nodes: any[]): Document { return { nodeType: "document", content: nodes, data: {} } as Document; } // eslint-disable-next-line @typescript-eslint/no-explicit-any function text(value: string, marks: Array<{ type: string }> = []): any { return { nodeType: "text", value, marks, data: {} }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any function paragraph(...children: any[]): any { return { nodeType: "paragraph", content: children, data: {} }; } // Get the two blog posts from the fixture const post0 = fixture.items[0]!; // Deep Dive const post1 = fixture.items[1]!; // Lessons // ── Standard blocks ───────────────────────────────────────────────────────── describe("Standard blocks", () => { it("paragraph → block with style normal", () => { const doc = makeDoc(paragraph(text("Hello world"))); const blocks = convert(doc); expect(blocks).toHaveLength(1); expect(blocks[0]).toMatchObject({ _type: "block", style: "normal", }); const children = blocks[0]!.children as PortableTextSpan[]; expect(children[0]!.text).toBe("Hello world"); }); it("heading-1 through heading-6 → block with style h1–h6", () => { for (let i = 1; i <= 6; i++) { const doc = makeDoc({ nodeType: `heading-${i}`, content: [text(`Heading ${i}`)], data: {}, }); const blocks = convert(doc); expect(blocks).toHaveLength(1); expect(blocks[0]).toMatchObject({ _type: "block", style: `h${i}`, }); } }); it("heading-2 from fixture → block with style h2", () => { const includes = buildFixtureIncludes(); const doc = post0.fields.content as unknown as Document; const blocks = richTextToPortableText(doc, includes); const h2 = blocks[0]!; expect(h2).toMatchObject({ _type: "block", style: "h2" }); expect((h2.children as PortableTextSpan[])[0]!.text).toBe("Why Migration Matters"); }); it("unordered-list with 3+ items → bullet list blocks", () => { const includes = buildFixtureIncludes(); const doc = post0.fields.content as unknown as Document; const blocks = richTextToPortableText(doc, includes); // Find bullet list items const bullets = blocks.filter((b) => b.listItem === "bullet"); expect(bullets.length).toBeGreaterThanOrEqual(3); for (const bullet of bullets) { expect(bullet).toMatchObject({ _type: "block", listItem: "bullet", level: 1, }); } // Verify texts const bulletTexts = bullets.map( (b) => ((b.children as PortableTextSpan[])[0] as PortableTextSpan).text, ); expect(bulletTexts).toEqual( expect.arrayContaining([ "Parse the source format", "Transform to target schema", "Validate the output", ]), ); }); it("ordered-list with 3+ items → numbered list blocks", () => { const includes = buildFixtureIncludes(); const doc = post0.fields.content as unknown as Document; const blocks = richTextToPortableText(doc, includes); const numbered = blocks.filter((b) => b.listItem === "number"); expect(numbered.length).toBeGreaterThanOrEqual(3); for (const item of numbered) { expect(item).toMatchObject({ _type: "block", listItem: "number", level: 1, }); } }); it("nested list (bullet inside numbered) → inner items have level 2", () => { const doc = makeDoc({ nodeType: "ordered-list", data: {}, content: [ { nodeType: "list-item", data: {}, content: [ paragraph(text("Top level")), { nodeType: "unordered-list", data: {}, content: [ { nodeType: "list-item", data: {}, content: [paragraph(text("Nested"))], }, ], }, ], }, ], }); const blocks = convert(doc); expect(blocks).toHaveLength(2); expect(blocks[0]).toMatchObject({ listItem: "number", level: 1, }); expect(blocks[1]).toMatchObject({ listItem: "bullet", level: 2, }); }); it("blockquote containing paragraphs → blocks with style blockquote", () => { const includes = buildFixtureIncludes(); const doc = post0.fields.content as unknown as Document; const blocks = richTextToPortableText(doc, includes); const quotes = blocks.filter((b) => b.style === "blockquote"); expect(quotes.length).toBeGreaterThanOrEqual(1); expect(quotes[0]).toMatchObject({ _type: "block", style: "blockquote", }); }); it("blockquote with 2 paragraphs → 2 blocks with style blockquote", () => { const doc = makeDoc({ nodeType: "blockquote", data: {}, content: [paragraph(text("First paragraph")), paragraph(text("Second paragraph"))], }); const blocks = convert(doc); expect(blocks).toHaveLength(2); expect(blocks[0]).toMatchObject({ style: "blockquote" }); expect(blocks[1]).toMatchObject({ style: "blockquote" }); }); it("hr → break with style lineBreak", () => { const doc = makeDoc({ nodeType: "hr", data: {} }); const blocks = convert(doc); expect(blocks).toHaveLength(1); expect(blocks[0]).toMatchObject({ _type: "break", style: "lineBreak", }); }); it("table with header + 2 rows → table block with rows and cells", () => { const doc = makeDoc({ nodeType: "table", data: {}, content: [ { nodeType: "table-row", data: {}, content: [ { nodeType: "table-header-cell", data: {}, content: [paragraph(text("Name"))], }, { nodeType: "table-header-cell", data: {}, content: [paragraph(text("Value"))], }, ], }, { nodeType: "table-row", data: {}, content: [ { nodeType: "table-cell", data: {}, content: [paragraph(text("A"))], }, { nodeType: "table-cell", data: {}, content: [paragraph(text("1"))], }, ], }, { nodeType: "table-row", data: {}, content: [ { nodeType: "table-cell", data: {}, content: [paragraph(text("B"))], }, { nodeType: "table-cell", data: {}, content: [paragraph(text("2"))], }, ], }, ], }); const blocks = convert(doc); expect(blocks).toHaveLength(1); expect(blocks[0]!._type).toBe("table"); const rows = blocks[0]!.rows as Array<{ _type: string; cells: string[]; }>; expect(rows).toHaveLength(3); expect(rows[0]!.cells).toEqual(["Name", "Value"]); expect(rows[1]!.cells).toEqual(["A", "1"]); expect(rows[2]!.cells).toEqual(["B", "2"]); }); it("table cell containing a hyperlink → link text preserved", () => { const doc = makeDoc({ nodeType: "table", data: {}, content: [ { nodeType: "table-row", data: {}, content: [ { nodeType: "table-cell", data: {}, content: [ { nodeType: "paragraph", data: {}, content: [ text("See "), { nodeType: "hyperlink", data: { uri: "https://example.com", }, content: [text("this link")], }, text(" here"), ], }, ], }, ], }, ], }); const blocks = convert(doc); const rows = blocks[0]!.rows as Array<{ cells: string[] }>; expect(rows[0]!.cells[0]).toBe("See this link here"); }); it("empty paragraph → filtered out", () => { const doc = makeDoc(paragraph(text(""))); const blocks = convert(doc); expect(blocks).toHaveLength(0); }); }); // ── Inline marks ──────────────────────────────────────────────────────────── describe("Inline marks", () => { it("bold → marks: ['strong']", () => { const doc = makeDoc(paragraph(text("bold text", [{ type: "bold" }]))); const blocks = convert(doc); const span = (blocks[0]!.children as PortableTextSpan[])[0]!; expect(span.marks).toEqual(["strong"]); }); it("italic → marks: ['em']", () => { const doc = makeDoc(paragraph(text("italic text", [{ type: "italic" }]))); const blocks = convert(doc); const span = (blocks[0]!.children as PortableTextSpan[])[0]!; expect(span.marks).toEqual(["em"]); }); it("code → marks: ['code']", () => { const doc = makeDoc(paragraph(text("code text", [{ type: "code" }]))); const blocks = convert(doc); const span = (blocks[0]!.children as PortableTextSpan[])[0]!; expect(span.marks).toEqual(["code"]); }); it("underline → marks: ['underline']", () => { const doc = makeDoc(paragraph(text("underlined", [{ type: "underline" }]))); const blocks = convert(doc); const span = (blocks[0]!.children as PortableTextSpan[])[0]!; expect(span.marks).toEqual(["underline"]); }); it("superscript → marks: ['sup']", () => { const doc = makeDoc(paragraph(text("sup", [{ type: "superscript" }]))); const blocks = convert(doc); const span = (blocks[0]!.children as PortableTextSpan[])[0]!; expect(span.marks).toEqual(["sup"]); }); it("subscript → marks: ['sub']", () => { const doc = makeDoc(paragraph(text("sub", [{ type: "subscript" }]))); const blocks = convert(doc); const span = (blocks[0]!.children as PortableTextSpan[])[0]!; expect(span.marks).toEqual(["sub"]); }); it("combined marks (bold + italic + code) → marks: ['strong', 'em', 'code']", () => { const doc = makeDoc( paragraph(text("all marks", [{ type: "bold" }, { type: "italic" }, { type: "code" }])), ); const blocks = convert(doc); const span = (blocks[0]!.children as PortableTextSpan[])[0]!; expect(span.marks).toEqual(["strong", "em", "code"]); }); it("italic + bold + code from fixture", () => { const includes = buildFixtureIncludes(); const doc = post0.fields.content as unknown as Document; const blocks = richTextToPortableText(doc, includes); // Second block is the paragraph with "Building a migration pipeline..." const para = blocks[1]!; expect(para._type).toBe("block"); const children = para.children as PortableTextSpan[]; // "every content format" has italic + bold const boldItalic = children.find((c) => c.text === "every content format"); expect(boldItalic).toBeDefined(); expect(boldItalic!.marks).toEqual(expect.arrayContaining(["em", "strong"])); // "code snippets" has italic + code const codeItalic = children.find((c) => c.text === "code snippets"); expect(codeItalic).toBeDefined(); expect(codeItalic!.marks).toEqual(expect.arrayContaining(["em", "code"])); }); }); // ── Hyperlinks ────────────────────────────────────────────────────────────── describe("Hyperlinks", () => { it("hyperlink with external URL → markDef with blank: true", () => { const includes = buildFixtureIncludes(); const doc = post0.fields.content as unknown as Document; const blocks = richTextToPortableText(doc, includes); // Find the paragraph with the "Workers documentation" hyperlink const linkBlock = blocks.find((b) => { const children = b.children as PortableTextSpan[] | undefined; return children?.some((c) => c.text === "Workers documentation"); }); expect(linkBlock).toBeDefined(); const markDefs = linkBlock!.markDefs as PortableTextMarkDefinition[]; const linkMark = markDefs.find((m) => m._type === "link"); expect(linkMark).toBeDefined(); expect(linkMark!.href).toBe("https://developers.cloudflare.com/workers/"); expect(linkMark!.blank).toBe(true); }); it("hyperlink with internal URL → markDef without blank", () => { const doc = makeDoc({ nodeType: "paragraph", data: {}, content: [ { nodeType: "hyperlink", data: { uri: "https://myblog.com/post" }, content: [text("link")], }, ], }); const blocks = convert(doc, emptyIncludes(), { blogHostname: "myblog.com", }); const markDefs = blocks[0]!.markDefs as PortableTextMarkDefinition[]; expect(markDefs[0]).toMatchObject({ _type: "link", href: "https://myblog.com/post", }); expect(markDefs[0]!.blank).toBeUndefined(); }); it("hyperlink with javascript: URI → sanitized to href: '#'", () => { const doc = makeDoc({ nodeType: "paragraph", data: {}, content: [ { nodeType: "hyperlink", data: { uri: "javascript:alert(1)" }, content: [text("evil link")], }, ], }); const blocks = convert(doc); const markDefs = blocks[0]!.markDefs as PortableTextMarkDefinition[]; expect(markDefs[0]!.href).toBe("#"); }); it("hyperlink with mixed-case JaVaScRiPt: URI → sanitized to href: '#'", () => { const doc = makeDoc({ nodeType: "paragraph", data: {}, content: [ { nodeType: "hyperlink", data: { uri: " JaVaScRiPt:alert(1)" }, content: [text("evil link")], }, ], }); const blocks = convert(doc); const markDefs = blocks[0]!.markDefs as PortableTextMarkDefinition[]; expect(markDefs[0]!.href).toBe("#"); }); it("entry-hyperlink → resolved to /slug/ from includes", () => { const includes = emptyIncludes(); includes.entries.set("entry-123", { id: "entry-123", contentType: "blogPost", fields: { slug: "my-post", title: "My Post" }, }); const doc = makeDoc({ nodeType: "paragraph", data: {}, content: [ { nodeType: "entry-hyperlink", data: { target: { sys: { id: "entry-123" } } }, content: [text("click here")], }, ], }); const blocks = convert(doc, includes); const markDefs = blocks[0]!.markDefs as PortableTextMarkDefinition[]; expect(markDefs[0]!.href).toBe("/my-post/"); }); it("entryHrefResolver output is sanitized against javascript: injection", () => { const includes = emptyIncludes(); includes.entries.set("entry-xss", { id: "entry-xss", contentType: "blogPost", fields: { slug: "xss-post", title: "XSS Post" }, }); const doc = makeDoc({ nodeType: "paragraph", data: {}, content: [ { nodeType: "entry-hyperlink", data: { target: { sys: { id: "entry-xss" } } }, content: [text("click me")], }, ], }); const blocks = richTextToPortableText(doc, includes, { entryHrefResolver: () => "javascript:alert(1)", }); const markDefs = blocks[0]!.markDefs as PortableTextMarkDefinition[]; expect(markDefs[0]!.href).toBe("#"); }); it("entryHrefResolver customizes entry-hyperlink URL shape", () => { const includes = emptyIncludes(); includes.entries.set("product-1", { id: "product-1", contentType: "product", fields: { slug: "widget", handle: "widget-pro" }, }); const doc = makeDoc({ nodeType: "paragraph", data: {}, content: [ { nodeType: "entry-hyperlink", data: { target: { sys: { id: "product-1" } } }, content: [text("buy now")], }, ], }); const blocks = richTextToPortableText(doc, includes, { entryHrefResolver: (entry) => `/products/${entry.fields.slug as string}/`, }); const markDefs = blocks[0]!.markDefs as PortableTextMarkDefinition[]; expect(markDefs[0]!.href).toBe("/products/widget/"); }); it("asset-hyperlink → resolved to asset URL from includes", () => { const includes = emptyIncludes(); includes.assets.set("asset-456", { id: "asset-456", url: "https://cdn.example.com/file.pdf", title: "My PDF", }); const doc = makeDoc({ nodeType: "paragraph", data: {}, content: [ { nodeType: "asset-hyperlink", data: { target: { sys: { id: "asset-456" } } }, content: [text("download")], }, ], }); const blocks = convert(doc, includes); const markDefs = blocks[0]!.markDefs as PortableTextMarkDefinition[]; expect(markDefs[0]!.href).toBe("https://cdn.example.com/file.pdf"); }); it("hyperlink with no resolvable target → href: '#'", () => { const doc = makeDoc({ nodeType: "paragraph", data: {}, content: [ { nodeType: "entry-hyperlink", data: { target: { sys: { id: "nonexistent" } } }, content: [text("broken link")], }, ], }); const blocks = convert(doc); const markDefs = blocks[0]!.markDefs as PortableTextMarkDefinition[]; expect(markDefs[0]!.href).toBe("#"); }); it("hyperlink span references the markDef key", () => { const doc = makeDoc({ nodeType: "paragraph", data: {}, content: [ { nodeType: "hyperlink", data: { uri: "https://example.com" }, content: [text("link text")], }, ], }); const blocks = convert(doc); const markDefs = blocks[0]!.markDefs as PortableTextMarkDefinition[]; const children = blocks[0]!.children as PortableTextSpan[]; const linkSpan = children.find((c) => c.text === "link text"); expect(linkSpan!.marks).toContain(markDefs[0]!._key); }); }); // ── Embedded entries ──────────────────────────────────────────────────────── describe("Embedded entries", () => { it("blogCodeBlock → codeBlock with code and language", () => { const includes = emptyIncludes(); includes.entries.set("code-1", { id: "code-1", contentType: "blogCodeBlock", fields: { code: 'console.log("hello")', language: "javascript", }, }); const doc = makeDoc({ nodeType: "embedded-entry-block", data: { target: { sys: { id: "code-1" } } }, }); const blocks = convert(doc, includes); expect(blocks).toHaveLength(1); expect(blocks[0]).toMatchObject({ _type: "code", code: 'console.log("hello")', language: "javascript", }); }); it("blogCodeBlock with missing language → language: ''", () => { const includes = emptyIncludes(); includes.entries.set("code-2", { id: "code-2", contentType: "blogCodeBlock", fields: { code: "some code" }, }); const doc = makeDoc({ nodeType: "embedded-entry-block", data: { target: { sys: { id: "code-2" } } }, }); const blocks = convert(doc, includes); expect(blocks[0]!.language).toBe(""); }); it("blogEmbeddedHtml → htmlBlock with html preserved verbatim", () => { const html = '
Note: test
'; const includes = emptyIncludes(); includes.entries.set("html-1", { id: "html-1", contentType: "blogEmbeddedHtml", fields: { customHtml: html }, }); const doc = makeDoc({ nodeType: "embedded-entry-block", data: { target: { sys: { id: "html-1" } } }, }); const blocks = convert(doc, includes); expect(blocks).toHaveLength(1); expect(blocks[0]).toMatchObject({ _type: "htmlBlock", html, }); }); it("HTML is preserved verbatim (no sanitization, no escaping)", () => { const includes = buildFixtureIncludes(); const doc = post0.fields.content as unknown as Document; const blocks = richTextToPortableText(doc, includes); const htmlBlock = blocks.find((b) => b._type === "htmlBlock"); expect(htmlBlock).toBeDefined(); // The fixture's blogEmbeddedHtml has actual HTML with tags and attributes expect((htmlBlock as any).html as string).toContain(""); }); it("blogImage → imageBlock with asset src, alt, width, height", () => { const includes = buildFixtureIncludes(); const doc = post1.fields.content as unknown as Document; const blocks = richTextToPortableText(doc, includes); const imageBlock = blocks.find((b) => b._type === "image"); expect(imageBlock).toBeDefined(); const asset = (imageBlock as any).asset as { src: string; alt: string; width?: number; height?: number; }; expect(asset.src).toContain("images.ctfassets.net"); expect(asset.src).toMatch(/^https:/); expect(typeof asset.width).toBe("number"); expect(typeof asset.height).toBe("number"); }); it("blogImage asset URL starting with // → prefixed with https:", () => { const includes = emptyIncludes(); includes.entries.set("img-1", { id: "img-1", contentType: "blogImage", fields: { assetFile: { sys: { id: "asset-proto" } }, }, }); includes.assets.set("asset-proto", { id: "asset-proto", url: "//images.ctfassets.net/test.png", title: "test", width: 100, height: 100, }); const doc = makeDoc({ nodeType: "embedded-entry-block", data: { target: { sys: { id: "img-1" } } }, }); const blocks = convert(doc, includes); const asset = blocks[0]!.asset as { src: string }; expect(asset.src).toBe("https://images.ctfassets.net/test.png"); }); it("blogImage with size: 'Wide' → size preserved", () => { const includes = emptyIncludes(); includes.entries.set("img-wide", { id: "img-wide", contentType: "blogImage", fields: { assetFile: { sys: { id: "asset-w" } }, size: "Wide", }, }); includes.assets.set("asset-w", { id: "asset-w", url: "https://cdn.example.com/wide.png", width: 1200, height: 600, }); const doc = makeDoc({ nodeType: "embedded-entry-block", data: { target: { sys: { id: "img-wide" } } }, }); const blocks = convert(doc, includes); expect(blocks[0]!.size).toBe("Wide"); }); it("blogImage with linkUrl → linkUrl preserved", () => { const includes = emptyIncludes(); includes.entries.set("img-link", { id: "img-link", contentType: "blogImage", fields: { assetFile: { sys: { id: "asset-l" } }, linkUrl: "https://example.com/target", }, }); includes.assets.set("asset-l", { id: "asset-l", url: "https://cdn.example.com/linked.png", width: 800, height: 400, }); const doc = makeDoc({ nodeType: "embedded-entry-block", data: { target: { sys: { id: "img-link" } } }, }); const blocks = convert(doc, includes); expect(blocks[0]!.linkUrl).toBe("https://example.com/target"); }); it("blogImage with javascript: linkUrl → linkUrl sanitized to '#'", () => { const includes = emptyIncludes(); includes.entries.set("img-xss", { id: "img-xss", contentType: "blogImage", fields: { assetFile: { sys: { id: "asset-x" } }, linkUrl: "javascript:alert(2)", }, }); includes.assets.set("asset-x", { id: "asset-x", url: "https://cdn.example.com/img.png", width: 100, height: 100, }); const doc = makeDoc({ nodeType: "embedded-entry-block", data: { target: { sys: { id: "img-xss" } } }, }); const blocks = convert(doc, includes); expect(blocks[0]!.linkUrl).toBe("#"); }); it("unknown content type → null, console warning", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const includes = emptyIncludes(); includes.entries.set("unknown-1", { id: "unknown-1", contentType: "blogWidget", fields: {}, }); const doc = makeDoc({ nodeType: "embedded-entry-block", data: { target: { sys: { id: "unknown-1" } } }, }); const blocks = convert(doc, includes); expect(blocks).toHaveLength(0); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Unknown embedded entry type: blogWidget"), ); warnSpy.mockRestore(); }); it("unresolved entry → null, console warning", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const doc = makeDoc({ nodeType: "embedded-entry-block", data: { target: { sys: { id: "nonexistent-id" } } }, }); const blocks = convert(doc); expect(blocks).toHaveLength(0); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Unresolved embedded entry")); warnSpy.mockRestore(); }); }); // ── Embedded assets (legacy) ──────────────────────────────────────────────── describe("Embedded assets (legacy)", () => { it("embedded-asset-block → imageBlock with src, alt, width, height", () => { const includes = buildFixtureIncludes(); const doc = post1.fields.content as unknown as Document; const blocks = richTextToPortableText(doc, includes); // Post 1 has an embedded-asset-block referencing asset-1 const imageBlocks = blocks.filter((b) => b._type === "image"); expect(imageBlocks.length).toBeGreaterThanOrEqual(1); // The legacy embedded asset image should have the architecture-diagram asset const legacyImage = imageBlocks.find((b) => { const asset = (b as any).asset as { src: string }; return asset.src.includes("architecture-diagram"); }); expect(legacyImage).toBeDefined(); const asset = (legacyImage as any).asset as { src: string; alt: string; width: number; height: number; }; expect(asset.src).toMatch(/^https:/); expect(asset.alt).toBe("A diagram showing the migration pipeline architecture"); expect(asset.width).toBe(1200); expect(asset.height).toBe(800); }); it("asset URL starting with // → prefixed with https:", () => { const includes = emptyIncludes(); includes.assets.set("proto-asset", { id: "proto-asset", url: "//images.ctfassets.net/legacy.png", title: "Legacy", width: 640, height: 480, }); const doc = makeDoc({ nodeType: "embedded-asset-block", data: { target: { sys: { id: "proto-asset" } } }, }); const blocks = convert(doc, includes); expect(blocks).toHaveLength(1); const asset = blocks[0]!.asset as { src: string }; expect(asset.src).toBe("https://images.ctfassets.net/legacy.png"); }); it("unresolved asset → null, console warning", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const doc = makeDoc({ nodeType: "embedded-asset-block", data: { target: { sys: { id: "missing-asset" } } }, }); const blocks = convert(doc); expect(blocks).toHaveLength(0); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Unresolved embedded asset")); warnSpy.mockRestore(); }); }); // ── Integration ───────────────────────────────────────────────────────────── describe("Integration", () => { it("full document (post 0) with all block types → valid PT array, no crashes", () => { const includes = buildFixtureIncludes(); const doc = post0.fields.content as unknown as Document; const blocks = richTextToPortableText(doc, includes); expect(blocks.length).toBeGreaterThan(5); expect(blocks.every((b) => b._type !== undefined)).toBe(true); expect(blocks.every((b) => b._key !== undefined)).toBe(true); // Should contain headings, paragraphs, lists, blockquotes, embedded entries const types = new Set(blocks.map((b) => b._type)); expect(types.has("block")).toBe(true); // Should have embedded code and html blocks const allTypes = blocks.map((b) => b._type); expect(allTypes).toContain("code"); expect(allTypes).toContain("htmlBlock"); }); it("full document (post 1) with embedded entries and assets → valid PT", () => { const includes = buildFixtureIncludes(); const doc = post1.fields.content as unknown as Document; const blocks = richTextToPortableText(doc, includes); expect(blocks.length).toBeGreaterThan(5); const types = new Set(blocks.map((b) => b._type)); expect(types.has("block")).toBe(true); expect(types.has("htmlBlock")).toBe(true); expect(types.has("image")).toBe(true); }); it("output is JSON-serializable (round-trips without loss)", () => { const includes = buildFixtureIncludes(); const doc = post0.fields.content as unknown as Document; const blocks = richTextToPortableText(doc, includes); const json = JSON.stringify(blocks); const parsed = JSON.parse(json); expect(parsed).toEqual(blocks); }); it("separate calls produce identical keys (call-scoped counters)", () => { const includes = buildFixtureIncludes(); const doc = post0.fields.content as unknown as Document; const blocks1 = richTextToPortableText(doc, includes); const blocks2 = richTextToPortableText(doc, includes); expect(blocks1.map((b) => b._key)).toEqual(blocks2.map((b) => b._key)); }); }); // ── Edge cases and regression tests ───────────────────────────────────────── describe("Edge cases", () => { it("blockquote containing a list preserves the list", () => { const doc = makeDoc({ nodeType: "blockquote", data: {}, content: [ paragraph(text("quoted text")), { nodeType: "unordered-list", data: {}, content: [ { nodeType: "list-item", data: {}, content: [paragraph(text("bullet point"))], }, ], }, ], }); const blocks = convert(doc); expect(blocks.length).toBeGreaterThanOrEqual(2); const listBlock = blocks.find((b) => b.listItem === "bullet"); expect(listBlock).toBeDefined(); expect((listBlock!.children as PortableTextSpan[])[0]!.text).toBe("bullet point"); }); it("list item containing an embedded entry preserves it", () => { const includes = emptyIncludes(); includes.entries.set("code-1", { id: "code-1", contentType: "blogCodeBlock", fields: { code: "console.log('hi')", language: "javascript" }, }); const doc = makeDoc({ nodeType: "ordered-list", data: {}, content: [ { nodeType: "list-item", data: {}, content: [ paragraph(text("step one")), { nodeType: "embedded-entry-block", data: { target: { sys: { id: "code-1" } } }, content: [], }, ], }, ], }); const blocks = convert(doc, includes); const codeBlock = blocks.find((b) => b._type === "code"); expect(codeBlock).toBeDefined(); expect(codeBlock!.code).toBe("console.log('hi')"); }); it("buildIncludes skips entries with missing sys.id", () => { const includes = buildIncludes({ Entry: [ { sys: { id: "valid-1", contentType: { sys: { id: "post" } } }, fields: { title: "ok" } }, { sys: {}, fields: { title: "no id" } }, { fields: { title: "no sys" } }, ], Asset: [ { sys: { id: "asset-1" }, fields: { file: { url: "https://x.com/a.jpg" } } }, { sys: {}, fields: {} }, ], }); expect(includes.entries.size).toBe(1); expect(includes.entries.get("valid-1")).toBeDefined(); expect(includes.assets.size).toBe(1); expect(includes.assets.get("asset-1")).toBeDefined(); }); it("resource-hyperlink preserves visible text", () => { const doc = makeDoc({ nodeType: "paragraph", data: {}, content: [ { nodeType: "resource-hyperlink", data: { target: { sys: { urn: "crn:contentful:some-resource" } } }, content: [text("linked text")], }, ], }); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const blocks = convert(doc); warnSpy.mockRestore(); const spans = blocks[0]!.children as PortableTextSpan[]; const linkedSpan = spans.find((s) => s.text === "linked text"); expect(linkedSpan).toBeDefined(); }); it("entryHrefResolver receives entry even without slug field", () => { const includes = emptyIncludes(); includes.entries.set("product-1", { id: "product-1", contentType: "product", fields: { handle: "widget-pro" }, }); const doc = makeDoc({ nodeType: "paragraph", data: {}, content: [ { nodeType: "entry-hyperlink", data: { target: { sys: { id: "product-1" } } }, content: [text("buy now")], }, ], }); const blocks = richTextToPortableText(doc, includes, { entryHrefResolver: (entry) => `/products/${entry.fields.handle as string}/`, }); const markDefs = blocks[0]!.markDefs as PortableTextMarkDefinition[]; expect(markDefs[0]!.href).toBe("/products/widget-pro/"); }); it("entry-hyperlink without slug and no resolver falls back to #", () => { const includes = emptyIncludes(); includes.entries.set("no-slug", { id: "no-slug", contentType: "product", fields: { handle: "widget" }, }); const doc = makeDoc({ nodeType: "paragraph", data: {}, content: [ { nodeType: "entry-hyperlink", data: { target: { sys: { id: "no-slug" } } }, content: [text("link")], }, ], }); const blocks = convert(doc, includes); const markDefs = blocks[0]!.markDefs as PortableTextMarkDefinition[]; expect(markDefs[0]!.href).toBe("#"); }); });