@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.
package/README.md CHANGED
@@ -109,6 +109,93 @@ branding:
109
109
 
110
110
  If no logo is configured, the Glossarist logo is shown by default. The footer always shows the Glossarist logo ("Powered by Glossarist").
111
111
 
112
+ ### Fonts
113
+
114
+ Brand typography is fully slot-based and category-agnostic. There are **four slots** — `title`, `heading`, `body`, `mono` — and each slot accepts **any category** (`serif`, `sans-serif`, `monospace`). Nothing dictates that "headings must be serif" or "body must be sans-serif". Pick whatever combination matches your brand.
115
+
116
+ #### Slots
117
+
118
+ | Slot | Applies to | Default family | Default category |
119
+ |---|---|---|---|
120
+ | `title` | The single most-prominent text on each page (home hero, concept name on detail page, dataset/group title) | DM Serif Display | serif |
121
+ | `heading` | h2–h6 section headings | DM Serif Display | serif |
122
+ | `body` | Paragraph text, lists, table cells | DM Sans | sans-serif |
123
+ | `mono` | Code blocks, inline code, kbd | JetBrains Mono | monospace |
124
+
125
+ The defaults preserve the Glossarist visual identity — override any of them to match your brand.
126
+
127
+ #### Per-slot category override
128
+
129
+ Set `category` on any slot to control the fallback chain (the browser uses it when the primary family fails to load or is still loading):
130
+
131
+ ```yaml
132
+ branding:
133
+ fonts:
134
+ title:
135
+ family: Inter
136
+ source: google
137
+ category: sans-serif # sans-serif title (was serif by default)
138
+ weights: [400, 600, 700]
139
+ heading:
140
+ family: Inter
141
+ source: google
142
+ category: sans-serif
143
+ weights: [600, 700]
144
+ body:
145
+ family: Merriweather
146
+ source: google
147
+ category: serif # serif body (was sans-serif by default)
148
+ weights: [400, 700]
149
+ mono:
150
+ family: Fira Code
151
+ source: google
152
+ category: monospace
153
+ weights: [400, 500]
154
+ ```
155
+
156
+ This produces:
157
+
158
+ | Slot | CSS variable | Stack |
159
+ |---|---|---|
160
+ | title | `--font-title` | `'Inter', system-ui, sans-serif` |
161
+ | heading | `--font-heading` (and `--font-header` for backward compat) | `'Inter', system-ui, sans-serif` |
162
+ | body | `--font-body` | `'Merriweather', Georgia, serif` |
163
+ | mono | `--font-mono` | `'Fira Code', ui-monospace, "JetBrains Mono", Menlo, Monaco, monospace` |
164
+
165
+ #### Source options
166
+
167
+ | `source` | Behavior |
168
+ |---|---|
169
+ | `google` | Build emits a Google Fonts CSS `@import` for the declared `family` + `weights`. |
170
+ | `url` | Build emits a `@font-face` block loading from `url`. |
171
+ | `local` | Consumer ships the font files in `public/`; no build-time fetch. |
172
+
173
+ #### Backward compatibility
174
+
175
+ The legacy `branding.fonts.header` slot still works — it's a deprecated alias for `branding.fonts.heading`. Existing configs don't break. The CSS variable `--font-header` is still emitted (as an alias for `--font-heading`) so existing stylesheets that reference it continue to work.
176
+
177
+ #### Mixed-category example: all sans-serif
178
+
179
+ ```yaml
180
+ branding:
181
+ fonts:
182
+ title: { family: Inter, source: google, category: sans-serif }
183
+ heading: { family: Inter, source: google, category: sans-serif }
184
+ body: { family: Inter, source: google, category: sans-serif }
185
+ mono: { family: JetBrains Mono, source: google, category: monospace }
186
+ ```
187
+
188
+ #### Mixed-category example: traditional serif
189
+
190
+ ```yaml
191
+ branding:
192
+ fonts:
193
+ title: { family: Playfair Display, source: google, category: serif }
194
+ heading: { family: Lora, source: google, category: serif }
195
+ body: { family: Source Sans Pro, source: google, category: sans-serif }
196
+ mono: { family: Source Code Pro, source: google, category: monospace }
197
+ ```
198
+
112
199
  ### Favicons
113
200
 
114
201
  `branding.favicon` accepts two shapes — a legacy **string** form and an **object** form that lets consumers provide their own canonical favicon set without writing a post-build script.
@@ -148,22 +235,33 @@ Then in `site-config.yml`:
148
235
  branding:
149
236
  favicon:
150
237
  base_path: / # URL prefix (BASE_PATH-aware; default '/')
151
- skip_default_links: true # suppress the default <link> block
152
238
  source_dir: assets/favicons # canonical files (all of them, in one directory)
153
- links_html: |- # raw HTML emitted verbatim in <head>
154
- <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
155
- <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
156
- <link rel="shortcut icon" href="/favicon.ico" />
157
- <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
158
- <link rel="manifest" href="/site.webmanifest" />
239
+ icons: # DATA — declare each icon, not HTML
240
+ - rel: icon
241
+ type: image/svg+xml
242
+ href: favicon.svg
243
+ - rel: icon
244
+ type: image/png
245
+ sizes: 96x96
246
+ href: favicon-96x96.png
247
+ - rel: shortcut icon
248
+ href: favicon.ico
249
+ - rel: apple-touch-icon
250
+ sizes: 180x180
251
+ href: apple-touch-icon.png
252
+ - rel: manifest
253
+ href: site.webmanifest
159
254
  ```
160
255
 
256
+ The CLI copies every file from `source_dir/` into `public/` (also removing the default cruft), then renders one `<link>` tag per icon entry. The `href` is a **filename** — the system applies the correct `base_path` prefix automatically. Absolute URLs (`https://cdn.example.com/x.png`) and root-relative paths (`/x.png`) are emitted unchanged.
257
+
161
258
  | Field | Type | Effect |
162
259
  |---|---|---|
163
260
  | `source_dir` | string | Path (relative to cwd) to a directory containing **all** canonical favicon files. The CLI copies every file in this directory into `public/`, overriding any defaults. Also removes the default-generated cruft (`apple-touch-icon-57x57.png`, `favicon-16x16.png`, etc.) so it doesn't linger. |
164
- | `links_html` | string | Raw HTML (one or more `<link>` tags) emitted verbatim in `<head>`. Use this to declare which of the source_dir files the browser should load, with what `sizes`/`type`. |
165
- | `skip_default_links` | boolean | When true, the CLI does NOT call the `favicons` package and does NOT emit the default `<link>` tags. Pair with `links_html` and `source_dir` for fully custom branding. |
261
+ | `icons` | `FaviconIcon[]` | **DATA — recommended.** Array of `{ rel, href, type?, sizes? }` entries. Each renders to one `<link>` tag with BASE_PATH-aware href. Replaces the default 16-link set. |
262
+ | `skip_default_links` | boolean | When true, the CLI does NOT call the `favicons` package and does NOT emit the default `<link>` tags. Pair with `icons` and `source_dir` for fully custom branding. |
166
263
  | `base_path` | string | URL prefix prepended to every emitted link. Useful for BASE_PATH-scoped deployments (e.g. `/vocab/`). |
264
+ | ~~`links_html`~~ | string | **@deprecated** — use `icons` instead. Raw HTML emitted verbatim. Impossible to validate or safely BASE_PATH-rewrite; kept for backward compat with a console warning. |
167
265
 
168
266
  The object form exists so consumers with a RealFaviconGenerator favicon set (or any other canonical brand favicon bundle) can install it without a post-build script. Previously this required workarounds like `glossarist/cie-eilv/scripts/install-favicons.mjs` (149 lines) and `glossarist/iala-vocab/scripts/install-favicons.mjs` (175 lines) — both are now unnecessary.
169
267
 
package/cli/index.mjs CHANGED
@@ -97,9 +97,12 @@ Environment:
97
97
  // package generates the full variant set from it.
98
98
  //
99
99
  // 2. Object — `{ source_dir?, links_html?, skip_default_links?, base_path? }`.
100
- // - `links_html`: raw HTML (one or more <link> tags) emitted verbatim.
101
- // Skips the `favicons` package entirely; consumer is responsible
102
- // for providing the actual icon files in public/.
100
+ // - `icons`: DATA — array of { rel, href, type?, sizes? } entries.
101
+ // The hrefs are filenames; the CLI applies BASE_PATH and renders
102
+ // well-formed <link> tags. Recommended over `links_html`.
103
+ // - `links_html` (deprecated): raw HTML emitted verbatim.
104
+ // Skips the `favicons` package entirely; impossible to validate
105
+ // or safely BASE_PATH-rewrite. Use `icons` instead.
103
106
  // - `source_dir`: directory of canonical favicon files copied as-is
104
107
  // into public/ (overriding any defaults). Useful when the consumer
105
108
  // already has a RealFaviconGenerator output set.
@@ -114,8 +117,34 @@ Environment:
114
117
 
115
118
  if (faviconCfgIsObject) {
116
119
  // Object form — honor each declared field.
120
+ if (faviconCfg.icons && Array.isArray(faviconCfg.icons) && faviconCfg.icons.length > 0) {
121
+ // Data-driven: build <link> tags from the icons array.
122
+ const basePathForIcons =
123
+ faviconCfg.base_path ||
124
+ process.env.BASE_PATH?.replace(/\/+$/, '') ||
125
+ '';
126
+ faviconHtml = faviconCfg.icons.map((icon) => {
127
+ const attrs = [`rel="${icon.rel || 'icon'}"`];
128
+ if (icon.type) attrs.push(`type="${icon.type}"`);
129
+ if (icon.sizes) attrs.push(`sizes="${icon.sizes}"`);
130
+ // Bare filenames get BASE_PATH-prefixed; absolute URLs / root-relative
131
+ // paths are emitted as-is.
132
+ let href = icon.href || '';
133
+ if (href && !/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith('//') && !href.startsWith('/')) {
134
+ href = basePathForIcons ? `${basePathForIcons}/${href}` : `/${href}`;
135
+ }
136
+ attrs.push(`href="${href}"`);
137
+ return `<link ${attrs.join(' ')} />`;
138
+ }).join('\n ');
139
+ skipDefaultGeneration = true;
140
+ }
117
141
  if (faviconCfg.links_html) {
118
- faviconHtml = String(faviconCfg.links_html);
142
+ if (faviconHtml) {
143
+ console.warn(' Warning: branding.favicon.icons and links_html are both set; using icons (data form).');
144
+ } else {
145
+ console.warn(' Warning: branding.favicon.links_html is deprecated. Use branding.favicon.icons (data) instead.');
146
+ faviconHtml = String(faviconCfg.links_html);
147
+ }
119
148
  skipDefaultGeneration = true;
120
149
  }
121
150
  if (faviconCfg.source_dir) {
package/cli/index.ts CHANGED
@@ -66,19 +66,23 @@ interface BrandingFonts {
66
66
  weights?: number[];
67
67
  }
68
68
 
69
+ interface FaviconIcon {
70
+ rel?: string;
71
+ href: string;
72
+ type?: string;
73
+ sizes?: string;
74
+ }
75
+
69
76
  interface FaviconConfig {
70
- /** Path prefix for all favicon URLs. Defaults to '/', set to a sub-path
71
- * when deploying under BASE_PATH != '/'. */
77
+ /** Path prefix for all favicon URLs. Defaults to BASE_PATH-aware '/'. */
72
78
  base_path?: string;
73
- /** Skip emitting the default <link rel="icon"> and <link rel="apple-touch-icon">
74
- * tags. Use when the consumer provides their own RFG-compatible markup via
75
- * `links_html`. Default: false (emit default tags). */
79
+ /** Skip emitting the default <link> block. */
76
80
  skip_default_links?: boolean;
77
- /** Raw HTML string (one or more <link> tags) to emit in <head> instead of
78
- * the default favicon block. RealFaviconGenerator output goes here. */
81
+ /** Consumer-declared icons (DATA, not HTML). Replaces the default set. */
82
+ icons?: FaviconIcon[];
83
+ /** @deprecated Use `icons` instead. Raw HTML — kept for backward compat. */
79
84
  links_html?: string;
80
- /** Consumer-side directory (relative to cwd) holding canonical favicon files.
81
- * The CLI copies these into public/, overriding defaults. */
85
+ /** Consumer-side directory of canonical favicon files. */
82
86
  source_dir?: string;
83
87
  }
84
88
 
@@ -133,8 +137,32 @@ async function runBuildPipeline(config: SiteConfig | null): Promise<void> {
133
137
 
134
138
  if (faviconCfgObject) {
135
139
  const cfg: FaviconConfig = faviconCfgObject;
140
+ if (cfg.icons && cfg.icons.length > 0) {
141
+ // Data-driven: build <link> tags from the icons array.
142
+ const basePathForIcons: string =
143
+ cfg.base_path ||
144
+ process.env.BASE_PATH?.replace(/\/+$/, '') ||
145
+ '';
146
+ faviconHtml = cfg.icons.map((icon: FaviconIcon): string => {
147
+ const attrs: string[] = [`rel="${icon.rel || 'icon'}"`];
148
+ if (icon.type) attrs.push(`type="${icon.type}"`);
149
+ if (icon.sizes) attrs.push(`sizes="${icon.sizes}"`);
150
+ let href: string = icon.href || '';
151
+ if (href && !/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith('//') && !href.startsWith('/')) {
152
+ href = basePathForIcons ? `${basePathForIcons}/${href}` : `/${href}`;
153
+ }
154
+ attrs.push(`href="${href}"`);
155
+ return `<link ${attrs.join(' ')} />`;
156
+ }).join('\n ');
157
+ skipDefaultGeneration = true;
158
+ }
136
159
  if (cfg.links_html) {
137
- faviconHtml = String(cfg.links_html);
160
+ if (faviconHtml) {
161
+ console.warn(' Warning: branding.favicon.icons and links_html are both set; using icons (data form).');
162
+ } else {
163
+ console.warn(' Warning: branding.favicon.links_html is deprecated. Use branding.favicon.icons (data) instead.');
164
+ faviconHtml = String(cfg.links_html);
165
+ }
138
166
  skipDefaultGeneration = true;
139
167
  }
140
168
  if (cfg.source_dir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.107",
3
+ "version": "0.7.109",
4
4
  "description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { resolveFaviconBase } from '../../utils/favicon-base';
3
+
4
+ /**
5
+ * Regression test for the sub-path deployment 404 bug.
6
+ *
7
+ * Bug (filed by user, 2026-08-01): deployments at sub-paths
8
+ * (e.g. `/cie-eilv/`) saw console 404s for `favicon-32x32.png`,
9
+ * `favicon-16x16.png`, `favicon-48x48.png` because the emitted
10
+ * `<link href>` pointed at the ROOT (`/favicon-32x32.png`) instead
11
+ * of the deployment sub-path (`/cie-eilv/favicon-32x32.png`).
12
+ *
13
+ * Root cause: `Default.astro` hardcoded `const faviconBase = '/'`.
14
+ * Fix: cascade through configuration sources to find the right prefix.
15
+ */
16
+ describe('resolveFaviconBase', () => {
17
+ it('returns / when nothing is configured (root deployment)', () => {
18
+ expect(resolveFaviconBase({})).toBe('/');
19
+ });
20
+
21
+ it('returns / when all candidates are empty', () => {
22
+ expect(resolveFaviconBase({
23
+ brandingFaviconBasePath: '',
24
+ siteConfigBasePath: '',
25
+ astroBaseUrl: '',
26
+ })).toBe('/');
27
+ });
28
+
29
+ it('uses branding.favicon.base_path when set (highest priority)', () => {
30
+ expect(resolveFaviconBase({
31
+ brandingFaviconBasePath: '/my-brand/',
32
+ siteConfigBasePath: '/should-be-ignored/',
33
+ astroBaseUrl: '/should-be-ignored/',
34
+ })).toBe('/my-brand/');
35
+ });
36
+
37
+ it('uses siteConfig.basePath when branding.favicon.base_path is absent', () => {
38
+ expect(resolveFaviconBase({
39
+ siteConfigBasePath: '/cie-eilv/',
40
+ astroBaseUrl: '/',
41
+ })).toBe('/cie-eilv/');
42
+ });
43
+
44
+ it('uses import.meta.env.BASE_URL when siteConfig.basePath is absent', () => {
45
+ expect(resolveFaviconBase({
46
+ astroBaseUrl: '/vocab/',
47
+ })).toBe('/vocab/');
48
+ });
49
+
50
+ /**
51
+ * THE LOAD-BEARING REGRESSION TEST.
52
+ *
53
+ * Reproduction of the bug: cie-eilv is deployed at /cie-eilv/, but
54
+ * the live site emitted <link href="/favicon-32x32.png">. The fix
55
+ * must produce /cie-eilv/favicon-32x32.png.
56
+ */
57
+ it('regression: resolves /cie-eilv/ when that is the configured basePath', () => {
58
+ const base = resolveFaviconBase({
59
+ siteConfigBasePath: '/cie-eilv/',
60
+ astroBaseUrl: '/',
61
+ });
62
+ expect(base).toBe('/cie-eilv/');
63
+ // Pin the full URL construction so a future regression that breaks
64
+ // the trailing slash also fails this test.
65
+ expect(`${base}favicon-32x32.png`).toBe('/cie-eilv/favicon-32x32.png');
66
+ expect(`${base}favicon-16x16.png`).toBe('/cie-eilv/favicon-16x16.png');
67
+ expect(`${base}favicon-48x48.png`).toBe('/cie-eilv/favicon-48x48.png');
68
+ });
69
+
70
+ it('appends trailing slash if consumer omitted it', () => {
71
+ expect(resolveFaviconBase({
72
+ siteConfigBasePath: '/cie-eilv', // no trailing slash
73
+ })).toBe('/cie-eilv/');
74
+ });
75
+
76
+ it('does not double-slash if consumer includes trailing slash', () => {
77
+ expect(resolveFaviconBase({
78
+ siteConfigBasePath: '/cie-eilv/',
79
+ })).toBe('/cie-eilv/');
80
+ });
81
+
82
+ it('handles deep sub-path deployments', () => {
83
+ expect(resolveFaviconBase({
84
+ siteConfigBasePath: '/vocab/iala/',
85
+ })).toBe('/vocab/iala/');
86
+ expect(`${resolveFaviconBase({ siteConfigBasePath: '/vocab/iala/' })}favicon.ico`)
87
+ .toBe('/vocab/iala/favicon.ico');
88
+ });
89
+ });
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildFaviconLinks, renderFaviconLinkTag, type FaviconIcon } from '../../utils/favicon-links';
3
+
4
+ /**
5
+ * Regression test for the sub-path deployment 404 bug + the
6
+ * data-driven icons API.
7
+ *
8
+ * Bug 1 (filed by user, 2026-08-01): deployments at sub-paths
9
+ * (e.g. `/cie-eilv/`) saw console 404s for `favicon-32x32.png`,
10
+ * `favicon-16x16.png`, `favicon-48x48.png` because the emitted
11
+ * `<link href>` pointed at the ROOT instead of the deployment sub-path.
12
+ *
13
+ * Bug 2 (filed by user, 2026-08-01): the `links_html` field required
14
+ * consumers to inject raw HTML — impossible to validate, can't be safely
15
+ * BASE_PATH-rewritten. Refactored to DATA: `icons: [{ rel, href, type?,
16
+ * sizes? }]` — href is a filename, system applies the correct prefix.
17
+ */
18
+ describe('buildFaviconLinks — BASE_PATH regression', () => {
19
+ it('emits /-prefixed hrefs for root deployment', () => {
20
+ const links = buildFaviconLinks({ astroBaseUrl: '/' });
21
+ const png16 = links.find(l => l.sizes === '16x16');
22
+ const png32 = links.find(l => l.sizes === '32x32');
23
+ const png48 = links.find(l => l.sizes === '48x48');
24
+ expect(png16?.href).toBe('/favicon-16x16.png');
25
+ expect(png32?.href).toBe('/favicon-32x32.png');
26
+ expect(png48?.href).toBe('/favicon-48x48.png');
27
+ });
28
+
29
+ /**
30
+ * THE LOAD-BEARING TEST — pins the exact bug the user reported.
31
+ */
32
+ it('emits /cie-eilv/-prefixed hrefs for sub-path deployment', () => {
33
+ const links = buildFaviconLinks({
34
+ siteConfigBasePath: '/cie-eilv/',
35
+ astroBaseUrl: '/',
36
+ });
37
+ const png16 = links.find(l => l.sizes === '16x16');
38
+ const png32 = links.find(l => l.sizes === '32x32');
39
+ const png48 = links.find(l => l.sizes === '48x48');
40
+ expect(png16?.href).toBe('/cie-eilv/favicon-16x16.png');
41
+ expect(png32?.href).toBe('/cie-eilv/favicon-32x32.png');
42
+ expect(png48?.href).toBe('/cie-eilv/favicon-48x48.png');
43
+ });
44
+
45
+ it('handles deep sub-paths', () => {
46
+ const links = buildFaviconLinks({ siteConfigBasePath: '/vocab/iala/' });
47
+ const icon = links.find(l => l.rel === 'icon' && l.type === 'image/x-icon');
48
+ expect(icon?.href).toBe('/vocab/iala/favicon.ico');
49
+ });
50
+
51
+ it('handles basePath without trailing slash', () => {
52
+ const links = buildFaviconLinks({ siteConfigBasePath: '/cie-eilv' });
53
+ const icon = links.find(l => l.rel === 'icon' && l.type === 'image/x-icon');
54
+ expect(icon?.href).toBe('/cie-eilv/favicon.ico');
55
+ });
56
+
57
+ it('never produces a bare "/" href', () => {
58
+ const scenarios = [
59
+ { astroBaseUrl: '/' },
60
+ { astroBaseUrl: '/vocab/' },
61
+ { siteConfigBasePath: '/cie-eilv/' },
62
+ { brandingFaviconBasePath: '/custom/' },
63
+ ];
64
+ for (const input of scenarios) {
65
+ const links = buildFaviconLinks(input);
66
+ for (const link of links) {
67
+ expect(link.href, `bare href for input ${JSON.stringify(input)}`).not.toBe('/');
68
+ expect(link.href.length, `empty href for input ${JSON.stringify(input)}`).toBeGreaterThan(1);
69
+ }
70
+ }
71
+ });
72
+
73
+ it('emits the canonical 16 default favicon tags', () => {
74
+ const links = buildFaviconLinks({ astroBaseUrl: '/' });
75
+ expect(links.length).toBe(16);
76
+ expect(links.filter(l => l.rel === 'icon').length).toBe(5);
77
+ expect(links.filter(l => l.rel === 'apple-touch-icon').length).toBe(11);
78
+ });
79
+ });
80
+
81
+ describe('buildFaviconLinks — consumer-declared icons (data form)', () => {
82
+ const consumerIcons: FaviconIcon[] = [
83
+ { rel: 'icon', type: 'image/svg+xml', href: 'favicon.svg' },
84
+ { rel: 'icon', type: 'image/png', sizes: '96x96', href: 'favicon-96x96.png' },
85
+ { rel: 'shortcut icon', href: 'favicon.ico' },
86
+ { rel: 'apple-touch-icon', sizes: '180x180', href: 'apple-touch-icon.png' },
87
+ { rel: 'manifest', href: 'site.webmanifest' },
88
+ ];
89
+
90
+ it('replaces the default set when icons is non-empty', () => {
91
+ const links = buildFaviconLinks({ astroBaseUrl: '/', icons: consumerIcons });
92
+ expect(links.length).toBe(consumerIcons.length);
93
+ // Should NOT contain any of the default 16 links.
94
+ expect(links.find(l => l.sizes === '16x16')).toBeUndefined();
95
+ expect(links.find(l => l.sizes === '32x32')).toBeUndefined();
96
+ });
97
+
98
+ it('prefixes bare filenames with the resolved base path', () => {
99
+ const links = buildFaviconLinks({
100
+ siteConfigBasePath: '/cie-eilv/',
101
+ icons: consumerIcons,
102
+ });
103
+ const svg = links.find(l => l.type === 'image/svg+xml');
104
+ const png96 = links.find(l => l.sizes === '96x96');
105
+ const ico = links.find(l => l.rel === 'shortcut icon');
106
+ const manifest = links.find(l => l.rel === 'manifest');
107
+ expect(svg?.href).toBe('/cie-eilv/favicon.svg');
108
+ expect(png96?.href).toBe('/cie-eilv/favicon-96x96.png');
109
+ expect(ico?.href).toBe('/cie-eilv/favicon.ico');
110
+ expect(manifest?.href).toBe('/cie-eilv/site.webmanifest');
111
+ });
112
+
113
+ it('emits absolute URLs as-is (no prefix)', () => {
114
+ const links = buildFaviconLinks({
115
+ astroBaseUrl: '/vocab/',
116
+ icons: [
117
+ { rel: 'icon', type: 'image/svg+xml', href: 'https://cdn.example.com/favicon.svg' },
118
+ { rel: 'icon', href: 'favicon.ico' },
119
+ ],
120
+ });
121
+ const cdn = links.find(l => l.href.startsWith('https://'));
122
+ const local = links.find(l => l.href.endsWith('favicon.ico') && !l.href.startsWith('https://'));
123
+ expect(cdn?.href).toBe('https://cdn.example.com/favicon.svg');
124
+ expect(local?.href).toBe('/vocab/favicon.ico');
125
+ });
126
+
127
+ it('emits root-relative paths as-is', () => {
128
+ const links = buildFaviconLinks({
129
+ astroBaseUrl: '/vocab/',
130
+ icons: [{ rel: 'icon', href: '/absolute-path.png' }],
131
+ });
132
+ expect(links[0].href).toBe('/absolute-path.png');
133
+ });
134
+
135
+ it('preserves type and sizes attributes from the data', () => {
136
+ const links = buildFaviconLinks({ astroBaseUrl: '/', icons: consumerIcons });
137
+ const svg = links.find(l => l.type === 'image/svg+xml');
138
+ const png96 = links.find(l => l.sizes === '96x96');
139
+ const touch = links.find(l => l.rel === 'apple-touch-icon');
140
+ expect(svg?.type).toBe('image/svg+xml');
141
+ expect(png96?.sizes).toBe('96x96');
142
+ expect(touch?.sizes).toBe('180x180');
143
+ });
144
+
145
+ it('falls back to default set when icons is empty array', () => {
146
+ const links = buildFaviconLinks({ astroBaseUrl: '/', icons: [] });
147
+ expect(links.length).toBe(16);
148
+ });
149
+
150
+ it('falls back to default set when icons is undefined', () => {
151
+ const links = buildFaviconLinks({ astroBaseUrl: '/' });
152
+ expect(links.length).toBe(16);
153
+ });
154
+ });
155
+
156
+ describe('renderFaviconLinkTag', () => {
157
+ it('renders all attributes in deterministic order', () => {
158
+ const html = renderFaviconLinkTag({
159
+ rel: 'icon',
160
+ type: 'image/png',
161
+ sizes: '32x32',
162
+ href: '/favicon-32x32.png',
163
+ });
164
+ expect(html).toBe('<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />');
165
+ });
166
+
167
+ it('omits optional attributes when not set', () => {
168
+ const html = renderFaviconLinkTag({
169
+ rel: 'apple-touch-icon',
170
+ sizes: '180x180',
171
+ href: '/apple-touch-icon.png',
172
+ });
173
+ expect(html).toBe('<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />');
174
+ });
175
+
176
+ it('never emits an empty href', () => {
177
+ const links = buildFaviconLinks({ siteConfigBasePath: '/sub-path/' });
178
+ for (const link of links) {
179
+ const html = renderFaviconLinkTag(link);
180
+ const hrefMatch = html.match(/href="([^"]+)"/);
181
+ expect(hrefMatch, `no href in: ${html}`).not.toBeNull();
182
+ expect(hrefMatch![1].length, `empty href in: ${html}`).toBeGreaterThan(0);
183
+ }
184
+ });
185
+ });
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ fontStack,
4
+ DEFAULT_FONTS,
5
+ DEFAULT_CATEGORY,
6
+ type FontConfigLike,
7
+ } from '../../utils/font-stack';
8
+
9
+ /**
10
+ * Font stack builder — guarantees that NO slot dictates a category.
11
+ * Consumers can pick serif, sans-serif, or monospace for title, heading,
12
+ * body, or mono independently.
13
+ */
14
+ describe('font-stack', () => {
15
+ describe('fontStack', () => {
16
+ it('returns undefined when font is null/undefined/empty', () => {
17
+ expect(fontStack(null, 'serif')).toBeUndefined();
18
+ expect(fontStack(undefined, 'serif')).toBeUndefined();
19
+ expect(fontStack({ family: '' }, 'serif')).toBeUndefined();
20
+ });
21
+
22
+ it('uses declared category for the fallback chain', () => {
23
+ const font: FontConfigLike = { family: 'Inter', category: 'sans-serif' };
24
+ expect(fontStack(font, 'serif')).toBe("'Inter', system-ui, sans-serif");
25
+ });
26
+
27
+ it('falls back to defaultCategory when category is omitted (backward compat)', () => {
28
+ const font: FontConfigLike = { family: 'Inter' };
29
+ // Default category is serif (header slot's prior behavior).
30
+ expect(fontStack(font, 'serif')).toBe("'Inter', Georgia, serif");
31
+ });
32
+
33
+ it('lets a sans-serif font be installed in the title slot', () => {
34
+ // The whole point of the redesign: titles don't have to be serif.
35
+ const font: FontConfigLike = { family: 'Inter', category: 'sans-serif' };
36
+ expect(fontStack(font, DEFAULT_CATEGORY.title)).toBe("'Inter', system-ui, sans-serif");
37
+ });
38
+
39
+ it('lets a serif font be installed in the body slot', () => {
40
+ const font: FontConfigLike = { family: 'Merriweather', category: 'serif' };
41
+ expect(fontStack(font, DEFAULT_CATEGORY.body)).toBe("'Merriweather', Georgia, serif");
42
+ });
43
+
44
+ it('supports monospace category for any slot', () => {
45
+ const font: FontConfigLike = { family: 'JetBrains Mono', category: 'monospace' };
46
+ expect(fontStack(font, DEFAULT_CATEGORY.mono)).toBe(
47
+ "'JetBrains Mono', ui-monospace, \"JetBrains Mono\", Menlo, Monaco, monospace",
48
+ );
49
+ });
50
+ });
51
+
52
+ describe('DEFAULT_FONTS', () => {
53
+ it('preserves the Glossarist visual identity (serif title + serif heading + sans body + mono)', () => {
54
+ // These defaults are NOT a dictate — consumers can override any slot
55
+ // to any category. They exist so existing deployments don't change
56
+ // appearance on upgrade.
57
+ expect(DEFAULT_FONTS.title).toContain('DM Serif Display');
58
+ expect(DEFAULT_FONTS.heading).toContain('DM Serif Display');
59
+ expect(DEFAULT_FONTS.body).toContain('DM Sans');
60
+ expect(DEFAULT_FONTS.mono).toContain('JetBrains Mono');
61
+ });
62
+
63
+ it('every default stack includes the appropriate fallback category', () => {
64
+ expect(DEFAULT_FONTS.title).toMatch(/Georgia, serif/);
65
+ expect(DEFAULT_FONTS.heading).toMatch(/Georgia, serif/);
66
+ expect(DEFAULT_FONTS.body).toMatch(/system-ui, sans-serif/);
67
+ expect(DEFAULT_FONTS.mono).toMatch(/monospace/);
68
+ });
69
+ });
70
+
71
+ describe('DEFAULT_CATEGORY', () => {
72
+ it('does NOT force title to be serif — it is only the default', () => {
73
+ // The default for title IS serif (visual identity), but consumers
74
+ // can override to any category. The default is just a fallback
75
+ // when `category` is omitted on the FontConfig.
76
+ expect(DEFAULT_CATEGORY.title).toBe('serif');
77
+ expect(DEFAULT_CATEGORY.heading).toBe('serif');
78
+ expect(DEFAULT_CATEGORY.body).toBe('sans-serif');
79
+ expect(DEFAULT_CATEGORY.mono).toBe('monospace');
80
+ });
81
+ });
82
+
83
+ describe('regression — every slot can be every category', () => {
84
+ // Pin the contract: the consumer can install any category in any slot.
85
+ // This is the answer to "What do you have to dictate that headers must
86
+ // be serif?" — nothing, anymore.
87
+ const categories: Array<['serif' | 'sans-serif' | 'monospace', RegExp]> = [
88
+ ['serif', /Georgia, serif/],
89
+ ['sans-serif', /system-ui, sans-serif/],
90
+ ['monospace', /monospace/],
91
+ ];
92
+ const slots = ['title', 'heading', 'body', 'mono'] as const;
93
+
94
+ for (const slot of slots) {
95
+ for (const [category, expectedFallback] of categories) {
96
+ it(`slot=${slot} with category=${category} → fallback matches`, () => {
97
+ const font: FontConfigLike = { family: 'TestFont', category };
98
+ const stack = fontStack(font, DEFAULT_CATEGORY[slot])!;
99
+ expect(stack).toContain("'TestFont'");
100
+ expect(stack).toMatch(expectedFallback);
101
+ });
102
+ }
103
+ }
104
+ });
105
+ });
@@ -297,7 +297,7 @@ const nonVerbalReps = computed(() => {
297
297
  </button>
298
298
  </div>
299
299
  </div>
300
- <h1 class="font-serif text-2xl sm:text-3xl text-ink-800 leading-snug mb-3" v-html="renderedPrimaryTerm"></h1>
300
+ <h1 class="font-title text-2xl sm:text-3xl text-ink-800 leading-snug mb-3" v-html="renderedPrimaryTerm"></h1>
301
301
  <div class="flex gap-2 overflow-x-auto pb-1 -mx-4 px-4 sm:flex-wrap sm:overflow-visible sm:mx-0 sm:pb-0 scrollbar-none">
302
302
  <span class="badge badge-blue font-mono">{{ conceptId }}</span>
303
303
  <span