Add WYSIWYG rich-text editor and reorganize admin UI into tabs
Rich-text fields (hero lead, paragraphs, FAQ-style copy, datenschutz sections, etc.) now use a proper toolbar editor - bold/italic/underline buttons plus a per-selection font picker drawn from the same curated font list as the Design tab - instead of typed **markdown** syntax. Content is stored as a small allowlisted HTML subset (strong/em/u/span with a validated data-font/br) and sanitized server-side with sanitize-html on every save (admin/lib/rich-sanitize.js), independent of what the client sends - verified this rejects script tags, event handlers, and made-up font names while preserving legitimate markup, and that re-saving already-sanitized content is a no-op. The Astro site mirrors the same allowlist at render time for defense-in-depth (src/lib/rich-sanitize.ts) and only requests Google Fonts for fonts actually referenced somewhere in content, in addition to the two Design-tab fonts - verified a font used via a data-font span gets its own Google Fonts request and [data-font] CSS rule in the built page, and that unused fonts don't get requested. Migrated the three existing fields that used the old markdown-lite syntax (**bold**/*italic*) to real HTML so they keep rendering correctly under the new system. Also replaces the single long scrolling admin page with a tabbed sidebar (grouped "Inhalte"/"Design") to cut down on clutter, per feedback that the all-in-one-page layout was hard to navigate.
This commit is contained in:
parent
b625bc208b
commit
7c67ae1ff5
19 changed files with 1005 additions and 96 deletions
|
|
@ -16,13 +16,18 @@ Zeitpunkt Zugriff auf Benutzername/Passwort/Secrets oder den Server selbst.
|
|||
## Was der Editor kann
|
||||
|
||||
- **Texte**: alle Überschriften, Absätze, Listen, FAQ, Preis-Punkte, Mappe-Einträge usw.
|
||||
der Startseite – inklusive Impressum und Datenschutzerklärung.
|
||||
der Startseite – inklusive Impressum und Datenschutzerklärung. Längere Texte haben einen
|
||||
Rich-Text-Editor mit Knöpfen für Fett/Kursiv/Unterstrichen sowie eine Schriftart-Auswahl,
|
||||
mit der einzelne markierte Wörter/Sätze eine eigene Schriftart bekommen können (aus der
|
||||
gleichen kuratierten Liste wie im Design-Tab) – unabhängig von den zwei Grundschriften.
|
||||
- **Bilder**: Logo, Mappe-Screenshots, Team-Foto, plus optional eigene Bilder anstelle der
|
||||
eingebauten Google-Vorschau und des PDF/Tabellen-Vergleichs.
|
||||
- **Links**: Menüpunkte, E-Mail-Adresse, Kontakt-Angaben.
|
||||
- **Design**: Schriftart für Überschriften/Fließtext (Auswahl aus gängigen Google Fonts
|
||||
oder Systemschrift), Schriftgrößen (7 Stufen, per Schieberegler), und *alle* Farben der
|
||||
Seite einzeln über Hex-Feld, RGB-Schieberegler und Transparenz-Regler.
|
||||
- **Übersicht**: die Oberfläche ist in Reiter/Tabs gegliedert (linke Seitenleiste, gruppiert
|
||||
in „Inhalte" und „Design") statt einer langen durchgehenden Seite.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,27 @@
|
|||
/**
|
||||
* Curated font list for the "Design" tab's font pickers. Kept in sync by
|
||||
* hand with src/lib/fonts.ts (duplicated because the Astro site is ESM/TS
|
||||
* and this backend is CommonJS) — only the `name` values matter here, the
|
||||
* astro site owns the fallback stacks and Google Fonts URL building.
|
||||
* Curated font list for the "Design" tab's font pickers and the rich-text
|
||||
* editor's per-selection font picker. Kept in sync by hand with
|
||||
* src/lib/fonts.ts (duplicated because the Astro site is ESM/TS and this
|
||||
* backend is CommonJS).
|
||||
*/
|
||||
const FONT_NAMES = [
|
||||
'Playfair Display',
|
||||
'Lora',
|
||||
'Merriweather',
|
||||
'Libre Baskerville',
|
||||
'DM Serif Display',
|
||||
'Georgia (Systemschrift)',
|
||||
'Inter',
|
||||
'Roboto',
|
||||
'Open Sans',
|
||||
'Poppins',
|
||||
'Montserrat',
|
||||
'Nunito',
|
||||
'Work Sans',
|
||||
'Source Sans 3',
|
||||
'System UI (Systemschrift)',
|
||||
const FONT_OPTIONS = [
|
||||
{ name: 'Playfair Display', fallback: "Georgia, 'Times New Roman', serif" },
|
||||
{ name: 'Lora', fallback: "Georgia, 'Times New Roman', serif" },
|
||||
{ name: 'Merriweather', fallback: "Georgia, 'Times New Roman', serif" },
|
||||
{ name: 'Libre Baskerville', fallback: "Georgia, 'Times New Roman', serif" },
|
||||
{ name: 'DM Serif Display', fallback: "Georgia, 'Times New Roman', serif" },
|
||||
{ name: 'Georgia (Systemschrift)', fallback: "Georgia, 'Times New Roman', serif" },
|
||||
{ name: 'Inter', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
|
||||
{ name: 'Roboto', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
|
||||
{ name: 'Open Sans', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
|
||||
{ name: 'Poppins', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
|
||||
{ name: 'Montserrat', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
|
||||
{ name: 'Nunito', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
|
||||
{ name: 'Work Sans', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
|
||||
{ name: 'Source Sans 3', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
|
||||
{ name: 'System UI (Systemschrift)', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
|
||||
];
|
||||
|
||||
module.exports = { FONT_NAMES };
|
||||
const FONT_NAMES = FONT_OPTIONS.map((f) => f.name);
|
||||
|
||||
module.exports = { FONT_OPTIONS, FONT_NAMES };
|
||||
|
|
|
|||
36
admin/lib/rich-sanitize.js
Normal file
36
admin/lib/rich-sanitize.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
const sanitizeHtml = require('sanitize-html');
|
||||
const { FONT_NAMES } = require('./fonts');
|
||||
|
||||
const OPTIONS = {
|
||||
// b/i are included because execCommand('bold'/'italic') emits those in
|
||||
// some browsers; transformTags normalizes them to the semantic tags.
|
||||
allowedTags: ['strong', 'em', 'u', 'span', 'br', 'b', 'i'],
|
||||
transformTags: {
|
||||
b: 'strong',
|
||||
i: 'em',
|
||||
},
|
||||
allowedAttributes: {
|
||||
span: ['data-font'],
|
||||
},
|
||||
// Reject any span whose data-font isn't one of our curated fonts, instead
|
||||
// of silently passing through an attacker-controlled string.
|
||||
exclusiveFilter: (frame) => {
|
||||
if (frame.tag === 'span') {
|
||||
const font = frame.attribs && frame.attribs['data-font'];
|
||||
return !font || !FONT_NAMES.includes(font);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
disallowedTagsMode: 'discard',
|
||||
};
|
||||
|
||||
/** Sanitizes rich-text HTML from the admin editor down to a small allowlist
|
||||
* (bold/italic/underline + font-picker spans) before it's stored or rendered
|
||||
* on the public site. Anything else - scripts, styles, links, attributes -
|
||||
* is stripped, not escaped, so plain text content is preserved. */
|
||||
function sanitizeRichText(html) {
|
||||
if (typeof html !== 'string') return '';
|
||||
return sanitizeHtml(html, OPTIONS).trim();
|
||||
}
|
||||
|
||||
module.exports = { sanitizeRichText };
|
||||
58
admin/lib/sanitize-content.js
Normal file
58
admin/lib/sanitize-content.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
const schema = require('./schema');
|
||||
const { sanitizeRichText } = require('./rich-sanitize');
|
||||
|
||||
function isPlainObject(v) {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function sanitizeItemFields(item, itemFields) {
|
||||
if (!isPlainObject(item)) return item;
|
||||
for (const field of itemFields) {
|
||||
sanitizeField(item, field);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function sanitizeField(obj, field) {
|
||||
const value = obj[field.key];
|
||||
switch (field.type) {
|
||||
case 'richtext':
|
||||
if (typeof value === 'string') obj[field.key] = sanitizeRichText(value);
|
||||
break;
|
||||
case 'list-richtext':
|
||||
if (Array.isArray(value)) obj[field.key] = value.map((v) => (typeof v === 'string' ? sanitizeRichText(v) : v));
|
||||
break;
|
||||
case 'list-object':
|
||||
if (Array.isArray(value)) value.forEach((item) => sanitizeItemFields(item, field.itemFields));
|
||||
break;
|
||||
case 'group':
|
||||
if (isPlainObject(value)) sanitizeItemFields(value, field.itemFields);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a deep copy of a content.json payload with every richtext /
|
||||
* list-richtext field run through the HTML allowlist sanitizer. Content
|
||||
* fields never trust client-submitted HTML directly - this is the
|
||||
* authoritative sanitization pass before anything is written to disk. */
|
||||
function sanitizeContentPayload(payload) {
|
||||
const copy = JSON.parse(JSON.stringify(payload));
|
||||
const contentSections = schema.filter((s) => (s.file || 'content') === 'content');
|
||||
|
||||
for (const section of contentSections) {
|
||||
if (section.type === 'array-root') {
|
||||
const arr = copy[section.key];
|
||||
if (Array.isArray(arr)) arr.forEach((item) => sanitizeItemFields(item, section.itemFields));
|
||||
} else if (isPlainObject(copy[section.key])) {
|
||||
for (const field of section.fields) {
|
||||
sanitizeField(copy[section.key], field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
module.exports = { sanitizeContentPayload };
|
||||
|
|
@ -6,7 +6,10 @@ const { FONT_NAMES } = require('./fonts');
|
|||
*
|
||||
* Field types: text, textarea, richtext, email, boolean, image,
|
||||
* list-string, list-richtext, list-object, group, select, size, color.
|
||||
* "richtext" fields support a tiny **bold** / *italic* subset (see src/lib/content.ts).
|
||||
* "richtext"/"list-richtext" fields are edited with a WYSIWYG toolbar
|
||||
* (bold/italic/underline + per-selection font) and stored as a small
|
||||
* allowlisted HTML subset - see admin/lib/rich-sanitize.js for what's
|
||||
* allowed and src/lib/content.ts for how it's rendered on the site.
|
||||
* "group" is a single nested object (not repeatable) described by itemFields.
|
||||
* "color" fields store { r, g, b, a }. "size" fields store a CSS length (e.g. "1.25rem").
|
||||
*/
|
||||
|
|
@ -285,7 +288,7 @@ module.exports = [
|
|||
{
|
||||
key: 'text',
|
||||
label: 'Text (leer = Platzhalter; {email} wird durch die Kontakt-E-Mail ersetzt)',
|
||||
type: 'textarea',
|
||||
type: 'richtext',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
234
admin/package-lock.json
generated
234
admin/package-lock.json
generated
|
|
@ -12,7 +12,8 @@
|
|||
"cookie": "^0.7.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"multer": "^2.0.1"
|
||||
"multer": "^2.0.1",
|
||||
"sanitize-html": "^2.17.7"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
|
|
@ -155,6 +156,12 @@
|
|||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.23",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
|
||||
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
|
|
@ -164,6 +171,15 @@
|
|||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
|
|
@ -183,6 +199,73 @@
|
|||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
|
||||
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0",
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
|
||||
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
|
||||
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
|
||||
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^3.0.0",
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
|
|
@ -224,6 +307,18 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
|
||||
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
|
|
@ -260,6 +355,18 @@
|
|||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
|
|
@ -433,6 +540,28 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
|
||||
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0",
|
||||
"domutils": "^4.0.2",
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
|
|
@ -480,6 +609,24 @@
|
|||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",
|
||||
"integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/launder": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
|
||||
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.7"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
|
@ -573,6 +720,24 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.19",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
||||
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
|
|
@ -606,6 +771,12 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-srcset": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
|
|
@ -621,6 +792,40 @@
|
|||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.18",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
||||
|
|
@ -704,6 +909,24 @@
|
|||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.17.7",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz",
|
||||
"integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.2.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"launder": "^1.7.1",
|
||||
"parse-srcset": "^1.0.2",
|
||||
"postcss": "^8.3.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
|
|
@ -827,6 +1050,15 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"cookie": "^0.7.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"multer": "^2.0.1"
|
||||
"multer": "^2.0.1",
|
||||
"sanitize-html": "^2.17.7"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
'use strict';
|
||||
|
||||
let schema = [];
|
||||
let fonts = [];
|
||||
let state = { content: null, theme: null };
|
||||
let activeSectionKey = null;
|
||||
const statusEl = document.getElementById('status');
|
||||
const sectionsEl = document.getElementById('sections');
|
||||
const navEl = document.getElementById('section-nav');
|
||||
|
|
@ -48,10 +50,27 @@
|
|||
return data;
|
||||
}
|
||||
|
||||
function injectFontPreviewStyles() {
|
||||
const rules = fonts
|
||||
.map((f) => `[data-font="${f.name}"] { font-family: '${f.name}', ${f.fallback}; }`)
|
||||
.join('\n');
|
||||
const style = el('style', {}, [rules]);
|
||||
document.head.appendChild(style);
|
||||
|
||||
if (fonts.some((f) => f.name !== 'System UI (Systemschrift)' && f.name !== 'Georgia (Systemschrift)')) {
|
||||
const families = fonts
|
||||
.filter((f) => !f.name.includes('Systemschrift'))
|
||||
.map((f) => `family=${encodeURIComponent(f.name)}:wght@400;500;600;700`)
|
||||
.join('&');
|
||||
const link = el('link', { rel: 'stylesheet', href: `https://fonts.googleapis.com/css2?${families}&display=swap` });
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Field renderers ---
|
||||
|
||||
function textField(field, getValue, setValue) {
|
||||
const isArea = field.type === 'textarea' || field.type === 'richtext';
|
||||
const isArea = field.type === 'textarea';
|
||||
const input = isArea
|
||||
? el('textarea', { rows: 4, oninput: (e) => setValue(e.target.value) })
|
||||
: el('input', {
|
||||
|
|
@ -59,11 +78,7 @@
|
|||
oninput: (e) => setValue(e.target.value),
|
||||
});
|
||||
input.value = getValue() || '';
|
||||
return el('div', { class: 'field' }, [
|
||||
el('label', {}, [field.label]),
|
||||
input,
|
||||
field.type === 'richtext' ? el('p', { class: 'field-hint' }, ['**fett**, *kursiv* möglich']) : null,
|
||||
]);
|
||||
return el('div', { class: 'field' }, [el('label', {}, [field.label]), input]);
|
||||
}
|
||||
|
||||
function selectField(field, getValue, setValue) {
|
||||
|
|
@ -270,6 +285,140 @@
|
|||
]);
|
||||
}
|
||||
|
||||
// --- Rich text (WYSIWYG) ---
|
||||
|
||||
function richTextEditor(getValue, setValue) {
|
||||
const editable = el('div', { class: 'richtext-editable', contenteditable: 'true' });
|
||||
editable.innerHTML = getValue() || '';
|
||||
|
||||
function commit() {
|
||||
setValue(editable.innerHTML);
|
||||
}
|
||||
|
||||
editable.addEventListener('input', commit);
|
||||
editable.addEventListener('blur', commit);
|
||||
|
||||
// Force plain-text paste so what you see while editing matches what
|
||||
// gets saved (formatting from external sources isn't in our allowlist
|
||||
// and would otherwise be silently stripped again on save).
|
||||
editable.addEventListener('paste', (e) => {
|
||||
e.preventDefault();
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
||||
document.execCommand('insertText', false, text);
|
||||
});
|
||||
|
||||
function runCommand(cmd) {
|
||||
editable.focus();
|
||||
document.execCommand(cmd, false, null);
|
||||
commit();
|
||||
}
|
||||
|
||||
function applyFont(fontName) {
|
||||
editable.focus();
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
|
||||
setStatus('Bitte zuerst Text markieren.', 'error');
|
||||
return;
|
||||
}
|
||||
const range = sel.getRangeAt(0);
|
||||
if (!editable.contains(range.commonAncestorContainer)) return;
|
||||
const span = document.createElement('span');
|
||||
span.setAttribute('data-font', fontName);
|
||||
try {
|
||||
range.surroundContents(span);
|
||||
} catch (err) {
|
||||
const contents = range.extractContents();
|
||||
span.appendChild(contents);
|
||||
range.insertNode(span);
|
||||
}
|
||||
sel.removeAllRanges();
|
||||
commit();
|
||||
}
|
||||
|
||||
function removeFontFromSelection() {
|
||||
editable.focus();
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (!editable.contains(range.commonAncestorContainer)) return;
|
||||
editable.querySelectorAll('span[data-font]').forEach((span) => {
|
||||
if (range.intersectsNode(span)) {
|
||||
const parent = span.parentNode;
|
||||
while (span.firstChild) parent.insertBefore(span.firstChild, span);
|
||||
parent.removeChild(span);
|
||||
}
|
||||
});
|
||||
commit();
|
||||
}
|
||||
|
||||
const boldBtn = el('button', { type: 'button', class: 'rt-btn', title: 'Fett', onclick: () => runCommand('bold') }, ['B']);
|
||||
const italicBtn = el('button', { type: 'button', class: 'rt-btn rt-italic', title: 'Kursiv', onclick: () => runCommand('italic') }, ['I']);
|
||||
const underlineBtn = el('button', { type: 'button', class: 'rt-btn rt-underline', title: 'Unterstrichen', onclick: () => runCommand('underline') }, ['U']);
|
||||
|
||||
const fontSelect = el('select', {
|
||||
class: 'rt-font-select',
|
||||
onchange: (e) => {
|
||||
if (e.target.value === '__remove__') removeFontFromSelection();
|
||||
else if (e.target.value) applyFont(e.target.value);
|
||||
e.target.value = '';
|
||||
},
|
||||
}, [
|
||||
el('option', { value: '' }, ['Schriftart …']),
|
||||
el('option', { value: '__remove__' }, ['— Schriftart entfernen —']),
|
||||
...fonts.map((f) => el('option', { value: f.name }, [f.name])),
|
||||
]);
|
||||
|
||||
const toolbar = el('div', { class: 'richtext-toolbar' }, [boldBtn, italicBtn, underlineBtn, fontSelect]);
|
||||
|
||||
return el('div', {}, [toolbar, editable]);
|
||||
}
|
||||
|
||||
function richTextField(field, getValue, setValue) {
|
||||
return el('div', { class: 'field' }, [el('label', {}, [field.label]), richTextEditor(getValue, setValue)]);
|
||||
}
|
||||
|
||||
function renderRichTextList(field, getObj) {
|
||||
const wrap = el('div', { class: 'field' }, [el('label', {}, [field.label])]);
|
||||
const listEl = el('div', {}, []);
|
||||
|
||||
function draw() {
|
||||
listEl.innerHTML = '';
|
||||
const arr = getObj()[field.key];
|
||||
arr.forEach((_, i) => {
|
||||
const editorEl = richTextEditor(
|
||||
() => arr[i],
|
||||
(v) => {
|
||||
arr[i] = v;
|
||||
}
|
||||
);
|
||||
const removeBtn = el('button', {
|
||||
type: 'button',
|
||||
class: 'remove-btn',
|
||||
onclick: () => {
|
||||
arr.splice(i, 1);
|
||||
draw();
|
||||
},
|
||||
}, ['Entfernen']);
|
||||
listEl.appendChild(el('div', { class: 'list-item-card' }, [editorEl, removeBtn]));
|
||||
});
|
||||
}
|
||||
|
||||
draw();
|
||||
|
||||
const addBtn = el('button', {
|
||||
type: 'button',
|
||||
class: 'add-btn',
|
||||
onclick: () => {
|
||||
getObj()[field.key].push('');
|
||||
draw();
|
||||
},
|
||||
}, ['+ hinzufügen']);
|
||||
|
||||
wrap.appendChild(listEl);
|
||||
wrap.appendChild(addBtn);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderFieldGroup(fields, getObj) {
|
||||
const container = el('div', {}, []);
|
||||
fields.forEach((field) => {
|
||||
|
|
@ -287,6 +436,8 @@
|
|||
container.appendChild(sizeField(field, getValue, setValue));
|
||||
} else if (field.type === 'color') {
|
||||
container.appendChild(colorField(field, getValue, setValue));
|
||||
} else if (field.type === 'richtext') {
|
||||
container.appendChild(richTextField(field, getValue, setValue));
|
||||
} else if (field.type === 'group') {
|
||||
container.appendChild(
|
||||
el('fieldset', { class: 'group-field' }, [
|
||||
|
|
@ -294,8 +445,10 @@
|
|||
renderFieldGroup(field.itemFields, () => getObj()[field.key]),
|
||||
])
|
||||
);
|
||||
} else if (field.type === 'list-string' || field.type === 'list-richtext') {
|
||||
} else if (field.type === 'list-string') {
|
||||
container.appendChild(renderStringList(field, getObj));
|
||||
} else if (field.type === 'list-richtext') {
|
||||
container.appendChild(renderRichTextList(field, getObj));
|
||||
} else if (field.type === 'list-object') {
|
||||
container.appendChild(renderObjectList(field, getObj));
|
||||
} else {
|
||||
|
|
@ -313,9 +466,7 @@
|
|||
listEl.innerHTML = '';
|
||||
const arr = getObj()[field.key];
|
||||
arr.forEach((_, i) => {
|
||||
const input = field.type === 'list-richtext'
|
||||
? el('textarea', { rows: 2 })
|
||||
: el('input', { type: 'text' });
|
||||
const input = el('input', { type: 'text' });
|
||||
input.value = arr[i];
|
||||
input.addEventListener('input', (e) => {
|
||||
arr[i] = e.target.value;
|
||||
|
|
@ -403,7 +554,7 @@
|
|||
function renderSection(section) {
|
||||
const id = 'section-' + section.key;
|
||||
const fileKind = section.file || 'content';
|
||||
const block = el('section', { class: 'section-block', id }, [el('h2', {}, [section.label])]);
|
||||
const block = el('section', { class: 'section-block', id, hidden: true }, [el('h2', {}, [section.label])]);
|
||||
|
||||
if (section.type === 'array-root') {
|
||||
const listField = { key: '__root__', itemLabel: section.itemLabel, itemFields: section.itemFields, label: '' };
|
||||
|
|
@ -418,24 +569,60 @@
|
|||
return block;
|
||||
}
|
||||
|
||||
function showSection(key) {
|
||||
activeSectionKey = key;
|
||||
sectionsEl.querySelectorAll('.section-block').forEach((elm) => {
|
||||
elm.hidden = elm.id !== 'section-' + key;
|
||||
});
|
||||
navEl.querySelectorAll('button').forEach((btn) => {
|
||||
btn.classList.toggle('active', btn.dataset.key === key);
|
||||
});
|
||||
try {
|
||||
localStorage.setItem('larpaso-admin-active-section', key);
|
||||
} catch (err) {
|
||||
/* ignore - not essential */
|
||||
}
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
sectionsEl.innerHTML = '';
|
||||
navEl.innerHTML = '';
|
||||
|
||||
let lastGroup = null;
|
||||
schema.forEach((section) => {
|
||||
const group = section.file === 'theme' ? 'Design' : 'Inhalte';
|
||||
if (group !== lastGroup) {
|
||||
navEl.appendChild(el('div', { class: 'nav-group-label' }, [group]));
|
||||
lastGroup = group;
|
||||
}
|
||||
navEl.appendChild(
|
||||
el('button', { type: 'button', 'data-key': section.key, onclick: () => showSection(section.key) }, [section.label])
|
||||
);
|
||||
sectionsEl.appendChild(renderSection(section));
|
||||
navEl.appendChild(el('a', { href: '#section-' + section.key }, [section.label]));
|
||||
});
|
||||
|
||||
let initial = schema[0] && schema[0].key;
|
||||
try {
|
||||
const saved = localStorage.getItem('larpaso-admin-active-section');
|
||||
if (saved && schema.some((s) => s.key === saved)) initial = saved;
|
||||
} catch (err) {
|
||||
/* ignore - not essential */
|
||||
}
|
||||
if (initial) showSection(initial);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
setStatus('Lädt …');
|
||||
const [fetchedSchema, content, theme] = await Promise.all([
|
||||
const [fetchedSchema, content, theme, fetchedFonts] = await Promise.all([
|
||||
api('/admin/api/schema'),
|
||||
api('/admin/api/content'),
|
||||
api('/admin/api/theme'),
|
||||
api('/admin/api/fonts'),
|
||||
]);
|
||||
schema = fetchedSchema;
|
||||
fonts = fetchedFonts;
|
||||
state = { content, theme };
|
||||
injectFontPreviewStyles();
|
||||
renderAll();
|
||||
setStatus('Bereit.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,9 +18,10 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<nav id="section-nav" class="section-nav" aria-label="Abschnitte"></nav>
|
||||
|
||||
<main id="sections"></main>
|
||||
</div>
|
||||
|
||||
<div id="log" class="build-log" hidden>
|
||||
<div class="build-log-header">
|
||||
|
|
|
|||
|
|
@ -56,25 +56,61 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|||
.btn-accent { background: var(--accent); color: #14110f; border-color: var(--accent); font-weight: 600; }
|
||||
.btn-ghost { background: transparent; border-color: transparent; color: var(--text-muted); }
|
||||
|
||||
.section-nav {
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
padding: 1rem 1.5rem 0;
|
||||
align-items: flex-start;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section-nav a {
|
||||
font-size: 0.8rem;
|
||||
.section-nav {
|
||||
position: sticky;
|
||||
top: 3.6rem;
|
||||
flex: 0 0 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
padding: 1.25rem 1rem;
|
||||
max-height: calc(100vh - 3.6rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section-nav .nav-group-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
margin: 1rem 0 0.3rem;
|
||||
padding: 0 0.6rem;
|
||||
}
|
||||
.section-nav .nav-group-label:first-child { margin-top: 0; }
|
||||
|
||||
.section-nav button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.section-nav a:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.section-nav button:hover { color: var(--text); border-color: var(--border); }
|
||||
|
||||
main { max-width: 860px; margin: 0 auto; padding: 1.5rem; }
|
||||
.section-nav button.active {
|
||||
color: var(--accent);
|
||||
background: var(--surface);
|
||||
border-color: var(--border);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 1.5rem 1.5rem 4rem;
|
||||
}
|
||||
|
||||
.section-block {
|
||||
background: var(--surface);
|
||||
|
|
@ -84,7 +120,24 @@ main { max-width: 860px; margin: 0 auto; padding: 1.5rem; }
|
|||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.section-block h2 { font-size: 1rem; margin: 0 0 1rem; color: var(--accent); }
|
||||
.section-block[hidden] { display: none; }
|
||||
|
||||
.section-block h2 { font-size: 1.1rem; margin: 0 0 1rem; color: var(--accent); }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.layout { flex-direction: column; }
|
||||
.section-nav {
|
||||
position: static;
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-height: none;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
padding: 1rem 1.5rem 0;
|
||||
}
|
||||
.section-nav .nav-group-label { width: 100%; margin: 0.5rem 0 0.2rem; padding: 0; }
|
||||
.section-nav button { width: auto; }
|
||||
}
|
||||
|
||||
.field { margin-bottom: 1rem; }
|
||||
|
||||
|
|
@ -151,6 +204,46 @@ main { max-width: 860px; margin: 0 auto; padding: 1.5rem; }
|
|||
.channel-label { font-size: 0.72rem; color: var(--text-muted); }
|
||||
.channel-value { font-size: 0.75rem; color: var(--text-muted); text-align: right; }
|
||||
|
||||
.richtext-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rt-btn {
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
min-width: 2.1rem;
|
||||
}
|
||||
.rt-btn.rt-italic { font-style: italic; }
|
||||
.rt-btn.rt-underline { text-decoration: underline; }
|
||||
|
||||
.rt-font-select {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
max-width: 11rem;
|
||||
}
|
||||
|
||||
.richtext-editable {
|
||||
width: 100%;
|
||||
min-height: 4.5rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.richtext-editable:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||
|
||||
.image-field { display: flex; align-items: center; gap: 0.9rem; flex-wrap: wrap; }
|
||||
.image-preview {
|
||||
width: 84px; height: 84px; border-radius: 8px; object-fit: cover;
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ const multer = require('multer');
|
|||
const auth = require('./lib/auth');
|
||||
const { readContent, writeContent, readTheme, writeTheme, PROJECT_DIR } = require('./lib/content-store');
|
||||
const { validateContent, validateTheme } = require('./lib/validate');
|
||||
const { sanitizeContentPayload } = require('./lib/sanitize-content');
|
||||
const schema = require('./lib/schema');
|
||||
const { runBuild } = require('./lib/build');
|
||||
const { FONT_OPTIONS } = require('./lib/fonts');
|
||||
|
||||
const PORT = process.env.PORT || 4321;
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(PROJECT_DIR, 'public', 'uploads');
|
||||
|
|
@ -138,17 +140,24 @@ app.get('/admin/api/schema', auth.requireAuth, (req, res) => {
|
|||
res.json(schema);
|
||||
});
|
||||
|
||||
app.get('/admin/api/fonts', auth.requireAuth, (req, res) => {
|
||||
res.json(FONT_OPTIONS);
|
||||
});
|
||||
|
||||
app.get('/admin/api/content', auth.requireAuth, (req, res) => {
|
||||
res.json(readContent());
|
||||
});
|
||||
|
||||
app.put('/admin/api/content', auth.requireAuth, auth.requireAdminHeader, (req, res) => {
|
||||
const errors = validateContent(req.body);
|
||||
// Sanitize before validating: rich-text HTML is never trusted as-is, and
|
||||
// the sanitized result is what actually gets checked and persisted.
|
||||
const sanitized = sanitizeContentPayload(req.body);
|
||||
const errors = validateContent(sanitized);
|
||||
if (errors.length > 0) {
|
||||
return res.status(400).json({ error: 'Ungültige Daten.', details: errors });
|
||||
}
|
||||
try {
|
||||
writeContent(req.body);
|
||||
writeContent(sanitized);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: `Speichern fehlgeschlagen: ${err.message}` });
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
"titleBefore": "Websites, die zu Ihrem ",
|
||||
"titleEmphasis": "Unternehmen",
|
||||
"titleAfter": " passen.",
|
||||
"lead": "**Gebaut mit KI-gestützter Entwicklung.** larpaso entwickelt individuelle Websites für Kunden aus unterschiedlichen Branchen. Durch den gezielten Einsatz von KI in der Entwicklung liefern wir hochwertige Ergebnisse in kürzerer Zeit, *ohne Kompromisse bei Qualität und Individualität.*",
|
||||
"lead": "<strong>Gebaut mit KI-gestützter Entwicklung.</strong> larpaso entwickelt individuelle Websites für Kunden aus unterschiedlichen Branchen. Durch den gezielten Einsatz von KI in der Entwicklung liefern wir hochwertige Ergebnisse in kürzerer Zeit, <em>ohne Kompromisse bei Qualität und Individualität.</em>",
|
||||
"ctaLabel": "Projekt anfragen",
|
||||
"visual": {
|
||||
"customImage": "",
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
"possibleTag": "Möglich",
|
||||
"possibleUrl": "ihr-betrieb.de",
|
||||
"possibleTitle": "Ihr Betrieb in [Ort] – Termine, Preise, Anfahrt",
|
||||
"possibleDesc": "Geöffnet Mo–Fr 9–18 Uhr. **Termin in zwei Klicks**, Preise stehen auf der Seite, Anfahrt und Parkplätze erklärt.",
|
||||
"possibleDesc": "Geöffnet Mo–Fr 9–18 Uhr. <strong>Termin in zwei Klicks</strong>, Preise stehen auf der Seite, Anfahrt und Parkplätze erklärt.",
|
||||
"possibleMeta": "★★★★★ [Zahl] Bewertungen · Jetzt geöffnet"
|
||||
}
|
||||
},
|
||||
|
|
@ -247,7 +247,7 @@
|
|||
"paragraphs": [
|
||||
"Technik ändert sich schnell. Und mit ihr, was Kundinnen und Kunden von einem Betrieb erwarten: eine Seite, die auf dem Handy funktioniert, klar sagt, worum es geht, und nicht nach gestern aussieht. Was heute auffällt, ist in zwei Jahren Standard. Wir bauen Websites für Menschen, die da mithalten wollen, ohne sich selbst in Technik einarbeiten zu müssen.",
|
||||
"Dabei helfen uns KI-Werkzeuge. Sie nehmen uns Routine ab, damit wir Zeit haben für das, worauf es ankommt: zuhören, mitdenken, sorgfältig arbeiten. So bleiben individuelle Lösungen in überschaubarer Zeit und zu planbaren Kosten möglich, weder Massenware noch Luxusgut.",
|
||||
"Der Name ist aus uns gebaut: **Lara** und **Pas**cal. Und die zweite Hälfte ist ein Wort: *paso*, spanisch für Schritt. Für uns steht es für Fortschritt. Wir lernen weiter, entwickeln unsere Arbeit weiter und Ihre Seite gleich mit, Schritt für Schritt."
|
||||
"Der Name ist aus uns gebaut: <strong>Lara</strong> und <strong>Pas</strong>cal. Und die zweite Hälfte ist ein Wort: <em>paso</em>, spanisch für Schritt. Für uns steht es für Fortschritt. Wir lernen weiter, entwickeln unsere Arbeit weiter und Ihre Seite gleich mit, Schritt für Schritt."
|
||||
],
|
||||
"photo": "",
|
||||
"laraBio": "",
|
||||
|
|
|
|||
219
package-lock.json
generated
219
package-lock.json
generated
|
|
@ -8,7 +8,11 @@
|
|||
"name": "larpaso-website",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"astro": "^7.3.2"
|
||||
"astro": "^7.3.2",
|
||||
"sanitize-html": "^2.17.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/sanitize-html": "^2.16.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/compiler-binding": {
|
||||
|
|
@ -1961,6 +1965,49 @@
|
|||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sanitize-html": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
|
||||
"integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"htmlparser2": "^10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sanitize-html/node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sanitize-html/node_modules/htmlparser2": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"entities": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
|
|
@ -2333,6 +2380,21 @@
|
|||
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.23",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
|
||||
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.7",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||
|
|
@ -2514,6 +2576,18 @@
|
|||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
|
|
@ -2710,6 +2784,107 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
|
||||
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0",
|
||||
"domutils": "^4.0.2",
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/dom-serializer": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
|
||||
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0",
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/domelementtype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
|
||||
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/domhandler": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
|
||||
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/domutils": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
|
||||
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^3.0.0",
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/entities": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
|
||||
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
|
|
@ -2752,6 +2927,15 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",
|
||||
"integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
|
|
@ -2780,6 +2964,15 @@
|
|||
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/launder": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
|
||||
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.7"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
|
|
@ -3364,6 +3557,12 @@
|
|||
"integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parse-srcset": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/piccolore": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
|
||||
|
|
@ -3544,6 +3743,24 @@
|
|||
"@rolldown/binding-win32-x64-msvc": "1.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.17.7",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz",
|
||||
"integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.2.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"launder": "^1.7.1",
|
||||
"parse-srcset": "^1.0.2",
|
||||
"postcss": "^8.3.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/satteri": {
|
||||
"version": "0.10.5",
|
||||
"resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@
|
|||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^7.3.2"
|
||||
"astro": "^7.3.2",
|
||||
"sanitize-html": "^2.17.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/sanitize-html": "^2.16.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import siteData from '../../content/site.json';
|
||||
import themeData from '../../content/theme.json';
|
||||
import { fontFamilyCss, googleFontsHref as buildGoogleFontsHref } from './fonts';
|
||||
import { fontFamilyCss, googleFontsHref as buildGoogleFontsHref, inlineFontFacesCss } from './fonts';
|
||||
import { sanitizeRich, collectUsedFonts } from './rich-sanitize';
|
||||
|
||||
export const site = siteData;
|
||||
export const theme = themeData;
|
||||
|
||||
export { sanitizeRich as rich };
|
||||
|
||||
const COLOR_VAR_NAMES: Record<string, string> = {
|
||||
bg: '--color-bg',
|
||||
bgAlt: '--color-bg-alt',
|
||||
|
|
@ -38,8 +41,28 @@ function rgba({ r, g, b, a }: { r: number; g: number; b: number; a: number }): s
|
|||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
}
|
||||
|
||||
function collectAllStrings(value: unknown, out: string[]): void {
|
||||
if (typeof value === 'string') {
|
||||
out.push(value);
|
||||
} else if (Array.isArray(value)) {
|
||||
value.forEach((v) => collectAllStrings(v, out));
|
||||
} else if (value && typeof value === 'object') {
|
||||
Object.values(value).forEach((v) => collectAllStrings(v, out));
|
||||
}
|
||||
}
|
||||
|
||||
/** Every font used anywhere via the rich-text editor's per-selection font
|
||||
* picker, on top of the theme's heading/body fonts - so a Google Fonts
|
||||
* request is only made for fonts actually in use. */
|
||||
function usedInlineFonts(): string[] {
|
||||
const strings: string[] = [];
|
||||
collectAllStrings(site, strings);
|
||||
return collectUsedFonts(strings);
|
||||
}
|
||||
|
||||
/** Builds a `:root` override block (with !important, so it always wins over
|
||||
* global.css's defaults regardless of stylesheet load order) from theme.json. */
|
||||
* global.css's defaults regardless of stylesheet load order) from theme.json,
|
||||
* plus `[data-font]` rules for any fonts used inline in rich-text content. */
|
||||
export function themeCss(t: typeof theme): string {
|
||||
const lines: string[] = [':root {'];
|
||||
for (const [key, varName] of Object.entries(COLOR_VAR_NAMES)) {
|
||||
|
|
@ -53,17 +76,18 @@ export function themeCss(t: typeof theme): string {
|
|||
lines.push(` --font-serif: ${fontFamilyCss(t.fonts.heading)} !important;`);
|
||||
lines.push(` --font-sans: ${fontFamilyCss(t.fonts.body)} !important;`);
|
||||
lines.push('}');
|
||||
lines.push(inlineFontFacesCss(usedInlineFonts()));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function googleFontsHref(t: typeof theme): string | null {
|
||||
return buildGoogleFontsHref(t.fonts.heading, t.fonts.body);
|
||||
return buildGoogleFontsHref([t.fonts.heading, t.fonts.body, ...usedInlineFonts()]);
|
||||
}
|
||||
|
||||
/** Replaces a literal `{email}` token with a mailto link, for legal-page
|
||||
* text where the email address needs to stay in sync with content.kontakt.email. */
|
||||
export function mdWithEmail(input: string | undefined | null, email: string): string {
|
||||
const html = md(input);
|
||||
export function richWithEmail(input: string | undefined | null, email: string): string {
|
||||
const html = sanitizeRich(input);
|
||||
const link = `<a href="mailto:${escapeAttr(email)}">${escapeHtml(email)}</a>`;
|
||||
return html.split('{email}').join(link);
|
||||
}
|
||||
|
|
@ -80,15 +104,3 @@ function escapeHtml(input: string): string {
|
|||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes HTML, then supports a tiny **bold** / *italic* subset so editors
|
||||
* can add emphasis without being able to inject arbitrary markup.
|
||||
*/
|
||||
export function md(input: string | undefined | null): string {
|
||||
if (!input) return '';
|
||||
let html = escapeHtml(input);
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
return html;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,10 +37,11 @@ export function fontFamilyCss(name: string): string {
|
|||
return font.google ? `'${font.name}', ${font.fallback}` : font.fallback;
|
||||
}
|
||||
|
||||
/** Builds the Google Fonts CSS2 stylesheet URL for the currently selected
|
||||
* heading/body fonts (system fonts are skipped — nothing to fetch). */
|
||||
export function googleFontsHref(headingFont: string, bodyFont: string): string | null {
|
||||
const families = [headingFont, bodyFont]
|
||||
/** Builds the Google Fonts CSS2 stylesheet URL for the given font names
|
||||
* (system fonts are skipped — nothing to fetch). Used for the theme's
|
||||
* heading/body fonts plus any fonts used inline via the rich-text editor. */
|
||||
export function googleFontsHref(fontNames: string[]): string | null {
|
||||
const families = fontNames
|
||||
.map(findFont)
|
||||
.filter((f) => f.google)
|
||||
.filter((f, i, arr) => arr.findIndex((g) => g.name === f.name) === i)
|
||||
|
|
@ -49,3 +50,9 @@ export function googleFontsHref(headingFont: string, bodyFont: string): string |
|
|||
if (families.length === 0) return null;
|
||||
return `https://fonts.googleapis.com/css2?${families.join('&')}&display=swap`;
|
||||
}
|
||||
|
||||
/** Builds `[data-font="Name"] { font-family: ...; }` rules for fonts used
|
||||
* inline via the rich-text editor's per-selection font picker. */
|
||||
export function inlineFontFacesCss(fontNames: string[]): string {
|
||||
return fontNames.map((name) => `[data-font="${name}"] { font-family: ${fontFamilyCss(name)}; }`).join('\n');
|
||||
}
|
||||
|
|
|
|||
42
src/lib/rich-sanitize.ts
Normal file
42
src/lib/rich-sanitize.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import sanitizeHtml from 'sanitize-html';
|
||||
import { FONT_OPTIONS } from './fonts';
|
||||
|
||||
const FONT_NAMES = FONT_OPTIONS.map((f) => f.name);
|
||||
|
||||
const OPTIONS: sanitizeHtml.IOptions = {
|
||||
allowedTags: ['strong', 'em', 'u', 'span', 'br', 'b', 'i'],
|
||||
transformTags: { b: 'strong', i: 'em' },
|
||||
allowedAttributes: { span: ['data-font'] },
|
||||
exclusiveFilter: (frame) => {
|
||||
if (frame.tag === 'span') {
|
||||
const font = frame.attribs && frame.attribs['data-font'];
|
||||
return !font || !FONT_NAMES.includes(font);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
disallowedTagsMode: 'discard',
|
||||
};
|
||||
|
||||
/** Defense-in-depth re-sanitization of rich-text HTML at render time,
|
||||
* mirroring admin/lib/rich-sanitize.js (the authoritative pass happens
|
||||
* there when content is saved). Guards against content.json ever being
|
||||
* hand-edited with something outside the allowlist. */
|
||||
export function sanitizeRich(html: string | undefined | null): string {
|
||||
if (!html) return '';
|
||||
return sanitizeHtml(html, OPTIONS).trim();
|
||||
}
|
||||
|
||||
/** Finds every `data-font="Name"` used anywhere in the given rich-text
|
||||
* strings, for building the page's Google Fonts request and per-font CSS. */
|
||||
export function collectUsedFonts(htmlStrings: string[]): string[] {
|
||||
const found = new Set<string>();
|
||||
const re = /data-font="([^"]+)"/g;
|
||||
for (const html of htmlStrings) {
|
||||
if (!html) continue;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(html))) {
|
||||
if (FONT_NAMES.includes(match[1])) found.add(match[1]);
|
||||
}
|
||||
}
|
||||
return [...found];
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { site, mdWithEmail } from '../lib/content';
|
||||
import { site, richWithEmail } from '../lib/content';
|
||||
|
||||
const { datenschutz, kontakt } = site;
|
||||
const introFallback =
|
||||
|
|
@ -20,7 +20,7 @@ const introFallback =
|
|||
{datenschutz.sections.map((section) => (
|
||||
<>
|
||||
<h2>{section.heading}</h2>
|
||||
<p class={section.text ? undefined : 'placeholder'} set:html={mdWithEmail(section.text, kontakt.email) || '[Text noch zu ergänzen]'} />
|
||||
<p class={section.text ? undefined : 'placeholder'} set:html={richWithEmail(section.text, kontakt.email) || '[Text noch zu ergänzen]'} />
|
||||
</>
|
||||
))}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { site, md } from '../lib/content';
|
||||
import { site, rich } from '../lib/content';
|
||||
|
||||
const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, kontakt } = site;
|
||||
---
|
||||
|
|
@ -12,7 +12,7 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
|
|||
<div class="hero-copy">
|
||||
<p class="eyebrow">{hero.eyebrow}</p>
|
||||
<h1>{hero.titleBefore}<em>{hero.titleEmphasis}</em>{hero.titleAfter}</h1>
|
||||
<p class="text-lead" set:html={md(hero.lead)} />
|
||||
<p class="text-lead" set:html={rich(hero.lead)} />
|
||||
<div class="hero-actions">
|
||||
<a class="btn btn-primary" href="#kontakt">{hero.ctaLabel}</a>
|
||||
</div>
|
||||
|
|
@ -35,7 +35,7 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
|
|||
<span class="search-tag search-tag-accent">{hero.visual.possibleTag}</span>
|
||||
<p class="search-url">{hero.visual.possibleUrl}</p>
|
||||
<p class="search-title">{hero.visual.possibleTitle}</p>
|
||||
<p class="search-desc" set:html={md(hero.visual.possibleDesc)} />
|
||||
<p class="search-desc" set:html={rich(hero.visual.possibleDesc)} />
|
||||
<p class="search-meta">{hero.visual.possibleMeta}</p>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -51,7 +51,7 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
|
|||
<p class="eyebrow">{preis.eyebrow}</p>
|
||||
<h2>{preis.title}</h2>
|
||||
<p class="price-tag"><span class="price-amount">{preis.amount}</span> {preis.amountNote}</p>
|
||||
<p class="text-lead">{preis.lead}</p>
|
||||
<p class="text-lead" set:html={rich(preis.lead)} />
|
||||
<div class="price-progress" aria-hidden="true">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
|
|
@ -66,8 +66,8 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
|
|||
|
||||
<aside class="price-aside card">
|
||||
<h3>{preis.asideTitle}</h3>
|
||||
<p>{preis.asideParagraph1}</p>
|
||||
<p>{preis.asideParagraph2}</p>
|
||||
<p set:html={rich(preis.asideParagraph1)} />
|
||||
<p set:html={rich(preis.asideParagraph2)} />
|
||||
<a class="btn btn-primary" href="#kontakt">{preis.ctaLabel}</a>
|
||||
</aside>
|
||||
</div>
|
||||
|
|
@ -78,7 +78,7 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
|
|||
<div class="container">
|
||||
<p class="eyebrow">{warum.eyebrow}</p>
|
||||
<h2>{warum.title}</h2>
|
||||
<p class="text-lead">{warum.lead}</p>
|
||||
<p class="text-lead" set:html={rich(warum.lead)} />
|
||||
|
||||
<div class="grid grid-3 differentiators">
|
||||
{highlights.map((item) => (
|
||||
|
|
@ -92,7 +92,7 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
|
|||
|
||||
<div class="readability">
|
||||
<h3>{warum.readability.title}</h3>
|
||||
<p class="text-lead">{warum.readability.lead}</p>
|
||||
<p class="text-lead" set:html={rich(warum.readability.lead)} />
|
||||
|
||||
{warum.readability.customImage ? (
|
||||
<img class="readability-image" src={warum.readability.customImage} alt="" loading="lazy" />
|
||||
|
|
@ -133,7 +133,7 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
|
|||
<div class="container">
|
||||
<p class="eyebrow">{leistungen.eyebrow}</p>
|
||||
<h2>{leistungen.title}</h2>
|
||||
<p class="text-lead">{leistungen.lead}</p>
|
||||
<p class="text-lead" set:html={rich(leistungen.lead)} />
|
||||
|
||||
<ul class="leistungen-list">
|
||||
{leistungen.items.map((item) => (
|
||||
|
|
@ -230,7 +230,7 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
|
|||
<p class="about-quote">{about.quote}</p>
|
||||
|
||||
{about.paragraphs.map((paragraph) => (
|
||||
<p set:html={md(paragraph)} />
|
||||
<p set:html={rich(paragraph)} />
|
||||
))}
|
||||
|
||||
<div class="about-bios">
|
||||
|
|
@ -273,7 +273,7 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
|
|||
<div class="contact-intro">
|
||||
<p class="eyebrow">{kontakt.eyebrow}</p>
|
||||
<h2>{kontakt.title}</h2>
|
||||
<p class="text-lead">{kontakt.lead}</p>
|
||||
<p class="text-lead" set:html={rich(kontakt.lead)} />
|
||||
|
||||
<div class="contact-detail">
|
||||
<p class="contact-label">Lieber anrufen</p>
|
||||
|
|
|
|||
Loading…
Reference in a new issue