diff --git a/claude-notes/plans/2026-07-29-pre-post-render-scripts.md b/claude-notes/plans/2026-07-29-pre-post-render-scripts.md new file mode 100644 index 000000000..0b96e9847 --- /dev/null +++ b/claude-notes/plans/2026-07-29-pre-post-render-scripts.md @@ -0,0 +1,414 @@ +# Pre- and post-render project scripts (bd-w348iu63) + +**Status: implemented 2026-07-31 (all phases complete, full verify +green) — awaiting commit approval.** + +Port Quarto 1's `project.pre-render` / `project.post-render` script support to +Quarto 2. Strand: `bd-w348iu63`. + +## Overview + +Quarto 1 lets a project declare scripts that run before and after a project +render: + +```yaml +project: + type: website + pre-render: prepare.py # string or list + post-render: + - cleanup.R + - tools/notify.sh +``` + +Scripts receive a `QUARTO_PROJECT_*` environment-variable contract, run with +the project root as cwd, and can (pre-render only) mutate the project — add +input files, edit `_quarto.yml` — with the project re-read afterward. + +### Correction to the session premise + +Q1's feature is **not** restricted to website projects. `pre-render` / +`post-render` live in the generic `project` schema +(`src/resources/schema/project.yml:44-51` in quarto-cli) and +`renderProject` runs them for every project type with zero type branching. +So "lift the website restriction" is already the Q1 baseline; the port keeps +that. The genuinely new design work in Q2 is: where the hooks fit in a +pipeline whose file set is fixed at discovery time, what the interpreter +dispatch looks like without a bundled Deno, and what preview/WASM do. + +## Q1 behavior assessment (what we're porting) + +Full investigation notes: see the session that produced this plan. Summary of +the contract, with file references into `external-sources/quarto-cli`: + +### Configuration + +- `project.pre-render`, `project.post-render`: `maybeArrayOf: string` + (string is normalized to a one-element list at read time, + `src/project/project-context.ts:280-290`). +- Scripts can also arrive via included metadata files and via extensions of + type `metadata` (paths resolved relative to the extension dir, + `src/extension/extension.ts:920-940`). Arrays concatenate on merge. + +### Execution point and frequency + +All in `src/command/render/project.ts` (`renderProject`): + +- Pre-render scripts run near the top of `renderProject` (`:309-368`), before + any file renders. Afterward Q1 **re-reads the whole project context** + (`:341-346`) — `_quarto.yml`, metadata includes, re-globbed input list — + and **recomputes the render list** (`:359-367`), so files created by a + pre-render script get rendered in the same pass and show up in navigation. +- A mutation guard then forbids three changes relative to the pre-script + config (`:84-107`, `:348-357`): `project.type`, `project.output-dir`, and + the project dir itself. Violation aborts the render. +- Post-render scripts run at the very end (`:831-861`), after the + project-type's internal `postRender` hook, with the list of produced + output files. +- **Scripts run once per `renderProject` invocation — including incremental + ones.** `quarto render single-file.qmd` inside a project still runs both + script sets (comments in Q1 claiming incremental gating are stale). The + scripts distinguish full vs partial renders via `QUARTO_PROJECT_RENDER_ALL`, + which is set to `"1"` only when the whole project is being rendered. +- A single-file render *outside* any project never runs scripts. + +### Environment contract + +Env is merged into (not replacing) the parent environment; cwd is the +project root. Both pre and post get: + +| Var | Value | +|---|---| +| `QUARTO_PROJECT_DIR` | absolute project dir (set process-wide in Q1) | +| `QUARTO_PROJECT_OUTPUT_DIR` | absolute output dir (= project dir if no `output-dir`) | +| `QUARTO_PROJECT_RENDER_ALL` | `"1"` iff rendering all inputs, else **absent** | +| `QUARTO_PROJECT_SCRIPT_PROGRESS` | `"1"`/`"0"` (multi-file render and not quiet) | +| `QUARTO_PROJECT_SCRIPT_QUIET` | `"1"`/`"0"` (from `--quiet`) | + +Pre-render only: `QUARTO_PROJECT_INPUT_FILES` — newline-separated paths of +the files about to render, relative to the project dir. +Post-render only: `QUARTO_PROJECT_OUTPUT_FILES` — newline-separated output +paths relative to the project dir (e.g. `_site/index.html`). + +Escape hatch for env-size limits (Q1 issue #10828): if the user sets +`QUARTO_USE_FILE_FOR_PROJECT_INPUT_FILES=` (resp. `..._OUTPUT_FILES`), +the list is written to that file instead of the env var. + +Known Q1 wart: the shared env is computed once before pre-render and reused +for post-render, so `RENDER_ALL` can be stale if a pre-render script changed +the input set. We should compute the post-render env fresh. + +### Interpreter dispatch (Q1) + +Each entry is parsed shell-ish (split on spaces, double quotes honored), so +`pre-render: python3 tools/gen.py --flag` works. Dispatch is **by extension +of the first token**: + +- `.ts`/`.js` → bundled **Deno** (`--allow-all`, import map for deno_std) +- `.py` → discovered Python (`py`/jupyter python/`python3`) +- `.r`/`.R` → `Rscript` (honors `QUARTO_R`) +- `.lua` → run as a **pandoc Lua filter** (`pandoc --from markdown --to plain --lua-filter script`) +- anything else → direct `exec` of the token, no shell wrapper (a `.sh` + needs an executable bit + shebang) + +### Failure handling (Q1 — bad, don't copy) + +Non-zero exit throws an *empty-message* `Error`; the user sees only the +script's own stderr. Remaining scripts don't run; pre-render failure aborts +before rendering; preview catches the error and keeps serving. + +### Preview (Q1) + +Project preview runs both script sets on the initial render **and again on +every file-change re-render** (no incremental guard) — a known perf/behavior +surprise, combined with the mandatory project re-read. + +## Q2 landscape (where this lands) + +- `q2 render` CLI: `crates/quarto/src/commands/render.rs` — + `execute_single_doc` (`:688`) / `execute_project` (`:748`); both do + `ProjectContext::discover()` → `ProjectPipeline::new()` → `run()`. +- Orchestrator: `crates/quarto-core/src/project/orchestrator.rs` — + `ProjectPipeline::run()` (`:861`) runs pass 1, then the *internal* + `ProjectType::pre_render` hook (`:884`), pass 2, internal `post_render` + (`:916-927`). **Note**: these internal Rust hooks are unrelated to user + scripts and must stay unrelated (website uses `post_render` for + sitemap/favicon). +- Config: `crates/quarto-core/src/project/mod.rs` — `ProjectConfig` + (`:313`), `parse_config` (`:607-681`). No schema/validation layer exists; + unknown keys are ignored silently. +- Subprocess infra: `SystemRuntime::exec_command` + (`crates/quarto-system-runtime/src/traits.rs:353-371`) exists but takes + **no cwd and no env** — insufficient as-is. Engines (knitr, jupyter) + instead use raw `std::process::Command` in native-gated modules; the + closest analogue to "run a user script portably" is + `crates/pampa/src/json_filter.rs` (incl. a Windows shebang workaround). +- **Q2 currently exports zero `QUARTO_*` env vars to subprocesses** — the + whole `QUARTO_PROJECT_*` contract is net-new. +- Preview: `q2 preview` does not run `ProjectPipeline` natively; the browser + WASM renders per-active-page, the native side records engine captures + (`crates/quarto-preview/src/lib.rs`, hooks at `:208` on-ready and `:246` + on-file-changed). Subprocesses are only possible on the native side. +- Prior art in plans: `claude-notes/plans/2026-03-16-extensions-grand-plan.md` + Phase 7 lists pre/post-render scripts as future extension-contributed + work. No existing braid strand covers user scripts. +- Naming collision to keep out of docs/errors: `"pre-render"`/`"post-render"` + are also **filter entry-point sentinels** in + `crates/quarto-core/src/filter_resolve.rs:31-44`. + +## Design + +### D1. Hook placement: around the pipeline, in a shared helper + +The key structural mismatch: Q2 fixes the project file set at +`ProjectContext::discover()` time, and `ProjectPipeline::run()` runs pass 1 +before any hook fires. Pre-render scripts must be able to create input +files. So the scripts cannot run inside the pipeline as it stands. + +**Decision**: run the scripts in the *drivers*, bracketing discovery and the +pipeline, via a shared native-only module `quarto-core::project::render_scripts`: + +``` +execute_project / execute_single_doc (render.rs), publish.rs: + 1. locate _quarto.yml, parse config (existing find_project_config/parse_config) + 2. run pre-render scripts (new) + 3. ProjectContext::discover() (existing — sees script-created files) + 4. validate: type/output-dir unchanged (new, Q1-compatible guard) + 5. ProjectPipeline::run() (existing, untouched) + 6. run post-render scripts (new, env computed fresh from step 5 outputs) +``` + +Because scripts run **before** the full discovery, we get Q1's +"re-read the project after pre-render" semantics for free — there is only +one authoritative read. Step 4 re-checks the two Q1-forbidden mutations +(`project.type`, `project.output-dir`) by comparing the step-1 parse with a +re-parse at step 3, with a proper diagnostic (Q1's error here has a typo and +its general script-failure error is empty — we do better). + +The internal `ProjectType::pre_render`/`post_render` Rust hooks are not +touched, and `ProjectPipeline::run()` itself is not modified. This keeps the +WASM pipeline path completely unaffected. + +Cost of this placement: each native driver (render, publish, later preview) +wires the calls explicitly. That's two call sites today and is the honest +shape — script execution is a native, filesystem-level concern, same tier as +`NativeRuntime::with_cache_dir` wiring that already lives in the drivers. + +Wrinkle: `QUARTO_PROJECT_INPUT_FILES` must describe the render set *after* +pre-render mutation in Q1 — actually no: Q1 passes the *pre-mutation* list +(computed before scripts run) and recomputes the render list afterward only +for rendering. We match Q1: compute the input list from a cheap pre-script +discovery (step 1 can reuse `ProjectContext::discover()`; it's not +expensive), pass it to the scripts, then re-discover at step 3. + +### D2. Config surface + +- `project.pre-render`, `project.post-render`: string or list of strings, + exactly Q1's shape. Parsed in `parse_config` into two new + `ProjectConfig` fields `pre_render_scripts` / `post_render_scripts` + (each `Vec` carrying the YAML `source_info`, following the + `project_resources::RawResourcePattern` pattern, so errors point at the + YAML entry). +- Available to **all** project kinds, single-file synthetic projects + excluded (matching Q1: no project ⇒ no scripts; `is_single_file` + contexts created from a bare `q2 render file.qmd` with no `_quarto.yml` + never run scripts). +- Typo guard: since Q2 has no schema layer, add a targeted diagnostic for + `project.pre_render` / `project.post_render` (underscore variants) — + cheap and catches the likely mistake. +- Extension-contributed scripts: **out of scope** (extensions Phase 7, + extensions grand plan); the config plumbing should not preclude it. + +### D3. Interpreter dispatch — simplified from Q1 + +Keep Q1's "parse shell-ish command line, dispatch on first token's +extension" model, minus the parts Q2 cannot honor: + +| Extension | Q1 | Q2 proposal | +|---|---|---| +| `.py` | discovered python | `python3`/`python` on PATH (honor `QUARTO_PYTHON` if set) | +| `.r`, `.R` | Rscript | Rscript via existing knitr discovery conventions (`QUARTO_R`) | +| `.ts`, `.js` | bundled Deno + import maps | **`node` from PATH only** (`QUARTO_NODE` override, same convention as the `q2 mcp` launcher's node lookup). No deno lookup, no import maps — Q1's Deno import-map scheme was a misguided stdlib-stability attempt we deliberately do not carry forward. `.ts` therefore only works where node can run it; the documented recommendation for anything else is an explicit interpreter in the command line. | +| `.lua` | pandoc filter (!) | **not special-cased** (decided; a future mlua-based runner is a possible follow-up strand if demand appears) | +| other | direct exec | direct exec, no shell; Windows shebang caveat documented (json_filter.rs precedent) | + +The command line is parsed with double-quote support (port of Q1's +`parseShellRunCommand`), so `pre-render: python3 tools/gen.py --flag` +works and sidesteps extension dispatch entirely — that stays the documented +recommendation for anything unusual. + +### D4. Environment contract — Q1-compatible, computed fresh per phase + +Export exactly Q1's variables (table above), with these fixes: + +- Post-render env computed **after** the render from actual results + (fresh `RENDER_ALL`, real output-file list from the pipeline's + `output_paths`), fixing Q1's staleness wart. +- `QUARTO_PROJECT_DIR` set per-subprocess (not process-wide like Q1). +- Keep the `QUARTO_USE_FILE_FOR_PROJECT_{INPUT,OUTPUT}_FILES` escape hatch — + small, and real projects hit env-size limits (Q1 #10828). +- Paths relative to project dir, newline-separated, matching Q1 so existing + user scripts port unchanged. + +Mechanism: extend nothing on `SystemRuntime` (avoids touching ~15 test +stubs for a native-only feature); the runner module uses +`std::process::Command` directly with `.current_dir(project_dir)` and +`.envs(...)`, native-gated at module level like +`engine/knitr/subprocess.rs`. If a future consumer needs script exec through +the runtime trait, that's a separate refactor. + +### D5. Failure handling — better than Q1 + +- Non-zero exit → abort with a real diagnostic: script entry (with YAML + source location), exit code, pointer that the script's own stderr appears + above. Registered as a `Q-*` code in `quarto-error-catalog`. +- Remaining scripts in the list do not run (Q1-compatible). +- Script stdout/stderr inherit by default; under `--quiet`, capture stdout + (Q1 behavior) but **still pass stderr through** on failure. + +### D6. Frequency semantics — Q1-compatible + +Scripts run on every project-scoped render (`FullProject` and `Subset`, +including `q2 render some-file.qmd` resolving into a project), once per +invocation. `QUARTO_PROJECT_RENDER_ALL=1` only for full renders. No +incremental gating — scripts that care use the env var, exactly as in Q1. + +### D7. Preview and WASM + +- **`q2 preview` (native side)**: run pre-render scripts **once at server + boot only** (alongside `record_eager_captures` in the on-ready hook). + No re-runs — not on file edits, not on `_quarto.yml` changes (decided; + restart the preview to re-run scripts). This is a deliberate improvement + over Q1's every-keystroke re-run; the browser-side per-page render makes + Q1's behavior impossible to match anyway. Post-render scripts do not run + in preview (there is no materialized output dir in the preview loop). + Deviations surfaced in docs. +- **Hub/WASM (browser preview, hub-client)**: scripts cannot run. If a + project declares them, surface a one-time diagnostic warning through the + existing `DiagnosticMessage` channel rather than failing. +- Phase-gated: preview integration is its own phase and can ship after the + render/publish support. + +## Resolved design questions (Carlos, 2026-07-29) + +1. **Mutation guard strictness** — keep Q1's ban: pre-render scripts may + not change `project.type` or `project.output-dir` (they already received + `QUARTO_PROJECT_OUTPUT_DIR`, so a change would hand them a stale value). +2. **`.ts`/`.js` support** — look up **`node` only** on PATH (with + `QUARTO_NODE` override, mirroring the `q2 mcp` launcher). No deno + lookup; explicitly do not reproduce Q1's Deno import-map scheme. +3. **`.lua` scripts** — drop Q1's pandoc-filter dispatch; no special case. + An mlua-based runner is a possible follow-up strand if demand appears. +4. **Preview cadence** — pre-render scripts run **on preview boot only**; + no re-runs of any kind (not even on `_quarto.yml` change). No + post-render in preview. Documented deviation from Q1. +5. **CLI escape hatch** — yes, add `--no-render-scripts` to `q2 render`. +6. **Naming** — `pre-render`/`post-render` spellings verbatim; no aliases. + +## Work items + +### Phase 0 — design sign-off +- [x] Resolve open questions 1–6 with Carlos; update this plan + (resolved 2026-07-29; see "Resolved design questions" above) +- [x] Explicit go-ahead from Carlos to begin execution (2026-07-31) + +### Phase 1 — tests first (TDD) +- [x] CLI e2e integration tests in + `crates/quarto/tests/integration/render_scripts_cli.rs` (14 tests; + verified failing before implementation, 13/14 red on 2026-07-31): + pre-render creates input; post-render OUTPUT_FILES; env contract + full vs subset; failing script aborts (exit code, stderr + pass-through, later scripts skipped); output-dir + type mutation + guards; string/list forms + ordering; explicit-interpreter command + line with quoted args; `--no-render-scripts`; escape hatch; + underscore typo warning; no-project render +- [x] Cross-platform fixture strategy: Python with graceful skip + (`require_python!`), `#[cfg(unix)]` shell + `#[cfg(windows)]` + batch variants for direct-exec +- [x] Unit tests for command-line parsing (quote handling), extraction, + typo guard, mutation guard, catalog registration (17 tests in + `render_scripts.rs`) + +### Phase 2 — config parsing +- [x] `RenderScript` type with `source_info`; `ProjectConfig::pre_render_scripts` + / `post_render_scripts`; extraction in `parse_config` + (`crates/quarto-core/src/project/mod.rs`) +- [x] String-or-list normalization; underscore-typo diagnostic (Q-5-11, + emitted by `underscore_typo_diagnostics`, printed by the render + driver) + +### Phase 3 — script runner +- [x] `crates/quarto-core/src/project/render_scripts.rs`: command-line + parser + config extraction (target-agnostic), exec half native-gated + (`#[cfg(not(target_arch = "wasm32"))] mod exec`); extension dispatch + (.py → QUARTO_PYTHON/python3, .r → knitr `find_rscript`, .ts/.js → + QUARTO_NODE/node, else direct exec resolved against project dir); + env assembly; catalog entries Q-5-8 (script failed), Q-5-9 + (forbidden mutation), Q-5-10 (launch failure), Q-5-11 (typo) +- [x] `QUARTO_USE_FILE_FOR_PROJECT_{INPUT,OUTPUT}_FILES` escape hatch + +### Phase 4 — driver wiring +- [x] `execute_project` in `crates/quarto/src/commands/render.rs`: + discover → run pre-render → re-discover → mutation guard → + pipeline → post-render (fresh env from `summary.outputs`, + post-render only after a successful render, Q1-compatible). + `execute_single_doc` needs no wiring — `RenderTarget::SingleDoc` + only fires with no surrounding `_quarto.yml`, so no scripts exist. +- [x] Same bracket in `crates/quarto/src/commands/publish.rs` + (`ProjectPublishRenderer::render`; post-render runs before the + sidecar walk so script-added output files get published) +- [x] `--no-render-scripts` flag on `q2 render` + +### Phase 5 — preview + WASM +- [x] Native preview: pre-render at boot only, inside the on-ready + spawn_blocking *before* `record_eager_captures` (scripts may + generate data the engines read); failure is reported but the + preview keeps serving. TDD test + `quarto-preview::integration render_scripts_boot` (verified red + first) also pins "no re-run on file change". +- [x] WASM/hub-client: one-time (AtomicBool) Q-5-12 warning pushed + into the `warnings` channel of + `render_project_active_page_to_response` when scripts are + configured +- [x] Full `cargo xtask verify` (WASM leg touched via quarto-core) — + all 14 steps green 2026-07-31 (first run flagged clippy + `map_unwrap_or` / unnested-or-patterns, fixed; second run + failed on a stale `node_modules` unrelated to this feature, + fixed with `npm install` from the repo root) + +### Phase 6 — verification + docs +- [x] End-to-end verification per CLAUDE.md (2026-07-31, output + inspected): fixture with `pre-render: gen_news.py` (creates + `news.qmd` from `QUARTO_PROJECT_INPUT_FILES`) and + `post-render: python3 report.py --label "site build"`. + `q2 render` printed: + ``` + Running pre-render script: gen_news.py + Rendering project: …/e2e-scripts (type: website) + Rendered 2 of 2 files to …/e2e-scripts/_site + Running post-render script: python3 report.py --label "site build" + ``` + `_site/news.html` contains "Generated from 1 inputs."; + `report.txt` contains `label=site build`, `render_all=1`, and + both `_site/*.html` paths. A failing script produces the Q-5-8 + ariadne diagnostic pointing at `_quarto.yml:4:15` + (`pre-render: gen_news.py`) with the exit status. +- [x] User-facing docs page `docs/guides/projects/scripts.qmd` + (sidebar-linked; rendered cleanly with + `cargo run --bin q2 -- render docs/guides/projects/scripts.qmd` + after `cargo xtask stage-doc-examples`) +- [x] Close out: `cargo build --workspace` clean; + `cargo nextest run --workspace` 10806 passed / 0 failed; + `cargo xtask verify` all steps passed; `cargo xtask lint` clean + (all 2026-07-31) + +## Q1 → Q2 behavior differences (running list for docs) + +| Area | Q1 | Q2 (decided) | +|---|---|---| +| `.ts`/`.js` | bundled Deno + import maps | `node` from PATH only (`QUARTO_NODE` override) | +| `.lua` | pandoc Lua filter | no special case | +| Post-render env | stale (computed pre-render) | fresh | +| Script failure msg | empty `Error` | sourced diagnostic + exit code | +| Preview cadence | scripts on every re-render | pre-render at boot only; no post-render in preview | +| Extension-contributed scripts | supported (1.5+) | deferred to extensions Phase 7 | +| Skip flag | none | `--no-render-scripts` | diff --git a/crates/quarto-core/src/engine/mod.rs b/crates/quarto-core/src/engine/mod.rs index f2fe26d61..907a93c64 100644 --- a/crates/quarto-core/src/engine/mod.rs +++ b/crates/quarto-core/src/engine/mod.rs @@ -95,6 +95,10 @@ pub use traits::ExecutionEngine; pub use jupyter::JupyterEngine; #[cfg(not(target_arch = "wasm32"))] pub use knitr::KnitrEngine; +// Rscript discovery, shared with the project render-script dispatcher +// (`project::render_scripts`). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use knitr::find_rscript; /// Print `perf.engine-discover jupyter=N rscript=N` to stderr when /// `QUARTO_PERF_STATS=1`. Call once at the end of a top-level diff --git a/crates/quarto-core/src/project/mod.rs b/crates/quarto-core/src/project/mod.rs index 98a7a0ff4..6e084fb94 100644 --- a/crates/quarto-core/src/project/mod.rs +++ b/crates/quarto-core/src/project/mod.rs @@ -31,6 +31,7 @@ pub mod listing; pub mod orchestrator; pub mod pass2_renderer; pub mod profile_cache; +pub mod render_scripts; pub mod sidebar_membership; pub mod website_config; // Every hook in this module is native-only (`#[cfg(not(wasm32))]` @@ -332,6 +333,19 @@ pub struct ProjectConfig { /// Empty when `project.resources` is absent. pub resources: Vec, + /// `project.pre-render` script entries (bd-w348iu63): command + /// lines run before a project render, in declaration order, with + /// each entry's YAML source location. A bare string normalizes to + /// a one-element list. Empty when the key is absent (including + /// single-file pseudo-projects, which never run scripts). See + /// [`render_scripts`] for the execution contract. + pub pre_render_scripts: Vec, + + /// `project.post-render` script entries — run after a successful + /// project render. Same shape as + /// [`pre_render_scripts`](Self::pre_render_scripts). + pub post_render_scripts: Vec, + /// Full project metadata as ConfigValue with source tracking. /// /// This is the entire `_quarto.yml` parsed with `InterpretationContext::ProjectConfig`, @@ -656,6 +670,9 @@ impl ProjectContext { &["project", "resources"], ); + let pre_render_scripts = render_scripts::extract_render_scripts(&metadata, "pre-render"); + let post_render_scripts = render_scripts::extract_render_scripts(&metadata, "post-render"); + // Resolve the project-level `brand:` once, here, so every // site-wide consumer reads the same answer. Relative brand // paths are written against the directory holding this @@ -674,6 +691,8 @@ impl ProjectContext { output_dir, render_patterns, resources, + pre_render_scripts, + post_render_scripts, metadata: Some(metadata), config_path: Some(path.to_path_buf()), brand, diff --git a/crates/quarto-core/src/project/render_scripts.rs b/crates/quarto-core/src/project/render_scripts.rs new file mode 100644 index 000000000..4a64dbc9b --- /dev/null +++ b/crates/quarto-core/src/project/render_scripts.rs @@ -0,0 +1,756 @@ +/* + * render_scripts.rs + * Copyright (c) 2026 Posit, PBC + * + * Project pre-render / post-render user scripts (bd-w348iu63). + */ + +//! Project `pre-render` / `post-render` user scripts (bd-w348iu63). +//! +//! A project can declare scripts that run before and after a project +//! render: +//! +//! ```yaml +//! project: +//! pre-render: prepare.py # string or list +//! post-render: +//! - cleanup.R +//! - tools/notify.sh +//! ``` +//! +//! Scripts run with the project root as cwd and receive the +//! `QUARTO_PROJECT_*` environment contract (see +//! [`RenderScriptsContext`]). The drivers (`q2 render`, `q2 publish`) +//! bracket project discovery and the pipeline with +//! [`run_render_scripts`]; pre-render scripts may create input files +//! (the project is discovered *after* they run) but may not change +//! `project.type` or `project.output-dir` +//! ([`check_forbidden_mutations`]). +//! +//! Config extraction ([`extract_render_scripts`]) and command-line +//! parsing ([`parse_shell_run_command`]) are target-agnostic; actual +//! execution is native-only (`#[cfg(not(target_arch = "wasm32"))]`) — +//! the WASM/hub path surfaces a diagnostic instead of running +//! anything. +//! +//! Plan: `claude-notes/plans/2026-07-29-pre-post-render-scripts.md`. + +use quarto_error_reporting::{DiagnosticMessage, DiagnosticMessageBuilder}; +use quarto_pandoc_types::ConfigValue; +use quarto_source_map::SourceInfo; + +use super::ProjectConfig; + +/// One `pre-render:` / `post-render:` entry from `_quarto.yml`: the +/// raw command line as written, plus the YAML scalar's source +/// location so diagnostics can point at the entry. +#[derive(Debug, Clone)] +pub struct RenderScript { + /// The command line as written by the user (e.g. `"prepare.py"`, + /// `"python3 tools/gen.py --flag"`). + pub command: String, + /// Source location of the YAML scalar that supplied + /// [`command`](Self::command). + pub source_info: SourceInfo, +} + +/// Extract `project.` (string or list-of-strings) into +/// [`RenderScript`]s carrying each scalar's source location. A bare +/// string normalizes to a one-element list, matching Quarto 1. +pub fn extract_render_scripts(meta: &ConfigValue, key: &str) -> Vec { + let Some(value) = meta.get("project").and_then(|p| p.get(key)) else { + return Vec::new(); + }; + if let Some(arr) = value.as_array() { + arr.iter() + .filter_map(|v| { + v.as_plain_text().map(|s| RenderScript { + command: s, + source_info: v.source_info.clone(), + }) + }) + .collect() + } else if let Some(s) = value.as_plain_text() { + vec![RenderScript { + command: s, + source_info: value.source_info.clone(), + }] + } else { + Vec::new() + } +} + +/// Parse a shell-ish command line: split on runs of whitespace, with +/// double quotes grouping words (`a "b c"` → `["a", "b c"]`). An +/// unterminated quote extends to the end of the line. Quote +/// characters themselves are removed. Port of Quarto 1's +/// `parseShellRunCommand` (`src/core/run/shell.ts`). +pub fn parse_shell_run_command(cmd_line: &str) -> Vec { + let mut tokens: Vec = Vec::new(); + let mut current = String::new(); + let mut in_token = false; + let mut in_quotes = false; + for ch in cmd_line.chars() { + match ch { + '"' => { + in_quotes = !in_quotes; + in_token = true; + } + c if c.is_whitespace() && !in_quotes => { + if in_token { + tokens.push(std::mem::take(&mut current)); + in_token = false; + } + } + c => { + current.push(c); + in_token = true; + } + } + } + if in_token { + tokens.push(current); + } + tokens +} + +/// Detect the likely-typo spellings `project.pre_render` / +/// `project.post_render` (underscores instead of hyphens) and return +/// warning diagnostics naming the correct key. Q2 has no schema +/// layer, so unknown keys are otherwise silently ignored — this +/// targeted guard catches the most probable mistake. +pub fn underscore_typo_diagnostics(config: &ProjectConfig) -> Vec { + let Some(meta) = &config.metadata else { + return Vec::new(); + }; + let Some(project) = meta.get("project") else { + return Vec::new(); + }; + let config_name = config + .config_path + .as_ref() + .map_or_else(|| "_quarto.yml".to_string(), |p| p.display().to_string()); + ["pre_render", "post_render"] + .iter() + .filter(|typo| project.get(typo).is_some()) + .map(|typo| { + let correct = typo.replace('_', "-"); + DiagnosticMessageBuilder::warning(format!( + "Unknown project key `{typo}` — did you mean `{correct}`?" + )) + .with_code("Q-5-11") + .problem(format!( + "`project.{typo}` in {config_name} is not a recognized key and is ignored. \ + Project render scripts are configured with `project.{correct}`." + )) + .build() + }) + .collect() +} + +#[cfg(not(target_arch = "wasm32"))] +mod exec { + use std::path::{Path, PathBuf}; + use std::process::{Command, Stdio}; + use std::sync::OnceLock; + + use quarto_error_reporting::DiagnosticMessageBuilder; + use quarto_source_map::{FileId, SourceContext, SourceInfo}; + + use super::RenderScript; + use crate::error::ParseError; + use crate::project::ProjectConfig; + + /// Which script list is being run. Controls the phase-specific + /// environment variable (`QUARTO_PROJECT_INPUT_FILES` vs + /// `QUARTO_PROJECT_OUTPUT_FILES`) and its file-based escape + /// hatch. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum ScriptPhase { + PreRender, + PostRender, + } + + impl ScriptPhase { + fn label(self) -> &'static str { + match self { + ScriptPhase::PreRender => "pre-render", + ScriptPhase::PostRender => "post-render", + } + } + + fn files_var(self) -> &'static str { + match self { + ScriptPhase::PreRender => "QUARTO_PROJECT_INPUT_FILES", + ScriptPhase::PostRender => "QUARTO_PROJECT_OUTPUT_FILES", + } + } + + /// Env-size escape hatch (Q1 issue #10828): when the user + /// sets this variable to a path, the file list is written + /// there instead of into the environment. + fn use_file_var(self) -> &'static str { + match self { + ScriptPhase::PreRender => "QUARTO_USE_FILE_FOR_PROJECT_INPUT_FILES", + ScriptPhase::PostRender => "QUARTO_USE_FILE_FOR_PROJECT_OUTPUT_FILES", + } + } + } + + /// Shared facts the `QUARTO_PROJECT_*` environment is assembled + /// from. Computed fresh per phase by the driver — notably the + /// post-render env reflects the *actual* render results, fixing + /// Q1's staleness wart. + #[derive(Debug)] + pub struct RenderScriptsContext<'a> { + /// Absolute project root; also the scripts' cwd. + pub project_dir: &'a Path, + /// Absolute output directory (= project dir when no + /// `output-dir` is configured). + pub output_dir: &'a Path, + /// Path of the `_quarto.yml` the scripts came from, used to + /// attach source snippets to diagnostics. + pub config_path: Option<&'a Path>, + /// True iff the whole project is being rendered. Exported as + /// `QUARTO_PROJECT_RENDER_ALL=1`; the variable is *absent* + /// otherwise (not `"0"`), matching Q1. + pub render_all: bool, + /// From `--quiet`: suppresses progress lines and captures + /// script stdout (stderr is still shown on failure). + pub quiet: bool, + /// Number of files in the render set, for the + /// `QUARTO_PROJECT_SCRIPT_PROGRESS` hint (`"1"` on a + /// multi-file render when not quiet). + pub file_count: usize, + } + + impl RenderScriptsContext<'_> { + /// The environment variables shared by both phases. + fn shared_env(&self) -> Vec<(&'static str, String)> { + let mut env: Vec<(&'static str, String)> = vec![ + ("QUARTO_PROJECT_DIR", self.project_dir.display().to_string()), + ( + "QUARTO_PROJECT_OUTPUT_DIR", + self.output_dir.display().to_string(), + ), + ( + "QUARTO_PROJECT_SCRIPT_PROGRESS", + if self.file_count > 1 && !self.quiet { + "1" + } else { + "0" + } + .to_string(), + ), + ( + "QUARTO_PROJECT_SCRIPT_QUIET", + if self.quiet { "1" } else { "0" }.to_string(), + ), + ]; + if self.render_all { + env.push(("QUARTO_PROJECT_RENDER_ALL", "1".to_string())); + } + env + } + } + + /// Run one phase's scripts in declaration order, stopping at the + /// first failure (Q1-compatible). `files` is the phase-specific + /// list — input files about to render (pre) or produced output + /// files (post) — as paths relative to the project dir. + pub fn run_render_scripts( + phase: ScriptPhase, + scripts: &[RenderScript], + ctx: &RenderScriptsContext, + files: &[PathBuf], + ) -> Result<(), ParseError> { + if scripts.is_empty() { + return Ok(()); + } + + let mut env = ctx.shared_env(); + let files_joined = files + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join("\n"); + if let Ok(list_path) = std::env::var(phase.use_file_var()) { + std::fs::write(&list_path, &files_joined).map_err(|e| { + script_error( + ctx, + None, + "Q-5-10", + format!("Failed to run {} scripts", phase.label()), + format!( + "Could not write the {} list to `{list_path}` \ + (from `{}`): {e}", + phase.files_var(), + phase.use_file_var() + ), + ) + })?; + } else { + env.push((phase.files_var(), files_joined)); + } + + for script in scripts { + run_one_script(phase, script, ctx, &env)?; + } + Ok(()) + } + + fn run_one_script( + phase: ScriptPhase, + script: &RenderScript, + ctx: &RenderScriptsContext, + env: &[(&'static str, String)], + ) -> Result<(), ParseError> { + let tokens = super::parse_shell_run_command(&script.command); + if tokens.is_empty() { + return Err(script_error( + ctx, + Some(&script.source_info), + "Q-5-10", + format!("Empty {} script entry", phase.label()), + format!( + "The `{}` entry is empty — expected a script path or command line.", + phase.label() + ), + )); + } + + quarto_util::user_status!( + ctx.quiet, + "Running {} script: {}", + phase.label(), + script.command + ); + + let mut cmd = build_script_command(ctx.project_dir, &tokens); + cmd.current_dir(ctx.project_dir); + for (k, v) in env { + cmd.env(k, v); + } + + // Under --quiet, capture output (script stdout is + // suppressed; captured stderr is replayed on failure so + // errors are never swallowed). Otherwise inherit both. + let status = if ctx.quiet { + cmd.stdout(Stdio::null()); + cmd.stderr(Stdio::piped()); + match cmd.spawn().and_then(|child| child.wait_with_output()) { + Ok(output) => { + if !output.status.success() { + eprint!("{}", String::from_utf8_lossy(&output.stderr)); + } + Ok(output.status) + } + Err(e) => Err(e), + } + } else { + cmd.status() + } + .map_err(|e| { + script_error( + ctx, + Some(&script.source_info), + "Q-5-10", + format!("Could not launch {} script", phase.label()), + format!( + "Failed to launch `{}`: {e}. Check that the interpreter is on PATH \ + (or set QUARTO_PYTHON / QUARTO_R / QUARTO_NODE), and that a \ + directly-executed script has a shebang line and the executable bit.", + script.command + ), + ) + })?; + + if !status.success() { + let exit_desc = match status.code() { + Some(code) => format!("exited with status {code}"), + None => "was terminated by a signal".to_string(), + }; + return Err(script_error( + ctx, + Some(&script.source_info), + "Q-5-8", + format!("{} script failed", capitalize(phase.label())), + format!( + "Script `{}` {exit_desc}. The script's own output appears above.", + script.command + ), + )); + } + Ok(()) + } + + /// Build the [`Command`] for a parsed script command line. + /// + /// Dispatch is by extension of the first token (Q1-compatible, + /// minus Deno and the `.lua` pandoc-filter special case — see the + /// plan's D3): + /// - `.py` → `QUARTO_PYTHON`, else `python3`/`python` on PATH + /// - `.r` → `QUARTO_R` (via the knitr discovery), else `Rscript` + /// - `.ts`/`.js` → `QUARTO_NODE`, else `node` + /// - anything else → direct exec, no shell (a `.sh` needs a + /// shebang and the executable bit; batch files work on Windows + /// because `Command` routes them through `cmd.exe`) + /// + /// The first token is resolved to an absolute path when it names + /// an existing file under the project dir — the scripts' cwd is + /// the project root, but `Command::new("foo.sh")` would otherwise + /// hit PATH lookup, which does not include the cwd. + fn build_script_command(project_dir: &Path, tokens: &[String]) -> Command { + let first = &tokens[0]; + let candidate = project_dir.join(first); + let program: PathBuf = if candidate.is_file() { + candidate + } else { + PathBuf::from(first) + }; + + let ext = program + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()); + let interpreter: Option = match ext.as_deref() { + Some("py") => Some(find_python().to_string()), + Some("r") => Some(find_rscript_program()), + Some("ts" | "js") => { + Some(std::env::var("QUARTO_NODE").unwrap_or_else(|_| "node".to_string())) + } + _ => None, + }; + + let mut cmd = match interpreter { + Some(interp) => { + let mut c = Command::new(interp); + c.arg(&program); + c + } + None => Command::new(&program), + }; + cmd.args(&tokens[1..]); + cmd + } + + /// Python discovery: `QUARTO_PYTHON` override, else the first of + /// `python3`/`python` (`python`/`python3` on Windows) that + /// answers `--version`. Cached for the process lifetime. + fn find_python() -> &'static str { + static PYTHON: OnceLock = OnceLock::new(); + PYTHON.get_or_init(|| { + if let Ok(p) = std::env::var("QUARTO_PYTHON") + && !p.is_empty() + { + return p; + } + let candidates: &[&str] = if cfg!(windows) { + &["python", "python3"] + } else { + &["python3", "python"] + }; + for candidate in candidates { + if let Ok(status) = Command::new(candidate) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + && status.success() + { + return candidate.to_string(); + } + } + "python3".to_string() + }) + } + + /// Rscript discovery: reuse the knitr engine's `find_rscript` + /// (which honors `QUARTO_R` as a binary path, an R home, or a bin + /// directory), falling back to plain `Rscript` on PATH. + fn find_rscript_program() -> String { + crate::engine::find_rscript() + .map_or_else(|| "Rscript".to_string(), |p| p.display().to_string()) + } + + fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } + } + + /// Assemble a [`ParseError`] for a script problem, attaching the + /// `_quarto.yml` snippet for the offending entry when the source + /// info resolves (same degradation contract as + /// [`crate::project_resources::resource_error_to_parse_error`]). + fn script_error( + ctx: &RenderScriptsContext, + source_info: Option<&SourceInfo>, + code: &str, + title: String, + problem: String, + ) -> ParseError { + let mut source_context = SourceContext::new(); + let mut builder = DiagnosticMessageBuilder::error(title).with_code(code); + if let Some(info) = source_info { + if let (Some((fid_usize, _, _)), Some(config_path)) = + (info.resolve_byte_range(), ctx.config_path) + { + let content = std::fs::read_to_string(config_path).ok(); + source_context.add_file_with_id( + FileId(fid_usize), + config_path.to_string_lossy().into_owned(), + content, + ); + } + builder = builder.with_location(info.clone()); + } + ParseError::new(vec![builder.problem(problem).build()], source_context) + } + + /// Q1-compatible mutation guard: a pre-render script may not + /// change `project.type` or `project.output-dir` — the scripts + /// already received `QUARTO_PROJECT_OUTPUT_DIR`, so a change + /// would hand them a stale value. Compares the pre-script parse + /// with the post-script re-parse; violation aborts the render. + pub fn check_forbidden_mutations( + before: &ProjectConfig, + after: &ProjectConfig, + ) -> Result<(), ParseError> { + let mut violations: Vec = Vec::new(); + if before.project_kind != after.project_kind { + violations.push(format!( + "`project.type` changed from `{}` to `{}`", + before.project_kind.as_str(), + after.project_kind.as_str() + )); + } + if before.output_dir != after.output_dir { + let show = |d: &Option| { + d.as_ref() + .map_or_else(|| "(unset)".to_string(), |p| format!("`{}`", p.display())) + }; + violations.push(format!( + "`project.output-dir` changed from {} to {}", + show(&before.output_dir), + show(&after.output_dir) + )); + } + if violations.is_empty() { + return Ok(()); + } + let config_name = after + .config_path + .as_ref() + .map_or_else(|| "_quarto.yml".to_string(), |p| p.display().to_string()); + let diagnostic = DiagnosticMessageBuilder::error( + "Pre-render script changed a forbidden project setting", + ) + .with_code("Q-5-9") + .problem(format!( + "While pre-render scripts ran, {config_name} changed: {}. \ + Pre-render scripts may modify the project (add inputs, edit config), \ + but `project.type` and `project.output-dir` must stay fixed — \ + the scripts already received QUARTO_PROJECT_OUTPUT_DIR based on them.", + violations.join("; ") + )) + .build(); + Err(ParseError::new(vec![diagnostic], SourceContext::new())) + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub use exec::{RenderScriptsContext, ScriptPhase, check_forbidden_mutations, run_render_scripts}; + +#[cfg(test)] +mod tests { + use super::*; + + // ── parse_shell_run_command ───────────────────────────────────── + + #[test] + fn parse_single_token() { + assert_eq!(parse_shell_run_command("prepare.py"), vec!["prepare.py"]); + } + + #[test] + fn parse_multiple_tokens_collapse_spaces() { + assert_eq!( + parse_shell_run_command("python3 tools/gen.py --flag"), + vec!["python3", "tools/gen.py", "--flag"] + ); + } + + #[test] + fn parse_double_quotes_group_words() { + assert_eq!( + parse_shell_run_command(r#"run.py --msg "two words" tail"#), + vec!["run.py", "--msg", "two words", "tail"] + ); + } + + #[test] + fn parse_unterminated_quote_extends_to_end() { + assert_eq!( + parse_shell_run_command(r#"run.py "a b c"#), + vec!["run.py", "a b c"] + ); + } + + #[test] + fn parse_empty_and_whitespace_only() { + assert!(parse_shell_run_command("").is_empty()); + assert!(parse_shell_run_command(" ").is_empty()); + } + + #[test] + fn parse_adjacent_quotes_join_token() { + // Q1 parity: quotes glue onto the surrounding token. + assert_eq!(parse_shell_run_command(r#"--opt="a b""#), vec!["--opt=a b"]); + } + + // ── extract_render_scripts ────────────────────────────────────── + + fn config_value_from_yaml(yaml: &str) -> ConfigValue { + use pampa::pandoc::yaml_to_config_value; + use pampa::utils::diagnostic_collector::DiagnosticCollector; + use quarto_config::InterpretationContext; + let parsed = quarto_yaml::parse_file(yaml, "_quarto.yml").expect("valid yaml"); + let mut diagnostics = DiagnosticCollector::new(); + yaml_to_config_value( + parsed, + InterpretationContext::ProjectConfig, + &mut diagnostics, + ) + } + + #[test] + fn extract_string_form_normalizes_to_one_element() { + let meta = config_value_from_yaml("project:\n pre-render: prepare.py\n"); + let scripts = extract_render_scripts(&meta, "pre-render"); + assert_eq!(scripts.len(), 1); + assert_eq!(scripts[0].command, "prepare.py"); + } + + #[test] + fn extract_list_form_preserves_order() { + let meta = + config_value_from_yaml("project:\n post-render:\n - cleanup.R\n - notify.sh\n"); + let scripts = extract_render_scripts(&meta, "post-render"); + assert_eq!( + scripts + .iter() + .map(|s| s.command.as_str()) + .collect::>(), + vec!["cleanup.R", "notify.sh"] + ); + } + + #[test] + fn extract_absent_key_is_empty() { + let meta = config_value_from_yaml("project:\n type: website\n"); + assert!(extract_render_scripts(&meta, "pre-render").is_empty()); + assert!(extract_render_scripts(&meta, "post-render").is_empty()); + } + + #[test] + fn extract_carries_source_info() { + let meta = config_value_from_yaml("project:\n pre-render: prepare.py\n"); + let scripts = extract_render_scripts(&meta, "pre-render"); + assert!( + scripts[0].source_info.resolve_byte_range().is_some(), + "the YAML scalar's source location must be preserved" + ); + } + + // ── underscore_typo_diagnostics ───────────────────────────────── + + #[test] + fn typo_guard_flags_underscore_spellings() { + let meta = config_value_from_yaml("project:\n pre_render: x.py\n post_render: y.py\n"); + let config = ProjectConfig { + metadata: Some(meta), + ..Default::default() + }; + let diags = underscore_typo_diagnostics(&config); + assert_eq!(diags.len(), 2); + let rendered: Vec = diags.iter().map(|d| d.to_text(None)).collect(); + assert!(rendered[0].contains("pre_render") && rendered[0].contains("pre-render")); + assert!(rendered[1].contains("post_render") && rendered[1].contains("post-render")); + } + + #[test] + fn typo_guard_silent_on_correct_spelling() { + let meta = config_value_from_yaml("project:\n pre-render: x.py\n"); + let config = ProjectConfig { + metadata: Some(meta), + ..Default::default() + }; + assert!(underscore_typo_diagnostics(&config).is_empty()); + } + + // ── mutation guard ────────────────────────────────────────────── + + #[cfg(not(target_arch = "wasm32"))] + mod mutation_guard { + use crate::project::{ProjectConfig, ProjectKind}; + use std::path::PathBuf; + + use super::super::check_forbidden_mutations; + + fn config(kind: ProjectKind, output_dir: Option<&str>) -> ProjectConfig { + ProjectConfig { + project_kind: kind, + output_dir: output_dir.map(PathBuf::from), + ..Default::default() + } + } + + #[test] + fn unchanged_config_passes() { + let before = config(ProjectKind::Website, Some("_site")); + let after = config(ProjectKind::Website, Some("_site")); + assert!(check_forbidden_mutations(&before, &after).is_ok()); + } + + #[test] + fn type_change_is_forbidden() { + let before = config(ProjectKind::Website, Some("_site")); + let after = config(ProjectKind::Default, Some("_site")); + let err = check_forbidden_mutations(&before, &after).unwrap_err(); + let text = format!("{err}"); + assert!(text.contains("project.type"), "got: {text}"); + } + + #[test] + fn output_dir_change_is_forbidden() { + let before = config(ProjectKind::Website, Some("_site")); + let after = config(ProjectKind::Website, Some("_other")); + let err = check_forbidden_mutations(&before, &after).unwrap_err(); + let text = format!("{err}"); + assert!(text.contains("output-dir"), "got: {text}"); + } + + #[test] + fn other_changes_are_allowed() { + let before = config(ProjectKind::Website, Some("_site")); + let mut after = config(ProjectKind::Website, Some("_site")); + after.render_patterns = vec!["*.qmd".to_string()]; + assert!(check_forbidden_mutations(&before, &after).is_ok()); + } + } + + // ── error catalog registration ────────────────────────────────── + + #[test] + fn render_script_error_codes_are_registered_in_catalog() { + for code in ["Q-5-8", "Q-5-9", "Q-5-10", "Q-5-11", "Q-5-12"] { + assert!( + quarto_error_catalog::ERROR_CATALOG.get(code).is_some(), + "{code} must be registered in the quarto-error-catalog" + ); + } + } +} diff --git a/crates/quarto-error-catalog/error_catalog.json b/crates/quarto-error-catalog/error_catalog.json index a43be4925..0d267c21c 100644 --- a/crates/quarto-error-catalog/error_catalog.json +++ b/crates/quarto-error-catalog/error_catalog.json @@ -1062,5 +1062,40 @@ "message_template": "A crossref identifier (e.g. a `fig-`/`tbl-` label or an explicit `#id`) is defined more than once in the same document. Each crossref id must be unique within a document, so a reference like `@fig-foo` resolves to exactly one target. Give one of the targets a different label.", "docs_url": "https://quarto.org/docs/errors/crossref/Q-15-1", "since_version": "99.9.9" + }, + "Q-5-8": { + "subsystem": "project", + "title": "Project Render Script Failed", + "message_template": "A `project.pre-render` or `project.post-render` script exited with a non-zero status (or was terminated by a signal). The render is aborted; remaining scripts in the list do not run. The script's own output appears above the diagnostic — fix the script (or remove it from `_quarto.yml`) and render again.", + "docs_url": "https://quarto.org/docs/errors/project/Q-5-8", + "since_version": "99.9.9" + }, + "Q-5-9": { + "subsystem": "project", + "title": "Pre-render Script Changed a Forbidden Project Setting", + "message_template": "While `project.pre-render` scripts ran, `_quarto.yml` changed its `project.type` or `project.output-dir`. Pre-render scripts may modify the project — add input files, edit configuration — but these two settings must stay fixed, because the scripts already received `QUARTO_PROJECT_OUTPUT_DIR` computed from them. Make the change directly in `_quarto.yml` instead of from a script.", + "docs_url": "https://quarto.org/docs/errors/project/Q-5-9", + "since_version": "99.9.9" + }, + "Q-5-10": { + "subsystem": "project", + "title": "Project Render Script Could Not Be Launched", + "message_template": "Quarto could not start a `project.pre-render` / `project.post-render` script. Common causes: the interpreter is not on PATH (set `QUARTO_PYTHON`, `QUARTO_R`, or `QUARTO_NODE` to override discovery), a directly-executed script is missing its shebang line or executable bit, or the script path does not exist. The entry can also be an explicit command line, e.g. `pre-render: python3 tools/gen.py`.", + "docs_url": "https://quarto.org/docs/errors/project/Q-5-10", + "since_version": "99.9.9" + }, + "Q-5-11": { + "subsystem": "project", + "title": "Unknown Project Key (Underscore Spelling)", + "message_template": "`_quarto.yml` contains `project.pre_render` or `project.post_render` (underscore spelling), which is not a recognized key and is ignored. Project render scripts are configured with the hyphenated keys `project.pre-render` / `project.post-render`.", + "docs_url": "https://quarto.org/docs/errors/project/Q-5-11", + "since_version": "99.9.9" + }, + "Q-5-12": { + "subsystem": "project", + "title": "Project Render Scripts Unsupported in This Environment", + "message_template": "The project configures `project.pre-render` / `project.post-render` scripts, but the current environment (the browser-based hub preview) cannot run subprocesses, so the scripts are skipped and the preview renders without them. Run `q2 render` on a machine with the required interpreters to execute the scripts.", + "docs_url": "https://quarto.org/docs/errors/project/Q-5-12", + "since_version": "99.9.9" } } diff --git a/crates/quarto-preview/src/lib.rs b/crates/quarto-preview/src/lib.rs index f2c2916ab..9a0f1a641 100644 --- a/crates/quarto-preview/src/lib.rs +++ b/crates/quarto-preview/src/lib.rs @@ -205,12 +205,23 @@ where .unwrap_or_else(|| config.data_dir.join("captures")); let registry_for_on_ready = engine_registry.clone(); let cache_dir_for_on_ready = cache_dir.clone(); + let project_root_for_scripts = config.project_root.clone(); let on_ready: server::OnReadyCallback = Box::new(move |ctx| { let registry = registry_for_on_ready; let cache_dir = cache_dir_for_on_ready; let runtime: Arc = Arc::new(NativeRuntime::new()); let ctx_for_driver = ctx.clone(); tokio::task::spawn_blocking(move || { + // bd-w348iu63 (plan D7): run the project's `pre-render` + // scripts once, at boot, before the eager captures — a + // script may generate data the engines read. Deliberately + // never re-run (not on file edits, not on `_quarto.yml` + // changes; restart the preview to re-run), and + // post-render scripts don't run in preview at all — both + // documented deviations from Quarto 1. + if let Some(root) = &project_root_for_scripts { + run_boot_pre_render_scripts(root); + } let result = pollster::block_on(capture_driver::record_eager_captures( ctx_for_driver, runtime, @@ -283,6 +294,58 @@ where Ok(()) } +/// bd-w348iu63: run the project's `project.pre-render` scripts once at +/// preview boot. Failures are reported but never fatal — the preview +/// keeps serving (matching Q1 preview's catch-and-continue), and the +/// fix-it path is "repair the script, restart `q2 preview`". +fn run_boot_pre_render_scripts(project_root: &std::path::Path) { + use quarto_core::ProjectContext; + use quarto_core::project::render_scripts; + + let runtime = NativeRuntime::new(); + let project = match ProjectContext::discover(project_root, &runtime) { + Ok(p) => p, + Err(e) => { + tracing::warn!(error = %e, "pre-render scripts: project discovery failed"); + return; + } + }; + for diagnostic in render_scripts::underscore_typo_diagnostics(&project.config) { + eprintln!("{}", diagnostic.to_text(None)); + } + if project.config.pre_render_scripts.is_empty() { + return; + } + let input_files: Vec = project + .files + .iter() + .map(|f| { + f.input + .strip_prefix(&project.dir) + .map_or_else(|_| f.input.clone(), |r| r.to_path_buf()) + }) + .collect(); + let ctx = render_scripts::RenderScriptsContext { + project_dir: &project.dir, + output_dir: &project.output_dir, + config_path: project.config.config_path.as_deref(), + render_all: true, + quiet: false, + file_count: input_files.len(), + }; + if let Err(parse_error) = render_scripts::run_render_scripts( + render_scripts::ScriptPhase::PreRender, + &project.config.pre_render_scripts, + &ctx, + &input_files, + ) { + eprintln!("{parse_error}"); + eprintln!( + "note: the preview keeps serving; fix the script and restart `q2 preview` to re-run it." + ); + } +} + /// Construct the StorageManager. `project_root` decides project vs /// standalone mode; either way, `config.data_dir` is the storage root. fn build_storage(config: &PreviewConfig) -> Result { diff --git a/crates/quarto-preview/tests/integration/main.rs b/crates/quarto-preview/tests/integration/main.rs index 5d4814dd8..6b326eb68 100644 --- a/crates/quarto-preview/tests/integration/main.rs +++ b/crates/quarto-preview/tests/integration/main.rs @@ -7,6 +7,7 @@ pub mod config_endpoint; pub mod diagnostics_capture_failure; pub mod diagnostics_endpoint; pub mod eager_capture; +pub mod render_scripts_boot; pub mod smoke; pub mod staleness; diff --git a/crates/quarto-preview/tests/integration/render_scripts_boot.rs b/crates/quarto-preview/tests/integration/render_scripts_boot.rs new file mode 100644 index 000000000..7be6c1f1e --- /dev/null +++ b/crates/quarto-preview/tests/integration/render_scripts_boot.rs @@ -0,0 +1,153 @@ +//! Pre-render scripts run once at preview boot (bd-w348iu63, plan D7). +//! +//! `q2 preview` runs the project's `project.pre-render` scripts +//! exactly once, at server boot, alongside the eager-capture driver +//! in the on-ready hook. They are deliberately NOT re-run on file +//! changes (a documented deviation from Quarto 1's every-re-render +//! behavior), and post-render scripts never run in preview (there is +//! no materialized output dir in the preview loop). +//! +//! The fixture script appends a line to `boot.log` on every run, so +//! the test can assert both "ran at boot" and "did not run again +//! after a file change". + +use std::net::TcpListener as StdTcpListener; +use std::path::Path; +use std::process::Command; +use std::time::{Duration, Instant}; + +use quarto_preview::PreviewConfig; + +fn pick_free_port() -> u16 { + let listener = StdTcpListener::bind("127.0.0.1:0").expect("probe bind"); + let port = listener.local_addr().expect("local_addr").port(); + drop(listener); + port +} + +async fn wait_for_health(port: u16) { + let url = format!("http://127.0.0.1:{port}/health"); + let deadline = Instant::now() + Duration::from_secs(10); + let client = reqwest::Client::new(); + loop { + if let Ok(resp) = client.get(&url).send().await + && resp.status().is_success() + { + return; + } + if Instant::now() >= deadline { + panic!("server didn't come up on port {port} within 10s"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +/// Find a Python interpreter for the fixture script; `None` ⇒ skip. +fn find_python() -> Option<&'static str> { + let candidates: &[&str] = if cfg!(windows) { + &["python", "python3"] + } else { + &["python3", "python"] + }; + for candidate in candidates { + if let Ok(status) = Command::new(candidate) + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + && status.success() + { + return Some(candidate); + } + } + None +} + +/// Poll until `path` exists (the boot script runs on a blocking +/// worker after the health endpoint is already up). +async fn wait_for_file(path: &Path, what: &str) { + let deadline = Instant::now() + Duration::from_secs(10); + while !path.exists() { + if Instant::now() >= deadline { + panic!("{what} not created within 10s: {}", path.display()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_render_scripts_run_once_at_boot() { + if find_python().is_none() { + eprintln!("SKIP: no python interpreter on PATH"); + return; + } + + let project = tempfile::TempDir::with_prefix("q2-preview-scripts-").unwrap(); + let project_dir = project + .path() + .canonicalize() + .unwrap_or_else(|_| project.path().to_path_buf()); + std::fs::write( + project_dir.join("_quarto.yml"), + "project:\n type: website\n pre-render: boot.py\n", + ) + .unwrap(); + std::fs::write( + project_dir.join("boot.py"), + "import os\nwith open(\"boot.log\", \"a\") as f:\n f.write(os.environ.get(\"QUARTO_PROJECT_RENDER_ALL\", \"absent\") + \"\\n\")\n", + ) + .unwrap(); + std::fs::write( + project_dir.join("index.qmd"), + "---\ntitle: Home\n---\n\nHome body.\n", + ) + .unwrap(); + let data = tempfile::TempDir::with_prefix("q2-preview-scripts-data-").unwrap(); + + let port = pick_free_port(); + let config = PreviewConfig { + host: "127.0.0.1".to_string(), + port, + project_root: Some(project_dir.clone()), + single_file: None, + data_dir: data.path().to_path_buf(), + spa_dir_override: None, + engine_registry: None, + engine_policy: Default::default(), + resource_html_files: Vec::new(), + cache_dir: None, + allow_edit: false, + }; + + let server = tokio::spawn(async move { + let _ = quarto_preview::run(config).await; + }); + + wait_for_health(port).await; + + // 1. The pre-render script ran at boot, with the full-render env. + let log_path = project_dir.join("boot.log"); + wait_for_file(&log_path, "boot.log").await; + let log = std::fs::read_to_string(&log_path).unwrap(); + assert_eq!( + log, "1\n", + "pre-render script should run exactly once at boot with QUARTO_PROJECT_RENDER_ALL=1" + ); + + // 2. A file change does NOT re-run the scripts (decided deviation + // from Q1). Give the watcher + any (incorrect) re-run a real + // chance to fire before asserting. + std::fs::write( + project_dir.join("index.qmd"), + "---\ntitle: Home\n---\n\nHome body — edited.\n", + ) + .unwrap(); + tokio::time::sleep(Duration::from_millis(1500)).await; + let log = std::fs::read_to_string(&log_path).unwrap(); + assert_eq!( + log, "1\n", + "pre-render scripts must not re-run on file changes" + ); + + server.abort(); +} diff --git a/crates/quarto/src/commands/publish.rs b/crates/quarto/src/commands/publish.rs index b07e0da44..c3e9d8e63 100644 --- a/crates/quarto/src/commands/publish.rs +++ b/crates/quarto/src/commands/publish.rs @@ -13,6 +13,7 @@ use std::sync::Arc; use anyhow::{Context, Result}; use async_trait::async_trait; use quarto_core::project::orchestrator::{ProjectPipeline, project_type_for}; +use quarto_core::project::render_scripts; use quarto_core::{Format, ProjectContext, RenderToFileOptions}; use quarto_publish::cli::{PublishCli, validate_and_resolve}; use quarto_publish::renderer::{PublishRenderFlags, PublishRenderer}; @@ -211,6 +212,39 @@ impl PublishRenderer for ProjectPublishRenderer { let result: Result = pollster::block_on(async move { let mut project = ProjectContext::discover(&project_dir, runtime.as_ref()) .map_err(|e| PublishError::Other(anyhow::anyhow!("{e}")))?; + + // bd-w348iu63: run `project.pre-render` scripts before + // the pipeline, then re-discover so script-created + // inputs are rendered and config edits are honored + // (`project.type` / `project.output-dir` changes are + // forbidden). Same bracket as `q2 render`'s + // `execute_project`; a publish renders the full project. + if !project.config.pre_render_scripts.is_empty() { + let input_files = + publish_relative_paths(project.files.iter().map(|f| &f.input), &project.dir); + let ctx = render_scripts::RenderScriptsContext { + project_dir: &project.dir, + output_dir: &project.output_dir, + config_path: project.config.config_path.as_deref(), + render_all: true, + quiet: false, + file_count: input_files.len(), + }; + render_scripts::run_render_scripts( + render_scripts::ScriptPhase::PreRender, + &project.config.pre_render_scripts, + &ctx, + &input_files, + ) + .map_err(|e| PublishError::Other(anyhow::anyhow!("{e}")))?; + + let re_project = ProjectContext::discover(&project_dir, runtime.as_ref()) + .map_err(|e| PublishError::Other(anyhow::anyhow!("{e}")))?; + render_scripts::check_forbidden_mutations(&project.config, &re_project.config) + .map_err(|e| PublishError::Other(anyhow::anyhow!("{e}")))?; + project = re_project; + } + let project_type = project_type_for(&project); let format = Format::from_format_string("html") .map_err(|e| PublishError::Other(anyhow::anyhow!("{e}")))?; @@ -229,6 +263,33 @@ impl PublishRenderer for ProjectPublishRenderer { .await .map_err(|e| PublishError::Other(anyhow::anyhow!("{e}")))?; + // bd-w348iu63: `project.post-render` scripts run after + // the render, before the publish upload — files they add + // to the output dir are picked up by the sidecar walk + // below. `QUARTO_PROJECT_OUTPUT_FILES` lists the actual + // pipeline outputs, project-relative. + if !project.config.post_render_scripts.is_empty() { + let output_files = publish_relative_paths( + summary.outputs.iter().map(|o| &o.output_path), + &project.dir, + ); + let ctx = render_scripts::RenderScriptsContext { + project_dir: &project.dir, + output_dir: &project.output_dir, + config_path: project.config.config_path.as_deref(), + render_all: true, + quiet: false, + file_count: summary.outputs.len(), + }; + render_scripts::run_render_scripts( + render_scripts::ScriptPhase::PostRender, + &project.config.post_render_scripts, + &ctx, + &output_files, + ) + .map_err(|e| PublishError::Other(anyhow::anyhow!("{e}")))?; + } + // Translate the summary into PublishFiles by collecting // each output path relative to the project's output dir. let output_dir = project.output_dir.clone(); @@ -310,6 +371,20 @@ impl PublishRenderer for ProjectPublishRenderer { } } +/// Make each path relative to `base` for the render-script file-list +/// contract (paths outside `base` pass through unchanged). +fn publish_relative_paths<'a>( + paths: impl Iterator, + base: &std::path::Path, +) -> Vec { + paths + .map(|p| { + p.strip_prefix(base) + .map_or_else(|_| p.clone(), |r| r.to_path_buf()) + }) + .collect() +} + /// Walk `output_dir` and append every regular file (relative, /// forward-slash) to `files`. fn collect_sidecar_files(output_dir: &std::path::Path, files: &mut Vec) -> Result<()> { diff --git a/crates/quarto/src/commands/render.rs b/crates/quarto/src/commands/render.rs index 9d7c19c23..a7ebb096d 100644 --- a/crates/quarto/src/commands/render.rs +++ b/crates/quarto/src/commands/render.rs @@ -35,6 +35,7 @@ use quarto_core::attribution::AttributionMode; use quarto_core::project::orchestrator::{ DiagnosticCounts, ProjectPipeline, RenderMode, project_type_for, }; +use quarto_core::project::render_scripts; use quarto_core::{Format, ProjectContext, QuartoError, RenderToFileOptions}; use quarto_error_reporting::{ DiagnosticMessage, DiagnosticMessageBuilder, JsonDiagnostic, JsonPass1Failure, @@ -97,6 +98,9 @@ pub struct RenderArgs { /// "stop at first *failure*" — a promoted warning does not stop /// the render. pub strict: bool, + /// Skip the project's `pre-render` / `post-render` scripts + /// (bd-w348iu63). The render itself is unaffected. + pub no_render_scripts: bool, } /// What to render after argument classification. @@ -766,6 +770,51 @@ fn execute_project( let mut project = ProjectContext::discover(&project_dir, runtime_arc.as_ref()) .context("Failed to discover project context")?; + // bd-w348iu63: warn about the likely `pre_render` / `post_render` + // misspellings (Q2 has no schema layer; unknown keys are + // otherwise silently ignored). + for diagnostic in render_scripts::underscore_typo_diagnostics(&project.config) { + eprintln!("{}", diagnostic.to_text(None)); + } + + // bd-w348iu63: run `project.pre-render` scripts, then re-discover + // the project so script-created inputs and config edits are + // picked up. `project.type` / `project.output-dir` changes are + // forbidden (Q1-compatible mutation guard). + let run_scripts = !args.no_render_scripts; + let render_all = targets.is_none(); + if run_scripts && !project.config.pre_render_scripts.is_empty() { + let input_files = match targets.as_deref() { + Some(t) => paths_relative_to(t.iter(), &project.dir), + None => paths_relative_to(project.files.iter().map(|f| &f.input), &project.dir), + }; + let ctx = render_scripts::RenderScriptsContext { + project_dir: &project.dir, + output_dir: &project.output_dir, + config_path: project.config.config_path.as_deref(), + render_all, + quiet: args.quiet, + file_count: input_files.len(), + }; + if let Err(parse_error) = render_scripts::run_render_scripts( + render_scripts::ScriptPhase::PreRender, + &project.config.pre_render_scripts, + &ctx, + &input_files, + ) { + exit_with_parse_error(parse_error, args); + } + + let re_project = ProjectContext::discover(&project_dir, runtime_arc.as_ref()) + .context("Failed to re-discover project context after pre-render scripts")?; + if let Err(parse_error) = + render_scripts::check_forbidden_mutations(&project.config, &re_project.config) + { + exit_with_parse_error(parse_error, args); + } + project = re_project; + } + quarto_util::user_status!( args.quiet, "Rendering project: {} (type: {})", @@ -829,9 +878,60 @@ fn execute_project( if should_exit_nonzero(&summary, args.strict) { std::process::exit(1); } + + // bd-w348iu63: run `project.post-render` scripts at the very end, + // only after a successful render (Q1-compatible: render errors + // skip them). The env is computed fresh from the actual results — + // `QUARTO_PROJECT_OUTPUT_FILES` lists what the pipeline really + // produced — fixing Q1's stale-env wart. + if run_scripts && !project.config.post_render_scripts.is_empty() { + let output_files = + paths_relative_to(summary.outputs.iter().map(|o| &o.output_path), &project.dir); + let ctx = render_scripts::RenderScriptsContext { + project_dir: &project.dir, + output_dir: &project.output_dir, + config_path: project.config.config_path.as_deref(), + render_all, + quiet: args.quiet, + file_count: total_files, + }; + if let Err(parse_error) = render_scripts::run_render_scripts( + render_scripts::ScriptPhase::PostRender, + &project.config.post_render_scripts, + &ctx, + &output_files, + ) { + exit_with_parse_error(parse_error, args); + } + } Ok(()) } +/// Make each path relative to `base` (paths already relative, or +/// outside `base`, pass through unchanged). Used for the +/// `QUARTO_PROJECT_INPUT_FILES` / `QUARTO_PROJECT_OUTPUT_FILES` +/// script contract, which promises project-relative paths. +fn paths_relative_to<'a>(paths: impl Iterator, base: &Path) -> Vec { + paths + .map(|p| { + p.strip_prefix(base) + .map_or_else(|_| p.clone(), |r| r.to_path_buf()) + }) + .collect() +} + +/// Print a render-script [`ParseError`](quarto_core::error::ParseError) +/// through the same channel the pipeline's parse errors use +/// (ariadne text, or NDJSON under `--json-errors`) and exit non-zero. +fn exit_with_parse_error(parse_error: quarto_core::ParseError, args: &RenderArgs) -> ! { + if args.json_errors { + emit_parse_error_json(&parse_error, None); + } else { + eprintln!("{}", parse_error); + } + std::process::exit(1); +} + /// Strict exit policy for `quarto render` (Decision D1, bd-creo). /// /// Any file that failed Pass-1 (parse / metadata error) or Pass-2 diff --git a/crates/quarto/src/main.rs b/crates/quarto/src/main.rs index fa1fb1a5c..8d92c8afb 100644 --- a/crates/quarto/src/main.rs +++ b/crates/quarto/src/main.rs @@ -170,6 +170,10 @@ enum Commands { /// non-zero. Useful in CI. Does not stop the render early. #[arg(long)] strict: bool, + + /// Skip the project's `pre-render` and `post-render` scripts. + #[arg(long = "no-render-scripts")] + no_render_scripts: bool, }, /// Start a live preview of a Quarto document or project. @@ -754,6 +758,7 @@ fn main() -> Result<()> { json_errors, fail_fast, strict, + no_render_scripts, .. } => commands::render::execute(commands::render::RenderArgs { inputs, @@ -768,6 +773,7 @@ fn main() -> Result<()> { json_errors, fail_fast, strict, + no_render_scripts, }), Commands::Preview { path, diff --git a/crates/quarto/tests/integration/main.rs b/crates/quarto/tests/integration/main.rs index e31fbb924..8bf5cf5e8 100644 --- a/crates/quarto/tests/integration/main.rs +++ b/crates/quarto/tests/integration/main.rs @@ -10,6 +10,7 @@ pub mod preview_cli; pub mod render_cli_e2e; pub mod render_exit_codes; pub mod render_integration; +pub mod render_scripts_cli; pub mod revealjs_cli; pub mod smoke_all; pub mod strict_mode; diff --git a/crates/quarto/tests/integration/render_scripts_cli.rs b/crates/quarto/tests/integration/render_scripts_cli.rs new file mode 100644 index 000000000..c47249fcb --- /dev/null +++ b/crates/quarto/tests/integration/render_scripts_cli.rs @@ -0,0 +1,685 @@ +/* + * render_scripts_cli.rs + * Copyright (c) 2026 Posit, PBC + * + * bd-w348iu63 — end-to-end CLI tests for project pre-render / + * post-render scripts. + */ + +//! End-to-end CLI tests for `project.pre-render` / `project.post-render` +//! scripts (bd-w348iu63). +//! +//! These spawn the real `q2` binary against fixture projects whose +//! scripts observe the `QUARTO_PROJECT_*` environment contract by +//! writing what they see to files the test then asserts on. +//! +//! Most fixtures use Python scripts (dispatched by the `.py` +//! extension) and skip gracefully when no Python is on PATH; the +//! direct-exec path gets platform-gated shell / batch variants. +//! Plan: claude-notes/plans/2026-07-29-pre-post-render-scripts.md + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use tempfile::TempDir; + +const Q2_BIN: &str = env!("CARGO_BIN_EXE_q2"); + +fn write_file(path: &Path, contents: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, contents).unwrap(); +} + +fn canonical(p: &Path) -> PathBuf { + p.canonicalize().unwrap_or_else(|_| p.to_path_buf()) +} + +/// Run `q2 render ` from `cwd` with extra environment +/// variables applied to the child. +fn run_q2_env(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> std::process::Output { + let mut cmd = Command::new(Q2_BIN); + cmd.current_dir(cwd); + cmd.arg("render"); + for a in args { + cmd.arg(a); + } + for (k, v) in env { + cmd.env(k, v); + } + cmd.output().expect("spawn q2 binary") +} + +fn run_q2(cwd: &Path, args: &[&str]) -> std::process::Output { + run_q2_env(cwd, args, &[]) +} + +/// Find a Python interpreter for fixture scripts, mirroring the +/// candidate order the dispatcher uses. `None` ⇒ the test should +/// skip (prints a note so the skip is visible in test output). +fn find_python() -> Option<&'static str> { + let candidates: &[&str] = if cfg!(windows) { + &["python", "python3"] + } else { + &["python3", "python"] + }; + for candidate in candidates { + if let Ok(status) = Command::new(candidate) + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + && status.success() + { + return Some(candidate); + } + } + None +} + +/// Skip macro: returns early from the test with a visible note when +/// no Python interpreter is available. +macro_rules! require_python { + () => { + match find_python() { + Some(py) => py, + None => { + eprintln!("SKIP: no python interpreter on PATH"); + return; + } + } + }; +} + +fn write_minimal_project(project: &Path, quarto_yml: &str) { + write_file(&project.join("_quarto.yml"), quarto_yml); + write_file( + &project.join("index.qmd"), + "---\ntitle: Home\n---\n\nHome body.\n", + ); + write_file(&project.join("a.qmd"), "---\ntitle: A\n---\n\nA body.\n"); +} + +/// A Python script that dumps every `QUARTO_*` environment variable +/// to `` as `KEY=VALUE` lines (values with newlines are +/// JSON-escaped so the dump stays line-oriented). +fn env_dump_script(name: &str) -> String { + format!( + r#"import os, json +with open({name:?}, "w") as f: + for k in sorted(os.environ): + if k.startswith("QUARTO_"): + f.write(k + "=" + json.dumps(os.environ[k]) + "\n") +"# + ) +} + +/// Parse an env-dump file produced by [`env_dump_script`] into +/// (key, decoded-value) pairs. +fn read_env_dump(path: &Path) -> Vec<(String, String)> { + let content = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("read env dump {}: {e}", path.display())); + content + .lines() + .map(|line| { + let (k, v) = line.split_once('=').expect("KEY=VALUE line"); + let decoded: String = serde_json::from_str(v).expect("JSON-quoted value"); + (k.to_string(), decoded) + }) + .collect() +} + +fn dump_value<'a>(dump: &'a [(String, String)], key: &str) -> Option<&'a str> { + dump.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()) +} + +// === Tests ============================================================ + +/// A pre-render script creates a new `.qmd`; the same `q2 render` +/// invocation renders it (the project is discovered after the script +/// runs). String config form. +#[test] +fn pre_render_script_creates_input_file() { + require_python!(); + let temp = TempDir::new().unwrap(); + let project = canonical(temp.path()); + write_minimal_project( + &project, + "project:\n type: website\n output-dir: _site\n pre-render: gen.py\n", + ); + write_file( + &project.join("gen.py"), + r#"import os +if not os.path.exists("generated.qmd"): + with open("generated.qmd", "w") as f: + f.write("---\ntitle: Generated\n---\n\nGenerated body.\n") +"#, + ); + + let out = run_q2(&project, &[]); + assert!( + out.status.success(), + "render should succeed; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + project.join("generated.qmd").exists(), + "pre-render script should have created generated.qmd" + ); + let html_path = project.join("_site/generated.html"); + assert!( + html_path.exists(), + "script-created input should be rendered in the same pass" + ); + let html = std::fs::read_to_string(&html_path).unwrap(); + assert!( + html.contains("Generated body."), + "rendered HTML should contain the generated body; got:\n{html}" + ); +} + +/// Post-render scripts receive `QUARTO_PROJECT_OUTPUT_FILES` +/// (newline-separated, project-relative) computed fresh from the +/// pipeline's actual outputs, plus the shared vars. +#[test] +fn post_render_script_receives_output_files() { + require_python!(); + let temp = TempDir::new().unwrap(); + let project = canonical(temp.path()); + write_minimal_project( + &project, + "project:\n type: website\n output-dir: _site\n post-render:\n - capture.py\n", + ); + write_file( + &project.join("capture.py"), + &env_dump_script("post-env.txt"), + ); + + let out = run_q2(&project, &[]); + assert!( + out.status.success(), + "render should succeed; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let dump = read_env_dump(&project.join("post-env.txt")); + let output_files = + dump_value(&dump, "QUARTO_PROJECT_OUTPUT_FILES").expect("OUTPUT_FILES set for post-render"); + let listed: Vec<&str> = output_files.lines().collect(); + // Project-relative paths, one per line. Both pages must appear. + let expect_index = Path::new("_site").join("index.html"); + let expect_a = Path::new("_site").join("a.html"); + for expected in [&expect_index, &expect_a] { + assert!( + listed.iter().any(|l| Path::new(l) == *expected), + "OUTPUT_FILES should list {}; got: {listed:?}", + expected.display() + ); + } + // Post-render must NOT see the pre-render-only var. + assert!( + dump_value(&dump, "QUARTO_PROJECT_INPUT_FILES").is_none(), + "INPUT_FILES is pre-render-only" + ); + // Shared vars. + assert_eq!( + dump_value(&dump, "QUARTO_PROJECT_DIR").map(Path::new), + Some(project.as_path()), + "QUARTO_PROJECT_DIR should be the absolute project dir" + ); + assert_eq!( + dump_value(&dump, "QUARTO_PROJECT_OUTPUT_DIR").map(Path::new), + Some(project.join("_site").as_path()), + "QUARTO_PROJECT_OUTPUT_DIR should be the absolute output dir" + ); +} + +/// Full-project render: `QUARTO_PROJECT_RENDER_ALL` is `"1"` and +/// `QUARTO_PROJECT_INPUT_FILES` lists every input, project-relative. +#[test] +fn env_contract_full_render() { + require_python!(); + let temp = TempDir::new().unwrap(); + let project = canonical(temp.path()); + write_minimal_project( + &project, + "project:\n type: website\n output-dir: _site\n pre-render: dump.py\n", + ); + write_file(&project.join("dump.py"), &env_dump_script("pre-env.txt")); + + let out = run_q2(&project, &[]); + assert!( + out.status.success(), + "render should succeed; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let dump = read_env_dump(&project.join("pre-env.txt")); + assert_eq!( + dump_value(&dump, "QUARTO_PROJECT_RENDER_ALL"), + Some("1"), + "full render sets RENDER_ALL=1" + ); + let input_files = + dump_value(&dump, "QUARTO_PROJECT_INPUT_FILES").expect("INPUT_FILES set for pre-render"); + let listed: Vec<&str> = input_files.lines().collect(); + for expected in ["index.qmd", "a.qmd"] { + assert!( + listed.iter().any(|l| Path::new(l) == Path::new(expected)), + "INPUT_FILES should list {expected}; got: {listed:?}" + ); + } + assert_eq!( + dump_value(&dump, "QUARTO_PROJECT_DIR").map(Path::new), + Some(project.as_path()), + ); + // Post-render-only var must be absent during pre-render. + assert!( + dump_value(&dump, "QUARTO_PROJECT_OUTPUT_FILES").is_none(), + "OUTPUT_FILES is post-render-only" + ); +} + +/// Single-file render *inside* a project still runs the scripts +/// (Q1-compatible), but `QUARTO_PROJECT_RENDER_ALL` is absent and +/// `INPUT_FILES` names only the targeted file. +#[test] +fn env_contract_subset_render() { + require_python!(); + let temp = TempDir::new().unwrap(); + let project = canonical(temp.path()); + write_minimal_project( + &project, + "project:\n type: website\n output-dir: _site\n pre-render: dump.py\n", + ); + write_file(&project.join("dump.py"), &env_dump_script("pre-env.txt")); + + let out = run_q2(&project, &["a.qmd"]); + assert!( + out.status.success(), + "subset render should succeed; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let dump = read_env_dump(&project.join("pre-env.txt")); + assert_eq!( + dump_value(&dump, "QUARTO_PROJECT_RENDER_ALL"), + None, + "RENDER_ALL must be absent (not \"0\") on a partial render" + ); + let input_files = dump_value(&dump, "QUARTO_PROJECT_INPUT_FILES").expect("INPUT_FILES set"); + let listed: Vec<&str> = input_files.lines().collect(); + assert_eq!( + listed.len(), + 1, + "subset render passes only the targeted file; got: {listed:?}" + ); + assert_eq!(Path::new(listed[0]), Path::new("a.qmd")); +} + +/// A failing pre-render script aborts the render with a diagnostic +/// naming the script and its exit code; later scripts do not run and +/// nothing is rendered. +#[test] +fn failing_pre_render_script_aborts() { + require_python!(); + let temp = TempDir::new().unwrap(); + let project = canonical(temp.path()); + write_minimal_project( + &project, + "project:\n type: website\n output-dir: _site\n pre-render:\n - fail.py\n - after.py\n", + ); + write_file( + &project.join("fail.py"), + "import sys\nsys.stderr.write(\"boom from fail.py\\n\")\nsys.exit(3)\n", + ); + write_file( + &project.join("after.py"), + "open(\"after-ran.txt\", \"w\").write(\"x\")\n", + ); + + let out = run_q2(&project, &[]); + assert!( + !out.status.success(), + "failing pre-render script must abort the render" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("fail.py"), + "diagnostic should name the failing script; got: {stderr}" + ); + assert!( + stderr.contains('3'), + "diagnostic should report the exit code (3); got: {stderr}" + ); + // The script's own stderr passes through. + assert!( + stderr.contains("boom from fail.py"), + "script stderr should be visible; got: {stderr}" + ); + assert!( + !project.join("after-ran.txt").exists(), + "scripts after the failing one must not run" + ); + assert!( + !project.join("_site/index.html").exists(), + "pre-render failure must abort before any rendering" + ); +} + +/// A pre-render script that changes `project.output-dir` in +/// `_quarto.yml` triggers the mutation guard: the render aborts with +/// a diagnostic. +#[test] +fn forbidden_output_dir_mutation_aborts() { + require_python!(); + let temp = TempDir::new().unwrap(); + let project = canonical(temp.path()); + write_minimal_project( + &project, + "project:\n type: website\n output-dir: _site\n pre-render: mutate.py\n", + ); + write_file( + &project.join("mutate.py"), + r#"with open("_quarto.yml", "w") as f: + f.write("project:\n type: website\n output-dir: _other\n pre-render: mutate.py\n") +"#, + ); + + let out = run_q2(&project, &[]); + assert!( + !out.status.success(), + "output-dir mutation by a pre-render script must abort the render; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("output-dir"), + "diagnostic should name the forbidden key; got: {stderr}" + ); + assert!( + !project.join("_other").exists() && !project.join("_site/index.html").exists(), + "no rendering should happen after a forbidden mutation" + ); +} + +/// A pre-render script that changes `project.type` also trips the +/// mutation guard. +#[test] +fn forbidden_project_type_mutation_aborts() { + require_python!(); + let temp = TempDir::new().unwrap(); + let project = canonical(temp.path()); + write_minimal_project( + &project, + "project:\n type: website\n output-dir: _site\n pre-render: mutate.py\n", + ); + write_file( + &project.join("mutate.py"), + r#"with open("_quarto.yml", "w") as f: + f.write("project:\n type: default\n output-dir: _site\n pre-render: mutate.py\n") +"#, + ); + + let out = run_q2(&project, &[]); + assert!( + !out.status.success(), + "project.type mutation by a pre-render script must abort the render" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("type"), + "diagnostic should name the forbidden key; got: {stderr}" + ); +} + +/// List config form: multiple pre-render scripts run in declaration +/// order, each with the project root as cwd. +#[test] +fn list_form_runs_scripts_in_order() { + require_python!(); + let temp = TempDir::new().unwrap(); + let project = canonical(temp.path()); + write_minimal_project( + &project, + "project:\n type: website\n output-dir: _site\n pre-render:\n - one.py\n - two.py\n", + ); + write_file( + &project.join("one.py"), + "open(\"order.log\", \"a\").write(\"one\\n\")\n", + ); + write_file( + &project.join("two.py"), + "open(\"order.log\", \"a\").write(\"two\\n\")\n", + ); + + let out = run_q2(&project, &[]); + assert!( + out.status.success(), + "render should succeed; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let log = std::fs::read_to_string(project.join("order.log")).expect("order.log written"); + assert_eq!(log, "one\ntwo\n", "scripts must run in declaration order"); +} + +/// An explicit-interpreter command line (` script.py arg`) +/// bypasses extension dispatch; arguments (including double-quoted +/// ones) reach the script. +#[test] +fn explicit_interpreter_command_line_with_args() { + let py = require_python!(); + let temp = TempDir::new().unwrap(); + let project = canonical(temp.path()); + write_minimal_project( + &project, + &format!( + "project:\n type: website\n output-dir: _site\n pre-render: {py} args.py --flag \"two words\"\n" + ), + ); + write_file( + &project.join("args.py"), + r#"import sys +with open("args.txt", "w") as f: + f.write("\n".join(sys.argv[1:])) +"#, + ); + + let out = run_q2(&project, &[]); + assert!( + out.status.success(), + "render should succeed; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let args = std::fs::read_to_string(project.join("args.txt")).expect("args.txt written"); + assert_eq!( + args, "--flag\ntwo words", + "quoted argument should arrive as a single argv entry" + ); +} + +/// `--no-render-scripts` skips both script phases. +#[test] +fn no_render_scripts_flag_skips_scripts() { + require_python!(); + let temp = TempDir::new().unwrap(); + let project = canonical(temp.path()); + write_minimal_project( + &project, + "project:\n type: website\n output-dir: _site\n pre-render: pre.py\n post-render: post.py\n", + ); + write_file( + &project.join("pre.py"), + "open(\"pre-ran.txt\", \"w\").write(\"x\")\n", + ); + write_file( + &project.join("post.py"), + "open(\"post-ran.txt\", \"w\").write(\"x\")\n", + ); + + let out = run_q2(&project, &["--no-render-scripts"]); + assert!( + out.status.success(), + "render should succeed; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !project.join("pre-ran.txt").exists(), + "--no-render-scripts must skip pre-render scripts" + ); + assert!( + !project.join("post-ran.txt").exists(), + "--no-render-scripts must skip post-render scripts" + ); + assert!( + project.join("_site/index.html").exists(), + "the render itself still happens" + ); +} + +/// `QUARTO_USE_FILE_FOR_PROJECT_INPUT_FILES=` diverts the input +/// list to a file; the env var is then not set on the script. +#[test] +fn input_files_escape_hatch_writes_file() { + require_python!(); + let temp = TempDir::new().unwrap(); + let project = canonical(temp.path()); + write_minimal_project( + &project, + "project:\n type: website\n output-dir: _site\n pre-render: dump.py\n", + ); + write_file(&project.join("dump.py"), &env_dump_script("pre-env.txt")); + + let list_file = project.join("input-list.txt"); + let out = run_q2_env( + &project, + &[], + &[( + "QUARTO_USE_FILE_FOR_PROJECT_INPUT_FILES", + list_file.to_str().unwrap(), + )], + ); + assert!( + out.status.success(), + "render should succeed; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let dump = read_env_dump(&project.join("pre-env.txt")); + assert!( + dump_value(&dump, "QUARTO_PROJECT_INPUT_FILES").is_none(), + "with the escape hatch active the env var must not be set" + ); + let listing = std::fs::read_to_string(&list_file).expect("input list file written"); + let listed: Vec<&str> = listing.lines().collect(); + for expected in ["index.qmd", "a.qmd"] { + assert!( + listed.iter().any(|l| Path::new(l) == Path::new(expected)), + "list file should contain {expected}; got: {listed:?}" + ); + } +} + +/// Direct-exec dispatch (no recognized extension): a shell script +/// with shebang + exec bit on Unix. +#[cfg(unix)] +#[test] +fn direct_exec_shell_script_runs() { + use std::os::unix::fs::PermissionsExt; + let temp = TempDir::new().unwrap(); + let project = canonical(temp.path()); + write_minimal_project( + &project, + "project:\n type: website\n output-dir: _site\n pre-render: mark.sh\n", + ); + let script = project.join("mark.sh"); + write_file(&script, "#!/bin/sh\necho marker > sh-ran.txt\n"); + let mut perms = std::fs::metadata(&script).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script, perms).unwrap(); + + let out = run_q2(&project, &[]); + assert!( + out.status.success(), + "render should succeed; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + project.join("sh-ran.txt").exists(), + "direct-exec shell script should have run from the project root" + ); +} + +/// Direct-exec dispatch on Windows: a `.bat` script. +#[cfg(windows)] +#[test] +fn direct_exec_batch_script_runs() { + let temp = TempDir::new().unwrap(); + let project = canonical(temp.path()); + write_minimal_project( + &project, + "project:\n type: website\n output-dir: _site\n pre-render: mark.bat\n", + ); + write_file( + &project.join("mark.bat"), + "@echo off\r\necho marker > bat-ran.txt\r\n", + ); + + let out = run_q2(&project, &[]); + assert!( + out.status.success(), + "render should succeed; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + project.join("bat-ran.txt").exists(), + "direct-exec batch script should have run from the project root" + ); +} + +/// Underscore-typo guard: `project.pre_render` (wrong spelling) emits +/// a warning naming the correct key; the render still succeeds and no +/// script runs. +#[test] +fn underscore_typo_warns_and_renders() { + let temp = TempDir::new().unwrap(); + let project = canonical(temp.path()); + write_minimal_project( + &project, + "project:\n type: website\n output-dir: _site\n pre_render: nope.py\n", + ); + // Deliberately no nope.py on disk — it must never be looked up. + + let out = run_q2(&project, &[]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "typo is a warning, not an error; stderr: {stderr}" + ); + assert!( + stderr.contains("pre_render") && stderr.contains("pre-render"), + "warning should name both the typo and the correct spelling; got: {stderr}" + ); + assert!(project.join("_site/index.html").exists()); +} + +/// A bare single-file render outside any project never runs scripts +/// (and the script wiring must not require a project config). +#[test] +fn no_project_renders_without_scripts() { + let temp = TempDir::new().unwrap(); + let dir = canonical(temp.path()); + write_file( + &dir.join("solo.qmd"), + "---\ntitle: Solo\n---\n\nSolo body.\n", + ); + + let out = run_q2(&dir, &["solo.qmd"]); + assert!( + out.status.success(), + "single-file render outside a project should succeed; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(dir.join("solo.html").exists()); +} diff --git a/crates/wasm-quarto-hub-client/src/lib.rs b/crates/wasm-quarto-hub-client/src/lib.rs index dfac6b955..426cec261 100644 --- a/crates/wasm-quarto-hub-client/src/lib.rs +++ b/crates/wasm-quarto-hub-client/src/lib.rs @@ -25,7 +25,8 @@ use quarto_core::{ render_qmd_to_preview_ast, }; use quarto_error_reporting::{ - DiagnosticMessage, JsonDiagnostic, JsonPass1Failure, diagnostic_to_json, with_source_file, + DiagnosticMessage, DiagnosticMessageBuilder, JsonDiagnostic, JsonPass1Failure, + diagnostic_to_json, with_source_file, }; use quarto_pandoc_types::ConfigValue; use quarto_sass::{ @@ -1770,6 +1771,11 @@ async fn render_project_active_page_to_response( // converts these to Monaco markers. let mut all_diags = active_output.diagnostics.clone(); all_diags.extend(summary.project_diagnostics); + // bd-w348iu63: project render scripts can't run in the browser — + // surface a one-time warning instead of silently ignoring them. + if let Some(diag) = render_scripts_unsupported_diagnostic(&project.config) { + all_diags.push(diag); + } let warnings = diagnostics_to_json(&all_diags, &active_output.source_context); // Plan 2A item 11: theme fingerprint is captured at the @@ -1843,6 +1849,34 @@ async fn render_project_active_page_to_response( .unwrap() } +/// bd-w348iu63: once-per-session warning when the project declares +/// `project.pre-render` / `project.post-render` scripts, which the +/// browser preview cannot run (no subprocesses in WASM). Returns +/// `None` when no scripts are configured or the warning already +/// fired. WASM is single-threaded, but `AtomicBool` keeps the static +/// safe by construction. +fn render_scripts_unsupported_diagnostic(config: &ProjectConfig) -> Option { + use std::sync::atomic::{AtomicBool, Ordering}; + static WARNED: AtomicBool = AtomicBool::new(false); + if config.pre_render_scripts.is_empty() && config.post_render_scripts.is_empty() { + return None; + } + if WARNED.swap(true, Ordering::Relaxed) { + return None; + } + Some( + DiagnosticMessageBuilder::warning("Project render scripts do not run in the hub preview") + .with_code("Q-5-12") + .problem( + "This project configures `project.pre-render` / `project.post-render` \ + scripts, which cannot run in the browser. The preview renders without \ + them; use `q2 render` on a machine with the interpreters installed to \ + run the scripts.", + ) + .build(), + ) +} + /// Build a `success: false` response with no diagnostics. fn error_response(msg: impl Into) -> String { serde_json::to_string(&RenderResponse { diff --git a/docs/_quarto.yml b/docs/_quarto.yml index 857ffc6ea..f0d78c31a 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -28,6 +28,7 @@ website: contents: - guides/authoring/index.qmd - guides/projects/create.qmd + - guides/projects/scripts.qmd - guides/publishing/index.qmd - id: Authoring collapse-level: 1 diff --git a/docs/guides/projects/scripts.qmd b/docs/guides/projects/scripts.qmd new file mode 100644 index 000000000..65127a0ca --- /dev/null +++ b/docs/guides/projects/scripts.qmd @@ -0,0 +1,135 @@ +--- +title: "Pre and Post Render Scripts" +--- + +Projects can run scripts before and after each render. Use them to +generate input files, fetch data, clean up artifacts, or notify other +systems when a render completes. + +## Configuration + +Declare scripts under the `project` key in `_quarto.yml`, as a single +entry or a list: + +``` yaml +project: + type: website + pre-render: prepare.py + post-render: + - cleanup.R + - tools/notify.sh +``` + +Scripts run in declaration order, for every project type. An entry can +also be a full command line (double quotes group arguments): + +``` yaml +project: + pre-render: python3 tools/gen.py --label "site build" +``` + +Scripts run on every project render, including a render of a single +file inside the project. A single-file render outside any project +(no `_quarto.yml`) never runs scripts. Pass `--no-render-scripts` to +`q2 render` to skip both script phases for one invocation. + +## How scripts are run + +Each entry's first token selects the interpreter by file extension: + +| Extension | Runs with | Override | +|---|---|---| +| `.py` | `python3` (or `python`) from `PATH` | `QUARTO_PYTHON` | +| `.r`, `.R` | `Rscript` | `QUARTO_R` | +| `.ts`, `.js` | `node` from `PATH` | `QUARTO_NODE` | +| anything else | executed directly | — | + +A directly-executed script (e.g. `.sh`) needs a shebang line and the +executable bit on Unix; on Windows, `.bat`/`.cmd` files work directly. +For anything unusual, write an explicit command line +(`pre-render: my-interpreter tools/gen.xyz`), which bypasses extension +dispatch entirely. + +All scripts run with the **project root as the working directory**. + +If a script exits with a non-zero status, the render is aborted with a +diagnostic pointing at the script's entry in `_quarto.yml` +([`Q-5-8`](/errors/index.qmd)); remaining scripts in the list do not +run. + +## Environment variables + +Scripts receive the following environment variables in addition to the +parent environment: + +| Variable | Value | +|---|---| +| `QUARTO_PROJECT_DIR` | Absolute path of the project directory | +| `QUARTO_PROJECT_OUTPUT_DIR` | Absolute path of the output directory | +| `QUARTO_PROJECT_RENDER_ALL` | `1` when the whole project is being rendered; **absent** otherwise (e.g. `q2 render page.qmd`) | +| `QUARTO_PROJECT_INPUT_FILES` | Pre-render only: newline-separated project-relative paths of the files about to render | +| `QUARTO_PROJECT_OUTPUT_FILES` | Post-render only: newline-separated project-relative paths of the produced outputs (e.g. `_site/index.html`) | +| `QUARTO_PROJECT_SCRIPT_PROGRESS` | `1` when it is appropriate for the script to print progress (multi-file render, not `--quiet`), else `0` | +| `QUARTO_PROJECT_SCRIPT_QUIET` | `1` under `--quiet`, else `0` | + +For example, a script that only wants to act on full renders: + +``` python +import os + +if not os.getenv("QUARTO_PROJECT_RENDER_ALL"): + exit() + +# ... work that should only happen on full project renders +``` + +Very large projects can overflow the environment size limit with the +file lists. Set `QUARTO_USE_FILE_FOR_PROJECT_INPUT_FILES=` +(resp. `QUARTO_USE_FILE_FOR_PROJECT_OUTPUT_FILES`) before invoking +`q2 render` and the list is written to that file instead of the +environment variable. + +## Modifying the project from a pre-render script + +Pre-render scripts may modify the project: create new `.qmd` input +files, edit `_quarto.yml`, update data files. Quarto reads the project +**after** the pre-render scripts finish, so generated files are +rendered in the same pass and appear in navigation. + +Two settings are fixed once the scripts start: `project.type` and +`project.output-dir`. A pre-render script that changes either aborts +the render ([`Q-5-9`](/errors/index.qmd)) — the scripts already +received `QUARTO_PROJECT_OUTPUT_DIR`, so a change would hand them a +stale value. + +## Preview + +`q2 preview` runs pre-render scripts **once, when the preview server +starts**. They are not re-run on file changes or configuration edits — +restart the preview to re-run them. Post-render scripts never run in +preview. A script failure at preview boot is reported but the preview +keeps serving. + +In the browser-based Quarto Hub preview, scripts cannot run at all; a +one-time warning is shown and pages render without them. + +## Differences from Quarto 1 + +If you are porting a project from Quarto 1: + +- **`.ts`/`.js` scripts** run with `node` from `PATH` (or + `QUARTO_NODE`). Quarto 1's bundled Deno and its import-map scheme + are not carried forward. TypeScript entries only work where `node` + can execute them; otherwise name an interpreter explicitly in the + command line. +- **`.lua` scripts** are no longer special-cased (Quarto 1 ran them as + Pandoc Lua filters). Use an explicit interpreter if you need them. +- **Preview cadence**: Quarto 1 re-ran scripts on every preview + re-render; Quarto 2 runs pre-render scripts at preview boot only. +- **`QUARTO_PROJECT_OUTPUT_FILES` is computed fresh** after the render + from the actual outputs (in Quarto 1 the shared environment was + computed once before the render and could be stale). +- **Script failures produce a real diagnostic** with the script name, + exit code, and the `_quarto.yml` location (Quarto 1 raised an + empty error). +- **`--no-render-scripts`** is new.