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 `©` 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:
42
README.md
42
README.md
@@ -83,8 +83,50 @@ vars:
|
|||||||
cta:
|
cta:
|
||||||
text: "Contact"
|
text: "Contact"
|
||||||
href: "mailto:info@example.de"
|
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
|
## Related
|
||||||
|
|
||||||
- Issue #341: Onepager Mono-Repo
|
- Issue #341: Onepager Mono-Repo
|
||||||
|
|||||||
55
render.sh
55
render.sh
@@ -8,12 +8,17 @@ SITE_YAML="$1"
|
|||||||
TEMPLATE_FILE="$2"
|
TEMPLATE_FILE="$2"
|
||||||
SITE_DIR=$(dirname "$SITE_YAML")
|
SITE_DIR=$(dirname "$SITE_YAML")
|
||||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
TEMPLATE_NAME=$(basename "$TEMPLATE_FILE" .html)
|
||||||
|
|
||||||
# Check dependencies
|
# Check dependencies
|
||||||
if ! command -v yq &>/dev/null; then
|
if ! command -v yq &>/dev/null; then
|
||||||
echo "ERROR: yq is required. Install: https://github.com/mikefarah/yq" >&2
|
echo "ERROR: yq is required. Install: https://github.com/mikefarah/yq" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if ! command -v jq &>/dev/null; then
|
||||||
|
echo "ERROR: jq is required for schema rendering." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Read site config
|
# Read site config
|
||||||
domain=$(yq -r '.domain' "$SITE_YAML")
|
domain=$(yq -r '.domain' "$SITE_YAML")
|
||||||
@@ -137,6 +142,28 @@ if [ -n "$content" ] && [ "$content" != "null" ]; then
|
|||||||
fi
|
fi
|
||||||
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
|
# Read template file and extract CSS/body sections
|
||||||
template_content=$(cat "$TEMPLATE_FILE")
|
template_content=$(cat "$TEMPLATE_FILE")
|
||||||
|
|
||||||
@@ -184,6 +211,7 @@ echo "$css_animations" > "$tmpdir/css_animations"
|
|||||||
echo "$css_noise" > "$tmpdir/css_noise"
|
echo "$css_noise" > "$tmpdir/css_noise"
|
||||||
echo "$template_css" > "$tmpdir/template_css"
|
echo "$template_css" > "$tmpdir/template_css"
|
||||||
printf '%b' "$template_body" > "$tmpdir/template_body"
|
printf '%b' "$template_body" > "$tmpdir/template_body"
|
||||||
|
printf '%s' "$schema_jsonld" > "$tmpdir/schema_jsonld"
|
||||||
|
|
||||||
# Use awk for reliable multiline substitution
|
# Use awk for reliable multiline substitution
|
||||||
awk -v vars="$tmpdir/css_variables" \
|
awk -v vars="$tmpdir/css_variables" \
|
||||||
@@ -192,6 +220,7 @@ awk -v vars="$tmpdir/css_variables" \
|
|||||||
-v noise="$tmpdir/css_noise" \
|
-v noise="$tmpdir/css_noise" \
|
||||||
-v tcss="$tmpdir/template_css" \
|
-v tcss="$tmpdir/template_css" \
|
||||||
-v tbody="$tmpdir/template_body" \
|
-v tbody="$tmpdir/template_body" \
|
||||||
|
-v schema="$tmpdir/schema_jsonld" \
|
||||||
-v fonts_file=<(echo "$fonts") \
|
-v fonts_file=<(echo "$fonts") \
|
||||||
'
|
'
|
||||||
function read_file(path, line, content) {
|
function read_file(path, line, content) {
|
||||||
@@ -200,6 +229,15 @@ function read_file(path, line, content) {
|
|||||||
close(path)
|
close(path)
|
||||||
return content
|
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 {
|
BEGIN {
|
||||||
cv = read_file(vars)
|
cv = read_file(vars)
|
||||||
cr = read_file(resp)
|
cr = read_file(resp)
|
||||||
@@ -207,15 +245,18 @@ BEGIN {
|
|||||||
cn = read_file(noise)
|
cn = read_file(noise)
|
||||||
tc = read_file(tcss)
|
tc = read_file(tcss)
|
||||||
tb = read_file(tbody)
|
tb = read_file(tbody)
|
||||||
|
sc = read_file(schema)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
gsub(/\{\{css_variables\}\}/, cv)
|
line = $0
|
||||||
gsub(/\{\{css_responsive\}\}/, cr)
|
line = lreplace(line, "{{css_variables}}", cv)
|
||||||
gsub(/\{\{css_animations\}\}/, ca)
|
line = lreplace(line, "{{css_responsive}}", cr)
|
||||||
gsub(/\{\{css_noise\}\}/, cn)
|
line = lreplace(line, "{{css_animations}}", ca)
|
||||||
gsub(/\{\{template_css\}\}/, tc)
|
line = lreplace(line, "{{css_noise}}", cn)
|
||||||
gsub(/\{\{body\}\}/, tb)
|
line = lreplace(line, "{{template_css}}", tc)
|
||||||
print
|
line = lreplace(line, "{{body}}", tb)
|
||||||
|
line = lreplace(line, "{{schema_jsonld}}", sc)
|
||||||
|
print line
|
||||||
}
|
}
|
||||||
' <<< "$output" | sed \
|
' <<< "$output" | sed \
|
||||||
-e "s|{{title}}|${title}|g" \
|
-e "s|{{title}}|${title}|g" \
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<meta name="robots" content="index, follow">
|
<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>">
|
<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}}
|
{{fonts}}
|
||||||
|
{{schema_jsonld}}
|
||||||
<style>
|
<style>
|
||||||
{{css_variables}}
|
{{css_variables}}
|
||||||
{{css_responsive}}
|
{{css_responsive}}
|
||||||
|
|||||||
25
tests/fixtures/article-editorial.yaml
vendored
Normal file
25
tests/fixtures/article-editorial.yaml
vendored
Normal 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
17
tests/fixtures/no-schema.yaml
vendored
Normal 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
32
tests/fixtures/person-explicit.yaml
vendored
Normal 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
28
tests/fixtures/product-default.yaml
vendored
Normal 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
92
tests/schema-test.sh
Executable 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 ]
|
||||||
Reference in New Issue
Block a user