From 8dd9762a9780c0161ac985be56638a760f2cc9de Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 27 Mar 2026 12:25:23 -0700 Subject: [PATCH 01/21] [backport] Fix Windows paths with accented characters breaking dart-sass (#14274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: set UTF-8 code page in safeWindowsExec batch files (#14267) safeWindowsExec writes temp .bat files as UTF-8 (Deno default), but cmd.exe reads them using the OEM code page (e.g., CP850). Multi-byte UTF-8 characters like é (0xC3 0xA9) get misinterpreted, breaking paths with accented characters (e.g., C:\Users\Sébastien\). Add `chcp 65001 >nul` to switch cmd.exe to UTF-8 before the command line is parsed. Use CRLF line endings for correct .bat parsing. * docs: add changelog entry for #14267 backport --- news/changelog-1.9.md | 10 ++++++ src/core/windows.ts | 2 +- tests/unit/windows-exec.test.ts | 55 +++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 1e13acd0afd..2e4216d74ac 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -1,3 +1,13 @@ +# v1.10 backports + +## In this release + +- ([#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. + +## In previous releases + +# v1.9 changes + All changes included in 1.9: ## Shortcodes diff --git a/src/core/windows.ts b/src/core/windows.ts index aed3d21fab8..c2880635c4a 100644 --- a/src/core/windows.ts +++ b/src/core/windows.ts @@ -156,7 +156,7 @@ export async function safeWindowsExec( try { Deno.writeTextFileSync( tempFile, - ["@echo off", [program, ...args].join(" ")].join("\n"), + ["@echo off", "chcp 65001 >nul", [program, ...args].join(" ")].join("\r\n"), ); return await fnExec(["cmd", "/c", tempFile]); } finally { diff --git a/tests/unit/windows-exec.test.ts b/tests/unit/windows-exec.test.ts index 2422a4c1ffe..80ac4709072 100644 --- a/tests/unit/windows-exec.test.ts +++ b/tests/unit/windows-exec.test.ts @@ -155,3 +155,58 @@ echo ARG: %~1 }, { ignore: !isWindows }, ); + +// Test that safeWindowsExec handles accented/Unicode characters in paths (issue #14267) +// The bug: safeWindowsExec writes .bat as UTF-8 but cmd.exe reads using OEM code page +// (e.g., CP850), garbling multi-byte chars like é (0xC3 0xA9 → two CP850 chars). +unitTest( + "safeWindowsExec - handles accented characters in paths (issue #14267)", + async () => { + const tempDir = Deno.makeTempDirSync({ prefix: "quarto-test" }); + + try { + // Create directory with accented characters (simulates C:\Users\Sébastien\) + const accentedDir = join(tempDir, "Sébastien", "project"); + Deno.mkdirSync(accentedDir, { recursive: true }); + + // Create a test file at the accented path + const testFile = join(accentedDir, "test.txt"); + Deno.writeTextFileSync(testFile, "accented path works"); + + // Create batch file that reads the accented-path file + const batFile = join(tempDir, "read-file.bat"); + Deno.writeTextFileSync( + batFile, + `@echo off +type %1 +`, + ); + + const quoted = requireQuoting([batFile, testFile]); + + const result = await safeWindowsExec( + quoted.args[0], + quoted.args.slice(1), + (cmd) => + execProcess({ + cmd: cmd[0], + args: cmd.slice(1), + stdout: "piped", + stderr: "piped", + }), + ); + + assert( + result.success, + `Should execute successfully with accented path. stderr: ${result.stderr}`, + ); + assert( + result.stdout?.includes("accented path works"), + `Should read file at accented path. Got: ${result.stdout}`, + ); + } finally { + Deno.removeSync(tempDir, { recursive: true }); + } + }, + { ignore: !isWindows }, +); From c829202bfe3d2c17f8698f01d30566259d64a880 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 31 Mar 2026 06:47:13 -0700 Subject: [PATCH 02/21] [backport] Fix transient .quarto_ipynb files accumulating during preview (#14288) * Fix transient .quarto_ipynb files accumulating during preview (#14281) Preview re-renders delete the fileInformationCache entry to pick up file changes, but this loses track of the transient .quarto_ipynb path. The collision-avoidance loop in jupyter.ts target() then sees the old file on disk and creates numbered variants (_1, _2, etc.) that are never cleaned up until preview exits. Add invalidateForFile() to FileInformationCacheMap that cleans up any transient notebook file from disk before removing the cache entry. Replace the bare delete() call in renderForPreview() with this method. Fixes #14281 --- news/changelog-1.9.md | 1 + src/command/preview/preview.ts | 5 +- src/project/project-shared.ts | 19 ++++- src/project/types.ts | 11 ++- .../project/file-information-cache.test.ts | 77 +++++++++++++++++++ 5 files changed, 108 insertions(+), 5 deletions(-) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 2e4216d74ac..46c821a13fc 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -3,6 +3,7 @@ ## In this release - ([#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. ## In previous releases diff --git a/src/command/preview/preview.ts b/src/command/preview/preview.ts index 7b85cafd0e8..edb2ac0b75c 100644 --- a/src/command/preview/preview.ts +++ b/src/command/preview/preview.ts @@ -427,9 +427,10 @@ export async function renderForPreview( // Invalidate file cache for the file being rendered so changes are picked up. // The project context persists across re-renders in preview mode, but the // fileInformationCache contains file content that needs to be refreshed. - // TODO(#13955): Consider adding a dedicated invalidateForFile() method on ProjectContext + // Uses invalidateForFile() to also clean up transient notebook files + // (.quarto_ipynb) from disk before removing the cache entry (#14281). if (project?.fileInformationCache) { - project.fileInformationCache.delete(file); + project.fileInformationCache.invalidateForFile(file); } // render diff --git a/src/project/project-shared.ts b/src/project/project-shared.ts index cf121d63d88..d9daaf0803c 100644 --- a/src/project/project-shared.ts +++ b/src/project/project-shared.ts @@ -22,6 +22,7 @@ import { readAndValidateYamlFromFile } from "../core/schema/validated-yaml.ts"; import { FileInclusion, FileInformation, + FileInformationCache, kProjectOutputDir, kProjectType, ProjectConfig, @@ -660,7 +661,7 @@ export async function projectResolveBrand( // Implements Cloneable but shares state intentionally - in preview mode, // the project context is reused across renders and cache state must persist. export class FileInformationCacheMap extends Map - implements Cloneable> { + implements FileInformationCache, Cloneable> { override get(key: string): FileInformation | undefined { return super.get(normalizePath(key)); } @@ -681,6 +682,22 @@ export class FileInformationCacheMap extends Map // return normalized keys as stored. Code iterating over the cache sees // normalized paths, which is consistent with how keys are stored. + // Removes a cache entry and cleans up any associated transient files. + // In preview mode, this should be used instead of delete() to ensure + // transient notebooks (.quarto_ipynb) are removed from disk before the + // cache entry is dropped. Without this, the collision-avoidance logic + // in jupyter.ts target() creates numbered variants on each re-render. + invalidateForFile(key: string): void { + const existing = this.get(key); + if (existing?.target?.data) { + const data = existing.target.data as { transient?: boolean }; + if (data.transient && existing.target.input) { + safeRemoveSync(existing.target.input); + } + } + this.delete(key); + } + // Returns this instance (shared reference) rather than a copy. // This is intentional: in preview mode, project context is cloned for // each render but the cache must be shared so invalidations persist. diff --git a/src/project/types.ts b/src/project/types.ts index 2c4948dc182..d36f04f2282 100644 --- a/src/project/types.ts +++ b/src/project/types.ts @@ -68,6 +68,13 @@ export type FileInformation = { brand?: LightDarkBrandDarkFlag; }; +export interface FileInformationCache extends Map { + // Removes a cache entry and cleans up any associated transient files from disk. + // Use this instead of delete() when invalidating entries that may reference + // transient notebooks (.quarto_ipynb) to prevent file accumulation. + invalidateForFile(key: string): void; +} + export interface ProjectContext extends Cloneable { dir: string; engines: string[]; @@ -76,7 +83,7 @@ export interface ProjectContext extends Cloneable { notebookContext: NotebookContext; outputNameIndex?: Map; - fileInformationCache: Map; + fileInformationCache: FileInformationCache; // This is a cache of _brand.yml for a project brandCache?: { brand?: LightDarkBrandDarkFlag }; @@ -182,7 +189,7 @@ export interface EngineProjectContext { * For file information cache management * Used for the transient notebook tracking in Jupyter */ - fileInformationCache: Map; + fileInformationCache: FileInformationCache; /** * Get the output directory for the project diff --git a/tests/unit/project/file-information-cache.test.ts b/tests/unit/project/file-information-cache.test.ts index 9f552e9c96b..666a0d2915b 100644 --- a/tests/unit/project/file-information-cache.test.ts +++ b/tests/unit/project/file-information-cache.test.ts @@ -9,6 +9,8 @@ import { unitTest } from "../../test.ts"; import { assert } from "testing/asserts"; +import { asMappedString } from "../../../src/core/lib/mapped-text.ts"; +import { existsSync } from "../../../src/deno_ral/fs.ts"; import { join, relative } from "../../../src/deno_ral/path.ts"; import { ensureFileInformationCache, @@ -130,3 +132,78 @@ unitTest( ); }, ); + +// deno-lint-ignore require-await +unitTest( + "fileInformationCache - invalidateForFile deletes transient notebook file", + async () => { + const project = createMockProjectContext(); + const sourcePath = join(project.dir, "doc.qmd"); + + // Create a real temp file simulating a transient .quarto_ipynb + const notebookPath = join(project.dir, "doc.quarto_ipynb"); + Deno.writeTextFileSync(notebookPath, '{"cells": []}'); + assert(existsSync(notebookPath), "Temp notebook file should exist"); + + // Populate cache entry with a transient target pointing to the file + const entry = ensureFileInformationCache(project, sourcePath); + entry.target = { + source: sourcePath, + input: notebookPath, + markdown: asMappedString(""), + metadata: {}, + data: { transient: true, kernelspec: {} }, + }; + + // Invalidate the cache entry for this file + project.fileInformationCache.invalidateForFile(sourcePath); + + // The transient file should be deleted from disk + assert( + !existsSync(notebookPath), + "Transient notebook file should be deleted on invalidation", + ); + // The cache entry should be removed + assert( + !project.fileInformationCache.has(sourcePath), + "Cache entry should be removed after invalidation", + ); + }, +); + +// deno-lint-ignore require-await +unitTest( + "fileInformationCache - invalidateForFile preserves non-transient files", + async () => { + const project = createMockProjectContext(); + const sourcePath = join(project.dir, "notebook.ipynb"); + + // Create a real file simulating a user's .ipynb (non-transient) + const notebookPath = join(project.dir, "notebook.ipynb"); + Deno.writeTextFileSync(notebookPath, '{"cells": []}'); + + // Populate cache entry with a non-transient target + const entry = ensureFileInformationCache(project, sourcePath); + entry.target = { + source: sourcePath, + input: notebookPath, + markdown: asMappedString(""), + metadata: {}, + data: { transient: false, kernelspec: {} }, + }; + + // Invalidate the cache entry + project.fileInformationCache.invalidateForFile(sourcePath); + + // The non-transient file should NOT be deleted + assert( + existsSync(notebookPath), + "Non-transient file should be preserved on invalidation", + ); + // But the cache entry should still be removed + assert( + !project.fileInformationCache.has(sourcePath), + "Cache entry should be removed after invalidation", + ); + }, +); From f2b075f5fef253f44dcd402deeac1757bee2bccc Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 1 Apr 2026 06:53:20 -0700 Subject: [PATCH 03/21] Add missing lua-types declarations for public Lua API (#14295) Add type declarations for functions exported in init.lua that were missing from the lua-types annotations. This feeds into the autogen docs effort (quarto-dev/quarto-web#1649). quarto.doc: add_resource, add_supporting, is_filter_active quarto.utils: type, table.isarray, table.contains, table.sortedPairs, resolve_path_relative_to_document, as_inlines, as_blocks, is_empty_node, render, match, add_to_blocks (cherry picked from commit 6ce64dc8a7610bf5322527e892110564a551d642) --- src/resources/lua-types/quarto/doc.lua | 27 ++++- src/resources/lua-types/quarto/utils.lua | 121 ++++++++++++++++++++++- 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/src/resources/lua-types/quarto/doc.lua b/src/resources/lua-types/quarto/doc.lua index 3da0e8e5eae..2642f9617dd 100644 --- a/src/resources/lua-types/quarto/doc.lua +++ b/src/resources/lua-types/quarto/doc.lua @@ -48,7 +48,25 @@ which must be copied into the LaTeX output directory. function quarto.doc.add_format_resource(file) end --[[ -Include text at the specified location (`in-header`, `before-body`, or `after-body`). +Add a resource file to the document. The file will be copied to the same +relative location in the output directory. + +The path should be relative to the Lua script calling this function. +]] +---@param path string Resource file path (relative to Lua script) +function quarto.doc.add_resource(path) end + +--[[ +Add a supporting file to the document. Supporting files are moved to the +output directory and may be cleaned up after rendering. + +The path should be relative to the Lua script calling this function. +]] +---@param path string Supporting file path (relative to Lua script) +function quarto.doc.add_supporting(path) end + +--[[ +Include text at the specified location (`in-header`, `before-body`, or `after-body`). ]] ---@param location 'in-header'|'before-body'|'after-body' Location for include ---@param text string Text to include @@ -101,6 +119,13 @@ Does the current output format include Bootstrap themed HTML ---@return boolean function quarto.doc.has_bootstrap() end +--[[ +Check whether a specific internal Quarto filter is currently active. +]] +---@param filter string Filter name to check +---@return boolean +function quarto.doc.is_filter_active(filter) end + --[[ Provides the project relative path to the current input if this render is in the context of a project (otherwise `nil`) diff --git a/src/resources/lua-types/quarto/utils.lua b/src/resources/lua-types/quarto/utils.lua index cd92c02fbf7..f1ebd4600f5 100644 --- a/src/resources/lua-types/quarto/utils.lua +++ b/src/resources/lua-types/quarto/utils.lua @@ -12,15 +12,60 @@ Note that you should use `quarto.log.output()` instead of this function. function quarto.utils.dump(value) end --[[ -Compute the absolute path to a file that is installed alongside the Lua script. +Extended type function that returns Pandoc types with Quarto custom node awareness. -This is useful for internal resources that your filter needs but should +Returns `"CustomBlock"` for Quarto custom block nodes and `"CustomInline"` for +custom inline nodes, otherwise delegates to `pandoc.utils.type()`. +]] +---@param value any Value to check +---@return string +function quarto.utils.type(value) end + +quarto.utils.table = {} + +--[[ +Return `true` if the table is a plain integer-indexed array. +]] +---@param t table Table to check +---@return boolean +function quarto.utils.table.isarray(t) end + +--[[ +Return `true` if the array-like table contains the given value. +]] +---@param t table Array-like table to search +---@param value any Value to find +---@return boolean +function quarto.utils.table.contains(t, value) end + +--[[ +Iterator that traverses table keys in sorted order. +]] +---@param t table Table to iterate +---@param f? function Optional comparison function for sorting +---@return function Iterator function +function quarto.utils.table.sortedPairs(t, f) end + +--[[ +Compute the absolute path to a file that is installed alongside the Lua script. + +This is useful for internal resources that your filter needs but should not be visible to the user. ]] ---@param path string Path of file relative to the Lua script ---@return string function quarto.utils.resolve_path(path) end +--[[ +Resolve a file path relative to the document's working directory. + +Unlike `resolve_path()` which resolves relative to the Lua script, +this resolves relative to the document being rendered. +]] +---@param path string File path to resolve +---@return string +function quarto.utils.resolve_path_relative_to_document(path) end + --[[ Converts a string to a list of Pandoc Inlines, processing any Quarto custom syntax in the string. @@ -38,6 +83,78 @@ syntax in the string. ---@return pandoc.Blocks function quarto.utils.string_to_blocks(path) end +--[[ +Coerce the given object into a `pandoc.Inlines` list. + +Handles Inline, Inlines, Block, Blocks, List, and table inputs. +More performant than `pandoc.Inlines()` as it avoids full marshal roundtrips. + +Note: The input object may be modified destructively. +]] +---@param obj any Object to coerce +---@return pandoc.Inlines +function quarto.utils.as_inlines(obj) end + +--[[ +Coerce the given object into a `pandoc.Blocks` list. + +Handles Block, Blocks, Inline, Inlines, List, Caption, and table inputs. +More performant than `pandoc.Blocks()` as it avoids full marshal roundtrips. + +Note: The input object may be modified destructively. +]] +---@param obj any Object to coerce +---@return pandoc.Blocks +function quarto.utils.as_blocks(obj) end + +--[[ +Return `true` if the given AST node is empty. + +A node is considered empty if it's an empty list/table, has empty `content`, +has empty `caption`, or has an empty `text` field. +]] +---@param node any AST node to check +---@return boolean +function quarto.utils.is_empty_node(node) end + +--[[ +Walk and render extended/custom AST nodes. + +Processes Quarto custom AST nodes (like Callout, Tabset, etc.) into their +final Pandoc representation. +]] +---@param node pandoc.Node AST node to render +---@return pandoc.Node +function quarto.utils.render(node) end + +--[[ +CSS-selector-like AST matcher for Pandoc nodes. + +Accepts string path selectors separated by `/` with support for: +- Element type selectors (e.g. `"Header"`, `"Para"`, `"Div"`) +- Child traversal via `/` separator +- `*` wildcard to match any child +- `{Type}` capture syntax to return matched nodes +- `:child` to search direct children +- `:descendant` to search all descendants +- `:nth-child(n)` to match a specific child index + +Returns a function that, when called with an AST node, returns the matched +node(s) or `false`/`nil` if no match. +]] +---@param ... string|function Selector path components +---@return function Matcher function +function quarto.utils.match(...) end + +--[[ +Append a Block, Blocks, or Inlines value to an existing Blocks list. + +Inlines are wrapped in a Plain block before appending. +]] +---@param blocks pandoc.Blocks Target Blocks list +---@param block pandoc.Block|pandoc.Blocks|pandoc.Inlines Value to append +function quarto.utils.add_to_blocks(blocks, block) end + --[[ Returns a filter that parses book metadata markers during document traversal. From e8126a5f1b5a092808983db6d2d76c91693d17ca Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 1 Apr 2026 09:16:36 -0700 Subject: [PATCH 04/21] Update remaining GitHub Actions for Node.js 24 compatibility (#14294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EndBug/add-and-commit: v9.1.4 (node20) → v10.0.0 (node24) - softprops/action-gh-release: v1-era (node20) → v2.6.1, remove deprecated env GITHUB_TOKEN - julia-actions/setup-julia: @v2 (node20) → master SHA with node24 (no release yet) Follow-up to #14199 which updated official actions/* and other third-party actions. (cherry picked from commit 0ae540e62b5ed165a5d878fb12530d9e000562d3) --- .github/workflows/create-release.yml | 6 ++---- .github/workflows/test-smokes.yml | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index f4db596d7bf..12ec4f5a757 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -89,7 +89,7 @@ jobs: run: | echo ${{ steps.read-version.outputs.version_full }} > version.txt - - uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 + - uses: EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321 if: ${{ inputs.publish-release }} id: version_commit with: @@ -677,9 +677,7 @@ jobs: - name: Create Release id: create_release - uses: softprops/action-gh-release@4634c16e79c963813287e889244c50009e7f0981 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe with: tag_name: ${{needs.configure.outputs.tag_name}} target_commitish: ${{ needs.configure.outputs.version_commit }} diff --git a/.github/workflows/test-smokes.yml b/.github/workflows/test-smokes.yml index bd70ac69648..961f8133ff8 100644 --- a/.github/workflows/test-smokes.yml +++ b/.github/workflows/test-smokes.yml @@ -217,7 +217,7 @@ jobs: quarto install chrome-headless-shell --no-prompt - name: Setup Julia - uses: julia-actions/setup-julia@v2 + uses: julia-actions/setup-julia@a8c65a2a580b6a5cf30070e825e62f9fc0fee1d7 id: setup-julia with: version: "1.11.7" From 448eea6aaf3de21d6c447be4dc87d6270eeb2deb Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 3 Apr 2026 06:03:29 -0700 Subject: [PATCH 05/21] [backport] Fix preview browse URL for single-file documents (#14301) [backport] Fix preview browse URL for single-file documents Backport of #14300. After #13804 made the project variable always non-null via singleFileProjectContext(), single-file preview broke: the browse URL included the output filename and GET / returned 404. Guard both the URL path computation and handler selection with !project.isSingleFile. - Fix initialPath using relative path instead of empty string - Fix handler selection using wrong default file for single files - Add unit tests for previewInitialPath() - Add manual preview test entries and plain.qmd fixture Fixes #14298 --- .claude/commands/quarto-preview-test/SKILL.md | 145 +++++++++++++ news/changelog-1.9.md | 1 + src/command/preview/preview.ts | 25 ++- tests/docs/manual/README.md | 17 ++ tests/docs/manual/preview/README.md | 196 ++++++++++++++++++ tests/docs/manual/preview/plain.qmd | 9 + tests/unit/preview-initial-path.test.ts | 53 +++++ tests/unit/project/utils.ts | 19 +- 8 files changed, 451 insertions(+), 14 deletions(-) create mode 100644 .claude/commands/quarto-preview-test/SKILL.md create mode 100644 tests/docs/manual/README.md create mode 100644 tests/docs/manual/preview/README.md create mode 100644 tests/docs/manual/preview/plain.qmd create mode 100644 tests/unit/preview-initial-path.test.ts diff --git a/.claude/commands/quarto-preview-test/SKILL.md b/.claude/commands/quarto-preview-test/SKILL.md new file mode 100644 index 00000000000..b902225e21f --- /dev/null +++ b/.claude/commands/quarto-preview-test/SKILL.md @@ -0,0 +1,145 @@ +--- +name: quarto-preview-test +description: Use when testing preview functionality, verifying live reload, or validating preview fixes. Covers starting preview with port/logging, browser verification via /agent-browser, and checking logs/filesystem for artifacts. +--- + +# Quarto Preview Test + +Interactive testing of `quarto preview` with automated browser verification. + +## Tools + +| Tool | When to use | +|------|-------------| +| `/agent-browser` | **Preferred.** Token-efficient browser automation. Navigate, verify content, screenshot. | +| Chrome DevTools MCP | Deep debugging: console messages, network requests, DOM inspection. | +| `jq` / `grep` | Parse debug log output. | + +## Prerequisites + +- Quarto dev version built (`./configure.sh` or `./configure.cmd`) +- Test environment configured (`tests/configure-test-env.sh` or `.ps1`) +- `/agent-browser` CLI installed (preferred), OR Chrome + Chrome DevTools MCP connected + +## Starting Preview + +Preview needs the test venv for Jupyter tests. Activate it first (`tests/.venv`), matching how `run-tests.sh` / `run-tests.ps1` do it. + +```bash +# Linux/macOS +source tests/.venv/bin/activate +./package/dist/bin/quarto preview --no-browser --port 4444 + +# Windows (Git Bash) +source tests/.venv/Scripts/activate +./package/dist/bin/quarto.cmd preview --no-browser --port 4444 +``` + +Use `--no-browser` to control browser connection. Use `--port` for a predictable URL. + +### With debug logging + +```bash +./package/dist/bin/quarto preview --no-browser --port 4444 --log-level debug 2>&1 | tee preview.log +``` + +### In background + +```bash +# Linux/macOS (after venv activation) +./package/dist/bin/quarto preview --no-browser --port 4444 & +PREVIEW_PID=$! +# ... run verification ... +kill $PREVIEW_PID + +# Windows (Git Bash, after venv activation) +./package/dist/bin/quarto.cmd preview --no-browser --port 4444 & +PREVIEW_PID=$! +# ... run verification ... +kill $PREVIEW_PID +``` + +## Edit-Verify Cycle + +The core test pattern: + +1. Start preview with `--no-browser --port 4444` +2. Use `/agent-browser` to navigate to `http://localhost:4444/` and verify content +3. Edit source file, wait 3-5 seconds for re-render +4. Verify content updated in browser +5. Check filesystem for unexpected artifacts +6. Stop preview, verify cleanup + +## What to Verify + +**In browser** (via `/agent-browser`): Page loads, content matches source, updates reflect edits. + +**In terminal/logs**: No `BadResource` errors, no crashes, preview stays responsive. + +**On filesystem**: No orphaned temp files, cleanup happens on exit. + +## Windows Limitations + +On Windows, `kill` from Git Bash does not trigger Quarto's `onCleanup` handler (SIGINT doesn't propagate to Windows processes the same way). Cleanup-on-exit verification requires an interactive terminal with Ctrl+C. For automated testing, verify artifacts *during* preview instead. + +## Context Types + +Preview behaves differently depending on input: + +| Input | Code path | +|-------|-----------| +| Single file (no project) | `preview()` -> `renderForPreview()` | +| File within a project | May redirect to project preview via `serveProject()` | +| Project directory | `serveProject()` -> `watchProject()` | + +See `llm-docs/preview-architecture.md` for the full architecture. + +## When NOT to Use + +- Automated smoke tests — use `tests/smoke/` instead +- Testing render output only (no live preview needed) — use `quarto render` +- CI environments without browser access + +## Test Matrix + +The full test matrix lives in `tests/docs/manual/preview/README.md`. Test fixtures live alongside it in `tests/docs/manual/preview/`. + +### Running specific tests by ID + +When invoked with test IDs (e.g., `/quarto-preview-test T17 T18`): + +1. Read `tests/docs/manual/preview/README.md` +2. Find each requested test by its ID (e.g., `#### T17:`) +3. Parse the **Setup**, **Steps**, and **Expected** fields +4. Execute each test following the steps, using the fixtures in `tests/docs/manual/preview/` +5. Report PASS/FAIL for each test with the actual vs expected result + +### Running tests by topic + +When invoked with a topic description instead of IDs (e.g., `/quarto-preview-test root URL` or "run preview tests for single-file"): + +1. Read `tests/docs/manual/preview/README.md` +2. Search test titles and descriptions for matches (keywords, issue numbers, feature area) +3. Present the matched tests to the user for confirmation before running: + ``` + Found these matching tests: + - T17: Single-file preview — root URL accessible (#14298) + - T18: Single-file preview — named output URL also accessible + Run these? [Y/n] + ``` +4. Only execute after user confirms + +### Running without arguments + +When invoked without test IDs or topic (e.g., `/quarto-preview-test`), use the general Edit-Verify Cycle workflow described above for ad-hoc preview testing. The test matrix is for targeted regression testing. + +## Baseline Comparison + +Compare dev build against installed release to distinguish regressions: + +```bash +quarto --version # installed +./package/dist/bin/quarto --version # dev +``` + +If both show the same issue, it's pre-existing. diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 46c821a13fc..5ab9145c937 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -4,6 +4,7 @@ - ([#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. ## In previous releases diff --git a/src/command/preview/preview.ts b/src/command/preview/preview.ts index edb2ac0b75c..3141913418a 100644 --- a/src/command/preview/preview.ts +++ b/src/command/preview/preview.ts @@ -143,6 +143,21 @@ interface PreviewOptions { presentation: boolean; } +export function previewInitialPath( + outputFile: string, + project: ProjectContext | undefined, +): string { + if (isPdfContent(outputFile)) { + return kPdfJsInitialPath; + } + if (project && !project.isSingleFile) { + return pathWithForwardSlashes( + relative(projectOutputDir(project), outputFile), + ); + } + return ""; +} + export async function preview( file: string, flags: RenderFlags, @@ -230,7 +245,7 @@ export async function preview( changeHandler.render, project, ) - : project + : project && !project.isSingleFile ? projectHtmlFileRequestHandler( project, normalizePath(file), @@ -250,13 +265,7 @@ export async function preview( ); // open browser if this is a browseable format - const initialPath = isPdfContent(result.outputFile) - ? kPdfJsInitialPath - : project - ? pathWithForwardSlashes( - relative(projectOutputDir(project), result.outputFile), - ) - : ""; + const initialPath = previewInitialPath(result.outputFile, project); if ( options.browser && !isServerSession() && diff --git a/tests/docs/manual/README.md b/tests/docs/manual/README.md new file mode 100644 index 00000000000..df15820e310 --- /dev/null +++ b/tests/docs/manual/README.md @@ -0,0 +1,17 @@ +# Manual Tests + +Tests that require interactive sessions, external services, or browser access that cannot run in automated CI. + +## Test Suites + +| Directory / File | Area | Skill | Description | +|-----------------|------|-------|-------------| +| `preview/` | `quarto preview` | `/quarto-preview-test` | Live preview server behavior: URL routing, file watching, live reload, transient file cleanup | +| `publish-connect-cloud/` | `quarto publish` | — | Posit Connect Cloud publishing with OAuth flow | +| `mermaid-svg-pdf-tooling.qmd` | `quarto render` | — | Mermaid SVG rendering to PDF with external tooling (rsvg-convert) | + +## Running Tests + +Each suite has its own README with test matrix and execution instructions. Test fixtures live alongside the README in each directory. + +For preview tests, use the `/quarto-preview-test` skill which automates the start-verify-cleanup cycle. diff --git a/tests/docs/manual/preview/README.md b/tests/docs/manual/preview/README.md new file mode 100644 index 00000000000..6575b7c5479 --- /dev/null +++ b/tests/docs/manual/preview/README.md @@ -0,0 +1,196 @@ +# Manual Preview Tests + +Tests for `quarto preview` behavior that require an interactive session with file-save events. These cannot run in automated smoke tests. + +## Automation + +Use the `/quarto-preview-test` command for the general workflow of starting preview, verifying with browser automation, and checking logs/filesystem. It documents the tools and patterns. + +For browser interaction, `/agent-browser` is preferred over Chrome DevTools MCP (more token-efficient). See the command for details. + +## Test Matrix: quarto_ipynb Accumulation (#14281) + +After every test involving Jupyter execution (Python/Julia cells), verify: +1. `ls *.quarto_ipynb*` — at most one `{name}.quarto_ipynb` (no `_1`, `_2` variants) +2. After Ctrl+C exit — no `.quarto_ipynb` files remain (unless `keep-ipynb: true`) + +For tests without Jupyter execution (T9, T10, T11), verify no `.quarto_ipynb` files are created at all. + +### P1: Critical + +#### T1: Single .qmd with Python — re-render accumulation + +- **Setup:** `test.qmd` with Python code cell +- **Steps:** `quarto preview test.qmd`, save 5 times, check files, Ctrl+C +- **Expected:** At most one `.quarto_ipynb` at any time. Zero after exit. +- **Catches:** `invalidateForFile()` not deleting transient file before cache eviction + +#### T2: Single .qmd with Python — startup duplicate + +- **Setup:** Same `test.qmd` +- **Steps:** `quarto preview test.qmd`, check files immediately after first render (before any saves), Ctrl+C +- **Expected:** Exactly one `.quarto_ipynb` during render. Zero after exit. +- **Catches:** `cmd.ts` not passing ProjectContext to `preview()` + +#### T3: .qmd in project — project-level preview + +- **Setup:** Website project (`_quarto.yml` with `type: website`), `index.qmd` with Python cell +- **Steps:** `quarto preview` (project dir), save `index.qmd` 3 times, check files, Ctrl+C +- **Expected:** At most one `index.quarto_ipynb`. Zero after exit. +- **Catches:** Fix works when `projectContext()` finds a real project + +#### T4: .qmd in project — single file preview + +- **Setup:** Same project as T3 +- **Steps:** `quarto preview index.qmd`, save 3 times, check files, Ctrl+C +- **Expected:** Same as T3. May redirect to project preview (expected behavior). +- **Catches:** Context passing works for files inside serveable projects + +### P2: Important + +#### T5: .qmd with Julia code cells + +- **Setup:** `julia-test.qmd` with Julia cell +- **Steps:** Same as T1 +- **Expected:** Same as T1. Julia uses the same Jupyter engine path. + +#### T6: Rapid successive saves + +- **Setup:** Same `test.qmd` as T1 +- **Steps:** Save 5 times within 2-3 seconds (faster than render completes) +- **Expected:** At most one `.quarto_ipynb`. Debounce/queue coalesces saves. +- **Catches:** Race condition in invalidation during in-progress render + +#### T7: `keep-ipynb: true` + +- **Setup:** `test.qmd` with `keep-ipynb: true` in YAML +- **Steps:** Preview, save 3 times, Ctrl+C, check files +- **Expected:** `test.quarto_ipynb` persists after exit (not cleaned up). No `_1` variants during preview. +- **Catches:** `invalidateForFile()` respects the `transient = false` flag set by `cleanupNotebook()` + +#### T8: `--to pdf` format + +- **Setup:** Same `test.qmd` (requires TinyTeX) +- **Steps:** `quarto preview test.qmd --to pdf`, save 3 times +- **Expected:** Same as T1. Transient notebook logic is format-independent. + +#### T9: Plain .qmd — no code cells (regression) + +- **Setup:** `plain.qmd` with only markdown content +- **Steps:** Preview, save 3 times, check for `.quarto_ipynb` files +- **Expected:** No `.quarto_ipynb` files ever created. +- **Catches:** Fix is a no-op when no Jupyter engine is involved + +#### T10: .qmd with R/knitr engine (regression) + +- **Setup:** `r-test.qmd` with R code cell and `engine: knitr` +- **Steps:** Preview, save 3 times, check for `.quarto_ipynb` files +- **Expected:** No `.quarto_ipynb` files. Knitr doesn't use Jupyter intermediate. + +#### T10b: File excluded from project inputs (regression) + +- **Setup:** Website project with `_quarto.yml`. Create `_excluded.qmd` with a Python cell (files starting with `_` are excluded from project inputs by default) +- **Steps:** `quarto preview _excluded.qmd`, save 3 times, check files, Ctrl+C +- **Expected:** Falls back to single-file preview (not project preview). At most one `.quarto_ipynb`. +- **Catches:** Context reuse from cmd.ts incorrectly applying project semantics to excluded files + +### P3: Nice-to-Have + +#### T11: Native .ipynb file + +- **Setup:** `notebook.ipynb` (native Jupyter notebook) +- **Steps:** Preview, save 3 times +- **Expected:** No transient `.quarto_ipynb` — the `.ipynb` is the source, not transient. + +#### T12: File with spaces in name + +- **Setup:** `my document.qmd` with Python cell +- **Steps:** `quarto preview "my document.qmd"`, save 3 times +- **Expected:** At most one `my document.quarto_ipynb`. Path normalization handles spaces. + +#### T13: File in subdirectory + +- **Setup:** `subdir/deep/test.qmd` with Python cell +- **Steps:** Preview from parent dir, save 3 times +- **Expected:** At most one transient notebook in `subdir/deep/`. + +#### T14: Change code cell content + +- **Setup:** `test.qmd` with `x = 1; print(x)` +- **Steps:** Change to `x = 2`, save; change to `x = 3`, save +- **Expected:** At most one `.quarto_ipynb`. Code changes trigger re-execution but file is cleaned. + +#### T15: Change YAML metadata + +- **Setup:** `test.qmd` with `title: "Test"` +- **Steps:** Change title, save; add `theme: cosmo`, save +- **Expected:** Same as T1. Metadata changes go through the same render/invalidation path. + +#### T16: Multiple .qmd files in project + +- **Setup:** Website with `index.qmd` (Python), `about.qmd` (Python), `plain.qmd` (no code) +- **Steps:** Preview project, edit index, navigate to about, edit about +- **Expected:** At most one `.quarto_ipynb` per Jupyter-using file. No accumulation. + +## Test Matrix: Single-file Preview Root URL (#14298) + +After every change to preview URL or handler logic, verify that single-file previews serve content at the root URL and print the correct Browse URL. + +### P1: Critical + +#### T17: Single-file preview — root URL accessible + +- **Setup:** `plain.qmd` with only markdown content (no code cells) +- **Steps:** `quarto preview plain.qmd --port XXXX --no-browser`, then `curl -s -o /dev/null -w "%{http_code}" http://localhost:XXXX/` +- **Expected:** HTTP 200. Browse URL prints `http://localhost:XXXX/` (no filename appended). +- **Catches:** `projectHtmlFileRequestHandler` used for single files (defaultFile=`index.html` instead of output filename), or `previewInitialPath` returning filename instead of `""` + +#### T18: Single-file preview — named output URL also accessible + +- **Setup:** Same `plain.qmd` +- **Steps:** `quarto preview plain.qmd --port XXXX --no-browser`, then `curl -s -o /dev/null -w "%{http_code}" http://localhost:XXXX/plain.html` +- **Expected:** HTTP 200. The output filename path also serves the rendered content. +- **Catches:** Handler regression where only root or only named path works + +### P2: Important + +#### T19: Project preview — non-index file URL correct + +- **Setup:** Website project with `_quarto.yml`, `index.qmd`, and `about.qmd` +- **Steps:** `quarto preview --port XXXX --no-browser`, navigate to `http://localhost:XXXX/about.html` +- **Expected:** HTTP 200. Browse URL may include path for non-index files in project context. +- **Catches:** `isSingleFile` guard accidentally excluding real project files from path computation + +## Test File Templates + +**Minimal Python .qmd:** +```yaml +--- +title: "Preview Test" +--- +``` + +```` +```{python} +print("Hello from Python") +``` +```` + +``` +Edit this line to trigger re-renders. +``` + +**Minimal website project (`_quarto.yml`):** +```yaml +project: + type: website +``` + +**keep-ipynb variant:** +```yaml +--- +title: "Keep ipynb Test" +execute: + keep-ipynb: true +--- +``` diff --git a/tests/docs/manual/preview/plain.qmd b/tests/docs/manual/preview/plain.qmd new file mode 100644 index 00000000000..27dd844ed46 --- /dev/null +++ b/tests/docs/manual/preview/plain.qmd @@ -0,0 +1,9 @@ +--- +title: "Plain QMD Test" +--- + +## T9: No code cells + +No .quarto_ipynb should ever be created. + +Edit counter: 0 diff --git a/tests/unit/preview-initial-path.test.ts b/tests/unit/preview-initial-path.test.ts new file mode 100644 index 00000000000..7fbd1b3ed42 --- /dev/null +++ b/tests/unit/preview-initial-path.test.ts @@ -0,0 +1,53 @@ +/* + * preview-initial-path.test.ts + * + * Tests that previewInitialPath computes the correct browse URL path + * for different project types. Regression test for #14298. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../test.ts"; +import { assertEquals } from "testing/asserts"; +import { join } from "../../src/deno_ral/path.ts"; +import { previewInitialPath } from "../../src/command/preview/preview.ts"; +import { createMockProjectContext } from "./project/utils.ts"; + +// deno-lint-ignore require-await +unitTest("previewInitialPath - single file returns empty path (#14298)", async () => { + const project = createMockProjectContext({ isSingleFile: true }); + const outputFile = join(project.dir, "hello.html"); + + const result = previewInitialPath(outputFile, project); + assertEquals(result, "", "Single-file preview should use root path, not filename"); + + project.cleanup(); +}); + +// deno-lint-ignore require-await +unitTest("previewInitialPath - project file returns relative path", async () => { + const project = createMockProjectContext(); + const outputFile = join(project.dir, "chapter.html"); + + const result = previewInitialPath(outputFile, project); + assertEquals(result, "chapter.html", "Project preview should include relative path"); + + project.cleanup(); +}); + +// deno-lint-ignore require-await +unitTest("previewInitialPath - project subdir returns relative path", async () => { + const project = createMockProjectContext(); + const outputFile = join(project.dir, "pages", "about.html"); + + const result = previewInitialPath(outputFile, project); + assertEquals(result, "pages/about.html", "Project preview should include subdirectory path"); + + project.cleanup(); +}); + +// deno-lint-ignore require-await +unitTest("previewInitialPath - undefined project returns empty path", async () => { + const result = previewInitialPath("/tmp/hello.html", undefined); + assertEquals(result, "", "No project should use root path"); +}); diff --git a/tests/unit/project/utils.ts b/tests/unit/project/utils.ts index e7b6c375e29..810847678b4 100644 --- a/tests/unit/project/utils.ts +++ b/tests/unit/project/utils.ts @@ -11,21 +11,28 @@ import { FileInformationCacheMap } from "../../../src/project/project-shared.ts" /** * Create a minimal mock ProjectContext for testing. - * Only provides the essential properties needed for cache-related tests. * - * @param dir - The project directory (defaults to a temp directory) + * @param options.dir - The project directory (defaults to a temp directory) + * @param options.isSingleFile - Whether this is a single-file project (defaults to false) + * @param options.config - Project config (defaults to { project: {} }) * @returns A mock ProjectContext suitable for unit testing */ export function createMockProjectContext( - dir?: string, + options?: { + dir?: string; + isSingleFile?: boolean; + config?: ProjectContext["config"]; + }, ): ProjectContext { - const projectDir = dir ?? Deno.makeTempDirSync({ prefix: "quarto-test" }); - const ownsDir = dir === undefined; + const projectDir = options?.dir ?? + Deno.makeTempDirSync({ prefix: "quarto-test" }); + const ownsDir = options?.dir === undefined; return { dir: projectDir, engines: [], files: { input: [] }, + config: options?.config ?? { project: {} }, notebookContext: {} as ProjectContext["notebookContext"], fileInformationCache: new FileInformationCacheMap(), resolveBrand: () => Promise.resolve(undefined), @@ -37,7 +44,7 @@ export function createMockProjectContext( clone: function () { return this; }, - isSingleFile: false, + isSingleFile: options?.isSingleFile ?? false, diskCache: {} as ProjectContext["diskCache"], temp: {} as ProjectContext["temp"], cleanup: () => { From 5f5993c7a7117488f9829d0cb361b936130ea093 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 3 Apr 2026 21:04:49 +0200 Subject: [PATCH 06/21] Fix skill about preview testing --- .claude/commands/quarto-preview-test/SKILL.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.claude/commands/quarto-preview-test/SKILL.md b/.claude/commands/quarto-preview-test/SKILL.md index b902225e21f..8f69b72644f 100644 --- a/.claude/commands/quarto-preview-test/SKILL.md +++ b/.claude/commands/quarto-preview-test/SKILL.md @@ -18,7 +18,7 @@ Interactive testing of `quarto preview` with automated browser verification. ## Prerequisites - Quarto dev version built (`./configure.sh` or `./configure.cmd`) -- Test environment configured (`tests/configure-test-env.sh` or `.ps1`) +- Test environment configured (`tests/configure-test-env.sh` or `tests/configure-test-env.ps1`) - `/agent-browser` CLI installed (preferred), OR Chrome + Chrome DevTools MCP connected ## Starting Preview @@ -92,8 +92,6 @@ Preview behaves differently depending on input: | File within a project | May redirect to project preview via `serveProject()` | | Project directory | `serveProject()` -> `watchProject()` | -See `llm-docs/preview-architecture.md` for the full architecture. - ## When NOT to Use - Automated smoke tests — use `tests/smoke/` instead From da25f511cd1f1f107f084cec7c91069415bec852 Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Tue, 7 Apr 2026 12:18:22 -0400 Subject: [PATCH 07/21] Add changelog entry for Typst margin layout (#13879) Also sort #13878 into numerical order. --- news/changelog-1.9.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 5ab9145c937..f94f7be9df8 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -91,12 +91,13 @@ All changes included in 1.9: - ([#13775](https://github.com/quarto-dev/quarto-cli/issues/13775)): Fix brand fonts not being applied when using `citeproc: true` with Typst format. Format detection now properly handles Pandoc format variants like `typst-citations`. - ([#13868](https://github.com/quarto-dev/quarto-cli/issues/13868)): Add image alt text support for PDF/UA accessibility. Alt text from markdown captions and explicit `alt` attributes is now passed to Typst's `image()` function. (Temporary workaround until [jgm/pandoc#11394](https://github.com/jgm/pandoc/pull/11394) is merged.) - ([#13870](https://github.com/quarto-dev/quarto-cli/issues/13870)): Add support for `alt` attribute on cross-referenced equations for improved accessibility. (author: @mcanouil) +- ([#13878](https://github.com/quarto-dev/quarto-cli/issues/13878)): Typst now uses Pandoc's skylighting for syntax highlighting by default (consistent with other formats). Use `syntax-highlighting: idiomatic` to opt-in to Typst's native syntax highlighting instead. +- ([#13879](https://github.com/quarto-dev/quarto-cli/pull/13879)): Add Tufte-style margin layout for Typst using the marginalia package. Supports `.column-margin` for margin notes, figures, tables, and captions; `.column-screen` and `.column-page` for full-width content; `citation-location: margin`; `reference-location: margin` for sidenotes; `suppress-bibliography`; `cap-location: margin`, `fig-cap-location: margin`, and `tbl-cap-location: margin`; `grid` options (`margin-width`, `body-width`, `gutter-width`); and `margin-geometry` for fine-grained layout control. - ([#13917](https://github.com/quarto-dev/quarto-cli/issues/13917)): Fix brand logo paths not resolving correctly for Typst documents in project subdirectories. Brand logo paths are now converted to project-absolute paths before merging with document metadata, replacing the fragile `projectOffset()` workaround. - ([#13942](https://github.com/quarto-dev/quarto-cli/issues/13942)): Fix Typst compilation errors showing confusing internal stack traces. Users now see only the relevant Typst error message. - ([#13950](https://github.com/quarto-dev/quarto-cli/pull/13950)): Replace ctheorems with theorion package for theorem environments. Add `theorem-appearance` option to control styling: `simple` (default, classic LaTeX style), `fancy` (colored boxes with brand colors), `clouds` (rounded backgrounds), or `rainbow` (colored start border and colored title). - ([#13954](https://github.com/quarto-dev/quarto-cli/issues/13954)): Add support for Typst book projects via format extensions. Quarto now bundles the `orange-book` extension which provides a textbook-style format with chapter numbering, cross-references, and professional styling. Book projects with `format: typst` automatically use this extension. - ([#13978](https://github.com/quarto-dev/quarto-cli/pull/13978)): Keep term and description together in definition lists to avoid breaking across pages. (author: @mcanouil) -- ([#13878](https://github.com/quarto-dev/quarto-cli/issues/13878)): Typst now uses Pandoc's skylighting for syntax highlighting by default (consistent with other formats). Use `syntax-highlighting: idiomatic` to opt-in to Typst's native syntax highlighting instead. - ([#14126](https://github.com/quarto-dev/quarto-cli/issues/14126)): Fix Skylighting code blocks in Typst lacking full-width background, padding, and border radius. A postprocessor patches the Pandoc-generated Skylighting function to add `width: 100%`, `inset: 8pt`, and `radius: 2pt` to the block call, matching the styling of native code blocks. Brand `monospace-block.background-color` also now correctly applies to Skylighting output. This workaround will be removed once the fix is upstreamed to Skylighting. - ([#14202](https://github.com/quarto-dev/quarto-cli/issues/14202)): Fix CSS inlining (`juice`) failing on Windows when Quarto is installed in a path with spaces (e.g., `C:\Program Files\Quarto\`). From cff33195d2542d6e6b182a5303ab9cad275d9c49 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 7 Apr 2026 19:10:42 +0200 Subject: [PATCH 08/21] [backport] Fix quarto inspect standalone file project emission for RStudio Backport from #14307. Gate `project` emission in `inspectDocumentConfig` on `!(isSingleFile && isRStudio())` so RStudio's publishing wizard doesn't crash on standalone .qmd files with Quarto >= 1.9.35. Ref: rstudio/rstudio#17333 --- news/changelog-1.9.md | 1 + src/core/platform.ts | 8 ++ src/inspect/inspect.ts | 5 +- tests/docs/inspect/standalone-hello.qmd | 6 ++ .../inspect-standalone-rstudio.test.ts | 78 +++++++++++++++++++ 5 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 tests/docs/inspect/standalone-hello.qmd create mode 100644 tests/smoke/inspect/inspect-standalone-rstudio.test.ts diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index f94f7be9df8..96f16ad8fe9 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -5,6 +5,7 @@ - ([#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. +- ([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. ## In previous releases diff --git a/src/core/platform.ts b/src/core/platform.ts index 85c34070cd0..01f18f374db 100644 --- a/src/core/platform.ts +++ b/src/core/platform.ts @@ -14,7 +14,15 @@ export function isWSL() { return !!Deno.env.get("WSL_DISTRO_NAME"); } +// Test override for isRStudio — avoids Deno.env.set() race conditions +// in parallel tests. See quarto-dev/quarto-cli#14218 and PR #12621. +let _isRStudioOverride: boolean | undefined; +export function _setIsRStudioForTest(value: boolean | undefined) { + _isRStudioOverride = value; +} + export function isRStudio() { + if (_isRStudioOverride !== undefined) return _isRStudioOverride; return !!Deno.env.get("RSTUDIO"); } diff --git a/src/inspect/inspect.ts b/src/inspect/inspect.ts index 3e98cc443fb..f7b4c283707 100644 --- a/src/inspect/inspect.ts +++ b/src/inspect/inspect.ts @@ -49,6 +49,7 @@ import { import { validateDocumentFromSource } from "../core/schema/validate-document.ts"; import { error } from "../deno_ral/log.ts"; import { ProjectContext } from "../project/types.ts"; +import { isRStudio } from "../core/platform.ts"; export function isProjectConfig( config: InspectedConfig, @@ -238,7 +239,9 @@ const inspectDocumentConfig = async (path: string) => { }; // if there is a project then add it - if (context?.config) { + // Suppress project for standalone files in RStudio: current releases + // assume project.dir implies _quarto.yml exists (rstudio/rstudio#17333) + if (context?.config && !(context.isSingleFile && isRStudio())) { config.project = await inspectProjectConfig(context); } return config; diff --git a/tests/docs/inspect/standalone-hello.qmd b/tests/docs/inspect/standalone-hello.qmd new file mode 100644 index 00000000000..151c07a56fb --- /dev/null +++ b/tests/docs/inspect/standalone-hello.qmd @@ -0,0 +1,6 @@ +--- +title: Hello +format: html +--- + +Hello world. diff --git a/tests/smoke/inspect/inspect-standalone-rstudio.test.ts b/tests/smoke/inspect/inspect-standalone-rstudio.test.ts new file mode 100644 index 00000000000..2f26608179b --- /dev/null +++ b/tests/smoke/inspect/inspect-standalone-rstudio.test.ts @@ -0,0 +1,78 @@ +/* +* inspect-standalone-rstudio.test.ts +* +* Copyright (C) 2020-2026 Posit Software, PBC +* +*/ + +import { existsSync } from "../../../src/deno_ral/fs.ts"; +import { _setIsRStudioForTest } from "../../../src/core/platform.ts"; +import { + ExecuteOutput, + testQuartoCmd, +} from "../../test.ts"; +import { assert, assertEquals } from "testing/asserts"; + +// Test: standalone file inspect with RStudio override should NOT emit project. +// Uses _setIsRStudioForTest to avoid Deno.env.set() race conditions in +// parallel tests (see #14218, PR #12621). +(() => { + const input = "docs/inspect/standalone-hello.qmd"; + const output = "docs/inspect/standalone-hello.json"; + testQuartoCmd( + "inspect", + [input, output], + [ + { + name: "inspect-standalone-no-project-in-rstudio", + verify: async (_outputs: ExecuteOutput[]) => { + assert(existsSync(output)); + const json = JSON.parse(Deno.readTextFileSync(output)); + assertEquals(json.project, undefined, + "Standalone file inspect should not emit 'project' when in RStudio"); + } + } + ], + { + setup: async () => { + _setIsRStudioForTest(true); + }, + teardown: async () => { + _setIsRStudioForTest(undefined); + if (existsSync(output)) { + Deno.removeSync(output); + } + } + }, + ); +})(); + +// Test: standalone file inspect WITHOUT RStudio should still emit project +(() => { + const input = "docs/inspect/standalone-hello.qmd"; + const output = "docs/inspect/standalone-hello-nors.json"; + testQuartoCmd( + "inspect", + [input, output], + [ + { + name: "inspect-standalone-has-project-outside-rstudio", + verify: async (_outputs: ExecuteOutput[]) => { + assert(existsSync(output)); + const json = JSON.parse(Deno.readTextFileSync(output)); + assert(json.project !== undefined, + "Standalone file inspect should emit 'project' when not in RStudio"); + assert(json.project.dir !== undefined, + "project.dir should be set"); + } + } + ], + { + teardown: async () => { + if (existsSync(output)) { + Deno.removeSync(output); + } + } + }, + ); +})(); From 9db6863e08604e7b32603ce2eefb5977c924d7c5 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 9 Apr 2026 10:33:11 +0200 Subject: [PATCH 09/21] [backport] Add arm64 Linux support for chrome-headless-shell and deprecate chromium installer (#14335) Add arm64 Linux support for `quarto install chrome-headless-shell` using Microsoft's Playwright CDN as the download source, since Chrome for Testing has no arm64 Linux builds. Version metadata comes from Playwright's browsers.json, and the arm64 binary name difference (`headless_shell` vs `chrome-headless-shell`) is abstracted by a platform-aware helper. Add deprecation warnings guiding users from `chromium` to `chrome-headless-shell`: - `quarto install chromium` and `quarto update chromium` show deprecation before proceeding (including on WSL) - `quarto check install` shows migration guidance when legacy Chromium is detected in the installed tools list Add CI workflow (`test-install.yml`) covering arm64 Linux and macOS tool installation, and chromium deprecation warnings across all platforms. Partial backport from #14334. Fixes #1187 --- .github/workflows/test-ff-matrix.yml | 2 + .github/workflows/test-install.yml | 134 ++++++++++++++++++ .github/workflows/test-smokes-parallel.yml | 1 + news/changelog-1.9.md | 1 + src/command/check/check.ts | 5 + src/tools/impl/chrome-for-testing.ts | 85 +++++++++-- src/tools/impl/chrome-headless-shell.ts | 39 ++++- src/tools/tools-console.ts | 8 ++ src/tools/tools.ts | 6 + tests/unit/tools/chrome-for-testing.test.ts | 66 ++++++++- .../unit/tools/chrome-headless-shell.test.ts | 33 +++-- 11 files changed, 359 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/test-install.yml diff --git a/.github/workflows/test-ff-matrix.yml b/.github/workflows/test-ff-matrix.yml index 8214caa5724..29645fcd5bd 100644 --- a/.github/workflows/test-ff-matrix.yml +++ b/.github/workflows/test-ff-matrix.yml @@ -20,6 +20,7 @@ on: - ".github/workflows/stale-needs-repro.yml" - ".github/workflows/test-bundle.yml" - ".github/workflows/test-smokes-parallel.yml" + - ".github/workflows/test-install.yml" - ".github/workflows/test-quarto-latexmk.yml" - ".github/workflows/update-test-timing.yml" pull_request: @@ -31,6 +32,7 @@ on: - ".github/workflows/performance-check.yml" - ".github/workflows/stale-needs-repro.yml" - ".github/workflows/test-bundle.yml" + - ".github/workflows/test-install.yml" - ".github/workflows/test-smokes-parallel.yml" - ".github/workflows/test-quarto-latexmk.yml" - ".github/workflows/update-test-timing.yml" diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml new file mode 100644 index 00000000000..d4967035f9d --- /dev/null +++ b/.github/workflows/test-install.yml @@ -0,0 +1,134 @@ +# Integration test for `quarto install` on platforms not covered by smoke tests. +# Smoke tests (test-smokes.yml) cover x86_64 Linux and Windows. +# This workflow fills the gap for arm64 Linux and macOS. +name: Test Tool Install +on: + workflow_dispatch: + push: + branches: + - main + - "v1.*" + paths: + - "src/tools/**" + - ".github/workflows/test-install.yml" + pull_request: + paths: + - "src/tools/**" + - ".github/workflows/test-install.yml" + schedule: + # Weekly Monday 9am UTC — detect upstream CDN/API breakage + - cron: "0 9 * * 1" + +permissions: + contents: read + +jobs: + test-install: + name: Install tools (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04-arm, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Repo + uses: actions/checkout@v6 + + - uses: ./.github/workflows/actions/quarto-dev + + - name: Install TinyTeX + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + quarto install tinytex + + - name: Install Chrome Headless Shell + run: | + quarto install chrome-headless-shell --no-prompt + + - name: Verify tools with quarto check + run: | + quarto check install + + test-chromium-deprecation: + name: Chromium deprecation warning (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Repo + uses: actions/checkout@v6 + + - uses: ./.github/workflows/actions/quarto-dev + + - name: Make quarto available in bash (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + quarto_cmd=$(command -v quarto.cmd) + dir=$(dirname "$quarto_cmd") + printf '#!/bin/bash\nexec "%s" "$@"\n' "$quarto_cmd" > "$dir/quarto" + chmod +x "$dir/quarto" + + - name: Install chromium and capture result + id: install-chromium + shell: bash + run: | + set +e + output=$(quarto install chromium --no-prompt 2>&1) + exit_code=$? + set -e + echo "$output" + if echo "$output" | grep -Fq "is deprecated"; then + echo "deprecation-warning=true" >> "$GITHUB_OUTPUT" + fi + if [ "$exit_code" -eq 0 ]; then + echo "chromium-installed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Assert install deprecation warning was shown + shell: bash + run: | + if [ "${{ steps.install-chromium.outputs.deprecation-warning }}" != "true" ]; then + echo "::error::Deprecation warning missing from quarto install chromium output" + exit 1 + fi + echo "Install deprecation warning found" + + - name: Update chromium and capture result + id: update-chromium + shell: bash + run: | + set +e + output=$(quarto update chromium --no-prompt 2>&1) + set -e + echo "$output" + if echo "$output" | grep -Fq "is deprecated"; then + echo "deprecation-warning=true" >> "$GITHUB_OUTPUT" + fi + + - name: Assert update deprecation warning was shown + shell: bash + run: | + if [ "${{ steps.update-chromium.outputs.deprecation-warning }}" != "true" ]; then + echo "::error::Deprecation warning missing from quarto update chromium output" + exit 1 + fi + echo "Update deprecation warning found" + + - name: Verify quarto check warns about outdated Chromium + shell: bash + run: | + if [ "${{ steps.install-chromium.outputs.chromium-installed }}" != "true" ]; then + echo "Chromium install did not succeed on this platform, skipping check" + exit 0 + fi + output=$(quarto check install 2>&1) + echo "$output" + if ! echo "$output" | grep -Fq "Chromium is outdated"; then + echo "::error::Outdated Chromium warning missing from quarto check" + exit 1 + fi + echo "Outdated Chromium warning found in quarto check" diff --git a/.github/workflows/test-smokes-parallel.yml b/.github/workflows/test-smokes-parallel.yml index 48e02401135..1f4185b4944 100644 --- a/.github/workflows/test-smokes-parallel.yml +++ b/.github/workflows/test-smokes-parallel.yml @@ -29,6 +29,7 @@ on: - ".github/workflows/stale-needs-repro.yml" - ".github/workflows/test-bundle.yml" - ".github/workflows/test-ff-matrix.yml" + - ".github/workflows/test-install.yml" - ".github/workflows/test-quarto-latexmk.yml" - ".github/workflows/update-test-timing.yml" push: diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 96f16ad8fe9..1c2af28a6b3 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -6,6 +6,7 @@ - ([#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. - ([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. +- ([#14334](https://github.com/quarto-dev/quarto-cli/pull/14334), [#9710](https://github.com/quarto-dev/quarto-cli/issues/9710)): Add arm64 Linux support for `quarto install chrome-headless-shell` using Playwright CDN as download source. `quarto install chromium` and `quarto update chromium` now show a deprecation warning — use `chrome-headless-shell` instead, which always installs the latest stable Chrome (the legacy `chromium` installer pins an outdated Puppeteer revision that cannot receive security updates). `quarto check install` also warns when legacy Chromium is detected. ## In previous releases diff --git a/src/command/check/check.ts b/src/command/check/check.ts index 46c6ed15728..ab7fda63976 100644 --- a/src/command/check/check.ts +++ b/src/command/check/check.ts @@ -364,6 +364,11 @@ async function checkInstall(conf: CheckConfiguration) { toolsJson[tool.name] = { version, }; + if (tool.name === "Chromium") { + toolsOutput.push( + `${kIndent} (Chromium is outdated. Run "quarto uninstall chromium" then "quarto install chrome-headless-shell")`, + ); + } } for (const tool of tools.notInstalled) { toolsOutput.push(`${kIndent}${tool.name}: (not installed)`); diff --git a/src/tools/impl/chrome-for-testing.ts b/src/tools/impl/chrome-for-testing.ts index ce543913937..4dd3e463775 100644 --- a/src/tools/impl/chrome-for-testing.ts +++ b/src/tools/impl/chrome-for-testing.ts @@ -18,6 +18,7 @@ import { InstallContext } from "../types.ts"; /** CfT platform identifiers matching the Google Chrome for Testing API. */ export type CftPlatform = | "linux64" + | "linux-arm64" | "mac-arm64" | "mac-x64" | "win32" @@ -32,11 +33,12 @@ export interface PlatformInfo { /** * Map os + arch to a CfT platform string. - * Throws on unsupported platforms (e.g., linux aarch64 — to be handled by Playwright CDN). + * Throws on unsupported platforms. */ export function detectCftPlatform(): PlatformInfo { const platformMap: Record = { "linux-x86_64": "linux64", + "linux-aarch64": "linux-arm64", "darwin-aarch64": "mac-arm64", "darwin-x86_64": "mac-x64", "windows-x86_64": "win64", @@ -47,14 +49,8 @@ export function detectCftPlatform(): PlatformInfo { const platform = platformMap[key]; if (!platform) { - if (os === "linux" && arch === "aarch64") { - throw new Error( - "linux-arm64 is not supported by Chrome for Testing. " + - "Use 'quarto install chromium' for arm64 support.", - ); - } throw new Error( - `Unsupported platform for Chrome for Testing: ${os} ${arch}`, + `Unsupported platform for chrome-headless-shell: ${os} ${arch}`, ); } @@ -122,6 +118,79 @@ export async function fetchLatestCftRelease(): Promise { }; } +/** Parsed entry from Playwright's browsers.json for chromium-headless-shell. */ +export interface PlaywrightBrowserEntry { + revision: string; + browserVersion: string; +} + +const kPlaywrightBrowsersJsonUrl = + "https://raw.githubusercontent.com/microsoft/playwright/main/packages/playwright-core/browsers.json"; + +/** Check if the current platform requires Playwright CDN (arm64 Linux). */ +export function isPlaywrightCdnPlatform(info?: PlatformInfo): boolean { + const p = info ?? detectCftPlatform(); + return p.platform === "linux-arm64"; +} + +/** + * Fetch Playwright's browsers.json and extract the chromium-headless-shell entry. + * Used as the version/revision source for arm64 Linux where CfT has no builds. + */ +export async function fetchPlaywrightBrowsersJson(): Promise { + let response: Response; + const fallbackHint = "\nIf this persists, install a system Chrome/Chromium instead " + + "(Quarto will detect it automatically)."; + try { + response = await fetch(kPlaywrightBrowsersJsonUrl); + } catch (e) { + throw new Error( + `Failed to fetch Playwright browsers.json: ${ + e instanceof Error ? e.message : String(e) + }${fallbackHint}`, + ); + } + + if (!response.ok) { + throw new Error( + `Playwright browsers.json returned ${response.status}: ${response.statusText}${fallbackHint}`, + ); + } + + // deno-lint-ignore no-explicit-any + let data: any; + try { + data = await response.json(); + } catch { + throw new Error("Playwright browsers.json returned invalid JSON"); + } + + const browsers = data?.browsers; + if (!Array.isArray(browsers)) { + throw new Error("Playwright browsers.json missing 'browsers' array"); + } + + // deno-lint-ignore no-explicit-any + const entry = browsers.find((b: any) => b.name === "chromium-headless-shell"); + if (!entry || !entry.revision || !entry.browserVersion) { + throw new Error( + "Playwright browsers.json has no 'chromium-headless-shell' entry with revision and browserVersion", + ); + } + + return { + revision: entry.revision, + browserVersion: entry.browserVersion, + }; +} + +/** + * Construct the Playwright CDN download URL for chrome-headless-shell on linux arm64. + */ +export function playwrightCdnDownloadUrl(revision: string): string { + return `https://cdn.playwright.dev/builds/chromium/${revision}/chromium-headless-shell-linux-arm64.zip`; +} + /** * Find a named executable inside an extracted CfT directory. * Handles platform-specific naming (.exe on Windows) and nested directory structures. diff --git a/src/tools/impl/chrome-headless-shell.ts b/src/tools/impl/chrome-headless-shell.ts index 7ac9c038c89..f950856f8a9 100644 --- a/src/tools/impl/chrome-headless-shell.ts +++ b/src/tools/impl/chrome-headless-shell.ts @@ -20,7 +20,10 @@ import { detectCftPlatform, downloadAndExtractCft, fetchLatestCftRelease, + fetchPlaywrightBrowsersJson, findCftExecutable, + isPlaywrightCdnPlatform, + playwrightCdnDownloadUrl, } from "./chrome-for-testing.ts"; const kVersionFileName = "version"; @@ -32,6 +35,18 @@ export function chromeHeadlessShellInstallDir(): string { return quartoDataDir("chrome-headless-shell"); } +/** + * The executable name for chrome-headless-shell on the current platform. + * CfT builds use "chrome-headless-shell", Playwright arm64 builds use "headless_shell". + */ +export function chromeHeadlessShellBinaryName(): string { + try { + return isPlaywrightCdnPlatform() ? "headless_shell" : "chrome-headless-shell"; + } catch { + return "chrome-headless-shell"; + } +} + /** * Find the chrome-headless-shell executable in the install directory. * Returns the absolute path if installed, undefined otherwise. @@ -41,7 +56,7 @@ export function chromeHeadlessShellExecutablePath(): string | undefined { if (!existsSync(dir)) { return undefined; } - return findCftExecutable(dir, "chrome-headless-shell"); + return findCftExecutable(dir, chromeHeadlessShellBinaryName()); } /** Record the installed version as a plain text file. */ @@ -62,7 +77,7 @@ export function readInstalledVersion(dir: string): string | undefined { /** Check if chrome-headless-shell is installed in the given directory. */ export function isInstalled(dir: string): boolean { return existsSync(join(dir, kVersionFileName)) && - findCftExecutable(dir, "chrome-headless-shell") !== undefined; + findCftExecutable(dir, chromeHeadlessShellBinaryName()) !== undefined; } // -- InstallableTool methods -- @@ -84,8 +99,22 @@ async function installedVersion(): Promise { } async function latestRelease(): Promise { + const platformInfo = detectCftPlatform(); + + if (isPlaywrightCdnPlatform(platformInfo)) { + // arm64 Linux: use Playwright CDN + const entry = await fetchPlaywrightBrowsersJson(); + const url = playwrightCdnDownloadUrl(entry.revision); + return { + url, + version: entry.browserVersion, + assets: [{ name: "chrome-headless-shell", url }], + }; + } + + // All other platforms: use CfT API const release = await fetchLatestCftRelease(); - const { platform } = detectCftPlatform(); + const { platform } = platformInfo; const downloads = release.downloads["chrome-headless-shell"]; if (!downloads) { @@ -110,13 +139,15 @@ async function preparePackage(ctx: InstallContext): Promise { const release = await latestRelease(); const workingDir = Deno.makeTempDirSync({ prefix: "quarto-chrome-hs-" }); + const binaryName = chromeHeadlessShellBinaryName(); + try { await downloadAndExtractCft( "Chrome Headless Shell", release.url, workingDir, ctx, - "chrome-headless-shell", + binaryName, ); } catch (e) { safeRemoveSync(workingDir, { recursive: true }); diff --git a/src/tools/tools-console.ts b/src/tools/tools-console.ts index 5cd9db25809..13356c94a86 100644 --- a/src/tools/tools-console.ts +++ b/src/tools/tools-console.ts @@ -149,6 +149,14 @@ export async function updateOrInstallTool( prompt?: boolean, updatePath?: boolean, ) { + // Deprecation warning for chromium + if (tool.toLowerCase() === "chromium" && action === "update") { + warning( + '"quarto update chromium" is deprecated and will be removed in Quarto 1.10. ' + + 'Use "quarto install chrome-headless-shell" instead.', + ); + } + const summary = await toolSummary(tool); if (action === "update") { diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 0ef3ad02534..c1c714da48c 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -92,6 +92,12 @@ export async function printToolInfo(name: string) { } export function checkToolRequirement(name: string) { + if (name.toLowerCase() === "chromium") { + warning( + '"quarto install chromium" is deprecated and will be removed in Quarto 1.10. ' + + 'Use "quarto install chrome-headless-shell" instead.', + ); + } if (name.toLowerCase() === "chromium" && isWSL()) { // TODO: Change to a quarto-web url page ? const troubleshootUrl = diff --git a/tests/unit/tools/chrome-for-testing.test.ts b/tests/unit/tools/chrome-for-testing.test.ts index b2af0ef1584..d5ef7ba707b 100644 --- a/tests/unit/tools/chrome-for-testing.test.ts +++ b/tests/unit/tools/chrome-for-testing.test.ts @@ -22,7 +22,7 @@ import { // Step 1: detectCftPlatform() unitTest("detectCftPlatform - returns valid CftPlatform for current system", async () => { const result = detectCftPlatform(); - const validPlatforms = ["linux64", "mac-arm64", "mac-x64", "win32", "win64"]; + const validPlatforms = ["linux64", "linux-arm64", "mac-arm64", "mac-x64", "win32", "win64"]; assert( validPlatforms.includes(result.platform), `Expected one of ${validPlatforms.join(", ")}, got: ${result.platform}`, @@ -172,3 +172,67 @@ unitTest( ignore: runningInCI(), }, ); + +// -- Playwright CDN tests (arm64 Linux support) -- + +import { + isPlaywrightCdnPlatform, + playwrightCdnDownloadUrl, + fetchPlaywrightBrowsersJson, +} from "../../../src/tools/impl/chrome-for-testing.ts"; + +// isPlaywrightCdnPlatform — pure logic, runs everywhere +unitTest("isPlaywrightCdnPlatform - returns false on non-arm64 platform", async () => { + if (os === "linux" && arch === "aarch64") return; // Skip on actual arm64 + const result = isPlaywrightCdnPlatform(); + assertEquals(result, false); +}); + +// playwrightCdnDownloadUrl — pure function, no HTTP +unitTest("playwrightCdnDownloadUrl - constructs correct arm64 URL", async () => { + const url = playwrightCdnDownloadUrl("1219"); + assert( + url.startsWith("https://cdn.playwright.dev/"), + `URL should start with cdn.playwright.dev, got: ${url}`, + ); + assert( + url.includes("/builds/chromium/1219/"), + `URL should contain revision path, got: ${url}`, + ); + assert( + url.endsWith("chromium-headless-shell-linux-arm64.zip"), + `URL should end with arm64 zip name, got: ${url}`, + ); +}); + +// fetchPlaywrightBrowsersJson — external HTTP, skip on CI +unitTest("fetchPlaywrightBrowsersJson - returns chromium-headless-shell entry", async () => { + const entry = await fetchPlaywrightBrowsersJson(); + assert(entry.revision, "revision should be non-empty"); + assert( + /^\d+$/.test(entry.revision), + `revision should be numeric, got: ${entry.revision}`, + ); + assert(entry.browserVersion, "browserVersion should be non-empty"); + assert( + /^\d+\.\d+\.\d+\.\d+$/.test(entry.browserVersion), + `browserVersion format wrong: ${entry.browserVersion}`, + ); +}, { ignore: runningInCI() }); + +// findCftExecutable with Playwright arm64 layout (chrome-linux/headless_shell) +unitTest("findCftExecutable - finds binary in Playwright arm64 layout", async () => { + if (isWindows) return; // arm64 layout is Linux-only, no .exe + const tempDir = Deno.makeTempDirSync(); + try { + const subdir = join(tempDir, "chrome-linux"); + Deno.mkdirSync(subdir); + Deno.writeTextFileSync(join(subdir, "headless_shell"), "fake binary"); + + const found = findCftExecutable(tempDir, "headless_shell"); + assert(found !== undefined, "should find headless_shell in chrome-linux/"); + assert(found!.endsWith("headless_shell"), `should end with headless_shell, got: ${found}`); + } finally { + safeRemoveSync(tempDir, { recursive: true }); + } +}); diff --git a/tests/unit/tools/chrome-headless-shell.test.ts b/tests/unit/tools/chrome-headless-shell.test.ts index 259caf685e9..00125ca0cc3 100644 --- a/tests/unit/tools/chrome-headless-shell.test.ts +++ b/tests/unit/tools/chrome-headless-shell.test.ts @@ -8,12 +8,13 @@ import { unitTest } from "../../test.ts"; import { assert, assertEquals } from "testing/asserts"; import { join } from "../../../src/deno_ral/path.ts"; import { existsSync, safeRemoveSync } from "../../../src/deno_ral/fs.ts"; -import { isWindows } from "../../../src/deno_ral/platform.ts"; +import { arch, isWindows, os } from "../../../src/deno_ral/platform.ts"; import { runningInCI } from "../../../src/core/ci-info.ts"; import { InstallContext } from "../../../src/tools/types.ts"; -import { detectCftPlatform, findCftExecutable } from "../../../src/tools/impl/chrome-for-testing.ts"; +import { detectCftPlatform, findCftExecutable, isPlaywrightCdnPlatform } from "../../../src/tools/impl/chrome-for-testing.ts"; import { installableTool, installableTools } from "../../../src/tools/tools.ts"; import { + chromeHeadlessShellBinaryName, chromeHeadlessShellInstallable, chromeHeadlessShellInstallDir, chromeHeadlessShellExecutablePath, @@ -92,10 +93,13 @@ unitTest("isInstalled - returns false when only binary exists (no version file)" const tempDir = Deno.makeTempDirSync(); try { const { platform } = detectCftPlatform(); + const binName = chromeHeadlessShellBinaryName(); + // CfT layout: chrome-headless-shell-{platform}/binary + // Playwright arm64 layout: chrome-linux/binary (found via walkSync fallback) const subdir = join(tempDir, `chrome-headless-shell-${platform}`); Deno.mkdirSync(subdir); - const binaryName = isWindows ? "chrome-headless-shell.exe" : "chrome-headless-shell"; - Deno.writeTextFileSync(join(subdir, binaryName), "fake"); + const target = isWindows ? `${binName}.exe` : binName; + Deno.writeTextFileSync(join(subdir, target), "fake"); assertEquals(isInstalled(tempDir), false); } finally { safeRemoveSync(tempDir, { recursive: true }); @@ -107,10 +111,11 @@ unitTest("isInstalled - returns true when version file and binary exist", async try { noteInstalledVersion(tempDir, "145.0.0.0"); const { platform } = detectCftPlatform(); + const binName = chromeHeadlessShellBinaryName(); const subdir = join(tempDir, `chrome-headless-shell-${platform}`); Deno.mkdirSync(subdir); - const binaryName = isWindows ? "chrome-headless-shell.exe" : "chrome-headless-shell"; - Deno.writeTextFileSync(join(subdir, binaryName), "fake"); + const target = isWindows ? `${binName}.exe` : binName; + Deno.writeTextFileSync(join(subdir, target), "fake"); assertEquals(isInstalled(tempDir), true); } finally { @@ -128,7 +133,12 @@ unitTest("latestRelease - returns valid RemotePackageInfo", async () => { `version format wrong: ${release.version}`, ); assert(release.url.startsWith("https://"), `URL should be https: ${release.url}`); - assert(release.url.includes(release.version), "URL should contain version"); + // CfT URLs contain the version; Playwright CDN URLs contain a revision number instead + if (!isPlaywrightCdnPlatform()) { + assert(release.url.includes(release.version), "CfT URL should contain version"); + } else { + assert(release.url.includes("cdn.playwright.dev"), "arm64 URL should use Playwright CDN"); + } assert(release.assets.length > 0, "should have at least one asset"); assertEquals(release.assets[0].name, "chrome-headless-shell"); }, { ignore: runningInCI() }); @@ -162,7 +172,7 @@ unitTest("preparePackage - downloads and extracts chrome-headless-shell", async try { assert(pkg.version, "version should be non-empty"); assert(pkg.filePath, "filePath should be non-empty"); - const binary = findCftExecutable(pkg.filePath, "chrome-headless-shell"); + const binary = findCftExecutable(pkg.filePath, chromeHeadlessShellBinaryName()); assert(binary !== undefined, "binary should exist in extracted dir"); } finally { safeRemoveSync(pkg.filePath, { recursive: true }); @@ -257,3 +267,10 @@ unitTest("tool registry - installableTool looks up chrome-headless-shell", async assert(tool !== undefined, "installableTool should find chrome-headless-shell"); assertEquals(tool.name, "Chrome Headless Shell"); }); + +// -- arm64 support -- + +unitTest("chromeHeadlessShellBinaryName - returns chrome-headless-shell on non-arm64", async () => { + if (os === "linux" && arch === "aarch64") return; // Skip on actual arm64 + assertEquals(chromeHeadlessShellBinaryName(), "chrome-headless-shell"); +}); From 35a1f14ec0f8811d75c591ec3f1079393b2a31b2 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:56:05 +0000 Subject: [PATCH 10/21] Update version.txt --- version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.txt b/version.txt index 6450ef95325..04a5f0b5274 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.9.36 +1.9.37 From 01754ec4cf5a13564354f430edae9f6339333afa Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 9 Apr 2026 12:25:21 +0200 Subject: [PATCH 11/21] [release checklist] Move release items --- news/changelog-1.9.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 1c2af28a6b3..77b0b52aef2 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -2,14 +2,14 @@ ## In this release +## In previous releases + - ([#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. - ([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. - ([#14334](https://github.com/quarto-dev/quarto-cli/pull/14334), [#9710](https://github.com/quarto-dev/quarto-cli/issues/9710)): Add arm64 Linux support for `quarto install chrome-headless-shell` using Playwright CDN as download source. `quarto install chromium` and `quarto update chromium` now show a deprecation warning — use `chrome-headless-shell` instead, which always installs the latest stable Chrome (the legacy `chromium` installer pins an outdated Puppeteer revision that cannot receive security updates). `quarto check install` also warns when legacy Chromium is detected. -## In previous releases - # v1.9 changes All changes included in 1.9: From e541ca7b76a11add8acb96e4de1647fa1baa1ad7 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 15 Apr 2026 15:42:30 +0200 Subject: [PATCH 12/21] [backport] Fix tinytex install silently ignoring extraction failures (#14360) Backport from #14357. `quarto install tinytex` silently succeeded when archive extraction failed, leaving users with a broken TinyTeX installation. Check the `unzip()` return value and surface a clear error message, including an xz-utils install hint on Linux for `.tar.xz` archives. Add `xz-utils`/`xz` to `.deb`/`.rpm` Recommends so the dependency is available on fresh installs. Fixes #14304 --- news/changelog-1.9.md | 2 + package/src/linux/installer.ts | 6 +- src/tools/impl/tinytex.ts | 10 +++- tests/unit/tools/tinytex.test.ts | 100 ++++++++++++++++++++++++++++++- 4 files changed, 113 insertions(+), 5 deletions(-) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 77b0b52aef2..61dbbb8dd59 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -2,6 +2,8 @@ ## In this release +- ([#14304](https://github.com/quarto-dev/quarto-cli/issues/14304)): Fix `quarto install tinytex` silently ignoring extraction failures. When archive extraction fails (e.g., `.tar.xz` on a system without `xz-utils`), the installer now reports a clear error instead of proceeding and failing with a confusing `NotFound` message. + ## In previous releases - ([#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. diff --git a/package/src/linux/installer.ts b/package/src/linux/installer.ts index cd90fd65bcb..366edafc296 100644 --- a/package/src/linux/installer.ts +++ b/package/src/linux/installer.ts @@ -92,11 +92,15 @@ async function createNfpmConfig( // Format-specific configuration if (format === 'deb') { config.overrides.deb = { - recommends: ["unzip"], + recommends: ["unzip", "xz-utils"], }; // Add Debian-specific metadata config.section = "user/text"; config.priority = "optional"; + } else if (format === 'rpm') { + config.overrides.rpm = { + recommends: ["xz"], + }; } return config; } diff --git a/src/tools/impl/tinytex.ts b/src/tools/impl/tinytex.ts index cb07917406b..b6c3be20338 100644 --- a/src/tools/impl/tinytex.ts +++ b/src/tools/impl/tinytex.ts @@ -190,7 +190,15 @@ async function install( await context.withSpinner( { message: `Unzipping ${basename(pkgInfo.filePath)}` }, async () => { - await unzip(pkgInfo.filePath); + const result = await unzip(pkgInfo.filePath); + if (!result.success) { + const hint = pkgInfo.filePath.endsWith(".tar.xz") && isLinux + ? "\nYou may need to install xz-utils (e.g., apt install xz-utils)." + : ""; + throw new Error( + `Failed to extract ${basename(pkgInfo.filePath)}.${hint}\n${result.stderr}`, + ); + } }, ); diff --git a/tests/unit/tools/tinytex.test.ts b/tests/unit/tools/tinytex.test.ts index 5522c1bc7d7..6ca85ab8885 100644 --- a/tests/unit/tools/tinytex.test.ts +++ b/tests/unit/tools/tinytex.test.ts @@ -5,10 +5,15 @@ */ import { unitTest } from "../../test.ts"; -import { assert, assertEquals } from "testing/asserts"; -import { tinyTexPkgName } from "../../../src/tools/impl/tinytex.ts"; +import { assert, assertEquals, assertRejects } from "testing/asserts"; +import { + tinyTexInstallable, + tinyTexPkgName, +} from "../../../src/tools/impl/tinytex.ts"; import { getLatestRelease } from "../../../src/tools/github.ts"; -import { GitHubRelease } from "../../../src/tools/types.ts"; +import { GitHubRelease, InstallContext, PackageInfo } from "../../../src/tools/types.ts"; +import { join } from "../../../src/deno_ral/path.ts"; +import { isLinux } from "../../../src/deno_ral/platform.ts"; // ---- Pure logic tests for tinyTexPkgName ---- @@ -158,3 +163,92 @@ unitTest( assertAssetExists(candidates, assetNames, "Windows"); }, ); + +// ---- Extraction failure tests ---- + +function createMockContext(workingDir: string): InstallContext { + return { + workingDir, + info: (_msg: string) => {}, + withSpinner: async (_options, op) => { + await op(); + }, + error: (_msg: string) => {}, + confirm: async (_msg: string) => true, + download: async (_name: string, _url: string, _target: string) => {}, + props: {}, + flags: {}, + }; +} + +unitTest( + "install - throws on extraction failure for corrupt archive", + async () => { + const workingDir = Deno.makeTempDirSync({ prefix: "quarto-tinytex-test" }); + try { + // Create a corrupt .tar.xz file that tar cannot extract + const badArchive = join( + workingDir, + "TinyTeX-linux-x86_64-v2026.04.tar.xz", + ); + Deno.writeTextFileSync(badArchive, "this is not a valid archive"); + + const pkgInfo: PackageInfo = { + filePath: badArchive, + version: "v2026.04", + }; + const context = createMockContext(workingDir); + + await assertRejects( + () => tinyTexInstallable.install(pkgInfo, context), + Error, + "Failed to extract", + ); + } finally { + Deno.removeSync(workingDir, { recursive: true }); + } + }, +); + +unitTest( + "install - extraction failure for .tar.xz includes xz-utils hint only on Linux", + async () => { + const workingDir = Deno.makeTempDirSync({ prefix: "quarto-tinytex-test" }); + try { + const badArchive = join( + workingDir, + "TinyTeX-linux-x86_64-v2026.04.tar.xz", + ); + Deno.writeTextFileSync(badArchive, "this is not a valid archive"); + + const pkgInfo: PackageInfo = { + filePath: badArchive, + version: "v2026.04", + }; + const context = createMockContext(workingDir); + + try { + await tinyTexInstallable.install(pkgInfo, context); + throw new Error("Expected install to throw"); + } catch (e) { + assert( + e instanceof Error && e.message.includes("Failed to extract"), + `Error should indicate extraction failure, got: ${e}`, + ); + if (isLinux) { + assert( + e.message.includes("xz-utils"), + `On Linux, error should mention xz-utils, got: ${e.message}`, + ); + } else { + assert( + !e.message.includes("xz-utils"), + `On non-Linux, error should not mention xz-utils, got: ${e.message}`, + ); + } + } + } finally { + Deno.removeSync(workingDir, { recursive: true }); + } + }, +); From 8b184cb87992bbcd6266767836386ab4b8a68700 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 16 Apr 2026 11:24:26 +0200 Subject: [PATCH 13/21] [backport] Fix Dart Sass failing in enterprise Windows with Group Policy restrictions (#14369) * Fall back to direct sass.bat execution when safeWindowsExec fails Enterprise Windows environments with Group Policy / AppLocker rules that block .bat execution from %TEMP% cause the safeWindowsExec temp wrapper to fail, breaking dart-sass invocation (regression from #14002). Try safeWindowsExec first (handles spaces in paths), then fall back to direct execProcess call (v1.8 behavior) when it fails. Fixes #14367 * Add changelog entry for #14367 --- news/changelog-1.9.md | 1 + src/core/dart-sass.ts | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 61dbbb8dd59..fc6030c262a 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -3,6 +3,7 @@ ## In this release - ([#14304](https://github.com/quarto-dev/quarto-cli/issues/14304)): Fix `quarto install tinytex` silently ignoring extraction failures. When archive extraction fails (e.g., `.tar.xz` on a system without `xz-utils`), the installer now reports a clear error instead of proceeding and failing with a confusing `NotFound` message. +- ([#14367](https://github.com/quarto-dev/quarto-cli/issues/14367)): Fix Dart Sass invocation failing on enterprise Windows systems where Group Policy blocks `.bat` execution from `%TEMP%`. When the `safeWindowsExec` temp wrapper is blocked, Quarto now falls back to calling `sass.bat` directly. ## In previous releases diff --git a/src/core/dart-sass.ts b/src/core/dart-sass.ts index 0c8e39c9795..1d925889a06 100644 --- a/src/core/dart-sass.ts +++ b/src/core/dart-sass.ts @@ -125,7 +125,22 @@ export async function dartCommand( }); }, ); - return processResult(result); + if (result.success) { + return processResult(result); + } + + // safeWindowsExec failed — fall back to direct execution (v1.8 behavior). + // Enterprise environments may block .bat execution from %TEMP% via + // Group Policy / AppLocker, causing the temp wrapper to fail. + // See https://github.com/quarto-dev/quarto-cli/issues/14367 + debug("[DART] safeWindowsExec failed, falling back to direct execution"); + const directResult = await execProcess({ + cmd: sass, + args, + stdout: "piped", + stderr: "piped", + }); + return processResult(directResult); } // Non-Windows: direct execution From f26f7f6e59032f5aa40926bbaa5577b43d31ee1f Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 22 Apr 2026 18:32:43 +0100 Subject: [PATCH 14/21] [backport] Bump julia-actions/cache to v3 (#14412) v2 is a composite action whose post-save and delete-old-caches steps don't run reliably, letting per-run julia caches (~250 MB each) accumulate on refs/heads/main until the repository hits its 10 GB cache cap. v3 is a JavaScript rewrite with a proper post hook. It also uses Node.js 24 directly, dropping the latent Node 20 exposure from v2's transitive actions/cache dependency. --- .github/workflows/test-smokes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-smokes.yml b/.github/workflows/test-smokes.yml index 961f8133ff8..9195ec07ff7 100644 --- a/.github/workflows/test-smokes.yml +++ b/.github/workflows/test-smokes.yml @@ -223,7 +223,7 @@ jobs: version: "1.11.7" - name: Load Julia packages from cache - uses: julia-actions/cache@v2 + uses: julia-actions/cache@v3 with: cache-name: julia-cache;workflow=${{ github.workflow }};job=${{ github.job }};julia=${{ steps.setup-julia.outputs.julia-version }} From a8748c37d48c715e77a469db0eabb8a248d20d19 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 28 Apr 2026 18:37:37 +0200 Subject: [PATCH 15/21] [backport] Cache Deno development cache in CI (#14452) * Cache Deno development cache in CI (#14408) * Gate vendor.sh cache wipe behind QUARTO_SKIP_DENO_CACHE_WIPE Default behavior unchanged: local ./configure.sh still wipes deno_cache before re-vendoring. CI opts into cache preservation by setting QUARTO_SKIP_DENO_CACHE_WIPE=1, enabling actions/cache to restore the cache and have it survive vendor.sh. * Gate vendor.cmd cache wipe behind QUARTO_SKIP_DENO_CACHE_WIPE Windows counterpart to the vendor.sh change. Behavior matches: default wipes, QUARTO_SKIP_DENO_CACHE_WIPE=1 preserves. * Cache Deno development cache in CI Adds actions/cache restore for ./package/dist/bin/deno_cache, keyed on the five files that influence Deno dep resolution: configuration, src/import_map.json, src/vendor_deps.ts, tests/test-deps.ts, and package/scripts/deno_std/deno_std.ts. Both configure steps set QUARTO_SKIP_DENO_CACHE_WIPE=1 so the restored cache survives the configure.sh / configure.cmd run. Adds dev-docs/ci-deno-caching.md explaining the cache layout, key composition, env var contract, and how to force invalidation. Adds a cross-link from dev-docs/upgrade-dependencies.md so a contributor bumping deno_std version sees the cache consequences. Expected per-OS cache size ~150-200 MB; total footprint ~600 MB across Linux/macOS/Windows, well under GitHub's 10 GB repo quota. (cherry picked from commit e4b75c1a32902f6d6e01ac62752a5b7d02ec0a12) * Fix configure scripts destroying restored Deno CI cache (#14450) The CI Deno dev cache (Cache Deno development cache step in .github/workflows/actions/quarto-dev/action.yml) restores package/dist/bin/deno_cache before configure runs. configure.sh and configure.cmd then unconditionally wipe package/dist when bootstrapping Deno, destroying the just-restored cache before vendor.sh reaches it. The vendor scripts already gate their own cache wipe with QUARTO_SKIP_DENO_CACHE_WIPE=1; the configure-time wipe was missed. Apply the same gate to configure.sh and configure.cmd so the restored cache survives. Local devs (env var unset) keep the original wipe behavior. (cherry picked from commit 3e11b832cc00a7eef2a37882d8ac3b6c016ccc37) --- .../workflows/actions/quarto-dev/action.yml | 12 ++ configure.cmd | 42 +++---- configure.sh | 6 +- dev-docs/ci-deno-caching.md | 107 ++++++++++++++++++ dev-docs/upgrade-dependencies.md | 2 + package/scripts/vendoring/vendor.cmd | 7 +- package/scripts/vendoring/vendor.sh | 5 +- 7 files changed, 157 insertions(+), 24 deletions(-) create mode 100644 dev-docs/ci-deno-caching.md diff --git a/.github/workflows/actions/quarto-dev/action.yml b/.github/workflows/actions/quarto-dev/action.yml index cc5292af525..42d1aa8884a 100644 --- a/.github/workflows/actions/quarto-dev/action.yml +++ b/.github/workflows/actions/quarto-dev/action.yml @@ -23,9 +23,19 @@ runs: restore-keys: | ${{ runner.os }}-cargo-typst-gather- + - name: Cache Deno development cache + uses: actions/cache@v5 + with: + path: ./package/dist/bin/deno_cache + key: ${{ runner.os }}-${{ runner.arch }}-deno-cache-v1-${{ hashFiles('configuration', 'src/import_map.json', 'src/vendor_deps.ts', 'tests/test-deps.ts', 'package/scripts/deno_std/deno_std.ts') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-deno-cache-v1- + - name: Configure Quarto (.sh) if: runner.os != 'Windows' shell: bash + env: + QUARTO_SKIP_DENO_CACHE_WIPE: "1" run: | # install with symlink in /usr/local/bin which in PATH on CI ./configure.sh @@ -33,6 +43,8 @@ runs: - name: Configure Quarto (.ps1) if: runner.os == 'Windows' shell: pwsh + env: + QUARTO_SKIP_DENO_CACHE_WIPE: "1" run: | ./configure.cmd "$(Get-ChildItem -Path ./package/dist/bin/quarto.cmd | %{ $_.FullName } | Split-Path)" >> $env:GITHUB_PATH diff --git a/configure.cmd b/configure.cmd index f1eccc9c3ed..6c7d3632940 100644 --- a/configure.cmd +++ b/configure.cmd @@ -15,27 +15,31 @@ if NOT DEFINED QUARTO_VENDOR_BINARIES ( if "%QUARTO_VENDOR_BINARIES%" == "true" ( - REM Windows-specific: Check if deno.exe is running before deleting package/dist - REM Extracted to package/scripts/windows/check-deno-in-use.cmd for maintainability - call package\scripts\windows\check-deno-in-use.cmd "!QUARTO_DIST_PATH!" - if "!ERRORLEVEL!"=="1" exit /B 1 - - echo Removing package/dist/ directory... - RMDIR /S /Q "!QUARTO_DIST_PATH!" 2>NUL - - REM Fallback: Verify deletion succeeded (defense in depth) - if exist "!QUARTO_DIST_PATH!" ( - echo. - echo ============================================================ - echo Error: Could not delete package/dist/ directory - echo This may be due to permissions, antivirus, or another process holding files - echo ============================================================ - echo. - echo Try closing applications and run configure.cmd again - exit /B 1 + REM CI sets QUARTO_SKIP_DENO_CACHE_WIPE=1 so the restored deno_cache survives + REM (matches the gate used by package\scripts\vendoring\vendor.cmd). + IF NOT "!QUARTO_SKIP_DENO_CACHE_WIPE!"=="1" ( + REM Windows-specific: Check if deno.exe is running before deleting package/dist + REM Extracted to package/scripts/windows/check-deno-in-use.cmd for maintainability + call package\scripts\windows\check-deno-in-use.cmd "!QUARTO_DIST_PATH!" + if "!ERRORLEVEL!"=="1" exit /B 1 + + echo Removing package/dist/ directory... + RMDIR /S /Q "!QUARTO_DIST_PATH!" 2>NUL + + REM Fallback: Verify deletion succeeded (defense in depth) + if exist "!QUARTO_DIST_PATH!" ( + echo. + echo ============================================================ + echo Error: Could not delete package/dist/ directory + echo This may be due to permissions, antivirus, or another process holding files + echo ============================================================ + echo. + echo Try closing applications and run configure.cmd again + exit /B 1 + ) ) - MKDIR !QUARTO_BIN_PATH!\tools + IF NOT EXIST "!QUARTO_BIN_PATH!\tools" MKDIR !QUARTO_BIN_PATH!\tools PUSHD !QUARTO_BIN_PATH!\tools ECHO Bootstrapping Deno... diff --git a/configure.sh b/configure.sh index 64f54e790e0..d505fd68dd2 100755 --- a/configure.sh +++ b/configure.sh @@ -37,7 +37,11 @@ if [[ "${QUARTO_VENDOR_BINARIES}" = "true" ]]; then # Ensure directory is there for Deno echo "Bootstrapping Deno..." - rm -rf "$QUARTO_DIST_PATH" + # CI sets QUARTO_SKIP_DENO_CACHE_WIPE=1 so the restored deno_cache survives + # (matches the gate used by package/scripts/vendoring/vendor.sh). + if [ "${QUARTO_SKIP_DENO_CACHE_WIPE}" != "1" ]; then + rm -rf "$QUARTO_DIST_PATH" + fi ## Binary Directory mkdir -p "$QUARTO_BIN_PATH/tools" diff --git a/dev-docs/ci-deno-caching.md b/dev-docs/ci-deno-caching.md new file mode 100644 index 00000000000..b368d127745 --- /dev/null +++ b/dev-docs/ci-deno-caching.md @@ -0,0 +1,107 @@ +# CI Deno cache + +How the Quarto development Deno cache is cached in GitHub Actions, how +invalidation works, and how to force a fresh download. + +## What gets cached + +Two Deno caches are restored by the `quarto-dev` composite action +(`.github/workflows/actions/quarto-dev/action.yml`): + +| Cache | Path | Populated by | +|-------|------|--------------| +| Deno standard library | `./src/resources/deno_std/cache` | `package/scripts/deno_std/deno_std.ts` | +| Deno development cache | `./package/dist/bin/deno_cache` | `package/scripts/vendoring/vendor.sh` / `vendor.cmd` | + +This document covers the second one. The first predates it and is keyed on +`deno_std.lock` + `deno_std.ts`. + +## Cache key + +``` +${{ runner.os }}-${{ runner.arch }}-deno-cache-v1- +``` + +`` is `hashFiles()` over five files: + +| File | Why it's in the key | +|------|---------------------| +| `configuration` | Pins the Deno binary version. | +| `src/import_map.json` | Top-level dep version map — the primary driver of what gets downloaded. | +| `src/vendor_deps.ts` | Explicit "things to vendor" entrypoint. | +| `tests/test-deps.ts` | Test-only deps entrypoint (iterated by vendor.sh / vendor.cmd). | +| `package/scripts/deno_std/deno_std.ts` | deno_std entrypoint iterated by the vendor scripts. | + +`restore-keys` falls back to the same prefix without the hash suffix, so a +partial-match cache from a sibling branch is reused if the exact key misses. + +### Not in the key + +- `quarto.ts` and other `src/**/*.ts` files: these are consumer code, not + dep-resolution inputs. `deno install` is additive on top of an existing + cache, so new imports in consumer code download without needing a key change. + Hashing `src/**` would invalidate on every commit. +- `vendor.sh` / `vendor.cmd`: they control *how* `deno install` runs, not + *what* it resolves. Including them would cross-invalidate (a Unix-only + `vendor.sh` edit would bust the Windows cache too, since `hashFiles()` is + OS-independent). +- `deno.lock` / `tests/deno.lock`: both are gitignored (`.gitignore:26`) and + are generated by `deno install` at run time, so they do not exist on a + fresh checkout and cannot serve as key inputs. + +## Wipe gate + +`vendor.sh` and `vendor.cmd` delete `./package/dist/bin/deno_cache` before +running `deno install`. That default is preserved for local development. In +CI we need the opposite — the cache was just restored by `actions/cache` and +must survive until `deno install` uses it. + +The coordination uses a single environment variable: + +- **Name:** `QUARTO_SKIP_DENO_CACHE_WIPE` +- **Truthy value:** `"1"` (anything else, including unset, means "wipe"). +- **Set by:** both configure steps in `action.yml` (`env: QUARTO_SKIP_DENO_CACHE_WIPE: "1"`). +- **Read by:** `vendor.sh` and `vendor.cmd`. Nothing else. +- **Do not set locally.** The variable only makes sense when paired with a + restore step. Without one, skipping the wipe just leaves a stale cache on + disk. + +## Forcing invalidation + +### When it happens automatically + +Editing any of the five keyed files changes the hash, which produces a new +cache key. Next CI run misses, re-downloads, then saves under the new key. +Old keys age out via GitHub's 7-day LRU. + +### When you have to force it manually + +Only if the cache contents go bad in a way the key can't detect — e.g. a +partial download that wasn't corrected, or a silent shape change from a new +Deno version that still resolves to the same `configuration` string. In that +case, bump the `-v1-` version prefix in both the `key` and `restore-keys` of +the cache step in `action.yml`: + +```diff +- key: ${{ runner.os }}-${{ runner.arch }}-deno-cache-v1-... ++ key: ${{ runner.os }}-${{ runner.arch }}-deno-cache-v2-... + restore-keys: | +- ${{ runner.os }}-${{ runner.arch }}-deno-cache-v1- ++ ${{ runner.os }}-${{ runner.arch }}-deno-cache-v2- +``` + +This avoids editing any of the five hashed files (which may not even be +involved in the regression) and leaves a clear audit trail in git history. + +## Rollback + +- **Cache misbehaves:** delete the "Cache Deno development cache" step in + `action.yml`. `configure.sh` / `configure.cmd` run `vendor.sh` / + `vendor.cmd` as before and redownload from scratch. +- **Wipe gate misbehaves:** remove the `QUARTO_SKIP_DENO_CACHE_WIPE` env + from the configure steps. Vendor scripts fall back to the default wipe. + +## Expected footprint + +~150–200 MB per OS, three OSes → ~600 MB total repo cache usage, well under +GitHub's 10 GB per-repo quota. diff --git a/dev-docs/upgrade-dependencies.md b/dev-docs/upgrade-dependencies.md index fe52814715d..6c888c15c87 100644 --- a/dev-docs/upgrade-dependencies.md +++ b/dev-docs/upgrade-dependencies.md @@ -12,6 +12,8 @@ Contact Carlos so he uploads the binaries to the S3 bucket. - run `./configure.sh`. +Bumping a version in `src/import_map.json` (or any of the other keyed files) automatically invalidates the CI Deno cache on next run. See [ci-deno-caching.md](ci-deno-caching.md) for the key composition and how to force invalidation manually. + ### Upgrade Deno download link for RHEL build from conda-forge - Go to and find the version of Deno required. diff --git a/package/scripts/vendoring/vendor.cmd b/package/scripts/vendoring/vendor.cmd index f9ab84973fa..e43fae3577b 100644 --- a/package/scripts/vendoring/vendor.cmd +++ b/package/scripts/vendoring/vendor.cmd @@ -18,9 +18,12 @@ IF NOT DEFINED DENO_DIR ( ECHO Revendoring quarto dependencies -REM remove deno_cache directory first +REM remove deno_cache directory first, unless explicitly told to preserve +REM (CI sets QUARTO_SKIP_DENO_CACHE_WIPE=1 so a restored cache survives vendor.cmd) IF EXIST "!DENO_DIR!" ( - RMDIR /S /Q "!DENO_DIR!" + IF NOT "!QUARTO_SKIP_DENO_CACHE_WIPE!"=="1" ( + RMDIR /S /Q "!DENO_DIR!" + ) ) PUSHD "!QUARTO_SRC_PATH!" diff --git a/package/scripts/vendoring/vendor.sh b/package/scripts/vendoring/vendor.sh index 04d899a51ed..976eb80d6b5 100755 --- a/package/scripts/vendoring/vendor.sh +++ b/package/scripts/vendoring/vendor.sh @@ -15,8 +15,9 @@ fi echo Revendoring quarto dependencies -# remove deno_cache directory first -if [ -d "$DENO_DIR" ]; then +# remove deno_cache directory first, unless explicitly told to preserve +# (CI sets QUARTO_SKIP_DENO_CACHE_WIPE=1 so a restored cache survives vendor.sh) +if [ -d "$DENO_DIR" ] && [ "${QUARTO_SKIP_DENO_CACHE_WIPE}" != "1" ]; then rm -rf "$DENO_DIR" fi From 89ff73c99ec24fc4d5b6b57d63c5ff9921ed49af Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 6 May 2026 19:42:40 +0200 Subject: [PATCH 16/21] [backport] fix(preview): restore --output-dir for single-file preview without _quarto.yml (#14492) * fix(preview): restore --output-dir for single-file preview without _quarto.yml When `quarto preview file.qmd --output-dir ` runs in a directory without _quarto.yml, preview pre-creates a single-file ProjectContext and passes it to render() as pContext. The synthetic-project trigger in render-shared only fired when context was null, bypassing it for the preview path and surfacing the project-only-flag check. Extend the trigger to also fire when pContext is a single-file context. Discarded single-file context cleans up via globalTempContext. Adds a unit test that exercises the exact preview pattern. Fixes #14489. Preview side of posit-dev/positron#13370. * docs(changelog): add 1.9 entry for #14489 preview --output-dir fix Pairs with the backported render-shared.ts fix in the previous commit. * docs(changelog): move 1.9 entry under v1.10 backports section Backport entries belong under the "v1.10 backports > In this release" heading on the v1.9 release branch, not under "v1.9 changes > Regression fixes" (which tracks original v1.9 fixes). --- news/changelog-1.9.md | 1 + src/command/render/render-shared.ts | 13 ++-- tests/unit/render-shared-output-dir.test.ts | 75 +++++++++++++++++++++ 3 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 tests/unit/render-shared-output-dir.test.ts diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index fc6030c262a..240630d15f0 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -4,6 +4,7 @@ - ([#14304](https://github.com/quarto-dev/quarto-cli/issues/14304)): Fix `quarto install tinytex` silently ignoring extraction failures. When archive extraction fails (e.g., `.tar.xz` on a system without `xz-utils`), the installer now reports a clear error instead of proceeding and failing with a confusing `NotFound` message. - ([#14367](https://github.com/quarto-dev/quarto-cli/issues/14367)): Fix Dart Sass invocation failing on enterprise Windows systems where Group Policy blocks `.bat` execution from `%TEMP%`. When the `safeWindowsExec` temp wrapper is blocked, Quarto now falls back to calling `sass.bat` directly. +- ([#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. ## In previous releases 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 + } + } + }, +); From b4c00ec9d546851846af181a5f2d50e697998ed0 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 22 May 2026 12:13:58 +0200 Subject: [PATCH 17/21] [backport] Fix dart-sass failure on Windows usernames containing ampersand (#14535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [backport] Bypass sass.bat on Windows to fix ampersand-in-username regression (#14534) The v1.9 introduction of safeWindowsExec broke dart-sass for users whose Windows profile path contains `&` (e.g., C:\Users\Tom & Jerry\). The ampersand is a cmd.exe command separator, so the temp .bat wrapper that safeWindowsExec writes is split mid-path and fails. The existing #14367 fallback to direct sass.bat execution does not help: Windows still spawns cmd.exe internally to interpret any .bat file, and the ampersand splits again. Backport of the dart.exe + sass.snapshot direct-invocation rewrite from PR #14273 (commit 4a7b6ce05). Calling dart.exe through Deno.Command bypasses cmd.exe entirely and removes the whole class of .bat issues on Windows. The bundled dart-sass version is identical between v1.9 and main (DARTSASS=1.87.0), so the src/dart.exe + src/sass.snapshot layout is the same on disk. This also subsumes the #14367 enterprise group-policy fallback (no .bat = no policy block) and reinforces the #14267 accented-path fix already backported via #14274. Out of scope: other safeWindowsExec callers (texlive, zip, shell) still go through cmd.exe and remain vulnerable to ampersand in paths. Those have separate fixes upstream and are not covered by this backport. * Add regression test for ampersand in dart-sass paths (#14534) Cover the exact path pattern that broke v1.9 (e.g. C:\Users\Tom & Jerry\) to prevent re-regression. Mirrors the existing spaced/accented tests by junctioning the real dart-sass install dir into an ampersand-containing parent directory and asserting compilation succeeds. The junction itself is created via cmd /c mklink — Deno.Command passes each arg as a separate CreateProcess argument, so the ampersand in the target path is not interpreted by cmd.exe as a command separator. --- news/changelog-1.9.md | 2 +- src/core/dart-sass.ts | 140 ++++++++++++++++---------------- tests/unit/dart-sass.test.ts | 151 +++++++++++++++++++++++++++++------ 3 files changed, 193 insertions(+), 100 deletions(-) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 240630d15f0..8fc84d8a18a 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -3,7 +3,7 @@ ## In this release - ([#14304](https://github.com/quarto-dev/quarto-cli/issues/14304)): Fix `quarto install tinytex` silently ignoring extraction failures. When archive extraction fails (e.g., `.tar.xz` on a system without `xz-utils`), the installer now reports a clear error instead of proceeding and failing with a confusing `NotFound` message. -- ([#14367](https://github.com/quarto-dev/quarto-cli/issues/14367)): Fix Dart Sass invocation failing on enterprise Windows systems where Group Policy blocks `.bat` execution from `%TEMP%`. When the `safeWindowsExec` temp wrapper is blocked, Quarto now falls back to calling `sass.bat` directly. +- ([#14367](https://github.com/quarto-dev/quarto-cli/issues/14367), [#14534](https://github.com/quarto-dev/quarto-cli/issues/14534)): Fix Dart Sass invocation failing on Windows when the user profile path contains `&` (e.g., `C:\Users\Tom & Jerry\`) and on enterprise systems where Group Policy blocks `.bat` execution from `%TEMP%`. Quarto now invokes the bundled `dart.exe` with `sass.snapshot` directly, bypassing `sass.bat` and `cmd.exe` entirely. - ([#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. ## In previous releases diff --git a/src/core/dart-sass.ts b/src/core/dart-sass.ts index 1d925889a06..1d08e2d68b9 100644 --- a/src/core/dart-sass.ts +++ b/src/core/dart-sass.ts @@ -1,20 +1,18 @@ /* * dart-sass.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2026 Posit Software, PBC */ import { join } from "../deno_ral/path.ts"; import { architectureToolsPath } from "./resources.ts"; import { execProcess } from "./process.ts"; -import { ProcessResult } from "./process-types.ts"; import { TempContext } from "./temp.ts"; import { lines } from "./text.ts"; import { debug, info } from "../deno_ral/log.ts"; import { existsSync } from "../deno_ral/fs.ts"; import { warnOnce } from "./log.ts"; import { isWindows } from "../deno_ral/platform.ts"; -import { requireQuoting, safeWindowsExec } from "./windows.ts"; export function dartSassInstallDir() { return architectureToolsPath("dart-sass"); @@ -60,17 +58,39 @@ export async function dartCompile( */ export interface DartCommandOptions { /** - * Override the sass executable path. - * Primarily used for testing with spaced paths. + * Override the dart-sass install directory. + * Used for testing with non-standard paths (spaces, accented characters). */ - sassPath?: string; + installDir?: string; } -export async function dartCommand( - args: string[], - options?: DartCommandOptions, -) { - const resolvePath = () => { +/** + * Resolve the dart-sass command and its base arguments. + * + * On Windows, calls dart.exe + sass.snapshot directly instead of going + * through sass.bat. The bundled sass.bat is a thin wrapper generated by + * dart_cli_pkg that just runs: + * "%SCRIPTPATH%\src\dart.exe" "%SCRIPTPATH%\src\sass.snapshot" %arguments% + * + * Template source: + * https://github.com/google/dart_cli_pkg/blob/main/lib/src/templates/standalone/executable.bat.mustache + * Upstream issue to ship standalone .exe instead of .bat + dart.exe: + * https://github.com/google/dart_cli_pkg/issues/67 + * + * Bypassing sass.bat avoids multiple .bat file issues on Windows: + * - Deno quoting bugs with spaced paths (#13997) + * - cmd.exe OEM code page misreading UTF-8 accented paths (#14267) + * - Enterprise group policy blocking .bat execution (#6651) + */ +function resolveSassCommand(options?: DartCommandOptions): { + cmd: string; + baseArgs: string[]; +} { + const installDir = options?.installDir; + if (installDir == null) { + // Only check env var override when no explicit installDir is provided. + // If QUARTO_DART_SASS doesn't exist on disk, fall through to use the + // bundled dart-sass at the default architectureToolsPath. const dartOverrideCmd = Deno.env.get("QUARTO_DART_SASS"); if (dartOverrideCmd) { if (!existsSync(dartOverrideCmd)) { @@ -78,77 +98,51 @@ export async function dartCommand( `Specified QUARTO_DART_SASS does not exist, using built in dart sass.`, ); } else { - return dartOverrideCmd; + return { cmd: dartOverrideCmd, baseArgs: [] }; } } + } - const command = isWindows ? "sass.bat" : "sass"; - return architectureToolsPath(join("dart-sass", command)); - }; - const sass = options?.sassPath ?? resolvePath(); + const sassDir = installDir ?? architectureToolsPath("dart-sass"); - // Process result helper (shared by Windows and non-Windows paths) - const processResult = (result: ProcessResult): string | undefined => { - if (result.success) { - if (result.stderr) { - info(result.stderr); - } - return result.stdout; - } else { - debug(`[DART path] : ${sass}`); - debug(`[DART args] : ${args.join(" ")}`); - debug(`[DART stdout] : ${result.stdout}`); - debug(`[DART stderr] : ${result.stderr}`); - - const errLines = lines(result.stderr || ""); - // truncate the last 2 lines (they include a pointer to the temp file containing - // all of the concatenated sass, which is more or less incomprehensible for users. - const errMsg = errLines.slice(0, errLines.length - 2).join("\n"); - throw new Error("Theme file compilation failed:\n\n" + errMsg); - } - }; - - // On Windows, use safeWindowsExec to handle paths with spaces - // (e.g., when Quarto is installed in C:\Program Files\) - // See https://github.com/quarto-dev/quarto-cli/issues/13997 if (isWindows) { - const quoted = requireQuoting([sass, ...args]); - const result = await safeWindowsExec( - quoted.args[0], - quoted.args.slice(1), - (cmd: string[]) => { - return execProcess({ - cmd: cmd[0], - args: cmd.slice(1), - stdout: "piped", - stderr: "piped", - }); - }, - ); - if (result.success) { - return processResult(result); - } - - // safeWindowsExec failed — fall back to direct execution (v1.8 behavior). - // Enterprise environments may block .bat execution from %TEMP% via - // Group Policy / AppLocker, causing the temp wrapper to fail. - // See https://github.com/quarto-dev/quarto-cli/issues/14367 - debug("[DART] safeWindowsExec failed, falling back to direct execution"); - const directResult = await execProcess({ - cmd: sass, - args, - stdout: "piped", - stderr: "piped", - }); - return processResult(directResult); + return { + cmd: join(sassDir, "src", "dart.exe"), + baseArgs: [join(sassDir, "src", "sass.snapshot")], + }; } - // Non-Windows: direct execution + return { cmd: join(sassDir, "sass"), baseArgs: [] }; +} + +export async function dartCommand( + args: string[], + options?: DartCommandOptions, +) { + const { cmd, baseArgs } = resolveSassCommand(options); + const result = await execProcess({ - cmd: sass, - args, + cmd, + args: [...baseArgs, ...args], stdout: "piped", stderr: "piped", }); - return processResult(result); + + if (result.success) { + if (result.stderr) { + info(result.stderr); + } + return result.stdout; + } else { + debug(`[DART cmd] : ${cmd}`); + debug(`[DART args] : ${[...baseArgs, ...args].join(" ")}`); + debug(`[DART stdout] : ${result.stdout}`); + debug(`[DART stderr] : ${result.stderr}`); + + const errLines = lines(result.stderr || ""); + // truncate the last 2 lines (they include a pointer to the temp file containing + // all of the concatenated sass, which is more or less incomprehensible for users. + const errMsg = errLines.slice(0, errLines.length - 2).join("\n"); + throw new Error("Theme file compilation failed:\n\n" + errMsg); + } } diff --git a/tests/unit/dart-sass.test.ts b/tests/unit/dart-sass.test.ts index 0e37241353c..6d5d3de86b9 100644 --- a/tests/unit/dart-sass.test.ts +++ b/tests/unit/dart-sass.test.ts @@ -2,7 +2,10 @@ * dart-sass.test.ts * * Tests for dart-sass functionality. - * Validates fix for https://github.com/quarto-dev/quarto-cli/issues/13997 + * Validates fixes for: + * https://github.com/quarto-dev/quarto-cli/issues/13997 (spaced paths) + * https://github.com/quarto-dev/quarto-cli/issues/14267 (accented paths) + * https://github.com/quarto-dev/quarto-cli/issues/6651 (enterprise .bat blocking) * * Copyright (C) 2020-2025 Posit Software, PBC */ @@ -13,46 +16,53 @@ import { isWindows } from "../../src/deno_ral/platform.ts"; import { join } from "../../src/deno_ral/path.ts"; import { dartCommand, dartSassInstallDir } from "../../src/core/dart-sass.ts"; +/** + * Helper: create a junction to the real dart-sass install dir at `targetDir`. + * Returns cleanup function to remove the junction. + */ +async function createDartSassJunction(targetDir: string) { + const sassInstallDir = dartSassInstallDir(); + const result = await new Deno.Command("cmd", { + args: ["/c", "mklink", "/J", targetDir, sassInstallDir], + }).output(); + + if (!result.success) { + const stderr = new TextDecoder().decode(result.stderr); + throw new Error(`Failed to create junction: ${stderr}`); + } + + return async () => { + await new Deno.Command("cmd", { + args: ["/c", "rmdir", targetDir], + }).output(); + }; +} + // Test that dartCommand handles spaced paths on Windows (issue #13997) -// The bug only triggers when BOTH the executable path AND arguments contain spaces. +// dart.exe is called directly, bypassing sass.bat and its quoting issues. unitTest( "dartCommand - handles spaced paths on Windows (issue #13997)", async () => { - // Create directories with spaces for both sass and file arguments const tempBase = Deno.makeTempDirSync({ prefix: "quarto_test_" }); const spacedSassDir = join(tempBase, "Program Files", "dart-sass"); const spacedProjectDir = join(tempBase, "My Project"); - const sassInstallDir = dartSassInstallDir(); + + let removeJunction: (() => Promise) | undefined; try { - // Create directories Deno.mkdirSync(join(tempBase, "Program Files"), { recursive: true }); Deno.mkdirSync(spacedProjectDir, { recursive: true }); - // Create junction (Windows directory symlink) to actual dart-sass - const junctionResult = await new Deno.Command("cmd", { - args: ["/c", "mklink", "/J", spacedSassDir, sassInstallDir], - }).output(); + removeJunction = await createDartSassJunction(spacedSassDir); - if (!junctionResult.success) { - const stderr = new TextDecoder().decode(junctionResult.stderr); - throw new Error(`Failed to create junction: ${stderr}`); - } - - // Create test SCSS file in spaced path (args with spaces) const inputScss = join(spacedProjectDir, "test style.scss"); const outputCss = join(spacedProjectDir, "test style.css"); Deno.writeTextFileSync(inputScss, "body { color: red; }"); - const spacedSassPath = join(spacedSassDir, "sass.bat"); - - // This is the exact bug scenario: spaced exe path + spaced args - // Without the fix, this fails with "C:\...\Program" not recognized const result = await dartCommand([inputScss, outputCss], { - sassPath: spacedSassPath, + installDir: spacedSassDir, }); - // Verify compilation succeeded (no stdout expected for file-to-file compilation) assert( result === undefined || result === "", "Sass compile should succeed (no stdout for file-to-file compilation)", @@ -62,14 +72,103 @@ unitTest( "Output CSS file should be created", ); } finally { - // Cleanup: remove junction first (rmdir for junctions), then temp directory try { - await new Deno.Command("cmd", { - args: ["/c", "rmdir", spacedSassDir], - }).output(); + if (removeJunction) await removeJunction(); + await Deno.remove(tempBase, { recursive: true }); + } catch (e) { + console.debug("Test cleanup failed:", e); + } + } + }, + { ignore: !isWindows }, +); + +// Test that dartCommand handles ampersand in paths (issue #14534) +// Windows user profile paths like C:\Users\Tom & Jerry\ broke dart-sass +// because the temp .bat wrapper written by safeWindowsExec was split on `&` +// by cmd.exe. Direct dart.exe invocation bypasses cmd.exe entirely. +unitTest( + "dartCommand - handles ampersand in paths (issue #14534)", + async () => { + const tempBase = Deno.makeTempDirSync({ prefix: "quarto_test_" }); + const ampSassDir = join(tempBase, "Tom & Jerry", "dart-sass"); + const ampProjectDir = join(tempBase, "Tom & Jerry", "project"); + + let removeJunction: (() => Promise) | undefined; + + try { + Deno.mkdirSync(join(tempBase, "Tom & Jerry"), { recursive: true }); + Deno.mkdirSync(ampProjectDir, { recursive: true }); + + removeJunction = await createDartSassJunction(ampSassDir); + + const inputScss = join(ampProjectDir, "style.scss"); + const outputCss = join(ampProjectDir, "style.css"); + Deno.writeTextFileSync(inputScss, "body { color: green; }"); + + const result = await dartCommand([inputScss, outputCss], { + installDir: ampSassDir, + }); + + assert( + result === undefined || result === "", + "Sass compile should succeed with ampersand in path", + ); + assert( + Deno.statSync(outputCss).isFile, + "Output CSS file should be created at ampersand path", + ); + } finally { + try { + if (removeJunction) await removeJunction(); + await Deno.remove(tempBase, { recursive: true }); + } catch (e) { + console.debug("Test cleanup failed:", e); + } + } + }, + { ignore: !isWindows }, +); + +// Test that dartCommand handles accented characters in paths (issue #14267) +// Accented chars in user paths (e.g., C:\Users\Sébastien\) broke when +// dart-sass was invoked through a .bat wrapper with UTF-8/OEM mismatch. +unitTest( + "dartCommand - handles accented characters in paths (issue #14267)", + async () => { + const tempBase = Deno.makeTempDirSync({ prefix: "quarto_test_" }); + const accentedSassDir = join(tempBase, "Sébastien", "dart-sass"); + const accentedProjectDir = join(tempBase, "Sébastien", "project"); + + let removeJunction: (() => Promise) | undefined; + + try { + Deno.mkdirSync(join(tempBase, "Sébastien"), { recursive: true }); + Deno.mkdirSync(accentedProjectDir, { recursive: true }); + + removeJunction = await createDartSassJunction(accentedSassDir); + + const inputScss = join(accentedProjectDir, "style.scss"); + const outputCss = join(accentedProjectDir, "style.css"); + Deno.writeTextFileSync(inputScss, "body { color: blue; }"); + + const result = await dartCommand([inputScss, outputCss], { + installDir: accentedSassDir, + }); + + assert( + result === undefined || result === "", + "Sass compile should succeed with accented path", + ); + assert( + Deno.statSync(outputCss).isFile, + "Output CSS file should be created at accented path", + ); + } finally { + try { + if (removeJunction) await removeJunction(); await Deno.remove(tempBase, { recursive: true }); } catch (e) { - // Best effort cleanup - log for debugging if it fails console.debug("Test cleanup failed:", e); } } From 4df84fd841dbfa1a66e22ac4592b8122c5c7cb83 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 08:52:05 +0000 Subject: [PATCH 18/21] Update version.txt --- version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.txt b/version.txt index 04a5f0b5274..a7c3730ad04 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.9.37 +1.9.38 From 86d05a58e2be632333e7df306c8b227d7d66a0cf Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Mon, 25 May 2026 11:42:18 +0200 Subject: [PATCH 19/21] Revert version.txt update (4df84fd841dbfa1a66e22ac4592b8122c5c7cb83) Publishing release has failed. --- version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.txt b/version.txt index a7c3730ad04..04a5f0b5274 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.9.38 +1.9.37 From 6ebb5db80eb542ac76189c3d7c33ae6f654b93d2 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 09:44:40 +0000 Subject: [PATCH 20/21] Update version.txt --- version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.txt b/version.txt index 04a5f0b5274..a7c3730ad04 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.9.37 +1.9.38 From 778425be878298ea324a309111ee13ca35eca18f Mon Sep 17 00:00:00 2001 From: Eric DeWitt Date: Sun, 19 Jul 2026 19:02:42 +0100 Subject: [PATCH 21/21] Support fully offline builds from a pre-populated deno cache Two changes needed to build quarto.js from a source tarball (no .git, no network) against a pre-populated, checksummed DENO_DIR, as required by distro packaging (MacPorts, etc.): - src/webui/quarto-preview/build.ts: buildFromGit() unconditionally triggers a network-dependent `npm install && npm run build` whenever `git ls-files` fails, which it always does when building from a release tarball rather than a git checkout -- even though quarto-preview.js is already committed to the tree. Add an explicit QUARTO_SKIP_PREVIEW_BUILD opt-out so packagers can use the shipped build instead of triggering a spurious rebuild. - package/scripts/vendoring/vendor.sh: the `deno install` calls that resolve quarto's TypeScript module graph have no way to enforce that resolution stays local to an already-populated cache. Add QUARTO_VENDOR_LOCK (passes --lock= --frozen) and QUARTO_VENDOR_OFFLINE=1 (passes --cached-only) so a cache miss fails loudly instead of silently reaching out to the network. Verified end-to-end: a full `configure.sh` + `quarto-bld prepare-dist` run against MacPorts-provided deno/pandoc/typst/dart-sass plus from-source esbuild/deno_dom dependencies, with these two env vars set and a pre-populated DENO_DIR, completes with zero network fetches and produces a working quarto.js that renders documents correctly. --- package/scripts/vendoring/vendor.sh | 12 +++++++++++- src/webui/quarto-preview/build.ts | 12 ++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/package/scripts/vendoring/vendor.sh b/package/scripts/vendoring/vendor.sh index 976eb80d6b5..350917d8237 100755 --- a/package/scripts/vendoring/vendor.sh +++ b/package/scripts/vendoring/vendor.sh @@ -23,8 +23,18 @@ fi pushd "${QUARTO_SRC_PATH}" set +e +# QUARTO_VENDOR_LOCK / QUARTO_VENDOR_OFFLINE let packagers building from a +# pre-populated, checksummed DENO_DIR (no network in the build sandbox) force +# resolution to fail loudly on any cache miss instead of silently fetching. +DENO_INSTALL_EXTRA_ARGS=() +if [ -n "$QUARTO_VENDOR_LOCK" ]; then + DENO_INSTALL_EXTRA_ARGS+=("--lock=$QUARTO_VENDOR_LOCK" "--frozen") +fi +if [ "$QUARTO_VENDOR_OFFLINE" = "1" ]; then + DENO_INSTALL_EXTRA_ARGS+=("--cached-only") +fi for entrypoint in quarto.ts vendor_deps.ts ../tests/test-deps.ts ../package/scripts/deno_std/deno_std.ts; do - $DENO_BIN_PATH install --allow-all --no-config --entrypoint $entrypoint "--importmap=$QUARTO_SRC_PATH/import_map.json" + $DENO_BIN_PATH install --allow-all --no-config --entrypoint $entrypoint "--importmap=$QUARTO_SRC_PATH/import_map.json" "${DENO_INSTALL_EXTRA_ARGS[@]}" done set -e popd diff --git a/src/webui/quarto-preview/build.ts b/src/webui/quarto-preview/build.ts index aaa30fb374e..a7929bb32e7 100644 --- a/src/webui/quarto-preview/build.ts +++ b/src/webui/quarto-preview/build.ts @@ -5,6 +5,18 @@ export {}; const args = Deno.args; +// Packagers building from a source tarball (no .git directory) rather than a +// git checkout have no way to satisfy buildFromGit()'s `git ls-files` check, +// which unconditionally triggers a rebuild (and thus a network-dependent +// `npm install`) even though quarto-preview.js is already committed to the +// tree. Let them opt out explicitly and use the shipped build. +if (Deno.env.get("QUARTO_SKIP_PREVIEW_BUILD")) { + console.log( + "QUARTO_SKIP_PREVIEW_BUILD set, skipping quarto-preview.js rebuild", + ); + Deno.exit(0); +} + // establish target js build time const kQuartoPreviewJs = "../../resources/preview/quarto-preview.js"; let jsBuildTime: number;