@rmdes/indiekit-endpoint-site-config 1.0.0-beta.6 → 1.0.0-beta.8
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 +2 -0
- package/lib/controllers/general.js +71 -0
- package/lib/controllers/homepage.js +16 -0
- package/lib/render/write-site-json.js +1 -0
- package/lib/storage/defaults-homepage.js +5 -0
- package/lib/storage/defaults-site.js +8 -0
- package/locales/en.json +24 -2
- package/locales/fr.json +24 -2
- package/package.json +1 -1
- package/views/partials/tab-strip.njk +2 -1
- package/views/site-config-general.njk +76 -0
- package/views/site-config-homepage.njk +12 -0
package/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { brandingRouter } from "./lib/controllers/branding.js";
|
|
|
7
7
|
import { homepageRouter } from "./lib/controllers/homepage.js";
|
|
8
8
|
import { blogRouter } from "./lib/controllers/blog.js";
|
|
9
9
|
import { navigationRouter } from "./lib/controllers/navigation.js";
|
|
10
|
+
import { generalRouter } from "./lib/controllers/general.js";
|
|
10
11
|
import { apiRouter } from "./lib/controllers/api.js";
|
|
11
12
|
|
|
12
13
|
import { getSiteConfig } from "./lib/storage/get-site-config.js";
|
|
@@ -77,6 +78,7 @@ export default class SiteConfigEndpoint {
|
|
|
77
78
|
protectedRouter.use("/homepage", homepageRouter(Indiekit));
|
|
78
79
|
protectedRouter.use("/blog", blogRouter(Indiekit));
|
|
79
80
|
protectedRouter.use("/navigation", navigationRouter(Indiekit));
|
|
81
|
+
protectedRouter.use("/general", generalRouter(Indiekit));
|
|
80
82
|
|
|
81
83
|
this.routes = protectedRouter;
|
|
82
84
|
|
|
@@ -0,0 +1,71 @@
|
|
|
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 { isValidUrl } from "../validators/identity.js";
|
|
6
|
+
|
|
7
|
+
function safeString(raw) {
|
|
8
|
+
return typeof raw === "string" ? raw.trim() : "";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Coerce an HTML checkbox value to a boolean.
|
|
13
|
+
* Unchecked checkboxes are absent from the body; checked ones send "on".
|
|
14
|
+
* @param {*} raw - Raw body value
|
|
15
|
+
* @returns {boolean}
|
|
16
|
+
*/
|
|
17
|
+
function checkbox(raw) {
|
|
18
|
+
return raw === "on" || raw === "true" || raw === true || raw === "1";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Accept an absolute URL or a root-relative path (e.g. "/ai", "/about/ai",
|
|
23
|
+
* "https://example.com/ai-policy"). Falls back to the default when invalid.
|
|
24
|
+
* @param {*} raw - Raw body value
|
|
25
|
+
* @param {string} [fallback] - Default link
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
function safeLinkOrDefault(raw, fallback = "/ai") {
|
|
29
|
+
const v = safeString(raw);
|
|
30
|
+
if (!v) return fallback;
|
|
31
|
+
if (v.startsWith("/")) return v; // root-relative path
|
|
32
|
+
if (isValidUrl(v, { allowEmpty: false })) return v; // absolute URL
|
|
33
|
+
return fallback;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseGeneralBody(body) {
|
|
37
|
+
return {
|
|
38
|
+
aiTransparency: checkbox(body.aiTransparency),
|
|
39
|
+
aiTransparencyUrl: safeLinkOrDefault(body.aiTransparencyUrl),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function generalRouter(Indiekit) {
|
|
44
|
+
const router = express.Router();
|
|
45
|
+
|
|
46
|
+
router.get("/", async (req, res, next) => {
|
|
47
|
+
try {
|
|
48
|
+
const config = await getSiteConfig(Indiekit);
|
|
49
|
+
res.render("site-config-general", {
|
|
50
|
+
config,
|
|
51
|
+
activeTab: "general",
|
|
52
|
+
});
|
|
53
|
+
} catch (error) {
|
|
54
|
+
next(error);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
router.post("/", async (req, res, next) => {
|
|
59
|
+
try {
|
|
60
|
+
const features = parseGeneralBody(req.body);
|
|
61
|
+
const userIdent = Indiekit.config?.publication?.me || "unknown";
|
|
62
|
+
const updated = await saveSiteConfig(Indiekit, { features }, userIdent);
|
|
63
|
+
await writeSiteJson(updated);
|
|
64
|
+
res.redirect("/site-config/general?saved=1");
|
|
65
|
+
} catch (error) {
|
|
66
|
+
next(error);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return router;
|
|
71
|
+
}
|
|
@@ -7,6 +7,20 @@ import { LAYOUT_PRESETS } from "../presets/layout-presets.js";
|
|
|
7
7
|
|
|
8
8
|
const VALID_LAYOUTS = new Set(["single-column", "two-column", "full-width-hero"]);
|
|
9
9
|
|
|
10
|
+
// Hero "read more" CTA: accept a root-relative path or absolute URL, else fall
|
|
11
|
+
// back to /about. Text is free-form (localizable per site), defaulting to "Read more".
|
|
12
|
+
function safeHeroLink(raw, fallback = "/about") {
|
|
13
|
+
const v = typeof raw === "string" ? raw.trim() : "";
|
|
14
|
+
if (!v) return fallback;
|
|
15
|
+
if (v.startsWith("/")) return v;
|
|
16
|
+
try { new URL(v); return v; } catch { return fallback; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function safeHeroText(raw, fallback = "Read more") {
|
|
20
|
+
const v = typeof raw === "string" ? raw.trim() : "";
|
|
21
|
+
return v || fallback;
|
|
22
|
+
}
|
|
23
|
+
|
|
10
24
|
export function parseEntryArray(value) {
|
|
11
25
|
if (Array.isArray(value)) return value;
|
|
12
26
|
if (typeof value === "string") {
|
|
@@ -33,6 +47,8 @@ export function parseHomepageBody(body) {
|
|
|
33
47
|
hero: {
|
|
34
48
|
enabled: body.heroEnabled === "on" || body.heroEnabled === true,
|
|
35
49
|
showSocial: body.heroShowSocial === "on" || body.heroShowSocial === true,
|
|
50
|
+
ctaText: safeHeroText(body.heroCtaText),
|
|
51
|
+
ctaUrl: safeHeroLink(body.heroCtaUrl),
|
|
36
52
|
},
|
|
37
53
|
sections: parseEntryArray(body.sections),
|
|
38
54
|
sidebar: parseEntryArray(body.sidebar),
|
|
@@ -47,6 +47,7 @@ export function renderSiteJson(config) {
|
|
|
47
47
|
identity: config.identity,
|
|
48
48
|
branding: config.branding,
|
|
49
49
|
navigation: config.navigation,
|
|
50
|
+
features: config.features,
|
|
50
51
|
updatedAt: config.updatedAt,
|
|
51
52
|
};
|
|
52
53
|
const replacer = (key, value) => (PRIVATE_KEYS.has(key) ? undefined : value);
|
|
@@ -20,6 +20,11 @@ export const DEFAULTS_HOMEPAGE = Object.freeze({
|
|
|
20
20
|
hero: Object.freeze({
|
|
21
21
|
enabled: true,
|
|
22
22
|
showSocial: true,
|
|
23
|
+
// "Read more" call-to-action shown after the site description in the hero.
|
|
24
|
+
// ctaText is free-text (localize per site, e.g. "À propos"); ctaUrl is the
|
|
25
|
+
// target (default /about — keeps existing sites unchanged).
|
|
26
|
+
ctaText: "Read more",
|
|
27
|
+
ctaUrl: "/about",
|
|
23
28
|
}),
|
|
24
29
|
sections: Object.freeze([
|
|
25
30
|
Object.freeze({
|
|
@@ -83,6 +83,14 @@ export const DEFAULTS_SITE = Object.freeze({
|
|
|
83
83
|
navigation: Object.freeze({
|
|
84
84
|
items: Object.freeze([]),
|
|
85
85
|
}),
|
|
86
|
+
// Site-wide feature toggles. `aiTransparency` gates the theme's per-post AI
|
|
87
|
+
// usage disclosure (off by default — not every site uses AI); when on, the
|
|
88
|
+
// disclosure renders on every post/page. `aiTransparencyUrl` is the
|
|
89
|
+
// customizable "learn more" link target (defaults to the /ai page).
|
|
90
|
+
features: Object.freeze({
|
|
91
|
+
aiTransparency: false,
|
|
92
|
+
aiTransparencyUrl: "/ai",
|
|
93
|
+
}),
|
|
86
94
|
});
|
|
87
95
|
|
|
88
96
|
// Legacy export name for any code path that hasn't been updated yet.
|
package/locales/en.json
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
"branding": "Branding",
|
|
8
8
|
"homepage": "Homepage",
|
|
9
9
|
"blog": "Blog",
|
|
10
|
-
"navigation": "Navigation"
|
|
10
|
+
"navigation": "Navigation",
|
|
11
|
+
"general": "General"
|
|
11
12
|
},
|
|
12
13
|
"common": {
|
|
13
14
|
"save": "Save",
|
|
@@ -16,6 +17,19 @@
|
|
|
16
17
|
"reset": "Reset",
|
|
17
18
|
"preview": "Live preview"
|
|
18
19
|
},
|
|
20
|
+
"general": {
|
|
21
|
+
"aiTransparency": {
|
|
22
|
+
"title": "AI transparency",
|
|
23
|
+
"description": "Show an AI usage disclosure on posts and pages. Turn this on only if you use AI to help draft or write content — when off, no AI disclosure is shown anywhere on the site.",
|
|
24
|
+
"label": "Show AI usage disclosure",
|
|
25
|
+
"hint": "When enabled, every post and page shows its AI usage (text/code level and tools) at the bottom.",
|
|
26
|
+
"urlLabel": "Learn-more link"
|
|
27
|
+
},
|
|
28
|
+
"aiTransparencyUrl": {
|
|
29
|
+
"label": "“Learn more” link",
|
|
30
|
+
"hint": "Where the “Learn more about AI usage” link points. Defaults to /ai; use any path (e.g. /about/ai) or full URL."
|
|
31
|
+
}
|
|
32
|
+
},
|
|
19
33
|
"identity": {
|
|
20
34
|
"title": "Identity",
|
|
21
35
|
"saved": "Identity saved successfully. Refresh your site to see changes.",
|
|
@@ -156,7 +170,15 @@
|
|
|
156
170
|
"hero": {
|
|
157
171
|
"title": "Hero Section",
|
|
158
172
|
"enabled": "Show hero section with author info",
|
|
159
|
-
"showSocial": "Show social links in hero"
|
|
173
|
+
"showSocial": "Show social links in hero",
|
|
174
|
+
"ctaText": {
|
|
175
|
+
"label": "“Read more” link text",
|
|
176
|
+
"hint": "Text for the call-to-action shown after the site description (e.g. “Read more”, “À propos”, “En savoir plus”)."
|
|
177
|
+
},
|
|
178
|
+
"ctaUrl": {
|
|
179
|
+
"label": "“Read more” link target",
|
|
180
|
+
"hint": "Where the hero “read more” link points. Defaults to /about; use any path (e.g. /apropos) or a full URL."
|
|
181
|
+
}
|
|
160
182
|
},
|
|
161
183
|
"sections": {
|
|
162
184
|
"title": "Content Sections",
|
package/locales/fr.json
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
"branding": "Identité visuelle",
|
|
8
8
|
"homepage": "Accueil",
|
|
9
9
|
"blog": "Blog",
|
|
10
|
-
"navigation": "Navigation"
|
|
10
|
+
"navigation": "Navigation",
|
|
11
|
+
"general": "Général"
|
|
11
12
|
},
|
|
12
13
|
"common": {
|
|
13
14
|
"save": "Enregistrer",
|
|
@@ -16,6 +17,19 @@
|
|
|
16
17
|
"reset": "Réinitialiser",
|
|
17
18
|
"preview": "Aperçu en direct"
|
|
18
19
|
},
|
|
20
|
+
"general": {
|
|
21
|
+
"aiTransparency": {
|
|
22
|
+
"title": "Transparence IA",
|
|
23
|
+
"description": "Afficher une mention d'utilisation de l'IA sur les billets et les pages. À activer uniquement si vous utilisez l'IA pour rédiger du contenu — désactivé, aucune mention IA n'apparaît sur le site.",
|
|
24
|
+
"label": "Afficher la mention d'utilisation de l'IA",
|
|
25
|
+
"hint": "Une fois activé, chaque billet et page affiche son utilisation de l'IA (niveau texte/code et outils) en bas de page.",
|
|
26
|
+
"urlLabel": "Lien « en savoir plus »"
|
|
27
|
+
},
|
|
28
|
+
"aiTransparencyUrl": {
|
|
29
|
+
"label": "Lien « en savoir plus »",
|
|
30
|
+
"hint": "Cible du lien « en savoir plus sur l'utilisation de l'IA ». Par défaut /ai ; utilisez un chemin (ex. /about/ai) ou une URL complète."
|
|
31
|
+
}
|
|
32
|
+
},
|
|
19
33
|
"identity": {
|
|
20
34
|
"title": "Identité",
|
|
21
35
|
"saved": "Identité enregistrée avec succès. Actualisez votre site pour voir les modifications.",
|
|
@@ -156,7 +170,15 @@
|
|
|
156
170
|
"hero": {
|
|
157
171
|
"title": "Section Hero",
|
|
158
172
|
"enabled": "Afficher la section hero avec les informations sur l'auteur",
|
|
159
|
-
"showSocial": "Afficher les liens sociaux dans le hero"
|
|
173
|
+
"showSocial": "Afficher les liens sociaux dans le hero",
|
|
174
|
+
"ctaText": {
|
|
175
|
+
"label": "Texte du lien « en savoir plus »",
|
|
176
|
+
"hint": "Texte de l'appel à l'action affiché après la description du site (ex. « À propos », « En savoir plus »)."
|
|
177
|
+
},
|
|
178
|
+
"ctaUrl": {
|
|
179
|
+
"label": "Cible du lien « en savoir plus »",
|
|
180
|
+
"hint": "Destination du lien « en savoir plus » du hero. Par défaut /about ; utilisez un chemin (ex. /apropos) ou une URL complète."
|
|
181
|
+
}
|
|
160
182
|
},
|
|
161
183
|
"sections": {
|
|
162
184
|
"title": "Sections de contenu",
|
package/package.json
CHANGED
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
{key: 'branding', href: '/site-config/branding'},
|
|
32
32
|
{key: 'homepage', href: '/site-config/homepage'},
|
|
33
33
|
{key: 'blog', href: '/site-config/blog'},
|
|
34
|
-
{key: 'navigation', href: '/site-config/navigation'}
|
|
34
|
+
{key: 'navigation', href: '/site-config/navigation'},
|
|
35
|
+
{key: 'general', href: '/site-config/general'}
|
|
35
36
|
] %}
|
|
36
37
|
{% for tab in tabs %}
|
|
37
38
|
<a href="{{ tab.href }}"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{% extends "document.njk" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}{{ __('siteConfig.tabs.general') }}{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block content %}
|
|
6
|
+
<style>
|
|
7
|
+
.sc-form { display: flex; flex-direction: column; gap: var(--space-xl, 2rem); }
|
|
8
|
+
.sc-section {
|
|
9
|
+
background: var(--color-offset, #f5f5f5);
|
|
10
|
+
border-radius: var(--border-radius-small, 0.5rem);
|
|
11
|
+
padding: var(--space-m, 1.5rem);
|
|
12
|
+
}
|
|
13
|
+
.sc-section h2 {
|
|
14
|
+
font: var(--font-heading, bold 1.25rem/1.4 sans-serif);
|
|
15
|
+
margin-block-end: var(--space-s, 0.75rem);
|
|
16
|
+
padding-block-end: var(--space-xs, 0.5rem);
|
|
17
|
+
border-block-end: 1px solid var(--color-outline-variant, #ddd);
|
|
18
|
+
}
|
|
19
|
+
.sc-section__desc {
|
|
20
|
+
color: var(--color-on-offset, #666);
|
|
21
|
+
margin-block-end: var(--space-m, 1.25rem);
|
|
22
|
+
}
|
|
23
|
+
.sc-field { margin-block-end: var(--space-m, 1.25rem); }
|
|
24
|
+
.sc-field--toggle { display: flex; align-items: flex-start; gap: var(--space-s, 0.75rem); }
|
|
25
|
+
.sc-field--toggle input { margin-block-start: 0.25rem; }
|
|
26
|
+
.sc-field__label { display: block; font-weight: 600; margin-block-end: var(--space-xs, 0.4rem); }
|
|
27
|
+
.sc-field__input {
|
|
28
|
+
width: 100%; max-width: 28rem;
|
|
29
|
+
padding: var(--space-xs, 0.5rem) var(--space-s, 0.75rem);
|
|
30
|
+
border: 1px solid var(--color-outline-variant, #ccc);
|
|
31
|
+
border-radius: var(--border-radius-small, 0.375rem);
|
|
32
|
+
font: inherit;
|
|
33
|
+
}
|
|
34
|
+
.sc-field__hint { color: var(--color-on-offset, #666); font-size: 0.875rem; margin-block-start: var(--space-xs, 0.4rem); }
|
|
35
|
+
</style>
|
|
36
|
+
|
|
37
|
+
<header class="page-header">
|
|
38
|
+
<h1 class="page-header__title">{{ __("siteConfig.title") }}</h1>
|
|
39
|
+
<p class="page-header__description">{{ __("siteConfig.description") }}</p>
|
|
40
|
+
</header>
|
|
41
|
+
|
|
42
|
+
{% include "partials/tab-strip.njk" %}
|
|
43
|
+
|
|
44
|
+
{% if request.query.saved %}
|
|
45
|
+
<div class="notice notice--success" role="status">
|
|
46
|
+
<p>{{ __("siteConfig.common.saved") }}</p>
|
|
47
|
+
</div>
|
|
48
|
+
{% endif %}
|
|
49
|
+
|
|
50
|
+
<form method="post" action="/site-config/general" class="sc-form">
|
|
51
|
+
<section class="sc-section">
|
|
52
|
+
<h2>{{ __("siteConfig.general.aiTransparency.title") }}</h2>
|
|
53
|
+
<p class="sc-section__desc">{{ __("siteConfig.general.aiTransparency.description") }}</p>
|
|
54
|
+
|
|
55
|
+
<div class="sc-field sc-field--toggle">
|
|
56
|
+
<input type="checkbox" id="aiTransparency" name="aiTransparency"
|
|
57
|
+
{% if config.features.aiTransparency %}checked{% endif %}>
|
|
58
|
+
<label for="aiTransparency">
|
|
59
|
+
<span class="sc-field__label">{{ __("siteConfig.general.aiTransparency.label") }}</span>
|
|
60
|
+
<span class="sc-field__hint">{{ __("siteConfig.general.aiTransparency.hint") }}</span>
|
|
61
|
+
</label>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div class="sc-field">
|
|
65
|
+
<label class="sc-field__label" for="aiTransparencyUrl">{{ __("siteConfig.general.aiTransparencyUrl.label") }}</label>
|
|
66
|
+
<input class="sc-field__input" type="text" id="aiTransparencyUrl" name="aiTransparencyUrl"
|
|
67
|
+
value="{{ config.features.aiTransparencyUrl or '/ai' }}" placeholder="/ai">
|
|
68
|
+
<p class="sc-field__hint">{{ __("siteConfig.general.aiTransparencyUrl.hint") }}</p>
|
|
69
|
+
</div>
|
|
70
|
+
</section>
|
|
71
|
+
|
|
72
|
+
<div class="button-group">
|
|
73
|
+
<button type="submit" class="button button--primary">{{ __("siteConfig.common.save") }}</button>
|
|
74
|
+
</div>
|
|
75
|
+
</form>
|
|
76
|
+
{% endblock %}
|
|
@@ -400,6 +400,18 @@
|
|
|
400
400
|
{{ __("siteConfig.homepage.hero.showSocial") }}
|
|
401
401
|
</label>
|
|
402
402
|
</div>
|
|
403
|
+
<div class="field">
|
|
404
|
+
<label class="field__label" for="heroCtaText">{{ __("siteConfig.homepage.hero.ctaText.label") }}</label>
|
|
405
|
+
<input class="field__input" type="text" id="heroCtaText" name="heroCtaText"
|
|
406
|
+
value="{{ homepage.hero.ctaText or 'Read more' }}" placeholder="Read more">
|
|
407
|
+
<p class="field__hint">{{ __("siteConfig.homepage.hero.ctaText.hint") }}</p>
|
|
408
|
+
</div>
|
|
409
|
+
<div class="field">
|
|
410
|
+
<label class="field__label" for="heroCtaUrl">{{ __("siteConfig.homepage.hero.ctaUrl.label") }}</label>
|
|
411
|
+
<input class="field__input" type="text" id="heroCtaUrl" name="heroCtaUrl"
|
|
412
|
+
value="{{ homepage.hero.ctaUrl or '/about' }}" placeholder="/about">
|
|
413
|
+
<p class="field__hint">{{ __("siteConfig.homepage.hero.ctaUrl.hint") }}</p>
|
|
414
|
+
</div>
|
|
403
415
|
</section>
|
|
404
416
|
|
|
405
417
|
{# Content Sections #}
|