Pi66

Home | Repos | About | Blog | Gaza

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
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(), }

// Created by Pi66

// Copyright ©Pi66 2026