Skip to content

Commit 7365042

Browse files
author
Ivan
committed
feat(micron): image links render as deferred placeholders
Links with img=1 (or pointing into a node /media/ tree, NomadNet 1.4.2) emit a mu-image placeholder carrying data-mu-image-* attributes, role=img and a Load image action instead of a plain anchor. /media/ accepts webp, png, jpg, jpeg, bmp, gif and tiff; /file/ stays webp-only. Traversal, unsafe characters and missing alt text still fall back to a normal link. Matches MeshChatX MicronParser.js output so hosts wire click-to-load without changes.
1 parent dccdbda commit 7365042

6 files changed

Lines changed: 584 additions & 1 deletion

File tree

CHANGELOG.md

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

33
Dates use YYYY-MM-DD.
44

5+
## [1.2.0] - 2026-09-10
6+
7+
### Added
8+
9+
- Image links now render as a deferred placeholder instead of a plain link. A link counts as an image when its fields contain `img=1`/`img=true`/`img=yes`, or when its URL points into a node's `/media/` folder (NomadNet 1.4.2 layout).
10+
- The placeholder carries `data-mu-image-*` attributes with the URL, path, alt text and the `w`, `h`, `s`, `k`, `a` and `profile` options, plus `role="img"`, an `aria-label`, a `Load image` action and a hidden `<img>` target, matching the markup MeshChatX's JS parser emits so hosts can wire click-to-load without changes.
11+
- `:/media/` and `hash:/media/` URLs accept `.webp`, `.png`, `.jpg`, `.jpeg`, `.bmp`, `.gif` and `.tiff`. `:/file/` URLs stay WebP-only. Traversal (`..`), control characters, unsafe filename characters and missing alt text keep rendering as a normal link.
12+
- Added `micron/image_test.go` covering every supported extension, hash-qualified URLs, option parsing and clamping, escaping, and the rejection cases.
13+
514
## [1.1.5] - 2026-09-07
615

716
### Fixed

micron/html_build.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ func (p *Parser) writeField(b *strings.Builder, f *Field, s *State) {
149149
}
150150

151151
func (p *Parser) writeLink(b *strings.Builder, lk *Link, s *State) {
152+
if lk.Image != nil {
153+
p.writeImage(b, lk.Image)
154+
return
155+
}
152156
direct := linkDirectURL(lk.URL)
153157
if len(lk.Fields) == 0 {
154158
b.WriteString(`<a class="Mu-nl" href="`)
@@ -219,6 +223,83 @@ func (p *Parser) writeLink(b *strings.Builder, lk *Link, s *State) {
219223
b.WriteString(`</a>`)
220224
}
221225

226+
// writeImage emits the deferred image placeholder consumed by the MeshChatX
227+
// frontend. It mirrors MicronParser.createImagePlaceholder: the div carries
228+
// data attributes, a meta span shows alt text plus optional size hint, an
229+
// actions span holds the load button, and a hidden img receives the loaded
230+
// resource.
231+
func (p *Parser) writeImage(b *strings.Builder, im *LinkImage) {
232+
alt := htmlAttr(im.Alt)
233+
b.WriteString(`<div class="mu-image" data-mu-image-url="`)
234+
b.WriteString(htmlAttr(im.RawURL))
235+
b.WriteString(`" data-mu-image-path="`)
236+
b.WriteString(htmlAttr(im.Path))
237+
b.WriteString(`" data-mu-image-alt="`)
238+
b.WriteString(alt)
239+
b.WriteString(`"`)
240+
if im.Width > 0 {
241+
b.WriteString(` data-mu-image-w="`)
242+
b.WriteString(strconv.Itoa(im.Width))
243+
b.WriteString(`"`)
244+
}
245+
if im.Height > 0 {
246+
b.WriteString(` data-mu-image-h="`)
247+
b.WriteString(strconv.Itoa(im.Height))
248+
b.WriteString(`"`)
249+
}
250+
if im.Size > 0 {
251+
b.WriteString(` data-mu-image-s="`)
252+
b.WriteString(strconv.Itoa(im.Size))
253+
b.WriteString(`"`)
254+
}
255+
if im.Key != "" {
256+
b.WriteString(` data-mu-image-k="`)
257+
b.WriteString(htmlAttr(im.Key))
258+
b.WriteString(`"`)
259+
}
260+
if im.Align != "" {
261+
b.WriteString(` data-mu-image-a="`)
262+
b.WriteString(htmlAttr(im.Align))
263+
b.WriteString(`"`)
264+
}
265+
if im.Profile != "" {
266+
b.WriteString(` data-mu-image-profile="`)
267+
b.WriteString(htmlAttr(im.Profile))
268+
b.WriteString(`"`)
269+
}
270+
b.WriteString(` role="img" aria-label="`)
271+
b.WriteString(alt)
272+
b.WriteString(`"`)
273+
if im.Width > 0 || im.Height > 0 {
274+
b.WriteString(` style="`)
275+
if im.Width > 0 {
276+
b.WriteString("width:")
277+
b.WriteString(strconv.Itoa(im.Width))
278+
b.WriteString("px")
279+
}
280+
if im.Height > 0 {
281+
if im.Width > 0 {
282+
b.WriteString(";")
283+
}
284+
b.WriteString("min-height:")
285+
b.WriteString(strconv.Itoa(im.Height))
286+
b.WriteString("px")
287+
}
288+
b.WriteString(`"`)
289+
}
290+
b.WriteString(`><span class="mu-image-meta"><span class="mu-image-alt">`)
291+
appendHTMLText(b, im.Alt)
292+
b.WriteString(`</span>`)
293+
if im.Size > 0 {
294+
b.WriteString(` <span class="mu-image-size">`)
295+
b.WriteString(formatMicronImageSize(im.Size))
296+
b.WriteString(`</span>`)
297+
}
298+
b.WriteString(`</span><span class="mu-image-actions"><a class="mu-image-action" data-mu-image-action="load" role="button" tabindex="0">Load image</a></span><img class="mu-image-output" alt="`)
299+
b.WriteString(alt)
300+
b.WriteString(`" hidden=""></div>`)
301+
}
302+
222303
func (p *Parser) writePartial(b *strings.Builder, pt *Partial, s *State, srcLine int) {
223304
b.WriteString(`<div class="Mu-partial"`)
224305
writeDataMuLine(b, srcLine)

micron/image.go

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
// Copyright Quad4 2026
2+
// SPDX-License-Identifier: 0BSD
3+
4+
package micron
5+
6+
import (
7+
"math"
8+
"regexp"
9+
"strconv"
10+
"strings"
11+
"unicode/utf8"
12+
)
13+
14+
// Micron image limits mirror MeshChatX MicronParser.js.
15+
const (
16+
micronImageMaxWidth = 8192
17+
micronImageMaxHeight = 8192
18+
micronImageMaxSizeHint = 100 * 1024 * 1024 // 100 MiB
19+
micronImageMaxAltLen = 240
20+
micronImageMaxKeyLen = 64
21+
micronImageMaxProfileLn = 32
22+
)
23+
24+
var (
25+
micronImageKeyRe = regexp.MustCompile(`^[a-zA-Z0-9_.-]*$`)
26+
micronImageProfileRe = regexp.MustCompile(`^[a-zA-Z0-9_-]*$`)
27+
micronImageHashRe = regexp.MustCompile(`(?i)^[a-f0-9]{32}$`)
28+
micronImageMediaRe = regexp.MustCompile(`(?i)\.(webp|png|jpe?g|bmp|gif|tiff)$`)
29+
)
30+
31+
// imageOptions carries the parsed key=value image hints from link fields.
32+
type imageOptions struct {
33+
img bool
34+
w int
35+
h int
36+
size int
37+
key string
38+
align string
39+
profile string
40+
}
41+
42+
// LinkImage describes a link that renders as a deferred image placeholder
43+
// instead of an anchor. It is set on Link.Image only when detection succeeds.
44+
type LinkImage struct {
45+
RawURL string `json:"raw_url"`
46+
Path string `json:"path"`
47+
Alt string `json:"alt"`
48+
Width int `json:"w,omitempty"`
49+
Height int `json:"h,omitempty"`
50+
Size int `json:"size,omitempty"`
51+
Key string `json:"key,omitempty"`
52+
Align string `json:"align,omitempty"`
53+
Profile string `json:"profile,omitempty"`
54+
}
55+
56+
// clampMicronImageNumber parses a positive finite number, floors it, and
57+
// clamps it to max. It returns ok=false for anything else, matching the JS
58+
// Number() based parsing in parseMicronImageOptions.
59+
func clampMicronImageNumber(v string, max int) (int, bool) {
60+
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
61+
if err != nil || math.IsNaN(n) || math.IsInf(n, 0) || n <= 0 {
62+
return 0, false
63+
}
64+
f := math.Floor(n)
65+
if f >= float64(max) {
66+
return max, true
67+
}
68+
return int(f), true
69+
}
70+
71+
// sanitizeMicronImageString trims, truncates to maxLen runes, then validates
72+
// against re. An empty result means the value is rejected.
73+
func sanitizeMicronImageString(v string, maxLen int, re *regexp.Regexp) string {
74+
trimmed := strings.TrimSpace(v)
75+
if utf8.RuneCountInString(trimmed) > maxLen {
76+
trimmed = string([]rune(trimmed)[:maxLen])
77+
}
78+
if re != nil && !re.MatchString(trimmed) {
79+
return ""
80+
}
81+
return trimmed
82+
}
83+
84+
// truncateMicronImageAlt trims whitespace and caps the alt text at
85+
// micronImageMaxAltLen runes.
86+
func truncateMicronImageAlt(v string) string {
87+
trimmed := strings.TrimSpace(v)
88+
if utf8.RuneCountInString(trimmed) > micronImageMaxAltLen {
89+
trimmed = string([]rune(trimmed)[:micronImageMaxAltLen])
90+
}
91+
return trimmed
92+
}
93+
94+
// parseMicronImageOptions scans pipe-split link fields. Each field may carry
95+
// several semicolon separated key=value parts, like the JS implementation.
96+
func parseMicronImageOptions(fields []string) imageOptions {
97+
opts := imageOptions{align: "left"}
98+
for _, raw := range fields {
99+
if raw == "" {
100+
continue
101+
}
102+
for _, part := range strings.Split(raw, ";") {
103+
idx := strings.IndexByte(part, '=')
104+
if idx <= 0 {
105+
continue
106+
}
107+
k := strings.ToLower(strings.TrimSpace(part[:idx]))
108+
v := strings.TrimSpace(part[idx+1:])
109+
switch k {
110+
case "img":
111+
switch strings.ToLower(v) {
112+
case "1", "true", "yes":
113+
opts.img = true
114+
default:
115+
opts.img = false
116+
}
117+
case "w":
118+
if n, ok := clampMicronImageNumber(v, micronImageMaxWidth); ok {
119+
opts.w = n
120+
} else {
121+
opts.w = 0
122+
}
123+
case "h":
124+
if n, ok := clampMicronImageNumber(v, micronImageMaxHeight); ok {
125+
opts.h = n
126+
} else {
127+
opts.h = 0
128+
}
129+
case "s":
130+
if n, ok := clampMicronImageNumber(v, micronImageMaxSizeHint); ok {
131+
opts.size = n
132+
} else {
133+
opts.size = 0
134+
}
135+
case "k":
136+
opts.key = sanitizeMicronImageString(v, micronImageMaxKeyLen, micronImageKeyRe)
137+
case "a":
138+
switch strings.ToLower(v) {
139+
case "left", "l":
140+
opts.align = "left"
141+
case "center", "c":
142+
opts.align = "center"
143+
case "right", "r":
144+
opts.align = "right"
145+
}
146+
case "profile":
147+
opts.profile = sanitizeMicronImageString(v, micronImageMaxProfileLn, micronImageProfileRe)
148+
}
149+
}
150+
}
151+
return opts
152+
}
153+
154+
// extractMicronImageFilePath normalizes a node file URL into hash:/path or
155+
// :/path form. It returns an empty string when the URL is not a safe media or
156+
// file path, matching MeshChatX MicronParser.extractMicronImageFilePath.
157+
func extractMicronImageFilePath(rawURL string) string {
158+
if rawURL == "" {
159+
return ""
160+
}
161+
url := rawURL
162+
if len(url) >= len("nomadnetwork://") && strings.EqualFold(url[:len("nomadnetwork://")], "nomadnetwork://") {
163+
url = url[len("nomadnetwork://"):]
164+
}
165+
if i := strings.IndexByte(url, '`'); i >= 0 {
166+
url = url[:i]
167+
}
168+
if i := strings.IndexByte(url, '?'); i >= 0 {
169+
url = url[:i]
170+
}
171+
if i := strings.IndexByte(url, '#'); i >= 0 {
172+
url = url[:i]
173+
}
174+
url = strings.TrimSpace(url)
175+
176+
path := url
177+
var hash string
178+
if i := strings.Index(url, ":/"); i >= 0 {
179+
hash = url[:i]
180+
path = url[i+2:]
181+
if hash != "" && !micronImageHashRe.MatchString(hash) {
182+
return ""
183+
}
184+
} else if strings.HasPrefix(url, ":") {
185+
path = url[1:]
186+
}
187+
188+
if strings.HasPrefix(path, "media/") {
189+
if !micronImageMediaRe.MatchString(path) {
190+
return ""
191+
}
192+
} else if strings.HasPrefix(path, "file/") {
193+
if !strings.HasSuffix(strings.ToLower(path), ".webp") {
194+
return ""
195+
}
196+
} else {
197+
return ""
198+
}
199+
200+
if strings.Contains(path, "..") {
201+
return ""
202+
}
203+
for _, r := range path {
204+
if r < 32 || strings.ContainsRune(`<>"|?*`, r) {
205+
return ""
206+
}
207+
}
208+
209+
if hash != "" {
210+
return hash + ":/" + path
211+
}
212+
return ":/" + path
213+
}
214+
215+
// detectImage mirrors the MeshChatX parseLink override: with fields present, a
216+
// link becomes an image when the img option is set or the normalized path is
217+
// under media/. A non-empty alt text and a valid image path are required.
218+
func (lk *Link) detectImage(rawLabel string) {
219+
if len(lk.Fields) == 0 {
220+
return
221+
}
222+
opts := parseMicronImageOptions(lk.Fields)
223+
rawURL := strings.TrimPrefix(lk.URL, "nomadnetwork://")
224+
imagePath := extractMicronImageFilePath(rawURL)
225+
withoutHash := imagePath
226+
if i := strings.Index(withoutHash, ":/"); i >= 0 {
227+
withoutHash = withoutHash[i+2:]
228+
}
229+
isMedia := strings.HasPrefix(withoutHash, "/media/") || strings.HasPrefix(withoutHash, "media/")
230+
if !opts.img && !isMedia {
231+
return
232+
}
233+
alt := truncateMicronImageAlt(rawLabel)
234+
if imagePath == "" || alt == "" {
235+
return
236+
}
237+
lk.Image = &LinkImage{
238+
RawURL: rawURL,
239+
Path: imagePath,
240+
Alt: alt,
241+
Width: opts.w,
242+
Height: opts.h,
243+
Size: opts.size,
244+
Key: opts.key,
245+
Align: opts.align,
246+
Profile: opts.profile,
247+
}
248+
}
249+
250+
// formatMicronImageSize renders a byte count like the JS Intl based helper:
251+
// raw bytes under 1 KiB, then one-decimal kB or MB with the fraction dropped
252+
// when it rounds to zero.
253+
func formatMicronImageSize(bytes int) string {
254+
if bytes < 0 {
255+
return ""
256+
}
257+
if bytes < 1024 {
258+
return strconv.Itoa(bytes) + " B"
259+
}
260+
if bytes < 1024*1024 {
261+
return formatMicronImageDecimal(float64(bytes)/1024) + " kB"
262+
}
263+
return formatMicronImageDecimal(float64(bytes)/(1024*1024)) + " MB"
264+
}
265+
266+
func formatMicronImageDecimal(v float64) string {
267+
r := math.Round(v*10) / 10
268+
return strconv.FormatFloat(r, 'f', -1, 64)
269+
}

0 commit comments

Comments
 (0)