@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.
Maintainers
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/adminroutes — 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=lfA 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-domPlinto 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
exportsmap points at.ts,.tsxand.astrofiles undersrc/. The.astroroutes 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/astroout and the build fails on the first.tsxit meets. Leave one of the others out and the build passes — Vite's bundler resolves it fine — andastro devdies 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/adminis 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 usePreviewButtonor.plinto-header.A site without Tailwind still needs it configured for the admin —
tailwindcss@3,postcss,autoprefixer, apostcss.config.mjs, and atailwind.config.mjswhosecontentlists the plinto packages above. Not importingglobals.cssfrom 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 includedCI 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.
