@abreen/tada 1.16.1 → 1.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +15 -0
  2. package/assets/material-symbols/celebration_24px.svg +1 -1
  3. package/assets/material-symbols/close_24px.svg +1 -1
  4. package/assets/material-symbols/contrast_20px.svg +1 -0
  5. package/assets/material-symbols/contrast_rtl_off_20px.svg +1 -0
  6. package/assets/material-symbols/info_20px.svg +1 -0
  7. package/assets/material-symbols/info_40px.svg +1 -0
  8. package/assets/material-symbols/menu_24px.svg +1 -1
  9. package/assets/material-symbols/open_in_new_20px.svg +1 -0
  10. package/assets/material-symbols/search_20px.svg +1 -0
  11. package/assets/material-symbols/smart_display_24px.svg +1 -0
  12. package/assets/material-symbols/south_east_20px.svg +1 -0
  13. package/assets/material-symbols/warning_20px.svg +1 -0
  14. package/assets/material-symbols/warning_40px.svg +1 -0
  15. package/build/bundle.ts +23 -4
  16. package/build/custom-fonts.ts +313 -0
  17. package/build/generate-fonts.ts +29 -3
  18. package/build/material-symbols.ts +58 -20
  19. package/build/pipeline.ts +1 -1
  20. package/build/types.d.ts +29 -0
  21. package/build/utils/render.ts +13 -5
  22. package/build/watch/build-full.ts +1 -1
  23. package/build/watch/build-incremental.ts +1 -1
  24. package/build/watch/validation.ts +18 -7
  25. package/init/content/markdown.md +2 -1
  26. package/package.json +2 -2
  27. package/schema/site.schema.json +70 -0
  28. package/src/_alerts.scss +17 -12
  29. package/src/_base.scss +10 -0
  30. package/src/_layout.scss +2 -2
  31. package/src/anchor/index.ts +2 -35
  32. package/src/anchor/style.scss +4 -28
  33. package/src/appearance-picker/index.ts +18 -5
  34. package/src/appearance-picker/style.scss +3 -13
  35. package/src/code.scss +1 -0
  36. package/src/header/_base.scss +10 -6
  37. package/src/material-symbols.scss +16 -3
  38. package/src/navigate/runtime.ts +13 -0
  39. package/src/question/style.scss +1 -2
  40. package/src/search/style.scss +5 -3
  41. package/src/style.scss +15 -7
  42. package/src/timezone/index.ts +42 -2
  43. package/src/timezone/style.scss +0 -6
  44. package/src/timezone/time-format.ts +13 -0
  45. package/src/toc/style.scss +16 -10
  46. package/src/trace/style.scss +1 -0
  47. package/templates/_theme.scss +16 -4
  48. package/templates/_top.html +23 -11
  49. package/templates/code.html +1 -1
  50. package/assets/material-symbols/contrast_24px.svg +0 -1
  51. package/assets/material-symbols/contrast_rtl_off_24px.svg +0 -1
  52. package/assets/material-symbols/info_24px.svg +0 -1
  53. package/assets/material-symbols/north_east_24px.svg +0 -1
  54. package/assets/material-symbols/search_24px.svg +0 -1
  55. package/assets/material-symbols/south_east_24px.svg +0 -1
  56. package/assets/material-symbols/tag_24px.svg +0 -1
  57. package/assets/material-symbols/warning_24px.svg +0 -1
package/README.md CHANGED
@@ -221,6 +221,20 @@ themeColor: hsl(351 70% 40%)
221
221
  tintAmount: 0
222
222
  defaultFont: serif
223
223
  defaultContrast: high
224
+ fontOverrides:
225
+ serif:
226
+ regular: fonts/body-regular.woff2
227
+ tuning:
228
+ scale: 1.125
229
+ lineHeight: 1.5
230
+ headingScale: 0.9
231
+ headingWeight: 400
232
+ serifMono:
233
+ regular: fonts/mono-regular.woff2
234
+ features: [ss02]
235
+ tuning:
236
+ scale: 0.94
237
+ lineHeight: 1.5
224
238
  features:
225
239
  search: true
226
240
  favicon: true
@@ -249,6 +263,7 @@ vars:
249
263
  | `tintAmount` | *Optional*, percentage (0-100) of tint to apply (default `100`) |
250
264
  | `defaultFont` | *Optional*, initial font pairing: `sans` or `serif` (default `sans`) |
251
265
  | `defaultContrast` | *Optional*, initial neutral-palette contrast: `standard` or `high` (default `standard`) |
266
+ | `fontOverrides` | *Optional*, custom serif and serif-monospace WOFF2 faces from `public/`; each configured family requires `regular` and may include styled faces, OpenType `features`, and font-specific `tuning` |
252
267
  | `faviconSymbol` | *Optional*, the text to use instead of `symbol` in the favicon |
253
268
  | `features.search` | Enable search UI and Pagefind index generation |
254
269
  | `features.favicon` | Enable automatically generated favicons |
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m80-80 200-560 360 360L80-80Zm132-132 282-100-182-182-100 282Zm370-246-42-42 224-224q32-32 77-32t77 32l24 24-42 42-24-24q-14-14-35-14t-35 14L582-458ZM422-618l-42-42 24-24q14-14 14-34t-14-34l-26-26 42-42 26 26q32 32 32 76t-32 76l-24 24Zm80 80-42-42 144-144q14-14 14-35t-14-35l-64-64 42-42 64 64q32 32 32 77t-32 77L502-538Zm160 160-42-42 64-64q32-32 77-32t77 32l64 64-42 42-64-64q-14-14-35-14t-35 14l-64 64ZM212-212Z"/></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m70.91-65.41 210.05-587.74 377.93 377.93L70.91-65.41Zm150.18-149.94L492.8-312 317.74-487.07l-96.65 271.72Zm370.24-239.06L545.74-500l219.93-219.93q33.44-33.44 80.71-33.44 47.27 0 80.71 33.44l24 24-45.83 45.58-24-24q-14-14-35-14t-35 14L591.33-454.41ZM423.91-621.59l-45.58-45.82 22.56-22.57q14-14 14-34t-14-34l-26-26 45.59-45.59 26 26q33.43 33.44 33.43 79.71 0 46.27-33.43 79.71l-22.57 22.56ZM507.74-538l-45.83-45.59 141.13-141.13q14-14 14-35t-14-35l-64-64 45.83-45.58 64 64q33.43 33.43 33.43 80.58t-33.43 80.59L507.74-538Zm167.17 167.41-45.58-45.82 62.56-62.57q33.44-33.43 80.71-33.43 47.27 0 80.7 33.43l64 64-45.82 45.83-64-64q-14-14-35-14t-35 14l-62.57 62.56ZM221.09-215.35Z"/></svg>
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"/></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M256-192.35 192.35-256l224-224-224-224L256-767.65l224 224 224-224L767.65-704l-224 224 224 224L704-192.35l-224-224-224 224Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="20" viewBox="0 -960 960 960" width="20"><path d="M327.89-118.47q-71.43-30.6-125.13-84.29-53.69-53.7-84.29-125.13-30.6-71.44-30.6-152.61 0-81.17 30.6-152.11 30.6-70.93 84.29-124.63 53.7-53.69 125.13-84.29 71.44-30.6 152.61-30.6 81.17 0 152.11 30.6 70.93 30.6 124.63 84.29 53.69 53.7 84.29 124.63 30.6 70.94 30.6 152.11 0 81.17-30.6 152.61-30.6 71.43-84.29 125.13-53.7 53.69-124.63 84.29-70.94 30.6-152.11 30.6-81.17 0-152.61-30.6Zm190.98-54.88q115.09-14.95 192.67-100.9Q789.13-360.2 789.13-480q0-119.09-77.59-205.27-77.58-86.19-192.67-101.14v613.06Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="20" viewBox="0 -960 960 960" width="20"><path d="m771.83-84.98-70.81-70.56q-49.95 32.71-105.75 50.19-55.8 17.48-115.24 17.48-80.94 0-152.37-30.62-71.44-30.62-125-84.17-53.55-53.56-84.17-125-30.62-71.43-30.62-152.37 0-59.44 17.48-115.24t50.19-105.75L88.8-768l53.4-53.39 683.02 683.02-53.39 53.39ZM480-170.87q43.04 0 83.45-12.52 40.4-12.52 77.68-33.04L480-377.57v206.7Zm327.09-92.41-61.37-60.13q20.29-36.49 31.85-75.73 11.56-39.25 11.56-80.86 0-129.04-90.16-219.09-90.16-90.04-218.97-90.04v198.76L263.28-807.09q48.16-31.54 103.34-48.29Q421.8-872.13 480-872.13q81.91 0 152.84 30.62 70.94 30.62 124.5 84.17 53.55 53.56 84.17 124.5 30.62 70.93 30.62 152.84 0 58.2-16.75 113.38-16.75 55.18-48.29 103.34Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="20" viewBox="0 -960 960 960" width="20"><path d="M440.41-284.41h79.18V-528h-79.18v243.59Zm68.44-322.47q11.93-11.67 11.93-28.91 0-17.24-11.66-29.12-11.67-11.87-28.91-11.87-17.24 0-29.12 11.66-11.87 11.67-11.87 28.91 0 17.24 11.72 29.12 11.73 11.87 28.85 11.87t29.06-11.66ZM480.28-87.87q-81.19 0-152.62-30.62-71.44-30.62-125-84.17-53.55-53.56-84.17-124.95Q87.87-399 87.87-480.46q0-81.45 30.62-152.38 30.62-70.94 84.17-124.5 53.56-53.55 124.95-84.17 71.39-30.62 152.85-30.62 81.45 0 152.38 30.62 70.94 30.62 124.5 84.17 53.55 53.56 84.17 124.72 30.62 71.16 30.62 152.34 0 81.19-30.62 152.62-30.62 71.44-84.17 125-53.56 53.55-124.72 84.17-71.16 30.62-152.34 30.62Zm-.28-83q129.04 0 219.09-90.04 90.04-90.05 90.04-219.09 0-129.04-90.04-219.09-90.05-90.04-219.09-90.04-129.04 0-219.09 90.04-90.04 90.05-90.04 219.09 0 129.04 90.04 219.09 90.05 90.04 219.09 90.04ZM480-480Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="40" viewBox="0 -960 960 960" width="40"><path d="M445.72-277.05h72.56V-520h-72.56v242.95Zm62.24-326.08q11.36-11.11 11.36-27.54 0-17.17-11.3-28.58-11.3-11.4-28.01-11.4-16.88 0-28.11 11.4-11.22 11.41-11.22 28.5 0 16.53 11.37 27.63 11.36 11.11 27.95 11.11t27.96-11.12ZM480.2-73.3q-84.44 0-158.48-31.96-74.03-31.96-129.27-87.19-55.23-55.24-87.19-129.3Q73.3-395.82 73.3-480.31q0-84.5 31.96-158.58 31.96-74.09 87.17-129t129.28-86.94q74.08-32.03 158.59-32.03t158.61 32.02q74.11 32.02 129 86.91 54.9 54.88 86.92 129.08 32.03 74.2 32.03 158.67 0 84.46-32.03 158.5-32.03 74.03-86.94 129.12t-129.08 87.17Q564.64-73.3 480.2-73.3Zm.13-75.76q138.05 0 234.33-96.51 96.28-96.52 96.28-234.76 0-138.05-96.16-234.33-96.15-96.28-234.86-96.28-137.79 0-234.33 96.16-96.53 96.15-96.53 234.86 0 137.79 96.51 234.33 96.52 96.53 234.76 96.53ZM480-480Z"/></svg>
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M120-240v-80h720v80H120Zm0-200v-80h720v80H120Zm0-200v-80h720v80H120Z"/></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M111.87-228.28v-91h736.26v91H111.87Zm0-206.22v-91h736.26v91H111.87Zm0-206.22v-91h736.26v91H111.87Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="20" viewBox="0 -960 960 960" width="20"><path d="M218.87-135.87q-34.48 0-58.74-24.26-24.26-24.26-24.26-58.74v-522.26q0-34.48 24.26-58.74 24.26-24.26 58.74-24.26H480v83H218.87v522.26h522.26V-480h83v261.13q0 34.48-24.26 58.74-24.26 24.26-58.74 24.26H218.87ZM394.41-336 336-394.41l346.72-346.72H576v-83h248.13V-576h-83v-106.72L394.41-336Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="20" viewBox="0 -960 960 960" width="20"><path d="M764.52-134.91 523.85-375.59q-29.76 21.05-65.44 33.07-35.67 12.02-76.29 12.02-102.19 0-173.86-71.67-71.67-71.68-71.67-173.83t71.67-173.83q71.67-71.67 173.83-71.67 102.15 0 173.82 71.67 71.68 71.68 71.68 173.86 0 40.86-12.02 76.3-12.03 35.43-33.07 64.95l240.91 241.15-58.89 58.66ZM382.09-413.5q67.84 0 115.17-47.33 47.33-47.32 47.33-115.17t-47.33-115.17q-47.33-47.33-115.17-47.33-67.85 0-115.18 47.33-47.32 47.32-47.32 115.17t47.32 115.17q47.33 47.33 115.18 47.33Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M379.28-296.65 664.3-480 379.28-663.35v366.7ZM162.87-151.87q-37.78 0-64.39-26.61t-26.61-64.39v-474.26q0-37.78 26.61-64.39t64.39-26.61h634.26q37.78 0 64.39 26.61t26.61 64.39v474.26q0 37.78-26.61 64.39t-64.39 26.61H162.87Zm0-91h634.26v-474.26H162.87v474.26Zm0 0v-474.26 474.26Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="20" viewBox="0 -960 960 960" width="20"><path d="M380.41-208.59v-83h229.35L183.87-717.48l58.65-58.65 425.89 425.89v-229.35h83v371h-371Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="20" viewBox="0 -960 960 960" width="20"><path d="M41.07-139.93 480-872.13l438.93 732.2H41.07Zm139.91-79.42h598.04L480-717.3 180.98-219.35Zm325.24-56.14q10.98-10.76 10.98-26.21 0-15.45-10.77-26.31t-26.22-10.86q-15.45 0-26.43 10.65t-10.98 26.1q0 15.45 10.77 26.42 10.77 10.98 26.22 10.98t26.43-10.77ZM444-384h72v-189.37h-72V-384Zm36-84.33Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="40" viewBox="0 -960 960 960" width="40"><path d="M34.18-116.57 480-886.86l445.82 770.29H34.18Zm126.17-72.97h639.3L480-741.09 160.35-189.54Zm346.79-59.03q9.9-10.02 9.9-24.47 0-14.46-10.02-24.24-10.01-9.78-24.47-9.78-14.46 0-24.35 9.9-9.9 9.89-9.9 24.35t10.01 24.35q10.02 9.9 24.47 9.9 14.46 0 24.36-10.01ZM449.33-352H516v-213.85h-66.67V-352ZM480-465.31Z"/></svg>
package/build/bundle.ts CHANGED
@@ -19,6 +19,13 @@ import type { SiteVariables } from './types';
19
19
  import timezones from '../src/timezone/timezones.json' with { type: 'json' };
20
20
  import pkg from '../package.json' with { type: 'json' };
21
21
  import { renderMaterialSymbolVariables } from './material-symbols';
22
+ import {
23
+ getSerifFontStack,
24
+ getSerifMonoFontStack,
25
+ renderCustomFontFaceScss,
26
+ renderCustomFontTuningScss,
27
+ renderFontFeatureSettings,
28
+ } from './custom-fonts';
22
29
 
23
30
  interface BunBuildPlugin {
24
31
  name: string;
@@ -62,6 +69,10 @@ function renderThemeScss(siteVariables: SiteVariables): string {
62
69
  const bgTraceLineActive = `hsl(${traceLineActiveHue}deg 100% 86%)`;
63
70
  const bgTraceLineActiveDark = `hsl(${traceLineActiveHue}deg 90% 18%)`;
64
71
  const materialSymbolVariables = renderMaterialSymbolVariables();
72
+ const customFontFaces = renderCustomFontFaceScss(siteVariables.fontOverrides);
73
+ const customFontTuning = renderCustomFontTuningScss(
74
+ siteVariables.fontOverrides,
75
+ );
65
76
 
66
77
  const renderedTemplate = _.template(template)({
67
78
  ...theme,
@@ -73,11 +84,19 @@ function renderThemeScss(siteVariables: SiteVariables): string {
73
84
  linkColorHoverDark,
74
85
  bgTraceLineActive,
75
86
  bgTraceLineActiveDark,
87
+ serifFontStack: getSerifFontStack(siteVariables.fontOverrides),
88
+ serifMonoFontStack: getSerifMonoFontStack(siteVariables.fontOverrides),
89
+ serifFontFeatureSettings: renderFontFeatureSettings(
90
+ siteVariables.fontOverrides?.serif,
91
+ ),
92
+ serifMonoFontFeatureSettings: renderFontFeatureSettings(
93
+ siteVariables.fontOverrides?.serifMono,
94
+ ),
76
95
  });
77
- const rendered = renderedTemplate.replace(
78
- '/* TADA_MATERIAL_SYMBOL_VARIABLES */',
79
- materialSymbolVariables,
80
- );
96
+ const rendered = renderedTemplate
97
+ .replace('/* TADA_CUSTOM_FONT_FACES */', customFontFaces)
98
+ .replace('/* TADA_CUSTOM_FONT_TUNING */', customFontTuning)
99
+ .replace('/* TADA_MATERIAL_SYMBOL_VARIABLES */', materialSymbolVariables);
81
100
 
82
101
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tada-'));
83
102
  const configDir = path.join(tmpDir, 'config');
@@ -0,0 +1,313 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import * as fontkit from 'fontkit';
4
+ import type { FontFamilyOverride, FontOverrides } from './types';
5
+
6
+ export const CUSTOM_FONT_FACE_DEFINITIONS = Object.freeze({
7
+ regular: Object.freeze({ fontStyle: 'normal', fontWeight: 400 }),
8
+ italic: Object.freeze({ fontStyle: 'italic', fontWeight: 400 }),
9
+ bold: Object.freeze({ fontStyle: 'normal', fontWeight: 700 }),
10
+ boldItalic: Object.freeze({ fontStyle: 'italic', fontWeight: 700 }),
11
+ });
12
+
13
+ export const CUSTOM_FONT_FAMILY_DEFINITIONS = Object.freeze({
14
+ serif: Object.freeze({ configKey: 'serif', cssFamily: 'Tada Custom Serif' }),
15
+ serifMono: Object.freeze({
16
+ configKey: 'serifMono',
17
+ cssFamily: 'Tada Custom Serif Mono',
18
+ }),
19
+ });
20
+
21
+ export type CustomFontFamilyKey = keyof typeof CUSTOM_FONT_FAMILY_DEFINITIONS;
22
+ export type CustomFontFaceKey = keyof typeof CUSTOM_FONT_FACE_DEFINITIONS;
23
+
24
+ const FACE_KEYS = Object.freeze(
25
+ Object.keys(CUSTOM_FONT_FACE_DEFINITIONS) as CustomFontFaceKey[],
26
+ );
27
+ const FAMILY_KEYS = Object.freeze(
28
+ Object.keys(CUSTOM_FONT_FAMILY_DEFINITIONS) as CustomFontFamilyKey[],
29
+ );
30
+
31
+ interface ParsedFont {
32
+ availableFeatures: readonly string[];
33
+ }
34
+
35
+ export interface CustomFontValidationOptions {
36
+ fontOverrides: FontOverrides | undefined;
37
+ publicDir: string;
38
+ publicFiles: ReadonlySet<string>;
39
+ readFile?: (filePath: string) => Buffer;
40
+ parseFont?: (contents: Buffer) => ParsedFont;
41
+ }
42
+
43
+ export function isValidPublicWoff2Path(filePath: string): boolean {
44
+ if (
45
+ filePath.length === 0 ||
46
+ filePath.startsWith('/') ||
47
+ /^[A-Za-z]:/.test(filePath) ||
48
+ filePath.includes('\\') ||
49
+ filePath.includes('?') ||
50
+ filePath.includes('#') ||
51
+ !filePath.endsWith('.woff2')
52
+ ) {
53
+ return false;
54
+ }
55
+
56
+ const segments = filePath.split('/');
57
+ return segments.every(
58
+ segment => segment.length > 0 && segment !== '.' && segment !== '..',
59
+ );
60
+ }
61
+
62
+ export function resolvePublicFontPath(
63
+ publicDir: string,
64
+ filePath: string,
65
+ ): string {
66
+ return path.resolve(publicDir, ...filePath.split('/'));
67
+ }
68
+
69
+ export function encodePublicAssetPath(filePath: string): string {
70
+ return filePath
71
+ .split('/')
72
+ .map(segment => encodeURIComponent(segment).replaceAll("'", '%27'))
73
+ .join('/');
74
+ }
75
+
76
+ export function renderFontFeatureSettings(
77
+ family: FontFamilyOverride | undefined,
78
+ ): string {
79
+ return family?.features?.length
80
+ ? family.features.map(feature => `'${feature}'`).join(', ')
81
+ : 'normal';
82
+ }
83
+
84
+ export function renderCustomFontFaceScss(
85
+ fontOverrides: FontOverrides | undefined,
86
+ ): string {
87
+ const rules: string[] = [];
88
+ for (const familyKey of FAMILY_KEYS) {
89
+ const family = fontOverrides?.[familyKey];
90
+ if (!family) {
91
+ continue;
92
+ }
93
+ const cssFamily = CUSTOM_FONT_FAMILY_DEFINITIONS[familyKey].cssFamily;
94
+ for (const faceKey of FACE_KEYS) {
95
+ const filePath = family[faceKey];
96
+ if (!filePath) {
97
+ continue;
98
+ }
99
+ const face = CUSTOM_FONT_FACE_DEFINITIONS[faceKey];
100
+ rules.push(` @font-face {
101
+ font-family: '${cssFamily}';
102
+ font-style: ${face.fontStyle};
103
+ font-weight: ${face.fontWeight};
104
+ src: url('${encodePublicAssetPath(filePath)}') format('woff2');
105
+ font-display: swap;
106
+ }`);
107
+ }
108
+ }
109
+ return rules.join('\n\n');
110
+ }
111
+
112
+ const HEADING_SIZES = Object.freeze({
113
+ h1: 2.5,
114
+ h2: 1.5,
115
+ h3: 1.17,
116
+ h4: 1,
117
+ h5: 0.83,
118
+ h6: 0.67,
119
+ });
120
+
121
+ function formatCssNumber(value: number): string {
122
+ const rounded = Number(value.toFixed(4));
123
+ return (rounded === 0 && value !== 0 ? value : rounded).toString();
124
+ }
125
+
126
+ export function renderCustomFontTuningScss(
127
+ fontOverrides: FontOverrides | undefined,
128
+ ): string {
129
+ const rules: string[] = [];
130
+ const serifTuning = fontOverrides?.serif?.tuning;
131
+ const monoTuning = fontOverrides?.serifMono?.tuning;
132
+ const rootSelector = ":root[data-font-preference='serif']";
133
+
134
+ if (serifTuning?.fontSizeAdjust !== undefined) {
135
+ rules.push(
136
+ ` ${rootSelector} {\n --font-size-adjust: cap-height ${formatCssNumber(serifTuning.fontSizeAdjust)};\n }`,
137
+ );
138
+ }
139
+
140
+ if (monoTuning?.fontSizeAdjust !== undefined) {
141
+ rules.push(
142
+ ` ${rootSelector} {\n --mono-font-size-adjust: cap-height ${formatCssNumber(monoTuning.fontSizeAdjust)};\n }`,
143
+ );
144
+ }
145
+
146
+ if (serifTuning?.scale !== undefined) {
147
+ rules.push(
148
+ ` ${rootSelector} {\n --font-size: ${formatCssNumber(serifTuning.scale)}rem;\n }`,
149
+ );
150
+ }
151
+
152
+ if (serifTuning?.lineHeight !== undefined) {
153
+ rules.push(
154
+ ` ${rootSelector} main.body {\n line-height: ${formatCssNumber(serifTuning.lineHeight)};\n }`,
155
+ );
156
+ }
157
+
158
+ if (
159
+ serifTuning?.scale !== undefined ||
160
+ serifTuning?.lineHeight !== undefined
161
+ ) {
162
+ rules.push(
163
+ ` ${rootSelector} main.body > :is(footer, .appearance-pickers, .slides-header, .file-header) {\n font-size: var(--font-size);\n line-height: var(--line-height);\n }`,
164
+ );
165
+ }
166
+
167
+ if (
168
+ serifTuning?.scale !== undefined ||
169
+ serifTuning?.headingScale !== undefined
170
+ ) {
171
+ const headingScale = serifTuning.headingScale ?? 1;
172
+ for (const [heading, size] of Object.entries(HEADING_SIZES)) {
173
+ rules.push(
174
+ ` ${rootSelector} .main-content ${heading}:not(.file-title) { font-size: ${formatCssNumber(size * headingScale)}rem; }`,
175
+ );
176
+ rules.push(
177
+ ` ${rootSelector} body.is-presenting .main-content .slide ${heading}:not(.file-title) { font-size: ${formatCssNumber(size * headingScale)}em; }`,
178
+ );
179
+ }
180
+ }
181
+
182
+ if (serifTuning?.headingWeight !== undefined) {
183
+ rules.push(
184
+ ` ${rootSelector} .main-content :is(h1, h2, h3, h4, h5, h6):not(.file-title) {\n font-weight: ${serifTuning.headingWeight};\n }`,
185
+ );
186
+ }
187
+
188
+ if (monoTuning?.scale !== undefined || monoTuning?.lineHeight !== undefined) {
189
+ const declarations: string[] = [];
190
+ if (monoTuning.scale !== undefined) {
191
+ declarations.push(
192
+ `--mono-font-size: ${formatCssNumber(monoTuning.scale)}em;`,
193
+ );
194
+ }
195
+ if (monoTuning.lineHeight !== undefined) {
196
+ declarations.push(
197
+ `--mono-line-height: ${formatCssNumber(monoTuning.lineHeight)};`,
198
+ );
199
+ }
200
+ rules.push(` ${rootSelector} {\n ${declarations.join('\n ')}\n }`);
201
+ }
202
+
203
+ return rules.join('\n\n');
204
+ }
205
+
206
+ export function getSerifFontStack(
207
+ fontOverrides: FontOverrides | undefined,
208
+ ): string {
209
+ const custom = fontOverrides?.serif
210
+ ? `'${CUSTOM_FONT_FAMILY_DEFINITIONS.serif.cssFamily}', `
211
+ : '';
212
+ return `${custom}'Source Serif 4', 'Times New Roman', 'Times', serif`;
213
+ }
214
+
215
+ export function getSerifMonoFontStack(
216
+ fontOverrides: FontOverrides | undefined,
217
+ ): string {
218
+ const custom = fontOverrides?.serifMono
219
+ ? `'${CUSTOM_FONT_FAMILY_DEFINITIONS.serifMono.cssFamily}', `
220
+ : '';
221
+ return `${custom}'Libertinus Mono', 'Courier New', 'Courier', monospace`;
222
+ }
223
+
224
+ function defaultParseFont(contents: Buffer): ParsedFont {
225
+ const font = fontkit.create(contents) as fontkit.Font;
226
+ return { availableFeatures: font.availableFeatures };
227
+ }
228
+
229
+ function validateFamily(
230
+ familyKey: CustomFontFamilyKey,
231
+ family: FontFamilyOverride,
232
+ options: Required<
233
+ Pick<CustomFontValidationOptions, 'readFile' | 'parseFont'>
234
+ > &
235
+ Pick<CustomFontValidationOptions, 'publicDir' | 'publicFiles'>,
236
+ ): string[] {
237
+ const diagnostics: string[] = [];
238
+ const parsedFaces: Partial<Record<CustomFontFaceKey, ParsedFont>> = {};
239
+
240
+ for (const faceKey of FACE_KEYS) {
241
+ const filePath = family[faceKey];
242
+ if (!filePath) {
243
+ continue;
244
+ }
245
+ const configPath = `fontOverrides.${familyKey}.${faceKey}`;
246
+ if (!isValidPublicWoff2Path(filePath)) {
247
+ diagnostics.push(
248
+ `${configPath} must be a public-relative POSIX path ending in .woff2`,
249
+ );
250
+ continue;
251
+ }
252
+
253
+ const absolutePath = resolvePublicFontPath(options.publicDir, filePath);
254
+ if (!options.publicFiles.has(absolutePath)) {
255
+ diagnostics.push(`${configPath} "${filePath}" does not exist in public/`);
256
+ continue;
257
+ }
258
+
259
+ let contents: Buffer;
260
+ try {
261
+ contents = options.readFile(absolutePath);
262
+ } catch {
263
+ diagnostics.push(`${configPath} "${filePath}" could not be read`);
264
+ continue;
265
+ }
266
+ if (contents.subarray(0, 4).toString('ascii') !== 'wOF2') {
267
+ diagnostics.push(`${configPath} "${filePath}" is not a WOFF2 font`);
268
+ continue;
269
+ }
270
+ try {
271
+ parsedFaces[faceKey] = options.parseFont(contents);
272
+ } catch {
273
+ diagnostics.push(`${configPath} "${filePath}" is not a valid WOFF2 font`);
274
+ }
275
+ }
276
+
277
+ for (const feature of family.features ?? []) {
278
+ for (const faceKey of FACE_KEYS) {
279
+ const parsed = parsedFaces[faceKey];
280
+ if (parsed && !parsed.availableFeatures.includes(feature)) {
281
+ diagnostics.push(
282
+ `fontOverrides.${familyKey}.features "${feature}" is not supported by fontOverrides.${familyKey}.${faceKey}`,
283
+ );
284
+ }
285
+ }
286
+ }
287
+
288
+ return diagnostics;
289
+ }
290
+
291
+ export function validateCustomFontOverrides(
292
+ options: CustomFontValidationOptions,
293
+ ): string[] {
294
+ const readFile = options.readFile ?? fs.readFileSync;
295
+ const parseFont = options.parseFont ?? defaultParseFont;
296
+ const diagnostics: string[] = [];
297
+
298
+ for (const familyKey of FAMILY_KEYS) {
299
+ const family = options.fontOverrides?.[familyKey];
300
+ if (family) {
301
+ diagnostics.push(
302
+ ...validateFamily(familyKey, family, {
303
+ publicDir: options.publicDir,
304
+ publicFiles: options.publicFiles,
305
+ readFile,
306
+ parseFont,
307
+ }),
308
+ );
309
+ }
310
+ }
311
+
312
+ return diagnostics;
313
+ }
@@ -2,6 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { getPackageDir } from './utils/paths';
4
4
  import { makeLogger } from './log';
5
+ import type { SiteVariables } from './types';
5
6
 
6
7
  const log = makeLogger(import.meta.url);
7
8
  const FONTS_DIR = path.join(getPackageDir(), 'fonts');
@@ -17,10 +18,35 @@ export const DEFAULT_FONT_PRELOAD_FILES = {
17
18
  ],
18
19
  } as const;
19
20
 
21
+ export interface DefaultFontPreloadFile {
22
+ filePath: string;
23
+ source: 'package' | 'public';
24
+ }
25
+
20
26
  export function getDefaultFontPreloadFiles(
21
- defaultFont: 'sans' | 'serif' | undefined,
22
- ): readonly string[] {
23
- return DEFAULT_FONT_PRELOAD_FILES[defaultFont ?? 'sans'];
27
+ siteVariables: Pick<SiteVariables, 'defaultFont' | 'fontOverrides'>,
28
+ ): readonly DefaultFontPreloadFile[] {
29
+ if ((siteVariables.defaultFont ?? 'sans') === 'sans') {
30
+ return DEFAULT_FONT_PRELOAD_FILES.sans.map(filePath => ({
31
+ filePath,
32
+ source: 'package',
33
+ }));
34
+ }
35
+
36
+ return [
37
+ siteVariables.fontOverrides?.serif?.regular
38
+ ? {
39
+ filePath: siteVariables.fontOverrides.serif.regular,
40
+ source: 'public',
41
+ }
42
+ : { filePath: DEFAULT_FONT_PRELOAD_FILES.serif[0], source: 'package' },
43
+ siteVariables.fontOverrides?.serifMono?.regular
44
+ ? {
45
+ filePath: siteVariables.fontOverrides.serifMono.regular,
46
+ source: 'public',
47
+ }
48
+ : { filePath: DEFAULT_FONT_PRELOAD_FILES.serif[1], source: 'package' },
49
+ ];
24
50
  }
25
51
 
26
52
  export function copyFonts(distDir: string): void {
@@ -5,43 +5,78 @@ import { getPackageDir } from './utils/paths';
5
5
  export const MATERIAL_SYMBOL_CONFIG = Object.freeze({
6
6
  family: 'Material Symbols Outlined',
7
7
  fill: 0,
8
- weight: 400,
8
+ weight: 500,
9
9
  grade: 0,
10
- opticalSize: 24,
10
+ opticalSizes: Object.freeze([20, 24, 40] as const),
11
11
  viewBox: '0 -960 960 960',
12
- source:
13
- 'https://github.com/google/material-design-icons/tree/master/symbols/web',
12
+ source: 'https://fonts.google.com/icons',
14
13
  } as const);
15
14
 
16
15
  export const MATERIAL_SYMBOLS = Object.freeze({
17
- tada: Object.freeze({ cssVariable: '--icon-tada', symbol: 'celebration' }),
16
+ tada: Object.freeze({
17
+ cssVariable: '--icon-tada',
18
+ symbol: 'celebration',
19
+ opticalSize: 24,
20
+ }),
18
21
  contrastStandard: Object.freeze({
19
22
  cssVariable: '--icon-contrast-standard',
20
23
  symbol: 'contrast_rtl_off',
24
+ opticalSize: 20,
21
25
  }),
22
26
  contrastHigh: Object.freeze({
23
27
  cssVariable: '--icon-contrast-high',
24
28
  symbol: 'contrast',
29
+ opticalSize: 20,
30
+ }),
31
+ info: Object.freeze({
32
+ cssVariable: '--icon-info',
33
+ symbol: 'info',
34
+ opticalSize: 40,
35
+ }),
36
+ infoCompact: Object.freeze({
37
+ cssVariable: '--icon-info-compact',
38
+ symbol: 'info',
39
+ opticalSize: 20,
40
+ }),
41
+ warning: Object.freeze({
42
+ cssVariable: '--icon-warning',
43
+ symbol: 'warning',
44
+ opticalSize: 40,
45
+ }),
46
+ warningCompact: Object.freeze({
47
+ cssVariable: '--icon-warning-compact',
48
+ symbol: 'warning',
49
+ opticalSize: 20,
50
+ }),
51
+ parent: Object.freeze({
52
+ cssVariable: '--icon-parent',
53
+ symbol: 'south_east',
54
+ opticalSize: 20,
55
+ }),
56
+ search: Object.freeze({
57
+ cssVariable: '--icon-search',
58
+ symbol: 'search',
59
+ opticalSize: 20,
25
60
  }),
26
- info: Object.freeze({ cssVariable: '--icon-info', symbol: 'info' }),
27
- warning: Object.freeze({ cssVariable: '--icon-warning', symbol: 'warning' }),
28
- parent: Object.freeze({ cssVariable: '--icon-parent', symbol: 'south_east' }),
29
- search: Object.freeze({ cssVariable: '--icon-search', symbol: 'search' }),
30
61
  externalLink: Object.freeze({
31
62
  cssVariable: '--icon-external-link',
32
- symbol: 'north_east',
63
+ symbol: 'open_in_new',
64
+ opticalSize: 20,
33
65
  }),
34
- headingAnchor: Object.freeze({
35
- cssVariable: '--icon-heading-anchor',
36
- symbol: 'tag',
66
+ headingPresent: Object.freeze({
67
+ cssVariable: '--icon-heading-present',
68
+ symbol: 'smart_display',
69
+ opticalSize: 24,
37
70
  }),
38
71
  headerMenu: Object.freeze({
39
72
  cssVariable: '--icon-header-menu',
40
73
  symbol: 'menu',
74
+ opticalSize: 24,
41
75
  }),
42
76
  headerClose: Object.freeze({
43
77
  cssVariable: '--icon-header-close',
44
78
  symbol: 'close',
79
+ opticalSize: 24,
45
80
  }),
46
81
  } as const);
47
82
 
@@ -56,11 +91,14 @@ function getAssetPath(packageDir: string, symbol: MaterialSymbol): string {
56
91
  return path.join(
57
92
  packageDir,
58
93
  'assets/material-symbols',
59
- `${symbol.symbol}_${MATERIAL_SYMBOL_CONFIG.opticalSize}px.svg`,
94
+ `${symbol.symbol}_${symbol.opticalSize}px.svg`,
60
95
  );
61
96
  }
62
97
 
63
- function isConfiguredSvg(svg: string): boolean {
98
+ function isConfiguredSvg(
99
+ svg: string,
100
+ opticalSize: MaterialSymbol['opticalSize'],
101
+ ): boolean {
64
102
  const svgTag = svg.match(/<svg\b[^>]*>/)?.[0];
65
103
  if (!svgTag) {
66
104
  return false;
@@ -70,8 +108,8 @@ function isConfiguredSvg(svg: string): boolean {
70
108
  new RegExp(`\\b${name}=["']${value}["']`).test(svgTag);
71
109
 
72
110
  return (
73
- hasAttribute('width', String(MATERIAL_SYMBOL_CONFIG.opticalSize)) &&
74
- hasAttribute('height', String(MATERIAL_SYMBOL_CONFIG.opticalSize)) &&
111
+ hasAttribute('width', String(opticalSize)) &&
112
+ hasAttribute('height', String(opticalSize)) &&
75
113
  hasAttribute('viewBox', MATERIAL_SYMBOL_CONFIG.viewBox)
76
114
  );
77
115
  }
@@ -94,14 +132,14 @@ export function renderMaterialSymbolVariables({
94
132
  );
95
133
  }
96
134
 
97
- if (!isConfiguredSvg(svg)) {
135
+ if (!isConfiguredSvg(svg, symbol.opticalSize)) {
98
136
  throw new Error(
99
- `Material Symbol "${symbol.symbol}" does not match the configured ${MATERIAL_SYMBOL_CONFIG.opticalSize}px SVG format: ${assetPath}`,
137
+ `Material Symbol "${symbol.symbol}" does not match the configured ${symbol.opticalSize}px SVG format: ${assetPath}`,
100
138
  );
101
139
  }
102
140
 
103
141
  const dataUri = `data:image/svg+xml,${encodeURIComponent(svg.trim())}`;
104
- return ` ${symbol.cssVariable}: url("${dataUri}");`;
142
+ return ` ${symbol.cssVariable}: url("${dataUri}");\n ${symbol.cssVariable}-size: ${symbol.opticalSize}px;`;
105
143
  })
106
144
  .join('\n');
107
145
  }
package/build/pipeline.ts CHANGED
@@ -45,7 +45,7 @@ export async function runPipeline(
45
45
  let published = false;
46
46
 
47
47
  try {
48
- const configDiagnostics = validateConfig(scan);
48
+ const configDiagnostics = validateConfig(scan, siteVariables);
49
49
  if (configDiagnostics.length > 0) {
50
50
  throw new Error(configDiagnostics[0].message);
51
51
  }