@rmdes/indiekit-endpoint-site-config 1.0.0-alpha.7 → 1.0.0-beta.2
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/index.js +45 -15
- package/lib/controllers/api.js +67 -0
- package/lib/controllers/blog.js +44 -0
- package/lib/controllers/branding.js +1 -1
- package/lib/controllers/homepage.js +121 -0
- package/lib/controllers/identity.js +53 -17
- package/lib/controllers/navigation.js +63 -0
- package/lib/discovery/scan-plugins.js +63 -0
- package/lib/presets/builtin-blog-post-widgets.js +14 -0
- package/lib/presets/builtin-sections.js +80 -0
- package/lib/presets/builtin-widgets.js +22 -0
- package/lib/presets/layout-presets.js +74 -0
- package/lib/render/resolve-tier2.js +1 -1
- package/lib/render/write-homepage-json.js +24 -0
- package/lib/render/write-site-json.js +21 -2
- package/lib/storage/backfill-identity.js +65 -0
- package/lib/storage/defaults-homepage.js +41 -0
- package/lib/storage/defaults-site.js +91 -0
- package/lib/storage/get-homepage-config.js +35 -0
- package/lib/storage/get-site-config.js +1 -1
- package/lib/storage/save-homepage-config.js +41 -0
- package/lib/storage/seed-from-env.js +46 -10
- package/lib/validators/identity.js +74 -0
- package/lib/validators/social.js +55 -0
- package/locales/en.json +131 -13
- package/locales/fr.json +194 -22
- package/package.json +1 -1
- package/views/partials/tab-strip.njk +7 -5
- package/views/site-config-blog.njk +587 -0
- package/views/site-config-homepage.njk +973 -0
- package/views/site-config-identity.njk +442 -40
- package/views/site-config-navigation.njk +97 -0
- package/lib/controllers/layout.js +0 -62
- package/lib/storage/defaults.js +0 -111
- package/views/site-config-layout.njk +0 -231
package/index.js
CHANGED
|
@@ -2,21 +2,31 @@ import express from "express";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
|
|
5
|
-
import { identityRouter
|
|
6
|
-
import { brandingRouter
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
5
|
+
import { identityRouter } from "./lib/controllers/identity.js";
|
|
6
|
+
import { brandingRouter } from "./lib/controllers/branding.js";
|
|
7
|
+
import { homepageRouter } from "./lib/controllers/homepage.js";
|
|
8
|
+
import { blogRouter } from "./lib/controllers/blog.js";
|
|
9
|
+
import { navigationRouter } from "./lib/controllers/navigation.js";
|
|
10
|
+
import { featuresRouter } from "./lib/controllers/features.js";
|
|
11
|
+
import { apiRouter } from "./lib/controllers/api.js";
|
|
12
|
+
|
|
13
|
+
import { getSiteConfig } from "./lib/storage/get-site-config.js";
|
|
14
|
+
import { getHomepageConfig } from "./lib/storage/get-homepage-config.js";
|
|
15
|
+
import { maybeSeedFromEnv } from "./lib/storage/seed-from-env.js";
|
|
16
|
+
import { maybeBackfillIdentity } from "./lib/storage/backfill-identity.js";
|
|
17
|
+
|
|
18
|
+
import { writeThemeCss } from "./lib/render/write-theme-css.js";
|
|
13
19
|
import { writeCriticalCss } from "./lib/render/write-critical-css.js";
|
|
14
|
-
import { writeSiteJson
|
|
20
|
+
import { writeSiteJson } from "./lib/render/write-site-json.js";
|
|
21
|
+
import { writeHomepageJson } from "./lib/render/write-homepage-json.js";
|
|
22
|
+
|
|
23
|
+
import { scanPlugins } from "./lib/discovery/scan-plugins.js";
|
|
15
24
|
|
|
16
25
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
26
|
|
|
18
27
|
const defaults = {
|
|
19
28
|
mountPath: "/site-config",
|
|
29
|
+
contentDir: "/app/data/content",
|
|
20
30
|
};
|
|
21
31
|
|
|
22
32
|
export default class SiteConfigEndpoint {
|
|
@@ -54,27 +64,47 @@ export default class SiteConfigEndpoint {
|
|
|
54
64
|
|
|
55
65
|
async init(Indiekit) {
|
|
56
66
|
Indiekit.addEndpoint(this);
|
|
67
|
+
Indiekit.addCollection("siteConfig");
|
|
68
|
+
Indiekit.addCollection("homepageConfig");
|
|
69
|
+
|
|
70
|
+
Indiekit.config.application.contentDir = this.options.contentDir;
|
|
71
|
+
|
|
57
72
|
this._apiRouter = apiRouter(Indiekit);
|
|
58
73
|
|
|
59
74
|
const protectedRouter = express.Router();
|
|
60
75
|
protectedRouter.get("/", (req, res) => res.redirect(`${this.mountPath}/identity`));
|
|
61
|
-
protectedRouter.use("/identity",
|
|
62
|
-
protectedRouter.use("/branding",
|
|
63
|
-
protectedRouter.use("/
|
|
64
|
-
protectedRouter.use("/
|
|
76
|
+
protectedRouter.use("/identity", identityRouter(Indiekit));
|
|
77
|
+
protectedRouter.use("/branding", brandingRouter(Indiekit));
|
|
78
|
+
protectedRouter.use("/homepage", homepageRouter(Indiekit));
|
|
79
|
+
protectedRouter.use("/blog", blogRouter(Indiekit));
|
|
80
|
+
protectedRouter.use("/navigation", navigationRouter(Indiekit));
|
|
81
|
+
protectedRouter.use("/features", featuresRouter(Indiekit));
|
|
65
82
|
|
|
66
83
|
this.routes = protectedRouter;
|
|
67
84
|
|
|
68
|
-
//
|
|
85
|
+
// First-boot seed + initial file write
|
|
69
86
|
try {
|
|
70
87
|
await maybeSeedFromEnv(Indiekit);
|
|
71
|
-
|
|
88
|
+
await maybeBackfillIdentity(Indiekit);
|
|
89
|
+
const config = await getSiteConfig(Indiekit);
|
|
90
|
+
const homepage = await getHomepageConfig(Indiekit);
|
|
72
91
|
await writeThemeCss(config);
|
|
73
92
|
await writeCriticalCss(config);
|
|
74
93
|
await writeSiteJson(config);
|
|
94
|
+
await writeHomepageJson(homepage);
|
|
75
95
|
} catch (error) {
|
|
76
96
|
console.warn("[site-config] initial render skipped:", error.message);
|
|
77
97
|
}
|
|
98
|
+
|
|
99
|
+
// Plugin discovery — defer until all plugins' init() has returned
|
|
100
|
+
const self = this;
|
|
101
|
+
process.nextTick(() => {
|
|
102
|
+
try {
|
|
103
|
+
scanPlugins(Indiekit, self);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.warn("[site-config] plugin discovery failed:", error.message);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
78
108
|
}
|
|
79
109
|
|
|
80
110
|
get routesPublic() {
|
package/lib/controllers/api.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import express from "express";
|
|
2
2
|
import { getSiteConfig, mergeWithDefaults } from "../storage/get-site-config.js";
|
|
3
|
+
import { getHomepageConfig } from "../storage/get-homepage-config.js";
|
|
3
4
|
import { renderThemeCss } from "../render/write-theme-css.js";
|
|
4
5
|
import { parseBrandingForm } from "./branding.js";
|
|
5
6
|
import { validateBranding } from "../validators/contrast.js";
|
|
@@ -111,6 +112,72 @@ export function apiRouter(Indiekit) {
|
|
|
111
112
|
}
|
|
112
113
|
});
|
|
113
114
|
|
|
115
|
+
/**
|
|
116
|
+
* GET /site-config/api/sections
|
|
117
|
+
*
|
|
118
|
+
* Discovery endpoint — returns the list of sections discovered from all
|
|
119
|
+
* registered plugins via the `homepageSections` collector. Consumed by the
|
|
120
|
+
* admin UI views (homepage composer) to populate the available-sections
|
|
121
|
+
* picker. Auth is enforced by the parent router mount (admin-only).
|
|
122
|
+
*/
|
|
123
|
+
router.get("/sections", (req, res) => {
|
|
124
|
+
res.setHeader("Cache-Control", "no-store");
|
|
125
|
+
res.json(Indiekit.config?.application?.discoveredSections || []);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* GET /site-config/api/widgets
|
|
130
|
+
*
|
|
131
|
+
* Discovery endpoint — returns the list of widgets discovered from all
|
|
132
|
+
* registered plugins via the `homepageWidgets` collector. Consumed by the
|
|
133
|
+
* admin UI views (sidebar composer). Auth is enforced by the parent
|
|
134
|
+
* router mount (admin-only).
|
|
135
|
+
*/
|
|
136
|
+
router.get("/widgets", (req, res) => {
|
|
137
|
+
res.setHeader("Cache-Control", "no-store");
|
|
138
|
+
res.json(Indiekit.config?.application?.discoveredWidgets || []);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* GET /site-config/api/blog-widgets
|
|
143
|
+
*
|
|
144
|
+
* Discovery endpoint — returns the list of blog-post-specific widgets
|
|
145
|
+
* discovered from all registered plugins via the `blogPostWidgets`
|
|
146
|
+
* collector. Consumed by the admin UI views (blog post sidebar composer).
|
|
147
|
+
* Auth is enforced by the parent router mount (admin-only).
|
|
148
|
+
*/
|
|
149
|
+
router.get("/blog-widgets", (req, res) => {
|
|
150
|
+
res.setHeader("Cache-Control", "no-store");
|
|
151
|
+
res.json(Indiekit.config?.application?.discoveredBlogPostWidgets || []);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* GET /site-config/api/homepage.json (PUBLIC)
|
|
156
|
+
*
|
|
157
|
+
* Public read-only view of the homepage config. Intended for external
|
|
158
|
+
* theme/SSG consumers (Eleventy, Hugo, Astro, etc.) that need to render
|
|
159
|
+
* the same layout shape Indiekit's own theme renders.
|
|
160
|
+
*
|
|
161
|
+
* Mounted under `routesPublic` (no auth). To avoid leaking PII, the
|
|
162
|
+
* `updatedBy` field (admin user URL) is stripped before sending.
|
|
163
|
+
*
|
|
164
|
+
* Security headers:
|
|
165
|
+
* - `Cache-Control: no-store` — operators flip layouts frequently
|
|
166
|
+
* - `X-Content-Type-Options: nosniff` — defense-in-depth against MIME
|
|
167
|
+
* confusion for a JSON endpoint that could be embedded in pages
|
|
168
|
+
*/
|
|
169
|
+
router.get("/homepage.json", async (req, res, next) => {
|
|
170
|
+
try {
|
|
171
|
+
const homepage = await getHomepageConfig(Indiekit);
|
|
172
|
+
const { updatedBy, ...publicShape } = homepage;
|
|
173
|
+
res.setHeader("Cache-Control", "no-store");
|
|
174
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
175
|
+
res.json(publicShape);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
next(error);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
114
181
|
return router;
|
|
115
182
|
}
|
|
116
183
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { getHomepageConfig } from "../storage/get-homepage-config.js";
|
|
3
|
+
import { saveHomepageConfig } from "../storage/save-homepage-config.js";
|
|
4
|
+
import { writeHomepageJson } from "../render/write-homepage-json.js";
|
|
5
|
+
import { parseEntryArray } from "./homepage.js";
|
|
6
|
+
|
|
7
|
+
export function parseBlogBody(body) {
|
|
8
|
+
return {
|
|
9
|
+
blogListingSidebar: parseEntryArray(body.blogListingSidebar),
|
|
10
|
+
blogPostSidebar: parseEntryArray(body.blogPostSidebar),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function blogRouter(Indiekit) {
|
|
15
|
+
const router = express.Router();
|
|
16
|
+
|
|
17
|
+
router.get("/", async (req, res, next) => {
|
|
18
|
+
try {
|
|
19
|
+
const homepage = await getHomepageConfig(Indiekit);
|
|
20
|
+
res.render("site-config-blog", {
|
|
21
|
+
homepage,
|
|
22
|
+
activeTab: "blog",
|
|
23
|
+
availableWidgets: Indiekit.config?.application?.discoveredWidgets || [],
|
|
24
|
+
availableBlogPostWidgets: Indiekit.config?.application?.discoveredBlogPostWidgets || [],
|
|
25
|
+
});
|
|
26
|
+
} catch (error) {
|
|
27
|
+
next(error);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
router.post("/", async (req, res, next) => {
|
|
32
|
+
try {
|
|
33
|
+
const patch = parseBlogBody(req.body);
|
|
34
|
+
const userIdent = Indiekit.config?.publication?.me || "unknown";
|
|
35
|
+
const updated = await saveHomepageConfig(Indiekit, patch, userIdent);
|
|
36
|
+
await writeHomepageJson(updated);
|
|
37
|
+
res.redirect("/site-config/blog?saved=1");
|
|
38
|
+
} catch (error) {
|
|
39
|
+
next(error);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return router;
|
|
44
|
+
}
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
derivePaletteFromBase,
|
|
18
18
|
} from "../render/derive-palette.js";
|
|
19
19
|
import { resolveTier2Defaults } from "../render/resolve-tier2.js";
|
|
20
|
-
import { ROLE_KEYS, DEFAULTS, emptyRoles } from "../storage/defaults.js";
|
|
20
|
+
import { ROLE_KEYS, DEFAULTS, emptyRoles } from "../storage/defaults-site.js";
|
|
21
21
|
import {
|
|
22
22
|
validateBranding,
|
|
23
23
|
partitionContrastResults,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { getSiteConfig } from "../storage/get-site-config.js";
|
|
3
|
+
import { getHomepageConfig } from "../storage/get-homepage-config.js";
|
|
4
|
+
import { saveHomepageConfig } from "../storage/save-homepage-config.js";
|
|
5
|
+
import { writeHomepageJson } from "../render/write-homepage-json.js";
|
|
6
|
+
import { LAYOUT_PRESETS } from "../presets/layout-presets.js";
|
|
7
|
+
|
|
8
|
+
const VALID_LAYOUTS = new Set(["single-column", "two-column", "full-width-hero"]);
|
|
9
|
+
|
|
10
|
+
export function parseEntryArray(value) {
|
|
11
|
+
if (Array.isArray(value)) return value;
|
|
12
|
+
if (typeof value === "string") {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(value);
|
|
15
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
16
|
+
} catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (value && typeof value === "object") {
|
|
21
|
+
return Object.values(value);
|
|
22
|
+
}
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function validLayout(raw) {
|
|
27
|
+
return VALID_LAYOUTS.has(raw) ? raw : "two-column";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseHomepageBody(body) {
|
|
31
|
+
return {
|
|
32
|
+
layout: validLayout(body.layout),
|
|
33
|
+
hero: {
|
|
34
|
+
enabled: body.heroEnabled === "on" || body.heroEnabled === true,
|
|
35
|
+
showSocial: body.heroShowSocial === "on" || body.heroShowSocial === true,
|
|
36
|
+
},
|
|
37
|
+
sections: parseEntryArray(body.sections),
|
|
38
|
+
sidebar: parseEntryArray(body.sidebar),
|
|
39
|
+
footer: parseEntryArray(body.footer),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function detectActivePreset(homepage, presets) {
|
|
44
|
+
for (const preset of presets) {
|
|
45
|
+
if (homepage.layout !== preset.layout) continue;
|
|
46
|
+
const configTypes = (homepage.sections || []).map((s) => s.type).join(",");
|
|
47
|
+
const presetTypes = preset.sections.map((s) => s.type).join(",");
|
|
48
|
+
if (configTypes !== presetTypes) continue;
|
|
49
|
+
const configWidgets = (homepage.sidebar || []).map((w) => w.type).join(",");
|
|
50
|
+
const presetWidgets = preset.sidebar.map((w) => w.type).join(",");
|
|
51
|
+
if (configWidgets !== presetWidgets) continue;
|
|
52
|
+
return preset.id;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function homepageRouter(Indiekit) {
|
|
58
|
+
const router = express.Router();
|
|
59
|
+
|
|
60
|
+
router.get("/", async (req, res, next) => {
|
|
61
|
+
try {
|
|
62
|
+
const config = await getSiteConfig(Indiekit);
|
|
63
|
+
const homepage = await getHomepageConfig(Indiekit);
|
|
64
|
+
const activePresetId = detectActivePreset(homepage, LAYOUT_PRESETS);
|
|
65
|
+
res.render("site-config-homepage", {
|
|
66
|
+
config,
|
|
67
|
+
homepage,
|
|
68
|
+
activeTab: "homepage",
|
|
69
|
+
layouts: [
|
|
70
|
+
{ id: "single-column", label: "Single Column" },
|
|
71
|
+
{ id: "two-column", label: "Two Column with Sidebar" },
|
|
72
|
+
{ id: "full-width-hero", label: "Full-width Hero + Grid" },
|
|
73
|
+
],
|
|
74
|
+
layoutPresets: LAYOUT_PRESETS,
|
|
75
|
+
availableSections: Indiekit.config?.application?.discoveredSections || [],
|
|
76
|
+
availableWidgets: Indiekit.config?.application?.discoveredWidgets || [],
|
|
77
|
+
activePresetId,
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
next(error);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
router.post("/", async (req, res, next) => {
|
|
85
|
+
try {
|
|
86
|
+
const patch = parseHomepageBody(req.body);
|
|
87
|
+
const userIdent = Indiekit.config?.publication?.me || "unknown";
|
|
88
|
+
const updated = await saveHomepageConfig(Indiekit, patch, userIdent);
|
|
89
|
+
await writeHomepageJson(updated);
|
|
90
|
+
res.redirect("/site-config/homepage?saved=1");
|
|
91
|
+
} catch (error) {
|
|
92
|
+
next(error);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
router.post("/apply-preset", async (req, res, next) => {
|
|
97
|
+
try {
|
|
98
|
+
const presetId = req.body.presetId;
|
|
99
|
+
const preset = LAYOUT_PRESETS.find((p) => p.id === presetId);
|
|
100
|
+
if (!preset) {
|
|
101
|
+
res.status(400).redirect("/site-config/homepage?error=preset");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const patch = {
|
|
105
|
+
layout: preset.layout,
|
|
106
|
+
hero: preset.hero,
|
|
107
|
+
sections: preset.sections,
|
|
108
|
+
sidebar: preset.sidebar,
|
|
109
|
+
footer: preset.footer,
|
|
110
|
+
};
|
|
111
|
+
const userIdent = Indiekit.config?.publication?.me || "unknown";
|
|
112
|
+
const updated = await saveHomepageConfig(Indiekit, patch, userIdent);
|
|
113
|
+
await writeHomepageJson(updated);
|
|
114
|
+
res.redirect("/site-config/homepage?saved=1");
|
|
115
|
+
} catch (error) {
|
|
116
|
+
next(error);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return router;
|
|
121
|
+
}
|
|
@@ -2,22 +2,68 @@ import express from "express";
|
|
|
2
2
|
import { getSiteConfig } from "../storage/get-site-config.js";
|
|
3
3
|
import { saveSiteConfig } from "../storage/save-site-config.js";
|
|
4
4
|
import { writeSiteJson } from "../render/write-site-json.js";
|
|
5
|
+
import {
|
|
6
|
+
isValidUrl, isValidEmail, isValidLocale,
|
|
7
|
+
normalizeCategoriesInput,
|
|
8
|
+
} from "../validators/identity.js";
|
|
9
|
+
import { sanitizeSocialList } from "../validators/social.js";
|
|
5
10
|
|
|
6
|
-
const LOCALE_RE = /^[a-z]{2}(-[A-Z]{2})?$/;
|
|
7
11
|
const TIMEZONE_RE = /^[A-Za-z]+\/[A-Za-z_\/]+$/;
|
|
8
12
|
|
|
9
13
|
function validLocale(raw) {
|
|
10
|
-
const v = raw
|
|
11
|
-
return
|
|
14
|
+
const v = (raw || "").trim();
|
|
15
|
+
return isValidLocale(v) ? v : "en";
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
function validTimezone(raw) {
|
|
15
|
-
const v = raw
|
|
16
|
-
// "UTC" is a special-case valid timezone that doesn't match the region/city pattern
|
|
19
|
+
const v = (raw || "").trim();
|
|
17
20
|
if (v === "UTC") return v;
|
|
18
21
|
return TIMEZONE_RE.test(v) ? v : "UTC";
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
function safeString(raw) {
|
|
25
|
+
return typeof raw === "string" ? raw.trim() : "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function safeUrlOrEmpty(raw) {
|
|
29
|
+
const v = safeString(raw);
|
|
30
|
+
return isValidUrl(v, { allowEmpty: true }) ? v : "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function safeEmailOrEmpty(raw) {
|
|
34
|
+
const v = safeString(raw);
|
|
35
|
+
return isValidEmail(v, { allowEmpty: true }) ? v : "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseSocialFromBody(body) {
|
|
39
|
+
if (!body.social) return [];
|
|
40
|
+
const raw = Array.isArray(body.social) ? body.social : Object.values(body.social);
|
|
41
|
+
return sanitizeSocialList(raw);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parseIdentityBody(body) {
|
|
45
|
+
return {
|
|
46
|
+
name: safeString(body.name),
|
|
47
|
+
avatar: safeUrlOrEmpty(body.avatar),
|
|
48
|
+
title: safeString(body.title),
|
|
49
|
+
pronoun: safeString(body.pronoun),
|
|
50
|
+
bio: safeString(body.bio),
|
|
51
|
+
description: safeString(body.description),
|
|
52
|
+
locality: safeString(body.locality),
|
|
53
|
+
country: safeString(body.country),
|
|
54
|
+
org: safeString(body.org),
|
|
55
|
+
url: safeUrlOrEmpty(body.url),
|
|
56
|
+
email: safeEmailOrEmpty(body.email),
|
|
57
|
+
keyUrl: safeUrlOrEmpty(body.keyUrl),
|
|
58
|
+
categories: normalizeCategoriesInput(body.categories),
|
|
59
|
+
social: parseSocialFromBody(body),
|
|
60
|
+
locale: validLocale(body.locale),
|
|
61
|
+
timezone: validTimezone(body.timezone),
|
|
62
|
+
defaultOgImage: safeUrlOrEmpty(body.defaultOgImage),
|
|
63
|
+
tagline: safeString(body.tagline),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
21
67
|
export function identityRouter(Indiekit) {
|
|
22
68
|
const router = express.Router();
|
|
23
69
|
|
|
@@ -35,19 +81,9 @@ export function identityRouter(Indiekit) {
|
|
|
35
81
|
|
|
36
82
|
router.post("/", async (req, res, next) => {
|
|
37
83
|
try {
|
|
38
|
-
const
|
|
39
|
-
identity: {
|
|
40
|
-
name: req.body.name?.trim() || "",
|
|
41
|
-
description: req.body.description?.trim() || "",
|
|
42
|
-
tagline: req.body.tagline?.trim() || "",
|
|
43
|
-
defaultAuthor: req.body.defaultAuthor?.trim() || "",
|
|
44
|
-
defaultOgImage: req.body.defaultOgImage?.trim() || "",
|
|
45
|
-
locale: validLocale(req.body.locale),
|
|
46
|
-
timezone: validTimezone(req.body.timezone),
|
|
47
|
-
},
|
|
48
|
-
};
|
|
84
|
+
const identity = parseIdentityBody(req.body);
|
|
49
85
|
const userIdent = Indiekit.config?.publication?.me || "unknown";
|
|
50
|
-
const updated = await saveSiteConfig(Indiekit,
|
|
86
|
+
const updated = await saveSiteConfig(Indiekit, { identity }, userIdent);
|
|
51
87
|
await writeSiteJson(updated);
|
|
52
88
|
res.redirect("/site-config/identity?saved=1");
|
|
53
89
|
} catch (error) {
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
function toArray(value) {
|
|
7
|
+
if (Array.isArray(value)) return value;
|
|
8
|
+
if (value === undefined || value === null) return [];
|
|
9
|
+
return [value];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isExternal(url) {
|
|
13
|
+
try {
|
|
14
|
+
const u = new URL(url);
|
|
15
|
+
return u.protocol === "http:" || u.protocol === "https:";
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseNavigationBody(body) {
|
|
22
|
+
const labels = toArray(body.navLabel).map((s) => (s || "").trim());
|
|
23
|
+
const urls = toArray(body.navUrl).map((s) => (s || "").trim());
|
|
24
|
+
const max = Math.max(labels.length, urls.length);
|
|
25
|
+
const items = [];
|
|
26
|
+
for (let i = 0; i < max; i++) {
|
|
27
|
+
const label = labels[i] || "";
|
|
28
|
+
const url = urls[i] || "";
|
|
29
|
+
if (label === "" && url === "") continue;
|
|
30
|
+
items.push({ label, url, external: isExternal(url) });
|
|
31
|
+
}
|
|
32
|
+
return { items };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function navigationRouter(Indiekit) {
|
|
36
|
+
const router = express.Router();
|
|
37
|
+
|
|
38
|
+
router.get("/", async (req, res, next) => {
|
|
39
|
+
try {
|
|
40
|
+
const config = await getSiteConfig(Indiekit);
|
|
41
|
+
res.render("site-config-navigation", {
|
|
42
|
+
config,
|
|
43
|
+
activeTab: "navigation",
|
|
44
|
+
});
|
|
45
|
+
} catch (error) {
|
|
46
|
+
next(error);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
router.post("/", async (req, res, next) => {
|
|
51
|
+
try {
|
|
52
|
+
const navigation = parseNavigationBody(req.body);
|
|
53
|
+
const userIdent = Indiekit.config?.publication?.me || "unknown";
|
|
54
|
+
const updated = await saveSiteConfig(Indiekit, { navigation }, userIdent);
|
|
55
|
+
await writeSiteJson(updated);
|
|
56
|
+
res.redirect("/site-config/navigation?saved=1");
|
|
57
|
+
} catch (error) {
|
|
58
|
+
next(error);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return router;
|
|
63
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin discovery — scan Indiekit.endpoints for homepageSections / homepageWidgets / blogPostWidgets.
|
|
3
|
+
* Merges with built-in presets, stores on Indiekit.config.application.
|
|
4
|
+
* @module discovery/scan-plugins
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { BUILTIN_SECTIONS } from "../presets/builtin-sections.js";
|
|
8
|
+
import { BUILTIN_WIDGETS } from "../presets/builtin-widgets.js";
|
|
9
|
+
import { BUILTIN_BLOG_POST_WIDGETS } from "../presets/builtin-blog-post-widgets.js";
|
|
10
|
+
|
|
11
|
+
function validEntry(entry) {
|
|
12
|
+
return entry && typeof entry.id === "string" && entry.id !== ""
|
|
13
|
+
&& typeof entry.label === "string" && entry.label !== "";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function scanPlugins(Indiekit, ownEndpoint) {
|
|
17
|
+
const sections = [...BUILTIN_SECTIONS];
|
|
18
|
+
const widgets = [...BUILTIN_WIDGETS];
|
|
19
|
+
const blogPostWidgets = [...BUILTIN_BLOG_POST_WIDGETS];
|
|
20
|
+
|
|
21
|
+
for (const endpoint of Indiekit.endpoints || []) {
|
|
22
|
+
if (endpoint === ownEndpoint) continue;
|
|
23
|
+
try {
|
|
24
|
+
const ep = endpoint;
|
|
25
|
+
if (ep.homepageSections) {
|
|
26
|
+
for (const s of ep.homepageSections) {
|
|
27
|
+
if (!validEntry(s)) {
|
|
28
|
+
console.warn(`[site-config] skipping malformed section from ${ep.name}`);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
sections.push({ ...s, sourcePlugin: ep.name });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (ep.homepageWidgets) {
|
|
35
|
+
for (const w of ep.homepageWidgets) {
|
|
36
|
+
if (!validEntry(w)) {
|
|
37
|
+
console.warn(`[site-config] skipping malformed widget from ${ep.name}`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
widgets.push({ ...w, sourcePlugin: ep.name });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (ep.blogPostWidgets) {
|
|
44
|
+
for (const w of ep.blogPostWidgets) {
|
|
45
|
+
if (!validEntry(w)) continue;
|
|
46
|
+
blogPostWidgets.push({ ...w, sourcePlugin: ep.name });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.warn(`[site-config] failed to read discovery getters from ${endpoint.name}: ${error.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
Indiekit.config.application.discoveredSections = sections;
|
|
55
|
+
Indiekit.config.application.discoveredWidgets = widgets;
|
|
56
|
+
Indiekit.config.application.discoveredBlogPostWidgets = [...blogPostWidgets, ...widgets];
|
|
57
|
+
|
|
58
|
+
console.log(
|
|
59
|
+
`[site-config] discovered ${sections.length} sections, ` +
|
|
60
|
+
`${widgets.length} widgets, ` +
|
|
61
|
+
`${Indiekit.config.application.discoveredBlogPostWidgets.length} blog-post widgets`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in blog-post-specific sidebar widgets absorbed from @rmdes/indiekit-endpoint-homepage.
|
|
3
|
+
* Universal sidebar widgets (from builtin-widgets.js) are merged in at scan time.
|
|
4
|
+
* @module presets/builtin-blog-post-widgets
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const BUILTIN_BLOG_POST_WIDGETS = Object.freeze([
|
|
8
|
+
{ id: "author-card-compact", label: "Author Card (Compact)", description: "Compact h-card with avatar and name", icon: "user", defaultConfig: {}, configSchema: {} },
|
|
9
|
+
{ id: "toc", label: "Table of Contents", description: "Auto-generated from headings", icon: "list", defaultConfig: {}, configSchema: {} },
|
|
10
|
+
{ id: "post-categories", label: "Post Categories", description: "Categories for the current post", icon: "tag", defaultConfig: {}, configSchema: {} },
|
|
11
|
+
{ id: "share", label: "Share", description: "Share on Bluesky and Mastodon", icon: "share", defaultConfig: {}, configSchema: {} },
|
|
12
|
+
{ id: "subscribe", label: "Subscribe", description: "RSS and JSON feed links", icon: "rss", defaultConfig: {}, configSchema: {} },
|
|
13
|
+
{ id: "recent-comments", label: "Recent Comments", description: "Latest IndieAuth comments", icon: "message-square", defaultConfig: {}, configSchema: {} },
|
|
14
|
+
]);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in section types absorbed from @rmdes/indiekit-endpoint-homepage.
|
|
3
|
+
* Pure data export — no logic.
|
|
4
|
+
* @module presets/builtin-sections
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const BUILTIN_SECTIONS = Object.freeze([
|
|
8
|
+
{
|
|
9
|
+
id: "hero",
|
|
10
|
+
label: "Hero Section",
|
|
11
|
+
description: "Author intro with avatar, name, title, and bio",
|
|
12
|
+
icon: "user",
|
|
13
|
+
dataEndpoint: null,
|
|
14
|
+
defaultConfig: { showAvatar: true, showSocialLinks: true },
|
|
15
|
+
configSchema: {
|
|
16
|
+
showAvatar: { type: "boolean", label: "Show avatar" },
|
|
17
|
+
showSocialLinks: { type: "boolean", label: "Show social links" },
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: "featured-posts",
|
|
22
|
+
label: "Featured Posts",
|
|
23
|
+
description: "Curated posts pinned as featured",
|
|
24
|
+
icon: "star",
|
|
25
|
+
dataEndpoint: null,
|
|
26
|
+
defaultConfig: { maxItems: 6, showSummary: true },
|
|
27
|
+
configSchema: {
|
|
28
|
+
maxItems: { type: "number", label: "Max items", min: 1, max: 20 },
|
|
29
|
+
showSummary: { type: "boolean", label: "Show post summary" },
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: "recent-posts",
|
|
34
|
+
label: "Recent Posts",
|
|
35
|
+
description: "Latest posts from your blog",
|
|
36
|
+
icon: "file-text",
|
|
37
|
+
dataEndpoint: null,
|
|
38
|
+
defaultConfig: { maxItems: 10, postTypes: ["note", "article", "photo", "bookmark"] },
|
|
39
|
+
configSchema: {
|
|
40
|
+
maxItems: { type: "number", label: "Max items", min: 1, max: 50 },
|
|
41
|
+
postTypes: { type: "array", label: "Post types to include" },
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "custom-html",
|
|
46
|
+
label: "Custom Content",
|
|
47
|
+
description: "Freeform HTML or Markdown block",
|
|
48
|
+
icon: "code",
|
|
49
|
+
dataEndpoint: null,
|
|
50
|
+
defaultConfig: { content: "" },
|
|
51
|
+
configSchema: {
|
|
52
|
+
content: { type: "textarea", label: "Content (HTML/Markdown)" },
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "posting-activity",
|
|
57
|
+
label: "Posting Activity",
|
|
58
|
+
description: "GitHub-style contribution graph showing posting frequency",
|
|
59
|
+
icon: "activity",
|
|
60
|
+
dataEndpoint: null,
|
|
61
|
+
defaultConfig: { title: "Posting Activity", limit: 1 },
|
|
62
|
+
configSchema: {
|
|
63
|
+
title: { type: "text", label: "Section title" },
|
|
64
|
+
years: { type: "text", label: "Years to show (comma-separated, e.g. 2026,2025)" },
|
|
65
|
+
limit: { type: "number", label: "Number of years (ignored if specific years set)", min: 0, max: 10 },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "ai-usage",
|
|
70
|
+
label: "AI Transparency",
|
|
71
|
+
description: "AI usage stats, level breakdown, and contribution graph",
|
|
72
|
+
icon: "zap",
|
|
73
|
+
dataEndpoint: null,
|
|
74
|
+
defaultConfig: { title: "AI Transparency", limit: 1 },
|
|
75
|
+
configSchema: {
|
|
76
|
+
title: { type: "text", label: "Section title" },
|
|
77
|
+
limit: { type: "number", label: "Years to show in graph", min: 1, max: 10 },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
]);
|