feat: renderer
This commit is contained in:
34
renderer/codec/codec.go
Normal file
34
renderer/codec/codec.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Package codec contains functions for rendering template.Block to a string.
|
||||
package codec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.max-richter.dev/max/marka/renderer/utils"
|
||||
"git.max-richter.dev/max/marka/template"
|
||||
)
|
||||
|
||||
// RenderBlock renders a single template block using the provided data.
|
||||
func RenderBlock(block template.Block, data map[string]any) (string, error) {
|
||||
if block.Type == template.MatchingBlock {
|
||||
return block.GetContent(), nil
|
||||
}
|
||||
|
||||
value, found := utils.GetValueFromPath(data, block.Path)
|
||||
if !found {
|
||||
return "", nil // If not found and not required, return empty string
|
||||
}
|
||||
|
||||
switch block.Codec {
|
||||
case template.CodecText:
|
||||
return fmt.Sprintf("%v", value), nil
|
||||
case template.CodecYaml:
|
||||
return RenderYaml(data, block)
|
||||
case template.CodecList:
|
||||
return RenderList(value, block)
|
||||
case template.CodecConst:
|
||||
return fmt.Sprintf("%v", block.Value), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown codec: %s for path '%s'", block.Codec, block.Path)
|
||||
}
|
||||
}
|
56
renderer/codec/list.go
Normal file
56
renderer/codec/list.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package codec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.max-richter.dev/max/marka/template"
|
||||
)
|
||||
|
||||
func RenderList(value any, block template.Block) (string, error) {
|
||||
list, ok := value.([]any)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("expected list for path '%s', got %T", block.Path, value)
|
||||
}
|
||||
|
||||
var renderedItems []string
|
||||
// Compile the list template for each item
|
||||
listBlocks, err := template.CompileTemplate(block.ListTemplate)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to compile list template for path '%s': %w", block.Path, err)
|
||||
}
|
||||
|
||||
for i, item := range list {
|
||||
itemMap, isMap := item.(map[string]any)
|
||||
if !isMap {
|
||||
// If it's not a map, treat it as a simple value for the '.' path
|
||||
itemMap = map[string]any{".": item}
|
||||
}
|
||||
|
||||
var itemBuffer bytes.Buffer
|
||||
for _, lb := range listBlocks {
|
||||
if lb.Type == template.MatchingBlock {
|
||||
itemBuffer.WriteString(lb.GetContent())
|
||||
} else if lb.Type == template.DataBlock {
|
||||
// Special handling for @index in list items
|
||||
if lb.Path == "@index" {
|
||||
itemBuffer.WriteString(fmt.Sprintf("%d", i+1))
|
||||
continue
|
||||
}
|
||||
// Special handling for '.' path when item is not a map
|
||||
if lb.Path == "." && !isMap {
|
||||
itemBuffer.WriteString(fmt.Sprintf("%v", item))
|
||||
continue
|
||||
}
|
||||
renderedItemPart, err := RenderBlock(lb, itemMap)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to render list item part for path '%s': %w", lb.Path, err)
|
||||
}
|
||||
itemBuffer.WriteString(renderedItemPart)
|
||||
}
|
||||
}
|
||||
renderedItems = append(renderedItems, itemBuffer.String())
|
||||
}
|
||||
return strings.Join(renderedItems, "\n"), nil
|
||||
}
|
83
renderer/codec/yaml.go
Normal file
83
renderer/codec/yaml.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package codec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
parserUtils "git.max-richter.dev/max/marka/parser/utils"
|
||||
"git.max-richter.dev/max/marka/renderer/utils"
|
||||
"git.max-richter.dev/max/marka/template"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func flattenInto(in map[string]any) map[string]any {
|
||||
out := make(map[string]any)
|
||||
|
||||
var recur func(prefix string, m map[string]any)
|
||||
recur = func(prefix string, m map[string]any) {
|
||||
for k, v := range m {
|
||||
key := k
|
||||
if prefix != "" {
|
||||
key = prefix + "." + k
|
||||
}
|
||||
|
||||
switch vv := v.(type) {
|
||||
case map[string]any:
|
||||
if len(vv) == 1 {
|
||||
recur(key, vv)
|
||||
} else {
|
||||
out[key] = vv
|
||||
}
|
||||
case map[any]any:
|
||||
tmp := make(map[string]any, len(vv))
|
||||
for kk, vv2 := range vv {
|
||||
if ks, ok := kk.(string); ok {
|
||||
tmp[ks] = vv2
|
||||
}
|
||||
}
|
||||
if len(tmp) == 1 {
|
||||
recur(key, tmp)
|
||||
} else {
|
||||
out[key] = tmp
|
||||
}
|
||||
default:
|
||||
out[key] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recur("", in)
|
||||
return out
|
||||
}
|
||||
|
||||
func RenderYaml(data map[string]any, block template.Block) (string, error) {
|
||||
renderedMap := make(map[string]any)
|
||||
for _, field := range block.Fields {
|
||||
if field.Hidden {
|
||||
continue
|
||||
}
|
||||
|
||||
var fieldValue any
|
||||
var found bool
|
||||
|
||||
if field.CodecType == template.CodecConst {
|
||||
fieldValue = field.Value
|
||||
found = true
|
||||
} else {
|
||||
fieldValue, found = utils.GetValueFromPath(data, field.Path)
|
||||
}
|
||||
|
||||
if found {
|
||||
renderedMap = parserUtils.SetPathValue(field.Path, fieldValue, renderedMap).(map[string]any)
|
||||
}
|
||||
}
|
||||
|
||||
renderedMap = flattenInto(renderedMap)
|
||||
|
||||
b, err := yaml.Marshal(renderedMap)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal YAML for path '%s': %w", block.Path, err)
|
||||
}
|
||||
return strings.TrimSuffix(string(b), "\n"), nil
|
||||
}
|
@@ -1,9 +1,78 @@
|
||||
// Package renderer provides functions for rendering Marka templates.
|
||||
package renderer
|
||||
|
||||
func RenderFile(templatePath, jsonPath string) ([]byte, error) {
|
||||
// TODO:
|
||||
// 1) load aliases + template
|
||||
// 2) validate JSON against schema (optional)
|
||||
// 3) apply codecs to produce Markdown
|
||||
return []byte{}, nil
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.max-richter.dev/max/marka/registry"
|
||||
"git.max-richter.dev/max/marka/renderer/codec"
|
||||
"git.max-richter.dev/max/marka/template"
|
||||
"git.max-richter.dev/max/marka/validator"
|
||||
)
|
||||
|
||||
const emptyBlock = "\uE000"
|
||||
|
||||
func fixRenderedBlock(input string) string {
|
||||
input = strings.ReplaceAll(input, "'@type':", "@type:")
|
||||
input = strings.ReplaceAll(input, "'@context':", "@context:")
|
||||
if len(input) == 0 {
|
||||
return emptyBlock
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func RenderFile(rawJSON []byte) ([]byte, error) {
|
||||
// 1) parse json
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(rawJSON, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JSON: %w", err)
|
||||
}
|
||||
|
||||
// 2) extract type from "@type" Property
|
||||
contentType, ok := data["@type"].(string)
|
||||
if !ok || contentType == "" {
|
||||
return nil, fmt.Errorf("JSON does not contain a valid '@type' property")
|
||||
}
|
||||
|
||||
// 3) get the template from the registry
|
||||
templateContent, err := registry.GetTemplate(contentType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get template for type '%s': %w", contentType, err)
|
||||
}
|
||||
|
||||
// 4) parse the template with the template package
|
||||
compiledTemplate, err := template.CompileTemplate(templateContent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile template for type '%s': %w", contentType, err)
|
||||
}
|
||||
|
||||
// 5) validate JSON against schema
|
||||
if schemaName, ok := data["@schema"].(string); ok {
|
||||
if validationErr := validator.ValidateSchema(data, schemaName); validationErr != nil {
|
||||
return nil, fmt.Errorf("failed to validate schema: %w", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 6) render the template with the blocks
|
||||
var buffer bytes.Buffer
|
||||
for _, block := range compiledTemplate {
|
||||
renderedContent, err := codec.RenderBlock(block, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to render block for path '%s': %w", block.Path, err)
|
||||
}
|
||||
renderedContent = fixRenderedBlock(renderedContent)
|
||||
buffer.WriteString(renderedContent)
|
||||
}
|
||||
|
||||
outputString := buffer.String()
|
||||
outputString = strings.ReplaceAll(outputString, "\n\n"+emptyBlock+"\n\n", "\n\n")
|
||||
outputString = strings.ReplaceAll(outputString, emptyBlock, "")
|
||||
if !strings.HasSuffix(outputString, "\n") {
|
||||
outputString += "\n"
|
||||
}
|
||||
|
||||
return []byte(outputString), nil
|
||||
}
|
||||
|
88
renderer/renderer_test.go
Normal file
88
renderer/renderer_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package renderer_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.max-richter.dev/max/marka/renderer"
|
||||
"git.max-richter.dev/max/marka/testdata"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
func TestRenderFile_RecipeSalad(t *testing.T) {
|
||||
inputJSON := testdata.Read(t, "recipe_salad/output.json")
|
||||
expectedMarkdown := testdata.Read(t, "recipe_salad/input.md")
|
||||
|
||||
gotMarkdown, err := renderer.RenderFile([]byte(inputJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("RenderFile failed: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(string(expectedMarkdown), string(gotMarkdown)); diff != "" {
|
||||
t.Errorf("Rendered markdown mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderFile_RecipeNoDescription(t *testing.T) {
|
||||
inputJSON := testdata.Read(t, "recipe_no_description/output.json")
|
||||
expectedMarkdown := testdata.Read(t, "recipe_no_description/input.md")
|
||||
|
||||
gotMarkdown, err := renderer.RenderFile([]byte(inputJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("RenderFile failed: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(string(expectedMarkdown), string(gotMarkdown)); diff != "" {
|
||||
t.Errorf("Rendered markdown mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderFile_MissingType(t *testing.T) {
|
||||
rawJSON := []byte(`{"name": "Test"}`)
|
||||
_, err := renderer.RenderFile(rawJSON)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing @type, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "JSON does not contain a valid '@type' property") {
|
||||
t.Errorf("expected missing @type error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderFile_InvalidJSON(t *testing.T) {
|
||||
rawJSON := []byte(`{"name": "Test"`)
|
||||
_, err := renderer.RenderFile(rawJSON)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to parse JSON") {
|
||||
t.Errorf("expected invalid JSON error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderFile_RecipeBaguette(t *testing.T) {
|
||||
inputJSON := testdata.Read(t, "baguette/output.json")
|
||||
expectedMarkdown := testdata.Read(t, "baguette/input.md")
|
||||
|
||||
gotMarkdown, err := renderer.RenderFile(inputJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderFile failed: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(string(expectedMarkdown), string(gotMarkdown)); diff != "" {
|
||||
t.Errorf("Rendered markdown mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderFile_ArticleSimple(t *testing.T) {
|
||||
inputJSON := testdata.Read(t, "article_simple/output.json")
|
||||
expectedMarkdown := testdata.Read(t, "article_simple/input.md")
|
||||
|
||||
gotMarkdown, err := renderer.RenderFile([]byte(inputJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("RenderFile failed: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(string(expectedMarkdown), string(gotMarkdown)); diff != "" {
|
||||
t.Errorf("Rendered markdown mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
32
renderer/utils/utils.go
Normal file
32
renderer/utils/utils.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Package utils provides utility functions for the renderer package.
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetValueFromPath retrieves a value from a nested map using a dot-separated path.
|
||||
func GetValueFromPath(data map[string]any, path string) (any, bool) {
|
||||
// Handle the special case where path is "." or empty, meaning the root data itself
|
||||
if path == "." || path == "" {
|
||||
return data, true
|
||||
}
|
||||
|
||||
keys := strings.Split(path, ".")
|
||||
var current any = data
|
||||
for _, key := range keys {
|
||||
if key == "" { // Skip empty keys from ".." or "path."
|
||||
continue
|
||||
}
|
||||
if m, ok := current.(map[string]any); ok {
|
||||
if val, found := m[key]; found {
|
||||
current = val
|
||||
} else {
|
||||
return nil, false
|
||||
}
|
||||
} else {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return current, true
|
||||
}
|
Reference in New Issue
Block a user