{"id":50942336,"url":"https://github.com/rmdes/indiekit-endpoint-site-config","last_synced_at":"2026-06-17T16:10:27.366Z","repository":{"id":360477773,"uuid":"1248548161","full_name":"rmdes/indiekit-endpoint-site-config","owner":"rmdes","description":"Site identity, branding, layout, and feature-flag configuration plugin for Indiekit. Provides admin UI + runtime CSS generation with semantic token system (Tier 1 palette / Tier 2 roles / Tier 3 alerts) and APCA contrast validation.","archived":false,"fork":false,"pushed_at":"2026-06-06T13:58:40.000Z","size":207,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-06T15:08:17.174Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/rmdes.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-24T19:36:10.000Z","updated_at":"2026-06-06T13:58:43.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/rmdes/indiekit-endpoint-site-config","commit_stats":null,"previous_names":["rmdes/indiekit-endpoint-site-config"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/rmdes/indiekit-endpoint-site-config","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rmdes%2Findiekit-endpoint-site-config","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rmdes%2Findiekit-endpoint-site-config/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rmdes%2Findiekit-endpoint-site-config/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rmdes%2Findiekit-endpoint-site-config/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/rmdes","download_url":"https://codeload.github.com/rmdes/indiekit-endpoint-site-config/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rmdes%2Findiekit-endpoint-site-config/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34453638,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-17T02:00:05.408Z","response_time":127,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-17T16:10:26.607Z","updated_at":"2026-06-17T16:10:27.350Z","avatar_url":"https://github.com/rmdes.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# @rmdes/indiekit-endpoint-site-config\n\nSite identity, branding, layout, and feature-flag configuration endpoint for [Indiekit](https://getindiekit.com).\n\nProvides an admin UI for configuring a multi-tenant Indiekit deployment from a single canonical theme. Runtime CSS generation lets operators customize colors, typography, and layout without redeploying the theme.\n\n## Status\n\nStable, in production. See `package.json` for version. Core tier plugin in `indiekit-cloudron` (cannot be disabled per-site).\n\n## Features\n\n- **Admin UI** (tabs: identity, branding, homepage, blog, navigation, general)\n  - Identity: name, domain, author, language\n  - Branding: 12-control theming (palette presets, semantic role overrides, mode preference)\n  - Homepage: hero, layout, featured sections from plugins, widget discovery\n  - Blog: post listing config, pagination\n  - Navigation: menu items, site structure\n  - General: publication settings\n- **Runtime CSS generation** — writes `theme.css` and `critical.css` to disk on each save; Eleventy picks them up via `inlineFile` filter on next rebuild\n- **APCA Lc contrast validation** — blocks saves with unreadable color combinations (Lc \u003c 30 hard, \u003c 45 warn)\n- **Version history** — last 10 saves snapshot to MongoDB; one-click revert\n- **Reset per-section + global** — undo any subsection or all branding back to defaults\n- **Live preview iframe** — pending form state previewed before save via query-param-driven endpoint\n- **Mode-aware preview toggle** — preview light or dark mode independently of OS preference\n- **Plugin discovery** — scans registered plugins for `homepageSections`, `homepageWidgets`, `blogPostWidgets`; exposes them via public API for UI composition\n\n## Architecture — 3-Tier Token System\n\n| Tier | What | Examples |\n|------|------|----------|\n| **1. Reference (palette)** | Derived OKLCH-based color scales | `--c-surface-50..950`, `--c-accent-50..950` |\n| **2. Semantic (roles)** | What templates actually USE | `--c-bg`, `--c-fg`, `--c-fg-muted`, `--c-heading`, `--c-link`, `--c-action`, `--c-action-fg`, `--c-surface`, `--c-border`, `--c-focus` |\n| **3. Alert states** | Fixed for accessibility | `--c-success`, `--c-warning`, `--c-danger` (with `-fg` variants) |\n\nTemplates reference Tier 2 utility classes (`text-heading`, `bg-action`, `border-border`, etc.). When the admin saves a role override, only that semantic token changes — every template element bound to that role updates within one Eleventy rebuild cycle.\n\nThis mirrors the established CMS pattern documented by [WordPress theme.json](https://developer.wordpress.org/themes/global-settings-and-styles/), [Material Design 3](https://m3.material.io/styles/color/system/overview), and [W3C Design Tokens Community Group](https://design-tokens.github.io/community-group/format/).\n\n## Installation\n\n```bash\nnpm install @rmdes/indiekit-endpoint-site-config\n```\n\n## Configuration\n\nIn your `indiekit.config.js`:\n\n```js\nimport SiteConfigEndpoint from \"@rmdes/indiekit-endpoint-site-config\";\n\nexport default {\n  plugins: [\n    new SiteConfigEndpoint({\n      mountPath: \"/site-config\",  // default\n    }),\n    // ... other plugins\n  ],\n};\n```\n\n## Storage\n\nThree MongoDB collections:\n\n1. **`siteConfig`** — singleton document `_id: \"primary\"` storing all site identity, branding, navigation, blog config (schema version 3)\n2. **`homepageConfig`** — homepage builder state: hero, layout, sections, widgets (discovered from plugins at init() time)\n3. **`compositions`** — site-builder v4 composition documents (schema version 4), seeded by the dual-running v3 → v4 migration (see [Blocks contract v2](#blocks-contract-v2-phase-2) below)\n\n## Routes\n\n### Admin (Protected by Indiekit session)\n\n| Path | Controller | Purpose |\n|------|-----------|---------|\n| `/site-config` | identity | Site name, domain, author, language |\n| `/site-config/branding` | branding | Palette, semantic tokens, mode preferences, APCA validation |\n| `/site-config/homepage` | homepage | Hero, layout, featured sections (from plugins), widgets |\n| `/site-config/blog` | blog | Post listing config, pagination settings |\n| `/site-config/navigation` | navigation | Menu items, navigation structure |\n| `/site-config/general` | general | General publication settings |\n\n### Public API\n\n| Method | Path | Purpose |\n|--------|------|---------|\n| GET | `/api/preview` | Live preview of current form state (renders theme.css with pending changes) |\n| GET | `/api/sections` | List of available homepage sections (discovered from registered plugins) |\n| GET | `/api/widgets` | List of available homepage widgets (discovered from plugins) |\n| GET | `/api/blog-widgets` | List of available blog post widgets (discovered from plugins) |\n| GET | `/api/homepage.json` | Rendered homepage config (consumed by theme or client-side builds) |\n\nThese endpoints enable the theme's admin UI to offer live previews and dynamic plugin discovery without exposing sensitive config data.\n\n## Blocks contract v2 (Phase 2)\n\nPhase 2 of the site builder introduces a unified **block catalog**: one validated registry of every block a site can place — built-ins, legacy plugin getters, and the new plugin-declared blocks — serialized to disk for the theme.\n\n### Declaring blocks (`get blocks()`)\n\nAny registered plugin can declare blocks via a `blocks` getter:\n\n```js\nexport default class GithubEndpoint {\n  get blocks() {\n    return [{\n      id: \"github-repos\",\n      version: 1,\n      label: \"GitHub Projects\",\n      description: \"Repos and commits\",\n      icon: \"github\",\n      category: \"social\",\n      placement: { regions: [\"sidebar\", \"main\"], surfaces: [\"homepage\", \"collection\"] },\n      multiple: true,\n      schema: { type: \"object\", additionalProperties: false, properties: { /* frozen JSON Schema subset */ } },\n      data: { source: \"api\" },\n    }];\n  }\n}\n```\n\nEach entry passes a strict gate at discovery time: flat kebab-case `id`, integer `version \u003e= 1`, non-empty `label`, `placement.regions` a non-empty subset of `main|sidebar|footer|hero`, optional `placement.surfaces` a subset of `homepage|collection|postType|standalone`, a valid `data` declaration, and a `schema` in the frozen subset below. Invalid entries are skipped with a console warning — discovery never crashes on a bad plugin.\n\n### Frozen JSON Schema subset\n\nBlock config schemas use a deliberately tiny subset of JSON Schema 2020-12. Anything outside the subset is **rejected at registration**, so the admin form generator, save-time validation, and the migrator all share the exact same semantics.\n\n| Allowed | Values |\n|---------|--------|\n| Property types | `string`, `integer`, `number`, `boolean`, `array` (of strings only — `items: { type: \"string\" }` exactly) |\n| Property keywords | `type`, `enum`, `default`, `minimum`, `maximum`, `maxLength`, `title`, `description`, `items`, `x-control`, `x-advanced` |\n| Top-level keywords | `type: \"object\"`, `additionalProperties: false` (**mandatory**), `properties`, `required` |\n| `x-control` | `textarea`, `markdown`, `color`, `post-type-picker` |\n| `x-advanced` | boolean — marks a field for the editor's \"advanced\" disclosure |\n\nGotchas the validator enforces:\n\n- **Defaults are validated against their own constraints** — a `default` that violates its property's `enum`/`minimum`/`maximum`/`maxLength` is rejected at registration.\n- **`required` means explicitly provided** — defaults never satisfy `required`. Declaring both `required` and a `default` on the same property means an empty config can never validate; don't combine them.\n- **Reserved property names** `__proto__`, `constructor`, and `prototype` are rejected (prototype-pollution guard).\n\n### Data sources\n\n| `data.source` | Meaning | Required fields |\n|---------------|---------|-----------------|\n| `file` | Block reads a JSON data file | `data.file` |\n| `collections` | Block reads an Eleventy collection | `data.key` |\n| `config` | Block renders from its config alone | — |\n| `api` | Block data is fetched from a plugin API | — |\n\n### Legacy back-compat\n\nThe three legacy getters (`homepageSections`, `homepageWidgets`, `blogPostWidgets`) keep working unchanged. The scanner synthesizes catalog entries from them, marked `legacy: true` with `version: 0`; legacy entries keep bespoke-template semantics (the theme renders them via their existing partials, never the generic renderers). Per-id precedence, higher wins: built-in \u003c legacy synthesis \u003c plugin `blocks` declaration — a `blocks` entry shadows a same-id legacy or built-in entry.\n\n### block-catalog.json artifact\n\nAfter plugin discovery, the catalog is written **atomically** (tmp file + rename, so the Eleventy watcher never sees a partial file) to:\n\n```\n/app/data/content/_data/block-catalog.json\n```\n\nShape: `{ catalogVersion: 1, generatedAt: \"\u003cISO timestamp\u003e\", blocks: [...] }`, with blocks sorted by id and restricted to a whitelisted public field set. Each block carries `requiresPlugin`: `null` for built-ins (always available) or the registering endpoint's name — from Phase 3 the theme maps this to its `loadedPlugins` gating. The artifact is inert in Phase 2; the theme starts consuming it in Phase 3.\n\n### Dual-running v3 → v4 migration\n\nOn boot, after discovery, v4 composition documents are computed from the v3 `homepageConfig` doc and seeded into the `compositions` collection — the homepage plus the two default sidebar surfaces (`collection:default`, `posttype:default`). The migration is **seed-if-absent**: it never modifies the v3 doc and never overwrites an existing composition, so it is safe on every boot and editor edits survive re-runs. v3 remains the source of truth (the legacy admin UI and `homepage.json` still own it) until the Phase 3 cutover.\n\nDiagnostics:\n\n- **Boot log** — look for `[site-config] v4 migration: seeded=[…] existing=[…] valid=true` (or `no v3 source, skipped`)\n- **`GET /site-config/api/migration-preview`** (authenticated admin API) — recomputes the migration as a dry run on every request and responds `{ docs, report, existing }`; it never writes\n\n### Phase 3: composition artifact + v3-save refresh\n\nPhase 3 publishes the homepage composition to disk — **the theme activation switch**:\n\n```\n/app/data/content/_data/compositions/homepage.json\n```\n\nWhen this file exists, the theme's Tier-0 renders the homepage from the v4 composition path; in its absence the legacy `homepage.json` path keeps rendering. The artifact carries only the published whitelist (`schemaVersion`, `kind`, `target`, `status`, `tree`, `updatedAt`) and is written atomically (tmp + rename). File naming: surface id with colons mapped to dashes (`collection:default` → `collection-default.json`).\n\nTwo writers keep it fresh:\n\n- **Boot** — after the migration step, the stored `compositions` homepage doc (if any) is (re)written to disk, self-healing the artifact on every start (`[site-config] composition artifact written: homepage`).\n- **v3 homepage save** — the v3 admin remains the ONLY editor until Phase 4, so every save (and preset apply) rebuilds the v4 composition from the v3 doc, validates it against the block catalog, **overwrites** the stored composition, and rewrites the artifact. Invalid trees write nothing (never replace a good artifact with a bad one); a refresh failure never fails the v3 save (`[site-config] v4 refresh failed: …`). The doc is fully rebuilt with fresh node ids on each save, so id-keyed client state resets until Phase 4.\n\n\u003e **Phase 4 MUST remove the v3-save refresh hook** when the composition editor becomes the source of truth, else v3 saves clobber editor work.\n\n### Phase 4: Design hub + composition editor\n\nPhase 4 makes the composition editor the homepage source of truth (the v3 homepage tab and its v3-save refresh hook are gone — see the mandate above, now fulfilled).\n\n**Routes** (all under `/site-config/design`, session-protected):\n\n| Method | Path | Purpose |\n|--------|------|---------|\n| GET | `/design` | Design hub — surface cards (homepage active; listing/posttype/pages are visible-but-disabled Phase 6 placeholders) |\n| GET | `/design/homepage` | The two-pane composition editor (zones + structural preview) |\n| POST | `/design/homepage/blocks/add` | Add a block to a zone (catalog/placement/duplicate gated) |\n| POST | `/design/homepage/blocks/:blockId/move-up` · `/move-down` | Reorder within a zone (no-JS path) |\n| POST | `/design/homepage/blocks/:blockId/move-to` | Move to another legal zone |\n| POST | `/design/homepage/blocks/:blockId/move-to-index` | Positional move (drag-end target, JS enhancement) |\n| POST | `/design/homepage/blocks/:blockId/remove` | Remove (immediate, with a 10s undo token in the flash) |\n| POST | `/design/homepage/blocks/restore` | Undo a removal (token strictly re-validated) |\n| POST | `/design/homepage/blocks/:blockId/config` | Save a block's schema-generated config form |\n| POST | `/design/homepage/arrangement` | Stack ↔ sidebar-right (sidebar blocks are appended to main, never dropped) |\n| POST | `/design/homepage/apply-recipe` | Apply a layout preset (replaces the draft, confirm-guarded) |\n| POST | `/design/homepage/publish` | Publish the draft (validated against the catalog; writes the artifact) |\n| POST | `/design/homepage/discard` | Discard the draft |\n| POST | `/design/mode` | Toggle simple/advanced editing mode (per-site) |\n\n**Draft model** — every mutating action saves a **draft** tree (`draftTree` on the `compositions` doc); nothing reaches the published tree or the on-disk artifact until an explicit Publish. Publish validates the candidate against the block catalog (gate, never transform) and writes the artifact atomically; Discard drops the draft. The editor works end-to-end without JavaScript — drag-drop, the add dialog, and flash auto-dismiss are progressive enhancements.\n\n**v3 homepage tab redirect** — `GET /site-config/homepage` now 303-redirects to `/site-config/design/homepage` (old bookmarks land on the editor; the v3 GET/POST/apply-preset handlers are deleted). The blog tab's sidebar zones still edit the v3 `homepageConfig` doc until Phase 6.\n\n**Static assets** — the editor's CSS/JS (`assets/editor.css`, `assets/editor.js`, `assets/preview.css`, vendored `assets/vendor/Sortable.min.js`) are served by the Indiekit frontend's plugin-asset convention at `/assets/@rmdes-indiekit-endpoint-site-config/…` (no CDN dependency; CSP-friendly).\n\n**Phase 6 surfaces** — the hub already lists `listing`, `posttype`, and `pages` as disabled cards; their composition surfaces (and the blog sidebars' cutover off the v3 doc) land in Phase 6.\n\n### Phase 5: True preview + build status\n\nPhase 5 adds a true preview (the draft rendered through the **production** theme renderer, zero drift) and a post-publish build-status surface.\n\n**Routes** (both on the session-protected design router):\n\n| Method | Path | Purpose |\n|--------|------|---------|\n| POST | `/design/homepage/preview` | Write the preview-draft artifact on demand (never per keystroke) |\n| GET | `/design/api/build-status` | Build-status API polled by the publish strip (`Cache-Control: no-store`) |\n\n**Preview-draft artifact** — the Update-preview POST writes `content/_data/compositions/preview-draft.json` (`{schemaVersion: 4, kind: \"preview\", tree, revision, token, generatedAt}`, atomic tmp + rename) from the draft tree (or the published tree when no draft exists). The theme renders it at `/preview/\u003ctoken\u003e/` — an unguessable 16-byte token stored on the `siteConfig` doc. Each preview write bumps a monotonic revision; the editor's preview pane polls the same-origin iframe until the new revision appears. **Publish rotates the token** (previously shared preview URLs expire) and rewrites a fresh preview-draft from the now-published tree — warn-only, a preview refresh failure never masks a successful publish.\n\n**Custom-tree scope (deliberate)** — the preview POST accepts custom (hand-built) trees, since they render through the same production renderer. But the editor view stays read-only for custom trees and offers **no preview pane affordance**; previewing a hand-built tree is its author's out-of-band concern.\n\n**Build-status API** — `GET /design/api/build-status` reads `/app/data/build-status.json` (written by the theme's build hooks as `{state: \"building\"|\"ok\", buildId, startedAt, finishedAt, durationSeconds, incremental, lastOkDurationSeconds}` and by start.sh's crash wrapper as a minimal `{state: \"failed\", error, finishedAt}`) and responds with the raw fields plus a computed `stuck` flag: a `building` state that has overrun `max(2 × lastOkDurationSeconds, 120)` seconds (the 120s floor absorbs full post-boot builds; 60 is the default when the duration is absent). The endpoint is tolerant by contract — an absent or corrupt file responds `{state: \"unknown\"}`, never a 500, and a `building` object missing `startedAt` is never stuck.\n\n**Publish-flow strip** — after a publish the redirect carries the publish epoch (`?published=\u003cms\u003e`, server clock — the same clock that writes `finishedAt`) and the draft bar's \"Live\" row gains a build-status strip. With JS, `editor.js` compares the API's `finishedAt` against the URL epoch (stateless — no storage, reload-safe) and polls every 5s until terminal: `ok` with `finishedAt` after the publish shows \"Live · \u003ctime\u003e\" (terminal); a stale `ok` or stale `failed` from before the publish keeps the \"Rebuilding — usually ~Xs on this site. Your current site stays online.\" copy and polls (the Eleventy watcher debounces ~5s before flipping to `building`, so the first probe after a publish always sees a stale status); `failed` after the publish shows the error excerpt plus a republish hint (terminal — the live site is unchanged); `stuck` explains that publishing again rewrites the homepage artifact (which also heals a missed watcher event). A legacy `?published=1` falls back to rendering the last-known terminal status as-is. Without JS, the strip renders the last-known status server-side with a reload-to-update note (no meta-refresh).\n\n## Theme integration\n\nThe companion Eleventy theme [`indiekit-eleventy-theme`](https://github.com/rmdes/indiekit-eleventy-theme) reads:\n- `/app/data/content/_data/theme.css` (runtime CSS vars, via `inlineFile` filter in a `theme.css.njk` template)\n- `/app/data/content/_data/critical.css` (per-site critical CSS for first paint)\n- `/app/data/content/_data/site-config.json` (structured config for `_data/site.js`)\n\nThe theme's `tailwind.config.js` exposes Tier 2 utility classes (`text-heading`, `bg-action`, `border-border`, etc.) bound to the CSS variables this plugin emits.\n\n## Mode handling\n\nThree states: `light`, `dark`, `auto`. In `auto` mode the plugin emits both `@media (prefers-color-scheme: dark)` AND a `.dark` class block, with the `@media` rule scoped to `:root:not(.light)` so an explicit user override (via JS toggle adding `.light`) wins over OS preference.\n\n## Testing\n\n```bash\nnpm test\n```\n\nRun with Node's test runner. Coverage includes schema validation, storage operations, palette derivation, semantic color resolution, APCA contrast validation, history management, reset functionality, and form parsing.\n\n## Dependencies\n\n- `apca-w3` + `colorparsley` — APCA Lc contrast calculation\n- `culori` — OKLCH palette derivation\n- `@indiekit/error`, `@indiekit/frontend`, `express@^5`\n\n## Plugin Origin\n\n**ORIGINAL plugin** — no upstream `@indiekit/endpoint-site-config` equivalent. This is a custom `@rmdes/*` plugin created as the successor to (and replacement for) an earlier `@indiekit/endpoint-homepage`.\n\n**Registry status:** Core tier in `indiekit-cloudron` — always installed, cannot be disabled per-site.\n\n## Development\n\nThis plugin is developed inside the [Indiekit development workspace](https://github.com/rmdes/indiekit-dev). The design spec lives at `documentation-central/plans/2026-05-24-theming-v2-design.md` in that workspace.\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frmdes%2Findiekit-endpoint-site-config","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Frmdes%2Findiekit-endpoint-site-config","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frmdes%2Findiekit-endpoint-site-config/lists"}