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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/Makefile +57 -17
  3. data/README.md +47 -14
  4. data/_plugins/figure_captions.rb +47 -10
  5. data/_sass/_documentation.scss +7 -5
  6. data/_sass/_manual.scss +7 -0
  7. data/docs/_documentation/en/02-tools.md +4 -4
  8. data/docs/_documentation/en/03-usage.md +62 -1
  9. data/docs/_documentation/en/06-github-web-editing.md +1 -1
  10. data/docs/_documentation/en/13-unaltremanual.md +12 -5
  11. data/docs/_documentation/en/20-syntax.md +14 -0
  12. data/docs/_documentation/en/25-caption-credits.md +120 -0
  13. data/docs/_documentation/en/26-image-backgrounds.md +103 -0
  14. data/docs/_documentation/en/31-template.md +1 -1
  15. data/docs/_documentation/en/32-development.md +1 -1
  16. data/docs/_documentation/en/40-distribution.md +60 -23
  17. data/docs/_documentation/en/42-docker-image.md +21 -11
  18. data/docs/_documentation/en/43-workspace-path-policies.md +232 -0
  19. data/docs/_documentation/en/44-editorial-review.md +237 -0
  20. data/docs/agents/action-prompts/00-start-site-session.txt +14 -7
  21. data/docs/agents/action-prompts/22-manual-style-audit.txt +3 -1
  22. data/docs/agents/manual-authoring-components.md +38 -0
  23. data/docs/agents/mcp-contract.md +112 -14
  24. data/docs/agents/visual-companions-0.4.0.md +74 -0
  25. data/docs/assets/img/caption-credits-demo.svg +19 -0
  26. data/scripts/editorial_check.py +12 -0
  27. data/scripts/image_background_check.py +12 -0
  28. data/scripts/manual/build_pdf.py +94 -16
  29. data/scripts/manual/filters/figure-captions.lua +65 -10
  30. data/scripts/manual/templates/manual.tex +6 -1
  31. data/scripts/test_gem_build.py +32 -2
  32. data/scripts/test_reproducible_jekyll_build.py +1 -1
  33. data/scripts/test_wheel_install.py +75 -5
  34. data/scripts/unaltraweb-mcp-bootstrap.sh +19 -1
  35. data/scripts/validate_distribution.py +19 -4
  36. data/scripts/validate_workflows.py +290 -5
  37. data/scripts/verify_package_publish.py +414 -0
  38. data/scripts/web_captures/render.py +1 -1
  39. data/src/unaltraweb_mcp/component-contract.json +37 -37
  40. data/src/unaltraweb_mcp/editorial.py +495 -0
  41. data/src/unaltraweb_mcp/editorial_sources.py +504 -0
  42. data/src/unaltraweb_mcp/image_backgrounds.py +334 -0
  43. data/src/unaltraweb_mcp/image_probe.py +149 -0
  44. data/src/unaltraweb_mcp/processes.py +146 -0
  45. 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."}