Firmendaten aus einer Quelle
Ein Datenblock firma.json erzeugt Kontaktblock und LocalBusiness-JSON-LD; ein Abgleich vergleicht sichtbaren Text und Markup und markiert jede Abweichung in allen drei Spalten.
Ein Datenblock im Projekt (zum Beispiel firma.json) ist die einzige Quelle für Name, Adresse, Telefon, Öffnungs- und Sonderzeiten, Leistungen mit ab-Preisen, Einzugsgebiet und Profil-Links. Daraus entstehen drei Ausgaben: der sichtbare Kontaktblock, die Sprechzeiten und das JSON-LD im Kopf der Seite (LocalBusiness-Untertyp wie Dentist, HairSalon oder AutoRepair, OpeningHoursSpecification samt Sonderzeiten, sameAs). Weil alle drei aus denselben Daten entstehen, laufen sie nicht auseinander. Ein Abgleich vergleicht sichtbaren Text und Markup und meldet Abweichungen.
Bei kleinen Firmen stehen Telefon und Zeiten oft an vier Stellen: Kopf, Fußzeile, Kontaktseite und ein SEO-Plugin. Nach der ersten Änderung stimmt eine davon nicht mehr. Google nennt für AI Overviews und AI Mode keine Zusatzanforderungen, empfiehlt aber strukturierte Daten, die zum sichtbaren Text passen, und ein aktuelles Unternehmensprofil. Genau das sichert eine einzige Quelle. Nebenbei sehen Menschen dieselben Zeiten wie Maschinen, auch an Feiertagen.
Wann einsetzen
Bei jeder Seite mit Ort und Kontakt: Praxis, Handwerk, Gastronomie, Hotel, Kanzlei, Fahrschule, Immobilien, Friseur, Fitness, Werkstatt, Verein, Kultur. Besonders wenn Sonderzeiten (Urlaub, Feiertage, Fortbildung) mehrmals im Jahr gepflegt werden oder mehrere Standorte dieselbe Vorlage nutzen.
Wann nicht
Nicht als Versprechen für Rankings oder KI-Antworten: Markup ist eine Empfehlung von Google, keine Garantie. Kein FAQPage-Markup mehr für Rich Results, Google zeigt sie seit 07.05.2026 nicht mehr. Keine Bewertungssterne über das eigene Unternehmen im Markup. Kein Markup für Inhalte, die auf der Seite nicht sichtbar sind. Eine llms.txt ersetzt den Datenblock nicht; sie ist optional und hat bei Google keine belegte Wirkung.
Varianten
- Praxis: Dentist (oder ein anderer MedicalBusiness-Untertyp) mit Sprechzeiten, Urlaub und Notdienst-Hinweis
- Salon: HairSalon mit Leistungen und ab-Preisen
- Werkstatt: AutoRepair mit Einzugsgebiet und Hol- und Bringdienst
- Mehrere Standorte: ein Datensatz je Standort, je Standortseite ein LocalBusiness
- Kultur: Event-Markup für einzelne Termine zusätzlich zum Ort
Umsetzung
Eine Datei firma.json im Projekt ist die einzige Quelle. Ein Build-Schritt oder Server-Include (Eleventy, Astro, PHP) rendert daraus den Kontaktblock (address, tel:-Link mit +49, mailto), die Zeiten als Tabelle, die Sonderzeiten als Hinweis und ein <script type="application/ld+json"> im Kopf. Gleiche Zeiträume werden im Markup über eine dayOfWeek-Liste zusammengefasst. Sonderzeiten stehen als OpeningHoursSpecification mit validFrom und validThrough, ganztägig geschlossen mit opens und closes "00:00" (so beschreibt es Google). Vergangene Sonderzeiten fallen in Seite und Markup gleichzeitig heraus. Leistungen als hasOfferCatalog mit Offer, Service und PriceSpecification minPrice. Profile als sameAs. Ein Test im Build liest das JSON-LD und den sichtbaren Text (innerText) und bricht bei Abweichungen ab; die Demo zeigt diesen Abgleich live. Prüfen mit dem Rich-Results-Test und dem Schema Markup Validator. Die Demo rendert im Browser, damit sich Branche, Sonderzeit und Fehler umschalten lassen; im Kopf steht das erzeugte JSON-LD trotzdem schon fertig im HTML.
Robust umsetzen
Telefon. Kontaktblock zuerst, Anruf als großer tel:-Knopf, Zeiten als Tabelle in voller Breite. Das JSON-LD ist für Telefon und Desktop gleich.
Ohne Bewegung. Kaum Bewegung: nur das kurze Aufleuchten geänderter Werte im Abgleich, das bei reduzierter Bewegung entfällt.
Barrierefrei. 1.3.1 Info und Beziehungen (A): Sprechzeiten als table mit th für Tage, Adresse in address, Telefon als echter tel:-Link. 3.2.4 Konsistente Identifikation (AA) und 3.2.6 Konsistente Hilfe (A): Kontaktwege stehen auf allen Seiten gleich, weil sie aus derselben Quelle kommen. 1.4.3 Kontrast (AA): Sonderzeiten-Hinweis nicht nur über Farbe, sondern mit Text und Datum. 2.4.6 Überschriften (AA): eigener Abschnitt „Sprechzeiten“ und „Sonderzeiten“. Zeiten in Textform („8 bis 12 Uhr“) oder mit time-Element, damit Screenreader sie verständlich vorlesen. Telefonnummer in Gruppen, damit sie einzeln vorgelesen wird.
Code
<!DOCTYPE html>
<!--
Firmendaten aus einer Quelle
Fundus · Zugang & Robustheit · https://cecodesign.de/lab/fundus/zugang/firmendaten-strukturiert/
Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Firmendaten aus einer Quelle · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<!-- Ausgabe 3: JSON-LD im Kopf. Wird im echten Projekt beim Bauen aus firma.json erzeugt; die Demo erzeugt es beim Umschalten neu. -->
<script type="application/ld+json" id="ld">
{
"@context": "https://schema.org",
"@type": "Dentist",
"@id": "https://www.zahnpraxis-neckarbogen.example/#firma",
"name": "Zahnpraxis Neckarbogen",
"url": "https://www.zahnpraxis-neckarbogen.example/",
"telephone": "+49 711 3947210",
"email": "praxis@zahnpraxis-neckarbogen.example",
"address": {
"@type": "PostalAddress",
"streetAddress": "Ritterstraße 14",
"postalCode": "73728",
"addressLocality": "Esslingen am Neckar",
"addressCountry": "DE"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": 48.74135,
"longitude": 9.30648
},
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": [
"Monday",
"Tuesday",
"Thursday"
],
"opens": "08:00",
"closes": "12:00"
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": [
"Monday"
],
"opens": "14:00",
"closes": "18:00"
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": [
"Tuesday",
"Thursday"
],
"opens": "14:00",
"closes": "19:00"
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": [
"Wednesday",
"Friday"
],
"opens": "08:00",
"closes": "13:00"
},
{
"@type": "OpeningHoursSpecification",
"opens": "08:00",
"closes": "12:00",
"validFrom": "2026-12-24",
"validThrough": "2026-12-24"
},
{
"@type": "OpeningHoursSpecification",
"opens": "00:00",
"closes": "00:00",
"validFrom": "2026-12-25",
"validThrough": "2027-01-01"
}
],
"areaServed": [
{
"@type": "City",
"name": "Esslingen am Neckar"
},
{
"@type": "City",
"name": "Ostfildern"
},
{
"@type": "City",
"name": "Denkendorf"
},
{
"@type": "City",
"name": "Altbach"
},
{
"@type": "City",
"name": "Deizisau"
}
],
"knowsLanguage": [
"de",
"en",
"tr"
],
"hasOfferCatalog": {
"@type": "OfferCatalog",
"name": "Leistungen",
"itemListElement": [
{
"@type": "Offer",
"itemOffered": {
"@type": "Service",
"name": "Kontrolle und Beratung"
}
},
{
"@type": "Offer",
"itemOffered": {
"@type": "Service",
"name": "Professionelle Zahnreinigung"
},
"priceSpecification": {
"@type": "PriceSpecification",
"minPrice": 95,
"priceCurrency": "EUR"
}
},
{
"@type": "Offer",
"itemOffered": {
"@type": "Service",
"name": "Aufhellung (Bleaching)"
},
"priceSpecification": {
"@type": "PriceSpecification",
"minPrice": 290,
"priceCurrency": "EUR"
}
},
{
"@type": "Offer",
"itemOffered": {
"@type": "Service",
"name": "Sprechstunde für Angstpatienten"
}
}
]
},
"sameAs": [
"https://maps.example/?cid=4718204391756682013",
"https://instagram.example/zahnpraxis.neckarbogen/"
],
"isAcceptingNewPatients": true
}
</script>
<style>
@font-face { font-family: 'Hedvig Letters Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/hedvig-letters-serif:vf@latest/latin-opsz-normal.woff2') format('woff2-variations'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Google Sans Flex'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 1000; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
:root {
/* Rezept praxis-gesundheit-ruhig: Petrol statt Klinikblau, warme Off-Whites, Grüngrau statt Schwarz */
--grund: #f5f7f7; --karte: #ffffff; --flaeche: #e2e0de; --flaeche-hell: #eeecea;
--text: #363b3b; --leise: #596666; --linie: rgba(54, 59, 59, .14);
--marke: #005450; --marke-hell: #e3eeec; --akzent: #ffc776; --akzent-text: #6b4a00;
--fehler: #a3261c; --fehler-hell: #fbe8e5; --gut: #0f6b52;
--code: #0f2826; --code-text: #e1ebe9; --code-leise: #93a9a5;
--kurve: cubic-bezier(0.2, 0, 0.38, 0.9); /* token-tech-praezise */
--ui: 200ms;
--rand: clamp(16px, 2.6vw, 36px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Google Sans Flex', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; font-size: 1.0625rem; line-height: 1.55; }
button { font: inherit; color: inherit; }
a { color: inherit; }
h1, h2, h3 { font-family: 'Hedvig Letters Serif', Georgia, serif; font-weight: 400; font-variation-settings: "opsz" 24; letter-spacing: -0.01em; line-height: 1.12; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.label { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.zahl { font-variant-numeric: tabular-nums; }
:focus-visible { outline: 3px solid var(--marke); outline-offset: 2px; border-radius: 6px; }
/* ---------- Kopf mit Pult ---------- */
.kopf { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px 32px; align-items: end; padding: 22px var(--rand) 16px; }
.kopf h1 { font-size: clamp(1.75rem, 1.3rem + 1.6vw, 2.6rem); margin-top: 6px; }
.kopf h1 i { font-style: normal; color: var(--marke); }
.intro { max-width: 62ch; color: var(--leise); margin-top: 8px; font-size: 1rem; }
.pult { display: flex; flex-wrap: wrap; gap: 10px 18px; justify-content: flex-start; }
@media (min-width: 1500px) { .kopf { grid-template-columns: minmax(0, 1fr) auto; } .pult { justify-content: flex-end; } }
.pult fieldset { border: 0; padding: 0; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.pult legend { padding: 0; margin-bottom: 6px; }
.gruppe { display: flex; flex-wrap: wrap; gap: 4px; padding: 3px; border-radius: 999px; background: var(--flaeche-hell); border: 1px solid var(--linie); }
.schalter { min-height: 34px; padding: 4px 13px; border: 0; border-radius: 999px; background: transparent; font-size: .875rem; font-weight: 500; cursor: pointer; transition: background-color var(--ui) var(--kurve), color var(--ui) var(--kurve); }
.schalter:hover { background: rgba(0, 84, 80, .08); }
.schalter[aria-pressed="true"] { background: var(--marke); color: #fff; }
.schalter--fehler[aria-pressed="true"]:not([data-fehler=""]) { background: var(--fehler); }
/* ---------- Abgleich ---------- */
.abgleich { margin: 0 var(--rand); display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; padding: 10px 14px; border-radius: 14px; background: var(--karte); border: 1px solid var(--linie); transition: border-color var(--ui) var(--kurve), background-color var(--ui) var(--kurve); }
.abgleich__status { display: flex; align-items: center; gap: 10px; font-weight: 560; font-size: .9375rem; }
.abgleich__punkt { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; background: var(--gut); color: #fff; flex: none; }
.abgleich__punkt svg { width: 13px; height: 13px; }
.abgleich__info { color: var(--leise); font-size: .875rem; }
.abgleich ul { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; flex-basis: 100%; }
.abgleich ul:empty { display: none; }
.abw-knopf { display: inline-flex; align-items: baseline; gap: 8px; min-height: 32px; padding: 5px 12px; border-radius: 10px; border: 1px solid rgba(163, 38, 28, .35); background: var(--fehler-hell); color: #7c1d15; font-size: .8125rem; text-align: left; cursor: pointer; transition: background-color var(--ui) var(--kurve); }
.abw-knopf:hover { background: #f7d6d1; }
.abw-knopf b { font-weight: 600; }
.abw-knopf code { font: 500 12px/1.3 'Geist Mono', monospace; }
.abgleich[data-zustand="fehler"] { border-color: rgba(163, 38, 28, .45); background: #fff8f7; }
.abgleich[data-zustand="fehler"] .abgleich__punkt { background: var(--fehler); }
/* ---------- Bühne: drei Spalten ---------- */
.buehne { display: grid; gap: 14px; padding: 14px var(--rand) 24px; grid-template-columns: minmax(0, 1fr); }
.spalte { min-width: 0; display: flex; flex-direction: column; border-radius: 16px; overflow: hidden; border: 1px solid var(--linie); background: var(--karte); }
.spalte__kopf { display: flex; align-items: baseline; gap: 10px; padding: 11px 14px 10px; border-bottom: 1px solid var(--linie); }
.spalte__kopf h2 { font-size: 1.125rem; }
.spalte__kopf .nr { font: 500 11px/1 'Geist Mono', monospace; color: var(--marke); border: 1px solid currentColor; border-radius: 999px; padding: 4px 7px; align-self: center; }
.spalte__kopf code { margin-left: auto; font: 500 11.5px/1.2 'Geist Mono', monospace; color: var(--leise); overflow-wrap: anywhere; text-align: right; }
.spalte--ld { background: var(--code); border-color: transparent; color: var(--code-text); }
.spalte--ld .spalte__kopf { border-color: rgba(225, 235, 233, .14); }
.spalte--ld .spalte__kopf .nr { color: var(--akzent); }
.spalte--ld .spalte__kopf code { color: var(--code-leise); }
.spalte--daten { background: #fbfaf8; }
/* Code-Ansichten */
.code { flex: 1; min-height: 0; max-height: 62svh; overflow: auto; padding: 10px 0 18px; font: 450 12px/1.62 'Geist Mono', ui-monospace, monospace; counter-reset: z; scrollbar-width: thin; }
.code:focus-visible, .seite-scroll:focus-visible { outline-offset: -3px; }
.l { display: grid; grid-template-columns: 3.2ch minmax(0, 1fr); column-gap: 1.4ch; padding: 0 12px 0 6px; border-left: 3px solid transparent; transition: background-color var(--ui) var(--kurve), border-color var(--ui) var(--kurve); }
.l::before { counter-increment: z; content: counter(z); content: counter(z) / ""; text-align: right; color: #8a9391; font-variant-numeric: tabular-nums; user-select: none; }
.spalte--ld .l::before { color: #7f9692; }
.t { padding-left: calc(var(--e, 0) * 1.6ch + 2ch); text-indent: -2ch; white-space: pre-wrap; overflow-wrap: anywhere; }
.c-k { color: var(--marke); }
.c-s { color: #7a4a00; }
.c-z { color: #8a2f1c; }
.c-p { color: #6a7473; }
.spalte--ld .c-k { color: #9fdccf; }
.spalte--ld .c-s { color: #ffd596; }
.spalte--ld .c-z { color: #ffb8a6; }
.spalte--ld .c-p { color: var(--code-leise); }
/* Verknüpfte Hervorhebung und Abweichungen */
.l.an { background: rgba(255, 199, 118, .38); border-left-color: #d79a2b; }
.spalte--ld .l.an { background: rgba(255, 199, 118, .17); border-left-color: var(--akzent); }
.l.abw { background: var(--fehler-hell); border-left-color: var(--fehler); }
.spalte--ld .l.abw { background: rgba(255, 120, 98, .2); border-left-color: #ff8a73; }
.l.neu, .seite .neu { animation: aufleuchten 1400ms var(--kurve); }
@keyframes aufleuchten { 0%, 30% { background-color: rgba(255, 199, 118, .7); } 100% { background-color: transparent; } }
/* ---------- Spalte 2: die Kundenseite ---------- */
.browser { display: flex; align-items: center; gap: 10px; padding: 9px 14px; background: var(--flaeche-hell); border-bottom: 1px solid var(--linie); font: 500 11.5px/1.2 'Geist Mono', monospace; color: var(--leise); }
.browser i { display: flex; gap: 5px; }
.browser i::before, .browser i::after, .browser i b { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #c9c6c2; display: block; }
.browser span { overflow-wrap: anywhere; }
.seite-scroll { flex: 1; min-height: 0; overflow: auto; scrollbar-width: thin; }
.seite { padding: clamp(18px, 2.4vw, 30px); display: grid; gap: 22px; }
.seite [data-feld] { border-radius: 8px; transition: background-color var(--ui) var(--kurve), box-shadow var(--ui) var(--kurve); }
.seite [data-feld].an { background: rgba(255, 199, 118, .38); box-shadow: 0 0 0 4px rgba(255, 199, 118, .38); }
.seite [data-feld].abw { background: var(--fehler-hell); box-shadow: 0 0 0 4px var(--fehler-hell), 0 0 0 5.5px var(--fehler); }
.seite .knopf.an { background: #003f3c; box-shadow: 0 0 0 4px rgba(255, 199, 118, .75); }
.seite .knopf.abw { background: var(--fehler); color: #fff; box-shadow: 0 0 0 3px #fff, 0 0 0 5px var(--fehler); }
.marke { display: flex; gap: 14px; align-items: center; }
.marke__zeichen { width: 52px; height: 52px; border-radius: 16px; flex: none; display: grid; place-items: center; background: var(--marke); color: #fff; }
.marke__zeichen svg { width: 30px; height: 30px; }
.marke h3 { font-size: clamp(1.45rem, 1.1rem + 1vw, 1.9rem); padding: 0 4px; margin: 0 -4px; }
.marke p { color: var(--leise); font-size: .9375rem; margin-top: 3px; }
.heute { display: flex; align-items: center; gap: 10px; font-size: .9375rem; }
.heute::before { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--gut); box-shadow: 0 0 0 4px rgba(15, 107, 82, .14); flex: none; }
.heute[data-zu]::before { background: #8a9494; box-shadow: none; }
.kontakt { display: grid; gap: 12px; grid-template-columns: minmax(0, 1fr) auto; align-items: center; padding: 16px; border-radius: 14px; background: var(--grund); }
.kontakt address { font-style: normal; line-height: 1.5; padding: 2px 4px; margin: -2px -4px; }
.kontakt__wege { display: flex; flex-direction: column; gap: 8px; align-items: flex-end; }
.knopf { display: inline-flex; align-items: center; gap: 9px; min-height: 46px; padding: 8px 18px; border-radius: 999px; background: var(--marke); color: #fff; font-weight: 540; text-decoration: none; white-space: nowrap; transition: background-color var(--ui) var(--kurve); }
.knopf:hover { background: #003f3c; }
.knopf svg { width: 17px; height: 17px; flex: none; }
.mail { font-size: .9375rem; text-underline-offset: 4px; padding: 2px 4px; overflow-wrap: anywhere; }
.sonder { border-radius: 14px; background: #fff6e4; border: 1px solid #f1d39b; padding: 14px 16px; }
.sonder h4 { font: 600 .9375rem/1.3 'Google Sans Flex', sans-serif; color: var(--akzent-text); display: flex; gap: 8px; align-items: center; }
.sonder h4 svg { width: 16px; height: 16px; }
.sonder ul { list-style: none; padding: 0; margin-top: 8px; display: grid; gap: 8px; }
.sonder li { padding: 3px 6px; margin: 0 -6px; font-size: .9375rem; }
.sonder time { font-weight: 600; font-variant-numeric: tabular-nums; }
.block h4 { font-size: 1.125rem; font-family: 'Hedvig Letters Serif', serif; font-weight: 400; margin-bottom: 8px; }
.zeiten { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
.zeiten th, .zeiten td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--linie); vertical-align: top; font-size: .9375rem; }
.zeiten th { font-weight: 500; width: 40%; }
.zeiten td { color: var(--text); }
.zeiten tr[data-heute] th, .zeiten tr[data-heute] td { font-weight: 640; color: var(--marke); }
.zeiten tr[data-heute] th::after { content: " · heute"; font-weight: 500; font-size: .8125rem; color: var(--leise); }
.zeiten .zu { color: var(--leise); }
.leistungen { list-style: none; padding: 0; display: grid; gap: 2px; }
.leistungen li { display: flex; justify-content: space-between; gap: 16px; padding: 7px 8px; border-bottom: 1px solid var(--linie); font-size: .9375rem; }
.leistungen .preis { white-space: nowrap; font-variant-numeric: tabular-nums; color: var(--leise); }
.leistungen .preis b { color: var(--text); font-weight: 600; }
.fuss { font-size: .8125rem; color: var(--leise); margin-top: 6px; }
.zwei { display: grid; gap: 22px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr)); }
.gebiet, .sprachen { padding: 2px 6px; margin: 0 -6px; font-size: .9375rem; }
.profile { list-style: none; padding: 2px 6px; margin: 0 -6px; display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: .9375rem; }
.profile a { text-underline-offset: 4px; }
.notdienst { font-size: .875rem; color: var(--leise); border-top: 1px solid var(--linie); padding-top: 14px; }
.demo-hinweis { position: fixed; left: 50%; bottom: 18px; translate: -50% 0; z-index: 20; max-width: min(92vw, 420px); padding: 10px 16px; border-radius: 12px; background: var(--text); color: #fff; font-size: .875rem; }
.demo-hinweis[hidden] { display: none; }
/* ---------- Google-Kasten ---------- */
.google { margin: 0 var(--rand) 40px; display: grid; gap: 14px 28px; grid-template-columns: minmax(0, 1fr); padding: 20px; border-radius: 16px; background: var(--marke-hell); }
.google h2 { font-size: 1.25rem; }
.google ul { padding-left: 1.1em; display: grid; gap: 6px; font-size: .9375rem; max-width: 80ch; }
.google .label { color: #3f5654; }
/* ---------- Breiten ---------- */
@media (min-width: 680px) {
.buehne { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.spalte--seite { grid-column: 1 / -1; order: -1; }
.code { max-height: 68svh; }
.google { grid-template-columns: 15rem minmax(0, 1fr); }
}
@media (min-width: 900px) {
.buehne { grid-template-columns: minmax(0, .92fr) minmax(0, 1.2fr) minmax(0, 1fr); height: max(500px, calc(100svh - 200px)); }
.spalte--seite { grid-column: auto; order: 0; }
.code { max-height: none; }
}
@media (max-width: 899px) { .seite-scroll { overflow: visible; } }
@media (max-width: 460px) {
.kontakt { grid-template-columns: minmax(0, 1fr); }
.kontakt__wege { align-items: stretch; }
.knopf { justify-content: center; }
.code { font-size: 11.5px; }
}
/* Vorschau 960 × 720: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .intro, .vorschau .pult, .vorschau .google { display: none; }
.vorschau .kopf { padding-top: 16px; padding-bottom: 12px; }
.vorschau .buehne { height: calc(100svh - 150px); }
.vorschau .buehne { grid-template-columns: minmax(0, .84fr) minmax(0, 1.04fr) minmax(0, 1.14fr); }
.vorschau .code { font-size: 11px; }
.vorschau .browser span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vorschau .l { grid-template-columns: 2.4ch minmax(0, 1fr); column-gap: 1ch; padding-right: 8px; }
:root.reduziert *, :root.reduziert *::before { transition-duration: .01ms !important; animation: none !important; }
.ohne-zeiger { display: none; }
@media (hover: none) {
.mit-zeiger { display: none; }
.ohne-zeiger { display: inline; }
.schalter:not([aria-pressed="true"]):hover { background: transparent; }
.abw-knopf:hover { background: var(--fehler-hell); }
.code .l { cursor: pointer; }
}
@media (forced-colors: active) {
button, [role=button], [role=tab], .schalter, .abw-knopf { border: 1px solid ButtonText; }
.schalter[aria-pressed="true"] { outline: 2px solid Highlight; }
.l.an, .l.abw, .seite [data-feld].an, .seite [data-feld].abw { outline: 2px solid Highlight; }
}
</style>
</head>
<body>
<main id="inhalt">
<header class="kopf">
<div>
<p class="label">Zugang & Robustheit · Firmendaten</p>
<h1>Eine Quelle, <i>drei Ausgaben</i></h1>
<p class="intro">Name, Adresse, Telefon, Zeiten und Leistungen stehen einmal in <code>firma.json</code>. Kontaktblock und JSON-LD entstehen daraus. Der Abgleich prüft, ob sichtbarer Text und Markup übereinstimmen. <span class="mit-zeiger">Fahren Sie über eine Zeile</span><span class="ohne-zeiger">Tippen Sie auf eine Zeile</span>, um sie in allen drei Spalten zu sehen.</p>
</div>
<div class="pult" role="group" aria-label="Demo steuern">
<fieldset>
<legend class="label">Branche</legend>
<div class="gruppe" id="p-branche">
<button type="button" class="schalter" data-v="zahnarzt" aria-pressed="true">Zahnarzt</button>
<button type="button" class="schalter" data-v="friseur" aria-pressed="false">Friseur</button>
<button type="button" class="schalter" data-v="werkstatt" aria-pressed="false">Werkstatt</button>
</div>
</fieldset>
<fieldset>
<legend class="label">Sonderzeit</legend>
<div class="gruppe">
<button type="button" class="schalter" id="p-sonder" aria-pressed="false">Schließtag eintragen</button>
</div>
</fieldset>
<fieldset>
<legend class="label">Fehler einbauen</legend>
<div class="gruppe" id="p-fehler">
<button type="button" class="schalter schalter--fehler" data-fehler="" aria-pressed="true">keiner</button>
<button type="button" class="schalter schalter--fehler" data-fehler="telefon" aria-pressed="false">Telefon von Hand</button>
<button type="button" class="schalter schalter--fehler" data-fehler="zeiten" aria-pressed="false">Markup veraltet</button>
</div>
</fieldset>
</div>
</header>
<section class="abgleich" id="abgleich" aria-label="Abgleich von Seite und Markup">
<p class="abgleich__status" role="status" id="abgleich-status"></p>
<p class="abgleich__info" id="abgleich-info"></p>
<ul id="abgleich-liste"></ul>
</section>
<div class="buehne">
<section class="spalte spalte--daten" aria-labelledby="h-daten">
<div class="spalte__kopf"><span class="nr">1</span><h2 id="h-daten">Daten</h2><code>firma.json</code></div>
<div class="code" id="c-daten" tabindex="0" role="region" aria-label="Datenblock firma.json"></div>
</section>
<section class="spalte spalte--seite" aria-labelledby="h-seite">
<div class="spalte__kopf"><span class="nr">2</span><h2 id="h-seite">Seite</h2><code>sichtbarer Kontaktblock</code></div>
<div class="browser" aria-hidden="true"><i><b></b></i><span id="adresszeile"></span></div>
<div class="seite-scroll" id="seite-scroll" tabindex="0" role="region" aria-label="Kontaktblock der Kundenseite">
<div class="seite" id="seite"></div>
</div>
</section>
<section class="spalte spalte--ld" aria-labelledby="h-ld">
<div class="spalte__kopf"><span class="nr">3</span><h2 id="h-ld">Markup</h2><code>application/ld+json</code></div>
<div class="code" id="c-ld" tabindex="0" role="region" aria-label="JSON-LD im Kopf der Seite"></div>
</section>
</div>
<section class="google" aria-labelledby="h-google">
<div><p class="label">Geprüft am 02.11.2026</p><h2 id="h-google">Was Google dazu sagt</h2></div>
<ul>
<li>Für AI Overviews und AI Mode gibt es keine zusätzlichen Anforderungen und kein besonderes Markup. Empfohlen: strukturierte Daten, die zum sichtbaren Text passen, und ein aktuelles Unternehmensprofil.</li>
<li>LocalBusiness braucht <code>name</code> und <code>address</code>. Sonderzeiten über <code>validFrom</code> und <code>validThrough</code>, ganztägig geschlossen mit <code>opens</code> und <code>closes</code> „00:00“.</li>
<li>FAQ-Rich-Results zeigt Google seit 07.05.2026 nicht mehr. Fragen bleiben als Text auf der Seite sinnvoll.</li>
<li>Neue KI-Textdateien braucht es laut Google nicht. Eine <code>llms.txt</code> ist optional und hat bei Google keine belegte Wirkung.</li>
</ul>
</section>
<p class="demo-hinweis" id="demo-hinweis" role="status" hidden></p>
</main>
<script id="daten-skript">
/* ---- firma.json: die einzige Quelle (hier drei Beispielfirmen aus Esslingen) ---- */
const TAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
const TAG_LANG = { Mo: 'Montag', Di: 'Dienstag', Mi: 'Mittwoch', Do: 'Donnerstag', Fr: 'Freitag', Sa: 'Samstag', So: 'Sonntag' };
const TAG_EN = { Mo: 'Monday', Di: 'Tuesday', Mi: 'Wednesday', Do: 'Thursday', Fr: 'Friday', Sa: 'Saturday', So: 'Sunday' };
const SPRACHE = { de: 'Deutsch', en: 'Englisch', tr: 'Türkisch', it: 'Italienisch' };
/* Datum in Europe/Berlin als YYYY-MM-DD und Rechnen auf UTC-Mittag */
function heuteISO(jetzt = new Date()) {
return new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit' }).format(jetzt);
}
function plusTage(iso, n) { const d = new Date(iso + 'T12:00:00Z'); d.setUTCDate(d.getUTCDate() + n); return d.toISOString().slice(0, 10); }
function wochentag(iso) { return TAGE[(new Date(iso + 'T12:00:00Z').getUTCDay() + 6) % 7]; }
/* nächster Tag mit Wochentag tag, frühestens in 10 Tagen */
function naechster(iso, tag) { let d = plusTage(iso, 10); while (wochentag(d) !== tag) d = plusTage(d, 1); return d; }
/* Weihnachten des laufenden Jahres; vergangene Sonderzeiten fallen unten heraus */
function weihnachtsjahr(iso) { return +iso.slice(0, 4); }
function firma(branche, heute, mitSonder) {
const j = weihnachtsjahr(heute);
const F = {
zahnarzt: {
typ: 'Dentist',
name: 'Zahnpraxis Neckarbogen',
zusatz: 'Zahnärztin Dr. Mira Hölderle · Zahnarzt Jonas Behringer',
url: 'https://www.zahnpraxis-neckarbogen.example/',
telefon: '+49 711 3947210',
email: 'praxis@zahnpraxis-neckarbogen.example',
adresse: { strasse: 'Ritterstraße 14', plz: '73728', ort: 'Esslingen am Neckar', land: 'DE' },
geo: [48.74135, 9.30648],
zeiten: { Mo: [['08:00', '12:00'], ['14:00', '18:00']], Di: [['08:00', '12:00'], ['14:00', '19:00']], Mi: [['08:00', '13:00']], Do: [['08:00', '12:00'], ['14:00', '19:00']], Fr: [['08:00', '13:00']], Sa: [], So: [] },
sonderzeiten: [
{ von: j + '-12-24', bis: j + '-12-24', zeiten: [['08:00', '12:00']], anlass: 'Heiligabend' },
{ von: j + '-12-25', bis: (j + 1) + '-01-01', zeiten: [], anlass: 'Feiertage und Praxisurlaub' }
],
schliesstag: { tag: 'Fr', anlass: 'Fortbildung des Teams' },
leistungen: [
{ name: 'Kontrolle und Beratung', ab: null, hinweis: 'Kassenleistung' },
{ name: 'Professionelle Zahnreinigung', ab: 95 },
{ name: 'Aufhellung (Bleaching)', ab: 290 },
{ name: 'Sprechstunde für Angstpatienten', ab: null, hinweis: 'nach Absprache' }
],
gebiet: ['Esslingen am Neckar', 'Ostfildern', 'Denkendorf', 'Altbach', 'Deizisau'],
sprachen: ['de', 'en', 'tr'],
neuePatienten: true,
profile: [
{ name: 'Google-Unternehmensprofil', url: 'https://maps.example/?cid=4718204391756682013' },
{ name: 'Instagram', url: 'https://instagram.example/zahnpraxis.neckarbogen/' }
],
hinweis: 'Bei Schmerzen außerhalb der Sprechzeiten hilft der zahnärztliche Notdienst. Im Notfall 112.'
},
friseur: {
typ: 'HairSalon',
name: 'Salon Webergasse',
zusatz: 'Inhaberin Selin Akbaş, Friseurmeisterin',
url: 'https://www.salon-webergasse.example/',
telefon: '+49 711 3165480',
email: 'termin@salon-webergasse.example',
adresse: { strasse: 'Webergasse 9', plz: '73728', ort: 'Esslingen am Neckar', land: 'DE' },
geo: [48.74052, 9.30431],
zeiten: { Mo: [], Di: [['09:00', '18:30']], Mi: [['09:00', '18:30']], Do: [['09:00', '20:00']], Fr: [['09:00', '18:30']], Sa: [['08:30', '14:00']], So: [] },
sonderzeiten: [
{ von: j + '-12-24', bis: j + '-12-24', zeiten: [['08:00', '12:00']], anlass: 'Heiligabend' },
{ von: j + '-12-31', bis: j + '-12-31', zeiten: [['08:00', '13:00']], anlass: 'Silvester' }
],
schliesstag: { tag: 'Di', anlass: 'Schulung für Farbtechnik' },
leistungen: [
{ name: 'Damenhaarschnitt mit Waschen und Föhnen', ab: 52 },
{ name: 'Herrenhaarschnitt', ab: 31 },
{ name: 'Farbe und Strähnen', ab: 68 },
{ name: 'Kinderhaarschnitt bis 10 Jahre', ab: 18 }
],
gebiet: ['Esslingen am Neckar'],
sprachen: ['de', 'tr'],
profile: [
{ name: 'Google-Unternehmensprofil', url: 'https://maps.example/?cid=9036621184520317745' },
{ name: 'Instagram', url: 'https://instagram.example/salon.webergasse/' }
],
hinweis: 'Termine auch kurzfristig per Telefon. Wer nicht kommen kann, sagt bitte 24 Stunden vorher ab.'
},
werkstatt: {
typ: 'AutoRepair',
name: 'Autowerkstatt Ferrante',
zusatz: 'Kfz-Meisterbetrieb · Inhaber Marco Ferrante',
url: 'https://www.werkstatt-ferrante.example/',
telefon: '+49 711 9318260',
email: 'service@werkstatt-ferrante.example',
adresse: { strasse: 'Mettinger Straße 141', plz: '73728', ort: 'Esslingen am Neckar', land: 'DE' },
geo: [48.73411, 9.27392],
zeiten: { Mo: [['07:30', '17:30']], Di: [['07:30', '17:30']], Mi: [['07:30', '17:30']], Do: [['07:30', '17:30']], Fr: [['07:30', '15:00']], Sa: [], So: [] },
sonderzeiten: [
{ von: j + '-12-24', bis: (j + 1) + '-01-01', zeiten: [], anlass: 'Betriebsferien' }
],
schliesstag: { tag: 'Fr', anlass: 'Inventur' },
leistungen: [
{ name: 'Inspektion nach Herstellervorgaben', ab: 149 },
{ name: 'Reifenwechsel mit Wuchten', ab: 39 },
{ name: 'Klimaservice', ab: 89 },
{ name: 'HU-Vorbereitung und Abnahme vor Ort', ab: null, hinweis: 'Preis nach Prüfstelle' }
],
gebiet: ['Esslingen am Neckar', 'Plochingen', 'Wernau', 'Altbach', 'Ostfildern'],
sprachen: ['de', 'it'],
profile: [
{ name: 'Google-Unternehmensprofil', url: 'https://maps.example/?cid=2257719046338815120' }
],
hinweis: 'Hol- und Bringdienst im Einzugsgebiet, bitte bei der Terminvergabe angeben.'
}
};
const f = JSON.parse(JSON.stringify(F[branche] || F.zahnarzt));
const st = f.schliesstag; delete f.schliesstag;
if (mitSonder) { const d = naechster(heute, st.tag); f.sonderzeiten.unshift({ von: d, bis: d, zeiten: [], anlass: st.anlass }); }
f.sonderzeiten = f.sonderzeiten.filter(s => s.bis >= heute);
f._schliesstag = st;
return f;
}
/* ---- Ausgabe 3: JSON-LD. felder[] sagt, zu welchem Datenfeld ein Eintrag gehört (nur für den Abgleich der Demo). ---- */
function jsonld(f) {
const felder = [];
const gruppen = new Map();
for (const tag of TAGE) for (const [o, c] of f.zeiten[tag]) {
const k = o + '|' + c;
if (!gruppen.has(k)) gruppen.set(k, []);
gruppen.get(k).push(tag);
}
const ohs = [];
for (const [k, tage] of gruppen) {
const [opens, closes] = k.split('|');
ohs.push({ '@type': 'OpeningHoursSpecification', dayOfWeek: tage.map(t => TAG_EN[t]), opens, closes });
felder.push(tage.map(t => 'zeit-' + t).join(' '));
}
for (const s of f.sonderzeiten) {
const z = s.zeiten.length ? s.zeiten : [['00:00', '00:00']];
for (const [opens, closes] of z) {
ohs.push({ '@type': 'OpeningHoursSpecification', opens, closes, validFrom: s.von, validThrough: s.bis });
felder.push('sonder-' + s.von);
}
}
const ld = {
'@context': 'https://schema.org',
'@type': f.typ,
'@id': f.url + '#firma',
name: f.name,
url: f.url,
telephone: f.telefon,
email: f.email,
address: { '@type': 'PostalAddress', streetAddress: f.adresse.strasse, postalCode: f.adresse.plz, addressLocality: f.adresse.ort, addressCountry: f.adresse.land },
geo: { '@type': 'GeoCoordinates', latitude: f.geo[0], longitude: f.geo[1] },
openingHoursSpecification: ohs,
areaServed: f.gebiet.map(name => ({ '@type': 'City', name })),
knowsLanguage: f.sprachen,
hasOfferCatalog: {
'@type': 'OfferCatalog', name: 'Leistungen',
itemListElement: f.leistungen.map(l => {
const o = { '@type': 'Offer', itemOffered: { '@type': 'Service', name: l.name } };
if (l.ab != null) o.priceSpecification = { '@type': 'PriceSpecification', minPrice: l.ab, priceCurrency: 'EUR' };
return o;
})
},
sameAs: f.profile.map(p => p.url)
};
if (f.neuePatienten != null && f.typ === 'Dentist') ld.isAcceptingNewPatients = f.neuePatienten;
return { ld, felder };
}
if (typeof module !== 'undefined') module.exports = { firma, jsonld, heuteISO };
</script>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s);
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const q = new URLSearchParams(location.search);
const BRANCHEN = ['zahnarzt', 'friseur', 'werkstatt'];
const zustand = { branche: BRANCHEN.includes(q.get('v')) ? q.get('v') : 'zahnarzt', sonder: false, fehler: '' };
const HEUTE = heuteISO();
let altText = null;
/* ---------- Code-Ansicht: zeilenweise, jede Zeile kennt ihr Datenfeld ---------- */
function wertHtml(w) {
if (Array.isArray(w)) return '<span class="c-p">[</span>' + w.map(wertHtml).join('<span class="c-p">, </span>') + '<span class="c-p">]</span>';
if (typeof w === 'string') return '<span class="c-s">' + esc(JSON.stringify(w)) + '</span>';
return '<span class="c-z">' + esc(String(w)) + '</span>';
}
const kurz = w => Array.isArray(w) && JSON.stringify(w).length <= 46 && w.every(x => typeof x !== 'object' || (Array.isArray(x) && x.every(y => typeof y !== 'object')));
function zeilen(wert, feldVon) {
const aus = [];
(function geh(w, pfad, ein, vor, nach, felder) {
const f = feldVon(pfad), fs = f ? felder.concat(f.split(' ')) : felder;
if (w === null || typeof w !== 'object' || kurz(w)) { aus.push({ t: vor + wertHtml(w) + nach, e: ein, f: fs }); return; }
const arr = Array.isArray(w), ein2 = arr ? w.map((x, i) => [i, x]) : Object.entries(w);
aus.push({ t: vor + '<span class="c-p">' + (arr ? '[' : '{') + '</span>', e: ein, f: fs });
ein2.forEach(([k, x], i) => geh(x, pfad.concat(k), ein + 1, arr ? '' : '<span class="c-k">' + esc(JSON.stringify(k)) + '</span><span class="c-p">: </span>', i < ein2.length - 1 ? '<span class="c-p">,</span>' : '', fs));
aus.push({ t: '<span class="c-p">' + (arr ? ']' : '}') + '</span>' + nach, e: ein, f: fs });
})(wert, [], 0, '', '', []);
return aus;
}
const zeilenHtml = z => z.map(l => '<span class="l"' + (l.f.length ? ' data-feld="' + [...new Set(l.f)].join(' ') + '"' : '') + '><span class="t" style="--e:' + l.e + '">' + (l.t || ' ') + '</span></span>').join('');
function feldDaten(p) {
const [a, b] = p;
if (p.length === 1) return { name: 'name', telefon: 'telephone', email: 'email', adresse: 'address', gebiet: 'gebiet', profile: 'profile', sprachen: 'sprachen', typ: 'typ', neuePatienten: 'neu' }[a] || null;
if (p.length === 2 && a === 'zeiten') return 'zeit-' + b;
if (p.length === 2 && a === 'leistungen') return 'leistung-' + b;
return null;
}
function feldLd(felder) {
return p => {
const [a, b, c, d] = p;
if (p.length === 1) return { name: 'name', telephone: 'telephone', email: 'email', address: 'address', areaServed: 'gebiet', sameAs: 'profile', knowsLanguage: 'sprachen', '@type': 'typ', isAcceptingNewPatients: 'neu' }[a] || null;
if (p.length === 2 && a === 'openingHoursSpecification') return felder[b];
if (p.length === 3 && a === 'hasOfferCatalog' && b === 'itemListElement') return 'leistung-' + c;
return null;
};
}
/* ---------- Ausgabe 2: sichtbarer Kontaktblock ---------- */
const zeit = s => s.replace(/^0(\d)/, '$1');
const spanne = ([o, c]) => zeit(o) + '–' + zeit(c);
const datum = iso => { const [y, m, d] = iso.split('-'); return d + '.' + m + '.' + y; };
const telAnzeige = t => { const d = t.replace(/\D/g, '').replace(/^49/, '0'); return d.slice(0, 4) + ' ' + d.slice(4).replace(/(\d{2})(\d{2})(\d+)/, '$1 $2 $3'); };
const ICON = {
Dentist: '<path d="M8.5 5.5c-2.6 0-4 2-3.6 4.8.4 2.6 1.4 4.4 1.8 7 .3 1.8.8 3.2 1.9 3.2 1.4 0 1.5-2.4 2-4.2.3-1 .8-1.6 1.4-1.6s1.1.6 1.4 1.6c.5 1.8.6 4.2 2 4.2 1.1 0 1.6-1.4 1.9-3.2.4-2.6 1.4-4.4 1.8-7 .4-2.8-1-4.8-3.6-4.8-1.6 0-2.3.9-3.5.9s-1.9-.9-3.5-.9Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>',
HairSalon: '<circle cx="8" cy="18" r="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="16" cy="18" r="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M9.7 16 17 4.5M14.3 16 7 4.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
AutoRepair: '<path d="M14.6 6.2a4.2 4.2 0 0 0-5.3 5.3l-5 5a1.7 1.7 0 0 0 2.4 2.4l5-5a4.2 4.2 0 0 0 5.3-5.3l-2.4 2.4-2.2-.3-.3-2.2 2.5-2.3Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>'
};
const TEL = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.6 3.5h2.6l1.4 4-1.9 1.3a11 11 0 0 0 6.5 6.5l1.3-1.9 4 1.4v2.6c0 1.1-.9 2-2 2A16.5 16.5 0 0 1 4.6 5.5c0-1.1.9-2 2-2Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>';
function seiteHtml(f, telSichtbar) {
const hTag = wochentag(HEUTE), hZeiten = f.zeiten[hTag];
const sHeute = f.sonderzeiten.find(s => s.von <= HEUTE && s.bis >= HEUTE);
const heuteText = sHeute ? (sHeute.zeiten.length ? 'Heute geänderte Zeiten: ' + sHeute.zeiten.map(spanne).join(' und ') + ' Uhr' : 'Heute geschlossen (' + sHeute.anlass + ')')
: hZeiten.length ? 'Heute, ' + TAG_LANG[hTag] + ': ' + hZeiten.map(spanne).join(' und ') + ' Uhr' : 'Heute, ' + TAG_LANG[hTag] + ': geschlossen';
const zu = sHeute ? !sHeute.zeiten.length : !hZeiten.length;
const praxis = f.typ === 'Dentist';
const sonder = f.sonderzeiten.length ? `
<aside class="sonder" aria-labelledby="h-sonder">
<h4 id="h-sonder"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="5.5" width="16" height="14" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M4 10h16M8.5 3.5v4M15.5 3.5v4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>Abweichende Zeiten</h4>
<ul>${f.sonderzeiten.map(s => `<li data-feld="sonder-${s.von}"><time datetime="${s.von}">${TAG_LANG[wochentag(s.von)].slice(0, 2)} ${datum(s.von)}</time>${s.bis !== s.von ? ` bis <time datetime="${s.bis}">${datum(s.bis)}</time>` : ''}: ${s.zeiten.length ? s.zeiten.map(spanne).join(' und ') + ' Uhr' : 'geschlossen'} · ${esc(s.anlass)}</li>`).join('')}</ul>
</aside>` : '';
return `
<div class="marke">
<span class="marke__zeichen"><svg viewBox="0 0 24 24" aria-hidden="true">${ICON[f.typ]}</svg></span>
<div><h3 data-feld="name">${esc(f.name)}</h3><p>${esc(f.zusatz)}</p></div>
</div>
<p class="heute"${zu ? ' data-zu' : ''}>${heuteText}</p>
<div class="kontakt">
<address data-feld="address">${esc(f.adresse.strasse)}<br>${f.adresse.plz} ${esc(f.adresse.ort)}</address>
<div class="kontakt__wege">
<a class="knopf" data-feld="telephone" href="tel:${telSichtbar.replace(/[^\d+]/g, '')}">${TEL}<span class="tel-nummer">${telAnzeige(telSichtbar)}</span></a>
<a class="mail" data-feld="email" href="mailto:${f.email}">${f.email}</a>
</div>
</div>
${sonder}
<section class="block" aria-labelledby="h-zeiten">
<h4 id="h-zeiten">${praxis ? 'Sprechzeiten' : 'Öffnungszeiten'}</h4>
<table class="zeiten">
<caption class="sr">${praxis ? 'Sprechzeiten' : 'Öffnungszeiten'} je Wochentag</caption>
<tbody>${TAGE.map(t => `<tr data-feld="zeit-${t}"${t === hTag ? ' data-heute' : ''}><th scope="row">${TAG_LANG[t]}</th><td${f.zeiten[t].length ? '' : ' class="zu"'}>${f.zeiten[t].length ? f.zeiten[t].map(spanne).join(' und ') + ' Uhr' : 'geschlossen'}</td></tr>`).join('')}</tbody>
</table>
</section>
<section class="block" aria-labelledby="h-leist">
<h4 id="h-leist">Leistungen</h4>
<ul class="leistungen">${f.leistungen.map((l, i) => `<li data-feld="leistung-${i}"><span class="l-name">${esc(l.name)}</span><span class="preis">${l.ab != null ? 'ab <b>' + l.ab + ' €</b>' : esc(l.hinweis)}</span></li>`).join('')}</ul>
<p class="fuss">${praxis ? 'Privatleistungen nach GOZ, der genaue Betrag steht im Heil- und Kostenplan.' : 'Ab-Preise inklusive Mehrwertsteuer, der genaue Preis nach Aufwand.'}</p>
</section>
<div class="zwei">
<section class="block" aria-labelledby="h-gebiet"><h4 id="h-gebiet">Einzugsgebiet</h4><p class="gebiet" data-feld="gebiet">${f.gebiet.join(', ')}</p></section>
<section class="block" aria-labelledby="h-sprachen"><h4 id="h-sprachen">Sprachen</h4><p class="sprachen" data-feld="sprachen">${f.sprachen.map(s => SPRACHE[s]).join(', ')}</p>${praxis && f.neuePatienten ? '<p class="sprachen" data-feld="neu">Wir nehmen neue Patientinnen und Patienten auf.</p>' : ''}</section>
</div>
<section class="block" aria-labelledby="h-profile"><h4 id="h-profile">Profile</h4><ul class="profile" data-feld="profile">${f.profile.map(p => `<li><a href="${p.url}" rel="me" data-beispiel>${esc(p.name)}</a></li>`).join('')}</ul></section>
<p class="notdienst">${esc(f.hinweis)}</p>`;
}
/* ---------- Abgleich: sichtbarer Text gegen JSON-LD (so auch als Test im Build) ---------- */
function abgleich() {
const ld = JSON.parse($('#ld').textContent);
const s = $('#seite');
const text = sel => { const el = s.querySelector(sel); return el ? el.innerText.replace(/\s+/g, ' ').trim() : ''; };
const zeitenAus = t => (t.match(/\d{1,2}:\d{2}/g) || []).map(x => x.padStart(5, '0')).reduce((a, x, i, arr) => (i % 2 ? a : a.concat(x + '–' + arr[i + 1])), []).filter(x => x !== '00:00–00:00').join(', ');
const ziffern = t => String(t || '').replace(/\D/g, '').replace(/^49/, '').replace(/^0/, '');
const erg = [];
const pruef = (feld, label, sw, mw, gleich = (a, b) => a === b) => erg.push({ feld, label, s: sw, m: mw, ok: gleich(sw, mw) });
pruef('name', 'Name', text('[data-feld="name"]'), ld.name);
pruef('telephone', 'Telefon', text('.tel-nummer'), ld.telephone, (a, b) => ziffern(a) === ziffern(b));
pruef('email', 'E-Mail', text('[data-feld="email"]'), ld.email);
const a = ld.address || {};
pruef('address', 'Adresse', text('[data-feld="address"]'), [a.streetAddress, a.postalCode, a.addressLocality].join(' '));
const regulaer = (ld.openingHoursSpecification || []).filter(o => !o.validFrom);
for (const t of TAGE) {
const m = regulaer.filter(o => [].concat(o.dayOfWeek).map(String).some(d => d.replace('https://schema.org/', '') === TAG_EN[t])).map(o => o.opens + '–' + o.closes).sort().join(', ');
pruef('zeit-' + t, TAG_LANG[t], zeitenAus(text(`[data-feld="zeit-${t}"] td`)), m, (x, y) => x === y);
}
const sonderM = new Map();
(ld.openingHoursSpecification || []).filter(o => o.validFrom).forEach(o => { const k = o.validFrom; sonderM.set(k, (sonderM.get(k) ? sonderM.get(k) + ', ' : '') + (o.opens === '00:00' && o.closes === '00:00' ? '' : o.opens + '–' + o.closes)); });
const sonderS = new Map([...s.querySelectorAll('[data-feld^="sonder-"]')].map(li => [li.dataset.feld.slice(7), zeitenAus(li.innerText.replace(/^.*?:\s/, ''))]));
for (const k of new Set([...sonderS.keys(), ...sonderM.keys()])) {
const sw = sonderS.has(k) ? (sonderS.get(k) || 'geschlossen') : 'fehlt', mw = sonderM.has(k) ? (sonderM.get(k).replace(/^, |, $/g, '') || 'geschlossen') : 'fehlt';
pruef('sonder-' + k, 'Sonderzeit ' + datum(k), sw, mw);
}
const angebote = ld.hasOfferCatalog ? ld.hasOfferCatalog.itemListElement : [];
s.querySelectorAll('[data-feld^="leistung-"]').forEach(li => {
const i = +li.dataset.feld.slice(9), o = angebote[i] || {};
const preis = (li.querySelector('.preis b') || {}).textContent;
pruef('leistung-' + i, 'Leistung', li.querySelector('.l-name').textContent + (preis ? ' ab ' + parseInt(preis, 10) : ''), ((o.itemOffered || {}).name || 'fehlt') + (o.priceSpecification ? ' ab ' + o.priceSpecification.minPrice : ''));
});
pruef('gebiet', 'Einzugsgebiet', text('[data-feld="gebiet"]'), (ld.areaServed || []).map(x => x.name).join(', '));
pruef('sprachen', 'Sprachen', text('[data-feld="sprachen"]'), (ld.knowsLanguage || []).map(x => SPRACHE[x] || x).join(', '));
pruef('profile', 'Profile', [...s.querySelectorAll('[data-feld="profile"] a')].map(x => x.href).join(' '), (ld.sameAs || []).join(' '));
return erg;
}
function zeigeAbgleich(erg) {
const fehler = erg.filter(e => !e.ok);
document.querySelectorAll('.abw').forEach(el => el.classList.remove('abw'));
fehler.forEach(e => alleMit(e.feld).forEach(el => el.classList.add('abw')));
const box = $('#abgleich');
box.dataset.zustand = fehler.length ? 'fehler' : 'gut';
$('#abgleich-status').innerHTML = '<span class="abgleich__punkt" aria-hidden="true">' + (fehler.length
? '<svg viewBox="0 0 24 24"><path d="M12 6v8M12 17.5v.5" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>'
: '<svg viewBox="0 0 24 24"><path d="m5 12.5 4.5 4.5L19 7.5" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>') + '</span>'
+ (fehler.length ? (fehler.length === 1 ? '1 Abweichung' : fehler.length + ' Abweichungen') + ' zwischen Seite und Markup' : 'Seite und Markup stimmen überein');
$('#abgleich-info').textContent = erg.length + ' Werte geprüft' + (zustand.fehler === 'telefon' ? ' · simuliert: Nummer im HTML von Hand geändert' : zustand.fehler === 'zeiten' ? ' · simuliert: JSON-LD aus einem alten Plugin-Stand' : ' · beide Ausgaben kommen aus firma.json');
$('#abgleich-liste').innerHTML = fehler.map(e => `<li><button type="button" class="abw-knopf" data-zeige="${e.feld}"><b>${esc(e.label)}</b> Seite <code>${esc(e.s || 'leer')}</code> · Markup <code>${esc(e.m || 'leer')}</code></button></li>`).join('');
}
/* ---------- Verknüpfte Hervorhebung ---------- */
function alleMit(feld) { return [...document.querySelectorAll('[data-feld]')].filter(el => el.dataset.feld.split(' ').includes(feld)); }
function hebe(felder) {
document.querySelectorAll('.an').forEach(el => el.classList.remove('an'));
if (!felder) return;
felder.forEach(f => alleMit(f).forEach(el => el.classList.add('an')));
}
function rolleZu(feld) {
['#c-daten', '#seite-scroll', '#c-ld'].forEach(sel => {
const box = $(sel), el = alleMit(feld).find(x => box.contains(x));
if (!el || box.scrollHeight <= box.clientHeight + 2) return;
const top = el.getBoundingClientRect().top - box.getBoundingClientRect().top + box.scrollTop - 48;
box.scrollTo({ top: Math.max(0, top), behavior: reduziert() ? 'auto' : 'smooth' });
});
}
/* ---------- Zeichnen ---------- */
function zeichne() {
const f = firma(zustand.branche, HEUTE, zustand.sonder);
const daten = JSON.parse(JSON.stringify(f)); delete daten._schliesstag;
/* Fehler simulieren: Seite oder Markup bekommen nicht die aktuellen Daten */
const telSichtbar = zustand.fehler === 'telefon' ? f.telefon.replace(/\d{2}$/, d => String((+d + 50) % 100).padStart(2, '0')) : f.telefon;
let quelleLd = f;
if (zustand.fehler === 'zeiten') {
quelleLd = firma(zustand.branche, HEUTE, false);
const tag = TAGE.find(t => quelleLd.zeiten[t].length > 1) || TAGE.find(t => quelleLd.zeiten[t].length);
const z = quelleLd.zeiten[tag]; z[z.length - 1] = [z[z.length - 1][0], String(+z[z.length - 1][1].slice(0, 2) - 1).padStart(2, '0') + ':00'];
quelleLd.sonderzeiten = quelleLd.sonderzeiten.slice(0, 0);
}
const { ld, felder } = jsonld(quelleLd);
$('#ld').textContent = JSON.stringify(ld, null, 2);
$('#c-daten').innerHTML = zeilenHtml(zeilen(daten, feldDaten));
$('#c-ld').innerHTML = zeilenHtml(zeilen(ld, feldLd(felder)));
$('#seite').innerHTML = seiteHtml(f, telSichtbar);
$('#adresszeile').textContent = f.url.replace('https://', '') + 'kontakt/';
$('#p-sonder').textContent = zustand.sonder ? datum(naechster(HEUTE, f._schliesstag.tag)).slice(0, 6) + ' geschlossen' : 'Schließtag eintragen';
$('#p-sonder').setAttribute('aria-pressed', String(zustand.sonder));
document.querySelectorAll('#p-branche .schalter').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === zustand.branche)));
document.querySelectorAll('#p-fehler .schalter').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.fehler === zustand.fehler)));
/* geänderte Werte kurz aufleuchten lassen */
const neuText = new Map();
document.querySelectorAll('[data-feld]').forEach(el => el.dataset.feld.split(' ').forEach(k => neuText.set(k, (neuText.get(k) || '') + el.textContent)));
if (altText) for (const [k, t] of neuText) if (altText.get(k) !== t) alleMit(k).forEach(el => el.classList.add('neu'));
altText = neuText;
setTimeout(() => document.querySelectorAll('.neu').forEach(el => el.classList.remove('neu')), 1500);
zeigeAbgleich(abgleich());
}
function setze(teil, ausNutzer) {
Object.assign(zustand, teil);
if ('branche' in teil) {
const u = new URL(location.href); u.searchParams.set('v', zustand.branche);
history.replaceState(null, '', u);
altText = null;
}
zeichne();
}
/* ---------- Bedienung ---------- */
$('#p-branche').addEventListener('click', e => { const b = e.target.closest('[data-v]'); if (b) { pause(); setze({ branche: b.dataset.v }); } });
$('#p-sonder').addEventListener('click', () => { pause(); setze({ sonder: !zustand.sonder }); });
$('#p-fehler').addEventListener('click', e => { const b = e.target.closest('[data-fehler]'); if (b) { pause(); setze({ fehler: b.dataset.fehler }); } });
$('#abgleich-liste').addEventListener('click', e => { const b = e.target.closest('[data-zeige]'); if (b) { hebe([b.dataset.zeige]); rolleZu(b.dataset.zeige); } });
const buehne = $('.buehne');
buehne.addEventListener('pointerover', e => { if (e.pointerType === 'touch') return; const el = e.target.closest('[data-feld]'); hebe(el ? el.dataset.feld.split(' ') : null); });
buehne.addEventListener('pointerleave', () => hebe(null));
buehne.addEventListener('click', e => {
const a = e.target.closest('a[data-beispiel]');
if (a) { e.preventDefault(); const h = $('#demo-hinweis'); h.textContent = 'Beispieladresse: ' + a.href + ' (in der Demo ohne Ziel)'; h.hidden = false; clearTimeout(h._t); h._t = setTimeout(() => { h.hidden = true; }, 3200); return; }
const l = e.target.closest('.code [data-feld]');
if (l) { const k = l.dataset.feld.split(' '); hebe(k); rolleZu(k[0]); }
});
buehne.addEventListener('focusin', e => { const el = e.target.closest('.seite [data-feld]'); if (el) hebe(el.dataset.feld.split(' ')); });
/* ---------- Vorführung ---------- */
let schleife = null, schritt = 0;
const ABLAUF = [
[1600, () => { setze({ sonder: false, fehler: '' }); hebe(['telephone']); rolleZu('telephone'); }],
[1700, () => { const t = TAGE.find(x => firma(zustand.branche, HEUTE, false).zeiten[x].length > 1) || 'Di'; hebe(['zeit-' + t]); rolleZu('zeit-' + t); }],
[2600, () => { hebe(null); setze({ sonder: true }); const f = firma(zustand.branche, HEUTE, true); rolleZu('sonder-' + f.sonderzeiten[0].von); }],
[3000, () => { setze({ fehler: 'telefon' }); rolleZu('telephone'); }],
[3200, () => { setze({ fehler: 'zeiten' }); const k = (document.querySelector('.abw-knopf') || {}).dataset; if (k) rolleZu(k.zeige); }],
[2200, () => { setze({ fehler: '', sonder: false }); hebe(null); ['#c-daten', '#seite-scroll', '#c-ld'].forEach(s => $(s).scrollTo({ top: 0, behavior: reduziert() ? 'auto' : 'smooth' })); }]
];
function lauf() {
const [dauer, fn] = ABLAUF[schritt % ABLAUF.length];
fn(); schritt++;
schleife = setTimeout(lauf, dauer);
}
function neustart() { pause(); zustand.sonder = false; zustand.fehler = ''; altText = null; hebe(null); zeichne(); }
function vorfuehren() { pause(); html.classList.add('vorfuehrung'); schritt = 0; lauf(); }
function pause() { clearTimeout(schleife); schleife = null; html.classList.remove('vorfuehrung'); }
function weiter() { if (!schleife) { html.classList.add('vorfuehrung'); lauf(); } }
zeichne();
})();
</script>
</body>
</html>