feat: renderer

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

View File

@@ -1,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.

View File

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

View File

@@ -1,8 +1,10 @@
go 1.24.3 go 1.24.5
use ( use (
./parser ./parser
./registry ./registry
./renderer ./renderer
./template ./template
./testdata
./validator
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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)
}
}

View File

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

View File

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

View File

@@ -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 entries, err := schemasFS.ReadDir("schema-org")
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 { if err != nil {
return fmt.Errorf("failed to compile schema %q: %w", ref, err) return nil, fmt.Errorf("read schema directory: %w", err)
} }
schemaCache.Store(ref, sch)
if err := sch.Validate(instance); err != nil { var out [][]byte
return fmt.Errorf("validation failed: %w", err)
}
return nil
}
// --- internals --- for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
func ensureCompiler() error { continue
loadOnce.Do(func() { }
c := jsonschema.NewCompiler() raw, err := schemasFS.ReadFile(filepath.ToSlash("schema-org/" + e.Name()))
// Load all embedded schemas and register them under their $id (e.g., "schema:Recipe").
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 %s: %w", e.Name(), err)
return
} }
out = append(out, raw)
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
raw, err := schemasFS.ReadFile(filepath.ToSlash("schema-org/" + e.Name()))
if err != nil {
compileErr = fmt.Errorf("read %s: %w", e.Name(), err)
return
}
// Unmarshal once for the compiler, but use $id from the raw JSON as the resource name.
js, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
if err != nil {
compileErr = fmt.Errorf("unmarshal %s: %w", e.Name(), err)
return
}
id := extractID(raw)
if id == "" {
// Fallbacks if $id is missing; Schema.org dumps typically use "schema:<Name>".
base := strings.TrimSuffix(e.Name(), ".json")
id = "schema:" + base
}
if err := c.AddResource(id, js); err != nil {
compileErr = fmt.Errorf("add resource %s: %w", id, err)
return
}
}
compiler = c
})
return compileErr
}
func extractID(raw []byte) string {
var tmp struct {
ID string `json:"$id"`
} }
_ = json.Unmarshal(raw, &tmp)
return strings.TrimSpace(tmp.ID)
}
func normalizeRef(name string) string { return out, nil
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
} }

View File

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

View File

@@ -6,12 +6,18 @@
- path: "@context" - 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
View 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
View 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
View 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
}

View File

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

View File

@@ -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,
}) })
} }

View File

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

View 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."
}

View File

@@ -1,4 +1,5 @@
--- ---
@type: Recipe
author.name: Max Richter author.name: Max Richter
--- ---

19
testdata/data/baguette/output.json vendored Normal file
View 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"
]
}

View File

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

View File

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

@@ -0,0 +1,3 @@
module git.max-richter.dev/max/marka/testdata
go 1.24.5

20
testdata/testdata.go vendored Normal file
View 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
View 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
View 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
View 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
}

View File

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