unaltraweb 0.3.0 → 0.5.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.
- checksums.yaml +4 -4
- data/Makefile +57 -17
- data/README.md +47 -14
- data/_plugins/figure_captions.rb +47 -10
- data/_sass/_documentation.scss +7 -5
- data/_sass/_manual.scss +7 -0
- data/docs/_documentation/en/02-tools.md +4 -4
- data/docs/_documentation/en/03-usage.md +62 -1
- data/docs/_documentation/en/06-github-web-editing.md +1 -1
- data/docs/_documentation/en/13-unaltremanual.md +12 -5
- data/docs/_documentation/en/20-syntax.md +14 -0
- data/docs/_documentation/en/25-caption-credits.md +120 -0
- data/docs/_documentation/en/26-image-backgrounds.md +103 -0
- data/docs/_documentation/en/31-template.md +1 -1
- data/docs/_documentation/en/32-development.md +1 -1
- data/docs/_documentation/en/40-distribution.md +60 -23
- data/docs/_documentation/en/42-docker-image.md +21 -11
- data/docs/_documentation/en/43-workspace-path-policies.md +232 -0
- data/docs/_documentation/en/44-editorial-review.md +237 -0
- data/docs/agents/action-prompts/00-start-site-session.txt +14 -7
- data/docs/agents/action-prompts/22-manual-style-audit.txt +3 -1
- data/docs/agents/manual-authoring-components.md +38 -0
- data/docs/agents/mcp-contract.md +112 -14
- data/docs/agents/visual-companions-0.4.0.md +74 -0
- data/docs/assets/img/caption-credits-demo.svg +19 -0
- data/scripts/editorial_check.py +12 -0
- data/scripts/image_background_check.py +12 -0
- data/scripts/manual/build_pdf.py +94 -16
- data/scripts/manual/filters/figure-captions.lua +65 -10
- data/scripts/manual/templates/manual.tex +6 -1
- data/scripts/test_gem_build.py +32 -2
- data/scripts/test_reproducible_jekyll_build.py +1 -1
- data/scripts/test_wheel_install.py +75 -5
- data/scripts/unaltraweb-mcp-bootstrap.sh +19 -1
- data/scripts/validate_distribution.py +19 -4
- data/scripts/validate_workflows.py +290 -5
- data/scripts/verify_package_publish.py +414 -0
- data/scripts/web_captures/render.py +1 -1
- data/src/unaltraweb_mcp/component-contract.json +37 -37
- data/src/unaltraweb_mcp/editorial.py +495 -0
- data/src/unaltraweb_mcp/editorial_sources.py +504 -0
- data/src/unaltraweb_mcp/image_backgrounds.py +334 -0
- data/src/unaltraweb_mcp/image_probe.py +149 -0
- data/src/unaltraweb_mcp/processes.py +146 -0
- metadata +16 -2
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""Bounded, read-only projection of reader-facing site sources (not an interpreter)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
5
|
+
import fnmatch
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import stat
|
|
11
|
+
from html.parser import HTMLParser
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Iterator
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
MAX_BYTES = 1024 * 1024
|
|
17
|
+
MAX_TOTAL_BYTES = 16 * MAX_BYTES
|
|
18
|
+
MAX_PATHS = 5000
|
|
19
|
+
MAX_FRAGMENTS = 10000
|
|
20
|
+
CONTENT_ROOTS = ("_pages", "_posts", "_news", "_projects", "_outputs", "_chapters", "_documentation", "_books", "_theses")
|
|
21
|
+
TEXT_SUFFIXES = {".md", ".markdown", ".html", ".qmd", ".rmd"}
|
|
22
|
+
PUBLIC_FIELDS = {"title", "short_title", "description", "excerpt", "summary", "bio", "caption", "alt"}
|
|
23
|
+
GENRES = {"prose", "bio", "news", "chapter", "procedure", "reference", "preface", "quote", "example", "cv"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def default_language(config: dict[str, Any]) -> str:
|
|
27
|
+
uw = config.get("unaltraweb") or {}
|
|
28
|
+
languages = config.get("languages") or []
|
|
29
|
+
return str(uw.get("default_lang") or uw.get("lang") or config.get("default_lang") or config.get("lang")
|
|
30
|
+
or (languages[0] if isinstance(languages, list) and languages else "en"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EditorialError(ValueError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def digest(content: bytes) -> str:
|
|
38
|
+
return hashlib.sha256(content).hexdigest()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def canonical(value: Any) -> bytes:
|
|
42
|
+
return (json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False) + "\n").encode()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def strict_json(text: str) -> Any:
|
|
46
|
+
def unique(pairs):
|
|
47
|
+
result = {}
|
|
48
|
+
for key, value in pairs:
|
|
49
|
+
if key in result:
|
|
50
|
+
raise EditorialError(f"Duplicate JSON key: {key}")
|
|
51
|
+
result[key] = value
|
|
52
|
+
return result
|
|
53
|
+
try:
|
|
54
|
+
return json.loads(text, object_pairs_hook=unique, parse_constant=lambda value: (_ for _ in ()).throw(EditorialError(f"Non-finite JSON: {value}")))
|
|
55
|
+
except (UnicodeError, json.JSONDecodeError) as exc:
|
|
56
|
+
raise EditorialError(f"Invalid editorial JSON: {exc}") from exc
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def yaml_value(text: str) -> Any:
|
|
60
|
+
try:
|
|
61
|
+
import yaml
|
|
62
|
+
except ImportError:
|
|
63
|
+
# The modular wheel has a restricted YAML fallback. Native gem checks
|
|
64
|
+
# install PyYAML explicitly and need no MCP orchestration modules.
|
|
65
|
+
from .site_tools import load_yaml_text
|
|
66
|
+
stripped = re.sub(r"\A\s*---\s*\n", "", text).strip()
|
|
67
|
+
if stripped in {"[]", "{}"}:
|
|
68
|
+
return strict_json(stripped)
|
|
69
|
+
if stripped.startswith("- "):
|
|
70
|
+
return load_yaml_text("items:\n" + "\n".join(" " + line for line in stripped.splitlines())).get("items", [])
|
|
71
|
+
value = load_yaml_text(text)
|
|
72
|
+
else:
|
|
73
|
+
class UniqueLoader(yaml.SafeLoader):
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
def mapping(loader, node):
|
|
77
|
+
result = {}
|
|
78
|
+
for key_node, value_node in node.value:
|
|
79
|
+
key = loader.construct_object(key_node, deep=True)
|
|
80
|
+
if not isinstance(key, (str, int, float, bool)) or key in result:
|
|
81
|
+
raise EditorialError("Editorial YAML requires unique scalar mapping keys.")
|
|
82
|
+
result[key] = loader.construct_object(value_node, deep=True)
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, mapping)
|
|
86
|
+
try:
|
|
87
|
+
value = yaml.load(text, Loader=UniqueLoader)
|
|
88
|
+
except yaml.YAMLError as exc:
|
|
89
|
+
raise EditorialError(f"Invalid editorial YAML: {exc}") from exc
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def yaml_mapping(text: str) -> dict[str, Any]:
|
|
94
|
+
value = yaml_value(text)
|
|
95
|
+
if value is None:
|
|
96
|
+
return {}
|
|
97
|
+
if not isinstance(value, dict):
|
|
98
|
+
raise EditorialError("Expected a YAML mapping.")
|
|
99
|
+
return value
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def relative_path(raw: str) -> str:
|
|
103
|
+
if not isinstance(raw, str) or not raw or raw.startswith("/") or "\\" in raw or any(ord(c) < 32 for c in raw):
|
|
104
|
+
raise EditorialError("Use a literal project-relative editorial path.")
|
|
105
|
+
if any(part in {"", ".", "..", ".git"} for part in raw.split("/")) or any(c in raw for c in "*?[]"):
|
|
106
|
+
raise EditorialError(f"Unsafe editorial path: {raw}")
|
|
107
|
+
return raw
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Reader:
|
|
111
|
+
"""Descriptor-relative reads; never follows an input symlink or creates files."""
|
|
112
|
+
def __init__(self, root: Path, *, max_bytes: int | None = None, max_total_bytes: int | None = None):
|
|
113
|
+
self.max_bytes = MAX_BYTES if max_bytes is None else max_bytes
|
|
114
|
+
self.max_total_bytes = MAX_TOTAL_BYTES if max_total_bytes is None else max_total_bytes
|
|
115
|
+
if self.max_bytes <= 0 or self.max_total_bytes <= 0:
|
|
116
|
+
raise EditorialError("Reader budgets must be positive.")
|
|
117
|
+
self.root = Path(root).expanduser().resolve(strict=True)
|
|
118
|
+
self.fd = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
119
|
+
self.cache: dict[str, bytes | None] = {}
|
|
120
|
+
self.walk_cache: dict[tuple[str, bool], list[str]] = {}
|
|
121
|
+
self.total = 0
|
|
122
|
+
self.visited = 0
|
|
123
|
+
|
|
124
|
+
def __enter__(self):
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
def __exit__(self, *args):
|
|
128
|
+
os.close(self.fd)
|
|
129
|
+
|
|
130
|
+
@contextlib.contextmanager
|
|
131
|
+
def parent(self, relative: str) -> Iterator[tuple[int, str]]:
|
|
132
|
+
parts = relative_path(relative).split("/")
|
|
133
|
+
fd = os.dup(self.fd)
|
|
134
|
+
try:
|
|
135
|
+
for part in parts[:-1]:
|
|
136
|
+
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
|
|
137
|
+
os.close(fd)
|
|
138
|
+
fd = child
|
|
139
|
+
yield fd, parts[-1]
|
|
140
|
+
finally:
|
|
141
|
+
os.close(fd)
|
|
142
|
+
|
|
143
|
+
def read(self, relative: str, *, optional: bool = False) -> bytes | None:
|
|
144
|
+
relative_path(relative)
|
|
145
|
+
if relative in self.cache:
|
|
146
|
+
value = self.cache[relative]
|
|
147
|
+
if value is None and not optional:
|
|
148
|
+
raise EditorialError(f"Missing editorial input: {relative}")
|
|
149
|
+
return value
|
|
150
|
+
try:
|
|
151
|
+
with self.parent(relative) as (parent, name):
|
|
152
|
+
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
|
|
153
|
+
try:
|
|
154
|
+
before = os.fstat(fd)
|
|
155
|
+
if not stat.S_ISREG(before.st_mode) or before.st_size > self.max_bytes:
|
|
156
|
+
raise EditorialError(f"Editorial input must be a bounded regular file: {relative}")
|
|
157
|
+
data = bytearray()
|
|
158
|
+
while len(data) <= self.max_bytes:
|
|
159
|
+
chunk = os.read(fd, min(65536, self.max_bytes + 1 - len(data)))
|
|
160
|
+
if not chunk:
|
|
161
|
+
break
|
|
162
|
+
data.extend(chunk)
|
|
163
|
+
after = os.fstat(fd)
|
|
164
|
+
current = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
|
165
|
+
identity = lambda s: (s.st_dev, s.st_ino, s.st_mode, s.st_size, s.st_mtime_ns, s.st_ctime_ns)
|
|
166
|
+
if len(data) > self.max_bytes or identity(before) != identity(after) or identity(after) != identity(current):
|
|
167
|
+
raise EditorialError(f"Editorial input changed while being read: {relative}")
|
|
168
|
+
value = bytes(data)
|
|
169
|
+
finally:
|
|
170
|
+
os.close(fd)
|
|
171
|
+
except FileNotFoundError as exc:
|
|
172
|
+
if not optional:
|
|
173
|
+
raise EditorialError(f"Missing editorial input: {relative}") from exc
|
|
174
|
+
value = None
|
|
175
|
+
except OSError as exc:
|
|
176
|
+
raise EditorialError(f"Unsafe or unreadable editorial input {relative}: {exc}") from exc
|
|
177
|
+
self.total += len(value or b"")
|
|
178
|
+
if self.total > self.max_total_bytes:
|
|
179
|
+
raise EditorialError("Editorial input budget exceeded; select a smaller target.")
|
|
180
|
+
self.cache[relative] = value
|
|
181
|
+
return value
|
|
182
|
+
|
|
183
|
+
def text(self, relative: str, *, optional: bool = False) -> str:
|
|
184
|
+
data = self.read(relative, optional=optional)
|
|
185
|
+
try:
|
|
186
|
+
text = (data or b"").decode("utf-8")
|
|
187
|
+
except UnicodeError as exc:
|
|
188
|
+
raise EditorialError(f"Editorial input is not UTF-8: {relative}") from exc
|
|
189
|
+
if "\x00" in text:
|
|
190
|
+
raise EditorialError(f"NUL in editorial input: {relative}")
|
|
191
|
+
return text
|
|
192
|
+
|
|
193
|
+
def walk(self, relative: str, *, include_hidden: bool = False) -> list[str]:
|
|
194
|
+
relative_path(relative)
|
|
195
|
+
cache_key = (relative, include_hidden)
|
|
196
|
+
if cache_key in self.walk_cache:
|
|
197
|
+
return self.walk_cache[cache_key]
|
|
198
|
+
result: list[str] = []
|
|
199
|
+
|
|
200
|
+
def visit(fd: int, prefix: str, depth: int):
|
|
201
|
+
if depth > 24:
|
|
202
|
+
raise EditorialError("Editorial directory depth exceeded.")
|
|
203
|
+
for name in sorted(os.listdir(fd)):
|
|
204
|
+
self.visited += 1
|
|
205
|
+
if self.visited > MAX_PATHS:
|
|
206
|
+
raise EditorialError("Editorial path budget exceeded.")
|
|
207
|
+
path = f"{prefix}/{name}"
|
|
208
|
+
info = os.stat(name, dir_fd=fd, follow_symlinks=False)
|
|
209
|
+
if stat.S_ISLNK(info.st_mode):
|
|
210
|
+
raise EditorialError(f"Symlink is not an editorial input: {path}")
|
|
211
|
+
if name.startswith(".") and not include_hidden:
|
|
212
|
+
continue
|
|
213
|
+
if stat.S_ISDIR(info.st_mode):
|
|
214
|
+
child = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
|
|
215
|
+
try:
|
|
216
|
+
visit(child, path, depth + 1)
|
|
217
|
+
finally:
|
|
218
|
+
os.close(child)
|
|
219
|
+
elif stat.S_ISREG(info.st_mode):
|
|
220
|
+
result.append(path)
|
|
221
|
+
else:
|
|
222
|
+
raise EditorialError(f"Special file is not an editorial input: {path}")
|
|
223
|
+
try:
|
|
224
|
+
with self.parent(relative) as (parent, name):
|
|
225
|
+
info = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
|
226
|
+
if stat.S_ISREG(info.st_mode):
|
|
227
|
+
result.append(relative)
|
|
228
|
+
else:
|
|
229
|
+
fd = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
|
|
230
|
+
try:
|
|
231
|
+
visit(fd, relative, 0)
|
|
232
|
+
finally:
|
|
233
|
+
os.close(fd)
|
|
234
|
+
except FileNotFoundError:
|
|
235
|
+
pass
|
|
236
|
+
except OSError as exc:
|
|
237
|
+
raise EditorialError(f"Unsafe editorial source tree {relative}: {exc}") from exc
|
|
238
|
+
self.walk_cache[cache_key] = result
|
|
239
|
+
return result
|
|
240
|
+
|
|
241
|
+
def root_pages(self) -> list[str]:
|
|
242
|
+
"""Only root-level Jekyll page candidates, not repository prose."""
|
|
243
|
+
names = os.listdir(self.fd)
|
|
244
|
+
if len(names) > MAX_PATHS:
|
|
245
|
+
raise EditorialError("Editorial root path budget exceeded.")
|
|
246
|
+
return sorted(name for name in names if not name.startswith((".", "_"))
|
|
247
|
+
and name not in {"README.md", "AGENTS.md", "CONTRIBUTING.md", "CHANGELOG.md", "TODO.md"}
|
|
248
|
+
and Path(name).suffix.lower() in TEXT_SUFFIXES)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def blank(text: str) -> str:
|
|
252
|
+
return re.sub(r"[^\n]", " ", text)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def prose_projection(text: str, *, mask_quotes: bool = True, mask_attributes: bool = True) -> str:
|
|
256
|
+
for pattern in (r"<!--.*?(?:-->|\Z)", r"{%\s*comment\s*%}.*?(?:{%\s*endcomment\s*%}|\Z)",
|
|
257
|
+
r"<(pre|code|script|style|blockquote|q)\b[^>]*>.*?</\1\s*>",
|
|
258
|
+
r"(?<!`)(`+)(?!`)[^`\n]*?\1(?!`)", r"\$\$.*?\$\$|\$[^$\n]+\$", r"{%.*?%}|{{.*?}}",
|
|
259
|
+
r"(?<!!)\[([^]\n]+)\]\(([^)\n]+)\)"):
|
|
260
|
+
if pattern.startswith(r"(?<!!)"):
|
|
261
|
+
text = re.sub(pattern, lambda m: " " + m[1] + blank(m[0][len(m[1]) + 1:]), text)
|
|
262
|
+
else:
|
|
263
|
+
text = re.sub(pattern, lambda m: blank(m[0]), text, flags=re.S | re.I)
|
|
264
|
+
if mask_attributes:
|
|
265
|
+
text = re.sub(r'''\{:(?:[^{}"'\n]|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*')*\}''', lambda m: blank(m[0]), text)
|
|
266
|
+
if mask_quotes:
|
|
267
|
+
text = re.sub(r'"[^"\n]+"|“[^”\n]+”|«[^»\n]+»', lambda m: blank(m[0]), text)
|
|
268
|
+
return text
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def fragment(path: str, text: str, *, line: int, field: str = "", kind: str = "prose", genre: str = "prose", language: str = "en", ordinal: int = 0) -> dict[str, Any]:
|
|
272
|
+
return {"id": digest(canonical([path, line, field, kind, ordinal]))[:24], "path": path,
|
|
273
|
+
"line": line, "field": field, "kind": kind, "genre": genre, "language": language,
|
|
274
|
+
"text": text, "prose": prose_projection(text)}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class HTMLFragments(HTMLParser):
|
|
278
|
+
def __init__(self, path: str, language: str, genre: str, *, line_offset: int = 0):
|
|
279
|
+
super().__init__(convert_charrefs=True)
|
|
280
|
+
self.path, self.language, self.genre = path, language, genre
|
|
281
|
+
self.line_offset = line_offset
|
|
282
|
+
self.stack: list[str] = []
|
|
283
|
+
self.fragments: list[dict[str, Any]] = []
|
|
284
|
+
|
|
285
|
+
def add(self, text: str, kind="prose", field=""):
|
|
286
|
+
if text.strip():
|
|
287
|
+
if len(self.fragments) >= MAX_FRAGMENTS:
|
|
288
|
+
raise EditorialError("HTML fragment budget exceeded.")
|
|
289
|
+
self.fragments.append(fragment(self.path, text, line=self.getpos()[0] + self.line_offset,
|
|
290
|
+
field=field, kind=kind, genre=self.genre, language=self.language,
|
|
291
|
+
ordinal=len(self.fragments)))
|
|
292
|
+
|
|
293
|
+
def handle_starttag(self, tag, attrs):
|
|
294
|
+
if tag in {"pre", "code", "script", "style", "blockquote", "q"}:
|
|
295
|
+
self.stack.append(tag)
|
|
296
|
+
if tag == "html":
|
|
297
|
+
self.language = dict(attrs).get("lang") or self.language
|
|
298
|
+
if not self.stack:
|
|
299
|
+
for key, value in attrs:
|
|
300
|
+
if key in {"alt", "title", "data-caption-source"} and value:
|
|
301
|
+
self.add(value, "metadata", f"{tag}.{key}")
|
|
302
|
+
|
|
303
|
+
def handle_endtag(self, tag):
|
|
304
|
+
if tag in self.stack:
|
|
305
|
+
index = len(self.stack) - 1 - self.stack[::-1].index(tag)
|
|
306
|
+
del self.stack[index:]
|
|
307
|
+
|
|
308
|
+
def handle_data(self, data):
|
|
309
|
+
if not self.stack:
|
|
310
|
+
self.add(data)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def metadata_fragments(path: str, value: Any, language: str, genre: str, *, config: bool = False) -> list[dict[str, Any]]:
|
|
314
|
+
result = []
|
|
315
|
+
public_config = {"title", "short_title", "description", "author", "unaltraweb"}
|
|
316
|
+
def visit(node, pointer="", key="", depth=0):
|
|
317
|
+
if depth > 20 or len(result) > MAX_FRAGMENTS:
|
|
318
|
+
raise EditorialError("Public metadata exceeds editorial limits.")
|
|
319
|
+
if isinstance(node, dict):
|
|
320
|
+
for name, child in node.items():
|
|
321
|
+
if not isinstance(name, str):
|
|
322
|
+
continue
|
|
323
|
+
if config and not pointer and name not in public_config:
|
|
324
|
+
continue
|
|
325
|
+
if name in {"editorial", "pdf", "computations", "features", "release"}:
|
|
326
|
+
continue
|
|
327
|
+
visit(child, pointer + "/" + name.replace("~", "~0").replace("/", "~1"), name, depth + 1)
|
|
328
|
+
elif isinstance(node, list):
|
|
329
|
+
for index, child in enumerate(node):
|
|
330
|
+
visit(child, f"{pointer}/{index}", key, depth + 1)
|
|
331
|
+
elif isinstance(node, str) and node.strip():
|
|
332
|
+
localized = re.fullmatch(r"(.+)_(ca|en|es)(?:_[A-Za-z]+)?", key)
|
|
333
|
+
base = localized[1] if localized else key
|
|
334
|
+
if base in PUBLIC_FIELDS:
|
|
335
|
+
result.append(fragment(path, node, line=0, field=pointer, kind="prose" if base == "bio" else "metadata",
|
|
336
|
+
genre="bio" if base == "bio" else genre,
|
|
337
|
+
language=localized[2] if localized else language, ordinal=len(result)))
|
|
338
|
+
visit(value)
|
|
339
|
+
return result
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def document_fragments(path: str, text: str, language: str, genre: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
343
|
+
front: dict[str, Any] = {}
|
|
344
|
+
body, offset = text, 0
|
|
345
|
+
match = re.match(r"\A---\s*\r?\n(.*?)\r?\n---[^\S\n]*(?:\r?\n|\Z)", text, flags=re.S)
|
|
346
|
+
if match:
|
|
347
|
+
front = yaml_mapping(match[1])
|
|
348
|
+
body, offset = text[match.end():], text[:match.end()].count("\n")
|
|
349
|
+
language = str(front.get("lang") or language)
|
|
350
|
+
local = front.get("editorial") or {}
|
|
351
|
+
if not isinstance(local, dict) or set(local) - {"genre"}:
|
|
352
|
+
raise EditorialError(f"Unsupported editorial front matter: {path}")
|
|
353
|
+
genre = str(local.get("genre") or genre)
|
|
354
|
+
if genre not in GENRES:
|
|
355
|
+
raise EditorialError(f"Unknown editorial genre in {path}: {genre}")
|
|
356
|
+
result = metadata_fragments(path, front, language, genre)
|
|
357
|
+
projected = prose_projection(body, mask_quotes=False, mask_attributes=False)
|
|
358
|
+
if Path(path).suffix.lower() == ".html":
|
|
359
|
+
parser = HTMLFragments(path, language, genre, line_offset=offset)
|
|
360
|
+
parser.feed(projected)
|
|
361
|
+
result.extend(parser.fragments)
|
|
362
|
+
return front, result
|
|
363
|
+
fence = ""
|
|
364
|
+
for line, (original, raw) in enumerate(zip(body.splitlines(), projected.splitlines()), offset + 1):
|
|
365
|
+
stripped = raw.strip()
|
|
366
|
+
marker = re.match(r"^(`{3,}|~{3,})", stripped)
|
|
367
|
+
if fence:
|
|
368
|
+
if marker and marker[1][0] == fence[0] and len(marker[1]) >= len(fence):
|
|
369
|
+
fence = ""
|
|
370
|
+
continue
|
|
371
|
+
if marker:
|
|
372
|
+
fence = marker[1]
|
|
373
|
+
continue
|
|
374
|
+
# A single blockquote is an attributed/example surface for semantic
|
|
375
|
+
# review. Higher-depth unaltraweb callouts remain ordinary prose.
|
|
376
|
+
if re.match(r"^>(?!>)", stripped):
|
|
377
|
+
result.append(fragment(path, original, line=line, genre="quote", language=language, ordinal=len(result)))
|
|
378
|
+
continue
|
|
379
|
+
if raw.startswith((" ", "\t")):
|
|
380
|
+
continue
|
|
381
|
+
if not stripped:
|
|
382
|
+
continue
|
|
383
|
+
image = re.compile(r'!\[([^]\n]*)\]\(([^)\n]*?)(?:\s+"([^"\n]*)")?\)')
|
|
384
|
+
if image.search(raw) or re.match(r"^:::\s*(?:table|subfigures)\b", stripped):
|
|
385
|
+
for credit in re.finditer(r'''\bdata-caption-source\s*=\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\s}]+))''', raw):
|
|
386
|
+
group = next(index for index in (1, 2, 3) if credit[index] is not None)
|
|
387
|
+
content = original[credit.start(group):credit.end(group)]
|
|
388
|
+
result.append(fragment(path, content, line=line, field="data-caption-source", kind="metadata",
|
|
389
|
+
genre=genre, language=language, ordinal=len(result)))
|
|
390
|
+
raw = re.sub(r'''\{:(?:[^{}"'\n]|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*')*\}''', lambda m: blank(m[0]), raw)
|
|
391
|
+
for item in image.finditer(raw):
|
|
392
|
+
for label, content in (("alt", item[1]), ("caption", item[3])):
|
|
393
|
+
if content:
|
|
394
|
+
result.append(fragment(path, content, line=line, field=label, kind="metadata", genre=genre,
|
|
395
|
+
language=language, ordinal=len(result)))
|
|
396
|
+
raw = image.sub(lambda m: blank(m[0]), raw)
|
|
397
|
+
if "<" in raw:
|
|
398
|
+
parser = HTMLFragments(path, language, genre)
|
|
399
|
+
parser.feed(raw)
|
|
400
|
+
for item in parser.fragments:
|
|
401
|
+
if item["kind"] == "metadata":
|
|
402
|
+
result.append(fragment(path, item["text"], line=line, field=item["field"], kind="metadata",
|
|
403
|
+
genre=genre, language=language, ordinal=len(result)))
|
|
404
|
+
raw = re.sub(r"<(?!insert\b)[^>]+>", lambda m: blank(m[0]), raw, flags=re.I)
|
|
405
|
+
if raw.strip():
|
|
406
|
+
unit = fragment(path, original, line=line, kind="heading" if stripped.startswith("#") else "prose",
|
|
407
|
+
genre=genre, language=language, ordinal=len(result))
|
|
408
|
+
unit["prose"] = prose_projection(raw)
|
|
409
|
+
result.append(unit)
|
|
410
|
+
return front, result
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def corpus(reader: Reader, config: dict[str, Any], profile: str, target: str = "", *, manual_only: bool = False) -> dict[str, Any]:
|
|
414
|
+
if not isinstance(target, str):
|
|
415
|
+
raise EditorialError("Editorial target must be a string.")
|
|
416
|
+
if target:
|
|
417
|
+
target = relative_path(target)
|
|
418
|
+
default_lang = default_language(config)
|
|
419
|
+
uw = config.get("unaltraweb") or {}
|
|
420
|
+
manual = uw.get("manual") or {}
|
|
421
|
+
if not isinstance(manual, dict):
|
|
422
|
+
raise EditorialError("Manual configuration must be a mapping.")
|
|
423
|
+
collection = str(manual.get("collection") or "chapters")
|
|
424
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", collection):
|
|
425
|
+
raise EditorialError("Unsafe manual collection name.")
|
|
426
|
+
roots = {f"_{collection}", "_pages"} if manual_only else set(CONTENT_ROOTS) | {"_data", f"_{collection}"}
|
|
427
|
+
extra = config.get("collections") or {}
|
|
428
|
+
if not manual_only and isinstance(extra, dict):
|
|
429
|
+
roots.update("_" + key for key in extra if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", str(key)))
|
|
430
|
+
root_pages = reader.root_pages() if not manual_only else []
|
|
431
|
+
if target and target != "_config.yml" and target.split("/")[0] not in roots and target not in root_pages:
|
|
432
|
+
raise EditorialError("Editorial target must be reader-facing content or public metadata.")
|
|
433
|
+
paths = ([target] if Path(target).suffix else reader.walk(target)) if target else [path for root in sorted(roots) for path in reader.walk(root)]
|
|
434
|
+
if not target:
|
|
435
|
+
paths.extend(root_pages)
|
|
436
|
+
if not target and not manual_only and reader.read("_config.yml", optional=True) is not None:
|
|
437
|
+
paths.append("_config.yml")
|
|
438
|
+
excludes = config.get("exclude") or []
|
|
439
|
+
if not isinstance(excludes, list):
|
|
440
|
+
raise EditorialError("Site exclude must be a list.")
|
|
441
|
+
languages = config.get("languages") or [default_lang, "ca", "es", "en"]
|
|
442
|
+
if not isinstance(languages, list) or any(not isinstance(lang, str) for lang in languages):
|
|
443
|
+
raise EditorialError("Site languages must be a list of language codes.")
|
|
444
|
+
sources, fragments, skipped = {}, [], []
|
|
445
|
+
for path in sorted(set(paths)):
|
|
446
|
+
if any(isinstance(pattern, str) and (fnmatch.fnmatch(path, pattern) or path.startswith(pattern.rstrip("/") + "/")) for pattern in excludes):
|
|
447
|
+
skipped.append({"path": path, "reason": "excluded by site configuration"})
|
|
448
|
+
continue
|
|
449
|
+
suffix = Path(path).suffix.lower()
|
|
450
|
+
metadata = path == "_config.yml" or path.startswith("_data/")
|
|
451
|
+
if suffix not in ({".yml", ".yaml", ".json"} if metadata else TEXT_SUFFIXES):
|
|
452
|
+
continue
|
|
453
|
+
content = reader.text(path)
|
|
454
|
+
if path in root_pages and not re.match(r"\A---\s*\r?\n", content):
|
|
455
|
+
skipped.append({"path": path, "reason": "root document has no Jekyll front matter"})
|
|
456
|
+
continue
|
|
457
|
+
lang = next((part for part in Path(path).parts if part in languages), default_lang)
|
|
458
|
+
genre = "news" if path.startswith(("_news/", "_posts/")) else "chapter" if path.startswith(f"_{collection}/") else "reference" if profile == "unaltredocs" else "prose"
|
|
459
|
+
if metadata:
|
|
460
|
+
if suffix == ".json":
|
|
461
|
+
data = strict_json(content)
|
|
462
|
+
else:
|
|
463
|
+
data = yaml_value(content)
|
|
464
|
+
units = metadata_fragments(path, data, lang, genre, config=path == "_config.yml")
|
|
465
|
+
else:
|
|
466
|
+
front, units = document_fragments(path, content, lang, genre)
|
|
467
|
+
profiles = front.get("profiles") or []
|
|
468
|
+
if isinstance(profiles, str):
|
|
469
|
+
profiles = [profiles]
|
|
470
|
+
if not isinstance(profiles, list) or any(not isinstance(item, str) for item in profiles):
|
|
471
|
+
raise EditorialError(f"Invalid profiles in {path}.")
|
|
472
|
+
if front.get("published") is False or (profiles and profile not in profiles):
|
|
473
|
+
skipped.append({"path": path, "reason": "not published in this profile"})
|
|
474
|
+
continue
|
|
475
|
+
if manual_only and path.startswith("_pages/") and front.get("layout") not in {"manual-home", "manual-chapter"} and "unaltremanual" not in profiles:
|
|
476
|
+
continue
|
|
477
|
+
sources[path] = {"path": path, "sha256": digest(content.encode()), "text": content, "editable_source": path}
|
|
478
|
+
fragments.extend(units)
|
|
479
|
+
if len(fragments) > MAX_FRAGMENTS:
|
|
480
|
+
raise EditorialError("Editorial fragment budget exceeded; select a smaller target.")
|
|
481
|
+
# A generated chapter is inspected as published, but its executable owner is
|
|
482
|
+
# the editing target and is part of the review fingerprint.
|
|
483
|
+
ownership = reader.text(".unaltraweb/computations.lock.json", optional=True)
|
|
484
|
+
if ownership:
|
|
485
|
+
lock = strict_json(ownership)
|
|
486
|
+
if not isinstance(lock, dict) or lock.get("version") != 1 or not isinstance(lock.get("records"), dict):
|
|
487
|
+
raise EditorialError("Invalid computation ownership inventory.")
|
|
488
|
+
for owner, record in lock["records"].items():
|
|
489
|
+
if not isinstance(record, dict) or not isinstance(record.get("output", {}), dict):
|
|
490
|
+
raise EditorialError("Invalid computation ownership record.")
|
|
491
|
+
output = record.get("output", {}).get("path")
|
|
492
|
+
if output is not None and not isinstance(output, str):
|
|
493
|
+
raise EditorialError("Invalid computed output path.")
|
|
494
|
+
if output in sources:
|
|
495
|
+
relative_path(owner)
|
|
496
|
+
if Path(owner).suffix.lower() not in {".qmd", ".rmd", ".r", ".py", ".ipynb"}:
|
|
497
|
+
raise EditorialError("Computed prose needs an executable source owner.")
|
|
498
|
+
sources[output]["editable_source"] = owner
|
|
499
|
+
sources[output]["owner_sha256"] = digest(reader.read(owner))
|
|
500
|
+
sources[output]["ownership_sha256"] = digest(canonical(record))
|
|
501
|
+
if target and not sources:
|
|
502
|
+
raise EditorialError(f"No selected publishable sources for target: {target}")
|
|
503
|
+
return {"sources": sources, "fragments": fragments, "skipped": skipped,
|
|
504
|
+
"coverage": "Markdown/HTML prose and public metadata fields; code, math and quotations require semantic/rendered review."}
|