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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions news/changelog-1.10.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ All changes included in 1.10:

- ([#6092](https://github.com/quarto-dev/quarto-cli/issues/6092)): Fix the `default-image-extension` being appended to an extensionless URL ending in a slash (e.g. `![](https://example.com/)`), which broke the embed/iframe image syntax.
- ([#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.
- ([#11703](https://github.com/quarto-dev/quarto-cli/issues/11703)): Fix SCSS color-variable export (`--quarto-scss-export-*`) being silently skipped when a theme rule contains consecutive semicolons (e.g. `max-height: 100% !important;;`).
- ([#14255](https://github.com/quarto-dev/quarto-cli/issues/14255)): Fix shortcodes inside inline and display math expressions not being resolved.
- ([#14342](https://github.com/quarto-dev/quarto-cli/issues/14342)): Work around TOCTOU race in Deno's `expandGlobSync` that can cause unexpected exceptions to be raised while traversing directories during project initialization.
- ([#14445](https://github.com/quarto-dev/quarto-cli/issues/14445)): Fix intermittent `Uncaught (in promise) TypeError: Writable stream is closed or errored.` aborting renders on Linux. `execProcess` now awaits and swallows the rejection from `process.stdin.close()` when the child closes its stdin first. The captured stderr is now also surfaced when `typst-gather analyze` falls back to staging all packages, so failures are diagnosable without bypassing `quarto`.
Expand All @@ -116,3 +117,4 @@ All changes included in 1.10:
- ([#14583](https://github.com/quarto-dev/quarto-cli/issues/14583)): Fix a shortcode used as an image source (e.g. `![]({{< meta logo >}})`) getting the `default-image-extension` appended, producing a doubled extension once the shortcode resolves.
- ([#14595](https://github.com/quarto-dev/quarto-cli/issues/14595)): Fix reload preview in code-server environment
- ([#14669](https://github.com/quarto-dev/quarto-cli/issues/14669)): Fix markdown output being deleted when `output-file` has an `.html` extension and an html format is paired with a markdown format.
- ([#14687](https://github.com/quarto-dev/quarto-cli/issues/14687)): Fix SCSS color-variable export (`--quarto-scss-export-*`) being silently skipped when a theme rule places a declaration on the same line as the opening brace with no space after the colon (e.g. `.example {width:100px;}`).
16 changes: 16 additions & 0 deletions src/core/sass/analyzer/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ export const makeParserModule = (
"$1$2 /* empty rule */ $3",
);

// https://github.com/quarto-dev/quarto-cli/issues/11703
// It also doesn't like consecutive semicolons inside a block
// (an empty statement, valid CSS/SCSS), even across lines,
// e.g. `a { b: 1;; }`. Collapse each run of semicolons into one.
contents = contents.replaceAll(
/;[\s;]*;/g,
";",
);

// it also really doesn't like statements that don't end in a semicolon
// so, in case you are reading this code to understand why the parser is failing,
// ensure that your SCSS has semicolons at the end of every statement.
Expand All @@ -40,6 +49,13 @@ export const makeParserModule = (
/(^\s*[A-Za-z0-9-]+):([^ \n])/mg,
"$1: $2",
);
// https://github.com/quarto-dev/quarto-cli/issues/14687
// ... and the same when the declaration doesn't start a line but
// follows `{` or `;`, e.g. `.example {width:100px;}`
contents = contents.replaceAll(
/([{;]\s*[A-Za-z0-9-]+):([^ \n])/g,
"$1: $2",
);

// scss-parser's tokenizer only handles ASCII identifier characters.
// Non-ASCII codepoints are valid in both CSS and SCSS identifiers:
Expand Down
14 changes: 14 additions & 0 deletions tests/docs/smoke-all/2026/07/14/custom-11703.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/*-- scss:defaults --*/
$issue-11703-color: #00ff00;

/*-- scss:rules --*/

/* A double semicolon (an empty statement, valid CSS/SCSS) used to break the
SCSS analyzer (https://github.com/quarto-dev/quarto-cli/issues/11703). */
.hanged {
max-height: 100% !important;;
}

#test-11703 {
background-color: $issue-11703-color;
}
13 changes: 13 additions & 0 deletions tests/docs/smoke-all/2026/07/14/custom-14687.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*-- scss:defaults --*/
$issue-14687-color: #ff0000;

/*-- scss:rules --*/

/* A declaration on the same line as the opening brace, with no space after
the colon and a non-identifier value, used to break the SCSS analyzer
(https://github.com/quarto-dev/quarto-cli/issues/14687). */
.example {width:100px;}

#test-14687 {
background-color: $issue-14687-color;
}
16 changes: 16 additions & 0 deletions tests/docs/smoke-all/2026/07/14/issue-11703.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
title: "Double semicolon in SCSS rule"
format:
html:
theme:
- default
- custom-11703.scss
_quarto:
tests:
html:
ensureCssRegexMatches:
- ['--quarto-scss-export-issue-11703-color', '\.hanged']
---

A theme rule containing a double semicolon (`max-height: 100% !important;;`)
must not break the SCSS color variable export.
17 changes: 17 additions & 0 deletions tests/docs/smoke-all/2026/07/14/issue-14687.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
title: "One-line SCSS rule with no space after colon"
format:
html:
theme:
- default
- custom-14687.scss
_quarto:
tests:
html:
ensureCssRegexMatches:
- ['--quarto-scss-export-issue-14687-color', '\.example']
---

A theme rule like `.example {width:100px;}` (declaration on the same line as
the opening brace, no space after the colon) must not break the SCSS color
variable export.
100 changes: 100 additions & 0 deletions tests/unit/scss-analyzer-parse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* scss-analyzer-parse.test.ts
*
* Tests for the SCSS static-analysis parser wrapper, in particular the
* text-level workarounds in src/core/sass/analyzer/parse.ts that shield
* the brittle third-party scss-parser from valid-but-oddly-formatted SCSS.
* Validates fixes for:
* https://github.com/quarto-dev/quarto-cli/issues/14687 (colon with no space)
* https://github.com/quarto-dev/quarto-cli/issues/11703 (double semicolon)
*
* Copyright (C) 2020-2026 Posit Software, PBC
*/

import { unitTest } from "../test.ts";
import { assert } from "testing/asserts";
import { parse } from "scss-parser";
import { makeParserModule } from "../../src/core/sass/analyzer/parse.ts";
import { cssVarsBlock } from "../../src/core/sass/add-css-vars.ts";

const { getSassAst } = makeParserModule(parse);

// SCSS snippets that are valid (Dart Sass compiles them) but used to make
// the raw scss-parser throw. Each entry documents a shape the workarounds
// in parse.ts must keep rescuing.
const parseableSnippets: [string, string][] = [
// issue #14687: declaration on the same line as `{`, no space after colon
[".example {width:100px;}", "same-line declaration, numeric value"],
[".example { width:100px; }", "same-line declaration, spaces inside braces"],
[".example {width:$foo;}", "same-line declaration, variable value"],
[".example {width:100px}", "same-line declaration, missing semicolon"],
[".example {color:#fff;}", "same-line declaration, hex color value"],
[".example {width:100%;}", "same-line declaration, percentage value"],
[".example {margin:0;}", "same-line declaration, bare zero value"],
[".example {width:.5em;}", "same-line declaration, leading-dot value"],
[
".example {width:100px;height:200px;}",
"second declaration follows a semicolon on the same line",
],
["a:hover {width:100px;}", "pseudo-class selector before a one-line rule"],
[
"@media (min-width:600px) { body { margin:0; } }",
"one-line media query with colon-no-space declarations",
],
[
".x {background:url(data:image/png;base64,AAAA);}",
"data: url payload containing colons and semicolons",
],
['.x {content:"{a:1}";}', "string value containing brace/colon characters"],
// issue #11703: consecutive semicolons (empty statements) inside a block
["a { b: 1;; }", "double semicolon after a declaration"],
["a { b: 1; ; }", "semicolons separated by a space"],
["a { b: 1;;; }", "triple semicolon"],
["a { b: 1;\n;\n}", "semicolons separated by a newline"],
[
".hanged { max-height: 100% !important;; }",
"double semicolon after !important (issue #11703 shape)",
],
// shapes that already worked before issue #14687; guard against regressions
[".example {\n width:100px;\n}", "line-start declaration, no space after colon"],
[".foo { &:hover {color:red;} }", "nested parent-selector rule on one line"],
["$brand: #ff0000;\n.example {width:100px;}", "variable assignment plus one-line rule"],
];

unitTest(
"scss-analyzer - parses valid SCSS shapes that break raw scss-parser (issues #14687, #11703)",
// deno-lint-ignore require-await
async () => {
for (const [snippet, description] of parseableSnippets) {
try {
const ast = getSassAst(snippet);
assert(
ast.type === "stylesheet",
`Expected a stylesheet AST for: ${description}`,
);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
throw new Error(
`getSassAst failed on "${snippet}" (${description}): ${message}`,
);
}
}
},
);

unitTest(
"scss-analyzer - cssVarsBlock exports colors despite one-line rules (issue #14687)",
// deno-lint-ignore require-await
async () => {
const scss = [
"$brand: #ff0000;",
"$body-bg: $brand;",
".example {width:100px;}",
].join("\n");
const block = cssVarsBlock(scss);
assert(
block.includes("--quarto-scss-export-brand:"),
`Expected $brand to be exported as a CSS variable, got:\n${block}`,
);
},
);
Loading