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,111 @@
|
|
|
1
|
+
"""Derived editions as the spec layer sees them: more units that answer to the spec.
|
|
2
|
+
|
|
3
|
+
An edition is a directory under `publish.variants`, one per kind. Its
|
|
4
|
+
documents sit at its top level, and each may keep a marks sidecar,
|
|
5
|
+
`_marks/STEM.md` beside it, as a chapter does. A sidecar's `@bind` marks
|
|
6
|
+
name the spec sections its document carries, and its `@omit` marks name
|
|
7
|
+
the ones the edition leaves out and why. So one commitment can be followed
|
|
8
|
+
from the chapter that states it to every edition that retells it.
|
|
9
|
+
|
|
10
|
+
An edition's unit is named `KIND/STEM`. The poems' `otto` is then never
|
|
11
|
+
taken for the canon's chapter `otto`, and nothing that maps the canon's
|
|
12
|
+
chapters to the spec, such as a trail or a backfill menu, hears of it.
|
|
13
|
+
|
|
14
|
+
This is layout only. The publisher renders an edition from the same
|
|
15
|
+
directories, and imports this module for where they are; the spec layer
|
|
16
|
+
never imports the publisher.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from quirework.manuscript import quarto
|
|
24
|
+
from quirework.manuscript.quarto import BOOK_CONFIG, MARKS_DIR
|
|
25
|
+
from quirework.project import current_project
|
|
26
|
+
|
|
27
|
+
# Every way an edition document can be written, in the order a sidecar's
|
|
28
|
+
# document is looked for.
|
|
29
|
+
_SUFFIXES = (".qmd", ".md")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def root() -> Path | None:
|
|
33
|
+
"""The configured editions root, or None for a project with no editions."""
|
|
34
|
+
if current_project().get("publish.variants") is None:
|
|
35
|
+
return None
|
|
36
|
+
return current_project().path("publish.variants")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def kinds() -> list[str]:
|
|
40
|
+
"""Every edition directory, composed or not, by kind, sorted."""
|
|
41
|
+
base = root()
|
|
42
|
+
if base is None or not base.is_dir():
|
|
43
|
+
return []
|
|
44
|
+
return sorted(
|
|
45
|
+
folder.name
|
|
46
|
+
for folder in base.iterdir()
|
|
47
|
+
if folder.is_dir() and not folder.name.startswith(("_", "."))
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def composed() -> list[str]:
|
|
52
|
+
"""Every edition that has a Quarto file of its own, by kind, sorted."""
|
|
53
|
+
return [kind for kind in kinds() if (root() / kind / BOOK_CONFIG).is_file()]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def documents(kind: str) -> list[Path]:
|
|
57
|
+
"""The documents a composed edition renders, as they are on disk.
|
|
58
|
+
|
|
59
|
+
Raises ValueError when its Quarto file is not a shape the parser reads.
|
|
60
|
+
"""
|
|
61
|
+
folder = root() / kind
|
|
62
|
+
return [
|
|
63
|
+
folder / rel for rel in quarto.documents(folder) if (folder / rel).is_file()
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sidecars() -> list[Path]:
|
|
68
|
+
"""Every edition's marks sidecars, by kind, then by name."""
|
|
69
|
+
base = root()
|
|
70
|
+
return [
|
|
71
|
+
path
|
|
72
|
+
for kind in kinds()
|
|
73
|
+
for path in sorted((base / kind / MARKS_DIR).glob("*.md"))
|
|
74
|
+
if path.is_file()
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _kind_and_stem(path: Path) -> tuple[str, str] | None:
|
|
79
|
+
"""(kind, stem) for an edition's document or sidecar, else None."""
|
|
80
|
+
base = root()
|
|
81
|
+
if base is None:
|
|
82
|
+
return None
|
|
83
|
+
path = Path(path)
|
|
84
|
+
if not path.is_absolute():
|
|
85
|
+
path = current_project().root / path
|
|
86
|
+
try:
|
|
87
|
+
parts = path.resolve().relative_to(base).parts
|
|
88
|
+
except ValueError:
|
|
89
|
+
return None
|
|
90
|
+
if len(parts) == 2 and Path(parts[1]).suffix in _SUFFIXES:
|
|
91
|
+
return parts[0], Path(parts[1]).stem
|
|
92
|
+
if len(parts) == 3 and parts[1] == MARKS_DIR and parts[2].endswith(".md"):
|
|
93
|
+
return parts[0], Path(parts[2]).stem
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def unit_of(path: Path | str) -> str | None:
|
|
98
|
+
"""`KIND/STEM` for an edition's document or sidecar, else None."""
|
|
99
|
+
found = _kind_and_stem(Path(path))
|
|
100
|
+
return f"{found[0]}/{found[1]}" if found else None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_sidecar(path: Path) -> bool:
|
|
104
|
+
return unit_of(path) is not None and Path(path).parent.name == MARKS_DIR
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def prose_for(sidecar: Path) -> Path:
|
|
108
|
+
"""The document an edition sidecar describes, or where it would be."""
|
|
109
|
+
sidecar = Path(sidecar)
|
|
110
|
+
candidates = [sidecar.parent.parent / f"{sidecar.stem}{s}" for s in _SUFFIXES]
|
|
111
|
+
return next((c for c in candidates if c.is_file()), candidates[0])
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Where the work lives: which files are the written work, in any format.
|
|
2
|
+
|
|
3
|
+
Several tools ask the same question in different words. Did this session edit
|
|
4
|
+
the work or the tooling around it? Is this commit one a reviewer must see?
|
|
5
|
+
Which paths does the history of the prose run through? Each used to answer it
|
|
6
|
+
for itself, from `manuscript.source` and the companion directories, which is
|
|
7
|
+
right for a book whose spec and marks sit inside its source and wrong for a
|
|
8
|
+
site that keeps them outside the tree its generator renders. This module is
|
|
9
|
+
the one answer, and it is built from what the project already declares:
|
|
10
|
+
|
|
11
|
+
- the units and their marks, as the format lays them out;
|
|
12
|
+
- the register: the shared spec files, each unit's own spec, the sources,
|
|
13
|
+
the references and the review notes;
|
|
14
|
+
- the companion collections;
|
|
15
|
+
- and, kept apart, the working roots (`manuscript.noncanonical_roots`), which
|
|
16
|
+
are writing without authority. A caller measuring effort counts them; a
|
|
17
|
+
caller deciding what a reviewer must see does not.
|
|
18
|
+
|
|
19
|
+
Every entry is a glob over project-relative POSIX paths in the `core.globs`
|
|
20
|
+
dialect. A project without a manuscript has an empty footprint.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
from quirework.core import globs
|
|
28
|
+
from quirework.manuscript import formats
|
|
29
|
+
from quirework.project import Project, current_project
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Footprint:
|
|
34
|
+
units: tuple[str, ...] # the prose and its marks, as the format lays them out
|
|
35
|
+
register: tuple[str, ...] # spec files, references, review notes
|
|
36
|
+
companions: tuple[str, ...] # reader-facing collections beside the work
|
|
37
|
+
working: tuple[str, ...] # working material: writing, but no authority
|
|
38
|
+
|
|
39
|
+
def patterns(self, working: bool = False) -> tuple[str, ...]:
|
|
40
|
+
out = self.units + self.register + self.companions
|
|
41
|
+
return out + self.working if working else out
|
|
42
|
+
|
|
43
|
+
def covers(self, path: str, working: bool = False) -> bool:
|
|
44
|
+
"""Whether a project-relative path is part of the work."""
|
|
45
|
+
path = path[2:] if path.startswith("./") else path
|
|
46
|
+
return globs.any_match(path, self.patterns(working))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _directories(project: Project, key: str) -> tuple[str, ...]:
|
|
50
|
+
"""A configured list of directories, each as a glob for everything under it."""
|
|
51
|
+
out = []
|
|
52
|
+
for value in project.get(key, ()) or ():
|
|
53
|
+
if not value:
|
|
54
|
+
continue
|
|
55
|
+
text = str(value).strip("/")
|
|
56
|
+
out.append("**" if text in ("", ".") else f"{text}/**")
|
|
57
|
+
return tuple(out)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _spec_files(project: Project) -> tuple[str, ...]:
|
|
61
|
+
"""The shared spec files, one or a list, as project-relative globs; one
|
|
62
|
+
outside the project has no such name and is left out, as `relative` does."""
|
|
63
|
+
if project.get("manuscript.spec") is None:
|
|
64
|
+
return ()
|
|
65
|
+
out = []
|
|
66
|
+
for path in project.paths("manuscript.spec"):
|
|
67
|
+
try:
|
|
68
|
+
out.append(path.relative_to(project.root).as_posix())
|
|
69
|
+
except ValueError:
|
|
70
|
+
continue
|
|
71
|
+
return tuple(out)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def footprint(project: Project | None = None) -> Footprint:
|
|
75
|
+
project = project or current_project()
|
|
76
|
+
if not project.get("manuscript"):
|
|
77
|
+
return Footprint((), (), (), ())
|
|
78
|
+
units = formats.get(project.get("manuscript.format", "quarto")).footprint(project)
|
|
79
|
+
register = _spec_files(project) + tuple(
|
|
80
|
+
found
|
|
81
|
+
for key in ("manuscript.references", "manuscript.review_notes")
|
|
82
|
+
if (found := formats.relative(project, key)) is not None
|
|
83
|
+
)
|
|
84
|
+
unit_spec = project.get("manuscript.unit_spec")
|
|
85
|
+
if unit_spec:
|
|
86
|
+
register += (str(unit_spec).replace("{slug}", "*"),)
|
|
87
|
+
from quirework.manuscript import sources
|
|
88
|
+
|
|
89
|
+
register += sources.footprint_patterns(project)
|
|
90
|
+
return Footprint(
|
|
91
|
+
units,
|
|
92
|
+
register,
|
|
93
|
+
_directories(project, "manuscript.companion_dirs"),
|
|
94
|
+
_directories(project, "manuscript.noncanonical_roots"),
|
|
95
|
+
)
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""What a manuscript format is, and how one is selected.
|
|
2
|
+
|
|
3
|
+
A format answers every question the spec layer asks about the written work's
|
|
4
|
+
layout: which files are its units and in what order, which files are prose,
|
|
5
|
+
where a unit's marks sidecar lives, and which unit a sidecar belongs to. The
|
|
6
|
+
spec, marks, quotations and trail are written once against this interface, so
|
|
7
|
+
a new layout is one new `Format` and no change to them.
|
|
8
|
+
|
|
9
|
+
`manuscript.format` names one. The two built in are `quarto` (a Quarto book's
|
|
10
|
+
chapters, in `_quarto.yml` order) and `pages` (a site's Markdown pages). A
|
|
11
|
+
project can select a format of its own as an installed extension,
|
|
12
|
+
`module:function`, where the function returns a `Format`; like every
|
|
13
|
+
extension, it is loaded only when the project names it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import importlib
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from quirework.core import frontmatter
|
|
24
|
+
from quirework.project import ConfigurationError, Project, current_project, extension
|
|
25
|
+
|
|
26
|
+
# The formats Quirework ships, by module. Imported on first use, so a project
|
|
27
|
+
# that never names a format pays for neither.
|
|
28
|
+
BUILTIN = {
|
|
29
|
+
"quarto": "quirework.manuscript.quarto",
|
|
30
|
+
"pages": "quirework.manuscript.pages",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# A slug is lowercase words joined by hyphens: the one shape a reference, a
|
|
34
|
+
# path pattern and a spec binding can all carry without quoting.
|
|
35
|
+
SLUG_OK = re.compile(r"\A[a-z0-9]+(-[a-z0-9]+)*\Z")
|
|
36
|
+
|
|
37
|
+
# Developer-guide files carry annotation *examples* in prose, not content.
|
|
38
|
+
SKIP_NAMES = {"AGENTS.md", "CLAUDE.md", "GEMINI.md"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Unit:
|
|
43
|
+
slug: str # the durable identity: sidecar, anchor, @unit(), binding key
|
|
44
|
+
rel: str # path to the prose file, relative to the project root
|
|
45
|
+
title: str # the H1 or frontmatter title, which may drift from the slug
|
|
46
|
+
number: int | None = None # a printed chapter number, from position
|
|
47
|
+
date: str | None = None # a page's frontmatter date
|
|
48
|
+
# Whether a page is a draft. None where the format has no publication
|
|
49
|
+
# state, as a book's chapters have none of their own.
|
|
50
|
+
draft: bool | None = None
|
|
51
|
+
lastmod: str | None = None # when a published page was last revised
|
|
52
|
+
weight: int | None = None # a page's frontmatter weight, for ordering
|
|
53
|
+
collection: str | None = None # the named collection a page belongs to
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Format:
|
|
57
|
+
"""One way of laying out a manuscript. Subclasses answer for their layout.
|
|
58
|
+
|
|
59
|
+
Every method that reads the tree takes ``docs``, the source directory,
|
|
60
|
+
which is `manuscript.source` in normal use and a temporary tree when a
|
|
61
|
+
check reads the work as it stood at a revision.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
name: str = ""
|
|
65
|
+
# What one unit is called in messages, unless `manuscript.unit_name` says.
|
|
66
|
+
noun: str = "unit"
|
|
67
|
+
|
|
68
|
+
def validate(self, project: Project) -> None:
|
|
69
|
+
"""Refuse a configuration this format cannot read. Called by
|
|
70
|
+
`Project.require("manuscript")` inside the project's scope."""
|
|
71
|
+
|
|
72
|
+
def registry(self, docs: Path) -> list[Unit]:
|
|
73
|
+
"""Every unit in reading order."""
|
|
74
|
+
raise NotImplementedError
|
|
75
|
+
|
|
76
|
+
def order_problems(self, docs: Path) -> list[str]:
|
|
77
|
+
"""What stops the units on disk from being the manuscript's units."""
|
|
78
|
+
raise NotImplementedError
|
|
79
|
+
|
|
80
|
+
def prose_files(self, docs: Path) -> list[Path]:
|
|
81
|
+
"""Every prose file on disk, whether or not a reading order lists it."""
|
|
82
|
+
raise NotImplementedError
|
|
83
|
+
|
|
84
|
+
def slug_of(self, path: Path) -> str:
|
|
85
|
+
"""The unit slug a prose file carries."""
|
|
86
|
+
raise NotImplementedError
|
|
87
|
+
|
|
88
|
+
def marks_path(self, slug: str, docs: Path) -> Path | None:
|
|
89
|
+
"""Where the unit's marks sidecar lives, or would; None if it has none."""
|
|
90
|
+
raise NotImplementedError
|
|
91
|
+
|
|
92
|
+
def sidecars(self, docs: Path) -> list[Path]:
|
|
93
|
+
"""Every marks sidecar on disk."""
|
|
94
|
+
raise NotImplementedError
|
|
95
|
+
|
|
96
|
+
def sidecar_slug(self, path: Path) -> str | None:
|
|
97
|
+
"""The unit a sidecar belongs to, or None if the file is no sidecar."""
|
|
98
|
+
raise NotImplementedError
|
|
99
|
+
|
|
100
|
+
def prose_for_sidecar(self, path: Path, docs: Path) -> Path | None:
|
|
101
|
+
"""The prose file a sidecar's marks describe; None when it has none."""
|
|
102
|
+
raise NotImplementedError
|
|
103
|
+
|
|
104
|
+
def harvest(self, docs: Path) -> list[Path]:
|
|
105
|
+
"""The files and directories `notes.scan` reads by default."""
|
|
106
|
+
raise NotImplementedError
|
|
107
|
+
|
|
108
|
+
def default_layers(self, project: Project) -> tuple[str, ...]:
|
|
109
|
+
"""Where the metadata sidecars are, as globs relative to the root."""
|
|
110
|
+
raise NotImplementedError
|
|
111
|
+
|
|
112
|
+
def prose_token(self, token: str) -> str | None:
|
|
113
|
+
"""The slug a token names when it is written as a prose file's path
|
|
114
|
+
(`the-sidecar.qmd`, `posts/the-kettle/index.md`), else None. A run of
|
|
115
|
+
prose that cites units reads such a token as a pointer."""
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
def listing(self, units: list[Unit]) -> list[str]:
|
|
119
|
+
"""The rows `spec units` prints, one per unit."""
|
|
120
|
+
return [f" {u.slug:34} {u.title}" for u in units]
|
|
121
|
+
|
|
122
|
+
def footprint(self, project: Project) -> tuple[str, ...]:
|
|
123
|
+
"""Where the units and their marks live, as globs over project-relative
|
|
124
|
+
paths (`core.globs`). Everything else in a work's footprint, its spec
|
|
125
|
+
and companions, is the project's to declare and `footprint` adds it."""
|
|
126
|
+
return ()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get(name: str) -> Format:
|
|
130
|
+
"""The format ``name`` names: a built-in, or an extension's."""
|
|
131
|
+
if name in BUILTIN:
|
|
132
|
+
return importlib.import_module(BUILTIN[name]).FORMAT
|
|
133
|
+
if ":" in name:
|
|
134
|
+
made = extension(name)()
|
|
135
|
+
if not isinstance(made, Format):
|
|
136
|
+
raise ConfigurationError(
|
|
137
|
+
f"manuscript.format {name} must return a quirework Format"
|
|
138
|
+
)
|
|
139
|
+
return made
|
|
140
|
+
raise ConfigurationError(
|
|
141
|
+
f"manuscript.format must be one of {', '.join(BUILTIN)} or an installed "
|
|
142
|
+
f"module:function, not {name!r}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def current() -> Format:
|
|
147
|
+
"""The selected project's format; `quarto` when it declares none."""
|
|
148
|
+
return get(current_project().get("manuscript.format", "quarto"))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# --------------------------------------------------------------------------- #
|
|
152
|
+
# helpers the built-in formats share
|
|
153
|
+
# --------------------------------------------------------------------------- #
|
|
154
|
+
def base(docs: Path) -> Path:
|
|
155
|
+
"""What a unit's `rel` is measured from: the project root when the source
|
|
156
|
+
is inside it, else the directory holding the source. The second case is a
|
|
157
|
+
tree outside any project, which is what a test hands the registry."""
|
|
158
|
+
root = current_project().root
|
|
159
|
+
return root if docs.resolve().is_relative_to(root) else docs.parent
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def rel(path: Path) -> str:
|
|
163
|
+
root = current_project().root
|
|
164
|
+
path = Path(path)
|
|
165
|
+
return str(path.relative_to(root)) if path.is_relative_to(root) else str(path)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_H1 = re.compile(r"^#\s+(.*?)\s*(?:\{(.*?)\})?\s*$")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def h1(text: str) -> tuple[str, str]:
|
|
172
|
+
"""(title, attribute block) of the first H1 after any frontmatter."""
|
|
173
|
+
found = frontmatter.block(text)
|
|
174
|
+
body = text[found.end() :] if found else text
|
|
175
|
+
for line in body.splitlines():
|
|
176
|
+
m = _H1.match(line)
|
|
177
|
+
if m:
|
|
178
|
+
return m.group(1).strip(), (m.group(2) or "")
|
|
179
|
+
return "", ""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def relative(project: Project, key: str) -> str | None:
|
|
183
|
+
"""A configured path as a project-relative POSIX path; None when it is
|
|
184
|
+
unset or lies outside the project, where no project-relative glob can
|
|
185
|
+
name it."""
|
|
186
|
+
if project.get(key) is None:
|
|
187
|
+
return None
|
|
188
|
+
try:
|
|
189
|
+
return project.path(key).relative_to(project.root).as_posix()
|
|
190
|
+
except ValueError:
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def slug_pattern(key: str) -> str | None:
|
|
195
|
+
"""A configured path pattern that must carry `{slug}`, or None if unset."""
|
|
196
|
+
pattern = current_project().get(key)
|
|
197
|
+
if pattern is not None and "{slug}" not in str(pattern):
|
|
198
|
+
raise ConfigurationError(
|
|
199
|
+
f"{key} must contain {{slug}}, e.g. 'specs/{{slug}}/"
|
|
200
|
+
f"{'spec' if key.endswith('spec') else 'marks'}.md'"
|
|
201
|
+
)
|
|
202
|
+
return pattern
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def pattern_regex(pattern: str) -> re.Pattern:
|
|
206
|
+
"""A `{slug}` path pattern as a regex capturing the slug."""
|
|
207
|
+
head, _, tail = pattern.partition("{slug}")
|
|
208
|
+
return re.compile(re.escape(head) + r"(?P<slug>[^/]+)" + re.escape(tail) + r"\Z")
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Where each unit of the work stands: an idea, planned, a draft, published.
|
|
2
|
+
|
|
3
|
+
A book's chapters are written and revised together and carry no state of
|
|
4
|
+
their own. A site's posts each live a life: a brief in working material, a
|
|
5
|
+
spec before a word is drafted, a draft, a publication, and for an explainer
|
|
6
|
+
years of revisions after it. The layers already hold every stage but the
|
|
7
|
+
first, so this module only reads them:
|
|
8
|
+
|
|
9
|
+
- an **idea** is a file `manuscript.ideas` matches that no unit has taken up
|
|
10
|
+
yet: none carries its name, and no mark or spec names where it lives (its
|
|
11
|
+
directory, when it is named by one), which is how a post's @genesis
|
|
12
|
+
points at the brief it grew from
|
|
13
|
+
- a unit is **planned** while it has a spec and no page
|
|
14
|
+
- a page is a **draft** or **published** by its own frontmatter
|
|
15
|
+
|
|
16
|
+
Two advisories follow from the published stage, because what an author
|
|
17
|
+
promised a reader is at stake there and nowhere earlier. A published unit
|
|
18
|
+
with an open @todo is waiting on something its readers cannot see. And a
|
|
19
|
+
published unit whose own spec changed after the page was last dated says
|
|
20
|
+
something it no longer holds, or holds something it no longer says, until the
|
|
21
|
+
page is revised and its revision field (`lastmod` unless
|
|
22
|
+
`manuscript.revision_field` names another) records it. The second reads the
|
|
23
|
+
history, so it needs the project in git; each page is compared with the
|
|
24
|
+
commit that last touched its spec, not with a clock.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
import subprocess
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from quirework.manuscript import notes, units
|
|
35
|
+
from quirework.project import ConfigurationError, current_project
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Idea:
|
|
40
|
+
slug: str
|
|
41
|
+
rel: str # the file the ideas glob matched, relative to the root
|
|
42
|
+
home: str # its directory when the idea is named by one, else rel
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def revision_field() -> str:
|
|
46
|
+
return str(current_project().get("manuscript.revision_field", "lastmod"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def ideas() -> list[Idea]:
|
|
50
|
+
"""Every file `manuscript.ideas` matches, named as a unit would be: by its
|
|
51
|
+
directory when the glob names a fixed file (`ideas/*/brief.md`), else by
|
|
52
|
+
its stem."""
|
|
53
|
+
project = current_project()
|
|
54
|
+
pattern = project.get("manuscript.ideas")
|
|
55
|
+
if not pattern:
|
|
56
|
+
return []
|
|
57
|
+
by_directory = not any(c in pattern.rpartition("/")[2] for c in "*?[")
|
|
58
|
+
out = []
|
|
59
|
+
for path in sorted(project.root.glob(pattern)):
|
|
60
|
+
if not path.is_file() or path.name.startswith("_") or path.name == "README.md":
|
|
61
|
+
continue
|
|
62
|
+
rel = path.relative_to(project.root).as_posix()
|
|
63
|
+
if by_directory:
|
|
64
|
+
home = path.parent.relative_to(project.root).as_posix()
|
|
65
|
+
out.append(Idea(path.parent.name, rel, home))
|
|
66
|
+
else:
|
|
67
|
+
out.append(Idea(path.stem, rel, rel))
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _names_path(said: str, path: str) -> bool:
|
|
72
|
+
"""Whether prose names `path` as a whole path: `ideas/lamp` is named by
|
|
73
|
+
"in ideas/lamp," and by "ideas/lamp/brief.md", not by "ideas/lamp-two".
|
|
74
|
+
A trailing period ends a sentence; one followed by a letter starts an
|
|
75
|
+
extension, and so names another file."""
|
|
76
|
+
return re.search(rf"(?<![\w./-]){re.escape(path)}(?![\w-]|\.\w)", said) is not None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def planned() -> list[tuple[str, Path]]:
|
|
80
|
+
"""(slug, spec file) for each unit spec whose unit has no page yet."""
|
|
81
|
+
if notes._unit_spec_pattern() is None:
|
|
82
|
+
return []
|
|
83
|
+
present = units.names()
|
|
84
|
+
return [
|
|
85
|
+
(owner, f)
|
|
86
|
+
for f in notes.spec_files()
|
|
87
|
+
if (owner := notes.spec_owner(f)) is not None and owner not in present
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def open_ideas() -> list[Idea]:
|
|
92
|
+
"""The ideas no unit has taken up yet."""
|
|
93
|
+
found = ideas()
|
|
94
|
+
if not found:
|
|
95
|
+
return []
|
|
96
|
+
taken = units.names() | {slug for slug, _ in planned()}
|
|
97
|
+
# A mark or a spec that names the idea's path has taken it up: a post's
|
|
98
|
+
# @genesis points at the brief it grew from. An idea named by its
|
|
99
|
+
# directory is its directory, so naming that is enough; an idea that is
|
|
100
|
+
# one file among others in a directory is named only by its own path.
|
|
101
|
+
try:
|
|
102
|
+
specs = notes.spec_files()
|
|
103
|
+
except ConfigurationError:
|
|
104
|
+
specs = []
|
|
105
|
+
said = "\n".join(
|
|
106
|
+
[a.body for a in notes.scan() if a.body]
|
|
107
|
+
+ [f.read_text(encoding="utf-8") for f in specs]
|
|
108
|
+
)
|
|
109
|
+
return [
|
|
110
|
+
idea
|
|
111
|
+
for idea in found
|
|
112
|
+
if idea.slug not in taken and not _names_path(said, idea.home)
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def published() -> list[units.Unit]:
|
|
117
|
+
return [u for u in units.registry() if u.draft is False]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def open_todos() -> list[str]:
|
|
121
|
+
"""Advisory lines: an open @todo on a published unit."""
|
|
122
|
+
live = {u.rel for u in published()}
|
|
123
|
+
if not live:
|
|
124
|
+
return []
|
|
125
|
+
return [
|
|
126
|
+
f" {a.source or a.file}:{a.line}: @todo {a.body}".rstrip()
|
|
127
|
+
for a in notes.scan()
|
|
128
|
+
if a.kind == "todo" and a.file in live
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _git(root, *args):
|
|
133
|
+
return subprocess.run(
|
|
134
|
+
["git", "-C", str(root), *args], capture_output=True, text=True
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _claims(text: str) -> str:
|
|
139
|
+
"""A spec's text as its claims: without comments, anchors or the spacing
|
|
140
|
+
a reflow changes. Renaming an anchor or rewrapping a paragraph revises
|
|
141
|
+
nothing a reader was told."""
|
|
142
|
+
text = re.sub(r"(?s)<!--.*?-->", " ", text)
|
|
143
|
+
text = re.sub(r"\{[^{}\n]*\}", " ", text)
|
|
144
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def unrevised() -> list[str] | None:
|
|
148
|
+
"""Advisory lines: a published unit whose own spec's claims changed after
|
|
149
|
+
the page was last dated. None when the project has no git history.
|
|
150
|
+
|
|
151
|
+
The spec is compared as it stood at the commit that last dated the page,
|
|
152
|
+
or, for a spec written after that, as it was first committed: writing a
|
|
153
|
+
spec for a post already published describes it, and only a change after
|
|
154
|
+
that revises it. The comparison is of claims, not bytes (`_claims`), and
|
|
155
|
+
of the working file, so an uncommitted revision is named before it is
|
|
156
|
+
committed and a page being redated in the same change is not.
|
|
157
|
+
"""
|
|
158
|
+
project = current_project()
|
|
159
|
+
root = project.root
|
|
160
|
+
if _git(root, "rev-parse", "--verify", "--quiet", "HEAD").returncode:
|
|
161
|
+
return None
|
|
162
|
+
field = revision_field()
|
|
163
|
+
dated = rf"^(date|{re.escape(field)})[[:space:]]*[:=]"
|
|
164
|
+
redating = re.compile(rf"(?m)^[+-](date|{re.escape(field)})\s*[:=]")
|
|
165
|
+
out = []
|
|
166
|
+
for u in published():
|
|
167
|
+
spec = notes.unit_spec_path(u.slug)
|
|
168
|
+
if spec is None or not spec.is_file():
|
|
169
|
+
continue
|
|
170
|
+
spec_rel = spec.relative_to(root).as_posix()
|
|
171
|
+
if redating.search(_git(root, "diff", "HEAD", "-U0", "--", u.rel).stdout):
|
|
172
|
+
continue # being redated now
|
|
173
|
+
last_dated = _git(
|
|
174
|
+
root, "log", "-1", "--format=%H", "-G", dated, "--", u.rel
|
|
175
|
+
).stdout.strip()
|
|
176
|
+
if not last_dated:
|
|
177
|
+
continue # the page has no dated history yet
|
|
178
|
+
then = _git(root, "show", f"{last_dated}:./{spec_rel}")
|
|
179
|
+
if then.returncode:
|
|
180
|
+
first = _git(
|
|
181
|
+
root, "log", "--diff-filter=A", "--format=%H", "--", spec_rel
|
|
182
|
+
).stdout.split()
|
|
183
|
+
if not first:
|
|
184
|
+
continue # being written, not revised
|
|
185
|
+
then = _git(root, "show", f"{first[-1]}:./{spec_rel}")
|
|
186
|
+
if _claims(then.stdout) != _claims(spec.read_text(encoding="utf-8")):
|
|
187
|
+
out.append(
|
|
188
|
+
f" {u.rel}: {spec_rel} changed after the {units.noun()} was last "
|
|
189
|
+
f"dated; revise it, set {field}, and tell a reader what changed"
|
|
190
|
+
)
|
|
191
|
+
return out
|