@glossarist/concept-browser 0.7.107 → 0.7.109

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.
@@ -1,10 +1,22 @@
1
1
  // === Branding ===
2
2
 
3
+ export type FontCategory = 'serif' | 'sans-serif' | 'monospace';
4
+
3
5
  export interface FontConfig {
4
6
  family: string;
5
7
  source: 'google' | 'url' | 'local';
6
8
  weights?: number[];
7
9
  url?: string;
10
+ /** Font category — controls the fallback chain when the primary family
11
+ * fails to load. Defaults preserve prior behavior: `'serif'` for header,
12
+ * `'sans-serif'` for body, `'monospace'` for mono.
13
+ *
14
+ * Set explicitly when the consumer's chosen family doesn't match the
15
+ * slot's default category — e.g. set `header: { family: 'Inter',
16
+ * category: 'sans-serif' }` so the fallback chain is
17
+ * `'Inter', system-ui, sans-serif` instead of `'Inter', Georgia, serif`.
18
+ */
19
+ category?: FontCategory;
8
20
  }
9
21
 
10
22
  export interface LogoConfig {
@@ -18,16 +30,31 @@ export interface LogoConfig {
18
30
  localDark?: string;
19
31
  }
20
32
 
33
+ export interface FaviconIcon {
34
+ /** The `rel` attribute: "icon", "shortcut icon", "apple-touch-icon", "manifest". */
35
+ rel: string;
36
+ /** Icon file path. Bare filenames ("favicon.ico") get BASE_PATH-prefixed
37
+ * automatically; absolute URLs and root-relative paths are emitted as-is. */
38
+ href: string;
39
+ /** Optional MIME type hint (e.g., "image/png", "image/svg+xml"). */
40
+ type?: string;
41
+ /** Optional sizes attribute (e.g., "180x180", "any"). */
42
+ sizes?: string;
43
+ }
44
+
21
45
  export interface FaviconConfig {
22
- /** Path prefix for all favicon URLs. Defaults to '/', set to a sub-path
23
- * when deploying under BASE_PATH != '/'. */
46
+ /** Path prefix for all favicon URLs. Defaults to BASE_PATH-aware '/'. */
24
47
  base_path?: string;
25
- /** Skip emitting the default <link rel="icon"> and <link rel="apple-touch-icon">
26
- * tags. Use when the consumer provides their own RFG-compatible markup via
27
- * `links_html`. Default: false (emit default tags). */
48
+ /** Skip emitting the default <link> block. Use when the consumer provides
49
+ * their own icons via `icons` or wants zero favicon <link> tags. */
28
50
  skip_default_links?: boolean;
29
- /** Raw HTML string (one or more <link> tags) to emit in <head> instead of
30
- * the default favicon block. RealFaviconGenerator output goes here. */
51
+ /** Consumer-declared icons (DATA, not HTML). Replaces the default 16-link
52
+ * set when non-empty. Each entry renders to a single <link> tag with
53
+ * BASE_PATH-aware href. */
54
+ icons?: FaviconIcon[];
55
+ /** @deprecated Use `icons` instead. Raw HTML emitted verbatim — impossible
56
+ * to validate, can't be safely BASE_PATH-rewritten. Kept for backward
57
+ * compat; emits a console warning when used. */
31
58
  links_html?: string;
32
59
  /** Consumer-side directory (relative to cwd) holding canonical favicon files.
33
60
  * The CLI's favicon step copies these into public/, overriding defaults. */
@@ -38,8 +65,18 @@ export interface SiteBranding {
38
65
  primaryColor?: string;
39
66
  darkColor?: string;
40
67
  fonts?: {
68
+ /** Most prominent display text on each page — home hero, concept name
69
+ * on the detail page, group/dataset title. No category is dictated;
70
+ * pick `serif`, `sans-serif`, or `monospace` for any slot. */
71
+ title?: FontConfig;
72
+ /** Section headings (h2–h6). */
73
+ heading?: FontConfig;
74
+ /** Backward-compat alias for `heading` (deprecated). */
41
75
  header?: FontConfig;
76
+ /** Paragraph text, lists, table cells. */
42
77
  body?: FontConfig;
78
+ /** Code blocks, inline code, kbd. */
79
+ mono?: FontConfig;
43
80
  };
44
81
  logo?: LogoConfig;
45
82
  footerLogo?: LogoConfig;
@@ -3,6 +3,7 @@ import type { PageConfig, SiteColors } from './types';
3
3
  import type { DatasetGroup } from './types';
4
4
  import { synthesizePages } from './page-types';
5
5
  import { locale } from '../i18n';
6
+ import { fontStack, DEFAULT_FONTS, DEFAULT_CATEGORY } from '../utils/font-stack';
6
7
 
7
8
  export interface RuntimeSiteConfig {
8
9
  id: string;
@@ -21,8 +22,11 @@ export interface RuntimeSiteConfig {
21
22
  primaryColor?: string;
22
23
  darkColor?: string;
23
24
  fonts?: {
24
- header?: { family: string; source: string; weights?: number[]; url?: string };
25
- body?: { family: string; source: string; weights?: number[]; url?: string };
25
+ title?: { family: string; source: string; weights?: number[]; url?: string; category?: 'serif' | 'sans-serif' | 'monospace' };
26
+ heading?: { family: string; source: string; weights?: number[]; url?: string; category?: 'serif' | 'sans-serif' | 'monospace' };
27
+ header?: { family: string; source: string; weights?: number[]; url?: string; category?: 'serif' | 'sans-serif' | 'monospace' }; // deprecated alias for heading
28
+ body?: { family: string; source: string; weights?: number[]; url?: string; category?: 'serif' | 'sans-serif' | 'monospace' };
29
+ mono?: { family: string; source: string; weights?: number[]; url?: string; category?: 'serif' | 'sans-serif' | 'monospace' };
26
30
  };
27
31
  logo?: { path: string; alt: string; url?: string; light?: string; dark?: string };
28
32
  footerLogo?: { path: string; alt: string; url?: string; light?: string; dark?: string };
@@ -95,13 +99,25 @@ function applyBranding(config: RuntimeSiteConfig) {
95
99
  root.style.setProperty('--brand-dark', b.darkColor);
96
100
  }
97
101
 
98
- if (b.fonts?.header) {
99
- loadFont(b.fonts.header);
100
- root.style.setProperty('--font-header', `'${b.fonts.header.family}', Georgia, serif`);
102
+ if (b.fonts?.title) {
103
+ loadFont(b.fonts.title);
104
+ root.style.setProperty('--font-title', fontStack(b.fonts.title, DEFAULT_CATEGORY.title) ?? DEFAULT_FONTS.title);
105
+ }
106
+ // heading is the new slot name; header is the backward-compat alias.
107
+ const headingFont = b.fonts?.heading ?? b.fonts?.header;
108
+ if (headingFont) {
109
+ loadFont(headingFont);
110
+ const stack = fontStack(headingFont, DEFAULT_CATEGORY.heading) ?? DEFAULT_FONTS.heading;
111
+ root.style.setProperty('--font-heading', stack);
112
+ root.style.setProperty('--font-header', stack);
101
113
  }
102
114
  if (b.fonts?.body) {
103
115
  loadFont(b.fonts.body);
104
- root.style.setProperty('--font-body', `'${b.fonts.body.family}', system-ui, sans-serif`);
116
+ root.style.setProperty('--font-body', fontStack(b.fonts.body, DEFAULT_CATEGORY.body) ?? DEFAULT_FONTS.body);
117
+ }
118
+ if (b.fonts?.mono) {
119
+ loadFont(b.fonts.mono);
120
+ root.style.setProperty('--font-mono', fontStack(b.fonts.mono, DEFAULT_CATEGORY.mono) ?? DEFAULT_FONTS.mono);
105
121
  }
106
122
  }
107
123
 
@@ -2,6 +2,14 @@
2
2
  import '@/styles/tailwind.css';
3
3
  import AppIsland from '../islands/AppIsland.vue';
4
4
  import { getSiteConfig } from '../lib/site-config';
5
+ import {
6
+ fontStack,
7
+ DEFAULT_FONTS,
8
+ DEFAULT_CATEGORY,
9
+ type FontConfigLike,
10
+ } from '../utils/font-stack';
11
+ import { resolveFaviconBase } from '../utils/favicon-base';
12
+ import { buildDefaultFaviconLinks, renderFaviconLinkTag } from '../utils/favicon-links';
5
13
 
6
14
  interface Props {
7
15
  title: string;
@@ -14,75 +22,111 @@ const { title, description = '', conceptTerm, conceptDefinition } = Astro.props;
14
22
  const siteConfig = await getSiteConfig();
15
23
 
16
24
  // Derive Google Fonts URL from siteConfig branding (build-time SSG).
17
- // Falls back to the default DM Sans / DM Serif Display / JetBrains Mono
18
- // when no branding.fonts is declared.
25
+ // Includes every slot the consumer declared (title / heading / body / mono,
26
+ // plus the deprecated `header` alias). Falls back to the default
27
+ // DM Sans / DM Serif Display / JetBrains Mono when nothing is declared.
19
28
  const brandingFonts = siteConfig?.branding?.fonts;
29
+ const headingFont = brandingFonts?.heading ?? brandingFonts?.header; // header = deprecated alias
30
+ const fontSlots = [
31
+ brandingFonts?.title,
32
+ headingFont,
33
+ brandingFonts?.body,
34
+ brandingFonts?.mono,
35
+ ];
20
36
  const fontParts: string[] = [];
21
- if (brandingFonts?.header?.source === 'google' && brandingFonts.header.family) {
22
- const w = (brandingFonts.header.weights || [400, 700]).join(';');
23
- fontParts.push(`family=${brandingFonts.header.family.replace(/ /g, '+')}:wght@${w}`);
24
- }
25
- if (brandingFonts?.body?.source === 'google' && brandingFonts.body.family) {
26
- const w = (brandingFonts.body.weights || [400, 700]).join(';');
27
- fontParts.push(`family=${brandingFonts.body.family.replace(/ /g, '+')}:wght@${w}`);
37
+ for (const f of fontSlots) {
38
+ if (f && (f as FontConfigLike).source === 'google' && (f as FontConfigLike).family) {
39
+ const w = (f.weights || [400, 700]).join(';');
40
+ fontParts.push(`family=${(f as FontConfigLike).family.replace(/ /g, '+')}:wght@${w}`);
41
+ }
28
42
  }
29
43
  const fontsUrl = fontParts.length > 0
30
44
  ? `https://fonts.googleapis.com/css2?${fontParts.join('&')}&display=swap`
31
45
  : 'https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=DM+Serif+Display:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap';
32
46
 
33
- const fontHeader = brandingFonts?.header?.family
34
- ? `'${brandingFonts.header.family}', Georgia, serif`
35
- : `'DM Serif Display', Georgia, serif`;
36
- const fontBody = brandingFonts?.body?.family
37
- ? `'${brandingFonts.body.family}', system-ui, sans-serif`
38
- : `'DM Sans', system-ui, sans-serif`;
47
+ // Build CSS font-family stacks via the shared SSOT. Consumers can pick
48
+ // any category for any slot — e.g. sans-serif title + serif heading —
49
+ // and the fallback chain follows the declared category.
50
+ const fontTitle = fontStack(brandingFonts?.title, DEFAULT_CATEGORY.title) ?? DEFAULT_FONTS.title;
51
+ const fontHeading = fontStack(headingFont, DEFAULT_CATEGORY.heading) ?? DEFAULT_FONTS.heading;
52
+ const fontBody = fontStack(brandingFonts?.body, DEFAULT_CATEGORY.body) ?? DEFAULT_FONTS.body;
53
+ const fontMono = fontStack(brandingFonts?.mono, DEFAULT_CATEGORY.mono) ?? DEFAULT_FONTS.mono;
39
54
 
40
- // Favicon configuration (issue #173).
55
+ // Favicon configuration (issues #173, #177, and the data-driven refactor).
56
+ //
57
+ // Consumers provide their own favicon set in one of these ways:
41
58
  //
42
- // Consumers provide their own favicon set in one of two ways:
59
+ // 1. Declare icons as DATA (recommended):
60
+ // branding:
61
+ // favicon:
62
+ // icons:
63
+ // - rel: icon
64
+ // type: image/svg+xml
65
+ // href: favicon.svg
66
+ // - rel: icon
67
+ // type: image/png
68
+ // sizes: 96x96
69
+ // href: favicon-96x96.png
70
+ // - rel: shortcut icon
71
+ // href: favicon.ico
72
+ // - rel: apple-touch-icon
73
+ // sizes: 180x180
74
+ // href: apple-touch-icon.png
75
+ // - rel: manifest
76
+ // href: site.webmanifest
77
+ // The hrefs are filenames (or absolute URLs) — the layout applies
78
+ // the correct BASE_PATH prefix automatically.
43
79
  //
44
- // 1. Drop canonical files at `branding.favicon.source_dir` (relative
45
- // to the consumer's cwd). The CLI's existing favicon step already
46
- // copies these into public/ overriding the defaults — this layout
47
- // just needs to emit the right <link> tags.
80
+ // 2. Drop canonical files at `branding.favicon.source_dir` (relative
81
+ // to the consumer's cwd). The CLI copies these into public/, and
82
+ // the layout emits the default 16-link set with BASE_PATH-aware URLs.
48
83
  //
49
- // 2. Set `branding.favicon.skip_default_links: true` and inject
50
- // custom <link> tags via the `branding.favicon.links_html`
51
- // string (e.g. RealFaviconGenerator output). The layout emits
52
- // that verbatim and skips the default tag block.
84
+ // 3. Set `branding.favicon.skip_default_links: true` to emit nothing
85
+ // (consumer takes full control via their own layout patching).
53
86
  //
54
- // When neither is set, today's default behavior is preserved.
87
+ // `links_html` is DEPRECATED — raw HTML can't be safely BASE_PATH-rewritten
88
+ // or validated. It still works for backward compat, with a console warning.
55
89
  const faviconCfg = siteConfig?.branding?.favicon ?? {};
56
- const skipDefaultFaviconLinks = faviconCfg.skip_default_links === true;
57
- const customFaviconLinks: string = faviconCfg.links_html ?? '';
58
- const faviconBase = faviconCfg.base_path ?? '/';
90
+ const faviconCfgIsObject =
91
+ faviconCfg !== null && typeof faviconCfg === 'object' && !Array.isArray(faviconCfg);
92
+ const skipDefaultFaviconLinks = faviconCfgIsObject && faviconCfg.skip_default_links === true;
93
+ const consumerIcons = faviconCfgIsObject ? (faviconCfg.icons ?? null) : null;
94
+ const deprecatedLinksHtml: string = faviconCfgIsObject ? (faviconCfg.links_html ?? '') : '';
95
+ if (deprecatedLinksHtml) {
96
+ console.warn(
97
+ 'branding.favicon.links_html is deprecated. Use branding.favicon.icons (data) instead — ' +
98
+ 'see src/utils/favicon-links.ts for the schema. links_html will be removed in a future release.',
99
+ );
100
+ }
101
+
102
+ // BASE_PATH-aware favicon URL prefix. Walks the configuration cascade:
103
+ // branding.favicon.base_path → siteConfig.basePath → import.meta.env.BASE_URL → '/'.
104
+ // Always ends with '/' so `${faviconBase}favicon.ico` resolves correctly
105
+ // whether the deployment is at '/' or '/cie-eilv/'.
106
+ //
107
+ // See src/utils/favicon-base.ts for the shared resolver + regression tests.
108
+ const faviconBaseInput = {
109
+ brandingFaviconBasePath: typeof faviconCfg === 'object' ? faviconCfg.base_path : undefined,
110
+ siteConfigBasePath: siteConfig?.basePath,
111
+ astroBaseUrl: import.meta.env.BASE_URL,
112
+ };
113
+ const faviconBase = resolveFaviconBase(faviconBaseInput);
114
+ // Build the favicon <link> block from data (consumer-declared icons or
115
+ // the default 16-link set). BASE_PATH is applied to every href.
116
+ const faviconLinks = buildFaviconLinks({
117
+ ...faviconBaseInput,
118
+ icons: consumerIcons ?? undefined,
119
+ }).map(renderFaviconLinkTag);
59
120
  ---
60
121
 
61
122
  <!DOCTYPE html>
62
123
  <html lang="en">
63
124
  <head>
64
125
  <meta charset="utf-8" />
65
- {!skipDefaultFaviconLinks && (
66
- <>
67
- <link rel="icon" type="image/x-icon" href={`${faviconBase}favicon.ico`} />
68
- <link rel="icon" type="image/svg+xml" href={`${faviconBase}favicon.svg`} />
69
- <link rel="icon" type="image/png" sizes="16x16" href={`${faviconBase}favicon-16x16.png`} />
70
- <link rel="icon" type="image/png" sizes="32x32" href={`${faviconBase}favicon-32x32.png`} />
71
- <link rel="icon" type="image/png" sizes="48x48" href={`${faviconBase}favicon-48x48.png`} />
72
- <link rel="apple-touch-icon" sizes="57x57" href={`${faviconBase}apple-touch-icon-57x57.png`} />
73
- <link rel="apple-touch-icon" sizes="60x60" href={`${faviconBase}apple-touch-icon-60x60.png`} />
74
- <link rel="apple-touch-icon" sizes="72x72" href={`${faviconBase}apple-touch-icon-72x72.png`} />
75
- <link rel="apple-touch-icon" sizes="76x76" href={`${faviconBase}apple-touch-icon-76x76.png`} />
76
- <link rel="apple-touch-icon" sizes="114x114" href={`${faviconBase}apple-touch-icon-114x114.png`} />
77
- <link rel="apple-touch-icon" sizes="120x120" href={`${faviconBase}apple-touch-icon-120x120.png`} />
78
- <link rel="apple-touch-icon" sizes="144x144" href={`${faviconBase}apple-touch-icon-144x144.png`} />
79
- <link rel="apple-touch-icon" sizes="152x152" href={`${faviconBase}apple-touch-icon-152x152.png`} />
80
- <link rel="apple-touch-icon" sizes="167x167" href={`${faviconBase}apple-touch-icon-167x167.png`} />
81
- <link rel="apple-touch-icon" sizes="180x180" href={`${faviconBase}apple-touch-icon-180x180.png`} />
82
- <link rel="apple-touch-icon" sizes="1024x1024" href={`${faviconBase}apple-touch-icon-1024x1024.png`} />
83
- </>
126
+ {!skipDefaultFaviconLinks && faviconLinks.length > 0 && (
127
+ <Fragment set:html={faviconLinks.join('\n ')} />
84
128
  )}
85
- {customFaviconLinks && <Fragment set:html={customFaviconLinks} />}
129
+ {deprecatedLinksHtml && <Fragment set:html={deprecatedLinksHtml} />}
86
130
  <meta name="apple-mobile-web-app-capable" content="yes" />
87
131
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
88
132
  <meta name="apple-mobile-web-app-title" content={siteConfig.title} />
@@ -93,7 +137,7 @@ const faviconBase = faviconCfg.base_path ?? '/';
93
137
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
94
138
  <link rel="preload" as="style" href={fontsUrl} onload="this.rel='stylesheet'">
95
139
  <noscript><link href={fontsUrl} rel="stylesheet"></noscript>
96
- <Fragment set:html={`<style>:root{--font-header:${fontHeader};--font-body:${fontBody}}</style>`} />
140
+ <Fragment set:html={`<style>:root{--font-title:${fontTitle};--font-heading:${fontHeading};--font-header:${fontHeading};--font-body:${fontBody};--font-mono:${fontMono}}</style>`} />
97
141
  <script is:inline>
98
142
  (function() {
99
143
  const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('theme='));
@@ -60,6 +60,8 @@
60
60
  --brand-primary: #2563eb;
61
61
  --brand-primary-rgb: 37, 99, 235;
62
62
  --brand-dark: #1e293b;
63
+ --font-title: "DM Serif Display", Georgia, serif;
64
+ --font-heading: "DM Serif Display", Georgia, serif;
63
65
  --font-header: "DM Serif Display", Georgia, serif;
64
66
  --font-body: "DM Sans", system-ui, sans-serif;
65
67
  --gold-accent: #B8935A;
@@ -73,6 +75,9 @@
73
75
  -webkit-font-smoothing: antialiased;
74
76
  -moz-osx-font-smoothing: grayscale;
75
77
  }
78
+ .font-title {
79
+ font-family: var(--font-title);
80
+ }
76
81
  h1, h2, h3, .font-serif {
77
82
  font-family: var(--font-header);
78
83
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Favicon base path resolver — shared SSOT for the URL prefix that
3
+ * goes in front of every favicon `<link href="...">`.
4
+ *
5
+ * Used by:
6
+ * - `src/layouts/Default.astro` (build-time SSG <link> emission)
7
+ * - `cli/index.mjs` / `cli/index.ts` (runtime <link> rewriting)
8
+ *
9
+ * Why this exists: a deployment at `/cie-eilv/` has its favicon files
10
+ * at `/cie-eilv/favicon.ico`, NOT `/favicon.ico`. Hardcoding `/` as
11
+ * the URL prefix (which 0.7.106 did) caused 404s on every sub-path
12
+ * deployment. This resolver walks the configuration cascade to find
13
+ * the right prefix.
14
+ */
15
+
16
+ export interface FaviconBaseInput {
17
+ /** Explicit consumer override via `branding.favicon.base_path`. */
18
+ brandingFaviconBasePath?: string;
19
+ /** Consumer's declared sub-path via `site-config.yml:basePath`. */
20
+ siteConfigBasePath?: string;
21
+ /** Build-time BASE_PATH from Astro/Vite (`import.meta.env.BASE_URL`). */
22
+ astroBaseUrl?: string;
23
+ }
24
+
25
+ /**
26
+ * Resolve the URL prefix for favicon links. Always returns a string
27
+ * ending with `/` so callers can do `${base}favicon.ico` without
28
+ * worrying about the separator.
29
+ *
30
+ * Cascade (first non-empty wins):
31
+ * 1. branding.favicon.base_path (explicit per-deployment override)
32
+ * 2. siteConfig.basePath (consumer's declared sub-path)
33
+ * 3. import.meta.env.BASE_URL (build-time BASE_PATH)
34
+ * 4. '/' (last resort — root deployment)
35
+ */
36
+ export function resolveFaviconBase(input: FaviconBaseInput): string {
37
+ const candidates = [
38
+ input.brandingFaviconBasePath,
39
+ input.siteConfigBasePath,
40
+ input.astroBaseUrl,
41
+ '/',
42
+ ];
43
+ const raw = candidates.find(c => c != null && c.length > 0) ?? '/';
44
+ return raw.endsWith('/') ? raw : `${raw}/`;
45
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Favicon link-tag builder — shared SSOT for the `<link rel="icon">`
3
+ * block emitted in `<head>`.
4
+ *
5
+ * Used by:
6
+ * - `src/layouts/Default.astro` (build-time SSG)
7
+ * - `cli/index.mjs` (consumer-side favicon pipeline)
8
+ *
9
+ * Design principle: declare icons as DATA, not HTML. Consumers describe
10
+ * their favicon set via `branding.favicon.icons: [{ rel, href, type?,
11
+ * sizes? }]`. The href is a filename (or path relative to the deployment
12
+ * root); the system applies the correct BASE_PATH prefix and renders
13
+ * well-formed `<link>` tags. No raw HTML in YAML.
14
+ *
15
+ * Why data over HTML: HTML-in-YAML is impossible to validate, can't be
16
+ * BASE_PATH-rewritten safely (regex on user-supplied markup is brittle),
17
+ * and forces the consumer to know the exact attribute order/shape the
18
+ * browser expects. Data lets us validate, normalize, and render.
19
+ */
20
+
21
+ import { resolveFaviconBase } from './favicon-base';
22
+
23
+ export interface FaviconIcon {
24
+ /** The `rel` attribute. Most common: "icon", "shortcut icon",
25
+ * "apple-touch-icon", "manifest". */
26
+ rel: string;
27
+ /** The icon file path. May be either:
28
+ * - A bare filename ("favicon.ico") — gets prefixed with the resolved
29
+ * base path automatically.
30
+ * - An absolute URL ("https://...") or root-relative path ("/x.png")
31
+ * — emitted as-is, no prefixing.
32
+ */
33
+ href: string;
34
+ /** Optional MIME type hint. */
35
+ type?: string;
36
+ /** Optional `sizes` attribute (e.g., "180x180", "any"). */
37
+ sizes?: string;
38
+ }
39
+
40
+ export interface FaviconLinkTag {
41
+ rel: string;
42
+ type?: string;
43
+ sizes?: string;
44
+ href: string;
45
+ }
46
+
47
+ /** Default favicon file set emitted by the CLI's `favicons` pipeline.
48
+ * Used when the consumer doesn't declare `branding.favicon.icons`. */
49
+ const DEFAULT_FAVICON_FILES: readonly FaviconIcon[] = [
50
+ { rel: 'icon', type: 'image/x-icon', href: 'favicon.ico' },
51
+ { rel: 'icon', type: 'image/svg+xml', href: 'favicon.svg' },
52
+ { rel: 'icon', type: 'image/png', sizes: '16x16', href: 'favicon-16x16.png' },
53
+ { rel: 'icon', type: 'image/png', sizes: '32x32', href: 'favicon-32x32.png' },
54
+ { rel: 'icon', type: 'image/png', sizes: '48x48', href: 'favicon-48x48.png' },
55
+ { rel: 'apple-touch-icon', sizes: '57x57', href: 'apple-touch-icon-57x57.png' },
56
+ { rel: 'apple-touch-icon', sizes: '60x60', href: 'apple-touch-icon-60x60.png' },
57
+ { rel: 'apple-touch-icon', sizes: '72x72', href: 'apple-touch-icon-72x72.png' },
58
+ { rel: 'apple-touch-icon', sizes: '76x76', href: 'apple-touch-icon-76x76.png' },
59
+ { rel: 'apple-touch-icon', sizes: '114x114',href: 'apple-touch-icon-114x114.png' },
60
+ { rel: 'apple-touch-icon', sizes: '120x120',href: 'apple-touch-icon-120x120.png' },
61
+ { rel: 'apple-touch-icon', sizes: '144x144',href: 'apple-touch-icon-144x144.png' },
62
+ { rel: 'apple-touch-icon', sizes: '152x152',href: 'apple-touch-icon-152x152.png' },
63
+ { rel: 'apple-touch-icon', sizes: '167x167',href: 'apple-touch-icon-167x167.png' },
64
+ { rel: 'apple-touch-icon', sizes: '180x180',href: 'apple-touch-icon-180x180.png' },
65
+ { rel: 'apple-touch-icon', sizes: '1024x1024',href: 'apple-touch-icon-1024x1024.png' },
66
+ ];
67
+
68
+ export interface BuildFaviconLinksInput {
69
+ /** Explicit consumer override via `branding.favicon.base_path`. */
70
+ brandingFaviconBasePath?: string;
71
+ /** Consumer's declared sub-path via `site-config.yml:basePath`. */
72
+ siteConfigBasePath?: string;
73
+ /** Build-time BASE_PATH from Astro/Vite (`import.meta.env.BASE_URL`). */
74
+ astroBaseUrl?: string;
75
+ /** Consumer-declared icons. When set, replaces the default set. */
76
+ icons?: readonly FaviconIcon[];
77
+ }
78
+
79
+ /**
80
+ * Build the list of favicon link tags with BASE_PATH-aware URLs.
81
+ *
82
+ * - If `input.icons` is provided (non-empty), uses that set; otherwise
83
+ * falls back to the default 16-link set.
84
+ * - For each icon, the href is normalized:
85
+ * - Filenames ("favicon.ico") → prefixed with the resolved base path.
86
+ * - Absolute URLs ("https://...") → emitted as-is.
87
+ * - Root-relative paths ("/x.png") → emitted as-is.
88
+ */
89
+ export function buildFaviconLinks(input: BuildFaviconLinksInput): FaviconLinkTag[] {
90
+ const base = resolveFaviconBase(input);
91
+ const icons = input.icons && input.icons.length > 0 ? input.icons : DEFAULT_FAVICON_FILES;
92
+ return icons.map(icon => ({
93
+ rel: icon.rel,
94
+ type: icon.type,
95
+ sizes: icon.sizes,
96
+ href: prefixHref(icon.href, base),
97
+ }));
98
+ }
99
+
100
+ /** Back-compat alias — older name for the same operation. */
101
+ export const buildDefaultFaviconLinks = buildFaviconLinks;
102
+
103
+ /** Decide whether to prefix the href with the base path.
104
+ * Absolute URLs and root-relative paths are emitted as-is; bare filenames
105
+ * and relative paths are prefixed. */
106
+ function prefixHref(href: string, base: string): string {
107
+ if (!href) return base;
108
+ // Absolute URL: https://, http://, //, data:, etc.
109
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//')) return href;
110
+ // Root-relative: /anything
111
+ if (href.startsWith('/')) return href;
112
+ // Bare filename or relative path: prefix with base.
113
+ return `${base}${href}`;
114
+ }
115
+
116
+ /**
117
+ * Render a single FaviconLinkTag to an HTML <link> string.
118
+ *
119
+ * Attribute order: rel, type (if present), sizes (if present), href.
120
+ * Deterministic for snapshot testing.
121
+ */
122
+ export function renderFaviconLinkTag(tag: FaviconLinkTag): string {
123
+ const attrs: string[] = [`rel="${tag.rel}"`];
124
+ if (tag.type) attrs.push(`type="${tag.type}"`);
125
+ if (tag.sizes) attrs.push(`sizes="${tag.sizes}"`);
126
+ attrs.push(`href="${tag.href}"`);
127
+ return `<link ${attrs.join(' ')} />`;
128
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Font stack builder — shared SSOT for `branding.fonts.*` fallback chains.
3
+ *
4
+ * Used by:
5
+ * - `src/layouts/Default.astro` (build-time SSG CSS variables)
6
+ * - `src/config/use-site-config.ts` (runtime CSS variable overrides)
7
+ *
8
+ * Design principle: NO slot dictates a font category. The consumer picks
9
+ * `serif`, `sans-serif`, or `monospace` for each slot independently. The
10
+ * defaults below preserve the Glossarist visual identity (serif title +
11
+ * serif heading + sans-serif body + monospace code) but every slot can
12
+ * be overridden to any category.
13
+ *
14
+ * Slot semantics:
15
+ * - title : single most-prominent display text on the page
16
+ * (home hero h1, concept name on detail page, group title)
17
+ * - heading : h2–h6 section headings
18
+ * - body : paragraph text, lists, table cells
19
+ * - mono : code blocks, inline code, kbd
20
+ *
21
+ * Backward compat:
22
+ * - `branding.fonts.header` is accepted as a deprecated alias for
23
+ * `branding.fonts.heading` (both write to `--font-heading`, which
24
+ * also surfaces as `--font-header` for legacy CSS).
25
+ */
26
+
27
+ export type FontCategory = 'serif' | 'sans-serif' | 'monospace';
28
+
29
+ export interface FontConfigLike {
30
+ family: string;
31
+ category?: FontCategory;
32
+ }
33
+
34
+ const FALLBACKS: Readonly<Record<FontCategory, string>> = {
35
+ serif: 'Georgia, serif',
36
+ 'sans-serif': 'system-ui, sans-serif',
37
+ monospace: 'ui-monospace, "JetBrains Mono", Menlo, Monaco, monospace',
38
+ };
39
+
40
+ /**
41
+ * Build a CSS font-family stack from a FontConfig-like object.
42
+ *
43
+ * - If `font` is null/undefined or has no family, returns `undefined`
44
+ * and the caller applies its slot-specific default.
45
+ * - If `font.category` is omitted, falls back to `defaultCategory`
46
+ * so existing configs preserve prior behavior.
47
+ */
48
+ export function fontStack(
49
+ font: FontConfigLike | null | undefined,
50
+ defaultCategory: FontCategory,
51
+ ): string | undefined {
52
+ if (!font?.family) return undefined;
53
+ const category: FontCategory = font.category ?? defaultCategory;
54
+ const fallback = FALLBACKS[category];
55
+ return `'${font.family}', ${fallback}`;
56
+ }
57
+
58
+ /** Default font stacks (used when the consumer declares nothing).
59
+ * Preserves the Glossarist visual identity. NOT a dictate — every
60
+ * slot can be overridden to any category via branding.fonts.*. */
61
+ export const DEFAULT_FONTS = {
62
+ title: `'DM Serif Display', ${FALLBACKS.serif}`,
63
+ heading: `'DM Serif Display', ${FALLBACKS.serif}`,
64
+ body: `'DM Sans', ${FALLBACKS['sans-serif']}`,
65
+ mono: `'JetBrains Mono', ${FALLBACKS.monospace}`,
66
+ } as const;
67
+
68
+ /** Slot → default category, used when the consumer omits `category`.
69
+ * These defaults preserve prior behavior and are NOT a constraint —
70
+ * consumers can pick any category for any slot. */
71
+ export const DEFAULT_CATEGORY = {
72
+ title: 'serif' as FontCategory,
73
+ heading: 'serif' as FontCategory,
74
+ body: 'sans-serif' as FontCategory,
75
+ mono: 'monospace' as FontCategory,
76
+ } as const;
77
+
@@ -321,7 +321,7 @@ function clearSection() {
321
321
 
322
322
  <!-- Header -->
323
323
  <div v-if="manifest" class="mb-8">
324
- <h1 class="font-serif text-3xl text-ink-800 mb-2">{{ localizedTitle }}</h1>
324
+ <h1 class="font-title text-3xl text-ink-800 mb-2">{{ localizedTitle }}</h1>
325
325
  <p class="text-ink-400 leading-relaxed max-w-2xl">{{ localizedDescription }}</p>
326
326
  <div class="flex flex-wrap gap-2 mt-4">
327
327
  <span class="badge" :style="conceptCountBadgeStyle">{{ manifest.conceptCount.toLocaleString() }} {{ t('dataset.concepts') }}</span>
@@ -96,7 +96,7 @@ function openDataset(id: string) {
96
96
  <template v-else-if="group">
97
97
  <div class="flex items-baseline gap-3 mb-2">
98
98
  <span class="text-2xl">{{ groupMeta.glyph }}</span>
99
- <h1 class="font-serif text-3xl text-ink-800 dark:text-ink-50">
99
+ <h1 class="font-title text-3xl text-ink-800 dark:text-ink-50">
100
100
  {{ group.label }}
101
101
  </h1>
102
102
  </div>
@@ -77,7 +77,7 @@ function goToGraph() { router.push({ name: 'graph' }); }
77
77
  <span class="text-[11px] font-semibold uppercase tracking-[0.2em] text-ink-300 hidden sm:inline">{{ localizedSubtitle }}</span>
78
78
  </template>
79
79
  </div>
80
- <h1 class="font-serif text-[2rem] sm:text-[2.75rem] text-ink-800 leading-[1.1] mb-4 tracking-tight">
80
+ <h1 class="font-title text-[2rem] sm:text-[2.75rem] text-ink-800 leading-[1.1] mb-4 tracking-tight">
81
81
  {{ localizedTitle }}
82
82
  <template v-if="localizedSubtitle"><br class="hidden sm:block" /> {{ localizedSubtitle }}</template>
83
83
  </h1>
@@ -13,9 +13,11 @@ export default {
13
13
  theme: {
14
14
  extend: {
15
15
  fontFamily: {
16
+ title: ['var(--font-title, "DM Serif Display")', 'Georgia', 'serif'],
17
+ heading: ['var(--font-heading, "DM Serif Display")', 'Georgia', 'serif'],
16
18
  serif: ['var(--font-header, "DM Serif Display")', 'Georgia', 'serif'],
17
19
  sans: ['var(--font-body, "DM Sans")', 'system-ui', 'sans-serif'],
18
- mono: ['"JetBrains Mono"', 'monospace'],
20
+ mono: ['var(--font-mono, "JetBrains Mono")', 'ui-monospace', 'monospace'],
19
21
  },
20
22
  colors: {
21
23
  ink: {