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 (
./parser
./registry
./renderer
./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/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)

View File

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

View File

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

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

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

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) {
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
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
go 1.24.3
require github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
require golang.org/x/text v0.14.0 // indirect

View File

@@ -1,118 +1,33 @@
package registry
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"sync"
"github.com/santhosh-tekuri/jsonschema/v6"
)
//go:embed schema-org/*.json
var schemasFS embed.FS
var (
loadOnce sync.Once
compiler *jsonschema.Compiler
compileErr error
schemaCache sync.Map // map[string]*jsonschema.Schema
)
// ValidateSchema validates instance against the Schema.org JSON Schema named `schemaName`.
// Examples: ValidateSchema(inst, "Recipe"), ValidateSchema(inst, "schema:Recipe").
func ValidateSchema(instance any, schemaName string) error {
if err := ensureCompiler(); err != nil {
return err
}
ref := normalizeRef(schemaName)
// Fast-path: reuse compiled schema if we have it.
if v, ok := schemaCache.Load(ref); ok {
if err := v.(*jsonschema.Schema).Validate(instance); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
return nil
}
// Compile on first use, then cache.
sch, err := compiler.Compile(ref)
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
}

View File

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

View File

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

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
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
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], ",")
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,
})
}

View File

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

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

View File

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