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
6447 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 this.resolvePath(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 === false) {
347
return undefined;
348
}
349
if (!spec) {
350
const lightLogo = findLogo("light", order);
351
const darkLogo = findLogo("dark", order);
352
if (!lightLogo && !darkLogo) {
353
return undefined;
354
}
355
return {
356
light: lightLogo || darkLogo,
357
dark: darkLogo || lightLogo,
358
};
359
}
360
if (typeof spec === "string") {
361
return {
362
light: resolveBrandLogo("light", spec),
363
dark: resolveBrandLogo("light", spec),
364
};
365
}
366
if ("path" in spec) {
367
return {
368
light: resolveLogoOptions("light", spec),
369
dark: resolveLogoOptions("dark", spec),
370
};
371
}
372
let light, dark;
373
if (!spec.light) {
374
light = findLogo("light", order);
375
} else if (typeof spec.light === "string") {
376
light = resolveBrandLogo("light", spec.light);
377
} else {
378
light = resolveLogoOptions("light", spec.light);
379
}
380
if (!spec.dark) {
381
dark = findLogo("dark", order);
382
} else if (typeof spec.dark === "string") {
383
dark = resolveBrandLogo("dark", spec.dark);
384
} else {
385
dark = resolveLogoOptions("dark", spec.dark);
386
}
387
// light logo default to dark logo if no light logo specified
388
if (!light && dark) {
389
light = { ...dark };
390
}
391
// dark logo default to light logo if no dark logo specified
392
// and dark mode is enabled
393
if (!dark && light && brand && brand.dark) {
394
dark = { ...light };
395
}
396
return {
397
light,
398
dark,
399
};
400
}
401
402
const ensureLeadingSlashIfNotExternal = (path: string) =>
403
isExternalPath(path) ? path : ensureLeadingSlash(path);
404
405
export function logoAddLeadingSlashes(
406
spec: NormalizedLogoLightDarkSpecifier | undefined,
407
brand: LightDarkBrand | undefined,
408
input: string | undefined,
409
): NormalizedLogoLightDarkSpecifier | undefined {
410
if (!spec) {
411
return spec;
412
}
413
if (input) {
414
const inputDir = dirname(resolve(input));
415
if (!brand || inputDir === brand.light?.projectDir) {
416
return spec;
417
}
418
}
419
return {
420
light: spec.light && {
421
...spec.light,
422
path: ensureLeadingSlashIfNotExternal(spec.light.path),
423
},
424
dark: spec.dark && {
425
...spec.dark,
426
path: ensureLeadingSlashIfNotExternal(spec.dark.path),
427
},
428
};
429
}
430
431
// this a typst workaround but might as well write it as a proper function
432
export function fillLogoPaths(
433
brand: LightDarkBrand | undefined,
434
spec: LogoLightDarkSpecifierPathOptional | undefined,
435
order: BrandNamedLogo[],
436
): LogoLightDarkSpecifier | undefined {
437
function findLogoSize(
438
mode: "light" | "dark",
439
): string | undefined {
440
if (brand?.[mode]) {
441
for (const size of order) {
442
if (brand[mode].processedData.logo[size]) {
443
return size;
444
}
445
}
446
}
447
return undefined;
448
}
449
function resolveMode(
450
mode: "light" | "dark",
451
spec: LogoSpecifierPathOptional | undefined,
452
): LogoSpecifier | undefined {
453
if (!spec) {
454
return undefined;
455
}
456
if (!spec || typeof spec === "string") {
457
return spec;
458
} else if (spec.path) {
459
return spec as LogoOptions;
460
} else {
461
const size = findLogoSize(mode) ||
462
findLogoSize(mode === "light" ? "dark" : "light");
463
if (size) {
464
return {
465
path: size,
466
...spec,
467
};
468
}
469
}
470
return undefined;
471
}
472
if (!spec || typeof spec === "string") {
473
return spec;
474
}
475
if ("light" in spec || "dark" in spec) {
476
return {
477
light: resolveMode("light", spec.light),
478
dark: resolveMode("dark", spec.dark),
479
};
480
}
481
return {
482
light: resolveMode("light", spec as LogoOptionsPathOptional),
483
dark: resolveMode("dark", spec as LogoOptionsPathOptional),
484
};
485
}
486
487
function splitColorLightDark(
488
bcld: BrandColorLightDark,
489
): LightDarkColor {
490
if (typeof bcld === "string") {
491
return { light: bcld, dark: bcld };
492
}
493
return bcld;
494
}
495
496
const enablesDarkMode = (x: BrandColorLightDark | BrandStringLightDark) =>
497
typeof x === "object" && x?.dark;
498
499
export function brandHasDarkMode(brand: BrandUnified): boolean {
500
if (brand.color) {
501
for (const colorName of Zod.BrandNamedThemeColor.options) {
502
if (!brand.color[colorName]) {
503
continue;
504
}
505
if (enablesDarkMode(brand.color![colorName])) {
506
return true;
507
}
508
}
509
}
510
if (brand.typography) {
511
for (const elementName of Zod.BrandNamedTypographyElements.options) {
512
const element = brand.typography[elementName];
513
if (!element || typeof element === "string") {
514
continue;
515
}
516
if (
517
"background-color" in element && element["background-color"] &&
518
enablesDarkMode(element["background-color"])
519
) {
520
return true;
521
}
522
if (
523
"color" in element && element["color"] &&
524
enablesDarkMode(element["color"])
525
) {
526
return true;
527
}
528
}
529
}
530
if (brand.logo) {
531
for (const logoName of Zod.BrandNamedLogo.options) {
532
const logo = brand.logo[logoName];
533
if (!logo || typeof logo === "string") {
534
continue;
535
}
536
if (enablesDarkMode(logo)) {
537
return true;
538
}
539
}
540
}
541
return false;
542
}
543
544
function sharedTypography(
545
unified: BrandTypographyUnified,
546
): BrandTypographySingle {
547
const ret: BrandTypographySingle = {
548
fonts: unified.fonts,
549
};
550
for (const elementName of Zod.BrandNamedTypographyElements.options) {
551
if (!unified[elementName]) {
552
continue;
553
}
554
if (typeof unified[elementName] === "string") {
555
ret[elementName] = unified[elementName];
556
continue;
557
}
558
ret[elementName] = Object.fromEntries(
559
Object.entries(unified[elementName]).filter(
560
([key, _]) => !["color", "background-color"].includes(key),
561
),
562
);
563
}
564
return ret;
565
}
566
567
function splitLogo(
568
unifiedLogo: BrandLogoUnified,
569
): { light: BrandLogoSingle; dark: BrandLogoSingle } {
570
const light: BrandLogoSingle = { images: unifiedLogo.images },
571
dark: BrandLogoSingle = { images: unifiedLogo.images };
572
for (const logoName of Zod.BrandNamedLogo.options) {
573
if (unifiedLogo[logoName]) {
574
if (typeof unifiedLogo[logoName] === "string") {
575
light[logoName] = dark[logoName] = unifiedLogo[logoName];
576
continue;
577
}
578
({ light: light[logoName], dark: dark[logoName] } =
579
unifiedLogo[logoName]);
580
}
581
}
582
return { light, dark };
583
}
584
585
export function splitUnifiedBrand(
586
unified: unknown,
587
brandDir: string,
588
projectDir: string,
589
): LightDarkBrandDarkFlag {
590
const unifiedBrand: BrandUnified = Zod.BrandUnified.parse(unified);
591
let typography: BrandTypographySingle | undefined = undefined;
592
let headingsColor: LightDarkColor | undefined = undefined;
593
let monospaceColor: LightDarkColor | undefined = undefined;
594
let monospaceBackgroundColor: LightDarkColor | undefined = undefined;
595
let monospaceInlineColor: LightDarkColor | undefined = undefined;
596
let monospaceInlineBackgroundColor: LightDarkColor | undefined = undefined;
597
let monospaceBlockColor: LightDarkColor | undefined = undefined;
598
let monospaceBlockBackgroundColor: LightDarkColor | undefined = undefined;
599
let linkColor: LightDarkColor | undefined = undefined;
600
let linkBackgroundColor: LightDarkColor | undefined = undefined;
601
if (unifiedBrand.typography) {
602
typography = sharedTypography(unifiedBrand.typography);
603
if (
604
unifiedBrand.typography.headings &&
605
typeof unifiedBrand.typography.headings !== "string" &&
606
unifiedBrand.typography.headings.color
607
) {
608
headingsColor = splitColorLightDark(
609
unifiedBrand.typography.headings.color,
610
);
611
}
612
if (
613
unifiedBrand.typography.monospace &&
614
typeof unifiedBrand.typography.monospace !== "string"
615
) {
616
if (unifiedBrand.typography.monospace.color) {
617
monospaceColor = splitColorLightDark(
618
unifiedBrand.typography.monospace.color,
619
);
620
}
621
if (unifiedBrand.typography.monospace["background-color"]) {
622
monospaceBackgroundColor = splitColorLightDark(
623
unifiedBrand.typography.monospace["background-color"],
624
);
625
}
626
}
627
if (
628
unifiedBrand.typography["monospace-inline"] &&
629
typeof unifiedBrand.typography["monospace-inline"] !== "string"
630
) {
631
if (unifiedBrand.typography["monospace-inline"].color) {
632
monospaceInlineColor = splitColorLightDark(
633
unifiedBrand.typography["monospace-inline"].color,
634
);
635
}
636
if (unifiedBrand.typography["monospace-inline"]["background-color"]) {
637
monospaceInlineBackgroundColor = splitColorLightDark(
638
unifiedBrand.typography["monospace-inline"]["background-color"],
639
);
640
}
641
}
642
if (
643
unifiedBrand.typography["monospace-block"] &&
644
typeof unifiedBrand.typography["monospace-block"] !== "string"
645
) {
646
if (unifiedBrand.typography["monospace-block"].color) {
647
monospaceBlockColor = splitColorLightDark(
648
unifiedBrand.typography["monospace-block"].color,
649
);
650
}
651
if (unifiedBrand.typography["monospace-block"]["background-color"]) {
652
monospaceBlockBackgroundColor = splitColorLightDark(
653
unifiedBrand.typography["monospace-block"]["background-color"],
654
);
655
}
656
}
657
if (
658
unifiedBrand.typography.link &&
659
typeof unifiedBrand.typography.link !== "string"
660
) {
661
if (unifiedBrand.typography.link.color) {
662
linkColor = splitColorLightDark(
663
unifiedBrand.typography.link.color,
664
);
665
}
666
if (unifiedBrand.typography.link["background-color"]) {
667
linkBackgroundColor = splitColorLightDark(
668
unifiedBrand.typography.link["background-color"],
669
);
670
}
671
}
672
}
673
const specializeTypography = (
674
typography: BrandTypographySingle,
675
mode: "light" | "dark",
676
) =>
677
typography && {
678
fonts: typography.fonts && [...typography.fonts],
679
base: !typography.base || typeof typography.base === "string"
680
? typography.base
681
: { ...typography.base },
682
headings: !typography.headings || typeof typography.headings === "string"
683
? typography.headings
684
: {
685
...typography.headings,
686
...(headingsColor?.[mode] && { color: headingsColor[mode] }),
687
},
688
monospace:
689
!typography.monospace || typeof typography.monospace === "string"
690
? typography.monospace
691
: {
692
...typography.monospace,
693
...(monospaceColor?.[mode] && { color: monospaceColor[mode] }),
694
...(monospaceBackgroundColor?.[mode] &&
695
{ "background-color": monospaceBackgroundColor[mode] }),
696
},
697
"monospace-inline": !typography["monospace-inline"] ||
698
typeof typography["monospace-inline"] === "string"
699
? typography["monospace-inline"]
700
: {
701
...typography["monospace-inline"],
702
...(monospaceInlineColor?.[mode] &&
703
{ color: monospaceInlineColor[mode] }),
704
...(monospaceInlineBackgroundColor?.[mode] &&
705
{ "background-color": monospaceInlineBackgroundColor[mode] }),
706
},
707
"monospace-block": !typography["monospace-block"] ||
708
typeof typography["monospace-block"] === "string"
709
? typography["monospace-block"]
710
: {
711
...typography["monospace-block"],
712
...(monospaceBlockColor?.[mode] &&
713
{ color: monospaceBlockColor[mode] }),
714
...(monospaceBlockBackgroundColor?.[mode] &&
715
{ "background-color": monospaceBlockBackgroundColor[mode] }),
716
},
717
link: !typography.link || typeof typography.link === "string"
718
? typography.link
719
: {
720
...typography.link,
721
...(linkColor?.[mode] && { color: linkColor[mode] }),
722
...(linkBackgroundColor?.[mode] &&
723
{ "background-color": linkBackgroundColor[mode] }),
724
},
725
};
726
const logos = unifiedBrand.logo && splitLogo(unifiedBrand.logo);
727
const lightBrand: BrandSingle = {
728
meta: unifiedBrand.meta,
729
color: { palette: unifiedBrand.color && { ...unifiedBrand.color.palette } },
730
typography: typography && specializeTypography(typography, "light"),
731
logo: logos && logos.light,
732
defaults: unifiedBrand.defaults,
733
};
734
const darkBrand: BrandSingle = {
735
meta: unifiedBrand.meta,
736
color: { palette: unifiedBrand.color && { ...unifiedBrand.color.palette } },
737
typography: typography && specializeTypography(typography, "dark"),
738
logo: logos && logos.dark,
739
defaults: unifiedBrand.defaults,
740
};
741
if (unifiedBrand.color) {
742
for (const colorName of Zod.BrandNamedThemeColor.options) {
743
if (!unifiedBrand.color[colorName]) {
744
continue;
745
}
746
const { light, dark } = splitColorLightDark(
747
unifiedBrand.color[colorName],
748
);
749
750
if (light !== undefined) lightBrand.color![colorName] = light;
751
if (dark !== undefined) darkBrand.color![colorName] = dark;
752
}
753
}
754
return {
755
light: new Brand(lightBrand, brandDir, projectDir),
756
dark: new Brand(darkBrand, brandDir, projectDir),
757
enablesDarkMode: brandHasDarkMode(unifiedBrand),
758
};
759
}
760
761