diff --git a/claude-notes/plans/2026-05-10-thread-user-grammars-renderer.md b/claude-notes/plans/2026-05-10-thread-user-grammars-renderer.md
new file mode 100644
index 000000000..b248289b0
--- /dev/null
+++ b/claude-notes/plans/2026-05-10-thread-user-grammars-renderer.md
@@ -0,0 +1,322 @@
+# Phase 9 follow-up: thread user_grammars through RenderToHtmlRenderer (bd-izfv)
+
+**Date:** 2026-05-10
+**Beads:** bd-izfv (P3, open → set to in_progress on `main` once user agrees)
+**Worktree:** `.worktrees/bd-izfv-thread-user-grammars` (branch `beads/bd-izfv-thread-user-grammars`, based on `main` @ `0fa2655d`)
+**Status:** Design questions answered (2026-05-10) — implementation not yet started. **Wait for user go-ahead before starting Phase 0.**
+
+## Triage verdict
+
+**Ready to design.** Bug is reproducible at HEAD via the e2e suite
+(playwright `highlighting/03-user-grammar-toml.qmd [html]` — observed
+`name = "example" count = 42` instead of the expected
+`
…hl-string…hl-number…hl-operator`). +The fix site is unambiguous (the explicit `let _ = user_grammars;` at +`crates/wasm-quarto-hub-client/src/lib.rs:1443` and the +`RenderContext` construction at +`crates/quarto-core/src/project/pass2_renderer.rs:336`). The only +non-trivial decision is the lifetime model for the provider when a +single `RenderToHtmlRenderer` is used across multiple pages — see +design questions below. + +## Issue context + +bd-izfv was filed by Carlos on 2026-04-29 as a P3 follow-up at +Phase-9 close-out (bd-ayj6). Description verbatim: + +> Today render_page_in_project's project-rendering branch drops +> user_grammars on the floor — the RenderToHtmlRenderer constructs +> its own per-page RenderContext and there's no path to attach +> user_grammars there. Single-file projects still wire user_grammars +> correctly via render_single_doc_to_response. +> +> Threading user grammars through the renderer is straightforward: +> add a field to RenderToHtmlRenderer holding an +> Arc
name = "example"
+count = 42
+```
+
+All four `ensureFileRegexMatches` patterns (`"example"`,
+`hl-number">42`, `hl-operator">=`) match.
+
+Two test-harness gaps surfaced during this phase, fixed in the
+same branch:
+
+- `smoke-all.spec.ts` previously read every fixture file via
+ `readFileSync(path, 'utf-8')` and forwarded it with
+ `contentType: 'text' as const`, which corrupts binary grammar
+ `.wasm` payloads before they reach Automerge → VFS. Discovery
+ (`smokeAllDiscovery.ts`) now detects binary extensions
+ (`.wasm`, common image and font types), reads them as bytes,
+ encodes base64, and tags `contentType: 'binary'` with a
+ matching `mimeType`. `smoke-all.spec.ts` forwards both fields
+ to `createProjectOnServer`. Output was inspected via the
+ iframe innerHTML before the fix (no `sourceCode toml` class,
+ no `hl-*` spans) and after the fix (the snippet above).
+- `previewExtraction.ts::getPreviewHtml` did a re-render via
+ `renderToHtml({ documentPath })` without forwarding a
+ `userGrammars` context, so user-grammar fixtures got
+ unhighlighted output from the helper even when the live
+ preview rendered correctly. The helper now derives a
+ `userGrammars` context from VFS state — stripping the
+ `/project/` prefix `vfs_list_files` returns to match
+ `discoverUserGrammars`'s project-relative shape, and wiring
+ binary/text resolvers back through `vfsReadBinaryFile` /
+ `vfsReadFile`.
+
+## Risks / tradeoffs
+
+- **Public API ripple.** Changing the type of
+ `RenderContext::user_grammar_provider` from `Option>` to
+ `Option>>` is a breaking change for any out-of-tree
+ consumer of `RenderContext`. There are none today, but the field
+ is `pub`. The compiler will catch every internal call site at
+ rename time.
+- **No CI coverage for `--e2e`.** Even after the fix, the e2e
+ fixture itself won't be caught by default CI — the Phase-0 Rust
+ unit test is what guards against regression. Worth a separate
+ follow-up beads to either gate a smoke subset of `--e2e` on PR
+ builds, or to expand the Rust integration tests further. Out of
+ scope for bd-izfv proper.
+- **`Rc>` borrow discipline.** The walker that calls
+ `provider.contains(...)` / `provider.highlight(...)` must not
+ hold a borrow across reentrant calls. The trait's `&mut self` on
+ `highlight` already pushes implementations toward short-lived
+ borrows, but watch for any code that grabs the provider once and
+ iterates — convert to per-call borrow there.
diff --git a/claude-notes/plans/thread-user-grammars-renderer-investigation/repro-and-codepath.md b/claude-notes/plans/thread-user-grammars-renderer-investigation/repro-and-codepath.md
new file mode 100644
index 000000000..64cbf40ee
--- /dev/null
+++ b/claude-notes/plans/thread-user-grammars-renderer-investigation/repro-and-codepath.md
@@ -0,0 +1,112 @@
+# bd-izfv investigation — repro + code path notes
+
+## Symptom (reproducible at HEAD)
+
+Smoke-all e2e fixture
+`crates/quarto/tests/smoke-all/highlighting/03-user-grammar/03-user-grammar-toml.qmd`
+fails its `ensureFileRegexMatches` assertions when rendered through the
+hub-client preview (Playwright `cargo xtask verify --e2e`).
+
+Expected (frontmatter):
+- `"example"`
+- `hl-number">42`
+- `hl-operator">=`
+
+Observed (from `hub-client/playwright-report/data/063e6...md`, the
+error-context page snapshot for the failing run):
+
+```
+- code [ref=f1e25]: name = "example" count = 42
+```
+
+A bare `` block — no `` wrapper, no
+`hl-*` spans. The user-supplied tree-sitter grammar at
+`_quarto/grammars/toml/` was never invoked.
+
+## Why it's the project-render branch specifically
+
+The fixture has `_quarto.yml` next to it, so on the hub-client side
+`renderPageInProject(path, grammarsHandle)` (hub-client/src/services/wasmRenderer.ts:944)
+takes the project branch — Pass-1 builds the index, Pass-2 runs
+`RenderToHtmlRenderer::render`.
+
+Single-file fall-through (no `_quarto.yml`) goes through
+`render_single_doc_to_response` (crates/wasm-quarto-hub-client/src/lib.rs:1341)
+which threads the provider correctly:
+
+```rust
+let mut ctx = RenderContext::new(project, &doc, &format, &binaries).with_options(options);
+if let Some(provider) = user_grammars {
+ ctx.user_grammar_provider = Some(Box::new(provider));
+}
+```
+
+The project branch is `render_project_active_page_to_response`
+(crates/wasm-quarto-hub-client/src/lib.rs:1416) and contains the
+explicit drop:
+
+```rust
+// Note: `user_grammars` is currently dropped on the orchestrator
+// path because the renderer constructs its own RenderContext
+// per page. Threading user grammars through the renderer is a
+// sub-phase 9.4 follow-up — file as bd-XXXX on close-out.
+let _ = user_grammars;
+```
+
+(That "file as bd-XXXX" was filed as bd-izfv.)
+
+## Where the wiring needs to happen
+
+`RenderToHtmlRenderer::render` constructs the per-page `RenderContext`
+itself (crates/quarto-core/src/project/pass2_renderer.rs:336):
+
+```rust
+let mut ctx = RenderContext::new(project, doc_info, format, &binaries).with_options(options);
+ctx.project_index = Some(index);
+ctx.resource_resolver = Some(resolver.clone());
+```
+
+The provider would have to be installed here — analogous to the
+single-doc branch's `ctx.user_grammar_provider = Some(Box::new(provider))`.
+
+## Lifetime constraints
+
+`RenderContext::user_grammar_provider` is typed
+`Option>`
+(crates/quarto-core/src/render.rs:164). The trait's `highlight`
+takes `&mut self` (crates/quarto-highlight/src/provider.rs:49), and
+the WASM impl `JsUserGrammars` holds a `HashMap`
+(crates/wasm-quarto-hub-client/src/lib.rs:188).
+
+`js_sys::Function` is `Clone` (it's a JS handle), so deriving `Clone`
+on `JsUserGrammars` is mechanical. **However**, the trait object
+(`Box`) is not `Clone` and the field can't
+ergonomically support multi-page renders without one of:
+
+- (A) **Single-render consume.** Hub-client uses `RenderMode::ActivePage`
+ which renders exactly one page per call. Store
+ `Option>` on `RenderToHtmlRenderer` and
+ `take()` it on the first/only `render()` call. Simple, but if a
+ future caller drives `RenderToHtmlRenderer` over multi-page modes,
+ pages 2..N silently lose user grammars.
+- (B) **Shared interior-mutable provider.** Change the field on
+ `RenderContext` to `Option>>`,
+ and adjust the existing call sites. Works for any RenderMode and
+ matches the WASM single-thread model. Costs a small public-API
+ ripple on `RenderContext` (and on every test that constructs one).
+- (C) **`Arc>`.** Same as B but `Send`/`Sync`. The pipeline
+ is `?Send` already; this would over-restrict.
+
+The bd-izfv description hints at (B): *"Arc>>
+(or similar)"* — though `Mutex` is wrong for the WASM single-thread
+case, `Arc>` would be a closer fit if we want sharability
+on wasm32. `Rc>` is also a candidate.
+
+## What the smoke-all CLI does (already passes)
+
+The native render path of the same fixture (`cargo xtask verify`,
+no `--e2e`) passes — see `crates/quarto-highlight/tests/user_grammar_toml.rs`
+and the smoke-all native runner. So the bug is strictly:
+"Phase-9 hub-client project-render path doesn't thread user grammars
+into Pass-2's per-page RenderContext."
diff --git a/crates/quarto-core/src/pipeline.rs b/crates/quarto-core/src/pipeline.rs
index d4ccee9a3..84e1f860b 100644
--- a/crates/quarto-core/src/pipeline.rs
+++ b/crates/quarto-core/src/pipeline.rs
@@ -527,7 +527,11 @@ pub async fn run_pipeline(
stage_ctx.artifacts = std::mem::take(&mut ctx.artifacts);
// Transfer user-grammar provider (browser path sets this; native CLI
// leaves it None and falls back to `CodeHighlightStage`'s disk scan).
- stage_ctx.user_grammar_provider = ctx.user_grammar_provider.take();
+ // Cloning the `Rc` is cheap and keeps the provider shared across
+ // every page the renderer touches (bd-izfv: the project-render path
+ // calls `run_pipeline` once per page through a single
+ // `RenderToHtmlRenderer`).
+ stage_ctx.user_grammar_provider = ctx.user_grammar_provider.clone();
// Transfer the project index (set by ProjectPipeline::pass_two).
// Cloning the `Arc` is cheap and keeps the RenderContext usable
// after the stage context is built.
diff --git a/crates/quarto-core/src/project/pass2_renderer.rs b/crates/quarto-core/src/project/pass2_renderer.rs
index 050e96352..24ab37cd2 100644
--- a/crates/quarto-core/src/project/pass2_renderer.rs
+++ b/crates/quarto-core/src/project/pass2_renderer.rs
@@ -33,7 +33,9 @@
//!
//! [`ProjectPipeline`]: crate::project::orchestrator::ProjectPipeline
+use std::cell::RefCell;
use std::path::Path;
+use std::rc::Rc;
use std::sync::Arc;
use async_trait::async_trait;
@@ -281,6 +283,14 @@ pub struct RenderToHtmlRenderer {
/// project scope alike) lives in WASM. `RenderResolverContext::vfs_root`
/// will be constructed with this root.
vfs_root: std::path::PathBuf,
+
+ /// Optional user-grammar provider attached by the caller. Shared
+ /// across every page the renderer touches (one
+ /// `RenderToHtmlRenderer` may produce many pages in `ActivePage`
+ /// mode plus future multi-page modes). The pipeline is `?Send`
+ /// so `Rc>` is correct on both wasm32 and on the
+ /// native single-task executor used by tests. (bd-izfv)
+ user_grammars: Option>>,
}
impl RenderToHtmlRenderer {
@@ -289,8 +299,21 @@ impl RenderToHtmlRenderer {
pub fn new(vfs_root: impl Into) -> Self {
Self {
vfs_root: vfs_root.into(),
+ user_grammars: None,
}
}
+
+ /// Attach a user-grammar provider. The renderer installs it on
+ /// every per-page [`crate::render::RenderContext`] before
+ /// running the pipeline, so `CodeHighlightStage` consults it
+ /// in preference to the native disk loader. (bd-izfv)
+ pub fn with_user_grammars(
+ mut self,
+ provider: Rc>,
+ ) -> Self {
+ self.user_grammars = Some(provider);
+ self
+ }
}
#[async_trait(?Send)]
@@ -337,6 +360,11 @@ impl Pass2Renderer for RenderToHtmlRenderer {
RenderContext::new(project, doc_info, format, &binaries).with_options(options);
ctx.project_index = Some(index);
ctx.resource_resolver = Some(resolver.clone());
+ // bd-izfv: forward the renderer-attached user-grammar provider
+ // (if any) to the per-page context. `run_pipeline` clones the
+ // `Rc` into the inner `StageContext`, so the same handle is
+ // shared across every page this renderer renders.
+ ctx.user_grammar_provider = self.user_grammars.clone();
let config = HtmlRenderConfig::with_resolver(resolver.clone());
let source_name = doc_info.input.to_string_lossy().to_string();
diff --git a/crates/quarto-core/src/render.rs b/crates/quarto-core/src/render.rs
index 42042861e..1bf79d273 100644
--- a/crates/quarto-core/src/render.rs
+++ b/crates/quarto-core/src/render.rs
@@ -12,7 +12,9 @@
//! - Transforms can access project configuration and format settings
//! - Writers use the context to determine output paths
+use std::cell::RefCell;
use std::path::PathBuf;
+use std::rc::Rc;
use std::sync::Arc;
use quarto_analysis::AnalysisContext;
@@ -161,7 +163,7 @@ pub struct RenderContext<'a> {
/// browser hub-client sets this to a `JsUserGrammars` (Phase 4.3 of
/// the syntax-highlighting plan) so JS-backed user grammars flow
/// through the same `CodeHighlightStage` code path.
- pub user_grammar_provider: Option>,
+ pub user_grammar_provider: Option>>,
/// Per-document resource report (`bd-o8pr`). Mirrors
/// [`crate::stage::StageContext::resource_report`]; `run_pipeline`
diff --git a/crates/quarto-core/src/stage/context.rs b/crates/quarto-core/src/stage/context.rs
index 7408f7024..495a15f0d 100644
--- a/crates/quarto-core/src/stage/context.rs
+++ b/crates/quarto-core/src/stage/context.rs
@@ -16,7 +16,9 @@
//! - Supports potential task spawning for parallelization
//! - Allows stages to clone what they need without lifetime constraints
+use std::cell::RefCell;
use std::path::PathBuf;
+use std::rc::Rc;
use std::sync::Arc;
use quarto_error_reporting::DiagnosticMessage;
@@ -148,7 +150,7 @@ pub struct StageContext {
/// - **Browser (hub-client)**: set to a `JsUserGrammars` handle that
/// forwards highlight requests to JS callbacks backed by
/// `web-tree-sitter`. See `crates/wasm-quarto-hub-client/src/lib.rs`.
- pub user_grammar_provider: Option>,
+ pub user_grammar_provider: Option>>,
}
impl StageContext {
diff --git a/crates/quarto-core/src/stage/stages/code_highlight.rs b/crates/quarto-core/src/stage/stages/code_highlight.rs
index 74db34cf6..942d0e046 100644
--- a/crates/quarto-core/src/stage/stages/code_highlight.rs
+++ b/crates/quarto-core/src/stage/stages/code_highlight.rs
@@ -78,17 +78,15 @@ impl PipelineStage for CodeHighlightStage {
// over the native disk-loader path. This lets both targets share
// one code path without cfg-gating at the call site.
//
- // Manual `&mut **b` rather than `as_deref_mut()` because the
- // latter preserves the trait-object's implicit `'static` bound,
- // which then fights the async-trait `'static` future-lifetime
- // check. `&mut **b` produces a fresh borrow with the natural
- // (method-body) lifetime.
- let result = if ctx.user_grammar_provider.is_some() {
- let user = ctx
- .user_grammar_provider
- .as_mut()
- .map(|b| &mut **b as &mut dyn quarto_highlight::UserGrammarProvider);
- quarto_highlight::annotate_pandoc(&mut doc.ast, user)
+ // The provider lives in an `Rc>` (bd-izfv) so a single
+ // handle can be shared across every page a renderer touches. We
+ // hold a `RefMut` only for the duration of the `annotate_pandoc`
+ // call; the trait's `&mut self` methods on `highlight` don't
+ // re-enter the provider, so the borrow is straight-line.
+ let result = if let Some(rc) = ctx.user_grammar_provider.as_ref() {
+ let mut guard = rc.borrow_mut();
+ let user_ref = Some(&mut *guard as &mut dyn quarto_highlight::UserGrammarProvider);
+ quarto_highlight::annotate_pandoc(&mut doc.ast, user_ref)
} else {
#[cfg(not(target_arch = "wasm32"))]
{
diff --git a/crates/quarto-core/tests/render_to_html_user_grammars.rs b/crates/quarto-core/tests/render_to_html_user_grammars.rs
new file mode 100644
index 000000000..b93fe6135
--- /dev/null
+++ b/crates/quarto-core/tests/render_to_html_user_grammars.rs
@@ -0,0 +1,159 @@
+/*
+ * tests/render_to_html_user_grammars.rs
+ * Copyright (c) 2026 Posit, PBC
+ *
+ * bd-izfv: regression test that `RenderToHtmlRenderer` forwards a
+ * caller-supplied user-grammar provider to the per-page
+ * `RenderContext`. Without this wiring the orchestrator's project-
+ * render path silently drops grammars and any qmd that lives under
+ * a `_quarto.yml` ancestor renders code blocks unhighlighted.
+ *
+ * The test installs a stub provider that emits a known `data-hl-spans`
+ * triple-array for one custom class, drives `RenderToHtmlRenderer`
+ * via `ProjectPipeline<…>::with_renderer` exactly like the WASM
+ * entry point does, and asserts the rendered HTML carries the
+ * matching `` wrapper. The pampa HTML writer
+ * turns `data-hl-spans` into the nested span markup (see
+ * `crates/pampa/src/writers/html.rs`).
+ */
+
+use std::cell::RefCell;
+use std::path::{Path, PathBuf};
+use std::rc::Rc;
+use std::sync::Arc;
+
+use tempfile::TempDir;
+
+use quarto_core::format::Format;
+use quarto_core::project::ProjectContext;
+use quarto_core::project::orchestrator::{ProjectPipeline, RenderMode, project_type_for};
+use quarto_core::project::pass2_renderer::{RenderToHtmlRenderer, WasmPassTwoOutput};
+use quarto_highlight::{HighlightError, UserGrammarProvider};
+use quarto_system_runtime::{NativeRuntime, SystemRuntime};
+
+/// Stub provider that owns one class and emits a fixed triple-array
+/// JSON covering the entire source. Used to verify the wiring without
+/// pulling in real tree-sitter grammars.
+struct StubProvider {
+ class: String,
+ capture: String,
+}
+
+impl UserGrammarProvider for StubProvider {
+ fn contains(&self, class: &str) -> bool {
+ class == self.class
+ }
+
+ fn highlight(&mut self, _class: &str, source: &str) -> Result