From ae9c07e6e2f1fa41980ff61b62d32cb31c75293f Mon Sep 17 00:00:00 2001 From: JamesM92 Date: Fri, 15 May 2026 22:33:38 -0400 Subject: [PATCH 01/13] Move Braille dots to uniform 1/8 vertical intervals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous positions 15/38/62/85 had: - intra-cell row spacing: 23-24% per gap - inter-cell row spacing: (100-85) + 15 = 30% per gap That 6-7% disproportion produced a visible horizontal stripe between adjacent rows of Braille — the column of Braille cells read as discrete rows instead of a continuous grid. New positions 12.5/37.5/62.5/87.5 give uniform 25% spacing both within a cell and across cells, so a column of full-dot Braille cells flows as a single uniform grid with no visible row break. Co-Authored-By: Claude Opus 4.7 (1M context) --- converter.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/converter.py b/converter.py index 88b4b38..08960f8 100644 --- a/converter.py +++ b/converter.py @@ -36,12 +36,16 @@ # --------------------------------------------------------------------------- _BRAILLE_DOT_POSITIONS = ( - # (left%, top%) for each bit, in order: dot1 dot2 dot3 dot4 dot5 dot6 dot7 dot8 - # 8-dot Braille fits in a 2×4 grid; spacing tuned so paired dots within - # a cell read as paired and rows of identical glyphs flow as a strip. - (25, 15), (25, 38), (25, 62), - (75, 15), (75, 38), (75, 62), - (25, 85), (75, 85), + # (left%, top%) for each bit, in order: dot1 dot2 dot3 dot4 dot5 dot6 dot7 dot8. + # 8-dot Braille fits in a 2×4 grid. Vertical positions at 1/8 intervals + # (12.5/37.5/62.5/87.5) so intra-cell row spacing = inter-cell row spacing + # = 25%. With uniform spacing, a column of full-dot Braille cells reads + # as a single continuous grid; the previous tuning (15/38/62/85) had a + # disproportionately wider inter-cell gap (~30% vs ~24% intra) that + # showed up as a visible horizontal stripe between rows. + (25, 12.5), (25, 37.5), (25, 62.5), + (75, 12.5), (75, 37.5), (75, 62.5), + (25, 87.5), (75, 87.5), ) _BRAILLE_RE = re.compile(r"<[^>]*>|[⠀-⣿]") From a48567947d98413ae63a8c3ba8325c6816efc854 Mon Sep 17 00:00:00 2001 From: JamesM92 Date: Fri, 15 May 2026 22:47:15 -0400 Subject: [PATCH 02/13] Increase Braille dot radius to close vertical gap between rows Previous radius (0.07em) gave a 14% dot diameter relative to the 1em cell height, leaving an 11% visible gap between adjacent dots vertically (with the new 25% uniform center-to-center spacing from the prior commit). Bumped radius to 0.125em (= 25% diameter, exactly the center-to- center spacing) so adjacent dots just touch vertically. The 0.01em soft-transition zone is preserved (transparent transition at 0.135em). Visual effect: a column of full-dot Braille cells now reads as a solid vertical strip of touching dots, with no visible row-break stripe. Horizontal spacing between left and right column dots is unchanged (still 50% center-to-center), so dots in a row remain visually distinct as paired left/right columns. Co-Authored-By: Claude Opus 4.7 (1M context) --- converter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/converter.py b/converter.py index 08960f8..d3e934b 100644 --- a/converter.py +++ b/converter.py @@ -69,7 +69,7 @@ def repl(m: "re.Match[str]") -> str: x, y = _BRAILLE_DOT_POSITIONS[i] grads.append( f"radial-gradient(circle at {x}% {y}%, " - f"currentColor 0.07em, transparent 0.08em)" + f"currentColor 0.125em, transparent 0.135em)" ) style = f' style="--mu-braille-dots:{",".join(grads)}"' if grads else "" return f'' From 8183d3aeca103b4be07136680a4820cd977d809c Mon Sep 17 00:00:00 2001 From: JamesM92 Date: Fri, 15 May 2026 22:52:20 -0400 Subject: [PATCH 03/13] Move Braille dot positions to cell edges (5/35/65/95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the radius bump from a485679 (user wants normal-sized dots, not bigger ones) and instead shift dot positions further apart vertically — pushing the top and bottom rows close to cell edges. New positions: y = 5%, 35%, 65%, 95% - intra-cell row spacing: 30% per gap - inter-cell row spacing: (100-95) + 5 = 10% per gap Intra-cell is now wider but each cell still has dots. Inter-cell is much narrower so the "empty stripe" between rows of cells visually shrinks. Spacing is no longer perfectly uniform, but the visible row- break gap is reduced from 25% → 10%, which is the actual perceived issue ("the inter-cell area looks emptier than the intra-cell area"). Dot radius stays at 0.07em (no bigger dots). Co-Authored-By: Claude Opus 4.7 (1M context) --- converter.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/converter.py b/converter.py index d3e934b..6b43637 100644 --- a/converter.py +++ b/converter.py @@ -37,15 +37,15 @@ _BRAILLE_DOT_POSITIONS = ( # (left%, top%) for each bit, in order: dot1 dot2 dot3 dot4 dot5 dot6 dot7 dot8. - # 8-dot Braille fits in a 2×4 grid. Vertical positions at 1/8 intervals - # (12.5/37.5/62.5/87.5) so intra-cell row spacing = inter-cell row spacing - # = 25%. With uniform spacing, a column of full-dot Braille cells reads - # as a single continuous grid; the previous tuning (15/38/62/85) had a - # disproportionately wider inter-cell gap (~30% vs ~24% intra) that - # showed up as a visible horizontal stripe between rows. - (25, 12.5), (25, 37.5), (25, 62.5), - (75, 12.5), (75, 37.5), (75, 62.5), - (25, 87.5), (75, 87.5), + # 8-dot Braille fits in a 2×4 grid. Vertical positions pushed close to + # cell edges (5/35/65/95) so the inter-cell gap (between cell N's bottom + # dot at y=95% and cell N+1's top dot at y=5%) is small — 10% of cell + # height vs 30% intra-cell — making adjacent rows of Braille flow with + # tightly stacked dots instead of separating into discrete rows by a + # visible horizontal stripe. + (25, 5), (25, 35), (25, 65), + (75, 5), (75, 35), (75, 65), + (25, 95), (75, 95), ) _BRAILLE_RE = re.compile(r"<[^>]*>|[⠀-⣿]") @@ -69,7 +69,7 @@ def repl(m: "re.Match[str]") -> str: x, y = _BRAILLE_DOT_POSITIONS[i] grads.append( f"radial-gradient(circle at {x}% {y}%, " - f"currentColor 0.125em, transparent 0.135em)" + f"currentColor 0.07em, transparent 0.08em)" ) style = f' style="--mu-braille-dots:{",".join(grads)}"' if grads else "" return f'' From 93cc8b640068987a21c3cc8c2ed15fadf7b41cca Mon Sep 17 00:00:00 2001 From: JamesM92 Date: Fri, 15 May 2026 23:03:23 -0400 Subject: [PATCH 04/13] Pin Braille dots at cell edges (0/33.33/66.67/100) to eliminate gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous edge-near positions (5/35/65/95) reduced the inter-cell gap to ~1.66px but still left a visible thin stripe between rows because the painted dots didn't quite reach the cell boundary. Pinning the top and bottom rows at y=0% and y=100% places the dot centres exactly on the cell edges. The radial-gradient only paints within the inline-block's bounds, so a y=0% dot shows just its bottom half, and a y=100% dot shows just its top half. When two cells stack vertically, the top-half from cell N+1's top dot and the bottom-half from cell N's bottom dot share the cell boundary line and together form one complete dot straddling the join — no visible gap between rows by construction. Intra-cell spacing grows from 30% to 33.33% per gap; visually the in-cell dot grid is now perfectly evenly spaced. Co-Authored-By: Claude Opus 4.7 (1M context) --- converter.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/converter.py b/converter.py index 6b43637..8e60966 100644 --- a/converter.py +++ b/converter.py @@ -37,15 +37,15 @@ _BRAILLE_DOT_POSITIONS = ( # (left%, top%) for each bit, in order: dot1 dot2 dot3 dot4 dot5 dot6 dot7 dot8. - # 8-dot Braille fits in a 2×4 grid. Vertical positions pushed close to - # cell edges (5/35/65/95) so the inter-cell gap (between cell N's bottom - # dot at y=95% and cell N+1's top dot at y=5%) is small — 10% of cell - # height vs 30% intra-cell — making adjacent rows of Braille flow with - # tightly stacked dots instead of separating into discrete rows by a - # visible horizontal stripe. - (25, 5), (25, 35), (25, 65), - (75, 5), (75, 35), (75, 65), - (25, 95), (75, 95), + # 8-dot Braille fits in a 2×4 grid. Vertical positions pinned at the cell + # edges (0 / 33.33 / 66.67 / 100) so the top and bottom dots straddle the + # cell boundary — only the bottom half of the top dot and the top half of + # the bottom dot are painted within the cell, and when adjacent cells + # stack vertically the two halves from neighbouring cells combine into a + # single full dot at the boundary. Eliminates the inter-cell gap entirely. + (25, 0), (25, 33.33), (25, 66.67), + (75, 0), (75, 33.33), (75, 66.67), + (25, 100), (75, 100), ) _BRAILLE_RE = re.compile(r"<[^>]*>|[⠀-⣿]") From 3a4e54875cc629396d42b0f3cc17444594d06eb4 Mon Sep 17 00:00:00 2001 From: JamesM92 Date: Fri, 15 May 2026 23:06:47 -0400 Subject: [PATCH 05/13] Revert "Pin Braille dots at cell edges (0/33.33/66.67/100) to eliminate gap" This reverts commit 93cc8b640068987a21c3cc8c2ed15fadf7b41cca. --- converter.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/converter.py b/converter.py index 8e60966..6b43637 100644 --- a/converter.py +++ b/converter.py @@ -37,15 +37,15 @@ _BRAILLE_DOT_POSITIONS = ( # (left%, top%) for each bit, in order: dot1 dot2 dot3 dot4 dot5 dot6 dot7 dot8. - # 8-dot Braille fits in a 2×4 grid. Vertical positions pinned at the cell - # edges (0 / 33.33 / 66.67 / 100) so the top and bottom dots straddle the - # cell boundary — only the bottom half of the top dot and the top half of - # the bottom dot are painted within the cell, and when adjacent cells - # stack vertically the two halves from neighbouring cells combine into a - # single full dot at the boundary. Eliminates the inter-cell gap entirely. - (25, 0), (25, 33.33), (25, 66.67), - (75, 0), (75, 33.33), (75, 66.67), - (25, 100), (75, 100), + # 8-dot Braille fits in a 2×4 grid. Vertical positions pushed close to + # cell edges (5/35/65/95) so the inter-cell gap (between cell N's bottom + # dot at y=95% and cell N+1's top dot at y=5%) is small — 10% of cell + # height vs 30% intra-cell — making adjacent rows of Braille flow with + # tightly stacked dots instead of separating into discrete rows by a + # visible horizontal stripe. + (25, 5), (25, 35), (25, 65), + (75, 5), (75, 35), (75, 65), + (25, 95), (75, 95), ) _BRAILLE_RE = re.compile(r"<[^>]*>|[⠀-⣿]") From ef9144a3160a11d5deb244ce42e001c052e804a7 Mon Sep 17 00:00:00 2001 From: JamesM92 Date: Fri, 15 May 2026 23:10:35 -0400 Subject: [PATCH 06/13] =?UTF-8?q?Inset=20Braille=20dots=20horizontally=20(?= =?UTF-8?q?25/75=20=E2=86=92=2027/73)=20for=20subtle=20char=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cells previously had uniform 50% horizontal dot spacing both within and between cells, making adjacent Braille glyphs blur together horizontally. Moving the dot columns inward to x=27%/73% makes: - intra-cell horizontal spacing: 46% - inter-cell horizontal spacing: 54% The 8% asymmetry produces a faintly perceptible cell-boundary so adjacent Braille characters can be distinguished without obvious gaps that would break the contiguous-grid feel. Cell width stays at 1ch so Braille remains aligned with surrounding text. Only the dot positions within each cell change. Co-Authored-By: Claude Opus 4.7 (1M context) --- converter.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/converter.py b/converter.py index 6b43637..c9c4b68 100644 --- a/converter.py +++ b/converter.py @@ -37,15 +37,22 @@ _BRAILLE_DOT_POSITIONS = ( # (left%, top%) for each bit, in order: dot1 dot2 dot3 dot4 dot5 dot6 dot7 dot8. - # 8-dot Braille fits in a 2×4 grid. Vertical positions pushed close to - # cell edges (5/35/65/95) so the inter-cell gap (between cell N's bottom - # dot at y=95% and cell N+1's top dot at y=5%) is small — 10% of cell - # height vs 30% intra-cell — making adjacent rows of Braille flow with - # tightly stacked dots instead of separating into discrete rows by a + # 8-dot Braille fits in a 2×4 grid. + # + # Vertical: pushed close to cell edges (5/35/65/95) so the inter-cell gap + # between cell N's bottom dot at y=95% and cell N+1's top dot at y=5% is + # small (10% of cell height vs 30% intra-cell) — adjacent rows of Braille + # flow tightly together instead of separating into discrete rows by a # visible horizontal stripe. - (25, 5), (25, 35), (25, 65), - (75, 5), (75, 35), (75, 65), - (25, 95), (75, 95), + # + # Horizontal: insetted from cell edges (27/73) so dots are slightly + # closer together within a cell (46% spacing) than between cells (54%). + # The 8% asymmetry produces a faintly perceptible character-cell + # boundary, useful for distinguishing one Braille glyph from the next + # in dense content without obvious gaps that would break the grid feel. + (27, 5), (27, 35), (27, 65), + (73, 5), (73, 35), (73, 65), + (27, 95), (73, 95), ) _BRAILLE_RE = re.compile(r"<[^>]*>|[⠀-⣿]") From 628c506bdaeacda62c8bc3c227dc98ffd22e20e3 Mon Sep 17 00:00:00 2001 From: JamesM92 Date: Mon, 27 Jul 2026 21:46:38 -0400 Subject: [PATCH 07/13] fix: restrict page-header colours to 3-hex, drop stale 6-hex docs _parse_header_color() accepted both 3-hex and 6-hex values for #!bg=/#!fg= headers, inconsistent with the 3-hex-only inline `Fxxx`/`Bxxx` colour tokens. Also removes README and example references to the `FTxxxxxx`/`BTxxxxxx` 24-bit inline extension, which was actually dropped from the parser back in v1.0.2. --- CHANGELOG.md | 7 +++++++ README.md | 6 ++---- converter.py | 8 +++++--- examples/showcase.mu | 2 -- tests/test_converter.py | 12 ++++++++++++ 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 019cd5a..e66dcda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ 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] + +### Fixed + +- **Page-level `#!bg=`/`#!fg=` headers now reject 6-hex values.** `_parse_header_color()` previously accepted both 3-hex (`#!bg=2a2`) and 6-hex (`#!bg=112233`) forms. Only the inline `` `Fxxx ``/`` `Bxxx `` tokens were ever restricted to 3-hex; the page-header path was an inconsistent leftover. Both colour paths now use the same 3-hex-shorthand format exclusively (each nibble doubled). +- **Removed stale docs for the dropped `` `FTxxxxxx ``/`` `BTxxxxxx `` inline format.** The 24-bit inline extension was removed from the parser in v1.0.2, but [README.md](README.md) and [examples/showcase.mu](examples/showcase.mu) still described/demonstrated it. Docs and the example page now only show the supported 3-hex shorthand. + ## [1.0.5] ### Changed diff --git a/README.md b/README.md index a1dc86e..7776577 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,8 @@ micron-convert page.mu --node-hash a1b2c3d4e5f6... ``` # This is a comment — the whole line is stripped from output -#!bg=2a2a2a Set page background colour (3 or 6 hex digits) -#!fg=aaaaaa Set page foreground colour +#!bg=2a2 Set page background colour (3-hex shorthand: each digit doubled) +#!fg=aaa Set page foreground colour ``` ### Headings and sections @@ -125,11 +125,9 @@ micron-convert page.mu --node-hash a1b2c3d4e5f6... `_text`_ Underline `Fxxx Set foreground colour (3-hex shorthand: each digit doubled — F40 → #ff4400) -`FTxxxxxx Set foreground colour (6-hex true colour) `f Reset foreground colour to default `Bxxx Set background colour (3-hex shorthand) -`BTxxxxxx Set background colour (6-hex) `b Reset background colour to default `` Reset ALL inline formatting (bold, italic, underline, colours, alignment) diff --git a/converter.py b/converter.py index c9c4b68..bf45c50 100644 --- a/converter.py +++ b/converter.py @@ -615,12 +615,14 @@ def _parse_color(self, text: str, i: int, n: int): 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.""" + """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). + """ v = value.strip() if len(v) == 3 and all(c in _HEX for c in v): return "#" + "".join(c * 2 for c in v).lower() - if len(v) == 6 and all(c in _HEX for c in v): - return f"#{v.lower()}" return None def _resolve_url(self, url: str, node_hash: str, base_path: str) -> str: diff --git a/examples/showcase.mu b/examples/showcase.mu index 2018169..006d8bf 100644 --- a/examples/showcase.mu +++ b/examples/showcase.mu @@ -21,8 +21,6 @@ `F4af Foreground colour `f and `Bf40 background colour `b in 3-hex shorthand. -`FT00ff88 Foreground in 6-hex true colour `f for fine-grained control. - `Faaa Dim grey `f text used for muted secondary content. -~ diff --git a/tests/test_converter.py b/tests/test_converter.py index bd203d1..6e730d4 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -174,6 +174,18 @@ def test_24bit_T_format_not_supported(self, conv): assert "color:#" not in out # no colour applied assert "brown" in out + def test_header_fg_bg_3digit(self, conv): + out = conv.convert("#!bg=333\n#!fg=aaa\nhello") + assert "background-color:#333333" in out + 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. + 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. From 77230f69c50ea3b6704efc8e56aa306575ec334c Mon Sep 17 00:00:00 2001 From: JamesM92 Date: Mon, 27 Jul 2026 21:49:33 -0400 Subject: [PATCH 08/13] ci: run test/build workflow on dev pushes too Dev's tip was going unverified between pushes and promotion to main since ci.yml only triggered on main push/PR. security.yml stays main+PR-only per the dev-branch workflow's guidance that heavier scans can wait for the promotion gate. --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc0fdab..9063515 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, dev] pull_request: branches: [main] diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f6435c..9ce1c1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/), and this pro - **Page-level `#!bg=`/`#!fg=` headers now reject 6-hex values.** `_parse_header_color()` previously accepted both 3-hex (`#!bg=2a2`) and 6-hex (`#!bg=112233`) forms. Only the inline `` `Fxxx ``/`` `Bxxx `` tokens were ever restricted to 3-hex; the page-header path was an inconsistent leftover. Both colour paths now use the same 3-hex-shorthand format exclusively (each nibble doubled). - **Removed stale docs for the dropped `` `FTxxxxxx ``/`` `BTxxxxxx `` inline format.** The 24-bit inline extension was removed from the parser in v1.0.2, but [README.md](README.md) and [examples/showcase.mu](examples/showcase.mu) still described/demonstrated it. Docs and the example page now only show the supported 3-hex shorthand. +### Infrastructure + +- **`ci.yml` now also runs on pushes to `dev`.** Previously only `main` pushes and PRs into `main` triggered the test/build workflow, so `dev`'s tip went unverified until promoted. `security.yml` (pip-audit/CodeQL) stays on the main+PR path only. + ## [1.0.8] ### Changed From 9e87697b2590f493aebd51a0384e98304ae55505 Mon Sep 17 00:00:00 2001 From: JamesM92 Date: Mon, 27 Jul 2026 22:30:14 -0400 Subject: [PATCH 09/13] docs+fix: correct project goal from MeshChat parity to NomadNet parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetched and read NomadNet's own Guide.py (in-app spec) and MicronParser.py (reference implementation) directly from the markqvist/NomadNet repo to re-audit every "MeshChat parity" claim in the codebase against the real target: rendering Micron the way actual NomadNet renders it, not just matching one third-party client. Behavior fixes (both narrow, matching NomadNet exactly): - Dividers only get a custom repeat-character/double-rule treatment when the line is exactly "-" + one more char, matching MicronParser.py's `len(line) == 2` check. `---`/`-==` now correctly fall back to the default rule instead of being misread as styled. - Page-level #!bg=/#!fg= headers accept both 3-hex and 6-hex again (Guide.py doesn't restrict header length the way it restricts the inline `Fxxx`/`Bxxx` tags) — reverts an over-correction from earlier this session that assumed headers should match inline's 3-hex-only rule. Docs corrections (README, CHANGELOG, code comments): - Renamed "Dynamic include" to "Partials" (NomadNet's own term) and documented what's actually discarded (refresh/fields/pid) instead of implying they're used. - Documented pipe-separated link field-specs as the real primary syntax; the existing backtick-joining leniency is MeshChat/ Micron2HTML-only and kept but no longer presented as canonical. - Documented NomadNet's own canonical checkbox/radio style (empty field label + trailing text) alongside the already-documented embedded-label form. - Fixed broken checkbox/radio examples in README and examples/showcase.mu (missing the mandatory backtick separator). - Fixed inline colour doubling example (was missing the command letter: "F40" -> "`FF40"). - Fixed incorrect file:// wording in the Security section (the code blocks a `/file/` path segment, not the file:// URI scheme). - Fixed stale "pure Python 3.9+" claim (>=3.10 since v1.0.8). - Added README "Known limitations" section: tables and anchors are unimplemented; alignment-anywhere and inline literal-toggle are documented as intentional supersets of NomadNet's line-scoped versions. - Documented to_text()/--format text, previously untested-in-docs. Test coverage: - 2 new divider tests locking in the length-2 rule. - 1 new header-color test confirming 6-hex support. - Renamed TestDynamicInclude -> TestPartials; added a test confirming fields/pid are discarded, matching corrected docs. - Relabeled "MeshChat parity" comments to "NomadNet" throughout converter.py and tests where the behavior is independently verified against MicronParser.py, without changing behavior. 57 tests passing (was 54). --- CHANGELOG.md | 25 ++++++- README.md | 66 +++++++++++++++---- cli.py | 2 +- converter.py | 142 ++++++++++++++++++++++++++++------------ examples/showcase.mu | 4 +- tests/test_converter.py | 106 +++++++++++++++++++++++------- 6 files changed, 261 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ce1c1b..6c8a685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,33 @@ The format follows [Keep a Changelog](https://keepachangelog.com/), and this pro ## [Unreleased] +### 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 NomadNet parity — rendering Micron the way the real [NomadNet](https://github.com/markqvist/NomadNet) client would, per its own [Guide.py](https://github.com/markqvist/NomadNet/blob/master/nomadnet/ui/textui/Guide.py) (the in-app spec for page authors) and [MicronParser.py](https://github.com/markqvist/NomadNet/blob/master/nomadnet/ui/textui/MicronParser.py) (the reference implementation), which were 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) — those comments were relabeled without any behavior change. Where the two genuinely disagreed: + +- **Divider parsing now matches NomadNet's exact-length-2 rule.** NomadNet only honours a custom divider character when the line is exactly `-` + one more character; anything else (`---`, `-==`, …) falls back to the default rule regardless of content. Previously any line starting with `-` used its second character as the repeat char / "double rule" trigger, so e.g. `---` incorrectly rendered as a `-`-repeat divider and `-==` incorrectly got the "double rule" `
` styling. +- **Page-level `#!bg=`/`#!fg=` headers were double-checked against Guide.py and confirmed to correctly accept both 3-hex and 6-hex** — unlike the inline `` `Fxxx ``/`` `Bxxx `` tokens, which the Guide explicitly restricts to 3 hex characters. (A same-session change had briefly tightened headers to 3-hex-only before this recheck; that's reverted here.) +- **The inline `` `FT<6hex> `` 24-bit colour stays unsupported, now for an accurate reason.** NomadNet's reference parser does implement it, but its own Guide never teaches it to page authors and MeshChat doesn't implement it either — so real-world `.mu` content won't rely on it. The old justification ("neither MeshChat nor NomadNet support it") was simply wrong about NomadNet's code; the comment now says why we still don't chase it. +- **"Dynamic include" is renamed "Partials"** (NomadNet's own term) and documented honestly: NomadNet partials asynchronously load and can auto-refresh a page fragment with request fields and a `pid` target; this converter has no way to do that in a one-shot conversion, so it renders a static link to the URL only, and the `refresh`/`fields`/`pid` components are now documented as discarded rather than implied to be used. +- **Link field-specs' primary documented syntax is now pipe-separated** (`` `[label`url`a=1|b=2] ``), matching NomadNet's actual syntax — the previous docs modeled it on backtick-separated segments (`` `[label`url`a=1`b=2] ``), which is a MeshChat/Micron2HTML leniency real NomadNet doesn't share (it renders nothing for a link with more than 3 backtick-separated components). The lenient behavior is unchanged, just no longer presented as the primary form. +- **Checkbox/radio field docs now also show NomadNet's own canonical style** (empty field label, visible text written after the `>`) alongside the embedded-label form already documented. +- Added a **Known limitations** section to the README: tables, anchors, and the partials simplification are undocumented-until-now gaps; alignment-anywhere and inline literal-toggle are documented as intentional supersets of NomadNet's line-scoped versions, not bugs. + +See [README.md](README.md) for the full corrected syntax reference. + ### Fixed -- **Page-level `#!bg=`/`#!fg=` headers now reject 6-hex values.** `_parse_header_color()` previously accepted both 3-hex (`#!bg=2a2`) and 6-hex (`#!bg=112233`) forms. Only the inline `` `Fxxx ``/`` `Bxxx `` tokens were ever restricted to 3-hex; the page-header path was an inconsistent leftover. Both colour paths now use the same 3-hex-shorthand format exclusively (each nibble doubled). +- **README form-field docs described broken checkbox/radio syntax.** `` ` `` and `` `<^|name|value> `` (no backtick before the closing `>`) look plausible but don't actually render as `` elements — NomadNet's own field parser requires the backtick + label, e.g. `` ` ``. [examples/showcase.mu](examples/showcase.mu) had the same broken form. Both corrected; the mandatory-backtick rule is now called out explicitly. +- **README security section mischaracterized the file-link block.** Said `file://` scheme links are blocked; the code actually blocks any resolved URL containing a `/file/` path segment (NomadNet's download-file convention) — an actual `file://` URL isn't specially handled. Wording corrected, and the default `hash://`-vs-custom-resolver distinction is now spelled out. +- **README said "pure Python 3.9+".** `pyproject.toml` has required `>=3.10` since v1.0.8; README now matches. - **Removed stale docs for the dropped `` `FTxxxxxx ``/`` `BTxxxxxx `` inline format.** The 24-bit inline extension was removed from the parser in v1.0.2, but [README.md](README.md) and [examples/showcase.mu](examples/showcase.mu) still described/demonstrated it. Docs and the example page now only show the supported 3-hex shorthand. +- **README's `` `Fxxx `` doubling example was missing the command letter.** Showed `F40 → #ff4400` (2 hex digits after a bare `F`, which doesn't match the actual 3-hex format); corrected to `` `FF40 → #ff4400 ``. + +### Added + +- **README documents `to_text()` and `--format text`.** Both existed and were tested but were never mentioned in the library/CLI usage sections. +- **README documents the partials token** (`` `{URL`refresh`fields} ``), previously present in the parser with no docs and no test coverage. Added 4 tests covering the happy path, missing refresh arg, discarded fields/pid, and unclosed-brace fallback. +- Divider length-2 rule: 2 new tests locking in the corrected `---`/`-==` fallback behavior. ### Infrastructure diff --git a/README.md b/README.md index eb52995..0571a7c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ A Python library and CLI tool that converts [Micron](https://github.com/markqvis 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. +**Design goal: NomadNet parity.** The converter aims to render the same Micron source the way the real NomadNet client would — following [NomadNet's own Guide](https://github.com/markqvist/NomadNet/blob/master/nomadnet/ui/textui/Guide.py) (the in-app spec written for page authors) and [MicronParser.py](https://github.com/markqvist/NomadNet/blob/master/nomadnet/ui/textui/MicronParser.py) (the reference implementation), not any one third-party client's behavior. Where those two disagree with each other, this library follows the Guide, since that's what real page authors read and write to. See [Known limitations](#known-limitations) for syntax NomadNet supports that isn't implemented here yet. + ## Installation ```bash @@ -23,7 +25,7 @@ cd Micron2HTML pip install -e . ``` -No runtime dependencies — pure Python 3.9+. +No runtime dependencies — pure Python 3.10+. ## Library usage @@ -45,6 +47,9 @@ html = conv.convert( # Inline-only — for titles, message previews, brand strings. # Returns formatted HTML without the
wrapper. title_html = conv.convert_inline("`F4af`!My Node`!`f") + +# Plain text — strips all formatting and colours, no HTML at all. +text = conv.to_text(micron_text) ``` `convert()` returns an HTML fragment (no `` or `` wrapper). Wrap it in your own template or use the CLI for standalone pages. @@ -68,7 +73,7 @@ conv = MicronConverter(url_resolver=my_resolver) ### Default stylesheet -A MeshChat-parity stylesheet ships with the package, named to make the design intent explicit: +A dark-terminal stylesheet ships with the package — visually styled after [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat)'s NomadNet renderer (its filename reflects that), independent of the markup-parsing parity goal above: ```html @@ -93,6 +98,9 @@ micron-convert page.mu --fragment # Set the node hash so internal links resolve correctly micron-convert page.mu --node-hash a1b2c3d4e5f6... + +# Strip all formatting and print plain text instead of HTML +micron-convert page.mu --format text ``` ## Micron syntax @@ -102,10 +110,12 @@ micron-convert page.mu --node-hash a1b2c3d4e5f6... ``` # This is a comment — the whole line is stripped from output -#!bg=2a2 Set page background colour (3-hex shorthand: each digit doubled) -#!fg=aaa Set page foreground colour +#!bg=2a2a2a Set page background colour (3 or 6 hex digits) +#!fg=aaa Set page foreground colour ``` +Unlike the inline `` `Fxxx ``/`` `Bxxx `` tags below, these headers aren't restricted to 3-hex — NomadNet's Guide gives a 3-hex example but doesn't state a length restriction, and both forms render correctly. + ### Headings and sections ``` @@ -117,11 +127,13 @@ micron-convert page.mu --node-hash a1b2c3d4e5f6... ### Dividers ``` ---- Horizontal rule --= Double horizontal rule -- Styled divider — repeats character `x` (e.g. -* renders centred * row) +- Horizontal rule (default style) +-= Row of `=` characters +-x Styled divider — repeats character `x` (e.g. -* renders a * row) ``` +A custom divider character only takes effect when the line is *exactly* two characters — `-` followed by one more. Any other length, including `---` or `-==`, falls back to the default rule rather than repeating what follows the first `-` (this matches NomadNet's own parser, which only reads a custom divider char off a length-2 line). + ### Inline formatting ``` @@ -129,7 +141,7 @@ micron-convert page.mu --node-hash a1b2c3d4e5f6... `*text`* Italic `_text`_ Underline -`Fxxx Set foreground colour (3-hex shorthand: each digit doubled — F40 → #ff4400) +`Fxxx Set foreground colour (3-hex shorthand: each digit doubled — `FF40 → #ff4400) `f Reset foreground colour to default `Bxxx Set background colour (3-hex shorthand) @@ -155,6 +167,18 @@ micron-convert page.mu --node-hash a1b2c3d4e5f6... `[Label`hash://a1b2c3/page.mu] Node link (resolved against node_hash) ``` +Links can also submit form-field data to a node-side page: `` `[Label`url`fields] ``, where `fields` is a single pipe-separated segment — `*` (submit every field on the page), field names to include (`username|auth_token`), or literal `key=value` pairs, mixable in any order (`*|action=preview|id=42`). The parsed spec is emitted verbatim as `data-field-spec` for the consuming application to read. Micron2HTML is also lenient about extra backtick-separated segments after the fields (`` `[Label`url`a=1`b=2] ``) and joins them back together rather than dropping the link — real NomadNet renders nothing at all for that malformed form, so stick to pipes for anything meant to work across clients. + +### Partials + +``` +`{URL} Partial with no auto-refresh +`{URL`refresh} Partial that re-fetches every `refresh` seconds (0 or omitted disables it) +`{URL`refresh`field1|field2|pid=x} Partial with request fields; `pid=` targets a specific partial for refresh +``` + +In real NomadNet, a partial asynchronously loads a fragment of another page and (optionally) re-fetches it in place on an interval — see NomadNet's Guide, "Partials" section. That's not something a one-shot markup→HTML conversion can reproduce without adding JavaScript, so Micron2HTML renders a plain `[live]` link to the target URL instead. The `refresh` and `field`/`pid` components are discarded entirely — only the URL is used. See [Known limitations](#known-limitations). + ### Literal blocks ``` @@ -169,18 +193,32 @@ No Micron formatting is applied inside. Fields render as disabled `` elements unless `authenticated=True` is passed to `convert()`. ``` -` Text input — name with optional default value -` Text input with character size (e.g. `<20|name`>) -` Password input (! flag) -` Checkbox (* at end pre-checks: `) -`<^|name|value> Radio button (* at end pre-selects) +` Text input — name with optional default value +` Text input with character size (e.g. `<20|name`>) +` Password input (! flag) +` Checkbox with label (* pre-checks: `) +`<^|name|value`label> Radio button with label (* pre-selects) ``` +The backtick before the closing `>` is mandatory for every field type, including checkbox and radio — `` ` `` without it is not valid Micron and renders as plain text, not an input. This matches NomadNet's own field parser, which gives up entirely when it can't find that backtick. + +NomadNet's Guide actually favors leaving the checkbox/radio field's own label empty and writing the visible label as plain text right after the `>` (e.g. `` `Label text ``) rather than embedding it inside the field as shown above. Both forms work identically — the parser accepts either — the embedded-label form here is just easier to read in isolation. + +## Known limitations + +Syntax NomadNet supports that this converter doesn't implement, or implements as a deliberate simplification: + +- **Tables** (`` `t ``/`` `t ``-wrapped markdown-style tables) — not implemented. A `` `t `` line currently renders as plain text. +- **Anchors** — auto-anchors from heading slugs, explicit `` `:name `` declarations, and `` `[label`#name] ``/`` `[label`#] `` jump links are not implemented. Links with a `#`-prefixed URL resolve like any other relative link rather than scrolling to a page position. +- **Partials** — implemented as a static link only; no auto-refresh, no request fields. See [Partials](#partials) above. +- **Alignment tags must appear at the start of a line** per NomadNet's Guide; this converter accepts `` `c ``/`` `l ``/`` `r ``/`` `a `` anywhere inline. This is a superset of NomadNet's behavior (it accepts more input, not less), so correctly-authored NomadNet pages aren't affected. +- **The literal-block toggle `` `= `` only fires on a line that is *exactly* `` `= `` and nothing else** in NomadNet; this converter also supports it as an inline mid-line toggle. Same superset relationship as above. + ## Security 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. -External URLs are rendered as plain `` links. File download links (`file://`) are blocked. Internal NomadNet links are resolved to application-relative hrefs. +External URLs are rendered as plain `` links. Links whose resolved path contains a `/file/` segment (NomadNet's download-file convention) are blocked and rendered as `#`. Internal NomadNet links resolve to canonical `hash://` URLs by default — pass a custom `url_resolver` to produce application-relative hrefs instead (see [Custom URL resolution](#custom-url-resolution)). ## Running tests diff --git a/cli.py b/cli.py index 5926f45..a93fa9b 100644 --- a/cli.py +++ b/cli.py @@ -32,7 +32,7 @@ def _bundled_css() -> str: - """Read the bundled MeshChat-parity stylesheet that ships with the package.""" + """Read the bundled dark-terminal stylesheet that ships with the package.""" css_path = os.path.join(os.path.dirname(__file__), "micron-meshchat.css") try: with open(css_path, "r", encoding="utf-8") as fh: diff --git a/converter.py b/converter.py index 3c46c8b..ce4b769 100644 --- a/converter.py +++ b/converter.py @@ -1,8 +1,19 @@ """ Micron markup → HTML converter. -Implements the full Micron specification as documented in: - https://github.com/markqvist/NomadNet/blob/master/nomadnet/ui/textui/Guide.py +Goal: NomadNet parity — render the same Micron source the way the real +NomadNet client would, not just the way any one third-party client does. +The reference sources are NomadNet's own: + - Guide.py (the in-app spec, written for page authors): + https://github.com/markqvist/NomadNet/blob/master/nomadnet/ui/textui/Guide.py + - MicronParser.py (the reference implementation): + https://github.com/markqvist/NomadNet/blob/master/nomadnet/ui/textui/MicronParser.py + +Where the two disagree (the parser accepts something the guide never +teaches), this converter follows the guide — that's what real page authors +actually write. Where NomadNet supports something this converter doesn't +yet implement (tables, anchors, live-refreshing partials), that's called +out at the relevant function rather than silently ignored. Default page colours match NomadNet's terminal defaults: background #000000 (black) @@ -277,9 +288,10 @@ def _process_line(self, line: str, node_hash: str, base_path: str, 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}
' @@ -320,9 +332,13 @@ def _process_line(self, line: str, node_hash: str, base_path: str, 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. + # Deliberate deviation from NomadNet here: its parse_line() + # returns None for an empty heading, so the reference client + # renders no row at all — not even blank space. We render a + # blank row instead (matching MeshChat's behaviour, which + # inserts a
) since collapsing the line entirely in an + # HTML document reads as a rendering bug rather than + # intentional spacing. Section depth is still updated above. return '
' inner = self._parse_inline(heading_text, node_hash, base_path, authenticated, doc) @@ -332,8 +348,9 @@ 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 + # 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" @@ -346,30 +363,38 @@ def _process_line(self, line: str, node_hash: str, base_path: str, 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 = [] @@ -534,12 +559,14 @@ def _parse_inline(self, text: str, node_hash: str, base_path: str, out.append("[") # 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>`) - # 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>`) — real NomadNet renders it as broken text + # too, not an input. elif nc == "<": field_start = i + 1 backtick_pos = text.find("`", field_start) @@ -550,10 +577,19 @@ 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 + # discarded entirely — only the URL is used. See README + # for the full syntax NomadNet supports here. elif nc == "{": end = text.find("}", i + 1) if end != -1: @@ -592,16 +628,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,20 +650,28 @@ 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. + """Parse a #!fg=X or #!bg=X page-header color value. - 3-hex only, matching the inline `Fxxx`/`Bxxx` colour format — - each nibble is doubled (f -> ff, 8 -> 88, 0 -> 00). + Unlike the inline `Fxxx`/`Bxxx` tokens (deliberately 3-hex-only, see + `_parse_color`), Guide.py's "Page Foreground and Background Colors" + section states no length restriction for the header value — it just + gives a 3-hex example (`#!bg=444`). NomadNet's colour-rendering + helpers (`low_color`/`high_color` in MicronParser.py) generically + handle both 3-hex and 6-hex strings, so headers accept both here too. + + Returns CSS color string or None. """ v = value.strip() if len(v) == 3 and all(c in _HEX for c in v): return "#" + "".join(c * 2 for c in v).lower() + if len(v) == 6 and all(c in _HEX for c in v): + return f"#{v.lower()}" return None def _resolve_url(self, url: str, node_hash: str, base_path: str) -> str: @@ -633,7 +682,9 @@ 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 +692,13 @@ def _render_field(self, field_content: str, field_data: str, checkbox : `` 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 (`` ` ``) + 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 `>`. """ diff --git a/examples/showcase.mu b/examples/showcase.mu index 006d8bf..5815198 100644 --- a/examples/showcase.mu +++ b/examples/showcase.mu @@ -71,8 +71,8 @@ URL only: `[`https://example.com] Username: `<20|username`> Password: `<20!|password`> - Subscribe: ` Yes, sign me up - Plan: `<^|plan|free|*> Free `<^|plan|pro> Pro + Subscribe: ` + Plan: `<^|plan|free|*`Free> `<^|plan|pro`Pro> -= diff --git a/tests/test_converter.py b/tests/test_converter.py index 6e730d4..06533ff 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 @@ -56,8 +56,9 @@ def test_heading_content_parsed(self, conv): 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
). + # Deliberate deviation from NomadNet: its parse_line() returns None + # for an empty heading (no row at all). We render a blank row + # instead — collapsing the line entirely reads as a bug in HTML. out = conv.convert(">") assert 'mu-blank' in out @@ -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 @@ -158,17 +178,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 @@ -179,16 +201,19 @@ def test_header_fg_bg_3digit(self, conv): assert "background-color:#333333" in out 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. + def test_header_fg_bg_6digit(self, conv): + # Unlike the inline `Fxxx`/`Bxxx` tokens (3-hex-only by design), + # page-level #!bg=/#!fg= headers accept 6-hex too — NomadNet's + # Guide.py doesn't restrict the header value's length the way it + # restricts the inline colour tags. out = conv.convert("#!bg=112233\n#!fg=aabbcc\nhello") - assert "background-color" not in out - assert "color:#" not in out + assert "background-color:#112233" in out + assert "color:#aabbcc" 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 @@ -289,6 +314,38 @@ def test_link_multi_field_spec_preserves_all(self, conv): assert 'data-field-spec="a=1`b=2`c=3"' in out +# --------------------------------------------------------------------------- +# Partials +# --------------------------------------------------------------------------- + +class TestPartials: + def test_partial_renders_live_link(self, conv): + out = conv.convert("`{hash:/abcdef/status.mu`5}") + assert "
out = conv.convert("`") assert 'type="checkbox"' in out @@ -337,8 +394,9 @@ def test_password_field(self, conv): assert 'disabled' in out def test_field_without_backtick_separator_is_eaten(self, conv): - # MeshChat parity: missing backtick in field syntax causes the whole - # `< token to be silently consumed (parseField returns null). + # NomadNet: missing backtick in field syntax causes the whole + # `< token to be silently consumed (MicronParser.py's field + # handler gives up when `line.find('`', field_start)` is -1). out = conv.convert("`") # The input opens with `< and never has a backtick before > — # parseField would return null, and the < is eaten. From 2b67fd14d9ffe0d55dbf1b5508a8b3d1779847c5 Mon Sep 17 00:00:00 2001 From: JamesM92 Date: Mon, 27 Jul 2026 22:36:58 -0400 Subject: [PATCH 10/13] fix: keep page-header colours 3-hex-only, don't chase 6-hex Reverts the header 6-hex acceptance added earlier this session. NomadNet's own docs would technically permit 6-hex there, but with no marker distinguishing 3-hex from 6-hex (unlike the inline tags, which would need something like NomadNet's `T` prefix), allowing both means a value's meaning silently depends on its length. One fixed width, applied consistently to every colour value in this converter, beats matching every length NomadNet's own code happens to tolerate. --- CHANGELOG.md | 2 +- README.md | 6 +++--- converter.py | 18 ++++++++++-------- tests/test_converter.py | 14 +++++++------- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c8a685..4246530 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/), and this pro 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 NomadNet parity — rendering Micron the way the real [NomadNet](https://github.com/markqvist/NomadNet) client would, per its own [Guide.py](https://github.com/markqvist/NomadNet/blob/master/nomadnet/ui/textui/Guide.py) (the in-app spec for page authors) and [MicronParser.py](https://github.com/markqvist/NomadNet/blob/master/nomadnet/ui/textui/MicronParser.py) (the reference implementation), which were 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) — those comments were relabeled without any behavior change. Where the two genuinely disagreed: - **Divider parsing now matches NomadNet's exact-length-2 rule.** NomadNet only honours a custom divider character when the line is exactly `-` + one more character; anything else (`---`, `-==`, …) falls back to the default rule regardless of content. Previously any line starting with `-` used its second character as the repeat char / "double rule" trigger, so e.g. `---` incorrectly rendered as a `-`-repeat divider and `-==` incorrectly got the "double rule" `
` styling. -- **Page-level `#!bg=`/`#!fg=` headers were double-checked against Guide.py and confirmed to correctly accept both 3-hex and 6-hex** — unlike the inline `` `Fxxx ``/`` `Bxxx `` tokens, which the Guide explicitly restricts to 3 hex characters. (A same-session change had briefly tightened headers to 3-hex-only before this recheck; that's reverted here.) +- **Page-level `#!bg=`/`#!fg=` headers stay 3-hex-only, by deliberate choice.** Guide.py doesn't explicitly restrict the header value's length the way it restricts the inline `` `Fxxx ``/`` `Bxxx `` tokens, and NomadNet's colour-rendering helpers would technically accept a 6-hex value here too — but with no marker distinguishing "3-hex" from "6-hex" (the inline tags would need something like the `T` prefix for that), allowing both means a value's meaning silently depends on its length. One fixed width, applied consistently to every colour value in this converter, is safer than chasing every length NomadNet's code happens to tolerate. - **The inline `` `FT<6hex> `` 24-bit colour stays unsupported, now for an accurate reason.** NomadNet's reference parser does implement it, but its own Guide never teaches it to page authors and MeshChat doesn't implement it either — so real-world `.mu` content won't rely on it. The old justification ("neither MeshChat nor NomadNet support it") was simply wrong about NomadNet's code; the comment now says why we still don't chase it. - **"Dynamic include" is renamed "Partials"** (NomadNet's own term) and documented honestly: NomadNet partials asynchronously load and can auto-refresh a page fragment with request fields and a `pid` target; this converter has no way to do that in a one-shot conversion, so it renders a static link to the URL only, and the `refresh`/`fields`/`pid` components are now documented as discarded rather than implied to be used. - **Link field-specs' primary documented syntax is now pipe-separated** (`` `[label`url`a=1|b=2] ``), matching NomadNet's actual syntax — the previous docs modeled it on backtick-separated segments (`` `[label`url`a=1`b=2] ``), which is a MeshChat/Micron2HTML leniency real NomadNet doesn't share (it renders nothing for a link with more than 3 backtick-separated components). The lenient behavior is unchanged, just no longer presented as the primary form. diff --git a/README.md b/README.md index 0571a7c..b76d136 100644 --- a/README.md +++ b/README.md @@ -110,11 +110,11 @@ micron-convert page.mu --format text ``` # This is a comment — the whole line is stripped from output -#!bg=2a2a2a Set page background colour (3 or 6 hex digits) -#!fg=aaa Set page foreground colour +#!bg=2a2 Set page background colour (3-hex shorthand: each digit doubled) +#!fg=aaa Set page foreground colour ``` -Unlike the inline `` `Fxxx ``/`` `Bxxx `` tags below, these headers aren't restricted to 3-hex — NomadNet's Guide gives a 3-hex example but doesn't state a length restriction, and both forms render correctly. +Deliberately 3-hex-only, matching the inline `` `Fxxx ``/`` `Bxxx `` tags below — NomadNet's own docs would technically permit a 6-hex value here too, but without a marker distinguishing the two (the way the inline tags would need a `T` prefix), a value's meaning would silently depend on its length. One fixed width, applied consistently, is safer. ### Headings and sections diff --git a/converter.py b/converter.py index ce4b769..b335e80 100644 --- a/converter.py +++ b/converter.py @@ -658,20 +658,22 @@ def _parse_color(self, text: str, i: int, n: int): def _parse_header_color(self, value: str) -> Optional[str]: """Parse a #!fg=X or #!bg=X page-header color value. - Unlike the inline `Fxxx`/`Bxxx` tokens (deliberately 3-hex-only, see - `_parse_color`), Guide.py's "Page Foreground and Background Colors" - section states no length restriction for the header value — it just - gives a 3-hex example (`#!bg=444`). NomadNet's colour-rendering - helpers (`low_color`/`high_color` in MicronParser.py) generically - handle both 3-hex and 6-hex strings, so headers accept both here too. + 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): return "#" + "".join(c * 2 for c in v).lower() - if len(v) == 6 and all(c in _HEX for c in v): - return f"#{v.lower()}" return None def _resolve_url(self, url: str, node_hash: str, base_path: str) -> str: diff --git a/tests/test_converter.py b/tests/test_converter.py index 06533ff..18e9b94 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -201,14 +201,14 @@ def test_header_fg_bg_3digit(self, conv): assert "background-color:#333333" in out assert "color:#aaaaaa" in out - def test_header_fg_bg_6digit(self, conv): - # Unlike the inline `Fxxx`/`Bxxx` tokens (3-hex-only by design), - # page-level #!bg=/#!fg= headers accept 6-hex too — NomadNet's - # Guide.py doesn't restrict the header value's length the way it - # restricts the inline colour tags. + def test_header_fg_bg_6digit_not_supported(self, conv): + # 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:#112233" in out - assert "color:#aabbcc" in out + assert "background-color" not in out + assert "color:#" not in out def test_line_level_bg_not_applied_to_div(self, conv): # NomadNet: `B` only sets colour state used per text part as it's From 37ff3e34ab2b7d28f9f0c010ce71dc3a75bd4b27 Mon Sep 17 00:00:00 2001 From: JamesM92 Date: Tue, 28 Jul 2026 18:10:19 -0400 Subject: [PATCH 11/13] feat: implement tables and anchors, tighten remaining NomadNet divergences Full NomadNet parity pass: implements the two previously-unimplemented real NomadNet features (tables, anchors) and reverses several Micron2HTML-only leniencies/extensions that had no equivalent in real NomadNet, so a page renders the same way in both directions. New features: - Tables (`t): rendered as box-drawing-character ASCII art (matching NomadNet's actual visual output, not a semantic ), ported from RNS's MarkdownToMicron.format_table_raw algorithm. Supports header/ alignment-separator/data rows, per-column alignment, min column width, optional `tc30-style whole-table alignment + max-width, escaped pipes. - Anchors: heading auto-anchors (slugified text -> id=), explicit `:name declarations (zero-width, first-wins shared namespace), `[label`#name] named jumps, and bare `[label`#] "jump to next heading" links (resolved via a one-time forward/backward pre-pass in convert()). Behavior changes (reversing MeshChat-only leniencies, matching NomadNet exactly): - Removed the inline mid-line `=...`= literal toggle - NomadNet only recognizes `= as a whole-line block toggle. This was an undocumented Micron2HTML extension with 2 tests exercising it; both rewritten. - Link field-specs with more than 3 backtick-separated segments now render nothing at all, reversing a v1.0.3 fix that was solving a MeshChat-specific quirk rather than real NomadNet behavior. The correct pipe-separated form (`[label`url`a=1|b=2]) is unaffected. - Empty heading lines now emit nothing (no row), matching NomadNet's parse_line() returning None - reverses a deliberate deviation made earlier this session, superseded by the broader parity goal. Partials (`{url`refresh`fields}) now expose refresh/fields/pid as data-refresh/data-fields/data-pid attributes instead of discarding them, so a consuming app can wire up its own live-refresh behavior - still no JS shipped by this library. Also corrected a factually wrong README claim: "alignment tags must appear at line start" was never an enforced NomadNet parser rule, just Guide.py's style advice to authors - Micron2HTML already matched real NomadNet's permissive behavior here. Test suite: 57 -> 90 (33 new/rewritten tests). README gains Tables and Anchors sections and an updated Known Limitations list (wcwidth and table-shrink-formula approximations, partials live-refresh gap, a rare anchor-collision edge case in the bare-hash pre-pass). --- CHANGELOG.md | 33 ++- README.md | 44 +++- __init__.py | 4 +- converter.py | 486 +++++++++++++++++++++++++++++++++++----- examples/showcase.mu | 24 ++ micron-meshchat.css | 17 ++ tests/test_converter.py | 238 ++++++++++++++++++-- 7 files changed, 748 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4246530..96d72e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,28 @@ The format follows [Keep a Changelog](https://keepachangelog.com/), and this pro ### 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 NomadNet parity — rendering Micron the way the real [NomadNet](https://github.com/markqvist/NomadNet) client would, per its own [Guide.py](https://github.com/markqvist/NomadNet/blob/master/nomadnet/ui/textui/Guide.py) (the in-app spec for page authors) and [MicronParser.py](https://github.com/markqvist/NomadNet/blob/master/nomadnet/ui/textui/MicronParser.py) (the reference implementation), which were 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) — those comments were relabeled without any behavior change. Where the two genuinely disagreed: +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. -- **Divider parsing now matches NomadNet's exact-length-2 rule.** NomadNet only honours a custom divider character when the line is exactly `-` + one more character; anything else (`---`, `-==`, …) falls back to the default rule regardless of content. Previously any line starting with `-` used its second character as the repeat char / "double rule" trigger, so e.g. `---` incorrectly rendered as a `-`-repeat divider and `-==` incorrectly got the "double rule" `
` styling. -- **Page-level `#!bg=`/`#!fg=` headers stay 3-hex-only, by deliberate choice.** Guide.py doesn't explicitly restrict the header value's length the way it restricts the inline `` `Fxxx ``/`` `Bxxx `` tokens, and NomadNet's colour-rendering helpers would technically accept a 6-hex value here too — but with no marker distinguishing "3-hex" from "6-hex" (the inline tags would need something like the `T` prefix for that), allowing both means a value's meaning silently depends on its length. One fixed width, applied consistently to every colour value in this converter, is safer than chasing every length NomadNet's code happens to tolerate. -- **The inline `` `FT<6hex> `` 24-bit colour stays unsupported, now for an accurate reason.** NomadNet's reference parser does implement it, but its own Guide never teaches it to page authors and MeshChat doesn't implement it either — so real-world `.mu` content won't rely on it. The old justification ("neither MeshChat nor NomadNet support it") was simply wrong about NomadNet's code; the comment now says why we still don't chase it. -- **"Dynamic include" is renamed "Partials"** (NomadNet's own term) and documented honestly: NomadNet partials asynchronously load and can auto-refresh a page fragment with request fields and a `pid` target; this converter has no way to do that in a one-shot conversion, so it renders a static link to the URL only, and the `refresh`/`fields`/`pid` components are now documented as discarded rather than implied to be used. -- **Link field-specs' primary documented syntax is now pipe-separated** (`` `[label`url`a=1|b=2] ``), matching NomadNet's actual syntax — the previous docs modeled it on backtick-separated segments (`` `[label`url`a=1`b=2] ``), which is a MeshChat/Micron2HTML leniency real NomadNet doesn't share (it renders nothing for a link with more than 3 backtick-separated components). The lenient behavior is unchanged, just no longer presented as the primary form. -- **Checkbox/radio field docs now also show NomadNet's own canonical style** (empty field label, visible text written after the `>`) alongside the embedded-label form already documented. -- Added a **Known limitations** section to the README: tables, anchors, and the partials simplification are undocumented-until-now gaps; alignment-anywhere and inline literal-toggle are documented as intentional supersets of NomadNet's line-scoped versions, not bugs. +**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 `
`, for genuine visual parity. Supports markdown-style header/separator/data rows, per-column alignment, minimum column width, an optional whole-table alignment + max-width suffix (`` `tc30 ``), and escaped pipes in cell content. Column widths use `len()` rather than `wcwidth` (documented simplification — avoids a new runtime dependency). +- **Anchors** — heading auto-anchors (slugified heading text, e.g. `>Hello World` → `id="hello-world"`), explicit `` `:name `` declarations (zero-width, first-declared-wins shared namespace), `` `[label`#name] `` named jump links, and bare `` `[label`#] `` "jump to the next heading" links. `slugify_micron()` is exported from the package. + +**Real, code-level behavior changes** (both reverse previously-shipped, explicitly-tested Micron2HTML-only leniencies/extensions that have no equivalent in real NomadNet): + +- **The inline mid-line literal-block toggle is removed.** `` `=...`= `` usable anywhere within a single line was a Micron2HTML-only extension with no real NomadNet equivalent — NomadNet's `` `= `` toggle only fires on a line that is *exactly* `` `= `` and nothing else (the multi-line block form, already correct). `` `=`!not bold`=`! `` now renders "not bold" as **bolded** text (the `` `= `` tokens are silently consumed like any unrecognized token, `` `! `` toggles bold normally around them) instead of suppressing the bold. +- **Link field-specs with more than 3 backtick-separated segments now render nothing at all**, matching NomadNet exactly (`` `[label`url`a=1`b=2`c=3] `` → no `` tag, not even fallback text). This reverses a v1.0.3 fix that leniently joined the extra segments back together — that fix was solving a MeshChat-specific quirk, not real NomadNet behavior (NomadNet's own link parser sets the URL to empty and skips emitting anything when there are more than 3 components). The correct, NomadNet-native way to pass multiple fields — a single pipe-separated segment (`` `[label`url`a=1|b=2|c=3] ``) — is unaffected and unchanged. +- **Empty heading lines now emit nothing** (no row, no blank space), matching NomadNet's `parse_line()` returning `None`. Reverses a deliberate deviation made earlier in this same body of work (rendering a visible blank row) once the broader goal was clarified to be exact parity rather than "whatever reads best in isolation." + +**Partials get richer, still without shipping JS:** + +- `` `{url`refresh`fields} ``'s `refresh` and `fields` (pipe-separated, may include `pid=`) components — previously silently discarded — are now exposed as `data-refresh`, `data-fields`, and `data-pid` attributes on the rendered link, so a consuming web app can wire up its own live-refresh behavior if it wants to. `refresh` only becomes `data-refresh` if it parses as a number `>= 1`, matching NomadNet's own "0 or omitted disables it" rule. Still no live refresh happens inside this library itself — that would require shipping JS, which this pure-Python library never has. + +**Docs corrected to match:** + +- Checkbox/radio field docs now also show NomadNet's own canonical style (empty field label, visible text written after the `>`) alongside the embedded-label form. +- Link field-specs' primary documented syntax is now pipe-separated (`` `[label`url`a=1|b=2] ``), matching NomadNet's actual syntax. +- README's "Known limitations" no longer lists tables, anchors, or "alignment tags must appear at line start" (the last of those was never a real parser rule to begin with — Guide.py's line-start wording is author style advice, not something `make_output()` actually enforces; Micron2HTML already matched real NomadNet's permissive behavior there). What's left: the partials live-refresh gap, the `wcwidth` and table-shrink-formula approximations, and a rare anchor-collision edge case in the bare-hash pre-pass — see README for details. See [README.md](README.md) for the full corrected syntax reference. @@ -30,8 +43,8 @@ See [README.md](README.md) for the full corrected syntax reference. ### Added - **README documents `to_text()` and `--format text`.** Both existed and were tested but were never mentioned in the library/CLI usage sections. -- **README documents the partials token** (`` `{URL`refresh`fields} ``), previously present in the parser with no docs and no test coverage. Added 4 tests covering the happy path, missing refresh arg, discarded fields/pid, and unclosed-brace fallback. -- Divider length-2 rule: 2 new tests locking in the corrected `---`/`-==` fallback behavior. +- **README documents the partials token** (`` `{URL`refresh`fields} ``), previously present in the parser with no docs and no test coverage. +- **33 new tests**: `TestTables` (13), `TestHeadingAnchors`/`TestExplicitAnchors`/`TestAnchorLinks` (18), divider length-2 rule (2), plus rewrites of the tests covering every behavior change above (empty headings, partials data-attributes, literal-toggle removal, link field-spec strictness). Test suite: 57 → 90. ### Infrastructure diff --git a/README.md b/README.md index b76d136..6348b52 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,21 @@ A custom divider character only takes effect when the line is *exactly* two char `[Label`hash://a1b2c3/page.mu] Node link (resolved against node_hash) ``` -Links can also submit form-field data to a node-side page: `` `[Label`url`fields] ``, where `fields` is a single pipe-separated segment — `*` (submit every field on the page), field names to include (`username|auth_token`), or literal `key=value` pairs, mixable in any order (`*|action=preview|id=42`). The parsed spec is emitted verbatim as `data-field-spec` for the consuming application to read. Micron2HTML is also lenient about extra backtick-separated segments after the fields (`` `[Label`url`a=1`b=2] ``) and joins them back together rather than dropping the link — real NomadNet renders nothing at all for that malformed form, so stick to pipes for anything meant to work across clients. +Links can also submit form-field data to a node-side page: `` `[Label`url`fields] ``, where `fields` is a single pipe-separated segment — `*` (submit every field on the page), field names to include (`username|auth_token`), or literal `key=value` pairs, mixable in any order (`*|action=preview|id=42`). The parsed spec is emitted verbatim as `data-field-spec` for the consuming application to read. A link with more than 3 backtick-separated components (e.g. `` `[Label`url`a=1`b=2] ``, extra fields separated by backticks instead of pipes) renders nothing at all, matching NomadNet exactly — always use pipes for multiple fields. + +### Anchors + +``` +`:name Declare an anchor at this point (zero-width, renders nothing) +`[Label`#name] Jump to a named anchor on this page +`[Label`#] Jump to the next `>` heading after this link's position +``` + +Every heading also becomes an anchor automatically, named by slugifying its text: lowercase, runs of non-alphanumeric characters collapsed to a single hyphen, leading/trailing hyphens stripped. `` >Hello World `` → anchor `hello-world`; `` >Introduction & Setup `` → `introduction-setup`. Explicit `` `:name `` anchors and heading auto-anchors share one namespace per page — if a name collides, the first one declared wins. + +Named-anchor links (`` `[Label`#name] ``) resolve to a plain `href="#name"` and work regardless of document order, since HTML's own fragment navigation resolves against whatever element ends up with that `id`. The bare `` `[Label`#] `` form is different: it looks forward from its own position in the document for the nearest following heading and links to that — useful for "Continue ↓" buttons without naming every section. `slugify_micron()` (also exported from the package) is available standalone if you want to build your own table of contents from a Micron document. + +Cross-page anchors (linking to a named anchor on a *different* page) use NomadNet's own `anchor=name` request-field convention rather than a URL fragment, and are out of scope here — a single `convert()` call over one page's text has no way to know another page's heading layout. ### Partials @@ -177,7 +191,7 @@ Links can also submit form-field data to a node-side page: `` `[Label`url`fields `{URL`refresh`field1|field2|pid=x} Partial with request fields; `pid=` targets a specific partial for refresh ``` -In real NomadNet, a partial asynchronously loads a fragment of another page and (optionally) re-fetches it in place on an interval — see NomadNet's Guide, "Partials" section. That's not something a one-shot markup→HTML conversion can reproduce without adding JavaScript, so Micron2HTML renders a plain `[live]` link to the target URL instead. The `refresh` and `field`/`pid` components are discarded entirely — only the URL is used. See [Known limitations](#known-limitations). +In real NomadNet, a partial asynchronously loads a fragment of another page and (optionally) re-fetches it in place on an interval — see NomadNet's Guide, "Partials" section. That's not something a one-shot markup→HTML conversion can reproduce without adding JavaScript, so Micron2HTML renders a plain `[live]` link to the target URL instead — but the `refresh`/`fields`/`pid` data isn't thrown away: it's exposed as `data-refresh`, `data-fields`, and `data-pid` attributes on that link, so a consuming web app can wire up its own live-refresh behaviour if it wants to. `refresh` only becomes a `data-refresh` attribute if it parses as a number `>= 1` (matching NomadNet's own "0 or omitted disables it" rule). See [Known limitations](#known-limitations). ### Literal blocks @@ -188,6 +202,21 @@ No Micron formatting is applied inside. `= ``` +Each `` `= `` must be **alone on its own line** — that's the only form NomadNet recognizes as a literal-block toggle. `` `= `` appearing mid-line, with other content around it, isn't special syntax at all; it's just consumed as an unrecognized token like any other. + +### Tables + +``` +`t +| Name | Price | Qty | +| ---- | :---: | --: | +| Apple | Free | 5 | +| Orange | Ask, nicely | 3 | +`t +``` + +Renders as literal box-drawing-character ASCII art (`┌───┬───┐` borders, padded monospace cells) inside the normal text flow — not a semantic HTML `
` — matching what real NomadNet actually shows. The first row is the header (always left-aligned); the second is a markdown-style alignment separator (`:---:` center, `---:` right, anything else left); remaining rows are data. Column width is the widest cell in that column (ignoring Micron formatting tokens for the width calculation, floor of 3 characters); cell text itself still gets parsed normally, so a colour or bold token inside a cell renders correctly. `` `t `` can take an optional alignment letter and/or max-width number right after it (e.g. `` `tc30 ``) to center/right/left-align the whole table and cap its total width — wide tables get their widest columns shrunk down to fit. Use `\|` inside a cell for a literal pipe character. + ### Form fields Fields render as disabled `` elements unless `authenticated=True` is passed to `convert()`. @@ -206,13 +235,10 @@ NomadNet's Guide actually favors leaving the checkbox/radio field's own label em ## Known limitations -Syntax NomadNet supports that this converter doesn't implement, or implements as a deliberate simplification: - -- **Tables** (`` `t ``/`` `t ``-wrapped markdown-style tables) — not implemented. A `` `t `` line currently renders as plain text. -- **Anchors** — auto-anchors from heading slugs, explicit `` `:name `` declarations, and `` `[label`#name] ``/`` `[label`#] `` jump links are not implemented. Links with a `#`-prefixed URL resolve like any other relative link rather than scrolling to a page position. -- **Partials** — implemented as a static link only; no auto-refresh, no request fields. See [Partials](#partials) above. -- **Alignment tags must appear at the start of a line** per NomadNet's Guide; this converter accepts `` `c ``/`` `l ``/`` `r ``/`` `a `` anywhere inline. This is a superset of NomadNet's behavior (it accepts more input, not less), so correctly-authored NomadNet pages aren't affected. -- **The literal-block toggle `` `= `` only fires on a line that is *exactly* `` `= `` and nothing else** in NomadNet; this converter also supports it as an inline mid-line toggle. Same superset relationship as above. +- **Partials render as a static link only** — no actual live auto-refresh happens (the `refresh`/`fields`/`pid` data is exposed via `data-*` attributes for a consuming app to use, but no JS ships with this library). See [Partials](#partials) above. +- **Table column widths use `len()`, not `wcwidth`** — NomadNet's own table renderer consults `wcwidth` for double-width Unicode glyphs; this converter doesn't, to avoid adding a runtime dependency this library has never had. Tables with wide (e.g. CJK) characters in cells may not align columns as precisely as real NomadNet would. +- **The table width-shrink algorithm is a faithful-effort approximation**, not a byte-for-byte port of NomadNet's exact "proportionally shrink the widest columns" formula (which isn't fully specified in the reference source) — it greedily shrinks the single widest column by one character at a time until the table fits. +- **A rare anchor-collision edge case**: the bare `` `[label`#] `` "jump to next heading" link is resolved by a pre-pass that only simulates *other headings* claiming anchor slugs, not explicit `` `:name `` anchors declared during the real render. If an explicit anchor earlier in the document happens to claim the exact slug a later heading would auto-generate, that heading loses its `id` (correct first-wins behavior), but a bare-hash link could still point at `href="#name"` where `name` belongs to the earlier anchor rather than the heading — a nearby, valid target, just not the intended one. Requires a deliberate or coincidental naming collision to trigger. ## Security diff --git a/__init__.py b/__init__.py index be44807..de13508 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1,4 @@ -from .converter import MicronConverter, default_url_resolver, UrlResolver +from .converter import MicronConverter, default_url_resolver, UrlResolver, slugify_micron -__all__ = ["MicronConverter", "default_url_resolver", "UrlResolver"] +__all__ = ["MicronConverter", "default_url_resolver", "UrlResolver", "slugify_micron"] __version__ = "1.0.8" diff --git a/converter.py b/converter.py index b335e80..4270929 100644 --- a/converter.py +++ b/converter.py @@ -11,9 +11,10 @@ Where the two disagree (the parser accepts something the guide never teaches), this converter follows the guide — that's what real page authors -actually write. Where NomadNet supports something this converter doesn't -yet implement (tables, anchors, live-refreshing partials), that's called -out at the relevant function rather than silently ignored. +actually write. Where NomadNet supports something this converter can't +fully reproduce (live-refreshing partials, which need JS this pure-Python +library doesn't ship), that's called out at the relevant function rather +than silently ignored — see README's "Known limitations". Default page colours match NomadNet's terminal defaults: background #000000 (black) @@ -29,6 +30,69 @@ _HEX = frozenset("0123456789abcdefABCDEF") +# --------------------------------------------------------------------------- +# Anchors +# +# Ported verbatim from NomadNet's MicronParser.py (slugify_micron / its +# strip regex), so auto-anchor slugs generated here match what real +# NomadNet would generate for the same heading text. +# --------------------------------------------------------------------------- + +_MICRON_STRIP_RE = re.compile( + r"`[FB]T[0-9a-fA-F]{6}" + r"|`[FB][0-9a-fA-F]{3}" + r"|`:[A-Za-z0-9_\-]*" + r"|`[!*_=fbacrl`<>{]" +) + +_ANCHOR_NAME_CHARS = frozenset( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-" +) + + +def slugify_micron(text: Optional[str]) -> str: + """Slugify heading text into an anchor name, matching NomadNet exactly. + + Strips Micron formatting tokens first (colour, bold/italic/underline/ + reset, alignment, link/field/partial-open, and anchor-declaration + tokens), then lowercases, collapses runs of non-alphanumeric characters + into a single hyphen, and strips leading/trailing hyphens. + + ">Hello World" -> "hello-world" + ">Introduction & Setup" -> "introduction-setup" + """ + if text is None: + return "" + stripped = _MICRON_STRIP_RE.sub("", text) + return re.sub(r"[^A-Za-z0-9]+", "-", stripped).strip("-").lower() + + +# --------------------------------------------------------------------------- +# Tables +# +# NomadNet renders `t ... `t blocks as literal box-drawing-character ASCII +# art (via RNS's MarkdownToMicron.format_table_raw), 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 # @@ -161,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 @@ -169,7 +240,6 @@ 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) @@ -220,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) @@ -232,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 @@ -282,6 +363,20 @@ 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() == "`=": @@ -313,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 @@ -326,20 +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: - # Deliberate deviation from NomadNet here: its parse_line() - # returns None for an empty heading, so the reference client - # renders no row at all — not even blank space. We render a - # blank row instead (matching MeshChat's behaviour, which - # inserts a
) since collapsing the line entirely in an - # HTML document reads as a rendering bug rather than - # intentional spacing. Section depth is still 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 @@ -348,6 +451,7 @@ 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 '' + 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 @@ -360,7 +464,7 @@ 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 ---- # NomadNet: only lines starting with `-` produce dividers. @@ -421,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])) @@ -524,40 +618,68 @@ 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 (`) # NomadNet: a field requires a backtick between `` — MicronParser.py's field handler does @@ -587,17 +709,20 @@ def _parse_inline(self, text: str, node_hash: str, base_path: str, # 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 - # discarded entirely — only the URL is used. See README - # for the full syntax NomadNet supports here. + # 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: @@ -680,6 +805,102 @@ 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. @@ -768,5 +989,158 @@ def _close_all(self, state: _InlineState) -> list: 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 5815198..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,6 +80,17 @@ URL only: `[`https://example.com] -~ +>Tables + +`t +| Name | Price | Qty | +| ---- | :---: | --: | +| `F3a3Apple`f | Free | `!5`! | +| Orange | Ask, nicely | 3 | +`t + +-~ + >Form fields Username: `<20|username`> 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/tests/test_converter.py b/tests/test_converter.py index 18e9b94..442a106 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -55,12 +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): - # Deliberate deviation from NomadNet: its parse_line() returns None - # for an empty heading (no row at all). We render a blank row - # instead — collapsing the line entirely reads as a bug in HTML. + 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() == "" # --------------------------------------------------------------------------- @@ -303,15 +303,199 @@ 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 "