feat: renderer

This commit is contained in:
2025-08-19 17:20:24 +02:00
parent 6db87db325
commit 210b31aef8
38 changed files with 727 additions and 299 deletions

View File

@@ -1,7 +1,3 @@
module git.max-richter.dev/max/marka/registry
go 1.24.3
require github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
require golang.org/x/text v0.14.0 // indirect

View File

@@ -1,118 +1,33 @@
package registry
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"sync"
"github.com/santhosh-tekuri/jsonschema/v6"
)
//go:embed schema-org/*.json
var schemasFS embed.FS
var (
loadOnce sync.Once
compiler *jsonschema.Compiler
compileErr error
schemaCache sync.Map // map[string]*jsonschema.Schema
)
// ValidateSchema validates instance against the Schema.org JSON Schema named `schemaName`.
// Examples: ValidateSchema(inst, "Recipe"), ValidateSchema(inst, "schema:Recipe").
func ValidateSchema(instance any, schemaName string) error {
if err := ensureCompiler(); err != nil {
return err
}
ref := normalizeRef(schemaName)
// Fast-path: reuse compiled schema if we have it.
if v, ok := schemaCache.Load(ref); ok {
if err := v.(*jsonschema.Schema).Validate(instance); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
return nil
}
// Compile on first use, then cache.
sch, err := compiler.Compile(ref)
func GetSchemas() ([][]byte, error) {
entries, err := schemasFS.ReadDir("schema-org")
if err != nil {
return fmt.Errorf("failed to compile schema %q: %w", ref, err)
return nil, fmt.Errorf("read schema directory: %w", err)
}
schemaCache.Store(ref, sch)
if err := sch.Validate(instance); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
return nil
}
var out [][]byte
// --- internals ---
func ensureCompiler() error {
loadOnce.Do(func() {
c := jsonschema.NewCompiler()
// Load all embedded schemas and register them under their $id (e.g., "schema:Recipe").
entries, err := schemasFS.ReadDir("schema-org")
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
raw, err := schemasFS.ReadFile(filepath.ToSlash("schema-org/" + e.Name()))
if err != nil {
compileErr = fmt.Errorf("read schema directory: %w", err)
return
return nil, fmt.Errorf("read %s: %w", e.Name(), err)
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
raw, err := schemasFS.ReadFile(filepath.ToSlash("schema-org/" + e.Name()))
if err != nil {
compileErr = fmt.Errorf("read %s: %w", e.Name(), err)
return
}
// Unmarshal once for the compiler, but use $id from the raw JSON as the resource name.
js, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
if err != nil {
compileErr = fmt.Errorf("unmarshal %s: %w", e.Name(), err)
return
}
id := extractID(raw)
if id == "" {
// Fallbacks if $id is missing; Schema.org dumps typically use "schema:<Name>".
base := strings.TrimSuffix(e.Name(), ".json")
id = "schema:" + base
}
if err := c.AddResource(id, js); err != nil {
compileErr = fmt.Errorf("add resource %s: %w", id, err)
return
}
}
compiler = c
})
return compileErr
}
func extractID(raw []byte) string {
var tmp struct {
ID string `json:"$id"`
out = append(out, raw)
}
_ = json.Unmarshal(raw, &tmp)
return strings.TrimSpace(tmp.ID)
}
func normalizeRef(name string) string {
n := strings.TrimSpace(name)
// Accept "Recipe" or "schema:Recipe" transparently.
if strings.HasPrefix(n, "schema:") || strings.HasPrefix(n, "http://") || strings.HasPrefix(n, "https://") {
return n
}
return "schema:" + n
return out, nil
}

View File

@@ -0,0 +1,29 @@
---
{
path: .
codec: yaml
fields:
- path: "@context"
codec: const
value: https://schema.org
hidden: true
- path: "@schema"
codec: const
value: Article
hidden: true
- path: "@type"
codec: const
value: Article
- path: image
- path: author.name
- path: author.@type
codec: const
value: Person
hidden: true
- path: datePublished
}
---
# { .headline }
{ .articleBody }

View File

@@ -6,12 +6,18 @@
- path: "@context"
codec: const
value: https://schema.org
hidden: true
- path: "@schema"
codec: const
value: Recipe
hidden: true
- path: "@type"
codec: const
value: Recipe
- path: image
- path: author.@type
codec: const
hidden: true
value: Person
- path: author.name
- path: datePublished
@@ -21,7 +27,7 @@
}
---
# { name | text,required }
# { name | text }
{ description | text }
@@ -29,7 +35,6 @@
{
path: recipeIngredient
codec: list
required: true
listTemplate: "- { . }"
}
@@ -37,6 +42,5 @@
{
path: recipeInstructions
codec: list
required: true
listTemplate: "{ @index }. { . }"
}

View File

@@ -1,38 +0,0 @@
package registry_test
import (
"testing"
"git.max-richter.dev/max/marka/registry"
)
func TestValidateRecipe_InvalidType(t *testing.T) {
recipe := map[string]any{
"@type": "Recipe",
"recipeYield": 4,
"recipeIngredient": []string{
"500 g flour",
"300 ml water",
},
"recipeInstructions": "Mix and bake.",
}
if err := registry.ValidateSchema(recipe, "schema:Recipe"); err == nil {
t.Fatalf("expected validation error for invalid recipe, got nil")
}
}
func TestValidateRecipe_Valid(t *testing.T) {
recipe := map[string]any{
"@type": "Recipe",
"name": "Simple Bread",
"cookTime": "PT30M",
"recipeIngredient": []any{"500 g flour", "300 ml water", "10 g salt", "3 g yeast"},
"recipeInstructions": "Mix ingredients, let dough rise, bake at 220°C for 35 minutes.",
"recipeYield": "1 loaf",
}
if err := registry.ValidateSchema(recipe, "schema:Recipe"); err != nil {
t.Fatalf("expected valid recipe, got error: %v", err)
}
}