diff --git a/README.md b/README.md deleted file mode 100644 index 12ebe1a..0000000 --- a/README.md +++ /dev/null @@ -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. - - diff --git a/cmd/marka/main.go b/cmd/marka/main.go deleted file mode 100644 index fb8ba6d..0000000 --- a/cmd/marka/main.go +++ /dev/null @@ -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 -} diff --git a/go.work b/go.work index d42da06..13c90e5 100644 --- a/go.work +++ b/go.work @@ -1,8 +1,10 @@ -go 1.24.3 +go 1.24.5 use ( ./parser ./registry ./renderer ./template + ./testdata + ./validator ) diff --git a/parser/decoders/decoder_test.go b/parser/decoders/decoder_test.go index 3a98e75..9bf634d 100644 --- a/parser/decoders/decoder_test.go +++ b/parser/decoders/decoder_test.go @@ -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) diff --git a/parser/decoders/yaml.go b/parser/decoders/yaml.go index f3b6978..2bcc16b 100644 --- a/parser/decoders/yaml.go +++ b/parser/decoders/yaml.go @@ -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) } } diff --git a/parser/go.mod b/parser/go.mod index 5528628..8dba3bf 100644 --- a/parser/go.mod +++ b/parser/go.mod @@ -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 ) diff --git a/parser/go.sum b/parser/go.sum index 01f9684..f65191b 100644 --- a/parser/go.sum +++ b/parser/go.sum @@ -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= diff --git a/parser/main_test.go b/parser/main_test.go deleted file mode 100644 index 70d8202..0000000 --- a/parser/main_test.go +++ /dev/null @@ -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) - } -} diff --git a/parser/matcher/matcher_test.go b/parser/matcher/matcher_test.go index 49ca67d..2de36d0 100644 --- a/parser/matcher/matcher_test.go +++ b/parser/matcher/matcher_test.go @@ -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 diff --git a/parser/main.go b/parser/parser.go similarity index 87% rename from parser/main.go rename to parser/parser.go index 6de0cb4..74150a7 100644 --- a/parser/main.go +++ b/parser/parser.go @@ -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 { diff --git a/parser/parser_test.go b/parser/parser_test.go new file mode 100644 index 0000000..f118c4b --- /dev/null +++ b/parser/parser_test.go @@ -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) + } +} diff --git a/parser/utils/read_test_data.go b/parser/utils/read_test_data.go deleted file mode 100644 index 850838d..0000000 --- a/parser/utils/read_test_data.go +++ /dev/null @@ -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) -} diff --git a/registry/go.mod b/registry/go.mod index dd79f1b..61e060e 100644 --- a/registry/go.mod +++ b/registry/go.mod @@ -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 diff --git a/registry/schemas.go b/registry/schemas.go index 9513308..1d1c11b 100644 --- a/registry/schemas.go +++ b/registry/schemas.go @@ -1,118 +1,33 @@ package registry import ( - "bytes" "embed" - "encoding/json" "fmt" "path/filepath" "strings" - "sync" - - "github.com/santhosh-tekuri/jsonschema/v6" ) //go:embed schema-org/*.json var schemasFS embed.FS -var ( - loadOnce sync.Once - compiler *jsonschema.Compiler - compileErr error - schemaCache sync.Map // map[string]*jsonschema.Schema -) - -// ValidateSchema validates instance against the Schema.org JSON Schema named `schemaName`. -// Examples: ValidateSchema(inst, "Recipe"), ValidateSchema(inst, "schema:Recipe"). -func ValidateSchema(instance any, schemaName string) error { - if err := ensureCompiler(); err != nil { - return err - } - - ref := normalizeRef(schemaName) - - // Fast-path: reuse compiled schema if we have it. - if v, ok := schemaCache.Load(ref); ok { - if err := v.(*jsonschema.Schema).Validate(instance); err != nil { - return fmt.Errorf("validation failed: %w", err) - } - return nil - } - - // Compile on first use, then cache. - sch, err := compiler.Compile(ref) +func GetSchemas() ([][]byte, error) { + entries, err := schemasFS.ReadDir("schema-org") if err != nil { - return fmt.Errorf("failed to compile schema %q: %w", ref, err) + return nil, fmt.Errorf("read schema directory: %w", err) } - schemaCache.Store(ref, sch) - if err := sch.Validate(instance); err != nil { - return fmt.Errorf("validation failed: %w", err) - } - return nil -} + var out [][]byte -// --- internals --- - -func ensureCompiler() error { - loadOnce.Do(func() { - c := jsonschema.NewCompiler() - - // Load all embedded schemas and register them under their $id (e.g., "schema:Recipe"). - entries, err := schemasFS.ReadDir("schema-org") + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + raw, err := schemasFS.ReadFile(filepath.ToSlash("schema-org/" + e.Name())) if err != nil { - compileErr = fmt.Errorf("read schema directory: %w", err) - return + return nil, fmt.Errorf("read %s: %w", e.Name(), err) } - - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { - continue - } - raw, err := schemasFS.ReadFile(filepath.ToSlash("schema-org/" + e.Name())) - if err != nil { - compileErr = fmt.Errorf("read %s: %w", e.Name(), err) - return - } - - // Unmarshal once for the compiler, but use $id from the raw JSON as the resource name. - js, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) - if err != nil { - compileErr = fmt.Errorf("unmarshal %s: %w", e.Name(), err) - return - } - - id := extractID(raw) - if id == "" { - // Fallbacks if $id is missing; Schema.org dumps typically use "schema:". - base := strings.TrimSuffix(e.Name(), ".json") - id = "schema:" + base - } - - if err := c.AddResource(id, js); err != nil { - compileErr = fmt.Errorf("add resource %s: %w", id, err) - return - } - } - - compiler = c - }) - return compileErr -} - -func extractID(raw []byte) string { - var tmp struct { - ID string `json:"$id"` + out = append(out, raw) } - _ = json.Unmarshal(raw, &tmp) - return strings.TrimSpace(tmp.ID) -} -func normalizeRef(name string) string { - n := strings.TrimSpace(name) - // Accept "Recipe" or "schema:Recipe" transparently. - if strings.HasPrefix(n, "schema:") || strings.HasPrefix(n, "http://") || strings.HasPrefix(n, "https://") { - return n - } - return "schema:" + n + return out, nil } diff --git a/registry/templates/Article.marka b/registry/templates/Article.marka new file mode 100644 index 0000000..3719104 --- /dev/null +++ b/registry/templates/Article.marka @@ -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 } diff --git a/registry/templates/Recipe.marka b/registry/templates/Recipe.marka index 891ca17..c625a77 100644 --- a/registry/templates/Recipe.marka +++ b/registry/templates/Recipe.marka @@ -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 }. { . }" } diff --git a/renderer/codec/codec.go b/renderer/codec/codec.go new file mode 100644 index 0000000..00e65a0 --- /dev/null +++ b/renderer/codec/codec.go @@ -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) + } +} diff --git a/renderer/codec/list.go b/renderer/codec/list.go new file mode 100644 index 0000000..9241634 --- /dev/null +++ b/renderer/codec/list.go @@ -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 +} diff --git a/renderer/codec/yaml.go b/renderer/codec/yaml.go new file mode 100644 index 0000000..73cc622 --- /dev/null +++ b/renderer/codec/yaml.go @@ -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 +} diff --git a/renderer/renderer.go b/renderer/renderer.go index a7c6877..a9d7214 100644 --- a/renderer/renderer.go +++ b/renderer/renderer.go @@ -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 } diff --git a/renderer/renderer_test.go b/renderer/renderer_test.go new file mode 100644 index 0000000..363d286 --- /dev/null +++ b/renderer/renderer_test.go @@ -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) + } +} diff --git a/renderer/utils/utils.go b/renderer/utils/utils.go new file mode 100644 index 0000000..16740eb --- /dev/null +++ b/renderer/utils/utils.go @@ -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 +} diff --git a/template/blocks.go b/template/blocks.go index eb80059..24f2b2e 100644 --- a/template/blocks.go +++ b/template/blocks.go @@ -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"` + Path string `yaml:"path"` + Value any `yaml:"value,omitempty"` + Codec string `yaml:"codec"` + 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, }) } diff --git a/template/structs.go b/template/structs.go index 450efb6..bf4a0da 100644 --- a/template/structs.go +++ b/template/structs.go @@ -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 diff --git a/testdata/data/article_simple/input.md b/testdata/data/article_simple/input.md new file mode 100644 index 0000000..4fe93f4 --- /dev/null +++ b/testdata/data/article_simple/input.md @@ -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. diff --git a/testdata/data/article_simple/output.json b/testdata/data/article_simple/output.json new file mode 100644 index 0000000..c330d18 --- /dev/null +++ b/testdata/data/article_simple/output.json @@ -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." +} \ No newline at end of file diff --git a/parser/testdata/baguette.md b/testdata/data/baguette/input.md similarity index 92% rename from parser/testdata/baguette.md rename to testdata/data/baguette/input.md index 28c932b..37fd637 100644 --- a/parser/testdata/baguette.md +++ b/testdata/data/baguette/input.md @@ -1,4 +1,5 @@ --- +@type: Recipe author.name: Max Richter --- diff --git a/testdata/data/baguette/output.json b/testdata/data/baguette/output.json new file mode 100644 index 0000000..7e0d06e --- /dev/null +++ b/testdata/data/baguette/output.json @@ -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" + ] +} diff --git a/parser/testdata/recipe_no_description/input.md b/testdata/data/recipe_no_description/input.md similarity index 99% rename from parser/testdata/recipe_no_description/input.md rename to testdata/data/recipe_no_description/input.md index 89f55c0..4c21274 100644 --- a/parser/testdata/recipe_no_description/input.md +++ b/testdata/data/recipe_no_description/input.md @@ -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. - diff --git a/parser/testdata/recipe_no_description/output.json b/testdata/data/recipe_no_description/output.json similarity index 100% rename from parser/testdata/recipe_no_description/output.json rename to testdata/data/recipe_no_description/output.json diff --git a/parser/testdata/recipe_salad/input.md b/testdata/data/recipe_salad/input.md similarity index 99% rename from parser/testdata/recipe_salad/input.md rename to testdata/data/recipe_salad/input.md index a7e941a..3699b22 100644 --- a/parser/testdata/recipe_salad/input.md +++ b/testdata/data/recipe_salad/input.md @@ -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. - diff --git a/parser/testdata/recipe_salad/output.json b/testdata/data/recipe_salad/output.json similarity index 100% rename from parser/testdata/recipe_salad/output.json rename to testdata/data/recipe_salad/output.json diff --git a/testdata/go.mod b/testdata/go.mod new file mode 100644 index 0000000..dd01c9b --- /dev/null +++ b/testdata/go.mod @@ -0,0 +1,3 @@ +module git.max-richter.dev/max/marka/testdata + +go 1.24.5 diff --git a/testdata/testdata.go b/testdata/testdata.go new file mode 100644 index 0000000..28d24b1 --- /dev/null +++ b/testdata/testdata.go @@ -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 +} diff --git a/validator/go.mod b/validator/go.mod new file mode 100644 index 0000000..36391e1 --- /dev/null +++ b/validator/go.mod @@ -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 diff --git a/validator/go.sum b/validator/go.sum new file mode 100644 index 0000000..b46b517 --- /dev/null +++ b/validator/go.sum @@ -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= diff --git a/validator/validator.go b/validator/validator.go new file mode 100644 index 0000000..2a03e9a --- /dev/null +++ b/validator/validator.go @@ -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 +} diff --git a/registry/templates_test.go b/validator/validator_test.go similarity index 78% rename from registry/templates_test.go rename to validator/validator_test.go index 7340d99..69a4817 100644 --- a/registry/templates_test.go +++ b/validator/validator_test.go @@ -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) } }