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 (
|
use (
|
||||||
./parser
|
./parser
|
||||||
./registry
|
./registry
|
||||||
./renderer
|
./renderer
|
||||||
./template
|
./template
|
||||||
|
./testdata
|
||||||
|
./validator
|
||||||
)
|
)
|
||||||
|
@@ -7,13 +7,13 @@ import (
|
|||||||
|
|
||||||
"git.max-richter.dev/max/marka/parser/decoders"
|
"git.max-richter.dev/max/marka/parser/decoders"
|
||||||
"git.max-richter.dev/max/marka/parser/matcher"
|
"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/registry"
|
||||||
"git.max-richter.dev/max/marka/template"
|
"git.max-richter.dev/max/marka/template"
|
||||||
|
"git.max-richter.dev/max/marka/testdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseBaguette(t *testing.T) {
|
func TestParseBaguette(t *testing.T) {
|
||||||
recipeMd := utils.ReadTestDataFile(t, "baguette.md")
|
recipeMd := testdata.Read(t, "baguette/input.md")
|
||||||
|
|
||||||
templateContent, err := registry.GetTemplate("Recipe")
|
templateContent, err := registry.GetTemplate("Recipe")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -25,7 +25,7 @@ func TestParseBaguette(t *testing.T) {
|
|||||||
t.Fatalf("Err: %s", err)
|
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)
|
parsed, err := decoders.Parse(matches)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Err: %s", err)
|
t.Fatalf("Err: %s", err)
|
||||||
|
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"git.max-richter.dev/max/marka/parser/utils"
|
"git.max-richter.dev/max/marka/parser/utils"
|
||||||
|
renderUtils "git.max-richter.dev/max/marka/renderer/utils"
|
||||||
"git.max-richter.dev/max/marka/template"
|
"git.max-richter.dev/max/marka/template"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
@@ -17,12 +18,15 @@ func Yaml(input string, block template.Block) (value any, error error) {
|
|||||||
|
|
||||||
var out any
|
var out any
|
||||||
for _, f := range block.Fields {
|
for _, f := range block.Fields {
|
||||||
|
if f.Hidden {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if f.CodecType == template.CodecConst {
|
if f.CodecType == template.CodecConst {
|
||||||
if f.Value != nil {
|
if f.Value != nil {
|
||||||
out = utils.SetPathValue(f.Path, f.Value, out)
|
out = utils.SetPathValue(f.Path, f.Value, out)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if value, ok := res[f.Path]; ok {
|
if value, ok := renderUtils.GetValueFromPath(res, f.Path); ok {
|
||||||
out = utils.SetPathValue(f.Path, value, out)
|
out = utils.SetPathValue(f.Path, value, out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,9 +2,18 @@ module git.max-richter.dev/max/marka/parser
|
|||||||
|
|
||||||
go 1.24.3
|
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 (
|
require (
|
||||||
github.com/google/go-cmp v0.7.0
|
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 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
|
||||||
github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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 h1:4J1+yLKUIPGexM/Si+9d3pij4hdc7aGO04NhrElqXbY=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.1/go.mod h1:CBdeces52/nUXndfQ5OY8GEQuNR9uEEOJPZj/Xq5IzU=
|
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"
|
"testing"
|
||||||
|
|
||||||
"git.max-richter.dev/max/marka/parser/matcher"
|
"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/registry"
|
||||||
"git.max-richter.dev/max/marka/template"
|
"git.max-richter.dev/max/marka/template"
|
||||||
|
"git.max-richter.dev/max/marka/testdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFuzzyFindAll(t *testing.T) {
|
func TestFuzzyFindAll(t *testing.T) {
|
||||||
recipeMd := utils.ReadTestDataFile(t, "baguette.md")
|
recipeMd := testdata.Read(t, "baguette/input.md")
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
Needle string
|
Needle string
|
||||||
@@ -28,7 +28,7 @@ func TestFuzzyFindAll(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
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 {
|
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)
|
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) {
|
func TestFuzzyBlockMatch(t *testing.T) {
|
||||||
recipeMd := utils.ReadTestDataFile(t, "baguette.md")
|
recipeMd := testdata.Read(t, "baguette/input.md")
|
||||||
schemaMd, err := registry.GetTemplate("Recipe")
|
schemaMd, err := registry.GetTemplate("Recipe")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Failed to load template: %s", err.Error())
|
t.Errorf("Failed to load template: %s", err.Error())
|
||||||
@@ -53,7 +53,7 @@ func TestFuzzyBlockMatch(t *testing.T) {
|
|||||||
fmt.Printf("block: %#v\n", b)
|
fmt.Printf("block: %#v\n", b)
|
||||||
}
|
}
|
||||||
|
|
||||||
matches := matcher.MatchBlocksFuzzy(recipeMd, blocks, 0.3)
|
matches := matcher.MatchBlocksFuzzy(string(recipeMd), blocks, 0.3)
|
||||||
|
|
||||||
expected := []struct {
|
expected := []struct {
|
||||||
value string
|
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) {
|
func ParseFile(markdownContent string) (any, error) {
|
||||||
markdownContent = strings.TrimSuffix(
|
markdownContent = prepareMarkdown(markdownContent)
|
||||||
strings.ReplaceAll(markdownContent, "@type:", `"@type":`),
|
|
||||||
"\n",
|
|
||||||
)
|
|
||||||
|
|
||||||
contentType, err := DetectType(markdownContent)
|
contentType, err := DetectType(markdownContent)
|
||||||
if err != nil {
|
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
|
module git.max-richter.dev/max/marka/registry
|
||||||
|
|
||||||
go 1.24.3
|
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
|
package registry
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed schema-org/*.json
|
//go:embed schema-org/*.json
|
||||||
var schemasFS embed.FS
|
var schemasFS embed.FS
|
||||||
|
|
||||||
var (
|
func GetSchemas() ([][]byte, error) {
|
||||||
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").
|
|
||||||
entries, err := schemasFS.ReadDir("schema-org")
|
entries, err := schemasFS.ReadDir("schema-org")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
compileErr = fmt.Errorf("read schema directory: %w", err)
|
return nil, fmt.Errorf("read schema directory: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var out [][]byte
|
||||||
|
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
raw, err := schemasFS.ReadFile(filepath.ToSlash("schema-org/" + e.Name()))
|
raw, err := schemasFS.ReadFile(filepath.ToSlash("schema-org/" + e.Name()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
compileErr = fmt.Errorf("read %s: %w", e.Name(), err)
|
return nil, fmt.Errorf("read %s: %w", e.Name(), err)
|
||||||
return
|
}
|
||||||
|
out = append(out, raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unmarshal once for the compiler, but use $id from the raw JSON as the resource name.
|
return out, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
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"
|
- path: "@context"
|
||||||
codec: const
|
codec: const
|
||||||
value: https://schema.org
|
value: https://schema.org
|
||||||
|
hidden: true
|
||||||
|
- path: "@schema"
|
||||||
|
codec: const
|
||||||
|
value: Recipe
|
||||||
|
hidden: true
|
||||||
- path: "@type"
|
- path: "@type"
|
||||||
codec: const
|
codec: const
|
||||||
value: Recipe
|
value: Recipe
|
||||||
- path: image
|
- path: image
|
||||||
- path: author.@type
|
- path: author.@type
|
||||||
codec: const
|
codec: const
|
||||||
|
hidden: true
|
||||||
value: Person
|
value: Person
|
||||||
- path: author.name
|
- path: author.name
|
||||||
- path: datePublished
|
- path: datePublished
|
||||||
@@ -21,7 +27,7 @@
|
|||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
# { name | text,required }
|
# { name | text }
|
||||||
|
|
||||||
{ description | text }
|
{ description | text }
|
||||||
|
|
||||||
@@ -29,7 +35,6 @@
|
|||||||
{
|
{
|
||||||
path: recipeIngredient
|
path: recipeIngredient
|
||||||
codec: list
|
codec: list
|
||||||
required: true
|
|
||||||
listTemplate: "- { . }"
|
listTemplate: "- { . }"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +42,5 @@
|
|||||||
{
|
{
|
||||||
path: recipeInstructions
|
path: recipeInstructions
|
||||||
codec: list
|
codec: list
|
||||||
required: true
|
|
||||||
listTemplate: "{ @index }. { . }"
|
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
|
package renderer
|
||||||
|
|
||||||
func RenderFile(templatePath, jsonPath string) ([]byte, error) {
|
import (
|
||||||
// TODO:
|
"bytes"
|
||||||
// 1) load aliases + template
|
"encoding/json"
|
||||||
// 2) validate JSON against schema (optional)
|
"fmt"
|
||||||
// 3) apply codecs to produce Markdown
|
"strings"
|
||||||
return []byte{}, nil
|
|
||||||
|
"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], ",")
|
optionSplit := strings.SplitSeq(split[1], ",")
|
||||||
for option := range optionSplit {
|
for option := range optionSplit {
|
||||||
switch strings.TrimSpace(option) {
|
switch strings.TrimSpace(option) {
|
||||||
case "required":
|
|
||||||
block.Required = true
|
|
||||||
case "number":
|
case "number":
|
||||||
block.Codec = CodecNumber
|
block.Codec = CodecNumber
|
||||||
}
|
}
|
||||||
@@ -84,17 +82,17 @@ func parseShortTemplate(input string) (Block, error) {
|
|||||||
type yamlBlock struct {
|
type yamlBlock struct {
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
Codec string `yaml:"codec"`
|
Codec string `yaml:"codec"`
|
||||||
Required bool `yaml:"required,omitempty"`
|
|
||||||
Value any `yaml:"value,omitempty"`
|
Value any `yaml:"value,omitempty"`
|
||||||
Fields []yamlField `yaml:"fields"`
|
Fields []yamlField `yaml:"fields"`
|
||||||
ListTemplate string `yaml:"listTemplate,omitempty"`
|
ListTemplate string `yaml:"listTemplate,omitempty"`
|
||||||
|
Hidden bool `yaml:"hidden,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type yamlField struct {
|
type yamlField struct {
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
Value any `yaml:"value,omitempty"`
|
Value any `yaml:"value,omitempty"`
|
||||||
Codec string `yaml:"codec"`
|
Codec string `yaml:"codec"`
|
||||||
Required bool `yaml:"required"`
|
Hidden bool `yaml:"hidden,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseYamlTemplate(input string) (block Block, err error) {
|
func parseYamlTemplate(input string) (block Block, err error) {
|
||||||
@@ -141,8 +139,8 @@ func parseYamlTemplate(input string) (block Block, err error) {
|
|||||||
fields = append(fields, BlockField{
|
fields = append(fields, BlockField{
|
||||||
Path: field.Path,
|
Path: field.Path,
|
||||||
CodecType: fieldCodec,
|
CodecType: fieldCodec,
|
||||||
Required: field.Required,
|
|
||||||
Value: field.Value,
|
Value: field.Value,
|
||||||
|
Hidden: field.Hidden,
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -10,15 +10,14 @@ const (
|
|||||||
type BlockField struct {
|
type BlockField struct {
|
||||||
Path string
|
Path string
|
||||||
CodecType CodecType
|
CodecType CodecType
|
||||||
Required bool
|
|
||||||
Value any
|
Value any
|
||||||
|
Hidden bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Block struct {
|
type Block struct {
|
||||||
Type BlockType
|
Type BlockType
|
||||||
Path string
|
Path string
|
||||||
Codec CodecType
|
Codec CodecType
|
||||||
Required bool
|
|
||||||
ListTemplate string
|
ListTemplate string
|
||||||
Fields []BlockField
|
Fields []BlockField
|
||||||
Value any
|
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
|
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
|
@type: Recipe
|
||||||
image: https://example.com/salad.jpg
|
|
||||||
author.name: Alex Chef
|
author.name: Alex Chef
|
||||||
prepTime: PT10M
|
|
||||||
cookTime: PT0M
|
cookTime: PT0M
|
||||||
|
image: https://example.com/salad.jpg
|
||||||
|
prepTime: PT10M
|
||||||
recipeYield: 2 servings
|
recipeYield: 2 servings
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -19,4 +19,3 @@ recipeYield: 2 servings
|
|||||||
1. Wash and dry the lettuce.
|
1. Wash and dry the lettuce.
|
||||||
2. Halve the cherry tomatoes.
|
2. Halve the cherry tomatoes.
|
||||||
3. Toss with olive oil and salt.
|
3. Toss with olive oil and salt.
|
||||||
|
|
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
@type: Recipe
|
@type: Recipe
|
||||||
image: https://example.com/salad.jpg
|
|
||||||
author.name: Alex Chef
|
author.name: Alex Chef
|
||||||
prepTime: PT10M
|
|
||||||
cookTime: PT0M
|
cookTime: PT0M
|
||||||
|
image: https://example.com/salad.jpg
|
||||||
|
prepTime: PT10M
|
||||||
recipeYield: 2 servings
|
recipeYield: 2 servings
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -21,4 +21,3 @@ A quick green salad.
|
|||||||
1. Wash and dry the lettuce.
|
1. Wash and dry the lettuce.
|
||||||
2. Halve the cherry tomatoes.
|
2. Halve the cherry tomatoes.
|
||||||
3. Toss with olive oil and salt.
|
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 (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.max-richter.dev/max/marka/registry"
|
"git.max-richter.dev/max/marka/validator"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestValidateRecipe_InvalidType(t *testing.T) {
|
func TestValidateRecipe_InvalidType(t *testing.T) {
|
||||||
@@ -17,7 +17,7 @@ func TestValidateRecipe_InvalidType(t *testing.T) {
|
|||||||
"recipeInstructions": "Mix and bake.",
|
"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")
|
t.Fatalf("expected validation error for invalid recipe, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@ func TestValidateRecipe_Valid(t *testing.T) {
|
|||||||
"recipeYield": "1 loaf",
|
"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)
|
t.Fatalf("expected valid recipe, got error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
Reference in New Issue
Block a user