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:
Pascal Scholz 2026-09-21 23:11:19 +02:00
parent b625bc208b
commit 7c67ae1ff5
19 changed files with 1005 additions and 96 deletions

View file

@ -16,13 +16,18 @@ Zeitpunkt Zugriff auf Benutzername/Passwort/Secrets oder den Server selbst.
## Was der Editor kann ## Was der Editor kann
- **Texte**: alle Überschriften, Absätze, Listen, FAQ, Preis-Punkte, Mappe-Einträge usw. - **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 - **Bilder**: Logo, Mappe-Screenshots, Team-Foto, plus optional eigene Bilder anstelle der
eingebauten Google-Vorschau und des PDF/Tabellen-Vergleichs. eingebauten Google-Vorschau und des PDF/Tabellen-Vergleichs.
- **Links**: Menüpunkte, E-Mail-Adresse, Kontakt-Angaben. - **Links**: Menüpunkte, E-Mail-Adresse, Kontakt-Angaben.
- **Design**: Schriftart für Überschriften/Fließtext (Auswahl aus gängigen Google Fonts - **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 oder Systemschrift), Schriftgrößen (7 Stufen, per Schieberegler), und *alle* Farben der
Seite einzeln über Hex-Feld, RGB-Schieberegler und Transparenz-Regler. 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.
--- ---

View file

@ -1,25 +1,27 @@
/** /**
* Curated font list for the "Design" tab's font pickers. Kept in sync by * Curated font list for the "Design" tab's font pickers and the rich-text
* hand with src/lib/fonts.ts (duplicated because the Astro site is ESM/TS * editor's per-selection font picker. Kept in sync by hand with
* and this backend is CommonJS) — only the `name` values matter here, the * src/lib/fonts.ts (duplicated because the Astro site is ESM/TS and this
* astro site owns the fallback stacks and Google Fonts URL building. * backend is CommonJS).
*/ */
const FONT_NAMES = [ const FONT_OPTIONS = [
'Playfair Display', { name: 'Playfair Display', fallback: "Georgia, 'Times New Roman', serif" },
'Lora', { name: 'Lora', fallback: "Georgia, 'Times New Roman', serif" },
'Merriweather', { name: 'Merriweather', fallback: "Georgia, 'Times New Roman', serif" },
'Libre Baskerville', { name: 'Libre Baskerville', fallback: "Georgia, 'Times New Roman', serif" },
'DM Serif Display', { name: 'DM Serif Display', fallback: "Georgia, 'Times New Roman', serif" },
'Georgia (Systemschrift)', { name: 'Georgia (Systemschrift)', fallback: "Georgia, 'Times New Roman', serif" },
'Inter', { name: 'Inter', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
'Roboto', { name: 'Roboto', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
'Open Sans', { name: 'Open Sans', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
'Poppins', { name: 'Poppins', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
'Montserrat', { name: 'Montserrat', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
'Nunito', { name: 'Nunito', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
'Work Sans', { name: 'Work Sans', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
'Source Sans 3', { name: 'Source Sans 3', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
'System UI (Systemschrift)', { 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 };

View 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 };

View 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 };

View file

@ -6,7 +6,10 @@ const { FONT_NAMES } = require('./fonts');
* *
* Field types: text, textarea, richtext, email, boolean, image, * Field types: text, textarea, richtext, email, boolean, image,
* list-string, list-richtext, list-object, group, select, size, color. * 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. * "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"). * "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', key: 'text',
label: 'Text (leer = Platzhalter; {email} wird durch die Kontakt-E-Mail ersetzt)', label: 'Text (leer = Platzhalter; {email} wird durch die Kontakt-E-Mail ersetzt)',
type: 'textarea', type: 'richtext',
}, },
], ],
}, },

234
admin/package-lock.json generated
View file

@ -12,7 +12,8 @@
"cookie": "^0.7.0", "cookie": "^0.7.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",
"multer": "^2.0.1" "multer": "^2.0.1",
"sanitize-html": "^2.17.7"
} }
}, },
"node_modules/accepts": { "node_modules/accepts": {
@ -155,6 +156,12 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT" "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": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -164,6 +171,15 @@
"ms": "2.0.0" "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": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -183,6 +199,73 @@
"npm": "1.2.8000 || >= 1.4.16" "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": { "node_modules/dotenv": {
"version": "16.6.1", "version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@ -224,6 +307,18 @@
"node": ">= 0.8" "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": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "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==", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT" "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": { "node_modules/etag": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@ -433,6 +540,28 @@
"node": ">= 0.4" "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": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -480,6 +609,24 @@
"node": ">= 0.10" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -573,6 +720,24 @@
"url": "https://opencollective.com/express" "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": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@ -606,6 +771,12 @@
"node": ">= 0.8" "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": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -621,6 +792,40 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "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": { "node_modules/proxy-addr": {
"version": "2.0.8", "version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@ -704,6 +909,24 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "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": { "node_modules/send": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@ -827,6 +1050,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",

View file

@ -12,6 +12,7 @@
"cookie": "^0.7.0", "cookie": "^0.7.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",
"multer": "^2.0.1" "multer": "^2.0.1",
"sanitize-html": "^2.17.7"
} }
} }

View file

@ -2,7 +2,9 @@
'use strict'; 'use strict';
let schema = []; let schema = [];
let fonts = [];
let state = { content: null, theme: null }; let state = { content: null, theme: null };
let activeSectionKey = null;
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const sectionsEl = document.getElementById('sections'); const sectionsEl = document.getElementById('sections');
const navEl = document.getElementById('section-nav'); const navEl = document.getElementById('section-nav');
@ -48,10 +50,27 @@
return data; 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 --- // --- Field renderers ---
function textField(field, getValue, setValue) { function textField(field, getValue, setValue) {
const isArea = field.type === 'textarea' || field.type === 'richtext'; const isArea = field.type === 'textarea';
const input = isArea const input = isArea
? el('textarea', { rows: 4, oninput: (e) => setValue(e.target.value) }) ? el('textarea', { rows: 4, oninput: (e) => setValue(e.target.value) })
: el('input', { : el('input', {
@ -59,11 +78,7 @@
oninput: (e) => setValue(e.target.value), oninput: (e) => setValue(e.target.value),
}); });
input.value = getValue() || ''; input.value = getValue() || '';
return el('div', { class: 'field' }, [ return el('div', { class: 'field' }, [el('label', {}, [field.label]), input]);
el('label', {}, [field.label]),
input,
field.type === 'richtext' ? el('p', { class: 'field-hint' }, ['**fett**, *kursiv* möglich']) : null,
]);
} }
function selectField(field, getValue, setValue) { 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) { function renderFieldGroup(fields, getObj) {
const container = el('div', {}, []); const container = el('div', {}, []);
fields.forEach((field) => { fields.forEach((field) => {
@ -287,6 +436,8 @@
container.appendChild(sizeField(field, getValue, setValue)); container.appendChild(sizeField(field, getValue, setValue));
} else if (field.type === 'color') { } else if (field.type === 'color') {
container.appendChild(colorField(field, getValue, setValue)); container.appendChild(colorField(field, getValue, setValue));
} else if (field.type === 'richtext') {
container.appendChild(richTextField(field, getValue, setValue));
} else if (field.type === 'group') { } else if (field.type === 'group') {
container.appendChild( container.appendChild(
el('fieldset', { class: 'group-field' }, [ el('fieldset', { class: 'group-field' }, [
@ -294,8 +445,10 @@
renderFieldGroup(field.itemFields, () => getObj()[field.key]), 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)); container.appendChild(renderStringList(field, getObj));
} else if (field.type === 'list-richtext') {
container.appendChild(renderRichTextList(field, getObj));
} else if (field.type === 'list-object') { } else if (field.type === 'list-object') {
container.appendChild(renderObjectList(field, getObj)); container.appendChild(renderObjectList(field, getObj));
} else { } else {
@ -313,9 +466,7 @@
listEl.innerHTML = ''; listEl.innerHTML = '';
const arr = getObj()[field.key]; const arr = getObj()[field.key];
arr.forEach((_, i) => { arr.forEach((_, i) => {
const input = field.type === 'list-richtext' const input = el('input', { type: 'text' });
? el('textarea', { rows: 2 })
: el('input', { type: 'text' });
input.value = arr[i]; input.value = arr[i];
input.addEventListener('input', (e) => { input.addEventListener('input', (e) => {
arr[i] = e.target.value; arr[i] = e.target.value;
@ -403,7 +554,7 @@
function renderSection(section) { function renderSection(section) {
const id = 'section-' + section.key; const id = 'section-' + section.key;
const fileKind = section.file || 'content'; 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') { if (section.type === 'array-root') {
const listField = { key: '__root__', itemLabel: section.itemLabel, itemFields: section.itemFields, label: '' }; const listField = { key: '__root__', itemLabel: section.itemLabel, itemFields: section.itemFields, label: '' };
@ -418,24 +569,60 @@
return block; 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() { function renderAll() {
sectionsEl.innerHTML = ''; sectionsEl.innerHTML = '';
navEl.innerHTML = ''; navEl.innerHTML = '';
let lastGroup = null;
schema.forEach((section) => { 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)); 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() { async function load() {
setStatus('Lädt …'); 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/schema'),
api('/admin/api/content'), api('/admin/api/content'),
api('/admin/api/theme'), api('/admin/api/theme'),
api('/admin/api/fonts'),
]); ]);
schema = fetchedSchema; schema = fetchedSchema;
fonts = fetchedFonts;
state = { content, theme }; state = { content, theme };
injectFontPreviewStyles();
renderAll(); renderAll();
setStatus('Bereit.'); setStatus('Bereit.');
} }

View file

@ -18,9 +18,10 @@
</div> </div>
</header> </header>
<div class="layout">
<nav id="section-nav" class="section-nav" aria-label="Abschnitte"></nav> <nav id="section-nav" class="section-nav" aria-label="Abschnitte"></nav>
<main id="sections"></main> <main id="sections"></main>
</div>
<div id="log" class="build-log" hidden> <div id="log" class="build-log" hidden>
<div class="build-log-header"> <div class="build-log-header">

View file

@ -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-accent { background: var(--accent); color: #14110f; border-color: var(--accent); font-weight: 600; }
.btn-ghost { background: transparent; border-color: transparent; color: var(--text-muted); } .btn-ghost { background: transparent; border-color: transparent; color: var(--text-muted); }
.section-nav { .layout {
display: flex; display: flex;
flex-wrap: wrap; align-items: flex-start;
gap: 0.4rem; max-width: 1200px;
padding: 1rem 1.5rem 0; margin: 0 auto;
} }
.section-nav a { .section-nav {
font-size: 0.8rem; 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); color: var(--text-muted);
text-decoration: none; margin: 1rem 0 0.3rem;
padding: 0.3rem 0.6rem; padding: 0 0.6rem;
border-radius: 999px; }
border: 1px solid var(--border); .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 { .section-block {
background: var(--surface); background: var(--surface);
@ -84,7 +120,24 @@ main { max-width: 860px; margin: 0 auto; padding: 1.5rem; }
margin-bottom: 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; } .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-label { font-size: 0.72rem; color: var(--text-muted); }
.channel-value { font-size: 0.75rem; color: var(--text-muted); text-align: right; } .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-field { display: flex; align-items: center; gap: 0.9rem; flex-wrap: wrap; }
.image-preview { .image-preview {
width: 84px; height: 84px; border-radius: 8px; object-fit: cover; width: 84px; height: 84px; border-radius: 8px; object-fit: cover;

View file

@ -9,8 +9,10 @@ const multer = require('multer');
const auth = require('./lib/auth'); const auth = require('./lib/auth');
const { readContent, writeContent, readTheme, writeTheme, PROJECT_DIR } = require('./lib/content-store'); const { readContent, writeContent, readTheme, writeTheme, PROJECT_DIR } = require('./lib/content-store');
const { validateContent, validateTheme } = require('./lib/validate'); const { validateContent, validateTheme } = require('./lib/validate');
const { sanitizeContentPayload } = require('./lib/sanitize-content');
const schema = require('./lib/schema'); const schema = require('./lib/schema');
const { runBuild } = require('./lib/build'); const { runBuild } = require('./lib/build');
const { FONT_OPTIONS } = require('./lib/fonts');
const PORT = process.env.PORT || 4321; const PORT = process.env.PORT || 4321;
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(PROJECT_DIR, 'public', 'uploads'); 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); 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) => { app.get('/admin/api/content', auth.requireAuth, (req, res) => {
res.json(readContent()); res.json(readContent());
}); });
app.put('/admin/api/content', auth.requireAuth, auth.requireAdminHeader, (req, res) => { 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) { if (errors.length > 0) {
return res.status(400).json({ error: 'Ungültige Daten.', details: errors }); return res.status(400).json({ error: 'Ungültige Daten.', details: errors });
} }
try { try {
writeContent(req.body); writeContent(sanitized);
res.json({ ok: true }); res.json({ ok: true });
} catch (err) { } catch (err) {
res.status(500).json({ error: `Speichern fehlgeschlagen: ${err.message}` }); res.status(500).json({ error: `Speichern fehlgeschlagen: ${err.message}` });

View file

@ -34,7 +34,7 @@
"titleBefore": "Websites, die zu Ihrem ", "titleBefore": "Websites, die zu Ihrem ",
"titleEmphasis": "Unternehmen", "titleEmphasis": "Unternehmen",
"titleAfter": " passen.", "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", "ctaLabel": "Projekt anfragen",
"visual": { "visual": {
"customImage": "", "customImage": "",
@ -47,7 +47,7 @@
"possibleTag": "Möglich", "possibleTag": "Möglich",
"possibleUrl": "ihr-betrieb.de", "possibleUrl": "ihr-betrieb.de",
"possibleTitle": "Ihr Betrieb in [Ort] – Termine, Preise, Anfahrt", "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" "possibleMeta": "★★★★★ [Zahl] Bewertungen · Jetzt geöffnet"
} }
}, },
@ -247,7 +247,7 @@
"paragraphs": [ "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.", "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.", "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": "", "photo": "",
"laraBio": "", "laraBio": "",

219
package-lock.json generated
View file

@ -8,7 +8,11 @@
"name": "larpaso-website", "name": "larpaso-website",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "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": { "node_modules/@astrojs/compiler-binding": {
@ -1961,6 +1965,49 @@
"@types/unist": "*" "@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": { "node_modules/@types/unist": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@ -2333,6 +2380,21 @@
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
"license": "CC0-1.0" "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": { "node_modules/defu": {
"version": "6.1.7", "version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
@ -2514,6 +2576,18 @@
"@esbuild/win32-x64": "0.28.2" "@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": { "node_modules/eventemitter3": {
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
@ -2710,6 +2784,107 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/http-cache-semantics": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "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" "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": { "node_modules/js-yaml": {
"version": "4.3.2", "version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
@ -2780,6 +2964,15 @@
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
"license": "MIT" "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": { "node_modules/lightningcss": {
"version": "1.33.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@ -3364,6 +3557,12 @@
"integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
"license": "MIT" "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": { "node_modules/piccolore": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
@ -3544,6 +3743,24 @@
"@rolldown/binding-win32-x64-msvc": "1.2.8" "@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": { "node_modules/satteri": {
"version": "0.10.5", "version": "0.10.5",
"resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz",

View file

@ -11,6 +11,10 @@
"astro": "astro" "astro": "astro"
}, },
"dependencies": { "dependencies": {
"astro": "^7.3.2" "astro": "^7.3.2",
"sanitize-html": "^2.17.7"
},
"devDependencies": {
"@types/sanitize-html": "^2.16.1"
} }
} }

View file

@ -1,10 +1,13 @@
import siteData from '../../content/site.json'; import siteData from '../../content/site.json';
import themeData from '../../content/theme.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 site = siteData;
export const theme = themeData; export const theme = themeData;
export { sanitizeRich as rich };
const COLOR_VAR_NAMES: Record<string, string> = { const COLOR_VAR_NAMES: Record<string, string> = {
bg: '--color-bg', bg: '--color-bg',
bgAlt: '--color-bg-alt', 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})`; 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 /** 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 { export function themeCss(t: typeof theme): string {
const lines: string[] = [':root {']; const lines: string[] = [':root {'];
for (const [key, varName] of Object.entries(COLOR_VAR_NAMES)) { 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-serif: ${fontFamilyCss(t.fonts.heading)} !important;`);
lines.push(` --font-sans: ${fontFamilyCss(t.fonts.body)} !important;`); lines.push(` --font-sans: ${fontFamilyCss(t.fonts.body)} !important;`);
lines.push('}'); lines.push('}');
lines.push(inlineFontFacesCss(usedInlineFonts()));
return lines.join('\n'); return lines.join('\n');
} }
export function googleFontsHref(t: typeof theme): string | null { 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 /** 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. */ * text where the email address needs to stay in sync with content.kontakt.email. */
export function mdWithEmail(input: string | undefined | null, email: string): string { export function richWithEmail(input: string | undefined | null, email: string): string {
const html = md(input); const html = sanitizeRich(input);
const link = `<a href="mailto:${escapeAttr(email)}">${escapeHtml(email)}</a>`; const link = `<a href="mailto:${escapeAttr(email)}">${escapeHtml(email)}</a>`;
return html.split('{email}').join(link); return html.split('{email}').join(link);
} }
@ -80,15 +104,3 @@ function escapeHtml(input: string): string {
.replace(/"/g, '&quot;') .replace(/"/g, '&quot;')
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
} }
/**
* 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;
}

View file

@ -37,10 +37,11 @@ export function fontFamilyCss(name: string): string {
return font.google ? `'${font.name}', ${font.fallback}` : font.fallback; return font.google ? `'${font.name}', ${font.fallback}` : font.fallback;
} }
/** Builds the Google Fonts CSS2 stylesheet URL for the currently selected /** Builds the Google Fonts CSS2 stylesheet URL for the given font names
* heading/body fonts (system fonts are skipped — nothing to fetch). */ * (system fonts are skipped — nothing to fetch). Used for the theme's
export function googleFontsHref(headingFont: string, bodyFont: string): string | null { * heading/body fonts plus any fonts used inline via the rich-text editor. */
const families = [headingFont, bodyFont] export function googleFontsHref(fontNames: string[]): string | null {
const families = fontNames
.map(findFont) .map(findFont)
.filter((f) => f.google) .filter((f) => f.google)
.filter((f, i, arr) => arr.findIndex((g) => g.name === f.name) === i) .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; if (families.length === 0) return null;
return `https://fonts.googleapis.com/css2?${families.join('&')}&display=swap`; 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
View 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];
}

View file

@ -1,6 +1,6 @@
--- ---
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import { site, mdWithEmail } from '../lib/content'; import { site, richWithEmail } from '../lib/content';
const { datenschutz, kontakt } = site; const { datenschutz, kontakt } = site;
const introFallback = const introFallback =
@ -20,7 +20,7 @@ const introFallback =
{datenschutz.sections.map((section) => ( {datenschutz.sections.map((section) => (
<> <>
<h2>{section.heading}</h2> <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]'} />
</> </>
))} ))}

View file

@ -1,6 +1,6 @@
--- ---
import Layout from '../layouts/Layout.astro'; 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; 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"> <div class="hero-copy">
<p class="eyebrow">{hero.eyebrow}</p> <p class="eyebrow">{hero.eyebrow}</p>
<h1>{hero.titleBefore}<em>{hero.titleEmphasis}</em>{hero.titleAfter}</h1> <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"> <div class="hero-actions">
<a class="btn btn-primary" href="#kontakt">{hero.ctaLabel}</a> <a class="btn btn-primary" href="#kontakt">{hero.ctaLabel}</a>
</div> </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> <span class="search-tag search-tag-accent">{hero.visual.possibleTag}</span>
<p class="search-url">{hero.visual.possibleUrl}</p> <p class="search-url">{hero.visual.possibleUrl}</p>
<p class="search-title">{hero.visual.possibleTitle}</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> <p class="search-meta">{hero.visual.possibleMeta}</p>
</div> </div>
</> </>
@ -51,7 +51,7 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
<p class="eyebrow">{preis.eyebrow}</p> <p class="eyebrow">{preis.eyebrow}</p>
<h2>{preis.title}</h2> <h2>{preis.title}</h2>
<p class="price-tag"><span class="price-amount">{preis.amount}</span> {preis.amountNote}</p> <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"> <div class="price-progress" aria-hidden="true">
<span></span><span></span><span></span> <span></span><span></span><span></span>
</div> </div>
@ -66,8 +66,8 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
<aside class="price-aside card"> <aside class="price-aside card">
<h3>{preis.asideTitle}</h3> <h3>{preis.asideTitle}</h3>
<p>{preis.asideParagraph1}</p> <p set:html={rich(preis.asideParagraph1)} />
<p>{preis.asideParagraph2}</p> <p set:html={rich(preis.asideParagraph2)} />
<a class="btn btn-primary" href="#kontakt">{preis.ctaLabel}</a> <a class="btn btn-primary" href="#kontakt">{preis.ctaLabel}</a>
</aside> </aside>
</div> </div>
@ -78,7 +78,7 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
<div class="container"> <div class="container">
<p class="eyebrow">{warum.eyebrow}</p> <p class="eyebrow">{warum.eyebrow}</p>
<h2>{warum.title}</h2> <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"> <div class="grid grid-3 differentiators">
{highlights.map((item) => ( {highlights.map((item) => (
@ -92,7 +92,7 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
<div class="readability"> <div class="readability">
<h3>{warum.readability.title}</h3> <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 ? ( {warum.readability.customImage ? (
<img class="readability-image" src={warum.readability.customImage} alt="" loading="lazy" /> <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"> <div class="container">
<p class="eyebrow">{leistungen.eyebrow}</p> <p class="eyebrow">{leistungen.eyebrow}</p>
<h2>{leistungen.title}</h2> <h2>{leistungen.title}</h2>
<p class="text-lead">{leistungen.lead}</p> <p class="text-lead" set:html={rich(leistungen.lead)} />
<ul class="leistungen-list"> <ul class="leistungen-list">
{leistungen.items.map((item) => ( {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> <p class="about-quote">{about.quote}</p>
{about.paragraphs.map((paragraph) => ( {about.paragraphs.map((paragraph) => (
<p set:html={md(paragraph)} /> <p set:html={rich(paragraph)} />
))} ))}
<div class="about-bios"> <div class="about-bios">
@ -273,7 +273,7 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
<div class="contact-intro"> <div class="contact-intro">
<p class="eyebrow">{kontakt.eyebrow}</p> <p class="eyebrow">{kontakt.eyebrow}</p>
<h2>{kontakt.title}</h2> <h2>{kontakt.title}</h2>
<p class="text-lead">{kontakt.lead}</p> <p class="text-lead" set:html={rich(kontakt.lead)} />
<div class="contact-detail"> <div class="contact-detail">
<p class="contact-label">Lieber anrufen</p> <p class="contact-label">Lieber anrufen</p>