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