Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions llm-docs/synthetic-project-context.md
Original file line number Diff line number Diff line change
@@ -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 |
1 change: 1 addition & 0 deletions news/changelog-1.10.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions src/command/render/render-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions tests/unit/render-shared-output-dir.test.ts
Original file line number Diff line number Diff line change
@@ -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 <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
}
}
},
);
Loading