diff --git a/src/command/use/commands/brand.ts b/src/command/use/commands/brand.ts index 6dac4b0dd07..fa521e6a219 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"; @@ -24,9 +23,194 @@ 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 }; +} + +// 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("") @@ -100,8 +284,44 @@ async function useBrand( // Extract and move the template into place const stagedDir = await stageBrand(source, tempContext); - // Filter the list to template files - const filesToCopy = templateFiles(stagedDir); + // 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; + + // 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; + } + + 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(brandFileDir, refPath); + if (existsSync(fullPath)) { + filesToCopy.push(fullPath); + } + } // Confirm changes to brand directory (skip for dry-run or force) if (!options.dryRun && !options.force) { @@ -125,10 +345,18 @@ async function useBrand( } // Build set of source file paths for comparison + // 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) => relative(stagedDir, f)), + .map((f) => { + // If this is the brand file, it will become _brand.yml + if (f === brandFilePath) { + return "_brand.yml"; + } + return relative(brandFileDir, f); + }), ); // Find extra files in target that aren't in source @@ -147,13 +375,22 @@ async function useBrand( for (const fileToCopy of filesToCopy) { const isDir = Deno.statSync(fileToCopy).isDirectory; - const rel = relative(stagedDir, fileToCopy); if (isDir) { continue; } + + // 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 - const targetPath = join(brandDir, rel); - const displayName = rel; + const targetPath = join(brandDir, targetRel); + const displayName = targetRel; const targetDir = dirname(targetPath); const copyAction = { file: displayName, @@ -387,10 +624,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/`); 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-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/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..9a6aff311c3 --- /dev/null +++ b/tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/brand.yml @@ -0,0 +1,10 @@ +meta: + name: Test Brand Extension +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/logo.png b/tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/logo.png new file mode 100644 index 00000000000..c8ba37dd5c9 Binary files /dev/null and b/tests/smoke/use-brand/brand-extension/_extensions/test-org/test-brand/logo.png differ 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 766f26ca410..1e21348674d 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: () => { @@ -327,18 +396,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 +420,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 +723,144 @@ 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(); + } + }, + // 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: () => { + 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" +); + +// 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" +);