Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
# @emdash-cms/contentful-to-portable-text
## 0.1.0
### Minor Changes
- [#699](https://github.com/emdash-cms/emdash/pull/699) [`2e2d4bc`](https://github.com/emdash-cms/emdash/commit/2e2d4bca69a42dd99beb9bd55cb800659c5476e4) Thanks [@MohamedH1998](https://github.com/MohamedH1998)! - Adds a package for converting Contentful Rich Text documents into Portable Text blocks.

View File

@@ -0,0 +1,51 @@
{
"name": "@emdash-cms/contentful-to-portable-text",
"version": "0.1.0",
"description": "Convert Contentful Rich Text AST to Portable Text",
"type": "module",
"main": "dist/index.mjs",
"types": "dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsdown src/index.ts --format esm --dts --clean",
"dev": "tsdown src/index.ts --format esm --dts --watch",
"test": "vitest",
"prepublishOnly": "node --run build",
"check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm",
"typecheck": "tsgo --noEmit"
},
"devDependencies": {
"@arethetypeswrong/cli": "catalog:",
"publint": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"repository": {
"type": "git",
"url": "git+https://github.com/emdash-cms/emdash.git",
"directory": "packages/contentful-to-portable-text"
},
"homepage": "https://github.com/emdash-cms/emdash",
"keywords": [
"contentful",
"rich-text",
"portable-text",
"migration",
"cms"
],
"author": "Matt Kane",
"license": "MIT",
"dependencies": {
"@contentful/rich-text-types": "^17.2.7",
"@portabletext/types": "^4.0.2"
}
}

View File

@@ -0,0 +1,12 @@
import type { ArbitraryTypedObject } from "@portabletext/types";
import type { ContentfulEntry } from "../types.js";
export function transformCodeBlock(entry: ContentfulEntry, key: string): ArbitraryTypedObject {
return {
_type: "code",
_key: key,
code: (entry.fields.code as string) ?? "",
language: (entry.fields.language as string) ?? "",
};
}

View File

@@ -0,0 +1,12 @@
import type { ArbitraryTypedObject } from "@portabletext/types";
import type { ContentfulEntry } from "../types.js";
/** HTML is preserved verbatim — sanitization is the renderer's responsibility. */
export function transformEmbeddedHtml(entry: ContentfulEntry, key: string): ArbitraryTypedObject {
return {
_type: "htmlBlock",
_key: key,
html: (entry.fields.customHtml as string) ?? "",
};
}

View File

@@ -0,0 +1,29 @@
import type { ArbitraryTypedObject } from "@portabletext/types";
import { sanitizeUri } from "../sanitize.js";
import type { ContentfulEntry, ContentfulIncludes } from "../types.js";
export function transformImageBlock(
entry: ContentfulEntry,
includes: ContentfulIncludes,
key: string,
): ArbitraryTypedObject {
const assetLink = entry.fields.assetFile as { sys?: { id?: string } } | undefined;
const assetId = assetLink?.sys?.id;
const asset = assetId ? includes.assets.get(assetId) : undefined;
const src = asset?.url ? (asset.url.startsWith("//") ? `https:${asset.url}` : asset.url) : "";
return {
_type: "image",
_key: key,
asset: {
src,
alt: asset?.description ?? asset?.title ?? "",
width: asset?.width,
height: asset?.height,
},
linkUrl: entry.fields.linkUrl ? sanitizeUri(entry.fields.linkUrl as string) : undefined,
size: (entry.fields.size as string) ?? undefined,
};
}

View File

@@ -0,0 +1,595 @@
/**
* Contentful Rich Text → Portable Text converter.
*
* Takes a Contentful Rich Text document + resolved includes map, returns
* Portable Text blocks. Uses canonical types from @contentful/rich-text-types
* and @portabletext/types.
*
* Handles:
* - Standard blocks: paragraph, headings, lists, blockquotes, hr, table
* - Standard marks: bold, italic, underline, code, superscript, subscript, strikethrough
* - Inline hyperlinks with internal/external detection
* - Entry hyperlinks and asset hyperlinks (resolved from includes)
* - Embedded entries: blogCodeBlock, blogEmbeddedHtml, blogImage (block-level)
* - Embedded inline entries and assets (preserved as custom PT blocks)
* - Embedded assets (legacy image pattern)
*
* Does NOT handle (by design):
* - Asset download/upload (that's the import source's job)
* - Heading anchor preservation (application-specific)
* - HTML sanitization (renderer's responsibility)
*/
// Re-export our own types + canonical types for consumer convenience
export type {
ContentfulIncludes,
ContentfulEntry,
ContentfulAsset,
ConvertOptions,
} from "./types.js";
export type { Document, Block, Inline, Text } from "@contentful/rich-text-types";
export type {
PortableTextBlock,
PortableTextSpan,
PortableTextMarkDefinition,
ArbitraryTypedObject,
} from "@portabletext/types";
import type { Document, Block, Inline, Text } from "@contentful/rich-text-types";
import { BLOCKS, INLINES, MARKS } from "@contentful/rich-text-types";
import type {
ArbitraryTypedObject,
PortableTextBlock,
PortableTextMarkDefinition,
PortableTextSpan,
} from "@portabletext/types";
import { transformCodeBlock } from "./blocks/code-block.js";
import { transformEmbeddedHtml } from "./blocks/embedded-html.js";
import { transformImageBlock } from "./blocks/image-block.js";
import { sanitizeUri } from "./sanitize.js";
import type { ContentfulIncludes, ConvertOptions } from "./types.js";
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Any Contentful Rich Text node (block, inline, or text) */
type ContentfulNode = Block | Inline | Text;
/** Inline child: either a text span or a custom inline object (e.g. inlineEntry) */
type InlineChild = PortableTextSpan | ArbitraryTypedObject;
/** Output block: either a standard PT block or a custom typed object */
type OutputBlock = PortableTextBlock | ArbitraryTypedObject;
/**
* Create a call-scoped key generator.
*
* Each invocation of richTextToPortableText gets its own counter so that
* concatenating the output of multiple calls (e.g. body + sidebar, or
* stitching multi-locale documents) never produces duplicate _key values.
*/
function createKeyGenerator(): () => string {
let n = 0;
return () => `k${(n++).toString(36)}`;
}
// ── Contentful node type → PT style mapping ─────────────────────────────────
const HEADING_MAP: Record<string, string> = {
[BLOCKS.HEADING_1]: "h1",
[BLOCKS.HEADING_2]: "h2",
[BLOCKS.HEADING_3]: "h3",
[BLOCKS.HEADING_4]: "h4",
[BLOCKS.HEADING_5]: "h5",
[BLOCKS.HEADING_6]: "h6",
};
const MARK_MAP: Record<string, string> = {
[MARKS.BOLD]: "strong",
[MARKS.ITALIC]: "em",
[MARKS.UNDERLINE]: "underline",
[MARKS.CODE]: "code",
[MARKS.SUPERSCRIPT]: "sup",
[MARKS.SUBSCRIPT]: "sub",
[MARKS.STRIKETHROUGH]: "s",
};
// ── Main converter ──────────────────────────────────────────────────────────
/**
* Convert a Contentful Rich Text document to Portable Text blocks.
*/
export function richTextToPortableText(
document: Document,
includes: ContentfulIncludes,
options: ConvertOptions = {},
): OutputBlock[] {
const generateKey = createKeyGenerator();
const blocks: OutputBlock[] = [];
for (const node of document.content) {
const converted = convertNode(node, includes, options, generateKey);
if (converted) {
if (Array.isArray(converted)) {
blocks.push(...converted);
} else {
blocks.push(converted);
}
}
}
return blocks;
}
// ── Node dispatcher ─────────────────────────────────────────────────────────
function convertNode(
node: ContentfulNode,
includes: ContentfulIncludes,
options: ConvertOptions,
generateKey: () => string,
): OutputBlock | OutputBlock[] | null {
switch (node.nodeType) {
case BLOCKS.PARAGRAPH:
return convertTextBlock(node, "normal", includes, options, generateKey);
case BLOCKS.HEADING_1:
case BLOCKS.HEADING_2:
case BLOCKS.HEADING_3:
case BLOCKS.HEADING_4:
case BLOCKS.HEADING_5:
case BLOCKS.HEADING_6:
return convertTextBlock(node, HEADING_MAP[node.nodeType]!, includes, options, generateKey);
case BLOCKS.QUOTE:
return convertBlockquote(node, includes, options, generateKey);
case BLOCKS.UL_LIST:
return convertList(node, "bullet", includes, options, generateKey);
case BLOCKS.OL_LIST:
return convertList(node, "number", includes, options, generateKey);
case BLOCKS.HR:
return { _type: "break", _key: generateKey(), style: "lineBreak" };
case BLOCKS.TABLE:
return convertTable(node, includes, options, generateKey);
case BLOCKS.EMBEDDED_ENTRY:
return convertEmbeddedEntry(node, includes, generateKey);
case BLOCKS.EMBEDDED_ASSET:
return convertEmbeddedAsset(node, includes, generateKey);
case BLOCKS.EMBEDDED_RESOURCE:
console.warn(
`[rich-text-to-pt] Embedded resource block encountered — resource links are not yet supported.`,
);
return null;
default:
console.warn(`[rich-text-to-pt] Unknown node type: ${node.nodeType}`);
return null;
}
}
// ── Text block (paragraph, heading) ─────────────────────────────────────────
function convertTextBlock(
node: ContentfulNode,
style: string,
includes: ContentfulIncludes,
options: ConvertOptions,
generateKey: () => string,
): OutputBlock | null {
const content = "content" in node ? (node.content as ContentfulNode[]) : [];
const { children, markDefs } = convertInlineContent(content, includes, options, generateKey);
// Skip empty paragraphs (Contentful emits these often)
if (
style === "normal" &&
children.length === 1 &&
children[0]!._type === "span" &&
(children[0] as PortableTextSpan).text === ""
) {
return null;
}
return {
_type: "block",
_key: generateKey(),
style,
children,
...(markDefs.length > 0 ? { markDefs } : {}),
};
}
// ── Blockquote ──────────────────────────────────────────────────────────────
function convertBlockquote(
node: ContentfulNode,
includes: ContentfulIncludes,
options: ConvertOptions,
generateKey: () => string,
): OutputBlock[] {
const content = "content" in node ? (node.content as ContentfulNode[]) : [];
const blocks: OutputBlock[] = [];
for (const child of content) {
if (child.nodeType === BLOCKS.PARAGRAPH) {
const block = convertTextBlock(child, "blockquote", includes, options, generateKey);
if (block) blocks.push(block);
} else {
// Blockquotes can contain lists, embedded entries, etc.
const converted = convertNode(child, includes, options, generateKey);
if (converted) {
if (Array.isArray(converted)) blocks.push(...converted);
else blocks.push(converted);
}
}
}
return blocks;
}
// ── Lists ───────────────────────────────────────────────────────────────────
function convertList(
node: ContentfulNode,
listItem: "bullet" | "number",
includes: ContentfulIncludes,
options: ConvertOptions,
generateKey: () => string,
level: number = 1,
): OutputBlock[] {
const content = "content" in node ? (node.content as ContentfulNode[]) : [];
const blocks: OutputBlock[] = [];
for (const item of content) {
if (item.nodeType !== BLOCKS.LIST_ITEM) continue;
const itemContent = "content" in item ? (item.content as ContentfulNode[]) : [];
for (const child of itemContent) {
if (child.nodeType === BLOCKS.PARAGRAPH) {
const childContent = "content" in child ? (child.content as ContentfulNode[]) : [];
const { children, markDefs } = convertInlineContent(
childContent,
includes,
options,
generateKey,
);
blocks.push({
_type: "block",
_key: generateKey(),
style: "normal",
listItem,
level,
children,
...(markDefs.length > 0 ? { markDefs } : {}),
});
} else if (child.nodeType === BLOCKS.UL_LIST || child.nodeType === BLOCKS.OL_LIST) {
const nestedType = child.nodeType === BLOCKS.UL_LIST ? "bullet" : "number";
blocks.push(...convertList(child, nestedType, includes, options, generateKey, level + 1));
} else {
// List items can contain embedded entries, blockquotes, etc.
const converted = convertNode(child, includes, options, generateKey);
if (converted) {
if (Array.isArray(converted)) blocks.push(...converted);
else blocks.push(converted);
}
}
}
}
return blocks;
}
// ── Table ───────────────────────────────────────────────────────────────────
function convertTable(
node: ContentfulNode,
_includes: ContentfulIncludes,
_options: ConvertOptions,
generateKey: () => string,
): OutputBlock {
const content = "content" in node ? (node.content as ContentfulNode[]) : [];
const rows: Array<{ _type: string; _key: string; cells: string[] }> = [];
for (const row of content) {
if (row.nodeType !== BLOCKS.TABLE_ROW) continue;
const rowContent = "content" in row ? (row.content as ContentfulNode[]) : [];
const cells: string[] = [];
for (const cell of rowContent) {
const cellContent = "content" in cell ? (cell.content as ContentfulNode[]) : [];
const text = cellContent
.flatMap((p) => {
const pContent = "content" in p ? (p.content as ContentfulNode[]) : [];
return pContent.map(extractText);
})
.join("");
cells.push(text);
}
rows.push({ _type: "tableRow", _key: generateKey(), cells });
}
return { _type: "table", _key: generateKey(), rows };
}
// ── Embedded entry ──────────────────────────────────────────────────────────
function convertEmbeddedEntry(
node: ContentfulNode,
includes: ContentfulIncludes,
generateKey: () => string,
): OutputBlock | null {
const targetId = (node.data?.target as { sys?: { id?: string } })?.sys?.id;
if (!targetId) return null;
const entry = includes.entries.get(targetId);
if (!entry) {
console.warn(`[rich-text-to-pt] Unresolved embedded entry: ${targetId}`);
return null;
}
switch (entry.contentType) {
case "blogCodeBlock":
return transformCodeBlock(entry, generateKey());
case "blogEmbeddedHtml":
return transformEmbeddedHtml(entry, generateKey());
case "blogImage":
return transformImageBlock(entry, includes, generateKey());
default:
console.warn(
`[rich-text-to-pt] Unknown embedded entry type: ${entry.contentType} (id: ${entry.id})`,
);
return null;
}
}
// ── Embedded asset (legacy image) ───────────────────────────────────────────
function convertEmbeddedAsset(
node: ContentfulNode,
includes: ContentfulIncludes,
generateKey: () => string,
): OutputBlock | null {
const targetId = (node.data?.target as { sys?: { id?: string } })?.sys?.id;
if (!targetId) return null;
const asset = includes.assets.get(targetId);
if (!asset) {
console.warn(`[rich-text-to-pt] Unresolved embedded asset: ${targetId}`);
return null;
}
return {
_type: "image",
_key: generateKey(),
asset: {
src: asset.url.startsWith("//") ? `https:${asset.url}` : asset.url,
alt: asset.description ?? asset.title ?? "",
width: asset.width,
height: asset.height,
},
};
}
// ── Inline content (spans + marks + links) ──────────────────────────────────
function convertInlineContent(
nodes: ContentfulNode[],
includes: ContentfulIncludes,
options: ConvertOptions,
generateKey: () => string,
): { children: InlineChild[]; markDefs: PortableTextMarkDefinition[] } {
const children: InlineChild[] = [];
const markDefs: PortableTextMarkDefinition[] = [];
for (const node of nodes) {
if (node.nodeType === "text") {
const marks = ((node as { marks?: Array<{ type: string }> }).marks ?? [])
.map((m) => MARK_MAP[m.type] ?? m.type)
.filter(Boolean);
children.push({
_type: "span",
_key: generateKey(),
text: (node as { value?: string }).value ?? "",
marks,
});
} else if (node.nodeType === INLINES.HYPERLINK) {
const rawUri = (node.data?.uri as string) ?? "";
const href = sanitizeUri(rawUri);
const markKey = generateKey();
const isExternal = isExternalLink(href, options.blogHostname);
markDefs.push({
_key: markKey,
_type: "link",
href,
...(isExternal ? { blank: true } : {}),
});
const linkContent = "content" in node ? (node.content as ContentfulNode[]) : [];
for (const child of linkContent) {
if (child.nodeType === "text") {
const marks = ((child as { marks?: Array<{ type: string }> }).marks ?? [])
.map((m) => MARK_MAP[m.type] ?? m.type)
.filter(Boolean);
children.push({
_type: "span",
_key: generateKey(),
text: (child as { value?: string }).value ?? "",
marks: [...marks, markKey],
});
}
}
} else if (
node.nodeType === INLINES.ENTRY_HYPERLINK ||
node.nodeType === INLINES.ASSET_HYPERLINK
) {
const targetId = (node.data?.target as { sys?: { id?: string } })?.sys?.id;
let href = "#";
if (node.nodeType === INLINES.ENTRY_HYPERLINK && targetId) {
const entry = includes.entries.get(targetId);
if (entry) {
const rawHref = options.entryHrefResolver
? options.entryHrefResolver(entry)
: entry.fields.slug
? `/${entry.fields.slug as string}/`
: "#";
href = sanitizeUri(rawHref);
}
} else if (node.nodeType === INLINES.ASSET_HYPERLINK && targetId) {
const asset = includes.assets.get(targetId);
if (asset?.url) {
const rawUrl = asset.url.startsWith("//") ? `https:${asset.url}` : asset.url;
href = sanitizeUri(rawUrl);
}
}
const markKey = generateKey();
markDefs.push({ _key: markKey, _type: "link", href });
const linkContent = "content" in node ? (node.content as ContentfulNode[]) : [];
for (const child of linkContent) {
if (child.nodeType === "text") {
const marks = ((child as { marks?: Array<{ type: string }> }).marks ?? [])
.map((m) => MARK_MAP[m.type] ?? m.type)
.filter(Boolean);
children.push({
_type: "span",
_key: generateKey(),
text: (child as { value?: string }).value ?? "",
marks: [...marks, markKey],
});
}
}
} else if (
node.nodeType === INLINES.EMBEDDED_ENTRY ||
node.nodeType === INLINES.EMBEDDED_RESOURCE
) {
const targetId = (node.data?.target as { sys?: { id?: string } })?.sys?.id;
console.warn(
`[rich-text-to-pt] Inline ${node.nodeType} encountered (target: ${targetId ?? "unknown"}). ` +
`Preserved as custom inline block — consumer should handle or strip.`,
);
children.push({
_type: node.nodeType === INLINES.EMBEDDED_ENTRY ? "inlineEntry" : "inlineResource",
_key: generateKey(),
referenceId: targetId ?? "",
});
} else if (node.nodeType === INLINES.RESOURCE_HYPERLINK) {
// Can't resolve the href, but preserve the visible text
console.warn(
`[rich-text-to-pt] Resource hyperlink encountered — link dropped, text preserved.`,
);
const linkContent = "content" in node ? (node.content as ContentfulNode[]) : [];
for (const child of linkContent) {
if (child.nodeType === "text") {
const marks = ((child as { marks?: Array<{ type: string }> }).marks ?? [])
.map((m) => MARK_MAP[m.type] ?? m.type)
.filter(Boolean);
children.push({
_type: "span",
_key: generateKey(),
text: (child as { value?: string }).value ?? "",
marks,
});
}
}
}
}
// Ensure at least one child (PT requires non-empty children array)
if (children.length === 0) {
children.push({
_type: "span",
_key: generateKey(),
text: "",
marks: [],
});
}
return { children, markDefs };
}
// ── Link classification ─────────────────────────────────────────────────────
function isExternalLink(uri: string, blogHostname?: string): boolean {
if (!uri || uri === "#") return false;
// Defense-in-depth: sanitizeUri already blocks //-prefixed URLs, but
// this check guards against direct callers or future call-order changes.
if (uri.startsWith("//")) return true;
if (!uri.startsWith("http")) return false;
try {
const hostname = new URL(uri).hostname;
if (blogHostname && hostname === blogHostname) return false;
return true;
} catch {
return false;
}
}
/** Recursively extract plain text from a Contentful inline node. */
function extractText(node: ContentfulNode): string {
if ("value" in node && node.value != null) return node.value;
const content = "content" in node ? (node.content as ContentfulNode[]) : [];
return content.map(extractText).join("");
}
// ── Build includes map from raw Contentful response ─────────────────────────
/**
* Build typed includes maps from a raw Contentful API response.
* Call this once per response, pass the result to richTextToPortableText.
*
* Works with both CDA responses (`includes.Entry[]`) and items arrays.
*/
export function buildIncludes(raw: {
Entry?: Array<Record<string, unknown>>;
Asset?: Array<Record<string, unknown>>;
}): ContentfulIncludes {
const entries = new Map<string, import("./types.js").ContentfulEntry>();
const assets = new Map<string, import("./types.js").ContentfulAsset>();
for (const entry of raw.Entry ?? []) {
const sys = entry.sys as { id?: string; contentType?: { sys?: { id?: string } } } | undefined;
if (!sys?.id) continue;
entries.set(sys.id, {
id: sys.id,
contentType: sys.contentType?.sys?.id ?? "unknown",
fields: (entry.fields as Record<string, unknown>) ?? {},
});
}
for (const asset of raw.Asset ?? []) {
const sys = asset.sys as { id?: string } | undefined;
if (!sys?.id) continue;
const fields = asset.fields as Record<string, unknown> | undefined;
const file = fields?.file as
| {
url?: string;
contentType?: string;
details?: { image?: { width?: number; height?: number } };
}
| undefined;
assets.set(sys.id, {
id: sys.id,
title: fields?.title as string | undefined,
description: fields?.description as string | undefined,
url: file?.url ?? "",
width: file?.details?.image?.width,
height: file?.details?.image?.height,
contentType: file?.contentType,
});
}
return { entries, assets };
}

View File

@@ -0,0 +1,13 @@
/**
* URL scheme allowlist matching packages/core/src/utils/url.ts and
* gutenberg-to-portable-text/src/url.ts. Rejects javascript:, data:,
* vbscript:, protocol-relative URLs, and any other unexpected scheme.
*/
const SAFE_URL_SCHEME_RE = /^(https?:|mailto:|tel:|\/(?!\/)|#)/i;
/** Returns the URL unchanged if safe, otherwise "#". */
export function sanitizeUri(uri: string): string {
if (!uri) return "#";
const trimmed = uri.trim();
return SAFE_URL_SCHEME_RE.test(trimmed) ? trimmed : "#";
}

View File

@@ -0,0 +1,30 @@
export interface ContentfulIncludes {
entries: Map<string, ContentfulEntry>;
assets: Map<string, ContentfulAsset>;
}
export interface ContentfulEntry {
id: string;
contentType: string;
fields: Record<string, unknown>;
}
export interface ContentfulAsset {
id: string;
title?: string;
description?: string;
url: string;
width?: number;
height?: number;
contentType?: string;
}
export interface ConvertOptions {
/** Hostname used to distinguish internal vs external links */
blogHostname?: string;
/**
* Custom resolver for entry-hyperlink hrefs. Defaults to `/${slug}/`.
* Override for non-blog URL structures (e.g. `/products/${slug}`).
*/
entryHrefResolver?: (entry: ContentfulEntry) => string;
}

View File

@@ -0,0 +1,123 @@
/**
* Tests for the buildIncludes() utility.
*/
import { describe, it, expect } from "vitest";
import { buildIncludes } from "../src/index.js";
import fixture from "./fixtures/contentful-blogpost.json";
describe("buildIncludes", () => {
it("builds entries Map from includes.Entry[] with id → { id, contentType, fields }", () => {
const includes = buildIncludes({
Entry: fixture.items as Array<Record<string, unknown>>,
});
// Fixture has 13 items total
expect(includes.entries.size).toBe(13);
// Check a specific entry (blogCodeBlock)
const codeBlock = includes.entries.get("code-block-1");
expect(codeBlock).toBeDefined();
expect(codeBlock!.id).toBe("code-block-1");
expect(codeBlock!.contentType).toBe("blogCodeBlock");
expect(codeBlock!.fields).toBeDefined();
expect(typeof codeBlock!.fields.code).toBe("string");
});
it("builds assets Map from includes.Asset[] with id → { id, title, description, url, width, height, contentType }", () => {
const includes = buildIncludes({
Asset: (fixture.includes?.Asset ?? []) as Array<Record<string, unknown>>,
});
expect(includes.assets.size).toBe(1);
const asset = includes.assets.get("asset-1");
expect(asset).toBeDefined();
expect(asset!.id).toBe("asset-1");
expect(asset!.title).toBe("Architecture diagram");
expect(asset!.description).toBe("A diagram showing the migration pipeline architecture");
expect(asset!.url).toBe(
"//images.ctfassets.net/test-space/asset-1/abc123/architecture-diagram.png",
);
expect(asset!.width).toBe(1200);
expect(asset!.height).toBe(800);
expect(asset!.contentType).toBe("image/png");
});
it("empty/missing includes → empty Maps (no crash)", () => {
const includes1 = buildIncludes({});
expect(includes1.entries.size).toBe(0);
expect(includes1.assets.size).toBe(0);
const includes2 = buildIncludes({ Entry: [], Asset: [] });
expect(includes2.entries.size).toBe(0);
expect(includes2.assets.size).toBe(0);
});
it("asset file URL and dimensions extracted from fields.file.url and fields.file.details.image", () => {
const includes = buildIncludes({
Asset: [
{
sys: { id: "test-asset" },
fields: {
title: "Test Image",
description: "A test image",
file: {
url: "//images.ctfassets.net/test.png",
contentType: "image/png",
details: {
size: 12345,
image: {
width: 800,
height: 600,
},
},
},
},
},
],
});
const asset = includes.assets.get("test-asset");
expect(asset).toBeDefined();
expect(asset!.url).toBe("//images.ctfassets.net/test.png");
expect(asset!.width).toBe(800);
expect(asset!.height).toBe(600);
expect(asset!.contentType).toBe("image/png");
expect(asset!.title).toBe("Test Image");
expect(asset!.description).toBe("A test image");
});
it("entries without contentType → contentType defaults to 'unknown'", () => {
const includes = buildIncludes({
Entry: [
{
sys: { id: "no-ct" },
fields: { name: "test" },
},
],
});
const entry = includes.entries.get("no-ct");
expect(entry).toBeDefined();
expect(entry!.contentType).toBe("unknown");
});
it("assets without file → url defaults to empty string, dimensions undefined", () => {
const includes = buildIncludes({
Asset: [
{
sys: { id: "no-file-asset" },
fields: { title: "No File" },
},
],
});
const asset = includes.assets.get("no-file-asset");
expect(asset).toBeDefined();
expect(asset!.url).toBe("");
expect(asset!.width).toBeUndefined();
expect(asset!.height).toBeUndefined();
});
});

View File

@@ -0,0 +1,561 @@
{
"sys": { "type": "Array" },
"total": 13,
"skip": 0,
"limit": 100,
"items": [
{
"metadata": { "tags": [], "concepts": [] },
"sys": {
"id": "post-1",
"type": "Entry",
"createdAt": "2025-06-10T12:00:00.000Z",
"updatedAt": "2025-06-15T14:30:00.000Z",
"contentType": { "sys": { "type": "Link", "linkType": "ContentType", "id": "blogPost" } },
"locale": "en-US"
},
"fields": {
"title": "Deep Dive: Testing the Migration Pipeline",
"slug": "migration-deep-dive",
"excerpt": "A comprehensive test post covering every rich text feature the converter needs to handle.",
"content": {
"nodeType": "document",
"data": {},
"content": [
{
"nodeType": "heading-2",
"data": {},
"content": [
{ "nodeType": "text", "value": "Why Migration Matters", "marks": [], "data": {} }
]
},
{
"nodeType": "paragraph",
"data": {},
"content": [
{
"nodeType": "text",
"value": "Building a migration pipeline requires handling ",
"marks": [{ "type": "italic" }],
"data": {}
},
{
"nodeType": "text",
"value": "every content format",
"marks": [{ "type": "italic" }, { "type": "bold" }],
"data": {}
},
{
"nodeType": "text",
"value": " the source system produces. This includes inline marks, ",
"marks": [{ "type": "italic" }],
"data": {}
},
{
"nodeType": "text",
"value": "code snippets",
"marks": [{ "type": "italic" }, { "type": "code" }],
"data": {}
},
{
"nodeType": "text",
"value": ", and more.",
"marks": [{ "type": "italic" }],
"data": {}
}
]
},
{
"nodeType": "paragraph",
"data": {},
"content": [
{ "nodeType": "text", "value": "Read the ", "marks": [], "data": {} },
{
"nodeType": "hyperlink",
"data": { "uri": "https://developers.cloudflare.com/workers/" },
"content": [
{
"nodeType": "text",
"value": "Workers documentation",
"marks": [],
"data": {}
}
]
},
{ "nodeType": "text", "value": " for more context.", "marks": [], "data": {} }
]
},
{
"nodeType": "unordered-list",
"data": {},
"content": [
{
"nodeType": "list-item",
"data": {},
"content": [
{
"nodeType": "paragraph",
"data": {},
"content": [
{
"nodeType": "text",
"value": "Parse the source format",
"marks": [],
"data": {}
}
]
}
]
},
{
"nodeType": "list-item",
"data": {},
"content": [
{
"nodeType": "paragraph",
"data": {},
"content": [
{
"nodeType": "text",
"value": "Transform to target schema",
"marks": [],
"data": {}
}
]
}
]
},
{
"nodeType": "list-item",
"data": {},
"content": [
{
"nodeType": "paragraph",
"data": {},
"content": [
{
"nodeType": "text",
"value": "Validate the output",
"marks": [],
"data": {}
}
]
}
]
}
]
},
{
"nodeType": "ordered-list",
"data": {},
"content": [
{
"nodeType": "list-item",
"data": {},
"content": [
{
"nodeType": "paragraph",
"data": {},
"content": [
{ "nodeType": "text", "value": "Export the data", "marks": [], "data": {} }
]
}
]
},
{
"nodeType": "list-item",
"data": {},
"content": [
{
"nodeType": "paragraph",
"data": {},
"content": [
{
"nodeType": "text",
"value": "Transform to Portable Text",
"marks": [],
"data": {}
}
]
}
]
},
{
"nodeType": "list-item",
"data": {},
"content": [
{
"nodeType": "paragraph",
"data": {},
"content": [
{
"nodeType": "text",
"value": "Ingest into EmDash",
"marks": [],
"data": {}
}
]
}
]
}
]
},
{
"nodeType": "blockquote",
"data": {},
"content": [
{
"nodeType": "paragraph",
"data": {},
"content": [
{
"nodeType": "text",
"value": "The best migration is the one you don't notice happened.",
"marks": [],
"data": {}
}
]
}
]
},
{
"nodeType": "embedded-entry-block",
"data": {
"target": { "sys": { "id": "code-block-1", "type": "Link", "linkType": "Entry" } }
},
"content": []
},
{
"nodeType": "embedded-entry-block",
"data": {
"target": { "sys": { "id": "html-block-1", "type": "Link", "linkType": "Entry" } }
},
"content": []
},
{
"nodeType": "paragraph",
"data": {},
"content": [{ "nodeType": "text", "value": "", "marks": [], "data": {} }]
}
]
},
"featured": false,
"tags": [
{ "sys": { "type": "Link", "linkType": "Entry", "id": "tag-1" } },
{ "sys": { "type": "Link", "linkType": "Entry", "id": "tag-2" } }
],
"author": [{ "sys": { "type": "Link", "linkType": "Entry", "id": "author-1" } }],
"publishDate": "2025-06-15T00:00+01:00",
"localeList": { "sys": { "type": "Link", "linkType": "Entry", "id": "locale-list-1" } }
}
},
{
"metadata": { "tags": [], "concepts": [] },
"sys": {
"id": "post-2",
"type": "Entry",
"createdAt": "2025-07-01T10:00:00.000Z",
"updatedAt": "2025-07-05T16:00:00.000Z",
"contentType": { "sys": { "type": "Link", "linkType": "ContentType", "id": "blogPost" } },
"locale": "en-US"
},
"fields": {
"title": "Working With Embedded Content",
"slug": "embedded-content",
"excerpt": "A second test post demonstrating embedded entries, embedded assets, and all block types.",
"content": {
"nodeType": "document",
"data": {},
"content": [
{
"nodeType": "paragraph",
"data": {},
"content": [
{
"nodeType": "text",
"value": "Contentful supports embedding entries and assets directly into rich text fields. This post exercises every embedded block type the converter handles.",
"marks": [],
"data": {}
}
]
},
{
"nodeType": "embedded-entry-block",
"data": {
"target": { "sys": { "id": "html-block-2", "type": "Link", "linkType": "Entry" } }
},
"content": []
},
{
"nodeType": "paragraph",
"data": {},
"content": [
{
"nodeType": "text",
"value": "Here is an image embedded via a ",
"marks": [],
"data": {}
},
{
"nodeType": "text",
"value": "blogImage",
"marks": [{ "type": "code" }],
"data": {}
},
{ "nodeType": "text", "value": " entry:", "marks": [], "data": {} }
]
},
{
"nodeType": "embedded-entry-block",
"data": {
"target": { "sys": { "id": "image-block-1", "type": "Link", "linkType": "Entry" } }
},
"content": []
},
{
"nodeType": "embedded-entry-block",
"data": {
"target": { "sys": { "id": "code-block-2", "type": "Link", "linkType": "Entry" } }
},
"content": []
},
{
"nodeType": "paragraph",
"data": {},
"content": [
{
"nodeType": "text",
"value": "And here is a legacy embedded asset (an image inserted directly rather than through a blogImage entry):",
"marks": [],
"data": {}
}
]
},
{
"nodeType": "embedded-asset-block",
"data": {
"target": { "sys": { "id": "asset-1", "type": "Link", "linkType": "Asset" } }
},
"content": []
},
{
"nodeType": "paragraph",
"data": {},
"content": [
{
"nodeType": "text",
"value": "That covers every embedded block type the converter needs to handle.",
"marks": [],
"data": {}
}
]
}
]
},
"featureImage": { "sys": { "type": "Link", "linkType": "Asset", "id": "asset-1" } },
"featured": true,
"tags": [{ "sys": { "type": "Link", "linkType": "Entry", "id": "tag-3" } }],
"author": [{ "sys": { "type": "Link", "linkType": "Entry", "id": "author-2" } }],
"publishDate": "2025-07-01T00:00+01:00"
}
},
{
"metadata": { "tags": [], "concepts": [] },
"sys": {
"id": "tag-1",
"type": "Entry",
"createdAt": "2025-06-01T10:00:00.000Z",
"updatedAt": "2025-06-01T10:00:00.000Z",
"contentType": { "sys": { "type": "Link", "linkType": "ContentType", "id": "blogTag" } },
"locale": "en-US"
},
"fields": { "name": "Engineering", "slug": "engineering" }
},
{
"metadata": { "tags": [], "concepts": [] },
"sys": {
"id": "tag-2",
"type": "Entry",
"createdAt": "2025-06-01T10:01:00.000Z",
"updatedAt": "2025-06-01T10:01:00.000Z",
"contentType": { "sys": { "type": "Link", "linkType": "ContentType", "id": "blogTag" } },
"locale": "en-US"
},
"fields": { "name": "Performance", "slug": "performance" }
},
{
"metadata": { "tags": [], "concepts": [] },
"sys": {
"id": "tag-3",
"type": "Entry",
"createdAt": "2025-06-01T10:02:00.000Z",
"updatedAt": "2025-06-01T10:02:00.000Z",
"contentType": { "sys": { "type": "Link", "linkType": "ContentType", "id": "blogTag" } },
"locale": "en-US"
},
"fields": { "name": "Migration", "slug": "migration" }
},
{
"metadata": { "tags": [], "concepts": [] },
"sys": {
"id": "author-1",
"type": "Entry",
"createdAt": "2025-05-01T09:00:00.000Z",
"updatedAt": "2025-05-01T09:00:00.000Z",
"contentType": { "sys": { "type": "Link", "linkType": "ContentType", "id": "blogAuthor" } },
"locale": "en-US"
},
"fields": {
"name": "Jane Engineer",
"slug": "jane-engineer",
"bio": "Writes about systems and infrastructure.",
"jobTitle": "Staff Engineer"
}
},
{
"metadata": { "tags": [], "concepts": [] },
"sys": {
"id": "author-2",
"type": "Entry",
"createdAt": "2025-05-01T09:01:00.000Z",
"updatedAt": "2025-05-01T09:01:00.000Z",
"contentType": { "sys": { "type": "Link", "linkType": "ContentType", "id": "blogAuthor" } },
"locale": "en-US"
},
"fields": {
"name": "Alex Content",
"slug": "alex-content",
"bio": "Focuses on CMS architecture and content modelling.",
"jobTitle": "Senior Developer"
}
},
{
"metadata": { "tags": [], "concepts": [] },
"sys": {
"id": "code-block-1",
"type": "Entry",
"createdAt": "2025-06-10T12:01:00.000Z",
"updatedAt": "2025-06-10T12:01:00.000Z",
"contentType": {
"sys": { "type": "Link", "linkType": "ContentType", "id": "blogCodeBlock" }
},
"locale": "en-US"
},
"fields": {
"code": "async function migrate(posts) {\n for (const post of posts) {\n await transform(post);\n await ingest(post);\n }\n}",
"language": "typescript"
}
},
{
"metadata": { "tags": [], "concepts": [] },
"sys": {
"id": "code-block-2",
"type": "Entry",
"createdAt": "2025-07-01T10:01:00.000Z",
"updatedAt": "2025-07-01T10:01:00.000Z",
"contentType": {
"sys": { "type": "Link", "linkType": "ContentType", "id": "blogCodeBlock" }
},
"locale": "en-US"
},
"fields": {
"code": "const includes = buildIncludes(response.includes);\nconst blocks = richTextToPortableText(doc, includes);"
}
},
{
"metadata": { "tags": [], "concepts": [] },
"sys": {
"id": "html-block-1",
"type": "Entry",
"createdAt": "2025-06-10T12:02:00.000Z",
"updatedAt": "2025-06-10T12:02:00.000Z",
"contentType": {
"sys": { "type": "Link", "linkType": "ContentType", "id": "blogEmbeddedHtml" }
},
"locale": "en-US"
},
"fields": {
"customHtml": "<div style=\"padding:1.5rem;background:#fff3cd;border:1px solid #ffc107;border-radius:8px\">\n <strong>Note:</strong> This section was auto-generated from the test fixture.\n <br><br>\n <a href=\"https://example.com\" target=\"_blank\" rel=\"noopener noreferrer\">Learn more &rarr;</a>\n</div>"
}
},
{
"metadata": { "tags": [], "concepts": [] },
"sys": {
"id": "html-block-2",
"type": "Entry",
"createdAt": "2025-07-01T10:02:00.000Z",
"updatedAt": "2025-07-01T10:02:00.000Z",
"contentType": {
"sys": { "type": "Link", "linkType": "ContentType", "id": "blogEmbeddedHtml" }
},
"locale": "en-US"
},
"fields": {
"customHtml": "<aside class=\"callout\"><p>Editor's note: this post was updated to reflect API changes.</p></aside>"
}
},
{
"metadata": { "tags": [], "concepts": [] },
"sys": {
"id": "image-block-1",
"type": "Entry",
"createdAt": "2025-07-01T10:03:00.000Z",
"updatedAt": "2025-07-01T10:03:00.000Z",
"contentType": { "sys": { "type": "Link", "linkType": "ContentType", "id": "blogImage" } },
"locale": "en-US"
},
"fields": {
"assetFile": { "sys": { "type": "Link", "linkType": "Asset", "id": "asset-1" } },
"linkUrl": "https://example.com/full-size",
"size": "Wide"
}
},
{
"metadata": { "tags": [], "concepts": [] },
"sys": {
"id": "locale-list-1",
"type": "Entry",
"createdAt": "2025-06-01T08:00:00.000Z",
"updatedAt": "2025-06-01T08:00:00.000Z",
"contentType": {
"sys": { "type": "Link", "linkType": "ContentType", "id": "configLocaleList" }
},
"locale": "en-US"
},
"fields": {
"name": "Default Locale Config",
"enUs": "Translated for Locale",
"deDe": "No Page for Locale",
"frFr": "Translated for Locale",
"jaJp": "No Page for Locale"
}
}
],
"includes": {
"Asset": [
{
"metadata": { "tags": [], "concepts": [] },
"sys": {
"id": "asset-1",
"type": "Asset",
"createdAt": "2025-05-20T08:00:00.000Z",
"updatedAt": "2025-05-20T08:00:00.000Z",
"locale": "en-US"
},
"fields": {
"title": "Architecture diagram",
"description": "A diagram showing the migration pipeline architecture",
"file": {
"url": "//images.ctfassets.net/test-space/asset-1/abc123/architecture-diagram.png",
"details": {
"size": 150346,
"image": { "width": 1200, "height": 800 }
},
"fileName": "architecture-diagram.png",
"contentType": "image/png"
}
}
}
]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2024",
"module": "preserve",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"skipLibCheck": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noImplicitOverride": true,
"verbatimModuleSyntax": true,
"resolveJsonModule": true
},
"include": ["src/**/*", "test/**/*"],
"exclude": ["node_modules", "dist"]
}