Skip to content
Merged
8 changes: 8 additions & 0 deletions news/changelog-1.10.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
All changes included in 1.10:

## Regression fixes

- ([#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.

## Formats

### `typst`

- ([#14261](https://github.com/quarto-dev/quarto-cli/issues/14261)): Fix theorem/example block titles containing inline code producing invalid Typst markup when syntax highlighting is applied.

## Other fixes and improvements

- ([#6651](https://github.com/quarto-dev/quarto-cli/issues/6651)): Fix dart-sass compilation failing in enterprise environments where `.bat` files are blocked by group policy.

125 changes: 67 additions & 58 deletions src/core/dart-sass.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
/*
* dart-sass.ts
*
* Copyright (C) 2020-2022 Posit Software, PBC
* Copyright (C) 2020-2025 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");
Expand Down Expand Up @@ -60,80 +58,91 @@ 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)) {
warnOnce(
`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();

// 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);
}
};
const sassDir = installDir ?? architectureToolsPath("dart-sass");

// 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",
});
},
);
return processResult(result);
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);
}
}
2 changes: 1 addition & 1 deletion src/core/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
104 changes: 78 additions & 26 deletions tests/unit/dart-sass.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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<void>) | 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)",
Expand All @@ -62,14 +72,56 @@ 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 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<void>) | 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);
}
}
Expand Down
Loading
Loading