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

@pramen/cms-editor

v0.0.74

Published

Visual block/page editor for @pramen/cms — a standalone React SPA that talks to the CMS handlers over HTTP.

Readme

@pramen/cms-editor

A visual block/page editor for @pramen/cms — a React SPA that talks to the CMS handlers over HTTP, served by your own site (see Deploy it). It mutates through the semantic handlers (addBlock/updateBlock/reorderRegion/publishPage/…), so field validation, region allow-lists, and the review/publish gates are all enforced server-side.

What it does

  • Page list + create (pick a content type).
  • Region canvas: each region (from the content type) lists its blocks; a palette adds blocks, filtered by the region's allowedTypes. Reorder (↑/↓) and remove.
  • Schema-driven field forms: one input per FieldDefinition type — text/textarea/richtext/url/number/boolean/select/media (with an upload+pick media picker)/repeater/group (recursively composed). Draft blocks may be incomplete; required fields are enforced at publish, not while editing.
  • Inspector tabs: Settings, SEO (meta/canonical/robots/OG), Workflow (submit → review → approve/reject/publish, role-gated), i18n (translations), Audit trail.
  • Types (/schema): authors the block types and content types everything above is built from — the inverse of the field form, editing a FieldDefinition[] and a content type's regions/page fields/default blocks. A type declared in code (defineBlockType / defineContentType, reconciled by cmsBootstrap) is marked code and shown read-only: the server owns that row and would revert an edit at the next boot.
  • Collections (your own pramen entities, edited with the same field DSL), site furniture (menus, redirects, taxonomies, widget areas) and custom admin pages (Block Kit) — each discovered at runtime, so there is one generic editor and no per-project code.
  • Media library, plus Users (admin-only: invite via magic link, roles, activate/delete) and Settings (self-service email/password) tabs.
  • Real URL routing (@buzola/router, file-based under src/routes/): every view is a deep-linkable URL (/, /media, /users, /settings, /pages/:pageId?tab=seo), the browser Back/Forward buttons work, and a refresh restores the current view. Routing state lives in the URL; block selection stays local (a transient overlay).

Run it

bun run --cwd packages/cms-editor build    # -> dist/editor.js + dist/editor.css
bun run --cwd packages/cms-editor dev      # watch + preview on http://localhost:5175

The build produces exactly two files and no index.html. That is deliberate: a baked shell can only hard-code root-absolute asset paths, which works at the origin root and nowhere else, and its companion config.js was a hand-edited untyped global that failed silently the moment it 404'd.

Deploy it

A host serves it, from a shell it renders. For an Astro site that is one line — see @pramen/cms-astro:

// astro.config.mjs
pramenCms({ backend: { url: "https://cms.example.workers.dev" }, admin: true })

That injects a catch-all route at /__admin, so every view is a real server route on the site's own origin: no dist/ to copy, no SPA-fallback rewrite, and no second hostname for the editor. The site's bundler emits and fingerprints editor.js / editor.css like any other asset, which is what makes serving it under a prefix work.

It does not move the API, though. The editor still calls the CMS at the backend.url the shell declares, so a CMS on its own Worker is still cross-origin and still needs CORS_ORIGINS to allow the site. CORS goes away only when the CMS shares the site's origin.

Everything the bundle needs at boot comes from that shell, and nothing else:

| What | How the shell provides it | Read by | | --- | --- | --- | | Where it is mounted | data-base-path on the mount node | src/mount.ts | | Which Worker + tenant to call | window.PRAMEN_CMS_EDITOR.backend | src/mount.ts | | Wordmark, sign-in URL, nav | the rest of window.PRAMEN_CMS_EDITOR | src/brand.ts, src/app-context.tsx |

The mount prefix is the constant the route was injected at, stamped onto the node by the same code that injected it — so the router cannot be mounted somewhere the server does not serve. Navigation is scoped to it, so a co-hosted editor intercepts only its own URLs (_404.tsx's catch-all matches every same-origin path, which un-scoped would mean a click on the host's own /blog rendering the editor's "Nothing lives here").

To write your own shell (a Worker route, another framework), render: the stylesheet, a <div id="app" data-base-path="…">, an inline script setting window.PRAMEN_CMS_EDITOR, and <script type="module" src="…editor.js"> — in that order. If the deployment has panels, add a <script type="importmap"> ahead of every module script mapping react, react-dom, react/jsx-runtime and react/jsx-dev-runtime at dist/panel-*.js. The dev preview in scripts/build.ts is the smallest complete example.

Configure it

The editor's own options travel in window.PRAMEN_CMS_EDITOR, which the integration writes from typed options (admin: { … }) — there is no file to edit:

admin: {
  brand: { name: "Acme", suffix: "cms" },            // the wordmark — see below
  // signInUrl: "/signin/",                          // ONLY once that page exists — see the warning
  // hidePages: true,                                // collections-only deployments
  // layout: "topbar",                               // horizontal nav instead of the sidebar — see below
  // pageHeader: { variant: "flat", accent: "#73e2b2" },  // dress the screen header — see below
  // extraNav: [{ label: "Curation", href: "/curate", target: "_self" }],
  // panels: ["/admin/curation.js"],                 // your own React screens — see below
}

layout — which chrome the nav wears

Two shapes for the same nav, and the same screens under either.

  • "sidebar" (default) — a left rail: an icon and a label per row, collapsible group headings, a toggle that narrows it to icons. What a dozen-plus destinations needs.
  • "topbar" — the Graphic Standard bar (podoba's Topbar): brand left, tabs right, the account avatar at the end, a hairline under it. For an editor embedded in a product that already wears that bar, or a nav that fits a row.

The bar does not revive the horizontal scroller the rail replaced: the first nav group renders as flat tabs and each later group folds into a dropdown (Pages · Lectures · Media · Site ⌄ · Apps ⌄ · System ⌄ + the avatar). Below md the whole nav moves into a dialog behind a hamburger. The breadcrumb keeps only its detail half, beside the wordmark — the lit tab already names the section.

chrome.ts owns the choice (CHROME_LAYOUT) and the two numbers that follow from it — the chrome's height and the air under it — as CSS custom properties, because every sticky header in the editor (page-header.tsx, the page editor's toolbar and inspector) is positioned against them. chrome-sidebar.tsx and chrome-topbar.tsx are the two components; routes/_layout.tsx derives the nav and hands either one the same ChromeProps.

pageHeader — dressing the screen header

The sticky panel with the <h1> and the primary action, on the editor's own screens. Three tokens, no DOM:

pageHeader: {
  variant: "flat",          // "cover" (default) | "flat" | "bare"
  accent: "#73e2b2",        // the colour the primary action wears
  titleFont: "Inter, system-ui, sans-serif",   // the <h1>, and only the <h1>
}
  • variant"cover" is the seeded Truchet artwork every screen gets by default; "flat" keeps the panel and drops the art; "bare" drops the panel too, so the title and action sit on the page the way a Graphic Standard section header does. The header still sticks and still condenses on scroll in all three.
  • accent — re-points --color-brand-primary inside the header only, so the primary action wears it and nothing else in the app moves. It must be an opaque hex or rgb() literal — not var(), oklch() or a colour with alpha — because the editor parses it to derive two things the host therefore cannot get wrong: the label colour on it (the better of podoba's ink and paper by WCAG contrast) and the hover shade (a dark accent lightens, a light one darkens). An accent no label reads on is still applied, with a console warning naming the ratio.
  • titleFont — a font-family list for the <h1>. The counts, labels and controls around it are the editor's chrome and stay in the design system's type. The editor loads no fonts of its own beyond podoba's, so the family has to be one the browser already has — your shell loads it.

Anything unusable is warned about and falls back to the shipped default; nothing here throws. page-header-style.ts owns the resolution and the colour maths, page-header.tsx renders it.

This replaces reaching into the editor's DOM from a stylesheet. A selector like div.sticky[class*="max-w-[1200px]"] > div.relative.isolate … > div.relative.grid > :not(h1) pins itself to private structure that a release can change with no error anywhere — and it cannot tell Media from a content type, or the panel from the button inside it, which is how a rule meant for "the header's action" turns + Upload into "New + Upload" and puts white text on a mint fill at 1.58:1. If these tokens do not cover your case, open an issue rather than a selector.

Panels — your own React screen inside the chrome

A panel is a component you build and this editor renders, at /apps/<slug>, inside the same sidebar, header and theme as everything else. It is for the screen Block Kit (adminPage()) cannot describe — one that needs local interaction: a control that responds as you type, a row that expands, a dialog, a redirect.

The entry is declared server-side with adminPanel() in app.ts (label, icon, navOrder, roles), so the nav position and the role filter are the same server facts they are for a Block Kit page — a panel you may not open is absent from the listing. This bundle supplies only the component:

import { useState } from "react";

function Curation({ api, basePath, theme, setError }) { /* ordinary React */ }

globalThis.PRAMEN_CMS_EDITOR_RUNTIME.registerPanel({
  slug: "curation",
  contract: 1,          // the panel runtime contract this bundle was BUILT against
  render: Curation,
});

contract is required and is a literal you write. Your bundle is compiled against your React and linked against the editor's, and nothing in the loading path notices if those disagree — so the editor asks which contract you built against and refuses a mismatch, naming the slug and the fix on the panel's own route. It is not readable off the runtime on purpose: that would be this editor checking its own number. PANEL_RUNTIME_CONTRACT in src/panels.ts is the current value and the list of what bumps it.

Build it with react, react-dom and both JSX runtimes external — that is the whole contract:

bun build src/admin/curation.tsx --outfile public/admin/curation.js --minify --target=browser \
  --external react --external react-dom --external react/jsx-runtime --external react/jsx-dev-runtime

The editor publishes its React on globalThis.PRAMEN_CMS_EDITOR_RUNTIME and the shell's import map points those specifiers at dist/panel-*.js, which read it back out. Two copies of React in one page share no hook dispatcher, so a bundled one throws on the panel's first hook.

A panel is handed api (call/resolve, as the signed-in user), basePath (the mount prefix, so your links stay inside it), theme, and setError (the chrome's error banner) — and nothing else. The full guide, including how the URLs are declared, is in docs/cms.md.

extraNav links open in a new tab by default, because the editor's catch-all route matches every same-origin path — a same-tab click would land on the editor's own 404 instead of your tool. Add target: "_self" to ask for a same-tab navigation; it is honoured only where the router provably will not claim the url:

| Link | Editor mounted under a prefix | Editor at the origin root | | --- | --- | --- | | Another origin (https://tools.acme.com/x) | same tab | same tab | | Same origin, outside the mount (/curate) | same tab | new tab | | Same origin, inside the mount | new tab | new tab |

Anything else — a relative href that resolves back inside the mount, a javascript: url, an unparseable one — degrades to a new tab rather than stranding the editor on its 404. A same-tab link runs the unsaved-changes guard first, so it cannot silently discard an edit in progress.

signInUrl must be a page that exists. An unauthenticated load calls it after clearing the stored session, so a path that 404s into this SPA's own catch-all leaves the editor bouncing between the redirect and itself with no session to recover from. Point it at a page you have already deployed. ?setup=1 always forces the built-in screen, for pasting a first-admin JWT.

Each screen's header is a cover panel with generated artwork (src/cover.tsx), derived from the screen's name: a hash seeds a PRNG that lays out a Truchet arc field under a colour wash drawn from a closed list of podoba accents. It exists because six list screens whose only difference is a word at the top read as one screen you keep landing on — and being derived means a new collection gets its own cover with nothing to author or upload. Seeded on the title's stable half, so adding a file does not redraw the picture.

Icons are Phosphor, regular weight, in one place (src/icons.tsx) and aliased to names that say what they mean in this app rather than what they depict — so the family is a decision recorded in one file, and no call site names a vendor. A collection or a Block Kit page can still supply its own (icon: "🎓"), which goes into the rail's icon column verbatim; resolving such a string against Phosphor by name is deliberately not offered, because a by-name lookup needs the whole 3000-icon registry in the bundle to let a deployment name one glyph it can already pass directly.

Set brand when you deploy this for a client. The editor ships as a package an agency installs on someone else's behalf, so the default wordmark — pramen · cms editor, at the head of the sidebar, on the Setup screen and in the browser tab — puts the framework's name where the client's belongs. name replaces it; suffix: null drops the · cms half entirely. A configured brand replaces the whole string, including the word "editor", so nothing English is appended to a client's name. Configure nothing and every surface renders exactly as it did before this option existed.

A malformed brand can never take the editor down: a non-string value is ignored rather than thrown on, and a brand that yields no usable name logs a console warning instead of silently shipping "pramen" to your client.

The shell's <title> is written before the bundle runs, so the app re-applies the configured brand on boot; the server-rendered tag is the pre-hydration fallback.

Settings → About still reports pramen · cms-editor. That row names the software you are running, not the deployment, which is what an About panel is for.

Routes are file-based: src/routes/* is scanned by the Bun plugin at build time, which (re)generates the checked-in src/buzola.gen.ts. After adding or renaming a route, run bun run codegen (the build does it automatically) so tsc sees the new route.

Build it against your own design system

The published dist/editor.js and dist/editor.css are self-contained: podoba's components are compiled into the bundle and its tokens and web font into the stylesheet, at the versions this package pins. That is what makes the drop-in mount work with no build config — and it means a site whose own design system is podoba gets our generation of it, not its own. No runtime option reaches inside a compiled bundle to change that.

So the same build is also an API:

// build-admin.ts — run with bun
import { buildEditor } from "@pramen/cms-editor/build";

await buildEditor({
  outdir: "public/admin",
  // Link podoba and React out of THIS project, so the editor moves when your design system does.
  designSystem: import.meta.dir,
  // The other half: CSS is compiled, not linked, so your tokens can only arrive through a
  // stylesheet in your own tree. Tailwind resolves a bare `@import` from the file that wrote
  // it, which is why compiling ours from here would still pick up our podoba.
  styles: "src/admin/editor.css",
  // And, if you need your design system's own header component rather than a recolour of ours:
  slots: { pageHeader: "src/admin/page-header.tsx" },
});
/* src/admin/editor.css */
@import "@pramen/cms-editor/app.css";  /* the editor's base rules */
@import "./tokens.css";                /* yours, after ours, so yours win */

Then point the mount at what you built, with the directory those six files are served from:

// astro.config.mjs
pramenCms({ admin: { editorAssets: "/admin" } })

All six move together — editor.js, editor.css and the four panel-*.js shims — because a shim re-exports the names of the React that bundle linked. Since this build never sees those paths it cannot fingerprint them either, so cache-busting is yours: emit under a content-hashed directory, or serve them with a short max-age.

designSystem and styles are one decision, not two. Setting only the first links your podoba into the bundle while the stylesheet stays compiled against ours — the editor comes up and the colours are subtly not yours. The build warns when it sees that combination.

Slots are deliberately few. Today there is one, pageHeader, because a slot is a standing promise that a component's props are stable and only a narrow, already-documented contract can carry that. Before reaching for one, check whether something cheaper already does the job:

| You want | Use | |---|---| | the header recoloured, unpanelled, or in your own face | admin: { pageHeader } — runtime config, no build | | the wordmark, the tab title, the nav shape | admin: { brand }, admin: { layout } | | a whole screen of your own | adminPage() or adminPanel() — no build either | | your design system's own header component | slots: { pageHeader } |

A slot that stops resolving is a build error, not a silent fallback: if a release moves the module a slot names, buildEditor throws rather than quietly handing you ours back. That is the difference between this and the alternative it replaces — a stylesheet or a bundler alias written against our internals, which the next release voids with no error anywhere.

Status

Verified end-to-end against a live example server (connect → create/open a page → add blocks in regions → edit fields → save → publish; confirmed the round-trip through the content API). Not yet browser-QA'd against a real production project — that's the integration phase. Rough edges: no HTML5 drag-and-drop yet (reorder is ↑/↓ buttons), no rendered live-preview pane (the canvas is structural; a rendered preview needs the project's block components), and title/slug editing needs a future updatePage handler.