quirework 0.1.0__py3-none-any.whl

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 (138) hide show
  1. quirework/__init__.py +3 -0
  2. quirework/__main__.py +3 -0
  3. quirework/cli.py +431 -0
  4. quirework/core/__init__.py +1 -0
  5. quirework/core/artifact_paths.py +86 -0
  6. quirework/core/checks.py +235 -0
  7. quirework/core/frontmatter.py +83 -0
  8. quirework/core/globs.py +45 -0
  9. quirework/core/pinned.py +169 -0
  10. quirework/core/usage.py +604 -0
  11. quirework/delivery/__init__.py +1 -0
  12. quirework/delivery/artifacts.py +210 -0
  13. quirework/delivery/text.py +166 -0
  14. quirework/manuscript/__init__.py +1 -0
  15. quirework/manuscript/backfill.py +1972 -0
  16. quirework/manuscript/editions.py +111 -0
  17. quirework/manuscript/footprint.py +95 -0
  18. quirework/manuscript/formats.py +208 -0
  19. quirework/manuscript/lifecycle.py +191 -0
  20. quirework/manuscript/notes.py +2301 -0
  21. quirework/manuscript/pages.py +284 -0
  22. quirework/manuscript/quarto.py +416 -0
  23. quirework/manuscript/quotes.py +512 -0
  24. quirework/manuscript/rename.py +309 -0
  25. quirework/manuscript/sources.py +286 -0
  26. quirework/manuscript/trail.py +1875 -0
  27. quirework/manuscript/units.py +416 -0
  28. quirework/project.py +645 -0
  29. quirework/provenance/__init__.py +1 -0
  30. quirework/provenance/agyfilter.py +436 -0
  31. quirework/provenance/backup/__init__.py +469 -0
  32. quirework/provenance/backup/base.py +290 -0
  33. quirework/provenance/backup/command.py +107 -0
  34. quirework/provenance/backup/directory.py +142 -0
  35. quirework/provenance/backup/git.py +150 -0
  36. quirework/provenance/backup/rclone.py +153 -0
  37. quirework/provenance/capture/__init__.py +1 -0
  38. quirework/provenance/capture/commands.py +28 -0
  39. quirework/provenance/capture/extract_artifacts.py +110 -0
  40. quirework/provenance/capture/extract_files.py +118 -0
  41. quirework/provenance/capture/fetch_conversation.py +127 -0
  42. quirework/provenance/capture/fetch_gemini.py +149 -0
  43. quirework/provenance/capture/render_transcript.py +126 -0
  44. quirework/provenance/capture/transform.py +22 -0
  45. quirework/provenance/catalog.py +733 -0
  46. quirework/provenance/ccfilter.py +1126 -0
  47. quirework/provenance/codexfilter.py +883 -0
  48. quirework/provenance/coldread.py +1731 -0
  49. quirework/provenance/curate.py +67 -0
  50. quirework/provenance/humaninput.py +811 -0
  51. quirework/provenance/ingest.py +31 -0
  52. quirework/provenance/ingestion/__init__.py +1 -0
  53. quirework/provenance/ingestion/agy.py +192 -0
  54. quirework/provenance/ingestion/claude.py +493 -0
  55. quirework/provenance/ingestion/codex.py +296 -0
  56. quirework/provenance/lineage.py +148 -0
  57. quirework/provenance/metrics.py +1247 -0
  58. quirework/provenance/rulings.py +478 -0
  59. quirework/provenance/scope.py +73 -0
  60. quirework/provenance/search.py +455 -0
  61. quirework/provenance/semantic.py +475 -0
  62. quirework/provenance/show.py +1213 -0
  63. quirework/provenance/store.py +1271 -0
  64. quirework/provenance/trees.py +133 -0
  65. quirework/provenance/worktrees.py +199 -0
  66. quirework/providers/__init__.py +1 -0
  67. quirework/providers/blind.py +2192 -0
  68. quirework/providers/canary.py +685 -0
  69. quirework/providers/catalog.py +44 -0
  70. quirework/providers/claude_login.py +92 -0
  71. quirework/providers/exits.py +196 -0
  72. quirework/providers/harness.py +830 -0
  73. quirework/providers/images.py +92 -0
  74. quirework/providers/models.py +1284 -0
  75. quirework/providers/quota.py +588 -0
  76. quirework/providers/reads.py +474 -0
  77. quirework/providers/replies.py +175 -0
  78. quirework/publish/__init__.py +1 -0
  79. quirework/publish/backends.py +112 -0
  80. quirework/publish/bookstage.py +1086 -0
  81. quirework/publish/build.py +60 -0
  82. quirework/publish/editions.py +48 -0
  83. quirework/publish/epubcheck.py +287 -0
  84. quirework/publish/experiment.py +1097 -0
  85. quirework/publish/figfonts.py +234 -0
  86. quirework/publish/fignumbers.py +293 -0
  87. quirework/publish/figures.py +729 -0
  88. quirework/publish/folios.py +62 -0
  89. quirework/publish/fonts.py +307 -0
  90. quirework/publish/graybuild.py +73 -0
  91. quirework/publish/hugo.py +117 -0
  92. quirework/publish/kindle.js +411 -0
  93. quirework/publish/kindle.py +1393 -0
  94. quirework/publish/panelkit.py +918 -0
  95. quirework/publish/plans.py +651 -0
  96. quirework/publish/proof.py +792 -0
  97. quirework/publish/redpen.py +1654 -0
  98. quirework/publish/render.py +942 -0
  99. quirework/publish/targets.py +216 -0
  100. quirework/publish/variant.py +918 -0
  101. quirework/runs/__init__.py +1 -0
  102. quirework/runs/__main__.py +15 -0
  103. quirework/runs/commands.py +278 -0
  104. quirework/runs/coverage.py +86 -0
  105. quirework/runs/engine.py +2143 -0
  106. quirework/runs/feed.py +853 -0
  107. quirework/runs/observe.py +379 -0
  108. quirework/runs/storage.py +155 -0
  109. quirework/settings.py +492 -0
  110. quirework/sops/__init__.py +1 -0
  111. quirework/sops/catalog.py +386 -0
  112. quirework/sops/commands.py +567 -0
  113. quirework/sops/composition.py +382 -0
  114. quirework/sops/instruments.py +184 -0
  115. quirework/sops/personas.py +296 -0
  116. quirework/sops/review.py +2568 -0
  117. quirework/sops/room.py +2791 -0
  118. quirework/sops/roundtable.py +2578 -0
  119. quirework/sops/sections.py +169 -0
  120. quirework/sops/workshop.py +1629 -0
  121. quirework/templates/backup.toml +39 -0
  122. quirework/templates/book.toml +88 -0
  123. quirework/templates/run.toml +32 -0
  124. quirework/templates/site.toml +90 -0
  125. quirework/templates/tells.toml +64 -0
  126. quirework/templates/terminology.toml +20 -0
  127. quirework/templates/tmpfiles.conf +42 -0
  128. quirework/testing.py +345 -0
  129. quirework/text/__init__.py +1 -0
  130. quirework/text/jargon.py +545 -0
  131. quirework/text/mdwrap.py +1001 -0
  132. quirework/text/tells.py +547 -0
  133. quirework-0.1.0.dist-info/METADATA +444 -0
  134. quirework-0.1.0.dist-info/RECORD +138 -0
  135. quirework-0.1.0.dist-info/WHEEL +5 -0
  136. quirework-0.1.0.dist-info/entry_points.txt +2 -0
  137. quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
  138. quirework-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,284 @@
1
+ """The `pages` manuscript format: a site's Markdown pages.
2
+
3
+ `manuscript.pages` is a glob under `manuscript.source`, the shape a static
4
+ site generator such as Hugo reads, and the pages it matches are the units,
5
+ oldest first. A site whose units are not all posts names collections instead,
6
+ each a glob and an order:
7
+
8
+ [manuscript.collections.posts]
9
+ pages = "posts/*/index.md"
10
+ [manuscript.collections.pages]
11
+ pages = "*.md"
12
+ order = "title"
13
+
14
+ Collections are read in the order declared, and a page two globs match
15
+ belongs to the first. `order` is `date` (the default: oldest first, undated
16
+ last), `weight` (the frontmatter weight, then title), `title` or `path`.
17
+
18
+ A page named `index.md` takes its slug from its directory (a page bundle),
19
+ any other page from its stem. A `_`-prefixed file is a list page or a partial,
20
+ never a unit. A page's `title`, `date`, `lastmod`, `weight` and `draft` come
21
+ from its frontmatter, in either convention. Pages carry no number.
22
+
23
+ A page's marks live at `manuscript.marks`, a path pattern with `{slug}` in it,
24
+ which should sit outside the rendered tree: anything inside it is the site
25
+ generator's to read, and marks are metadata a reader must never be served.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from pathlib import Path
31
+ from typing import NamedTuple
32
+
33
+ from quirework.core import frontmatter, globs
34
+ from quirework.manuscript import formats
35
+ from quirework.manuscript.formats import SKIP_NAMES, SLUG_OK, Format, Unit
36
+ from quirework.project import ConfigurationError, Project, current_project
37
+
38
+
39
+ class Collection(NamedTuple):
40
+ name: str | None # None for the single, unnamed collection `pages` makes
41
+ pages: str # a glob under the source
42
+ order: str
43
+
44
+
45
+ ORDERS = {
46
+ "date": lambda u: (u.date is None, u.date or "", u.slug),
47
+ "weight": lambda u: (u.weight is None, u.weight or 0, u.title.lower(), u.slug),
48
+ "title": lambda u: (u.title.lower(), u.slug),
49
+ "path": lambda u: u.rel,
50
+ }
51
+
52
+
53
+ def collections(project: Project | None = None) -> list[Collection]:
54
+ """The collections of pages, in the order the project declares them."""
55
+ project = project or current_project()
56
+ declared = project.get("manuscript.collections")
57
+ single = project.get("manuscript.pages")
58
+ if declared and single:
59
+ raise ConfigurationError(
60
+ "manuscript.pages and manuscript.collections both name the pages; keep one"
61
+ )
62
+ if not declared:
63
+ if not single:
64
+ raise ConfigurationError(
65
+ "manuscript.format = 'pages' needs manuscript.pages, a glob under "
66
+ "manuscript.source such as 'posts/*/index.md', or "
67
+ "manuscript.collections"
68
+ )
69
+ return [Collection(None, single, "date")]
70
+ out = []
71
+ for name, table in declared.items():
72
+ where = f"manuscript.collections.{name}"
73
+ if not isinstance(table, dict) or not table.get("pages"):
74
+ raise ConfigurationError(f"{where} needs pages, a glob under the source")
75
+ unknown = set(table) - {"pages", "order"}
76
+ if unknown:
77
+ raise ConfigurationError(
78
+ f"{where}: unknown keys {', '.join(sorted(unknown))}"
79
+ )
80
+ order = table.get("order", "date")
81
+ if order not in ORDERS:
82
+ raise ConfigurationError(
83
+ f"{where}.order must be one of {', '.join(ORDERS)}, not {order!r}"
84
+ )
85
+ out.append(Collection(name, table["pages"], order))
86
+ return out
87
+
88
+
89
+ def _marks() -> str | None:
90
+ """`manuscript.marks`, or None when the project keeps no marks."""
91
+ return formats.slug_pattern("manuscript.marks")
92
+
93
+
94
+ def _weight(value) -> int | None:
95
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
96
+
97
+
98
+ def _page(path: Path, base: Path, collection: str | None) -> Unit:
99
+ text = path.read_text(encoding="utf-8")
100
+ try:
101
+ meta = frontmatter.fields(text)
102
+ except ValueError as exc:
103
+ raise ValueError(f"{formats.rel(path)}: {exc}") from None
104
+ title = frontmatter.as_text(meta.get("title")) or formats.h1(text)[0]
105
+ slug = FORMAT.slug_of(path)
106
+ weight = meta.get("weight")
107
+ if isinstance(weight, str) and weight.lstrip("-").isdigit():
108
+ weight = int(weight) # YAML is read as text
109
+ return Unit(
110
+ slug,
111
+ str(path.relative_to(base)),
112
+ title or slug,
113
+ None,
114
+ frontmatter.as_text(meta.get("date")),
115
+ bool(meta.get("draft", False)),
116
+ frontmatter.as_text(
117
+ meta.get(current_project().get("manuscript.revision_field", "lastmod"))
118
+ ),
119
+ _weight(weight),
120
+ collection,
121
+ )
122
+
123
+
124
+ def _is_page(path: Path) -> bool:
125
+ """A Hugo branch bundle's `_index.md` is a list page, and other
126
+ `_`-prefixed files are partials, so neither is a unit."""
127
+ return (
128
+ path.is_file() and not path.name.startswith("_") and path.name not in SKIP_NAMES
129
+ )
130
+
131
+
132
+ class Pages(Format):
133
+ name = "pages"
134
+ noun = "page"
135
+
136
+ def validate(self, project: Project) -> None:
137
+ # A glob, not a path: it names the pages under the source, and matching
138
+ # nothing is legitimate for a site with no posts yet.
139
+ collections(project)
140
+ _marks()
141
+
142
+ def _members(self, docs: Path) -> list[tuple[Collection, list[Path]]]:
143
+ """Each collection with its pages; a page two globs match is the
144
+ first one's."""
145
+ seen: set[Path] = set()
146
+ out = []
147
+ for collection in collections():
148
+ found = [
149
+ p
150
+ for p in sorted(docs.glob(collection.pages))
151
+ if _is_page(p) and p not in seen
152
+ ]
153
+ seen.update(found)
154
+ out.append((collection, found))
155
+ return out
156
+
157
+ def registry(self, docs: Path) -> list[Unit]:
158
+ """Every page, collection by collection, each in its own order."""
159
+ base = formats.base(docs)
160
+ out: list[Unit] = []
161
+ for collection, paths in self._members(docs):
162
+ pages = [_page(p, base, collection.name) for p in paths]
163
+ out += sorted(pages, key=ORDERS[collection.order])
164
+ return out
165
+
166
+ def order_problems(self, docs: Path) -> list[str]:
167
+ """Two pages sharing a slug, or a slug no reference could name."""
168
+ from quirework.manuscript import units
169
+
170
+ problems: list[str] = []
171
+ seen: dict[str, str] = {}
172
+ for u in self.registry(docs):
173
+ if u.slug in seen:
174
+ problems.append(
175
+ f"{u.rel}: slug '{u.slug}' is also {seen[u.slug]}'s; a slug "
176
+ f"is an identity and names one {units.noun()}"
177
+ )
178
+ seen.setdefault(u.slug, u.rel)
179
+ if not SLUG_OK.match(u.slug):
180
+ problems.append(
181
+ f"{u.rel}: slug '{u.slug}' is not lowercase words joined by "
182
+ f"hyphens, so no @unit() reference can name it"
183
+ )
184
+ return problems
185
+
186
+ def prose_files(self, docs: Path) -> list[Path]:
187
+ return sorted(p for _, paths in self._members(docs) for p in paths)
188
+
189
+ def slug_of(self, path: Path) -> str:
190
+ """A page bundle's `index.md` is named by its directory; a page file by
191
+ its stem."""
192
+ path = Path(path)
193
+ return path.parent.name if path.stem == "index" else path.stem
194
+
195
+ def prose_token(self, token: str) -> str | None:
196
+ # Written as a collection's glob names a page, from the source or from
197
+ # the project root: `posts/the-kettle/index.md`.
198
+ source = formats.relative(current_project(), "manuscript.source")
199
+ for collection in collections():
200
+ shapes = [collection.pages]
201
+ if source not in (None, "."):
202
+ shapes.append(f"{source}/{collection.pages}")
203
+ if any(globs.matches(token, shape) for shape in shapes):
204
+ return self.slug_of(Path(token))
205
+ return None
206
+
207
+ def marks_path(self, slug: str, docs: Path) -> Path | None:
208
+ pattern = _marks()
209
+ if pattern is None:
210
+ return None
211
+ return current_project().root / pattern.replace("{slug}", slug)
212
+
213
+ def sidecars(self, docs: Path) -> list[Path]:
214
+ pattern = _marks()
215
+ if pattern is None:
216
+ return []
217
+ root = current_project().root
218
+ return sorted(
219
+ p for p in root.glob(pattern.replace("{slug}", "*")) if p.is_file()
220
+ )
221
+
222
+ def sidecar_slug(self, path: Path) -> str | None:
223
+ pattern = _marks()
224
+ if pattern is None:
225
+ return None
226
+ root = current_project().root
227
+ try:
228
+ relative = Path(path).resolve().relative_to(root.resolve()).as_posix()
229
+ except ValueError:
230
+ return None
231
+ m = formats.pattern_regex(pattern).match(relative)
232
+ return m.group("slug") if m else None
233
+
234
+ def prose_for_sidecar(self, path: Path, docs: Path) -> Path | None:
235
+ slug = self.sidecar_slug(path)
236
+ if slug is None:
237
+ return None
238
+ for p in self.prose_files(docs):
239
+ if self.slug_of(p) == slug:
240
+ return p
241
+ return None
242
+
243
+ def harvest(self, docs: Path) -> list[Path]:
244
+ # File by file: the sidecars live outside the source, and a site's
245
+ # source holds much besides the manuscript.
246
+ return self.prose_files(docs) + self.sidecars(docs)
247
+
248
+ def default_layers(self, project: Project) -> tuple[str, ...]:
249
+ pattern = _marks()
250
+ return (pattern.replace("{slug}", "*"),) if pattern else ()
251
+
252
+ def footprint(self, project: Project) -> tuple[str, ...]:
253
+ # The pages, each bundle whole (its figures are the post's too), and
254
+ # the marks. Not the whole source: a site's source holds list pages,
255
+ # taxonomies and whatever else the generator reads.
256
+ source = formats.relative(project, "manuscript.source")
257
+ out: list[str] = []
258
+ if source is not None:
259
+ prefix = "" if source == "." else f"{source}/"
260
+ for collection in collections(project):
261
+ out.append(prefix + collection.pages)
262
+ head, _, name = collection.pages.rpartition("/")
263
+ if name == "index.md" and head:
264
+ out.append(f"{prefix}{head}/**")
265
+ marks = project.get("manuscript.marks")
266
+ if marks:
267
+ out.append(str(marks).replace("{slug}", "*"))
268
+ return tuple(out)
269
+
270
+ def listing(self, units: list[Unit]) -> list[str]:
271
+ out: list[str] = []
272
+ named = len({u.collection for u in units}) > 1
273
+ width = max([34, *(len(u.slug) + 1 for u in units)])
274
+ current = object()
275
+ for u in units:
276
+ if named and u.collection != current:
277
+ current = u.collection
278
+ out.append(f"{u.collection}:")
279
+ state = "draft" if u.draft else "published"
280
+ out.append(f" {u.date or '-':<25} {state:<9} {u.slug:{width}} {u.title}")
281
+ return out
282
+
283
+
284
+ FORMAT = Pages()
@@ -0,0 +1,416 @@
1
+ """The `quarto` manuscript format: a Quarto book's chapters.
2
+
3
+ The format takes its units, and their order, from the `book.chapters` block of
4
+ the configured Quarto file. That block is read here, by one walker, and the
5
+ publisher's staging code (`publish.bookstage`) reads it through the same
6
+ functions when it rewrites a staged copy. A shape this parser does not
7
+ recognize raises rather than yielding a partial book.
8
+
9
+ A chapter's slug is its file stem, its marks sit beside it as
10
+ `_marks/<stem>.md`, and a printed number is derived from its position.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from pathlib import Path
17
+ from typing import NamedTuple
18
+
19
+ from quirework.manuscript import formats
20
+ from quirework.manuscript.formats import Format, Unit
21
+ from quirework.project import ConfigurationError, Project
22
+
23
+ BOOK_CONFIG = "_quarto.yml"
24
+ _CHAPTER_SUFFIXES = (".qmd", ".md", ".ipynb")
25
+
26
+ _YAML_ITEM = re.compile(r"^(\s*)-\s+(.*?)\s*$")
27
+
28
+
29
+ def _indent(line: str) -> int:
30
+ return len(line) - len(line.lstrip())
31
+
32
+
33
+ def _unquote(s: str) -> str:
34
+ return s[1:-1] if len(s) >= 2 and s[0] == s[-1] and s[0] in "\"'" else s
35
+
36
+
37
+ def _find_chapters_block(lines: list[str]) -> tuple[int, int, int]:
38
+ """Locate `book:` → `chapters:` in a staged `_quarto.yml`: (key line, first line
39
+ after the list, the key's indent). Raises ValueError when it is not there."""
40
+ try:
41
+ bi = next(i for i, l in enumerate(lines) if l.rstrip() == "book:")
42
+ except StopIteration:
43
+ raise ValueError(f"{BOOK_CONFIG}: no top-level `book:` key") from None
44
+ ci = None
45
+ for i in range(bi + 1, len(lines)):
46
+ s = lines[i].strip()
47
+ if not s or s.startswith("#"):
48
+ continue
49
+ if _indent(lines[i]) == 0:
50
+ break # left the book: block without finding it
51
+ if s == "chapters:":
52
+ ci = i
53
+ break
54
+ if ci is None:
55
+ raise ValueError(f"{BOOK_CONFIG}: no `chapters:` under `book:`")
56
+ cind = _indent(lines[ci])
57
+ j = ci + 1
58
+ while j < len(lines) and (not lines[j].strip() or _indent(lines[j]) > cind):
59
+ j += 1
60
+ while j - 1 > ci and not lines[j - 1].strip():
61
+ j -= 1 # leave the blank line before the next key
62
+ return ci, j, cind
63
+
64
+
65
+ def _part_chapters(cont: list[str]) -> list[str]:
66
+ """The chapter files nested under one `- part:` item."""
67
+ files: list[str] = []
68
+ seen = False
69
+ for line in cont:
70
+ s = line.strip()
71
+ if not s or s.startswith("#"):
72
+ continue
73
+ if s == "chapters:":
74
+ seen = True
75
+ continue
76
+ m = _YAML_ITEM.match(line)
77
+ if not (seen and m):
78
+ raise ValueError(f"{BOOK_CONFIG}: unexpected line under a part: {line!r}")
79
+ files.append(_unquote(m.group(2)))
80
+ return files
81
+
82
+
83
+ class _Item(NamedTuple):
84
+ """One entry in a staged `book.chapters` list, as `_chapters_items` reads it.
85
+
86
+ `kind` is `comment` (the line verbatim, so a rewrite can put it back),
87
+ `chapter` (a file), `part-file` (`part: sections/part-two.qmd`, a divider that
88
+ is itself prose) or `part-title` (`part: "Part One: …"`, a bare heading).
89
+ `value` is the comment line, the file path, or the part title; `children`
90
+ are the chapter files nested under a part."""
91
+
92
+ kind: str
93
+ value: str
94
+ children: list[str]
95
+
96
+
97
+ def _chapters_items(lines: list[str], ci: int, end: int) -> tuple[int, list[_Item]]:
98
+ """Parse a located `book.chapters` block into `(item indent, items)`.
99
+
100
+ **The single walker.** Every consumer of the book's reading order goes
101
+ through this -- `book_chapters` (read-only), and bookstage's `flatten_book_parts` (rewrites
102
+ the block) and `book_rows` (the numbered reading order, which the Contents
103
+ page and the reader kit are both projections of) -- so a `_quarto.yml` shape
104
+ one of them understands cannot silently mislead another. There used to be two hand-rolled walkers here that agreed only by
105
+ inspection. Raises ValueError on a shape this does not recognize, so a
106
+ future `_quarto.yml` cannot be quietly read as a partial book."""
107
+ block = lines[ci + 1 : end]
108
+ real = [l for l in block if l.strip() and not l.strip().startswith("#")]
109
+ if not real:
110
+ raise ValueError(f"{BOOK_CONFIG}: `book.chapters` is empty")
111
+ item_ind = _indent(real[0])
112
+
113
+ items: list[_Item] = []
114
+ k = 0
115
+ while k < len(block):
116
+ line = block[k]
117
+ s = line.strip()
118
+ k += 1
119
+ if not s:
120
+ continue
121
+ if s.startswith("#"):
122
+ items.append(_Item("comment", line, []))
123
+ continue
124
+ m = _YAML_ITEM.match(line)
125
+ if not m or _indent(line) != item_ind:
126
+ raise ValueError(
127
+ f"{BOOK_CONFIG}: unexpected line in book.chapters: {line!r}"
128
+ )
129
+ cont: list[str] = []
130
+ while k < len(block) and (not block[k].strip() or _indent(block[k]) > item_ind):
131
+ cont.append(block[k])
132
+ k += 1
133
+ body = m.group(2)
134
+ if not body.startswith("part:"):
135
+ if [l for l in cont if l.strip()]:
136
+ raise ValueError(
137
+ f"{BOOK_CONFIG}: nested keys under a chapter: {line!r}"
138
+ )
139
+ items.append(_Item("chapter", _unquote(body), []))
140
+ continue
141
+ val = _unquote(body[len("part:") :].strip())
142
+ kids = _part_chapters(cont)
143
+ if val.endswith(_CHAPTER_SUFFIXES):
144
+ items.append(_Item("part-file", val, kids))
145
+ elif val:
146
+ items.append(_Item("part-title", val, kids))
147
+ else:
148
+ raise ValueError(f"{BOOK_CONFIG}: a `part:` with neither title nor file")
149
+ return item_ind, items
150
+
151
+
152
+ def read_items(stage: Path) -> tuple[list[str], int, int, int, list[_Item]]:
153
+ """`_chapters_items` over a stage's `_quarto.yml`, plus what a rewrite needs."""
154
+ lines = (stage / BOOK_CONFIG).read_text(encoding="utf-8").splitlines()
155
+ ci, end, _ = _find_chapters_block(lines)
156
+ item_ind, items = _chapters_items(lines, ci, end)
157
+ return lines, ci, end, item_ind, items
158
+
159
+
160
+ def book_chapters(stage: Path) -> list[str]:
161
+ """Ordered chapter files from a staged `_quarto.yml`, parts flattened.
162
+
163
+ Reading order, one entry per real file: bare chapters, file-based part dividers
164
+ (`part: sections/part-two.qmd`), and each part's nested chapters. Title-only parts
165
+ (`part: "Part One: …"`) contribute nothing, since they name no file."""
166
+ _, _, _, _, items = read_items(stage)
167
+ out: list[str] = []
168
+ for it in items:
169
+ if it.kind in ("chapter", "part-file"):
170
+ out.append(it.value)
171
+ out.extend(it.children)
172
+ return out
173
+
174
+
175
+ def is_book(stage: Path) -> bool:
176
+ """Whether a Quarto project is a book: it says `type: book`, or has `book:`.
177
+
178
+ A derived edition need not be one. A memo renders as a plain project of
179
+ one document, and the code that rewrites a book's reading order has
180
+ nothing to rewrite there. Either sign makes a book, so a book whose
181
+ `book:` block is missing still fails as a book rather than passing as a
182
+ plain project.
183
+ """
184
+ lines = (stage / BOOK_CONFIG).read_text(encoding="utf-8").splitlines()
185
+ if any(line.rstrip() == "book:" for line in lines):
186
+ return True
187
+ try:
188
+ top = next(i for i, line in enumerate(lines) if line.rstrip() == "project:")
189
+ except StopIteration:
190
+ return False
191
+ for line in lines[top + 1 :]:
192
+ s = line.strip()
193
+ if not s or s.startswith("#"):
194
+ continue
195
+ if _indent(line) == 0:
196
+ break
197
+ if re.fullmatch(r"type:\s*[\"']?book[\"']?(\s+#.*)?", s):
198
+ return True
199
+ return False
200
+
201
+
202
+ def _render_list(lines: list[str]) -> list[str] | None:
203
+ """The items of `project:` → `render:`, or None when there is no such list."""
204
+ try:
205
+ top = next(i for i, line in enumerate(lines) if line.rstrip() == "project:")
206
+ except StopIteration:
207
+ return None
208
+ for i in range(top + 1, len(lines)):
209
+ s = lines[i].strip()
210
+ if not s or s.startswith("#"):
211
+ continue
212
+ if _indent(lines[i]) == 0:
213
+ return None
214
+ if s == "render:":
215
+ key = _indent(lines[i])
216
+ items = []
217
+ for line in lines[i + 1 :]:
218
+ if not line.strip() or line.strip().startswith("#"):
219
+ continue
220
+ if _indent(line) <= key:
221
+ break
222
+ m = _YAML_ITEM.match(line)
223
+ if not m:
224
+ raise ValueError(
225
+ f"{BOOK_CONFIG}: unexpected line in project.render: {line!r}"
226
+ )
227
+ items.append(_unquote(m.group(2)))
228
+ return items
229
+ return None
230
+
231
+
232
+ def documents(stage: Path) -> list[str]:
233
+ """The files a Quarto project renders, in order.
234
+
235
+ A book's are its chapters. A plain project renders what its
236
+ `project.render` list names or, with no list, every input file whose path
237
+ has no part starting with `_` or `.`, which is the set Quarto itself
238
+ picks. Either way this is what a check over the rendered artifacts must
239
+ count, since a figure in a file that never renders is in no artifact.
240
+ """
241
+ if is_book(stage):
242
+ return book_chapters(stage)
243
+ lines = (stage / BOOK_CONFIG).read_text(encoding="utf-8").splitlines()
244
+ listed = _render_list(lines)
245
+ if listed is not None:
246
+ return listed
247
+ return sorted(
248
+ str(path.relative_to(stage))
249
+ for path in stage.rglob("*")
250
+ if path.suffix in _CHAPTER_SUFFIXES
251
+ and path.is_file()
252
+ and path.name.lower() not in ("readme.md", "readme.qmd")
253
+ and not any(
254
+ part.startswith(("_", ".")) for part in path.relative_to(stage).parts
255
+ )
256
+ )
257
+
258
+
259
+ def part_dividers(docs: Path) -> set[str]:
260
+ """The file-based `part:` entries. Quarto renders these as part dividers
261
+ rather than chapters, so they carry no printed number even though their H1
262
+ is not marked `.unnumbered`."""
263
+ text = (docs / BOOK_CONFIG).read_text(encoding="utf-8")
264
+ return {
265
+ m.group(1)
266
+ for m in re.finditer(
267
+ r"(?m)^\s*-\s+part:\s*[\"']?([\w./-]+\.qmd)[\"']?\s*$", text
268
+ )
269
+ }
270
+
271
+
272
+ # A chapter's marks live in a sidecar `<dir>/_marks/<stem>.md` beside it, never
273
+ # inline in the prose. The directory's name is Quirework's convention.
274
+ MARKS_DIR = "_marks"
275
+
276
+
277
+ class Quarto(Format):
278
+ name = "quarto"
279
+ noun = "chapter"
280
+
281
+ def validate(self, project: Project) -> None:
282
+ chapters = project.path("manuscript.chapters")
283
+ if not chapters.is_dir():
284
+ raise ConfigurationError(
285
+ f"configured canon directory is missing: {chapters}"
286
+ )
287
+ quarto = project.path("manuscript.quarto")
288
+ if not quarto.is_file():
289
+ raise ConfigurationError(f"configured Quarto file is missing: {quarto}")
290
+
291
+ def registry(self, docs: Path) -> list[Unit]:
292
+ """Every book file in reading order, from `_quarto.yml` intersected with
293
+ disk.
294
+
295
+ The config is the sole source of order -- a filename only has to be
296
+ unique now, never to sort -- so this is the one function that knows
297
+ what "the next chapter" means. The printed number is derived here and
298
+ nowhere else.
299
+ """
300
+ base = formats.base(docs)
301
+ dividers = part_dividers(docs)
302
+ out: list[Unit] = []
303
+ n = 0
304
+ for relpath in book_chapters(docs):
305
+ p = docs / relpath
306
+ if not p.exists():
307
+ continue # order_problems reports it; don't crash
308
+ title, attrs = formats.h1(p.read_text(encoding="utf-8"))
309
+ numbered = (
310
+ relpath not in dividers
311
+ and "unnumbered" not in attrs
312
+ and p.name != "index.qmd"
313
+ )
314
+ if numbered:
315
+ n += 1
316
+ out.append(
317
+ Unit(p.stem, str(p.relative_to(base)), title, n if numbered else None)
318
+ )
319
+ return out
320
+
321
+ def order_problems(self, docs: Path) -> list[str]:
322
+ """`_quarto.yml` <-> disk, both directions.
323
+
324
+ The dual of the orphan-sidecar invariant, and under slug-only the only
325
+ remaining way to orphan a chapter: rename the file, forget the config.
326
+ `book_chapters` raises on a shape it cannot parse but never asks
327
+ whether the files it names exist, and nothing asked the reverse."""
328
+ cfg = docs / BOOK_CONFIG
329
+ if not cfg.exists():
330
+ return []
331
+ listed = book_chapters(docs)
332
+ problems = [
333
+ f"{cfg.name}: lists {r}, which does not exist"
334
+ for r in listed
335
+ if not (docs / r).exists()
336
+ ]
337
+ on_disk = {
338
+ str(p.relative_to(docs))
339
+ for p in docs.rglob("*.qmd")
340
+ if p.parent.name != MARKS_DIR
341
+ }
342
+ for extra in sorted(on_disk - set(listed)):
343
+ problems.append(
344
+ f"{extra}: on disk but absent from {cfg.name}, so it is "
345
+ f"in no reading order and renders nowhere"
346
+ )
347
+ return problems
348
+
349
+ def prose_files(self, docs: Path) -> list[Path]:
350
+ # A chapter on disk but missing from `_quarto.yml` is still prose,
351
+ # which is why this reads the disk and not the registry.
352
+ return sorted(p for p in docs.rglob("*.qmd") if p.parent.name != MARKS_DIR)
353
+
354
+ def slug_of(self, path: Path) -> str:
355
+ return Path(path).stem
356
+
357
+ def prose_token(self, token: str) -> str | None:
358
+ return Path(token).stem if token.endswith(".qmd") else None
359
+
360
+ def marks_path(self, slug: str, docs: Path) -> Path | None:
361
+ # A sidecar's place is beside its chapter, so a slug no chapter
362
+ # carries has none.
363
+ for prose in self.prose_files(docs):
364
+ if prose.stem == slug:
365
+ return prose.parent / MARKS_DIR / f"{slug}.md"
366
+ return None
367
+
368
+ def sidecars(self, docs: Path) -> list[Path]:
369
+ return sorted(docs.rglob(f"{MARKS_DIR}/*.md"))
370
+
371
+ def sidecar_slug(self, path: Path) -> str | None:
372
+ path = Path(path)
373
+ return path.stem if path.parent.name == MARKS_DIR else None
374
+
375
+ def prose_for_sidecar(self, path: Path, docs: Path) -> Path | None:
376
+ # The sibling path whether or not it exists: the sidecar's position
377
+ # names it, and an orphan is reported by naming that path.
378
+ slug = self.sidecar_slug(path)
379
+ if slug is None:
380
+ return None
381
+ return Path(path).parent.parent / f"{slug}.qmd"
382
+
383
+ def harvest(self, docs: Path) -> list[Path]:
384
+ # The whole source directory, because the sidecars sit beside the
385
+ # chapters.
386
+ return [docs]
387
+
388
+ def default_layers(self, project: Project) -> tuple[str, ...]:
389
+ # A source outside the project root cannot be expressed relative to
390
+ # it, so that case searches the project tree for the directory name
391
+ # rather than silently matching nothing.
392
+ source = project.path("manuscript.source")
393
+ try:
394
+ relative = source.relative_to(project.root)
395
+ except ValueError:
396
+ return (f"**/{MARKS_DIR}/*.md",)
397
+ return (f"{relative}/**/{MARKS_DIR}/*.md",)
398
+
399
+ def footprint(self, project: Project) -> tuple[str, ...]:
400
+ # Everything under the source: the chapters, the sidecars beside
401
+ # them, and the figures and front matter the book is made of.
402
+ source = formats.relative(project, "manuscript.source")
403
+ if source is None:
404
+ return ()
405
+ return ("**",) if source == "." else (f"{source}/**",)
406
+
407
+ def listing(self, units: list[Unit]) -> list[str]:
408
+ return [
409
+ f" {u.number:>2} {u.slug:26} {u.title}"
410
+ if u.number
411
+ else f" - {u.slug:26} {u.title}"
412
+ for u in units
413
+ ]
414
+
415
+
416
+ FORMAT = Quarto()