/** * Tests for the main Gutenberg to Portable Text converter */ import { describe, it, expect } from "vitest"; import { gutenbergToPortableText, htmlToPortableText, parseGutenbergBlocks } from "../src/index.js"; import type { PortableTextTextBlock, PortableTextImageBlock, PortableTextGalleryBlock, PortableTextTableBlock, PortableTextButtonsBlock, PortableTextCoverBlock, } from "../src/types.js"; const HTML_TAG_PATTERN = /<[^>]+>/g; const knownProviders = [ ["youtube.com", "youtube"], ["youtu.be", "youtube"], ["vimeo.com", "vimeo"], ["twitter.com", "twitter"], ["x.com", "twitter"], ["instagram.com", "instagram"], ["facebook.com", "facebook"], ["tiktok.com", "tiktok"], ["spotify.com", "spotify"], ["soundcloud.com", "soundcloud"], ["codepen.io", "codepen"], ["gist.github.com", "gist"], ] as const; describe("gutenbergToPortableText", () => { describe("empty content", () => { it("returns empty array for empty string", () => { expect(gutenbergToPortableText("")).toEqual([]); }); it("returns empty array for whitespace", () => { expect(gutenbergToPortableText(" \n\t ")).toEqual([]); }); it("returns empty array for null-ish values", () => { expect(gutenbergToPortableText(null as unknown as string)).toEqual([]); expect(gutenbergToPortableText(undefined as unknown as string)).toEqual([]); }); }); describe("basic HTML content", () => { it("converts simple HTML to a paragraph block", () => { const content = "

Hello world

"; const result = gutenbergToPortableText(content); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ _type: "block", style: "normal", }); const block = result[0] as PortableTextTextBlock; expect(block.children[0]?.text).toBe("Hello world"); }); it("converts mixed blocks and text", () => { const content = `

Intro text

weird middle text

More text

`; const result = gutenbergToPortableText(content); expect(result).toHaveLength(3); expect(result[0]).toMatchObject({ _type: "block", style: "normal", }); expect(result[1]).toMatchObject({ _type: "block", style: "normal", }); expect(result[2]).toMatchObject({ _type: "block", style: "normal", }); const block1 = result[0] as PortableTextTextBlock; const block2 = result[1] as PortableTextTextBlock; const block3 = result[2] as PortableTextTextBlock; expect(block1.children[0]?.text).toBe("Intro text"); expect(block2.children[0]?.text).toBe("weird middle text"); expect(block3.children[0]?.text).toBe("More text"); }); it("handles trailing text content", () => { const content = `

Paragraph text

Trailing text`; const result = gutenbergToPortableText(content); expect(result).toHaveLength(2); expect(result[0]).toMatchObject({ _type: "block", style: "normal", }); expect(result[1]).toMatchObject({ _type: "block", style: "normal", }); const block1 = result[0] as PortableTextTextBlock; const block2 = result[1] as PortableTextTextBlock; expect(block1.children[0]?.text).toBe("Paragraph text"); expect(block2.children[0]?.text).toBe("Trailing text"); }); }); describe("paragraph blocks", () => { it("converts simple paragraph", () => { const content = `

Hello world

`; const result = gutenbergToPortableText(content); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ _type: "block", style: "normal", }); const block = result[0] as PortableTextTextBlock; expect(block.children[0]?.text).toBe("Hello world"); }); it("converts paragraph with inline formatting", () => { const content = `

Hello bold and italic world

`; const result = gutenbergToPortableText(content); const block = result[0] as PortableTextTextBlock; expect(block.children.length).toBeGreaterThan(1); const boldSpan = block.children.find((c) => c.marks?.includes("strong")); const italicSpan = block.children.find((c) => c.marks?.includes("em")); expect(boldSpan?.text).toBe("bold"); expect(italicSpan?.text).toBe("italic"); }); it("converts paragraph with link", () => { const content = `

Visit our site

`; const result = gutenbergToPortableText(content); const block = result[0] as PortableTextTextBlock; expect(block.markDefs).toHaveLength(1); expect(block.markDefs?.[0]).toMatchObject({ _type: "link", href: "https://example.com", }); }); it("skips empty paragraphs", () => { const content = `

`; const result = gutenbergToPortableText(content); expect(result).toHaveLength(0); }); it("handles multiple paragraphs", () => { const content = `

First paragraph

Second paragraph

`; const result = gutenbergToPortableText(content); expect(result).toHaveLength(2); }); }); describe("heading blocks", () => { it("converts h1", () => { const content = `

Main Title

`; const result = gutenbergToPortableText(content); expect(result[0]).toMatchObject({ _type: "block", style: "h1", }); }); it("converts h2 (default level)", () => { const content = `

Subtitle

`; const result = gutenbergToPortableText(content); expect(result[0]).toMatchObject({ _type: "block", style: "h2", }); }); it("converts h3-h6", () => { for (let level = 3; level <= 6; level++) { const content = ` Heading ${level} `; const result = gutenbergToPortableText(content); expect(result[0]).toMatchObject({ _type: "block", style: `h${level}`, }); } }); it("preserves formatting in headings", () => { const content = `

Title with bold

`; const result = gutenbergToPortableText(content); const block = result[0] as PortableTextTextBlock; const boldSpan = block.children.find((c) => c.marks?.includes("strong")); expect(boldSpan?.text).toBe("bold"); }); }); describe("list blocks", () => { it("converts unordered list", () => { const content = ` `; const result = gutenbergToPortableText(content); expect(result).toHaveLength(3); result.forEach((block) => { expect(block).toMatchObject({ _type: "block", listItem: "bullet", level: 1, }); }); }); it("converts ordered list", () => { const content = `
  1. First
  2. Second
`; const result = gutenbergToPortableText(content); expect(result).toHaveLength(2); result.forEach((block) => { expect(block).toMatchObject({ _type: "block", listItem: "number", level: 1, }); }); }); it("preserves formatting in list items", () => { const content = ` `; const result = gutenbergToPortableText(content); const block = result[0] as PortableTextTextBlock; const boldSpan = block.children.find((c) => c.marks?.includes("strong")); expect(boldSpan?.text).toBe("bold"); }); it("handles nested lists", () => { const content = ` `; const result = gutenbergToPortableText(content); const level1 = result.filter((b) => (b as PortableTextTextBlock).level === 1); const level2 = result.filter((b) => (b as PortableTextTextBlock).level === 2); expect(level1.length).toBeGreaterThanOrEqual(1); expect(level2.length).toBeGreaterThanOrEqual(1); }); }); describe("quote blocks", () => { it("converts simple quote", () => { const content = `

To be or not to be

`; const result = gutenbergToPortableText(content); expect(result[0]).toMatchObject({ _type: "block", style: "blockquote", }); }); it("handles quote with citation", () => { const content = `

To be or not to be

`; const result = gutenbergToPortableText(content); // Should have quote block + citation block expect(result.length).toBeGreaterThanOrEqual(1); expect(result[0]).toMatchObject({ _type: "block", style: "blockquote", }); }); it("handles multi-paragraph quote", () => { const content = `

First paragraph of quote

Second paragraph of quote

`; const result = gutenbergToPortableText(content); const quoteBlocks = result.filter((b) => (b as PortableTextTextBlock).style === "blockquote"); expect(quoteBlocks).toHaveLength(2); }); }); describe("image blocks", () => { it("converts image with URL in attrs", () => { const content = `
A photo
`; const result = gutenbergToPortableText(content); expect(result[0]).toMatchObject({ _type: "image", alt: "A photo", }); const img = result[0] as PortableTextImageBlock; expect(img.asset.url).toBe("https://example.com/photo.jpg"); }); it("extracts image from HTML when not in attrs", () => { const content = `
Photo
`; const result = gutenbergToPortableText(content); const img = result[0] as PortableTextImageBlock; expect(img.asset.url).toBe("https://example.com/photo.jpg"); expect(img.alt).toBe("Photo"); }); it("extracts caption from figcaption", () => { const content = `
My caption
`; const result = gutenbergToPortableText(content); const img = result[0] as PortableTextImageBlock; expect(img.caption).toBe("My caption"); }); it("uses media map when provided", () => { const content = `
`; const mediaMap = new Map([[123, "emdash-media-abc"]]); const result = gutenbergToPortableText(content, { mediaMap }); const img = result[0] as PortableTextImageBlock; expect(img.asset._ref).toBe("emdash-media-abc"); }); it("handles alignment", () => { const content = `
`; const result = gutenbergToPortableText(content); const img = result[0] as PortableTextImageBlock; expect(img.alignment).toBe("center"); }); }); describe("code blocks", () => { it("converts code block", () => { const content = `
const x = 1;
`; const result = gutenbergToPortableText(content); expect(result[0]).toMatchObject({ _type: "code", code: "const x = 1;", }); }); it("preserves language attribute", () => { const content = `
const x = 1;
`; const result = gutenbergToPortableText(content); expect(result[0]).toMatchObject({ _type: "code", language: "javascript", }); }); it("decodes HTML entities in code", () => { const content = `
<div>Hello</div>
`; const result = gutenbergToPortableText(content); expect(result[0]).toMatchObject({ _type: "code", code: "
Hello
", }); }); it("handles multiline code", () => { const content = `
function hello() {
  return "world";
}
`; const result = gutenbergToPortableText(content); expect((result[0] as { code: string }).code).toContain("\n"); }); }); describe("embed blocks", () => { it("converts YouTube embed", () => { const content = `
https://www.youtube.com/watch?v=abc123
`; const result = gutenbergToPortableText(content); expect(result[0]).toMatchObject({ _type: "embed", url: "https://www.youtube.com/watch?v=abc123", provider: "youtube", }); }); it("converts Twitter embed", () => { const content = `
https://twitter.com/user/status/123
`; const result = gutenbergToPortableText(content); expect(result[0]).toMatchObject({ _type: "embed", provider: "twitter", }); }); it.each(knownProviders)( "detects provider from URL when not specified for %s", (domain, provider) => { const content = `
https://${domain}/123456
`; const result = gutenbergToPortableText(content); expect(result[0]).toMatchObject({ _type: "embed", provider, }); }, ); it("converts audio embeds", () => { const content = ` `; const result = gutenbergToPortableText(content); expect(result[0]).toMatchObject({ _type: "embed", url: "https://example.com/audio.mp3", provider: "audio", }); }); it("converts video embeds", () => { const content = ` `; const result = gutenbergToPortableText(content); expect(result[0]).toMatchObject({ _type: "embed", url: "https://example.com/video.mp4", provider: "video", }); }); it("infers video source from