xtxt 0.1.0__tar.gz

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.
xtxt-0.1.0/.gitignore ADDED
@@ -0,0 +1,12 @@
1
+ /xtxt
2
+ *.html
3
+ !docs/*.html
4
+ !editors/**/*.html
5
+ .DS_Store
6
+ .claude/
7
+ __pycache__/
8
+ .pytest_cache/
9
+ node_modules/
10
+ *.egg-info/
11
+ sdk/python/dist/
12
+ sdk/js/*.tgz
xtxt-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SaiMouli3
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.
xtxt-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: xtxt
3
+ Version: 0.1.0
4
+ Summary: Parser and renderer for the XTXT plain-text document format
5
+ Project-URL: Homepage, https://github.com/SaiMouli3/xtxt
6
+ Project-URL: Specification, https://github.com/SaiMouli3/xtxt/blob/main/SPEC.md
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 SaiMouli3
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+ License-File: LICENSE
29
+ Keywords: document,format,markdown,parser,xtxt
30
+ Classifier: Development Status :: 3 - Alpha
31
+ Classifier: License :: OSI Approved :: MIT License
32
+ Classifier: Programming Language :: Python :: 3
33
+ Classifier: Topic :: Text Processing :: Markup
34
+ Requires-Python: >=3.10
35
+ Description-Content-Type: text/markdown
36
+
37
+ # xtxt (Python)
38
+
39
+ Parser, validator, extractor and HTML renderer for the
40
+ [XTXT](https://github.com/SaiMouli3/xtxt) plain-text document format.
41
+ Pure Python, no dependencies.
42
+
43
+ ```python
44
+ import xtxt
45
+
46
+ res = xtxt.parse_file("notes.xtxt")
47
+ for issue in xtxt.validate(res.doc):
48
+ print(issue.line, issue.severity, issue.message)
49
+
50
+ data = xtxt.extract(res.doc) # outline, tasks, blocks, links, media, code
51
+ html = xtxt.render_html(res.doc, full=True)
52
+ ```
53
+
54
+ ```sh
55
+ python -m xtxt extract notes.xtxt
56
+ ```
57
+
58
+ This is a port of the Go reference implementation, not a binding. Both are
59
+ checked against the same fixtures in `conformance/`.
xtxt-0.1.0/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # xtxt (Python)
2
+
3
+ Parser, validator, extractor and HTML renderer for the
4
+ [XTXT](https://github.com/SaiMouli3/xtxt) plain-text document format.
5
+ Pure Python, no dependencies.
6
+
7
+ ```python
8
+ import xtxt
9
+
10
+ res = xtxt.parse_file("notes.xtxt")
11
+ for issue in xtxt.validate(res.doc):
12
+ print(issue.line, issue.severity, issue.message)
13
+
14
+ data = xtxt.extract(res.doc) # outline, tasks, blocks, links, media, code
15
+ html = xtxt.render_html(res.doc, full=True)
16
+ ```
17
+
18
+ ```sh
19
+ python -m xtxt extract notes.xtxt
20
+ ```
21
+
22
+ This is a port of the Go reference implementation, not a binding. Both are
23
+ checked against the same fixtures in `conformance/`.
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "xtxt"
7
+ version = "0.1.0"
8
+ description = "Parser and renderer for the XTXT plain-text document format"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { file = "LICENSE" }
12
+ keywords = ["xtxt", "document", "format", "parser", "markdown"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Text Processing :: Markup",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/SaiMouli3/xtxt"
22
+ Specification = "https://github.com/SaiMouli3/xtxt/blob/main/SPEC.md"
23
+
24
+ [tool.hatch.build]
25
+ # Do not consult .gitignore. The repo root ignores `/xtxt` (the built CLI
26
+ # binary), and hatchling applies that pattern relative to this directory —
27
+ # which silently excluded the package source and produced an empty wheel.
28
+ ignore-vcs = true
29
+ exclude = ["test_conformance.py", "dist", "*.pyc", "__pycache__"]
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["xtxt"]
33
+
34
+ [tool.hatch.build.targets.sdist]
35
+ include = ["xtxt", "README.md", "LICENSE", "pyproject.toml"]
@@ -0,0 +1,1090 @@
1
+ """XTXT — a plain-text document format with structure.
2
+
3
+ A pure-Python implementation of the format defined in SPEC.md. No dependencies.
4
+
5
+ >>> import xtxt
6
+ >>> doc = xtxt.parse("# Hello\\n\\nworld\\n").doc
7
+ >>> [n.kind for n in doc.nodes]
8
+ ['heading', 'paragraph']
9
+
10
+ This is a port, not a binding: it agrees with the Go reference implementation
11
+ because both are checked against the same conformance suite, not because one
12
+ calls the other.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import html as _html
18
+ import json
19
+ import re
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any, Iterable
23
+
24
+ __version__ = "0.1.0"
25
+ __all__ = [
26
+ "Arg", "Item", "Node", "Document", "Issue", "Result",
27
+ "parse", "parse_file", "validate", "extract", "canonical",
28
+ "render_html", "parse_table", "parse_fields", "inline_html", "inline_text",
29
+ ]
30
+
31
+ # --------------------------------------------------------------------------
32
+ # Model
33
+ # --------------------------------------------------------------------------
34
+
35
+
36
+ @dataclass
37
+ class Arg:
38
+ key: str
39
+ value: str
40
+
41
+
42
+ @dataclass
43
+ class Item:
44
+ text: str
45
+ ordered: bool = False
46
+ checked: bool | None = None
47
+
48
+
49
+ class Args(list):
50
+ """A directive's arguments, in source order."""
51
+
52
+ def get(self, key: str) -> str:
53
+ for a in self:
54
+ if a.key == key:
55
+ return a.value
56
+ return ""
57
+
58
+ def has(self, key: str) -> bool:
59
+ return any(a.key == key for a in self)
60
+
61
+ def positional(self, i: int) -> str:
62
+ n = 0
63
+ for a in self:
64
+ if a.key == "":
65
+ if n == i:
66
+ return a.value
67
+ n += 1
68
+ return ""
69
+
70
+ def resolve(self, key: str) -> str:
71
+ """The named argument, falling back to the first positional one."""
72
+ return self.get(key) or self.positional(0)
73
+
74
+
75
+ @dataclass
76
+ class Node:
77
+ kind: str
78
+ name: str = ""
79
+ level: int = 0
80
+ text: str = ""
81
+ args: Args = field(default_factory=Args)
82
+ items: list[Item] = field(default_factory=list)
83
+ line: int = 0
84
+
85
+ def fields(self) -> "Fields":
86
+ """The payload read as an ordered `Key: value` record."""
87
+ if self.kind != "block":
88
+ return Fields()
89
+ return parse_fields(self.text)
90
+
91
+
92
+ @dataclass
93
+ class Document:
94
+ version: str = ""
95
+ nodes: list[Node] = field(default_factory=list)
96
+
97
+ def metadata(self) -> dict[str, str]:
98
+ for n in self.nodes:
99
+ if n.kind == "block" and n.name == "metadata":
100
+ return _parse_metadata(n.text)
101
+ return {}
102
+
103
+
104
+ @dataclass
105
+ class Issue:
106
+ severity: str # "error" or "warning"
107
+ line: int
108
+ message: str
109
+
110
+
111
+ @dataclass
112
+ class Result:
113
+ doc: Document
114
+ issues: list[Issue] = field(default_factory=list)
115
+
116
+ def has_errors(self) -> bool:
117
+ return any(i.severity == "error" for i in self.issues)
118
+
119
+
120
+ # --------------------------------------------------------------------------
121
+ # Parser
122
+ # --------------------------------------------------------------------------
123
+
124
+ _NAME_START = re.compile(r"[A-Za-z_]")
125
+ _NAME_BYTE = re.compile(r"[A-Za-z0-9_-]")
126
+
127
+ FENCED_BY_DEFAULT = {
128
+ "code", "table", "math", "mermaid", "metadata", "comment", "raw",
129
+ }
130
+
131
+
132
+ def parse_file(path: str | Path) -> Result:
133
+ return parse(Path(path).read_text(encoding="utf-8"))
134
+
135
+
136
+ def parse(src: str) -> Result:
137
+ return _Parser(_read_lines(src)).run()
138
+
139
+
140
+ def _read_lines(src: str) -> list[str]:
141
+ lines = src.replace("\r\n", "\n").split("\n")
142
+ if lines and lines[-1] == "":
143
+ lines.pop()
144
+ if lines:
145
+ lines[0] = lines[0].lstrip("")
146
+ return lines
147
+
148
+
149
+ def _is_blank(s: str) -> bool:
150
+ return s.strip() == ""
151
+
152
+
153
+ def _heading_level(s: str) -> int:
154
+ s = s.lstrip(" \t")
155
+ n = 0
156
+ while n < len(s) and s[n] == "#":
157
+ n += 1
158
+ if n == 0 or n > 6 or n >= len(s) or s[n] != " ":
159
+ return 0
160
+ return n
161
+
162
+
163
+ def _is_directive(s: str) -> bool:
164
+ t = s.lstrip(" \t")
165
+ return len(t) >= 2 and t[0] == "@" and bool(_NAME_START.match(t[1]))
166
+
167
+
168
+ def _directive_name(s: str) -> tuple[str, str]:
169
+ t = s.lstrip(" \t")
170
+ i = 1
171
+ while i < len(t) and _NAME_BYTE.match(t[i]):
172
+ i += 1
173
+ return t[1:i], t[i:]
174
+
175
+
176
+ def _item_prefix(s: str) -> str:
177
+ t = s.lstrip(" \t")
178
+ if len(t) >= 2 and t[0] in "-*" and t[1] == " ":
179
+ return t[:2]
180
+ i = 0
181
+ while i < len(t) and t[i].isdigit():
182
+ i += 1
183
+ if i > 0 and i + 1 < len(t) and t[i] == "." and t[i + 1] == " ":
184
+ return t[: i + 2]
185
+ return ""
186
+
187
+
188
+ class _Parser:
189
+ def __init__(self, lines: list[str]):
190
+ self.lines = lines
191
+ self.i = 0
192
+ self.doc = Document()
193
+ self.issues: list[Issue] = []
194
+
195
+ def run(self) -> Result:
196
+ while self.i < len(self.lines):
197
+ line = self.lines[self.i]
198
+ if _is_blank(line):
199
+ self.i += 1
200
+ elif _is_directive(line):
201
+ self._directive()
202
+ elif _heading_level(line) > 0:
203
+ lvl = _heading_level(line)
204
+ self._emit(Node("heading", level=lvl,
205
+ text=line.lstrip(" \t")[lvl:].strip(), line=self.i + 1))
206
+ self.i += 1
207
+ elif line.strip().startswith(">"):
208
+ self._quote()
209
+ elif _item_prefix(line):
210
+ self._list()
211
+ else:
212
+ self._paragraph()
213
+ return Result(self.doc, self.issues)
214
+
215
+ def _emit(self, n: Node) -> None:
216
+ self.doc.nodes.append(n)
217
+
218
+ def _err(self, line: int, msg: str) -> None:
219
+ self.issues.append(Issue("error", line, msg))
220
+
221
+ # -- directives --------------------------------------------------------
222
+
223
+ def _directive(self) -> None:
224
+ start = self.i
225
+ name, rest = _directive_name(self.lines[self.i])
226
+
227
+ args, ok = self._args(rest)
228
+ if not ok:
229
+ self._err(start + 1, f"unclosed argument list for @{name}")
230
+ self.i = start + 1
231
+ return
232
+
233
+ if name.startswith("end"):
234
+ self._err(start + 1, f"@{name} has no matching opening fence")
235
+ self.i += 1
236
+ return
237
+
238
+ if name == "xtxt" and not self.doc.nodes:
239
+ self.doc.version = args.resolve("version")
240
+ self.i += 1
241
+ return
242
+
243
+ end = self._find_fence(name, self.i + 1)
244
+ if end >= 0:
245
+ body = self.lines[self.i + 1: end]
246
+ self.i = end + 1
247
+ self._emit(Node("block", name=name, args=args,
248
+ text=_trim_fence_body(body), line=start + 1))
249
+ return
250
+
251
+ if name in FENCED_BY_DEFAULT:
252
+ self._err(start + 1, f"unclosed @{name} block: no matching @end{name}")
253
+ self.i += 1
254
+ self._emit(Node("directive", name=name, args=args, line=start + 1))
255
+
256
+ def _find_fence(self, name: str, frm: int) -> int:
257
+ closer = "@end" + name
258
+ for j in range(frm, len(self.lines)):
259
+ if self.lines[j].rstrip(" \t") == closer:
260
+ return j
261
+ return -1
262
+
263
+ def _args(self, rest: str) -> tuple[Args, bool]:
264
+ trimmed = rest.strip()
265
+ if not trimmed.startswith("("):
266
+ return Args(), True
267
+ buf = trimmed
268
+ line = self.i
269
+ while True:
270
+ inner, ok = _balanced(buf)
271
+ if ok:
272
+ self.i = line
273
+ return _split_args(inner), True
274
+ line += 1
275
+ if line >= len(self.lines):
276
+ return Args(), False
277
+ buf += "\n" + self.lines[line]
278
+
279
+ # -- text blocks -------------------------------------------------------
280
+
281
+ def _quote(self) -> None:
282
+ start = self.i
283
+ parts = []
284
+ while self.i < len(self.lines):
285
+ t = self.lines[self.i].strip()
286
+ if not t.startswith(">"):
287
+ break
288
+ parts.append(t[1:].strip())
289
+ self.i += 1
290
+ self._emit(Node("quote", text=" ".join(parts), line=start + 1))
291
+
292
+ def _list(self) -> None:
293
+ start = self.i
294
+ items: list[Item] = []
295
+ while self.i < len(self.lines):
296
+ pre = _item_prefix(self.lines[self.i])
297
+ if not pre:
298
+ break
299
+ body = self.lines[self.i].lstrip(" \t")[len(pre):].strip()
300
+ it = Item(text="", ordered=pre[0].isdigit())
301
+ if len(body) >= 3 and body[0] == "[" and body[2] == "]" and body[1] in " xX":
302
+ it.checked = body[1] != " "
303
+ body = body[3:].strip()
304
+ it.text = body
305
+ items.append(it)
306
+ self.i += 1
307
+ self._emit(Node("list", items=items, line=start + 1))
308
+
309
+ def _paragraph(self) -> None:
310
+ start = self.i
311
+ parts = []
312
+ while self.i < len(self.lines):
313
+ line = self.lines[self.i]
314
+ if (_is_blank(line) or _heading_level(line) > 0 or _is_directive(line)
315
+ or _item_prefix(line) or line.strip().startswith(">")):
316
+ break
317
+ t = line.strip()
318
+ if t.startswith("\\@"):
319
+ t = t[1:]
320
+ parts.append(t)
321
+ self.i += 1
322
+ self._emit(Node("paragraph", text=" ".join(parts), line=start + 1))
323
+
324
+
325
+ def _balanced(s: str) -> tuple[str, bool]:
326
+ depth = 0
327
+ in_quote = esc = False
328
+ for i, c in enumerate(s):
329
+ if esc:
330
+ esc = False
331
+ elif c == "\\":
332
+ esc = True
333
+ elif in_quote:
334
+ if c == '"':
335
+ in_quote = False
336
+ elif c == '"':
337
+ in_quote = True
338
+ elif c == "(":
339
+ depth += 1
340
+ elif c == ")":
341
+ depth -= 1
342
+ if depth == 0:
343
+ return s[1:i], True
344
+ return "", False
345
+
346
+
347
+ def _split_top(s: str, sep: str) -> list[str]:
348
+ out = []
349
+ depth = 0
350
+ in_quote = esc = False
351
+ start = 0
352
+ for i, c in enumerate(s):
353
+ if esc:
354
+ esc = False
355
+ elif c == "\\":
356
+ esc = True
357
+ elif in_quote:
358
+ if c == '"':
359
+ in_quote = False
360
+ elif c == '"':
361
+ in_quote = True
362
+ elif c == "(":
363
+ depth += 1
364
+ elif c == ")":
365
+ depth -= 1
366
+ elif c == sep and depth == 0:
367
+ out.append(s[start:i])
368
+ start = i + 1
369
+ out.append(s[start:])
370
+ return out
371
+
372
+
373
+ def _is_name(s: str) -> bool:
374
+ s = s.strip()
375
+ return bool(s) and bool(_NAME_START.match(s[0])) and all(_NAME_BYTE.match(c) for c in s[1:])
376
+
377
+
378
+ def _split_args(s: str) -> Args:
379
+ args = Args()
380
+ for fieldstr in _split_top(s, ","):
381
+ fieldstr = fieldstr.strip()
382
+ if not fieldstr:
383
+ continue
384
+ parts = _split_top(fieldstr, "=")
385
+ key, val = "", fieldstr
386
+ if len(parts) >= 2 and _is_name(parts[0]):
387
+ key = parts[0].strip()
388
+ val = fieldstr[len(parts[0]) + 1:].strip()
389
+ args.append(Arg(key, _unquote(val)))
390
+ return args
391
+
392
+
393
+ def _unquote(s: str) -> str:
394
+ s = s.strip()
395
+ if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
396
+ return _unescape(s[1:-1])
397
+ return s
398
+
399
+
400
+ def _unescape(s: str) -> str:
401
+ if "\\" not in s:
402
+ return s
403
+ out = []
404
+ i = 0
405
+ while i < len(s):
406
+ if s[i] == "\\" and i + 1 < len(s):
407
+ i += 1
408
+ out.append({"n": "\n", "t": "\t"}.get(s[i], s[i]))
409
+ else:
410
+ out.append(s[i])
411
+ i += 1
412
+ return "".join(out)
413
+
414
+
415
+ def _trim_fence_body(body: list[str]) -> str:
416
+ body = list(body)
417
+ if body and _is_blank(body[0]):
418
+ body.pop(0)
419
+ if body and _is_blank(body[-1]):
420
+ body.pop()
421
+ return "\n".join(
422
+ l.replace("\\@end", "@end", 1) if l.lstrip(" \t").startswith("\\@end") else l
423
+ for l in body
424
+ )
425
+
426
+
427
+ def _parse_metadata(payload: str) -> dict[str, str]:
428
+ out: dict[str, str] = {}
429
+ for line in payload.split("\n"):
430
+ if _is_blank(line) or "=" not in line:
431
+ continue
432
+ k, v = line.split("=", 1)
433
+ out[k.strip().lower()] = v.strip()
434
+ return out
435
+
436
+
437
+ # --------------------------------------------------------------------------
438
+ # Tables
439
+ # --------------------------------------------------------------------------
440
+
441
+
442
+ @dataclass
443
+ class Table:
444
+ header: list[str] = field(default_factory=list)
445
+ rows: list[list[str]] = field(default_factory=list)
446
+ align: list[str] = field(default_factory=list)
447
+
448
+
449
+ def _split_cells(line: str) -> list[str]:
450
+ return [c.strip() for c in line.strip().strip("|").split("|")]
451
+
452
+
453
+ def _is_separator_row(cells: list[str]) -> bool:
454
+ return bool(cells) and all(c and not c.strip("-:") for c in cells)
455
+
456
+
457
+ def parse_table(n: Node) -> Table:
458
+ t = Table()
459
+ rows: list[list[str]] = []
460
+ sep_at = -1
461
+ for line in n.text.split("\n"):
462
+ if _is_blank(line):
463
+ continue
464
+ cells = _split_cells(line)
465
+ if sep_at < 0 and _is_separator_row(cells):
466
+ sep_at = len(rows)
467
+ t.align = [
468
+ "center" if c.startswith(":") and c.endswith(":")
469
+ else "right" if c.endswith(":") else "left"
470
+ for c in cells
471
+ ]
472
+ continue
473
+ rows.append(cells)
474
+ if not rows:
475
+ return t
476
+ if sep_at <= 0:
477
+ sep_at = 1
478
+ t.header = rows[sep_at - 1] if sep_at > 1 else rows[0]
479
+ t.rows = rows[sep_at:]
480
+ return t
481
+
482
+
483
+ # --------------------------------------------------------------------------
484
+ # Records (Key: value payloads)
485
+ # --------------------------------------------------------------------------
486
+
487
+
488
+ class Fields(list):
489
+ """An ordered `Key: value` record. Order matters: a @chat block's turns
490
+ are fields, and their sequence is the conversation."""
491
+
492
+ def get(self, key: str) -> str:
493
+ for f in self:
494
+ if f.key.lower() == key.lower():
495
+ return f.value
496
+ return ""
497
+
498
+ def map(self) -> dict[str, str]:
499
+ out: dict[str, str] = {}
500
+ for f in self:
501
+ out.setdefault(f.key.lower(), f.value)
502
+ return out
503
+
504
+
505
+ @dataclass
506
+ class Field:
507
+ key: str
508
+ value: str
509
+
510
+
511
+ # A field key is a label, not a sentence: these caps are what keep ordinary
512
+ # prose containing a colon from being read as a record field.
513
+ MAX_FIELD_KEY_LEN = 32
514
+ MAX_FIELD_KEY_WORDS = 3
515
+
516
+
517
+ def _is_field_line(line: str) -> tuple[str, str, bool]:
518
+ for i, c in enumerate(line):
519
+ if i > MAX_FIELD_KEY_LEN:
520
+ break
521
+ if c in ":=":
522
+ k = line[:i].strip()
523
+ if not k or not _NAME_START.match(k[0]) or len(k.split()) > MAX_FIELD_KEY_WORDS:
524
+ return "", "", False
525
+ return k, line[i + 1:].strip(), True
526
+ if not (c in " _-." or _NAME_BYTE.match(c)):
527
+ return "", "", False
528
+ return "", "", False
529
+
530
+
531
+ def parse_fields(payload: str) -> Fields:
532
+ out = Fields()
533
+ cur: Field | None = None
534
+ preamble: list[str] = []
535
+ for line in payload.split("\n"):
536
+ k, v, ok = _is_field_line(line)
537
+ if ok:
538
+ cur = Field(k, v)
539
+ out.append(cur)
540
+ continue
541
+ if cur is None:
542
+ preamble.append(line)
543
+ continue
544
+ cur.value = line.strip() if cur.value == "" else cur.value + "\n" + line
545
+ for f in out:
546
+ f.value = f.value.strip()
547
+ text = "\n".join(preamble).strip()
548
+ if text:
549
+ out.insert(0, Field("", text))
550
+ return out
551
+
552
+
553
+ # --------------------------------------------------------------------------
554
+ # Inline formatting
555
+ # --------------------------------------------------------------------------
556
+
557
+
558
+ def _footnote_ref(s: str, i: int) -> tuple[str, int, bool]:
559
+ if i + 2 >= len(s) or s[i + 1] != "^":
560
+ return "", 0, False
561
+ close = s.find("]", i + 2)
562
+ if close <= i + 2 - 1 or close < 0:
563
+ return "", 0, False
564
+ ident = s[i + 2:close]
565
+ if not ident or " " in ident or "\t" in ident:
566
+ return "", 0, False
567
+ return ident, close, True
568
+
569
+
570
+ def _find_close(s: str, frm: int, mark: str) -> int:
571
+ i = frm
572
+ while i + len(mark) <= len(s):
573
+ if s[i] == "\\":
574
+ i += 2
575
+ continue
576
+ if s.startswith(mark, i):
577
+ return -1 if i == frm else i
578
+ i += 1
579
+ return -1
580
+
581
+
582
+ def _link(s: str, i: int) -> tuple[str, str, int, bool]:
583
+ close = _find_close(s, i + 1, "]")
584
+ if close < 0 or close + 1 >= len(s) or s[close + 1] != "(":
585
+ return "", "", 0, False
586
+ inner, ok = _balanced(s[close + 1:])
587
+ if not ok:
588
+ return "", "", 0, False
589
+ return s[i + 1:close], inner.strip(), close + 1 + len(inner) + 1, True
590
+
591
+
592
+ def inline_html(s: str) -> str:
593
+ """Convert inline markup to HTML, escaping everything else."""
594
+ out: list[str] = []
595
+ i = 0
596
+ while i < len(s):
597
+ c = s[i]
598
+ if c == "\\" and i + 1 < len(s):
599
+ i += 1
600
+ out.append(_html.escape(s[i], quote=False))
601
+ elif c == "`":
602
+ end = s.find("`", i + 1)
603
+ if end >= 0:
604
+ out.append("<code>" + _html.escape(s[i + 1:end], quote=False) + "</code>")
605
+ i = end
606
+ else:
607
+ out.append("&#96;")
608
+ elif c == "*":
609
+ mark = "**" if s.startswith("**", i) else "*"
610
+ tag = "strong" if mark == "**" else "em"
611
+ end = _find_close(s, i + len(mark), mark)
612
+ if end >= 0:
613
+ out.append(f"<{tag}>{inline_html(s[i + len(mark):end])}</{tag}>")
614
+ i = end + len(mark) - 1
615
+ else:
616
+ out.append("*")
617
+ elif c == "[":
618
+ ident, end, ok = _footnote_ref(s, i)
619
+ if ok:
620
+ e = _html.escape(ident, quote=True)
621
+ out.append(f'<sup class="fnref" id="fnref-{e}"><a href="#fn-{e}">{e}</a></sup>')
622
+ i = end
623
+ else:
624
+ label, target, end, ok = _link(s, i)
625
+ if ok:
626
+ out.append(f'<a href="{_html.escape(target, quote=True)}">{inline_html(label)}</a>')
627
+ i = end
628
+ else:
629
+ out.append("[")
630
+ else:
631
+ out.append(_html.escape(c, quote=False))
632
+ i += 1
633
+ return "".join(out)
634
+
635
+
636
+ def inline_text(s: str) -> str:
637
+ """Strip inline markup, for plain text and analysis."""
638
+ out: list[str] = []
639
+ i = 0
640
+ while i < len(s):
641
+ c = s[i]
642
+ if c == "\\" and i + 1 < len(s):
643
+ i += 1
644
+ out.append(s[i])
645
+ elif c == "`":
646
+ end = s.find("`", i + 1)
647
+ if end >= 0:
648
+ out.append(s[i + 1:end])
649
+ i = end
650
+ else:
651
+ out.append(c)
652
+ elif c == "*":
653
+ mark = "**" if s.startswith("**", i) else "*"
654
+ end = _find_close(s, i + len(mark), mark)
655
+ if end >= 0:
656
+ out.append(inline_text(s[i + len(mark):end]))
657
+ i = end + len(mark) - 1
658
+ else:
659
+ out.append(c)
660
+ elif c == "[":
661
+ ident, end, ok = _footnote_ref(s, i)
662
+ if ok:
663
+ out.append(f"[{ident}]")
664
+ i = end
665
+ else:
666
+ label, _t, end, ok = _link(s, i)
667
+ if ok:
668
+ out.append(inline_text(label))
669
+ i = end
670
+ else:
671
+ out.append(c)
672
+ else:
673
+ out.append(c)
674
+ i += 1
675
+ return "".join(out)
676
+
677
+
678
+ # --------------------------------------------------------------------------
679
+ # Validation
680
+ # --------------------------------------------------------------------------
681
+
682
+ KNOWN: dict[str, bool] = {
683
+ "xtxt": False, "image": False, "video": False, "audio": False,
684
+ "attachment": False, "include": False, "embed": False, "hr": False,
685
+ "code": True, "table": True, "math": True, "mermaid": True,
686
+ "metadata": True, "comment": True, "raw": True, "chart": True,
687
+ "footnote": True,
688
+ "task": True, "decision": True, "knowledge": True,
689
+ "ai": True, "prompt": True, "chat": True, "note": True,
690
+ }
691
+
692
+ _REQUIRED_SRC = {"image", "video", "audio", "attachment", "include"}
693
+
694
+
695
+ def validate(doc: Document) -> list[Issue]:
696
+ """Semantic checks on top of the parser's syntactic ones."""
697
+ issues: list[Issue] = []
698
+
699
+ def warn(line: int, msg: str) -> None:
700
+ issues.append(Issue("warning", line, msg))
701
+
702
+ metadata_seen = False
703
+ notes: dict[str, int] = {}
704
+
705
+ for n in doc.nodes:
706
+ if n.kind not in ("directive", "block"):
707
+ continue
708
+ if n.name not in KNOWN:
709
+ warn(n.line, f"unknown directive @{n.name} (preserved, but this reader cannot render it)")
710
+ continue
711
+ fenced = KNOWN[n.name]
712
+ if fenced and n.kind != "block":
713
+ warn(n.line, f"@{n.name} is a block directive and should be closed with @end{n.name}")
714
+ if not fenced and n.kind == "block":
715
+ warn(n.line, f"@{n.name} is not a block directive but was closed with @end{n.name}")
716
+ if n.name in _REQUIRED_SRC and not n.args.resolve("src"):
717
+ warn(n.line, f"@{n.name} has no src")
718
+ if n.name == "metadata":
719
+ if metadata_seen:
720
+ warn(n.line, "duplicate @metadata block")
721
+ metadata_seen = True
722
+ elif n.name == "table":
723
+ t = parse_table(n)
724
+ if not t.header:
725
+ warn(n.line, "@table is empty")
726
+ else:
727
+ for i, row in enumerate(t.rows):
728
+ if len(row) != len(t.header):
729
+ warn(n.line + 1 + i,
730
+ f"table row has {len(row)} cells, header has {len(t.header)}")
731
+ elif n.name == "code":
732
+ if not n.args.resolve("language"):
733
+ warn(n.line, "@code has no language; syntax highlighting will be skipped")
734
+ elif n.name == "chart":
735
+ if not _chart_rows(n):
736
+ warn(n.line, "@chart has no readable data rows")
737
+ elif n.name == "task":
738
+ if not n.fields().get("title"):
739
+ warn(n.line, "@task has no Title field")
740
+ elif n.name == "footnote":
741
+ ident = n.args.resolve("id")
742
+ if not ident:
743
+ warn(n.line, "@footnote has no id; references cannot point at it")
744
+ notes[ident] = n.line
745
+
746
+ issues.extend(_check_footnote_refs(doc, notes))
747
+ return issues
748
+
749
+
750
+ def _check_footnote_refs(doc: Document, notes: dict[str, int]) -> list[Issue]:
751
+ issues: list[Issue] = []
752
+ cited: set[str] = set()
753
+
754
+ def visit(text: str, line: int) -> None:
755
+ i = 0
756
+ while i < len(text):
757
+ if text[i] == "\\":
758
+ i += 2
759
+ continue
760
+ if text[i] == "[":
761
+ ident, end, ok = _footnote_ref(text, i)
762
+ if ok:
763
+ cited.add(ident)
764
+ if ident not in notes:
765
+ issues.append(Issue("warning", line,
766
+ f'footnote reference [^{ident}] has no matching '
767
+ f'@footnote(id="{ident}")'))
768
+ i = end
769
+ i += 1
770
+
771
+ for n in doc.nodes:
772
+ if n.kind in ("heading", "paragraph", "quote"):
773
+ visit(n.text, n.line)
774
+ elif n.kind == "list":
775
+ for it in n.items:
776
+ visit(it.text, n.line)
777
+ for ident, line in notes.items():
778
+ if ident and ident not in cited:
779
+ issues.append(Issue("warning", line, f'@footnote(id="{ident}") is never referenced'))
780
+ return issues
781
+
782
+
783
+ def _chart_rows(n: Node) -> list[list[str]]:
784
+ rows = []
785
+ for line in n.text.split("\n"):
786
+ if _is_blank(line):
787
+ continue
788
+ cells = _split_cells(line) if "|" in line else line.rsplit(None, 1)
789
+ if len(cells) >= 2 and not _is_separator_row(cells):
790
+ rows.append([c.strip() for c in cells])
791
+ return rows
792
+
793
+
794
+ def sort_issues(issues: Iterable[Issue]) -> list[Issue]:
795
+ return sorted(issues, key=lambda i: i.line)
796
+
797
+
798
+ # --------------------------------------------------------------------------
799
+ # Extraction — the machine-facing view
800
+ # --------------------------------------------------------------------------
801
+
802
+ _PRESENTATIONAL = {
803
+ "code", "table", "math", "mermaid", "metadata", "comment", "raw",
804
+ "image", "video", "audio", "attachment", "hr", "xtxt", "include",
805
+ "embed", "footnote",
806
+ }
807
+
808
+
809
+ def _links_in(s: str, line: int) -> list[dict[str, Any]]:
810
+ out = []
811
+ i = 0
812
+ while i < len(s):
813
+ if s[i] == "\\":
814
+ i += 2
815
+ continue
816
+ if s[i] == "[":
817
+ label, target, end, ok = _link(s, i)
818
+ if ok:
819
+ out.append({"text": inline_text(label), "href": target, "line": line})
820
+ i = end
821
+ i += 1
822
+ return out
823
+
824
+
825
+ def extract(doc: Document) -> dict[str, Any]:
826
+ """Everything an agent needs without inferring structure from prose."""
827
+ out: dict[str, Any] = {
828
+ "version": doc.version, "metadata": doc.metadata(),
829
+ "outline": [], "tasks": [], "blocks": [], "links": [],
830
+ "media": [], "code": [], "text": "", "words": 0,
831
+ }
832
+ prose: list[str] = []
833
+
834
+ for n in doc.nodes:
835
+ if n.kind == "heading":
836
+ text = inline_text(n.text)
837
+ out["outline"].append({"level": n.level, "text": text, "line": n.line})
838
+ prose.append(text)
839
+ out["links"].extend(_links_in(n.text, n.line))
840
+ elif n.kind in ("paragraph", "quote"):
841
+ prose.append(inline_text(n.text))
842
+ out["links"].extend(_links_in(n.text, n.line))
843
+ elif n.kind == "list":
844
+ for it in n.items:
845
+ prose.append(inline_text(it.text))
846
+ out["links"].extend(_links_in(it.text, n.line))
847
+ if it.checked is not None:
848
+ out["tasks"].append({
849
+ "title": inline_text(it.text), "done": it.checked, "line": n.line,
850
+ })
851
+ elif n.kind in ("directive", "block"):
852
+ _absorb(out, n, prose)
853
+
854
+ out["text"] = "\n\n".join(prose)
855
+ out["words"] = len(out["text"].split())
856
+ return out
857
+
858
+
859
+ def _absorb(out: dict[str, Any], n: Node, prose: list[str]) -> None:
860
+ if n.name in ("comment", "metadata"):
861
+ return
862
+ if n.name in ("image", "video", "audio", "attachment"):
863
+ out["media"].append({
864
+ "kind": n.name, "src": n.args.resolve("src"),
865
+ "caption": inline_text(n.args.get("caption")), "line": n.line,
866
+ })
867
+ if n.args.get("caption"):
868
+ prose.append(inline_text(n.args.get("caption")))
869
+ return
870
+ if n.name == "code":
871
+ out["code"].append({
872
+ "language": n.args.resolve("language"),
873
+ "lines": n.text.count("\n") + 1, "line": n.line, "source": n.text,
874
+ })
875
+ return
876
+ if n.name == "table":
877
+ t = parse_table(n)
878
+ for row in [t.header] + t.rows:
879
+ prose.append(" | ".join(row))
880
+ return
881
+ if n.name in _PRESENTATIONAL:
882
+ if n.kind == "block" and n.text:
883
+ prose.append(n.text)
884
+ return
885
+
886
+ f = n.fields()
887
+ block: dict[str, Any] = {"type": n.name, "line": n.line, "text": n.text}
888
+ if len(n.args):
889
+ block["args"] = {(a.key or str(i)): a.value for i, a in enumerate(n.args)}
890
+ if f:
891
+ block["fields"] = f.map()
892
+ block["order"] = [x.key for x in f]
893
+ out["blocks"].append(block)
894
+
895
+ if n.name == "task":
896
+ m = f.map()
897
+ status = m.get("status", "")
898
+ out["tasks"].append({
899
+ "title": m.get("title") or m.get("", ""),
900
+ "status": status, "owner": m.get("owner", ""), "due": m.get("due", ""),
901
+ "done": status.lower() in ("done", "complete"), "line": n.line,
902
+ })
903
+ if n.kind == "block" and n.text:
904
+ prose.append(n.text)
905
+
906
+
907
+ # --------------------------------------------------------------------------
908
+ # Conformance
909
+ # --------------------------------------------------------------------------
910
+
911
+
912
+ def canonical(doc: Document) -> dict[str, Any]:
913
+ """The normalised shape used by the conformance suite."""
914
+ return {
915
+ "version": doc.version,
916
+ "nodes": [
917
+ {
918
+ "kind": n.kind,
919
+ "name": n.name,
920
+ "level": n.level,
921
+ "text": n.text,
922
+ "args": [{"key": a.key, "value": a.value} for a in n.args],
923
+ "items": [
924
+ {"text": it.text, "ordered": it.ordered, "checked": it.checked}
925
+ for it in n.items
926
+ ],
927
+ "line": n.line,
928
+ }
929
+ for n in doc.nodes
930
+ ],
931
+ }
932
+
933
+
934
+ def canonical_issues(issues: Iterable[Issue]) -> list[dict[str, Any]]:
935
+ return [{"severity": i.severity, "line": i.line} for i in sort_issues(issues)]
936
+
937
+
938
+ # --------------------------------------------------------------------------
939
+ # HTML rendering
940
+ # --------------------------------------------------------------------------
941
+
942
+
943
+ def render_html(doc: Document, full: bool = False, title: str = "") -> str:
944
+ """Render to HTML. `full` wraps the result in a standalone document."""
945
+ body: list[str] = []
946
+ notes: list[Node] = []
947
+ for n in doc.nodes:
948
+ if n.kind == "heading":
949
+ body.append(f"<h{n.level}>{inline_html(n.text)}</h{n.level}>")
950
+ elif n.kind == "paragraph":
951
+ body.append(f"<p>{inline_html(n.text)}</p>")
952
+ elif n.kind == "quote":
953
+ body.append(f"<blockquote><p>{inline_html(n.text)}</p></blockquote>")
954
+ elif n.kind == "list":
955
+ body.append(_list_html(n))
956
+ elif n.kind in ("directive", "block"):
957
+ if n.name == "footnote":
958
+ notes.append(n)
959
+ else:
960
+ body.append(_directive_html(n))
961
+ if notes:
962
+ items = []
963
+ for i, n in enumerate(notes):
964
+ ident = _html.escape(n.args.resolve("id") or str(i + 1), quote=True)
965
+ items.append(f'<li id="fn-{ident}">{inline_html(n.text)} '
966
+ f'<a class="fnback" href="#fnref-{ident}">&#8617;</a></li>')
967
+ body.append('<section class="footnotes"><ol>' + "".join(items) + "</ol></section>")
968
+
969
+ out = "\n".join(x for x in body if x)
970
+ if not full:
971
+ return out
972
+ if not title:
973
+ title = doc.metadata().get("title") or next(
974
+ (inline_text(n.text) for n in doc.nodes if n.kind == "heading"), "Untitled")
975
+ return (
976
+ '<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n'
977
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
978
+ f"<title>{_html.escape(title, quote=False)}</title>\n"
979
+ "</head>\n<body>\n<main class=\"xtxt\">\n" + out + "\n</main>\n</body>\n</html>\n"
980
+ )
981
+
982
+
983
+ def _list_html(n: Node) -> str:
984
+ if not n.items:
985
+ return ""
986
+ tag = "ol" if n.items[0].ordered else "ul"
987
+ cls = ' class="checklist"' if n.items[0].checked is not None else ""
988
+ parts = [f"<{tag}{cls}>"]
989
+ for it in n.items:
990
+ box = ""
991
+ if it.checked is not None:
992
+ box = f'<input type="checkbox" disabled{" checked" if it.checked else ""}> '
993
+ parts.append(f"<li>{box}{inline_html(it.text)}</li>")
994
+ parts.append(f"</{tag}>")
995
+ return "".join(parts)
996
+
997
+
998
+ def _directive_html(n: Node) -> str:
999
+ esc = lambda s: _html.escape(s, quote=True)
1000
+ if n.name in ("comment", "metadata"):
1001
+ return ""
1002
+ if n.name == "hr":
1003
+ return "<hr>"
1004
+ if n.name == "image":
1005
+ alt = n.args.get("alt") or inline_text(n.args.get("caption"))
1006
+ attrs = "".join(f' {k}="{esc(n.args.get(k))}"' for k in ("width", "height") if n.args.get(k))
1007
+ cap = n.args.get("caption")
1008
+ figcap = f"<figcaption>{inline_html(cap)}</figcaption>" if cap else ""
1009
+ return f'<figure><img src="{esc(n.args.resolve("src"))}" alt="{esc(alt)}"{attrs}>{figcap}</figure>'
1010
+ if n.name in ("video", "audio"):
1011
+ extra = " controls playsinline" if n.name == "video" else " controls"
1012
+ cap = n.args.get("caption")
1013
+ figcap = f"<figcaption>{inline_html(cap)}</figcaption>" if cap else ""
1014
+ return f'<figure><{n.name} src="{esc(n.args.resolve("src"))}"{extra}></{n.name}>{figcap}</figure>'
1015
+ if n.name == "attachment":
1016
+ src = n.args.resolve("src")
1017
+ return f'<p class="attachment"><a href="{esc(src)}" download>{esc(n.args.get("name") or src)}</a></p>'
1018
+ if n.name == "code":
1019
+ lang = n.args.resolve("language")
1020
+ cls = f' class="language-{esc(lang)}"' if lang else ""
1021
+ return f"<pre><code{cls}>{_html.escape(n.text, quote=False)}</code></pre>"
1022
+ if n.name == "math":
1023
+ return f'<div class="math">{_html.escape(n.text, quote=False)}</div>'
1024
+ if n.name == "mermaid":
1025
+ return f'<pre class="mermaid">{_html.escape(n.text, quote=False)}</pre>'
1026
+ if n.name == "raw":
1027
+ return n.text if n.args.resolve("format") == "html" else f"<pre>{_html.escape(n.text, quote=False)}</pre>"
1028
+ if n.name == "table":
1029
+ return _table_html(n)
1030
+ if n.kind == "block":
1031
+ f = n.fields()
1032
+ if f:
1033
+ rows = "".join(f"<dt>{esc(x.key or '—')}</dt><dd>{inline_html(x.value)}</dd>" for x in f)
1034
+ return (f'<section class="record" data-type="{esc(n.name)}">'
1035
+ f'<h4 class="record-type">{esc(n.name)}</h4><dl>{rows}</dl></section>')
1036
+ return f'<div class="unknown" data-directive="{esc(n.name)}">{esc(_source_of(n))}</div>'
1037
+
1038
+
1039
+ def _table_html(n: Node) -> str:
1040
+ t = parse_table(n)
1041
+ if not t.header:
1042
+ return ""
1043
+
1044
+ def style(i: int) -> str:
1045
+ a = t.align[i] if i < len(t.align) else "left"
1046
+ return f' style="text-align:{a}"' if a != "left" else ""
1047
+
1048
+ head = "".join(f"<th{style(i)}>{inline_html(h)}</th>" for i, h in enumerate(t.header))
1049
+ rows = "".join(
1050
+ "<tr>" + "".join(f"<td{style(i)}>{inline_html(c)}</td>" for i, c in enumerate(r)) + "</tr>"
1051
+ for r in t.rows
1052
+ )
1053
+ return f"<table>\n<thead><tr>{head}</tr></thead>\n<tbody>{rows}</tbody>\n</table>"
1054
+
1055
+
1056
+ def _source_of(n: Node) -> str:
1057
+ parts = []
1058
+ for a in n.args:
1059
+ v = a.value
1060
+ if not v or any(c in v for c in ' ,)"'):
1061
+ v = '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
1062
+ parts.append(f"{a.key}={v}" if a.key else v)
1063
+ out = "@" + n.name + (f"({', '.join(parts)})" if parts else "")
1064
+ if n.kind == "block":
1065
+ out += f"\n{n.text}\n@end{n.name}"
1066
+ return out
1067
+
1068
+
1069
+ def _main(argv: list[str]) -> int:
1070
+ import argparse
1071
+ ap = argparse.ArgumentParser(prog="python -m xtxt", description="XTXT tools")
1072
+ ap.add_argument("command", choices=["ast", "extract", "html", "validate"])
1073
+ ap.add_argument("file")
1074
+ ns = ap.parse_args(argv)
1075
+
1076
+ res = parse_file(ns.file)
1077
+ if ns.command == "validate":
1078
+ issues = sort_issues(list(res.issues) + validate(res.doc))
1079
+ for i in issues:
1080
+ print(f"{ns.file}:{i.line}: {i.severity}: {i.message}")
1081
+ if not issues:
1082
+ print(f"{ns.file}: ok ({len(res.doc.nodes)} blocks)")
1083
+ return 1 if any(i.severity == "error" for i in issues) else 0
1084
+ if ns.command == "ast":
1085
+ print(json.dumps(canonical(res.doc), indent=2))
1086
+ elif ns.command == "extract":
1087
+ print(json.dumps(extract(res.doc), indent=2))
1088
+ else:
1089
+ print(render_html(res.doc, full=True))
1090
+ return 0
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from . import _main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(_main(sys.argv[1:]))