Skip to content

Commit f5a08b8

Browse files
Add built-in extensions infrastructure with lipsum as first built-in
Built-in extensions ship with the binary and are discovered before user extensions, matching TS Quarto's src/resources/extensions/ pattern. User extensions with the same name override built-ins (last-match-wins). Key changes: - Copy lipsum extension verbatim from TS Quarto to resources/extensions/ - Add ResourceBundle in extension/mod.rs (cfg-gated for native) - Modify discover_extensions() to accept builtin_extensions_dir param - Change find_extension() from .find() to .rfind() for user-wins semantics - Pass org name explicitly from scanner to read_extension_with_org(), fixing org detection for non-_extensions directory roots - Make ResourceBundle::path() non-panicking (store Result in OnceLock) - WASM: embed extensions via include_dir! in wasm-quarto-hub-client, populate VFS under /__quarto_resources__/extensions/ - WASM: builtin_extensions_path() falls back to VFS path - Smoke tests: builtin-lipsum-shortcode tests built-in discovery, lipsum-override tests user extension priority with Greek output
1 parent 72d5d7a commit f5a08b8

17 files changed

Lines changed: 657 additions & 40 deletions

File tree

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
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

Comments
 (0)