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:
7
packages/contentful-to-portable-text/CHANGELOG.md
Normal file
7
packages/contentful-to-portable-text/CHANGELOG.md
Normal 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.
|
||||
51
packages/contentful-to-portable-text/package.json
Normal file
51
packages/contentful-to-portable-text/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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) ?? "",
|
||||
};
|
||||
}
|
||||
@@ -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) ?? "",
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
595
packages/contentful-to-portable-text/src/index.ts
Normal file
595
packages/contentful-to-portable-text/src/index.ts
Normal 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 };
|
||||
}
|
||||
13
packages/contentful-to-portable-text/src/sanitize.ts
Normal file
13
packages/contentful-to-portable-text/src/sanitize.ts
Normal 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 : "#";
|
||||
}
|
||||
30
packages/contentful-to-portable-text/src/types.ts
Normal file
30
packages/contentful-to-portable-text/src/types.ts
Normal 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;
|
||||
}
|
||||
123
packages/contentful-to-portable-text/test/build-includes.test.ts
Normal file
123
packages/contentful-to-portable-text/test/build-includes.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
561
packages/contentful-to-portable-text/test/fixtures/contentful-blogpost.json
vendored
Normal file
561
packages/contentful-to-portable-text/test/fixtures/contentful-blogpost.json
vendored
Normal 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 →</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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
1150
packages/contentful-to-portable-text/test/rich-text-to-pt.test.ts
Normal file
1150
packages/contentful-to-portable-text/test/rich-text-to-pt.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
22
packages/contentful-to-portable-text/tsconfig.json
Normal file
22
packages/contentful-to-portable-text/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user