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` 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 993fb5eac0a..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", @@ -162,9 +163,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/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); -} 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 >}} 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..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,6 +584,63 @@ export const ensureFileRegexMatches = ( return(verifyFileRegexMatches(regexChecker)(file, matchesUntyped, noMatchesUntyped)); }; +// Use this function to Regex match text in CSS files linked from the HTML document +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[]) => { + // 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 stylesheet links and read their content + let combinedContent = ""; + 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) => { + 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 = (