@vibelord/lanes 1.2.0 → 1.3.0
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.
- package/package.json +1 -1
- package/payload/lanes/README.md +4 -3
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +301 -45
- package/payload/lanes/container/lane +42 -1
- package/payload/lanes/container/lane-mcp +65 -6
- package/payload/lanes/container/lane_core.py +178 -0
- package/payload/lanes/docs/ARCHITECTURE.md +192 -20
- package/payload/lanes/docs/DESIGN.md +6 -3
- package/payload/lanes/docs/INSTALL.md +25 -8
- package/payload/lanes/docs/RUNBOOK.md +369 -8
- package/payload/lanes/docs/USERGUIDE.md +201 -25
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/host-install.sh +39 -5
- package/payload/lanes/libexec/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- package/payload/lanes/libexec/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +5 -0
- package/payload/lanes/libexec/notify.py +65 -5
- package/payload/lanes/libexec/sidebar.py +106 -13
- package/payload/lanes/libexec/statusd.py +97 -5
- package/payload/lanes/libexec/tokens.py +31 -9
- package/payload/lanes/libexec/update.py +94 -22
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- package/payload/lanes/web/Caddyfile +24 -1
- package/payload/lanes/web/README.md +86 -8
- package/payload/lanes/web/browser.js +2 -4
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.js +3 -2
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- package/payload/manifest.json +4 -3
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
"""mdlite.py — a Markdown subset rendered to an HTML fragment, standard library only (W10 W2, LNS-103).
|
|
2
|
+
|
|
3
|
+
The docs cockpit (`lanes-docsd`, W10 W3) renders what agents publish from a lane with it. Agent content is hostile, so
|
|
4
|
+
the scope is chosen for that one use: the output holds only the fixed tags below, every text node and attribute value
|
|
5
|
+
passes `html.escape(s, quote=True)` exactly once, raw HTML in the input is text, and a link or image whose destination
|
|
6
|
+
is not on the allowlist keeps its text and loses its URL. Deterministic: the same text always renders the same bytes.
|
|
7
|
+
|
|
8
|
+
render(text) -> str an HTML fragment (no <html>/<body>), each block on its own line, ending in a newline ('' for
|
|
9
|
+
blank input); TooLarge (a ValueError) past MAX_BYTES of UTF-8, TypeError for a non-str
|
|
10
|
+
|
|
11
|
+
Blocks: ATX headings (# to ######, closing hashes dropped); paragraphs (blank-line separated; soft line breaks join with
|
|
12
|
+
one space); - * + and 1. 1) lists, tight, one nesting level by 2+ spaces (deeper items join that level); fences ``` and
|
|
13
|
+
~~~ with an optional info string — `mermaid` gives <pre class="mermaid">, any other <pre><code class="language-X">
|
|
14
|
+
with X the info's first word lowercased and reduced to [a-z0-9_-] (none: no class), unterminated = code to the end;
|
|
15
|
+
blockquotes (> , nested at most MAX_QUOTE_DEPTH deep, deeper markers are text); pipe tables with a separator row whose
|
|
16
|
+
cell count matches the header (colons accepted, alignment not rendered; | inside a code span or as \\| stays in the
|
|
17
|
+
cell); rules --- *** ___. Not in the subset: setext headings, indented code, HTML blocks, entity decoding (< shows as
|
|
18
|
+
typed), hard line breaks, footnotes, task lists, blocks inside list items.
|
|
19
|
+
|
|
20
|
+
Inline: `code`, **strong**, *em* / _em_ (CommonMark delimiter runs, without the rule of three), backslash escapes of
|
|
21
|
+
ASCII punctuation, [text](url "title"), <scheme:...> and <user@host> autolinks, . A link's href must
|
|
22
|
+
start with http:// https:// mailto: /lanes-docs/ or # and hold no space, control or DEL character (a space inside
|
|
23
|
+
<...> becomes %20), no backslash, and — checked after the backslash escapes are resolved — no `.` or `..` segment in
|
|
24
|
+
its path, `%2e` spellings in any case included, and on this site no `%2f`/`%5c` in it (a browser resolves dot
|
|
25
|
+
segments before it requests, and a server may decode an encoded slash into one, so a prefix test let
|
|
26
|
+
`/lanes-docs/../ws/x` and `/lanes-docs/%2e%2e/ws/x` reach any path of the site: security review 2026-10-08, F3);
|
|
27
|
+
http(s) links get rel="noopener noreferrer" target="_blank". An image's src is exactly a document's bytes on this site,
|
|
28
|
+
/lanes-docs/d/<id>/raw or /lanes-docs/d/<id>/artifact (<id> docs_store's [a-z0-9]{10,20}), or a base64 raster
|
|
29
|
+
data:image/png|jpeg|gif|webp; anything else renders the alt text. No link inside a link.
|
|
30
|
+
|
|
31
|
+
python3 libexec/mdlite.py [FILE] renders FILE (or stdin) to stdout (a manual check; docsd imports render()).
|
|
32
|
+
"""
|
|
33
|
+
import bisect
|
|
34
|
+
import html
|
|
35
|
+
import re
|
|
36
|
+
import sys
|
|
37
|
+
import unicodedata
|
|
38
|
+
|
|
39
|
+
MAX_BYTES = 1 << 20 # 1 MiB of UTF-8: larger input is refused, not truncated
|
|
40
|
+
MAX_QUOTE_DEPTH = 8 # nested blockquotes; recursion depth of the block parser
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TooLarge(ValueError):
|
|
44
|
+
"""The input is longer than MAX_BYTES of UTF-8."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, size, limit=MAX_BYTES):
|
|
47
|
+
super().__init__(f'markdown input is {size} bytes; the limit is {limit}')
|
|
48
|
+
self.size, self.limit = size, limit
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_esc = html.escape # quote=True is its default: & < > " ' all become references, once
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------------------------------------------------
|
|
55
|
+
# Blocks
|
|
56
|
+
|
|
57
|
+
_FENCE = re.compile(r'( {0,3})(`{3,}|~{3,})[ \t]*(.*)')
|
|
58
|
+
_HEADING = re.compile(r' {0,3}(#{1,6})(?:[ \t]+(.*))?')
|
|
59
|
+
_RULE = re.compile(r' {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*')
|
|
60
|
+
_QUOTE = re.compile(r' {0,3}> ?')
|
|
61
|
+
_ITEM = re.compile(r'( {0,3})([-*+]|[0-9]{1,9}[.)])(?:[ \t]+(.*)|[ \t]*)')
|
|
62
|
+
_SEPARATOR_CELL = re.compile(r':?-+:?')
|
|
63
|
+
_MARKS = frozenset('`~#-*_>+0123456789') # the first characters (after up to 3 spaces) a block other than a table
|
|
64
|
+
_CELL_TOKEN = re.compile(r'\\[\s\S]|`+|\|')
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _fence(line):
|
|
68
|
+
"""(indent, marker char, marker length, info) when the line opens a fence, else None."""
|
|
69
|
+
m = _FENCE.fullmatch(line)
|
|
70
|
+
if not m or (m.group(2)[0] == '`' and '`' in m.group(3)):
|
|
71
|
+
return None
|
|
72
|
+
return len(m.group(1)), m.group(2)[0], len(m.group(2)), m.group(3).strip()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _closes(line, char, length):
|
|
76
|
+
body = line.strip(' \t')
|
|
77
|
+
return len(line) - len(line.lstrip(' ')) <= 3 and len(body) >= length and body == char * len(body)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _indent(line):
|
|
81
|
+
"""Leading whitespace in columns, a tab counting to the next multiple of 4."""
|
|
82
|
+
return len(line[:len(line) - len(line.lstrip(' \t'))].expandtabs(4))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _item(line):
|
|
86
|
+
"""(indent, ordered, delimiter, number, text) when the line is a list item, else None."""
|
|
87
|
+
m = _ITEM.fullmatch(line)
|
|
88
|
+
if not m:
|
|
89
|
+
return None
|
|
90
|
+
marker = m.group(2)
|
|
91
|
+
ordered = marker[0].isdigit()
|
|
92
|
+
return len(m.group(1)), ordered, marker[-1], int(marker[:-1]) if ordered else 1, m.group(3) or ''
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _separator(line):
|
|
96
|
+
"""The cell count of a table separator row (|-separated cells of dashes, colons at either end), else 0. Split, not
|
|
97
|
+
one regex: whitespace quantifiers side by side backtrack quadratically on a long run of spaces."""
|
|
98
|
+
s = line.strip()
|
|
99
|
+
if '|' not in s or len(line) - len(line.lstrip(' ')) > 3:
|
|
100
|
+
return 0
|
|
101
|
+
s = s[1:] if s.startswith('|') else s
|
|
102
|
+
s = s[:-1] if s.endswith('|') else s
|
|
103
|
+
cells = s.split('|')
|
|
104
|
+
return len(cells) if all(_SEPARATOR_CELL.fullmatch(cell.strip(' \t')) for cell in cells) else 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _table_at(lines, i):
|
|
108
|
+
"""The header cells when lines[i] opens a table (its separator row's cell count matches), else None."""
|
|
109
|
+
if i + 1 >= len(lines) or '|' not in lines[i]:
|
|
110
|
+
return None
|
|
111
|
+
count = _separator(lines[i + 1])
|
|
112
|
+
header = _cells(lines[i]) if count else None
|
|
113
|
+
return header if header is not None and len(header) == count else None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _starts_block(lines, i, depth, in_paragraph=False):
|
|
117
|
+
line = lines[i]
|
|
118
|
+
if line.lstrip(' ')[:1] not in _MARKS:
|
|
119
|
+
return '|' in line and _table_at(lines, i) is not None
|
|
120
|
+
if _fence(line) or _HEADING.fullmatch(line) or _RULE.fullmatch(line):
|
|
121
|
+
return True
|
|
122
|
+
if depth < MAX_QUOTE_DEPTH and _QUOTE.match(line):
|
|
123
|
+
return True
|
|
124
|
+
item = _item(line)
|
|
125
|
+
if item and not (in_paragraph and (not item[4].strip() or (item[1] and item[3] != 1))):
|
|
126
|
+
return True
|
|
127
|
+
return _table_at(lines, i) is not None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _blocks(lines, depth):
|
|
131
|
+
out, i, n = [], 0, len(lines)
|
|
132
|
+
while i < n:
|
|
133
|
+
line = lines[i]
|
|
134
|
+
if not line.strip():
|
|
135
|
+
i += 1
|
|
136
|
+
continue
|
|
137
|
+
marked = line.lstrip(' ')[:1] in _MARKS
|
|
138
|
+
fence = _fence(line) if marked else None
|
|
139
|
+
if fence:
|
|
140
|
+
indent, char, length, info = fence
|
|
141
|
+
body, i = [], i + 1
|
|
142
|
+
while i < n and not _closes(lines[i], char, length):
|
|
143
|
+
text = lines[i]
|
|
144
|
+
body.append(text[min(indent, len(text) - len(text.lstrip(' '))):])
|
|
145
|
+
i += 1
|
|
146
|
+
i += 1
|
|
147
|
+
code = _esc('\n'.join(body)) + '\n' if body else ''
|
|
148
|
+
word = info.split()[0].lower() if info else ''
|
|
149
|
+
if word == 'mermaid':
|
|
150
|
+
out.append(f'<pre class="mermaid">{code}</pre>')
|
|
151
|
+
else:
|
|
152
|
+
lang = re.sub(r'[^a-z0-9_-]', '', word)[:32]
|
|
153
|
+
out.append(f'<pre><code class="language-{lang}">{code}</code></pre>' if lang
|
|
154
|
+
else f'<pre><code>{code}</code></pre>')
|
|
155
|
+
continue
|
|
156
|
+
m = _HEADING.fullmatch(line) if marked else None
|
|
157
|
+
if m:
|
|
158
|
+
text = (m.group(2) or '').strip()
|
|
159
|
+
bare = text.rstrip('#')
|
|
160
|
+
if not bare:
|
|
161
|
+
text = ''
|
|
162
|
+
elif bare != text and bare[-1] in ' \t':
|
|
163
|
+
text = bare.rstrip()
|
|
164
|
+
level = len(m.group(1))
|
|
165
|
+
out.append(f'<h{level}>{_inline(text)}</h{level}>')
|
|
166
|
+
i += 1
|
|
167
|
+
continue
|
|
168
|
+
if marked and _RULE.fullmatch(line):
|
|
169
|
+
out.append('<hr>')
|
|
170
|
+
i += 1
|
|
171
|
+
continue
|
|
172
|
+
if marked and depth < MAX_QUOTE_DEPTH and _QUOTE.match(line):
|
|
173
|
+
inner = []
|
|
174
|
+
while i < n and _QUOTE.match(lines[i]):
|
|
175
|
+
inner.append(lines[i][_QUOTE.match(lines[i]).end():])
|
|
176
|
+
i += 1
|
|
177
|
+
out.append('<blockquote>\n' + '\n'.join(_blocks(inner, depth + 1)) + '\n</blockquote>')
|
|
178
|
+
continue
|
|
179
|
+
if marked and _item(line):
|
|
180
|
+
html_list, i = _list(lines, i, depth)
|
|
181
|
+
out.append(html_list)
|
|
182
|
+
continue
|
|
183
|
+
header = _table_at(lines, i)
|
|
184
|
+
if header is not None:
|
|
185
|
+
rows, i = [], i + 2
|
|
186
|
+
while i < n and lines[i].strip() and '|' in lines[i]:
|
|
187
|
+
cells = _cells(lines[i])[:len(header)]
|
|
188
|
+
rows.append(cells + [''] * (len(header) - len(cells)))
|
|
189
|
+
i += 1
|
|
190
|
+
parts = ['<table>', '<thead>', '<tr>' + ''.join(f'<th>{_inline(c)}</th>' for c in header) + '</tr>',
|
|
191
|
+
'</thead>']
|
|
192
|
+
if rows:
|
|
193
|
+
parts += ['<tbody>'] + ['<tr>' + ''.join(f'<td>{_inline(c)}</td>' for c in row) + '</tr>'
|
|
194
|
+
for row in rows] + ['</tbody>']
|
|
195
|
+
out.append('\n'.join(parts + ['</table>']))
|
|
196
|
+
continue
|
|
197
|
+
para = [line.strip()]
|
|
198
|
+
i += 1
|
|
199
|
+
while i < n and lines[i].strip() and not _starts_block(lines, i, depth, in_paragraph=True):
|
|
200
|
+
para.append(lines[i].strip())
|
|
201
|
+
i += 1
|
|
202
|
+
out.append(f'<p>{_inline(" ".join(para))}</p>')
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _list(lines, i, depth):
|
|
207
|
+
"""One list from lines[i]: (html, next index). Items hold inline text; nested items form one level."""
|
|
208
|
+
first = _item(lines[i])
|
|
209
|
+
base, ordered, delim, start = first[0], first[1], first[2], first[3]
|
|
210
|
+
items = [] # [text parts, [[ordered, start, [item text parts...]], ...]]
|
|
211
|
+
n, previous_blank = len(lines), False
|
|
212
|
+
while i < n:
|
|
213
|
+
line = lines[i]
|
|
214
|
+
if not line.strip():
|
|
215
|
+
k = i + 1
|
|
216
|
+
while k < n and not lines[k].strip():
|
|
217
|
+
k += 1
|
|
218
|
+
nxt = _item(lines[k]) if k < n else None
|
|
219
|
+
sibling = nxt and nxt[0] < base + 2 and nxt[1] == ordered and nxt[2] == delim
|
|
220
|
+
if k < n and (sibling or _indent(lines[k]) >= base + 2):
|
|
221
|
+
i, previous_blank = k, True
|
|
222
|
+
continue
|
|
223
|
+
break
|
|
224
|
+
item, indent = _item(line), _indent(line)
|
|
225
|
+
if indent < base + 2 and item:
|
|
226
|
+
if item[1] != ordered or item[2] != delim:
|
|
227
|
+
break
|
|
228
|
+
items.append([[item[4]], []])
|
|
229
|
+
elif indent >= base + 2:
|
|
230
|
+
nested = _item(line.lstrip(' \t'))
|
|
231
|
+
children = items[-1][1]
|
|
232
|
+
if nested:
|
|
233
|
+
if not children or children[-1][0] != nested[1]:
|
|
234
|
+
children.append([nested[1], nested[3], []])
|
|
235
|
+
children[-1][2].append([nested[4]])
|
|
236
|
+
elif children:
|
|
237
|
+
children[-1][2][-1].append(line.strip())
|
|
238
|
+
else:
|
|
239
|
+
items[-1][0].append(line.strip())
|
|
240
|
+
else:
|
|
241
|
+
if previous_blank or _starts_block(lines, i, depth):
|
|
242
|
+
break
|
|
243
|
+
children = items[-1][1]
|
|
244
|
+
(children[-1][2][-1] if children else items[-1][0]).append(line.strip())
|
|
245
|
+
previous_blank = False
|
|
246
|
+
i += 1
|
|
247
|
+
return _render_list(ordered, start, items, True), i
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _render_list(ordered, start, items, top):
|
|
251
|
+
tag = 'ol' if ordered else 'ul'
|
|
252
|
+
parts = [f'<ol start="{start}">' if ordered and start != 1 else f'<{tag}>']
|
|
253
|
+
for item in items:
|
|
254
|
+
text = _inline(' '.join(p for p in (item[0] if top else item) if p))
|
|
255
|
+
children = item[1] if top else []
|
|
256
|
+
if children:
|
|
257
|
+
nested = '\n'.join(_render_list(o, s, kids, False) for o, s, kids in children)
|
|
258
|
+
parts.append(f'<li>{text}\n{nested}\n</li>')
|
|
259
|
+
else:
|
|
260
|
+
parts.append(f'<li>{text}</li>')
|
|
261
|
+
return '\n'.join(parts + [f'</{tag}>'])
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------------------------------------------------------------
|
|
265
|
+
# Inline
|
|
266
|
+
|
|
267
|
+
_SPECIAL = re.compile(r'[\\`*_!\[<]')
|
|
268
|
+
_RUN = re.compile(r'`+')
|
|
269
|
+
_DELIMS = re.compile(r'\*+|_+')
|
|
270
|
+
_BRACKET_TOKEN = re.compile(r'\\[\s\S]|`+|[\[\]]')
|
|
271
|
+
_ASCII_PUNCT = frozenset('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~')
|
|
272
|
+
_ESCAPED = re.compile(r'\\([!-/:-@\[-`{-~])')
|
|
273
|
+
# (dest) — <angle form> or a raw form with one level of balanced parentheses — then an optional "t", 't' or (t) title.
|
|
274
|
+
_DEST = re.compile(r'\([ \t]*(?:<([^<>\n]*)>|((?:[^\s()\\]|\\\S|\((?:[^\s()\\]|\\\S)*\))*))'
|
|
275
|
+
r'(?:[ \t]+("(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'|\((?:[^()\\]|\\.)*\)))?[ \t]*\)')
|
|
276
|
+
_AUTOLINK = re.compile(r'<([A-Za-z][A-Za-z0-9+.-]{1,31}:[^\x00-\x20\x7f<>]*)>')
|
|
277
|
+
_EMAIL = re.compile(r"<([A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
|
|
278
|
+
r"(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*)>")
|
|
279
|
+
_HREF = re.compile(r'(?:(?i:https?://|mailto:)|/lanes-docs/|#)')
|
|
280
|
+
_SRC = re.compile(r'/lanes-docs/d/[a-z0-9]{10,20}/(?:raw|artifact)|(?i:data:image/(?:png|jpeg|gif|webp);base64,)'
|
|
281
|
+
r'[A-Za-z0-9+/]*={0,2}') # fullmatch: the whole src, nothing after it
|
|
282
|
+
_WEB = re.compile(r'(?i:https?://)')
|
|
283
|
+
_BAD_URL = re.compile(r'[\x00-\x20\x7f\\]') # a browser reads `\` as `/` in an http(s) URL
|
|
284
|
+
_DOT_SEGMENT = re.compile(r'(?:\.|%2e){1,2}', re.I) # fullmatch: `.` `..` `%2e` `.%2E` `%2e%2e` …
|
|
285
|
+
_PATH_END = re.compile(r'[?#]')
|
|
286
|
+
_ENCODED_SLASH = re.compile(r'%(?:2f|5c)', re.I)
|
|
287
|
+
_EXTERNAL = ' rel="noopener noreferrer" target="_blank"'
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _runs(s):
|
|
291
|
+
"""{run length: [start of each maximal backtick run of that length]}, ascending."""
|
|
292
|
+
by_length = {}
|
|
293
|
+
for m in _RUN.finditer(s):
|
|
294
|
+
by_length.setdefault(m.end() - m.start(), []).append(m.start())
|
|
295
|
+
return by_length
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _closer(runs, at, length):
|
|
299
|
+
"""Where the code span opened by the run of `length` backticks at `at` closes (the next maximal run of exactly that
|
|
300
|
+
length), or None: the run is literal."""
|
|
301
|
+
starts = runs.get(length)
|
|
302
|
+
if not starts:
|
|
303
|
+
return None
|
|
304
|
+
k = bisect.bisect_right(starts, at)
|
|
305
|
+
return starts[k] if k < len(starts) else None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _cells(line):
|
|
309
|
+
"""A table row's cells: split on | outside code spans; \\| is a literal | (inside a code span too)."""
|
|
310
|
+
s = line.strip()
|
|
311
|
+
if s.startswith('|'):
|
|
312
|
+
s = s[1:]
|
|
313
|
+
if '`' not in s and '\\' not in s:
|
|
314
|
+
cells = [cell.strip() for cell in s.split('|')]
|
|
315
|
+
return cells[:-1] if len(cells) > 1 and s.endswith('|') else cells
|
|
316
|
+
runs, cells, cur, pos, last, split_last = _runs(s), [], [], 0, 0, False
|
|
317
|
+
while (m := _CELL_TOKEN.search(s, pos)) is not None:
|
|
318
|
+
token, pos, split_last = m.group(), m.end(), False
|
|
319
|
+
if token[0] == '`':
|
|
320
|
+
close = _closer(runs, m.start(), len(token))
|
|
321
|
+
if close is not None:
|
|
322
|
+
cur += [s[last:m.start()], s[m.start():close + len(token)].replace('\\|', '|')]
|
|
323
|
+
last = pos = close + len(token)
|
|
324
|
+
elif token == '|':
|
|
325
|
+
cur.append(s[last:m.start()])
|
|
326
|
+
cells.append(''.join(cur).strip())
|
|
327
|
+
cur, last, split_last = [], pos, True
|
|
328
|
+
if not (split_last and last == len(s)):
|
|
329
|
+
cells.append((''.join(cur) + s[last:]).strip())
|
|
330
|
+
return cells
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _brackets(s, runs):
|
|
334
|
+
"""{index of [: index of its matching ]}, skipping backslash escapes and code spans as the scanner does."""
|
|
335
|
+
match, stack, pos = {}, [], 0
|
|
336
|
+
while (m := _BRACKET_TOKEN.search(s, pos)) is not None:
|
|
337
|
+
token, pos = m.group(), m.end()
|
|
338
|
+
if token[0] == '`':
|
|
339
|
+
close = _closer(runs, m.start(), len(token))
|
|
340
|
+
if close is not None:
|
|
341
|
+
pos = close + len(token)
|
|
342
|
+
elif token == '[':
|
|
343
|
+
stack.append(m.start())
|
|
344
|
+
elif token == ']' and stack:
|
|
345
|
+
match[stack.pop()] = m.start()
|
|
346
|
+
return match
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _class(ch):
|
|
350
|
+
"""' ' whitespace, 'p' punctuation (ASCII, or a Unicode P* or S* category), 'w' anything else: the flanking rule's
|
|
351
|
+
three kinds of neighbour."""
|
|
352
|
+
kind = _ASCII_CLASS.get(ch)
|
|
353
|
+
if kind is None:
|
|
354
|
+
kind = ' ' if ch.isspace() else 'p' if unicodedata.category(ch)[0] in 'PS' else 'w'
|
|
355
|
+
return kind
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
_ASCII_CLASS = {chr(c): ' ' if chr(c).isspace() else 'p' if chr(c) in _ASCII_PUNCT else 'w' for c in range(128)}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class _Delim:
|
|
362
|
+
"""A run of * or _: literal until the emphasis pass pairs it; then it carries the tags it opens or closes."""
|
|
363
|
+
__slots__ = ('char', 'count', 'can_open', 'can_close', 'opens', 'closes')
|
|
364
|
+
|
|
365
|
+
def __init__(self, char, count, before, after):
|
|
366
|
+
left = after != ' ' and (after != 'p' or before != 'w')
|
|
367
|
+
right = before != ' ' and (before != 'p' or after != 'w')
|
|
368
|
+
if char == '*':
|
|
369
|
+
self.can_open, self.can_close = left, right
|
|
370
|
+
else:
|
|
371
|
+
self.can_open, self.can_close = left and (not right or before == 'p'), right and (not left or after == 'p')
|
|
372
|
+
self.char, self.count, self.opens, self.closes = char, count, [], []
|
|
373
|
+
|
|
374
|
+
def html(self):
|
|
375
|
+
return ''.join(self.closes) + self.char * self.count + ''.join(self.opens)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _emphasis(nodes):
|
|
379
|
+
"""CommonMark's process-emphasis over the delimiter runs (no rule of three); every pair encloses a contiguous
|
|
380
|
+
stretch, so the tags nest. The opener stack grows as the runs are reached, so every removal is at its top, and a
|
|
381
|
+
failed search raises that character's floor: linear in the number of runs."""
|
|
382
|
+
stack, floor = [], {'*': -1, '_': -1}
|
|
383
|
+
for k, closer in enumerate(nodes):
|
|
384
|
+
if not isinstance(closer, _Delim):
|
|
385
|
+
continue
|
|
386
|
+
while closer.can_close and closer.count:
|
|
387
|
+
found = len(stack) - 1
|
|
388
|
+
while found >= 0 and stack[found] > floor[closer.char]:
|
|
389
|
+
candidate = nodes[stack[found]]
|
|
390
|
+
if candidate.char == closer.char and candidate.can_open:
|
|
391
|
+
break
|
|
392
|
+
found -= 1
|
|
393
|
+
else:
|
|
394
|
+
floor[closer.char] = k - 1
|
|
395
|
+
break
|
|
396
|
+
opener = nodes[stack[found]]
|
|
397
|
+
use = 2 if opener.count >= 2 and closer.count >= 2 else 1
|
|
398
|
+
tag = 'strong' if use == 2 else 'em'
|
|
399
|
+
opener.count -= use
|
|
400
|
+
closer.count -= use
|
|
401
|
+
opener.opens.insert(0, f'<{tag}>')
|
|
402
|
+
closer.closes.append(f'</{tag}>')
|
|
403
|
+
del stack[found + 1:] # the runs between the pair stay literal
|
|
404
|
+
if not opener.count:
|
|
405
|
+
stack.pop()
|
|
406
|
+
if closer.count and closer.can_open:
|
|
407
|
+
stack.append(k)
|
|
408
|
+
return ''.join(node.html() if isinstance(node, _Delim) else node for node in nodes)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _target(m):
|
|
412
|
+
"""(url or None, title or None) of a _DEST match, backslash escapes resolved."""
|
|
413
|
+
if m.group(1) is not None:
|
|
414
|
+
url = _ESCAPED.sub(r'\1', m.group(1)).replace(' ', '%20')
|
|
415
|
+
else:
|
|
416
|
+
url = _ESCAPED.sub(r'\1', m.group(2))
|
|
417
|
+
title = _ESCAPED.sub(r'\1', m.group(3)[1:-1]) if m.group(3) else None
|
|
418
|
+
return (url if url and not _BAD_URL.search(url) else None), title
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _title(title):
|
|
422
|
+
return f' title="{_esc(title)}"' if title else ''
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _href_ok(url):
|
|
426
|
+
"""Whether `url` (escapes resolved, _BAD_URL-free) may be a link: an allowed start, and no `.`/`..` segment in its
|
|
427
|
+
path — the part before `?` or `#`, after the authority of an http(s) URL (mailto: and # have none); a path on this
|
|
428
|
+
site (`/lanes-docs/…`) also no encoded slash or backslash (`%2f`, `%5c`), which a server may decode into one."""
|
|
429
|
+
if not _HREF.match(url):
|
|
430
|
+
return False
|
|
431
|
+
if url[:1] == '/':
|
|
432
|
+
path = url
|
|
433
|
+
if _ENCODED_SLASH.search(_PATH_END.split(path, 1)[0]):
|
|
434
|
+
return False
|
|
435
|
+
elif _WEB.match(url):
|
|
436
|
+
rest = url[url.index('//') + 2:]
|
|
437
|
+
start = min((at for at in (rest.find('/'), rest.find('?'), rest.find('#')) if at >= 0), default=len(rest))
|
|
438
|
+
path = rest[start:] if rest[start:start + 1] == '/' else ''
|
|
439
|
+
else:
|
|
440
|
+
return True
|
|
441
|
+
path = _PATH_END.split(path, 1)[0]
|
|
442
|
+
return not any(_DOT_SEGMENT.fullmatch(segment) for segment in path.split('/'))
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _link(s, opening, closing):
|
|
446
|
+
m = _DEST.match(s, closing + 1)
|
|
447
|
+
if not m:
|
|
448
|
+
return None
|
|
449
|
+
url, title = _target(m)
|
|
450
|
+
text = _inline(s[opening + 1:closing], in_link=True)
|
|
451
|
+
if url is None or not _href_ok(url):
|
|
452
|
+
return text, m.end() # the text stays, the destination is dropped
|
|
453
|
+
return f'<a href="{_esc(url)}"{_title(title)}{_EXTERNAL if _WEB.match(url) else ""}>{text}</a>', m.end()
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _image(s, opening, closing):
|
|
457
|
+
m = _DEST.match(s, closing + 1)
|
|
458
|
+
if not m:
|
|
459
|
+
return None
|
|
460
|
+
url, title = _target(m)
|
|
461
|
+
alt = _ESCAPED.sub(r'\1', s[opening + 1:closing])
|
|
462
|
+
if url is None or not _SRC.fullmatch(url):
|
|
463
|
+
return _esc(alt), m.end() # the alt text stays, the image is dropped
|
|
464
|
+
return f'<img src="{_esc(url)}" alt="{_esc(alt)}"{_title(title)}>', m.end()
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _autolink(s, at):
|
|
468
|
+
m = _AUTOLINK.match(s, at)
|
|
469
|
+
if m:
|
|
470
|
+
url = m.group(1)
|
|
471
|
+
if '\\' in url or not _href_ok(url):
|
|
472
|
+
return None # a scheme off the allowlist: the whole thing is text
|
|
473
|
+
return f'<a href="{_esc(url)}"{_EXTERNAL if _WEB.match(url) else ""}>{_esc(url)}</a>', m.end()
|
|
474
|
+
m = _EMAIL.match(s, at)
|
|
475
|
+
if m:
|
|
476
|
+
return f'<a href="mailto:{_esc(m.group(1))}">{_esc(m.group(1))}</a>', m.end()
|
|
477
|
+
return None
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _inline(s, in_link=False):
|
|
481
|
+
"""One block's inline text as HTML: text escaped once, the constructs above as tags."""
|
|
482
|
+
if _SPECIAL.search(s) is None:
|
|
483
|
+
return _esc(s)
|
|
484
|
+
runs = _runs(s) if '`' in s else {}
|
|
485
|
+
brackets = _brackets(s, runs) if '[' in s else {}
|
|
486
|
+
nodes, text, i, n, delims = [], [], 0, len(s), False
|
|
487
|
+
search, append = _SPECIAL.search, text.append
|
|
488
|
+
while i < n:
|
|
489
|
+
m = search(s, i)
|
|
490
|
+
if m is None:
|
|
491
|
+
append(s[i:])
|
|
492
|
+
break
|
|
493
|
+
j = m.start()
|
|
494
|
+
if j > i:
|
|
495
|
+
append(s[i:j])
|
|
496
|
+
c, done = s[j], None
|
|
497
|
+
if c == '\\':
|
|
498
|
+
if j + 1 < n and s[j + 1] in _ASCII_PUNCT:
|
|
499
|
+
append(s[j + 1])
|
|
500
|
+
i = j + 2
|
|
501
|
+
else:
|
|
502
|
+
append('\\')
|
|
503
|
+
i = j + 1
|
|
504
|
+
continue
|
|
505
|
+
if c == '`':
|
|
506
|
+
length = _RUN.match(s, j).end() - j
|
|
507
|
+
close = _closer(runs, j, length)
|
|
508
|
+
if close is None:
|
|
509
|
+
append('`' * length)
|
|
510
|
+
i = j + length
|
|
511
|
+
continue
|
|
512
|
+
code = s[j + length:close]
|
|
513
|
+
if len(code) > 1 and code[0] == ' ' and code[-1] == ' ' and code.strip(' '):
|
|
514
|
+
code = code[1:-1]
|
|
515
|
+
done = f'<code>{_esc(code)}</code>', close + length
|
|
516
|
+
elif c == '*' or c == '_':
|
|
517
|
+
end = _DELIMS.match(s, j).end()
|
|
518
|
+
run = _Delim(c, end - j, _class(s[j - 1]) if j else ' ', _class(s[end]) if end < n else ' ')
|
|
519
|
+
i = end
|
|
520
|
+
if run.can_open or run.can_close:
|
|
521
|
+
if text:
|
|
522
|
+
nodes.append(_esc(''.join(text)))
|
|
523
|
+
text.clear()
|
|
524
|
+
nodes.append(run)
|
|
525
|
+
delims = True
|
|
526
|
+
else:
|
|
527
|
+
append(s[j:end]) # can neither open nor close (snake_case, a * b): text
|
|
528
|
+
continue
|
|
529
|
+
elif c == '!' and j + 1 < n and s[j + 1] == '[' and j + 1 in brackets:
|
|
530
|
+
done = _image(s, j + 1, brackets[j + 1])
|
|
531
|
+
elif c == '[' and not in_link and j in brackets:
|
|
532
|
+
done = _link(s, j, brackets[j])
|
|
533
|
+
elif c == '<' and not in_link:
|
|
534
|
+
done = _autolink(s, j)
|
|
535
|
+
if done is None:
|
|
536
|
+
append(c)
|
|
537
|
+
i = j + 1
|
|
538
|
+
continue
|
|
539
|
+
if text:
|
|
540
|
+
nodes.append(_esc(''.join(text)))
|
|
541
|
+
text.clear()
|
|
542
|
+
nodes.append(done[0])
|
|
543
|
+
i = done[1]
|
|
544
|
+
if text:
|
|
545
|
+
nodes.append(_esc(''.join(text)))
|
|
546
|
+
return _emphasis(nodes) if delims else ''.join(nodes)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
# ---------------------------------------------------------------------------------------------------------------------
|
|
550
|
+
|
|
551
|
+
def render(text: str) -> str:
|
|
552
|
+
"""Markdown text -> an HTML fragment (see the module docstring for the subset and the safety contract)."""
|
|
553
|
+
if not isinstance(text, str):
|
|
554
|
+
raise TypeError(f'render() takes str, not {type(text).__name__}')
|
|
555
|
+
size = len(text.encode('utf-8', 'surrogatepass'))
|
|
556
|
+
if size > MAX_BYTES:
|
|
557
|
+
raise TooLarge(size)
|
|
558
|
+
text = re.sub('[\ud800-\udfff]', '\ufffd', text.replace('\r\n', '\n').replace('\r', '\n').replace('\0', '\ufffd'))
|
|
559
|
+
blocks = _blocks(text.split('\n'), 0)
|
|
560
|
+
return '\n'.join(blocks) + '\n' if blocks else ''
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def main(argv):
|
|
564
|
+
if len(argv) > 2:
|
|
565
|
+
sys.exit('usage: python3 mdlite.py [FILE]')
|
|
566
|
+
with (open(argv[1], encoding='utf-8') if len(argv) == 2 else sys.stdin) as source:
|
|
567
|
+
text = source.read()
|
|
568
|
+
try:
|
|
569
|
+
sys.stdout.write(render(text))
|
|
570
|
+
except TooLarge as error:
|
|
571
|
+
sys.exit(f'mdlite: {error}')
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
if __name__ == '__main__':
|
|
575
|
+
main(sys.argv)
|
|
@@ -16,6 +16,11 @@ One JSON file per enrolled device under ~/.local/state/lanes/members/ (directory
|
|
|
16
16
|
(`Tailscale-User-Login`; outside request mode the invite's email, or its name when that is a handle), `name`
|
|
17
17
|
and `email` may be null. `via` is invite|request|manual; an invite record carries its `invite_id`, a request
|
|
18
18
|
record its `request_id`, a manual one neither. `approved_by` is a login or null; `workspaces` lists board ids.
|
|
19
|
+
What `workspaces` means (DEF-1852): only the docs service reads it (docsd.member_workspaces: the boards whose
|
|
20
|
+
shared documents and upload lanes a member gets; a view shows every board whatever it says). A record that lists
|
|
21
|
+
boards — an invite's `--workspaces`, copied at redeem — grants those; a record whose list is empty or absent — a
|
|
22
|
+
key added by hand (`lanes keys add|adopt`, the operator's own case), a request, an invite minted without
|
|
23
|
+
`--workspaces` — grants every workspace the host serves. An empty list therefore never means "no board".
|
|
19
24
|
* `invited_as` (optional, additive; an invite record only) is the invite's own {name, email} when a TRUSTED tailnet
|
|
20
25
|
caller redeemed it (request mode): the owner is then the caller, whoever the invite was minted for, and this keeps
|
|
21
26
|
who it was minted for beside it. Absent when no trusted caller redeemed (the owner then came from the invite).
|