Micron2HTML 1.0.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ from .converter import MicronConverter, default_url_resolver, UrlResolver
2
+
3
+ __all__ = ["MicronConverter", "default_url_resolver", "UrlResolver"]
4
+ __version__ = "1.0.3"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
micron2html/cli.py ADDED
@@ -0,0 +1,107 @@
1
+ """
2
+ CLI tool: convert a .mu Micron file to HTML or plain text.
3
+
4
+ Usage:
5
+ python -m micron2html.cli input.mu > output.html
6
+ python -m micron2html.cli input.mu -o output.html
7
+ cat page.mu | python -m micron2html.cli -
8
+ python -m micron2html.cli input.mu --format text
9
+ """
10
+
11
+ import argparse
12
+ import os
13
+ import sys
14
+ from .converter import MicronConverter
15
+
16
+ _HTML_TEMPLATE = """\
17
+ <!DOCTYPE html>
18
+ <html lang="en">
19
+ <head>
20
+ <meta charset="UTF-8">
21
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
22
+ <title>{title}</title>
23
+ <style>
24
+ {css}
25
+ </style>
26
+ </head>
27
+ <body>
28
+ {body}
29
+ </body>
30
+ </html>
31
+ """
32
+
33
+
34
+ def _bundled_css() -> str:
35
+ """Read the bundled MeshChat-parity stylesheet that ships with the package."""
36
+ css_path = os.path.join(os.path.dirname(__file__), "micron-meshchat.css")
37
+ try:
38
+ with open(css_path, "r", encoding="utf-8") as fh:
39
+ return fh.read()
40
+ except OSError:
41
+ return ""
42
+
43
+
44
+ def main(argv=None):
45
+ parser = argparse.ArgumentParser(
46
+ description="Convert Micron (.mu) markup to HTML or plain text"
47
+ )
48
+ parser.add_argument(
49
+ "input",
50
+ metavar="FILE",
51
+ help="Input .mu file (use - for stdin)",
52
+ )
53
+ parser.add_argument(
54
+ "-o", "--output",
55
+ metavar="FILE",
56
+ default=None,
57
+ help="Output file (default: stdout)",
58
+ )
59
+ parser.add_argument(
60
+ "-f", "--format",
61
+ choices=["html", "text"],
62
+ default="html",
63
+ help="Output format (default: html)",
64
+ )
65
+ parser.add_argument(
66
+ "--fragment",
67
+ action="store_true",
68
+ help="HTML mode only: emit a fragment instead of a full <html> page",
69
+ )
70
+ parser.add_argument(
71
+ "--node-hash",
72
+ default="",
73
+ metavar="HASH",
74
+ help="Node destination hash (used to build internal link hrefs)",
75
+ )
76
+ args = parser.parse_args(argv)
77
+
78
+ if args.input == "-":
79
+ text = sys.stdin.read()
80
+ title = "Micron Page"
81
+ else:
82
+ with open(args.input, "r", encoding="utf-8") as fh:
83
+ text = fh.read()
84
+ title = os.path.basename(args.input)
85
+
86
+ converter = MicronConverter()
87
+
88
+ if args.format == "text":
89
+ output = converter.to_text(text)
90
+ if not output.endswith("\n"):
91
+ output += "\n"
92
+ else:
93
+ body = converter.convert(text, node_hash=args.node_hash)
94
+ if args.fragment:
95
+ output = body
96
+ else:
97
+ output = _HTML_TEMPLATE.format(title=title, css=_bundled_css(), body=body)
98
+
99
+ if args.output:
100
+ with open(args.output, "w", encoding="utf-8") as fh:
101
+ fh.write(output)
102
+ else:
103
+ sys.stdout.write(output)
104
+
105
+
106
+ if __name__ == "__main__":
107
+ main()
@@ -0,0 +1,703 @@
1
+ """
2
+ Micron markup → HTML converter.
3
+
4
+ Implements the full Micron specification as documented in:
5
+ https://github.com/markqvist/NomadNet/blob/master/nomadnet/ui/textui/Guide.py
6
+
7
+ Default page colours match NomadNet's terminal defaults:
8
+ background #000000 (black)
9
+ foreground #aaaaaa (light grey)
10
+ """
11
+
12
+ import html
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from typing import Callable, Optional
16
+
17
+ _TAG_RE = re.compile(r'<[^>]+>')
18
+
19
+ _HEX = frozenset("0123456789abcdefABCDEF")
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Braille rendering
23
+ #
24
+ # The bundled Roboto Mono Nerd Font has no Braille glyphs at all, and the
25
+ # system-monospace fallbacks on most platforms render Braille at much less
26
+ # than full cell width — adjacent cells leave visible gaps and a row of
27
+ # full-dot Braille reads as separated dots instead of a contiguous grid.
28
+ #
29
+ # We post-process the converted HTML to replace each Braille character
30
+ # (U+2800–U+28FF) with a `<span class="mu-braille">` whose dots are
31
+ # painted by CSS via `--mu-braille-dots` (a list of `radial-gradient`s,
32
+ # one per raised dot). The result is font-independent: the cells always
33
+ # render at exactly `1ch` wide with the dots at fixed fractional positions.
34
+ #
35
+ # The bundled `micron-meshchat.css` ships the matching `.mu-braille` rule.
36
+ # ---------------------------------------------------------------------------
37
+
38
+ _BRAILLE_DOT_POSITIONS = (
39
+ # (left%, top%) for each bit, in order: dot1 dot2 dot3 dot4 dot5 dot6 dot7 dot8
40
+ # 8-dot Braille fits in a 2×4 grid; spacing tuned so paired dots within
41
+ # a cell read as paired and rows of identical glyphs flow as a strip.
42
+ (25, 15), (25, 38), (25, 62),
43
+ (75, 15), (75, 38), (75, 62),
44
+ (25, 85), (75, 85),
45
+ )
46
+
47
+ _BRAILLE_RE = re.compile(r"<[^>]*>|[⠀-⣿]")
48
+
49
+
50
+ def _braillify_html(html_str: str) -> str:
51
+ """Replace Braille codepoints in HTML text with CSS-drawn span elements.
52
+
53
+ Tags and attribute values are matched first (greedy alternation) and
54
+ pass through untouched, so nothing inside ``<a href="…">`` or
55
+ ``data-…`` attributes gets rewritten.
56
+ """
57
+ def repl(m: "re.Match[str]") -> str:
58
+ s = m.group(0)
59
+ if s.startswith("<"):
60
+ return s
61
+ bits = ord(s) - 0x2800
62
+ grads = []
63
+ for i in range(8):
64
+ if bits & (1 << i):
65
+ x, y = _BRAILLE_DOT_POSITIONS[i]
66
+ grads.append(
67
+ f"radial-gradient(circle at {x}% {y}%, "
68
+ f"currentColor 0.07em, transparent 0.08em)"
69
+ )
70
+ style = f' style="--mu-braille-dots:{",".join(grads)}"' if grads else ""
71
+ return f'<span class="mu-braille"{style}></span>'
72
+ return _BRAILLE_RE.sub(repl, html_str)
73
+
74
+ # Type alias for a URL resolver: (raw_url, node_hash, base_path) -> href
75
+ UrlResolver = Callable[[str, str, str], str]
76
+
77
+
78
+ def default_url_resolver(url: str, node_hash: str, base_path: str) -> str:
79
+ """Library default: produce canonical NomadNet URLs without app-specific wrapping.
80
+
81
+ - http(s):// URLs pass through unchanged
82
+ - hash:/... and nomadnetwork:// URLs are returned canonicalized as hash://<hash>/<path>
83
+ - relative paths are resolved against (node_hash, base_path)
84
+ - /file/ links are blocked (return "#")
85
+ - empty/unknown returns "#"
86
+
87
+ Wrap this in your own resolver to produce app-specific hrefs (e.g. "/page?url=…").
88
+ """
89
+ if not url:
90
+ return "#"
91
+
92
+ if url.startswith("http://") or url.startswith("https://"):
93
+ return url
94
+
95
+ def _is_blocked(u: str) -> bool:
96
+ return "/file/" in u
97
+
98
+ if url.startswith("nomadnetwork://"):
99
+ body = url[len("nomadnetwork://"):]
100
+ return "#" if _is_blocked("/" + body) else f"hash://{body}"
101
+
102
+ if url.startswith("hash://"):
103
+ return "#" if _is_blocked(url) else url
104
+
105
+ if url.startswith("hash:/"):
106
+ return "#" if _is_blocked(url) else f"hash://{url[len('hash:/'):]}"
107
+
108
+ # Bare-hash format: <hex>:/path or :/path (current node)
109
+ colon_slash = url.find(":/")
110
+ if colon_slash == 0 and node_hash:
111
+ path_part = url[1:]
112
+ return "#" if _is_blocked(path_part) else f"hash://{node_hash}{path_part}"
113
+ if colon_slash > 0:
114
+ candidate = url[:colon_slash]
115
+ if 8 <= len(candidate) <= 64 and all(c in _HEX for c in candidate):
116
+ full = f"hash://{candidate}{url[colon_slash + 1:]}"
117
+ return "#" if _is_blocked(full) else full
118
+
119
+ if url.startswith("/") and node_hash:
120
+ return "#" if _is_blocked(url) else f"hash://{node_hash}{url}"
121
+
122
+ if node_hash and url:
123
+ base_dir = (base_path.rsplit("/", 1)[0] + "/") if "/" in base_path else "/"
124
+ return f"hash://{node_hash}{base_dir}{url}"
125
+
126
+ return "#"
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # State dataclasses
131
+ # ---------------------------------------------------------------------------
132
+
133
+ @dataclass
134
+ class _DocState:
135
+ """Document-level state that persists across lines."""
136
+ align: str = "" # "", "left", "center", "right"
137
+ section: int = 0 # current section depth (number of leading >)
138
+ literal: bool = False # inside a `= ... `= block
139
+ literal_lines: list = field(default_factory=list)
140
+ doc_fg: str = "" # CSS color from #!fg= header
141
+ doc_bg: str = "" # CSS color from #!bg= header
142
+
143
+
144
+ @dataclass
145
+ class _InlineState:
146
+ """Per-line inline formatting state."""
147
+ bold: bool = False
148
+ italic: bool = False
149
+ underline: bool = False
150
+ literal: bool = False
151
+ tag_stack: list = field(default_factory=list) # list of (type_str, close_html)
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Converter
156
+ # ---------------------------------------------------------------------------
157
+
158
+ class MicronConverter:
159
+ """Convert Micron markup text to an HTML fragment.
160
+
161
+ Parameters
162
+ ----------
163
+ url_resolver
164
+ Callable ``(raw_url, node_hash, base_path) -> href`` invoked for every
165
+ link in the input. Defaults to :func:`default_url_resolver`, which
166
+ emits canonical ``hash://...`` URLs. Web frontends typically wrap
167
+ that with their own URL pattern (e.g. ``/page?url=…``).
168
+ """
169
+
170
+ def __init__(self, url_resolver: Optional[UrlResolver] = None):
171
+ self._url_resolver: UrlResolver = url_resolver or default_url_resolver
172
+
173
+ def convert(self, text: str, node_hash: str = "", base_path: str = "",
174
+ authenticated: bool = False, render_braille: bool = True) -> str:
175
+ """Convert a full Micron document to an HTML fragment.
176
+
177
+ Parameters
178
+ ----------
179
+ text
180
+ Micron source text.
181
+ node_hash
182
+ Destination hash of the source NomadNet node — used to resolve
183
+ relative and node-relative links.
184
+ base_path
185
+ Path of the current page (e.g. ``/page/index.mu``) — used to
186
+ resolve relative links against the page's directory.
187
+ authenticated
188
+ When ``True``, form fields are rendered as editable ``<input>``
189
+ elements. When ``False`` (default), they are rendered as
190
+ disabled inputs so guests can see them but not submit.
191
+ render_braille
192
+ When ``True`` (default), Braille characters (U+2800–U+28FF) in
193
+ the output are replaced with ``<span class="mu-braille">``
194
+ elements that render the dots via CSS — see the comment above
195
+ ``_braillify_html`` for rationale. Pass ``False`` to keep the
196
+ raw Braille codepoints (e.g. when feeding the result to a
197
+ downstream consumer that strips tags).
198
+ """
199
+ lines = text.split("\n")
200
+ doc = _DocState()
201
+ parts = []
202
+
203
+ for line in lines:
204
+ result = self._process_line(line, node_hash, base_path, authenticated, doc)
205
+ if result is not None:
206
+ parts.append(result)
207
+
208
+ # Flush any unclosed literal block
209
+ if doc.literal and doc.literal_lines:
210
+ content = html.escape("\n".join(doc.literal_lines))
211
+ parts.append(f'<pre class="mu-literal">{content}</pre>')
212
+
213
+ body = "\n".join(parts)
214
+
215
+ # Wrap with page-level colours if #!fg/#!bg headers were found
216
+ styles = []
217
+ if doc.doc_bg:
218
+ styles.append(f"background-color:{doc.doc_bg}")
219
+ if doc.doc_fg:
220
+ styles.append(f"color:{doc.doc_fg}")
221
+ if styles:
222
+ result = f'<div class="mu-page" style="{";".join(styles)}">{body}</div>'
223
+ else:
224
+ result = body
225
+ return _braillify_html(result) if render_braille else result
226
+
227
+ def convert_inline(self, text: str, node_hash: str = "", base_path: str = "",
228
+ authenticated: bool = False, render_braille: bool = True) -> str:
229
+ """Convert a single line of Micron markup to inline HTML.
230
+
231
+ Returns formatted HTML *without* the ``<div class="mu-line">`` wrapper —
232
+ useful for rendering titles, message previews, brand elements, and
233
+ anywhere you need just the inline formatting (colors, bold, links).
234
+
235
+ Multi-line input has all newlines replaced with spaces.
236
+
237
+ ``render_braille`` controls the Braille post-processing — see
238
+ :meth:`convert` for details.
239
+ """
240
+ single = text.replace("\n", " ").strip()
241
+ result = self._parse_inline(single, node_hash, base_path, authenticated, _DocState())
242
+ return _braillify_html(result) if render_braille else result
243
+
244
+ def to_text(self, text: str) -> str:
245
+ """Render Micron markup to plain text, stripping formatting and colors.
246
+
247
+ Useful for message previews in conversation lists, search indexing,
248
+ accessibility tools, and CLI/terminal display where HTML is unwanted.
249
+ Links retain only their label text; URLs are dropped. Literal blocks
250
+ appear as their raw content. Page-level fg/bg headers are dropped.
251
+ """
252
+ html_out = self.convert(text, render_braille=False)
253
+ plain = _TAG_RE.sub("", html_out)
254
+ return html.unescape(plain).strip()
255
+
256
+ # ------------------------------------------------------------------
257
+ # Line-level processing
258
+ # ------------------------------------------------------------------
259
+
260
+ def _process_line(self, line: str, node_hash: str, base_path: str,
261
+ authenticated: bool, doc: _DocState) -> Optional[str]:
262
+
263
+ # ---- Inside a multi-line literal block ----
264
+ if doc.literal:
265
+ if line.rstrip() == "`=":
266
+ doc.literal = False
267
+ content = html.escape("\n".join(doc.literal_lines))
268
+ doc.literal_lines = []
269
+ # MeshChat parity: literal lines inherit the surrounding
270
+ # section depth's indent (its parser applies section indent
271
+ # to every line, literal or not).
272
+ indent = max(0, doc.section - 1) * 20
273
+ style_attr = f' style="margin-left:{indent}px"' if indent else ''
274
+ return f'<pre class="mu-literal"{style_attr}>{content}</pre>'
275
+ doc.literal_lines.append(line)
276
+ return None
277
+
278
+ # ---- Comment / page-header lines (start with #) ----
279
+ if line.startswith("#"):
280
+ raw = line.strip()
281
+ if raw.startswith("#!bg="):
282
+ color = self._parse_header_color(raw[5:].strip())
283
+ if color:
284
+ doc.doc_bg = color
285
+ elif raw.startswith("#!fg="):
286
+ color = self._parse_header_color(raw[5:].strip())
287
+ if color:
288
+ doc.doc_fg = color
289
+ return None
290
+
291
+ stripped = line.rstrip("\r")
292
+
293
+ # ---- Literal block start/end: standalone `= line ----
294
+ if stripped.strip() == "`=":
295
+ doc.literal = True
296
+ doc.literal_lines = []
297
+ return None
298
+
299
+ # ---- Full reset: standalone `` resets doc-level state ----
300
+ if stripped.strip() == "``":
301
+ doc.align = ""
302
+ return None
303
+
304
+ # ---- Section headings: line starts with one or more > ----
305
+ if line.startswith(">"):
306
+ level = 0
307
+ while level < len(line) and line[level] == ">":
308
+ level += 1
309
+ doc.section = level
310
+ heading_text = line[level:].strip()
311
+ if not heading_text:
312
+ # MeshChat parity: empty heading line still emits a blank row
313
+ # (its parseLine returns null and the outer convertMicronToHtml
314
+ # loop appends a <br>). State (section depth) is updated above.
315
+ return '<div class="mu-blank"></div>'
316
+ inner = self._parse_inline(heading_text, node_hash, base_path,
317
+ authenticated, doc)
318
+ # Heading bg extends to the container's left edge for ALL levels
319
+ # (bg starts at 0 regardless of depth). The heading TEXT is
320
+ # tabbed inward via `padding-left` so deeper headings indent
321
+ # while their bg still spans the full row.
322
+ text_indent = (level - 1) * 20
323
+ style_attr = f' style="padding-left:{text_indent}px"' if text_indent else ''
324
+ # MeshChat parity: only heading levels 1–3 have a bg block; level
325
+ # 4+ falls back to the "plain" style (no bg, default fg). We
326
+ # render levels >3 as `.mu-line` so they get plain rendering.
327
+ if level == 1:
328
+ cls = "mu-h1"
329
+ elif level == 2:
330
+ cls = "mu-h2"
331
+ elif level == 3:
332
+ cls = "mu-h3"
333
+ else:
334
+ cls = "mu-line"
335
+ return f'<div class="{cls}"{style_attr}>{inner}</div>'
336
+
337
+ # ---- Dividers ----
338
+ # MeshChat parity: only lines starting with `-` produce dividers.
339
+ # `=-`, `==`, `===` etc. fall through and render as regular text.
340
+ s = line.strip()
341
+ if s and s[0] == "-":
342
+ indent = max(0, doc.section - 1) * 20
343
+ style_attr = f' style="margin-left:{indent}px"' if indent else ''
344
+ if len(s) == 1:
345
+ # `-` alone — thin solid rule (browser-default <hr>)
346
+ return f'<hr class="mu-hr"{style_attr}>'
347
+ if s[1] == "=":
348
+ # `-=` — row of `=` characters
349
+ return f'<hr class="mu-hr mu-hr-double"{style_attr}>'
350
+ # `--`, `-~`, `-*`, `-X`, etc. — styled divider; preserve the
351
+ # character so the renderer can repeat it across the row.
352
+ char_content = html.escape(s[1])
353
+ return f'<div class="mu-divider"{style_attr}>{char_content}</div>'
354
+
355
+ # ---- Empty line ----
356
+ if not line.strip():
357
+ return '<div class="mu-blank"></div>'
358
+
359
+ # ---- Regular text line ----
360
+ # MeshChat parity: no line-level bg from a leading `B` token (its
361
+ # parser doesn't do that). Bg only applies inside the explicit span.
362
+ inner = self._parse_inline(line, node_hash, base_path, authenticated, doc)
363
+
364
+ style_parts = []
365
+ if doc.align:
366
+ style_parts.append(f"text-align:{doc.align}")
367
+ indent = max(0, doc.section - 1) * 20
368
+ if indent:
369
+ style_parts.append(f"margin-left:{indent}px")
370
+
371
+ style_attr = f' style="{";".join(style_parts)}"' if style_parts else ''
372
+ return f'<div class="mu-line"{style_attr}>{inner}</div>'
373
+
374
+ # ------------------------------------------------------------------
375
+ # Inline parsing (character-level state machine)
376
+ # ------------------------------------------------------------------
377
+
378
+ def _parse_inline(self, text: str, node_hash: str, base_path: str,
379
+ authenticated: bool, doc: _DocState) -> str:
380
+ state = _InlineState()
381
+ out = []
382
+ i = 0
383
+ n = len(text)
384
+
385
+ while i < n:
386
+ ch = text[i]
387
+
388
+ # ---- Inline literal mode: pass through until closing `= ----
389
+ if state.literal:
390
+ if ch == "`" and i + 1 < n and text[i + 1] == "=":
391
+ state.literal = False
392
+ i += 2
393
+ else:
394
+ out.append(html.escape(ch))
395
+ i += 1
396
+ continue
397
+
398
+ # ---- Backslash escape ----
399
+ if ch == "\\" and i + 1 < n:
400
+ out.append(html.escape(text[i + 1]))
401
+ i += 2
402
+ continue
403
+
404
+ # ---- Backtick token ----
405
+ if ch == "`":
406
+ i += 1
407
+ if i >= n:
408
+ out.extend(self._close_all(state))
409
+ break
410
+ nc = text[i]
411
+
412
+ # Reset ALL formatting (``)
413
+ if nc == "`":
414
+ out.extend(self._close_all(state))
415
+ doc.align = ""
416
+ i += 1
417
+
418
+ # Bold (`!)
419
+ elif nc == "!":
420
+ if state.bold:
421
+ out.append("</strong>")
422
+ self._pop_tag(state, "strong")
423
+ else:
424
+ out.append("<strong>")
425
+ state.tag_stack.append(("strong", "</strong>"))
426
+ state.bold = not state.bold
427
+ i += 1
428
+
429
+ # Underline (`_)
430
+ elif nc == "_":
431
+ if state.underline:
432
+ out.append("</span>")
433
+ self._pop_tag(state, "underline")
434
+ else:
435
+ out.append('<span class="mu-ul">')
436
+ state.tag_stack.append(("underline", "</span>"))
437
+ state.underline = not state.underline
438
+ i += 1
439
+
440
+ # Italic (`*)
441
+ elif nc == "*":
442
+ if state.italic:
443
+ out.append("</em>")
444
+ self._pop_tag(state, "em")
445
+ else:
446
+ out.append("<em>")
447
+ state.tag_stack.append(("em", "</em>"))
448
+ state.italic = not state.italic
449
+ i += 1
450
+
451
+ # Foreground color (`Fxxx or `FTxxxxxx)
452
+ elif nc == "F":
453
+ i += 1
454
+ color, i = self._parse_color(text, i, n)
455
+ if color:
456
+ out.append(f'<span style="color:{color}">')
457
+ state.tag_stack.append(("fg", "</span>"))
458
+
459
+ # Reset foreground (`f)
460
+ elif nc == "f":
461
+ self._close_innermost(state, "fg", out)
462
+ i += 1
463
+
464
+ # Background color (`Bxxx or `BTxxxxxx)
465
+ elif nc == "B":
466
+ i += 1
467
+ color, i = self._parse_color(text, i, n)
468
+ if color:
469
+ out.append(f'<span style="background-color:{color}">')
470
+ state.tag_stack.append(("bg", "</span>"))
471
+
472
+ # Reset background (`b)
473
+ elif nc == "b":
474
+ self._close_innermost(state, "bg", out)
475
+ i += 1
476
+
477
+ # Alignment — updates persistent doc state
478
+ elif nc == "c":
479
+ doc.align = "center"
480
+ i += 1
481
+ elif nc == "l":
482
+ doc.align = "left"
483
+ i += 1
484
+ elif nc == "r":
485
+ doc.align = "right"
486
+ i += 1
487
+ elif nc == "a":
488
+ doc.align = ""
489
+ i += 1
490
+
491
+ # Inline literal mode (`=)
492
+ elif nc == "=":
493
+ state.literal = True
494
+ i += 1
495
+
496
+ # Link (`[label`URL`field1=v1`field2=v2…] or `[URL])
497
+ elif nc == "[":
498
+ i += 1 # past [
499
+ end = text.find("]", i)
500
+ if end != -1:
501
+ link_inner = text[i:end]
502
+ parts = link_inner.split("`")
503
+ if len(parts) >= 2:
504
+ lbl, url = parts[0], parts[1]
505
+ # Preserve all backtick-separated field specs.
506
+ # Earlier versions only took parts[2], silently
507
+ # dropping every field after the first.
508
+ fspec = "`".join(parts[2:]) if len(parts) > 2 else ""
509
+ else:
510
+ url = parts[0]
511
+ lbl = ""
512
+ fspec = ""
513
+ href = self._resolve_url(url, node_hash, base_path)
514
+ display = html.escape(lbl) if lbl else html.escape(url)
515
+ extra = (f' data-field-spec="{html.escape(fspec)}"'
516
+ if fspec else "")
517
+ out.append(
518
+ f'<a href="{html.escape(href)}" class="mu-link"{extra}>'
519
+ f'{display}</a>'
520
+ )
521
+ i = end + 1
522
+ else:
523
+ out.append("[")
524
+
525
+ # Field (`<flags|name`default>)
526
+ # MeshChat parity: a field requires a backtick between
527
+ # `<flags|name` and `default>`. Without it, MeshChat's
528
+ # parseField returns null and the `<` is silently eaten.
529
+ # We mirror that exactly so checkbox/radio shorthand that
530
+ # omits the backtick (`<?|name|value>`, `<^|name|value>`)
531
+ # produces the same broken render in both renderers.
532
+ elif nc == "<":
533
+ field_start = i + 1
534
+ backtick_pos = text.find("`", field_start)
535
+ end = text.find(">", backtick_pos + 1) if backtick_pos != -1 else -1
536
+ if backtick_pos != -1 and end != -1:
537
+ field_content = text[field_start:backtick_pos]
538
+ field_data = text[backtick_pos + 1:end]
539
+ out.append(self._render_field(field_content, field_data, authenticated))
540
+ i = end + 1
541
+ else:
542
+ # Malformed — eat the `<` silently, matching MeshChat.
543
+ i += 1
544
+
545
+ # Dynamic include (`{URL`refresh})
546
+ elif nc == "{":
547
+ end = text.find("}", i + 1)
548
+ if end != -1:
549
+ dyn_inner = text[i + 1:end]
550
+ dyn_url = dyn_inner.split("`")[0].strip()
551
+ href = self._resolve_url(dyn_url, node_hash, base_path)
552
+ out.append(
553
+ f'<a href="{html.escape(href)}" class="mu-dynamic">[live]</a>'
554
+ )
555
+ i = end + 1
556
+ else:
557
+ out.append("`{")
558
+ i += 1
559
+
560
+ else:
561
+ # Unknown token — silently consume both the backtick and
562
+ # the unknown char, matching the behaviour of NomadNet's
563
+ # MicronParser (and Liam Cottle's MicronParser.js port).
564
+ # If you want a literal backtick, use `\`` to escape it.
565
+ i += 1
566
+
567
+ continue
568
+
569
+ out.append(html.escape(ch))
570
+ i += 1
571
+
572
+ out.extend(self._close_all(state))
573
+ return "".join(out)
574
+
575
+ # ------------------------------------------------------------------
576
+ # Helpers
577
+ # ------------------------------------------------------------------
578
+
579
+ def _parse_color(self, text: str, i: int, n: int):
580
+ """Parse a Micron color token after the F/B prefix.
581
+
582
+ Format: 3 hex chars — each nibble doubled (f→ff, 8→88, 0→00).
583
+
584
+ MeshChat parity:
585
+ - Always consume the next 3 chars after F/B (if available),
586
+ regardless of whether they're valid hex. This matches NomadNet's
587
+ MicronParser and Liam Cottle's MicronParser.js, both of which
588
+ do `line.substr(i+1,3)` + `skip = 3` unconditionally.
589
+ - If the 3 chars aren't valid hex, no colour is applied (the
590
+ colour-state holds the invalid string; rendering ignores it),
591
+ but the 3 chars are still consumed so they don't leak as text.
592
+ - The 24-bit `T<6hex>` extension that Micron2HTML used to accept
593
+ has been dropped — neither MeshChat nor NomadNet support it.
594
+
595
+ Returns (css_color_str | None, new_index).
596
+ """
597
+ if i + 3 <= n:
598
+ h3 = text[i:i + 3]
599
+ if all(c in _HEX for c in h3):
600
+ return "#" + "".join(c * 2 for c in h3).lower(), i + 3
601
+ # Invalid hex — still consume the 3 chars to match MeshChat,
602
+ # but signal "no colour" so the caller doesn't open a span.
603
+ return None, i + 3
604
+ return None, i
605
+
606
+ def _parse_header_color(self, value: str) -> Optional[str]:
607
+ """Parse a #!fg=X or #!bg=X color value. Returns CSS color or None."""
608
+ v = value.strip()
609
+ if len(v) == 3 and all(c in _HEX for c in v):
610
+ return "#" + "".join(c * 2 for c in v).lower()
611
+ if len(v) == 6 and all(c in _HEX for c in v):
612
+ return f"#{v.lower()}"
613
+ return None
614
+
615
+ def _resolve_url(self, url: str, node_hash: str, base_path: str) -> str:
616
+ """Convert a Micron URL to an href via the configured resolver."""
617
+ return self._url_resolver(url, node_hash, base_path)
618
+
619
+ def _render_field(self, field_content: str, field_data: str,
620
+ authenticated: bool = False) -> str:
621
+ """Render a Micron input field.
622
+
623
+ Mirrors `parseField()` in liamcottle/reticulum-meshchat MicronParser.js.
624
+
625
+ Formats (the `\\`` is the required separator between flags|name and
626
+ default/label):
627
+ text/password : `<[size][!]|name\\`default>`
628
+ checkbox : `<?[size]|field_name|value[|*]\\`label>`
629
+ radio : `<^[size]|field_name|value[|*]\\`label>`
630
+
631
+ `field_content` is everything between `<` and the backtick.
632
+ `field_data` is everything between the backtick and `>`.
633
+ """
634
+ dis = "" if authenticated else " disabled"
635
+
636
+ field_masked = False
637
+ field_width = 24
638
+ field_type = "field"
639
+ field_name = field_content
640
+ field_value = ""
641
+ field_prechecked = False
642
+
643
+ if "|" in field_content:
644
+ f_components = field_content.split("|")
645
+ field_flags = f_components[0]
646
+ field_name = f_components[1] if len(f_components) > 1 else ""
647
+
648
+ if "^" in field_flags:
649
+ field_type = "radio"
650
+ field_flags = field_flags.replace("^", "")
651
+ elif "?" in field_flags:
652
+ field_type = "checkbox"
653
+ field_flags = field_flags.replace("?", "")
654
+ elif "!" in field_flags:
655
+ field_masked = True
656
+ field_flags = field_flags.replace("!", "")
657
+
658
+ if field_flags:
659
+ try:
660
+ w = int(field_flags)
661
+ field_width = min(w, 256)
662
+ except ValueError:
663
+ pass
664
+
665
+ if len(f_components) > 2:
666
+ field_value = f_components[2]
667
+ if len(f_components) > 3 and f_components[3] == "*":
668
+ field_prechecked = True
669
+
670
+ name_attr = html.escape(field_name)
671
+ if field_type in ("checkbox", "radio"):
672
+ value = field_value or field_data
673
+ label = field_data
674
+ chk = " checked" if field_prechecked else ""
675
+ return (f'<input type="{field_type}" name="{name_attr}" '
676
+ f'value="{html.escape(value)}"{dis}{chk}> '
677
+ f'{html.escape(label)}')
678
+
679
+ # Text / password
680
+ itype = "password" if field_masked else "text"
681
+ return (f'<input type="{itype}" name="{name_attr}" '
682
+ f'value="{html.escape(field_data)}" '
683
+ f'size="{field_width}"{dis} class="mu-field">')
684
+
685
+ def _pop_tag(self, state: _InlineState, tag_type: str) -> None:
686
+ for j in range(len(state.tag_stack) - 1, -1, -1):
687
+ if state.tag_stack[j][0] == tag_type:
688
+ state.tag_stack.pop(j)
689
+ return
690
+
691
+ def _close_innermost(self, state: _InlineState, tag_type: str, out: list) -> None:
692
+ for j in range(len(state.tag_stack) - 1, -1, -1):
693
+ if state.tag_stack[j][0] == tag_type:
694
+ out.append(state.tag_stack[j][1])
695
+ state.tag_stack.pop(j)
696
+ return
697
+
698
+ def _close_all(self, state: _InlineState) -> list:
699
+ tags = [close for _, close in reversed(state.tag_stack)]
700
+ state.tag_stack.clear()
701
+ state.bold = state.italic = state.underline = False
702
+ state.literal = False
703
+ return tags
@@ -0,0 +1,245 @@
1
+ /* Micron2HTML — default stylesheet (v1.0.4)
2
+ *
3
+ * Drop this in alongside the converter's HTML output for instant rendering
4
+ * that matches Reticulum MeshChat's NomadNet renderer. All rules are scoped
5
+ * to .mu-* classes so they won't bleed into the rest of your page.
6
+ *
7
+ * Pairs with the bundled `RobotoMonoNerdFont-Regular.ttf` (CC BY 4.0, by
8
+ * Christian Robertson + Nerd Fonts contributors) — drop both files in the
9
+ * same directory and the @font-face below picks it up automatically.
10
+ *
11
+ * The Braille block (U+2800-28FF) is carved out of Roboto Mono and routed
12
+ * to a system-font fallback chain — Roboto Mono's Braille glyphs don't
13
+ * reach the cell edges, so a row of full-dot Braille reads as gappy dots
14
+ * instead of a contiguous grid (MeshChat-parity bug, fixed here so every
15
+ * downstream consumer gets the right rendering for free).
16
+ */
17
+
18
+ @font-face {
19
+ font-family: 'Roboto Mono Nerd Font';
20
+ src: url('./RobotoMonoNerdFont-Regular.ttf') format('truetype');
21
+ font-weight: 400;
22
+ font-style: normal;
23
+ font-display: swap;
24
+ unicode-range: U+0000-27FF, U+2900-FFFF, U+10000-10FFFF;
25
+ }
26
+
27
+ /* Braille — system font fallback. None of these are bundled; the browser
28
+ * uses whichever the user's OS ships (Linux usually has Noto or DejaVu,
29
+ * macOS has Apple Symbols, Windows has Segoe UI Symbol). If none are
30
+ * installed, the browser falls back to its generic monospace default and
31
+ * the gap returns — but that's strictly better than always-gappy, and
32
+ * adds zero KB to the page weight. */
33
+ @font-face {
34
+ font-family: 'Roboto Mono Nerd Font';
35
+ src: local('Noto Sans Mono'),
36
+ local('DejaVu Sans Mono'),
37
+ local('Symbola'),
38
+ local('Apple Symbols'),
39
+ local('Segoe UI Symbol');
40
+ font-weight: 400;
41
+ font-style: normal;
42
+ unicode-range: U+2800-28FF;
43
+ }
44
+
45
+ /* ---- Page wrapper -----------------------------------------------------
46
+ * Emitted when the source has `#!bg=` or `#!fg=` headers; otherwise the
47
+ * Micron content is bare. For standalone use, wrap your converter output
48
+ * in `<div class="mu-page">` to get the full terminal aesthetic. */
49
+ .mu-page {
50
+ font-family: "Roboto Mono Nerd Font", "Cascadia Code", "JetBrains Mono",
51
+ "Consolas", "Courier New", monospace;
52
+ font-size: 16.64px;
53
+ line-height: normal;
54
+ color: #dddddd;
55
+ background: #000000;
56
+ padding: 12px;
57
+ white-space: pre;
58
+ /* Only the Regular weight ships with Micron2HTML — disable browser
59
+ * synthesis so faux-bold doesn't break monospace alignment in centred
60
+ * lines that mix bold and non-bold spans. Use the text-shadow trick
61
+ * below to fake a bold visual without changing character widths. */
62
+ font-synthesis-weight: none;
63
+ font-kerning: none;
64
+ /* No line-wrap on Micron content — long lines scroll horizontally so
65
+ * box-drawing diagrams and ASCII art stay aligned. */
66
+ overflow: auto;
67
+ }
68
+
69
+ /* ---- Section headings (MeshChat STYLES_DARK) ----
70
+ * heading1: bg #bbbbbb, default fg #222222 (overridable by author Fxxx)
71
+ * heading2: bg #999999, default fg #111111
72
+ * heading3: bg #777777, default fg #000000
73
+ *
74
+ * The bg always extends to the container's left edge; the heading text is
75
+ * tabbed in via `padding-left` (set inline by the converter from depth). */
76
+ .mu-h1, .mu-h2, .mu-h3 {
77
+ display: block;
78
+ font-family: inherit;
79
+ font-weight: bold;
80
+ /* Closing the inflated leading the Nerd Font carries — keeps adjacent
81
+ * box-drawing rows touching across the heading. Scales with font-size. */
82
+ margin: 0 0 -1.08em 0;
83
+ white-space: pre;
84
+ }
85
+ .mu-h1 { background: #bbbbbb; color: #222222; }
86
+ .mu-h2 { background: #999999; color: #111111; }
87
+ .mu-h3 { background: #777777; color: #000000; }
88
+
89
+ /* ---- Body lines & blanks --------------------------------------------- */
90
+ .mu-line {
91
+ display: block;
92
+ margin: 0 0 -1.08em 0;
93
+ line-height: 1;
94
+ white-space: pre;
95
+ }
96
+ .mu-blank {
97
+ display: block;
98
+ line-height: 1;
99
+ height: 1em;
100
+ margin-bottom: -1.08em;
101
+ }
102
+
103
+ /* ---- Dividers --------------------------------------------------------
104
+ * `-` / `--` → thin solid line (browser-default <hr> with currentColor)
105
+ * `-=` / `=-` → row of `=` characters (CSS `::before` content)
106
+ * `-X` styled → row of the source char (text-shadow repeats it 150x)
107
+ * Matches what MeshChat displays via its own browser-default <hr>. */
108
+ .mu-hr {
109
+ display: block;
110
+ border: 0;
111
+ border-top: 1px solid currentColor;
112
+ margin: 0 0 -1.08em 0;
113
+ height: 0;
114
+ }
115
+ .mu-hr-double {
116
+ display: block;
117
+ border: 0;
118
+ margin: 0 0 -1.08em 0;
119
+ height: 1em;
120
+ overflow: hidden;
121
+ white-space: nowrap;
122
+ color: inherit;
123
+ font-family: inherit;
124
+ }
125
+ .mu-hr-double::before {
126
+ content: "================================================================================================================================================================";
127
+ }
128
+ .mu-divider {
129
+ display: block;
130
+ margin: 0 0 -1.08em 0;
131
+ overflow: hidden;
132
+ white-space: nowrap;
133
+ color: inherit;
134
+ text-shadow:
135
+ 1ch 0, 2ch 0, 3ch 0, 4ch 0, 5ch 0, 6ch 0, 7ch 0, 8ch 0, 9ch 0, 10ch 0,
136
+ 11ch 0, 12ch 0, 13ch 0, 14ch 0, 15ch 0, 16ch 0, 17ch 0, 18ch 0, 19ch 0, 20ch 0,
137
+ 21ch 0, 22ch 0, 23ch 0, 24ch 0, 25ch 0, 26ch 0, 27ch 0, 28ch 0, 29ch 0, 30ch 0,
138
+ 31ch 0, 32ch 0, 33ch 0, 34ch 0, 35ch 0, 36ch 0, 37ch 0, 38ch 0, 39ch 0, 40ch 0,
139
+ 41ch 0, 42ch 0, 43ch 0, 44ch 0, 45ch 0, 46ch 0, 47ch 0, 48ch 0, 49ch 0, 50ch 0,
140
+ 51ch 0, 52ch 0, 53ch 0, 54ch 0, 55ch 0, 56ch 0, 57ch 0, 58ch 0, 59ch 0, 60ch 0,
141
+ 61ch 0, 62ch 0, 63ch 0, 64ch 0, 65ch 0, 66ch 0, 67ch 0, 68ch 0, 69ch 0, 70ch 0,
142
+ 71ch 0, 72ch 0, 73ch 0, 74ch 0, 75ch 0, 76ch 0, 77ch 0, 78ch 0, 79ch 0, 80ch 0,
143
+ 81ch 0, 82ch 0, 83ch 0, 84ch 0, 85ch 0, 86ch 0, 87ch 0, 88ch 0, 89ch 0, 90ch 0,
144
+ 91ch 0, 92ch 0, 93ch 0, 94ch 0, 95ch 0, 96ch 0, 97ch 0, 98ch 0, 99ch 0, 100ch 0,
145
+ 101ch 0, 102ch 0, 103ch 0, 104ch 0, 105ch 0, 106ch 0, 107ch 0, 108ch 0, 109ch 0, 110ch 0,
146
+ 111ch 0, 112ch 0, 113ch 0, 114ch 0, 115ch 0, 116ch 0, 117ch 0, 118ch 0, 119ch 0, 120ch 0,
147
+ 121ch 0, 122ch 0, 123ch 0, 124ch 0, 125ch 0, 126ch 0, 127ch 0, 128ch 0, 129ch 0, 130ch 0,
148
+ 131ch 0, 132ch 0, 133ch 0, 134ch 0, 135ch 0, 136ch 0, 137ch 0, 138ch 0, 139ch 0, 140ch 0,
149
+ 141ch 0, 142ch 0, 143ch 0, 144ch 0, 145ch 0, 146ch 0, 147ch 0, 148ch 0, 149ch 0, 150ch 0;
150
+ }
151
+
152
+ /* ---- Braille (CSS-drawn dots) ---------------------------------------
153
+ * The converter replaces every Braille character (U+2800–U+28FF) with
154
+ * a `<span class="mu-braille">` whose `--mu-braille-dots` custom
155
+ * property holds a list of `radial-gradient`s, one per raised dot.
156
+ * Roboto Mono Nerd Font has no Braille glyphs, and most system-monospace
157
+ * fallbacks render Braille narrower than the cell — drawing the dots
158
+ * ourselves keeps every cell at exactly `1ch` and lets a row of full-dot
159
+ * Braille flow as a contiguous grid. */
160
+ .mu-braille {
161
+ display: inline-block;
162
+ width: 1ch;
163
+ height: 1em;
164
+ vertical-align: baseline;
165
+ position: relative;
166
+ }
167
+ .mu-braille::before {
168
+ content: "";
169
+ position: absolute;
170
+ inset: 0;
171
+ background: var(--mu-braille-dots, transparent);
172
+ background-repeat: no-repeat;
173
+ }
174
+
175
+ /* ---- Bold (fake-bold via text-shadow) -------------------------------
176
+ * We only ship the Regular weight, so synthesised bold would shift glyph
177
+ * widths and break monospace alignment. Drawing the glyph 0.5px to the
178
+ * right thickens it visually without changing the layout box. */
179
+ strong {
180
+ font-weight: normal;
181
+ text-shadow: 0.5px 0 0 currentColor;
182
+ }
183
+
184
+ /* ---- Links -----------------------------------------------------------
185
+ * `color: inherit` so the surrounding span colour flows through (matches
186
+ * MeshChat — links don't have a fixed colour, they take the parser state). */
187
+ .mu-link {
188
+ color: inherit;
189
+ text-decoration: none;
190
+ }
191
+ .mu-link:hover { text-decoration: underline; }
192
+
193
+ /* ---- Inline underline & dynamic-include link ---- */
194
+ .mu-ul { text-decoration: underline; }
195
+ .mu-dynamic {
196
+ color: #c8905b;
197
+ font-style: italic;
198
+ }
199
+
200
+ /* ---- Literal blocks (`= ... `=) --------------------------------------
201
+ * Plain pre-styled text — no border, no bg, no padding. MeshChat doesn't
202
+ * box literal content; it flows inline at the same font/colour as the
203
+ * surrounding paragraph. */
204
+ .mu-literal {
205
+ white-space: pre;
206
+ font-family: inherit;
207
+ font-size: inherit;
208
+ line-height: 1;
209
+ color: inherit;
210
+ background: transparent;
211
+ border: 0;
212
+ padding: 0;
213
+ margin: 0;
214
+ }
215
+
216
+ /* ---- Form fields -----------------------------------------------------
217
+ * Text/password inputs: white background with mid-grey text (matches
218
+ * MeshChat's disabled-input look). Padding bumped from default browser
219
+ * metrics so they read at body size. */
220
+ .mu-field {
221
+ background: #ffffff;
222
+ border: 1px solid #333333;
223
+ color: #bababa;
224
+ font-family: inherit;
225
+ font-size: inherit;
226
+ padding: 6px 8px;
227
+ border-radius: 2px;
228
+ cursor: not-allowed;
229
+ }
230
+ .mu-field:not([disabled]) {
231
+ color: #bababa;
232
+ cursor: text;
233
+ outline: none;
234
+ }
235
+ .mu-field:focus { outline: none; border-color: #5ba3c9; }
236
+
237
+ input[type="checkbox"], input[type="radio"] {
238
+ accent-color: #5ba3c9;
239
+ cursor: pointer;
240
+ margin: 0 4px 0 0;
241
+ }
242
+ input[type="checkbox"][disabled], input[type="radio"][disabled] {
243
+ cursor: not-allowed;
244
+ opacity: 0.5;
245
+ }
@@ -0,0 +1,227 @@
1
+ Metadata-Version: 2.4
2
+ Name: Micron2HTML
3
+ Version: 1.0.5
4
+ Summary: Convert Micron markup (NomadNet) to HTML
5
+ Author-email: James Manley <jamesmanley1992@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/JamesM92/Micron2HTML
8
+ Project-URL: Repository, https://github.com/JamesM92/Micron2HTML
9
+ Project-URL: Issues, https://github.com/JamesM92/Micron2HTML/issues
10
+ Keywords: nomadnet,reticulum,micron,markup,html,converter
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Text Processing :: Markup :: HTML
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8.0; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # Micron2HTML
29
+
30
+ A Python library and CLI tool that converts [Micron](https://github.com/markqvist/NomadNet) markup to HTML.
31
+
32
+ Micron is the terminal markup language used by [NomadNet](https://github.com/markqvist/NomadNet) nodes. This library lets you render Micron pages in web browsers and other HTML-capable environments.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install Micron2HTML
38
+ ```
39
+
40
+ Or from source:
41
+
42
+ ```bash
43
+ git clone https://github.com/JamesM92/Micron2HTML.git
44
+ cd Micron2HTML
45
+ pip install -e .
46
+ ```
47
+
48
+ No runtime dependencies — pure Python 3.9+.
49
+
50
+ ## Library usage
51
+
52
+ ```python
53
+ from micron2html import MicronConverter
54
+
55
+ conv = MicronConverter()
56
+
57
+ html = conv.convert(micron_text)
58
+
59
+ # With context for resolving internal links
60
+ html = conv.convert(
61
+ micron_text,
62
+ node_hash="a1b2c3d4...", # destination hash of the source node
63
+ base_path="/page/index.mu", # current page path
64
+ authenticated=True, # render form fields as interactive inputs
65
+ )
66
+
67
+ # Inline-only — for titles, message previews, brand strings.
68
+ # Returns formatted HTML without the <div class="mu-line"> wrapper.
69
+ title_html = conv.convert_inline("`F4af`!My Node`!`f")
70
+ ```
71
+
72
+ `convert()` returns an HTML fragment (no `<html>` or `<body>` wrapper). Wrap it in your own template or use the CLI for standalone pages.
73
+
74
+ ### Custom URL resolution
75
+
76
+ By default, links resolve to canonical `hash://<hash>/<path>` URLs (and `http(s)://` URLs pass through). If your web app uses a different URL pattern — e.g. `/page?url=…` — pass a resolver callback:
77
+
78
+ ```python
79
+ import urllib.parse
80
+ from micron2html import MicronConverter, default_url_resolver
81
+
82
+ def my_resolver(url: str, node_hash: str, base_path: str) -> str:
83
+ canonical = default_url_resolver(url, node_hash, base_path)
84
+ if canonical.startswith("hash://"):
85
+ return f"/page?url={urllib.parse.quote(canonical, safe='')}"
86
+ return canonical
87
+
88
+ conv = MicronConverter(url_resolver=my_resolver)
89
+ ```
90
+
91
+ ### Default stylesheet
92
+
93
+ A MeshChat-parity stylesheet ships with the package, named to make the design intent explicit:
94
+
95
+ ```html
96
+ <link rel="stylesheet" href="/static/micron-meshchat.css">
97
+ ```
98
+
99
+ The file lives at `micron2html/micron-meshchat.css` in the installed package — copy it into your static directory, or import it via your build pipeline. All rules are scoped to `.mu-*` classes so they won't bleed into the rest of your page.
100
+
101
+ ## CLI usage
102
+
103
+ ```bash
104
+ # Convert a file and print to stdout
105
+ micron-convert page.mu
106
+
107
+ # Convert and write to a file
108
+ micron-convert page.mu -o page.html
109
+
110
+ # Read from stdin
111
+ cat page.mu | micron-convert -
112
+
113
+ # Output an HTML fragment instead of a full page
114
+ micron-convert page.mu --fragment
115
+
116
+ # Set the node hash so internal links resolve correctly
117
+ micron-convert page.mu --node-hash a1b2c3d4e5f6...
118
+ ```
119
+
120
+ ## Micron syntax
121
+
122
+ ### Comments and headers
123
+
124
+ ```
125
+ # This is a comment — the whole line is stripped from output
126
+
127
+ #!bg=2a2a2a Set page background colour (3 or 6 hex digits)
128
+ #!fg=aaaaaa Set page foreground colour
129
+ ```
130
+
131
+ ### Headings and sections
132
+
133
+ ```
134
+ >Section heading h1
135
+ >>Subsection h2
136
+ >>>Sub-subsection h3
137
+ ```
138
+
139
+ ### Dividers
140
+
141
+ ```
142
+ --- Horizontal rule
143
+ -= Double horizontal rule
144
+ -<x> Styled divider — repeats character `x` (e.g. -* renders centred * row)
145
+ ```
146
+
147
+ ### Inline formatting
148
+
149
+ ```
150
+ `!text`! Bold
151
+ `*text`* Italic
152
+ `_text`_ Underline
153
+
154
+ `Fxxx Set foreground colour (3-hex shorthand: each digit doubled — F40 → #ff4400)
155
+ `FTxxxxxx Set foreground colour (6-hex true colour)
156
+ `f Reset foreground colour to default
157
+
158
+ `Bxxx Set background colour (3-hex shorthand)
159
+ `BTxxxxxx Set background colour (6-hex)
160
+ `b Reset background colour to default
161
+
162
+ `` Reset ALL inline formatting (bold, italic, underline, colours, alignment)
163
+ ```
164
+
165
+ ### Alignment
166
+
167
+ ```
168
+ `a Left align (default)
169
+ `c Centre align
170
+ `r Right align
171
+ ```
172
+
173
+ ### Links
174
+
175
+ ```
176
+ `[Label`href] Labelled link
177
+ `[`http://example.com] URL-only link
178
+ `[Label`/relative/path.mu] Relative path (resolved against base_path)
179
+ `[Label`hash://a1b2c3/page.mu] Node link (resolved against node_hash)
180
+ ```
181
+
182
+ ### Literal blocks
183
+
184
+ ```
185
+ `=
186
+ This text is rendered verbatim in a <pre> block.
187
+ No Micron formatting is applied inside.
188
+ `=
189
+ ```
190
+
191
+ ### Form fields
192
+
193
+ Fields render as disabled `<input>` elements unless `authenticated=True` is passed to `convert()`.
194
+
195
+ ```
196
+ `<name`default> Text input — name with optional default value
197
+ `<size|name`default> Text input with character size (e.g. `<20|name`>)
198
+ `<!|name`default> Password input (! flag)
199
+ `<?|name|value> Checkbox (* at end pre-checks: `<?|name|value|*>)
200
+ `<^|name|value> Radio button (* at end pre-selects)
201
+ ```
202
+
203
+ ## Security
204
+
205
+ All user-supplied content is HTML-escaped before output. The converter is safe to use with untrusted Micron input — XSS via markup is explicitly tested in the test suite.
206
+
207
+ External URLs are rendered as plain `<a>` links. File download links (`file://`) are blocked. Internal NomadNet links are resolved to application-relative hrefs.
208
+
209
+ ## Running tests
210
+
211
+ ```bash
212
+ pip install pytest
213
+ pytest tests/
214
+ ```
215
+
216
+ ## License
217
+
218
+ MIT — see [LICENSE](LICENSE).
219
+
220
+ ## Related
221
+
222
+ - [NomadNet](https://github.com/markqvist/NomadNet) — the NomadNet node software (defines the Micron spec)
223
+ - [Ansi2MicronMU](https://github.com/JamesM92/Ansi2MicronMU) — the other direction: convert ANSI terminal output (e.g. from `git log --color`, `htop`, `ls --color`) into Micron. Pair with Micron2HTML to expose existing CLI tools through a NomadNet site or a web frontend:
224
+ ```bash
225
+ git log --color=always | ansi2micron | micron-convert -
226
+ ```
227
+ - [NomadDockerNet](https://github.com/JamesM92/NomadDockerNet) — the web browser that uses this library
@@ -0,0 +1,12 @@
1
+ micron2html/RobotoMonoNerdFont-Regular.ttf,sha256=M1f-wczi0wyaVHqzX1vRdCwAXOY0MJZCd1C98WC-Co0,2389336
2
+ micron2html/__init__.py,sha256=vSuREPZzjb477beWMTKY59ha-WeXTfhkA8TLTH3rBKA,166
3
+ micron2html/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
4
+ micron2html/cli.py,sha256=EwmaZS8z09lwAVnQz9UgkV9gA-VxtYQEYVwFXNhDSKM,2701
5
+ micron2html/converter.py,sha256=xpIeFPx79jrmOf1Pfd0gXYo25dbjp0RzCxBO-qHDjjo,29036
6
+ micron2html/micron-meshchat.css,sha256=j2Is7iVIItQ0TPKKnMBb9Y3seCaRB2mQfbZour7mjNo,9108
7
+ micron2html-1.0.5.dist-info/licenses/LICENSE,sha256=CujYd6ElAA7VqlV0v2xV0ZSGv5xEJel0-2OTAt7oCSM,1069
8
+ micron2html-1.0.5.dist-info/METADATA,sha256=-WrhTuKwRQ-CkTM8SS0Sl5GVuBXGmIBBRU74_J3tUSo,6896
9
+ micron2html-1.0.5.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ micron2html-1.0.5.dist-info/entry_points.txt,sha256=RUScR5OAU7gvP5CkunlJOEHkYnZ_RvJbsvw09riagZE,56
11
+ micron2html-1.0.5.dist-info/top_level.txt,sha256=dAyG4ERpgklG3XCHIHqqPVI9YuzUPqSMYa4QigkiJbc,12
12
+ micron2html-1.0.5.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ micron-convert = micron2html.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 James Manley
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ micron2html