mAi: #9 - GEO Schema-Slot {{schema_jsonld}} in templates/base.html + render.sh

Schema-Markup-Mechanismus für templated Sites (custom Sites bleiben unberührt).

- templates/base.html: {{schema_jsonld}} Slot im <head>.
- site.yaml: optionaler `schema:` Block. `type:` -> `@type`, `@context`
  wird automatisch ergänzt. Fehlt der Block, bleibt der Slot leer.
- render.sh: liest schema via `yq -o=json`, transformiert mit jq, fügt
  Template-Default für `type` ein (person-* -> Person, product-* -> Product,
  editorial -> Article).
- render.sh: literal-string replace (lreplace) statt awk gsub für multiline-
  Substitution. Behebt nebenbei einen latenten Bug, bei dem `&copy;` im
  template_body als `{{body}}copy;` corrupted wurde (gsub interpretierte
  `&` als matched text).
- tests/schema-test.sh + 4 Fixtures: validiert explicit type, Template-
  Defaults für 3 Templates, leerer Slot ohne schema-Block.
- README.md: Schema.org-Konvention dokumentiert (Block-Format, Defaults,
  Custom-Sites-Hinweis, Schema.org-Validator-Link).

QA: ./build.sh -> 59 sites OK, custom Sites byte-identical zur Source,
3 templated Fixtures rendern valides JSON-LD (Person/Product/Article),
no-schema-Fixture produziert keinen <script>-Tag.

Closes #9 nicht - head reviewed + merged.
This commit is contained in:
mAi
2026-04-30 02:50:08 +02:00
parent 156f156aa7
commit 29965c1164
8 changed files with 285 additions and 7 deletions

View File

@@ -83,8 +83,50 @@ vars:
cta:
text: "Contact"
href: "mailto:info@example.de"
schema:
type: Person
name: "Erika Mustermann"
url: "https://example.de/"
jobTitle: "Patentanwältin"
sameAs:
- https://www.linkedin.com/in/erika-mustermann/
- https://github.com/erika-mustermann
```
## Schema.org / JSON-LD (GEO/SEO)
Templated sites can declare a `schema:` block in `site.yaml`. `render.sh` emits it as `<script type="application/ld+json">…</script>` inside `<head>` (slot `{{schema_jsonld}}` in `templates/base.html`). See `docs/geo-seo-guideline.md` §3.3 for rationale.
### Conventions
- `schema.type``@type` (the YAML key is `type`, the rendered key is `@type`).
- `@context: https://schema.org` is added automatically.
- Nested objects use the JSON-LD form directly: write `"@type": Organization` (quoted because of the `@`).
- Array fields like `sameAs:` are passed through as JSON arrays.
- If `schema:` is absent, no `<script>` tag is emitted (empty slot).
- If `schema.type` is omitted, the template default applies:
- `person-dark`, `person-light``Person`
- `product-dark``Product`
- `editorial``Article`
- `fun`, `minimal` → no default (set `type:` explicitly).
Supported types include `Person`, `Organization`, `Article`, `Product`, `FAQPage`, `LocalBusiness`. Schema.org accepts any type — these are just the ones we use most.
### Custom sites
`template: custom` skips rendering, so the slot is **not applied**. Hand-craft the JSON-LD directly inside `index.html` — typically right before `</head>`.
### Testing
```bash
./tests/schema-test.sh
```
Renders fixture files in `tests/fixtures/` and validates that JSON-LD is well-formed, has the correct `@context`/`@type`, and that sites without `schema:` produce no script tag.
For Schema.org-validator checks (recommended for any new live site that uses the slot), paste the rendered `<script type="application/ld+json">` block into <https://validator.schema.org/>.
## Related
- Issue #341: Onepager Mono-Repo

View File

@@ -8,12 +8,17 @@ SITE_YAML="$1"
TEMPLATE_FILE="$2"
SITE_DIR=$(dirname "$SITE_YAML")
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
TEMPLATE_NAME=$(basename "$TEMPLATE_FILE" .html)
# Check dependencies
if ! command -v yq &>/dev/null; then
echo "ERROR: yq is required. Install: https://github.com/mikefarah/yq" >&2
exit 1
fi
if ! command -v jq &>/dev/null; then
echo "ERROR: jq is required for schema rendering." >&2
exit 1
fi
# Read site config
domain=$(yq -r '.domain' "$SITE_YAML")
@@ -137,6 +142,28 @@ if [ -n "$content" ] && [ "$content" != "null" ]; then
fi
fi
# Build JSON-LD schema block (GEO/SEO Schema.org markup)
# Reads .schema from site.yaml. If absent: empty slot.
# If .schema.type missing: fall back to template default.
schema_jsonld=""
schema_json=$(yq -o=json '.schema // null' "$SITE_YAML")
if [ "$schema_json" != "null" ]; then
schema_type=$(echo "$schema_json" | jq -r '.type // empty')
if [ -z "$schema_type" ]; then
case "$TEMPLATE_NAME" in
person-dark|person-light) schema_type="Person" ;;
product-dark) schema_type="Product" ;;
editorial) schema_type="Article" ;;
esac
fi
if [ -n "$schema_type" ]; then
jsonld=$(echo "$schema_json" | jq -c \
--arg t "$schema_type" \
'{"@context": "https://schema.org", "@type": $t} + (. | del(.type))')
schema_jsonld=" <script type=\"application/ld+json\">${jsonld}</script>"
fi
fi
# Read template file and extract CSS/body sections
template_content=$(cat "$TEMPLATE_FILE")
@@ -184,6 +211,7 @@ echo "$css_animations" > "$tmpdir/css_animations"
echo "$css_noise" > "$tmpdir/css_noise"
echo "$template_css" > "$tmpdir/template_css"
printf '%b' "$template_body" > "$tmpdir/template_body"
printf '%s' "$schema_jsonld" > "$tmpdir/schema_jsonld"
# Use awk for reliable multiline substitution
awk -v vars="$tmpdir/css_variables" \
@@ -192,6 +220,7 @@ awk -v vars="$tmpdir/css_variables" \
-v noise="$tmpdir/css_noise" \
-v tcss="$tmpdir/template_css" \
-v tbody="$tmpdir/template_body" \
-v schema="$tmpdir/schema_jsonld" \
-v fonts_file=<(echo "$fonts") \
'
function read_file(path, line, content) {
@@ -200,6 +229,15 @@ function read_file(path, line, content) {
close(path)
return content
}
# Literal string replace — avoids gsub replacement-string interpretation of & and \
function lreplace(haystack, needle, repl, pos, out) {
out = ""
while ((pos = index(haystack, needle)) > 0) {
out = out substr(haystack, 1, pos - 1) repl
haystack = substr(haystack, pos + length(needle))
}
return out haystack
}
BEGIN {
cv = read_file(vars)
cr = read_file(resp)
@@ -207,15 +245,18 @@ BEGIN {
cn = read_file(noise)
tc = read_file(tcss)
tb = read_file(tbody)
sc = read_file(schema)
}
{
gsub(/\{\{css_variables\}\}/, cv)
gsub(/\{\{css_responsive\}\}/, cr)
gsub(/\{\{css_animations\}\}/, ca)
gsub(/\{\{css_noise\}\}/, cn)
gsub(/\{\{template_css\}\}/, tc)
gsub(/\{\{body\}\}/, tb)
print
line = $0
line = lreplace(line, "{{css_variables}}", cv)
line = lreplace(line, "{{css_responsive}}", cr)
line = lreplace(line, "{{css_animations}}", ca)
line = lreplace(line, "{{css_noise}}", cn)
line = lreplace(line, "{{template_css}}", tc)
line = lreplace(line, "{{body}}", tb)
line = lreplace(line, "{{schema_jsonld}}", sc)
print line
}
' <<< "$output" | sed \
-e "s|{{title}}|${title}|g" \

View File

@@ -8,6 +8,7 @@
<meta name="robots" content="index, follow">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>{{favicon}}</text></svg>">
{{fonts}}
{{schema_jsonld}}
<style>
{{css_variables}}
{{css_responsive}}

25
tests/fixtures/article-editorial.yaml vendored Normal file
View File

@@ -0,0 +1,25 @@
domain: example-editorial.de
template: editorial
title: "Über das Schreiben — ein Manifest"
description: "Gedanken zum Schreiben in Zeiten von KI."
lang: de
vars:
name: "Anonym"
tagline: "Gedanken zum Schreiben in Zeiten von KI."
accent: "#c9a84c"
accent_light: "rgba(201, 168, 76, 0.1)"
font_primary: "Inter"
font_secondary: "Newsreader"
content: |
Schreiben ist denken auf Papier.
Der erste Satz ist der schwerste.
schema:
headline: "Über das Schreiben — ein Manifest"
author:
"@type": Person
name: "Anonym"
datePublished: "2026-04-30"
inLanguage: "de"

17
tests/fixtures/no-schema.yaml vendored Normal file
View File

@@ -0,0 +1,17 @@
domain: example-noschema.de
template: minimal
title: "No Schema Test"
description: "A minimal site without a schema block."
lang: de
vars:
name: "Test"
tagline: "Empty schema slot expected."
accent: "#c9a84c"
accent_light: "rgba(201, 168, 76, 0.1)"
font_primary: "Inter"
font_secondary: "Inter"
sections: []
cta:
text: "Kontakt"
href: "#"

32
tests/fixtures/person-explicit.yaml vendored Normal file
View File

@@ -0,0 +1,32 @@
domain: example-person.de
template: person-dark
title: "Erika Mustermann — Patentanwältin"
description: "Erika Mustermann — Patentanwältin in Berlin, Schwerpunkt Software-Patente."
lang: de
vars:
name: "Erika Mustermann"
role: "Patentanwältin"
initials: "EM"
tagline: "Patentanwältin mit Schwerpunkt Software-Patente und UPC-Verfahren in Berlin, seit 2010."
accent: "#c9a84c"
accent_light: "rgba(201, 168, 76, 0.1)"
font_primary: "Inter"
font_secondary: "Inter"
tags: ["UPC", "Software-Patente"]
sections: []
cta:
text: "Kontakt"
href: "mailto:erika@example-person.de"
schema:
type: Person
name: "Erika Mustermann"
url: "https://example-person.de/"
jobTitle: "Patentanwältin"
worksFor:
"@type": Organization
name: "Mustermann & Partner"
sameAs:
- https://www.linkedin.com/in/erika-mustermann/
- https://github.com/erika-mustermann

28
tests/fixtures/product-default.yaml vendored Normal file
View File

@@ -0,0 +1,28 @@
domain: example-product.de
template: product-dark
title: "Beispielprodukt"
description: "Ein Beispielprodukt für Schema-Test."
lang: de
vars:
name: "Beispielprodukt"
role: ""
initials: ""
tagline: "Ein nützliches Tool für SEO-Audits."
accent: "#c9a84c"
accent_light: "rgba(201, 168, 76, 0.1)"
font_primary: "Inter"
font_secondary: "Inter"
tags: []
sections: []
cta:
text: "Mehr erfahren"
href: "https://example-product.de/"
schema:
name: "Beispielprodukt"
description: "Ein nützliches Tool für SEO-Audits."
brand:
"@type": Organization
name: "Beispiel GmbH"
url: "https://example-product.de/"

92
tests/schema-test.sh Executable file
View File

@@ -0,0 +1,92 @@
#!/bin/bash
# Schema-slot tests for render.sh
# Renders fixture site.yaml files and verifies JSON-LD output.
#
# Run from repo root: ./tests/schema-test.sh
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
FIXTURES="$SCRIPT_DIR/fixtures"
pass=0
fail=0
assert_jsonld() {
local name="$1" yaml="$2" template="$3" expected_type="$4"
local out
out=$("$ROOT_DIR/render.sh" "$yaml" "$ROOT_DIR/templates/${template}.html")
local script
script=$(echo "$out" | grep -oE '<script type="application/ld\+json">[^<]+</script>' || true)
if [ -z "$script" ]; then
echo " [FAIL] $name — no JSON-LD <script> found"
fail=$((fail + 1))
return
fi
local json
json=$(echo "$script" | sed -E 's|<script[^>]*>||; s|</script>||')
if ! echo "$json" | jq empty 2>/dev/null; then
echo " [FAIL] $name — invalid JSON: $json"
fail=$((fail + 1))
return
fi
local ctx atype
ctx=$(echo "$json" | jq -r '."@context"')
atype=$(echo "$json" | jq -r '."@type"')
if [ "$ctx" != "https://schema.org" ]; then
echo " [FAIL] $name — wrong @context: $ctx"
fail=$((fail + 1))
return
fi
if [ "$atype" != "$expected_type" ]; then
echo " [FAIL] $name — expected @type=$expected_type, got $atype"
fail=$((fail + 1))
return
fi
if echo "$json" | jq -e 'has("type")' >/dev/null; then
echo " [FAIL] $name — raw 'type' key still present (should be '@type')"
fail=$((fail + 1))
return
fi
echo " [PASS] $name — @type=$atype, valid JSON-LD"
pass=$((pass + 1))
}
assert_no_jsonld() {
local name="$1" yaml="$2" template="$3"
local out
out=$("$ROOT_DIR/render.sh" "$yaml" "$ROOT_DIR/templates/${template}.html")
if echo "$out" | grep -q 'application/ld+json'; then
echo " [FAIL] $name — unexpected JSON-LD present"
fail=$((fail + 1))
return
fi
echo " [PASS] $name — no JSON-LD (as expected)"
pass=$((pass + 1))
}
echo "== schema-test.sh =="
# Explicit type
assert_jsonld "person-explicit (type: Person)" \
"$FIXTURES/person-explicit.yaml" "person-dark" "Person"
# Default from template (no .schema.type)
assert_jsonld "product-default (default Product)" \
"$FIXTURES/product-default.yaml" "product-dark" "Product"
assert_jsonld "article-editorial (default Article)" \
"$FIXTURES/article-editorial.yaml" "editorial" "Article"
# No schema → no script
assert_no_jsonld "no-schema (empty slot)" \
"$FIXTURES/no-schema.yaml" "minimal"
echo "== $pass passed, $fail failed =="
[ $fail -eq 0 ]