Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/command/use/commands/brand.ts
6442 views
1
/*
2
* brand.ts
3
*
4
* Copyright (C) 2021-2025 Posit Software, PBC
5
*/
6
7
import {
8
ExtensionSource,
9
extensionSource,
10
} from "../../../extension/extension-host.ts";
11
import { info } from "../../../deno_ral/log.ts";
12
import { Confirm } from "cliffy/prompt/mod.ts";
13
import { basename, dirname, join, relative } from "../../../deno_ral/path.ts";
14
import { ensureDir, ensureDirSync, existsSync } from "../../../deno_ral/fs.ts";
15
import { TempContext } from "../../../core/temp-types.ts";
16
import { downloadWithProgress } from "../../../core/download.ts";
17
import { withSpinner } from "../../../core/console.ts";
18
import { unzip } from "../../../core/zip.ts";
19
import { Command } from "cliffy/command/mod.ts";
20
import { initYamlIntelligenceResourcesFromFilesystem } from "../../../core/schema/utils.ts";
21
import { createTempContext } from "../../../core/temp.ts";
22
import { InternalError } from "../../../core/lib/error.ts";
23
import { notebookContext } from "../../../render/notebook/notebook-context.ts";
24
import { projectContext } from "../../../project/project-context.ts";
25
import { afterConfirm } from "../../../tools/tools-console.ts";
26
import { readYaml } from "../../../core/yaml.ts";
27
import { Metadata } from "../../../config/types.ts";
28
29
const kRootTemplateName = "template.qmd";
30
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
214
export const useBrandCommand = new Command()
215
.name("brand")
216
.arguments("<target:string>")
217
.description(
218
"Use a brand for this project.",
219
)
220
.option(
221
"--force",
222
"Skip all prompts and confirmations",
223
)
224
.option(
225
"--dry-run",
226
"Show what would happen without making changes",
227
)
228
.example(
229
"Use a brand from Github",
230
"quarto use brand <gh-org>/<gh-repo>",
231
)
232
.action(
233
async (
234
options: { force?: boolean; dryRun?: boolean },
235
target: string,
236
) => {
237
if (options.force && options.dryRun) {
238
throw new Error("Cannot use --force and --dry-run together");
239
}
240
await initYamlIntelligenceResourcesFromFilesystem();
241
const temp = createTempContext();
242
try {
243
await useBrand(options, target, temp);
244
} finally {
245
temp.cleanup();
246
}
247
},
248
);
249
250
async function useBrand(
251
options: { force?: boolean; dryRun?: boolean },
252
target: string,
253
tempContext: TempContext,
254
) {
255
// Print header for dry-run
256
if (options.dryRun) {
257
info("\nDry run - no changes will be made.");
258
}
259
260
// Resolve brand host and trust
261
const source = await extensionSource(target);
262
// Is this source valid?
263
if (!source) {
264
info(
265
`Brand not found in local or remote sources`,
266
);
267
return;
268
}
269
270
// Check trust (skip for dry-run or force)
271
if (!options.dryRun && !options.force) {
272
const trusted = await isTrusted(source);
273
if (!trusted) {
274
return;
275
}
276
}
277
278
// Resolve brand directory
279
const brandDir = await ensureBrandDirectory(
280
options.force === true,
281
options.dryRun === true,
282
);
283
284
// Extract and move the template into place
285
const stagedDir = await stageBrand(source, tempContext);
286
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
}
325
326
// Confirm changes to brand directory (skip for dry-run or force)
327
if (!options.dryRun && !options.force) {
328
const filename = (typeof (source.resolvedTarget) === "string"
329
? source.resolvedTarget
330
: source.resolvedFile) || "brand.zip";
331
332
const allowUse = await Confirm.prompt({
333
message: `Proceed with using brand ${filename}?`,
334
default: true,
335
});
336
if (!allowUse) {
337
return;
338
}
339
}
340
341
if (!options.dryRun) {
342
info(
343
`\nPreparing brand files...`,
344
);
345
}
346
347
// 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
350
const sourceFiles = new Set(
351
filesToCopy
352
.filter((f) => !Deno.statSync(f).isDirectory)
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
}),
360
);
361
362
// Find extra files in target that aren't in source
363
const extraFiles = findExtraFiles(brandDir, sourceFiles);
364
365
// Track files by action type
366
const wouldOverwrite: string[] = [];
367
const wouldCreate: string[] = [];
368
const wouldRemove: string[] = [];
369
const copyActions: Array<{
370
file: string;
371
action: "create" | "overwrite";
372
copy: () => Promise<void>;
373
}> = [];
374
let removed: string[] = [];
375
376
for (const fileToCopy of filesToCopy) {
377
const isDir = Deno.statSync(fileToCopy).isDirectory;
378
if (isDir) {
379
continue;
380
}
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
391
// Compute the paths
392
const targetPath = join(brandDir, targetRel);
393
const displayName = targetRel;
394
const targetDir = dirname(targetPath);
395
const copyAction = {
396
file: displayName,
397
copy: async () => {
398
// Ensure the directory exists
399
await ensureDir(targetDir);
400
401
// Copy the file into place
402
await Deno.copyFile(fileToCopy, targetPath);
403
},
404
};
405
406
if (existsSync(targetPath)) {
407
// File exists - will be overwritten
408
if (options.dryRun) {
409
wouldOverwrite.push(displayName);
410
} else if (!options.force) {
411
// Prompt for overwrite
412
const proceed = await Confirm.prompt({
413
message: `Overwrite file ${displayName}?`,
414
default: true,
415
});
416
if (proceed) {
417
copyActions.push({ ...copyAction, action: "overwrite" });
418
} else {
419
throw new Error(
420
`The file ${displayName} already exists and would be overwritten by this action.`,
421
);
422
}
423
} else {
424
// Force mode - overwrite without prompting
425
copyActions.push({ ...copyAction, action: "overwrite" });
426
}
427
} else {
428
// File doesn't exist - will be created
429
if (options.dryRun) {
430
wouldCreate.push(displayName);
431
} else {
432
copyActions.push({ ...copyAction, action: "create" });
433
}
434
}
435
}
436
437
// Output dry-run summary and return
438
if (options.dryRun) {
439
if (wouldOverwrite.length > 0) {
440
info(`\nWould overwrite:`);
441
for (const file of wouldOverwrite) {
442
info(` - ${file}`);
443
}
444
}
445
if (wouldCreate.length > 0) {
446
info(`\nWould create:`);
447
for (const file of wouldCreate) {
448
info(` - ${file}`);
449
}
450
}
451
if (extraFiles.length > 0) {
452
info(`\nWould remove:`);
453
for (const file of extraFiles) {
454
info(` - ${file}`);
455
}
456
}
457
return;
458
}
459
460
// Copy the files
461
if (copyActions.length > 0) {
462
await withSpinner({ message: "Copying files..." }, async () => {
463
for (const copyAction of copyActions) {
464
await copyAction.copy();
465
}
466
});
467
}
468
469
// Handle extra files in target (not in source)
470
if (extraFiles.length > 0) {
471
const removeExtras = async () => {
472
for (const file of extraFiles) {
473
await Deno.remove(join(brandDir, file));
474
}
475
// Clean up empty directories
476
cleanupEmptyDirs(brandDir);
477
removed = extraFiles;
478
};
479
480
if (options.force) {
481
await removeExtras();
482
} else {
483
// Show the files that would be removed
484
info(`\nExtra files not in source brand:`);
485
for (const file of extraFiles) {
486
info(` - ${file}`);
487
}
488
// Use afterConfirm pattern - declining doesn't cancel command
489
await afterConfirm(
490
`Remove these ${extraFiles.length} file(s)?`,
491
removeExtras,
492
);
493
}
494
}
495
496
// Output summary of changes
497
const overwritten = copyActions.filter((a) => a.action === "overwrite");
498
const created = copyActions.filter((a) => a.action === "create");
499
if (overwritten.length > 0) {
500
info(`\nOverwritten:`);
501
for (const a of overwritten) {
502
info(` - ${a.file}`);
503
}
504
}
505
if (created.length > 0) {
506
info(`\nCreated:`);
507
for (const a of created) {
508
info(` - ${a.file}`);
509
}
510
}
511
if (removed.length > 0) {
512
info(`\nRemoved:`);
513
for (const file of removed) {
514
info(` - ${file}`);
515
}
516
}
517
}
518
519
async function stageBrand(
520
source: ExtensionSource,
521
tempContext: TempContext,
522
) {
523
if (source.type === "remote") {
524
// A temporary working directory
525
const workingDir = tempContext.createDir();
526
527
// Stages a remote file by downloading and unzipping it
528
const archiveDir = join(workingDir, "archive");
529
ensureDirSync(archiveDir);
530
531
// The filename
532
const filename = (typeof (source.resolvedTarget) === "string"
533
? source.resolvedTarget
534
: source.resolvedFile) || "brand.zip";
535
536
// The tarball path
537
const toFile = join(archiveDir, filename);
538
539
// Download the file
540
await downloadWithProgress(source.resolvedTarget, `Downloading`, toFile);
541
542
// Unzip and remove zip
543
await unzipInPlace(toFile);
544
545
// Try to find the correct sub directory
546
if (source.targetSubdir) {
547
const sourceSubDir = join(archiveDir, source.targetSubdir);
548
if (existsSync(sourceSubDir)) {
549
return sourceSubDir;
550
}
551
}
552
553
// Couldn't find a source sub dir, see if there is only a single
554
// subfolder and if so use that
555
const dirEntries = Deno.readDirSync(archiveDir);
556
let count = 0;
557
let name;
558
let hasFiles = false;
559
for (const dirEntry of dirEntries) {
560
// ignore any files
561
if (dirEntry.isDirectory) {
562
name = dirEntry.name;
563
count++;
564
} else {
565
hasFiles = true;
566
}
567
}
568
// there is a lone subfolder - use that.
569
if (!hasFiles && count === 1 && name) {
570
return join(archiveDir, name);
571
}
572
573
return archiveDir;
574
} else {
575
if (typeof source.resolvedTarget !== "string") {
576
throw new InternalError(
577
"Local resolved extension should always have a string target.",
578
);
579
}
580
581
if (Deno.statSync(source.resolvedTarget).isDirectory) {
582
// copy the contents of the directory, filtered by quartoignore
583
return source.resolvedTarget;
584
} else {
585
// A temporary working directory
586
const workingDir = tempContext.createDir();
587
const targetFile = join(workingDir, basename(source.resolvedTarget));
588
589
// Copy the zip to the working dir
590
Deno.copyFileSync(
591
source.resolvedTarget,
592
targetFile,
593
);
594
595
await unzipInPlace(targetFile);
596
return workingDir;
597
}
598
}
599
}
600
601
// Determines whether the user trusts the brand
602
async function isTrusted(
603
source: ExtensionSource,
604
): Promise<boolean> {
605
if (source.type === "remote") {
606
// Write the preamble
607
const preamble =
608
`\nIf you do not trust the authors of the brand, we recommend that you do not install or use the brand.`;
609
info(preamble);
610
611
// Ask for trust
612
const question = "Do you trust the authors of this brand";
613
const confirmed: boolean = await Confirm.prompt({
614
message: question,
615
default: true,
616
});
617
return confirmed;
618
} else {
619
return true;
620
}
621
}
622
623
async function ensureBrandDirectory(force: boolean, dryRun: boolean) {
624
const currentDir = Deno.cwd();
625
const nbContext = notebookContext();
626
const project = await projectContext(currentDir, nbContext);
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");
631
if (!existsSync(brandDir)) {
632
if (dryRun) {
633
info(` Would create directory: _brand/`);
634
} else if (!force) {
635
// Prompt for confirmation
636
if (
637
!await Confirm.prompt({
638
message: `Create brand directory ${brandDir}?`,
639
default: true,
640
})
641
) {
642
throw new Error(`Could not create brand directory ${brandDir}`);
643
}
644
ensureDirSync(brandDir);
645
} else {
646
// Force mode - create without prompting
647
ensureDirSync(brandDir);
648
}
649
}
650
return brandDir;
651
}
652
653
// Unpack and stage a zipped file
654
async function unzipInPlace(zipFile: string) {
655
// Unzip the file
656
await withSpinner(
657
{ message: "Unzipping" },
658
async () => {
659
// Unzip the archive
660
const result = await unzip(zipFile);
661
if (!result.success) {
662
throw new Error("Failed to unzip brand.\n" + result.stderr);
663
}
664
665
// Remove the tar ball itself
666
await Deno.remove(zipFile);
667
668
return Promise.resolve();
669
},
670
);
671
}
672
673
// Find files in target directory that aren't in source
674
function findExtraFiles(
675
targetDir: string,
676
sourceFiles: Set<string>,
677
): string[] {
678
const extraFiles: string[] = [];
679
680
function walkDir(dir: string, baseRel: string = "") {
681
if (!existsSync(dir)) return;
682
for (const entry of Deno.readDirSync(dir)) {
683
// Use join() for cross-platform path separator compatibility
684
// This matches the behavior of relative() used to build sourceFiles
685
const rel = baseRel ? join(baseRel, entry.name) : entry.name;
686
if (entry.isDirectory) {
687
walkDir(join(dir, entry.name), rel);
688
} else if (!sourceFiles.has(rel)) {
689
extraFiles.push(rel);
690
}
691
}
692
}
693
694
walkDir(targetDir);
695
return extraFiles;
696
}
697
698
// Clean up empty directories after file removal
699
function cleanupEmptyDirs(dir: string) {
700
if (!existsSync(dir)) return;
701
for (const entry of Deno.readDirSync(dir)) {
702
if (entry.isDirectory) {
703
const subdir = join(dir, entry.name);
704
cleanupEmptyDirs(subdir);
705
// Check if now empty
706
const contents = [...Deno.readDirSync(subdir)];
707
if (contents.length === 0) {
708
Deno.removeSync(subdir);
709
}
710
}
711
}
712
}
713
714