Merge branch 'mai/hermes/issue-9-geo-schema-slot': GEO Schema-Slot (#9)
This commit is contained in:
42
README.md
42
README.md
@@ -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
|
||||
|
||||
55
render.sh
55
render.sh
@@ -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" \
|
||||
|
||||
@@ -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
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