unaltraweb 0.3.0 → 0.5.0

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/Makefile +57 -17
  3. data/README.md +47 -14
  4. data/_plugins/figure_captions.rb +47 -10
  5. data/_sass/_documentation.scss +7 -5
  6. data/_sass/_manual.scss +7 -0
  7. data/docs/_documentation/en/02-tools.md +4 -4
  8. data/docs/_documentation/en/03-usage.md +62 -1
  9. data/docs/_documentation/en/06-github-web-editing.md +1 -1
  10. data/docs/_documentation/en/13-unaltremanual.md +12 -5
  11. data/docs/_documentation/en/20-syntax.md +14 -0
  12. data/docs/_documentation/en/25-caption-credits.md +120 -0
  13. data/docs/_documentation/en/26-image-backgrounds.md +103 -0
  14. data/docs/_documentation/en/31-template.md +1 -1
  15. data/docs/_documentation/en/32-development.md +1 -1
  16. data/docs/_documentation/en/40-distribution.md +60 -23
  17. data/docs/_documentation/en/42-docker-image.md +21 -11
  18. data/docs/_documentation/en/43-workspace-path-policies.md +232 -0
  19. data/docs/_documentation/en/44-editorial-review.md +237 -0
  20. data/docs/agents/action-prompts/00-start-site-session.txt +14 -7
  21. data/docs/agents/action-prompts/22-manual-style-audit.txt +3 -1
  22. data/docs/agents/manual-authoring-components.md +38 -0
  23. data/docs/agents/mcp-contract.md +112 -14
  24. data/docs/agents/visual-companions-0.4.0.md +74 -0
  25. data/docs/assets/img/caption-credits-demo.svg +19 -0
  26. data/scripts/editorial_check.py +12 -0
  27. data/scripts/image_background_check.py +12 -0
  28. data/scripts/manual/build_pdf.py +94 -16
  29. data/scripts/manual/filters/figure-captions.lua +65 -10
  30. data/scripts/manual/templates/manual.tex +6 -1
  31. data/scripts/test_gem_build.py +32 -2
  32. data/scripts/test_reproducible_jekyll_build.py +1 -1
  33. data/scripts/test_wheel_install.py +75 -5
  34. data/scripts/unaltraweb-mcp-bootstrap.sh +19 -1
  35. data/scripts/validate_distribution.py +19 -4
  36. data/scripts/validate_workflows.py +290 -5
  37. data/scripts/verify_package_publish.py +414 -0
  38. data/scripts/web_captures/render.py +1 -1
  39. data/src/unaltraweb_mcp/component-contract.json +37 -37
  40. data/src/unaltraweb_mcp/editorial.py +495 -0
  41. data/src/unaltraweb_mcp/editorial_sources.py +504 -0
  42. data/src/unaltraweb_mcp/image_backgrounds.py +334 -0
  43. data/src/unaltraweb_mcp/image_probe.py +149 -0
  44. data/src/unaltraweb_mcp/processes.py +146 -0
  45. metadata +16 -2
@@ -8,15 +8,17 @@ The client registration should launch:
8
8
  make --silent --no-print-directory -C ${factoryRoot} mcp-stdio
9
9
  ```
10
10
 
11
- The opened workspace is the consumer website repository. The pending distribution contract selects `ghcr.io/dosquartsdedocs/unaltraweb-mcp:0.3.0` for the factory logic, without asserting that the remote image exists yet. The launcher reuses it locally or attempts to pull it; an unavailable pending release fails visibly rather than triggering a local fallback build. This is the canonical ContExt command from `mcp-factory.yml`; the transport also sets `MCP_CONSUMER_WORKSPACE=${workspaceFolder}` in the child environment:
11
+ The opened workspace is the consumer website repository. gContExt preparation inspects or pulls the full public digest selected by `MCP_RELEASE_IMAGE`, and the launcher executes that exact image; it never falls back to an implicit source build. The pin advances in a separate post-release change only after a receipt records the new digest. This is the canonical gContExt command from `mcp-factory.yml`; the transport also sets `MCP_CONSUMER_WORKSPACE=${workspaceFolder}` in the child environment:
12
12
 
13
13
  ```bash
14
14
  MCP_CONSUMER_WORKSPACE="$PWD" make --silent --no-print-directory -C /path/to/unaltraweb mcp-stdio
15
15
  ```
16
16
 
17
- Replace `/path/to/unaltraweb` with the checkout's absolute path. The bootstrap canonicalizes the inherited environment value after process launch; neither Make nor generated shell source evaluates consumer path text. The declared launcher remains `make`, which ContExt permits for a container runtime without a `runtime.allowed_host_launchers` exception. Restart clients such as OpenCode after changing their MCP registration.
17
+ Replace `/path/to/unaltraweb` with the checkout's absolute path. The bootstrap canonicalizes the inherited environment value after process launch; neither Make nor generated shell source evaluates consumer path text. The declared launcher remains `make`, which gContExt permits for a container runtime without a `runtime.allowed_host_launchers` exception. Restart clients such as OpenCode after changing their MCP registration.
18
18
 
19
- ContExt dependency preparation builds the runtime and required companions but does not initialize consumer content. The transport passes `${workspaceFolder}` only through `MCP_CONSUMER_WORKSPACE`; it never sets `transport.cwd` or embeds the consumer path in a Make assignment. The factory command may therefore use `make -C` without changing or reparsing the selected consumer root. Companion-aware checks and smoke tests include both required providers, while provider updates remain explicit. The manifest does not advertise an `init` command, and both companion dependencies set `init: false`. Use `new_web` explicitly when a new consumer site should be created. Restart long-lived MCP clients after registration, rebuilds, or provider upgrades so their stdio processes use the selected releases.
19
+ gContExt dependency preparation ensures the selected MCP release image and prepares required companions but does not initialize consumer content. The transport passes `${workspaceFolder}` only through `MCP_CONSUMER_WORKSPACE`; it never sets `transport.cwd` or embeds the consumer path in a Make assignment. The factory command may therefore use `make -C` without changing or reparsing the selected consumer root. Companion-aware checks and smoke tests include both required providers, while provider updates remain explicit. The manifest does not advertise an `init` command, and both companion dependencies set `init: false`. Use `new_web` explicitly when a new consumer site should be created. Restart long-lived MCP clients after registration, release-pin changes, or provider upgrades so their stdio processes use the selected releases.
20
+
21
+ Repository editing coordination remains a control-plane responsibility rather than an `unaltraweb` runtime feature. Request one top-level MCP and let the control plane select its declared dependency closure; unrelated user registrations remain configured until explicitly removed and clients reconnect. Before editing, the control plane runs its read-only checkout preflight against the one primary mutable checkout and, when required, holds a process-scoped cooperative lease through its `exec` wrapper. Only one editing session may be active per repository. The control plane must never create, switch to, move, prune, repair, or remove Git worktrees implicitly.
20
22
 
21
23
  For a new agent-driven workspace, create or select the empty Git repository first, open that directory in the IDE, register the factory, restart the client, and then call `new_web`. The tool remains confined to the configured project root and does not accept an arbitrary destination. Generated sites include user-owned `README.md` and `AGENTS.md`, profile-specific source directories, and a managed runtime baseline; `scaffold_sync` never rewrites those user-owned guidance or content files.
22
24
 
@@ -30,15 +32,42 @@ A version-1 receipt contains only the provider result contract: `schema_version`
30
32
 
31
33
  The request digest is SHA-256 over `unaltraweb-companion-receipt-v1\0OWNER\0`, followed for each sorted provider source by its UTF-8 project-relative path and bytes, each prefixed by an unsigned eight-byte big-endian length. Vega sources are the manifest and all project Vega/Vega-Lite specifications; diagram sources are all supported Mermaid and PlantUML files. Receipt publication remains provider-owned, but a provider cannot expand or reduce the input inventory accepted by unaltraweb.
32
34
 
35
+ ## Discovery Workspace Policies
36
+
37
+ `mcp-factory.yml` declares schema-v1 `workspace_rule.path_policies` against the
38
+ consumer root `.`. The central manager owns `workspace-check`; it is not an
39
+ unaltraweb MCP tool and does not invoke `site_check`, `down`, rendering or any
40
+ other provider command. It checks the complete `install: true` dependency closure,
41
+ retaining each provider's own binding and root. The current selection is
42
+ `diavisuals`, `vegavisuals`, then `unaltraweb` (three factories, two dependencies).
43
+
44
+ Ignored paths require real Git ignore coverage and no indexed descendants;
45
+ existing versioned files must be indexed and not ignored; absent versioned paths
46
+ are allowed. `consumer` leaves the Git decision to the project. Cleanup metadata
47
+ does not authorize deletion, and `generated_paths` alone conveys neither ignore
48
+ nor cleanup policy. Optional computation/PDF paths do not initialize those
49
+ features or require manual content in other profiles.
50
+
51
+ The [path audit and consumer update procedure](../_documentation/en/43-workspace-path-policies.md)
52
+ documents why `tmp`, Scimago inputs, PDF provenance, preview ownership, locks and
53
+ recovery backups need explicit preservation decisions. Renderer receipts and Vega
54
+ paths retain their provider policies; configurable outputs and edited visual
55
+ assets have no blanket policy here. The initial adoption changes discovery only:
56
+ the four v0.4.0 package scaffolds already meet these policies, and neither their
57
+ baseline format nor runtime cleanup changes.
58
+
33
59
  ## Resources
34
60
 
35
61
  | Resource | Description |
36
62
  | --- | --- |
37
63
  | `web://distribution` | Package-owned component BOM plus offline, feature-aware doctor findings for the current factory and project. |
38
- | `web://site-context` | Site profile, feature flags, content inventory, bibliography, bibliometrics, and build state. |
64
+ | `web://site-context` | Site profile, feature flags, content inventory, bibliography, bibliometrics, build state, and offline consumer-update advisory. |
39
65
  | `web://site-doctor` | Read-only offline distribution, project-contract, freshness, scaffold-drift, and core-override findings. |
40
66
  | `web://starter-templates` | Starter website templates available to initialize a new workspace. |
41
67
  | `web://profile-contract` | Checks for `unaltreselfie`, `unaltreprojecte`, `unaltremanual`, and `unaltredocs`. |
68
+ | `web://editorial-policy` | Effective common, profile, genre and local writing policy, supported languages and policy fingerprint. |
69
+ | `web://editorial-status` | Anchored review passes, retained decisions and source/policy staleness; also available in `site_context.editorial`. |
70
+ | `web://image-backgrounds` | Read-only source-image opacity advisories, generated-source ownership and explicit inspection limits. |
42
71
  | `web://profile-prune-plan` | Dry-run list of profile-specific content that can be removed from the active profile. |
43
72
  | `web://content-inventory` | Local editable collections, `_data/`, and assets. |
44
73
  | `web://language-policy` | Default language, configured languages, and editorial translation workflow settings. |
@@ -63,16 +92,24 @@ The request digest is SHA-256 over `unaltraweb-companion-receipt-v1\0OWNER\0`, f
63
92
  | `starter_templates` | List package-owned profile scaffolds under the legacy inventory name. |
64
93
  | `initialize_site` | Compatibility alias for `new_web`; external templates and overwrite mode are rejected. |
65
94
  | `detect_site` | Detect an unaltraweb consumer from `_config.yml` and `Gemfile`, and report whether its Makefile exposes the native build/serve contract. |
66
- | `site_context` | Read the main local state for an agent session. |
95
+ | `site_context` | Read the main local state plus `update_status`: current/target package versions, planned paths, preserved customizations, conflicts and a reviewed-plan digest. |
67
96
  | `site_doctor` | Combine distribution doctor with strict project config, identity/language, generated Make contract, scaffold drift, required generated-output/receipt status, existing HTML audit, companion actions, and core override inventory. Unknown required status is blocking. Read-only and offline. |
68
- | `site_check` | Run profile, freshness, companion visualization/diagram receipt, bibliography, bibliometrics, and build-state checks without network. |
97
+ | `site_check` | Run profile, publication-copy source diagnostics, freshness, companion visualization/diagram receipt, bibliography, bibliometrics, and build-state checks without network. |
69
98
  | `site_source_read` | Read one allowed UTF-8 site source and return its exact SHA-256. |
70
99
  | `site_source_write` | Dry-run or atomically create/update one allowed source. Creates require `create_only`; updates require the exact SHA-256 returned by a read. |
71
100
  | `site_source_delete` | Dry-run or delete one allowed source with exact SHA-256 and explicit confirmation. It never deletes `_config.yml` or directories. |
72
- | `scaffold_sync` | Dry-run or transactionally synchronize the nine package-managed scaffold controls, including the collaboration contract, Dependabot policy, pull-request template, dependency pins, and deploy caller, against `.unaltraweb/scaffold.json`; edited files are conflicts, exact current package bytes may be adopted without a rewrite, every output is staged, adopted and unchanged files are rechecked before and after the last manifest write, and rollback covers partial apply. README prose remains site-owned. |
101
+ | `scaffold_sync` | Dry-run or transactionally synchronize the nine package-managed scaffold controls against `.unaltraweb/scaffold.json`; preserve local edits if the package file has not changed from baseline, reject conflicting edits and known version downgrades, and optionally require `expected_plan_sha256` from the reviewed proposal. Exact package bytes may be adopted without rewriting. Adopted, unchanged and preserved files are rechecked around the manifest-last transaction; rollback covers partial apply. README prose remains site-owned. |
73
102
  | `profile_check` | Check current profile and expected content/config paths. |
103
+ | `prose_check` | Check a selected content target or all supported reader-facing sources and public metadata using shared profile/genre rules; no writes or model calls. |
104
+ | `editorial_policy` | Inspect common guidance, profile defaults, genre overrides, local writing preferences and optional publication requirements. |
105
+ | `editorial_status` | Inspect `context/editorial-state.json`, revision, review history and staleness without creating files. |
106
+ | `editorial_review_prepare` | Prepare bounded source text, exact fragment anchors, source digest, editable owners and a structure/line/copy/evidence rubric. |
107
+ | `editorial_review_record` | Explicitly record a report with exact quotes/anchors, current source digest and `expected_revision`; never edit prose or grant author approval. |
108
+ | `editorial_review_resolve` | Record an accepted/rejected/resolved disposition with a reason and current revision; preserve prior decisions. |
109
+ | `editorial_publication_check` | Check publication copy and opt-in fresh-review requirements; optional `output_folder` adds rendered HTML and private-context leakage checks. |
110
+ | `image_background_check` | Warn about transparent or unverifiable raster/SVG images in a source selection or rendered output folder. Any opaque colour is valid; the tool never modifies files or fetches remote images. |
74
111
  | `manual_source_quality_check` | For `unaltremanual`, check captioned tables and figures, resolve local visual sources, compare embedded SVG text with body text on web/PDF, and suggest support-specific dimensions. |
75
- | `manual_editorial_quality_check` | Reject non-publishable metatext, user/agent instructions, workflow markers, drafting notes, and placeholders in manual bodies; return the editorial review checklist and local writing-profile path. |
112
+ | `manual_editorial_quality_check` | Manual-scoped compatibility wrapper around shared prose rules; preserve the review checklist and writing-profile path while allowing legitimate quotations, examples and reader-facing language. |
76
113
  | `manual_authoring_capabilities` | Return the paragraph-development model and structured component catalogue an MCP writing assistant must use. |
77
114
  | `manual_computation_status` | Inspect executable manual sources, selected images, generated outputs, and freshness without executing code. |
78
115
  | `manual_computation_check` | Reject missing, modified, orphaned, or stale generated Markdown and figures. |
@@ -83,6 +120,8 @@ The request digest is SHA-256 over `unaltraweb-companion-receipt-v1\0OWNER\0`, f
83
120
  | `web_capture_render` | Start Jekyll and Chromium on an ephemeral internal Docker network, then publish original PNG plus editable annotated SVG from declared CSS selectors. |
84
121
  | `manual_pdf_status` | Inspect PDF configuration, sources, generated artefacts, published paths, selector, and freshness without Docker, network, or writes. |
85
122
  | `manual_pdf_build` | Build one or all configured language PDFs and first-page cover previews under `tmp/`; `release_selector` defaults to `latest` and is part of the PDF fingerprint. |
123
+ | `manual_pdf_preview_prepare` | Build only stale `latest` PDF languages, then atomically stage ignored, untracked PDF and cover copies for local Jekyll review under a cleanup receipt. Always reports `publishes: false`; disabled PDF configuration is a no-op unless receipt-owned preview files still require cleanup. |
124
+ | `manual_pdf_preview_clean` | Dry-run or remove only preview files whose path, Git state, content hash, and file identity still match the staging receipt. Real cleanup requires explicit confirmation plus the dry-run's exact `receipt_sha256` as `expected_receipt_sha256`, and never publishes. |
86
125
  | `manual_pdf_publish` | Copy built PDFs and covers to configured public assets. Defaults to dry-run; real publication requires explicit confirmation, and the selector must match the build. |
87
126
  | `manual_release_status` | Offline, read-only inspection of selector-bound build receipt, HTML audit, PDF evidence, stable policy, and local candidate state. |
88
127
  | `manual_release_check` | Fail unless the selector-bound candidate exactly matches the current verified source, site, PDF, cover, manifest, and checksums. |
@@ -107,11 +146,13 @@ The request digest is SHA-256 over `unaltraweb-companion-receipt-v1\0OWNER\0`, f
107
146
  | `preview_stop` | Remove only that project's labelled preview container. |
108
147
  | `http_check` | Probe bounded safe paths on the current project's owned labelled preview. The origin is derived internally; redirects and arbitrary origins are rejected. |
109
148
 
110
- Advanced computation, capture, PDF, and bibliometrics tools delegate to factory-owned Make targets against the consumer project. Fresh package scaffolds therefore do not need to copy those implementation targets into each website. A new `unaltremanual` does include a consumer-owned `.unaltraweb/computations.yml` that selects the release's R and Python workers; the first render reuses a local image or pulls that selected image automatically. The worker layers remain external distribution components and are not copied into the wheel or site. The tool names use `bibliometrics_*` even though factory Make targets retain `metrics-*` for backwards compatibility.
149
+ Advanced computation, capture, PDF, and bibliometrics tools delegate to factory-owned Make targets against the consumer project. Fresh package scaffolds therefore do not copy worker implementation into each website. Their managed outer `build`, `test`, and `serve` targets call `manual_pdf_preview_prepare` in a short-lived controller before Jekyll starts. That controller mounts a locally available Docker socket when a stale PDF may need a nested worker, but can still execute a disabled or already-fresh operation when no socket is available; `build-native`, `test-native`, `serve-native`, and the persistent preview container remain socket-free. A new `unaltremanual` does include a consumer-owned `.unaltraweb/computations.yml` that selects the release's R and Python workers; the first render reuses a local image or pulls that selected image automatically. The worker layers remain external distribution components and are not copied into the wheel or site. The tool names use `bibliometrics_*` even though factory Make targets retain `metrics-*` for backwards compatibility.
111
150
 
112
151
  `distribution_doctor` findings always include `code`, `severity`, `expected`, `actual`, and `remediation`. Missing factory assets in a direct wheel install produce healthy limited wheel mode, not a false failure. When Docker checks are requested, doctor uses only `docker version` and `docker image inspect`; it does not pull, build, start, or remove anything.
113
152
 
114
- Manual PDF publication is a local workspace operation: it copies reviewed artefacts from `tmp/manual-pdf/` to configured paths such as `assets/pdf/` and `assets/img/`. It never commits, pushes, creates releases, or writes outside the consumer workspace. Use one selector consistently across `manual_pdf_build`, `manual_pdf_publish`, `build_site`, and `manual_release_prepare`. `latest` is the default; stable selectors use `vYYYY.MM(.N)` and require the consumer repository root to be an exact clean Git checkout without nested repositories, submodules, or clean/smudge filters. Stable Jekyll builds run in an MCP image selected by immutable digest, derive `SOURCE_DATE_EPOCH` from the consumer commit, and record both identities in their version-2 candidate manifest. Run `manual_source_quality_check`, `manual_editorial_quality_check`, `manual_pdf_status`, `manual_pdf_build`, and a `manual_pdf_publish` dry-run before calling `manual_pdf_publish(dry_run=false, confirm_publish=true)`. A stable caller additionally submits the SHA-256 of its checked local `tmp/manual-release/<selector>/release-manifest.json`; only the GitHub workflow has tag and release authority.
153
+ Manual PDF preview staging is distinct from publication. `manual_pdf_preview_prepare` is fixed to `latest`, builds stale languages, requires the repository root plus ignored and untracked public destinations, writes its ignored receipt under `.cache/unaltraweb/`, and never commits, pushes, tags, releases, or deploys. It refuses unmanaged existing files and leaves each current generated-equivalent PDF or cover unowned and unchanged. Confirmed publication first writes expected hashes to `.cache/unaltraweb/manual-pdf-publication-intent.json`, then records non-owning file identity at `.cache/unaltraweb/manual-pdf-publication.json`; an interrupted or later preview cycle can replace only matching deployment products without granting cleanup authority over them. `manual_pdf_preview_clean` preserves any staged file that was edited, replaced, mode-changed, tracked, or made non-ignored and retains the receipt when cleanup conflicts. Its dry-run returns `receipt_sha256`; confirmed cleanup must submit that exact digest so a newly prepared generation cannot be deleted under an older review. Receipt-owned files must be cleaned before PDF output is disabled or real publication. The direct MCP sequence is prepare, `build_site`, `preview_start`, human browser/PDF review, cleanup dry-run, and digest-bound confirmed cleanup.
154
+
155
+ Manual PDF publication is a local workspace operation: it copies reviewed artefacts from `tmp/manual-pdf/` to configured paths such as `assets/pdf/` and `assets/img/`. It never commits, pushes, creates releases, or writes outside the consumer workspace. Use one selector consistently across `manual_pdf_build`, `manual_pdf_publish`, `build_site`, and `manual_release_prepare`. `latest` is the default; stable selectors use `vYYYY.MM(.N)` and require the consumer repository root to be an exact clean Git checkout without nested repositories, submodules, or clean/smudge filters. Stable Jekyll builds run in an MCP image selected by immutable digest, derive `SOURCE_DATE_EPOCH` from the consumer commit, and record both identities in their version-2 candidate manifest. Run `manual_source_quality_check`, `manual_editorial_quality_check`, `manual_pdf_status`, `manual_pdf_preview_prepare`, browser/PDF review, confirmed preview cleanup, and a `manual_pdf_publish` dry-run before calling `manual_pdf_publish(dry_run=false, confirm_publish=true)`. A stable caller additionally submits the SHA-256 of its checked local `tmp/manual-release/<selector>/release-manifest.json`; only the GitHub workflow has tag and release authority.
115
156
 
116
157
  ## New Site Initialization
117
158
 
@@ -123,15 +164,72 @@ It sets `lang`, `default_lang`, and `languages` so a new site has an explicit so
123
164
 
124
165
  Before writing, it validates every managed path, rejects destination symlinks, and compares existing files with the complete rendered scaffold through bounded regular-file reads. Identical files make repeated calls idempotent. Any differing file or file/directory collision detected during preflight aborts the whole operation before website files are written. Descriptor-relative, no-clobber writes and a final descriptor-relative content check prevent raced paths from being followed or overwritten; overwrite mode is not available. The baseline manifest is created after every other scaffold file.
125
166
 
126
- The baseline records exactly `.gitignore`, `.unaltraweb/docker-mount.sh`, `.github/CONTRIBUTING.md`, `.github/dependabot.yml`, `Makefile`, `Gemfile`, `Gemfile.lock`, `.github/pull_request_template.md`, and `.github/workflows/deploy.yml`. `scaffold_sync` updates one of these while its bytes still match the recorded baseline, adopts it when its bytes already equal the current package payload, creates a newly managed path only when it is absent, and reports other local edits or deletions as conflicts. Any conflict prevents every apply. It removes retired paths only from the baseline and never deletes their project files; it also never changes site-owned README/agent guidance, config, seed content, bibliography, data, or assets. A real synchronization requires `dry_run=false` and `confirm_sync=true`; adopted and unchanged files are included in the final rechecks around the manifest-last commit.
167
+ The baseline records exactly `.gitignore`, `.unaltraweb/docker-mount.sh`, `.github/CONTRIBUTING.md`, `.github/dependabot.yml`, `Makefile`, `Gemfile`, `Gemfile.lock`, `.github/pull_request_template.md`, and `.github/workflows/deploy.yml`. `scaffold_sync` updates one of these while its bytes still match the recorded baseline, adopts it when its bytes already equal the current package payload, and creates a newly managed path only when absent. If the package payload still equals the baseline but the local file differs, it preserves the local file and the original baseline hash; a subsequent upstream edit will still conflict. Conflicting local/upstream edits, missing managed files, collisions, unsafe paths and known newer consumer versions block the whole apply. It removes retired paths only from the baseline and never deletes their project files; it also never changes site-owned README/agent guidance, config, seed content, bibliography, data, or assets. A real synchronization requires `dry_run=false` and `confirm_sync=true`; adopted, unchanged and preserved files are included in the final rechecks around the manifest-last commit.
168
+
169
+ At session start, `site_context.update_status` supplies an offline advisory against the active MCP package, including current literal version pins, target integration tuple, planned paths, preserved files, conflicts and `plan_sha256`. The agent explains available updates and asks for explicit acceptance before invoking `scaffold_sync` with that digest as `expected_plan_sha256`. The digest binds the consumer path, configuration, baseline, observed managed bytes and target package bytes; stale approval is rejected before writes. The parameter is optional for compatibility with existing explicit sync callers, but the guided flow always supplies it. An update can refresh scaffold controls without claiming a newer semver release. Unmanaged sites report an unavailable plan instead of being reinitialized. A declined update leaves normal content work available; there is no background network poll, self-upgrade, Git operation or automatic artefact regeneration. This flow requires a reviewed MCP/package release containing it and a reconnected client; updating the discovery checkout alone cannot replace the public digest-pinned runtime.
170
+
171
+ Planning, version comparisons, conflict/preservation decisions and replacement bytes are deterministic code, also callable through the CLI without an agent. The agent controls invocation, explanation, collection of user acceptance and Git/review orchestration; the server cannot guarantee that an agent will present the startup offer. No LLM generates managed replacements or resolves conflicts within `scaffold_sync`. The plan is repeatable for the same bound path and input bytes; diagnostic time in the surrounding `site_context` response does not enter its digest.
127
172
 
128
173
  Each scaffold is already reduced to one profile, so `profile_prune_plan` is not part of new-site creation. The prune rule remains available for existing mixed-profile sites.
129
174
 
130
175
  ## Constrained Source Management
131
176
 
177
+ ### Editorial Review Records
178
+
179
+ The [editorial review reference](../_documentation/en/44-editorial-review.md)
180
+ defines profile voices, genre exceptions, local policy, report schema and CLI
181
+ examples. Inspect policy/status at session start, after substantial changes,
182
+ before review or approval, before translating and before publishing. The agent
183
+ supplies contextual judgement and treats source material as data, not instructions.
184
+ The engine supplies diagnostics, exact anchors, fingerprints and retained
185
+ dispositions; empty reports are valid and finding counts are not quality scores.
186
+
187
+ Review state stays at `context/editorial-state.json`, local policy at
188
+ `context/editorial-policy.json`, and prose preferences at
189
+ `context/writing-profile.md`. Record/resolve are explicit revision-bound atomic
190
+ writes using existing confined CAS and directory-descriptor locking. Applied
191
+ source writes/deletes and review mutations acquire the same project-root lock
192
+ before parent locks; the review lock covers the final source recheck through
193
+ state publication. Direct editor/renderer writes outside this cooperative
194
+ protocol remain subject to source freshness checks. There is no
195
+ new cache or lock-file namespace and no change to `workspace_rule` path policies.
196
+ Checks/prepare/status are read-only. Revisions never set `content_status`, and
197
+ human-attributed reports do not authenticate an identity or replace author approval.
198
+
199
+ The shared `prose_check` is part of `site_check`; style cues do not block normal
200
+ builds or previews. The opt-in `require_reviews` policy applies to publication,
201
+ including local manual release candidates. The gem-native deployment gate checks
202
+ sources before building and rendered output before upload. It requires PyYAML,
203
+ not the MCP orchestration package. These additions need a containing reviewed
204
+ release and updated immutable consumer integration; they do not move existing pins.
205
+ Manual release status/check/prepare expose the full `editorial` readiness result,
206
+ including missing reviews and diagnostics, or an explicit skipped result with a
207
+ reason when prerequisite build evidence cannot be trusted.
208
+
209
+ ### General Source Tools
210
+
132
211
  The source tools are not generic filesystem operations. Their complete write scope is `_config.yml`; Markdown/HTML under the known content collections; YAML, JSON, or CSV below `_data/`; and Markdown below `context/`. Workflows, Makefiles, Gemfiles, layouts, includes, plugins, Sass, bibliography, binary assets, generated paths, symlinks, directories, absolute paths, and traversal are outside this API.
133
212
 
134
- All operations use project-confined descriptor-relative no-follow traversal. Nonblocking open rejects FIFOs/devices before reading, size is checked before allocation, and files/proposed content are limited to 1 MiB. Text must be UTF-8 without NUL bytes; YAML and JSON reject duplicate keys, and JSON rejects non-finite numbers. Reads return SHA-256. Existing writes require that exact digest; new writes require `create_only=true`. Apply takes an advisory parent lock, moves the expected object to a private backup, verifies content and identity before and after publication, and restores the backup when a final-window edit is detected. Deletes use the equivalent verified tombstone flow. `_config.yml` is never deletable.
213
+ All operations use project-confined descriptor-relative no-follow traversal. Nonblocking open rejects FIFOs/devices before reading, size is checked before allocation, and files/proposed content are limited to 1 MiB. Text must be UTF-8 without NUL bytes; YAML and JSON reject duplicate keys, and JSON rejects non-finite numbers. Reads return SHA-256. Existing writes require that exact digest; new writes require `create_only=true`. Apply takes the project-root advisory lock before the parent lock, moves the expected object to a private backup, verifies content and identity before and after publication, and restores the backup when a final-window edit is detected. Deletes use the same root-before-parent ordering and the equivalent verified tombstone flow. `_config.yml` is never deletable.
214
+
215
+ ## Image Backgrounds And Companion Linking
216
+
217
+ Image background checks are advisory and share one CLI/MCP/native implementation.
218
+ `site_check` includes source references, `html_audit` includes rendered image
219
+ references, and MCP PDF builds expose source-image advisories. Report warnings to
220
+ the author; do not silently flatten assets onto white. The decoder runs in a
221
+ bounded child process, disallows external SVG resources and reports unsupported
222
+ cases as unverifiable. The [image background reference](../_documentation/en/26-image-backgrounds.md)
223
+ documents supported formats, sampling limits and the native gem command.
224
+
225
+ For companion linking, compare the selected BOM and dependency capabilities with
226
+ each running provider's `factory_manifest`, `compatibility_status` and release
227
+ evidence. A development checkout announcing a future version is not a published
228
+ release or evidence that a long-lived MCP process has upgraded. The current
229
+ published companion selections are `diavisuals v0.3.1` and `vegavisuals v0.3.1`.
230
+ Advance them only after their immutable releases exist and their receipt/tool
231
+ contracts have been verified; source-checkout version drift remains an explicit
232
+ control-plane finding, not a reason to weaken receipt validation.
135
233
 
136
234
  ## Language And Translation Discipline
137
235
 
@@ -145,13 +243,13 @@ Translations are a pre-publication task. They should preserve `ref`, citations,
145
243
 
146
244
  ## Docker Runtime And Preview
147
245
 
148
- `make mcp-build` builds `ghcr.io/dosquartsdedocs/unaltraweb:0.3.0` and then `ghcr.io/dosquartsdedocs/unaltraweb-mcp:0.3.0` locally. `make mcp-smoke` runs a real MCP client/server stdio exchange, compiles a temporary minimal site, and exercises preview start/status/stop. `mcp-stdio` remains dormant until a client launches it; dependency preparation never invokes it.
246
+ `make mcp-build` prepares only the immutable public image selected by `MCP_RELEASE_IMAGE`; it does not build source. `make mcp-image`, `make mcp-check` and `make mcp-smoke` build checkout source under local `unaltraweb:dev` and `unaltraweb-mcp:dev` names. The smoke target also builds `unaltraweb-manual-pdf:dev`, runs a real MCP client/server stdio exchange, builds a stale manual PDF through the short-lived socket-enabled controller, and verifies preview start/status/stop against a persistent container that has no Docker socket. `mcp-stdio` remains dormant until a client launches it; dependency preparation never invokes it.
149
247
 
150
248
  Run `site_doctor` and `site_check`, then resolve any blocking validation result before compiling. `build_site` reuses the active MCP container and the consumer's `build-native` target, and runs the local HTML audit after a successful Jekyll process. The generated `test-native` target also runs `html_audit`. This is intentionally different from the consumer's normal host-side `make build`, which starts a Jekyll container and would create a nested runtime when called from MCP.
151
249
 
152
250
  Make delegation and feasible Docker control calls use one bounded subprocess runner. Status and control commands have short deadlines, builds/renders have target-specific longer deadlines, timeout terminates the process group and returns code `124`, and retained stdout/stderr is capped with explicit truncation fields. Factory commands that promise JSON fail closed when output is empty, malformed, non-object, non-finite, or truncated. Every bind source and target is encoded as a quoted Docker CSV field, so commas or quotes in host paths cannot introduce duplicate mount fields; carriage-return and newline path characters are rejected before canonicalization or mount construction. Computation, capture, and PDF containers carry factory, worker-role, project, and invocation-token labels plus cidfiles; after timeout cleanup selects all four labels and cannot remove unrelated containers.
153
251
 
154
- A preview must outlive one MCP tool invocation, so it runs in a separate container made from the same MCP/Jekyll image. Its deterministic name is derived from the canonical host project path and it carries the factory, role, and project labels. Its isolated container always listens on port `4000`; the default host port is allocated atomically by Docker on loopback and is reported as `preview_status.port`, preventing the old cross-project collision on host port `4000`. Starting an already-running preview probes it again instead of creating a duplicate. A preview created under the former fixed-port default is accepted as compatible with the new automatic default until it is stopped; its next start uses dynamic allocation. Changing an explicit requested port or profile still requires stopping first. Stdio session containers intentionally have Docker-generated names so independent clients can run simultaneously, but carry the same stable project ID and labels as previews and capture resources. Stop and cleanup operations select ownership labels before removing anything.
252
+ A preview must outlive one MCP tool invocation, so it runs in a separate container made from the same MCP/Jekyll image. Its deterministic name is derived from the canonical host project path and it carries the factory, role, and project labels. Its isolated container always listens on port `4000`; the default host port is allocated atomically by Docker on loopback and is reported as `preview_status.port`, preventing the old cross-project collision on host port `4000`. Starting an already-running preview probes it again instead of creating a duplicate. A preview created under the former fixed-port default is accepted as compatible with the new automatic default until it is stopped; its next start uses dynamic allocation. Changing an explicit requested port or profile still requires stopping first. Stdio session containers intentionally have Docker-generated names so independent clients can run simultaneously, but carry the same stable project ID and labels as previews and capture resources. That process-level capability does not authorize concurrent editing sessions in one repository. Stop and cleanup operations select ownership labels before removing anything.
155
253
 
156
254
  `preview_status.url` is the browser URL published on host loopback. `preview_status.internal_url` is informational; callers do not pass it to `http_check`. That tool verifies preview ownership, derives the exact container-internal HTTP origin, disables environment proxies, accepts at most 20 local paths within a bounded timeout and response-read budget, and rejects absolute URLs, protocol-relative forms, traversal, fragments, hostile characters, non-2xx results, and redirects. Preview readiness checks the configured home permalink and generated root candidates rather than guessing a language route. `MCP_CONSUMER_WORKSPACE=/canonical/consumer/path make mcp-down` removes containers and networks selected by both `io.context.mcp-factory=unaltraweb` and the stable project label. A simultaneous `MCP_PROJECT_ID` must match that canonical live path. If the original path has moved or disappeared, pass the absent path with the retained ID or explicitly clear `MCP_CONSUMER_WORKSPACE`; only then is the retained ID accepted without path access. `make mcp-down-all` is the explicit maintainer cleanup for every resource carrying the factory label. Neither target deletes images or touches unlabelled resources.
157
255
 
@@ -0,0 +1,74 @@
1
+ # Published visual companions: native acceptance and release handoff
2
+
3
+ Tracking: [issue 67](https://github.com/dosquartsdedocs/unaltraweb/issues/67).
4
+ Owner branch: `fix/67-published-visual-companions`, based on
5
+ `bfabaa2738ddd24725f5e386d60b58a9a8294316` after an eligible primary-checkout
6
+ preflight. This is next-release source, not a replacement for published core 0.4.0.
7
+
8
+ ## Selected artifacts
9
+
10
+ | Provider | Integrated published source | Wheel SHA-256 |
11
+ | --- | --- | --- |
12
+ | Diavisuals v0.4.0 | `1e842967eabbad4cdb7dcb081493c1c25772dc5f` | `bfedcc9e2554f25ce4a1c33e556f352210848fccb8da800d1c3900dd86c48c93` |
13
+ | Vegavisuals v0.4.0 | `68c0b231402ae9485cc34ce530dc5239cb0ec194` | `b52ffa743643dd6b5e0320e7a9aa0cd500ea06262b7d5098c0c3f94a379bc0ea` |
14
+
15
+ Canonical URLs are in `components.<provider>.reference`; dependency `uv_spec`
16
+ values derive from them. Validation binds a wheel's repository, release path,
17
+ package/version filename and nonzero SHA-256. Existing exact Git references
18
+ remain accepted. No additional component-contract field or wire version is needed.
19
+ The published Vega wheel/renderer archive targets Linux/amd64.
20
+
21
+ Native lifecycle flags, tools/resources and the complete dependency closure are
22
+ preserved. Vega consumer scaffold revision now comes from its published commit.
23
+ The current native receipts retain their original provider-owned semantics.
24
+ This unit does not add a v1 artifact importer or claim domain bundle compatibility.
25
+
26
+ ## Executed acceptance — 2026-09-26
27
+
28
+ - `make distribution-check`: passed.
29
+ - `PYTHONPATH=src python3 -m unittest discover -s test -p 'test_*.py'`:
30
+ 544 discovered, 515 passed, 29 optional cases skipped.
31
+ - `make wheel-check`: passed against a clean factory-free installed wheel.
32
+ - `make mcp-check mcp-smoke`: passed through the owner Docker development images,
33
+ including real stdio and preview/manual-PDF paths.
34
+ - `make docs-build`: passed in the published core runtime.
35
+ - `test/published_companion_smoke.py`: passed with separate installed published
36
+ wheel CLIs, their tested renderer images and a new temporary consumer with spaces.
37
+ It renders Mermaid, PlantUML, Vega-Lite with retained CSV data, and raw Vega;
38
+ checks actual provider receipts through `site_check`; rejects modified data and
39
+ diagram bytes; then accepts the relocated consumer. No receipt is fabricated.
40
+ - `git diff --check`: passed.
41
+
42
+ Reproduce the provider acceptance after installing the exact BOM wheel URLs and
43
+ loading/verifying the renderer archives from their releases:
44
+
45
+ ```bash
46
+ PYTHONPATH=src python3 test/published_companion_smoke.py \
47
+ --diavisuals /absolute/published-diavisuals/bin/diavisuals \
48
+ --vegavisuals /absolute/published-vegavisuals/bin/vegavisuals
49
+ ```
50
+
51
+ Tested renderer IDs:
52
+ `sha256:5a6887b372a0e1c386a7b54981d11ae1910215baeb6a12dfd0706b3e85ef0846`
53
+ (Diavisuals) and
54
+ `sha256:695125943d0fbc3aa7c877babb9a11a6501bbc7ac265b0975bb5657c60439d98`
55
+ (Vega). These identify the release archives' images, not OCI registry RepoDigests.
56
+
57
+ ## Activation and publication order
58
+
59
+ 1. Review/integrate this owner PR and choose the next coordinated core release
60
+ version. Do not rebuild or republish the existing 0.4.0 artifacts under this
61
+ changed BOM. Keep other already integrated editorial/scaffold changes in scope
62
+ when preparing that coherent release.
63
+ 2. Run the normal candidate/receipt/signing/package release procedure in
64
+ `docs/_documentation/en/40-distribution.md`. Verify installed runtime and
65
+ native receipts against the exact candidate digests; unchanged workers can
66
+ retain their existing reviewed identities.
67
+ 3. Advance `MCP_RELEASE_IMAGE` only in the normal post-release change, using its
68
+ new receipt. It currently remains
69
+ `ghcr.io/dosquartsdedocs/unaltraweb-mcp@sha256:389bc585cdb4fc89d3372f4896a55fe26e15df38b46bc114ce44fdb3f1c8deb9`.
70
+ A green development manifest does not mean that this older running image has
71
+ acquired the new companion contract.
72
+ 4. Reconcile hub dependency state and repair the existing unaltraweb client drift
73
+ through normal dependency-aware installation. Reconnect clients and inspect the
74
+ selected runtime's own manifest before updating real consumer scaffolds.
@@ -0,0 +1,19 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="640" height="180" viewBox="0 0 640 180" role="img" aria-labelledby="title desc">
2
+ <title id="title">Three linked stages</title>
3
+ <desc id="desc">Three labelled boxes, A, B and C, connected from left to right.</desc>
4
+ <rect width="640" height="180" fill="#fff"/>
5
+ <g fill="#eaf1f7" stroke="#315a75" stroke-width="2">
6
+ <rect x="35" y="45" width="150" height="90" rx="8"/>
7
+ <rect x="245" y="45" width="150" height="90" rx="8"/>
8
+ <rect x="455" y="45" width="150" height="90" rx="8"/>
9
+ </g>
10
+ <g fill="none" stroke="#315a75" stroke-width="3">
11
+ <path d="M185 90h50m-10-8 10 8-10 8"/>
12
+ <path d="M395 90h50m-10-8 10 8-10 8"/>
13
+ </g>
14
+ <g fill="#213f56" font-family="sans-serif" font-size="18" text-anchor="middle">
15
+ <text x="110" y="97">A</text>
16
+ <text x="320" y="97">B</text>
17
+ <text x="530" y="97">C</text>
18
+ </g>
19
+ </svg>
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env python3
2
+ """Run the gem's offline editorial publication check without an MCP checkout."""
3
+ from pathlib import Path
4
+ import sys
5
+
6
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
7
+
8
+ from unaltraweb_mcp.editorial import main
9
+
10
+
11
+ if __name__ == "__main__":
12
+ raise SystemExit(main())
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env python3
2
+ """Run the packaged image background advisory without an MCP factory runtime."""
3
+ from pathlib import Path
4
+ import sys
5
+
6
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
7
+
8
+ from unaltraweb_mcp.image_backgrounds import main
9
+
10
+
11
+ if __name__ == "__main__":
12
+ raise SystemExit(main())
@@ -45,11 +45,18 @@ DISPLAY_MATH_BLOCK_RE = re.compile(
45
45
  re.MULTILINE | re.DOTALL,
46
46
  )
47
47
  BASEURL_RE = re.compile(r"\{\{\s*site\.baseurl\s*\}\}")
48
+ KRAMDOWN_ATTRS = r'''\{:(?:[^{}"'\n]|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*')*\}'''
49
+ CAPTION_SOURCE_ATTR_RE = re.compile(
50
+ r'''(?:\A|\s)data-caption-source\s*=\s*(?:"(?P<double>(?:\\.|[^"\\])*)"|'(?P<single>(?:\\.|[^'\\])*)'|(?P<bare>[^\s}]+))'''
51
+ )
48
52
  IMAGE_RE = re.compile(
49
53
  r"!\[(?P<alt>[^\]]*)\]\((?P<path>\S+?)(?:\s+(?:\"(?P<double_title>[^\"]*)\"|'(?P<single_title>[^']*)'))?\)"
50
- r"(?P<attrs>\{:[^}\n]*\})?"
54
+ rf"(?:[ \t]*(?P<attrs>{KRAMDOWN_ATTRS}))?"
55
+ )
56
+ TABLE_DIV_RE = re.compile(
57
+ rf'''^:::\s*table\s+(?P<quote>["'])(?P<caption>[^\n]+?)(?P=quote)(?:[ \t]+(?P<attrs>{KRAMDOWN_ATTRS}))?[ \t]*\n(?P<body>.*?)^:::\s*$''',
58
+ re.MULTILINE | re.DOTALL,
51
59
  )
52
- TABLE_DIV_RE = re.compile(r'^::: table\s+["\'](.+?)["\']\s*\n(.*?)^:::\s*$', re.MULTILINE | re.DOTALL)
53
60
  LISTING_DIV_RE = re.compile(
54
61
  r'^:::\s*listing\s+"(?P<caption>[^"]+)"\s*\n(?P<body>.*?)^:::\s*$',
55
62
  re.MULTILINE | re.DOTALL,
@@ -61,7 +68,7 @@ TABLE_GUARD_AFTER_HEADING_RE = re.compile(
61
68
  re.MULTILINE,
62
69
  )
63
70
  SUBFIGURES_DIV_RE = re.compile(
64
- r'^:::\s*subfigures(?:\s+(?P<layout>[^\s"]+))?(?:\s+"(?P<caption>[^"]*)")?\s*\n'
71
+ rf'^:::\s*subfigures(?:[ \t]+(?P<layout>[^\s"{{]+))?(?:[ \t]+"(?P<caption>[^"]*)")?(?:[ \t]+(?P<attrs>{KRAMDOWN_ATTRS}))?[ \t]*\n'
65
72
  r'(?P<body>.*?)^:::\s*$',
66
73
  re.MULTILINE | re.DOTALL,
67
74
  )
@@ -787,6 +794,60 @@ def resolve_visual_source(
787
794
  raise ManualPdfError(f"No printable SVG found for diagram source: {path}")
788
795
 
789
796
 
797
+ def caption_source_attribute(raw: str) -> tuple[str, str]:
798
+ """Consume the caption-only field before emitting image attributes."""
799
+ source = raw.strip().removeprefix("{:").removesuffix("}").strip()
800
+ match = CAPTION_SOURCE_ATTR_RE.search(source)
801
+ if not match:
802
+ return "", raw
803
+ credit = next(value for value in match.group("double", "single", "bare") if value is not None)
804
+ credit = re.sub(r'''\\(["'\\])''', r"\1", credit).strip()
805
+ remaining = (source[:match.start()] + " " + source[match.end():]).strip()
806
+ return credit, "{: " + remaining + "}" if remaining else ""
807
+
808
+
809
+ def caption_with_source(caption: str, source: str) -> str:
810
+ return caption + f" [{source}]{{.uw-caption-source}}" if source else caption
811
+
812
+
813
+ def image_destinations(markdown: str) -> list[str]:
814
+ """Find assets after transformation, including nested caption spans/cites.
815
+
816
+ The source IMAGE_RE cannot represent arbitrary nested inline brackets. Asset
817
+ freshness must still include every image consumed by Pandoc, including an
818
+ inline image nested in another image's caption.
819
+ """
820
+ paths = []
821
+ for opening in re.finditer(r"(?<!\\)!\[", markdown):
822
+ index, depth = opening.end(), 1
823
+ while index < len(markdown) and depth:
824
+ character = markdown[index]
825
+ if character == "\\":
826
+ index += 2
827
+ continue
828
+ if character == "[":
829
+ depth += 1
830
+ elif character == "]":
831
+ depth -= 1
832
+ index += 1
833
+ if depth or index >= len(markdown) or markdown[index] != "(":
834
+ continue
835
+ start = index = index + 1
836
+ parentheses = 0
837
+ while index < len(markdown):
838
+ character = markdown[index]
839
+ if character.isspace() or (character == ")" and not parentheses):
840
+ break
841
+ if character == "(":
842
+ parentheses += 1
843
+ elif character == ")":
844
+ parentheses -= 1
845
+ index += 1
846
+ if index > start:
847
+ paths.append(markdown[start:index])
848
+ return paths
849
+
850
+
790
851
  def pandoc_image_attributes(raw: str) -> str:
791
852
  source = raw.strip()
792
853
  if not source:
@@ -887,7 +948,9 @@ def transform_markdown(
887
948
  return "[" + "; ".join(f"@{key}" for key in keys) + "]"
888
949
 
889
950
  def table(match: re.Match[str]) -> str:
890
- body = match.group(2).strip()
951
+ body = match.group("body").strip()
952
+ credit, _ = caption_source_attribute(match.group("attrs") or "")
953
+ caption = caption_with_source(match.group("caption").strip(), credit)
891
954
  rows = [
892
955
  re.sub(r"\s+", " ", line.strip().strip("|"))
893
956
  for line in body.splitlines()
@@ -898,7 +961,7 @@ def transform_markdown(
898
961
  page_guard = r"\clearpage" if required_baselines >= 40 else f"\\Needspace{{{required_baselines}\\baselineskip}}"
899
962
  return (
900
963
  f"```{{=latex}}\n{page_guard}\n```\n\n"
901
- f"Table: {match.group(1).strip()}\n\n{body}"
964
+ f"Table: {caption}\n\n{body}"
902
965
  )
903
966
 
904
967
  def listing(match: re.Match[str]) -> str:
@@ -957,8 +1020,9 @@ def transform_markdown(
957
1020
  default_language=visual_default_language,
958
1021
  languages=[str(value) for value in configured_visual_languages],
959
1022
  )
960
- caption = title or alt
961
- attributes = pandoc_image_attributes(match.group("attrs") or "")
1023
+ credit, raw_attrs = caption_source_attribute(match.group("attrs") or "")
1024
+ caption = caption_with_source(title or alt, credit)
1025
+ attributes = pandoc_image_attributes(raw_attrs)
962
1026
  return f"![{caption}]({printable}){attributes}"
963
1027
 
964
1028
  def callout(match: re.Match[str]) -> str:
@@ -991,6 +1055,14 @@ def transform_markdown(
991
1055
  padding = " " if value.startswith("`") or value.endswith("`") else ""
992
1056
  return f"{fence}{padding}{value}{padding}{fence}{{=latex}}"
993
1057
 
1058
+ def credited_caption(caption: str, credit: str) -> str:
1059
+ # Leave credit-bearing captions as Pandoc inlines so citeproc and
1060
+ # links see them before the figure filter styles the credit span.
1061
+ # Separate an authored closing code fence from the generated raw
1062
+ # fence; adjacent backticks would change the Markdown tokenisation.
1063
+ return (raw_latex_inline(r"\caption[{") + caption + " " + raw_latex_inline("}]{")
1064
+ + caption_with_source(caption, credit) + raw_latex_inline("}"))
1065
+
994
1066
  images = list(IMAGE_RE.finditer(match.group("body")))
995
1067
  if not images:
996
1068
  raise ManualPdfError(f"Subfigures block contains no images in {source.relative_to(project)}")
@@ -1008,13 +1080,16 @@ def transform_markdown(
1008
1080
  )
1009
1081
 
1010
1082
  overall_caption = latex_caption(match.group("caption") or "")
1083
+ overall_credit, _ = caption_source_attribute(match.group("attrs") or "")
1011
1084
  figure_start = "```{=latex}\n\\begin{figure}[H]\n\\centering\n"
1012
- if overall_caption:
1085
+ if overall_caption and not overall_credit:
1013
1086
  figure_start += (
1014
1087
  f"\\caption{{{overall_caption}}}\n"
1015
1088
  "{\\color{ManualMuted!45}\\rule{\\linewidth}{0.35pt}}\\par\\medskip\n"
1016
1089
  )
1017
1090
  rendered = [figure_start + "```"]
1091
+ if overall_credit:
1092
+ rendered.append(credited_caption(match.group("caption") or "", overall_credit))
1018
1093
  image_index = 0
1019
1094
  max_image_height = f"{0.52 / len(row_sizes):.3f}".rstrip("0").rstrip(".")
1020
1095
  for row_index, row_size in enumerate(row_sizes):
@@ -1031,7 +1106,8 @@ def transform_markdown(
1031
1106
  default_language=visual_default_language,
1032
1107
  languages=[str(value) for value in configured_visual_languages],
1033
1108
  )
1034
- attributes = pandoc_image_attributes(item.group("attrs") or "")
1109
+ credit, raw_attrs = caption_source_attribute(item.group("attrs") or "")
1110
+ attributes = pandoc_image_attributes(raw_attrs)
1035
1111
  row.extend([
1036
1112
  raw_latex_inline(
1037
1113
  f"\\begin{{subfigure}}[t]{{{panel_width}\\linewidth}}"
@@ -1041,10 +1117,11 @@ def transform_markdown(
1041
1117
  f"![]({printable}){attributes}",
1042
1118
  ])
1043
1119
  separator = r"\hfill" if column_index < row_size - 1 else ""
1044
- row.append(raw_latex_inline(
1045
- f"\\caption{{{latex_caption(caption)}}}"
1046
- f"\\end{{subfigure}}{separator}"
1047
- ))
1120
+ if credit:
1121
+ row.append(credited_caption(caption, credit))
1122
+ else:
1123
+ row.append(raw_latex_inline(f"\\caption{{{latex_caption(caption)}}}"))
1124
+ row.append(raw_latex_inline(f"\\end{{subfigure}}{separator}"))
1048
1125
  rendered.append("".join(row))
1049
1126
  if row_index < len(row_sizes) - 1:
1050
1127
  rendered.append("```{=latex}\n\\par\\medskip\n```")
@@ -1246,7 +1323,9 @@ def assemble(project: Path, config: dict[str, Any], lang: str, paths: dict[str,
1246
1323
  metadata["include-home"] = includes_home
1247
1324
  metadata["has-listings"] = "data-listing-caption=" in assembled_markdown
1248
1325
  prose_markdown = FENCED_CODE_BLOCK_RE.sub("", assembled_markdown)
1249
- metadata["has-figures"] = bool(IMAGE_RE.search(prose_markdown) or r"\begin{figure}" in prose_markdown)
1326
+ # Transformed image captions can contain nested credit spans/citations;
1327
+ # IMAGE_RE is the source reader, not a parser for those Pandoc inlines.
1328
+ metadata["has-figures"] = bool(image_destinations(MARKDOWN_INLINE_CODE_RE.sub("", prose_markdown)) or r"\begin{figure}" in prose_markdown)
1250
1329
  metadata["has-tables"] = bool(re.search(r"^Table:\s+\S", prose_markdown, re.MULTILINE))
1251
1330
  return metadata, source_paths, assembled_markdown
1252
1331
 
@@ -1410,8 +1489,7 @@ def build_dependencies(project: Path, metadata: dict[str, Any], source_paths: li
1410
1489
  dependencies.append((f"asset:{path.relative_to(project)}", path))
1411
1490
  dependency_markdown = FENCED_CODE_BLOCK_RE.sub("", markdown)
1412
1491
  dependency_markdown = MARKDOWN_INLINE_CODE_RE.sub("", dependency_markdown)
1413
- for match in IMAGE_RE.finditer(dependency_markdown):
1414
- raw = match.group("path")
1492
+ for raw in image_destinations(dependency_markdown):
1415
1493
  if raw.startswith(("http://", "https://", "data:", "#")):
1416
1494
  continue
1417
1495
  local_path, _ = split_url_decoration(raw)