diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index edc947d..cbc05af 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -27,6 +27,7 @@ on:
- package-lock.json
- scripts/build_static_frontend.sh
- scripts/audit_public_assets.mjs
+ - scripts/html_metadata_tags.cjs
- scripts/check_deployment_governance.mjs
- .github/workflows/deploy-pages.yml
workflow_dispatch:
@@ -155,6 +156,8 @@ jobs:
no_script_style_path="${RUNNER_TEMP}/pages-no-script.css"
app_path="${RUNNER_TEMP}/pages-versorgungs-kompass.html"
registration_path="${RUNNER_TEMP}/pages-versorgungs-netzwerk.html"
+ root_share_image_path="${RUNNER_TEMP}/mitmachen-share-v1.png"
+ network_share_image_path="${RUNNER_TEMP}/versorgungs-netzwerk-share-v1.png"
curl --fail "${curl_retry_args[@]}" \
"${page_root}/?${cache_buster}" --output "$root_app_path"
@@ -177,6 +180,16 @@ jobs:
"${page_root}/mitmachen/versorgungs-netzwerk.html?${cache_buster}" --output "$registration_path"
curl --fail "${curl_retry_args[@]}" \
"${page_root}/data/data-service.js?${cache_buster}" >/dev/null
+ root_share_content_type="$(curl --fail "${curl_retry_args[@]}" \
+ "${page_root}/public/media/social/mitmachen-share-v1.png?${cache_buster}" \
+ --output "$root_share_image_path" --write-out '%{content_type}')"
+ network_share_content_type="$(curl --fail "${curl_retry_args[@]}" \
+ "${page_root}/public/media/social/versorgungs-netzwerk-share-v1.png?${cache_buster}" \
+ --output "$network_share_image_path" --write-out '%{content_type}')"
+ if [ "$root_share_content_type" != "image/png" ] || [ "$network_share_content_type" != "image/png" ]; then
+ echo "GitHub Pages does not serve the share previews as image/png." >&2
+ exit 1
+ fi
grep --extended-regexp --ignore-case --quiet 'synthetisch|fiktiv' "$demo_data_path"
grep --extended-regexp --quiet 'dataMode:[[:space:]]*"demo"' "$runtime_config_path"
@@ -186,7 +199,14 @@ jobs:
for app_label in Versorgung Auswertung Aktivitäten Stakeholder Expertenkreis Hospitationen Beobachtungen Fragebogen Dashboard Formate Teams; do
grep --fixed-strings --quiet "$app_label" "$app_path"
done
- node - "$app_path" "$registration_path" "$root_app_path" "$manifest_path" "$no_script_style_path" <<'NODE'
+ node - \
+ "$app_path" \
+ "$registration_path" \
+ "$root_app_path" \
+ "$manifest_path" \
+ "$no_script_style_path" \
+ "$root_share_image_path" \
+ "$network_share_image_path" <<'NODE'
const fs = require("node:fs");
const html = fs.readFileSync(process.argv[2], "utf8");
const positions = ["./data/demo-data.js", "./data/demo-api.js", "./data/data-service.js"].map((value) => html.indexOf(value));
@@ -203,6 +223,97 @@ jobs:
if (/data\/(?:runtime-config|demo-data|demo-api)\.js/i.test(registrationHtml)) {
throw new Error("Die statische Netzwerkregistrierung bindet unerwartet Demo-Datenadapter ein.");
}
+ for (const [label, document, expected] of [
+ ["Startseite", html, {
+ canonical: "https://timofrank.github.io/mitmachen/",
+ title: "Jetzt #Mitmachen: Gemeinsam Versorgung besser machen",
+ description: "Entdecken Sie vier Kompasse, die Menschen, Wissen und Ideen verbinden – in einer öffentlichen Demo mit ausschließlich fiktiven Daten.",
+ image: "https://timofrank.github.io/mitmachen/public/media/social/mitmachen-share-v1.png"
+ }],
+ ["Versorgungs-Netzwerk", registrationHtml, {
+ canonical: "https://timofrank.github.io/mitmachen/mitmachen/versorgungs-netzwerk.html",
+ title: "Ihre Erfahrung zählt: Digitale Versorgung besser machen",
+ description: "Entdecken Sie die Idee des Versorgungs-Netzwerks – als Konzeptdemo mit fiktiven Angaben, ohne echte Anmeldung, Übermittlung oder Speicherung.",
+ image: "https://timofrank.github.io/mitmachen/public/media/social/versorgungs-netzwerk-share-v1.png"
+ }]
+ ]) {
+ for (const marker of [
+ ``,
+ ``,
+ ``,
+ ``,
+ '',
+ '',
+ ''
+ ]) {
+ if (!document.includes(marker)) {
+ throw new Error(`${label} enthält nicht den erwarteten Social-Preview-Vertrag: ${marker}`);
+ }
+ }
+ }
+ function crc32(buffer) {
+ let crc = 0xffffffff;
+ for (const byte of buffer) {
+ crc ^= byte;
+ for (let bit = 0; bit < 8; bit += 1) {
+ crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
+ }
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+ }
+ function inspectPng(image) {
+ if (
+ image.length < 45
+ || image.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a"
+ ) {
+ return null;
+ }
+ let offset = 8;
+ let width;
+ let height;
+ let sawHeader = false;
+ let sawImageData = false;
+ let sawEnd = false;
+ while (offset + 12 <= image.length) {
+ const length = image.readUInt32BE(offset);
+ const typeStart = offset + 4;
+ const dataStart = offset + 8;
+ const dataEnd = dataStart + length;
+ const chunkEnd = dataEnd + 4;
+ if (chunkEnd > image.length) return null;
+ const type = image.subarray(typeStart, dataStart).toString("ascii");
+ if (crc32(image.subarray(typeStart, dataEnd)) !== image.readUInt32BE(dataEnd)) return null;
+ if (!sawHeader) {
+ if (type !== "IHDR" || length !== 13) return null;
+ width = image.readUInt32BE(dataStart);
+ height = image.readUInt32BE(dataStart + 4);
+ sawHeader = true;
+ } else if (type === "IHDR") {
+ return null;
+ }
+ if (type === "IDAT") sawImageData = true;
+ if (type === "IEND") {
+ if (length !== 0 || chunkEnd !== image.length) return null;
+ sawEnd = true;
+ offset = chunkEnd;
+ break;
+ }
+ offset = chunkEnd;
+ }
+ if (!sawHeader || !sawImageData || !sawEnd || offset !== image.length) return null;
+ return { width, height };
+ }
+ for (const imagePath of [process.argv[7], process.argv[8]]) {
+ const image = fs.readFileSync(imagePath);
+ const dimensions = inspectPng(image);
+ if (
+ dimensions?.width !== 1200
+ || dimensions?.height !== 630
+ || image.length > 600_000
+ ) {
+ throw new Error(`Share-Vorschau ist kein valides PNG mit 1200 x 630 Pixeln unter 600 KB: ${imagePath}`);
+ }
+ }
const rootHtml = fs.readFileSync(process.argv[4], "utf8");
const noScriptCss = fs.readFileSync(process.argv[6], "utf8");
if (
diff --git a/dokumentation/assets/social/README.md b/dokumentation/assets/social/README.md
new file mode 100644
index 0000000..02d455b
--- /dev/null
+++ b/dokumentation/assets/social/README.md
@@ -0,0 +1,22 @@
+# Share-Vorschauen
+
+Die HTML-Dateien in diesem Ordner sind die reproduzierbaren Browserquellen für
+die 1200 × 630 Pixel großen Open-Graph-Bilder der öffentlichen `#Mitmachen`-Demo.
+
+- `mitmachen-share-v1.html` rendert
+ `public/media/social/mitmachen-share-v1.png`.
+- `versorgungs-netzwerk-share-v1.html` rendert
+ `public/media/social/versorgungs-netzwerk-share-v1.png`.
+- `share-card-preview.html` zeigt beide Bilder zusammen mit den zugehörigen
+ Vorschautiteln und -beschreibungen für die visuelle Abnahme.
+- `share-card-thumbnail-check.html` zeigt beide Motive zusätzlich in 300 × 158
+ und 160 × 84 Pixeln.
+
+Die Grafiken verwenden ausschließlich projektinterne Markenassets,
+synthetische Demo-Inhalte und kein gematik-Unternehmenslogo. Bei einer
+inhaltlichen Änderung erhält die Datei eine neue Versionsnummer, damit bereits
+von Messengern gecachte Linkvorschauen nicht unbemerkt veraltet bleiben.
+
+Die Motive sind bewusst auf Marke, eine große Botschaft und ein grobes Motiv
+reduziert. Tragende Schrift ist mindestens 80 Pixel groß; die Abnahme erfolgt
+zusätzlich in einer kompakten Vorschau mit weniger als 400 Pixel Kartenbreite.
diff --git a/dokumentation/assets/social/mitmachen-share-v1.html b/dokumentation/assets/social/mitmachen-share-v1.html
new file mode 100644
index 0000000..23d30bb
--- /dev/null
+++ b/dokumentation/assets/social/mitmachen-share-v1.html
@@ -0,0 +1,178 @@
+
+
+
+
+
+
+ #Mitmachen – Share-Vorschau
+
+
+
+
+
+
+ Demo
+
+
+
+ Gemeinsam
Versorgung
besser machen.
+
+
+
+
+
diff --git a/dokumentation/assets/social/share-card-preview.html b/dokumentation/assets/social/share-card-preview.html
new file mode 100644
index 0000000..8ffaaf9
--- /dev/null
+++ b/dokumentation/assets/social/share-card-preview.html
@@ -0,0 +1,195 @@
+
+
+
+
+
+
+ #Mitmachen – Linkvorschauen
+
+
+
+
+
+
+
+
+ Öffentliche Demo
+
+
+
timofrank.github.io
+
Jetzt #Mitmachen: Gemeinsam Versorgung besser machen
+
Entdecken Sie vier Kompasse, die Menschen, Wissen und Ideen verbinden – in einer öffentlichen Demo mit ausschließlich fiktiven Daten.
+
+
+
+
+ Versorgungs-Netzwerk
+
+
+
timofrank.github.io
+
Ihre Erfahrung zählt: Digitale Versorgung besser machen
+
Entdecken Sie die Idee des Versorgungs-Netzwerks – als Konzeptdemo mit fiktiven Angaben, ohne echte Anmeldung, Übermittlung oder Speicherung.
+
+
+
+
+ Beide Karten verwenden absolute HTTPS-Bild-URLs und 1200 × 630 Pixel große PNGs.
+
+
+
diff --git a/dokumentation/assets/social/share-card-thumbnail-check.html b/dokumentation/assets/social/share-card-thumbnail-check.html
new file mode 100644
index 0000000..3025725
--- /dev/null
+++ b/dokumentation/assets/social/share-card-thumbnail-check.html
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+ #Mitmachen – Thumbnail-Abnahme
+
+
+
+
+ Abnahme im Messenger-Maßstab
+
+
+
300 × 158
Pixel
+
+
+
+ #Mitmachen
+
+
+
+ Versorgungs-Netzwerk
+
+
+
+
+
+
160 × 84
Pixel
+
+
+
+ #Mitmachen
+
+
+
+ Versorgungs-Netzwerk
+
+
+
+
+
+
+
diff --git a/dokumentation/assets/social/versorgungs-netzwerk-share-v1.html b/dokumentation/assets/social/versorgungs-netzwerk-share-v1.html
new file mode 100644
index 0000000..0e3012c
--- /dev/null
+++ b/dokumentation/assets/social/versorgungs-netzwerk-share-v1.html
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+ Versorgungs-Netzwerk – Share-Vorschau
+
+
+
+
+
+
+ Konzeptdemo
+
+
+
+
+
Versorgungs-Netzwerk
+
Ihre Erfahrung
zählt.
+
+
+
+
+
+
+
diff --git a/public/brand/asset-manifest.json b/public/brand/asset-manifest.json
index ebfbe00..09b8543 100644
--- a/public/brand/asset-manifest.json
+++ b/public/brand/asset-manifest.json
@@ -1,6 +1,6 @@
{
"schemaVersion": 4,
- "lastReviewed": "2026-07-28",
+ "lastReviewed": "2026-07-29",
"commonSender": {
"name": "#Mitmachen",
"canonicalKit": "public/brand/mitmachen/"
@@ -140,6 +140,22 @@
"source": "repo-authored",
"status": "demo-only",
"mayBeModified": true
+ },
+ {
+ "path": "public/media/social/mitmachen-share-v1.png",
+ "role": "public-demo-social-preview",
+ "owner": "Versorgungs-CRM project",
+ "source": "browser-rendered from dokumentation/assets/social/mitmachen-share-v1.html",
+ "status": "approved-for-public-demo",
+ "mayBeModified": true
+ },
+ {
+ "path": "public/media/social/versorgungs-netzwerk-share-v1.png",
+ "role": "registration-demo-social-preview",
+ "owner": "Versorgungs-CRM project",
+ "source": "browser-rendered from dokumentation/assets/social/versorgungs-netzwerk-share-v1.html",
+ "status": "approved-for-public-demo",
+ "mayBeModified": true
}
]
}
diff --git a/public/media/social/mitmachen-share-v1.png b/public/media/social/mitmachen-share-v1.png
new file mode 100644
index 0000000..056c7ba
Binary files /dev/null and b/public/media/social/mitmachen-share-v1.png differ
diff --git a/public/media/social/versorgungs-netzwerk-share-v1.png b/public/media/social/versorgungs-netzwerk-share-v1.png
new file mode 100644
index 0000000..8976b5f
Binary files /dev/null and b/public/media/social/versorgungs-netzwerk-share-v1.png differ
diff --git a/scripts/audit_public_assets.mjs b/scripts/audit_public_assets.mjs
index a309263..6cfed83 100644
--- a/scripts/audit_public_assets.mjs
+++ b/scripts/audit_public_assets.mjs
@@ -2,6 +2,9 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { dirname, extname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
+import htmlMetadataTags from "./html_metadata_tags.cjs";
+
+const { parseHtmlAttributes, scanHtmlStartTags } = htmlMetadataTags;
const root = fileURLToPath(new URL("..", import.meta.url));
const args = process.argv.slice(2);
@@ -37,6 +40,112 @@ function walk(directory) {
});
}
+function parsedMetadataTags(html, tagNames, label) {
+ return scanHtmlStartTags(html, tagNames).map((tag) => {
+ const parsed = parseHtmlAttributes(tag);
+ assert(
+ parsed.duplicateNames.length === 0,
+ `${label} darf in Share-relevanten Tags keine doppelten Attribute enthalten: ${parsed.duplicateNames.join(", ")}`
+ );
+ assert(
+ parsed.structuralCharacterReferenceNames.length === 0,
+ `${label} darf name, property oder rel nicht per Zeichenreferenz verschleiern`
+ );
+ return parsed.values;
+ });
+}
+
+function metadataContent(html, attribute, key, label) {
+ const matches = parsedMetadataTags(html, ["meta"], label)
+ .filter((attributes) => String(attributes[attribute] || "").toLowerCase() === key.toLowerCase());
+ assert(matches.length === 1, `${label} muss genau ein ${attribute}="${key}" enthalten`);
+ return matches[0]?.content;
+}
+
+function canonicalHref(html, label) {
+ const matches = parsedMetadataTags(html, ["link"], label)
+ .filter((attributes) => String(attributes.rel || "").toLowerCase().split(/\s+/).includes("canonical"));
+ assert(matches.length === 1, `${label} muss genau einen Canonical-Link enthalten`);
+ return matches[0]?.href;
+}
+
+function crc32(buffer) {
+ let crc = 0xffffffff;
+ for (const byte of buffer) {
+ crc ^= byte;
+ for (let bit = 0; bit < 8; bit += 1) {
+ crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
+ }
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+}
+
+function inspectPng(filePath) {
+ const image = readFileSync(filePath);
+ const pngSignature = "89504e470d0a1a0a";
+ if (image.length < 45 || image.subarray(0, 8).toString("hex") !== pngSignature) return null;
+
+ let offset = 8;
+ let width;
+ let height;
+ let sawHeader = false;
+ let sawImageData = false;
+ let sawEnd = false;
+ while (offset + 12 <= image.length) {
+ const length = image.readUInt32BE(offset);
+ const typeStart = offset + 4;
+ const dataStart = offset + 8;
+ const dataEnd = dataStart + length;
+ const chunkEnd = dataEnd + 4;
+ if (chunkEnd > image.length) return null;
+
+ const type = image.subarray(typeStart, dataStart).toString("ascii");
+ const expectedCrc = image.readUInt32BE(dataEnd);
+ if (crc32(image.subarray(typeStart, dataEnd)) !== expectedCrc) return null;
+ if (!sawHeader) {
+ if (type !== "IHDR" || length !== 13) return null;
+ width = image.readUInt32BE(dataStart);
+ height = image.readUInt32BE(dataStart + 4);
+ sawHeader = true;
+ } else if (type === "IHDR") {
+ return null;
+ }
+ if (type === "IDAT") sawImageData = true;
+ if (type === "IEND") {
+ if (length !== 0 || chunkEnd !== image.length) return null;
+ sawEnd = true;
+ offset = chunkEnd;
+ break;
+ }
+ offset = chunkEnd;
+ }
+
+ if (!sawHeader || !sawImageData || !sawEnd || offset !== image.length) return null;
+ return { width, height };
+}
+
+const pagesBaseUrl = "https://timofrank.github.io/mitmachen";
+const shareContracts = [
+ {
+ documents: ["index.html", "versorgungs-kompass.html", "demo/index.html"],
+ url: `${pagesBaseUrl}/`,
+ title: "Jetzt #Mitmachen: Gemeinsam Versorgung besser machen",
+ description: "Entdecken Sie vier Kompasse, die Menschen, Wissen und Ideen verbinden – in einer öffentlichen Demo mit ausschließlich fiktiven Daten.",
+ image: `${pagesBaseUrl}/public/media/social/mitmachen-share-v1.png`,
+ imageAlt: "#Mitmachen: Gemeinsam Versorgung besser machen – mit vier Kompassen in einer öffentlichen Demo.",
+ imagePath: "public/media/social/mitmachen-share-v1.png"
+ },
+ {
+ documents: ["mitmachen/versorgungs-netzwerk.html"],
+ url: `${pagesBaseUrl}/mitmachen/versorgungs-netzwerk.html`,
+ title: "Ihre Erfahrung zählt: Digitale Versorgung besser machen",
+ description: "Entdecken Sie die Idee des Versorgungs-Netzwerks – als Konzeptdemo mit fiktiven Angaben, ohne echte Anmeldung, Übermittlung oder Speicherung.",
+ image: `${pagesBaseUrl}/public/media/social/versorgungs-netzwerk-share-v1.png`,
+ imageAlt: "#Mitmachen Versorgungs-Netzwerk: Ihre Erfahrung zählt – Konzeptdemo für digitale Beteiligung.",
+ imagePath: "public/media/social/versorgungs-netzwerk-share-v1.png"
+ }
+];
+
assert(existsSync(artifactRoot) && statSync(artifactRoot).isDirectory(), `${artifactLabel} fehlt oder ist kein Verzeichnis`);
const actualFiles = walk(artifactRoot)
@@ -121,7 +230,9 @@ const requiredFiles = new Set([
"public/demo-profile-viewer.svg",
"public/hospitation/mitmachen-hospitations-framework.docx",
"public/hospitation/mitmachen-hospitations-framework.pdf",
- "public/media/demo/mitmachen/versorgungs-netzwerk-concept.svg"
+ "public/media/demo/mitmachen/versorgungs-netzwerk-concept.svg",
+ "public/media/social/mitmachen-share-v1.png",
+ "public/media/social/versorgungs-netzwerk-share-v1.png"
]);
for (const required of requiredFiles) {
@@ -131,6 +242,67 @@ for (const file of actualFiles) {
assert(requiredFiles.has(file), `${artifactLabel}/${file} ist nicht fuer die oeffentliche Demo freigegeben`);
}
+for (const contract of shareContracts) {
+ const imagePath = join(artifactRoot, contract.imagePath);
+ if (existsSync(imagePath)) {
+ const dimensions = inspectPng(imagePath);
+ assert(
+ dimensions?.width === 1200 && dimensions?.height === 630,
+ `${artifactLabel}/${contract.imagePath} muss ein PNG mit 1200 x 630 Pixeln sein`
+ );
+ assert(
+ statSync(imagePath).size <= 600_000,
+ `${artifactLabel}/${contract.imagePath} muss fuer Messenger hoechstens 600 KB gross sein`
+ );
+ }
+
+ for (const document of contract.documents) {
+ const documentPath = join(artifactRoot, document);
+ if (!existsSync(documentPath)) continue;
+ const html = readFileSync(documentPath, "utf8");
+ const label = `${artifactLabel}/${document}`;
+ assert(canonicalHref(html, label) === contract.url, `${label} verwendet nicht die kanonische Pages-URL`);
+ for (const [property, expected] of [
+ ["og:type", "website"],
+ ["og:locale", "de_DE"],
+ ["og:site_name", "#Mitmachen"],
+ ["og:title", contract.title],
+ ["og:description", contract.description],
+ ["og:url", contract.url],
+ ["og:image", contract.image],
+ ["og:image:secure_url", contract.image],
+ ["og:image:type", "image/png"],
+ ["og:image:width", "1200"],
+ ["og:image:height", "630"],
+ ["og:image:alt", contract.imageAlt]
+ ]) {
+ assert(
+ metadataContent(html, "property", property, label) === expected,
+ `${label} verwendet fuer ${property} nicht den freigegebenen Wert`
+ );
+ }
+ for (const [name, expected] of [
+ ["twitter:card", "summary_large_image"],
+ ["twitter:title", contract.title],
+ ["twitter:description", contract.description],
+ ["twitter:image", contract.image],
+ ["twitter:image:alt", contract.imageAlt]
+ ]) {
+ assert(
+ metadataContent(html, "name", name, label) === expected,
+ `${label} verwendet fuer ${name} nicht den freigegebenen Wert`
+ );
+ }
+ for (const url of [canonicalHref(html, label), metadataContent(html, "property", "og:url", label), metadataContent(html, "property", "og:image", label)]) {
+ try {
+ assert(new URL(url).protocol === "https:", `${label} verwendet eine nicht sichere Share-URL: ${url}`);
+ } catch {
+ assert(false, `${label} verwendet eine ungueltige Share-URL: ${url}`);
+ }
+ }
+ }
+}
+
const textExtensions = new Set([".css", ".html", ".js", ".json", ".mjs", ".webmanifest"]);
const firstPartyText = actualFiles
.filter((file) => textExtensions.has(extname(file)))
diff --git a/scripts/build_static_frontend.sh b/scripts/build_static_frontend.sh
index 2fb90cc..e7d038d 100755
--- a/scripts/build_static_frontend.sh
+++ b/scripts/build_static_frontend.sh
@@ -156,6 +156,7 @@ build_pages() {
"$STAGE_DIR/public/brand/modules/formate" \
"$STAGE_DIR/public/brand/versorgungs-kompass" \
"$STAGE_DIR/public/media/demo/mitmachen" \
+ "$STAGE_DIR/public/media/social" \
"$STAGE_DIR/deutschlandkarte-project/data" \
"$STAGE_DIR/state-flags" \
"$STAGE_DIR/mitmachen" \
@@ -248,6 +249,8 @@ EOF
cp "$ROOT_DIR/public/brand/versorgungs-kompass/mark.svg" "$STAGE_DIR/public/brand/versorgungs-kompass/mark.svg"
cp "$ROOT_DIR/public/brand/versorgungs-kompass/mark-on-dark.svg" "$STAGE_DIR/public/brand/versorgungs-kompass/mark-on-dark.svg"
cp "$ROOT_DIR/public/media/demo/mitmachen/versorgungs-netzwerk-concept.svg" "$STAGE_DIR/public/media/demo/mitmachen/versorgungs-netzwerk-concept.svg"
+ cp "$ROOT_DIR/public/media/social/mitmachen-share-v1.png" "$STAGE_DIR/public/media/social/mitmachen-share-v1.png"
+ cp "$ROOT_DIR/public/media/social/versorgungs-netzwerk-share-v1.png" "$STAGE_DIR/public/media/social/versorgungs-netzwerk-share-v1.png"
cp "$ROOT_DIR/public/manifest.pages.webmanifest" "$STAGE_DIR/manifest.webmanifest"
for asset in mitmachen-hospitations-framework.docx mitmachen-hospitations-framework.pdf; do
if [ -f "$ROOT_DIR/public/hospitation/$asset" ]; then
@@ -266,10 +269,15 @@ EOF
perl -0pi -e 's#\.\./vendor/#./vendor/#g; s#\.\./data/#__ROOT_DATA__/#g; s#\./data/#./deutschlandkarte-project/data/#g; s#__ROOT_DATA__/#./data/#g' "$STAGE_DIR/versorgungs-kompass-map-teaser.html" "$STAGE_DIR/versorgungs-kompass-contact-mini-map.html"
perl -0pi -e 's#"start_url": "\.\./frontend/app/versorgungs-kompass\.html"#"start_url": "./\#home"#; s#"start_url": "\.\./app/versorgungs-kompass\.html"#"start_url": "./\#home"#; s#"scope": "\.\./"#"scope": "./"#; s#"src": "\./brand/#"src": "./public/brand/#g; s#"src": "\./app-icon-#"src": "./public/app-icon-#g' "$STAGE_DIR/manifest.webmanifest"
- node - "$STAGE_DIR" <<'NODE'
+ node - "$STAGE_DIR" "$ROOT_DIR" <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const root = process.argv[2];
+const repositoryRoot = process.argv[3];
+const {
+ parseHtmlAttributes,
+ scanHtmlStartTags
+} = require(path.join(repositoryRoot, "scripts", "html_metadata_tags.cjs"));
function walk(directory) {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
@@ -279,6 +287,58 @@ function walk(directory) {
});
}
+function escapeAttribute(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll('"', """)
+ .replaceAll("<", "<")
+ .replaceAll(">", ">");
+}
+
+function injectShareMetadata(html, metadata) {
+ const existingTags = scanHtmlStartTags(html, ["link", "meta"])
+ .map((tag) => parseHtmlAttributes(tag));
+ const unsafeTag = existingTags.find((parsed) =>
+ parsed.duplicateNames.length > 0 || parsed.structuralCharacterReferenceNames.length > 0
+ );
+ if (unsafeTag) {
+ throw new Error(`Pages-Build fand mehrdeutige Share-Metadaten fuer ${metadata.url}.`);
+ }
+ const alreadyHasShareMetadata = existingTags.some((attributes) => {
+ attributes = attributes.values;
+ const rel = String(attributes.rel || "").toLowerCase().split(/\s+/);
+ const property = String(attributes.property || "").toLowerCase();
+ const name = String(attributes.name || "").toLowerCase();
+ return rel.includes("canonical") || property.startsWith("og:") || name.startsWith("twitter:");
+ });
+ if (alreadyHasShareMetadata) {
+ throw new Error(`Pages-Build fand bereits Share-Metadaten fuer ${metadata.url}.`);
+ }
+
+ const tags = [
+ ``,
+ '',
+ '',
+ '',
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ '',
+ '',
+ '',
+ ``,
+ '',
+ ``,
+ ``,
+ ``,
+ ``
+ ];
+
+ return html.replace(/\s*<\/head>/, `\n ${tags.join("\n ")}\n `);
+}
+
for (const htmlPath of walk(root)) {
const html = fs.readFileSync(htmlPath, "utf8").replace(
/\n?\s*\n \n ' + dataServiceScript
);
+const pagesBaseUrl = "https://timofrank.github.io/mitmachen";
+const rootShareMetadata = {
+ url: `${pagesBaseUrl}/`,
+ title: "Jetzt #Mitmachen: Gemeinsam Versorgung besser machen",
+ description: "Entdecken Sie vier Kompasse, die Menschen, Wissen und Ideen verbinden – in einer öffentlichen Demo mit ausschließlich fiktiven Daten.",
+ image: `${pagesBaseUrl}/public/media/social/mitmachen-share-v1.png`,
+ imageAlt: "#Mitmachen: Gemeinsam Versorgung besser machen – mit vier Kompassen in einer öffentlichen Demo."
+};
+appHtml = injectShareMetadata(appHtml, rootShareMetadata);
fs.writeFileSync(appPath, appHtml);
fs.writeFileSync(path.join(root, "index.html"), appHtml);
+const registrationPath = path.join(root, "mitmachen", "versorgungs-netzwerk.html");
+const registrationHtml = injectShareMetadata(fs.readFileSync(registrationPath, "utf8"), {
+ url: `${pagesBaseUrl}/mitmachen/versorgungs-netzwerk.html`,
+ title: "Ihre Erfahrung zählt: Digitale Versorgung besser machen",
+ description: "Entdecken Sie die Idee des Versorgungs-Netzwerks – als Konzeptdemo mit fiktiven Angaben, ohne echte Anmeldung, Übermittlung oder Speicherung.",
+ image: `${pagesBaseUrl}/public/media/social/versorgungs-netzwerk-share-v1.png`,
+ imageAlt: "#Mitmachen Versorgungs-Netzwerk: Ihre Erfahrung zählt – Konzeptdemo für digitale Beteiligung."
+});
+fs.writeFileSync(registrationPath, registrationHtml);
+
function redirectDocument(target) {
- return `
+ return injectShareMetadata(`
Versorgungs-Kompass Demo
-
Oeffentliche Demo oeffnen
-`;
+`, rootShareMetadata);
}
fs.writeFileSync(path.join(root, "demo", "index.html"), redirectDocument("../#home"));
diff --git a/scripts/html_metadata_tags.cjs b/scripts/html_metadata_tags.cjs
new file mode 100644
index 0000000..fa5ef80
--- /dev/null
+++ b/scripts/html_metadata_tags.cjs
@@ -0,0 +1,108 @@
+"use strict";
+
+const RAW_TEXT_ELEMENTS = new Set(["script", "style", "textarea", "title"]);
+const STRUCTURAL_METADATA_ATTRIBUTES = new Set(["name", "property", "rel"]);
+
+function findTagEnd(html, startIndex) {
+ let quote = "";
+ for (let index = startIndex; index < html.length; index += 1) {
+ const character = html[index];
+ if (quote) {
+ if (character === quote) quote = "";
+ continue;
+ }
+ if (character === '"' || character === "'") {
+ quote = character;
+ continue;
+ }
+ if (character === ">") return index;
+ }
+ return -1;
+}
+
+function scanHtmlStartTags(html, acceptedTagNames) {
+ const accepted = new Set(acceptedTagNames.map((name) => String(name).toLowerCase()));
+ const tags = [];
+ let cursor = 0;
+
+ while (cursor < html.length) {
+ const start = html.indexOf("<", cursor);
+ if (start < 0) break;
+
+ if (html.startsWith("", start + 4);
+ cursor = commentEnd < 0 ? html.length : commentEnd + 3;
+ continue;
+ }
+
+ const nameMatch = /^<([A-Za-z][^\t\n\f\r />]*)/.exec(html.slice(start));
+ if (!nameMatch) {
+ const declarationEnd = findTagEnd(html, start + 1);
+ cursor = declarationEnd < 0 ? start + 1 : declarationEnd + 1;
+ continue;
+ }
+
+ const tagName = nameMatch[1].toLowerCase();
+ const end = findTagEnd(html, start + nameMatch[0].length);
+ if (end < 0) break;
+ if (accepted.has(tagName)) tags.push(html.slice(start, end + 1));
+ cursor = end + 1;
+
+ if (RAW_TEXT_ELEMENTS.has(tagName)) {
+ const closingTag = new RegExp(`${tagName}(?=[\\t\\n\\f\\r />])`, "ig");
+ closingTag.lastIndex = cursor;
+ const closingMatch = closingTag.exec(html);
+ if (!closingMatch) {
+ cursor = html.length;
+ } else {
+ const closingEnd = findTagEnd(html, closingTag.lastIndex);
+ cursor = closingEnd < 0 ? html.length : closingEnd + 1;
+ }
+ }
+ }
+
+ return tags;
+}
+
+function decodeAttributeValue(value) {
+ return value
+ .replace(/([0-9a-f]+);?/gi, (_, digits) => String.fromCodePoint(Number.parseInt(digits, 16)))
+ .replace(/([0-9]+);?/g, (_, digits) => String.fromCodePoint(Number.parseInt(digits, 10)))
+ .replace(/&(amp|apos|gt|lt|quot);/gi, (_, name) => ({
+ amp: "&",
+ apos: "'",
+ gt: ">",
+ lt: "<",
+ quot: '"'
+ })[name.toLowerCase()]);
+}
+
+function parseHtmlAttributes(tag) {
+ const values = Object.create(null);
+ const duplicateNames = [];
+ const structuralCharacterReferenceNames = [];
+
+ for (const match of tag.matchAll(/\b([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g)) {
+ const name = match[1].toLowerCase();
+ const rawValue = match[2] ?? match[3] ?? match[4] ?? "";
+ if (Object.hasOwn(values, name)) {
+ duplicateNames.push(name);
+ continue;
+ }
+ values[name] = decodeAttributeValue(rawValue);
+ if (STRUCTURAL_METADATA_ATTRIBUTES.has(name) && rawValue.includes("&")) {
+ structuralCharacterReferenceNames.push(name);
+ }
+ }
+
+ return {
+ values,
+ duplicateNames: [...new Set(duplicateNames)],
+ structuralCharacterReferenceNames: [...new Set(structuralCharacterReferenceNames)]
+ };
+}
+
+module.exports = {
+ parseHtmlAttributes,
+ scanHtmlStartTags
+};
diff --git a/scripts/test_deployment_separation.mjs b/scripts/test_deployment_separation.mjs
index efda5e9..e81a552 100644
--- a/scripts/test_deployment_separation.mjs
+++ b/scripts/test_deployment_separation.mjs
@@ -3,6 +3,9 @@ import { createHash } from "node:crypto";
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
+import htmlMetadataTags from "./html_metadata_tags.cjs";
+
+const { parseHtmlAttributes, scanHtmlStartTags } = htmlMetadataTags;
const root = process.cwd();
const distRoot = path.join(root, "dist");
@@ -15,6 +18,24 @@ const publicAudit = path.join(root, "scripts", "audit_public_assets.mjs");
const targetAudit = path.join(root, "scripts", "audit_target_assets.mjs");
const apiBaseUrl = "https://gateway.pre-gematik.example";
+const quotedGreaterThanTag = scanHtmlStartTags(
+ '',
+ ["meta"]
+);
+assert.equal(quotedGreaterThanTag.length, 1, "Der Metadaten-Scanner darf nicht an > innerhalb von Quotes abbrechen");
+assert.equal(parseHtmlAttributes(quotedGreaterThanTag[0]).values.content, "Wrong>Preview");
+
+const duplicateAttributeTag = parseHtmlAttributes('');
+assert.equal(duplicateAttributeTag.values.property, "og:title", "Attributparsing muss wie der Browser den ersten Wert behalten");
+assert.deepEqual(duplicateAttributeTag.duplicateNames, ["property"]);
+
+const encodedStructuralTag = parseHtmlAttributes('');
+assert.deepEqual(
+ encodedStructuralTag.structuralCharacterReferenceNames,
+ ["property"],
+ "Zeichenreferenzen in strukturellen Metadaten-Attributen muessen fail-closed markiert werden"
+);
+
function build(...args) {
execFileSync("bash", [builder, ...args], { cwd: root, encoding: "utf8", stdio: "pipe" });
}
@@ -75,6 +96,8 @@ try {
assert.equal(fs.existsSync(path.join(pagesDir, "data", "runtime-config.js")), true, "Pages muss eine explizite Demo-Runtime enthalten");
assert.equal(fs.existsSync(path.join(pagesDir, "vendor", "leaflet", "leaflet.js")), true, "Pages muss die Kartenbibliothek enthalten");
assert.equal(fs.existsSync(path.join(pagesDir, "vendor", "xlsx", "xlsx.bundle.js")), true, "Pages muss die Exportbibliothek der Voll-App enthalten");
+ assert.equal(fs.existsSync(path.join(pagesDir, "public", "media", "social", "mitmachen-share-v1.png")), true, "Pages muss das #Mitmachen-Share-Bild enthalten");
+ assert.equal(fs.existsSync(path.join(pagesDir, "public", "media", "social", "versorgungs-netzwerk-share-v1.png")), true, "Pages muss das Netzwerk-Share-Bild enthalten");
const pagesRootHtml = fs.readFileSync(path.join(pagesDir, "index.html"), "utf8");
const pagesAliasHtml = fs.readFileSync(path.join(pagesDir, "versorgungs-kompass.html"), "utf8");
const pagesNoScriptCss = fs.readFileSync(path.join(pagesDir, "versorgungs-kompass-no-script.css"), "utf8");
@@ -85,10 +108,18 @@ try {
assert.match(pagesNoScriptCss, /\.view-panel\[data-view-panel="home"\]\s*\{[\s\S]*display:\s*block\s*!important/i);
assert.match(pagesNoScriptCss, /button\[data-home-scroll-cue\]\s*\{[\s\S]*display:\s*none/i);
assert.match(pagesRootHtml, //i);
+ assert.match(pagesRootHtml, //);
+ assert.match(pagesRootHtml, //);
+ assert.match(pagesRootHtml, //);
+ assert.match(pagesRootHtml, //);
+ assert.match(pagesRootHtml, //);
assert.match(pagesRootHtml, /\.\/data\/demo-data\.js[\s\S]*\.\/data\/demo-api\.js[\s\S]*\.\/data\/data-service\.js/);
assert.doesNotMatch(pagesRootHtml, /data-public-entry="home"|data-public-entry-styles|>\s*Demo öffnen(?:\s|<)/i);
assert.doesNotMatch(pagesRootHtml, //);
+ assert.match(pagesDemoAliasHtml, //);
assert.match(
fs.readFileSync(path.join(pagesDir, "versorgungs-kompass.html"), "utf8"),
/href="\.\/public\/brand\/mitmachen\/icons\/app-icon-32\.png"/
@@ -172,6 +203,11 @@ try {
}
const pagesRegistrationHtml = fs.readFileSync(path.join(pagesDir, "mitmachen", "versorgungs-netzwerk.html"), "utf8");
assert.match(pagesRegistrationHtml, /', "Auth-Skript"]
@@ -298,7 +389,9 @@ try {
"public-login.html",
"enrollment.html",
"enrollment.css",
- "enrollment.js"
+ "enrollment.js",
+ "public/media/social/mitmachen-share-v1.png",
+ "public/media/social/versorgungs-netzwerk-share-v1.png"
);
const targetPublicIndexHtml = fs.readFileSync(path.join(targetDir, "public-index.html"), "utf8");
@@ -346,6 +439,7 @@ try {
const targetText = textArtifact(targetDir);
assert.doesNotMatch(targetText, /https:\/\/[a-z0-9-]+\.supabase\.co/i, "Target darf keine direkte Supabase-Projekt-URL enthalten");
+ assert.doesNotMatch(targetText, /https:\/\/timofrank\.github\.io\/mitmachen\/public\/media\/social\//i, "Target darf keine Pages-spezifischen Share-URLs enthalten");
assert.doesNotMatch(targetText, /@supabase\/supabase-js|supabase-js@/i, "Target darf kein Supabase Browser-SDK laden");
assert.doesNotMatch(targetText, new RegExp(["arbeits", "raum"].join(""), "i"), "Target enthaelt nicht mehr freigegebenes Wording");