karim 685592362a Squashed 'cms/core/' changes from ac7538f..8f4068d
8f4068d docs: HANDOVER — stage 6 (schema editor) + 9 (kgva on core) done; live cutovers pending
59b9e20 core: schema editor preserves unknown frontmatter (no data loss on save)
f8f4131 core: stage 6 finish — schema-driven editor (admin renders from /api/schema)
dd45602 docs: HANDOVER — stages 6 (partial)/7/8 done, openbureau on core; next = kgva

git-subtree-dir: cms/core
git-subtree-split: 8f4068d109d70d33b6619e039aff56ea3962802f
2026-06-30 18:36:26 +02:00

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

  1. Point CMS_CONFIG at a config module (e.g. /site/cms.config.js).
  2. 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 the dialog plugin (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), so requireAuth and 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 ~12 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.js classify / order / buildPath driven by collections (api/src/collections.js + tests; behaviour 1:1 vs openbureau.config.js)
  • stats.js counters driven by collections (statKey / draftStatKey), dialog counts gated by hasPlugin('dialog')
  • [~] plugin manager built + unit-tested (api/src/plugin-manager.js, api/test/plugin-manager.test.js): loads config.plugins, mounts routes by auth tier, runs boot/publish/preview hooks, merges stats, surfaces migrations. NOT yet wired into index.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 at api/src/plugins/dialog/index.js wraps the existing dialog modules (routes by tier, syncLibrary on boot/publish, forum counters as stats), loaded + structurally tested (api/test/dialog-plugin.test.js). TODO (live-tested): wire it into index.js, capture the Supabase DDL into migrations/001_dialog.sql, move the dialog source under plugins/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 + dialog plugin + its config), behaviour identical — tested on the live stack
  • onboard kgva as the second consumer (auth: 'local', no plugins, DB-less)
S
Description
No description provided
Readme 3.7 MiB
Languages
JavaScript 49.5%
CSS 28.1%
HTML 14.1%
Shell 7.8%
Dockerfile 0.5%