merodi
A markdown-based static site generator.
git clone https://git.pi66.xyz/merodi
About | Files | Commits | Refs | License
feat: implement custom attribute parser
Author: pi66
Date: 2026-10-12 21:03
commit: a2e83da8
- remove both `goldmark-attributes` and `goldmark-intline-attributes` dependencies - replace the built-in `WithAttribute()` parser with a custom implementation
Stat
| M | examples/plugins/enable.lua | 3 | +1 | -2 |
| D | go.mod | 2 | +0 | -2 |
| D | go.sum | 4 | +0 | -4 |
| A | src/core/build/compile/markdown/attrparser/attrparser.go | 257 | +257 | -0 |
| M | src/core/lua/enable.go | 7 | +2 | -5 |
Diff
diff --git a/examples/plugins/enable.lua b/examples/plugins/enable.lua
index 44f469c..39f5478 100644
--- a/examples/plugins/enable.lua
+++ b/examples/plugins/enable.lua
@@ -7,8 +7,7 @@ merodi.enable.Strikethrough() -- supports ~~strikethrough~~
merodi.enable.Table() -- supports Markdown tables
merodi.enable.TaskList() -- supports task lists
merodi.enable.Typographer() -- replaces quotes and dashes with typographic ones
-merodi.enable.HeaderAttr() -- allows attributes on Markdown elements (Headers only)
-merodi.enable.BlockAttr() -- allows attributes on Markdown elements (Blocks only)
+merodi.enable.HTMLAttr() -- allows attributes on Markdown elements
merodi.enable.AutoHeadingID() -- adds IDs to headings automatically
merodi.enable.EscapedSpace() -- allows spaces to be escaped
merodi.enable.SetextHeading() -- supports `Heading\n=======` headings
diff --git a/go.mod b/go.mod
index 1ea9465..6f972b5 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,6 @@ go 1.26.5
require (
...
Show all
Show less
diff --git a/examples/plugins/enable.lua b/examples/plugins/enable.lua
index 44f469c..39f5478 100644
--- a/examples/plugins/enable.lua
+++ b/examples/plugins/enable.lua
@@ -7,8 +7,7 @@ merodi.enable.Strikethrough() -- supports ~~strikethrough~~
merodi.enable.Table() -- supports Markdown tables
merodi.enable.TaskList() -- supports task lists
merodi.enable.Typographer() -- replaces quotes and dashes with typographic ones
-merodi.enable.HeaderAttr() -- allows attributes on Markdown elements (Headers only)
-merodi.enable.BlockAttr() -- allows attributes on Markdown elements (Blocks only)
+merodi.enable.HTMLAttr() -- allows attributes on Markdown elements
merodi.enable.AutoHeadingID() -- adds IDs to headings automatically
merodi.enable.EscapedSpace() -- allows spaces to be escaped
merodi.enable.SetextHeading() -- supports `Heading\n=======` headings
diff --git a/go.mod b/go.mod
index 1ea9465..6f972b5 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,6 @@ go 1.26.5
require (
...
diff --git a/examples/plugins/enable.lua b/examples/plugins/enable.lua
index 44f469c..39f5478 100644
--- a/examples/plugins/enable.lua
+++ b/examples/plugins/enable.lua
@@ -7,8 +7,7 @@ merodi.enable.Strikethrough() -- supports ~~strikethrough~~
merodi.enable.Table() -- supports Markdown tables
merodi.enable.TaskList() -- supports task lists
merodi.enable.Typographer() -- replaces quotes and dashes with typographic ones
-merodi.enable.HeaderAttr() -- allows attributes on Markdown elements (Headers only)
-merodi.enable.BlockAttr() -- allows attributes on Markdown elements (Blocks only)
+merodi.enable.HTMLAttr() -- allows attributes on Markdown elements
merodi.enable.AutoHeadingID() -- adds IDs to headings automatically
merodi.enable.EscapedSpace() -- allows spaces to be escaped
merodi.enable.SetextHeading() -- supports `Heading\n=======` headings
diff --git a/go.mod b/go.mod
index 1ea9465..6f972b5 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,6 @@ go 1.26.5
require (
github.com/BurntSushi/toml v1.6.0
github.com/fsnotify/fsnotify v1.10.1
- github.com/mdigger/goldmark-attributes v0.0.0-20250724115859-bd3108091530
github.com/nikolalohinski/gonja/v2 v2.5.2-0.20260925050630-cba6d3edb81d
github.com/yuin/goldmark v1.8.6
github.com/yuin/gopher-lua v1.1.2
@@ -16,7 +15,6 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
- github.com/nemunaire/goldmark-inline-attributes v0.0.0-20251123083539-f128b1ee39c6 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/stretchr/testify v1.10.0 // indirect
diff --git a/go.sum b/go.sum
index edad775..0305419 100644
--- a/go.sum
+++ b/go.sum
@@ -20,15 +20,11 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
-github.com/mdigger/goldmark-attributes v0.0.0-20250724115859-bd3108091530 h1:PtnMRIkeWQi6FgIdfI1mtm+cMX1g1KVs+0NuJYeT8Tw=
-github.com/mdigger/goldmark-attributes v0.0.0-20250724115859-bd3108091530/go.mod h1:Df2jMu8JhRCOgI3hp7CU8Y8Rjw+uaOySYtZk+P6+Vj0=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/nemunaire/goldmark-inline-attributes v0.0.0-20251123083539-f128b1ee39c6 h1:0nZzCUnaGntkIvj+LLp6M1YS/6K2HDXYYs2uM70c3wg=
-github.com/nemunaire/goldmark-inline-attributes v0.0.0-20251123083539-f128b1ee39c6/go.mod h1:+hOsEgoiJWqQOErC3EHg5PihpoiZVqVYibAvPDojQQw=
github.com/nikolalohinski/gonja/v2 v2.5.2-0.20260925050630-cba6d3edb81d h1:ri11gJT5tmR+4loo+QSinWhJrkQPgqFQeZXjMJLvTBo=
github.com/nikolalohinski/gonja/v2 v2.5.2-0.20260925050630-cba6d3edb81d/go.mod h1:UIzXPVuOsr5h7dZ5DUbqk3/Z7oFA/NLGQGMjqT4L2aU=
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
diff --git a/src/core/build/compile/markdown/attrparser/attrparser.go b/src/core/build/compile/markdown/attrparser/attrparser.go
new file mode 100644
index 0000000..ef39561
--- /dev/null
+++ b/src/core/build/compile/markdown/attrparser/attrparser.go
@@ -0,0 +1,257 @@
+package attrparser
+
+import (
+ "bytes"
+ "regexp"
+
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/text"
+ "github.com/yuin/goldmark/util"
+)
+
+var Extension goldmark.Extender = ext{}
+
+type ext struct{}
+
+func (ext) Extend(m goldmark.Markdown) {
+ m.Parser().AddOptions(
+ parser.WithBlockParsers(
+ util.Prioritized(wrapParser{}, 150),
+ util.Prioritized(lineParser{}, 151),
+ ),
+ parser.WithInlineParsers(util.Prioritized(inline{}, 199)),
+ parser.WithASTTransformers(util.Prioritized(finish{}, 100)),
+ )
+}
+
+var (
+ kindWrap = ast.NewNodeKind("AttrWrap")
+ kindLine = ast.NewNodeKind("AttrLine")
+ stackKey = parser.NewContextKey()
+)
+
+type node struct {
+ ast.BaseBlock
+ kind ast.NodeKind
+ attrs parser.Attributes
+}
+
+func (n *node) Kind() ast.NodeKind { return n.kind }
+func (n *node) Dump(s []byte, level int) { ast.DumpHelper(n, s, level, nil, nil) }
+
+func attrsAt(b []byte) (parser.Attributes, int, bool) {
+ if len(b) == 0 || b[0] != '{' {
+ return nil, 0, false
+ }
+ r := text.NewReader(b)
+ attrs, ok := parser.ParseAttributes(r)
+ _, s := r.Position()
+ return attrs, s.Start, ok
+}
+
+func add(n ast.Node, attrs parser.Attributes) {
+ for _, a := range attrs {
+ if old, ok := n.Attribute(a.Name); ok && string(a.Name) == "class" {
+ o, _ := old.([]byte)
+ v, _ := a.Value.([]byte)
+ a.Value = append(append(append([]byte{}, o...), ' '), v...)
+ }
+ n.SetAttribute(a.Name, a.Value)
+ }
+}
+
+var wrapRe = regexp.MustCompile(`^\[\s*(?:>|#{1,6}\s|[-*+]\s|\d+[.)]\s)`)
+
+type wrapParser struct{}
+
+func (wrapParser) Trigger() []byte { return []byte{'['} }
+
+func (wrapParser) Open(_ ast.Node, r text.Reader, _ parser.Context) (ast.Node, parser.State) {
+ line, _ := r.PeekLine()
+ t := bytes.TrimLeft(line, " \t")
+ tt := bytes.TrimRight(t, " \t\r\n")
+ j := bytes.LastIndexByte(tt, '{')
+ if j < 1 || !wrapRe.Match(tt) {
+ return nil, parser.NoChildren
+ }
+ i := bytes.LastIndexByte(tt[:j], ']')
+ if i < 0 || len(bytes.Trim(tt[i+1:j], " \t")) != 0 {
+ return nil, parser.NoChildren
+ }
+ if _, n, ok := attrsAt(tt[j:]); !ok || n != len(tt)-j {
+ return nil, parser.NoChildren
+ }
+ skip := len(line) - len(t) + 1
+ for skip < len(line) && (line[skip] == ' ' || line[skip] == '\t') {
+ skip++
+ }
+ r.Advance(skip)
+ return &node{kind: kindWrap}, parser.HasChildren
+}
+
+func (wrapParser) Continue(ast.Node, text.Reader, parser.Context) parser.State {
+ return parser.Close
+}
+func (wrapParser) Close(ast.Node, text.Reader, parser.Context) {}
+func (wrapParser) CanInterruptParagraph() bool { return false }
+func (wrapParser) CanAcceptIndentedLine() bool { return false }
+
+
+type lineParser struct{}
+
+func (lineParser) Trigger() []byte { return []byte{'{'} }
+
+func (lineParser) Open(_ ast.Node, r text.Reader, _ parser.Context) (ast.Node, parser.State) {
+ line, _ := r.PeekLine()
+ t := bytes.TrimSpace(line)
+ attrs, n, ok := attrsAt(t)
+ if !ok || n != len(t) {
+ return nil, parser.NoChildren
+ }
+ r.Advance(len(line))
+ return &node{kind: kindLine, attrs: attrs}, parser.NoChildren
+}
+
+func (lineParser) Continue(ast.Node, text.Reader, parser.Context) parser.State {
+ return parser.Close
+}
+func (lineParser) Close(ast.Node, text.Reader, parser.Context) {}
+func (lineParser) CanInterruptParagraph() bool { return true }
+func (lineParser) CanAcceptIndentedLine() bool { return false }
+
+
+type opener struct {
+ end, n int
+ attrs parser.Attributes
+}
+
+func closeIdx(b []byte) int {
+ depth := 0
+ for i := 0; i < len(b); i++ {
+ switch b[i] {
+ case '\\':
+ i++
+ case '`':
+ n := 1
+ for i+n < len(b) && b[i+n] == '`' {
+ n++
+ }
+ if j := bytes.Index(b[i+n:], bytes.Repeat([]byte("`"), n)); j >= 0 {
+ i += n + j + n - 1
+ } else {
+ i += n - 1
+ }
+ case '[':
+ depth++
+ case ']':
+ if depth--; depth == 0 {
+ return i
+ }
+ }
+ }
+ return -1
+}
+
+type inline struct{}
+
+func (inline) Trigger() []byte { return []byte{'[', ']', '{'} }
+
+func (inline) Parse(parent ast.Node, r text.Reader, pc parser.Context) ast.Node {
+ line, seg := r.PeekLine()
+ pos := seg.Start
+ st, _ := pc.Get(stackKey).([]opener)
+ eat := func(n int) ast.Node {
+ r.Advance(n)
+ return ast.NewTextSegment(text.NewSegment(pos, pos))
+ }
+ if line[0] == '{' {
+ attrs, n, ok := attrsAt(line)
+ lines := parent.Lines()
+ if !ok || lines.Len() == 0 || seg.Stop != lines.At(lines.Len()-1).Stop ||
+ len(bytes.TrimSpace(line[n:])) != 0 {
+ return nil
+ }
+ t := parent
+ if p := parent.Parent(); p != nil && (p.Kind() == ast.KindBlockquote || p.Kind() == ast.KindListItem) {
+ t = p
+ }
+ add(t, attrs)
+ if x, ok := parent.LastChild().(*ast.Text); ok {
+ x.Segment = x.Segment.TrimRightSpace(r.Source())
+ }
+ return eat(n)
+ }
+
+ if line[0] == '[' {
+ end := closeIdx(line)
+ if end < 0 {
+ return nil
+ }
+ j := end + 1
+ for j < len(line) && (line[j] == ' ' || line[j] == '\t') {
+ j++
+ }
+ attrs, n, ok := attrsAt(line[j:])
+ if !ok {
+ return nil
+ }
+ pc.Set(stackKey, append(st, opener{pos + end, j - end - 1 + n, attrs}))
+ return eat(1)
+ }
+
+ for len(st) > 0 && st[len(st)-1].end < pos {
+ st = st[:len(st)-1]
+ }
+ if len(st) > 0 && st[len(st)-1].end == pos {
+ o := st[len(st)-1]
+ pc.Set(stackKey, st[:len(st)-1])
+ add(parent, o.attrs)
+ return eat(1 + o.n)
+ }
+ pc.Set(stackKey, st)
+
+ j := 1
+ for j < len(line) && (line[j] == ' ' || line[j] == '\t') {
+ j++
+ }
+ if attrs, n, ok := attrsAt(line[j:]); ok { for c := parent; c.Parent() != nil; c = c.Parent() {
+ if w, ok := c.Parent().(*node); ok && w.kind == kindWrap {
+ add(c, attrs)
+ if t, ok := parent.LastChild().(*ast.Text); ok {
+ t.Segment = t.Segment.TrimRightSpace(r.Source())
+ }
+ return eat(j + n)
+ }
+ }
+ }
+ return nil
+}
+
+type finish struct{}
+
+func (finish) Transform(doc *ast.Document, _ text.Reader, _ parser.Context) {
+ var ns []*node
+ _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
+ if v, ok := n.(*node); ok && entering {
+ ns = append(ns, v)
+ }
+ return ast.WalkContinue, nil
+ })
+ for _, n := range ns {
+ p := n.Parent()
+ switch n.kind {
+ case kindLine:
+ if prev := n.PreviousSibling(); prev != nil {
+ add(prev, n.attrs)
+ }
+ case kindWrap:
+ for c := n.FirstChild(); c != nil; c = n.FirstChild() {
+ n.RemoveChild(n, c)
+ p.InsertBefore(p, n, c)
+ }
+ }
+ p.RemoveChild(p, n)
+ }
+}
diff --git a/src/core/lua/enable.go b/src/core/lua/enable.go
index 7bcd7d6..10148b2 100644
--- a/src/core/lua/enable.go
+++ b/src/core/lua/enable.go
@@ -5,9 +5,8 @@ import (
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/util"
+ "merodi/src/core/build/compile/markdown/attrparser"
glua "github.com/yuin/gopher-lua"
- ga "github.com/mdigger/goldmark-attributes"
- gia "github.com/nemunaire/goldmark-inline-attributes"
)
var extensionsMap = map[string]goldmark.Extender{
@@ -20,12 +19,10 @@ var extensionsMap = map[string]goldmark.Extender{
"Footnote": extension.Footnote,
"Typographer": extension.Typographer,
"CJK": extension.CJK,
- "BlockAttr": ga.Extension,
- "InlineAttr": gia.Extension,
+ "HTMLAttr": attrparser.Extension ,
}
var parsersMap = map[string]parser.Option{
- "HeaderAttr": parser.WithAttribute(),
"AutoHeadingID": parser.WithAutoHeadingID(),
"EscapedSpace": parser.WithEscapedSpace(),
}