Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 125 additions & 9 deletions src/command/use/commands/brand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<target:string>")
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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/`);
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
meta:
name: Test Brand Extension
color:
primary: "#007bff"
secondary: "#6c757d"
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
97 changes: 93 additions & 4 deletions tests/smoke/use/brand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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"
);
Loading