Skip to content

Commit f423499

Browse files
authored
Merge branch 'main' into mh/update-julia-engine-0.2.0
2 parents 8950c5e + e626825 commit f423499

11 files changed

Lines changed: 359 additions & 91 deletions

File tree

news/changelog-1.10.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
All changes included in 1.10:
22

3+
## Regression fixes
4+
5+
- ([#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.
6+
7+
## Formats
8+
9+
### `typst`
10+
11+
- ([#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.
12+
13+
## Commands
14+
15+
### `quarto create`
16+
17+
- ([#14250](https://github.com/quarto-dev/quarto-cli/issues/14250)): Fix `quarto create` producing read-only files when Quarto is installed via system packages (e.g., `.deb`). Files copied from installed resources now have user-write permission ensured.
18+
319
## Engines
420

521
### `julia`
@@ -15,9 +31,7 @@ All changes included in 1.10:
1531
- Fix cache invalidation to hash full `Manifest.toml` content so dependency version changes correctly invalidate the cache.
1632
- Fix duplicate YAML keys when Python/R cells have cell options like `echo`.
1733

18-
## Formats
34+
## Other fixes and improvements
1935

20-
### `typst`
21-
22-
- ([#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.
36+
- ([#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.
2337

src/command/create/artifacts/artifact-shared.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import { gfmAutoIdentifier } from "../../../core/pandoc/pandoc-id.ts";
1212
import { coerce } from "semver/mod.ts";
1313
import { info } from "../../../deno_ral/log.ts";
1414
import { basename, dirname, join, relative } from "../../../deno_ral/path.ts";
15-
import { ensureDirSync, walkSync } from "../../../deno_ral/fs.ts";
15+
import {
16+
ensureDirSync,
17+
ensureUserWritable,
18+
walkSync,
19+
} from "../../../deno_ral/fs.ts";
1620
import { renderEjs } from "../../../core/ejs.ts";
1721
import { safeExistsSync } from "../../../core/path.ts";
1822
import { CreateDirective, CreateDirectiveData } from "../cmd-types.ts";
@@ -116,6 +120,7 @@ const renderArtifact = (
116120
}
117121
ensureDirSync(dirname(target));
118122
Deno.copyFileSync(src, target);
123+
ensureUserWritable(target);
119124
return target;
120125
}
121126
};

src/core/dart-sass.ts

Lines changed: 67 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,18 @@
11
/*
22
* dart-sass.ts
33
*
4-
* Copyright (C) 2020-2022 Posit Software, PBC
4+
* Copyright (C) 2020-2025 Posit Software, PBC
55
*/
66
import { join } from "../deno_ral/path.ts";
77

88
import { architectureToolsPath } from "./resources.ts";
99
import { execProcess } from "./process.ts";
10-
import { ProcessResult } from "./process-types.ts";
1110
import { TempContext } from "./temp.ts";
1211
import { lines } from "./text.ts";
1312
import { debug, info } from "../deno_ral/log.ts";
1413
import { existsSync } from "../deno_ral/fs.ts";
1514
import { warnOnce } from "./log.ts";
1615
import { isWindows } from "../deno_ral/platform.ts";
17-
import { requireQuoting, safeWindowsExec } from "./windows.ts";
1816

1917
export function dartSassInstallDir() {
2018
return architectureToolsPath("dart-sass");
@@ -60,80 +58,91 @@ export async function dartCompile(
6058
*/
6159
export interface DartCommandOptions {
6260
/**
63-
* Override the sass executable path.
64-
* Primarily used for testing with spaced paths.
61+
* Override the dart-sass install directory.
62+
* Used for testing with non-standard paths (spaces, accented characters).
6563
*/
66-
sassPath?: string;
64+
installDir?: string;
6765
}
6866

69-
export async function dartCommand(
70-
args: string[],
71-
options?: DartCommandOptions,
72-
) {
73-
const resolvePath = () => {
67+
/**
68+
* Resolve the dart-sass command and its base arguments.
69+
*
70+
* On Windows, calls dart.exe + sass.snapshot directly instead of going
71+
* through sass.bat. The bundled sass.bat is a thin wrapper generated by
72+
* dart_cli_pkg that just runs:
73+
* "%SCRIPTPATH%\src\dart.exe" "%SCRIPTPATH%\src\sass.snapshot" %arguments%
74+
*
75+
* Template source:
76+
* https://github.com/google/dart_cli_pkg/blob/main/lib/src/templates/standalone/executable.bat.mustache
77+
* Upstream issue to ship standalone .exe instead of .bat + dart.exe:
78+
* https://github.com/google/dart_cli_pkg/issues/67
79+
*
80+
* Bypassing sass.bat avoids multiple .bat file issues on Windows:
81+
* - Deno quoting bugs with spaced paths (#13997)
82+
* - cmd.exe OEM code page misreading UTF-8 accented paths (#14267)
83+
* - Enterprise group policy blocking .bat execution (#6651)
84+
*/
85+
function resolveSassCommand(options?: DartCommandOptions): {
86+
cmd: string;
87+
baseArgs: string[];
88+
} {
89+
const installDir = options?.installDir;
90+
if (installDir == null) {
91+
// Only check env var override when no explicit installDir is provided.
92+
// If QUARTO_DART_SASS doesn't exist on disk, fall through to use the
93+
// bundled dart-sass at the default architectureToolsPath.
7494
const dartOverrideCmd = Deno.env.get("QUARTO_DART_SASS");
7595
if (dartOverrideCmd) {
7696
if (!existsSync(dartOverrideCmd)) {
7797
warnOnce(
7898
`Specified QUARTO_DART_SASS does not exist, using built in dart sass.`,
7999
);
80100
} else {
81-
return dartOverrideCmd;
101+
return { cmd: dartOverrideCmd, baseArgs: [] };
82102
}
83103
}
104+
}
84105

85-
const command = isWindows ? "sass.bat" : "sass";
86-
return architectureToolsPath(join("dart-sass", command));
87-
};
88-
const sass = options?.sassPath ?? resolvePath();
89-
90-
// Process result helper (shared by Windows and non-Windows paths)
91-
const processResult = (result: ProcessResult): string | undefined => {
92-
if (result.success) {
93-
if (result.stderr) {
94-
info(result.stderr);
95-
}
96-
return result.stdout;
97-
} else {
98-
debug(`[DART path] : ${sass}`);
99-
debug(`[DART args] : ${args.join(" ")}`);
100-
debug(`[DART stdout] : ${result.stdout}`);
101-
debug(`[DART stderr] : ${result.stderr}`);
102-
103-
const errLines = lines(result.stderr || "");
104-
// truncate the last 2 lines (they include a pointer to the temp file containing
105-
// all of the concatenated sass, which is more or less incomprehensible for users.
106-
const errMsg = errLines.slice(0, errLines.length - 2).join("\n");
107-
throw new Error("Theme file compilation failed:\n\n" + errMsg);
108-
}
109-
};
106+
const sassDir = installDir ?? architectureToolsPath("dart-sass");
110107

111-
// On Windows, use safeWindowsExec to handle paths with spaces
112-
// (e.g., when Quarto is installed in C:\Program Files\)
113-
// See https://github.com/quarto-dev/quarto-cli/issues/13997
114108
if (isWindows) {
115-
const quoted = requireQuoting([sass, ...args]);
116-
const result = await safeWindowsExec(
117-
quoted.args[0],
118-
quoted.args.slice(1),
119-
(cmd: string[]) => {
120-
return execProcess({
121-
cmd: cmd[0],
122-
args: cmd.slice(1),
123-
stdout: "piped",
124-
stderr: "piped",
125-
});
126-
},
127-
);
128-
return processResult(result);
109+
return {
110+
cmd: join(sassDir, "src", "dart.exe"),
111+
baseArgs: [join(sassDir, "src", "sass.snapshot")],
112+
};
129113
}
130114

131-
// Non-Windows: direct execution
115+
return { cmd: join(sassDir, "sass"), baseArgs: [] };
116+
}
117+
118+
export async function dartCommand(
119+
args: string[],
120+
options?: DartCommandOptions,
121+
) {
122+
const { cmd, baseArgs } = resolveSassCommand(options);
123+
132124
const result = await execProcess({
133-
cmd: sass,
134-
args,
125+
cmd,
126+
args: [...baseArgs, ...args],
135127
stdout: "piped",
136128
stderr: "piped",
137129
});
138-
return processResult(result);
130+
131+
if (result.success) {
132+
if (result.stderr) {
133+
info(result.stderr);
134+
}
135+
return result.stdout;
136+
} else {
137+
debug(`[DART cmd] : ${cmd}`);
138+
debug(`[DART args] : ${[...baseArgs, ...args].join(" ")}`);
139+
debug(`[DART stdout] : ${result.stdout}`);
140+
debug(`[DART stderr] : ${result.stderr}`);
141+
142+
const errLines = lines(result.stderr || "");
143+
// truncate the last 2 lines (they include a pointer to the temp file containing
144+
// all of the concatenated sass, which is more or less incomprehensible for users.
145+
const errMsg = errLines.slice(0, errLines.length - 2).join("\n");
146+
throw new Error("Theme file compilation failed:\n\n" + errMsg);
147+
}
139148
}

src/core/windows.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ export async function safeWindowsExec(
156156
try {
157157
Deno.writeTextFileSync(
158158
tempFile,
159-
["@echo off", [program, ...args].join(" ")].join("\n"),
159+
["@echo off", "chcp 65001 >nul", [program, ...args].join(" ")].join("\r\n"),
160160
);
161161
return await fnExec(["cmd", "/c", tempFile]);
162162
} finally {

src/deno_ral/fs.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,15 @@ export function safeChmodSync(path: string, mode: number): void {
191191
}
192192
}
193193
}
194+
195+
/**
196+
* Ensure a file has user write permission. Files copied from installed
197+
* resources (e.g. system packages) may be read-only, but users expect
198+
* to edit files created by `quarto create`. No-op on Windows.
199+
*/
200+
export function ensureUserWritable(path: string): void {
201+
const mode = safeModeFromFile(path);
202+
if (mode !== undefined && !(mode & 0o200)) {
203+
safeChmodSync(path, mode | 0o200);
204+
}
205+
}

src/project/project-create.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
*/
66

77
import * as ld from "../core/lodash.ts";
8-
import { ensureDirSync, existsSync } from "../deno_ral/fs.ts";
8+
import {
9+
ensureDirSync,
10+
ensureUserWritable,
11+
existsSync,
12+
} from "../deno_ral/fs.ts";
913
import { basename, dirname, join } from "../deno_ral/path.ts";
1014
import { info } from "../deno_ral/log.ts";
1115

@@ -139,6 +143,7 @@ export async function projectCreate(options: ProjectCreateOptions) {
139143
if (!existsSync(dest)) {
140144
ensureDirSync(dirname(dest));
141145
copyTo(src, dest);
146+
ensureUserWritable(dest);
142147
if (!options.quiet) {
143148
info("- Created " + displayName, { indent: 2 });
144149
}
@@ -256,6 +261,7 @@ function projectMarkdownFile(
256261
const name = basename(from);
257262
const target = join(dirname(path), name);
258263
copyTo(from, target);
264+
ensureUserWritable(target);
259265
});
260266

261267
return subdirectory ? join(subdirectory, name) : name;

tests/smoke/create/create.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import { execProcess } from "../../../src/core/process.ts";
99
import { join } from "../../../src/deno_ral/path.ts";
10+
import { walkSync } from "../../../src/deno_ral/fs.ts";
1011
import { CreateResult } from "../../../src/command/create/cmd-types.ts";
1112
import { assert } from "testing/asserts";
1213
import { quartoDevCmd } from "../../utils.ts";
@@ -61,6 +62,27 @@ for (const type of Object.keys(kCreateTypes)) {
6162
assert(process.success, process.stderr);
6263
});
6364

65+
// Verify all created files are user-writable.
66+
// NOTE: In dev environments, resource files are already writable (0o644),
67+
// so this test passes even without ensureUserWritable. It guards against
68+
// regressions; the unit test in file-permissions.test.ts covers the
69+
// read-only → writable transition directly.
70+
await t.step({
71+
name: `> check writable ${type} ${template}`,
72+
ignore: Deno.build.os === "windows",
73+
fn: () => {
74+
for (const entry of walkSync(artifactPath)) {
75+
if (entry.isFile) {
76+
const stat = Deno.statSync(entry.path);
77+
assert(
78+
stat.mode !== null && (stat.mode! & 0o200) !== 0,
79+
`File ${entry.path} is not user-writable (mode: ${stat.mode?.toString(8)})`,
80+
);
81+
}
82+
}
83+
},
84+
});
85+
6486
// Render the artifact
6587
await t.step(`> render ${type} ${template}`, async () => {
6688
const path = result!.path;

0 commit comments

Comments
 (0)