Merge branch 'mai/hermes/issue-9-geo-schema-slot': GEO Schema-Slot (#9)

This commit is contained in:
mAi
2026-04-30 02:53:30 +02:00
8 changed files with 285 additions and 7 deletions

View File

@@ -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

View File

@@ -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" \

View File

@@ -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
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 ]