git-subtree-dir: cms/core git-subtree-split: 2f52ec7536ee87b4f097d169e1cb0f6a85aebe84
openbureau-core
A generic, schema-driven Hugo CMS engine, extracted from the openbureau site.
A site provides one config (collections + plugins); the core gives it an admin,
content CRUD over content/**/*.md (gray-matter), real Hugo preview/publish,
Supabase auth, uploads and git backup. openbureau and karimgabrielevarano.xyz
both consume it.
How a site uses it
- Point
CMS_CONFIGat a config module (e.g./site/cms.config.js). - Mount the site repo at
SITE_DIR(default/site). Run the container.
SITE_DIR=/site
CMS_CONFIG=/site/cms.config.js
SUPABASE_URL=… SUPABASE_SERVICE_KEY=… JWT_SECRET=… ADMIN_EMAILS=…
Config API
export default {
site: 'name',
admins: ['you@example.com'], // bootstrap admins (env ADMIN_EMAILS still wins)
auth: 'supabase', // 'supabase' (GoTrue) | 'local' (file JWTs, no DB)
plugins: ['dialog'], // optional features (see Plugins)
collections: [
{
kind: 'project', // internal id
label: 'Portfolio', // shown in the admin
order: 0, // sort order in the list
path: 'portfolio/:slug', // file pattern under content/ (:params captured)
// index: true, // matches _index.md (a section)
// fallback: true, // matches anything not matched above
// sections: [...], // fixed choices for a :section param
statKey: 'projects', // key in the stats response
fields: [ // editor form, in order
{ name: 'title', type: 'string', required: true },
{ name: 'images', type: 'list', of: { src: 'image', name: 'string' } },
{ name: 'body', type: 'markdown' },
],
},
],
};
path patterns: literal segments must match; :param segments are captured
(the last one is the slug). index: true → _index.md. fallback: true →
everything else. The engine derives content classification, the buildPath, the
list ordering, the stats counters and the admin editor from this.
Field types: string · text · markdown · slug · date · number · bool · select (options) · image · list · list(of:{…}).
Plugins
Everything beyond the generic engine is a plugin. A site opts into plugins via
plugins: [...] in its config; core's plugin manager loads them and wires them in.
The engine stays lean; openbureau = core + its plugins + its config.
A plugin is a module at api/src/plugins/<name>/index.js exporting a manifest:
export default {
name: 'dialog',
routes: [ // mounted under /api
{ path: '/forums', app: forums, public: true }, // skips requireAuth
{ path: '/comments', app: comments }, // auth required
{ path: '/admin/forums', app: adminForums, admin: true }, // admins only
],
onBoot: async (ctx) => {}, // run once at startup (e.g. syncLibrary)
onPublish: async (ctx, { path }) => {}, // hook after a publish build
onPreview: async (ctx, { path }) => {}, // hook after a preview build
migrations: ['001_forums.sql'], // applied to Postgres/Supabase on boot
stats: async (ctx) => ({ forums, threads, comments }), // merged into /api/stats
admin: { /* UI panels the admin SPA mounts for this plugin */ },
};
The plugin manager (core): reads config.plugins, imports each module, mounts
its routes in the right auth tier (public → before requireAuth, the rest after,
admin: true behind requireAdmin), registers its boot/publish/preview/stats
hooks, and runs its migrations. The admin SPA loads UI from
admin/src/plugins/<name> for enabled plugins only.
First plugin: dialog — the comment/forum subsystem (Supabase
forums/threads/comments, library↔thread sync, forum admin UI). openbureau enables
it; kgva doesn't, so none of its routes, hooks, tables or stats load there.
Auth providers
Auth is a config seam (auth: 'supabase' | 'local', default supabase) so a site
can run without a database. The engine already verifies tokens locally — HS256
against JWT_SECRET, no GoTrue roundtrip ([api/src/auth.js]) — so a provider only
has to issue tokens and store users.
supabase(today): GoTrue logs the user in and is the user store; the engine verifies the JWT. Needed when there are multiple editors or thedialogplugin (its forums/threads/comments live in Postgres). This is the heavy path.local: file-based users (bcrypt hashes) + self-signed HS256 JWTs of the same claim shape (sub,email,app_metadata.role,exp), sorequireAuthand roles are unchanged. No GoTrue, no Postgres, no PostgREST. A dialog-less site (e.g. kgva, single admin) then runs as just Node + Hugo + nginx (~80 MB vs the ~1–2 GB Supabase suite). Trade-off: you own password hashing; you lose GoTrue's password-reset / rate-limiting — fine for a single-admin CMS.
What a pluginless core needs from Supabase is only the auth half anyway: content
is Markdown on disk, uploads are local FS (static/images, via sharp), and
PostgREST/tables are a dialog-plugin need. local drops that half too.
Architecture
api/— Node engine (Hono):auth(Supabase JWT),files(gray-matter CRUD),hugo(coalesced build / preview / publish + git),routes/*,ratelimit,config(this loader).admin/— React/Vite SPA; renders list + editor from the config schema.examples/—openbureau.config.js,kgva.config.js(the two consumers).
Status / migration stages
This repo currently holds the engine as extracted plus the config API and the two target configs. Generalisation is staged because the api and admin share a data contract and openbureau is in production — they must change together and be tested on the live Supabase/Hugo stack, not flipped blind.
- Repo, engine, config loader, example configs (openbureau + kgva)
files.jsclassify / order / buildPath driven bycollections(api/src/collections.js+ tests; behaviour 1:1 vs openbureau.config.js)stats.jscounters driven bycollections(statKey/draftStatKey), dialog counts gated byhasPlugin('dialog')- [~] plugin manager built + unit-tested (
api/src/plugin-manager.js,api/test/plugin-manager.test.js): loadsconfig.plugins, mounts routes by auth tier, runs boot/publish/preview hooks, merges stats, surfaces migrations. NOT yet wired intoindex.js(lands with stage 5, needs the live stack); admin plugin-UI loading still TODO - [~] extract openbureau's extras into plugins — first
dialog: manifest scaffold atapi/src/plugins/dialog/index.jswraps the existing dialog modules (routes by tier,syncLibraryon boot/publish, forum counters asstats), loaded + structurally tested (api/test/dialog-plugin.test.js). TODO (live-tested): wire it intoindex.js, capture the Supabase DDL intomigrations/001_dialog.sql, move the dialog source underplugins/dialog/ index.js/publish.js: no hard-coded dialog — everything via the manager- admin: editor + sidebar groups rendered from the config schema
- auth provider (
supabase|local):local= file users + self-signed HS256 JWTs (same claim shape), no DB — lets a dialog-less core run Supabase-free - cut openbureau over to consume core (= core +
dialogplugin + its config), behaviour identical — tested on the live stack - onboard kgva as the second consumer (
auth: 'local', no plugins, DB-less)