package md
import "github.com/yuin/goldmark/parser"
const mdTag = "md"
type config struct {
disallowUnknownFields bool
parser parser.Parser
}
// Option is a functional option type for the Unmarshal function.
type Option func(*config)
// WithParser is a functional option that allows you to set the parser to be used by Unmarshal.
func WithParser(p parser.Parser) Option {
return func(m *config) {
m.parser = p
}
}
// WithDisallowUnknownFields is a functional option that allows you to disallow unknown fields in the markdown.
func WithDisallowUnknownFields() Option {
return func(m *config) {
m.disallowUnknownFields = true
}
}
package md
import (
"errors"
"fmt"
"reflect"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/text"
)
type decoder struct {
config *config
}
func newDecoder(option ...Option) *decoder {
config := &config{
parser: goldmark.DefaultParser(),
disallowUnknownFields: false,
}
for _, opt := range option {
opt(config)
}
return &decoder{config: config}
}
func (d *decoder) unmarshal(md []byte, v any) error {
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Pointer || rv.IsNil() {
return errors.New("v must be a non-nil pointer")
}
dst := rv.Elem()
src := reflect.TypeOf(v).Elem()
txt, block := firstBlock(d.config.parser, md)
fieldIndex := 0
for block != nil {
if fieldIndex >= dst.NumField() {
return d.handleExtraMarkdownBlocks(block)
}
var err error
if fieldIndex, block, err = d.assignBlockContentToField(fieldIndex, src, block, txt, dst); err != nil {
return err
}
}
return handleAdditionalFields(fieldIndex, dst, src)
}
func (d *decoder) assignBlockContentToField(fieldIndex int, src reflect.Type, block ast.Node, txt text.Reader, dst reflect.Value) (nextFieldIndex int, nextBlock ast.Node, err error) {
tag, omitempty, exists := tag(src.Field(fieldIndex))
// if the field is not tagged, skip it.
if !exists {
return fieldIndex + 1, block.NextSibling(), nil
}
tagName, ok := tagName(block)
// if the field is tagged but the element is not supported
if !ok {
if d.config.disallowUnknownFields {
return 0, nil, fmt.Errorf("unexpected element: %s", block.Kind().String())
}
return fieldIndex, block.NextSibling(), nil
}
if tag == tagName {
dst.Field(fieldIndex).SetString(content(block, txt))
} else { // the field is tagged but the element is not supported
if omitempty { // if the field has omitempty tag, skip it.
return fieldIndex + 1, block, nil
} else {
return 0, nil, fmt.Errorf("unexpected %s: %s", tagName, content(block, txt))
}
}
return fieldIndex + 1, block.NextSibling(), nil
}
func (d *decoder) handleExtraMarkdownBlocks(block ast.Node) error {
var extraBlocks []string
for ; block != nil; block = block.NextSibling() {
tagName, ok := tagName(block)
if !ok {
if d.config.disallowUnknownFields {
return fmt.Errorf("unexpected element: %s", block.Kind().String())
}
continue
}
extraBlocks = append(extraBlocks, tagName)
}
if len(extraBlocks) > 0 {
return fmt.Errorf("extra blocks: %s", strings.Join(extraBlocks, ", "))
}
return nil
}
package md
import (
"bytes"
"fmt"
"reflect"
)
type encoder struct{}
func newEncoder() *encoder {
return &encoder{}
}
func (e *encoder) marshal(v any) ([]byte, error) {
val := reflect.ValueOf(v)
if val.Kind() != reflect.Ptr || val.IsNil() {
return nil, fmt.Errorf("v must be a non-nil pointer")
}
val = val.Elem()
if val.Kind() != reflect.Struct {
return nil, fmt.Errorf("v must be a pointer to a struct")
}
var buf bytes.Buffer
typ := val.Type()
for i := 0; i < val.NumField(); i++ {
field := val.Field(i)
if field.Kind() != reflect.String {
return nil, fmt.Errorf("field %s must be a string", typ.Field(i).Name)
}
tag, omitempty, exists := tag(typ.Field(i))
if !exists {
continue
}
if omitempty && field.String() == "" {
continue
}
switch tag {
case "blockquote":
buf.WriteString(fmt.Sprintf("> %s\n\n", field.String()))
case "code_block":
buf.WriteString(fmt.Sprintf("```\n%s\n```\n\n", field.String()))
case "heading1":
buf.WriteString(fmt.Sprintf("# %s\n\n", field.String()))
case "heading2":
buf.WriteString(fmt.Sprintf("## %s\n\n", field.String()))
case "heading3":
buf.WriteString(fmt.Sprintf("### %s\n\n", field.String()))
case "heading4":
buf.WriteString(fmt.Sprintf("#### %s\n\n", field.String()))
case "heading5":
buf.WriteString(fmt.Sprintf("##### %s\n\n", field.String()))
case "heading6":
buf.WriteString(fmt.Sprintf("###### %s\n\n", field.String()))
case "paragraph":
buf.WriteString(fmt.Sprintf("%s\n\n", field.String()))
case "thematic_break":
buf.WriteString("---\n\n")
default:
return nil, fmt.Errorf("unsupported tag: %s", tag)
}
}
return buf.Bytes(), nil
}
package md
// Marshal takes any value (v) as an argument and returns a byte slice of markdown (md).
// It uses the struct tags of v to generate markdown blocks.
// The function returns an error if the value is not a struct or if a field in the struct is not supported.
func Marshal(v any) ([]byte, error) {
return newEncoder().marshal(v)
}
// Package md implements functions to parse markdown into Go structs, similar to how JSON is parsed into Go structs.
package md
import (
"fmt"
"reflect"
"strconv"
"strings"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
)
func tagName(node ast.Node) (string, bool) {
switch n := node.(type) {
case *ast.Blockquote:
return "blockquote", true
case *ast.FencedCodeBlock:
return "code_block", true
case *ast.Heading:
return "heading" + strconv.Itoa(n.Level), true
case *ast.Paragraph:
return "paragraph", true
case *ast.ThematicBreak:
return "thematic_break", true
default:
return "", false
}
}
func handleAdditionalFields(fieldIndex int, dst reflect.Value, src reflect.Type) error {
var missingFields []string
for ; fieldIndex < dst.NumField(); fieldIndex++ {
tag, omitempty, _ := tag(src.Field(fieldIndex))
if omitempty {
continue
}
missingFields = append(missingFields, tag)
}
if len(missingFields) > 0 {
return fmt.Errorf("missing %s", strings.Join(missingFields, ", "))
}
return nil
}
func content(v ast.Node, txt text.Reader) string {
if v.Type() != ast.TypeBlock {
return ""
}
s := ""
for i := range v.Lines().Len() {
line := v.Lines().At(i)
s += string(line.Value(txt.Source()))
}
for c := v.FirstChild(); c != nil; c = c.NextSibling() {
s += content(c, txt)
}
return s
}
func firstBlock(p parser.Parser, md []byte) (text.Reader, ast.Node) {
txt := text.NewReader(md)
node := p.Parse(txt)
return txt, node.FirstChild()
}
func tag(field reflect.StructField) (value string, omitempty bool, exists bool) {
tag, ok := field.Tag.Lookup(mdTag)
if !ok {
return "", false, false
}
if strings.HasSuffix(tag, ",omitempty") {
omitempty = true
tag = strings.TrimSuffix(tag, ",omitempty")
}
return tag, omitempty, true
}
package md
// Unmarshal takes a byte slice of markdown (md) and a non-nil pointer (v) as arguments.
// It parses the markdown into blocks and assigns the content of each block to the corresponding field in v.
// The function uses struct tags to map markdown block to fields in v.
// If a required block (one without the 'omitempty' option in its tag) is missing from the markdown, the function returns an error.
// If an unexpected block element is encountered, the function also returns an error.
func Unmarshal(md []byte, v any, option ...Option) error {
return newDecoder(option...).unmarshal(md, v)
}