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 + + + +
+
+ #Mitmachen + 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 + + + +
+
+

#Mitmachen-Linkvorschauen

+

Bewusst kompakt dargestellt: Beide Motive bleiben auch als kleine Messenger-Thumbnails eindeutig.

+
+ +
+
+

Ö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-Vorschau in 300 mal 158 Pixel +
#Mitmachen
+
+
+ Versorgungs-Netzwerk-Vorschau in 300 mal 158 Pixel +
Versorgungs-Netzwerk
+
+
+
+ +
+

160 × 84
Pixel

+
+
+ #Mitmachen-Vorschau in 160 mal 84 Pixel +
#Mitmachen
+
+
+ Versorgungs-Netzwerk-Vorschau in 160 mal 84 Pixel +
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 + + + +
+
+ #Mitmachen + Konzeptdemo +
+ +
+ + + +
+
+ + 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*]*src=["'][^"']*(?:auth-config|auth-guard)\.js[^"']*["'][^>]*><\/script>/gi, @@ -291,8 +351,8 @@ const appPath = path.join(root, "versorgungs-kompass.html"); let appHtml = fs.readFileSync(appPath, "utf8"); if (!/", - ' \n ' + /\s*<\/head>/, + '\n \n ' ); } appHtml = appHtml.replace( @@ -307,22 +367,40 @@ appHtml = appHtml.replace( dataServiceScript, '\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(`])`, "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(/&#x([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");