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.
- quirework/__init__.py +3 -0
- quirework/__main__.py +3 -0
- quirework/cli.py +431 -0
- quirework/core/__init__.py +1 -0
- quirework/core/artifact_paths.py +86 -0
- quirework/core/checks.py +235 -0
- quirework/core/frontmatter.py +83 -0
- quirework/core/globs.py +45 -0
- quirework/core/pinned.py +169 -0
- quirework/core/usage.py +604 -0
- quirework/delivery/__init__.py +1 -0
- quirework/delivery/artifacts.py +210 -0
- quirework/delivery/text.py +166 -0
- quirework/manuscript/__init__.py +1 -0
- quirework/manuscript/backfill.py +1972 -0
- quirework/manuscript/editions.py +111 -0
- quirework/manuscript/footprint.py +95 -0
- quirework/manuscript/formats.py +208 -0
- quirework/manuscript/lifecycle.py +191 -0
- quirework/manuscript/notes.py +2301 -0
- quirework/manuscript/pages.py +284 -0
- quirework/manuscript/quarto.py +416 -0
- quirework/manuscript/quotes.py +512 -0
- quirework/manuscript/rename.py +309 -0
- quirework/manuscript/sources.py +286 -0
- quirework/manuscript/trail.py +1875 -0
- quirework/manuscript/units.py +416 -0
- quirework/project.py +645 -0
- quirework/provenance/__init__.py +1 -0
- quirework/provenance/agyfilter.py +436 -0
- quirework/provenance/backup/__init__.py +469 -0
- quirework/provenance/backup/base.py +290 -0
- quirework/provenance/backup/command.py +107 -0
- quirework/provenance/backup/directory.py +142 -0
- quirework/provenance/backup/git.py +150 -0
- quirework/provenance/backup/rclone.py +153 -0
- quirework/provenance/capture/__init__.py +1 -0
- quirework/provenance/capture/commands.py +28 -0
- quirework/provenance/capture/extract_artifacts.py +110 -0
- quirework/provenance/capture/extract_files.py +118 -0
- quirework/provenance/capture/fetch_conversation.py +127 -0
- quirework/provenance/capture/fetch_gemini.py +149 -0
- quirework/provenance/capture/render_transcript.py +126 -0
- quirework/provenance/capture/transform.py +22 -0
- quirework/provenance/catalog.py +733 -0
- quirework/provenance/ccfilter.py +1126 -0
- quirework/provenance/codexfilter.py +883 -0
- quirework/provenance/coldread.py +1731 -0
- quirework/provenance/curate.py +67 -0
- quirework/provenance/humaninput.py +811 -0
- quirework/provenance/ingest.py +31 -0
- quirework/provenance/ingestion/__init__.py +1 -0
- quirework/provenance/ingestion/agy.py +192 -0
- quirework/provenance/ingestion/claude.py +493 -0
- quirework/provenance/ingestion/codex.py +296 -0
- quirework/provenance/lineage.py +148 -0
- quirework/provenance/metrics.py +1247 -0
- quirework/provenance/rulings.py +478 -0
- quirework/provenance/scope.py +73 -0
- quirework/provenance/search.py +455 -0
- quirework/provenance/semantic.py +475 -0
- quirework/provenance/show.py +1213 -0
- quirework/provenance/store.py +1271 -0
- quirework/provenance/trees.py +133 -0
- quirework/provenance/worktrees.py +199 -0
- quirework/providers/__init__.py +1 -0
- quirework/providers/blind.py +2192 -0
- quirework/providers/canary.py +685 -0
- quirework/providers/catalog.py +44 -0
- quirework/providers/claude_login.py +92 -0
- quirework/providers/exits.py +196 -0
- quirework/providers/harness.py +830 -0
- quirework/providers/images.py +92 -0
- quirework/providers/models.py +1284 -0
- quirework/providers/quota.py +588 -0
- quirework/providers/reads.py +474 -0
- quirework/providers/replies.py +175 -0
- quirework/publish/__init__.py +1 -0
- quirework/publish/backends.py +112 -0
- quirework/publish/bookstage.py +1086 -0
- quirework/publish/build.py +60 -0
- quirework/publish/editions.py +48 -0
- quirework/publish/epubcheck.py +287 -0
- quirework/publish/experiment.py +1097 -0
- quirework/publish/figfonts.py +234 -0
- quirework/publish/fignumbers.py +293 -0
- quirework/publish/figures.py +729 -0
- quirework/publish/folios.py +62 -0
- quirework/publish/fonts.py +307 -0
- quirework/publish/graybuild.py +73 -0
- quirework/publish/hugo.py +117 -0
- quirework/publish/kindle.js +411 -0
- quirework/publish/kindle.py +1393 -0
- quirework/publish/panelkit.py +918 -0
- quirework/publish/plans.py +651 -0
- quirework/publish/proof.py +792 -0
- quirework/publish/redpen.py +1654 -0
- quirework/publish/render.py +942 -0
- quirework/publish/targets.py +216 -0
- quirework/publish/variant.py +918 -0
- quirework/runs/__init__.py +1 -0
- quirework/runs/__main__.py +15 -0
- quirework/runs/commands.py +278 -0
- quirework/runs/coverage.py +86 -0
- quirework/runs/engine.py +2143 -0
- quirework/runs/feed.py +853 -0
- quirework/runs/observe.py +379 -0
- quirework/runs/storage.py +155 -0
- quirework/settings.py +492 -0
- quirework/sops/__init__.py +1 -0
- quirework/sops/catalog.py +386 -0
- quirework/sops/commands.py +567 -0
- quirework/sops/composition.py +382 -0
- quirework/sops/instruments.py +184 -0
- quirework/sops/personas.py +296 -0
- quirework/sops/review.py +2568 -0
- quirework/sops/room.py +2791 -0
- quirework/sops/roundtable.py +2578 -0
- quirework/sops/sections.py +169 -0
- quirework/sops/workshop.py +1629 -0
- quirework/templates/backup.toml +39 -0
- quirework/templates/book.toml +88 -0
- quirework/templates/run.toml +32 -0
- quirework/templates/site.toml +90 -0
- quirework/templates/tells.toml +64 -0
- quirework/templates/terminology.toml +20 -0
- quirework/templates/tmpfiles.conf +42 -0
- quirework/testing.py +345 -0
- quirework/text/__init__.py +1 -0
- quirework/text/jargon.py +545 -0
- quirework/text/mdwrap.py +1001 -0
- quirework/text/tells.py +547 -0
- quirework-0.1.0.dist-info/METADATA +444 -0
- quirework-0.1.0.dist-info/RECORD +138 -0
- quirework-0.1.0.dist-info/WHEEL +5 -0
- quirework-0.1.0.dist-info/entry_points.txt +2 -0
- quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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()
|