diff --git a/llm-docs/synthetic-project-context.md b/llm-docs/synthetic-project-context.md new file mode 100644 index 00000000000..eb01f2d0cc4 --- /dev/null +++ b/llm-docs/synthetic-project-context.md @@ -0,0 +1,144 @@ +--- +main_commit: e768e5c2d +analyzed_date: 2026-05-06 +key_files: + - src/command/render/render-shared.ts + - src/project/project-context.ts + - src/project/types/single-file/single-file.ts + - src/project/types.ts + - src/command/preview/preview.ts + - src/command/preview/preview-shiny.ts +--- + +# Synthetic Project Context (`--output-dir` without `_quarto.yml`) + +How `quarto render foo.qmd --output-dir baz/` and `quarto preview foo.qmd +--output-dir baz/` work in a directory without `_quarto.yml`. + +## What it is + +`projectContextForDirectory()` (in `src/project/project-context.ts`) wraps +`projectContext(path, ctx, opts, /*force*/ true)`. When `force` is true, +`projectContext` walks the directory and produces a `ProjectContext` even +when no `_quarto.yml` is present — the resulting context has +`config: { project: {} }`, `files.input` populated, and `isSingleFile: false`. + +This is the **synthetic project** mechanism. It exists to allow the +project-only flag `--output-dir` to function on free-floating files. + +## Why two single-file mechanisms exist + +`singleFileProjectContext()` (in `src/project/types/single-file/single-file.ts`) +also produces a `ProjectContext` for a single file. They are not duplicates: + +| Mechanism | Purpose | `config` | `files.input` | `isSingleFile` | +|-----------|---------|----------|---------------|----------------| +| `singleFileProjectContext` | Default fallback for single-file renders, extension support | `{ project: {} }` (1.9+) | `[]` | `true` | +| `projectContextForDirectory` | Synthetic project for `--output-dir` without `_quarto.yml` | `{ project: {} }` (walked) | `[walked .qmd files]` | `false` | + +The shapes diverged for historical reasons: + +- `singleFileProjectContext` got `config` populated in 1.9 (Gordon, commit + `1f94440b8`) so extensions can contribute metadata to single-file renders. + This was for **extensions**, not `--output-dir`. +- `projectContextForDirectory` predates that (1.4, JJ, commit `71b4df65f`) + and is purpose-built for the `--output-dir` use case. + +## When the synthetic project is used in `render-shared.ts` + +`render()` chooses between mechanisms in this order: + +```typescript +let context = pContext || (await projectContext(path, nbContext, options)); + +// Promote to synthetic project when --output-dir is used and we don't +// have a real project (or the caller pre-passed only a single-file context). +if (options.flags?.outputDir && (!context || context.isSingleFile)) { + context = await projectContextForDirectory(path, nbContext, options); + options.forceClean = options.flags.clean !== false; +} + +// Otherwise, fall back to single-file context. +if (!context) { + context = await singleFileProjectContext(path, nbContext, options); +} +``` + +The `context.isSingleFile` arm covers the preview path (`preview.ts` +pre-creates a `singleFileProjectContext` and passes it as `pContext`). +Without the `isSingleFile` arm, the synthetic-project trigger is skipped +and the path falls through to `validateDocumentRenderFlags`, which throws +`The --output-dir flag can only be used when rendering projects.` — +regression behind `quarto-dev/quarto-cli#14489`. + +## `forceClean` and `.quarto` cleanup + +When the synthetic project is created, `options.forceClean` is set: + +```typescript +options.forceClean = options.flags.clean !== false; +``` + +This signals `project.ts` cleanup logic to remove the temporary `.quarto` +scratch directory at the end of the render (#9745, #13625). On Windows, +file handles must close before the directory is removed; the project +context's `cleanup()` is called first. + +For the preview path, `forceClean=true` is set on every re-render's +`RenderOptions`. This means `.quarto` is recreated and cleaned each +re-render. Sub-optimal for perf but correct behaviorally. + +## `validateDocumentRenderFlags` — the original gate + +```typescript +function validateDocumentRenderFlags(flags?: RenderFlags) { + if (flags) { + const projectOnly = { + "--output-dir": flags.outputDir, + "--site-url": flags.siteUrl, + }; + for (const arg of Object.keys(projectOnly)) { + if (projectOnly[arg]) { + throw new Error( + `The ${arg} flag can only be used when rendering projects.`, + ); + } + } + } +} +``` + +Originally `--output-dir` was strictly project-only and this was the +gate. The synthetic-project mechanism is the deliberate back-door for +`--output-dir`; `--site-url` has no back-door so the gate still applies. + +## Key history + +| Date | Commit | Change | +|------|--------|--------| +| 2023-11 (1.4) | `71b4df65f` | JJ: support `--output-dir` for single-file renders via synthetic project | +| 2025-08 (1.9) | `1f94440b8` | Gordon: `singleFileProjectContext` gets `config = { project: {} }` for extensions (unrelated to `--output-dir`) | +| 2025-09 (1.9) | `017349c6f` | Gordon: extensible engine architecture foundation | +| 2025-12 (1.9) | `77633b39a` | Carlos: preview pre-creates project context — introduced the regression | +| 2026-01 (1.9.37) | `c3384a5ff` | Christophe: restore synthetic-project creation for the `pContext === null` path (only partially fixed the regression) | +| 2026-05 (1.9.x) | this PR | extend synthetic-project trigger to `pContext.isSingleFile` (closes the regression for preview) | + +## Related test surface + +- `tests/smoke/render/render-output-dir.test.ts` — covers the render + path (`pContext` null + `--output-dir`). +- `tests/unit/render-shared-output-dir.test.ts` — covers the preview + pattern (`pContext = singleFileProjectContext`, `--output-dir`). +- Manual fixtures: `tests/docs/manual/preview/` (matrix entries + describing reproductions). + +## Key files + +| File | Purpose | +|------|---------| +| `src/command/render/render-shared.ts` | `render()` — chooses synthetic vs single-file vs full project | +| `src/project/project-context.ts` | `projectContext(force=true)` walks dir → synthetic project; `projectContextForDirectory()` is the wrapper | +| `src/project/types/single-file/single-file.ts` | `singleFileProjectContext()` — minimal context, `isSingleFile: true` | +| `src/project/types.ts` | `ProjectContext.isSingleFile` discriminator | +| `src/command/preview/preview.ts` | Pre-creates project context, passes as `pContext` to `render()` | +| `src/command/preview/preview-shiny.ts` | Shiny preview path; also goes through `renderForPreview` so the same `render()` flow applies | diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 93f30f3ff46..58f3cd418d7 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -5,6 +5,7 @@ All changes included in 1.10: - ([#14267](https://github.com/quarto-dev/quarto-cli/issues/14267)): Fix Windows paths with accented characters (e.g., `C:\Users\Sébastien\`) breaking dart-sass compilation. - ([#14281](https://github.com/quarto-dev/quarto-cli/issues/14281)): Fix transient `.quarto_ipynb` files accumulating during `quarto preview` with Jupyter engine. - ([#14298](https://github.com/quarto-dev/quarto-cli/issues/14298)): Fix `quarto preview` browse URL including output filename (e.g., `hello.html`) for single-file documents, breaking Posit Workbench proxied server access. +- ([#14489](https://github.com/quarto-dev/quarto-cli/issues/14489)): Restore `--output-dir` support for `quarto preview` of single files when no `_quarto.yml` is present (e.g. R-package workspaces). Regression introduced in v1.9.18. - ([rstudio/rstudio#17333](https://github.com/rstudio/rstudio/issues/17333)): Fix `quarto inspect` on standalone files emitting project metadata that breaks RStudio's publishing wizard. ## Accessibility diff --git a/src/command/render/render-shared.ts b/src/command/render/render-shared.ts index 473c27498fd..06589b6ca1b 100644 --- a/src/command/render/render-shared.ts +++ b/src/command/render/render-shared.ts @@ -49,10 +49,15 @@ export async function render( // determine target context/files let context = pContext || (await projectContext(path, nbContext, options)); - // Create a synthetic project when --output-dir is used without a project file - // This creates a temporary .quarto directory to manage the render, which must - // be fully cleaned up afterward to avoid leaving debris (see #9745) - if (!context && options.flags?.outputDir) { + // Create a synthetic project when --output-dir is used without a real project. + // Triggers in two situations: + // 1. No context yet (render path: no _quarto.yml found above) + // 2. pContext is a single-file context (preview pre-creates one in + // preview.ts before calling render(), which would otherwise bypass + // synthetic-project creation — regression behind #14489) + // The synthetic project provides a temporary .quarto directory so output-dir + // handling works the same as for real projects (see #9745, #13625). + if (options.flags?.outputDir && (!context || context.isSingleFile)) { context = await projectContextForDirectory(path, nbContext, options); // forceClean signals this is a synthetic project that needs full cleanup diff --git a/tests/unit/render-shared-output-dir.test.ts b/tests/unit/render-shared-output-dir.test.ts new file mode 100644 index 00000000000..41d3c65638f --- /dev/null +++ b/tests/unit/render-shared-output-dir.test.ts @@ -0,0 +1,75 @@ +/* + * render-shared-output-dir.test.ts + * + * Regression test for posit-dev/positron#13370 / quarto-dev/quarto-cli#14489. + * + * When `quarto preview file.qmd --output-dir ` runs in a directory + * without _quarto.yml, preview pre-creates a singleFileProjectContext and + * passes it to render() as pContext. The synthetic-project trigger in + * render-shared.ts only fired when pContext was null, so the path fell + * through to validateDocumentRenderFlags() and threw. + * + * This test mimics that exact pattern and asserts no throw + output + * landed in --output-dir. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../test.ts"; +import { assert } from "testing/asserts"; +import { join } from "../../src/deno_ral/path.ts"; +import { existsSync } from "../../src/deno_ral/fs.ts"; +import { render } from "../../src/command/render/render-shared.ts"; +import { singleFileProjectContext } from "../../src/project/types/single-file/single-file.ts"; +import { notebookContext } from "../../src/render/notebook/notebook-context.ts"; +import { renderServices } from "../../src/command/render/render-services.ts"; +import { initYamlIntelligenceResourcesFromFilesystem } from "../../src/core/schema/utils.ts"; + +unitTest( + "render() with --output-dir succeeds when caller pre-passes a single-file context (#14489)", + async () => { + await initYamlIntelligenceResourcesFromFilesystem(); + + const tempBase = Deno.makeTempDirSync({ prefix: "quarto_test_outdir_" }); + const inputDir = join(tempBase, "src"); + const outputDir = join(tempBase, "out"); + Deno.mkdirSync(inputDir); + Deno.mkdirSync(outputDir); + + const inputFile = join(inputDir, "test.qmd"); + Deno.writeTextFileSync( + inputFile, + "---\ntitle: Test\nformat: html\n---\n\nHello world.\n", + ); + + const nbCtx = notebookContext(); + const services = renderServices(nbCtx); + + const renderOptions = { + services, + flags: { outputDir }, + pandocArgs: [], + }; + + // Mimic preview's pattern: pre-create singleFileProjectContext, pass as pContext. + const project = await singleFileProjectContext(inputFile, nbCtx, renderOptions); + + try { + // Before the fix this throws "The --output-dir flag can only be used + // when rendering projects." + await render(inputFile, renderOptions, project); + + assert( + existsSync(join(outputDir, "test.html")), + `Expected output HTML at ${join(outputDir, "test.html")} after render with --output-dir`, + ); + } finally { + services.cleanup?.(); + try { + Deno.removeSync(tempBase, { recursive: true }); + } catch { + // best-effort cleanup + } + } + }, +);