@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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +4 -3
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +301 -45
  5. package/payload/lanes/container/lane +42 -1
  6. package/payload/lanes/container/lane-mcp +65 -6
  7. package/payload/lanes/container/lane_core.py +178 -0
  8. package/payload/lanes/docs/ARCHITECTURE.md +192 -20
  9. package/payload/lanes/docs/DESIGN.md +6 -3
  10. package/payload/lanes/docs/INSTALL.md +25 -8
  11. package/payload/lanes/docs/RUNBOOK.md +369 -8
  12. package/payload/lanes/docs/USERGUIDE.md +201 -25
  13. package/payload/lanes/holders.lock +5 -0
  14. package/payload/lanes/install/enable-docs.sh +78 -0
  15. package/payload/lanes/install/host-install.sh +39 -5
  16. package/payload/lanes/libexec/docs_store.py +995 -0
  17. package/payload/lanes/libexec/docsd.py +2010 -0
  18. package/payload/lanes/libexec/mdlite.py +575 -0
  19. package/payload/lanes/libexec/members.py +5 -0
  20. package/payload/lanes/libexec/notify.py +65 -5
  21. package/payload/lanes/libexec/sidebar.py +106 -13
  22. package/payload/lanes/libexec/statusd.py +97 -5
  23. package/payload/lanes/libexec/tokens.py +31 -9
  24. package/payload/lanes/libexec/update.py +94 -22
  25. package/payload/lanes/libexec/uploads.py +538 -0
  26. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  27. package/payload/lanes/web/Caddyfile +24 -1
  28. package/payload/lanes/web/README.md +86 -8
  29. package/payload/lanes/web/browser.js +2 -4
  30. package/payload/lanes/web/docs.css +52 -0
  31. package/payload/lanes/web/docs.js +36 -0
  32. package/payload/lanes/web/mobile.js +3 -2
  33. package/payload/lanes/web/upload.css +17 -0
  34. package/payload/lanes/web/upload.js +84 -0
  35. 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 (&lt; 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, ![alt](src "title"). 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).