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

@pantheon-systems/p1-media-r2

v0.2.0

Published

Puck media library plugin backed by Cloudflare R2

Downloads

281

Readme

@pantheon-systems/p1-media-r2

A Puck editor plugin that adds a media library, a rich p1-media field type, and render helpers for Puck-based P1 sites. Backed by a Cloudflare Worker + R2 + D1 media store — see the monorepo README for the backend and the API reference for calling the Worker directly.

The plugin has two distinct audiences with different levels of involvement.


For component developers

If you are building a Puck component for a P1 site, you do not need to install or configure the plugin. CCR wires it up automatically when it initialises the Puck editor — workerUrl, siteId, workstreamId, and getAuthToken all come from CCR context.

There are two ways to author an image field:

  • Basic mode — name a text field with a standard pattern (below); the picker activates automatically and stores a plain CDN URL string. Render it with buildImageUrl.
  • Rich mode — declare a p1-media field; it stores a MediaValue object carrying the version URL plus metadata (alt, caption, …). Render it with the getMediaProps / MediaImage / MediaFigure helpers.

Both are supported; basic mode is preserved indefinitely.

Field naming — auto-detected patterns

Name your image fields using any of the following patterns and the media library picker will appear automatically in the Puck sidebar:

  • image, imageUrl
  • logo, logoUrl
  • media, mediaUrl
  • icon, iconUrl
  • thumbnail, thumbnailUrl
  • Any field ending in ImageUrl or LogoUrl

Navigation URL patterns (buttonUrl, linkUrl, ctaUrl) and alt text fields are excluded.

Declaring a rich media field (p1-media)

The quickest integration is the ready-made component the plugin ships:

import { createMediaFigureBlock } from "@pantheon-systems/p1-media-r2";

const config = {
  components: {
    MediaFigureBlock: createMediaFigureBlock({
      mediaBaseUrl: "https://media.p1.pantheon.io",
    }),
  },
};

createMediaFigureBlock options:

| Option | Default | Description | |--------|---------|-------------| | mediaBaseUrl | (required) | CDN image origin used to validate value URLs — NOT the Worker API URL | | transform | { width: 1200, height: 630, format: "auto" } | Render-time transform; keep both dimensions (see crop note below) | | label | "Media Figure" | Component label in the Puck sidebar | | fieldLabel | "Photo" | Label of the media field | | schema | — | MetadataFieldDef[] passed to MediaFigure; pins figcaption field order/labels | | className / captionClassName | — | Forwarded to MediaFigure | | placeholder | "Choose a photo from the media library" | Rendered when no photo is chosen (or its URL fails validation) |

To put the field on your own component instead, declare it directly. p1-media is registered by the plugin at editor runtime, so it is not part of Puck's built-in Field union — cast the declaration:

import type { Field } from "@puckeditor/core";
import { MediaImage, type MediaFieldValue } from "@pantheon-systems/p1-media-r2";

const heroBlock = {
  fields: {
    photo: { type: "p1-media", label: "Photo" } as unknown as Field,
  },
  defaultProps: { photo: null },
  render: ({ photo }: { photo?: MediaFieldValue | null }) => (
    <MediaImage image={photo} mediaBaseUrl={MEDIA_BASE} transform={{ width: 1200, height: 630 }} />
  ),
};

Uploading with metadata

Choosing or dropping files in the library modal stages them in a metadata grid before anything is uploaded: one row per image, one column per schema field (alt, caption, …). Filling it is optional — Upload sends whatever is non-empty as metadata defaults on each asset. Every item's ✎ button opens the same grid to edit an existing asset's metadata (empty cells clear the field via PATCH /media/:assetId).

The grid is keyboard-first: Tab moves through cells, Enter/↓ and ↑ move within a column. Pasting multi-cell TSV or CSV (e.g. copied from a spreadsheet) fills the grid from the focused cell. If the first pasted row is a header naming schema fields (alt, caption, …), columns are mapped by name instead — and with a filename column, rows are matched to the staged files by filename, so a whole exported sheet can be pasted into any cell.

The edit panel also has Replace image…, which uploads a new file as a new immutable version of the same asset (POST /media/:assetId/versions) — placements pinned to older versions keep serving; metadata is untouched.

The library grid itself is keyboard-navigable (roving tab stop: arrow keys move between tiles, Enter/Space selects, E edits, Delete removes) and lazily rendered — tiles are revealed in batches of 60 as you scroll, images load with loading="lazy", and the list fetches up to the Worker's 500-item cap (use search beyond that).

Cropping

Crop intent always lives in the value's URL query — the stored value stays a plain CDN URL (basic mode) or a MediaValue whose url carries the params (rich mode):

| Editor control | URL params | Notes | |----------------|------------|-------| | Fit in | ?fit=scale-down | Default; never crops | | Smart crop | ?fit=cover&gravity=auto | Content-aware crop toward the render transform's aspect ratio | | Custom… (rich field only) | ?trim.left=…&trim.top=…&trim.width=…&trim.height=… | Interactive cropper with fixed (1:1, 4:3, 3:2, 16:9) or free aspect ratios; values are source-image pixels |

The render-time transform composes on top of the crop: a trim region is cut from the source first, then resized to width/height. See the image transformation params in the API reference for everything the /image route accepts.

Rendering — basic mode (string value)

The stored value is a clean CDN URL. Use buildImageUrl to add size, format, and quality at render time; it preserves the editor's crop intent (?fit=…&gravity=…).

import { buildImageUrl } from "@pantheon-systems/p1-media-r2";

<img src={buildImageUrl(data.heroImage, { width: 1200, height: 630, format: "webp" })} />
<img src={buildImageUrl(data.thumbnail, { width: 150, height: 150, format: "webp", quality: 80 })} />

Rendering — rich mode (p1-media value)

The stored value is a MediaValue object. Use the render helpers, which return/apply the alt text and captured dimensions. mediaBaseUrl (the CDN image origin) is required and passed at render — the helpers run in RSC and can't read plugin config, and they fail closed (empty src) for any URL not on that origin. URLs must be https, with one local-dev exception: when the configured base is itself http on a loopback host (http://localhost:8788 — a local wrangler dev worker), same-origin http URLs are allowed so rich values render locally. Production is unaffected (real CDN bases are https).

import { MediaImage, MediaFigure, getMediaProps } from "@pantheon-systems/p1-media-r2";

const MEDIA_BASE = "https://media.p1.pantheon.io";

// <img>-like wrapper: src + alt + width/height come from the value
<MediaImage image={data.heroImage} mediaBaseUrl={MEDIA_BASE} transform={{ width: 1200, height: 630, format: "webp" }} />

// figure + caption/credit/byline composed generically from the metadata schema
<MediaFigure image={data.heroImage} mediaBaseUrl={MEDIA_BASE} transform={{ width: 1200, height: 630, format: "webp" }} />

// or spread the raw props onto your own element / next/image
const { src, alt, width, height } = getMediaProps(data.heroImage, { mediaBaseUrl: MEDIA_BASE, transform: { width: 1200, height: 630 } });

Pass both width and height in the transform. The editor's crop toggle is carried as ?fit=cover&gravity=auto (smart crop) vs ?fit=scale-down (fit in), and fit only changes the output when there is a target aspect ratio to crop against — with a width-only transform both modes produce identical pixels and the crop control appears to do nothing.

All three helpers accept string | MediaValue | null | undefined and render nothing (MediaImage/MediaFigure return null; getMediaProps returns src: "") when the value is empty or its URL fails origin validation — branch on that for a placeholder. Full option surface:

getMediaProps(value, options){ src, alt, width?, height? }

| Option | Description | |--------|-------------| | mediaBaseUrl | CDN image origin; validation fails closed without it | | transform | ImageTransformParams{ width?, height?, format? ("auto" \| "webp" \| "jpeg" \| "png" \| "gif" \| "avif"), quality? }, merged onto the validated URL, preserving the editor's crop params |

Spread the result onto your own element or next/image (with remotePatterns set to the CDN origin — next/image fetches server-side).

<MediaImage /> props

| Prop | Description | |------|-------------| | image | The field value | | mediaBaseUrl / transform | As above | | alt | Overrides the value's alt text | | …anything else | All other <img> attributes (className, loading, sizes, …) pass through; width/height come from the value's captured dimensions |

<MediaFigure /> props

| Prop | Description | |------|-------------| | image | The field value | | mediaBaseUrl / transform | As above | | schema | MetadataFieldDef[] (e.g. the fetched GET /media/schema) — pins figcaption field order and labels. Without it, the value's own string metadata keys render in key order, which can shift across saves | | className / captionClassName | Styling hooks for the <figure> and <figcaption> |

alt renders on the <img>; every other non-empty string metadata field renders as an escaped <span data-field="…"> inside the <figcaption>.

Stored field value formats

Basic mode — a CDN URL (new keys are {siteId}/assets/{assetId}/{versionId}-{filename}; the editor's crop is ?fit=cover&gravity=auto for smart crop, ?fit=scale-down for fit-in; a rich value's url may instead carry ?trim.… from the custom cropper):

https://media.p1.pantheon.io/image/{siteId}/assets/{assetId}/{versionId}-{filename}?fit=cover&gravity=auto

Rich mode — a MediaValue object:

{
  "assetId": "…", "versionId": "…",
  "url": "https://media.p1.pantheon.io/image/{siteId}/assets/{assetId}/{versionId}-{filename}",
  "width": 1600, "height": 900,
  "alt": "A red barn at sunset",
  "metaSchemaVersion": 1
}

Legacy documents may still hold the old {siteId}/{workstreamId}/media/{timestamp}-{filename} URL form; the render helpers accept it (as a string) unchanged.


For site developers enabling the media library

If you are building a P1-powered site and want editors to have a media library in the Puck sidebar, install this package and add the media plugin alongside the standard CCR editor setup. The values required by createMediaPlugin — site ID, workstream ID, and auth token — are all available from CCR context, so no additional configuration is needed beyond wiring them through.

Install

pnpm add @pantheon-systems/p1-media-r2

Integration with puck-css

Pass the media plugin via additionalPlugins in useP1Editor. The hook handles stable plugin merging internally — no manual override wiring needed.

import { useMemo } from "react";
import { Puck } from "@puckeditor/core";
import { createMediaPlugin } from "@pantheon-systems/p1-media-r2";
import { useP1Editor, useP1Auth } from "@pantheon-systems/puck-css";

function Editor({ siteId, workstreamId, documentPath, config }) {
  const { getToken } = useP1Auth();

  const mediaPlugin = useMemo(
    () =>
      createMediaPlugin({
        workerUrl: "https://staging.media.p1.pantheon.io",
        siteId,       // from CCR context
        workstreamId, // from CCR context
        getAuthToken: getToken, // from useP1Auth — always returns the current token
      }),
    [siteId, workstreamId, getToken],
  );

  const { loading, error, puckKey, puckProps } = useP1Editor({
    documentPath,
    puckConfig: config,
    additionalPlugins: [mediaPlugin],
  });

  if (loading) return null;
  if (error) return <div>Error: {error.message}</div>;
  return <Puck key={puckKey} {...puckProps} />;
}

createMediaPlugin options

| Option | Type | Description | |--------|------|-------------| | workerUrl | string | Base URL of the deployed p1-media Worker | | siteId | string | Site UUID — from CCR context | | workstreamId | string | Workstream UUID — from CCR context. Legacy: still sent but the site-scoped worker ignores it | | getAuthToken | () => Promise<string \| null> \| string \| null | Returns the CCR auth bearer token — pass useP1Auth().getToken directly | | fieldNamePatterns | RegExp[] | Override the default field name patterns | | metadataFields | MetadataFieldDef[] | Fallback metadata schema for the p1-media field when GET /media/schema is unavailable (defaults to [{ name: "alt", label: "Alt text", type: "string" }]) |


Exports

import {
  createMediaPlugin,           // Plugin factory (for CCR integration)
  createMediaFigureBlock,      // Ready-made Puck component: p1-media field + MediaFigure render
  buildImageUrl,               // Apply transform params to a CDN URL string (basic mode)
  getMediaProps,               // (value, { mediaBaseUrl, transform }) -> { src, alt, width?, height? }
  MediaImage,                  // <img>-like component for a MediaValue
  MediaFigure,                 // figure + schema-driven caption/credit/byline
  makeMediaValue,              // build a MediaValue (enforces the assetId+versionId invariant)
  isMediaValue,                // narrow string | MediaValue
  DEFAULT_MEDIA_PATTERNS,      // default field-name patterns
} from "@pantheon-systems/p1-media-r2";

import type {
  MediaPluginOptions,          // createMediaPlugin config shape
  MediaFigureBlockOptions,     // createMediaFigureBlock config shape
  MediaFigureBlockProps,       // { photo: MediaFieldValue | null }
  ImageTransformParams,        // { width?, height?, format? (incl. "auto"), quality? }
  MediaValue,                  // rich field value: { assetId, versionId, url, alt?, ... }
  MediaFieldValue,             // string | MediaValue
  MediaProps,                  // getMediaProps return shape
  MetadataFieldDef,            // { name, label, type, required? }
  GetMediaPropsOptions,        // { mediaBaseUrl, transform? }
} from "@pantheon-systems/p1-media-r2";

The server-safe subset (buildImageUrl, getMediaProps, MediaImage, MediaFigure, createMediaFigureBlock, makeMediaValue, isMediaValue, and the types) is also exported from the package's react-server entry for use in RSC. The interactive cropper (react-image-crop) is bundled into the client entry with its styles injected at runtime — consumers install nothing extra.