diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9ce1c1b..bfd1e87 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,12 +3,49 @@
All notable changes to Micron2HTML are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
-## [Unreleased]
+## [1.1.0] - 2026-07-30
+
+### Changed — goal correction: MeshChat parity → NomadNet parity
+
+The project's stated design goal was "MeshChat parity" (matching [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat)'s `MicronParser.js`). That target was corrected to full NomadNet parity — rendering Micron the way the real [NomadNet](https://github.com/markqvist/NomadNet) client would, in both directions, per its own [Guide.py](https://github.com/markqvist/NomadNet/blob/master/nomadnet/ui/textui/Guide.py) (the in-app spec for page authors), [MicronParser.py](https://github.com/markqvist/NomadNet/blob/master/nomadnet/ui/textui/MicronParser.py) (the reference implementation), and RNS's `MarkdownToMicron.format_table_raw` (the table-rendering algorithm), all fetched and read line-by-line for this pass. Most of what was previously justified as "MeshChat parity" turned out to already match NomadNet's own behavior too (unknown-token consumption, the field backtick requirement, heading depth limits, no line-level `B` fill, alignment tokens working anywhere inline) — those comments were relabeled without any behavior change.
+
+**New features, previously unimplemented:**
+
+- **Tables** (`` `t ``). Rendered as literal box-drawing-character ASCII art — the same visual approach real NomadNet uses — not a semantic HTML `
— that's what real NomadNet clients actually show, so that's what
+# this converter builds too, for genuine visual parity.
+# ---------------------------------------------------------------------------
+
+_TABLE_TOGGLE_RE = re.compile(r"^`t([lcr]?)(\d*)$")
+
+_TABLE_H, _TABLE_V = "─", "│"
+_TABLE_TL, _TABLE_TR, _TABLE_BL, _TABLE_BR = "┌", "┐", "└", "┘"
+_TABLE_ML, _TABLE_MR, _TABLE_TM, _TABLE_BM, _TABLE_MM = "├", "┤", "┬", "┴", "┼"
+
+_TABLE_MIN_COL_WIDTH = 3
+
+# Strips colour/bold/italic/underline/reset tokens for width-measurement
+# purposes only — a cell's *visible* width shouldn't count its formatting.
+_MICRON_TOKEN_RE = re.compile(
+ r"`[FB]T[0-9a-fA-F]{6}"
+ r"|`[FB][0-9a-fA-F]{3}"
+ r"|`[!*_=fb]"
+)
+
+
# ---------------------------------------------------------------------------
# Braille rendering
#
@@ -150,6 +225,13 @@ class _DocState:
literal_lines: list = field(default_factory=list)
doc_fg: str = "" # CSS color from #!fg= header
doc_bg: str = "" # CSS color from #!bg= header
+ anchors: set = field(default_factory=set) # claimed anchor names, first-wins
+ next_heading_map: list = field(default_factory=list) # line idx -> next heading's slug
+ line_index: int = 0 # current line, set by convert()'s loop
+ table_mode: bool = False # inside a `t ... `t block
+ table_lines: list = field(default_factory=list)
+ table_align: str = "" # "", "l", "c", "r" — captured at `t open
+ table_max_width: int = 100 # captured at `t open
@dataclass
@@ -158,8 +240,7 @@ class _InlineState:
bold: bool = False
italic: bool = False
underline: bool = False
- literal: bool = False
- tag_stack: list = field(default_factory=list) # list of (type_str, close_html)
+ tag_stack: list = field(default_factory=list) # list of (type_str, open_html, close_html)
# ---------------------------------------------------------------------------
@@ -209,9 +290,11 @@ def convert(self, text: str, node_hash: str = "", base_path: str = "",
"""
lines = text.split("\n")
doc = _DocState()
+ doc.next_heading_map = self._compute_next_heading_map(lines)
parts = []
- for line in lines:
+ for idx, line in enumerate(lines):
+ doc.line_index = idx
result = self._process_line(line, node_hash, base_path, authenticated, doc)
if result is not None:
parts.append(result)
@@ -221,6 +304,15 @@ def convert(self, text: str, node_hash: str = "", base_path: str = "",
content = html.escape("\n".join(doc.literal_lines))
parts.append(f'
{content}
')
+ # Flush any unclosed table
+ if doc.table_mode and doc.table_lines:
+ raw_lines, align, max_w = doc.table_lines, doc.table_align, doc.table_max_width
+ doc.table_mode, doc.table_lines, doc.table_align, doc.table_max_width = False, [], "", 100
+ rendered = self._render_table(raw_lines, align, max_w, node_hash,
+ base_path, authenticated, doc)
+ if rendered is not None:
+ parts.append(rendered)
+
body = "\n".join(parts)
# Wrap with page-level colours if #!fg/#!bg headers were found
@@ -271,15 +363,30 @@ def to_text(self, text: str) -> str:
def _process_line(self, line: str, node_hash: str, base_path: str,
authenticated: bool, doc: _DocState) -> Optional[str]:
+ # ---- Inside a `t ... `t table block ----
+ # Mutually exclusive with doc.literal by construction (both are only
+ # entered from the plain fallthrough path below), so this can safely
+ # come first.
+ if doc.table_mode:
+ if _TABLE_TOGGLE_RE.match(line.rstrip("\r").strip()):
+ doc.table_mode = False
+ raw_lines, align, max_w = doc.table_lines, doc.table_align, doc.table_max_width
+ doc.table_lines, doc.table_align, doc.table_max_width = [], "", 100
+ return self._render_table(raw_lines, align, max_w,
+ node_hash, base_path, authenticated, doc)
+ doc.table_lines.append(line)
+ return None
+
# ---- Inside a multi-line literal block ----
if doc.literal:
if line.rstrip() == "`=":
doc.literal = False
content = html.escape("\n".join(doc.literal_lines))
doc.literal_lines = []
- # MeshChat parity: literal lines inherit the surrounding
- # section depth's indent (its parser applies section indent
- # to every line, literal or not).
+ # NomadNet: literal lines inherit the surrounding section
+ # depth's indent — MicronParser.py's parse_line() wraps
+ # every widget in Padding(left=left_indent(state), ...)
+ # whenever depth > 0, literal or not.
indent = max(0, doc.section - 1) * 20
style_attr = f' style="margin-left:{indent}px"' if indent else ''
return f'
{content}
'
@@ -301,6 +408,16 @@ def _process_line(self, line: str, node_hash: str, base_path: str,
stripped = line.rstrip("\r")
+ # ---- Table start: standalone `t[align][width] line ----
+ m = _TABLE_TOGGLE_RE.match(stripped.strip())
+ if m:
+ align_char, width_str = m.group(1), m.group(2)
+ doc.table_mode = True
+ doc.table_lines = []
+ doc.table_align = align_char
+ doc.table_max_width = int(width_str) if width_str else 100
+ return None
+
# ---- Literal block start/end: standalone `= line ----
if stripped.strip() == "`=":
doc.literal = True
@@ -314,16 +431,18 @@ def _process_line(self, line: str, node_hash: str, base_path: str,
# ---- Section headings: line starts with one or more > ----
if line.startswith(">"):
- level = 0
- while level < len(line) and line[level] == ">":
- level += 1
+ level, heading_text = self._split_heading(line)
doc.section = level
- heading_text = line[level:].strip()
if not heading_text:
- # MeshChat parity: empty heading line still emits a blank row
- # (its parseLine returns null and the outer convertMicronToHtml
- # loop appends a ). State (section depth) is updated above.
- return ''
+ # NomadNet: parse_line() returns None for an empty heading —
+ # no row at all, not even blank space. Section depth is
+ # still updated above.
+ return None
+ # Auto-anchor: every heading's slugified text becomes a jump
+ # target, claimed before parsing the text itself so a same-slug
+ # explicit `:name inside this same heading loses the tie.
+ slug = slugify_micron(heading_text)
+ claimed = self._claim_anchor(doc, slug)
inner = self._parse_inline(heading_text, node_hash, base_path,
authenticated, doc)
# Heading bg extends to the container's left edge for ALL levels
@@ -332,8 +451,10 @@ def _process_line(self, line: str, node_hash: str, base_path: str,
# while their bg still spans the full row.
text_indent = (level - 1) * 20
style_attr = f' style="padding-left:{text_indent}px"' if text_indent else ''
- # MeshChat parity: only heading levels 1–3 have a bg block; level
- # 4+ falls back to the "plain" style (no bg, default fg). We
+ id_attr = f' id="{html.escape(claimed)}"' if claimed else ''
+ # NomadNet: only heading levels 1-3 have a defined style
+ # (STYLES_DARK/STYLES_LIGHT in MicronParser.py only define
+ # heading1/2/3); level 4+ falls back to plain rendering. We
# render levels >3 as `.mu-line` so they get plain rendering.
if level == 1:
cls = "mu-h1"
@@ -343,33 +464,41 @@ def _process_line(self, line: str, node_hash: str, base_path: str,
cls = "mu-h3"
else:
cls = "mu-line"
- return f'
{inner}
'
+ return f'
{inner}
'
# ---- Dividers ----
- # MeshChat parity: only lines starting with `-` produce dividers.
+ # NomadNet: only lines starting with `-` produce dividers.
# `=-`, `==`, `===` etc. fall through and render as regular text.
s = line.strip()
if s and s[0] == "-":
indent = max(0, doc.section - 1) * 20
style_attr = f' style="margin-left:{indent}px"' if indent else ''
- if len(s) == 1:
- # `-` alone — thin solid rule (browser-default )
- return f''
- if s[1] == "=":
- # `-=` — row of `=` characters
- return f''
- # `--`, `-~`, `-*`, `-X`, etc. — styled divider; preserve the
- # character so the renderer can repeat it across the row.
- char_content = html.escape(s[1])
- return f'
{char_content}
'
+ # NomadNet's parser only honours a custom divider character when
+ # the line is *exactly* "-" + one more character (see
+ # MicronParser.py's parse_line: `if len(line) == 2`). Any other
+ # length — a bare `-`, or `---`, `-==`, etc. — falls back to the
+ # default rule, regardless of what follows the first `-`.
+ if len(s) == 2:
+ if s[1] == "=":
+ # `-=` — row of `=` characters
+ return f''
+ # `-~`, `-*`, `-X`, etc. — styled divider; preserve the
+ # character so the renderer can repeat it across the row.
+ char_content = html.escape(s[1])
+ return f'
{char_content}
'
+ # `-` alone, or any other length (`--`, `---`, `-==`, …) —
+ # default thin solid rule (browser-default ).
+ return f''
# ---- Empty line ----
if not line.strip():
return ''
# ---- Regular text line ----
- # MeshChat parity: no line-level bg from a leading `B` token (its
- # parser doesn't do that). Bg only applies inside the explicit span.
+ # NomadNet: `B` only sets the colour state used to style each text
+ # part as it's emitted (see make_style() in MicronParser.py) — there's
+ # no concept of a leading `B` token filling the whole row. Bg only
+ # applies inside the explicit span.
inner = self._parse_inline(line, node_hash, base_path, authenticated, doc)
style_parts = []
@@ -396,16 +525,6 @@ def _parse_inline(self, text: str, node_hash: str, base_path: str,
while i < n:
ch = text[i]
- # ---- Inline literal mode: pass through until closing `= ----
- if state.literal:
- if ch == "`" and i + 1 < n and text[i + 1] == "=":
- state.literal = False
- i += 2
- else:
- out.append(html.escape(ch))
- i += 1
- continue
-
# ---- Backslash escape ----
if ch == "\\" and i + 1 < n:
out.append(html.escape(text[i + 1]))
@@ -429,33 +548,30 @@ def _parse_inline(self, text: str, node_hash: str, base_path: str,
# Bold (`!)
elif nc == "!":
if state.bold:
- out.append("")
- self._pop_tag(state, "strong")
+ self._close_innermost(state, "strong", out)
else:
out.append("")
- state.tag_stack.append(("strong", ""))
+ state.tag_stack.append(("strong", "", ""))
state.bold = not state.bold
i += 1
# Underline (`_)
elif nc == "_":
if state.underline:
- out.append("")
- self._pop_tag(state, "underline")
+ self._close_innermost(state, "underline", out)
else:
out.append('')
- state.tag_stack.append(("underline", ""))
+ state.tag_stack.append(("underline", '', ""))
state.underline = not state.underline
i += 1
# Italic (`*)
elif nc == "*":
if state.italic:
- out.append("")
- self._pop_tag(state, "em")
+ self._close_innermost(state, "em", out)
else:
out.append("")
- state.tag_stack.append(("em", ""))
+ state.tag_stack.append(("em", "", ""))
state.italic = not state.italic
i += 1
@@ -464,8 +580,9 @@ def _parse_inline(self, text: str, node_hash: str, base_path: str,
i += 1
color, i = self._parse_color(text, i, n)
if color:
- out.append(f'')
- state.tag_stack.append(("fg", ""))
+ open_html = f''
+ out.append(open_html)
+ state.tag_stack.append(("fg", open_html, ""))
# Reset foreground (`f)
elif nc == "f":
@@ -477,8 +594,9 @@ def _parse_inline(self, text: str, node_hash: str, base_path: str,
i += 1
color, i = self._parse_color(text, i, n)
if color:
- out.append(f'')
- state.tag_stack.append(("bg", ""))
+ open_html = f''
+ out.append(open_html)
+ state.tag_stack.append(("bg", open_html, ""))
# Reset background (`b)
elif nc == "b":
@@ -499,47 +617,77 @@ def _parse_inline(self, text: str, node_hash: str, base_path: str,
doc.align = ""
i += 1
- # Inline literal mode (`=)
- elif nc == "=":
- state.literal = True
- i += 1
-
# Link (`[label`URL`field1=v1`field2=v2…] or `[URL])
+ # NomadNet: `[label`url`fields] has at most 3 backtick-
+ # separated components (fields is itself pipe-separated for
+ # multiple values, e.g. `a=1|b=2`). More than 3 components
+ # renders nothing at all — MicronParser.py's link handler
+ # sets link_url = "" in that case, and its `if len(link_url)
+ # != 0:` guard skips emitting anything.
elif nc == "[":
i += 1 # past [
end = text.find("]", i)
if end != -1:
link_inner = text[i:end]
parts = link_inner.split("`")
- if len(parts) >= 2:
- lbl, url = parts[0], parts[1]
- # Preserve all backtick-separated field specs.
- # Earlier versions only took parts[2], silently
- # dropping every field after the first.
- fspec = "`".join(parts[2:]) if len(parts) > 2 else ""
+ if len(parts) == 1:
+ url, lbl, fspec = parts[0], "", ""
+ elif len(parts) == 2:
+ lbl, url, fspec = parts[0], parts[1], ""
+ elif len(parts) == 3:
+ lbl, url, fspec = parts[0], parts[1], parts[2]
else:
- url = parts[0]
- lbl = ""
- fspec = ""
- href = self._resolve_url(url, node_hash, base_path)
- display = html.escape(lbl) if lbl else html.escape(url)
- extra = (f' data-field-spec="{html.escape(fspec)}"'
- if fspec else "")
- out.append(
- f''
- f'{display}'
- )
+ lbl, url, fspec = "", "", ""
+ if url:
+ # `#`-prefixed URLs are page-local anchor jumps,
+ # not resolved through the normal URL resolver:
+ # named (`#name`) or "jump to the next heading
+ # after this point" (bare `#`).
+ if url == "#":
+ href = self._resolve_bare_hash_link(doc)
+ elif url.startswith("#"):
+ href = url
+ else:
+ href = self._resolve_url(url, node_hash, base_path)
+ display = html.escape(lbl) if lbl else html.escape(url)
+ extra = (f' data-field-spec="{html.escape(fspec)}"'
+ if fspec else "")
+ out.append(
+ f''
+ f'{display}'
+ )
i = end + 1
else:
out.append("[")
+ # Explicit anchor (`:name) — zero-width jump target.
+ # NomadNet: name chars are A-Za-z0-9_-, terminated by any
+ # other character. Shares the same claim/first-wins
+ # namespace as heading auto-anchors (_claim_anchor). Emitted
+ # as an empty inline at the exact point it
+ # appears — HTML fragment navigation resolves against any
+ # element with a matching id, not just block containers, so
+ # this needs no special-casing for multiple anchors on one
+ # line or an anchor alone on an otherwise-empty line.
+ elif nc == ":":
+ i += 1
+ start = i
+ while i < n and text[i] in _ANCHOR_NAME_CHARS:
+ i += 1
+ name = text[start:i]
+ claimed = self._claim_anchor(doc, name)
+ if claimed:
+ out.append(f'')
+
# Field (`)
- # MeshChat parity: a field requires a backtick between
- # ``. Without it, MeshChat's
- # parseField returns null and the `<` is silently eaten.
- # We mirror that exactly so checkbox/radio shorthand that
- # omits the backtick (`|name|value>`, `<^|name|value>`)
- # produces the same broken render in both renderers.
+ # NomadNet: a field requires a backtick between `` — MicronParser.py's field handler does
+ # `backtick_pos = line.find('`', field_start)` and gives up
+ # entirely if it's not found (`pass # No '`', invalid field`).
+ # We mirror that exactly, including for checkbox/radio
+ # shorthand that omits the backtick (`|name|value>`,
+ # `<^|name|value>`) — real NomadNet renders it as broken text
+ # too, not an input.
elif nc == "<":
field_start = i + 1
backtick_pos = text.find("`", field_start)
@@ -550,18 +698,30 @@ def _parse_inline(self, text: str, node_hash: str, base_path: str,
out.append(self._render_field(field_content, field_data, authenticated))
i = end + 1
else:
- # Malformed — eat the `<` silently, matching MeshChat.
+ # Malformed — eat the `<` silently, matching NomadNet.
i += 1
- # Dynamic include (`{URL`refresh})
+ # Partial (`{URL`refresh`fields})
+ # NomadNet's partials asynchronously load and (optionally)
+ # periodically re-fetch a fragment of another page in place —
+ # see Guide.py's "Partials" section. That's not something a
+ # one-shot markup->HTML conversion can reproduce without
+ # adding JS, so this renders a plain clickable link to the
+ # target URL instead. The `refresh` and `fields` (pipe-
+ # separated, may include `pid=`) components are exposed
+ # as data-refresh/data-fields/data-pid attributes so a
+ # consuming web app can wire up its own live-refresh
+ # behaviour if it wants to — no JS shipped here.
elif nc == "{":
end = text.find("}", i + 1)
if end != -1:
dyn_inner = text[i + 1:end]
- dyn_url = dyn_inner.split("`")[0].strip()
+ dyn_parts = dyn_inner.split("`")
+ dyn_url = dyn_parts[0].strip()
href = self._resolve_url(dyn_url, node_hash, base_path)
+ extra = self._render_partial_data_attrs(dyn_parts)
out.append(
- f'[live]'
+ f'[live]'
)
i = end + 1
else:
@@ -592,16 +752,21 @@ def _parse_color(self, text: str, i: int, n: int):
Format: 3 hex chars — each nibble doubled (f→ff, 8→88, 0→00).
- MeshChat parity:
+ NomadNet parity notes:
- Always consume the next 3 chars after F/B (if available),
- regardless of whether they're valid hex. This matches NomadNet's
- MicronParser and Liam Cottle's MicronParser.js, both of which
- do `line.substr(i+1,3)` + `skip = 3` unconditionally.
+ regardless of whether they're valid hex. Both MicronParser.py
+ (`line[i+1:i+4]` + `skip = 3`) and MeshChat's MicronParser.js
+ (`line.substr(i+1,3)` + `skip = 3`) do this unconditionally.
- If the 3 chars aren't valid hex, no colour is applied (the
colour-state holds the invalid string; rendering ignores it),
but the 3 chars are still consumed so they don't leak as text.
- - The 24-bit `T<6hex>` extension that Micron2HTML used to accept
- has been dropped — neither MeshChat nor NomadNet support it.
+ - NomadNet's *reference parser* also accepts a `T<6hex>` 24-bit
+ form (`FTrrggbb`) — but its own Guide.py never teaches this to
+ page authors, and MeshChat doesn't implement it either. Since
+ the point of this converter is to render what real `.mu` pages
+ actually contain, not every corner of one client's source, we
+ deliberately stay 3-hex-only: any real-world page relying on
+ `FT<6hex>` wouldn't render correctly in MeshChat anyway.
Returns (css_color_str | None, new_index).
"""
@@ -609,16 +774,26 @@ def _parse_color(self, text: str, i: int, n: int):
h3 = text[i:i + 3]
if all(c in _HEX for c in h3):
return "#" + "".join(c * 2 for c in h3).lower(), i + 3
- # Invalid hex — still consume the 3 chars to match MeshChat,
+ # Invalid hex — still consume the 3 chars to match NomadNet,
# but signal "no colour" so the caller doesn't open a span.
return None, i + 3
return None, i
def _parse_header_color(self, value: str) -> Optional[str]:
- """Parse a #!fg=X or #!bg=X color value. Returns CSS color or None.
-
- 3-hex only, matching the inline `Fxxx`/`Bxxx` colour format —
- each nibble is doubled (f -> ff, 8 -> 88, 0 -> 00).
+ """Parse a #!fg=X or #!bg=X page-header color value.
+
+ 3-hex only. Guide.py's "Page Foreground and Background Colors"
+ section doesn't actually state a length restriction for the header
+ value — it just gives a 3-hex example (`#!bg=444`) — and NomadNet's
+ colour-rendering helpers technically accept 6-hex strings too. We
+ deliberately restrict to 3-hex anyway, for the same reason as the
+ inline `Fxxx`/`Bxxx` tags (see `_parse_color`): there's no marker
+ distinguishing "3-hex" from "6-hex" the way the inline tags use a
+ `T` prefix, so allowing both here just means a mistyped or
+ ambiguous value silently changes meaning depending on its length.
+ One fixed width, applied consistently everywhere, is safer.
+
+ Returns CSS color string or None.
"""
v = value.strip()
if len(v) == 3 and all(c in _HEX for c in v):
@@ -629,11 +804,109 @@ def _resolve_url(self, url: str, node_hash: str, base_path: str) -> str:
"""Convert a Micron URL to an href via the configured resolver."""
return self._url_resolver(url, node_hash, base_path)
+ def _compute_next_heading_map(self, lines: list) -> list:
+ """For each line index, find the nearest heading strictly after it.
+
+ Powers the bare `[label`#] link ("jump to the next heading after
+ this point"). Returns a list parallel to `lines`, each entry either
+ the anchor slug of the nearest following heading or None.
+
+ Two passes: forward records which line has which heading's slug
+ (re-simulating first-wins collision handling locally, matching
+ `_claim_anchor`); backward fills each index from what's `upcoming`
+ *before* folding in that same line's own slug, so a heading never
+ targets itself.
+ """
+ n = len(lines)
+ slug_at = [None] * n
+ seen = set()
+ for k, raw in enumerate(lines):
+ if raw.startswith(">"):
+ _, heading_text = self._split_heading(raw)
+ if heading_text:
+ slug = slugify_micron(heading_text)
+ if slug and slug not in seen:
+ seen.add(slug)
+ slug_at[k] = slug
+
+ next_map = [None] * n
+ upcoming = None
+ for k in range(n - 1, -1, -1):
+ next_map[k] = upcoming
+ if slug_at[k] is not None:
+ upcoming = slug_at[k]
+ return next_map
+
+ def _resolve_bare_hash_link(self, doc: "_DocState") -> str:
+ """Resolve a bare `#` link to the next heading after this point.
+
+ Falls back to a harmless "#" when there's no following heading, or
+ no document context at all (e.g. convert_inline(), which never runs
+ the multi-line pre-pass so next_heading_map is empty).
+ """
+ if doc.next_heading_map and doc.line_index < len(doc.next_heading_map):
+ slug = doc.next_heading_map[doc.line_index]
+ if slug:
+ return f"#{slug}"
+ return "#"
+
+ def _split_heading(self, line: str) -> tuple:
+ """Split a `>`-prefixed line into (level, stripped heading text)."""
+ level = 0
+ while level < len(line) and line[level] == ">":
+ level += 1
+ return level, line[level:].strip()
+
+ def _claim_anchor(self, doc: "_DocState", name: str) -> Optional[str]:
+ """Claim an anchor name in the document's shared namespace.
+
+ Returns the name if it was successfully claimed (non-empty, not
+ already taken), else None. First declared wins — a later duplicate
+ (whether another heading's auto-slug or an explicit `:name) is
+ silently ignored, matching NomadNet's own anchor-collision rule.
+ """
+ if not name or name in doc.anchors:
+ return None
+ doc.anchors.add(name)
+ return name
+
+ def _render_partial_data_attrs(self, dyn_parts: list) -> str:
+ """Build data-refresh/data-fields/data-pid attributes for a partial.
+
+ `dyn_parts` is the backtick-split `{url`refresh`fields}` content.
+ Matches NomadNet's own parse_partial(): refresh is a float, and a
+ value < 1 (including 0 or unparseable) disables refresh entirely —
+ not just "any positive number". `fields` is pipe-separated; a
+ `pid=` entry is also surfaced as its own data-pid attribute,
+ mirroring NomadNet's special-casing of that one field.
+ """
+ attrs = []
+
+ if len(dyn_parts) > 1:
+ try:
+ refresh = float(dyn_parts[1])
+ except ValueError:
+ refresh = None
+ if refresh is not None and refresh >= 1:
+ attrs.append(f' data-refresh="{refresh}"')
+
+ if len(dyn_parts) > 2 and dyn_parts[2]:
+ fields = dyn_parts[2]
+ attrs.append(f' data-fields="{html.escape(fields)}"')
+ for f in fields.split("|"):
+ if f.startswith("pid="):
+ attrs.append(f' data-pid="{html.escape(f[len("pid="):])}"')
+ break
+
+ return "".join(attrs)
+
def _render_field(self, field_content: str, field_data: str,
authenticated: bool = False) -> str:
"""Render a Micron input field.
- Mirrors `parseField()` in liamcottle/reticulum-meshchat MicronParser.js.
+ Matches the field-parsing logic in NomadNet's own MicronParser.py
+ (the `elif c == '<':` branch), which liamcottle/reticulum-meshchat's
+ MicronParser.js independently arrives at the same behavior for.
Formats (the `\\`` is the required separator between flags|name and
default/label):
@@ -641,6 +914,13 @@ def _render_field(self, field_content: str, field_data: str,
checkbox : `[size]|field_name|value[|*]\\`label>`
radio : `<^[size]|field_name|value[|*]\\`label>`
+ Note: NomadNet's own Guide.py teaches a slightly different checkbox/
+ radio style — leave the field's own label empty (`` `|name|value`> ``)
+ and write the visible label as plain text after the `>`. Embedding
+ the label inside the field (as shown above) is equally valid — the
+ parser accepts non-empty `field_data` as the label either way — it's
+ just not the style the guide's own examples use.
+
`field_content` is everything between `<` and the backtick.
`field_data` is everything between the backtick and `>`.
"""
@@ -691,22 +971,185 @@ def _render_field(self, field_content: str, field_data: str,
f'value="{html.escape(field_data)}" '
f'size="{field_width}"{dis} class="mu-field">')
- def _pop_tag(self, state: _InlineState, tag_type: str) -> None:
- for j in range(len(state.tag_stack) - 1, -1, -1):
- if state.tag_stack[j][0] == tag_type:
- state.tag_stack.pop(j)
- return
-
def _close_innermost(self, state: _InlineState, tag_type: str, out: list) -> None:
+ """Close the named tag, wherever it sits in the stack.
+
+ HTML closing tags are positional/LIFO, so closing something that
+ isn't currently innermost can't just emit its close tag in place —
+ that would close whatever *is* innermost instead. Unwind: close
+ every entry above the target (innermost-first), close the target
+ itself, then reopen the unwound entries (in their original order)
+ as fresh elements and push them back. Same colour/bold/etc. either
+ side, just split across an extra pair of tags at the unwind point.
+ """
for j in range(len(state.tag_stack) - 1, -1, -1):
if state.tag_stack[j][0] == tag_type:
- out.append(state.tag_stack[j][1])
- state.tag_stack.pop(j)
+ above = state.tag_stack[j + 1:]
+ for _, _, close_html in reversed(above):
+ out.append(close_html)
+ out.append(state.tag_stack[j][2])
+ del state.tag_stack[j:]
+ for entry in above:
+ out.append(entry[1])
+ state.tag_stack.append(entry)
return
def _close_all(self, state: _InlineState) -> list:
- tags = [close for _, close in reversed(state.tag_stack)]
+ tags = [close for _, _, close in reversed(state.tag_stack)]
state.tag_stack.clear()
state.bold = state.italic = state.underline = False
- state.literal = False
return tags
+
+ # ------------------------------------------------------------------
+ # Table rendering
+ # ------------------------------------------------------------------
+
+ def _render_table(self, raw_lines: list, align: str, max_width: int,
+ node_hash: str, base_path: str, authenticated: bool,
+ doc: _DocState) -> Optional[str]:
+ """Render a buffered `t ... `t block as box-drawing ASCII art.
+
+ Ports NomadNet's own MarkdownToMicron.format_table_raw() algorithm
+ (from RNS's rngit util) so cell content, column widths, and
+ alignment match what real NomadNet would draw for the same
+ markdown-table input. Generated rows are fed back through
+ `_process_line`, so cell content like a colour token renders
+ correctly and a table nested under a section picks up its indent
+ for free — same as any other text line.
+ """
+ if len(raw_lines) < 2:
+ return None
+
+ header_cells = self._parse_table_row(raw_lines[0])
+ ncols = len(header_cells)
+ aligns = self._parse_table_alignments(self._parse_table_row(raw_lines[1]), ncols)
+
+ data_rows = []
+ for raw in raw_lines[2:]:
+ cells = self._parse_table_row(raw)
+ cells = (cells + [""] * ncols)[:ncols]
+ data_rows.append(cells)
+
+ col_widths = [_TABLE_MIN_COL_WIDTH] * ncols
+ for row in [header_cells] + data_rows:
+ for j, cell in enumerate(row):
+ col_widths[j] = max(col_widths[j], self._visible_width(cell))
+ col_widths = self._shrink_table_widths(col_widths, max_width)
+
+ lines = [self._table_border(col_widths, "top"),
+ self._table_row(header_cells, col_widths, ["left"] * ncols),
+ self._table_border(col_widths, "mid")]
+ for row in data_rows:
+ lines.append(self._table_row(row, col_widths, aligns))
+ lines.append(self._table_border(col_widths, "bottom"))
+
+ # Wrap the whole table in the requested alignment via a direct
+ # state assignment (not an injected `c/`a pseudo-line) — a bare
+ # alignment-only line produces no visible output of its own, which
+ # would just add stray empty rows above/below the table.
+ if align:
+ doc.align = {"l": "left", "c": "center", "r": "right"}[align]
+
+ rendered = []
+ for raw in lines:
+ out = self._process_line(raw, node_hash, base_path, authenticated, doc)
+ if out is not None:
+ rendered.append(out)
+
+ if align:
+ doc.align = ""
+ return f'
{"".join(rendered)}
'
+
+ def _parse_table_row(self, line: str) -> list:
+ """Split a markdown-table row into cells on unescaped `|`."""
+ line = line.strip()
+ if line.startswith("|"):
+ line = line[1:]
+ if line.endswith("|"):
+ line = line[:-1]
+
+ cells = []
+ current = []
+ escaped = False
+ for ch in line:
+ if escaped:
+ current.append(ch)
+ escaped = False
+ elif ch == "\\":
+ current.append(ch)
+ escaped = True
+ elif ch == "|":
+ cells.append("".join(current).strip())
+ current = []
+ else:
+ current.append(ch)
+ cells.append("".join(current).strip())
+ return cells
+
+ def _parse_table_alignments(self, cells: list, ncols: int) -> list:
+ """Parse a markdown separator row (`:---:`/`--:`/`---`) per column."""
+ aligns = []
+ for cell in cells:
+ c = cell.strip()
+ if c.startswith(":") and c.endswith(":"):
+ aligns.append("center")
+ elif c.endswith(":"):
+ aligns.append("right")
+ else:
+ aligns.append("left")
+ aligns += ["left"] * (ncols - len(aligns))
+ return aligns[:ncols]
+
+ def _visible_width(self, text: str) -> int:
+ """Character width of a cell, ignoring Micron formatting tokens.
+
+ NomadNet's own implementation also consults `wcwidth` for
+ double-width glyphs; deliberately not ported here (would add a
+ runtime dependency this pure-Python library has never had) —
+ documented simplification in the README.
+ """
+ return len(_MICRON_TOKEN_RE.sub("", text))
+
+ def _pad_cell(self, text: str, width: int, align: str) -> str:
+ """Pad (or, if needed, truncate) a cell to `width` visible columns."""
+ visible = self._visible_width(text)
+ if visible > width:
+ # Truncating mid-token could leave an unclosed span/strong tag —
+ # dropping formatting on truncation is strictly safer than that.
+ text = _MICRON_TOKEN_RE.sub("", text)[:width]
+ visible = len(text)
+ pad = width - visible
+ if align == "right":
+ return " " * pad + text
+ if align == "center":
+ left = pad // 2
+ return " " * left + text + " " * (pad - left)
+ return text + " " * pad
+
+ def _shrink_table_widths(self, col_widths: list, max_width: int) -> list:
+ """Greedily shrink the widest column until the table fits max_width.
+
+ Faithful-effort port of NomadNet's "proportionally shrink the
+ widest columns" — not a byte-for-byte match of its exact formula,
+ which isn't fully specified in the reference source.
+ """
+ widths = list(col_widths)
+ ncols = len(widths)
+ total = sum(widths) + ncols * 3 + 1
+ while total > max_width and max(widths) > _TABLE_MIN_COL_WIDTH:
+ j = widths.index(max(widths))
+ widths[j] -= 1
+ total -= 1
+ return widths
+
+ def _table_border(self, col_widths: list, kind: str) -> str:
+ left, mid, right = {
+ "top": (_TABLE_TL, _TABLE_TM, _TABLE_TR),
+ "mid": (_TABLE_ML, _TABLE_MM, _TABLE_MR),
+ "bottom": (_TABLE_BL, _TABLE_BM, _TABLE_BR),
+ }[kind]
+ return left + mid.join(_TABLE_H * (w + 2) for w in col_widths) + right
+
+ def _table_row(self, cells: list, col_widths: list, aligns: list) -> str:
+ padded = [self._pad_cell(c, w, a) for c, w, a in zip(cells, col_widths, aligns)]
+ return _TABLE_V + " " + f" {_TABLE_V} ".join(padded) + " " + _TABLE_V
diff --git a/examples/showcase.mu b/examples/showcase.mu
index 006d8bf..8844418 100644
--- a/examples/showcase.mu
+++ b/examples/showcase.mu
@@ -57,6 +57,19 @@ URL only: `[`https://example.com]
-~
+>Anchors
+
+`[Jump to the anchor below`#custom-anchor]
+`[Jump to the next heading`#]
+
+`:custom-anchor
+This paragraph is a jump target declared with an explicit anchor tag.
+
+Every heading (including this one) is also an anchor automatically — its
+slug is derived from the heading text.
+
+-~
+
>Literal block
`=
@@ -67,12 +80,23 @@ URL only: `[`https://example.com]
-~
+>Tables
+
+`t
+| Name | Price | Qty |
+| ---- | :---: | --: |
+| `F3a3Apple`f | Free | `!5`! |
+| Orange | Ask, nicely | 3 |
+`t
+
+-~
+
>Form fields
Username: `<20|username`>
Password: `<20!|password`>
- Subscribe: `|subscribe|yes|*> Yes, sign me up
- Plan: `<^|plan|free|*> Free `<^|plan|pro> Pro
+ Subscribe: `|subscribe|yes|*`Yes, sign me up>
+ Plan: `<^|plan|free|*`Free> `<^|plan|pro`Pro>
-=
diff --git a/micron-meshchat.css b/micron-meshchat.css
index e206a31..8283897 100644
--- a/micron-meshchat.css
+++ b/micron-meshchat.css
@@ -197,6 +197,23 @@ strong {
font-style: italic;
}
+/* ---- Explicit anchors (`:name) -----------------------------------------
+ * Zero-width jump target — defensive-only, an empty shouldn't take
+ * width by default, but this is cheap insurance against monospace
+ * column-alignment drift from any browser quirk. */
+.mu-anchor {
+ display: inline-block;
+ width: 0;
+ height: 0;
+ overflow: hidden;
+}
+
+/* ---- Tables (`t ... `t) -------------------------------------------------
+ * Rendered as box-drawing-character ASCII art, same as real NomadNet — the
+ * horizontal alignment is already baked into the padded cell text itself,
+ * so this class is just a marker/CSS hook, not load-bearing for layout. */
+.mu-table { display: block; }
+
/* ---- Literal blocks (`= ... `=) --------------------------------------
* Plain pre-styled text — no border, no bg, no padding. MeshChat doesn't
* box literal content; it flows inline at the same font/colour as the
diff --git a/pyproject.toml b/pyproject.toml
index 61417be..2c7bdf0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "Micron2HTML"
-version = "1.0.8"
+version = "1.1.0"
description = "Convert Micron markup (NomadNet) to HTML"
readme = "README.md"
license = { text = "MIT" }
diff --git a/tests/test_converter.py b/tests/test_converter.py
index 6e730d4..deb19eb 100644
--- a/tests/test_converter.py
+++ b/tests/test_converter.py
@@ -43,7 +43,7 @@ def test_h3(self, conv):
assert 'class="mu-h3"' in out
def test_heading_level_4_falls_back_to_mu_line(self, conv):
- # MeshChat parity: only heading1/2/3 styles exist; deeper levels
+ # NomadNet: only heading1/2/3 styles exist; deeper levels
# fall back to "plain" rendering (no bg block).
out = conv.convert(">>>> Level 4")
assert 'mu-line' in out
@@ -55,11 +55,12 @@ def test_heading_content_parsed(self, conv):
assert 'class="mu-h1"' in out
assert "" in out
- def test_empty_heading_emits_blank_line(self, conv):
- # MeshChat parity: empty heading produces a blank row (its parseLine
- # returns null and the outer loop appends a ).
+ def test_empty_heading_emits_nothing(self, conv):
+ # NomadNet: parse_line() returns None for an empty heading — no
+ # row, no blank space.
out = conv.convert(">")
- assert 'mu-blank' in out
+ assert 'mu-blank' not in out
+ assert out.strip() == ""
# ---------------------------------------------------------------------------
@@ -80,8 +81,26 @@ def test_double_equals(self, conv):
out = conv.convert("-=")
assert "mu-hr-double" in out
+ def test_triple_dash_falls_back_to_default_rule(self, conv):
+ # NomadNet only honours a custom divider character when the line is
+ # exactly "-" + one more character (MicronParser.py's parse_line:
+ # `if len(line) == 2`). "---" is length 3, so it falls back to the
+ # default rule rather than being treated as a "-"-repeat divider.
+ out = conv.convert("---")
+ assert "mu-divider" not in out
+ assert "mu-hr-double" not in out
+ assert "" not in out
def test_unknown_token_eaten_silently(self, conv):
- # MeshChat parity: backtick + unknown char are silently consumed
- # (NomadNet's `default: break;` behaviour). The rendered text
- # content should have "foo bar" (with the `> consumed) and no
- # leftover backtick or > glyph.
+ # NomadNet: an unrecognized char after a backtick falls through
+ # make_output()'s if/elif chain with no output — same outcome as
+ # MeshChat's `default: break;`. The rendered text content should
+ # have "foo bar" (with the `> consumed) and no leftover backtick
+ # or > glyph.
import re
out = conv.convert("foo `> bar")
# strip all HTML tags to get just the rendered text
@@ -143,6 +163,63 @@ def test_unknown_token_eaten_silently(self, conv):
assert ">" not in text
+# ---------------------------------------------------------------------------
+# Tag nesting (out-of-order close / unwind-reopen)
+#
+# HTML closing tags are positional/LIFO. Closing a non-innermost open tag
+# (e.g. `b while `F is still open) can't just emit its close tag in place —
+# that would close whatever's actually innermost instead. These tests lock
+# in the unwind (close innermost-first down to the target, then reopen the
+# unwound tags) that keeps HTML nesting valid regardless of stack order.
+# ---------------------------------------------------------------------------
+
+class TestTagNesting:
+ def _assert_balanced(self, out):
+ import re
+ stack = []
+ for m in re.finditer(r'?(strong|em|span)\b[^>]*>', out):
+ tag = m.group(0)
+ if tag.startswith(""):
+ name = re.match(r'(\w+)', tag).group(1)
+ assert stack and stack[-1] == name, f"mismatched close {tag!r} in {out!r}"
+ stack.pop()
+ else:
+ stack.append(re.match(r'<(\w+)', tag).group(1))
+ assert not stack, f"unclosed tags {stack} in {out!r}"
+
+ def test_bg_close_while_fg_still_open_unwinds_correctly(self, conv):
+ # Regression: `b closing "bg" while a later-opened "fg" span is
+ # still on top of the stack must close fg first, then bg, then
+ # reopen fg — not just emit in place (which would close fg,
+ # not bg, in the real DOM).
+ out = conv.convert("`B777 X`f `F975`b <>`F333", authenticated=True)
+ self._assert_balanced(out)
+ # bg's own close must appear before "<>" is reached, not float to EOL
+ bg_close_pos = out.index("") # fg empty-close + bg close
+ after_pos = out.index("<>")
+ assert bg_close_pos < after_pos
+ # the reopened fg span must actually wrap "<>" so it's still coloured
+ assert ' <>' in out
+
+ def test_bold_close_while_fg_still_open_unwinds_correctly(self, conv):
+ out = conv.convert("`!X`F975 Y`!Z`f", authenticated=True)
+ self._assert_balanced(out)
+ # bold must close before "Z" is emitted — not swallow it.
+ assert out.index("") < out.index("Z")
+
+ def test_triple_nested_outermost_close_unwinds_two_levels(self, conv):
+ out = conv.convert("`!`_`*deep`!more`_more2`*end", authenticated=True)
+ self._assert_balanced(out)
+ assert "deep" in out
+
+ def test_isolated_close_without_interleaving_still_works(self, conv):
+ # Baseline: closing the genuinely-innermost tag needs no unwind at
+ # all — must keep working exactly as before.
+ out = conv.convert("`B777 X`f`b <>", authenticated=True)
+ self._assert_balanced(out)
+ assert out.count("") == 1
+
+
# ---------------------------------------------------------------------------
# Colors
# ---------------------------------------------------------------------------
@@ -158,17 +235,19 @@ def test_bg_3digit(self, conv):
assert "background-color:#333333" in out
def test_invalid_hex_consumes_three_chars(self, conv):
- # MeshChat parity: F/B always consume 3 chars after the prefix
- # regardless of validity. Invalid hex applies no colour but still
- # eats the 3 chars so they don't leak as text.
+ # NomadNet: F/B always consume 3 chars after the prefix regardless
+ # of validity. Invalid hex applies no colour but still eats the
+ # 3 chars so they don't leak as text.
out = conv.convert("`Fxxx hello`f")
assert "xxx" not in out
assert "hello" in out
def test_24bit_T_format_not_supported(self, conv):
- # MeshChat doesn't support `FT<6hex>` 24-bit colour. v1.0.2 drops it
- # for parity. The `T` plus 2 next chars are consumed as a (failed)
- # 3-char hex, leaving the tail as visible text.
+ # NomadNet's own reference parser DOES accept `FT<6hex>`, but its
+ # Guide.py never teaches it and MeshChat doesn't implement it — we
+ # stay 3-hex-only to match what real page authors actually write.
+ # The `T` plus 2 next chars are consumed as a (failed) 3-char hex,
+ # leaving the tail as visible text.
out = conv.convert("`FT8b4513 brown`f")
assert "color:#8b4513" not in out
assert "color:#" not in out # no colour applied
@@ -180,15 +259,18 @@ def test_header_fg_bg_3digit(self, conv):
assert "color:#aaaaaa" in out
def test_header_fg_bg_6digit_not_supported(self, conv):
- # Page-level headers use the same 3-hex shorthand as inline colours —
- # 6-hex values are invalid and silently produce no colour.
+ # Deliberately 3-hex-only, same as the inline colour tags: with no
+ # marker distinguishing 3-hex from 6-hex, allowing both would make
+ # a value's meaning depend silently on its length. NomadNet's own
+ # docs would technically permit 6-hex here, but we don't chase it.
out = conv.convert("#!bg=112233\n#!fg=aabbcc\nhello")
assert "background-color" not in out
assert "color:#" not in out
def test_line_level_bg_not_applied_to_div(self, conv):
- # MeshChat parity: a leading B token doesn't fill the entire line —
- # only the explicit span gets the background.
+ # NomadNet: `B` only sets colour state used per text part as it's
+ # emitted — a leading B token doesn't fill the entire line, only
+ # the explicit span gets the background.
out = conv.convert("`B400 red `b end")
# the .mu-line wrapper itself should NOT carry background-color
assert 'class="mu-line"' in out
@@ -278,15 +360,238 @@ def test_link_single_field_spec(self, conv):
)
assert 'data-field-spec="action=register"' in out
- def test_link_multi_field_spec_preserves_all(self, conv):
- # Regression: earlier versions only captured parts[2] and dropped
- # every field after the first. The full backtick-separated spec
- # must be preserved so the renderer can forward each key=value.
+ def test_link_multi_field_spec_uses_pipes_not_extra_backticks(self, conv):
+ # NomadNet-native form: multiple fields go in ONE backtick-delimited
+ # segment, pipe-separated.
+ out = conv.convert(
+ "`[Go`:/page/x.mu`a=1|b=2|c=3]",
+ node_hash="deadbeef",
+ )
+ assert 'data-field-spec="a=1|b=2|c=3"' in out
+
+ def test_link_with_more_than_three_backtick_segments_renders_nothing(self, conv):
+ # NomadNet: more than 3 backtick-separated components makes the
+ # whole link vanish (link_url = ""), not a leniently-joined spec.
+ # This reverses a v1.0.3 fix that was solving a MeshChat-specific
+ # leniency, not real NomadNet behavior.
out = conv.convert(
"`[Go`:/page/x.mu`a=1`b=2`c=3]",
node_hash="deadbeef",
)
- assert 'data-field-spec="a=1`b=2`c=3"' in out
+ assert " Hello World")
+ assert 'id="hello-world"' in out
+
+ def test_heading_auto_anchor_strips_formatting_tokens(self, conv):
+ out = conv.convert("> `!Bold`! Heading")
+ assert 'id="bold-heading"' in out
+
+ def test_heading_auto_anchor_ampersand_and_punctuation(self, conv):
+ out = conv.convert("> Introduction & Setup")
+ assert 'id="introduction-setup"' in out
+
+ def test_heading_level_4_also_gets_auto_anchor(self, conv):
+ out = conv.convert(">>>> Deep Heading")
+ assert 'id="deep-heading"' in out
+
+ def test_heading_auto_anchor_collision_first_wins(self, conv):
+ out = conv.convert("> Same\n> Same")
+ assert out.count('id="same"') == 1
+
+ def test_empty_heading_no_anchor(self, conv):
+ out = conv.convert("> ")
+ assert 'id="' not in out
+
+
+class TestExplicitAnchors:
+ def test_explicit_anchor_zero_width(self, conv):
+ out = conv.convert("`:mark hello")
+ assert 'id="mark"' in out
+ assert 'class="mu-anchor"' in out
+ assert "hello" in out
+
+ def test_explicit_anchor_name_terminates_at_delimiter(self, conv):
+ out = conv.convert("`:foo-bar baz")
+ assert 'id="foo-bar"' in out
+
+ def test_explicit_anchor_alone_on_empty_line(self, conv):
+ out = conv.convert("`:onlyanchor")
+ assert 'id="onlyanchor"' in out
+
+ def test_explicit_and_heading_anchors_share_namespace(self, conv):
+ out = conv.convert("`:shared marker\n> Shared")
+ assert out.count('id="shared"') == 1
+
+ def test_multiple_explicit_anchors_on_one_line(self, conv):
+ out = conv.convert("`:first x `:second y")
+ assert 'id="first"' in out
+ assert 'id="second"' in out
+
+ def test_explicit_anchor_name_stops_before_special_chars(self, conv):
+ out = conv.convert("`:foo")
+ assert 'id="foo"' in out
+ assert "