Skip to content

Commit 67cdc52

Browse files
Merge pull request #13880 from quarto-dev/feature/use-brand-compatibility
quarto use brand improvements
2 parents 62db6ad + eb5674c commit 67cdc52

File tree

21 files changed

+546
-17
lines changed

21 files changed

+546
-17
lines changed

src/command/use/commands/brand.ts

Lines changed: 248 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,201 @@ import { TempContext } from "../../../core/temp-types.ts";
1616
import { downloadWithProgress } from "../../../core/download.ts";
1717
import { withSpinner } from "../../../core/console.ts";
1818
import { unzip } from "../../../core/zip.ts";
19-
import { templateFiles } from "../../../extension/template.ts";
2019
import { Command } from "cliffy/command/mod.ts";
2120
import { initYamlIntelligenceResourcesFromFilesystem } from "../../../core/schema/utils.ts";
2221
import { createTempContext } from "../../../core/temp.ts";
2322
import { InternalError } from "../../../core/lib/error.ts";
2423
import { notebookContext } from "../../../render/notebook/notebook-context.ts";
2524
import { projectContext } from "../../../project/project-context.ts";
2625
import { afterConfirm } from "../../../tools/tools-console.ts";
26+
import { readYaml } from "../../../core/yaml.ts";
27+
import { Metadata } from "../../../config/types.ts";
2728

2829
const kRootTemplateName = "template.qmd";
2930

31+
// Brand extension detection result
32+
interface BrandExtensionInfo {
33+
isBrandExtension: boolean;
34+
extensionDir?: string; // Directory containing the brand extension
35+
brandFileName?: string; // The original brand file name (e.g., "brand.yml")
36+
}
37+
38+
// Check if a directory contains a brand extension
39+
function checkForBrandExtension(dir: string): BrandExtensionInfo {
40+
const extensionFiles = ["_extension.yml", "_extension.yaml"];
41+
42+
for (const file of extensionFiles) {
43+
const path = join(dir, file);
44+
if (existsSync(path)) {
45+
try {
46+
const yaml = readYaml(path) as Metadata;
47+
// Check for contributes.metadata.project.brand
48+
const contributes = yaml?.contributes as Metadata | undefined;
49+
const metadata = contributes?.metadata as Metadata | undefined;
50+
const project = metadata?.project as Metadata | undefined;
51+
const brandFile = project?.brand as string | undefined;
52+
53+
if (brandFile && typeof brandFile === "string") {
54+
return {
55+
isBrandExtension: true,
56+
extensionDir: dir,
57+
brandFileName: brandFile,
58+
};
59+
}
60+
} catch {
61+
// If we can't read/parse the extension file, continue searching
62+
}
63+
}
64+
}
65+
66+
return { isBrandExtension: false };
67+
}
68+
69+
// Search for a brand extension in the staged directory
70+
// Searches: root, _extensions/*, _extensions/*/*
71+
function findBrandExtension(stagedDir: string): BrandExtensionInfo {
72+
// First check the root directory
73+
const rootCheck = checkForBrandExtension(stagedDir);
74+
if (rootCheck.isBrandExtension) {
75+
return rootCheck;
76+
}
77+
78+
// Check _extensions directory
79+
const extensionsDir = join(stagedDir, "_extensions");
80+
if (!existsSync(extensionsDir)) {
81+
return { isBrandExtension: false };
82+
}
83+
84+
try {
85+
// Check direct children: _extensions/extension-name/
86+
for (const entry of Deno.readDirSync(extensionsDir)) {
87+
if (!entry.isDirectory) continue;
88+
89+
const extPath = join(extensionsDir, entry.name);
90+
const check = checkForBrandExtension(extPath);
91+
if (check.isBrandExtension) {
92+
return check;
93+
}
94+
95+
// Check nested: _extensions/org/extension-name/
96+
for (const nested of Deno.readDirSync(extPath)) {
97+
if (!nested.isDirectory) continue;
98+
const nestedPath = join(extPath, nested.name);
99+
const nestedCheck = checkForBrandExtension(nestedPath);
100+
if (nestedCheck.isBrandExtension) {
101+
return nestedCheck;
102+
}
103+
}
104+
}
105+
} catch {
106+
// Directory read error, return not found
107+
}
108+
109+
return { isBrandExtension: false };
110+
}
111+
112+
// Extract a path string from various formats:
113+
// - string: "path/to/file"
114+
// - object with path: { path: "path/to/file", alt: "..." }
115+
function extractPath(value: unknown): string | undefined {
116+
if (typeof value === "string") {
117+
return value;
118+
}
119+
if (value && typeof value === "object" && "path" in value) {
120+
const pathValue = (value as Record<string, unknown>).path;
121+
if (typeof pathValue === "string") {
122+
return pathValue;
123+
}
124+
}
125+
return undefined;
126+
}
127+
128+
// Check if a path is a local file (not a URL)
129+
function isLocalPath(path: string): boolean {
130+
return !path.startsWith("http://") && !path.startsWith("https://");
131+
}
132+
133+
// Extract all referenced file paths from a brand YAML file
134+
function extractBrandFilePaths(brandYamlPath: string): string[] {
135+
const paths: string[] = [];
136+
137+
try {
138+
const yaml = readYaml(brandYamlPath) as Metadata;
139+
if (!yaml) return paths;
140+
141+
// Extract logo paths
142+
const logo = yaml.logo as Metadata | undefined;
143+
if (logo) {
144+
// Handle logo.images (named resources)
145+
// Format: logo.images.<name> can be string or { path, alt }
146+
const images = logo.images as Metadata | undefined;
147+
if (images && typeof images === "object") {
148+
for (const value of Object.values(images)) {
149+
const path = extractPath(value);
150+
if (path && isLocalPath(path)) {
151+
paths.push(path);
152+
}
153+
}
154+
}
155+
156+
// Handle logo.small, logo.medium, logo.large
157+
// Format: string or { light: string, dark: string }
158+
for (const size of ["small", "medium", "large"]) {
159+
const sizeValue = logo[size];
160+
if (!sizeValue) continue;
161+
162+
if (typeof sizeValue === "string") {
163+
if (isLocalPath(sizeValue)) {
164+
paths.push(sizeValue);
165+
}
166+
} else if (typeof sizeValue === "object") {
167+
// Handle { light: "...", dark: "..." }
168+
const lightDark = sizeValue as Record<string, unknown>;
169+
if (
170+
typeof lightDark.light === "string" && isLocalPath(lightDark.light)
171+
) {
172+
paths.push(lightDark.light);
173+
}
174+
if (
175+
typeof lightDark.dark === "string" && isLocalPath(lightDark.dark)
176+
) {
177+
paths.push(lightDark.dark);
178+
}
179+
}
180+
}
181+
}
182+
183+
// Extract typography font file paths
184+
const typography = yaml.typography as Metadata | undefined;
185+
if (typography) {
186+
const fonts = typography.fonts as unknown[] | undefined;
187+
if (Array.isArray(fonts)) {
188+
for (const font of fonts) {
189+
if (!font || typeof font !== "object") continue;
190+
const fontObj = font as Record<string, unknown>;
191+
192+
// Only process fonts with source: "file"
193+
if (fontObj.source !== "file") continue;
194+
195+
const files = fontObj.files as unknown[] | undefined;
196+
if (Array.isArray(files)) {
197+
for (const file of files) {
198+
const path = extractPath(file);
199+
if (path && isLocalPath(path)) {
200+
paths.push(path);
201+
}
202+
}
203+
}
204+
}
205+
}
206+
}
207+
} catch {
208+
// If we can't read/parse the brand file, return empty list
209+
}
210+
211+
return paths;
212+
}
213+
30214
export const useBrandCommand = new Command()
31215
.name("brand")
32216
.arguments("<target:string>")
@@ -100,8 +284,44 @@ async function useBrand(
100284
// Extract and move the template into place
101285
const stagedDir = await stageBrand(source, tempContext);
102286

103-
// Filter the list to template files
104-
const filesToCopy = templateFiles(stagedDir);
287+
// Check if this is a brand extension
288+
const brandExtInfo = findBrandExtension(stagedDir);
289+
290+
// Determine the actual source directory and file mapping
291+
const sourceDir = brandExtInfo.isBrandExtension
292+
? brandExtInfo.extensionDir!
293+
: stagedDir;
294+
295+
// Find the brand file
296+
const brandFileName = brandExtInfo.isBrandExtension
297+
? brandExtInfo.brandFileName!
298+
: existsSync(join(sourceDir, "_brand.yml"))
299+
? "_brand.yml"
300+
: existsSync(join(sourceDir, "_brand.yaml"))
301+
? "_brand.yaml"
302+
: undefined;
303+
304+
if (!brandFileName) {
305+
info("No brand file (_brand.yml or _brand.yaml) found in source");
306+
return;
307+
}
308+
309+
const brandFilePath = join(sourceDir, brandFileName);
310+
// Get the directory containing the brand file (for resolving relative paths)
311+
const brandFileDir = dirname(brandFilePath);
312+
313+
// Extract referenced file paths from the brand YAML
314+
const referencedPaths = extractBrandFilePaths(brandFilePath);
315+
316+
// Build list of files to copy: brand file + referenced files
317+
// Referenced paths are relative to the brand file's directory
318+
const filesToCopy: string[] = [brandFilePath];
319+
for (const refPath of referencedPaths) {
320+
const fullPath = join(brandFileDir, refPath);
321+
if (existsSync(fullPath)) {
322+
filesToCopy.push(fullPath);
323+
}
324+
}
105325

106326
// Confirm changes to brand directory (skip for dry-run or force)
107327
if (!options.dryRun && !options.force) {
@@ -125,10 +345,18 @@ async function useBrand(
125345
}
126346

127347
// Build set of source file paths for comparison
348+
// Paths are relative to the brand file's directory
349+
// For brand extensions, the brand file is renamed to _brand.yml
128350
const sourceFiles = new Set(
129351
filesToCopy
130352
.filter((f) => !Deno.statSync(f).isDirectory)
131-
.map((f) => relative(stagedDir, f)),
353+
.map((f) => {
354+
// If this is the brand file, it will become _brand.yml
355+
if (f === brandFilePath) {
356+
return "_brand.yml";
357+
}
358+
return relative(brandFileDir, f);
359+
}),
132360
);
133361

134362
// Find extra files in target that aren't in source
@@ -147,13 +375,22 @@ async function useBrand(
147375

148376
for (const fileToCopy of filesToCopy) {
149377
const isDir = Deno.statSync(fileToCopy).isDirectory;
150-
const rel = relative(stagedDir, fileToCopy);
151378
if (isDir) {
152379
continue;
153380
}
381+
382+
// Compute target path relative to brand file's directory
383+
// The brand file itself is renamed to _brand.yml
384+
let targetRel: string;
385+
if (fileToCopy === brandFilePath) {
386+
targetRel = "_brand.yml";
387+
} else {
388+
targetRel = relative(brandFileDir, fileToCopy);
389+
}
390+
154391
// Compute the paths
155-
const targetPath = join(brandDir, rel);
156-
const displayName = rel;
392+
const targetPath = join(brandDir, targetRel);
393+
const displayName = targetRel;
157394
const targetDir = dirname(targetPath);
158395
const copyAction = {
159396
file: displayName,
@@ -387,10 +624,10 @@ async function ensureBrandDirectory(force: boolean, dryRun: boolean) {
387624
const currentDir = Deno.cwd();
388625
const nbContext = notebookContext();
389626
const project = await projectContext(currentDir, nbContext);
390-
if (!project) {
391-
throw new Error(`Could not find project dir for ${currentDir}`);
392-
}
393-
const brandDir = join(project.dir, "_brand");
627+
// Use project directory if available, otherwise fall back to current directory
628+
// (single-file mode without _quarto.yml)
629+
const baseDir = project?.dir ?? currentDir;
630+
const brandDir = join(baseDir, "_brand");
394631
if (!existsSync(brandDir)) {
395632
if (dryRun) {
396633
info(` Would create directory: _brand/`);
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Basic Brand
2+
3+
This README should not be copied.

tests/smoke/use-brand/basic-brand/_brand.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,11 @@ meta:
22
name: Basic Test Brand
33
color:
44
primary: "#007bff"
5+
logo:
6+
small: logo.png
7+
typography:
8+
fonts:
9+
- source: file
10+
family: "Custom Font"
11+
files:
12+
- fonts/custom-font.woff2
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DUMMY WOFF2 FONT
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
title: Test Brand Extension with Subdir
2+
author: Test Author
3+
version: 1.0.0
4+
quarto-required: ">=1.4.0"
5+
contributes:
6+
metadata:
7+
project:
8+
brand: subdir/brand.yml
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
meta:
2+
name: Test Brand Extension Subdir
3+
color:
4+
primary: "#ff5733"
5+
logo:
6+
small: logo.png
7+
medium: images/nested-logo.png
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
title: Test Brand Extension
2+
author: Test Author
3+
version: 1.0.0
4+
quarto-required: ">=1.4.0"
5+
contributes:
6+
metadata:
7+
project:
8+
brand: brand.yml
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
meta:
2+
name: Test Brand Extension
3+
color:
4+
primary: "#007bff"
5+
secondary: "#6c757d"
6+
logo:
7+
images:
8+
brand:
9+
path: logo.png
10+
alt: "Brand extension logo"

0 commit comments

Comments
 (0)