imprenta 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. imprenta-0.1.0/.github/scripts/roll_changelog.py +99 -0
  2. imprenta-0.1.0/.github/workflows/ci.yml +65 -0
  3. imprenta-0.1.0/.github/workflows/publish.yml +80 -0
  4. imprenta-0.1.0/.github/workflows/release.yml +256 -0
  5. imprenta-0.1.0/.gitignore +10 -0
  6. imprenta-0.1.0/CHANGELOG.md +44 -0
  7. imprenta-0.1.0/CITATION.cff +12 -0
  8. imprenta-0.1.0/CLAUDE.md +62 -0
  9. imprenta-0.1.0/LICENSE +21 -0
  10. imprenta-0.1.0/Makefile +43 -0
  11. imprenta-0.1.0/PKG-INFO +195 -0
  12. imprenta-0.1.0/README.md +158 -0
  13. imprenta-0.1.0/pyproject.toml +76 -0
  14. imprenta-0.1.0/src/imprenta/__init__.py +30 -0
  15. imprenta-0.1.0/src/imprenta/__main__.py +3 -0
  16. imprenta-0.1.0/src/imprenta/build.py +161 -0
  17. imprenta-0.1.0/src/imprenta/catalog.py +245 -0
  18. imprenta-0.1.0/src/imprenta/check.py +362 -0
  19. imprenta-0.1.0/src/imprenta/cli.py +153 -0
  20. imprenta-0.1.0/src/imprenta/content.py +74 -0
  21. imprenta-0.1.0/src/imprenta/data/toc.lua +94 -0
  22. imprenta-0.1.0/src/imprenta/edit.py +150 -0
  23. imprenta-0.1.0/src/imprenta/parts.py +196 -0
  24. imprenta-0.1.0/src/imprenta/project.py +154 -0
  25. imprenta-0.1.0/src/imprenta/render.py +177 -0
  26. imprenta-0.1.0/src/imprenta/targets.py +190 -0
  27. imprenta-0.1.0/src/imprenta/tui.py +567 -0
  28. imprenta-0.1.0/tests/conftest.py +141 -0
  29. imprenta-0.1.0/tests/test_catalog.py +91 -0
  30. imprenta-0.1.0/tests/test_check.py +176 -0
  31. imprenta-0.1.0/tests/test_edit.py +88 -0
  32. imprenta-0.1.0/tests/test_parts.py +96 -0
  33. imprenta-0.1.0/tests/test_targets.py +110 -0
  34. imprenta-0.1.0/tests/test_tui.py +44 -0
  35. imprenta-0.1.0/uv.lock +699 -0
@@ -0,0 +1,99 @@
1
+ """Move CHANGELOG.md's [Unreleased] entries under a new version heading.
2
+
3
+ Usage: roll_changelog.py CHANGELOG TAG PREVIOUS_TAG DATE NOTES_OUT [TAG_ALREADY_AT_HEAD]
4
+ roll_changelog.py --notes CHANGELOG TAG NOTES_OUT
5
+
6
+ Called by release.yml. Writes the release notes to NOTES_OUT and edits the
7
+ changelog in place; touches neither when there is nothing to release:
8
+
9
+ - [Unreleased] has entries: they become ``## [X.Y.Z] - DATE`` and an empty
10
+ [Unreleased] is left above them.
11
+ - [Unreleased] is empty but the commit already shipped as another tag (a manual
12
+ minor/major bump after the automatic patch): a short ``## [X.Y.Z]`` entry
13
+ points at that release, whose entries become the notes.
14
+
15
+ Keep a Changelog link references at the bottom are updated when present.
16
+
17
+ With --notes, CHANGELOG has already been rolled for TAG (a re-run resuming a
18
+ release whose changelog commit reached main): only the notes are written, the
19
+ same ones the roll produced. Exits 1 if CHANGELOG has no section for TAG.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ HEADING = re.compile(r"^## \[(?P<name>[^\]]+)\]", re.MULTILINE)
29
+ LINKS = re.compile(r"^\[[^\]]+\]: ", re.MULTILINE)
30
+
31
+
32
+ def section(text: str, name: str) -> tuple[int, int, int] | None:
33
+ """(heading start, body start, body end) of the ``## [name]`` section."""
34
+ for m in HEADING.finditer(text):
35
+ if m.group("name") == name:
36
+ body = text.index("\n", m.start()) + 1
37
+ nxt = HEADING.search(text, body)
38
+ links = LINKS.search(text, body)
39
+ ends = [x.start() for x in (nxt, links) if x] + [len(text)]
40
+ return m.start(), body, min(ends)
41
+ return None
42
+
43
+
44
+ def update_links(text: str, version: str, base: str) -> str:
45
+ """Point [Unreleased] at the new tag and add a link for the new version."""
46
+ m = re.search(r"^\[Unreleased\]: (?P<repo>\S+)/compare/\S+\.\.\.HEAD$", text, re.M)
47
+ if not m:
48
+ return text
49
+ repo, tag = m.group("repo"), f"v{version}"
50
+ link = f"{repo}/compare/{base}...{tag}" if base else f"{repo}/releases/tag/{tag}"
51
+ new = f"[Unreleased]: {repo}/compare/{tag}...HEAD\n[{version}]: {link}"
52
+ return text[: m.start()] + new + text[m.end() :]
53
+
54
+
55
+ SAME_CODE = re.compile(r"^Same code as (?P<tag>v\S+), released as \S+\.")
56
+
57
+
58
+ def rolled_notes(path: str, tag: str, notes_out: str) -> None:
59
+ text = Path(path).read_text()
60
+ rolled = section(text, tag.removeprefix("v"))
61
+ if rolled is None:
62
+ sys.exit(f"{path} has no section for {tag}")
63
+ notes = text[rolled[1] : rolled[2]].strip()
64
+ if (m := SAME_CODE.match(notes)) and (shipped := section(text, m["tag"].removeprefix("v"))):
65
+ notes = f"{notes}\n\n{text[shipped[1] : shipped[2]].strip()}"
66
+ Path(notes_out).write_text(notes + "\n")
67
+
68
+
69
+ def main(path: str, tag: str, previous: str, date: str, notes_out: str, at_head: str = "") -> None:
70
+ file = Path(path)
71
+ text = file.read_text()
72
+ version = tag.removeprefix("v")
73
+ unreleased = section(text, "Unreleased")
74
+ if unreleased is None:
75
+ return
76
+ _, body_start, body_end = unreleased
77
+ body = text[body_start:body_end].strip()
78
+ base = previous
79
+ if body:
80
+ notes = body
81
+ rolled = f"\n## [{version}] - {date}\n\n{body}\n\n"
82
+ elif at_head and (shipped := section(text, at_head.removeprefix("v"))):
83
+ shipped_body = text[shipped[1] : shipped[2]].strip()
84
+ base = at_head
85
+ notes = f"Same code as {at_head}, released as {tag}.\n\n{shipped_body}".strip()
86
+ rolled = f"\n## [{version}] - {date}\n\nSame code as {at_head}, released as {tag}.\n\n"
87
+ else:
88
+ return
89
+
90
+ text = text[:body_start] + rolled + text[body_end:].lstrip("\n")
91
+ file.write_text(update_links(text, version, base))
92
+ Path(notes_out).write_text(notes + "\n")
93
+
94
+
95
+ if __name__ == "__main__":
96
+ if sys.argv[1:2] == ["--notes"]:
97
+ rolled_notes(*sys.argv[2:])
98
+ else:
99
+ main(*sys.argv[1:])
@@ -0,0 +1,65 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ workflow_dispatch:
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ concurrency:
14
+ # PR runs supersede each other. Every other run gets its own group, so each
15
+ # push to main finishes CI and can be released.
16
+ group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
17
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
18
+
19
+ jobs:
20
+ lint:
21
+ name: lint
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+
26
+ - name: Set up uv
27
+ uses: astral-sh/setup-uv@v5
28
+
29
+ # Ruff only, no project install, at the exact version in uv.lock so CI
30
+ # and local `uv sync` lint with the same release.
31
+ - name: Resolve locked ruff version
32
+ run: |
33
+ version=$(awk '/^name = "ruff"$/{f=1} f && /^version = /{gsub(/"/,"",$3); print $3; exit}' uv.lock)
34
+ test -n "$version" || { echo "ruff not found in uv.lock" >&2; exit 1; }
35
+ echo "RUFF_VERSION=$version" >> "$GITHUB_ENV"
36
+
37
+ - name: Lint
38
+ run: uvx "ruff@$RUFF_VERSION" check src tests
39
+
40
+ - name: Format check
41
+ run: uvx "ruff@$RUFF_VERSION" format --check src tests
42
+
43
+ test:
44
+ name: test (py${{ matrix.python-version }})
45
+ runs-on: ubuntu-latest
46
+ strategy:
47
+ fail-fast: false
48
+ matrix:
49
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
50
+ steps:
51
+ - uses: actions/checkout@v4
52
+ with:
53
+ fetch-depth: 0 # full history + tags for hatch-vcs versioning
54
+
55
+ - name: Set up uv
56
+ uses: astral-sh/setup-uv@v5
57
+ with:
58
+ python-version: ${{ matrix.python-version }}
59
+ enable-cache: true
60
+
61
+ - name: Install
62
+ run: uv sync --extra dev
63
+
64
+ - name: Test
65
+ run: uv run pytest
@@ -0,0 +1,80 @@
1
+ name: Publish
2
+
3
+ # Publishes to PyPI when a GitHub Release is published, or when release.yml
4
+ # starts it on a new tag. It only runs on a vX.Y.Z tag.
5
+ #
6
+ # Uses PyPI Trusted Publishing (OIDC) — no API token/secret required. Before the
7
+ # first release, configure a Trusted Publisher on PyPI for this project:
8
+ # PyPI → project "imprenta" → Settings → Publishing → add a GitHub publisher with
9
+ # owner=jmcmeen, repo=imprenta, workflow=publish.yml, environment=pypi.
10
+ # Also create a GitHub environment named "pypi" (Settings → Environments).
11
+
12
+ on:
13
+ release:
14
+ types: [published]
15
+ workflow_dispatch:
16
+
17
+ permissions:
18
+ contents: read
19
+
20
+ jobs:
21
+ build:
22
+ name: Build distributions
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ with:
27
+ fetch-depth: 0 # full history + tags for hatch-vcs versioning
28
+
29
+ - name: Set up uv
30
+ uses: astral-sh/setup-uv@v5
31
+ with:
32
+ python-version: "3.12"
33
+
34
+ - name: Require a release tag on main
35
+ run: |
36
+ # Run by hand on a branch, this would build and upload a dev version;
37
+ # on a tag off main, code that never went through main and its CI.
38
+ if [ "$GITHUB_REF_TYPE" != tag ] || ! [[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
39
+ echo "::error::Publish only runs on a vX.Y.Z tag, not $GITHUB_REF_TYPE '$GITHUB_REF_NAME'."
40
+ exit 1
41
+ fi
42
+ git fetch -q origin +refs/heads/main:refs/remotes/origin/main
43
+ if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
44
+ echo "::error::$GITHUB_REF_NAME isn't on main; only release tags from main are published."
45
+ exit 1
46
+ fi
47
+
48
+ - name: Build sdist + wheel
49
+ run: |
50
+ # Version from the tag this run is on, not `git describe`: when two
51
+ # tags share a commit, describe can pick the older one.
52
+ export SETUPTOOLS_SCM_PRETEND_VERSION="${GITHUB_REF_NAME#v}"
53
+ uv build
54
+
55
+ - name: Upload artifacts
56
+ uses: actions/upload-artifact@v4
57
+ with:
58
+ name: dist
59
+ path: dist/
60
+
61
+ publish:
62
+ name: Publish to PyPI
63
+ needs: build
64
+ runs-on: ubuntu-latest
65
+ environment:
66
+ name: pypi
67
+ url: https://pypi.org/p/imprenta
68
+ permissions:
69
+ id-token: write # required for trusted publishing
70
+ steps:
71
+ - name: Download built distributions
72
+ uses: actions/download-artifact@v4
73
+ with:
74
+ name: dist
75
+ path: dist/
76
+
77
+ - name: Publish
78
+ uses: pypa/gh-action-pypi-publish@release/v1
79
+ with:
80
+ skip-existing: true # a retried release re-dispatches this
@@ -0,0 +1,256 @@
1
+ name: Release
2
+
3
+ # Cuts a release when a merge to main passes CI: the latest vX.Y.Z tag gets its
4
+ # patch number bumped (v0.1.0 if there are no tags yet). Merges that land while
5
+ # a release is running can share the next release (see below), so a release is
6
+ # per merge in the usual case, not guaranteed for each one.
7
+ # Python packages take their version from that tag via hatch-vcs; a repo whose
8
+ # version lives in files (e.g. Cargo.toml) provides .github/scripts/
9
+ # set_version.sh, which is run with the new version (X.Y.Z). CHANGELOG.md's
10
+ # [Unreleased] entries move under the new version in a github-actions[bot]
11
+ # commit on main, along with any set_version.sh edits; that commit is what gets
12
+ # tagged, and the entries become the release notes. If main has moved on by
13
+ # then, nothing is pushed and this merge isn't released on its own: the release
14
+ # for the newer merge includes it (a manual minor/major run says to run again).
15
+ #
16
+ # For a minor or major release, run this workflow by hand on main ("Run
17
+ # workflow", or `gh workflow run release.yml -f bump=minor`). It releases the
18
+ # head of main with that part bumped; the next merge continues from it. If the
19
+ # merge was already released as a patch, the commit carries both tags. It
20
+ # refuses to run until CI has passed on that commit. If a release is already
21
+ # running and a merge's release queues behind your run, GitHub cancels your
22
+ # pending run in its favor -- Actions shows it as cancelled; run it again.
23
+ #
24
+ # Releasing runs are serialized, and GitHub keeps only the newest pending one,
25
+ # so merges that land close together can share a release. The lock is on the
26
+ # job, so skipped runs (failed CI, dispatch off main) never take that slot.
27
+ # A commit that is already inside the latest release is skipped, so an old run
28
+ # finishing late never gets a newer version than the code after it. Re-running
29
+ # a run whose release exists only re-dispatches publish.yml (which skips files
30
+ # already on PyPI).
31
+
32
+ on:
33
+ workflow_run:
34
+ workflows: [CI]
35
+ types: [completed]
36
+ branches: [main]
37
+ workflow_dispatch:
38
+ inputs:
39
+ bump:
40
+ description: Version part to bump
41
+ type: choice
42
+ options: [minor, major, patch]
43
+ default: minor
44
+
45
+ permissions:
46
+ contents: write # changelog commit, tag + release
47
+ actions: write # dispatch publish.yml
48
+
49
+ jobs:
50
+ release:
51
+ name: Tag and release
52
+ if: >-
53
+ (github.event_name == 'workflow_run'
54
+ && github.event.workflow_run.conclusion == 'success'
55
+ && github.event.workflow_run.event == 'push')
56
+ || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')
57
+ runs-on: ubuntu-latest
58
+ concurrency:
59
+ group: release
60
+ cancel-in-progress: false
61
+ env:
62
+ SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
63
+ BUMP: ${{ inputs.bump || 'patch' }}
64
+ GH_TOKEN: ${{ github.token }}
65
+ BOT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
66
+ steps:
67
+ - uses: actions/checkout@v4
68
+ with:
69
+ ref: ${{ github.event.workflow_run.head_sha || github.sha }}
70
+ fetch-depth: 0 # all tags
71
+
72
+ - name: Require passing CI
73
+ if: github.event_name == 'workflow_dispatch'
74
+ run: |
75
+ # A release commit never runs CI (bot pushes don't trigger it), so
76
+ # check the commit it was made on.
77
+ tested=$SHA
78
+ if git rev-parse -q --verify HEAD^ > /dev/null &&
79
+ [ "$(git log -1 --format=%ae HEAD)" = "$BOT_EMAIL" ] &&
80
+ git log -1 --format=%s HEAD | grep -q '^Update changelog for '; then
81
+ tested=$(git rev-parse HEAD^)
82
+ fi
83
+ passed=$(gh run list --workflow ci.yml --commit "$tested" --event push \
84
+ --status success --json databaseId --jq length)
85
+ if [ "$passed" -eq 0 ]; then
86
+ echo "::error::CI hasn't passed on $tested yet; wait for it, then run this again."
87
+ exit 1
88
+ fi
89
+
90
+ - name: Compute version
91
+ id: version
92
+ run: |
93
+ semver='^v[0-9]+\.[0-9]+\.[0-9]+$'
94
+ bump() {
95
+ IFS=. read -r major minor patch <<< "${1#v}"
96
+ case "$BUMP" in
97
+ major) echo "v$((major + 1)).0.0" ;;
98
+ minor) echo "v$major.$((minor + 1)).0" ;;
99
+ *) echo "v$major.$minor.$((patch + 1))" ;;
100
+ esac
101
+ }
102
+ first() { if [ "$BUMP" = major ]; then echo v1.0.0; else echo v0.1.0; fi; }
103
+ # This commit's releases, oldest first: tags on it, or on a release
104
+ # commit the bot made on top of it.
105
+ ours=$(git tag --points-at HEAD | grep -E "$semver" || true)
106
+ for t in $(git tag --contains HEAD | grep -E "$semver" || true); do
107
+ if [ "$(git rev-parse "$t^{commit}^")" = "$(git rev-parse HEAD)" ] &&
108
+ [ "$(git log -1 --format=%ae "$t")" = "$BOT_EMAIL" ] &&
109
+ git log -1 --format=%s "$t" | grep -q '^Update changelog for '; then
110
+ ours="$ours $t"
111
+ fi
112
+ done
113
+ ours=$(printf '%s\n' $ours | grep . | sort -uV || true)
114
+ newest=$(printf '%s\n' $ours | tail -1)
115
+ if [ "$BUMP" = patch ]; then
116
+ # A patch run on a released commit retries its newest release.
117
+ retry=$newest
118
+ else
119
+ # A minor or major run retries only its own bump: a tag of this
120
+ # commit that is exactly that bump of the release before it, or the
121
+ # first release when there is none before it. (Asking for the same
122
+ # bump again on the same commit counts as a retry: releasing
123
+ # identical code under two new minor versions is never wanted.)
124
+ retry=
125
+ merged=$(git tag --merged HEAD | grep -E "$semver" || true)
126
+ for t in $ours; do
127
+ base=$(printf '%s\n' $merged "$t" | sort -uV | grep -B1 -xF "$t" | grep -vxF "$t" || true)
128
+ if { [ -n "$base" ] && [ "$(bump "$base")" = "$t" ]; } ||
129
+ { [ -z "$base" ] && [ "$t" = "$(first)" ]; }; then
130
+ retry=$t
131
+ fi
132
+ done
133
+ fi
134
+ if [ -n "$retry" ]; then
135
+ echo "$SHA is already $retry; retrying its release and publish."
136
+ echo "tag=$retry" >> "$GITHUB_OUTPUT"
137
+ exit 0
138
+ fi
139
+ latest=$(git tag --list 'v*' | grep -E "$semver" | sort -V | tail -1 || true)
140
+ if [ -z "$latest" ]; then
141
+ next=$(first)
142
+ elif git merge-base --is-ancestor HEAD "$latest" &&
143
+ ! printf '%s\n' $ours | grep -qxF "$latest"; then
144
+ echo "$SHA is already included in $latest; nothing to do."
145
+ exit 0
146
+ else
147
+ next=$(bump "$latest")
148
+ fi
149
+ echo "Releasing $next ($BUMP bump; previous: ${latest:-none})"
150
+ {
151
+ echo "tag=$next"
152
+ echo "new=true"
153
+ echo "previous=$latest"
154
+ echo "at_head=$newest"
155
+ } >> "$GITHUB_OUTPUT"
156
+
157
+ - name: Roll changelog and set version
158
+ id: changelog
159
+ if: steps.version.outputs.new == 'true'
160
+ env:
161
+ TAG: ${{ steps.version.outputs.tag }}
162
+ PREVIOUS: ${{ steps.version.outputs.previous }}
163
+ AT_HEAD: ${{ steps.version.outputs.at_head }}
164
+ run: |
165
+ notes="$RUNNER_TEMP/notes.md"
166
+ # True if a newer commit on main includes $SHA. Fails the step if main
167
+ # no longer contains $SHA at all (rewritten): releasing it would
168
+ # publish code that isn't on main, and skipping would hide that.
169
+ main_moved_past_sha() {
170
+ # Called inside `if`, where set -e is off: check the fetch by hand
171
+ # so a stale origin/main never decides a release.
172
+ if ! git fetch -q origin +refs/heads/main:refs/remotes/origin/main; then
173
+ echo "::error::Fetching main failed; can't tell whether it moved past $SHA."
174
+ exit 1
175
+ fi
176
+ [ "$(git rev-parse origin/main)" != "$SHA" ] || return 1
177
+ if ! git merge-base --is-ancestor "$SHA" origin/main; then
178
+ echo "::error::main no longer contains $SHA; not releasing $TAG."
179
+ exit 1
180
+ fi
181
+ }
182
+ # Releasing $SHA as is would leave its entries under [Unreleased]
183
+ # (they'd appear in the next release too) and miss the version files.
184
+ skip_release() {
185
+ echo "::notice::main moved past $SHA; not releasing $TAG here. The release for the newer merge includes this one (for a manual minor/major run, run it again)."
186
+ echo "skip=true" >> "$GITHUB_OUTPUT"
187
+ }
188
+ # A re-run after this changelog commit reached main but the release
189
+ # wasn't created: release that commit, with the notes it rolled,
190
+ # rather than rolling $SHA again (that push would be rejected).
191
+ git fetch -q origin +refs/heads/main:refs/remotes/origin/main
192
+ rolled=$(git log --format='%H%x09%P%x09%ae%x09%s' "$SHA..origin/main" |
193
+ awk -F '\t' -v sha="$SHA" -v bot="$BOT_EMAIL" -v subject="Update changelog for $TAG" \
194
+ '$2 == sha && $3 == bot && $4 == subject { print $1 }')
195
+ if [ -n "$rolled" ]; then
196
+ echo "Release commit for $TAG is already on main ($rolled); releasing that."
197
+ echo "sha=$rolled" >> "$GITHUB_OUTPUT"
198
+ if git show "$rolled:CHANGELOG.md" > "$RUNNER_TEMP/CHANGELOG.md" 2> /dev/null &&
199
+ python3 .github/scripts/roll_changelog.py --notes "$RUNNER_TEMP/CHANGELOG.md" "$TAG" "$notes"; then
200
+ echo "notes=$notes" >> "$GITHUB_OUTPUT"
201
+ fi
202
+ exit 0
203
+ fi
204
+ if [ -f CHANGELOG.md ]; then
205
+ python3 .github/scripts/roll_changelog.py CHANGELOG.md "$TAG" "$PREVIOUS" \
206
+ "$(date -u +%Y-%m-%d)" "$notes" "$AT_HEAD"
207
+ fi
208
+ if [ -f .github/scripts/set_version.sh ]; then
209
+ bash .github/scripts/set_version.sh "${TAG#v}"
210
+ fi
211
+ # Stage everything, so files the hook creates or stages are included.
212
+ git add -A
213
+ if git diff --cached --quiet; then
214
+ echo "Nothing under [Unreleased] and no version files; using generated notes."
215
+ # Nothing to push, so main moving on isn't caught by the push below.
216
+ if main_moved_past_sha; then
217
+ skip_release
218
+ fi
219
+ exit 0
220
+ fi
221
+ git config user.name "github-actions[bot]"
222
+ git config user.email "$BOT_EMAIL"
223
+ git commit -qm "Update changelog for $TAG"
224
+ # Fast-forward only: if main moved past $SHA, leave it alone.
225
+ if git push origin HEAD:refs/heads/main; then
226
+ echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
227
+ if [ -s "$notes" ]; then
228
+ echo "notes=$notes" >> "$GITHUB_OUTPUT"
229
+ else
230
+ echo "Nothing under [Unreleased]; using generated notes."
231
+ fi
232
+ exit 0
233
+ fi
234
+ if ! main_moved_past_sha; then
235
+ echo "::error::Pushing the release commit for $TAG failed, and main hasn't moved."
236
+ exit 1
237
+ fi
238
+ skip_release
239
+
240
+ - name: Create release
241
+ if: steps.version.outputs.tag != '' && steps.changelog.outputs.skip != 'true'
242
+ env:
243
+ TAG: ${{ steps.version.outputs.tag }}
244
+ TARGET: ${{ steps.changelog.outputs.sha || env.SHA }}
245
+ NOTES: ${{ steps.changelog.outputs.notes }}
246
+ run: |
247
+ if ! gh release view "$TAG" > /dev/null 2>&1; then
248
+ if [ -n "$NOTES" ]; then notes=(--notes-file "$NOTES"); else notes=(--generate-notes); fi
249
+ gh release create "$TAG" --target "$TARGET" --title "$TAG" "${notes[@]}"
250
+ fi
251
+ # A release made with GITHUB_TOKEN doesn't fire publish.yml's release
252
+ # trigger, so start it directly on the new tag. Repos with nothing to
253
+ # publish have no publish.yml.
254
+ if [ -f .github/workflows/publish.yml ]; then
255
+ gh workflow run publish.yml --ref "$TAG"
256
+ fi
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .coverage
10
+ .DS_Store
@@ -0,0 +1,44 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-10-23
10
+
11
+ ### Added
12
+
13
+ - **The content arranger.** `content/<name>.md` files are arranged into products by
14
+ `catalog.<name>.yaml` files: `defaults`, `rendering` (render variants), `strings`
15
+ (labels), `terms` (glossary column aliases), `groups`, `recipes` (shapes with
16
+ `$name` substitution) and `products` (an ordered `parts` list, a `theme`, and the
17
+ fields `status`, `version`, `variants`, `tags`). Nothing in the engine knows what the
18
+ content is about or what language it is in.
19
+ - **Generated parts.** `toc` (positional: lists every heading placed after it, with
20
+ page numbers resolved by Paged.js; settings `depth`, `pages`, `title`) and `terms`
21
+ (a glossary built from the content's term tables, with a label column when the
22
+ terms come from more than one labelled file).
23
+ - **Styled parts.** Any content part may carry `{style: <name>, toc: false}`; the
24
+ build wraps every part in `<div class="part part-<name> style-<style>">` for the
25
+ theme to style. A cover is a content file with `style: cover`. A content file
26
+ that is only comments (a placeholder) is skipped, so it never costs a page.
27
+ - **Rendering.** pandoc + pagedjs-cli (Paged.js in headless Chrome) for PDF variants,
28
+ pandoc EPUB 3 for `renderer: epub` variants, with size and palette css layers.
29
+ Incremental builds keyed on file mtimes plus a stamp of the catalog rows and the
30
+ imprenta version.
31
+ - **Targets.** `(catalog, product, variant)`; `imprenta build` takes `<catalog>`,
32
+ `<product>`, `<catalog>/<product>`, each optionally `.<variant>`, or nothing for
33
+ everything; `--tag` narrows to products carrying a tag. `imprenta affected --since`
34
+ maps changed paths to targets for incremental CI.
35
+ - **Validation.** `imprenta check` enforces the content rules (one leading H1, H2
36
+ sections, max H4, front matter) and the catalog rules (every name resolves, settings
37
+ per part kind, recipes, themes, publishing fields, rendering layers).
38
+ - **Catalog editing.** `imprenta.edit` writes catalogs through ruamel.yaml's
39
+ round-trip mode so comments survive.
40
+ - **The manager.** `imprenta manager` (extra `imprenta[tui]`): a Textual dashboard of
41
+ every product with a product screen for sync-free publishing fields, variant
42
+ subscriptions, builds and outputs.
43
+ - **API.** `imprenta.Project` as the entry point: catalogs, targets, build, check.
44
+
@@ -0,0 +1,12 @@
1
+ # Citation metadata (Citation File Format 1.2.0 — https://citation-file-format.github.io).
2
+ # `version`/`date-released` are omitted so this file never drifts from the actual release.
3
+ cff-version: 1.2.0
4
+ title: "imprenta: a content arranger and styler"
5
+ message: "If you use this software, please cite it using this metadata."
6
+ type: software
7
+ authors:
8
+ - family-names: McMeen
9
+ given-names: John
10
+ email: johnmcmeen@gmail.com
11
+ repository-code: "https://github.com/jmcmeen/imprenta"
12
+ license: MIT
@@ -0,0 +1,62 @@
1
+ # imprenta: conventions for Claude Code
2
+
3
+ ## What this is
4
+
5
+ A content arranger and styler. It is a Python package (`src/imprenta`) with a CLI (`imprenta`), an
6
+ optional Textual TUI (`imprenta[tui]`) and a public API (`imprenta.Project`).
7
+ The README describes the model; this file is about working on the code.
8
+
9
+ ## Hard rules
10
+
11
+ - **Nothing content-specific.** No countries, languages, brands, slang, guides
12
+ or books in code, comments, docstrings, tests or docs. Examples use neutral
13
+ names (`intro`, `alpha`, `almanac`, `leaflet`, `release`). What a tag means,
14
+ where outputs are published, what a glossary column is called: all of that is
15
+ the content repo's business and lives in its catalog or its own scripts.
16
+ - **The engine never injects or shifts headings.** What is in a content file
17
+ is what renders. Themes style; they do not restructure.
18
+ - **A catalog has exactly these sections:** `defaults`, `rendering`,
19
+ `strings`, `terms`, `groups`, `recipes`, `products`. Adding a field is an
20
+ API change: update `catalog.py`, `check.py`, the README and the CHANGELOG
21
+ together.
22
+ - **Everything in a parts list is a name.** No sigils, no catch-all. Names
23
+ resolve as generated part, group, content file, in that order.
24
+ - **Catalogs are written only through `imprenta.edit`** (ruamel round-trip).
25
+ PyYAML's dump drops comments. ruamel attaches a comment to the key before
26
+ it, so popping a key can take the next comment with it.
27
+ - **Paged.js matches break rules by selector**, not computed style. To keep an
28
+ element from breaking, exclude it from the rule; a later `break-before:
29
+ auto` does not undo an earlier `page`.
30
+
31
+ ## Layout
32
+
33
+ ```text
34
+ src/imprenta/
35
+ project.py Project: root, dirs, catalogs (cached on mtime), content names, targets, build, check
36
+ catalog.py Catalog: sections, rendering, product_def (recipes + $name), plan, resolve_parts, render_row
37
+ content.py front matter, comments, headings, word counts, css-safe names
38
+ parts.py generated parts (toc marker, terms glossary), part wrapping, assemble
39
+ render.py pandoc / pagedjs-cli / Chrome, doctor, asset copying, the packaged toc.lua
40
+ build.py freshness (inputs + stamp), building one target
41
+ targets.py Target, all_targets, affected_targets, changed_since, spec parsing
42
+ check.py the validator (content rules + catalog rules)
43
+ edit.py round-trip catalog editing
44
+ cli.py argparse entry point; tui.py the manager (textual, optional)
45
+ data/toc.lua the pandoc filter behind the positional table of contents
46
+ tests/ pytest; conftest builds a two-catalog sample project in tmp_path
47
+ ```
48
+
49
+ ## Working here
50
+
51
+ - `make install` (uv sync with the dev and TUI extras), `make test`, `make
52
+ lint`, `make fmt`, `make check` (what CI runs). Python ≥ 3.10.
53
+ - Versions come from `vX.Y.Z` tags via hatch-vcs. `release.yml` bumps the patch
54
+ on every green merge to main and rolls `CHANGELOG.md`'s Unreleased section
55
+ into the release; a minor or major bump is a manual run of that workflow.
56
+ Add every user-visible change under `## [Unreleased]` in the same commit.
57
+ - `publish.yml` publishes to PyPI on a release (trusted publishing, environment
58
+ `pypi`). Content repos pin `imprenta==X.Y.Z`.
59
+ - Tests never call pandoc or Chrome. Rendering is exercised from a content repo
60
+ (`imprenta build`), the logic around it is what the suite covers.
61
+ - The TUI builds through `python -m imprenta build …` in a worker thread so
62
+ its output streams into the log pane; it never imports make.
imprenta-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 John McMeen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.