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

@plinto/astro

v0.3.0

Published

Plinto is an admin UI for Astro MDX sites, bundled into the deployed site itself: git-backed content, a visual block editor, and surgical AI translations that carry what changed in one language into the others.

Readme

@plinto/astro

Part of Plinto, an admin UI for Astro MDX sites bundled into the deployed site itself. The repository README says what Plinto is and how the packages fit together; this one covers the package.

A Plinto site is a normal Astro MDX site with a nicer admin.

Everything about the site — routing, i18n, sitemap, redirects, styling — is ordinary Astro config and ordinary site code. Plinto contributes only the editing:

  • Injected /plinto/admin routes — a content admin mounted by the integration, no files copied into your project.
  • A visual block editor (built on Puck) that reads and writes your MDX pages in place. The files it writes are files a person could also have typed — frontmatter, import statements, components — and a page it did not change is written back byte-identical.
  • Browser-side git (isomorphic-git + lightning-fs): the deployed static site can clone its own repository into the browser, let an editor work, and commit and push the result. No server, no database — GitHub is the backend.
  • Translation sync: every language keeps a copy of every sibling's file as of the last time it looked, committed under .obelum/ at the repository root. When a sibling's file differs from the copy, the language is stale, and a Claude agent translates what changed — not the whole page. The documents themselves carry no bookkeeping. Conflicting concurrent edits are merged by an agent that asks the editor plain-language questions when it cannot decide; a conflicting copy under .obelum/ is never merged, only reset to what both sides started from, so the language reads as behind on both edits rather than falsely in sync.

Plinto does no build-time output processing at all. Remove the integration and your site still builds, byte for byte.

How content is laid out

A page is one MDX file in Astro's own pages directory, and Astro renders it — the URL is the file path:

src/pages/sv/index.mdx        ->  /sv/
src/pages/sv/streaming.mdx    ->  /sv/streaming/
src/partials/sv/TopBar.mdx        (no URL — the site's layout places it)
content/staff/sv/havard.mdx       (a collection entry — never routed)

Each page is self-contained: frontmatter naming its layout, then the import statements for the blocks it uses, then the blocks:

---
layout: '@/layouts/Layout.astro'
title: Streaming
---

import { CmFeatureRow } from '@example/blocks/CmFeatureRow';

<CmFeatureRow id="feat-8d1c4e7a" title="Livestreaming">
  Markdown between the tags, as usual.
</CmFeatureRow>

Everything in the file is yours; the translation sync keeps no bookkeeping in it. The editor derives the import block from your block registry, so the file stays valid however it is written.

The synced copies

At the repository root sits one .obelum/ directory, like .git, holding for every language a copy of every language's file as of the last time it was synced, at the file's own repository path:

.obelum/sv/src/pages/en/streaming.mdx     the English page as Swedish last saw it
.obelum/sv/src/pages/sv/streaming.mdx     the Swedish page at its last sync
.obelum/sv/content/news/en/summer-cup.mdx
.obelum/sv/sites/cupmanager/src/pages/en/streaming.mdx   in a monorepo

.obelum/sv/ is Swedish's checkout of the whole repository, as far as it knows. A language is stale when a sibling's real file differs from its copy, and what the translator is shown is that difference. Saving with Edit leaves the copies alone, so siblings go stale; Fix merges the change into their copies, so a correction or deliberately local content is never news; a sync snaps the language's copies to the real files. A language's copy of itself is what a translator is told to preserve when that language is the target, so it moves only when the language is synced, or when a sync round (Obelum's syncAll) has carried an edit to every sibling and the edit is behind everyone. Nothing sits under src/ or the content directories, so the site never sees the copies. Delete .obelum/ and the site is byte-identical; every language then reads as never synced and one full round rebuilds it.

Two lines worth adding to the repository's .gitattributes, so the copies collapse in GitHub diffs and .mdx files keep the line endings the copies compare on:

.obelum/** linguist-generated=true
*.mdx text eol=lf

A site coming from the older frontmatter bookkeeping (rev / synced) migrates in one commit with scripts/migrate-to-obelum-copies.mjs: it strips those lines and writes the first copies, every language synced to every other as the files stand.

Adding plinto to an Astro site

npm install @plinto/astro @astrojs/react react react-dom

Plinto needs Astro 6 or 7, @astrojs/react 5 and React 19 (its peer range). A site on Astro 5 upgrades first — astro, @astrojs/mdx and @astrojs/react together; npm's peer warning is the only other place this is said.

1. astro.config.mjs — declare your languages in Astro's own i18n config (plinto reads it there; routing: 'manual' is required because the injected admin routes live outside the locale tree), and register the integration:

import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import mdx from '@astrojs/mdx';
import plinto from '@plinto/astro/integration';

export default defineConfig({
  i18n: { locales: ['en', 'sv'], defaultLocale: 'en', routing: 'manual' },
  integrations: [
    react(),
    mdx({ smartypants: false }),   // the editor round-trips these files;
                                   // rendered punctuation must match the bytes
    plinto({
      git: { corsProxy: 'https://your-proxy.example.workers.dev' },
      blocksPath: 'src/plinto-blocks.tsx',
    }),
  ],
});

That is the whole required config: a CORS proxy for browser-side git, and the path to your block registry. Content directories (src/pages, src/partials, content, public/media), the git branch, storage names, and language labels all follow conventions and can be overridden only when your site differs. Whether your default locale is URL-prefixed is not a setting either — the filesystem states it: a src/pages/{defaultLocale}/ directory exists exactly when it is.

2. src/middleware.ts — Astro's manual i18n routing insists this file is the site's own; it is a two-line re-export of plinto's passthrough:

export { onRequest } from '@plinto/astro/middleware';

3. src/plinto-blocks.tsx — the block registry. A block is a React component with its editor config attached; the registry is a list of components and nothing else:

import type { BlockConfig, BlockRegistry } from '@plinto/astro/config';
import { richtext } from '@plinto/astro/config';

export const Hero = ({ title, body }: { title: string; body: string }) => (
  <section><h1>{title}</h1><p>{body}</p></section>
);
Hero.config = {
  fields: {
    title: { type: 'text', label: 'Title' },
    body: richtext('Body'),
  },
} satisfies BlockConfig<{ title: string; body: string }>;

export const blocks: BlockRegistry = { Hero };

Field sentinels — richtext(), mediaPicker(), pageLink(), iconPicker(), stringList() (a string[] as one item per line) — declare the plinto-aware field types; everything else is Puck's own field vocabulary. Never declare an id field: Puck owns props.id.

4. tsconfig.json — the @/* alias. The editor writes each page's imports as @/… (whatever relative path the registry used), so a page resolves from any depth; without the alias the first saved page does not build:

{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } }

5. Run it. astro dev and open /plinto/admin. In dev mode the admin reads and writes your real working tree through the dev server and commits with your own git. The deployed static site serves the same admin in browser mode: it clones the repository into the browser (via the CORS proxy, with a GitHub fine-grained PAT) and pushes commits back — pushing to the default branch is publishing, if your CI deploys from it.

Existing pages: an already-MDX site keeps building unchanged from day one. The block editor opens the pages that are made of blocks; a page of loose prose is refused loudly rather than silently rewritten (the editor's data model is blocks, and it never destroys what it cannot represent). Adopt page by page: convert a page to blocks when you want it visually editable, leave it as hand-written MDX when you don't.

A save keeps your formatting. Every part of the page the edit left alone — frontmatter, imports, each block and each child block — is written back as the text it was. Only what changed is written in the editor's own form (sorted imports, @/ paths, object and array props as one-line JSON). So an untouched page saves byte-identical, and a one-word edit is a diff of that one block.

Block ids. Puck tells blocks apart by props.id. A block without one gets a Type-uuid each time the page opens. The id is written to the file only once that block is edited. A block that already uses id as an HTML anchor keeps its value, since Puck only needs ids to be unique. But id belongs to Puck: a new block's anchor will be a Type-uuid.

What you also need, honestly

  • One line of Vite config, because this package ships uncompiled source. There is no build step: the exports map points at .ts, .tsx and .astro files under src/. The .astro routes have to ship that way — Astro compiles them itself — and the rest follows. So your bundler has to be told to transpile this package rather than treat it as ready-made JavaScript:

    vite: {
      ssr: {
        noExternal: ['@plinto/astro', '@plinto/admin', '@plinto/core'],
      },
    }

    Every plinto package, not just the one you installed: they are all source-only. Leave @plinto/astro out and the build fails on the first .tsx it meets. Leave one of the others out and the build passes — Vite's bundler resolves it fine — and astro dev dies on "Cannot find module", because that path goes through Node's own ESM resolution instead.

  • A git CORS proxy. Browsers cannot speak the git smart-HTTP protocol cross-origin; a small Cloudflare Worker forwards it. One to copy, with its deploy steps, is in examples/proxy. Any isomorphic-git-compatible proxy works in its place.

  • Tailwind. The admin UI is styled with Tailwind classes, and — more fundamentally — the editor canvas renders your blocks, so the editor route needs your site's compiled utilities either way. Your Tailwind config must scan the library alongside your own source:

    content: [
      './src/**/*.{astro,js,ts,jsx,tsx,mdx}',
      './node_modules/@plinto/admin/src/**/*.{ts,tsx}',
      './node_modules/@plinto/astro/src/**/*.{astro,ts,tsx}',
    ],

    @plinto/admin is the one that matters — that is where the admin UI is. Missing it does not break loudly: the classes your own site happens to share are still generated, so the admin comes out almost right.

    The admin's own routes import @plinto/astro/styles/globals.css (the Tailwind entry with the admin's design tokens) themselves. Import it from your layout only if public pages use PreviewButton or .plinto-header.

    A site without Tailwind still needs it configured for the admin — tailwindcss@3, postcss, autoprefixer, a postcss.config.mjs, and a tailwind.config.mjs whose content lists the plinto packages above. Not importing globals.css from the layout keeps Tailwind's reset off the public pages.

  • An Anthropic API key per editor, for the translation sync and merge agents. Everything else — editing, saving, publishing — works without one.

    Both keys are held in memory, for the page: the admin passes them along when it moves between its own pages, and nothing else. Leaving for the site itself, a reload, a new tab, or closing the tab is the logout, so a script on one of the site's public pages has nothing to read. The login is a name plus a setup code, shaped so a password manager fills it in. Unpublished work is not lost by logging out — the clone stays in the browser.

The pieces

This package is the Astro adapter, and it is the small one. Everything a person clicks is @plinto/admin; the engine under that is @plinto/core, and under that @obelum/core. A site imports none of them directly.

@plinto/astro          this package
  integration.ts        validates the site, injects routes, resolves config
                        into virtual:plinto-config
  plinto.ts              the composition root: reads that config once and
                        hands @plinto/admin the seven things only an Astro
                        adapter can know
  config.ts             the public config types and field sentinels
  islands/              four wrappers that mount an admin island inside its
                        runtime provider
  routes/               the injected .astro routes — fifteen lines each — and
                        the dev-only working-tree API
  lib/urls.ts           where a document is *served*: Astro's routing
  lib/listing/page-rules.ts   what Astro calls a page file
  lib/ops/index.ts      the published @plinto/astro/lib/ops, for your blocks
  lib/hooks/            what a block may call at build time and in the browser

@plinto/admin          the editing application, React, no
                      generator: the admin, the Puck editor, MDX ⇄ Puck,
                      the listing walks, the media browser

@plinto/core           headless: layout (where a document
                      lives), storage (dev-server HTTP or lightning-fs +
                      isomorphic-git), ops, agents, settings, frontmatter,
                      lfs

@obelum/core           the synced copies: staleness, the translator's
                      brief, the fix fan-out. No I/O.

Three rules the layout serves. Block and UI code go through lib/ops, never the filesystem — that is what makes the same editor work against a dev server and against a repository cloned into the browser. Everything in @plinto/core takes its configuration as an argument, so no module below the adapter can reach for the site's directories or locales. And what an adapter has to supply is seven things — see AdminHost in @plinto/admin — which is the honest measure of how much of plinto is tied to Astro at all. Counting runtime imports, there is no cycle at any level.

Media

Media files are stored as git-LFS pointers; the bytes live in LFS. Uploads are downscaled in the browser before anything is committed — LFS history is immutable, so a 6 MB phone photo must never reach it. In browser mode the bytes wait in IndexedDB until publish uploads them through the LFS batch API.

Development

npm test          # vitest across every package — the sync engine, the MDX
                  # round-trip, the git store and its three-way merge, LFS,
                  # the layout rules
npx tsc --noEmit  # clean in each package, tests included

CI runs both of those plus a build of the playground on each push and pull request. "It builds" is not the bar: the round trip is, and the suite's own worst bug was a fixture that made half of it unreachable.

License

MIT.