From 878eb5c2e340ccb307c016e18626de8ba36bc950 Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Tue, 13 Jan 2026 01:16:24 -0500 Subject: [PATCH 1/4] claude: support single-file mode in `quarto use brand` Fall back to current working directory when no _quarto.yml project is found, allowing the command to work in single-file mode. Co-Authored-By: Claude Opus 4.5 --- src/command/use/commands/brand.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/command/use/commands/brand.ts b/src/command/use/commands/brand.ts index 6dac4b0dd07..e4d865d0a32 100644 --- a/src/command/use/commands/brand.ts +++ b/src/command/use/commands/brand.ts @@ -387,10 +387,10 @@ async function ensureBrandDirectory(force: boolean, dryRun: boolean) { const currentDir = Deno.cwd(); const nbContext = notebookContext(); const project = await projectContext(currentDir, nbContext); - if (!project) { - throw new Error(`Could not find project dir for ${currentDir}`); - } - const brandDir = join(project.dir, "_brand"); + // Use project directory if available, otherwise fall back to current directory + // (single-file mode without _quarto.yml) + const baseDir = project?.dir ?? currentDir; + const brandDir = join(baseDir, "_brand"); if (!existsSync(brandDir)) { if (dryRun) { info(` Would create directory: _brand/`); From 601e3a8fc61180dfc8b40e0013b1c1295ee48eb2 Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Tue, 13 Jan 2026 02:01:20 -0500 Subject: [PATCH 2/4] claude: support brand extensions in `quarto use brand` When `quarto use brand` encounters a brand extension (has _extension.yml with contributes.metadata.project.brand), it now: - Detects the extension in the staged directory (root, _extensions/*, or _extensions/*/*) - Uses the extension directory as the source - Excludes _extension.yml from being copied - Renames the brand file (e.g., brand.yml) to _brand.yml Also updates tests: - Scenario 8 now tests single-file mode (no _quarto.yml) - Adds Scenario 15 for brand extension installation - Adds Scenario 16 for brand extension dry-run Fixes #13863 Co-Authored-By: Claude Opus 4.5 --- src/command/use/commands/brand.ts | 126 +++++++++++++++++- .../test-org/test-brand/_extension.yml | 8 ++ .../_extensions/test-org/test-brand/brand.yml | 5 + .../_extensions/test-org/test-brand/logo.png | Bin 0 -> 1862 bytes tests/smoke/use/brand.test.ts | 97 +++++++++++++- 5 files changed, 227 insertions(+), 9 deletions(-) create mode 100644 tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/_extension.yml create mode 100644 tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/brand.yml create mode 100644 tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/logo.png diff --git a/src/command/use/commands/brand.ts b/src/command/use/commands/brand.ts index e4d865d0a32..53bdc4e50d7 100644 --- a/src/command/use/commands/brand.ts +++ b/src/command/use/commands/brand.ts @@ -24,9 +24,92 @@ import { InternalError } from "../../../core/lib/error.ts"; import { notebookContext } from "../../../render/notebook/notebook-context.ts"; import { projectContext } from "../../../project/project-context.ts"; import { afterConfirm } from "../../../tools/tools-console.ts"; +import { readYaml } from "../../../core/yaml.ts"; +import { Metadata } from "../../../config/types.ts"; const kRootTemplateName = "template.qmd"; +// Brand extension detection result +interface BrandExtensionInfo { + isBrandExtension: boolean; + extensionDir?: string; // Directory containing the brand extension + brandFileName?: string; // The original brand file name (e.g., "brand.yml") +} + +// Check if a directory contains a brand extension +function checkForBrandExtension(dir: string): BrandExtensionInfo { + const extensionFiles = ["_extension.yml", "_extension.yaml"]; + + for (const file of extensionFiles) { + const path = join(dir, file); + if (existsSync(path)) { + try { + const yaml = readYaml(path) as Metadata; + // Check for contributes.metadata.project.brand + const contributes = yaml?.contributes as Metadata | undefined; + const metadata = contributes?.metadata as Metadata | undefined; + const project = metadata?.project as Metadata | undefined; + const brandFile = project?.brand as string | undefined; + + if (brandFile && typeof brandFile === "string") { + return { + isBrandExtension: true, + extensionDir: dir, + brandFileName: brandFile, + }; + } + } catch { + // If we can't read/parse the extension file, continue searching + } + } + } + + return { isBrandExtension: false }; +} + +// Search for a brand extension in the staged directory +// Searches: root, _extensions/*, _extensions/*/* +function findBrandExtension(stagedDir: string): BrandExtensionInfo { + // First check the root directory + const rootCheck = checkForBrandExtension(stagedDir); + if (rootCheck.isBrandExtension) { + return rootCheck; + } + + // Check _extensions directory + const extensionsDir = join(stagedDir, "_extensions"); + if (!existsSync(extensionsDir)) { + return { isBrandExtension: false }; + } + + try { + // Check direct children: _extensions/extension-name/ + for (const entry of Deno.readDirSync(extensionsDir)) { + if (!entry.isDirectory) continue; + + const extPath = join(extensionsDir, entry.name); + const check = checkForBrandExtension(extPath); + if (check.isBrandExtension) { + return check; + } + + // Check nested: _extensions/org/extension-name/ + for (const nested of Deno.readDirSync(extPath)) { + if (!nested.isDirectory) continue; + const nestedPath = join(extPath, nested.name); + const nestedCheck = checkForBrandExtension(nestedPath); + if (nestedCheck.isBrandExtension) { + return nestedCheck; + } + } + } + } catch { + // Directory read error, return not found + } + + return { isBrandExtension: false }; +} + export const useBrandCommand = new Command() .name("brand") .arguments("") @@ -100,8 +183,24 @@ async function useBrand( // Extract and move the template into place const stagedDir = await stageBrand(source, tempContext); + // Check if this is a brand extension + const brandExtInfo = findBrandExtension(stagedDir); + + // Determine the actual source directory and file mapping + const sourceDir = brandExtInfo.isBrandExtension + ? brandExtInfo.extensionDir! + : stagedDir; + // Filter the list to template files - const filesToCopy = templateFiles(stagedDir); + let filesToCopy = templateFiles(sourceDir); + + // For brand extensions, exclude _extension.yml/_extension.yaml + if (brandExtInfo.isBrandExtension) { + filesToCopy = filesToCopy.filter((f) => { + const name = basename(f); + return name !== "_extension.yml" && name !== "_extension.yaml"; + }); + } // Confirm changes to brand directory (skip for dry-run or force) if (!options.dryRun && !options.force) { @@ -125,10 +224,20 @@ async function useBrand( } // Build set of source file paths for comparison + // For brand extensions, we need to account for the brand file rename const sourceFiles = new Set( filesToCopy .filter((f) => !Deno.statSync(f).isDirectory) - .map((f) => relative(stagedDir, f)), + .map((f) => { + const rel = relative(sourceDir, f); + // If this is a brand extension and this is the brand file, it will become _brand.yml + if ( + brandExtInfo.isBrandExtension && rel === brandExtInfo.brandFileName + ) { + return "_brand.yml"; + } + return rel; + }), ); // Find extra files in target that aren't in source @@ -147,13 +256,20 @@ async function useBrand( for (const fileToCopy of filesToCopy) { const isDir = Deno.statSync(fileToCopy).isDirectory; - const rel = relative(stagedDir, fileToCopy); + const rel = relative(sourceDir, fileToCopy); if (isDir) { continue; } + + // For brand extensions, rename the brand file to _brand.yml + let targetRel = rel; + if (brandExtInfo.isBrandExtension && rel === brandExtInfo.brandFileName) { + targetRel = "_brand.yml"; + } + // Compute the paths - const targetPath = join(brandDir, rel); - const displayName = rel; + const targetPath = join(brandDir, targetRel); + const displayName = targetRel; const targetDir = dirname(targetPath); const copyAction = { file: displayName, diff --git a/tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/_extension.yml b/tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/_extension.yml new file mode 100644 index 00000000000..30faf6b6004 --- /dev/null +++ b/tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/_extension.yml @@ -0,0 +1,8 @@ +title: Test Brand Extension +author: Test Author +version: 1.0.0 +quarto-required: ">=1.4.0" +contributes: + metadata: + project: + brand: brand.yml diff --git a/tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/brand.yml b/tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/brand.yml new file mode 100644 index 00000000000..f1d27b165e4 --- /dev/null +++ b/tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/brand.yml @@ -0,0 +1,5 @@ +meta: + name: Test Brand Extension +color: + primary: "#007bff" + secondary: "#6c757d" diff --git a/tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/logo.png b/tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c8ba37dd5c9224af174f37da11521bc5e80c29ca GIT binary patch literal 1862 zcmV-M2f6r(P)xA%C=uJ}w}5&U+~@vO?tKzRMN zq6Of09vK-*XD;ecI$J8@pH3RryU3YgBy6cx=mx_6+dOqZ#pv2YX;p9Z%S$~JihUf~ zQzoU}L(bsWQ}BycQ(0W*7TU#>kBaM5qZnkdO3V50Bd%Y;BIStN9FBnq!(iux^}Tbe z6+)>uYy)9K2)wgvI>dcHtGzZLv`XO#o7zpW6ld+kem$dTt(g9o{ut@^xV$$W|Fo1O z&h>as*YrC*m=CFt$ zRV~tGYpHiN=esbNZZu}voWs4(zGEdc=ALItlEp|cUVNRZ=z^LTk^FNiG$oBigw=T2znGt zbg!oRk<|s)NA&u8ghTI=FJ?m5$3~97FZU}rVo;=HS#`E{KSZ;t0&DW)Gn4feo#kek1aAF)Z=OU zNM0k`K{*6tza3WxLtBt7J$RZJNcxg;5J?)=q1;)@q_R*C%0L2a0f*j-JgnuPk<0!$ zl*uL#s^x?xMuCo$)n*Kx9{;>Oi7*AP$Mp=L8sIG{#9%o@C=Pk67o}$)KRzYh7UTL! zkbQP3*vbLx$G2l0uHLXBN%-~%uHJOA_tirN zzI`gLzM2dg4ZfWdaP>c1oyK#d7%a)>_tpp=Nt#Vtbo&+Z>~2W76AqWuZ@tJ3VvX`G zxcb*U!k7s-30F@iI1@R}&T3qJ+tvzua&N%Z*U@mT`1aMf`X=%r&)x=B=k4uzc5PmK z1+HHBECJSa_;z-|@SUHtboy1N<&W~FOmns)ddvcp=Xe|H1sSi16$9s*2FB+J(m^`% zlbPjrpYqtYe&rS6P>(-s++n@{ZQOBtk$$H`0a=!V!0On()Z*JgO<2T&tEY&yNV?C? z;g>Ll@{!PQog!0M9~-WD`1T37dR0QfB3iUZJxs!c*(;*Ox5xAGx=W%RTz$>Mw`;1Q z%(@1C+&`!A^@$!2zMaWldCvz@hfJOL_M*DD!coM+h|_pBtcBda7?YHLQ+cn}2>NkeI-y;{_ z9>mobKd!!};@h9;f7apZhxgeX`1TZBy$dgYWMg0^U&&qPv%d>he@w!+`*HO%IOyEZ z7UJqB`y=ad^|Be?$YIQ(gC6HUY;f6|0GYkr9(TPB{qZ4Fuj~G2V!yQ*Fy)`g8=sAD zS3jdp;J%MH9-B8lh^xoujbDRr*Yn1oX+t+}{NLe?=bL%s!Oa{0H+kcE2i|z%kMPD9 zLts@7VuQuTNVSqTo|Aav$+f)k`aC<$AVWlP_19yY;vGbFe#ATu%%U|Oa5QIuGtY-s z0Ew;4std<)$OORMT;6ysuD&=adY1FXr}E4D^X$p#f{DEGeSAC9c;gkB zsVgfp%>QjiE6>iZ)#6(OHy$Ik5xYB1 zo!cMcjX&kPUHEpIdE-m;{>b>E!wh`8o;SWI?z5NMQy+aDZ~VLQ?UAf~O~tqC?w<#F zlF9$xD&p;tj)83AaDHLnE-?L*^}|@hJI7;vrode59>AC`!e46 z66TFB>Adl>w)q0y_&4C&XYs}_##+S_-e>;?EfH%Hu z-uUvzdE*nVxZe8NF1zfq%Pza@vdb>J?DE0oKPD?97$k(*6951J07*qoM6N<$g52iX AK>z>% literal 0 HcmV?d00001 diff --git a/tests/smoke/use/brand.test.ts b/tests/smoke/use/brand.test.ts index 766f26ca410..01fbaba0a59 100644 --- a/tests/smoke/use/brand.test.ts +++ b/tests/smoke/use/brand.test.ts @@ -327,18 +327,22 @@ testQuartoCmd( "quarto use brand - nested directory structure" ); -// Scenario 8: Error - no project directory +// Scenario 8: Single-file mode (no _quarto.yml) - should work, using current directory const noProjectDir = join(tempDir, "no-project"); ensureDirSync(noProjectDir); testQuartoCmd( "use", ["brand", join(fixtureDir, "basic-brand"), "--force"], [ - printsMessage({ level: "ERROR", regex: /Could not find project dir/ }), + noErrorsOrWarnings, + // Should create _brand/ in the current directory even without _quarto.yml + folderExists(join(noProjectDir, "_brand")), + fileExists(join(noProjectDir, "_brand", "_brand.yml")), + fileExists(join(noProjectDir, "_brand", "logo.png")), ], { setup: () => { - // No _quarto.yml created - this should cause an error + // No _quarto.yml created - single-file mode should work return Promise.resolve(); }, cwd: () => noProjectDir, @@ -347,7 +351,7 @@ testQuartoCmd( return Promise.resolve(); } }, - "quarto use brand - error on no project" + "quarto use brand - single-file mode (no _quarto.yml)" ); // Scenario 9: Nested directory - overwrite files in subdirectories, remove extra @@ -650,3 +654,88 @@ testQuartoCmd( }, "quarto use brand - deeply nested directories recursively cleaned up" ); + +// Scenario 15: Brand extension - basic installation +// Tests that brand extensions are detected and the brand file is renamed to _brand.yml +const brandExtDir = join(tempDir, "brand-ext"); +ensureDirSync(brandExtDir); +testQuartoCmd( + "use", + ["brand", join(fixtureDir, "brand-extension"), "--force"], + [ + noErrorsOrWarnings, + folderExists(join(brandExtDir, "_brand")), + // brand.yml should be renamed to _brand.yml + fileExists(join(brandExtDir, "_brand", "_brand.yml")), + // logo.png should be copied + fileExists(join(brandExtDir, "_brand", "logo.png")), + // _extension.yml should NOT be copied + { + name: "_extension.yml should not be copied", + verify: () => { + if (existsSync(join(brandExtDir, "_brand", "_extension.yml"))) { + throw new Error("_extension.yml should not be copied from brand extension"); + } + return Promise.resolve(); + } + }, + // Verify the content is correct (from brand.yml, not some other file) + { + name: "_brand.yml should contain brand extension content", + verify: () => { + const content = Deno.readTextFileSync(join(brandExtDir, "_brand", "_brand.yml")); + if (!content.includes("Test Brand Extension")) { + throw new Error("_brand.yml should contain content from brand.yml"); + } + return Promise.resolve(); + } + }, + ], + { + setup: () => { + Deno.writeTextFileSync(join(brandExtDir, "_quarto.yml"), "project:\n type: default\n"); + return Promise.resolve(); + }, + cwd: () => brandExtDir, + teardown: () => { + try { Deno.removeSync(brandExtDir, { recursive: true }); } catch { /* ignore */ } + return Promise.resolve(); + } + }, + "quarto use brand - brand extension installation" +); + +// Scenario 16: Brand extension - dry-run shows correct file names +const brandExtDryRunDir = join(tempDir, "brand-ext-dry-run"); +ensureDirSync(brandExtDryRunDir); +testQuartoCmd( + "use", + ["brand", join(fixtureDir, "brand-extension"), "--dry-run"], + [ + noErrorsOrWarnings, + // Should show _brand.yml (renamed from brand.yml), not brand.yml + filesInSections({ create: ["_brand.yml", "logo.png"] }, true), + // _brand directory should not exist in dry-run mode + { + name: "_brand directory should not exist in dry-run mode", + verify: () => { + if (existsSync(join(brandExtDryRunDir, "_brand"))) { + throw new Error("_brand directory should not exist in dry-run mode"); + } + return Promise.resolve(); + } + } + ], + { + setup: () => { + Deno.writeTextFileSync(join(brandExtDryRunDir, "_quarto.yml"), "project:\n type: default\n"); + return Promise.resolve(); + }, + cwd: () => brandExtDryRunDir, + teardown: () => { + try { Deno.removeSync(brandExtDryRunDir, { recursive: true }); } catch { /* ignore */ } + return Promise.resolve(); + } + }, + "quarto use brand - brand extension dry-run shows renamed file" +); From a251b809feca16754e872ef68dc5dea7fc3cae77 Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Fri, 30 Jan 2026 14:44:41 -0500 Subject: [PATCH 3/4] claude: only copy files explicitly referenced in _brand.yml `quarto use brand` now only copies files that are explicitly referenced in the brand YAML file, rather than copying all files in the source directory. Referenced files include: - The brand file itself (_brand.yml) - Logo paths: logo.small, logo.medium, logo.large (string or light/dark) - Named logo images: logo.images. (string or {path, alt}) - Font files: typography.fonts[*].files[*] when source: file Updates test fixtures to use various logo/font reference patterns: - String form: logo.small: logo.png - Object with alt: logo.images.main: {path: ..., alt: ...} - Light/dark variants: logo.small: {light: ..., dark: ...} - Font string: typography.fonts[*].files: ["path"] - Font object: typography.fonts[*].files: [{path, weight, style}] Adds unreferenced files to fixtures to verify they are NOT copied: - README.md, unused-styles.css, notes.txt, template.html - fonts/unused-italic.woff2, images/extra-icon.png Co-Authored-By: Claude Opus 4.5 --- src/command/use/commands/brand.ts | 136 ++++++++++++++++-- tests/smoke/use-brand/basic-brand/README.md | 3 + tests/smoke/use-brand/basic-brand/_brand.yml | 8 ++ .../basic-brand/fonts/custom-font.woff2 | 1 + .../_extensions/test-org/test-brand/brand.yml | 5 + .../test-org/test-brand/template.html | 1 + .../use-brand/multi-file-brand/_brand.yml | 19 +++ .../multi-file-brand/fonts/brand-bold.woff2 | 1 + .../fonts/brand-regular.woff2 | 1 + .../fonts/unused-italic.woff2 | 1 + .../multi-file-brand/unused-styles.css | 2 + tests/smoke/use-brand/nested-brand/_brand.yml | 4 + .../nested-brand/images/extra-icon.png | 1 + tests/smoke/use-brand/nested-brand/notes.txt | 1 + tests/smoke/use/brand.test.ts | 83 ++++++++++- 15 files changed, 256 insertions(+), 11 deletions(-) create mode 100644 tests/smoke/use-brand/basic-brand/README.md create mode 100644 tests/smoke/use-brand/basic-brand/fonts/custom-font.woff2 create mode 100644 tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/template.html create mode 100644 tests/smoke/use-brand/multi-file-brand/fonts/brand-bold.woff2 create mode 100644 tests/smoke/use-brand/multi-file-brand/fonts/brand-regular.woff2 create mode 100644 tests/smoke/use-brand/multi-file-brand/fonts/unused-italic.woff2 create mode 100644 tests/smoke/use-brand/multi-file-brand/unused-styles.css create mode 100644 tests/smoke/use-brand/nested-brand/images/extra-icon.png create mode 100644 tests/smoke/use-brand/nested-brand/notes.txt diff --git a/src/command/use/commands/brand.ts b/src/command/use/commands/brand.ts index 53bdc4e50d7..4973e889dcd 100644 --- a/src/command/use/commands/brand.ts +++ b/src/command/use/commands/brand.ts @@ -16,7 +16,6 @@ import { TempContext } from "../../../core/temp-types.ts"; import { downloadWithProgress } from "../../../core/download.ts"; import { withSpinner } from "../../../core/console.ts"; import { unzip } from "../../../core/zip.ts"; -import { templateFiles } from "../../../extension/template.ts"; import { Command } from "cliffy/command/mod.ts"; import { initYamlIntelligenceResourcesFromFilesystem } from "../../../core/schema/utils.ts"; import { createTempContext } from "../../../core/temp.ts"; @@ -110,6 +109,108 @@ function findBrandExtension(stagedDir: string): BrandExtensionInfo { return { isBrandExtension: false }; } +// Extract a path string from various formats: +// - string: "path/to/file" +// - object with path: { path: "path/to/file", alt: "..." } +function extractPath(value: unknown): string | undefined { + if (typeof value === "string") { + return value; + } + if (value && typeof value === "object" && "path" in value) { + const pathValue = (value as Record).path; + if (typeof pathValue === "string") { + return pathValue; + } + } + return undefined; +} + +// Check if a path is a local file (not a URL) +function isLocalPath(path: string): boolean { + return !path.startsWith("http://") && !path.startsWith("https://"); +} + +// Extract all referenced file paths from a brand YAML file +function extractBrandFilePaths(brandYamlPath: string): string[] { + const paths: string[] = []; + + try { + const yaml = readYaml(brandYamlPath) as Metadata; + if (!yaml) return paths; + + // Extract logo paths + const logo = yaml.logo as Metadata | undefined; + if (logo) { + // Handle logo.images (named resources) + // Format: logo.images. can be string or { path, alt } + const images = logo.images as Metadata | undefined; + if (images && typeof images === "object") { + for (const value of Object.values(images)) { + const path = extractPath(value); + if (path && isLocalPath(path)) { + paths.push(path); + } + } + } + + // Handle logo.small, logo.medium, logo.large + // Format: string or { light: string, dark: string } + for (const size of ["small", "medium", "large"]) { + const sizeValue = logo[size]; + if (!sizeValue) continue; + + if (typeof sizeValue === "string") { + if (isLocalPath(sizeValue)) { + paths.push(sizeValue); + } + } else if (typeof sizeValue === "object") { + // Handle { light: "...", dark: "..." } + const lightDark = sizeValue as Record; + if ( + typeof lightDark.light === "string" && isLocalPath(lightDark.light) + ) { + paths.push(lightDark.light); + } + if ( + typeof lightDark.dark === "string" && isLocalPath(lightDark.dark) + ) { + paths.push(lightDark.dark); + } + } + } + } + + // Extract typography font file paths + const typography = yaml.typography as Metadata | undefined; + if (typography) { + const fonts = typography.fonts as unknown[] | undefined; + if (Array.isArray(fonts)) { + for (const font of fonts) { + if (!font || typeof font !== "object") continue; + const fontObj = font as Record; + + // Only process fonts with source: "file" + if (fontObj.source !== "file") continue; + + const files = fontObj.files as unknown[] | undefined; + if (Array.isArray(files)) { + for (const file of files) { + const path = extractPath(file); + if (path && isLocalPath(path)) { + paths.push(path); + } + } + } + } + } + } + } catch { + // If we can't read/parse the brand file, return empty list + } + + return paths; +} + export const useBrandCommand = new Command() .name("brand") .arguments("") @@ -191,15 +292,32 @@ async function useBrand( ? brandExtInfo.extensionDir! : stagedDir; - // Filter the list to template files - let filesToCopy = templateFiles(sourceDir); + // Find the brand file + const brandFileName = brandExtInfo.isBrandExtension + ? brandExtInfo.brandFileName! + : existsSync(join(sourceDir, "_brand.yml")) + ? "_brand.yml" + : existsSync(join(sourceDir, "_brand.yaml")) + ? "_brand.yaml" + : undefined; + + if (!brandFileName) { + info("No brand file (_brand.yml or _brand.yaml) found in source"); + return; + } - // For brand extensions, exclude _extension.yml/_extension.yaml - if (brandExtInfo.isBrandExtension) { - filesToCopy = filesToCopy.filter((f) => { - const name = basename(f); - return name !== "_extension.yml" && name !== "_extension.yaml"; - }); + const brandFilePath = join(sourceDir, brandFileName); + + // Extract referenced file paths from the brand YAML + const referencedPaths = extractBrandFilePaths(brandFilePath); + + // Build list of files to copy: brand file + referenced files + const filesToCopy: string[] = [brandFilePath]; + for (const refPath of referencedPaths) { + const fullPath = join(sourceDir, refPath); + if (existsSync(fullPath)) { + filesToCopy.push(fullPath); + } } // Confirm changes to brand directory (skip for dry-run or force) diff --git a/tests/smoke/use-brand/basic-brand/README.md b/tests/smoke/use-brand/basic-brand/README.md new file mode 100644 index 00000000000..7a93756e12c --- /dev/null +++ b/tests/smoke/use-brand/basic-brand/README.md @@ -0,0 +1,3 @@ +# Basic Brand + +This README should not be copied. diff --git a/tests/smoke/use-brand/basic-brand/_brand.yml b/tests/smoke/use-brand/basic-brand/_brand.yml index 52e42199215..52e295ad3a4 100644 --- a/tests/smoke/use-brand/basic-brand/_brand.yml +++ b/tests/smoke/use-brand/basic-brand/_brand.yml @@ -2,3 +2,11 @@ meta: name: Basic Test Brand color: primary: "#007bff" +logo: + small: logo.png +typography: + fonts: + - source: file + family: "Custom Font" + files: + - fonts/custom-font.woff2 diff --git a/tests/smoke/use-brand/basic-brand/fonts/custom-font.woff2 b/tests/smoke/use-brand/basic-brand/fonts/custom-font.woff2 new file mode 100644 index 00000000000..afa17bb7b02 --- /dev/null +++ b/tests/smoke/use-brand/basic-brand/fonts/custom-font.woff2 @@ -0,0 +1 @@ +DUMMY WOFF2 FONT diff --git a/tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/brand.yml b/tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/brand.yml index f1d27b165e4..9a6aff311c3 100644 --- a/tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/brand.yml +++ b/tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/brand.yml @@ -3,3 +3,8 @@ meta: color: primary: "#007bff" secondary: "#6c757d" +logo: + images: + brand: + path: logo.png + alt: "Brand extension logo" diff --git a/tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/template.html b/tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/template.html new file mode 100644 index 00000000000..35fa31f0578 --- /dev/null +++ b/tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/template.html @@ -0,0 +1 @@ +Template file that should not be copied diff --git a/tests/smoke/use-brand/multi-file-brand/_brand.yml b/tests/smoke/use-brand/multi-file-brand/_brand.yml index 11c1107b45f..9a5479db1a4 100644 --- a/tests/smoke/use-brand/multi-file-brand/_brand.yml +++ b/tests/smoke/use-brand/multi-file-brand/_brand.yml @@ -2,3 +2,22 @@ meta: name: Multi-file Test Brand color: primary: "#28a745" +logo: + images: + main: + path: logo.png + alt: "Main logo" + favicon: + path: favicon.png + alt: "Favicon" +typography: + fonts: + - source: file + family: "Brand Font" + files: + - path: fonts/brand-regular.woff2 + weight: 400 + style: normal + - path: fonts/brand-bold.woff2 + weight: 700 + style: normal diff --git a/tests/smoke/use-brand/multi-file-brand/fonts/brand-bold.woff2 b/tests/smoke/use-brand/multi-file-brand/fonts/brand-bold.woff2 new file mode 100644 index 00000000000..b37a14af8a1 --- /dev/null +++ b/tests/smoke/use-brand/multi-file-brand/fonts/brand-bold.woff2 @@ -0,0 +1 @@ +DUMMY BOLD FONT diff --git a/tests/smoke/use-brand/multi-file-brand/fonts/brand-regular.woff2 b/tests/smoke/use-brand/multi-file-brand/fonts/brand-regular.woff2 new file mode 100644 index 00000000000..b6cb50f7527 --- /dev/null +++ b/tests/smoke/use-brand/multi-file-brand/fonts/brand-regular.woff2 @@ -0,0 +1 @@ +DUMMY REGULAR FONT diff --git a/tests/smoke/use-brand/multi-file-brand/fonts/unused-italic.woff2 b/tests/smoke/use-brand/multi-file-brand/fonts/unused-italic.woff2 new file mode 100644 index 00000000000..a1349dc1a9b --- /dev/null +++ b/tests/smoke/use-brand/multi-file-brand/fonts/unused-italic.woff2 @@ -0,0 +1 @@ +DUMMY UNUSED FONT diff --git a/tests/smoke/use-brand/multi-file-brand/unused-styles.css b/tests/smoke/use-brand/multi-file-brand/unused-styles.css new file mode 100644 index 00000000000..9b6ff8e34de --- /dev/null +++ b/tests/smoke/use-brand/multi-file-brand/unused-styles.css @@ -0,0 +1,2 @@ +/* Unreferenced styles */ +.unused { color: red; } diff --git a/tests/smoke/use-brand/nested-brand/_brand.yml b/tests/smoke/use-brand/nested-brand/_brand.yml index 345aa0e4b15..4651b5d5e61 100644 --- a/tests/smoke/use-brand/nested-brand/_brand.yml +++ b/tests/smoke/use-brand/nested-brand/_brand.yml @@ -2,3 +2,7 @@ meta: name: Nested Test Brand color: primary: "#dc3545" +logo: + small: + light: images/logo.png + dark: images/header.png diff --git a/tests/smoke/use-brand/nested-brand/images/extra-icon.png b/tests/smoke/use-brand/nested-brand/images/extra-icon.png new file mode 100644 index 00000000000..52b425a5471 --- /dev/null +++ b/tests/smoke/use-brand/nested-brand/images/extra-icon.png @@ -0,0 +1 @@ +Extra unreferenced image placeholder diff --git a/tests/smoke/use-brand/nested-brand/notes.txt b/tests/smoke/use-brand/nested-brand/notes.txt new file mode 100644 index 00000000000..78b0632209b --- /dev/null +++ b/tests/smoke/use-brand/nested-brand/notes.txt @@ -0,0 +1 @@ +Unreferenced notes file diff --git a/tests/smoke/use/brand.test.ts b/tests/smoke/use/brand.test.ts index 01fbaba0a59..521bbb0812e 100644 --- a/tests/smoke/use/brand.test.ts +++ b/tests/smoke/use/brand.test.ts @@ -74,6 +74,19 @@ testQuartoCmd( folderExists(join(basicDir, "_brand")), fileExists(join(basicDir, "_brand", "_brand.yml")), fileExists(join(basicDir, "_brand", "logo.png")), + // Font file referenced in typography.fonts should be copied + folderExists(join(basicDir, "_brand", "fonts")), + fileExists(join(basicDir, "_brand", "fonts", "custom-font.woff2")), + // README.md is NOT referenced in _brand.yml - should NOT be copied + { + name: "README.md should not be copied (unreferenced)", + verify: () => { + if (existsSync(join(basicDir, "_brand", "README.md"))) { + throw new Error("README.md should not be copied - it is not referenced in _brand.yml"); + } + return Promise.resolve(); + } + }, ], { setup: () => { @@ -98,7 +111,7 @@ testQuartoCmd( [ noErrorsOrWarnings, printsMessage({ level: "INFO", regex: /Would create directory/ }), - filesInSections({ create: ["_brand.yml", "logo.png"] }, true), + filesInSections({ create: ["_brand.yml", "logo.png", "fonts/custom-font.woff2"] }, true), { name: "_brand directory should not exist in dry-run mode", verify: () => { @@ -108,7 +121,19 @@ testQuartoCmd( } return Promise.resolve(); } - } + }, + // README.md should NOT appear in dry-run output (unreferenced) + { + name: "README.md should not be listed in dry-run output (unreferenced)", + verify: (outputs: ExecuteOutput[]) => { + for (const output of outputs) { + if (output.msg.includes("README.md")) { + throw new Error("README.md should not appear in dry-run output - it is not referenced in _brand.yml"); + } + } + return Promise.resolve(); + } + }, ], { setup: () => { @@ -284,6 +309,30 @@ testQuartoCmd( fileExists(join(multiFileDir, "_brand", "_brand.yml")), fileExists(join(multiFileDir, "_brand", "logo.png")), fileExists(join(multiFileDir, "_brand", "favicon.png")), + // Font files referenced in typography.fonts should be copied + folderExists(join(multiFileDir, "_brand", "fonts")), + fileExists(join(multiFileDir, "_brand", "fonts", "brand-regular.woff2")), + fileExists(join(multiFileDir, "_brand", "fonts", "brand-bold.woff2")), + // unused-styles.css is NOT referenced in _brand.yml - should NOT be copied + { + name: "unused-styles.css should not be copied (unreferenced)", + verify: () => { + if (existsSync(join(multiFileDir, "_brand", "unused-styles.css"))) { + throw new Error("unused-styles.css should not be copied - it is not referenced in _brand.yml"); + } + return Promise.resolve(); + } + }, + // fonts/unused-italic.woff2 is NOT referenced in _brand.yml - should NOT be copied + { + name: "fonts/unused-italic.woff2 should not be copied (unreferenced)", + verify: () => { + if (existsSync(join(multiFileDir, "_brand", "fonts", "unused-italic.woff2"))) { + throw new Error("fonts/unused-italic.woff2 should not be copied - it is not referenced in _brand.yml"); + } + return Promise.resolve(); + } + }, ], { setup: () => { @@ -312,6 +361,26 @@ testQuartoCmd( folderExists(join(nestedDir, "_brand", "images")), fileExists(join(nestedDir, "_brand", "images", "logo.png")), fileExists(join(nestedDir, "_brand", "images", "header.png")), + // notes.txt is NOT referenced in _brand.yml - should NOT be copied + { + name: "notes.txt should not be copied (unreferenced)", + verify: () => { + if (existsSync(join(nestedDir, "_brand", "notes.txt"))) { + throw new Error("notes.txt should not be copied - it is not referenced in _brand.yml"); + } + return Promise.resolve(); + } + }, + // images/extra-icon.png is NOT referenced in _brand.yml - should NOT be copied + { + name: "images/extra-icon.png should not be copied (unreferenced)", + verify: () => { + if (existsSync(join(nestedDir, "_brand", "images", "extra-icon.png"))) { + throw new Error("images/extra-icon.png should not be copied - it is not referenced in _brand.yml"); + } + return Promise.resolve(); + } + }, ], { setup: () => { @@ -690,6 +759,16 @@ testQuartoCmd( return Promise.resolve(); } }, + // template.html is NOT referenced in brand.yml - should NOT be copied + { + name: "template.html should not be copied (unreferenced)", + verify: () => { + if (existsSync(join(brandExtDir, "_brand", "template.html"))) { + throw new Error("template.html should not be copied - it is not referenced in brand.yml"); + } + return Promise.resolve(); + } + }, ], { setup: () => { From eb5674c163163993600776d9b3d316c606247ac9 Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Fri, 30 Jan 2026 15:07:19 -0500 Subject: [PATCH 4/4] claude: resolve brand file paths relative to brand file directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a brand extension specifies a brand file in a subdirectory (e.g., contributes.metadata.project.brand: subdir/brand.yml), referenced files in that brand.yml are now correctly resolved relative to the brand file's directory, not the extension root. For example, if subdir/brand.yml references: - logo.png → looks for subdir/logo.png - images/nested-logo.png → looks for subdir/images/nested-logo.png The target paths in _brand/ preserve the relative structure from the brand file's perspective: - subdir/logo.png → _brand/logo.png - subdir/images/nested-logo.png → _brand/images/nested-logo.png Adds test fixture and scenario 17 for brand extensions with subdirectory brand files including nested referenced paths. Co-Authored-By: Claude Opus 4.5 --- src/command/use/commands/brand.ts | 27 ++++++----- .../test-org/test-brand/_extension.yml | 8 ++++ .../test-org/test-brand/subdir/brand.yml | 7 +++ .../test-brand/subdir/images/nested-logo.png | 1 + .../test-org/test-brand/subdir/logo.png | 1 + tests/smoke/use/brand.test.ts | 46 +++++++++++++++++++ 6 files changed, 78 insertions(+), 12 deletions(-) create mode 100644 tests/smoke/use-brand/brand-extension-subdir/_extensions/test-org/test-brand/_extension.yml create mode 100644 tests/smoke/use-brand/brand-extension-subdir/_extensions/test-org/test-brand/subdir/brand.yml create mode 100644 tests/smoke/use-brand/brand-extension-subdir/_extensions/test-org/test-brand/subdir/images/nested-logo.png create mode 100644 tests/smoke/use-brand/brand-extension-subdir/_extensions/test-org/test-brand/subdir/logo.png diff --git a/src/command/use/commands/brand.ts b/src/command/use/commands/brand.ts index 4973e889dcd..fa521e6a219 100644 --- a/src/command/use/commands/brand.ts +++ b/src/command/use/commands/brand.ts @@ -307,14 +307,17 @@ async function useBrand( } const brandFilePath = join(sourceDir, brandFileName); + // Get the directory containing the brand file (for resolving relative paths) + const brandFileDir = dirname(brandFilePath); // Extract referenced file paths from the brand YAML const referencedPaths = extractBrandFilePaths(brandFilePath); // Build list of files to copy: brand file + referenced files + // Referenced paths are relative to the brand file's directory const filesToCopy: string[] = [brandFilePath]; for (const refPath of referencedPaths) { - const fullPath = join(sourceDir, refPath); + const fullPath = join(brandFileDir, refPath); if (existsSync(fullPath)) { filesToCopy.push(fullPath); } @@ -342,19 +345,17 @@ async function useBrand( } // Build set of source file paths for comparison - // For brand extensions, we need to account for the brand file rename + // Paths are relative to the brand file's directory + // For brand extensions, the brand file is renamed to _brand.yml const sourceFiles = new Set( filesToCopy .filter((f) => !Deno.statSync(f).isDirectory) .map((f) => { - const rel = relative(sourceDir, f); - // If this is a brand extension and this is the brand file, it will become _brand.yml - if ( - brandExtInfo.isBrandExtension && rel === brandExtInfo.brandFileName - ) { + // If this is the brand file, it will become _brand.yml + if (f === brandFilePath) { return "_brand.yml"; } - return rel; + return relative(brandFileDir, f); }), ); @@ -374,15 +375,17 @@ async function useBrand( for (const fileToCopy of filesToCopy) { const isDir = Deno.statSync(fileToCopy).isDirectory; - const rel = relative(sourceDir, fileToCopy); if (isDir) { continue; } - // For brand extensions, rename the brand file to _brand.yml - let targetRel = rel; - if (brandExtInfo.isBrandExtension && rel === brandExtInfo.brandFileName) { + // Compute target path relative to brand file's directory + // The brand file itself is renamed to _brand.yml + let targetRel: string; + if (fileToCopy === brandFilePath) { targetRel = "_brand.yml"; + } else { + targetRel = relative(brandFileDir, fileToCopy); } // Compute the paths diff --git a/tests/smoke/use-brand/brand-extension-subdir/_extensions/test-org/test-brand/_extension.yml b/tests/smoke/use-brand/brand-extension-subdir/_extensions/test-org/test-brand/_extension.yml new file mode 100644 index 00000000000..27ce469ea66 --- /dev/null +++ b/tests/smoke/use-brand/brand-extension-subdir/_extensions/test-org/test-brand/_extension.yml @@ -0,0 +1,8 @@ +title: Test Brand Extension with Subdir +author: Test Author +version: 1.0.0 +quarto-required: ">=1.4.0" +contributes: + metadata: + project: + brand: subdir/brand.yml diff --git a/tests/smoke/use-brand/brand-extension-subdir/_extensions/test-org/test-brand/subdir/brand.yml b/tests/smoke/use-brand/brand-extension-subdir/_extensions/test-org/test-brand/subdir/brand.yml new file mode 100644 index 00000000000..b1051680f92 --- /dev/null +++ b/tests/smoke/use-brand/brand-extension-subdir/_extensions/test-org/test-brand/subdir/brand.yml @@ -0,0 +1,7 @@ +meta: + name: Test Brand Extension Subdir +color: + primary: "#ff5733" +logo: + small: logo.png + medium: images/nested-logo.png diff --git a/tests/smoke/use-brand/brand-extension-subdir/_extensions/test-org/test-brand/subdir/images/nested-logo.png b/tests/smoke/use-brand/brand-extension-subdir/_extensions/test-org/test-brand/subdir/images/nested-logo.png new file mode 100644 index 00000000000..a15bb813d9c --- /dev/null +++ b/tests/smoke/use-brand/brand-extension-subdir/_extensions/test-org/test-brand/subdir/images/nested-logo.png @@ -0,0 +1 @@ +NESTED LOGO PNG diff --git a/tests/smoke/use-brand/brand-extension-subdir/_extensions/test-org/test-brand/subdir/logo.png b/tests/smoke/use-brand/brand-extension-subdir/_extensions/test-org/test-brand/subdir/logo.png new file mode 100644 index 00000000000..0f5879fc5c8 --- /dev/null +++ b/tests/smoke/use-brand/brand-extension-subdir/_extensions/test-org/test-brand/subdir/logo.png @@ -0,0 +1 @@ +SUBDIR LOGO PNG diff --git a/tests/smoke/use/brand.test.ts b/tests/smoke/use/brand.test.ts index 521bbb0812e..1e21348674d 100644 --- a/tests/smoke/use/brand.test.ts +++ b/tests/smoke/use/brand.test.ts @@ -818,3 +818,49 @@ testQuartoCmd( }, "quarto use brand - brand extension dry-run shows renamed file" ); + +// Scenario 17: Brand extension with brand file in subdirectory +// Tests that brand path in _extension.yml can be a relative path (e.g., subdir/brand.yml) +// and that referenced files are resolved relative to the brand file's directory +const brandExtSubdirDir = join(tempDir, "brand-ext-subdir"); +ensureDirSync(brandExtSubdirDir); +testQuartoCmd( + "use", + ["brand", join(fixtureDir, "brand-extension-subdir"), "--force"], + [ + noErrorsOrWarnings, + folderExists(join(brandExtSubdirDir, "_brand")), + // subdir/brand.yml should be renamed to _brand.yml + fileExists(join(brandExtSubdirDir, "_brand", "_brand.yml")), + // logo.png (referenced as logo.png in subdir/brand.yml) should be copied + // The logo is at subdir/logo.png relative to extension dir + fileExists(join(brandExtSubdirDir, "_brand", "logo.png")), + // images/nested-logo.png (referenced as images/nested-logo.png in subdir/brand.yml) + // should be copied to _brand/images/nested-logo.png + folderExists(join(brandExtSubdirDir, "_brand", "images")), + fileExists(join(brandExtSubdirDir, "_brand", "images", "nested-logo.png")), + // Verify the content is correct + { + name: "_brand.yml should contain subdir brand content", + verify: () => { + const content = Deno.readTextFileSync(join(brandExtSubdirDir, "_brand", "_brand.yml")); + if (!content.includes("Test Brand Extension Subdir")) { + throw new Error("_brand.yml should contain content from subdir/brand.yml"); + } + return Promise.resolve(); + } + }, + ], + { + setup: () => { + Deno.writeTextFileSync(join(brandExtSubdirDir, "_quarto.yml"), "project:\n type: default\n"); + return Promise.resolve(); + }, + cwd: () => brandExtSubdirDir, + teardown: () => { + try { Deno.removeSync(brandExtSubdirDir, { recursive: true }); } catch { /* ignore */ } + return Promise.resolve(); + } + }, + "quarto use brand - brand extension with subdir brand file" +);