diff --git a/src/command/use/commands/brand.ts b/src/command/use/commands/brand.ts index 6dac4b0dd07..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, @@ -387,10 +503,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/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 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.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" +);