|
| 1 | +--- |
| 2 | +main_commit: 94ebb7f79 |
| 3 | +analyzed_date: 2026-03-31 |
| 4 | +key_files: |
| 5 | + - src/command/preview/cmd.ts |
| 6 | + - src/command/preview/preview.ts |
| 7 | + - src/project/serve/serve.ts |
| 8 | + - src/project/serve/watch.ts |
| 9 | + - src/project/project-shared.ts |
| 10 | + - src/execute/jupyter/jupyter.ts |
| 11 | + - src/execute/engine.ts |
| 12 | +--- |
| 13 | + |
| 14 | +# Preview Architecture |
| 15 | + |
| 16 | +How `quarto preview` works, from CLI entry through rendering and file watching. |
| 17 | + |
| 18 | +## Entry Points |
| 19 | + |
| 20 | +- `src/command/preview/cmd.ts` — CLI command handler, routing logic |
| 21 | +- `src/command/preview/preview.ts` — Single-file preview lifecycle |
| 22 | +- `src/project/serve/serve.ts` — Project preview via `serveProject()` |
| 23 | +- `src/project/serve/watch.ts` — Project file watcher |
| 24 | + |
| 25 | +## cmd.ts Branching (5 Paths) |
| 26 | + |
| 27 | +The command handler in `cmd.ts` determines which preview mode to use. The key variables `file` and `projectTarget` are mutated as a state machine to route between paths. |
| 28 | + |
| 29 | +``` |
| 30 | +quarto preview [input] |
| 31 | + │ |
| 32 | + ▼ |
| 33 | + isFile(input)? |
| 34 | + ├── YES ──► Create ProjectContext, detect format |
| 35 | + │ ├── Shiny? ──► previewShiny() / serve() ──► EXIT (Path C) |
| 36 | + │ ├── Serveable project, file NOT in inputs? |
| 37 | + │ │ └── .md + external previewer ──► file = project.dir (Path B1) |
| 38 | + │ ├── Serveable project, file IN inputs? |
| 39 | + │ │ └── HTML/serve output ──► renderProject() then file = project.dir (Path B2) |
| 40 | + │ └── None of above ──► file stays as-is (Path A) |
| 41 | + │ |
| 42 | + └── NO (directory) ──► straight to isDirectory check (Path D) |
| 43 | + │ |
| 44 | + ▼ |
| 45 | + isDirectory(file)? ← file may have been mutated above |
| 46 | + ├── YES ──► serveProject(projectTarget, ...) ← projectTarget may be ProjectContext |
| 47 | + └── NO ──► preview(file, ..., project) ← single-file preview |
| 48 | +``` |
| 49 | + |
| 50 | +### Path details |
| 51 | + |
| 52 | +| Path | Input | Condition | `file` mutated? | Terminal action | |
| 53 | +|------|-------|-----------|-----------------|-----------------| |
| 54 | +| A | file | Not in serveable project | No | `preview()` | |
| 55 | +| B1 | file | `.md` not in project inputs + external previewer | `file = project.dir` | `serveProject()` | |
| 56 | +| B2 | file | In project inputs, HTML/serve output | `file = project.dir` | `serveProject()` (after pre-render) | |
| 57 | +| C | file | Shiny document | N/A (exits early) | `previewShiny()`/`serve()` | |
| 58 | +| D | directory | User passed directory or cwd | N/A (isFile skipped) | `serveProject()` | |
| 59 | + |
| 60 | +The `file` mutation pattern (`file = project.dir`) is intentional design by JJ Allaire (2022, commit `5508ace5bd`). It converts a single-file preview into a project preview when the file lives in a serveable project, so the browser gets full project navigation. |
| 61 | + |
| 62 | +`projectTarget` (`string | ProjectContext`) carries the context to `serveProject()`, which accepts both types. When it receives a string, it resolves the project itself. |
| 63 | + |
| 64 | +## Single-File Preview Lifecycle (Path A) |
| 65 | + |
| 66 | +### Context creation |
| 67 | + |
| 68 | +`cmd.ts` creates a `ProjectContext` for format detection (routing decisions). This context is passed to `preview()` via the `pProject` parameter to avoid creating a duplicate. |
| 69 | + |
| 70 | +```typescript |
| 71 | +// cmd.ts creates context for routing |
| 72 | +project = (await projectContext(dirname(file), nbContext)) || |
| 73 | + (await singleFileProjectContext(file, nbContext)); |
| 74 | + |
| 75 | +// preview() reuses it |
| 76 | +export async function preview( |
| 77 | + file, flags, pandocArgs, options, |
| 78 | + pProject?: ProjectContext, // reused from cmd.ts |
| 79 | +) |
| 80 | +``` |
| 81 | + |
| 82 | +This mirrors `render()`'s `pContext` pattern in `render-shared.ts`. |
| 83 | + |
| 84 | +### Startup sequence |
| 85 | + |
| 86 | +1. `preview()` receives or creates `ProjectContext` |
| 87 | +2. `previewFormat()` determines output format (calls `renderFormats()` if `--to` not specified) |
| 88 | +3. `renderForPreview()` does the initial render |
| 89 | +4. `createChangeHandler()` sets up file watchers |
| 90 | +5. HTTP dev server starts |
| 91 | + |
| 92 | +### Re-render on file change |
| 93 | + |
| 94 | +When the watched source file changes: |
| 95 | + |
| 96 | +1. `createChangeHandler` triggers the `render` closure |
| 97 | +2. `renderForPreview()` is called with the **same** `project` from the closure |
| 98 | +3. `invalidateForFile(file)` cleans up the transient notebook and removes the cache entry |
| 99 | +4. `render()` runs with the project context, which creates a fresh target/notebook |
| 100 | +5. Browser reloads |
| 101 | + |
| 102 | +The project context persists across all re-renders. Only the per-file cache entry is invalidated. |
| 103 | + |
| 104 | +## FileInformationCache and invalidateForFile |
| 105 | + |
| 106 | +`FileInformationCacheMap` stores per-file cached data: |
| 107 | + |
| 108 | +| Field | Content | Cost of re-computation | |
| 109 | +|-------|---------|----------------------| |
| 110 | +| `fullMarkdown` | Expanded markdown with includes | Re-reads file, re-expands includes | |
| 111 | +| `includeMap` | Include source→target mappings | Recomputed with markdown | |
| 112 | +| `codeCells` | Parsed code cells | Recomputed from markdown | |
| 113 | +| `engine` | Execution engine instance | Re-determined | |
| 114 | +| `target` | Execution target (includes `.quarto_ipynb` path) | Re-created by `target()` | |
| 115 | +| `metadata` | YAML front matter | Recomputed from markdown | |
| 116 | +| `brand` | Resolved `_brand.yml` data | Re-loaded from disk | |
| 117 | + |
| 118 | +### invalidateForFile() (added for #14281) |
| 119 | + |
| 120 | +Before each preview re-render, the cache entry for the changed file must be invalidated so fresh content is picked up. `invalidateForFile()` does two things: |
| 121 | + |
| 122 | +1. Deletes any transient `.quarto_ipynb` file from disk (if the cached target is transient) |
| 123 | +2. Removes the cache entry |
| 124 | + |
| 125 | +Without step 1, the Jupyter engine's `target()` function sees the old file on disk and its collision-avoidance loop creates numbered variants (`_1`, `_2`, etc.) that accumulate. |
| 126 | + |
| 127 | +### cleanupFileInformationCache() |
| 128 | + |
| 129 | +Called at project cleanup (preview exit). Delegates to `invalidateForFile()` for each cache entry, removing all transient files and clearing the cache. This is the final cleanup — `invalidateForFile()` handles per-render cleanup for individual files. |
| 130 | + |
| 131 | +## Transient Notebook Lifecycle (.quarto_ipynb) |
| 132 | + |
| 133 | +When rendering a `.qmd` with a Jupyter kernel, the engine creates a transient `.ipynb` notebook: |
| 134 | + |
| 135 | +1. `target()` in `jupyter.ts` generates the path: `{stem}.quarto_ipynb` |
| 136 | +2. If the file already exists, a collision-avoidance loop appends `_1`, `_2`, etc. |
| 137 | +3. The target is marked `data: { transient: true }` |
| 138 | +4. `execute()` runs the notebook through Jupyter |
| 139 | +5. `cleanupNotebook()` flips `transient = false` if `keep-ipynb: true` |
| 140 | +6. At preview exit, `cleanupFileInformationCache()` deletes files where `transient = true` |
| 141 | + |
| 142 | +## Context Computation Count (Summary) |
| 143 | + |
| 144 | +| Scenario | Startup computations | Per-change | |
| 145 | +|----------|---------------------|------------| |
| 146 | +| Single file, no project | 1 (cmd.ts, passed to preview) | 0 (cached project reused) | |
| 147 | +| Single file in serveable project | 1 (cmd.ts, passed to serveProject) | See project rows | |
| 148 | +| Project directory | 1 (serve.ts) | See project rows | |
| 149 | +| Project: single input changed | — | 1 (render() without pContext) | |
| 150 | +| Project: multiple inputs changed | — | 0 (renderProject reuses cached) | |
| 151 | +| Project: config file changed (HTML) | — | 1 (refreshProjectConfig) | |
| 152 | + |
| 153 | +## Key Files |
| 154 | + |
| 155 | +| File | Purpose | |
| 156 | +|------|---------| |
| 157 | +| `src/command/preview/cmd.ts` | CLI handler, routing state machine | |
| 158 | +| `src/command/preview/preview.ts` | Single-file preview lifecycle, `renderForPreview()`, `previewFormat()` | |
| 159 | +| `src/project/serve/serve.ts` | `serveProject()` — project preview server | |
| 160 | +| `src/project/serve/watch.ts` | `watchProject()` — file watcher, `refreshProjectConfig()` | |
| 161 | +| `src/command/render/render-shared.ts` | `render()` — accepts optional `pContext` | |
| 162 | +| `src/command/render/render-contexts.ts` | `renderContexts()`, `renderFormats()` — calls `fileExecutionEngineAndTarget()` | |
| 163 | +| `src/execute/engine.ts` | `fileExecutionEngineAndTarget()` — caching wrapper | |
| 164 | +| `src/execute/jupyter/jupyter.ts` | `target()` — creates `.quarto_ipynb`, collision-avoidance loop | |
| 165 | +| `src/project/project-shared.ts` | `FileInformationCacheMap`, `invalidateForFile()`, `cleanupFileInformationCache()` | |
| 166 | +| `src/project/types/single-file/single-file.ts` | `singleFileProjectContext()` — creates minimal context | |
0 commit comments