|
| 1 | +# Plan: Built-in Extensions Infrastructure |
| 2 | + |
| 3 | +## Status: Complete (Phases 1-5) |
| 4 | + |
| 5 | +--- |
| 6 | + |
| 7 | +## Overview |
| 8 | + |
| 9 | +Add infrastructure for built-in extensions that ship with the q2 binary, |
| 10 | +matching TS Quarto's `src/resources/extensions/quarto/` pattern. Built-in |
| 11 | +extensions are normal extensions discovered before user extensions, but user |
| 12 | +extensions with the same name override them (last-writer-wins in the |
| 13 | +extensions map). |
| 14 | + |
| 15 | +The first built-in extension is `quarto/lipsum`, copied verbatim from |
| 16 | +TS Quarto. |
| 17 | + |
| 18 | +## Prerequisites (completed) |
| 19 | + |
| 20 | +- **Pandoc-compatible fuzzy type coercion** (`72d5d7af`): `pandoc.Para("text")` |
| 21 | + now auto-coerces plain strings via `peek_inlines_fuzzy`, matching |
| 22 | + pandoc-lua-marshal behavior. This is required because TS Quarto's |
| 23 | + `lipsum.lua` calls `pandoc.Para(paras[outIdx])` with a bare string. |
| 24 | + |
| 25 | +## Codebase Context |
| 26 | + |
| 27 | +### Extension discovery (current) |
| 28 | +- `crates/quarto-core/src/extension/discover.rs` — `discover_extensions()` |
| 29 | + walks from input dir up to project root, scanning `_extensions/` dirs. |
| 30 | + Returns `Vec<Extension>` with project-level first (lower priority), |
| 31 | + subdirectory-level last (higher priority). |
| 32 | +- `scan_extension_entry()` is already generic: it checks for |
| 33 | + `_extension.yml` directly (unorganized), or recurses one level treating |
| 34 | + the entry as an organization dir (org/name pattern). This means built-in |
| 35 | + extension dirs with `quarto/lipsum/_extension.yml` structure will be |
| 36 | + scanned correctly without changes to the scanning logic. |
| 37 | +- `crates/quarto-core/src/stage/context.rs:103` — calls |
| 38 | + `discover_extensions()` and stores result in `StageContext.extensions`. |
| 39 | + |
| 40 | +### Extension lookup callers |
| 41 | +`find_extension()` is called from **3 places** — all must use last-match |
| 42 | +semantics for user-wins override: |
| 43 | +1. `shortcode_resolve.rs:331` — shortcode extension lookup |
| 44 | +2. `filter_resolve.rs:200` — filter extension lookup |
| 45 | +3. `metadata_merge.rs:95` — format extension lookup |
| 46 | + |
| 47 | +### Shortcode resolution priority (current) |
| 48 | +`shortcode_resolve.rs:304`: |
| 49 | +1. Built-in Rust handlers (e.g., `meta`) — highest priority |
| 50 | +2. Already-loaded Lua handlers |
| 51 | +3. Name-based extension lookup (on-demand loading) — lowest priority |
| 52 | + |
| 53 | +### Resource embedding patterns |
| 54 | +- **Native**: `include_dir!` + `ResourceBundle` (lazy extract to temp dir) |
| 55 | +- **WASM**: `EmbeddedResources` populated into VFS at |
| 56 | + `/__quarto_resources__/` prefix, preserved across `vfs.clear()` |
| 57 | + |
| 58 | +### TS Quarto behavior to match |
| 59 | +- Built-in extensions live under org `"quarto"` (`kBuiltInExtOrg`) |
| 60 | +- `builtinExtensions()` returns path to `resources/extensions/` (no |
| 61 | + `_extensions/` wrapper needed) |
| 62 | +- `readExtensions()` is generic: entries without `_extension.yml` are |
| 63 | + treated as org dirs and recursed one level — same as our |
| 64 | + `scan_extension_entry()` |
| 65 | +- `inputExtensionDirs()` returns `[builtinExtensions(), ...user dirs]` |
| 66 | +- `loadExtensions()` iterates in order, later entries overwrite earlier |
| 67 | + → **user extensions override built-ins with same ID** |
| 68 | +- Built-in shortcode handlers (meta, env, etc.) are separate and always |
| 69 | + override — they are NOT extensions |
| 70 | + |
| 71 | +### Key files |
| 72 | +| File | Role | |
| 73 | +|---|---| |
| 74 | +| `crates/quarto-core/src/extension/discover.rs` | Extension discovery | |
| 75 | +| `crates/quarto-core/src/stage/context.rs` | Pipeline wiring | |
| 76 | +| `crates/quarto-core/src/transforms/shortcode_resolve.rs` | Shortcode resolution | |
| 77 | +| `crates/quarto-core/src/filter_resolve.rs` | Filter extension lookup | |
| 78 | +| `crates/quarto-core/src/stage/stages/metadata_merge.rs` | Format extension lookup | |
| 79 | +| `crates/quarto-core/src/resources.rs` | `ResourceBundle` for native | |
| 80 | +| `crates/quarto-sass/src/resources.rs` | `RESOURCE_PATH_PREFIX` = `/__quarto_resources__` | |
| 81 | +| `crates/wasm-quarto-hub-client/src/lib.rs` | WASM init, VFS population | |
| 82 | +| `crates/quarto-system-runtime/src/wasm.rs` | VFS, `clear_preserving_prefix` | |
| 83 | + |
| 84 | +--- |
| 85 | + |
| 86 | +## Work Items |
| 87 | + |
| 88 | +### Phase 1: Add `resources/extensions/` directory with lipsum |
| 89 | + |
| 90 | +- [x] **1.1** Create `resources/extensions/quarto/lipsum/` with files copied |
| 91 | + verbatim from TS Quarto (`~/src/quarto-cli/src/resources/extensions/quarto/lipsum/`): |
| 92 | + - `_extension.yml` — use TS Quarto's version (title: Lipsum, author: Charles Teague, version: 1.0.2) |
| 93 | + - `lipsum.lua` — TS Quarto's version (uses `pandoc.Para(paras[outIdx])`, |
| 94 | + NOT the q2 test fixture's `pandoc.Para({pandoc.Str(paras[outIdx])})`) |
| 95 | + - `lipsum.json` — full 17-paragraph version from TS Quarto |
| 96 | + |
| 97 | + TS Quarto's `lipsum.lua` calls `pandoc.Para(paras[outIdx])` with a bare |
| 98 | + string. This works in q2 as of `72d5d7af` (fuzzy type coercion). |
| 99 | + |
| 100 | +### Phase 2: Native — embed and discover built-in extensions |
| 101 | + |
| 102 | +- [x] **2.1** Add `ResourceBundle` in `crates/quarto-core/src/extension/mod.rs` |
| 103 | + (or a new `builtin.rs`): |
| 104 | + ```rust |
| 105 | + use include_dir::{include_dir, Dir}; |
| 106 | + use crate::resources::ResourceBundle; |
| 107 | + |
| 108 | + static BUILTIN_EXTENSIONS_DIR: Dir = |
| 109 | + include_dir!("$CARGO_MANIFEST_DIR/../../resources/extensions"); |
| 110 | + pub static BUILTIN_EXTENSIONS: ResourceBundle = |
| 111 | + ResourceBundle::new("builtin-extensions", &BUILTIN_EXTENSIONS_DIR); |
| 112 | + ``` |
| 113 | + |
| 114 | +- [x] **2.2** Modify `discover_extensions()` signature to accept an optional |
| 115 | + built-in extensions path: |
| 116 | + ```rust |
| 117 | + pub fn discover_extensions( |
| 118 | + input: &Path, |
| 119 | + project_dir: Option<&Path>, |
| 120 | + builtin_extensions_dir: Option<&Path>, |
| 121 | + runtime: &dyn SystemRuntime, |
| 122 | + ) -> Vec<Extension> |
| 123 | + ``` |
| 124 | + When `builtin_extensions_dir` is `Some`, scan it **first** (before user |
| 125 | + dirs). Since user dirs are scanned after, and `find_extension()` currently |
| 126 | + returns the first match, we need to change `find_extension()` to return |
| 127 | + the **last** match instead. This matches TS Quarto's "later overwrites |
| 128 | + earlier" semantics. |
| 129 | + |
| 130 | + No changes needed to `scan_extension_entry()` — it already handles the |
| 131 | + org/name directory structure generically (checks for `_extension.yml`, |
| 132 | + else recurses one level treating the entry as an org dir). |
| 133 | + |
| 134 | +- [x] **2.3** Update `StageContext::new()` in `context.rs` to extract the |
| 135 | + `ResourceBundle` path and pass it to `discover_extensions()`: |
| 136 | + ```rust |
| 137 | + let builtin_ext_path = crate::extension::BUILTIN_EXTENSIONS.path().ok(); |
| 138 | + let extensions = crate::extension::discover_extensions( |
| 139 | + &document.input, |
| 140 | + project_dir, |
| 141 | + builtin_ext_path, |
| 142 | + runtime.as_ref(), |
| 143 | + ); |
| 144 | + ``` |
| 145 | + |
| 146 | +- [x] **2.4** Write unit tests for built-in extension discovery: |
| 147 | + - Test that built-in lipsum is discovered when no user extensions exist |
| 148 | + - Test that a user `_extensions/lipsum/` overrides the built-in |
| 149 | + - Test that a user `_extensions/quarto/lipsum/` (with org) also overrides |
| 150 | + |
| 151 | +### Phase 3: WASM — populate VFS with built-in extensions |
| 152 | + |
| 153 | +- [x] **3.1** In `crates/wasm-quarto-hub-client/src/lib.rs`, add the |
| 154 | + built-in extensions to `populate_vfs_with_embedded_resources()`. |
| 155 | + The extensions need to be embedded in the WASM crate separately (it has |
| 156 | + its own Cargo.toml). Add an `EmbeddedResources` or `include_dir!` for |
| 157 | + the extensions directory, and populate files under |
| 158 | + `/__quarto_resources__/extensions/quarto/lipsum/...`. |
| 159 | + |
| 160 | +- [x] **3.2** Modify `discover_extensions()` in WASM context: pass |
| 161 | + `/__quarto_resources__/extensions` as the `builtin_extensions_dir`. |
| 162 | + The VFS's `dir_list()` and `path_exists()` already work on these paths. |
| 163 | + |
| 164 | +- [x] **3.3** WASM path resolution verification: the lipsum Lua script uses |
| 165 | + `quarto.utils.resolve_path("lipsum.json")` which resolves relative to |
| 166 | + the extension's directory, and `io.open()` which uses the synthetic Lua |
| 167 | + io tables (added in `96635fb2`). Both need to work with VFS paths under |
| 168 | + `/__quarto_resources__/extensions/`. Verify this in the end-to-end test. |
| 169 | + |
| 170 | +- [x] **3.4** Verify the lipsum extension works end-to-end in WASM: |
| 171 | + build WASM, add a test qmd with `{{< lipsum 1 >}}` to the VFS, render, |
| 172 | + and check output contains lorem ipsum text. |
| 173 | + |
| 174 | +### Phase 4: Update lipsum smoke test to use built-in |
| 175 | + |
| 176 | +- [x] **4.1** Remove the local `_extensions/lipsum/` from the lipsum |
| 177 | + smoke test fixture at |
| 178 | + `crates/quarto/tests/smoke-all/extensions/lipsum-shortcode/`. The test |
| 179 | + should now discover lipsum from the built-in extensions. Rename the test |
| 180 | + directory to reflect it's testing built-in extension discovery (e.g., |
| 181 | + `builtin-lipsum-shortcode/`). |
| 182 | + |
| 183 | +- [x] **4.2** Add a new smoke test that has BOTH a local `_extensions/lipsum/` |
| 184 | + AND the built-in, to verify user override behavior. The local extension |
| 185 | + should produce different output (e.g., always return "USER_OVERRIDE") so |
| 186 | + the test can assert which one ran. |
| 187 | + |
| 188 | +### Phase 5: Verify |
| 189 | + |
| 190 | +- [x] **5.1** `cargo nextest run -p quarto-core` — extension discovery tests |
| 191 | +- [x] **5.2** `cargo nextest run -p quarto --test smoke_all` — lipsum smoke tests |
| 192 | +- [x] **5.3** `cargo nextest run --workspace` — no regressions |
| 193 | +- [x] **5.4** `cargo xtask verify` — full verification including WASM build |
| 194 | + |
| 195 | +## Design Notes |
| 196 | + |
| 197 | +### Why `find_extension` should return the last match |
| 198 | + |
| 199 | +Currently `find_extension()` uses `.find()` which returns the first match. |
| 200 | +With built-ins prepended to the vec, that means built-ins would win. TS |
| 201 | +Quarto's `loadExtensions()` uses a map where later entries overwrite |
| 202 | +earlier ones, achieving "user wins". Changing to `.rfind()` (or reversing |
| 203 | +iteration) is the minimal change to match this behavior. |
| 204 | + |
| 205 | +This affects all 3 callers (`shortcode_resolve.rs`, `filter_resolve.rs`, |
| 206 | +`metadata_merge.rs`) — the user-wins semantics is correct for all of them. |
| 207 | + |
| 208 | +### No directory structure mismatch |
| 209 | + |
| 210 | +Our `scan_extension_entry()` already handles both patterns: |
| 211 | +1. Direct extension: entry has `_extension.yml` → load it |
| 212 | +2. Organization dir: entry has no `_extension.yml` → recurse one level |
| 213 | + |
| 214 | +This matches TS Quarto's `readExtensions()` which uses the same two-level |
| 215 | +scan. The built-in `resources/extensions/quarto/lipsum/_extension.yml` |
| 216 | +structure will be scanned correctly: `quarto/` has no `_extension.yml` so |
| 217 | +it's treated as an org dir, then `lipsum/` has `_extension.yml` and is |
| 218 | +loaded with `organization: "quarto"`. |
| 219 | + |
| 220 | +### WASM crate has separate dependencies |
| 221 | + |
| 222 | +`crates/wasm-quarto-hub-client/` is excluded from the workspace and has |
| 223 | +its own `Cargo.toml`. If we use `include_dir!` there, it needs its own |
| 224 | +dependency on the `include_dir` crate, and the path must be relative to |
| 225 | +that crate's `CARGO_MANIFEST_DIR`. |
| 226 | + |
| 227 | +### WASM path resolution for extension resources |
| 228 | + |
| 229 | +In WASM, `quarto.utils.resolve_path("lipsum.json")` resolves relative to |
| 230 | +the extension's directory path. For built-in extensions, this path will be |
| 231 | +under `/__quarto_resources__/extensions/quarto/lipsum/`. The synthetic |
| 232 | +`io.open()` (added in `96635fb2`) reads from the VFS. Both should work |
| 233 | +since the extension's `path` field will point to the VFS location, but |
| 234 | +this is explicitly verified in Phase 3.3. |
| 235 | + |
| 236 | +### No changes to hub-client discovery.rs |
| 237 | + |
| 238 | +The hub project discovery (`crates/quarto-hub/src/discovery.rs`) only |
| 239 | +handles file syncing for collaborative editing. Built-in extensions don't |
| 240 | +need to be synced to collaborators — they're embedded in the binary on |
| 241 | +both sides. No changes needed there. |
| 242 | + |
| 243 | +## Files Touched |
| 244 | + |
| 245 | +| File | Change | |
| 246 | +|---|---| |
| 247 | +| `resources/extensions/quarto/lipsum/` | New: verbatim copy from TS Quarto | |
| 248 | +| `crates/quarto-core/src/extension/discover.rs` | Add `builtin_extensions_dir` param, change `find_extension` to last-match | |
| 249 | +| `crates/quarto-core/src/extension/mod.rs` (or `builtin.rs`) | New: `ResourceBundle` for built-in extensions | |
| 250 | +| `crates/quarto-core/src/stage/context.rs` | Pass built-in path to discovery | |
| 251 | +| `crates/wasm-quarto-hub-client/src/lib.rs` | Populate VFS with built-in extensions | |
| 252 | +| `crates/quarto/tests/smoke-all/extensions/lipsum-shortcode/` | Remove local extension, test built-in | |
| 253 | +| New smoke test fixture | Test user-override of built-in extension | |
0 commit comments