@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 +107 -9
- package/cli/index.mjs +33 -4
- package/cli/index.ts +38 -10
- package/package.json +1 -1
- package/src/__tests__/utils/favicon-base.test.ts +89 -0
- package/src/__tests__/utils/favicon-links.test.ts +185 -0
- package/src/__tests__/utils/font-stack.test.ts +105 -0
- package/src/components/ConceptDetail.vue +1 -1
- package/src/config/types.ts +44 -7
- package/src/config/use-site-config.ts +22 -6
- package/src/layouts/Default.astro +94 -50
- package/src/styles/tailwind.css +5 -0
- package/src/utils/favicon-base.ts +45 -0
- package/src/utils/favicon-links.ts +128 -0
- package/src/utils/font-stack.ts +77 -0
- package/src/views/DatasetView.vue +1 -1
- package/src/views/GroupView.vue +1 -1
- package/src/views/HomeView.vue +1 -1
- package/tailwind.config.js +3 -1
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
| `
|
|
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 `
|
|
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
|
-
// - `
|
|
101
|
-
//
|
|
102
|
-
//
|
|
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
|
-
|
|
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 '/'
|
|
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
|
|
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
|
-
/**
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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
|