diff --git a/claude-notes/plans/2026-08-14-format-css-not-copied.md b/claude-notes/plans/2026-08-14-format-css-not-copied.md new file mode 100644 index 000000000..5859dbd09 --- /dev/null +++ b/claude-notes/plans/2026-08-14-format-css-not-copied.md @@ -0,0 +1,487 @@ +# format.html.css files are neither copied into the site nor rebased per page (bd-format-css-not-copied-crn3bjdz) + +**Date:** 2026-08-14 +**Braid:** bd-format-css-not-copied-crn3bjdz (bug, p1, label `websites`) +**Checkout:** main checkout, branch `main` @ `10d86829` (investigation only — no worktree/branch created) +**Status:** Implementation complete on branch +`braid/bd-format-css-not-copied-crn3bjdz` (commits `37758160` + +`86a6f79d` on top of the investigation commits). All phases done; full +`cargo xtask verify` green; preview verified in a real browser (false +Q-5-29 fixed, css-application gap filed as bd-b3oq2fsy). Awaiting +push/PR approval. + +## Phase 4 evidence (end-to-end, real binary) + +Run 2026-08-14 on the implementation branch: + +``` +cargo run --bin q2 -- render claude-notes/plans/format-css-not-copied-investigation/repro +# → "Rendered 2 of 2 files", exit 0 +``` + +Output inspected directly (matches Q1's expected table from the repro +README exactly): + +- `_site/styles.css` exists (marker property `--repro-project-css` present); +- `_site/site_libs/quarto-contrib/quarto-project/acme/widget/widget.css` + exists (marker `--repro-extension-css` present); no `_site/_extensions/`; +- `index.html`: `href="styles.css"`, + `href="site_libs/quarto-contrib/quarto-project/acme/widget/widget.css"`, + linked after the theme bundle; +- `deep/deeper/index.html`: `href="../../styles.css"`, + `href="../../site_libs/quarto-contrib/quarto-project/acme/widget/widget.css"`. + +Also verified through the binary: single-doc `fancyfmt-html` extension +render relocates+copies bundled css to +`test_files/quarto-contrib/quarto-project/fancyfmt/fmt-style.css` (its +smoke-all fixture now pins this — previously the link only worked because +output landed beside the source tree). + +## Implementation shape (as landed) + +- **Marking** (`crates/quarto-core/src/project/format_css.rs`): + `mark_css_path_values` — existence-driven, per layer, called from + `MetadataMergeStage` for the project layer (after the `!path` + adjustment; diagnostics dropped there), directory-metadata layers, and + the document layer (diagnostics pushed per document). + `missing_project_css_diagnostics` runs once per project render from + `ProjectPipeline::run` into `project_diagnostics` (Q-5-29). +- **Transform** (`crates/quarto-core/src/transforms/format_css.rs`): + `FormatCssTransform` (Normalization phase, self-gated to HTML-family, + no-op in VFS mode) — consumes only marked Path entries; mirrors + project-relative paths; relocates `_extensions/**` to + `quarto-contrib/quarto-project/**` via `ArtifactScope::Project` + resolver queries; pushes `ResourceCopyIntent`s (skipping src==dest); + rewrites entries to per-page hrefs. Never diagnoses. +- **Revealjs**: `apply_template.rs` reveal branch appends + `user_css_urls(&metadata)` after the vendored deck assets. +- **Q-5-29**: catalog entry + `docs/errors/project/Q-5-29.qmd`. + +## Triage verdict + +**Ready to design.** The symptom reproduces at HEAD, both halves of the fix +have direct in-tree precedent (favicon copy + Path-kind metadata rebase / +resource-resolver href resolution), and the remaining decisions are genuine +design choices (output layout, which rebase mechanism, scope), not missing +information. + +## Issue context + +Filed 2026-08-14 by the q2-connect-docs porting session (origin strand in that +skein: br-format-css-not-copied-4jnxbq38). A website project declaring + +```yaml +format: + html: + css: + - styles.css + - _extensions/acme/widget/widget.css +``` + +gets a `` to each file on every page, but: + +1. **Not copied** — neither file is written into `_site/`, so every link + 404s. No diagnostic; exit 0. Q1 copies both (project css to + `_site/styles.css`; extension-owned css to + `site_libs/quarto-contrib/quarto-project/acme/widget/widget.css`). +2. **Not rebased** — the href is emitted verbatim at every depth + (`styles.css` on `deep/deeper/index.html`, where Q1 emits + `../../styles.css`). The built-in theme stylesheets on the same page *are* + rebased — that asymmetry is the control. + +Real-world impact: all 352 rendered pages of the Posit Connect docs port link +two nonexistent stylesheets (704 broken references). Invisible to text-diff +sweeps because CSS contributes no text. + +Not the same as bd-of20unsb (extension `contributes.formats` fragment paths, +fixed in 0.21.0): here the paths live in the project's own `_quarto.yml`; the +second one merely points into `_extensions/`. Notably, the repro's +`_extensions/acme/widget/` contains **only** `widget.css` — no +`_extension.yml` — so no extension machinery is involved at all. + +## Dependency graph + +**Empty** — no edges in the skein (strand is hours old). Context instead +comes from the strands the description references: + +- **bd-root-relative-paths-design-fc5pvkcv** (in_progress, design) — the + navbar-logo/root-absolute-path design session. Its Decision 5 ("favicon is + not special — config-declared assets q2 knows about get the same + warn-and-continue copy treatment") is the stated policy this bug falls + under. Its case-A fix (0.21.0) built `copy_navbar_logo` / + `copy_footer_images` on the shared `copy_asset_file` helper — the exact + seam to extend. +- **bd-of20unsb** (in_progress; fix shipped in 0.21.0 per repro README) — + extension-fragment path rebasing. Its mechanism (mark values as + `ConfigValueKind::Path`, existence-driven, then let the metadata merge + rebase them per document) is one of the two candidate mechanisms for the + rebase half here. + +## What the code looks like today + +All paths verified at `main` @ `10d86829`: + +- **Link emission**: `extract_css_from_meta` + (`crates/quarto-core/src/template.rs:928`) reads the `css` metadata key + (scalar / PandocInlines / array) and appends the strings **verbatim** to + the template `css` list (`render_with_compiled_template`, + `template.rs:699-709`). No resolver, no copy, no existence check. +- **Why theme css *is* rebased**: built-in stylesheets are artifacts; + `ApplyTemplateStage` computes their URLs via the per-page + `ResourceResolverContext` (`apply_template.rs:166`, + `collect_artifact_urls`). User css never touches that path. +- **Copy boundary**: `crates/quarto-core/src/project/website_post_render.rs` + has `copy_favicon`, `copy_navbar_logo`, `copy_footer_images`, all sharing + `copy_asset_file` and the warn-on-missing-source pattern. `format.html.css` + has no counterpart. (All native-only; the in-browser preview has no on-disk + output dir.) +- **Href precedent (per-page transform)**: `WebsiteFaviconTransform` + (`transforms/website_favicon.rs`) resolves a page-relative href through + `ctx.resource_resolver` and appends the `` to + `rendered.includes.header`. +- **Rebase precedent (metadata merge)**: `FRAGMENT_PATH_PATTERNS` + (`project/mod.rs:696`) already lists `["format", "*", "css"]` — but only + for **extension** `contributes.project` fragments. Values marked + `ConfigValueKind::Path` are rebased project-root → document-dir by + `adjust_paths_to_document_dir` during the metadata merge + (`metadata_merge.rs:256` applies it to the project-config layer). The + project's own `_quarto.yml` values are never *marked* Path-kind, so the + machinery never fires for them. +- **Resource collector**: `resource_collector.rs` walks the AST only; + metadata-declared css is invisible to it (same blind spot the design + strand documents for raw HTML). + +### Repro at HEAD + +Fixture: `claude-notes/plans/format-css-not-copied-investigation/repro/` +(mirrors the external repro at +`~/repos/github/cscheid/q2-connect-docs/llms-info/repros/format-css-not-copied/`). + +Run 2026-08-14 at `main` @ `10d86829` (pre-flight `cargo xtask verify +--skip-hub-build` green, 12167/12167): + +``` +cargo run --bin q2 -- render claude-notes/plans/format-css-not-copied-investigation/repro +# → "Rendered 2 of 2 files", exit 0, no diagnostic +``` + +Observed output (inspected directly): + +- `_site/styles.css`: **does not exist**; no css file anywhere in `_site` + besides `site_libs/` assets. Marker custom properties absent from the + theme bundle — the declared css is dropped entirely. +- `_site/index.html` links: `site_libs/…` (fine), then verbatim + `href="styles.css"` and `href="_extensions/acme/widget/widget.css"` — + both 404. +- `_site/deep/deeper/index.html`: `../../site_libs/…` (rebased correctly) + immediately beside verbatim `href="styles.css"` / + `href="_extensions/acme/widget/widget.css"` — the asymmetry the strand + describes, confirmed on one page. + +Both defects confirmed; matches the external repro's table for q2 0.21.0. + +## Evidence pass 1: how and why Q1 organizes the output (external-sources/quarto-cli) + +Two separate machineries, plus a website-only patch; `css:` straddles them. + +**Rebase (metadata-time, not DOM-time).** Q1 runs pandoc with +`cwd = dirname(input)` (`src/command/render/pandoc.ts:371-372`), so every +relative path in the defaults file must be input-dir-relative. Project-config +paths are authored project-relative, so Q1 rewrites them at metadata-merge +time: `toInputRelativePaths` (`src/project/project-shared.ts:138-207`), called +from `projectMetadataForInputFile` (`render-contexts.ts:741-757`). It is a +blind recursive walk over the whole merged config: any non-absolute string +that names an existing file under the project dir becomes +`/` (`offset = relative(inputDir, baseDir)`) — that is the +entire story of `../../styles.css`. **Existence-driven, layer-aware (the +same function runs per layer: project config, `_metadata.yml` with the +metadata file's dir, extension config with the extension dir as base).** +The HTML template then emits the string verbatim +(`resources/formats/html/pandoc/html.template:23-24`). The deno-dom +postprocessor never rewrites a plain relative `css` href — it only records +it as a resource ref. + +**Copy (resource-ref mirror).** The website HTML postprocessor collects every +resource-tag href; refs are resolved to absolute paths and mirrored: +`join(formatOutputDir, relative(projDir, file))` +(`src/command/render/project.ts:735-766`). So project-root css lands at +`_site/styles.css` purely because resource copying is a project-relative +mirror. `copyResourceFile` also chases `url()`/`@import` inside copied css +(`project-resources.ts:110-138`). + +**The `_extensions` relocation (website/book-only).** +`projectExtensionPathResolver` (`src/extension/extension.ts:158-189`) is a +resolver injected into the website postprocessor: an href resolving under +`_extensions/` is copied to +`/quarto-contrib/quarto-project/` +and the DOM attribute rewritten. Rationale, from the code and introducing +commits (`364eb9a2d`, `b1e866553`): + +1. Underscore-prefixed dirs are systematically non-output + (`projectHiddenIgnoreGlob`, `project-context.ts:878-886` — the same list + that excludes `README.*`, `CLAUDE.md`, `AGENTS.md`). Publishing + `_site/_extensions/` would leak Lua sources, `_extension.yml`, READMEs; + relocating means only individually-referenced files ship. This matches + the user's stated constraint exactly. +2. The lib dir is freezer-managed and pruned by a whitelist of known names + (`formatLibDirs`, `project-default.ts:23-29`); `quarto-contrib` is the + single reserved namespace that protects third-party content without + enumerating extension names. `quarto-project` is a fixed literal meaning + "the project itself is the contributor" (used when a file is referenced + by raw path rather than through a named HTML dependency). There is no + per-org namespace logic — the `_extensions` prefix is stripped and the + remainder (org segment included) preserved verbatim. + +**Book = website machinery** (`book.ts:74` inherits, `book.ts:225-243` +merges website formatExtras), so the relocation applies there too. +Single-doc renders never rebase (no project offset) and never relocate; +lib assets go to `_files/libs/`. Notably, a *default*-type project +with an `output-dir` mirrors `_extensions/...` css into the output +verbatim in Q1 — the relocation is deliberately a website/book behavior. + +**Other keys (Q1):** `include-*` files are inlined by pandoc (never +copied); `format-resources` are flattened **basename-only** next to the +output — they exist for writers that want sibling files (LaTeX `.cls`, +Typst), not for ``s; `theme`/SCSS compiles to a cache then ships as a +FormatDependency under `site_libs/bootstrap|quarto-html` with hrefs built +from the input-relative lib dir; document `resources:`/`project.resources` +mirror project-relative paths, no hrefs. Four placement policies for four +consumption models. + +## Evidence pass 2: q2 boundary audit + +Per-key status at `main` @ `10d86829` (file:line cites in the agent record; +load-bearing ones inline): + +| key | q2 today | +|---|---| +| `format.html.css` | linked verbatim (`extract_css_from_meta`, `template.rs:928`); never copied; only other references are the two extension pattern tables. **On revealjs, user css is dropped entirely** — the scaffold only takes artifact URLs (`revealjs/assemble.rs:369-410`, `apply_template.rs:302-306`), so not even a broken `` is emitted. | +| `theme` | compiled to Project-scoped artifact `css:theme:` (`compile_theme_css.rs:707-730`); per-page URL via resolver (`apply_template.rs:166` → `html_url_for`); flushed to `site_libs` (website) or per-page `_files` (default/book). Healthy. | +| `include-in-header`/`-before-body`/`-after-body` | inlined by `IncludeResolveStage` into `rendered.includes.*` — Q1 parity. Raw `