content-bridge
v0.1.1
Published
Framework for migrating boatloads of content between CMSs without rebuilding the plumbing every time.
Maintainers
Readme
Content Bridge
A toolkit for moving bulk content between CMSs so you don't rebuild the plumbing every time.
What you don't have to rebuild:
- Reference lookups
- Asset deduplication
- Rate limiting
- Validation logging
- Import ordering
- Batch writes
// Import a few thousand WordPress posts into Sanity — references, assets, and Portable Text included, done.
const articles = transformDocuments(posts, (post) => ({
_type: "article",
title: post.post_title,
image: resolveAsset(post.thumbnail_url),
content: createPortableTextFromHtml(post.post_content, { schema }),
author: resolveReference({
docType: "author",
field: "name",
value: post.author_name,
}),
}));
await new ContentBridge({
adapter: createSanityAdapter(client),
contentTypes: {
article: { transform: articles, mode: "create" },
},
}).run();Under the hood
Source Records
↓
transformDocuments()
↓
┌─────────────────────┐
│ resolveReference() │
│ resolveAsset() │
│ whenValue() │
└─────────────────────┘
↓
Validation
↓
Write DocumentsTable of contents
- Features
- Installation
- Quick start
- Common migration patterns
- Migration analyzers
- Adapters
- Running an import
- Import observability
- Validation hooks
- Core pipeline
- Sanity
- WordPress Utilities
- Development
- Troubleshooting
- License
Features
- Import related content types in a single run
- Resolve references without N+1 lookups
- Upload each asset once, reuse everywhere
- Convert HTML to Portable Text
- Validate content before writes
- Analyze source content before importing
- Swap CMS implementations via adapters
Installation
bun add content-bridge -D
pnpm add content-bridge -D
npm install content-bridge -DClient libraries, e.g. @sanity/client need to be installed separately depending on the adapter you're using.
Quick start
import { createClient } from "@sanity/client";
import {
ContentBridge,
resolveAsset,
resolveReference,
transformDocuments,
} from "content-bridge";
import { createPortableTextFromHtml, createSanityAdapter } from "content-bridge/sanity";
const articles = [
{
Title: "Hello world",
Author: "Jane Doe",
Body: "<p>Some HTML content</p>",
HeroImage: "./images/hero.jpg",
},
];
const articleTransform = transformDocuments(articles, (doc) => ({
_type: "article",
title: doc.Title,
author: resolveReference({
docType: "author",
field: "name",
value: doc.Author,
}),
image: resolveAsset(doc.HeroImage),
content: createPortableTextFromHtml(doc.Body, {
schema: {
name: "content",
type: "array",
of: [{ type: "block" }],
},
}),
}));
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: process.env.SANITY_DATASET!,
token: process.env.SANITY_TOKEN!,
apiVersion: "2026-01-08",
useCdn: false,
});
await new ContentBridge({
adapter: createSanityAdapter(client),
printOutput: true,
contentTypes: {
article: {
transform: articleTransform,
mode: "create",
purgeType: false,
dryRun: false,
},
},
}).run();Purging When multiple types are purged, they are deleted in reverse declaration order so that referencing types are removed before the types they point to, avoiding dangling-reference failures during purge.
Common migration patterns
Multi-type imports
Multiple types are processed in order of declaration by default. This means author documents are written before article references are resolved. Articles can then reference authors without running separate imports.
const authorTransform = transformDocuments(authors, (a) => ({
_type: "author",
name: a.name,
}));
const articleTransform = transformDocuments(articles, (a) => ({
_type: "article",
title: a.title,
author: resolveReference({ docType: "author", field: "name", value: a.authorName }),
}));
await new ContentBridge({
adapter,
contentTypes: {
author: { transform: authorTransform, mode: "create" },
article: { transform: articleTransform, mode: "create" },
},
}).run();WordPress to Portable Text
WordPress HTML has usually survived years of plugins, editors, embeds, and "temporary" fixes. processHtml can help transform it before trying to upload.
import { removeWpComments, unwrapWpBlockColumns } from "content-bridge/wordpress"
const cleaned = processHtml(wpHtml)
.use(removeWpComments)
.use(unwrapWpBlockColumns)
.result();
const content = createPortableTextFromHtml(cleaned, { schema });Migration analyzers
Before importing thousands of records, it's nice to know how many unique values, shortcodes, or HTML tags exist in the content. Finding random <i> tags halfway through a migration is nobody's friend. content-bridge/analyze contains a few helpers for this.
| Utility | Purpose |
|---|---|
| runHtmlCoverageReport | Reports HTML tags seen in your source content and whether they are covered by your blockRules or default Portable Text handlers. |
| runShortcodeCoverageReport | Reports shortcodes seen in source content and whether they are covered by your shortcode map. |
| getUniqueValues | Returns unique primitive values for one field across records. |
import {
getUniqueValues,
runHtmlCoverageReport,
runShortcodeCoverageReport,
} from "content-bridge/analyze";
runHtmlCoverageReport({
html: record.Body,
blockRules,
schema,
ignore: ["meta"],
});
runShortcodeCoverageReport({
html: record.Body,
shortcodes: { caption: /\[caption[^\]]*\][\s\S]*?\[\/caption\]/gm },
ignore: ["gallery"],
});
const records = [{ Author: "Jane Doe" }, { Author: "John Smith" }];
const authors = getUniqueValues(records, "Author");
// → ["Jane Doe", "John Smith"]Adapters
Current Adapters
- Sanity ✅
- Contentful (planned)
- Shopify (planned)
The pipeline talks to your CMS through an ImportAdapter. Want to roll your own? Implement ImportAdapter (and then make a PR 🙃):
import type { ImportAdapter } from "content-bridge";
export const createMyCmsAdapter = (/* your client */): ImportAdapter => ({
idField: "id",
validateDocument(doc, mode) {
// Optional: return { ok: false, reason: "..." } to skip document
return { ok: true };
},
fetchDocumentsByField(type, field) { /* ... */ },
formatReference(id) { /* ... */ },
uploadAsset(pathOrUrl, options) { /* ... */ },
writeDocuments(documents, mode, dryRun, onBatchComplete) { /* ... */ },
purge(types) { /* ... */ },
countDocuments?(type) { /* ... */ },
});idField is required and tells the core pipeline where adapter document identity lives.
validateDocument is optional and can enforce adapter-specific requirements (for example, mandatory type/id fields).
Running an import
Runs the full import pipeline.
import { ContentBridge } from "content-bridge";
await new ContentBridge({
adapter,
printOutput: true, // write JSON snapshots to import-print/{typename}.json
contentTypes: {
article: {
transform: myTransformer,
mode: "create", // create | createIfNotExists | createOrReplace | updateExistingOnly
purgeType: false, // delete existing docs of this type before import
dryRun: false, // validate without writing
validation: {
run: (doc) => ({ ok: true }), // optional user-defined validation callback
strict: false, // halt before writes if any doc fails
},
},
},
}).run();Mutation modes and required fields
Required document fields are adapter-defined. The table below describes write behavior only.
| Mode | Behavior | Adapter id required? |
|---|---|---|
| create | Always creates new documents | No |
| createIfNotExists | Uses adapter-specific create-if-missing behavior when id is present; otherwise falls back to create | No |
| createOrReplace | Uses adapter-specific replace/upsert behavior when id is present; otherwise falls back to create | No |
| updateExistingOnly | Skips docs that fail adapter validation for update-only mode | Adapter-defined |
Config options
| Option | Where it is set | Scope | Description |
|---|---|---|---|
| dryRun | contentTypes.<type>.dryRun | Per content type | Runs validation/path resolution without writing documents for that content type. |
| purgeType | contentTypes.<type>.purgeType | Per content type | 🚨 Deletes ALL existing docs of that type before import (when enabled). When 2+ types are purged, deletion runs in reverse declaration order so referencing types are cleared before the types they point to. |
| validation.run | contentTypes.<type>.validation.run | Per content type | Optional callback returning { ok: true } or { ok: false, errors } for per-document checks after resolution and before writes. |
| validation.strict | contentTypes.<type>.validation.strict | Per content type | When true, halts the import before any writes if any document of that content type fails validation.run. |
| printOutput | Top-level importer config | Whole import run | Writes transformed JSON snapshots to import-print/. |
| orderedProcessing | Top-level importer config | Whole import run | Controls per-type ordered execution. Defaults to true when 2+ content types are configured, and false when only one type is configured. Set false to force unordered/batch processing for multi-type imports. |
These are importer config flags, not fields on transformed documents.
Import observability
Import status is reported in phases:
Each import run reports progress in console and writes non-fatal errors into category-specific files:
import-error-asset.log, import-error-reference.log, and import-error-validation.log.
At the end of a run, a console summary shows:
Each import-error-*.log file is organized in run blocks. Every block starts with a summary header:
- run label + timestamp
- total error count
- error counts by category (
asset,reference,validation) - detailed per-error entries with context
What counts as a non-fatal logged error
- Missing asset files and asset upload failures (remote fetch failure or upload exception)
- Unresolved references from field-based lookups
- User validation callback failures (
contentTypes.<type>.validation.run) - Resolver max-depth warnings for nested references
Non-fatal errors are logged and import continues.
When a document is skipped
Skipped-document counts only include whole documents that are not sent to writeDocuments.
Documents are skipped when adapter validation returns { ok: false, reason }.
Skip reasons are adapter-defined strings.
Field-level omissions are not counted as a skipped document:
- unresolved references are omitted from the document
- failed asset uploads are omitted from the document
Those are field-level and stay in the non-fatal error log.
Validation hooks
Validation hooks run after references and assets are resolved, but before documents are written.
Configure per content type:
await new ContentBridge({
adapter,
contentTypes: {
article: {
transform: articleTransform,
mode: "create",
validation: {
run: validateArticle,
strict: true,
},
},
},
}).run();validation.run(doc)must return{ ok: true }or{ ok: false, errors }validation.strict: truehalts the run before any writes when validation failsvalidation.runis wrapped intry/catch; callback exceptions are converted into validation failurachs and loggachd (they do not crash the import process)
If you use Zod:
safeParse()is recommended (returns structured issues)parse()throws on invalid input, but the importer catches that throw and records it as a validation failure entry
Option A: Zod
import { z } from "zod";
import type { UserValidationResult } from "content-bridge";
const ArticleSchema = z.object({
_type: z.literal("article"),
title: z.string().min(1),
});
const validatchrticle = (
doc: Record<string, unknown>,
): UserValidationResult => {
const result = ArticleSchema.safeParse(doc);
if (result.success) {
return { ok: true };
}
return {
ok: false,
errors: result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
};
};Option B: Sanity schema validation
If you already maintain Sanity schema definitions, run those rules in validation.run and map issues to UserValidationResult.
import type { UserValidationResult } from "content-bridge";
// import { yourSanityValidationFn } from "your-sanity-validation-runtime";
const validateArticle = (
doc: Record<string, unknown>,
): UserValidationResult => {
const issues = yourSanityValidationFn(doc);
if (issues.length === 0) {
return { ok: true };
}
return {
ok: false,
errors: issues.map((issue) => ({
field: issue.path?.join("."),
message: issue.message,
})),
};
};Example strict-mode output
Console:
🚨 Document validation failed in 2 documents (strict mode).
🚨 Skipping write of type "article" due to strict: true (2 failed validation).
See import-error-validation.log for detailed field errors.import-error-validation.log:
=== Content import (2026-06-16T23:20:05.469Z) ===
Summary: category=validation count=2 totalErrors=4 categories=[asset=0, reference=2, validation=2]
- [2026-06-16T23:20:08.220Z] Document validation failed
type: article
documentIndex: 0
errors (2):
1. title - Invalid input: expected string, received null
2. author - Invalid input: expected string, received undefinedCore pipeline
Map your source data
Need to turn raw source records into CMS documents?
Use transformDocuments.
import { transformDocuments } from "content-bridge";
const transformer = transformDocuments(sourceRecords, (record) => ({
_type: "article",
title: record.title,
image: resolveAsset(record.heroImagePath),
author: resolveReference({ docType: "author", field: "name", value: record.authorName }),
...whenValue("content", createPortableTextFromHtml(record.body, { schema })),
}));Include _type (and _id for update-only flows) when using the Sanity adapter. resolveReference and resolveAsset are resolved later in the pipeline and auto-omit the field if resolution fails. Use whenValue for synchronous values that may be null.
Want to test a handful of documents before importing 6,000?
.slice()the source array.
Optional fields
Got a field that may be empty? Use whenValue.
import { whenValue } from "content-bridge";
// ✓ Portable Text may be null for empty body
...whenValue("content", createPortableTextFromHtml(body, { schema })),
// ✓ Any other nullable synchronous value
...whenValue("subtitle", record.subtitle ?? null),
// ✗ TypeScript error — resolvers handle their own omission
...whenValue("image", resolveAsset(record.path)),Not for resolveReference or resolveAsset. Those return a resolver marker at transform time and auto-omit the field themselves when resolution fails. Passing a resolver to whenValue is a TypeScript error by design.
References
Need to connect imported documents together?
Use resolveReference.
import { resolveReference } from "content-bridge";
author: resolveReference({
docType: "author",
field: "name",
value: "Jane Doe",
as: "reference", // or "id"
}),Documents of each referenced type are queried once and indexed — 1,000 articles and 50 authors don't mean 1,000 API calls. as: "reference" (default) returns an adapter-formatted reference; as: "id" returns the raw id string. Unresolved lookups omit the field and log to import-error-reference.log.
Assets
Need to upload images or files? Use resolveAsset.
import { resolveAsset } from "content-bridge";
image: resolveAsset("./images/hero.jpg"),
image: resolveAsset("https://example.com/photo.jpg", { filename: "hero.jpg" }),
attachment: resolveAsset("./export.csv", { assetType: "file" }),
image: resolveAsset("./photo.jpg", { extraData: { credit: "Nathan Nye" } }),| Option | Type | Description |
|---|---|---|
| filename | string | Override the filename used when uploading |
| assetType | "image" \| "file" | Force upload type instead of auto-detecting from extension |
| extraData | Record<string, unknown> | Fields to patch onto the asset document after upload |
The same asset is uploaded once and reused across all documents that reference it. Missing files or upload failures omit the field and log to import-error-asset.log.
Clean up HTML
Got messy WordPress-y HTML before converting to Portable Text? Use processHtml.
import { processHtml } from "content-bridge";
import { removeWpComments, unwrapWpBlockColumns } from "content-bridge/wordpress";
const cleaned = processHtml(rawHtml)
.use(removeWpComments)
.use(unwrapWpBlockColumns)
.result();| Method | Description |
|:---|:---|
| collapseExcessiveNewlines() | Reduces 3+ newlines to 2 |
| trimTrailingWhitespace() | Removes trailing whitespace |
| newlinesToBreaks() | Converts \n\n to </p><p> |
| removeAll(regex) | Removes all matches |
| replaceAll(search, replace) | Replaces all matches |
| swapTag(old, new) | Renames HTML tags |
| trim() | Trims leading/trailing whitespace |
| use(fn) | Custom transform |
| tap(fn) | Inspect without modifying |
| result() | Returns the final string |
Sanity
Connect to Sanity
Use createSanityAdapter.
import { createSanityAdapter } from "content-bridge/sanity";
// default values shown
const adapter = createSanityAdapter(client, {
rateLimit: { perSecond: 20 },
write: { requestLimitBytes: 4_000_000 },
assets: { concurrency: 10 },
});Convert HTML to Portable Text
Use createPortableTextFromHtml.
import {
collapseExcessiveEmptyBlocksAfterLists,
createPortableTextFromHtml,
trimTrailingEmptyBlocks,
} from "content-bridge/sanity";
content: createPortableTextFromHtml(html, {
schema: {
name: "content",
type: "array",
of: [{ type: "block" }, { type: "object", name: "pullquote", fields: [...] }],
},
shortcodes: { caption: /\[caption[^\]]*\][\s\S]*?\[\/caption\]/gm },
blockRules: { /* custom HTML tag → block mappers */ },
post: (blocks) =>
collapseExcessiveEmptyBlocksAfterLists(trimTrailingEmptyBlocks(blocks)),
}),Returns null for empty input. Use whenValue or a conditional spread to omit the field. trimTrailingEmptyBlocks and collapseExcessiveEmptyBlocksAfterLists are available as standalone helpers if you need them outside the post hook.
Sanity skip reasons
The built-in Sanity adapter emits these skip reasons from validateDocument:
empty-document-shapemissing-id-update-only
WordPress Utilities
Split delimited fields
Got pipe-separated or otherwise delimited meta values?
Use delimitedFieldToArray.
import { delimitedFieldToArray } from "content-bridge/wordpress";
delimitedFieldToArray("tag one | tag two | tag three");
// → ["tag one", "tag two", "tag three"]| Param | Default | Description |
|---|---|---|
| data | — | Raw delimited string |
| separator | \| | Delimiter to split on |
| trim | true | Trim whitespace from each item |
Strip WordPress markup
Need to remove <!-- wp:... --> block comments or unwrap single-column div wrappers?
Use removeWpComments and unwrapWpBlockColumns with processHtml.
import { removeWpComments, unwrapWpBlockColumns } from "content-bridge/wordpress";
const cleaned = processHtml(rawHtml)
.use(removeWpComments)
.use(unwrapWpBlockColumns)
.result();Handle shortcodes
Need WordPress shortcodes in Portable Text?
Pass a shortcode map directly to createPortableTextFromHtml({ shortcodes }) — it handles wrapping internally.
content: createPortableTextFromHtml(html, {
schema,
shortcodes: { caption: /\[caption[^\]]*\][\s\S]*?\[\/caption\]/gm },
}),Use standalone handleShortcodes only when you need preprocessed HTML for a separate step before conversion. Don't pass the same map to both.
🧠 Behind the scenes, shortcodes are replaced by
<figure data-shortcode="..."/>tags that can be parsed in the Portable Text handler above.
import { handleShortcodes } from "content-bridge/wordpress";
const html = handleShortcodes(
{ caption: /\[caption[^\]]*\][\s\S]*?\[\/caption\]/gm },
rawHtml,
);Development
bun install
bun run buildFor live integration testing:
cd playground
cp .env.example .env
bun run dev # watch mode
bun run import # one-shotThe playground links to library source, so changes in src/ reload without rebuilding.
Troubleshooting
References aren't resolving
- Check that the referenced content type was imported first
- Verify the lookup field exists in the target documents
- Check
import-error-reference.log
Assets aren't uploading
- Verify local file paths are correct
- Verify remote URLs are publicly accessible (WordPress URLs are locked to outside access)
- Check
import-error-asset.log
Documents are being skipped
- Check adapter validation requirements
- Check the reported skip reason
- Run with
printOutput: true - Run an analyze for the unique vaues of a field before importing to check any weird values
Validation is failing
- Check
import-error-validation.log - Run validation without
strict: trueto inspect failures
License
MIT © Nathan Nye
