/**
* 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 textMore 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 = `
- Item one
- Item two
- Item three
`;
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 = `
- First
- 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 = `
`;
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 = `
`;
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 = `
`;
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