Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/core/brand/brand.ts
3562 views
1
/*
2
* brand.ts
3
*
4
* Class that implements support for `_brand.yml` data in Quarto
5
*
6
* Copyright (C) 2024 Posit Software, PBC
7
*/
8
9
import {
10
BrandColorLightDark,
11
BrandFont,
12
BrandLogoExplicitResource,
13
BrandLogoResource,
14
BrandLogoSingle,
15
BrandLogoUnified,
16
BrandNamedLogo,
17
BrandNamedThemeColor,
18
BrandSingle,
19
BrandStringLightDark,
20
BrandTypographyOptionsBase,
21
BrandTypographyOptionsHeadingsSingle,
22
BrandTypographySingle,
23
BrandTypographyUnified,
24
BrandUnified,
25
LogoLightDarkSpecifier,
26
LogoOptions,
27
NormalizedLogoLightDarkSpecifier,
28
Zod,
29
} from "../../resources/types/zod/schema-types.ts";
30
import { InternalError } from "../lib/error.ts";
31
32
import { dirname, join, relative, resolve } from "../../deno_ral/path.ts";
33
import { warnOnce } from "../log.ts";
34
import { isCssColorName } from "../css/color-names.ts";
35
import {
36
LogoLightDarkSpecifierPathOptional,
37
LogoOptionsPathOptional,
38
LogoSpecifier,
39
LogoSpecifierPathOptional,
40
} from "../../resources/types/schema-types.ts";
41
import { ensureLeadingSlash } from "../path.ts";
42
43
type ProcessedBrandData = {
44
color: Record<string, string>;
45
typography: BrandTypographySingle;
46
logo: {
47
small?: BrandLogoExplicitResource;
48
medium?: BrandLogoExplicitResource;
49
large?: BrandLogoExplicitResource;
50
images: Record<string, BrandLogoExplicitResource>;
51
};
52
};
53
54
export class Brand {
55
data: BrandSingle;
56
brandDir: string;
57
projectDir: string;
58
processedData: ProcessedBrandData;
59
60
constructor(
61
readonly brand: unknown,
62
brandDir: string,
63
projectDir: string,
64
) {
65
this.data = Zod.BrandSingle.parse(brand);
66
this.brandDir = brandDir;
67
this.projectDir = projectDir;
68
this.processedData = this.processData(this.data);
69
}
70
71
processData(data: BrandSingle): ProcessedBrandData {
72
const color: Record<string, string> = {};
73
for (const colorName of Object.keys(data.color?.palette ?? {})) {
74
color[colorName] = this.getColor(colorName);
75
}
76
for (const colorName of Object.keys(data.color ?? {})) {
77
if (colorName === "palette") {
78
continue;
79
}
80
color[colorName] = this.getColor(colorName);
81
}
82
83
const typography: BrandTypographySingle = {};
84
const base = this.getFont("base");
85
if (base) {
86
typography.base = base;
87
}
88
const headings = this.getFont("headings");
89
if (headings) {
90
typography.headings = headings;
91
}
92
const link = data.typography?.link;
93
if (link) {
94
typography.link = link;
95
}
96
let monospace = this.getFont("monospace");
97
let monospaceInline = this.getFont("monospace-inline");
98
let monospaceBlock = this.getFont("monospace-block");
99
100
if (monospace) {
101
if (typeof monospace === "string") {
102
monospace = { family: monospace };
103
}
104
typography.monospace = monospace;
105
}
106
if (monospaceInline && typeof monospaceInline === "string") {
107
monospaceInline = { family: monospaceInline };
108
}
109
if (monospaceBlock && typeof monospaceBlock === "string") {
110
monospaceBlock = { family: monospaceBlock };
111
}
112
113
// cut off control flow here so the type checker knows these
114
// are not strings
115
if (typeof monospace === "string") {
116
throw new InternalError("should never happen");
117
}
118
if (typeof monospaceInline === "string") {
119
throw new InternalError("should never happen");
120
}
121
if (typeof monospaceBlock === "string") {
122
throw new InternalError("should never happen");
123
}
124
125
if (monospace || monospaceInline) {
126
typography["monospace-inline"] = {
127
...(monospace ?? {}),
128
...(monospaceInline ?? {}),
129
};
130
}
131
if (monospaceBlock) {
132
if (typeof monospaceBlock === "string") {
133
monospaceBlock = { family: monospaceBlock };
134
}
135
}
136
if (monospace || monospaceBlock) {
137
typography["monospace-block"] = {
138
...(monospace ?? {}),
139
...(monospaceBlock ?? {}),
140
};
141
}
142
143
const logo: ProcessedBrandData["logo"] = { images: {} };
144
for (
145
const size of Zod.BrandNamedLogo.options
146
) {
147
const v = this.getLogo(size);
148
if (v) {
149
logo[size] = v;
150
}
151
}
152
for (const [key, value] of Object.entries(data.logo?.images ?? {})) {
153
logo.images[key] = this.resolvePath(value);
154
}
155
156
return {
157
color,
158
typography,
159
logo,
160
};
161
}
162
163
// semantics of name resolution for colors:
164
// - if the name is in the "palette" key, use that value as they key for a recursive call (so color names can be aliased or redefined away from scss defaults)
165
// - if the name is a default color name, call getColor recursively (so defaults can use named values)
166
// - otherwise, assume it's a color value and return it
167
getColor(name: string, quiet = false): string {
168
const seenValues = new Set<string>();
169
170
do {
171
if (seenValues.has(name)) {
172
throw new Error(
173
`Circular reference in _brand.yml color definitions: ${
174
Array.from(seenValues).join(
175
" -> ",
176
)
177
}`,
178
);
179
}
180
seenValues.add(name);
181
if (this.data.color?.palette?.[name]) {
182
name = this.data.color.palette[name] as string;
183
} else if (
184
Zod.BrandNamedThemeColor.options.includes(
185
name as BrandNamedThemeColor,
186
) &&
187
this.data.color?.[name as BrandNamedThemeColor]
188
) {
189
name = this.data.color[name as BrandNamedThemeColor]!;
190
} else {
191
// if the name is not a default color name, assume it's a color value
192
if (!isCssColorName(name) && !quiet) {
193
warnOnce(
194
`"${name}" is not a valid CSS color name.\nThis might cause SCSS compilation to fail, or the color to have no effect.`,
195
);
196
}
197
return name;
198
}
199
} while (seenValues.size < 100); // 100 ought to be enough for anyone, with apologies to Bill Gates
200
throw new Error(
201
"Recursion depth exceeded 100 in _brand.yml color definitions",
202
);
203
}
204
205
getFont(
206
name: string,
207
):
208
| BrandTypographyOptionsBase
209
| BrandTypographyOptionsHeadingsSingle
210
| undefined {
211
if (!this.data.typography) {
212
return undefined;
213
}
214
const typography = this.data.typography;
215
switch (name) {
216
case "base":
217
return typography.base;
218
case "headings":
219
return typography.headings;
220
case "link":
221
return typography.link;
222
case "monospace":
223
return typography.monospace;
224
case "monospace-inline":
225
return typography["monospace-inline"];
226
case "monospace-block":
227
return typography["monospace-block"];
228
}
229
return undefined;
230
}
231
232
getFontResources(name: string): BrandFont[] {
233
if (name === "fonts") {
234
throw new Error(
235
"'fonts' is a reserved name in _brand.yml typography definitions",
236
);
237
}
238
if (!this.data.typography) {
239
return [];
240
}
241
const typography = this.data.typography;
242
const fonts = typography.fonts;
243
return fonts ?? [];
244
}
245
246
resolvePath(entry: BrandLogoResource) {
247
const pathPrefix = relative(this.projectDir, this.brandDir);
248
if (typeof entry === "string") {
249
return { path: isExternalPath(entry) ? entry : join(pathPrefix, entry) };
250
}
251
return {
252
...entry,
253
path: isExternalPath(entry.path)
254
? entry.path
255
: join(pathPrefix, entry.path),
256
};
257
}
258
259
getLogoResource(name: string): BrandLogoExplicitResource {
260
const entry = this.data.logo?.images?.[name];
261
if (!entry) {
262
return { path: name };
263
}
264
return this.resolvePath(entry);
265
}
266
getLogo(name: BrandNamedLogo): BrandLogoExplicitResource | undefined {
267
const entry = this.data.logo?.[name];
268
if (!entry) {
269
return undefined;
270
}
271
return this.getLogoResource(entry);
272
}
273
}
274
275
function isExternalPath(path: string) {
276
return /^\w+:/.test(path);
277
}
278
279
export type LightDarkBrand = {
280
light?: Brand;
281
dark?: Brand;
282
};
283
284
export type LightDarkBrandDarkFlag = {
285
light?: Brand;
286
dark?: Brand;
287
enablesDarkMode: boolean;
288
};
289
290
export type LightDarkColor = {
291
light?: string;
292
dark?: string;
293
};
294
295
export const getFavicon = (brand: Brand): string | undefined => {
296
const logoInfo = brand.getLogo("small");
297
if (!logoInfo) {
298
return undefined;
299
}
300
return logoInfo.path;
301
};
302
303
export function resolveLogo(
304
brand: LightDarkBrand | undefined,
305
spec: LogoLightDarkSpecifier | undefined,
306
order: BrandNamedLogo[],
307
): NormalizedLogoLightDarkSpecifier | undefined {
308
const resolveBrandLogo = (
309
mode: "light" | "dark",
310
name: string,
311
): LogoOptions => {
312
const logo = brand?.[mode]?.processedData?.logo;
313
return logo &&
314
((Zod.BrandNamedLogo.options.includes(name as BrandNamedLogo) &&
315
logo[name as BrandNamedLogo]) || logo.images[name]) ||
316
{ path: name };
317
};
318
function findLogo(
319
mode: "light" | "dark",
320
order: BrandNamedLogo[],
321
): LogoOptions | undefined {
322
if (brand?.[mode]) {
323
for (const size of order) {
324
const logo = brand[mode].processedData.logo[size];
325
if (logo) {
326
return logo;
327
}
328
}
329
}
330
return undefined;
331
}
332
const resolveLogoOptions = (
333
mode: "light" | "dark",
334
logo: LogoOptions,
335
): LogoOptions => {
336
const logo2 = resolveBrandLogo(mode, logo.path);
337
if (logo2) {
338
const { path: _, ...rest } = logo;
339
return {
340
...logo2,
341
...rest,
342
};
343
}
344
return logo;
345
};
346
if (!spec) {
347
return {
348
light: findLogo("light", order) || findLogo("dark", order),
349
dark: findLogo("dark", order) || findLogo("light", order),
350
};
351
}
352
if (typeof spec === "string") {
353
return {
354
light: resolveBrandLogo("light", spec),
355
dark: resolveBrandLogo("light", spec),
356
};
357
}
358
if ("path" in spec) {
359
return {
360
light: resolveLogoOptions("light", spec),
361
dark: resolveLogoOptions("dark", spec),
362
};
363
}
364
let light, dark;
365
if (!spec.light) {
366
light = findLogo("light", order);
367
} else if (typeof spec.light === "string") {
368
light = resolveBrandLogo("light", spec.light);
369
} else {
370
light = resolveLogoOptions("light", spec.light);
371
}
372
if (!spec.dark) {
373
dark = findLogo("dark", order);
374
} else if (typeof spec.dark === "string") {
375
dark = resolveBrandLogo("dark", spec.dark);
376
} else {
377
dark = resolveLogoOptions("dark", spec.dark);
378
}
379
// light logo default to dark logo if no light logo specified
380
if (!light && dark) {
381
light = { ...dark };
382
}
383
// dark logo default to light logo if no dark logo specified
384
// and dark mode is enabled
385
if (!dark && light && brand && brand.dark) {
386
dark = { ...light };
387
}
388
return {
389
light,
390
dark,
391
};
392
}
393
394
const ensureLeadingSlashIfNotExternal = (path: string) =>
395
isExternalPath(path) ? path : ensureLeadingSlash(path);
396
397
export function logoAddLeadingSlashes(
398
spec: NormalizedLogoLightDarkSpecifier | undefined,
399
brand: LightDarkBrand | undefined,
400
input: string | undefined,
401
): NormalizedLogoLightDarkSpecifier | undefined {
402
if (!spec) {
403
return spec;
404
}
405
if (input) {
406
const inputDir = dirname(resolve(input));
407
if (!brand || inputDir === brand.light?.projectDir) {
408
return spec;
409
}
410
}
411
return {
412
light: spec.light && {
413
...spec.light,
414
path: ensureLeadingSlashIfNotExternal(spec.light.path),
415
},
416
dark: spec.dark && {
417
...spec.dark,
418
path: ensureLeadingSlashIfNotExternal(spec.dark.path),
419
},
420
};
421
}
422
423
// this a typst workaround but might as well write it as a proper function
424
export function fillLogoPaths(
425
brand: LightDarkBrand | undefined,
426
spec: LogoLightDarkSpecifierPathOptional | undefined,
427
order: BrandNamedLogo[],
428
): LogoLightDarkSpecifier | undefined {
429
function findLogoSize(
430
mode: "light" | "dark",
431
): string | undefined {
432
if (brand?.[mode]) {
433
for (const size of order) {
434
if (brand[mode].processedData.logo[size]) {
435
return size;
436
}
437
}
438
}
439
return undefined;
440
}
441
function resolveMode(
442
mode: "light" | "dark",
443
spec: LogoSpecifierPathOptional | undefined,
444
): LogoSpecifier | undefined {
445
if (!spec) {
446
return undefined;
447
}
448
if (!spec || typeof spec === "string") {
449
return spec;
450
} else if (spec.path) {
451
return spec as LogoOptions;
452
} else {
453
const size = findLogoSize(mode) ||
454
findLogoSize(mode === "light" ? "dark" : "light");
455
if (size) {
456
return {
457
path: size,
458
...spec,
459
};
460
}
461
}
462
return undefined;
463
}
464
if (!spec || typeof spec === "string") {
465
return spec;
466
}
467
if ("light" in spec || "dark" in spec) {
468
return {
469
light: resolveMode("light", spec.light),
470
dark: resolveMode("dark", spec.dark),
471
};
472
}
473
return {
474
light: resolveMode("light", spec as LogoOptionsPathOptional),
475
dark: resolveMode("dark", spec as LogoOptionsPathOptional),
476
};
477
}
478
479
function splitColorLightDark(
480
bcld: BrandColorLightDark,
481
): LightDarkColor {
482
if (typeof bcld === "string") {
483
return { light: bcld, dark: bcld };
484
}
485
return bcld;
486
}
487
488
const enablesDarkMode = (x: BrandColorLightDark | BrandStringLightDark) =>
489
typeof x === "object" && x?.dark;
490
491
export function brandHasDarkMode(brand: BrandUnified): boolean {
492
if (brand.color) {
493
for (const colorName of Zod.BrandNamedThemeColor.options) {
494
if (!brand.color[colorName]) {
495
continue;
496
}
497
if (enablesDarkMode(brand.color![colorName])) {
498
return true;
499
}
500
}
501
}
502
if (brand.typography) {
503
for (const elementName of Zod.BrandNamedTypographyElements.options) {
504
const element = brand.typography[elementName];
505
if (!element || typeof element === "string") {
506
continue;
507
}
508
if (
509
"background-color" in element && element["background-color"] &&
510
enablesDarkMode(element["background-color"])
511
) {
512
return true;
513
}
514
if (
515
"color" in element && element["color"] &&
516
enablesDarkMode(element["color"])
517
) {
518
return true;
519
}
520
}
521
}
522
if (brand.logo) {
523
for (const logoName of Zod.BrandNamedLogo.options) {
524
const logo = brand.logo[logoName];
525
if (!logo || typeof logo === "string") {
526
continue;
527
}
528
if (enablesDarkMode(logo)) {
529
return true;
530
}
531
}
532
}
533
return false;
534
}
535
536
function sharedTypography(
537
unified: BrandTypographyUnified,
538
): BrandTypographySingle {
539
const ret: BrandTypographySingle = {
540
fonts: unified.fonts,
541
};
542
for (const elementName of Zod.BrandNamedTypographyElements.options) {
543
if (!unified[elementName]) {
544
continue;
545
}
546
if (typeof unified[elementName] === "string") {
547
ret[elementName] = unified[elementName];
548
continue;
549
}
550
ret[elementName] = Object.fromEntries(
551
Object.entries(unified[elementName]).filter(
552
([key, _]) => !["color", "background-color"].includes(key),
553
),
554
);
555
}
556
return ret;
557
}
558
559
function splitLogo(
560
unifiedLogo: BrandLogoUnified,
561
): { light: BrandLogoSingle; dark: BrandLogoSingle } {
562
const light: BrandLogoSingle = { images: unifiedLogo.images },
563
dark: BrandLogoSingle = { images: unifiedLogo.images };
564
for (const logoName of Zod.BrandNamedLogo.options) {
565
if (unifiedLogo[logoName]) {
566
if (typeof unifiedLogo[logoName] === "string") {
567
light[logoName] = dark[logoName] = unifiedLogo[logoName];
568
continue;
569
}
570
({ light: light[logoName], dark: dark[logoName] } =
571
unifiedLogo[logoName]);
572
}
573
}
574
return { light, dark };
575
}
576
577
export function splitUnifiedBrand(
578
unified: unknown,
579
brandDir: string,
580
projectDir: string,
581
): LightDarkBrandDarkFlag {
582
const unifiedBrand: BrandUnified = Zod.BrandUnified.parse(unified);
583
let typography: BrandTypographySingle | undefined = undefined;
584
let headingsColor: LightDarkColor | undefined = undefined;
585
let monospaceColor: LightDarkColor | undefined = undefined;
586
let monospaceBackgroundColor: LightDarkColor | undefined = undefined;
587
let monospaceInlineColor: LightDarkColor | undefined = undefined;
588
let monospaceInlineBackgroundColor: LightDarkColor | undefined = undefined;
589
let monospaceBlockColor: LightDarkColor | undefined = undefined;
590
let monospaceBlockBackgroundColor: LightDarkColor | undefined = undefined;
591
let linkColor: LightDarkColor | undefined = undefined;
592
let linkBackgroundColor: LightDarkColor | undefined = undefined;
593
if (unifiedBrand.typography) {
594
typography = sharedTypography(unifiedBrand.typography);
595
if (
596
unifiedBrand.typography.headings &&
597
typeof unifiedBrand.typography.headings !== "string" &&
598
unifiedBrand.typography.headings.color
599
) {
600
headingsColor = splitColorLightDark(
601
unifiedBrand.typography.headings.color,
602
);
603
}
604
if (
605
unifiedBrand.typography.monospace &&
606
typeof unifiedBrand.typography.monospace !== "string"
607
) {
608
if (unifiedBrand.typography.monospace.color) {
609
monospaceColor = splitColorLightDark(
610
unifiedBrand.typography.monospace.color,
611
);
612
}
613
if (unifiedBrand.typography.monospace["background-color"]) {
614
monospaceBackgroundColor = splitColorLightDark(
615
unifiedBrand.typography.monospace["background-color"],
616
);
617
}
618
}
619
if (
620
unifiedBrand.typography["monospace-inline"] &&
621
typeof unifiedBrand.typography["monospace-inline"] !== "string"
622
) {
623
if (unifiedBrand.typography["monospace-inline"].color) {
624
monospaceInlineColor = splitColorLightDark(
625
unifiedBrand.typography["monospace-inline"].color,
626
);
627
}
628
if (unifiedBrand.typography["monospace-inline"]["background-color"]) {
629
monospaceInlineBackgroundColor = splitColorLightDark(
630
unifiedBrand.typography["monospace-inline"]["background-color"],
631
);
632
}
633
}
634
if (
635
unifiedBrand.typography["monospace-block"] &&
636
typeof unifiedBrand.typography["monospace-block"] !== "string"
637
) {
638
if (unifiedBrand.typography["monospace-block"].color) {
639
monospaceBlockColor = splitColorLightDark(
640
unifiedBrand.typography["monospace-block"].color,
641
);
642
}
643
if (unifiedBrand.typography["monospace-block"]["background-color"]) {
644
monospaceBlockBackgroundColor = splitColorLightDark(
645
unifiedBrand.typography["monospace-block"]["background-color"],
646
);
647
}
648
}
649
if (
650
unifiedBrand.typography.link &&
651
typeof unifiedBrand.typography.link !== "string"
652
) {
653
if (unifiedBrand.typography.link.color) {
654
linkColor = splitColorLightDark(
655
unifiedBrand.typography.link.color,
656
);
657
}
658
if (unifiedBrand.typography.link["background-color"]) {
659
linkBackgroundColor = splitColorLightDark(
660
unifiedBrand.typography.link["background-color"],
661
);
662
}
663
}
664
}
665
const specializeTypography = (
666
typography: BrandTypographySingle,
667
mode: "light" | "dark",
668
) =>
669
typography && {
670
fonts: typography.fonts && [...typography.fonts],
671
base: !typography.base || typeof typography.base === "string"
672
? typography.base
673
: { ...typography.base },
674
headings: !typography.headings || typeof typography.headings === "string"
675
? typography.headings
676
: {
677
...typography.headings,
678
color: headingsColor && headingsColor[mode],
679
},
680
monospace:
681
!typography.monospace || typeof typography.monospace === "string"
682
? typography.monospace
683
: {
684
...typography.monospace,
685
color: monospaceColor && monospaceColor[mode],
686
"background-color": monospaceBackgroundColor &&
687
monospaceBackgroundColor[mode],
688
},
689
"monospace-inline": !typography["monospace-inline"] ||
690
typeof typography["monospace-inline"] === "string"
691
? typography["monospace-inline"]
692
: {
693
...typography["monospace-inline"],
694
color: monospaceInlineColor && monospaceInlineColor[mode],
695
"background-color": monospaceInlineBackgroundColor &&
696
monospaceInlineBackgroundColor[mode],
697
},
698
"monospace-block": !typography["monospace-block"] ||
699
typeof typography["monospace-block"] === "string"
700
? typography["monospace-block"]
701
: {
702
...typography["monospace-block"],
703
color: monospaceBlockColor && monospaceBlockColor[mode],
704
"background-color": monospaceBlockBackgroundColor &&
705
monospaceBlockBackgroundColor[mode],
706
},
707
link: !typography.link || typeof typography.link === "string"
708
? typography.link
709
: {
710
...typography.link,
711
color: linkColor && linkColor[mode],
712
"background-color": linkBackgroundColor &&
713
linkBackgroundColor[mode],
714
},
715
};
716
const logos = unifiedBrand.logo && splitLogo(unifiedBrand.logo);
717
const lightBrand: BrandSingle = {
718
meta: unifiedBrand.meta,
719
color: { palette: unifiedBrand.color && { ...unifiedBrand.color.palette } },
720
typography: typography && specializeTypography(typography, "light"),
721
logo: logos && logos.light,
722
defaults: unifiedBrand.defaults,
723
};
724
const darkBrand: BrandSingle = {
725
meta: unifiedBrand.meta,
726
color: { palette: unifiedBrand.color && { ...unifiedBrand.color.palette } },
727
typography: typography && specializeTypography(typography, "dark"),
728
logo: logos && logos.dark,
729
defaults: unifiedBrand.defaults,
730
};
731
if (unifiedBrand.color) {
732
for (const colorName of Zod.BrandNamedThemeColor.options) {
733
if (!unifiedBrand.color[colorName]) {
734
continue;
735
}
736
({
737
light: lightBrand.color![colorName],
738
dark: darkBrand.color![colorName],
739
} = splitColorLightDark(unifiedBrand.color![colorName]));
740
}
741
}
742
return {
743
light: new Brand(lightBrand, brandDir, projectDir),
744
dark: new Brand(darkBrand, brandDir, projectDir),
745
enablesDarkMode: brandHasDarkMode(unifiedBrand),
746
};
747
}
748
749