feat: renderer
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
# Marka
|
||||
|
||||
Bidirectional mapping between Markdown and JSON (Schema.org-style) via small, declarative templates.
|
||||
Marka lets you parse Markdown → JSON and render JSON → Markdown using the same template.
|
||||
|
||||
|
@@ -1,62 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"git.max-richter.dev/max/marka/parser"
|
||||
"git.max-richter.dev/max/marka/renderer"
|
||||
)
|
||||
|
||||
func main() {
|
||||
sub := "parse"
|
||||
if len(os.Args) > 1 {
|
||||
sub = os.Args[1]
|
||||
}
|
||||
switch sub {
|
||||
case "parse":
|
||||
fs := flag.NewFlagSet("parse", flag.ExitOnError)
|
||||
tpl := fs.String("template", "", "template file (Markdown)")
|
||||
in := fs.String("in", "", "input markdown")
|
||||
schema := fs.String("schema", "", "json schema (optional)")
|
||||
out := fs.String("out", "-", "output json (- for stdout)")
|
||||
_ = fs.Parse(os.Args[2:])
|
||||
|
||||
data, err := parser.ParseFile(*tpl, *in, *schema)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
enc := json.NewEncoder(dest(*out))
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(data)
|
||||
|
||||
case "render":
|
||||
fs := flag.NewFlagSet("render", flag.ExitOnError)
|
||||
tpl := fs.String("template", "", "template file (Markdown)")
|
||||
in := fs.String("in", "", "input json")
|
||||
out := fs.String("out", "-", "output markdown (- for stdout)")
|
||||
_ = fs.Parse(os.Args[2:])
|
||||
|
||||
md, err := renderer.RenderFile(*tpl, *in)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
_, _ = dest(*out).Write(md)
|
||||
|
||||
default:
|
||||
log.Fatalf("unknown subcommand: %s (use parse|render)", sub)
|
||||
}
|
||||
}
|
||||
|
||||
func dest(path string) *os.File {
|
||||
if path == "-" {
|
||||
return os.Stdout
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return f
|
||||
}
|
4
go.work
4
go.work
@@ -1,8 +1,10 @@
|
||||
go 1.24.3
|
||||
go 1.24.5
|
||||
|
||||
use (
|
||||
./parser
|
||||
./registry
|
||||
./renderer
|
||||
./template
|
||||
./testdata
|
||||
./validator
|
||||
)
|
||||
|
@@ -7,13 +7,13 @@ import (
|
||||
|
||||
"git.max-richter.dev/max/marka/parser/decoders"
|
||||
"git.max-richter.dev/max/marka/parser/matcher"
|
||||
"git.max-richter.dev/max/marka/parser/utils"
|
||||
"git.max-richter.dev/max/marka/registry"
|
||||
"git.max-richter.dev/max/marka/template"
|
||||
"git.max-richter.dev/max/marka/testdata"
|
||||
)
|
||||
|
||||
func TestParseBaguette(t *testing.T) {
|
||||
recipeMd := utils.ReadTestDataFile(t, "baguette.md")
|
||||
recipeMd := testdata.Read(t, "baguette/input.md")
|
||||
|
||||
templateContent, err := registry.GetTemplate("Recipe")
|
||||
if err != nil {
|
||||
@@ -25,7 +25,7 @@ func TestParseBaguette(t *testing.T) {
|
||||
t.Fatalf("Err: %s", err)
|
||||
}
|
||||
|
||||
matches := matcher.MatchBlocksFuzzy(recipeMd, blocks, 0.3)
|
||||
matches := matcher.MatchBlocksFuzzy(string(recipeMd), blocks, 0.3)
|
||||
parsed, err := decoders.Parse(matches)
|
||||
if err != nil {
|
||||
t.Fatalf("Err: %s", err)
|
||||
|
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"git.max-richter.dev/max/marka/parser/utils"
|
||||
renderUtils "git.max-richter.dev/max/marka/renderer/utils"
|
||||
"git.max-richter.dev/max/marka/template"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
@@ -17,12 +18,15 @@ func Yaml(input string, block template.Block) (value any, error error) {
|
||||
|
||||
var out any
|
||||
for _, f := range block.Fields {
|
||||
if f.Hidden {
|
||||
continue
|
||||
}
|
||||
if f.CodecType == template.CodecConst {
|
||||
if f.Value != nil {
|
||||
out = utils.SetPathValue(f.Path, f.Value, out)
|
||||
}
|
||||
} else {
|
||||
if value, ok := res[f.Path]; ok {
|
||||
if value, ok := renderUtils.GetValueFromPath(res, f.Path); ok {
|
||||
out = utils.SetPathValue(f.Path, value, out)
|
||||
}
|
||||
}
|
||||
|
@@ -2,9 +2,18 @@ module git.max-richter.dev/max/marka/parser
|
||||
|
||||
go 1.24.3
|
||||
|
||||
require github.com/agext/levenshtein v1.2.3
|
||||
require (
|
||||
git.max-richter.dev/max/marka/registry v0.0.0-20250817132016-6db87db32567
|
||||
git.max-richter.dev/max/marka/template v0.0.0-20250817132016-6db87db32567
|
||||
github.com/agext/levenshtein v1.2.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/google/go-cmp v0.7.0
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.1 // indirect
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.1
|
||||
)
|
||||
|
@@ -1,6 +1,16 @@
|
||||
git.max-richter.dev/max/marka/registry v0.0.0-20250817132016-6db87db32567 h1:oe7Xb8dE43S8mRla5hfEqagMnvhvEVHsvRlzl2v540w=
|
||||
git.max-richter.dev/max/marka/registry v0.0.0-20250817132016-6db87db32567/go.mod h1:qGWl42P8mgEktfor/IjQp0aS9SqmpeIlhSuVTlUOXLQ=
|
||||
git.max-richter.dev/max/marka/template v0.0.0-20250817132016-6db87db32567 h1:XIx89KqTgd/h14oe5mLvT9E8+jGEAjWgudqiMtQdcec=
|
||||
git.max-richter.dev/max/marka/template v0.0.0-20250817132016-6db87db32567/go.mod h1:Uxi5xcxtnjopsIZjjMlFaWJGuglB9JNL++FuaSbOf6U=
|
||||
github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
|
||||
github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.1 h1:4J1+yLKUIPGexM/Si+9d3pij4hdc7aGO04NhrElqXbY=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.1/go.mod h1:CBdeces52/nUXndfQ5OY8GEQuNR9uEEOJPZj/Xq5IzU=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
|
@@ -1,69 +0,0 @@
|
||||
package parser_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.max-richter.dev/max/marka/parser"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
func TestParseRecipe_Golden(t *testing.T) {
|
||||
td := filepath.Join("testdata", "recipe_salad")
|
||||
input := filepath.Join(td, "input.md")
|
||||
output := filepath.Join(td, "output.json")
|
||||
|
||||
inputContent, err := os.ReadFile(input)
|
||||
if err != nil {
|
||||
t.Fatalf("read input.md: %v", err)
|
||||
}
|
||||
|
||||
got, err := parser.ParseFile(string(inputContent))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
}
|
||||
|
||||
var want map[string]any
|
||||
b, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatalf("read expected.json: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(b, &want); err != nil {
|
||||
t.Fatalf("unmarshal expected.json: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Fatalf("JSON mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecipe_NoDescription(t *testing.T) {
|
||||
td := filepath.Join("testdata", "recipe_no_description")
|
||||
input := filepath.Join(td, "input.md")
|
||||
output := filepath.Join(td, "output.json")
|
||||
|
||||
inputContent, err := os.ReadFile(input)
|
||||
if err != nil {
|
||||
t.Fatalf("read input.md: %v", err)
|
||||
}
|
||||
|
||||
got, err := parser.ParseFile(string(inputContent))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
}
|
||||
|
||||
var want map[string]any
|
||||
b, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatalf("read expected.json: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(b, &want); err != nil {
|
||||
t.Fatalf("unmarshal expected.json: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Fatalf("JSON mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
@@ -5,13 +5,13 @@ import (
|
||||
"testing"
|
||||
|
||||
"git.max-richter.dev/max/marka/parser/matcher"
|
||||
"git.max-richter.dev/max/marka/parser/utils"
|
||||
"git.max-richter.dev/max/marka/registry"
|
||||
"git.max-richter.dev/max/marka/template"
|
||||
"git.max-richter.dev/max/marka/testdata"
|
||||
)
|
||||
|
||||
func TestFuzzyFindAll(t *testing.T) {
|
||||
recipeMd := utils.ReadTestDataFile(t, "baguette.md")
|
||||
recipeMd := testdata.Read(t, "baguette/input.md")
|
||||
|
||||
tests := []struct {
|
||||
Needle string
|
||||
@@ -28,7 +28,7 @@ func TestFuzzyFindAll(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
start, end := matcher.FuzzyFind(recipeMd, test.StartIndex, test.Needle, 0.3) // allow 50% error
|
||||
start, end := matcher.FuzzyFind(string(recipeMd), test.StartIndex, test.Needle, 0.3) // allow 50% error
|
||||
|
||||
if start != test.Start || end != test.End {
|
||||
t.Errorf("Start or end do not match: Needle=%q Start=%d/%d End=%d/%d", test.Needle, test.Start, start, test.End, end)
|
||||
@@ -37,7 +37,7 @@ func TestFuzzyFindAll(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFuzzyBlockMatch(t *testing.T) {
|
||||
recipeMd := utils.ReadTestDataFile(t, "baguette.md")
|
||||
recipeMd := testdata.Read(t, "baguette/input.md")
|
||||
schemaMd, err := registry.GetTemplate("Recipe")
|
||||
if err != nil {
|
||||
t.Errorf("Failed to load template: %s", err.Error())
|
||||
@@ -53,7 +53,7 @@ func TestFuzzyBlockMatch(t *testing.T) {
|
||||
fmt.Printf("block: %#v\n", b)
|
||||
}
|
||||
|
||||
matches := matcher.MatchBlocksFuzzy(recipeMd, blocks, 0.3)
|
||||
matches := matcher.MatchBlocksFuzzy(string(recipeMd), blocks, 0.3)
|
||||
|
||||
expected := []struct {
|
||||
value string
|
||||
|
@@ -41,11 +41,15 @@ func DetectType(markdownContent string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func prepareMarkdown(input string) string {
|
||||
input = strings.TrimSuffix(input, "\n")
|
||||
input = strings.ReplaceAll(input, "@type:", `"@type":`)
|
||||
input = strings.ReplaceAll(input, "@context:", `"@context":`)
|
||||
return input
|
||||
}
|
||||
|
||||
func ParseFile(markdownContent string) (any, error) {
|
||||
markdownContent = strings.TrimSuffix(
|
||||
strings.ReplaceAll(markdownContent, "@type:", `"@type":`),
|
||||
"\n",
|
||||
)
|
||||
markdownContent = prepareMarkdown(markdownContent)
|
||||
|
||||
contentType, err := DetectType(markdownContent)
|
||||
if err != nil {
|
86
parser/parser_test.go
Normal file
86
parser/parser_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package parser_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.max-richter.dev/max/marka/parser"
|
||||
"git.max-richter.dev/max/marka/testdata"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
func TestParseRecipe_Golden(t *testing.T) {
|
||||
inputContent := testdata.Read(t, "recipe_salad/input.md")
|
||||
output := testdata.Read(t, "recipe_salad/output.json")
|
||||
|
||||
got, err := parser.ParseFile(string(inputContent))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
}
|
||||
|
||||
var want map[string]any
|
||||
if err := json.Unmarshal(output, &want); err != nil {
|
||||
t.Fatalf("unmarshal expected.json: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Fatalf("JSON mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecipe_NoDescription(t *testing.T) {
|
||||
inputContent := testdata.Read(t, "recipe_no_description/input.md")
|
||||
|
||||
got, err := parser.ParseFile(string(inputContent))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
}
|
||||
|
||||
var want map[string]any
|
||||
output := testdata.Read(t, "recipe_no_description/output.json")
|
||||
if err := json.Unmarshal(output, &want); err != nil {
|
||||
t.Fatalf("unmarshal expected.json: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Fatalf("JSON mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecipe_Baguette(t *testing.T) {
|
||||
inputContent := testdata.Read(t, "baguette/input.md")
|
||||
|
||||
got, err := parser.ParseFile(string(inputContent))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
}
|
||||
|
||||
var want map[string]any
|
||||
output := testdata.Read(t, "baguette/output.json")
|
||||
if err := json.Unmarshal(output, &want); err != nil {
|
||||
t.Fatalf("unmarshal expected.json: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Fatalf("JSON mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArticle_Simple(t *testing.T) {
|
||||
inputContent := testdata.Read(t, "article_simple/input.md")
|
||||
|
||||
got, err := parser.ParseFile(string(inputContent))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
}
|
||||
|
||||
var want map[string]any
|
||||
output := testdata.Read(t, "article_simple/output.json")
|
||||
if err := json.Unmarshal(output, &want); err != nil {
|
||||
t.Fatalf("unmarshal expected.json: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Fatalf("JSON mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
@@ -1,16 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func ReadTestDataFile(t *testing.T, fileName string) string {
|
||||
path := filepath.Join("../testdata", fileName)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read test data file: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
@@ -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
|
||||
|
@@ -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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compile schema %q: %w", ref, err)
|
||||
}
|
||||
schemaCache.Store(ref, sch)
|
||||
|
||||
if err := sch.Validate(instance); err != nil {
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- internals ---
|
||||
|
||||
func ensureCompiler() error {
|
||||
loadOnce.Do(func() {
|
||||
c := jsonschema.NewCompiler()
|
||||
|
||||
// Load all embedded schemas and register them under their $id (e.g., "schema:Recipe").
|
||||
func GetSchemas() ([][]byte, error) {
|
||||
entries, err := schemasFS.ReadDir("schema-org")
|
||||
if err != nil {
|
||||
compileErr = fmt.Errorf("read schema directory: %w", err)
|
||||
return
|
||||
return nil, fmt.Errorf("read schema directory: %w", err)
|
||||
}
|
||||
|
||||
var out [][]byte
|
||||
|
||||
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
|
||||
return nil, fmt.Errorf("read %s: %w", e.Name(), err)
|
||||
}
|
||||
out = append(out, raw)
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
_ = 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
|
||||
}
|
||||
|
29
registry/templates/Article.marka
Normal file
29
registry/templates/Article.marka
Normal 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 }
|
@@ -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 }. { . }"
|
||||
}
|
||||
|
34
renderer/codec/codec.go
Normal file
34
renderer/codec/codec.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Package codec contains functions for rendering template.Block to a string.
|
||||
package codec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.max-richter.dev/max/marka/renderer/utils"
|
||||
"git.max-richter.dev/max/marka/template"
|
||||
)
|
||||
|
||||
// RenderBlock renders a single template block using the provided data.
|
||||
func RenderBlock(block template.Block, data map[string]any) (string, error) {
|
||||
if block.Type == template.MatchingBlock {
|
||||
return block.GetContent(), nil
|
||||
}
|
||||
|
||||
value, found := utils.GetValueFromPath(data, block.Path)
|
||||
if !found {
|
||||
return "", nil // If not found and not required, return empty string
|
||||
}
|
||||
|
||||
switch block.Codec {
|
||||
case template.CodecText:
|
||||
return fmt.Sprintf("%v", value), nil
|
||||
case template.CodecYaml:
|
||||
return RenderYaml(data, block)
|
||||
case template.CodecList:
|
||||
return RenderList(value, block)
|
||||
case template.CodecConst:
|
||||
return fmt.Sprintf("%v", block.Value), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown codec: %s for path '%s'", block.Codec, block.Path)
|
||||
}
|
||||
}
|
56
renderer/codec/list.go
Normal file
56
renderer/codec/list.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package codec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.max-richter.dev/max/marka/template"
|
||||
)
|
||||
|
||||
func RenderList(value any, block template.Block) (string, error) {
|
||||
list, ok := value.([]any)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("expected list for path '%s', got %T", block.Path, value)
|
||||
}
|
||||
|
||||
var renderedItems []string
|
||||
// Compile the list template for each item
|
||||
listBlocks, err := template.CompileTemplate(block.ListTemplate)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to compile list template for path '%s': %w", block.Path, err)
|
||||
}
|
||||
|
||||
for i, item := range list {
|
||||
itemMap, isMap := item.(map[string]any)
|
||||
if !isMap {
|
||||
// If it's not a map, treat it as a simple value for the '.' path
|
||||
itemMap = map[string]any{".": item}
|
||||
}
|
||||
|
||||
var itemBuffer bytes.Buffer
|
||||
for _, lb := range listBlocks {
|
||||
if lb.Type == template.MatchingBlock {
|
||||
itemBuffer.WriteString(lb.GetContent())
|
||||
} else if lb.Type == template.DataBlock {
|
||||
// Special handling for @index in list items
|
||||
if lb.Path == "@index" {
|
||||
itemBuffer.WriteString(fmt.Sprintf("%d", i+1))
|
||||
continue
|
||||
}
|
||||
// Special handling for '.' path when item is not a map
|
||||
if lb.Path == "." && !isMap {
|
||||
itemBuffer.WriteString(fmt.Sprintf("%v", item))
|
||||
continue
|
||||
}
|
||||
renderedItemPart, err := RenderBlock(lb, itemMap)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to render list item part for path '%s': %w", lb.Path, err)
|
||||
}
|
||||
itemBuffer.WriteString(renderedItemPart)
|
||||
}
|
||||
}
|
||||
renderedItems = append(renderedItems, itemBuffer.String())
|
||||
}
|
||||
return strings.Join(renderedItems, "\n"), nil
|
||||
}
|
83
renderer/codec/yaml.go
Normal file
83
renderer/codec/yaml.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package codec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
parserUtils "git.max-richter.dev/max/marka/parser/utils"
|
||||
"git.max-richter.dev/max/marka/renderer/utils"
|
||||
"git.max-richter.dev/max/marka/template"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func flattenInto(in map[string]any) map[string]any {
|
||||
out := make(map[string]any)
|
||||
|
||||
var recur func(prefix string, m map[string]any)
|
||||
recur = func(prefix string, m map[string]any) {
|
||||
for k, v := range m {
|
||||
key := k
|
||||
if prefix != "" {
|
||||
key = prefix + "." + k
|
||||
}
|
||||
|
||||
switch vv := v.(type) {
|
||||
case map[string]any:
|
||||
if len(vv) == 1 {
|
||||
recur(key, vv)
|
||||
} else {
|
||||
out[key] = vv
|
||||
}
|
||||
case map[any]any:
|
||||
tmp := make(map[string]any, len(vv))
|
||||
for kk, vv2 := range vv {
|
||||
if ks, ok := kk.(string); ok {
|
||||
tmp[ks] = vv2
|
||||
}
|
||||
}
|
||||
if len(tmp) == 1 {
|
||||
recur(key, tmp)
|
||||
} else {
|
||||
out[key] = tmp
|
||||
}
|
||||
default:
|
||||
out[key] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recur("", in)
|
||||
return out
|
||||
}
|
||||
|
||||
func RenderYaml(data map[string]any, block template.Block) (string, error) {
|
||||
renderedMap := make(map[string]any)
|
||||
for _, field := range block.Fields {
|
||||
if field.Hidden {
|
||||
continue
|
||||
}
|
||||
|
||||
var fieldValue any
|
||||
var found bool
|
||||
|
||||
if field.CodecType == template.CodecConst {
|
||||
fieldValue = field.Value
|
||||
found = true
|
||||
} else {
|
||||
fieldValue, found = utils.GetValueFromPath(data, field.Path)
|
||||
}
|
||||
|
||||
if found {
|
||||
renderedMap = parserUtils.SetPathValue(field.Path, fieldValue, renderedMap).(map[string]any)
|
||||
}
|
||||
}
|
||||
|
||||
renderedMap = flattenInto(renderedMap)
|
||||
|
||||
b, err := yaml.Marshal(renderedMap)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal YAML for path '%s': %w", block.Path, err)
|
||||
}
|
||||
return strings.TrimSuffix(string(b), "\n"), nil
|
||||
}
|
@@ -1,9 +1,78 @@
|
||||
// Package renderer provides functions for rendering Marka templates.
|
||||
package renderer
|
||||
|
||||
func RenderFile(templatePath, jsonPath string) ([]byte, error) {
|
||||
// TODO:
|
||||
// 1) load aliases + template
|
||||
// 2) validate JSON against schema (optional)
|
||||
// 3) apply codecs to produce Markdown
|
||||
return []byte{}, nil
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.max-richter.dev/max/marka/registry"
|
||||
"git.max-richter.dev/max/marka/renderer/codec"
|
||||
"git.max-richter.dev/max/marka/template"
|
||||
"git.max-richter.dev/max/marka/validator"
|
||||
)
|
||||
|
||||
const emptyBlock = "\uE000"
|
||||
|
||||
func fixRenderedBlock(input string) string {
|
||||
input = strings.ReplaceAll(input, "'@type':", "@type:")
|
||||
input = strings.ReplaceAll(input, "'@context':", "@context:")
|
||||
if len(input) == 0 {
|
||||
return emptyBlock
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func RenderFile(rawJSON []byte) ([]byte, error) {
|
||||
// 1) parse json
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(rawJSON, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JSON: %w", err)
|
||||
}
|
||||
|
||||
// 2) extract type from "@type" Property
|
||||
contentType, ok := data["@type"].(string)
|
||||
if !ok || contentType == "" {
|
||||
return nil, fmt.Errorf("JSON does not contain a valid '@type' property")
|
||||
}
|
||||
|
||||
// 3) get the template from the registry
|
||||
templateContent, err := registry.GetTemplate(contentType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get template for type '%s': %w", contentType, err)
|
||||
}
|
||||
|
||||
// 4) parse the template with the template package
|
||||
compiledTemplate, err := template.CompileTemplate(templateContent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile template for type '%s': %w", contentType, err)
|
||||
}
|
||||
|
||||
// 5) validate JSON against schema
|
||||
if schemaName, ok := data["@schema"].(string); ok {
|
||||
if validationErr := validator.ValidateSchema(data, schemaName); validationErr != nil {
|
||||
return nil, fmt.Errorf("failed to validate schema: %w", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 6) render the template with the blocks
|
||||
var buffer bytes.Buffer
|
||||
for _, block := range compiledTemplate {
|
||||
renderedContent, err := codec.RenderBlock(block, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to render block for path '%s': %w", block.Path, err)
|
||||
}
|
||||
renderedContent = fixRenderedBlock(renderedContent)
|
||||
buffer.WriteString(renderedContent)
|
||||
}
|
||||
|
||||
outputString := buffer.String()
|
||||
outputString = strings.ReplaceAll(outputString, "\n\n"+emptyBlock+"\n\n", "\n\n")
|
||||
outputString = strings.ReplaceAll(outputString, emptyBlock, "")
|
||||
if !strings.HasSuffix(outputString, "\n") {
|
||||
outputString += "\n"
|
||||
}
|
||||
|
||||
return []byte(outputString), nil
|
||||
}
|
||||
|
88
renderer/renderer_test.go
Normal file
88
renderer/renderer_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package renderer_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.max-richter.dev/max/marka/renderer"
|
||||
"git.max-richter.dev/max/marka/testdata"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
func TestRenderFile_RecipeSalad(t *testing.T) {
|
||||
inputJSON := testdata.Read(t, "recipe_salad/output.json")
|
||||
expectedMarkdown := testdata.Read(t, "recipe_salad/input.md")
|
||||
|
||||
gotMarkdown, err := renderer.RenderFile([]byte(inputJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("RenderFile failed: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(string(expectedMarkdown), string(gotMarkdown)); diff != "" {
|
||||
t.Errorf("Rendered markdown mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderFile_RecipeNoDescription(t *testing.T) {
|
||||
inputJSON := testdata.Read(t, "recipe_no_description/output.json")
|
||||
expectedMarkdown := testdata.Read(t, "recipe_no_description/input.md")
|
||||
|
||||
gotMarkdown, err := renderer.RenderFile([]byte(inputJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("RenderFile failed: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(string(expectedMarkdown), string(gotMarkdown)); diff != "" {
|
||||
t.Errorf("Rendered markdown mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderFile_MissingType(t *testing.T) {
|
||||
rawJSON := []byte(`{"name": "Test"}`)
|
||||
_, err := renderer.RenderFile(rawJSON)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing @type, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "JSON does not contain a valid '@type' property") {
|
||||
t.Errorf("expected missing @type error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderFile_InvalidJSON(t *testing.T) {
|
||||
rawJSON := []byte(`{"name": "Test"`)
|
||||
_, err := renderer.RenderFile(rawJSON)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to parse JSON") {
|
||||
t.Errorf("expected invalid JSON error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderFile_RecipeBaguette(t *testing.T) {
|
||||
inputJSON := testdata.Read(t, "baguette/output.json")
|
||||
expectedMarkdown := testdata.Read(t, "baguette/input.md")
|
||||
|
||||
gotMarkdown, err := renderer.RenderFile(inputJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderFile failed: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(string(expectedMarkdown), string(gotMarkdown)); diff != "" {
|
||||
t.Errorf("Rendered markdown mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderFile_ArticleSimple(t *testing.T) {
|
||||
inputJSON := testdata.Read(t, "article_simple/output.json")
|
||||
expectedMarkdown := testdata.Read(t, "article_simple/input.md")
|
||||
|
||||
gotMarkdown, err := renderer.RenderFile([]byte(inputJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("RenderFile failed: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(string(expectedMarkdown), string(gotMarkdown)); diff != "" {
|
||||
t.Errorf("Rendered markdown mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
32
renderer/utils/utils.go
Normal file
32
renderer/utils/utils.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Package utils provides utility functions for the renderer package.
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetValueFromPath retrieves a value from a nested map using a dot-separated path.
|
||||
func GetValueFromPath(data map[string]any, path string) (any, bool) {
|
||||
// Handle the special case where path is "." or empty, meaning the root data itself
|
||||
if path == "." || path == "" {
|
||||
return data, true
|
||||
}
|
||||
|
||||
keys := strings.Split(path, ".")
|
||||
var current any = data
|
||||
for _, key := range keys {
|
||||
if key == "" { // Skip empty keys from ".." or "path."
|
||||
continue
|
||||
}
|
||||
if m, ok := current.(map[string]any); ok {
|
||||
if val, found := m[key]; found {
|
||||
current = val
|
||||
} else {
|
||||
return nil, false
|
||||
}
|
||||
} else {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return current, true
|
||||
}
|
@@ -70,8 +70,6 @@ func parseShortTemplate(input string) (Block, error) {
|
||||
optionSplit := strings.SplitSeq(split[1], ",")
|
||||
for option := range optionSplit {
|
||||
switch strings.TrimSpace(option) {
|
||||
case "required":
|
||||
block.Required = true
|
||||
case "number":
|
||||
block.Codec = CodecNumber
|
||||
}
|
||||
@@ -84,17 +82,17 @@ func parseShortTemplate(input string) (Block, error) {
|
||||
type yamlBlock struct {
|
||||
Path string `yaml:"path"`
|
||||
Codec string `yaml:"codec"`
|
||||
Required bool `yaml:"required,omitempty"`
|
||||
Value any `yaml:"value,omitempty"`
|
||||
Fields []yamlField `yaml:"fields"`
|
||||
ListTemplate string `yaml:"listTemplate,omitempty"`
|
||||
Hidden bool `yaml:"hidden,omitempty"`
|
||||
}
|
||||
|
||||
type yamlField struct {
|
||||
Path string `yaml:"path"`
|
||||
Value any `yaml:"value,omitempty"`
|
||||
Codec string `yaml:"codec"`
|
||||
Required bool `yaml:"required"`
|
||||
Hidden bool `yaml:"hidden,omitempty"`
|
||||
}
|
||||
|
||||
func parseYamlTemplate(input string) (block Block, err error) {
|
||||
@@ -141,8 +139,8 @@ func parseYamlTemplate(input string) (block Block, err error) {
|
||||
fields = append(fields, BlockField{
|
||||
Path: field.Path,
|
||||
CodecType: fieldCodec,
|
||||
Required: field.Required,
|
||||
Value: field.Value,
|
||||
Hidden: field.Hidden,
|
||||
})
|
||||
|
||||
}
|
||||
|
@@ -10,15 +10,14 @@ const (
|
||||
type BlockField struct {
|
||||
Path string
|
||||
CodecType CodecType
|
||||
Required bool
|
||||
Value any
|
||||
Hidden bool
|
||||
}
|
||||
|
||||
type Block struct {
|
||||
Type BlockType
|
||||
Path string
|
||||
Codec CodecType
|
||||
Required bool
|
||||
ListTemplate string
|
||||
Fields []BlockField
|
||||
Value any
|
||||
|
8
testdata/data/article_simple/input.md
vendored
Normal file
8
testdata/data/article_simple/input.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
@type: Article
|
||||
author.name: John Doe
|
||||
---
|
||||
|
||||
# My First Article
|
||||
|
||||
This is the content of my first article. It's a simple one.
|
10
testdata/data/article_simple/output.json
vendored
Normal file
10
testdata/data/article_simple/output.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": "My First Article",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "John Doe"
|
||||
},
|
||||
"articleBody": "This is the content of my first article. It's a simple one."
|
||||
}
|
@@ -1,4 +1,5 @@
|
||||
---
|
||||
@type: Recipe
|
||||
author.name: Max Richter
|
||||
---
|
||||
|
19
testdata/data/baguette/output.json
vendored
Normal file
19
testdata/data/baguette/output.json
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Recipe",
|
||||
"name": "Baguette",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Max Richter"
|
||||
},
|
||||
"description": "My favourite baguette recipe",
|
||||
"recipeIngredient": [
|
||||
"Flour",
|
||||
"Water",
|
||||
"Salt"
|
||||
],
|
||||
"recipeInstructions": [
|
||||
"Mix Flour Water and Salt",
|
||||
"Bake the bread"
|
||||
]
|
||||
}
|
@@ -1,9 +1,9 @@
|
||||
---
|
||||
@type: Recipe
|
||||
image: https://example.com/salad.jpg
|
||||
author.name: Alex Chef
|
||||
prepTime: PT10M
|
||||
cookTime: PT0M
|
||||
image: https://example.com/salad.jpg
|
||||
prepTime: PT10M
|
||||
recipeYield: 2 servings
|
||||
---
|
||||
|
||||
@@ -19,4 +19,3 @@ recipeYield: 2 servings
|
||||
1. Wash and dry the lettuce.
|
||||
2. Halve the cherry tomatoes.
|
||||
3. Toss with olive oil and salt.
|
||||
|
@@ -1,9 +1,9 @@
|
||||
---
|
||||
@type: Recipe
|
||||
image: https://example.com/salad.jpg
|
||||
author.name: Alex Chef
|
||||
prepTime: PT10M
|
||||
cookTime: PT0M
|
||||
image: https://example.com/salad.jpg
|
||||
prepTime: PT10M
|
||||
recipeYield: 2 servings
|
||||
---
|
||||
|
||||
@@ -21,4 +21,3 @@ A quick green salad.
|
||||
1. Wash and dry the lettuce.
|
||||
2. Halve the cherry tomatoes.
|
||||
3. Toss with olive oil and salt.
|
||||
|
3
testdata/go.mod
vendored
Normal file
3
testdata/go.mod
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
module git.max-richter.dev/max/marka/testdata
|
||||
|
||||
go 1.24.5
|
20
testdata/testdata.go
vendored
Normal file
20
testdata/testdata.go
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
// Package testdata provides test data for marka
|
||||
package testdata
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
//go:embed data/*
|
||||
var dataFS embed.FS
|
||||
|
||||
func Read(t *testing.T, fileName string) []byte {
|
||||
path := filepath.Join("data", fileName)
|
||||
data, err := dataFS.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read test data file: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
7
validator/go.mod
Normal file
7
validator/go.mod
Normal file
@@ -0,0 +1,7 @@
|
||||
module git.max-richter.dev/max/marka/validator
|
||||
|
||||
go 1.24.5
|
||||
|
||||
require github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||
|
||||
require golang.org/x/text v0.14.0 // indirect
|
4
validator/go.sum
Normal file
4
validator/go.sum
Normal file
@@ -0,0 +1,4 @@
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
93
validator/validator.go
Normal file
93
validator/validator.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Package validator provides a validator for the marka data.
|
||||
package validator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.max-richter.dev/max/marka/registry"
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
)
|
||||
|
||||
var (
|
||||
loadOnce sync.Once
|
||||
compiler *jsonschema.Compiler
|
||||
compileErr error
|
||||
schemaCache sync.Map
|
||||
)
|
||||
|
||||
// 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)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
sch, err := compiler.Compile(ref)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compile schema %q: %w", ref, err)
|
||||
}
|
||||
schemaCache.Store(ref, sch)
|
||||
|
||||
if err := sch.Validate(instance); err != nil {
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureCompiler() error {
|
||||
loadOnce.Do(func() {
|
||||
c := jsonschema.NewCompiler()
|
||||
|
||||
rawSchemas, err := registry.GetSchemas()
|
||||
if err != nil {
|
||||
compileErr = fmt.Errorf("read schema directory: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, rawSchema := range rawSchemas {
|
||||
js, err := jsonschema.UnmarshalJSON(bytes.NewReader(rawSchema))
|
||||
if err != nil {
|
||||
compileErr = err
|
||||
return
|
||||
}
|
||||
|
||||
id := extractID(rawSchema)
|
||||
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"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &tmp)
|
||||
return strings.TrimSpace(tmp.ID)
|
||||
}
|
||||
|
||||
func normalizeRef(name string) string {
|
||||
n := strings.TrimSpace(name)
|
||||
if strings.HasPrefix(n, "schema:") || strings.HasPrefix(n, "http://") || strings.HasPrefix(n, "https://") {
|
||||
return n
|
||||
}
|
||||
return "schema:" + n
|
||||
}
|
@@ -1,9 +1,9 @@
|
||||
package registry_test
|
||||
package validator_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.max-richter.dev/max/marka/registry"
|
||||
"git.max-richter.dev/max/marka/validator"
|
||||
)
|
||||
|
||||
func TestValidateRecipe_InvalidType(t *testing.T) {
|
||||
@@ -17,7 +17,7 @@ func TestValidateRecipe_InvalidType(t *testing.T) {
|
||||
"recipeInstructions": "Mix and bake.",
|
||||
}
|
||||
|
||||
if err := registry.ValidateSchema(recipe, "schema:Recipe"); err == nil {
|
||||
if err := validator.ValidateSchema(recipe, "schema:Recipe"); err == nil {
|
||||
t.Fatalf("expected validation error for invalid recipe, got nil")
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func TestValidateRecipe_Valid(t *testing.T) {
|
||||
"recipeYield": "1 loaf",
|
||||
}
|
||||
|
||||
if err := registry.ValidateSchema(recipe, "schema:Recipe"); err != nil {
|
||||
if err := validator.ValidateSchema(recipe, "schema:Recipe"); err != nil {
|
||||
t.Fatalf("expected valid recipe, got error: %v", err)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user