/** * 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 } from "../src/types.js"; const HTML_TAG_PATTERN = /<[^>]+>/g; 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("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("detects provider from URL when not specified", () => { const content = `
https://vimeo.com/123456
`; const result = gutenbergToPortableText(content); expect(result[0]).toMatchObject({ _type: "embed", provider: "vimeo", }); }); }); describe("separator/spacer blocks", () => { it("converts separator to break", () => { const content = `
`; const result = gutenbergToPortableText(content); expect(result[0]).toMatchObject({ _type: "break", style: "lineBreak", }); }); it("converts spacer to break", () => { const content = ` `; const result = gutenbergToPortableText(content); expect(result[0]).toMatchObject({ _type: "break", }); }); }); describe("columns blocks", () => { it("converts columns with content", () => { const content = `

Column 1

Column 2

`; const result = gutenbergToPortableText(content); expect(result[0]).toMatchObject({ _type: "columns", }); const cols = result[0] as { columns: Array<{ content: unknown[] }> }; expect(cols.columns).toHaveLength(2); expect(cols.columns[0]?.content.length).toBeGreaterThan(0); }); }); describe("group blocks", () => { it("flattens group block content", () => { const content = `

Paragraph in group

Heading in group

`; const result = gutenbergToPortableText(content); // Group should be flattened - we get the inner blocks directly expect(result.some((b) => (b as PortableTextTextBlock).style === "normal")).toBe(true); expect(result.some((b) => (b as PortableTextTextBlock).style === "h2")).toBe(true); }); }); describe("unknown blocks", () => { it("creates htmlBlock fallback for unknown blocks", () => { const content = `
Custom content
`; const result = gutenbergToPortableText(content); expect(result[0]).toMatchObject({ _type: "htmlBlock", originalBlockName: "my-plugin/custom-block", }); expect((result[0] as { html: string }).html).toContain("Custom content"); }); it("preserves original attrs in fallback", () => { const content = `
Content
`; const result = gutenbergToPortableText(content); expect((result[0] as { originalAttrs: Record }).originalAttrs).toMatchObject( { setting: true, count: 5, }, ); }); }); describe("custom transformers", () => { it("uses custom transformer when provided", () => { const content = `
Great product!
`; const result = gutenbergToPortableText(content, { customTransformers: { "my-plugin/testimonial": (block, _opts, ctx) => [ { _type: "testimonial" as const, _key: ctx.generateKey(), text: block.innerHTML.replace(HTML_TAG_PATTERN, "").trim(), rating: block.attrs.rating as number, } as unknown as import("../src/types.js").PortableTextBlock, ], }, }); expect(result[0]).toMatchObject({ _type: "testimonial", text: "Great product!", rating: 5, }); }); }); describe("mixed content", () => { it("handles complex document with multiple block types", () => { const content = `

Welcome

This is the introduction.

Hero

A quote

`; const result = gutenbergToPortableText(content); // h1 + p + image + 2 list items + quote = 6 blocks expect(result.length).toBeGreaterThanOrEqual(5); const types = result.map((b) => b._type); expect(types).toContain("block"); expect(types).toContain("image"); }); }); }); describe("htmlToPortableText", () => { it("converts simple HTML paragraphs", () => { const html = "

Hello world

"; const result = htmlToPortableText(html); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ _type: "block", style: "normal", }); }); it("converts headings", () => { const html = "

Title

Subtitle

"; const result = htmlToPortableText(html); expect(result).toHaveLength(2); expect(result[0]).toMatchObject({ style: "h1" }); expect(result[1]).toMatchObject({ style: "h2" }); }); it("converts lists", () => { const html = ""; const result = htmlToPortableText(html); expect(result).toHaveLength(2); result.forEach((b) => { expect(b).toMatchObject({ listItem: "bullet" }); }); }); it("converts blockquotes", () => { const html = "
A quote
"; const result = htmlToPortableText(html); expect(result[0]).toMatchObject({ _type: "block", style: "blockquote", }); }); it("converts code blocks", () => { const html = "
const x = 1;
"; const result = htmlToPortableText(html); expect(result[0]).toMatchObject({ _type: "code", code: "const x = 1;", }); }); it("converts horizontal rules", () => { const html = "

Before


After

"; const result = htmlToPortableText(html); const breakBlock = result.find((b) => b._type === "break"); expect(breakBlock).toBeDefined(); }); it("handles inline formatting", () => { const html = "

Hello bold and italic

"; const result = htmlToPortableText(html); const block = result[0] as PortableTextTextBlock; expect(block.children.some((c) => c.marks?.includes("strong"))).toBe(true); expect(block.children.some((c) => c.marks?.includes("em"))).toBe(true); }); }); describe("WordPress.com classic editor content", () => { // Test case from sparge.wordpress.com - classic editor with linked images // and HTML entities in URLs (& instead of &) const spargePostContent = `

Hip Hops Nelson Sauvin is the first of my Christmas brews.

It's inspired by BrewDog 77, which is a classic lager dry-hopped with Nelson Sauvin. OK, so Hip Hops is 6.3% rather than 4.9%, and uses ordinary Saaz in the boil, but the essence is the same: it's a delicious, crisp German-style lager, given a New Zealand accent with a big hit of Nelson Sauvin.

`; it("extracts linked images with decoded URLs", () => { const result = htmlToPortableText(spargePostContent); // Should have at least one image block const imageBlocks = result.filter((b) => b._type === "image"); expect(imageBlocks.length).toBeGreaterThanOrEqual(1); // First block should be the image const img = imageBlocks[0]; expect(img._type).toBe("image"); // URL should have decoded HTML entities (& not &) expect(img.asset.url).toBe( "https://sparge.wordpress.com/wp-content/uploads/2011/11/hip-hop.jpg?w=205&h=300", ); expect(img.asset.url).not.toContain("&"); // Link should be preserved expect(img.link).toBe("https://sparge.wordpress.com/wp-content/uploads/2011/11/hip-hop.jpg"); }); it("preserves text content alongside images", () => { const result = htmlToPortableText(spargePostContent); // Should have text blocks with the paragraph content const textBlocks = result.filter((b) => b._type === "block"); expect(textBlocks.length).toBeGreaterThanOrEqual(1); // Check that text content is preserved const allText = textBlocks.flatMap((b) => b.children.map((c) => c.text)).join(""); expect(allText).toContain("Hip Hops Nelson Sauvin"); expect(allText).toContain("Christmas brews"); }); it("preserves links in text", () => { const result = htmlToPortableText(spargePostContent); // Should have text blocks with links const textBlocks = result.filter((b) => b._type === "block"); // Find block with BrewDog link const blockWithLink = textBlocks.find((b) => b.markDefs?.length); expect(blockWithLink).toBeDefined(); expect(blockWithLink?.markDefs).toContainEqual( expect.objectContaining({ _type: "link", href: "http://www.brewdog.com/product/77-lager", }), ); }); it("decodes HTML entities in standalone image src", () => { const html = `test`; const result = htmlToPortableText(html); expect(result).toHaveLength(1); const img = result[0] as PortableTextImageBlock; expect(img._type).toBe("image"); expect(img.asset.url).toBe("https://example.com/photo.jpg?w=200&h=300"); }); it("decodes & variant in URLs", () => { const html = `

test

`; const result = htmlToPortableText(html); const img = result.find((b) => b._type === "image") as PortableTextImageBlock; expect(img.asset.url).toBe("https://example.com/photo.jpg?a=1&b=2"); }); it("decodes & in URLs", () => { const html = `

test

`; const result = htmlToPortableText(html); const img = result.find((b) => b._type === "image") as PortableTextImageBlock; expect(img.asset.url).toBe("https://example.com/photo.jpg?a=1&b=2"); }); // Test for figure with HTML entities it("decodes HTML entities in figure images", () => { const html = `
test
Caption
`; const result = htmlToPortableText(html); const img = result[0] as PortableTextImageBlock; expect(img._type).toBe("image"); expect(img.asset.url).toBe("https://example.com/photo.jpg?w=200&h=300"); expect(img.caption).toBe("Caption"); }); }); describe("parseGutenbergBlocks", () => { it("parses blocks without converting", () => { const content = `

Hello

`; const blocks = parseGutenbergBlocks(content); expect(blocks).toHaveLength(1); expect(blocks[0]?.blockName).toBe("core/paragraph"); expect(blocks[0]?.innerHTML).toContain("Hello"); }); it("returns empty array for empty content", () => { expect(parseGutenbergBlocks("")).toEqual([]); }); it("preserves block attributes", () => { const content = `

Title

`; const blocks = parseGutenbergBlocks(content); expect(blocks[0]?.attrs).toMatchObject({ level: 3, align: "center", }); }); it("handles nested blocks", () => { const content = `

Nested

`; const blocks = parseGutenbergBlocks(content); expect(blocks[0]?.blockName).toBe("core/columns"); expect(blocks[0]?.innerBlocks.length).toBeGreaterThan(0); }); });