npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

content-bridge

v0.1.1

Published

Framework for migrating boatloads of content between CMSs without rebuilding the plumbing every time.

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 Documents

Table of contents

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 -D

Client 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: true halts the run before any writes when validation fails
  • validation.run is wrapped in try/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 undefined

Core 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-shape
  • missing-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 build

For live integration testing:

cd playground
cp .env.example .env
bun run dev      # watch mode
bun run import   # one-shot

The 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: true to inspect failures

License

MIT © Nathan Nye