@rmdes/indiekit-endpoint-site-config 1.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # @rmdes/indiekit-endpoint-site-config
2
+
3
+ Site identity, branding, layout, and feature flag configuration plugin for Indiekit.
4
+
5
+ Documentation lives in `documentation-central/docs/site-config-plugin.md` in the workspace.
6
+
7
+ ## Dependencies
8
+
9
+ - `culori` — used for OKLCH-based palette derivation (see `lib/render/derive-palette.js`, added in a later task).
package/index.js ADDED
@@ -0,0 +1,83 @@
1
+ import express from "express";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { identityRouter } from "./lib/controllers/identity.js";
6
+ import { brandingRouter } from "./lib/controllers/branding.js";
7
+ import { layoutRouter } from "./lib/controllers/layout.js";
8
+ import { featuresRouter } from "./lib/controllers/features.js";
9
+ import { apiRouter } from "./lib/controllers/api.js";
10
+ import { getSiteConfig } from "./lib/storage/get-site-config.js";
11
+ import { writeThemeCss } from "./lib/render/write-theme-css.js";
12
+ import { writeSiteJson } from "./lib/render/write-site-json.js";
13
+
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+
16
+ const defaults = {
17
+ mountPath: "/site-config",
18
+ };
19
+
20
+ export default class SiteConfigEndpoint {
21
+ name = "Site Config endpoint";
22
+
23
+ constructor(options = {}) {
24
+ this.options = { ...defaults, ...options };
25
+ this.mountPath = this.options.mountPath;
26
+ }
27
+
28
+ get localesDirectory() {
29
+ return path.join(__dirname, "locales");
30
+ }
31
+
32
+ get viewsDirectory() {
33
+ return path.join(__dirname, "views");
34
+ }
35
+
36
+ get navigationItems() {
37
+ return {
38
+ href: this.options.mountPath,
39
+ text: "siteConfig.title",
40
+ requiresDatabase: true,
41
+ };
42
+ }
43
+
44
+ get shortcutItems() {
45
+ return {
46
+ url: this.options.mountPath,
47
+ name: "siteConfig.title",
48
+ iconName: "settings",
49
+ requiresDatabase: true,
50
+ };
51
+ }
52
+
53
+ async init(Indiekit) {
54
+ Indiekit.addEndpoint(this);
55
+ this._apiRouter = apiRouter(Indiekit);
56
+
57
+ const protectedRouter = express.Router();
58
+ protectedRouter.get("/", (req, res) => res.redirect(`${this.mountPath}/identity`));
59
+ protectedRouter.use("/identity", identityRouter(Indiekit));
60
+ protectedRouter.use("/branding", brandingRouter(Indiekit));
61
+ protectedRouter.use("/layout", layoutRouter(Indiekit));
62
+ protectedRouter.use("/features", featuresRouter(Indiekit));
63
+
64
+ this.routes = protectedRouter;
65
+
66
+ // Ensure files exist on first boot — synchronously regenerate.
67
+ try {
68
+ const config = await getSiteConfig(Indiekit);
69
+ await writeThemeCss(config);
70
+ await writeSiteJson(config);
71
+ } catch (error) {
72
+ console.warn("[site-config] initial render skipped:", error.message);
73
+ }
74
+ }
75
+
76
+ get routesPublic() {
77
+ const router = express.Router();
78
+ if (this._apiRouter) {
79
+ router.use("/api", this._apiRouter);
80
+ }
81
+ return router;
82
+ }
83
+ }
@@ -0,0 +1,45 @@
1
+ import express from "express";
2
+ import { getSiteConfig } from "../storage/get-site-config.js";
3
+ import { renderThemeCss } from "../render/write-theme-css.js";
4
+
5
+ export function apiRouter(Indiekit) {
6
+ const router = express.Router();
7
+
8
+ router.get("/preview", async (req, res, next) => {
9
+ try {
10
+ const config = await getSiteConfig(Indiekit);
11
+ const themeCss = renderThemeCss(config);
12
+ res.setHeader("Content-Type", "text/html");
13
+ res.setHeader("X-Content-Type-Options", "nosniff");
14
+ res.setHeader("Cache-Control", "no-store");
15
+ res.send(`<!doctype html>
16
+ <html lang="${escapeHtml(config.identity.locale || "en")}">
17
+ <head>
18
+ <meta charset="utf-8">
19
+ <title>Preview</title>
20
+ <style>
21
+ ${themeCss}
22
+ body { font-family: var(--font-sans); background: rgb(var(--c-surface-50)); color: rgb(var(--c-surface-900)); margin: 0; padding: 1.5rem; }
23
+ h1 { font-family: var(--font-serif); color: rgb(var(--c-primary)); margin: 0 0 0.5em; font-size: 1.5em; }
24
+ p { color: rgb(var(--c-surface-700)); line-height: 1.5; margin: 0 0 1em; }
25
+ .button { background: rgb(var(--c-accent-500)); color: rgb(var(--c-surface-50)); padding: 0.4em 0.8em; border-radius: 0.4em; display: inline-block; font-weight: 600; }
26
+ a { color: rgb(var(--c-link)); }
27
+ </style>
28
+ </head>
29
+ <body>
30
+ <h1>${escapeHtml(config.identity.name || "Untitled site")}</h1>
31
+ <p>${escapeHtml(config.identity.description || "Sample description")}</p>
32
+ <p><a href="#">A sample link</a> and <span class="button">an action button</span>.</p>
33
+ </body>
34
+ </html>`);
35
+ } catch (error) {
36
+ next(error);
37
+ }
38
+ });
39
+
40
+ return router;
41
+ }
42
+
43
+ export function escapeHtml(s) {
44
+ return String(s).replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
45
+ }
@@ -0,0 +1,97 @@
1
+ import express from "express";
2
+ import { getSiteConfig } from "../storage/get-site-config.js";
3
+ import { saveSiteConfig } from "../storage/save-site-config.js";
4
+ import { writeSiteJson } from "../render/write-site-json.js";
5
+ import { writeThemeCss } from "../render/write-theme-css.js";
6
+ import { isValidHexColor, normalizeHex } from "../validators/color.js";
7
+ import { isValidFont, CURATED_FONTS } from "../validators/font.js";
8
+ import { SURFACE_PRESETS } from "../render/surface-presets.js";
9
+
10
+ const VALID_PRESETS = new Set([...Object.keys(SURFACE_PRESETS), "custom"]);
11
+
12
+ export function brandingRouter(Indiekit) {
13
+ const router = express.Router();
14
+
15
+ router.get("/", async (req, res, next) => {
16
+ try {
17
+ const config = await getSiteConfig(Indiekit);
18
+ res.render("site-config-branding", {
19
+ config,
20
+ activeTab: "branding",
21
+ curatedFonts: CURATED_FONTS,
22
+ success: req.query.success,
23
+ });
24
+ } catch (error) {
25
+ next(error);
26
+ }
27
+ });
28
+
29
+ router.post("/", async (req, res, next) => {
30
+ try {
31
+ const body = req.body;
32
+
33
+ // Collect custom surface if preset === custom
34
+ const surfaceCustom = body.surfacePreset === "custom"
35
+ ? Object.fromEntries(
36
+ [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
37
+ .map((k) => [k, normalizeHex(body[`surfaceCustom_${k}`])])
38
+ .filter(([, v]) => v != null),
39
+ )
40
+ : null;
41
+
42
+ if (body.surfacePreset === "custom") {
43
+ const missing = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
44
+ .filter((k) => !surfaceCustom[k]);
45
+ if (missing.length > 0) {
46
+ return res.status(400).send(`Custom palette is missing tones: ${missing.join(", ")}`);
47
+ }
48
+ }
49
+
50
+ // Validate and collect brand tokens
51
+ const colors = {};
52
+ for (const token of ["primary", "link", "focus", "success", "warning", "danger"]) {
53
+ const raw = body[`colors_${token}`];
54
+ if (!isValidHexColor(raw)) {
55
+ return res.status(400).send(`Invalid color for ${token}: ${raw}`);
56
+ }
57
+ colors[token] = normalizeHex(raw);
58
+ }
59
+
60
+ // Validate typography
61
+ const typography = {
62
+ hosting: ["self", "bunny"].includes(body.typography_hosting) ? body.typography_hosting : "self",
63
+ };
64
+ for (const cat of ["sans", "serif", "mono"]) {
65
+ const name = body[`typography_${cat}`];
66
+ if (!isValidFont(name, cat)) {
67
+ return res.status(400).send(`Invalid font for ${cat}: ${name}`);
68
+ }
69
+ typography[cat] = name;
70
+ }
71
+
72
+ const accentBase = normalizeHex(body.accentBase);
73
+ if (!accentBase) return res.status(400).send("Invalid accentBase");
74
+
75
+ const patch = {
76
+ branding: {
77
+ surfacePreset: VALID_PRESETS.has(body.surfacePreset) ? body.surfacePreset : "warm-stone",
78
+ surfaceCustom,
79
+ accentBase,
80
+ colors,
81
+ typography,
82
+ },
83
+ };
84
+
85
+ const userIdent = Indiekit.config?.publication?.me || "unknown";
86
+ const updated = await saveSiteConfig(Indiekit, patch, userIdent);
87
+ await writeSiteJson(updated);
88
+ await writeThemeCss(updated);
89
+ const message = encodeURIComponent(res.locals.__("siteConfig.common.saved"));
90
+ res.redirect(`/site-config/branding?success=${message}`);
91
+ } catch (error) {
92
+ next(error);
93
+ }
94
+ });
95
+
96
+ return router;
97
+ }
@@ -0,0 +1,56 @@
1
+ import express from "express";
2
+ import { getSiteConfig } from "../storage/get-site-config.js";
3
+ import { saveSiteConfig } from "../storage/save-site-config.js";
4
+ import { writeSiteJson } from "../render/write-site-json.js";
5
+
6
+ /**
7
+ * Discover feature flags from loaded plugins by reading `plugin.featureFlag`.
8
+ * Plugins without the capability are skipped.
9
+ */
10
+ export function discoverFlags(Indiekit) {
11
+ const plugins = Indiekit.config?.plugins || [];
12
+ return plugins
13
+ .map((p) => p.featureFlag)
14
+ .filter(Boolean)
15
+ .sort((a, b) => (a.category || "").localeCompare(b.category || ""));
16
+ }
17
+
18
+ export function featuresRouter(Indiekit) {
19
+ const router = express.Router();
20
+
21
+ router.get("/", async (req, res, next) => {
22
+ try {
23
+ const config = await getSiteConfig(Indiekit);
24
+ const flags = discoverFlags(Indiekit);
25
+ res.render("site-config-features", {
26
+ config,
27
+ activeTab: "features",
28
+ flags,
29
+ success: req.query.success,
30
+ });
31
+ } catch (error) {
32
+ next(error);
33
+ }
34
+ });
35
+
36
+ router.post("/", async (req, res, next) => {
37
+ try {
38
+ const flags = discoverFlags(Indiekit);
39
+ const features = {};
40
+ for (const flag of flags) {
41
+ features[flag.key] = req.body[`feature_${flag.key}`] === "on";
42
+ }
43
+ const userIdent = Indiekit.config?.publication?.me || "unknown";
44
+ const updated = await saveSiteConfig(Indiekit, { features }, userIdent);
45
+ await writeSiteJson(updated);
46
+ const message = encodeURIComponent(
47
+ res.locals.__("siteConfig.common.saved"),
48
+ );
49
+ res.redirect(`/site-config/features?success=${message}`);
50
+ } catch (error) {
51
+ next(error);
52
+ }
53
+ });
54
+
55
+ return router;
56
+ }
@@ -0,0 +1,61 @@
1
+ import express from "express";
2
+ import { getSiteConfig } from "../storage/get-site-config.js";
3
+ import { saveSiteConfig } from "../storage/save-site-config.js";
4
+ import { writeSiteJson } from "../render/write-site-json.js";
5
+
6
+ const LOCALE_RE = /^[a-z]{2}(-[A-Z]{2})?$/;
7
+ const TIMEZONE_RE = /^[A-Za-z]+\/[A-Za-z_\/]+$/;
8
+
9
+ function validLocale(raw) {
10
+ const v = raw?.trim() || "en";
11
+ return LOCALE_RE.test(v) ? v : "en";
12
+ }
13
+
14
+ function validTimezone(raw) {
15
+ const v = raw?.trim() || "UTC";
16
+ // "UTC" is a special-case valid timezone that doesn't match the region/city pattern
17
+ if (v === "UTC") return v;
18
+ return TIMEZONE_RE.test(v) ? v : "UTC";
19
+ }
20
+
21
+ export function identityRouter(Indiekit) {
22
+ const router = express.Router();
23
+
24
+ router.get("/", async (req, res, next) => {
25
+ try {
26
+ const config = await getSiteConfig(Indiekit);
27
+ res.render("site-config-identity", {
28
+ config,
29
+ activeTab: "identity",
30
+ success: req.query.success,
31
+ });
32
+ } catch (error) {
33
+ next(error);
34
+ }
35
+ });
36
+
37
+ router.post("/", async (req, res, next) => {
38
+ try {
39
+ const patch = {
40
+ identity: {
41
+ name: req.body.name?.trim() || "",
42
+ description: req.body.description?.trim() || "",
43
+ tagline: req.body.tagline?.trim() || "",
44
+ defaultAuthor: req.body.defaultAuthor?.trim() || "",
45
+ defaultOgImage: req.body.defaultOgImage?.trim() || "",
46
+ locale: validLocale(req.body.locale),
47
+ timezone: validTimezone(req.body.timezone),
48
+ },
49
+ };
50
+ const userIdent = Indiekit.config?.publication?.me || "unknown";
51
+ const updated = await saveSiteConfig(Indiekit, patch, userIdent);
52
+ await writeSiteJson(updated);
53
+ const message = encodeURIComponent(res.locals.__("siteConfig.common.saved"));
54
+ res.redirect(`/site-config/identity?success=${message}`);
55
+ } catch (error) {
56
+ next(error);
57
+ }
58
+ });
59
+
60
+ return router;
61
+ }
@@ -0,0 +1,66 @@
1
+ import express from "express";
2
+ import { getSiteConfig } from "../storage/get-site-config.js";
3
+ import { saveSiteConfig } from "../storage/save-site-config.js";
4
+ import { writeSiteJson } from "../render/write-site-json.js";
5
+
6
+ const PRESETS = ["blog", "portfolio", "business", "landing"];
7
+
8
+ export function layoutRouter(Indiekit) {
9
+ const router = express.Router();
10
+
11
+ router.get("/", async (req, res, next) => {
12
+ try {
13
+ const config = await getSiteConfig(Indiekit);
14
+ res.render("site-config-layout", {
15
+ config,
16
+ activeTab: "layout",
17
+ presets: PRESETS,
18
+ success: req.query.success,
19
+ });
20
+ } catch (error) {
21
+ next(error);
22
+ }
23
+ });
24
+
25
+ router.post("/", async (req, res, next) => {
26
+ try {
27
+ const navItems = [];
28
+ const labels = [].concat(req.body.navLabel || []);
29
+ const urls = [].concat(req.body.navUrl || []);
30
+ for (let i = 0; i < labels.length; i++) {
31
+ if (labels[i] && urls[i]) {
32
+ const url = urls[i].trim();
33
+ navItems.push({
34
+ label: labels[i].trim(),
35
+ url,
36
+ external: url.startsWith("http"),
37
+ });
38
+ }
39
+ }
40
+
41
+ const patch = {
42
+ layout: {
43
+ preset: PRESETS.includes(req.body.preset) ? req.body.preset : "blog",
44
+ sidebarEnabled: req.body.sidebarEnabled === "on",
45
+ sidebarSide: ["left", "right"].includes(req.body.sidebarSide)
46
+ ? req.body.sidebarSide
47
+ : "right",
48
+ navItems,
49
+ // footerColumns intentionally omitted — managed by a future tab; deepMerge would replace, not preserve
50
+ },
51
+ };
52
+
53
+ const userIdent = Indiekit.config?.publication?.me || "unknown";
54
+ const updated = await saveSiteConfig(Indiekit, patch, userIdent);
55
+ await writeSiteJson(updated);
56
+ const message = encodeURIComponent(
57
+ res.locals.__("siteConfig.common.saved"),
58
+ );
59
+ res.redirect(`/site-config/layout?success=${message}`);
60
+ } catch (error) {
61
+ next(error);
62
+ }
63
+ });
64
+
65
+ return router;
66
+ }
@@ -0,0 +1,41 @@
1
+ import { parse, formatHex, oklch, clampChroma } from "culori";
2
+ import { SURFACE_PRESETS } from "./surface-presets.js";
3
+
4
+ const SCALE_STEPS = [
5
+ { key: 50, l: 0.97 }, { key: 100, l: 0.94 }, { key: 200, l: 0.86 },
6
+ { key: 300, l: 0.78 }, { key: 400, l: 0.65 }, { key: 500, l: 0.55 },
7
+ { key: 600, l: 0.45 }, { key: 700, l: 0.36 }, { key: 800, l: 0.27 },
8
+ { key: 900, l: 0.20 }, { key: 950, l: 0.13 },
9
+ ];
10
+
11
+ const REQUIRED_SCALE_KEYS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
12
+
13
+ export function derivePaletteFromBase(baseHex) {
14
+ const base = oklch(parse(baseHex));
15
+ if (!base) throw new Error(`Invalid color: ${baseHex}`);
16
+ const palette = {};
17
+ for (const step of SCALE_STEPS) {
18
+ const color = {
19
+ mode: "oklch",
20
+ l: step.l,
21
+ c: base.c * (step.l < 0.5 ? step.l * 1.8 : (1 - step.l) * 1.8),
22
+ h: base.h ?? 0, // hue unused when base.c === 0; fallback prevents NaN in OKLCH ops
23
+ };
24
+ palette[step.key] = formatHex(clampChroma(color, "oklch", "rgb"));
25
+ }
26
+ return palette;
27
+ }
28
+
29
+ export function getSurfacePalette(preset, custom = null) {
30
+ if (preset === "custom") {
31
+ if (!custom) throw new Error("Custom preset selected but no custom palette provided");
32
+ const missing = REQUIRED_SCALE_KEYS.filter((k) => !custom[k]);
33
+ if (missing.length > 0) {
34
+ throw new Error(`Custom palette is missing required scale keys: ${missing.join(", ")}`);
35
+ }
36
+ return custom;
37
+ }
38
+ const p = SURFACE_PRESETS[preset];
39
+ if (!p) throw new Error(`Unknown surface preset: ${preset}`);
40
+ return p;
41
+ }
@@ -0,0 +1,25 @@
1
+ // `cool-slate` and `neutral-zinc` palette values are derived from Tailwind CSS,
2
+ // MIT License — Copyright (c) Tailwind Labs, Inc.
3
+ // https://tailwindcss.com/docs/customizing-colors
4
+ //
5
+ // `warm-stone` is the original rmendes.net palette.
6
+ export const SURFACE_PRESETS = Object.freeze({
7
+ "warm-stone": Object.freeze({
8
+ 50: "#faf8f5", 100: "#f4f2ee", 200: "#e8e5df",
9
+ 300: "#d5d0c8", 400: "#a09a90", 500: "#7a746a",
10
+ 600: "#5c5750", 700: "#3f3b35", 800: "#2a2722",
11
+ 900: "#1c1b19", 950: "#0f0e0d",
12
+ }),
13
+ "cool-slate": Object.freeze({
14
+ 50: "#f8fafc", 100: "#f1f5f9", 200: "#e2e8f0",
15
+ 300: "#cbd5e1", 400: "#94a3b8", 500: "#64748b",
16
+ 600: "#475569", 700: "#334155", 800: "#1e293b",
17
+ 900: "#0f172a", 950: "#020617",
18
+ }),
19
+ "neutral-zinc": Object.freeze({
20
+ 50: "#fafafa", 100: "#f4f4f5", 200: "#e4e4e7",
21
+ 300: "#d4d4d8", 400: "#a1a1aa", 500: "#71717a",
22
+ 600: "#52525b", 700: "#3f3f46", 800: "#27272a",
23
+ 900: "#18181b", 950: "#09090b",
24
+ }),
25
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Site config JSON writer.
3
+ * Renders a site config object as a JSON string for Eleventy consumption,
4
+ * and optionally writes it atomically to disk.
5
+ *
6
+ * The JSON is consumed by Eleventy via `_data/site-config.json`. Template
7
+ * authors access fields like `{{ site.identity.name }}` and
8
+ * `{{ site.branding.colors.primary }}` in Nunjucks templates.
9
+ *
10
+ * @module render/write-site-json
11
+ */
12
+
13
+ import { writeFile, rename, mkdir } from "node:fs/promises";
14
+ import { randomBytes } from "node:crypto";
15
+ import path from "node:path";
16
+
17
+ // Keys that must never appear in the public Eleventy data layer.
18
+ // updatedBy may contain a user email or profile URL (PII).
19
+ // We strip by key name at any depth so future nested fields are safe by default.
20
+ const PRIVATE_KEYS = new Set(["updatedBy"]);
21
+
22
+ /**
23
+ * Render the site config as a JSON string suitable for Eleventy consumption.
24
+ * Strips PRIVATE_KEYS at any depth (currently only `updatedBy`).
25
+ *
26
+ * `updatedAt` (a timestamp) is intentionally kept — template authors may want
27
+ * to display "site last updated YYYY-MM-DD" using this field.
28
+ *
29
+ * @param {object} config - Merged site config object (output of mergeWithDefaults)
30
+ * @returns {string} Pretty-printed JSON source (2-space indent)
31
+ */
32
+ export function renderSiteJson(config) {
33
+ const replacer = (key, value) => (PRIVATE_KEYS.has(key) ? undefined : value);
34
+ return JSON.stringify(config, replacer, 2);
35
+ }
36
+
37
+ /**
38
+ * Write the site config JSON to disk atomically (tmp file → rename).
39
+ * Creates the output directory if it does not exist.
40
+ *
41
+ * The tmp file uses a random suffix so concurrent callers don't collide on a
42
+ * shared tmp name (two simultaneous writes both renaming the same file would
43
+ * cause one to fail with ENOENT). The rename itself is atomic on POSIX —
44
+ * Eleventy's file watcher will not observe a partial write.
45
+ *
46
+ * @param {object} config - Full site config (from mergeWithDefaults)
47
+ * @param {string} [outputPath="/app/data/content/_data/site-config.json"] - Destination path
48
+ * @returns {Promise<void>}
49
+ */
50
+ export async function writeSiteJson(
51
+ config,
52
+ outputPath = "/app/data/content/_data/site-config.json",
53
+ ) {
54
+ const json = renderSiteJson(config);
55
+ await mkdir(path.dirname(outputPath), { recursive: true });
56
+ const tmp = `${outputPath}.${randomBytes(6).toString("hex")}.tmp`;
57
+ await writeFile(tmp, json, "utf8");
58
+ await rename(tmp, outputPath);
59
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Theme CSS writer.
3
+ * Renders a `:root {}` block of CSS custom properties from a site config,
4
+ * and optionally writes it atomically to disk.
5
+ *
6
+ * CSS variables use space-separated RGB triplets (no commas) so that
7
+ * Tailwind's alpha-value pattern works: `rgb(var(--c-primary) / <alpha-value>)`.
8
+ *
9
+ * @module render/write-theme-css
10
+ */
11
+
12
+ import { writeFile, rename, mkdir } from "node:fs/promises";
13
+ import { randomBytes } from "node:crypto";
14
+ import path from "node:path";
15
+ import { getSurfacePalette, derivePaletteFromBase } from "./derive-palette.js";
16
+ import { normalizeHex } from "../validators/color.js";
17
+
18
+ /**
19
+ * Convert a hex color string to a space-separated RGB triplet.
20
+ * Returns "0 0 0" for invalid or null input (safe fallback).
21
+ *
22
+ * 8-digit (alpha) hex inputs have their alpha channel dropped; a warning is
23
+ * logged because the RGB triplet output format cannot represent alpha.
24
+ * (Tailwind applies alpha at the use site via the <alpha-value> pattern.)
25
+ *
26
+ * @param {string|null} hex - Hex color string (e.g. "#ff0000")
27
+ * @returns {string} Space-separated triplet (e.g. "255 0 0")
28
+ */
29
+ function hexToRgbTriplet(hex) {
30
+ const v = normalizeHex(hex);
31
+ if (!v) return "0 0 0";
32
+ if (v.length === 9) {
33
+ // 8-digit hex includes alpha; alpha is not representable in our CSS var format.
34
+ // (Tailwind applies alpha at use site via the <alpha-value> pattern.)
35
+ console.warn(`[site-config] alpha channel in '${hex}' is not supported in CSS custom properties and will be ignored`);
36
+ }
37
+ const r = parseInt(v.slice(1, 3), 16);
38
+ const g = parseInt(v.slice(3, 5), 16);
39
+ const b = parseInt(v.slice(5, 7), 16);
40
+ return `${r} ${g} ${b}`;
41
+ }
42
+
43
+ /**
44
+ * CSS generic font keywords (and ui-* generics) per CSS Fonts spec.
45
+ * These MUST be emitted unquoted; quoting them turns them into a search for a
46
+ * custom font-family named "ui-monospace" rather than the CSS generic keyword.
47
+ */
48
+ const CSS_GENERIC_FONT_KEYWORDS = new Set([
49
+ "system-ui",
50
+ "ui-sans-serif",
51
+ "ui-serif",
52
+ "ui-monospace",
53
+ "ui-rounded",
54
+ "sans-serif",
55
+ "serif",
56
+ "monospace",
57
+ "cursive",
58
+ "fantasy",
59
+ ]);
60
+
61
+ /**
62
+ * Format a font-family name for a CSS declaration. Generic keywords are
63
+ * emitted unquoted; named families are wrapped in quotes via JSON.stringify
64
+ * (which also escapes any special characters safely).
65
+ *
66
+ * @param {string} name - Font family name
67
+ * @returns {string} CSS-ready font family token
68
+ */
69
+ function formatFontFamily(name) {
70
+ return CSS_GENERIC_FONT_KEYWORDS.has(name) ? name : JSON.stringify(name);
71
+ }
72
+
73
+ /**
74
+ * Render the site's theme as a CSS string with custom properties.
75
+ *
76
+ * Note: only light-mode (:root) variables are emitted. Dark-mode overrides
77
+ * are out of scope for this module; the theme uses Tailwind's class-based
78
+ * dark mode (darkMode: "class") via existing styling, not via separate
79
+ * CSS variable overrides.
80
+ *
81
+ * @param {object} config - Merged site config object (output of mergeWithDefaults)
82
+ * @returns {string} CSS source ready to be written or served
83
+ */
84
+ export function renderThemeCss(config) {
85
+ const surface = getSurfacePalette(
86
+ config.branding.surfacePreset,
87
+ config.branding.surfaceCustom,
88
+ );
89
+ const accent = derivePaletteFromBase(config.branding.accentBase);
90
+ const { primary, link, focus, success, warning, danger } = config.branding.colors;
91
+ const { sans, serif, mono } = config.branding.typography;
92
+
93
+ const surfaceVars = Object.entries(surface)
94
+ .map(([k, v]) => ` --c-surface-${k}: ${hexToRgbTriplet(v)};`)
95
+ .join("\n");
96
+ const accentVars = Object.entries(accent)
97
+ .map(([k, v]) => ` --c-accent-${k}: ${hexToRgbTriplet(v)};`)
98
+ .join("\n");
99
+
100
+ return `:root {
101
+ ${surfaceVars}
102
+ ${accentVars}
103
+ --c-primary: ${hexToRgbTriplet(primary)};
104
+ --c-link: ${hexToRgbTriplet(link)};
105
+ --c-focus: ${hexToRgbTriplet(focus)};
106
+ --c-success: ${hexToRgbTriplet(success)};
107
+ --c-warning: ${hexToRgbTriplet(warning)};
108
+ --c-danger: ${hexToRgbTriplet(danger)};
109
+ --font-sans: ${formatFontFamily(sans)}, system-ui, sans-serif;
110
+ --font-serif: ${formatFontFamily(serif)}, Georgia, serif;
111
+ --font-mono: ${formatFontFamily(mono)}, ui-monospace, monospace;
112
+ }
113
+ `;
114
+ }
115
+
116
+ /**
117
+ * Write theme CSS to disk atomically (tmp file → rename).
118
+ * Creates the output directory if it does not exist.
119
+ *
120
+ * The tmp file uses a random suffix so concurrent callers don't collide on a
121
+ * shared tmp name (two simultaneous writes both renaming the same file would
122
+ * cause one to fail with ENOENT). The rename itself is atomic on POSIX —
123
+ * Eleventy's file watcher will not observe a partial write.
124
+ *
125
+ * @param {object} config - Full site config (from mergeWithDefaults)
126
+ * @param {string} [outputPath="/app/data/content/_data/theme.css"] - Destination path
127
+ * @returns {Promise<void>}
128
+ */
129
+ export async function writeThemeCss(config, outputPath = "/app/data/content/_data/theme.css") {
130
+ const css = renderThemeCss(config);
131
+ await mkdir(path.dirname(outputPath), { recursive: true });
132
+ const tmp = `${outputPath}.${randomBytes(6).toString("hex")}.tmp`;
133
+ await writeFile(tmp, css, "utf8");
134
+ await rename(tmp, outputPath);
135
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Default values for site configuration.
3
+ * Single source of truth for the schema's defaults.
4
+ * Frozen for immutability — never mutate this object directly.
5
+ * @module storage/defaults
6
+ */
7
+
8
+ import { CURATED_FONTS } from "../validators/font.js";
9
+
10
+ export const DEFAULTS = Object.freeze({
11
+ schemaVersion: 1,
12
+ identity: Object.freeze({
13
+ name: "My IndieWeb Site",
14
+ description: "A site built with Indiekit",
15
+ tagline: "",
16
+ defaultAuthor: "",
17
+ defaultOgImage: "",
18
+ locale: "en",
19
+ timezone: "UTC",
20
+ }),
21
+ branding: Object.freeze({
22
+ surfacePreset: "warm-stone",
23
+ surfaceCustom: null,
24
+ accentBase: "#f59e0b",
25
+ colors: Object.freeze({
26
+ primary: "#1f3a8a",
27
+ link: "#3b82f6",
28
+ focus: "#fbbf24",
29
+ success: "#16a34a",
30
+ warning: "#eab308",
31
+ danger: "#dc2626",
32
+ }),
33
+ typography: Object.freeze({
34
+ sans: CURATED_FONTS.sans[0],
35
+ serif: CURATED_FONTS.serif[0],
36
+ mono: CURATED_FONTS.mono[0],
37
+ hosting: "self",
38
+ }),
39
+ logo: "",
40
+ favicon: "",
41
+ }),
42
+ layout: Object.freeze({
43
+ preset: "blog",
44
+ sidebarEnabled: true,
45
+ sidebarSide: "right",
46
+ navItems: Object.freeze([
47
+ Object.freeze({ label: "Home", url: "/", external: false }),
48
+ ]),
49
+ footerColumns: Object.freeze([]),
50
+ }),
51
+ features: Object.freeze({
52
+ webmentions: true,
53
+ syndication: true,
54
+ activitypub: false,
55
+ search: true,
56
+ rss: true,
57
+ }),
58
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Site configuration reader and merger.
3
+ * Provides mergeWithDefaults (pure) and getSiteConfig (async DB read).
4
+ * @module storage/get-site-config
5
+ */
6
+
7
+ import { DEFAULTS } from "./defaults.js";
8
+
9
+ /**
10
+ * Deep-merge source into target, returning a new object.
11
+ * Arrays are replaced (not concatenated).
12
+ * null values are treated as scalars (not objects to merge into).
13
+ *
14
+ * @param {object} target - Base object (e.g. DEFAULTS)
15
+ * @param {object} source - Override object
16
+ * @returns {object} New merged object
17
+ */
18
+ export function deepMerge(target, source) {
19
+ const out = Array.isArray(target) ? [...target] : { ...target };
20
+ for (const key of Object.keys(source || {})) {
21
+ const srcVal = source[key];
22
+ const tgtVal = out[key];
23
+ if (
24
+ srcVal &&
25
+ typeof srcVal === "object" &&
26
+ !Array.isArray(srcVal) &&
27
+ tgtVal &&
28
+ typeof tgtVal === "object" &&
29
+ !Array.isArray(tgtVal)
30
+ ) {
31
+ out[key] = deepMerge(tgtVal, srcVal);
32
+ } else if (srcVal !== undefined) {
33
+ out[key] = srcVal;
34
+ }
35
+ }
36
+ return out;
37
+ }
38
+
39
+ /**
40
+ * Merge an input object with defaults.
41
+ * Returns a fully-populated config with all required keys.
42
+ *
43
+ * @param {object} input - Partial config (may be empty)
44
+ * @returns {object} Merged config
45
+ */
46
+ export function mergeWithDefaults(input) {
47
+ return deepMerge(DEFAULTS, input || {});
48
+ }
49
+
50
+ /**
51
+ * Read site config from MongoDB, merged with defaults.
52
+ * Returns defaults-only config when no database is configured.
53
+ *
54
+ * @param {object} Indiekit - Indiekit application instance
55
+ * @returns {Promise<object>} Full site config
56
+ */
57
+ export async function getSiteConfig(Indiekit) {
58
+ const db = Indiekit.database;
59
+ if (!db) return mergeWithDefaults({});
60
+ const doc = await db.collection("siteConfig").findOne({ _id: "primary" });
61
+ if (!doc) return mergeWithDefaults({});
62
+ const { _id, ...fields } = doc;
63
+ return mergeWithDefaults(fields);
64
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Site configuration writer.
3
+ * Persists a patched config to MongoDB with upsert, merging with defaults.
4
+ * @module storage/save-site-config
5
+ */
6
+
7
+ import { mergeWithDefaults, deepMerge } from "./get-site-config.js";
8
+
9
+ /**
10
+ * Save a partial config patch to MongoDB, merged with defaults.
11
+ * Creates the document if it doesn't exist (upsert).
12
+ * Records updatedAt (ISO 8601) and updatedBy on every write.
13
+ *
14
+ * Uses deepMerge against the existing document so partial-section patches
15
+ * (e.g. { branding: { accentBase: "#new" } }) do not erase sibling fields.
16
+ * Uses replaceOne to match sibling plugin convention and avoid MongoDB's
17
+ * "Mod on _id not allowed" error from $set on a doc containing _id.
18
+ *
19
+ * @param {object} Indiekit - Indiekit application instance
20
+ * @param {object} patch - Partial config values to apply
21
+ * @param {string} [userIdentifier] - Identifier of the user making the change
22
+ * @returns {Promise<object>} The fully merged config that was saved
23
+ * @throws {Error} When no database is configured
24
+ */
25
+ export async function saveSiteConfig(Indiekit, patch, userIdentifier) {
26
+ const db = Indiekit.database;
27
+ if (!db) throw new Error("Database not configured");
28
+ const collection = db.collection("siteConfig");
29
+ const existing = await collection.findOne({ _id: "primary" });
30
+ const { _id, ...existingFields } = existing || {};
31
+ const merged = mergeWithDefaults(deepMerge(existingFields, patch));
32
+ merged.updatedAt = new Date().toISOString();
33
+ merged.updatedBy = userIdentifier || "unknown";
34
+ await collection.replaceOne(
35
+ { _id: "primary" },
36
+ { _id: "primary", ...merged },
37
+ { upsert: true }
38
+ );
39
+ return merged;
40
+ }
@@ -0,0 +1,19 @@
1
+ // Hex validation uses a regex rather than culori because the responsibilities
2
+ // are different: this module guards user input (predicate + normalize); the
3
+ // palette derivation layer (lib/render/derive-palette.js) uses culori for
4
+ // OKLCH conversion and scale generation. Keeping the two paths separate avoids
5
+ // pulling a 50kb color library into the validator's hot path.
6
+ const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
7
+
8
+ export function isValidHexColor(value) {
9
+ return typeof value === "string" && HEX_RE.test(value);
10
+ }
11
+
12
+ export function normalizeHex(value) {
13
+ if (!isValidHexColor(value)) return null;
14
+ const v = value.toLowerCase();
15
+ if (v.length === 4) {
16
+ return "#" + v[1].repeat(2) + v[2].repeat(2) + v[3].repeat(2);
17
+ }
18
+ return v;
19
+ }
@@ -0,0 +1,16 @@
1
+ export const CURATED_FONTS = Object.freeze({
2
+ // "system-ui" is a CSS generic — no @font-face needed
3
+ sans: Object.freeze(["Inter", "Source Sans Pro", "system-ui"]),
4
+ // "Georgia" is system-bundled on all major platforms — no @font-face needed
5
+ serif: Object.freeze(["Fraunces", "Source Serif Pro", "Georgia"]),
6
+ // "ui-monospace" is a CSS generic — no @font-face needed
7
+ mono: Object.freeze(["ui-monospace", "JetBrains Mono", "Source Code Pro"]),
8
+ });
9
+
10
+ export function isValidFont(name, category = null) {
11
+ if (typeof name !== "string") return false;
12
+ if (category != null) {
13
+ return CURATED_FONTS[category]?.includes(name) ?? false;
14
+ }
15
+ return Object.values(CURATED_FONTS).some((list) => list.includes(name));
16
+ }
@@ -0,0 +1,57 @@
1
+ {
2
+ "siteConfig": {
3
+ "title": "Site Config",
4
+ "tabs": {
5
+ "identity": "Identity",
6
+ "branding": "Branding",
7
+ "layout": "Layout",
8
+ "features": "Feature Flags"
9
+ },
10
+ "identity": {
11
+ "title": "Identity",
12
+ "name": "Site name",
13
+ "description": "Description",
14
+ "tagline": "Tagline",
15
+ "defaultAuthor": "Default author",
16
+ "defaultOgImage": "Default OpenGraph image URL",
17
+ "locale": "Locale (ISO 639-1)",
18
+ "timezone": "Timezone (IANA)"
19
+ },
20
+ "branding": {
21
+ "title": "Branding",
22
+ "surfacePreset": "Surface palette",
23
+ "surfaceCustom": "Custom surface tones",
24
+ "accent": "Accent",
25
+ "brandTokens": "Brand tokens",
26
+ "primary": "Primary",
27
+ "link": "Link",
28
+ "focus": "Focus ring",
29
+ "success": "Success",
30
+ "warning": "Warning",
31
+ "danger": "Danger",
32
+ "typography": "Typography",
33
+ "fontSans": "Sans-serif font",
34
+ "fontSerif": "Serif font",
35
+ "fontMono": "Monospace font",
36
+ "fontHosting": "Font hosting",
37
+ "logo": "Logo",
38
+ "favicon": "Favicon"
39
+ },
40
+ "layout": {
41
+ "title": "Layout",
42
+ "preset": "Layout preset",
43
+ "sidebarEnabled": "Sidebar enabled",
44
+ "sidebarSide": "Sidebar side",
45
+ "navItems": "Navigation items"
46
+ },
47
+ "features": {
48
+ "title": "Feature Flags",
49
+ "empty": "No plugins declare a feature flag yet."
50
+ },
51
+ "common": {
52
+ "save": "Save changes",
53
+ "saved": "Saved",
54
+ "preview": "Live preview"
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,57 @@
1
+ {
2
+ "siteConfig": {
3
+ "title": "Configuration du site",
4
+ "tabs": {
5
+ "identity": "Identité",
6
+ "branding": "Identité visuelle",
7
+ "layout": "Mise en page",
8
+ "features": "Fonctionnalités"
9
+ },
10
+ "identity": {
11
+ "title": "Identité",
12
+ "name": "Nom du site",
13
+ "description": "Description",
14
+ "tagline": "Slogan",
15
+ "defaultAuthor": "Auteur par défaut",
16
+ "defaultOgImage": "Image OpenGraph par défaut",
17
+ "locale": "Langue (ISO 639-1)",
18
+ "timezone": "Fuseau horaire (IANA)"
19
+ },
20
+ "branding": {
21
+ "title": "Identité visuelle",
22
+ "surfacePreset": "Palette de fond",
23
+ "surfaceCustom": "Tons personnalisés",
24
+ "accent": "Accent",
25
+ "brandTokens": "Couleurs de marque",
26
+ "primary": "Couleur principale",
27
+ "link": "Lien",
28
+ "focus": "Anneau de focus",
29
+ "success": "Succès",
30
+ "warning": "Avertissement",
31
+ "danger": "Erreur",
32
+ "typography": "Typographie",
33
+ "fontSans": "Police sans-serif",
34
+ "fontSerif": "Police serif",
35
+ "fontMono": "Police mono",
36
+ "fontHosting": "Hébergement des polices",
37
+ "logo": "Logo",
38
+ "favicon": "Favicon"
39
+ },
40
+ "layout": {
41
+ "title": "Mise en page",
42
+ "preset": "Modèle",
43
+ "sidebarEnabled": "Barre latérale activée",
44
+ "sidebarSide": "Côté de la barre latérale",
45
+ "navItems": "Éléments de navigation"
46
+ },
47
+ "features": {
48
+ "title": "Fonctionnalités",
49
+ "empty": "Aucun plugin ne déclare encore de drapeau de fonctionnalité."
50
+ },
51
+ "common": {
52
+ "save": "Enregistrer",
53
+ "saved": "Enregistré",
54
+ "preview": "Aperçu en direct"
55
+ }
56
+ }
57
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@rmdes/indiekit-endpoint-site-config",
3
+ "version": "1.0.0-alpha.1",
4
+ "type": "module",
5
+ "description": "Site identity, branding, layout, and feature flag configuration for Indiekit",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "lib/",
13
+ "views/",
14
+ "locales/",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test tests/*.test.js",
19
+ "publish:beta": "npm publish --tag beta"
20
+ },
21
+ "engines": {
22
+ "node": ">=22"
23
+ },
24
+ "peerDependencies": {
25
+ "@indiekit/indiekit": ">=1.0.0-beta.27"
26
+ },
27
+ "dependencies": {
28
+ "@indiekit/error": "^1.0.0-beta.25",
29
+ "@indiekit/frontend": "^1.0.0-beta.25",
30
+ "culori": "^4.0.1",
31
+ "express": "^5.0.0"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ }
36
+ }
@@ -0,0 +1,11 @@
1
+ {# locals: name (string), label (string), value (hex string) #}
2
+ <div class="form-field form-field--color">
3
+ <label for="{{ name }}">{{ label }}</label>
4
+ <div class="color-picker">
5
+ <input type="color" class="color-picker__swatch" id="{{ name }}-swatch" value="{{ value }}"
6
+ oninput="document.getElementById('{{ name }}').value = this.value">
7
+ <input type="text" class="color-picker__hex" id="{{ name }}" name="{{ name }}"
8
+ value="{{ value }}" pattern="^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" required
9
+ oninput="document.getElementById('{{ name }}-swatch').value = this.value">
10
+ </div>
11
+ </div>
@@ -0,0 +1,16 @@
1
+ {# locals: activeTab (string, one of identity|branding|layout|features) #}
2
+ <nav class="site-config__tabs" aria-label="{{ __('siteConfig.title') }}">
3
+ {% set tabs = [
4
+ {key: 'identity', href: '/site-config/identity'},
5
+ {key: 'branding', href: '/site-config/branding'},
6
+ {key: 'layout', href: '/site-config/layout'},
7
+ {key: 'features', href: '/site-config/features'}
8
+ ] %}
9
+ {% for tab in tabs %}
10
+ <a href="{{ tab.href }}"
11
+ class="site-config__tab {% if activeTab == tab.key %}site-config__tab--active{% endif %}"
12
+ {% if activeTab == tab.key %}aria-current="page"{% endif %}>
13
+ {{ __('siteConfig.tabs.' + tab.key) }}
14
+ </a>
15
+ {% endfor %}
16
+ </nav>
@@ -0,0 +1,76 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block title %}{{ __('siteConfig.branding.title') }}{% endblock %}
4
+
5
+ {% block content %}
6
+ <h1>{{ __('siteConfig.title') }}</h1>
7
+ {% include "partials/tab-strip.njk" %}
8
+
9
+ <form method="post" action="/site-config/branding" class="site-config__form site-config__form--branding">
10
+ <div class="site-config__columns">
11
+ <div class="site-config__form-column">
12
+ <fieldset>
13
+ <legend>{{ __('siteConfig.branding.surfacePreset') }}</legend>
14
+ <div class="form-radio-group">
15
+ {% for preset in ['warm-stone', 'cool-slate', 'neutral-zinc', 'custom'] %}
16
+ <label class="form-radio">
17
+ <input type="radio" name="surfacePreset" value="{{ preset }}"
18
+ {% if config.branding.surfacePreset == preset %}checked{% endif %}>
19
+ <span>{{ preset }}</span>
20
+ </label>
21
+ {% endfor %}
22
+ </div>
23
+ </fieldset>
24
+
25
+ <fieldset class="surface-custom" data-show-when="surfacePreset=custom">
26
+ <legend>{{ __('siteConfig.branding.surfaceCustom') }}</legend>
27
+ {% for tone in [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] %}
28
+ {% set v = config.branding.surfaceCustom and config.branding.surfaceCustom[tone] or '#888888' %}
29
+ {% include "partials/color-picker.njk" with { name: 'surfaceCustom_' + tone, label: 'surface-' + tone, value: v } %}
30
+ {% endfor %}
31
+ </fieldset>
32
+
33
+ <fieldset>
34
+ <legend>{{ __('siteConfig.branding.accent') }}</legend>
35
+ {% include "partials/color-picker.njk" with { name: 'accentBase', label: __('siteConfig.branding.accent'), value: config.branding.accentBase } %}
36
+ </fieldset>
37
+
38
+ <fieldset>
39
+ <legend>{{ __('siteConfig.branding.brandTokens') }}</legend>
40
+ {% for token in ['primary', 'link', 'focus', 'success', 'warning', 'danger'] %}
41
+ {% include "partials/color-picker.njk" with { name: 'colors_' + token, label: __('siteConfig.branding.' + token), value: config.branding.colors[token] } %}
42
+ {% endfor %}
43
+ </fieldset>
44
+
45
+ <fieldset>
46
+ <legend>{{ __('siteConfig.branding.typography') }}</legend>
47
+ {% for cat in ['sans', 'serif', 'mono'] %}
48
+ <div class="form-field">
49
+ <label for="typography_{{ cat }}">{{ __('siteConfig.branding.font' + cat | capitalize) }}</label>
50
+ <select id="typography_{{ cat }}" name="typography_{{ cat }}">
51
+ {% for f in curatedFonts[cat] %}
52
+ <option value="{{ f }}" {% if config.branding.typography[cat] == f %}selected{% endif %}>{{ f }}</option>
53
+ {% endfor %}
54
+ </select>
55
+ </div>
56
+ {% endfor %}
57
+ <div class="form-field">
58
+ <label for="typography_hosting">{{ __('siteConfig.branding.fontHosting') }}</label>
59
+ <select id="typography_hosting" name="typography_hosting">
60
+ <option value="self" {% if config.branding.typography.hosting == 'self' %}selected{% endif %}>self-hosted</option>
61
+ <option value="bunny" {% if config.branding.typography.hosting == 'bunny' %}selected{% endif %}>Bunny Fonts</option>
62
+ </select>
63
+ </div>
64
+ </fieldset>
65
+
66
+ <button type="submit" class="button">{{ __('siteConfig.common.save') }}</button>
67
+ </div>
68
+
69
+ <div class="site-config__preview-column">
70
+ <h3>{{ __('siteConfig.common.preview') }}</h3>
71
+ {# TODO Task 10: /site-config/api/preview endpoint not yet implemented; iframe will 404 until then #}
72
+ <iframe src="/site-config/api/preview" class="site-config__preview-iframe" title="{{ __('siteConfig.common.preview') }}"></iframe>
73
+ </div>
74
+ </div>
75
+ </form>
76
+ {% endblock %}
@@ -0,0 +1,35 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block title %}{{ __('siteConfig.features.title') }}{% endblock %}
4
+
5
+ {% block content %}
6
+ <h1>{{ __('siteConfig.title') }}</h1>
7
+ {% include "partials/tab-strip.njk" %}
8
+
9
+ {% if flags.length == 0 %}
10
+ <p class="empty-state">{{ __('siteConfig.features.empty') }}</p>
11
+ {% else %}
12
+ <form method="post" action="/site-config/features" class="site-config__form">
13
+ {% set currentCategory = '' %}
14
+ {% for flag in flags %}
15
+ {% if flag.category != currentCategory %}
16
+ {% if not loop.first %}</fieldset>{% endif %}
17
+ <fieldset>
18
+ <legend>{{ flag.category }}</legend>
19
+ {% set currentCategory = flag.category %}
20
+ {% endif %}
21
+ <div class="form-field">
22
+ <label>
23
+ <input type="checkbox" name="feature_{{ flag.key }}"
24
+ {% if config.features[flag.key] %}checked{% elif flag.default %}checked{% endif %}>
25
+ <strong>{{ flag.label }}</strong>
26
+ {% if flag.description %}<span class="hint">{{ flag.description }}</span>{% endif %}
27
+ {% if flag.requiresRestart %}<span class="badge">Restart required</span>{% endif %}
28
+ </label>
29
+ </div>
30
+ {% endfor %}
31
+ </fieldset>
32
+ <button type="submit" class="button">{{ __('siteConfig.common.save') }}</button>
33
+ </form>
34
+ {% endif %}
35
+ {% endblock %}
@@ -0,0 +1,42 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block title %}{{ __('siteConfig.identity.title') }}{% endblock %}
4
+
5
+ {% block content %}
6
+ <h1>{{ __('siteConfig.title') }}</h1>
7
+ {% include "partials/tab-strip.njk" %}
8
+
9
+ <form method="post" action="/site-config/identity" class="site-config__form">
10
+ <div class="form-field">
11
+ <label for="name">{{ __('siteConfig.identity.name') }}</label>
12
+ <input type="text" id="name" name="name" value="{{ config.identity.name }}" required>
13
+ </div>
14
+ <div class="form-field">
15
+ <label for="description">{{ __('siteConfig.identity.description') }}</label>
16
+ <textarea id="description" name="description" rows="2">{{ config.identity.description }}</textarea>
17
+ </div>
18
+ <div class="form-field">
19
+ <label for="tagline">{{ __('siteConfig.identity.tagline') }}</label>
20
+ <input type="text" id="tagline" name="tagline" value="{{ config.identity.tagline }}">
21
+ </div>
22
+ <div class="form-field">
23
+ <label for="defaultAuthor">{{ __('siteConfig.identity.defaultAuthor') }}</label>
24
+ <input type="text" id="defaultAuthor" name="defaultAuthor" value="{{ config.identity.defaultAuthor }}">
25
+ </div>
26
+ <div class="form-field">
27
+ <label for="defaultOgImage">{{ __('siteConfig.identity.defaultOgImage') }}</label>
28
+ <input type="url" id="defaultOgImage" name="defaultOgImage" value="{{ config.identity.defaultOgImage }}">
29
+ </div>
30
+ <div class="form-row">
31
+ <div class="form-field">
32
+ <label for="locale">{{ __('siteConfig.identity.locale') }}</label>
33
+ <input type="text" id="locale" name="locale" value="{{ config.identity.locale }}" maxlength="5">
34
+ </div>
35
+ <div class="form-field">
36
+ <label for="timezone">{{ __('siteConfig.identity.timezone') }}</label>
37
+ <input type="text" id="timezone" name="timezone" value="{{ config.identity.timezone }}">
38
+ </div>
39
+ </div>
40
+ <button type="submit" class="button">{{ __('siteConfig.common.save') }}</button>
41
+ </form>
42
+ {% endblock %}
@@ -0,0 +1,55 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block title %}{{ __('siteConfig.layout.title') }}{% endblock %}
4
+
5
+ {% block content %}
6
+ <h1>{{ __('siteConfig.title') }}</h1>
7
+ {% include "partials/tab-strip.njk" %}
8
+
9
+ <form method="post" action="/site-config/layout" class="site-config__form">
10
+ <div class="form-field">
11
+ <label for="preset">{{ __('siteConfig.layout.preset') }}</label>
12
+ <select id="preset" name="preset">
13
+ {% for p in presets %}
14
+ <option value="{{ p }}" {% if config.layout.preset == p %}selected{% endif %}>{{ p }}</option>
15
+ {% endfor %}
16
+ </select>
17
+ </div>
18
+ <div class="form-field">
19
+ <label>
20
+ <input type="checkbox" name="sidebarEnabled" {% if config.layout.sidebarEnabled %}checked{% endif %}>
21
+ {{ __('siteConfig.layout.sidebarEnabled') }}
22
+ </label>
23
+ </div>
24
+ <div class="form-field">
25
+ <label for="sidebarSide">{{ __('siteConfig.layout.sidebarSide') }}</label>
26
+ <select id="sidebarSide" name="sidebarSide">
27
+ <option value="left" {% if config.layout.sidebarSide == 'left' %}selected{% endif %}>left</option>
28
+ <option value="right" {% if config.layout.sidebarSide == 'right' %}selected{% endif %}>right</option>
29
+ </select>
30
+ </div>
31
+
32
+ <fieldset>
33
+ <legend>{{ __('siteConfig.layout.navItems') }}</legend>
34
+ <table class="form-table">
35
+ <thead>
36
+ <tr><th>Label</th><th>URL</th></tr>
37
+ </thead>
38
+ <tbody>
39
+ {% for item in config.layout.navItems %}
40
+ <tr>
41
+ <td><input type="text" name="navLabel" value="{{ item.label }}"></td>
42
+ <td><input type="text" name="navUrl" value="{{ item.url }}"></td>
43
+ </tr>
44
+ {% endfor %}
45
+ <tr>
46
+ <td><input type="text" name="navLabel" placeholder="New label"></td>
47
+ <td><input type="text" name="navUrl" placeholder="/path or https://..."></td>
48
+ </tr>
49
+ </tbody>
50
+ </table>
51
+ </fieldset>
52
+
53
+ <button type="submit" class="button">{{ __('siteConfig.common.save') }}</button>
54
+ </form>
55
+ {% endblock %}