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 +12 -0
- xtxt-0.1.0/LICENSE +21 -0
- xtxt-0.1.0/PKG-INFO +59 -0
- xtxt-0.1.0/README.md +23 -0
- xtxt-0.1.0/pyproject.toml +35 -0
- xtxt-0.1.0/xtxt/__init__.py +1090 -0
- xtxt-0.1.0/xtxt/__main__.py +6 -0
xtxt-0.1.0/.gitignore
ADDED
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("`")
|
|
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}">↩</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
|