@rmdes/indiekit-endpoint-site-config 1.0.0-beta.5 → 1.0.0-beta.7
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/identity.js +1 -0
- package/lib/render/write-site-json.js +1 -0
- package/lib/storage/defaults-site.js +13 -0
- package/locales/en.json +17 -3
- package/locales/fr.json +17 -3
- 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-identity.njk +6 -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
|
+
}
|
|
@@ -44,6 +44,7 @@ function parseSocialFromBody(body) {
|
|
|
44
44
|
export function parseIdentityBody(body) {
|
|
45
45
|
return {
|
|
46
46
|
name: safeString(body.name),
|
|
47
|
+
siteName: safeString(body.siteName),
|
|
47
48
|
avatar: safeUrlOrEmpty(body.avatar),
|
|
48
49
|
title: safeString(body.title),
|
|
49
50
|
pronoun: safeString(body.pronoun),
|
|
@@ -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);
|
|
@@ -37,6 +37,11 @@ export const DEFAULTS_SITE = Object.freeze({
|
|
|
37
37
|
schemaVersion: 3,
|
|
38
38
|
identity: Object.freeze({
|
|
39
39
|
name: "",
|
|
40
|
+
// Site title / brand — drives the header, <title>, og:site_name and
|
|
41
|
+
// schema.org publisher. Distinct from `name` (the person, used by the
|
|
42
|
+
// h-card / hero). Empty falls back to `name` in the theme so single-author
|
|
43
|
+
// sites where the brand IS the person keep working with one field.
|
|
44
|
+
siteName: "",
|
|
40
45
|
avatar: "",
|
|
41
46
|
title: "",
|
|
42
47
|
pronoun: "",
|
|
@@ -78,6 +83,14 @@ export const DEFAULTS_SITE = Object.freeze({
|
|
|
78
83
|
navigation: Object.freeze({
|
|
79
84
|
items: Object.freeze([]),
|
|
80
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
|
+
}),
|
|
81
94
|
});
|
|
82
95
|
|
|
83
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,12 +17,25 @@
|
|
|
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.",
|
|
22
36
|
"profile": {
|
|
23
37
|
"legend": "Profile",
|
|
24
|
-
"name": { "label": "Name", "hint": "
|
|
38
|
+
"name": { "label": "Name", "hint": "The person's name, shown in the h-card, hero, and author cards. For the site/brand title, use Site title below." },
|
|
25
39
|
"title": { "label": "Title", "hint": "Job title or tagline (e.g. 'Middleware Engineer')" },
|
|
26
40
|
"avatar": { "label": "Avatar URL", "hint": "URL of your profile photo for the h-card" },
|
|
27
41
|
"pronoun": { "label": "Pronouns", "hint": "e.g. she/her, he/him, they/them" },
|
|
@@ -55,7 +69,7 @@
|
|
|
55
69
|
"preferences": {
|
|
56
70
|
"legend": "Site preferences"
|
|
57
71
|
},
|
|
58
|
-
"
|
|
72
|
+
"siteName": { "label": "Site title", "hint": "Shown in the site header, browser tab, and link previews. Leave blank to use your Name above (for single-author sites where the brand is the person)." },
|
|
59
73
|
"description": "Description",
|
|
60
74
|
"tagline": "Tagline",
|
|
61
75
|
"defaultOgImage": "Default OpenGraph image URL",
|
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,12 +17,25 @@
|
|
|
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.",
|
|
22
36
|
"profile": {
|
|
23
37
|
"legend": "Profil",
|
|
24
|
-
"name": { "label": "Nom", "hint": "
|
|
38
|
+
"name": { "label": "Nom", "hint": "Le nom de la personne, affiché dans la h-card, le hero et les cartes d'auteur. Pour le titre du site/de la marque, utilisez Titre du site ci-dessous." },
|
|
25
39
|
"title": { "label": "Titre", "hint": "Intitulé de poste ou slogan (ex. « Ingénieur middleware »)" },
|
|
26
40
|
"avatar": { "label": "URL de l'avatar","hint": "URL de votre photo de profil pour la h-card" },
|
|
27
41
|
"pronoun": { "label": "Pronoms", "hint": "ex. elle, il, iel" },
|
|
@@ -55,7 +69,7 @@
|
|
|
55
69
|
"preferences": {
|
|
56
70
|
"legend": "Préférences du site"
|
|
57
71
|
},
|
|
58
|
-
"
|
|
72
|
+
"siteName": { "label": "Titre du site", "hint": "Affiché dans l'en-tête du site, l'onglet du navigateur et les aperçus de liens. Laissez vide pour utiliser votre Nom ci-dessus (pour les sites mono-auteur où la marque est la personne)." },
|
|
59
73
|
"description": "Description",
|
|
60
74
|
"tagline": "Slogan",
|
|
61
75
|
"defaultOgImage": "Image OpenGraph par défaut",
|
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 %}
|
|
@@ -334,6 +334,12 @@
|
|
|
334
334
|
<section class="hp-section">
|
|
335
335
|
<h2>{{ __('siteConfig.identity.preferences.legend') }}</h2>
|
|
336
336
|
<div class="hp-field-grid">
|
|
337
|
+
<div class="field field--full">
|
|
338
|
+
<label class="field__label" for="siteName">{{ __('siteConfig.identity.siteName.label') }}</label>
|
|
339
|
+
<input class="field__input" type="text" id="siteName" name="siteName"
|
|
340
|
+
value="{{ config.identity.siteName or '' }}">
|
|
341
|
+
<p class="field__hint">{{ __('siteConfig.identity.siteName.hint') }}</p>
|
|
342
|
+
</div>
|
|
337
343
|
<div class="field">
|
|
338
344
|
<label class="field__label" for="locale">{{ __('siteConfig.identity.locale') }}</label>
|
|
339
345
|
<input class="field__input" type="text" id="locale" name="locale"
|