From afea67b4ea8d06b9cf7631ea3f4d99b52dba5ebf Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Fri, 16 Jan 2026 13:45:07 -0500 Subject: [PATCH 1/5] claude: Add ensureCssRegexMatches test assertion Add a new test assertion function that checks regex patterns against CSS files in a document's supporting files directory. This is useful for verifying CSS content that is generated during rendering. Co-Authored-By: Claude Opus 4.5 --- tests/smoke/smoke-all.test.ts | 2 ++ tests/verify.ts | 47 +++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/tests/smoke/smoke-all.test.ts b/tests/smoke/smoke-all.test.ts index b5aaafa6897..b92c9479aff 100644 --- a/tests/smoke/smoke-all.test.ts +++ b/tests/smoke/smoke-all.test.ts @@ -18,6 +18,7 @@ import { breakQuartoMd } from "../../src/core/lib/break-quarto-md.ts"; import { parse } from "../../src/core/yaml.ts"; import { cleanoutput } from "./render/render.ts"; import { + ensureCssRegexMatches, ensureEpubFileRegexMatches, ensureDocxRegexMatches, ensureDocxXpath, @@ -171,6 +172,7 @@ function resolveTestSpecs( const result = []; // deno-lint-ignore no-explicit-any const verifyMap: Record = { + ensureCssRegexMatches, ensureEpubFileRegexMatches, ensureHtmlElements, ensureHtmlElementContents, diff --git a/tests/verify.ts b/tests/verify.ts index a38dbbd01d5..524ad8f5eed 100644 --- a/tests/verify.ts +++ b/tests/verify.ts @@ -584,6 +584,53 @@ export const ensureFileRegexMatches = ( return(verifyFileRegexMatches(regexChecker)(file, matchesUntyped, noMatchesUntyped)); }; +// Use this function to Regex match text in CSS files in the supporting files directory +export const ensureCssRegexMatches = ( + file: string, + matchesUntyped: (string | RegExp)[], + noMatchesUntyped?: (string | RegExp)[], +): Verify => { + const asRegexp = (m: string | RegExp) => { + if (typeof m === "string") { + return new RegExp(m, "m"); + } + return m; + }; + const matches = matchesUntyped.map(asRegexp); + const noMatches = noMatchesUntyped?.map(asRegexp); + + return { + name: `Inspecting CSS files for Regex matches`, + verify: async (_output: ExecuteOutput[]) => { + // Find support directory from file path + const [dir, stem] = dirAndStem(file); + const supportDir = join(dir, stem + "_files"); + + // Find all CSS files recursively and combine their content + let combinedContent = ""; + for (const entry of walkSync(supportDir, { exts: [".css"] })) { + combinedContent += await Deno.readTextFile(entry.path) + "\n"; + } + + matches.forEach((regex) => { + assert( + regex.test(combinedContent), + `Required CSS match ${String(regex)} is missing.`, + ); + }); + + if (noMatches) { + noMatches.forEach((regex) => { + assert( + !regex.test(combinedContent), + `Illegal CSS match ${String(regex)} was found.`, + ); + }); + } + }, + }; +}; + // Use this function to Regex match text in the intermediate kept file // FIXME: do this properly without resorting on file having keep-* export const verifyKeepFileRegexMatches = ( From c91c4935215f4af07a5e290819483dcb4fbbd85f Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Fri, 16 Jan 2026 13:45:22 -0500 Subject: [PATCH 2/5] claude: Fix remote fonts not recognised in brand extensions (#13685) When a brand.yml file with remote font URLs (e.g., https://...) is located in a subfolder (like _extensions/my-brand/), the font URL was incorrectly joined with the path prefix, resulting in broken paths like "_extensions/my-brand/https:/...". Add isExternalPath helper to detect URLs and skip path joining for external font paths. Co-Authored-By: Claude Opus 4.5 --- src/core/sass/brand.ts | 7 ++++++- .../typography/remote-font-extension/.gitignore | 2 ++ .../_extensions/my-brand/_extension.yml | 8 ++++++++ .../_extensions/my-brand/mybrand.yml | 8 ++++++++ .../typography/remote-font-extension/_quarto.yml | 5 +++++ .../remote-font-extension.qmd | 15 +++++++++++++++ 6 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 tests/docs/smoke-all/brand/typography/remote-font-extension/.gitignore create mode 100644 tests/docs/smoke-all/brand/typography/remote-font-extension/_extensions/my-brand/_extension.yml create mode 100644 tests/docs/smoke-all/brand/typography/remote-font-extension/_extensions/my-brand/mybrand.yml create mode 100644 tests/docs/smoke-all/brand/typography/remote-font-extension/_quarto.yml create mode 100644 tests/docs/smoke-all/brand/typography/remote-font-extension/remote-font-extension.qmd diff --git a/src/core/sass/brand.ts b/src/core/sass/brand.ts index 993fb5eac0a..51036ccbddc 100644 --- a/src/core/sass/brand.ts +++ b/src/core/sass/brand.ts @@ -150,6 +150,8 @@ const googleFontImportString = (description: BrandFontGoogle) => { }:${styleString}wght@${weights}&display=${display}');`; }; +const isExternalPath = (path: string) => /^\w+:/.test(path); + const fileFontImportString = (brand: Brand, description: BrandFontFile) => { const pathPrefix = relative(brand.projectDir, brand.brandDir); const parts = []; @@ -162,9 +164,12 @@ const fileFontImportString = (brand: Brand, description: BrandFontFile) => { weight = file.weight; style = file.style; } + const fontUrl = isExternalPath(path) + ? path + : join(pathPrefix, path).replace(/\\/g, "/"); parts.push(`@font-face { font-family: '${description.family}'; - src: url('${join(pathPrefix, path).replace(/\\/g, "/")}'); + src: url('${fontUrl}'); font-weight: ${weight || "normal"}; font-style: ${style || "normal"}; }\n`); diff --git a/tests/docs/smoke-all/brand/typography/remote-font-extension/.gitignore b/tests/docs/smoke-all/brand/typography/remote-font-extension/.gitignore new file mode 100644 index 00000000000..ad293093b07 --- /dev/null +++ b/tests/docs/smoke-all/brand/typography/remote-font-extension/.gitignore @@ -0,0 +1,2 @@ +/.quarto/ +**/*.quarto_ipynb diff --git a/tests/docs/smoke-all/brand/typography/remote-font-extension/_extensions/my-brand/_extension.yml b/tests/docs/smoke-all/brand/typography/remote-font-extension/_extensions/my-brand/_extension.yml new file mode 100644 index 00000000000..859925eab79 --- /dev/null +++ b/tests/docs/smoke-all/brand/typography/remote-font-extension/_extensions/my-brand/_extension.yml @@ -0,0 +1,8 @@ +title: My Brand +author: Quarto +version: 1.0.0 +quarto-required: ">=99.9.0" +contributes: + metadata: + project: + brand: mybrand.yml diff --git a/tests/docs/smoke-all/brand/typography/remote-font-extension/_extensions/my-brand/mybrand.yml b/tests/docs/smoke-all/brand/typography/remote-font-extension/_extensions/my-brand/mybrand.yml new file mode 100644 index 00000000000..1f8fcf43c9d --- /dev/null +++ b/tests/docs/smoke-all/brand/typography/remote-font-extension/_extensions/my-brand/mybrand.yml @@ -0,0 +1,8 @@ +typography: + fonts: + - family: Noto Sans + source: file + files: + - path: https://notofonts.github.io/latin-greek-cyrillic/fonts/NotoSans/unhinted/ttf/NotoSans-Regular.ttf + base: + family: Noto Sans diff --git a/tests/docs/smoke-all/brand/typography/remote-font-extension/_quarto.yml b/tests/docs/smoke-all/brand/typography/remote-font-extension/_quarto.yml new file mode 100644 index 00000000000..f98ff850162 --- /dev/null +++ b/tests/docs/smoke-all/brand/typography/remote-font-extension/_quarto.yml @@ -0,0 +1,5 @@ +project: + type: default +format: + html: + theme: brand diff --git a/tests/docs/smoke-all/brand/typography/remote-font-extension/remote-font-extension.qmd b/tests/docs/smoke-all/brand/typography/remote-font-extension/remote-font-extension.qmd new file mode 100644 index 00000000000..25812585524 --- /dev/null +++ b/tests/docs/smoke-all/brand/typography/remote-font-extension/remote-font-extension.qmd @@ -0,0 +1,15 @@ +--- +title: Remote Font Extension Test +_quarto: + tests: + html: + ensureCssRegexMatches: + - ['src:url\("https://notofonts\.github\.io/'] + - ['_extensions/my-brand/https:'] +--- + +# Remote Font Test + +This document tests that remote font URLs in brand extensions are handled correctly (issue #13685). + +{{< lipsum 1 >}} From 3cd1e7fc580911ad2560922ca0513a63a96ac921 Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Fri, 16 Jan 2026 14:25:17 -0500 Subject: [PATCH 3/5] claude: Add changelog entry for #13685 Co-Authored-By: Claude Opus 4.5 --- news/changelog-1.9.md | 1 + 1 file changed, 1 insertion(+) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 894773bce7d..4163bd44de8 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -33,6 +33,7 @@ All changes included in 1.9: - ([#11929](https://github.com/quarto-dev/quarto-cli/issues/11929)): Import all `brand.typography.fonts` in CSS, whether or not fonts are referenced by typography elements. - ([#13413](https://github.com/quarto-dev/quarto-cli/issues/13413)): Fix uncentered play button in `video` shortcodes from cross-reference divs. (author: @bruvellu) - ([#13508](https://github.com/quarto-dev/quarto-cli/issues/13508)): Add `aria-label` support to `video` shortcode for improved accessibility. +- ([#13685](https://github.com/quarto-dev/quarto-cli/issues/13685)): Fix remote font URLs in brand extensions being incorrectly joined with the extension path, resulting in broken font imports. ### `typst` From bf48448c40486cd9b77be31f752d69b92de1377f Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Mon, 19 Jan 2026 17:02:13 -0500 Subject: [PATCH 4/5] claude: Consolidate isExternalPath helper to src/core/url.ts Move the duplicated isExternalPath function to a single location in src/core/url.ts and update all call sites to import from there. This addresses feedback from @cderv on PR #13901. Co-Authored-By: Claude Opus 4.5 --- src/core/brand/brand.ts | 5 +---- src/core/sass/brand.ts | 3 +-- src/core/url.ts | 13 ++++++++----- src/project/types/website/website-navigation.ts | 5 +---- src/project/types/website/website-utils.ts | 5 +---- 5 files changed, 12 insertions(+), 19 deletions(-) diff --git a/src/core/brand/brand.ts b/src/core/brand/brand.ts index 38c1d0c80ca..19e545a67e4 100644 --- a/src/core/brand/brand.ts +++ b/src/core/brand/brand.ts @@ -32,6 +32,7 @@ import { InternalError } from "../lib/error.ts"; import { dirname, join, relative, resolve } from "../../deno_ral/path.ts"; import { warnOnce } from "../log.ts"; import { isCssColorName } from "../css/color-names.ts"; +import { isExternalPath } from "../url.ts"; import { LogoLightDarkSpecifierPathOptional, LogoOptionsPathOptional, @@ -272,10 +273,6 @@ export class Brand { } } -function isExternalPath(path: string) { - return /^\w+:/.test(path); -} - export type LightDarkBrand = { light?: Brand; dark?: Brand; diff --git a/src/core/sass/brand.ts b/src/core/sass/brand.ts index 51036ccbddc..07f4695aea8 100644 --- a/src/core/sass/brand.ts +++ b/src/core/sass/brand.ts @@ -26,6 +26,7 @@ import { Brand } from "../brand/brand.ts"; import { darkModeDefault } from "../../format/html/format-html-info.ts"; import { kBrandMode } from "../../config/constants.ts"; import { join, relative } from "../../deno_ral/path.ts"; +import { isExternalPath } from "../url.ts"; const defaultColorNameMap: Record = { "link-color": "link", @@ -150,8 +151,6 @@ const googleFontImportString = (description: BrandFontGoogle) => { }:${styleString}wght@${weights}&display=${display}');`; }; -const isExternalPath = (path: string) => /^\w+:/.test(path); - const fileFontImportString = (brand: Brand, description: BrandFontFile) => { const pathPrefix = relative(brand.projectDir, brand.brandDir); const parts = []; diff --git a/src/core/url.ts b/src/core/url.ts index a07ac222f70..2b3c5caf714 100644 --- a/src/core/url.ts +++ b/src/core/url.ts @@ -1,9 +1,8 @@ /* -* url.ts -* -* Copyright (C) 2020-2022 Posit Software, PBC -* -*/ + * url.ts + * + * Copyright (C) 2020-2022 Posit Software, PBC + */ import { ensureTrailingSlash, pathWithForwardSlashes } from "./path.ts"; @@ -11,6 +10,10 @@ export function isHttpUrl(url: string) { return /^https?:/i.test(url); } +export function isExternalPath(path: string) { + return /^\w+:/.test(path); +} + export function joinUrl(baseUrl: string, path: string) { const baseHasSlash = baseUrl.endsWith("/"); diff --git a/src/project/types/website/website-navigation.ts b/src/project/types/website/website-navigation.ts index 30ed38da25f..3c55bea6008 100644 --- a/src/project/types/website/website-navigation.ts +++ b/src/project/types/website/website-navigation.ts @@ -12,6 +12,7 @@ import { Document, Element } from "../../../core/deno-dom.ts"; import { pathWithForwardSlashes, safeExistsSync } from "../../../core/path.ts"; import { resourcePath } from "../../../core/resources.ts"; +import { isExternalPath } from "../../../core/url.ts"; import { renderEjs } from "../../../core/ejs.ts"; import { warnOnce } from "../../../core/log.ts"; import { asHtmlId } from "../../../core/html.ts"; @@ -1540,10 +1541,6 @@ function navigationDependency(resource: string) { }; } -function isExternalPath(path: string) { - return /^\w+:/.test(path); -} - function resolveNavReferences( collection: unknown | Array | Record, ) { diff --git a/src/project/types/website/website-utils.ts b/src/project/types/website/website-utils.ts index 01d42b7c8e4..6aa00f8aadb 100644 --- a/src/project/types/website/website-utils.ts +++ b/src/project/types/website/website-utils.ts @@ -8,6 +8,7 @@ import { Document, Element } from "../../../core/deno-dom.ts"; import { getDecodedAttribute } from "../../../core/html.ts"; import { resolveInputTarget } from "../../project-index.ts"; import { pathWithForwardSlashes, safeExistsSync } from "../../../core/path.ts"; +import { isExternalPath } from "../../../core/url.ts"; import { projectOffset, projectOutputDir } from "../../project-shared.ts"; import { engineValidExtensions } from "../../../execute/engine.ts"; import { ProjectContext } from "../../types.ts"; @@ -119,7 +120,3 @@ export async function resolveProjectInputLinks( } } } - -function isExternalPath(path: string) { - return /^\w+:/.test(path); -} From e453b35968a203daec8eebdfce0cb313ef0c280f Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Mon, 19 Jan 2026 20:58:06 -0500 Subject: [PATCH 5/5] claude: Improve ensureCssRegexMatches to parse HTML for CSS links Instead of globbing all CSS files in the support directory, parse the HTML document to find `` tags and only check those specific CSS files. This is more targeted and accurate. Co-Authored-By: Claude Opus 4.5 --- tests/verify.ts | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/tests/verify.ts b/tests/verify.ts index 524ad8f5eed..cbfdca3836e 100644 --- a/tests/verify.ts +++ b/tests/verify.ts @@ -5,7 +5,7 @@ */ import { existsSync, walkSync } from "../src/deno_ral/fs.ts"; -import { DOMParser, NodeList } from "../src/core/deno-dom.ts"; +import { DOMParser, Element, NodeList } from "../src/core/deno-dom.ts"; import { assert } from "testing/asserts"; import { basename, dirname, join, relative, resolve } from "../src/deno_ral/path.ts"; import { parseXmlDocument } from "slimdom"; @@ -584,7 +584,7 @@ export const ensureFileRegexMatches = ( return(verifyFileRegexMatches(regexChecker)(file, matchesUntyped, noMatchesUntyped)); }; -// Use this function to Regex match text in CSS files in the supporting files directory +// Use this function to Regex match text in CSS files linked from the HTML document export const ensureCssRegexMatches = ( file: string, matchesUntyped: (string | RegExp)[], @@ -602,14 +602,24 @@ export const ensureCssRegexMatches = ( return { name: `Inspecting CSS files for Regex matches`, verify: async (_output: ExecuteOutput[]) => { - // Find support directory from file path - const [dir, stem] = dirAndStem(file); - const supportDir = join(dir, stem + "_files"); + // Parse the HTML file to find linked CSS files + const htmlContent = await Deno.readTextFile(file); + const doc = new DOMParser().parseFromString(htmlContent, "text/html")!; + const [dir] = dirAndStem(file); - // Find all CSS files recursively and combine their content + // Find all stylesheet links and read their content let combinedContent = ""; - for (const entry of walkSync(supportDir, { exts: [".css"] })) { - combinedContent += await Deno.readTextFile(entry.path) + "\n"; + const links = doc.querySelectorAll('link[rel="stylesheet"]'); + for (const link of links) { + const href = (link as Element).getAttribute("href"); + if (href && !href.startsWith("http://") && !href.startsWith("https://")) { + const cssPath = join(dir, href); + try { + combinedContent += await Deno.readTextFile(cssPath) + "\n"; + } catch { + // Skip files that don't exist (e.g., external URLs we couldn't parse) + } + } } matches.forEach((regex) => {