quartobot 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.
- quartobot/__init__.py +6 -0
- quartobot/cli.py +215 -0
- quartobot/init_project.py +428 -0
- quartobot/resolve.py +310 -0
- quartobot/scan.py +299 -0
- quartobot/validate.py +264 -0
- quartobot-0.1.0.dist-info/METADATA +126 -0
- quartobot-0.1.0.dist-info/RECORD +11 -0
- quartobot-0.1.0.dist-info/WHEEL +4 -0
- quartobot-0.1.0.dist-info/entry_points.txt +2 -0
- quartobot-0.1.0.dist-info/licenses/LICENSE +21 -0
quartobot/__init__.py
ADDED
quartobot/cli.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Command-line interface for quartobot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from quartobot import __version__
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
13
|
+
@click.version_option(version=__version__, prog_name="quartobot")
|
|
14
|
+
def main() -> None:
|
|
15
|
+
"""quartobot: manuscript-as-software, on Quarto.
|
|
16
|
+
|
|
17
|
+
Pre-render and out-of-render tooling for Quarto projects that use the
|
|
18
|
+
quarto-manubot-cite extension.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@main.command()
|
|
23
|
+
@click.argument(
|
|
24
|
+
"path",
|
|
25
|
+
type=click.Path(exists=True, file_okay=True, dir_okay=True, path_type=Path),
|
|
26
|
+
default=".",
|
|
27
|
+
)
|
|
28
|
+
def scan(path: Path) -> None:
|
|
29
|
+
"""Scan a Quarto project for cite keys and group by prefix.
|
|
30
|
+
|
|
31
|
+
Walks .qmd and .md files under PATH, extracts cite keys (both
|
|
32
|
+
persistent-identifier ones like @doi: and @pmid:, and hand-curated
|
|
33
|
+
keys), and reports counts and duplicates. Pure read; no network.
|
|
34
|
+
|
|
35
|
+
Exits 1 if any duplicates are found (so it works as a pre-commit
|
|
36
|
+
hook), 0 otherwise.
|
|
37
|
+
"""
|
|
38
|
+
from quartobot.scan import format_scan_result, scan_path
|
|
39
|
+
|
|
40
|
+
result = scan_path(path)
|
|
41
|
+
relative_to = path if path.is_dir() else path.parent
|
|
42
|
+
click.echo(format_scan_result(result, relative_to=relative_to))
|
|
43
|
+
if result.duplicates:
|
|
44
|
+
raise SystemExit(1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@main.command()
|
|
48
|
+
@click.argument("keys", nargs=-1)
|
|
49
|
+
@click.option(
|
|
50
|
+
"--from-scan",
|
|
51
|
+
type=click.Path(exists=True, file_okay=True, dir_okay=True, path_type=Path),
|
|
52
|
+
default=None,
|
|
53
|
+
help="Resolve every persistent-identifier key found by scanning this path.",
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--output",
|
|
57
|
+
type=click.Path(file_okay=True, dir_okay=False, path_type=Path),
|
|
58
|
+
default="references.json",
|
|
59
|
+
show_default=True,
|
|
60
|
+
help="Path to write the resolved CSL JSON bibliography to.",
|
|
61
|
+
)
|
|
62
|
+
@click.option(
|
|
63
|
+
"--cache",
|
|
64
|
+
type=click.Path(file_okay=True, dir_okay=False, path_type=Path),
|
|
65
|
+
default=None,
|
|
66
|
+
help=(
|
|
67
|
+
"Optional path to read cached entries from. Cache hits skip the "
|
|
68
|
+
"network call. Defaults to the value of --output, so resolve is "
|
|
69
|
+
"idempotent against its own previous output."
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
@click.option(
|
|
73
|
+
"--dry-run",
|
|
74
|
+
is_flag=True,
|
|
75
|
+
help="Report what would be resolved without making network calls.",
|
|
76
|
+
)
|
|
77
|
+
@click.option(
|
|
78
|
+
"--id-mode",
|
|
79
|
+
type=click.Choice(["short-hash", "citation-key"]),
|
|
80
|
+
default="short-hash",
|
|
81
|
+
show_default=True,
|
|
82
|
+
help=(
|
|
83
|
+
"How to populate the CSL `id` field. `short-hash` (default) "
|
|
84
|
+
"keeps manubot's hash form, which the `pandoc-manubot-cite` "
|
|
85
|
+
"filter expects. `citation-key` writes the original "
|
|
86
|
+
"`prefix:identifier`, which lets pandoc-citeproc match prose "
|
|
87
|
+
"keys directly with no filter in the chain — the pre-render-"
|
|
88
|
+
"hook architecture."
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
def resolve(
|
|
92
|
+
keys: tuple[str, ...],
|
|
93
|
+
from_scan: Path | None,
|
|
94
|
+
output: Path,
|
|
95
|
+
cache: Path | None,
|
|
96
|
+
dry_run: bool,
|
|
97
|
+
id_mode: str,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Pre-fetch citations and write CSL JSON to disk.
|
|
100
|
+
|
|
101
|
+
Resolves persistent-identifier cite keys via manubot.cite and writes
|
|
102
|
+
the resulting CSL JSON to --output (default `references.json`). The
|
|
103
|
+
point isn't to replace what manubot does at render — it's to do the
|
|
104
|
+
network work on a developer's machine ahead of push, so CI never
|
|
105
|
+
sees a Crossref or PubMed hiccup.
|
|
106
|
+
|
|
107
|
+
Pass keys as arguments (`@doi:10.x/y pmid:12345`) or use --from-scan
|
|
108
|
+
to resolve every persistent-identifier cite found in a project.
|
|
109
|
+
Hand-curated keys (no recognized prefix) are skipped — those live
|
|
110
|
+
in references.bib and pandoc citeproc handles them.
|
|
111
|
+
|
|
112
|
+
Exits 1 if any keys fail to resolve, 0 otherwise.
|
|
113
|
+
"""
|
|
114
|
+
from quartobot.resolve import collect_resolvable_keys, format_outcome, resolve_keys
|
|
115
|
+
|
|
116
|
+
collected: list[str] = []
|
|
117
|
+
for k in keys:
|
|
118
|
+
# Allow either `@doi:10.x/y` or `doi:10.x/y` — strip leading @.
|
|
119
|
+
collected.append(k.lstrip("@"))
|
|
120
|
+
|
|
121
|
+
if from_scan is not None:
|
|
122
|
+
collected.extend(collect_resolvable_keys(from_scan))
|
|
123
|
+
|
|
124
|
+
# De-dup while preserving order.
|
|
125
|
+
seen: set[str] = set()
|
|
126
|
+
unique: list[str] = []
|
|
127
|
+
for k in collected:
|
|
128
|
+
if k not in seen:
|
|
129
|
+
seen.add(k)
|
|
130
|
+
unique.append(k)
|
|
131
|
+
|
|
132
|
+
if not unique:
|
|
133
|
+
click.echo("No persistent-identifier cite keys to resolve.")
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
cache_path = cache if cache is not None else output
|
|
137
|
+
outcome = resolve_keys(
|
|
138
|
+
unique,
|
|
139
|
+
cache_path=cache_path,
|
|
140
|
+
output_path=output,
|
|
141
|
+
dry_run=dry_run,
|
|
142
|
+
id_mode=id_mode,
|
|
143
|
+
)
|
|
144
|
+
click.echo(format_outcome(outcome))
|
|
145
|
+
if outcome.failures:
|
|
146
|
+
raise SystemExit(1)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@main.command()
|
|
150
|
+
@click.argument(
|
|
151
|
+
"project",
|
|
152
|
+
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
|
153
|
+
default=".",
|
|
154
|
+
)
|
|
155
|
+
def validate(project: Path) -> None:
|
|
156
|
+
"""Pre-flight check a Quarto project for the quartobot pattern.
|
|
157
|
+
|
|
158
|
+
Runs a battery of static config checks: the extension is installed,
|
|
159
|
+
`_quarto.yml` declares `bibliography:` and the manubot keys, the
|
|
160
|
+
output bibliography is also in the bibliography list, no duplicate
|
|
161
|
+
cite keys across files.
|
|
162
|
+
|
|
163
|
+
Citation-resolution checks (does Crossref actually return metadata
|
|
164
|
+
for this DOI?) are out of scope here — they need network. Run
|
|
165
|
+
`quartobot resolve --dry-run --from-scan .` if you want that.
|
|
166
|
+
|
|
167
|
+
Exits 1 if any check fails, 0 if all pass.
|
|
168
|
+
"""
|
|
169
|
+
from quartobot.validate import format_outcome, validate_project
|
|
170
|
+
|
|
171
|
+
outcome = validate_project(project)
|
|
172
|
+
click.echo(format_outcome(outcome))
|
|
173
|
+
if not outcome.passed:
|
|
174
|
+
raise SystemExit(1)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@main.command()
|
|
178
|
+
@click.argument(
|
|
179
|
+
"project",
|
|
180
|
+
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
|
181
|
+
default=".",
|
|
182
|
+
)
|
|
183
|
+
@click.option(
|
|
184
|
+
"--project-type",
|
|
185
|
+
type=click.Choice(["auto", "manuscript", "book"]),
|
|
186
|
+
default="auto",
|
|
187
|
+
show_default=True,
|
|
188
|
+
help=(
|
|
189
|
+
"Quarto project shape. `auto` detects from an existing _quarto.yml "
|
|
190
|
+
"and falls back to manuscript when there's nothing to detect from."
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
def init(project: Path, project_type: str) -> None:
|
|
194
|
+
"""Scaffold the quartobot pattern into an existing Quarto project.
|
|
195
|
+
|
|
196
|
+
Writes the files that make a vanilla Quarto project adopt the
|
|
197
|
+
quartobot pattern: `_quarto.yml` (when absent), `references.bib`,
|
|
198
|
+
the version banner template + dev placeholder, a ten-line GitHub
|
|
199
|
+
Actions workflow that calls the upstream reusable workflow, the
|
|
200
|
+
PR-cleanup workflow, and `.gitignore` augments.
|
|
201
|
+
|
|
202
|
+
Conservative — never overwrites existing files. If `_quarto.yml`
|
|
203
|
+
already exists, prints a YAML snippet to merge in manually.
|
|
204
|
+
|
|
205
|
+
Doesn't run `quarto add` or `pip install` for you; the printed
|
|
206
|
+
"next steps" walk through both.
|
|
207
|
+
"""
|
|
208
|
+
from quartobot.init_project import format_outcome, init_project
|
|
209
|
+
|
|
210
|
+
outcome = init_project(project, project_type=project_type)
|
|
211
|
+
click.echo(format_outcome(outcome, project=project))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
if __name__ == "__main__":
|
|
215
|
+
main()
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""Scaffold the quartobot pattern into an existing Quarto project.
|
|
2
|
+
|
|
3
|
+
`quartobot init` writes the files that make a vanilla Quarto project
|
|
4
|
+
adopt the quartobot pattern: a manubot-wired `_quarto.yml`, a seed
|
|
5
|
+
`references.bib`, the version-banner HTML template, a `.gitignore`
|
|
6
|
+
augment, and a ten-line GitHub Actions workflow that calls the upstream
|
|
7
|
+
reusable workflow.
|
|
8
|
+
|
|
9
|
+
Conservative by default:
|
|
10
|
+
|
|
11
|
+
- Files that already exist are NEVER overwritten. `init` skips them and
|
|
12
|
+
reports what it skipped.
|
|
13
|
+
- `_quarto.yml` is the one file where partial overlap is likely — for
|
|
14
|
+
that path, if the file exists, init prints a YAML snippet the user
|
|
15
|
+
should merge in manually.
|
|
16
|
+
- `.gitignore` gets new lines appended (idempotent).
|
|
17
|
+
- The Quarto extension itself is installed by `quarto add` (run
|
|
18
|
+
separately); init only scaffolds the surrounding files.
|
|
19
|
+
|
|
20
|
+
The flow is intentionally pre-`usethis`: no interactive prompts, no
|
|
21
|
+
auto-merge. Once the CLI matures we can add `--force` for overwrites
|
|
22
|
+
and a `quartobot use-<thing>` family for piecewise scaffolding.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Sequence
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Literal
|
|
31
|
+
|
|
32
|
+
import yaml
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------- templates
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_QUARTO_YML_MANUSCRIPT = """\
|
|
38
|
+
project:
|
|
39
|
+
type: default
|
|
40
|
+
|
|
41
|
+
filters:
|
|
42
|
+
- quarto-manubot-cite
|
|
43
|
+
|
|
44
|
+
# Manubot wiring. Defaults shipped by the quartobot extension; tune as needed.
|
|
45
|
+
manubot-output-bibliography: references.json
|
|
46
|
+
manubot-bibliography-cache: _freeze/manubot-cache.json
|
|
47
|
+
manubot-fail-on-errors: false
|
|
48
|
+
manubot-infer-citekey-prefixes: true
|
|
49
|
+
|
|
50
|
+
bibliography:
|
|
51
|
+
- references.bib
|
|
52
|
+
- references.json
|
|
53
|
+
|
|
54
|
+
format:
|
|
55
|
+
html:
|
|
56
|
+
toc: true
|
|
57
|
+
embed-resources: true
|
|
58
|
+
include-before-body:
|
|
59
|
+
- _version-banner.html
|
|
60
|
+
pdf:
|
|
61
|
+
documentclass: article
|
|
62
|
+
keep-tex: true
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
_QUARTO_YML_BOOK = """\
|
|
66
|
+
project:
|
|
67
|
+
type: book
|
|
68
|
+
|
|
69
|
+
book:
|
|
70
|
+
title: "My quartobot book"
|
|
71
|
+
author: "Your Name"
|
|
72
|
+
date: today
|
|
73
|
+
chapters:
|
|
74
|
+
- index.qmd
|
|
75
|
+
search: true
|
|
76
|
+
page-navigation: true
|
|
77
|
+
|
|
78
|
+
filters:
|
|
79
|
+
- quarto-manubot-cite
|
|
80
|
+
|
|
81
|
+
manubot-output-bibliography: references.json
|
|
82
|
+
manubot-bibliography-cache: _freeze/manubot-cache.json
|
|
83
|
+
manubot-fail-on-errors: false
|
|
84
|
+
manubot-infer-citekey-prefixes: true
|
|
85
|
+
|
|
86
|
+
bibliography:
|
|
87
|
+
- references.bib
|
|
88
|
+
- references.json
|
|
89
|
+
|
|
90
|
+
format:
|
|
91
|
+
html:
|
|
92
|
+
theme: cosmo
|
|
93
|
+
toc: true
|
|
94
|
+
include-before-body:
|
|
95
|
+
- _version-banner.html
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
_REFERENCES_BIB = """\
|
|
99
|
+
% Hand-curated entries live here. Auto-resolved entries written by
|
|
100
|
+
% pandoc-manubot-cite land in references.json (regenerated each
|
|
101
|
+
% render, ignored by git).
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
# Embedded HTML banners. Lines are long because CSS is inlined for
|
|
105
|
+
# users who want to drop the file into a fresh project without
|
|
106
|
+
# wrangling a separate stylesheet. Lint exceptions tagged per-line.
|
|
107
|
+
# fmt: off
|
|
108
|
+
_VERSION_BANNER_TEMPLATE = (
|
|
109
|
+
'<div class="version-banner" style="background:#fff7e0;border-bottom:2px solid #f0b400;padding:0.55rem 1rem;font-family:system-ui,-apple-system,\'Segoe UI\',sans-serif;font-size:0.92rem;text-align:center;color:#3a2c00;">\n' # noqa: E501
|
|
110
|
+
" <strong>This version:</strong>\n"
|
|
111
|
+
' <a href="__VERSION_URL__" style="color:#3a2c00;text-decoration:underline;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">__VERSION_SHA__</a>\n' # noqa: E501
|
|
112
|
+
" · \n"
|
|
113
|
+
' <a href="__VERSION_LATEST__" style="color:#3a2c00;">latest HTML</a>\n'
|
|
114
|
+
" · \n"
|
|
115
|
+
' <a href="__VERSION_GH__" style="color:#3a2c00;">GitHub</a>\n'
|
|
116
|
+
"</div>\n"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
_VERSION_BANNER_DEV = (
|
|
120
|
+
'<div class="version-banner" style="background:#eef2ff;border-bottom:2px solid #6366f1;padding:0.55rem 1rem;font-family:system-ui,-apple-system,\'Segoe UI\',sans-serif;font-size:0.92rem;text-align:center;color:#1e1b4b;">\n' # noqa: E501
|
|
121
|
+
" <strong>Development build</strong> · permalink set by CI on push to <code>main</code>\n" # noqa: E501
|
|
122
|
+
"</div>\n"
|
|
123
|
+
)
|
|
124
|
+
# fmt: on
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _render_workflow(project_type: str) -> str:
|
|
128
|
+
"""Return the ten-line workflow caller for the given project type."""
|
|
129
|
+
return f"""\
|
|
130
|
+
# Renders on every push and PR via the upstream reusable workflow.
|
|
131
|
+
# Override inputs in the `with:` block below; see
|
|
132
|
+
# https://github.com/seandavi/quartobot/blob/main/.github/workflows/render-reusable.yml
|
|
133
|
+
# for the full list.
|
|
134
|
+
|
|
135
|
+
name: Render
|
|
136
|
+
|
|
137
|
+
on:
|
|
138
|
+
push:
|
|
139
|
+
branches: [main]
|
|
140
|
+
pull_request:
|
|
141
|
+
branches: [main]
|
|
142
|
+
workflow_dispatch:
|
|
143
|
+
|
|
144
|
+
jobs:
|
|
145
|
+
render:
|
|
146
|
+
uses: seandavi/quartobot/.github/workflows/render-reusable.yml@main
|
|
147
|
+
permissions:
|
|
148
|
+
contents: write
|
|
149
|
+
pull-requests: write
|
|
150
|
+
with:
|
|
151
|
+
project-type: {project_type}
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
_PR_CLOSED_WORKFLOW = """\
|
|
156
|
+
name: Clean up PR preview
|
|
157
|
+
|
|
158
|
+
on:
|
|
159
|
+
pull_request:
|
|
160
|
+
types: [closed]
|
|
161
|
+
|
|
162
|
+
permissions:
|
|
163
|
+
contents: write
|
|
164
|
+
|
|
165
|
+
concurrency:
|
|
166
|
+
group: gh-pages-deploy
|
|
167
|
+
cancel-in-progress: false
|
|
168
|
+
|
|
169
|
+
jobs:
|
|
170
|
+
cleanup:
|
|
171
|
+
runs-on: ubuntu-latest
|
|
172
|
+
if: github.event.pull_request.head.repo.full_name == github.repository
|
|
173
|
+
steps:
|
|
174
|
+
- name: Check whether gh-pages branch exists
|
|
175
|
+
id: branch
|
|
176
|
+
run: |
|
|
177
|
+
if git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1; then
|
|
178
|
+
echo "exists=true" >> "$GITHUB_OUTPUT"
|
|
179
|
+
else
|
|
180
|
+
echo "exists=false" >> "$GITHUB_OUTPUT"
|
|
181
|
+
fi
|
|
182
|
+
|
|
183
|
+
- name: Check out gh-pages
|
|
184
|
+
if: steps.branch.outputs.exists == 'true'
|
|
185
|
+
uses: actions/checkout@v4
|
|
186
|
+
with:
|
|
187
|
+
ref: gh-pages
|
|
188
|
+
fetch-depth: 1
|
|
189
|
+
|
|
190
|
+
- name: Remove PR preview directory
|
|
191
|
+
if: steps.branch.outputs.exists == 'true'
|
|
192
|
+
run: |
|
|
193
|
+
pr="${{ github.event.pull_request.number }}"
|
|
194
|
+
[ -d "pr/${pr}" ] && rm -rf "pr/${pr}" || exit 0
|
|
195
|
+
|
|
196
|
+
- name: Commit and push
|
|
197
|
+
if: steps.branch.outputs.exists == 'true'
|
|
198
|
+
run: |
|
|
199
|
+
git config user.name "github-actions[bot]"
|
|
200
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
201
|
+
git add -A
|
|
202
|
+
if git diff --cached --quiet; then exit 0; fi
|
|
203
|
+
git commit -m "Clean up preview for PR #${{ github.event.pull_request.number }}"
|
|
204
|
+
git push
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
_GITIGNORE_LINES = [
|
|
209
|
+
"# quartobot",
|
|
210
|
+
"_book/",
|
|
211
|
+
"_freeze/",
|
|
212
|
+
".quarto/",
|
|
213
|
+
"_extensions/",
|
|
214
|
+
"references.json",
|
|
215
|
+
"*_files/",
|
|
216
|
+
"**/*.quarto_ipynb",
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ---------------------------------------------------------------- outcome types
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
ActionStatus = Literal["written", "skipped-exists", "appended", "manual-merge"]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass(frozen=True)
|
|
227
|
+
class Action:
|
|
228
|
+
"""One file write (or non-write) the init flow attempted."""
|
|
229
|
+
|
|
230
|
+
path: Path
|
|
231
|
+
status: ActionStatus
|
|
232
|
+
detail: str | None = None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass
|
|
236
|
+
class InitOutcome:
|
|
237
|
+
"""The aggregate result of an init run."""
|
|
238
|
+
|
|
239
|
+
actions: list[Action] = field(default_factory=list)
|
|
240
|
+
project_type: str = "manuscript"
|
|
241
|
+
manual_merge_snippet: str | None = None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------- helpers
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def detect_project_type(project: Path) -> str:
|
|
248
|
+
"""Read `_quarto.yml`; return `"manuscript"`, `"book"`, or `"unknown"`.
|
|
249
|
+
|
|
250
|
+
A missing or unparsable `_quarto.yml` returns `"unknown"` — init
|
|
251
|
+
treats that as the manuscript default.
|
|
252
|
+
"""
|
|
253
|
+
yml = project / "_quarto.yml"
|
|
254
|
+
if not yml.exists():
|
|
255
|
+
return "unknown"
|
|
256
|
+
try:
|
|
257
|
+
loaded = yaml.safe_load(yml.read_text(encoding="utf-8"))
|
|
258
|
+
except yaml.YAMLError:
|
|
259
|
+
return "unknown"
|
|
260
|
+
if not isinstance(loaded, dict):
|
|
261
|
+
return "unknown"
|
|
262
|
+
proj = loaded.get("project")
|
|
263
|
+
if isinstance(proj, dict) and proj.get("type") == "book":
|
|
264
|
+
return "book"
|
|
265
|
+
return "manuscript"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _write_if_missing(path: Path, content: str) -> Action:
|
|
269
|
+
if path.exists():
|
|
270
|
+
return Action(path=path, status="skipped-exists")
|
|
271
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
path.write_text(content, encoding="utf-8")
|
|
273
|
+
return Action(path=path, status="written")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _ensure_gitignore(project: Path) -> Action:
|
|
277
|
+
"""Append missing lines to `.gitignore`. Idempotent."""
|
|
278
|
+
gi = project / ".gitignore"
|
|
279
|
+
existing = gi.read_text(encoding="utf-8").splitlines() if gi.exists() else []
|
|
280
|
+
existing_set = set(existing)
|
|
281
|
+
to_add = [line for line in _GITIGNORE_LINES if line not in existing_set]
|
|
282
|
+
if not to_add:
|
|
283
|
+
return Action(path=gi, status="skipped-exists", detail="all lines present")
|
|
284
|
+
if existing and existing[-1] != "":
|
|
285
|
+
existing.append("") # leading blank separator
|
|
286
|
+
new_lines = existing + to_add + [""]
|
|
287
|
+
gi.write_text("\n".join(new_lines), encoding="utf-8")
|
|
288
|
+
return Action(
|
|
289
|
+
path=gi,
|
|
290
|
+
status="appended",
|
|
291
|
+
detail=f"added {len(to_add)} line(s)",
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ---------------------------------------------------------------- top-level
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def init_project(
|
|
299
|
+
project: Path,
|
|
300
|
+
*,
|
|
301
|
+
project_type: str = "auto",
|
|
302
|
+
) -> InitOutcome:
|
|
303
|
+
"""Scaffold quartobot files into `project`.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
project: Path to an existing Quarto project root (or empty dir).
|
|
307
|
+
project_type: `"auto"` (detect from existing `_quarto.yml`),
|
|
308
|
+
`"manuscript"`, or `"book"`. `"auto"` defaults to
|
|
309
|
+
`"manuscript"` when there's nothing to detect from.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
`InitOutcome` describing each file action.
|
|
313
|
+
"""
|
|
314
|
+
if project_type == "auto":
|
|
315
|
+
detected = detect_project_type(project)
|
|
316
|
+
ptype = detected if detected != "unknown" else "manuscript"
|
|
317
|
+
else:
|
|
318
|
+
ptype = project_type
|
|
319
|
+
|
|
320
|
+
outcome = InitOutcome(project_type=ptype)
|
|
321
|
+
|
|
322
|
+
# _quarto.yml — never overwrite. If absent, write the appropriate
|
|
323
|
+
# default. If present, print a snippet for manual merge.
|
|
324
|
+
yml_path = project / "_quarto.yml"
|
|
325
|
+
if yml_path.exists():
|
|
326
|
+
outcome.actions.append(
|
|
327
|
+
Action(
|
|
328
|
+
path=yml_path,
|
|
329
|
+
status="manual-merge",
|
|
330
|
+
detail="merge the manubot block manually",
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
outcome.manual_merge_snippet = _quarto_yml_snippet_for_manual_merge()
|
|
334
|
+
else:
|
|
335
|
+
content = _QUARTO_YML_BOOK if ptype == "book" else _QUARTO_YML_MANUSCRIPT
|
|
336
|
+
outcome.actions.append(_write_if_missing(yml_path, content))
|
|
337
|
+
|
|
338
|
+
# Other files — write only if missing.
|
|
339
|
+
outcome.actions.append(_write_if_missing(project / "references.bib", _REFERENCES_BIB))
|
|
340
|
+
outcome.actions.append(
|
|
341
|
+
_write_if_missing(
|
|
342
|
+
project / "_version-banner.html.template",
|
|
343
|
+
_VERSION_BANNER_TEMPLATE,
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
outcome.actions.append(_write_if_missing(project / "_version-banner.html", _VERSION_BANNER_DEV))
|
|
347
|
+
outcome.actions.append(
|
|
348
|
+
_write_if_missing(
|
|
349
|
+
project / ".github" / "workflows" / "render.yml",
|
|
350
|
+
_render_workflow(ptype),
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
outcome.actions.append(
|
|
354
|
+
_write_if_missing(
|
|
355
|
+
project / ".github" / "workflows" / "pr-closed.yml",
|
|
356
|
+
_PR_CLOSED_WORKFLOW,
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# .gitignore is the only file where we modify-in-place.
|
|
361
|
+
outcome.actions.append(_ensure_gitignore(project))
|
|
362
|
+
|
|
363
|
+
return outcome
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _quarto_yml_snippet_for_manual_merge() -> str:
|
|
367
|
+
"""Return the YAML lines to add to an existing `_quarto.yml`."""
|
|
368
|
+
return """\
|
|
369
|
+
# Add to your existing _quarto.yml:
|
|
370
|
+
|
|
371
|
+
filters:
|
|
372
|
+
- quarto-manubot-cite
|
|
373
|
+
|
|
374
|
+
manubot-output-bibliography: references.json
|
|
375
|
+
manubot-bibliography-cache: _freeze/manubot-cache.json
|
|
376
|
+
manubot-fail-on-errors: false
|
|
377
|
+
manubot-infer-citekey-prefixes: true
|
|
378
|
+
|
|
379
|
+
bibliography:
|
|
380
|
+
- references.bib
|
|
381
|
+
- references.json
|
|
382
|
+
|
|
383
|
+
format:
|
|
384
|
+
html:
|
|
385
|
+
include-before-body:
|
|
386
|
+
- _version-banner.html
|
|
387
|
+
"""
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def format_outcome(outcome: InitOutcome, *, project: Path) -> str:
|
|
391
|
+
"""Pretty-print an InitOutcome."""
|
|
392
|
+
glyphs = {
|
|
393
|
+
"written": "+",
|
|
394
|
+
"appended": "~",
|
|
395
|
+
"skipped-exists": "·",
|
|
396
|
+
"manual-merge": "!",
|
|
397
|
+
}
|
|
398
|
+
lines: list[str] = []
|
|
399
|
+
lines.append(f"Project type: {outcome.project_type}")
|
|
400
|
+
lines.append("")
|
|
401
|
+
for action in outcome.actions:
|
|
402
|
+
try:
|
|
403
|
+
relative = action.path.relative_to(project)
|
|
404
|
+
except ValueError:
|
|
405
|
+
relative = action.path
|
|
406
|
+
glyph = glyphs.get(action.status, "?")
|
|
407
|
+
line = f" {glyph} {relative} [{action.status}]"
|
|
408
|
+
if action.detail:
|
|
409
|
+
line += f" — {action.detail}"
|
|
410
|
+
lines.append(line)
|
|
411
|
+
lines.append("")
|
|
412
|
+
if outcome.manual_merge_snippet:
|
|
413
|
+
lines.append(outcome.manual_merge_snippet)
|
|
414
|
+
lines.append("Next steps:")
|
|
415
|
+
lines.append(" 1. quarto add seandavi/quartobot --no-prompt")
|
|
416
|
+
lines.append(" 2. pip install 'manubot>=0.6,<0.7'")
|
|
417
|
+
lines.append(" 3. Add citations to your prose: @doi:..., @pmid:..., etc.")
|
|
418
|
+
lines.append(" 4. quarto render")
|
|
419
|
+
return "\n".join(lines)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
__all__: Sequence[str] = (
|
|
423
|
+
"Action",
|
|
424
|
+
"InitOutcome",
|
|
425
|
+
"detect_project_type",
|
|
426
|
+
"format_outcome",
|
|
427
|
+
"init_project",
|
|
428
|
+
)
|