From 53f3691ae6a392f2954949f88bda09cfb091aab2 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Tue, 14 Jul 2026 11:44:57 -0500 Subject: [PATCH 1/2] Fix SCSS analysis failing on same-line declarations with no space after colon (#14687) scss-parser cannot tokenize `prop:value` (no space after the colon) unless the value is a bare identifier. The existing workaround in analyzer/parse.ts only rescued declarations at line start, so rules like `.example {width:100px;}` reached the raw parser, cssVarsBlock threw SCSSParsingError, and the --quarto-scss-export-* block was silently dropped. Add a sibling rewrite covering declarations that follow `{` or `;` on the same line. Co-Authored-By: Claude Fable 5 --- news/changelog-1.10.md | 1 + src/core/sass/analyzer/parse.ts | 7 ++ .../smoke-all/2026/07/14/custom-14687.scss | 13 +++ .../docs/smoke-all/2026/07/14/issue-14687.qmd | 17 ++++ tests/unit/scss-analyzer-parse.test.ts | 89 +++++++++++++++++++ 5 files changed, 127 insertions(+) create mode 100644 tests/docs/smoke-all/2026/07/14/custom-14687.scss create mode 100644 tests/docs/smoke-all/2026/07/14/issue-14687.qmd create mode 100644 tests/unit/scss-analyzer-parse.test.ts diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 09ef7aa32da..f35f75c9d0c 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -116,3 +116,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;}`). diff --git a/src/core/sass/analyzer/parse.ts b/src/core/sass/analyzer/parse.ts index a2f73206c80..71de3a03fa9 100644 --- a/src/core/sass/analyzer/parse.ts +++ b/src/core/sass/analyzer/parse.ts @@ -40,6 +40,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: diff --git a/tests/docs/smoke-all/2026/07/14/custom-14687.scss b/tests/docs/smoke-all/2026/07/14/custom-14687.scss new file mode 100644 index 00000000000..afafbc80a1f --- /dev/null +++ b/tests/docs/smoke-all/2026/07/14/custom-14687.scss @@ -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; +} diff --git a/tests/docs/smoke-all/2026/07/14/issue-14687.qmd b/tests/docs/smoke-all/2026/07/14/issue-14687.qmd new file mode 100644 index 00000000000..6af032fe6c7 --- /dev/null +++ b/tests/docs/smoke-all/2026/07/14/issue-14687.qmd @@ -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. diff --git a/tests/unit/scss-analyzer-parse.test.ts b/tests/unit/scss-analyzer-parse.test.ts new file mode 100644 index 00000000000..a060aed7a27 --- /dev/null +++ b/tests/unit/scss-analyzer-parse.test.ts @@ -0,0 +1,89 @@ +/* + * 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 fix for https://github.com/quarto-dev/quarto-cli/issues/14687 + * + * 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"], + // 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 (issue #14687)", + // 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}`, + ); + }, +); From 303f65da5ba243713a236c28c4c7bbeab7ce777e Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Tue, 14 Jul 2026 12:12:39 -0500 Subject: [PATCH 2/2] Fix SCSS analysis failing on consecutive semicolons (#11703) scss-parser throws on consecutive semicolons inside a block (an empty statement, valid CSS/SCSS), even when separated by whitespace or newlines, e.g. `a { b: 1;; }`. Collapse each run of semicolons into one before parsing, alongside the other analyzer text workarounds. Co-Authored-By: Claude Fable 5 --- news/changelog-1.10.md | 1 + src/core/sass/analyzer/parse.ts | 9 +++++++++ .../docs/smoke-all/2026/07/14/custom-11703.scss | 14 ++++++++++++++ tests/docs/smoke-all/2026/07/14/issue-11703.qmd | 16 ++++++++++++++++ tests/unit/scss-analyzer-parse.test.ts | 15 +++++++++++++-- 5 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 tests/docs/smoke-all/2026/07/14/custom-11703.scss create mode 100644 tests/docs/smoke-all/2026/07/14/issue-11703.qmd diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index f35f75c9d0c..d3422010c4e 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -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`. diff --git a/src/core/sass/analyzer/parse.ts b/src/core/sass/analyzer/parse.ts index 71de3a03fa9..c228514de1b 100644 --- a/src/core/sass/analyzer/parse.ts +++ b/src/core/sass/analyzer/parse.ts @@ -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. diff --git a/tests/docs/smoke-all/2026/07/14/custom-11703.scss b/tests/docs/smoke-all/2026/07/14/custom-11703.scss new file mode 100644 index 00000000000..330e1b64027 --- /dev/null +++ b/tests/docs/smoke-all/2026/07/14/custom-11703.scss @@ -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; +} diff --git a/tests/docs/smoke-all/2026/07/14/issue-11703.qmd b/tests/docs/smoke-all/2026/07/14/issue-11703.qmd new file mode 100644 index 00000000000..77079a29fe5 --- /dev/null +++ b/tests/docs/smoke-all/2026/07/14/issue-11703.qmd @@ -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. diff --git a/tests/unit/scss-analyzer-parse.test.ts b/tests/unit/scss-analyzer-parse.test.ts index a060aed7a27..c6c9c43ad16 100644 --- a/tests/unit/scss-analyzer-parse.test.ts +++ b/tests/unit/scss-analyzer-parse.test.ts @@ -4,7 +4,9 @@ * 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 fix for https://github.com/quarto-dev/quarto-cli/issues/14687 + * 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 */ @@ -44,6 +46,15 @@ const parseableSnippets: [string, string][] = [ "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"], @@ -51,7 +62,7 @@ const parseableSnippets: [string, string][] = [ ]; unitTest( - "scss-analyzer - parses valid SCSS shapes that break raw scss-parser (issue #14687)", + "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) {