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,210 @@
|
|
|
1
|
+
"""Explicit delivery transforms, independent of exposure and session topology.
|
|
2
|
+
|
|
3
|
+
Text is not silently cleaned or summarized. Source shaping is a preparation
|
|
4
|
+
choice; arbitrary files remain opaque unless their declared delivery needs a
|
|
5
|
+
conversion. A registry allows further transports without editing the engine.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from quirework.runs.storage import digest
|
|
13
|
+
from quirework.runs.storage import relative
|
|
14
|
+
|
|
15
|
+
TRANSFORMS = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register(name):
|
|
19
|
+
def install(function):
|
|
20
|
+
if name in TRANSFORMS:
|
|
21
|
+
raise ValueError(f"duplicate delivery transform: {name}")
|
|
22
|
+
TRANSFORMS[name] = function
|
|
23
|
+
return function
|
|
24
|
+
|
|
25
|
+
return install
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def copy(source, target):
|
|
29
|
+
if source.is_symlink() or not source.is_file():
|
|
30
|
+
raise ValueError(f"material must be a regular file: {source}")
|
|
31
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
shutil.copyfile(source, target)
|
|
33
|
+
return target
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@register("text")
|
|
37
|
+
def text(source, directory):
|
|
38
|
+
source.read_text(encoding="utf-8") # Reject a binary pretending to be text.
|
|
39
|
+
return [("text", copy(source, directory / "material.txt"))]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@register("image")
|
|
43
|
+
def image(source, directory):
|
|
44
|
+
# SVG is not a native image attachment in these adapters. Make conversion
|
|
45
|
+
# explicit with delivery=svg; never claim that staging an SVG showed it.
|
|
46
|
+
if source.suffix.lower() not in (".png", ".jpg", ".jpeg", ".webp"):
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"image delivery requires PNG, JPEG or WebP; use svg for conversion"
|
|
49
|
+
)
|
|
50
|
+
return [("image", copy(source, directory / ("image" + source.suffix.lower())))]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@register("svg")
|
|
54
|
+
def svg(source, directory):
|
|
55
|
+
frozen = copy(source, directory / "source.svg")
|
|
56
|
+
dest = directory / "image.png"
|
|
57
|
+
subprocess.run(
|
|
58
|
+
["rsvg-convert", "-w", "1600", "-o", str(dest), str(frozen)],
|
|
59
|
+
check=True,
|
|
60
|
+
capture_output=True,
|
|
61
|
+
)
|
|
62
|
+
return [("image", dest)]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@register("pages")
|
|
66
|
+
def pages(source, directory):
|
|
67
|
+
from pypdf import PdfReader
|
|
68
|
+
|
|
69
|
+
frozen = copy(source, directory / "source.pdf")
|
|
70
|
+
subprocess.run(
|
|
71
|
+
["pdftoppm", "-png", "-scale-to", "1600", str(frozen), str(directory / "page")],
|
|
72
|
+
check=True,
|
|
73
|
+
capture_output=True,
|
|
74
|
+
)
|
|
75
|
+
files = sorted(
|
|
76
|
+
directory.glob("page-*.png"), key=lambda p: int(p.stem.rsplit("-", 1)[1])
|
|
77
|
+
)
|
|
78
|
+
if not files or len(files) != len(PdfReader(frozen).pages):
|
|
79
|
+
raise RuntimeError("incomplete PDF page rasterization")
|
|
80
|
+
return [("image", p) for p in files]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@register("file")
|
|
84
|
+
def file(source, directory):
|
|
85
|
+
return [("file", copy(source, directory / ("material" + source.suffix)))]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def prepare(materials, origin, destination, cache=None):
|
|
89
|
+
"""Freeze the exact source plus derived delivery bytes, with neutral names."""
|
|
90
|
+
if not isinstance(materials, list):
|
|
91
|
+
raise ValueError("materials must be a list")
|
|
92
|
+
result = []
|
|
93
|
+
for index, item in enumerate(materials):
|
|
94
|
+
if not isinstance(item, dict) or set(item) - {"source", "delivery", "label"}:
|
|
95
|
+
raise ValueError("material fields: source, delivery, optional label")
|
|
96
|
+
if item.get("delivery") not in TRANSFORMS:
|
|
97
|
+
raise ValueError(f"unknown delivery: {item.get('delivery')}")
|
|
98
|
+
if "label" in item and (
|
|
99
|
+
not isinstance(item["label"], str) or not item["label"].strip()
|
|
100
|
+
):
|
|
101
|
+
raise ValueError("material label must contain text")
|
|
102
|
+
source = Path(item["source"]).expanduser()
|
|
103
|
+
source = source if source.is_absolute() else origin / source
|
|
104
|
+
directory = destination / f"asset-{index:03d}"
|
|
105
|
+
directory.mkdir(parents=True)
|
|
106
|
+
before = digest(source)
|
|
107
|
+
key = (item["delivery"], before, source.suffix.lower())
|
|
108
|
+
cached = cache.get(key) if cache is not None else None
|
|
109
|
+
# Composite prose/figure transforms have multiple source dependencies;
|
|
110
|
+
# don't cache them under the chapter hash alone.
|
|
111
|
+
cacheable = item["delivery"] not in ("prose", "prose-images")
|
|
112
|
+
if cached and cacheable:
|
|
113
|
+
previous, output_items = cached
|
|
114
|
+
shutil.copytree(previous, directory, dirs_exist_ok=True)
|
|
115
|
+
values = [(kind, directory / rel) for kind, rel in output_items]
|
|
116
|
+
else:
|
|
117
|
+
values = TRANSFORMS[item["delivery"]](source, directory)
|
|
118
|
+
if cache is not None and cacheable:
|
|
119
|
+
cache[key] = (
|
|
120
|
+
directory,
|
|
121
|
+
[(kind, str(p.relative_to(directory))) for kind, p in values],
|
|
122
|
+
)
|
|
123
|
+
if digest(source) != before:
|
|
124
|
+
raise RuntimeError(f"source changed while freezing: {source}")
|
|
125
|
+
for kind, path in values:
|
|
126
|
+
result.append(
|
|
127
|
+
dict(
|
|
128
|
+
kind=kind,
|
|
129
|
+
path=str(path.relative_to(destination)),
|
|
130
|
+
sha256=digest(path),
|
|
131
|
+
source_sha256=before,
|
|
132
|
+
delivery=item["delivery"],
|
|
133
|
+
label=item.get("label"),
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def stage(items, frozen, destination, virtual):
|
|
140
|
+
"""Only the declared current turn is copied; return prompt and attachments."""
|
|
141
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
chunks, images, files = [], [], []
|
|
143
|
+
for index, item in enumerate(items):
|
|
144
|
+
source = frozen / relative(item["path"])
|
|
145
|
+
path = destination / f"material-{index:03d}{source.suffix}"
|
|
146
|
+
copy(source, path)
|
|
147
|
+
shown = virtual / path.name
|
|
148
|
+
if item["kind"] == "text":
|
|
149
|
+
label = f": {item['label']}" if item.get("label") else ""
|
|
150
|
+
chunks.append(
|
|
151
|
+
f"\n=== MATERIAL {index + 1}{label} ===\n{path.read_text()}\n=== END MATERIAL ==="
|
|
152
|
+
)
|
|
153
|
+
elif item["kind"] == "image":
|
|
154
|
+
images.append((path, shown))
|
|
155
|
+
if item.get("label"):
|
|
156
|
+
chunks.append(f"\nAttached image {len(images)}: {item['label']}\n")
|
|
157
|
+
else:
|
|
158
|
+
files.append((path, shown))
|
|
159
|
+
if item.get("label"):
|
|
160
|
+
chunks.append(f"\nFile {shown}: {item['label']}\n")
|
|
161
|
+
if files:
|
|
162
|
+
chunks.append(
|
|
163
|
+
"\nRead the supplied files at these exact paths; do not search for them:\n"
|
|
164
|
+
+ "\n".join(str(v) for _, v in files)
|
|
165
|
+
)
|
|
166
|
+
return "\n".join(chunks), images, files
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@register("prose")
|
|
170
|
+
def prose(source, directory):
|
|
171
|
+
from quirework.delivery.text import reader_markdown
|
|
172
|
+
|
|
173
|
+
frozen = copy(source, directory / ("source" + source.suffix))
|
|
174
|
+
text, figures = reader_markdown(frozen.read_text())
|
|
175
|
+
for figure in figures:
|
|
176
|
+
text = text.replace(
|
|
177
|
+
f"![{figure['caption']}](../{figure['file']})",
|
|
178
|
+
f"[Figure caption: {figure['caption']}]",
|
|
179
|
+
)
|
|
180
|
+
dest = directory / "material.md"
|
|
181
|
+
dest.write_text(text)
|
|
182
|
+
return [("text", dest)]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@register("prose-images")
|
|
186
|
+
def prose_images(source, directory):
|
|
187
|
+
from quirework.delivery.text import reader_markdown
|
|
188
|
+
|
|
189
|
+
# The figures travel as images, so their alt text would only restate
|
|
190
|
+
# them in words, and a seat could rate a figure from that without ever
|
|
191
|
+
# looking at it. Text-only `prose` keeps it: there the description is
|
|
192
|
+
# the only account of the figure the seat gets.
|
|
193
|
+
frozen = copy(source, directory / ("source" + source.suffix))
|
|
194
|
+
text, figures = reader_markdown(frozen.read_text(), alt=False)
|
|
195
|
+
dest = directory / "material.md"
|
|
196
|
+
dest.write_text(text)
|
|
197
|
+
result = [("text", dest)]
|
|
198
|
+
for index, figure in enumerate(figures):
|
|
199
|
+
# A figure travels from beside the page that embeds it. A site-root
|
|
200
|
+
# path or a URL names something only the published site can find.
|
|
201
|
+
if "://" in figure["source"] or Path(figure["source"]).is_absolute():
|
|
202
|
+
raise ValueError(
|
|
203
|
+
f"{source.name}: figure {figure['source']} is not a file beside "
|
|
204
|
+
"the page, so it cannot be delivered as an image; deliver the "
|
|
205
|
+
"page as text, or its figures as their own materials"
|
|
206
|
+
)
|
|
207
|
+
original = source.parent / figure["source"]
|
|
208
|
+
transform = svg if original.suffix.lower() == ".svg" else image
|
|
209
|
+
result += transform(original, directory / f"figure-{index:03d}")
|
|
210
|
+
return result
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""A reader's copy of a unit's source: the Markdown a reader of the page sees.
|
|
2
|
+
|
|
3
|
+
A seat handed prose must get what a reader of the rendered page gets and
|
|
4
|
+
nothing the author keeps for themselves: no frontmatter (a draft flag, tags, a
|
|
5
|
+
summary the author wrote about the piece), no comments (the marks), no raw
|
|
6
|
+
output blocks, no anchors and no generator syntax. It is not a renderer and
|
|
7
|
+
makes no layout claim.
|
|
8
|
+
|
|
9
|
+
The source may be written for Quarto or for a site generator, and the
|
|
10
|
+
projection reads either by its syntax alone, since a delivery never knows the
|
|
11
|
+
project's format and must not import it:
|
|
12
|
+
|
|
13
|
+
- frontmatter in either convention, TOML between `+++` or YAML between `---`
|
|
14
|
+
- a figure as Pandoc writes it, `{fig-alt="..."}`, whose
|
|
15
|
+
`fig-alt` is required; as plain Markdown, ``; or as
|
|
16
|
+
Hugo's `{{< figure src="..." alt="..." caption="..." >}}`
|
|
17
|
+
- any other shortcode: a paired one keeps its content and loses its tags, a
|
|
18
|
+
link whose target is one keeps its text, and a standalone one is dropped,
|
|
19
|
+
since it renders as something no Markdown reader can be handed
|
|
20
|
+
- a Pandoc fenced div, such as a table float or a callout, which is paired
|
|
21
|
+
syntax too: its fence lines go and what it holds stays
|
|
22
|
+
|
|
23
|
+
Anything that survives the projection and should not have is refused by name
|
|
24
|
+
rather than delivered.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from quirework.core import frontmatter
|
|
31
|
+
|
|
32
|
+
HTML_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
33
|
+
|
|
34
|
+
RAW_BLOCK = re.compile(r"^```\{=[^}]+\}\s*$.*?^```\s*$", re.MULTILINE | re.DOTALL)
|
|
35
|
+
|
|
36
|
+
ATTR_BLOCK = re.compile(r"\s*\{(?=[#.])[^{}\n]*\}")
|
|
37
|
+
|
|
38
|
+
# A fenced div's fence line, opening or closing: three or more colons, then
|
|
39
|
+
# an attribute block or a bare class name on an opening fence, and optionally
|
|
40
|
+
# trailing colons. Only the fence goes; the lines between are prose, a table
|
|
41
|
+
# or a caption, and a reader of the page reads them.
|
|
42
|
+
DIV_FENCE = re.compile(
|
|
43
|
+
r"^[ \t]*:{3,}[ \t]*(?:\{[^{}\n]*\}|[A-Za-z][\w-]*)?[ \t]*:*[ \t]*$",
|
|
44
|
+
re.MULTILINE,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_IMAGE = r"(?:svg|png|jpe?g|webp)"
|
|
48
|
+
|
|
49
|
+
# `{attrs}`: Pandoc's figure when the attribute block is there,
|
|
50
|
+
# plain Markdown when it is not. A title after the source is allowed.
|
|
51
|
+
FIGURE = re.compile(
|
|
52
|
+
r"!\[(?P<caption>[^\]]*)\]\((?P<name>[^)\"]+?\." + _IMAGE + r")"
|
|
53
|
+
r"(?:\s+\"[^\"]*\")?\)"
|
|
54
|
+
r"(?P<attrs>\{[^\n]*\})?"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# A shortcode tag, `{{< ... >}}` or `{{% ... %}}`. The escaped form a page
|
|
58
|
+
# uses to show one literally (`{{</* ... */>}}`) is not a tag.
|
|
59
|
+
_SHORTCODE = r"\{\{[<%](?!/\*)\s*(?P<body>.*?)\s*[%>]\}\}"
|
|
60
|
+
SHORTCODE = re.compile(_SHORTCODE, re.DOTALL)
|
|
61
|
+
SHORTCODE_LINK = re.compile(r"\[(?P<text>[^\]]*)\]\(\s*" + _SHORTCODE + r"\s*\)")
|
|
62
|
+
_PARAM = re.compile(r"""(\w+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))""")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _params(body: str) -> dict[str, str]:
|
|
66
|
+
return {
|
|
67
|
+
m.group(1): next(g for g in m.group(2, 3, 4) if g is not None)
|
|
68
|
+
for m in _PARAM.finditer(body)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _paired(text: str) -> str:
|
|
73
|
+
"""Drop the tags of every paired shortcode, keeping what they enclose."""
|
|
74
|
+
names = {
|
|
75
|
+
m.group("body").split()[0].lstrip("/")
|
|
76
|
+
for m in SHORTCODE.finditer(text)
|
|
77
|
+
if m.group("body").startswith("/")
|
|
78
|
+
}
|
|
79
|
+
for name in names:
|
|
80
|
+
tag = r"\{\{[<%]\s*/?" + re.escape(name) + r"\b.*?[%>]\}\}"
|
|
81
|
+
text = re.sub(tag, "", text, flags=re.DOTALL)
|
|
82
|
+
return text
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def reader_markdown(text: str, alt: bool = True) -> tuple[str, list[dict]]:
|
|
86
|
+
"""Reader-facing Markdown from a unit's source, and its figures.
|
|
87
|
+
|
|
88
|
+
``alt`` prints each figure's alt text as a visible description under the
|
|
89
|
+
embed. Keep it where the reader gets no image, since it is then the only
|
|
90
|
+
account of the figure; drop it where the image is delivered, since there
|
|
91
|
+
it restates the picture and lets a reader rate one it never looked at.
|
|
92
|
+
"""
|
|
93
|
+
found = frontmatter.block(text)
|
|
94
|
+
if found:
|
|
95
|
+
text = text[found.end() :]
|
|
96
|
+
elif text.startswith(("---\n", "+++\n")):
|
|
97
|
+
raise RuntimeError("unterminated frontmatter")
|
|
98
|
+
text = HTML_COMMENT.sub("", text)
|
|
99
|
+
text = RAW_BLOCK.sub("", text)
|
|
100
|
+
figures = []
|
|
101
|
+
|
|
102
|
+
def figure(name: str, caption: str, description: str) -> str:
|
|
103
|
+
png = Path(name).with_suffix(".png").name
|
|
104
|
+
figures.append(
|
|
105
|
+
{
|
|
106
|
+
"source": name,
|
|
107
|
+
"file": f"figures/{png}",
|
|
108
|
+
"caption": caption,
|
|
109
|
+
"alt": description,
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
embed = f""
|
|
113
|
+
return f"{embed}\n\n*Figure description:* {description}" if alt else embed
|
|
114
|
+
|
|
115
|
+
def markdown_figure(match: re.Match) -> str:
|
|
116
|
+
caption = match.group("caption").strip()
|
|
117
|
+
name = match.group("name")
|
|
118
|
+
attrs = match.group("attrs")
|
|
119
|
+
if attrs is not None:
|
|
120
|
+
alt_match = re.search(r'fig-alt="([^"]+)"', attrs)
|
|
121
|
+
if not alt_match:
|
|
122
|
+
raise RuntimeError(f"figure has no fig-alt: {name}")
|
|
123
|
+
return figure(name, caption, alt_match.group(1))
|
|
124
|
+
if not caption:
|
|
125
|
+
raise RuntimeError(f"figure has no alt text: {name}")
|
|
126
|
+
return figure(name, caption, caption)
|
|
127
|
+
|
|
128
|
+
def shortcode_figure(match: re.Match) -> str:
|
|
129
|
+
body = match.group("body")
|
|
130
|
+
if not re.match(r"figure\b", body):
|
|
131
|
+
return match.group(0)
|
|
132
|
+
params = _params(body)
|
|
133
|
+
name = params.get("src", "")
|
|
134
|
+
if not re.search(r"\." + _IMAGE + r"\Z", name):
|
|
135
|
+
raise RuntimeError(f"figure shortcode has no image source: {body}")
|
|
136
|
+
caption = params.get("caption") or params.get("title") or ""
|
|
137
|
+
description = params.get("alt") or caption
|
|
138
|
+
if not description:
|
|
139
|
+
raise RuntimeError(f"figure has no alt text: {name}")
|
|
140
|
+
return figure(name, caption, description)
|
|
141
|
+
|
|
142
|
+
text = FIGURE.sub(markdown_figure, text)
|
|
143
|
+
text = SHORTCODE.sub(shortcode_figure, text)
|
|
144
|
+
text = SHORTCODE_LINK.sub(lambda m: m.group("text"), text)
|
|
145
|
+
text = _paired(text)
|
|
146
|
+
text = re.sub(r"(?m)^[ \t]*" + _SHORTCODE + r"[ \t]*$", "", text, flags=re.DOTALL)
|
|
147
|
+
text = SHORTCODE.sub("", text)
|
|
148
|
+
text = DIV_FENCE.sub("", text)
|
|
149
|
+
text = ATTR_BLOCK.sub("", text)
|
|
150
|
+
text = re.sub(r"\n{3,}", "\n\n", text).strip() + "\n"
|
|
151
|
+
leaks = []
|
|
152
|
+
if "<!--" in text:
|
|
153
|
+
leaks.append("HTML comment")
|
|
154
|
+
if "{#" in text:
|
|
155
|
+
leaks.append("anchor")
|
|
156
|
+
if re.search(r"!\[[^\]]*\]\([^)]*\.svg", text):
|
|
157
|
+
leaks.append("SVG source path")
|
|
158
|
+
if re.search(r"^```\{=", text, re.MULTILINE):
|
|
159
|
+
leaks.append("raw-format block")
|
|
160
|
+
if SHORTCODE.search(text):
|
|
161
|
+
leaks.append("shortcode")
|
|
162
|
+
if re.search(r"^[ \t]*:{3,}", text, re.MULTILINE):
|
|
163
|
+
leaks.append("div fence")
|
|
164
|
+
if leaks:
|
|
165
|
+
raise RuntimeError(f"clean Markdown still contains: {', '.join(leaks)}")
|
|
166
|
+
return text, figures
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The written work: its units, spec, marks, quotations and trail."""
|