|
| 1 | +# Embedded-resource virtual-path contract |
| 2 | + |
| 3 | +**Status:** Design (2026-07-03). Fix tracked by strand `bd-nxxgg0pp` |
| 4 | +(discovered from `bd-3fgnmlco`, blocks the `bd-22rtwdur` smoke-all umbrella). |
| 5 | + |
| 6 | +Quarto compiles Bootstrap/theme SCSS from resources embedded at build time |
| 7 | +via `include_dir!`. The `@import`/`@use` statements *inside* that embedded |
| 8 | +SCSS (e.g. Bootstrap's own `@import "vendor/rfs"`) are resolved at compile |
| 9 | +time against a **virtual** load path rooted at `RESOURCE_PATH_PREFIX = |
| 10 | +"/__quarto_resources__"`. This document defines the contract that virtual |
| 11 | +namespace must obey so resolution behaves identically on every OS and on |
| 12 | +both compile backends (native grass, WASM dart-sass). |
| 13 | + |
| 14 | +## Two path domains |
| 15 | + |
| 16 | +Two distinct kinds of path flow through the SASS layer, both currently |
| 17 | +typed as `std::path::PathBuf`/`&Path`: |
| 18 | + |
| 19 | +- **(a) Real OS paths** — user project documents, custom theme directories |
| 20 | + (`ThemeContext` load paths). `Path::join` and native separators are |
| 21 | + *correct* here. This domain is not changed by this work. |
| 22 | +- **(b) Virtual embedded-resource namespace** — `/__quarto_resources__/…`. |
| 23 | + Must be **forward-slash on every platform**. This is a URL-like opaque |
| 24 | + string domain, not a filesystem path. |
| 25 | + |
| 26 | +The bug (below) is that domain (b) is forced through `std::path` machinery |
| 27 | +that is correct only for domain (a). |
| 28 | + |
| 29 | +## Root cause (Windows) — source-anchored |
| 30 | + |
| 31 | +On a Windows build, every embedded `@import` fails to resolve, theme/ |
| 32 | +bootstrap/highlight compilation errors, and the render silently falls back |
| 33 | +to base CSS (`compile_theme_css.rs:547`). Chain: |
| 34 | + |
| 35 | +1. `default_load_paths()` (`quarto-sass/src/resources.rs:305-310`) returns |
| 36 | + virtual paths built with `format!` — forward-slash, e.g. |
| 37 | + `/__quarto_resources__/bootstrap/scss`. |
| 38 | +2. grass resolves `@import "vendor/rfs"` in `Visitor::find_import` |
| 39 | + (`grass_compiler 0.13.4`, `crates/compiler/src/evaluate/visitor.rs:858-859`): |
| 40 | + `let path_buf = load_path.join(path);`. `PathBuf::join` is target-OS |
| 41 | + aware and inserts `\` on Windows → `/__quarto_resources__/bootstrap/scss\vendor/rfs`. |
| 42 | + grass's own `options.rs` documents this limitation ("still using |
| 43 | + std::path… constrains to the target platform"). |
| 44 | +3. grass calls `self.options.fs.is_file(&path_buf)` on that raw `\`-joined |
| 45 | + path (via the `try_path!` macro, visitor.rs:819/823). `Fs::canonicalize` |
| 46 | + is **not** a pre-lookup hook — `import_like_node` (visitor.rs:891) calls |
| 47 | + it only *after* `find_import` already matched, for the `import_cache`/ |
| 48 | + `files_seen` key and `fs.read`. So the `Fs` impl must normalize itself. |
| 49 | +4. Our `RuntimeFs` (`quarto-system-runtime/src/sass_native.rs:84-119`) |
| 50 | + checks embedded first → `EmbeddedResources::is_file` → |
| 51 | + `strip_prefix` (`quarto-sass/src/resources.rs:167-184`), which is pure |
| 52 | + `&str` surgery that only ever trims `/`. It yields relative key |
| 53 | + `\vendor/rfs.scss`. |
| 54 | +5. Embedded file keys are forward-slash — `include_dir_macros 0.7.4` |
| 55 | + normalizes `\`→`/` at compile time (`src/lib.rs:134-136`). So |
| 56 | + `\vendor/rfs.scss` never matches `vendor/rfs.scss` → miss. |
| 57 | + |
| 58 | +Empirically verified: `PathBuf::from("/__quarto_resources__/bootstrap/scss") |
| 59 | +.join("vendor/_rfs.scss").to_string_lossy()` = `…/scss\vendor/_rfs.scss` on |
| 60 | +Windows; `str::lines`-style stripping leaves the leading `\`. Real OS load |
| 61 | +paths (custom themes, reveal SCSS) are immune — `RuntimeFs`'s `std::fs` |
| 62 | +fallback is separator-tolerant on Windows, and reveal SCSS is fully inlined |
| 63 | +(no `find_import`). |
| 64 | + |
| 65 | +## Native vs WASM contract |
| 66 | + |
| 67 | +| Aspect | Native (grass) | WASM (dart-sass via JS) | |
| 68 | +|---|---|---| |
| 69 | +| Prefix | `RESOURCE_PATH_PREFIX` | same constant, re-used | |
| 70 | +| Separator | forward-slash by construction; **breaks** when grass's `Path::join` injects `\` | pure forward-slash JS strings; `vfs:` URL importer never touches `path` | |
| 71 | +| Lookup | `EmbeddedResources::strip_prefix` accepts 3 path shapes, normalizes to a relative key | JS importer builds full keys itself (`loadPath + "/" + url`), flat exact-match VFS | |
| 72 | +| Correctness on Windows | **broken** (this bug) | correct (no `\` ever introduced) | |
| 73 | + |
| 74 | +WASM already matches the dart-sass best-practice importer contract |
| 75 | +(custom `vfs:` scheme, `canonicalize`→`load`, pure string) in |
| 76 | +`ts-packages/wasm-js-bridge/src/sass.js`. The native side is the one that |
| 77 | +diverges from the shared forward-slash contract. |
| 78 | + |
| 79 | +**Known WASM divergences (deferred — not this bug):** |
| 80 | +- WASM `populate_vfs_with_embedded_resources` |
| 81 | + (`wasm-quarto-hub-client/src/lib.rs:64-76`) materializes **only the |
| 82 | + Bootstrap pool** into the VFS; native exposes all five pools via |
| 83 | + `CombinedResources`. A future cross-pool `@import`-by-path would resolve |
| 84 | + native but fail WASM. Currently masked (no such import shipped). |
| 85 | +- The `sass-utils` load path is a dead no-op on WASM (nothing stored there). |
| 86 | + |
| 87 | +Tracked separately (see Deferred work). |
| 88 | + |
| 89 | +## Fix |
| 90 | + |
| 91 | +**Reuse the existing shared helper — do not introduce a new function.** |
| 92 | +`quarto-util` already owns the canonicalization boundary: |
| 93 | + |
| 94 | +```rust |
| 95 | +// crates/quarto-util/src/path.rs:23 |
| 96 | +pub fn to_forward_slashes(path: &Path) -> String { |
| 97 | + path.to_string_lossy().replace('\\', "/") |
| 98 | +} |
| 99 | +``` |
| 100 | + |
| 101 | +This is byte-identical to what an earlier draft proposed as a new |
| 102 | +`to_virtual_key`, and it is already unit-tested (unix no-op + Windows |
| 103 | +conversion). It is leading-slash-preserving and idempotent — exactly what |
| 104 | +`strip_prefix` needs, since the very next step, `.strip_prefix(RESOURCE_PATH_PREFIX)`, |
| 105 | +depends on the leading `/`. (An earlier draft's extra |
| 106 | +`split('/').filter(!empty).join("/")` would have *dropped* that leading |
| 107 | +slash and broken the prefix strip; grass's `load_path.join(path)` never |
| 108 | +produces `//` anyway, so only the `\`→`/` replacement is needed.) |
| 109 | + |
| 110 | +There are already two private copies of this logic |
| 111 | +(`quarto-core/src/stage/stages/document_profile.rs:132` `to_forward_slash`, |
| 112 | +`quarto-core/src/project/discovery.rs:181` `to_forward_slashes`), which is |
| 113 | +exactly the proliferation to avoid — consolidate on the `quarto-util` one. |
| 114 | + |
| 115 | +- **Add `quarto-util` as a dependency of `quarto-sass`** (it is not one |
| 116 | + today). `quarto-util::path` is pure `std` and already reasons about |
| 117 | + `wasm32` behavior (`is_rooted`), so it is safe in `quarto-sass`'s WASM |
| 118 | + build. Verify the WASM build during implementation. |
| 119 | +- **Placement — `EmbeddedResources` only.** Call `to_forward_slashes` at |
| 120 | + the top of `strip_prefix` (which every `is_file`/`is_dir`/`read`/`read_str` |
| 121 | + funnels through) and in `collect_files`/`collect_directories` for key |
| 122 | + symmetry. This is the embedded-namespace boundary — the same "normalize |
| 123 | + once at the boundary" idiom `include_dir` and `rust-embed` use. |
| 124 | +- **`RuntimeFs` stays untouched.** Its `read`/`is_file` fall back to real |
| 125 | + `std::fs` for user files; normalizing `\`→`/` there would mangle genuine |
| 126 | + Windows paths (`C:\Users\…`). Normalization is strictly embedded-only. |
| 127 | + (This corrects a research suggestion to also normalize in `RuntimeFs`.) |
| 128 | +- **Shared helper, not a newtype (for now).** grass's `Fs` and |
| 129 | + `SystemRuntime` are hard-typed to `std::path`; a `VirtualPath` newtype |
| 130 | + would ripple through both for a currently-single namespace. Promote to a |
| 131 | + newtype (rust-analyzer `VfsPath` style) only if the virtual surface grows. |
| 132 | +- **Document the two domains** at the top of `resources.rs` so future |
| 133 | + embedded providers route through `to_forward_slashes` instead of growing |
| 134 | + their own string-trimming. |
| 135 | + |
| 136 | +## Test plan (TDD) |
| 137 | + |
| 138 | +1. **RED (in-source unit test, runs on Linux CI):** feed a backslash-joined |
| 139 | + virtual path (as grass produces on Windows) to `EmbeddedResources::is_file` |
| 140 | + / `read` and assert it resolves. Build the `\` path inline so the test |
| 141 | + exercises the corruption on any host. Confirm it fails before the fix. |
| 142 | +2. **GREEN:** add `quarto-util` as a `quarto-sass` dependency and call |
| 143 | + `quarto_util::path::to_forward_slashes` at the top of `strip_prefix` and |
| 144 | + in `collect_files`/`collect_directories`. Do **not** add a new |
| 145 | + normalization function. |
| 146 | +3. **Regression:** full `cargo nextest run -p quarto-sass` — the 30 |
| 147 | + `@import "vendor/rfs"` failures (uncovered once the parse_layer CRLF bug |
| 148 | + `bd-3fgnmlco` was fixed) must go to zero. |
| 149 | + |
| 150 | +## Deferred work (own strands) |
| 151 | + |
| 152 | +- **Unify embedded pools into the WASM VFS** / drop the dead `sass-utils` |
| 153 | + load path, so native and WASM resolve the same cross-pool imports. |
| 154 | +- **`windows-latest` CI job** running `compile_scss_with_embedded` against |
| 155 | + Bootstrap — CI is Unix-only today, so this bug cannot regress-guard on CI. |
| 156 | + The in-source backslash test covers the logic; real-Windows E2E coverage |
| 157 | + needs the job. |
| 158 | + |
| 159 | +## Decisions (2026-07-03, with Chris) |
| 160 | + |
| 161 | +- Abstraction: reuse the existing `quarto_util::to_forward_slashes` (add |
| 162 | + `quarto-util` dep to `quarto-sass`); no new function; newtype deferred. |
| 163 | + Consolidate the two private dupes onto it opportunistically. |
| 164 | +- Placement: `EmbeddedResources` boundary only; `RuntimeFs` untouched. |
| 165 | +- WASM pool divergence: documented + deferred to a strand. |
| 166 | +- Windows CI job: deferred to a strand. |
0 commit comments