Skip to content

Commit 9aa87a5

Browse files
committed
feat(micron): parity with micron-parser-js ce24ce4
Port upstream accessibility, field sizing, anchors and serif support: - Parser.Accessibility: heading role/aria-level, divider separator, fold glyph and anchor aria-hidden, partial status roles, field aria-label, Mu-sr readable text next to monospace visual spans - Parser.Serif: Mu-serif root class - Field WxR flags render textarea with rows/cols - Explicit `:name anchors and generated heading slug anchors - Bare-# links resolve to the next heading slug (_resolveEmptyAnchors) - FormatNomadnetworkURL keeps #-prefixed URLs unwrapped - CRLF/CR input normalized to LF - WASM convert accepts accessibility and serif args; libmicron exports micron_convert_opts - Vendored reference JS bumped to upstream ce24ce4 with DOMPurify factory shim for the node harness and real purify.min.js for bench
1 parent ed9e2b7 commit 9aa87a5

21 files changed

Lines changed: 1702 additions & 302 deletions

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,32 @@
22

33
Dates use YYYY-MM-DD.
44

5+
## [1.3.0] - 2026-09-17
6+
7+
### Added
8+
9+
- `Parser.Accessibility` option matching micron-parser-js `options.accessibility`. When enabled:
10+
- Headings emit `role="heading"` and `aria-level` on the inner div.
11+
- Dividers emit `role="separator"`.
12+
- Fold glyphs and generated anchors emit `aria-hidden="true"`.
13+
- Partial placeholders emit `role="status"`, `aria-live="polite"` and `aria-busy="true"`.
14+
- Field inputs and textareas emit `aria-label` with the field name.
15+
- Monospace per-segment visual spans are paired with `Mu-sr` readable text so screen readers see coherent words.
16+
- `Parser.Serif` option adds `Mu-serif` to the root element for serif rendering.
17+
- Field `WxR` flags: `` `<20x3|bio`text> `` renders a `<textarea rows="3" cols="20">` instead of an `<input>`.
18+
- Explicit `` `:name `` anchors emit `<a id="name" class="micron-anchor">`.
19+
- Headings emit a generated slug anchor `<a id="slug" class="micron-anchor micron-header-anchor">` before their content.
20+
- Links with a bare `#` destination keep `href="#"` and are rewritten to the first following heading slug, matching `_resolveEmptyAnchors`.
21+
- CRLF and CR input is normalized to LF before parsing.
22+
- WASM `convert` accepts optional accessibility and serif boolean arguments (4th and 5th).
23+
- `libmicron` exports `micron_convert_opts` accepting accessibility and serif flags.
24+
- Vendored reference JS updated to micron-parser-js `ce24ce4`; `web/static/vendor/dompurify.min.js` added since upstream now requires DOMPurify.
25+
- Added `micron/a11y_test.go` covering the new emit paths.
26+
27+
### Changed
28+
29+
- `FormatNomadnetworkURL` returns `#`-prefixed URLs unchanged instead of wrapping them in `nomadnetwork://`.
30+
531
## [1.2.0] - 2026-09-10
632

733
### Added

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ p := micron.Parser{DarkTheme: true, ForceMonospace: true}
2929
html := p.ConvertMicronToHTML("> Title\n\nHello `!world`! and `*micron`*.\n")
3030
```
3131

32+
Optional flags: `Accessibility` emits heading/separator/partial roles, `aria-level`,
33+
`aria-label` and `Mu-sr` readable text. `Serif` adds `Mu-serif` to the root element.
34+
3235
Parser is safe to reuse across goroutines. Related APIs:
3336

3437
```text

cmd/libmicron/main.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ func micron_convert(markup *C.char, darkTheme, forceMonospace C.int) *C.char {
4040
return cString(p.ConvertMicronToHTML(goString(markup)))
4141
}
4242

43+
//export micron_convert_opts
44+
func micron_convert_opts(markup *C.char, darkTheme, forceMonospace, accessibility, serif C.int) *C.char {
45+
p := micron.Parser{
46+
DarkTheme: darkTheme != 0,
47+
ForceMonospace: forceMonospace != 0,
48+
Accessibility: accessibility != 0,
49+
Serif: serif != 0,
50+
}
51+
return cString(p.ConvertMicronToHTML(goString(markup)))
52+
}
53+
4354
//export micron_parse_header_tags
4455
func micron_parse_header_tags(markup *C.char) *C.char {
4556
colors := micron.ParseHeaderTags(goString(markup))

cmd/wasm/main.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,15 @@ func main() {
2929
if len(args) > 2 {
3030
mono = args[2].Bool()
3131
}
32-
p := micron.Parser{DarkTheme: dark, ForceMonospace: mono}
32+
a11y := false
33+
if len(args) > 3 {
34+
a11y = args[3].Bool()
35+
}
36+
serif := false
37+
if len(args) > 4 {
38+
serif = args[4].Bool()
39+
}
40+
p := micron.Parser{DarkTheme: dark, ForceMonospace: mono, Accessibility: a11y, Serif: serif}
3341
return p.ConvertMicronToHTML(markup)
3442
})
3543
collectFields := js.FuncOf(func(this js.Value, args []js.Value) any {

micron/a11y_test.go

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright Quad4 2026
2+
// SPDX-License-Identifier: 0BSD
3+
4+
package micron
5+
6+
import (
7+
"strings"
8+
"testing"
9+
)
10+
11+
func TestAccessibilityHeadingRole(t *testing.T) {
12+
p := Parser{DarkTheme: true, ForceMonospace: true, Accessibility: true}
13+
out := p.ConvertMicronToHTML("> Title\n")
14+
if !strings.Contains(out, `role="heading"`) {
15+
t.Fatalf("expected role=heading, got: %s", out)
16+
}
17+
if !strings.Contains(out, `aria-level="1"`) {
18+
t.Fatalf("expected aria-level=1, got: %s", out)
19+
}
20+
if strings.Count(out, `role="heading"`) != 1 {
21+
t.Fatalf("expected exactly one heading role, got: %s", out)
22+
}
23+
}
24+
25+
func TestAccessibilityHeadingLevel(t *testing.T) {
26+
p := Parser{DarkTheme: true, ForceMonospace: true, Accessibility: true}
27+
out := p.ConvertMicronToHTML(">>> Deep\n")
28+
if !strings.Contains(out, `aria-level="3"`) {
29+
t.Fatalf("expected aria-level=3, got: %s", out)
30+
}
31+
}
32+
33+
func TestAccessibilityOffByDefault(t *testing.T) {
34+
p := Parser{DarkTheme: true, ForceMonospace: true}
35+
out := p.ConvertMicronToHTML("> Title\n-=\nx\n")
36+
for _, frag := range []string{`role="heading"`, `aria-level`, `role="separator"`, `Mu-sr`, `aria-live`} {
37+
if strings.Contains(out, frag) {
38+
t.Fatalf("expected no %q without Accessibility, got: %s", frag, out)
39+
}
40+
}
41+
}
42+
43+
func TestAccessibilityDividerSeparator(t *testing.T) {
44+
p := Parser{DarkTheme: true, ForceMonospace: true, Accessibility: true}
45+
out := p.ConvertMicronToHTML("-=\n")
46+
if !strings.Contains(out, `role="separator"`) {
47+
t.Fatalf("expected role=separator on divider, got: %s", out)
48+
}
49+
}
50+
51+
func TestAccessibilityFoldGlyphHidden(t *testing.T) {
52+
p := Parser{DarkTheme: true, ForceMonospace: true, Accessibility: true}
53+
out := p.ConvertMicronToHTML("`+> Fold\nbody\n`-\n")
54+
if !strings.Contains(out, `class="Mu-fold-glyph" aria-hidden="true"`) {
55+
t.Fatalf("expected aria-hidden fold glyph, got: %s", out)
56+
}
57+
}
58+
59+
func TestAccessibilityPartialStatus(t *testing.T) {
60+
p := Parser{DarkTheme: true, ForceMonospace: true, Accessibility: true}
61+
out := p.ConvertMicronToHTML("`{file.mu`30`a=b}\n")
62+
if !strings.Contains(out, `role="status"`) {
63+
t.Fatalf("expected role=status on partial, got: %s", out)
64+
}
65+
if !strings.Contains(out, `aria-live="polite"`) {
66+
t.Fatalf("expected aria-live=polite on partial, got: %s", out)
67+
}
68+
}
69+
70+
func TestAccessibilityReadableText(t *testing.T) {
71+
p := Parser{DarkTheme: true, ForceMonospace: true, Accessibility: true}
72+
out := p.ConvertMicronToHTML("hello world\n")
73+
if !strings.Contains(out, `class="Mu-sr"`) {
74+
t.Fatalf("expected Mu-sr readable span, got: %s", out)
75+
}
76+
if !strings.Contains(out, `aria-hidden="true"`) {
77+
t.Fatalf("expected aria-hidden visual span, got: %s", out)
78+
}
79+
}
80+
81+
func TestAccessibilityFieldLabel(t *testing.T) {
82+
p := Parser{DarkTheme: true, ForceMonospace: true, Accessibility: true}
83+
out := p.ConvertMicronToHTML("`<24|username`alice>\n")
84+
if !strings.Contains(out, `aria-label="username"`) {
85+
t.Fatalf("expected aria-label on field, got: %s", out)
86+
}
87+
if !strings.Contains(out, `name="username"`) {
88+
t.Fatalf("expected name attribute preserved, got: %s", out)
89+
}
90+
}
91+
92+
func TestFieldWidthAndRows(t *testing.T) {
93+
p := Parser{DarkTheme: true, ForceMonospace: true}
94+
out := p.ConvertMicronToHTML("`<40|name`bob>\n")
95+
if !strings.Contains(out, `size="40"`) {
96+
t.Fatalf("expected size=40 on input, got: %s", out)
97+
}
98+
if !strings.Contains(out, "<input") {
99+
t.Fatalf("expected input element, got: %s", out)
100+
}
101+
}
102+
103+
func TestFieldRowsTextarea(t *testing.T) {
104+
p := Parser{DarkTheme: true, ForceMonospace: true}
105+
out := p.ConvertMicronToHTML("`<20x3|bio`hello>\n")
106+
if !strings.Contains(out, "<textarea") {
107+
t.Fatalf("expected textarea for WxR field, got: %s", out)
108+
}
109+
if !strings.Contains(out, `rows="3"`) {
110+
t.Fatalf("expected rows=3, got: %s", out)
111+
}
112+
if !strings.Contains(out, `cols="20"`) {
113+
t.Fatalf("expected cols=20, got: %s", out)
114+
}
115+
if !strings.Contains(out, `name="bio"`) {
116+
t.Fatalf("expected name=bio, got: %s", out)
117+
}
118+
}
119+
120+
func TestHeadingAnchorSlug(t *testing.T) {
121+
p := Parser{DarkTheme: true, ForceMonospace: true}
122+
out := p.ConvertMicronToHTML("> My Section!\n")
123+
if !strings.Contains(out, `id="my-section"`) {
124+
t.Fatalf("expected slug anchor id=my-section, got: %s", out)
125+
}
126+
if !strings.Contains(out, `micron-header-anchor`) {
127+
t.Fatalf("expected micron-header-anchor class, got: %s", out)
128+
}
129+
}
130+
131+
func TestExplicitAnchor(t *testing.T) {
132+
p := Parser{DarkTheme: true, ForceMonospace: true}
133+
out := p.ConvertMicronToHTML("text `:target` more\n")
134+
if !strings.Contains(out, `id="target"`) {
135+
t.Fatalf("expected explicit anchor id=target, got: %s", out)
136+
}
137+
if !strings.Contains(out, `class="micron-anchor"`) {
138+
t.Fatalf("expected micron-anchor class, got: %s", out)
139+
}
140+
}
141+
142+
func TestEmptyAnchorResolvesToNextHeading(t *testing.T) {
143+
p := Parser{DarkTheme: true, ForceMonospace: true}
144+
out := p.ConvertMicronToHTML("`[next`#]\n> Second\n")
145+
if !strings.Contains(out, `href="#second"`) {
146+
t.Fatalf("expected href resolved to #second, got: %s", out)
147+
}
148+
if strings.Contains(out, `href="#"`) {
149+
t.Fatalf("expected no unresolved empty anchor, got: %s", out)
150+
}
151+
}
152+
153+
func TestEmptyAnchorUnmatchedStays(t *testing.T) {
154+
p := Parser{DarkTheme: true, ForceMonospace: true}
155+
out := p.ConvertMicronToHTML("> First\n`[next`#]\n")
156+
if !strings.Contains(out, `href="#"`) {
157+
t.Fatalf("expected unresolved empty anchor to stay href=#, got: %s", out)
158+
}
159+
}
160+
161+
func TestSerifRootClass(t *testing.T) {
162+
p := Parser{DarkTheme: true, ForceMonospace: true, Serif: true}
163+
out := p.ConvertMicronToHTML("body\n")
164+
if !strings.Contains(out, `Mu-serif`) {
165+
t.Fatalf("expected Mu-serif root class, got: %s", out)
166+
}
167+
}
168+
169+
func TestSerifOffByDefault(t *testing.T) {
170+
p := Parser{DarkTheme: true, ForceMonospace: true}
171+
out := p.ConvertMicronToHTML("body\n")
172+
if strings.Contains(out, `Mu-serif`) {
173+
t.Fatalf("expected no Mu-serif without Serif, got: %s", out)
174+
}
175+
}
176+
177+
func TestCRLFNormalization(t *testing.T) {
178+
p := Parser{DarkTheme: true, ForceMonospace: true}
179+
crlf := p.ConvertMicronToHTML("> Title\r\nbody\r\n")
180+
lf := p.ConvertMicronToHTML("> Title\nbody\n")
181+
if crlf != lf {
182+
t.Fatalf("CRLF and LF input must render identically.\nCRLF: %s\nLF: %s", crlf, lf)
183+
}
184+
}
185+
186+
func TestHashLinkNotNomadWrapped(t *testing.T) {
187+
p := Parser{DarkTheme: true, ForceMonospace: true}
188+
out := p.ConvertMicronToHTML("> Target\n`[back`#]\n")
189+
if strings.Contains(out, `href="nomadnetwork://#"`) {
190+
t.Fatalf("expected bare # href, got nomadnetwork-wrapped: %s", out)
191+
}
192+
}

micron/anchor.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright Quad4 2026
2+
// SPDX-License-Identifier: 0BSD
3+
4+
package micron
5+
6+
import (
7+
"regexp"
8+
"strings"
9+
)
10+
11+
// micronStripRe mirrors micron-parser-js _MICRON_STRIP_RE: inline format
12+
// toggles, F/B colors, and `:name anchor codes removed before slugification.
13+
var micronStripRe = regexp.MustCompile("`[FB]T[0-9a-fA-F]{6}|`[FB][0-9a-fA-F]{3}|`:[A-Za-z0-9_-]*|`[!*_=fbacrl`<>{]")
14+
15+
var slugNonAlnumRe = regexp.MustCompile(`[^A-Za-z0-9]+`)
16+
17+
// slugifyMicron derives a same-page anchor id from heading source text,
18+
// matching micron-parser-js slugifyMicron.
19+
func slugifyMicron(text string) string {
20+
stripped := micronStripRe.ReplaceAllString(text, "")
21+
slug := slugNonAlnumRe.ReplaceAllString(stripped, "-")
22+
slug = strings.Trim(slug, "-")
23+
return strings.ToLower(slug)
24+
}
25+
26+
const (
27+
headerAnchorClassMarker = `" class="micron-anchor micron-header-anchor"`
28+
emptyHrefAttr = `href="#"`
29+
emptyTitleAttr = ` title="#"`
30+
)
31+
32+
// resolveEmptyAnchorHrefs rewrites href="#" links to the id of the first
33+
// micron-header-anchor that follows them, matching micron-parser-js
34+
// _resolveEmptyAnchors. The link title is updated to match.
35+
func resolveEmptyAnchorHrefs(html string) string {
36+
if !strings.Contains(html, emptyHrefAttr) || !strings.Contains(html, headerAnchorClassMarker) {
37+
return html
38+
}
39+
40+
type headerAnchor struct {
41+
pos int
42+
id string
43+
}
44+
var headers []headerAnchor
45+
for off := 0; off < len(html); {
46+
idx := strings.Index(html[off:], headerAnchorClassMarker)
47+
if idx < 0 {
48+
break
49+
}
50+
pos := off + idx
51+
start := pos
52+
for start > 0 && pos-start < 128 && html[start-1] != '<' {
53+
start--
54+
}
55+
seg := html[start:pos]
56+
if _, after, ok := strings.Cut(seg, `id="`); ok {
57+
headers = append(headers, headerAnchor{pos: pos, id: after})
58+
}
59+
off = pos + 1
60+
}
61+
if len(headers) == 0 {
62+
return html
63+
}
64+
65+
var out strings.Builder
66+
out.Grow(len(html) + 64)
67+
last := 0
68+
hi := 0
69+
for {
70+
idx := strings.Index(html[last:], emptyHrefAttr)
71+
if idx < 0 {
72+
break
73+
}
74+
pos := last + idx
75+
for hi < len(headers) && headers[hi].pos <= pos {
76+
hi++
77+
}
78+
if hi >= len(headers) {
79+
break
80+
}
81+
id := headers[hi].id
82+
out.WriteString(html[last:pos])
83+
out.WriteString(`href="#`)
84+
out.WriteString(id)
85+
out.WriteByte('"')
86+
pos += len(emptyHrefAttr)
87+
if strings.HasPrefix(html[pos:], emptyTitleAttr) {
88+
out.WriteString(` title="#`)
89+
out.WriteString(id)
90+
out.WriteByte('"')
91+
pos += len(emptyTitleAttr)
92+
}
93+
last = pos
94+
}
95+
out.WriteString(html[last:])
96+
return out.String()
97+
}

micron/ast.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ type Inline struct {
6060
Field *Field `json:"field,omitempty"`
6161
Link *Link `json:"link,omitempty"`
6262
Partial *Partial `json:"partial,omitempty"`
63+
// AnchorName carries a `:name anchor identifier; AnchorHeader marks
64+
// auto-generated heading slugs rendered as micron-header-anchor.
65+
AnchorName string `json:"anchor_name,omitempty"`
66+
AnchorHeader bool `json:"anchor_header,omitempty"`
6367
}
6468

6569
// Severity ranks a diagnostic.

0 commit comments

Comments
 (0)