@glossarist/concept-browser 0.7.107 → 0.7.108
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 +87 -0
- package/package.json +1 -1
- package/src/__tests__/utils/favicon-base.test.ts +89 -0
- package/src/__tests__/utils/favicon-links.test.ts +124 -0
- package/src/__tests__/utils/font-stack.test.ts +105 -0
- package/src/components/ConceptDetail.vue +1 -1
- package/src/config/types.ts +22 -0
- package/src/config/use-site-config.ts +22 -6
- package/src/layouts/Default.astro +46 -35
- package/src/styles/tailwind.css +5 -0
- package/src/utils/favicon-base.ts +45 -0
- package/src/utils/favicon-links.ts +79 -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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.108",
|
|
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,124 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildDefaultFaviconLinks, renderFaviconLinkTag } from '../../utils/favicon-links';
|
|
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 (favicon-base.ts) and
|
|
15
|
+
* emit links with the resolved prefix.
|
|
16
|
+
*
|
|
17
|
+
* This test exercises the full link-emission path: config in →
|
|
18
|
+
* resolved base → link tags with correct hrefs.
|
|
19
|
+
*/
|
|
20
|
+
describe('buildDefaultFaviconLinks — BASE_PATH regression', () => {
|
|
21
|
+
it('emits /-prefixed hrefs for root deployment', () => {
|
|
22
|
+
const links = buildDefaultFaviconLinks({ astroBaseUrl: '/' });
|
|
23
|
+
const png16 = links.find(l => l.sizes === '16x16');
|
|
24
|
+
const png32 = links.find(l => l.sizes === '32x32');
|
|
25
|
+
const png48 = links.find(l => l.sizes === '48x48');
|
|
26
|
+
expect(png16?.href).toBe('/favicon-16x16.png');
|
|
27
|
+
expect(png32?.href).toBe('/favicon-32x32.png');
|
|
28
|
+
expect(png48?.href).toBe('/favicon-48x48.png');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* THE LOAD-BEARING TEST — pins the exact bug the user reported.
|
|
33
|
+
*
|
|
34
|
+
* Reproduction: cie-eilv deployment at /cie-eilv/. The 0.7.106/0.7.107
|
|
35
|
+
* build emitted href="/favicon-32x32.png" — wrong. The fix must emit
|
|
36
|
+
* href="/cie-eilv/favicon-32x32.png".
|
|
37
|
+
*/
|
|
38
|
+
it('emits /cie-eilv/-prefixed hrefs for sub-path deployment', () => {
|
|
39
|
+
const links = buildDefaultFaviconLinks({
|
|
40
|
+
siteConfigBasePath: '/cie-eilv/',
|
|
41
|
+
astroBaseUrl: '/',
|
|
42
|
+
});
|
|
43
|
+
const png16 = links.find(l => l.sizes === '16x16');
|
|
44
|
+
const png32 = links.find(l => l.sizes === '32x32');
|
|
45
|
+
const png48 = links.find(l => l.sizes === '48x48');
|
|
46
|
+
expect(png16?.href).toBe('/cie-eilv/favicon-16x16.png');
|
|
47
|
+
expect(png32?.href).toBe('/cie-eilv/favicon-32x32.png');
|
|
48
|
+
expect(png48?.href).toBe('/cie-eilv/favicon-48x48.png');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('handles deep sub-paths', () => {
|
|
52
|
+
const links = buildDefaultFaviconLinks({
|
|
53
|
+
siteConfigBasePath: '/vocab/iala/',
|
|
54
|
+
});
|
|
55
|
+
const icon = links.find(l => l.rel === 'icon' && l.type === 'image/x-icon');
|
|
56
|
+
expect(icon?.href).toBe('/vocab/iala/favicon.ico');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('handles basePath without trailing slash', () => {
|
|
60
|
+
const links = buildDefaultFaviconLinks({
|
|
61
|
+
siteConfigBasePath: '/cie-eilv', // no trailing slash
|
|
62
|
+
});
|
|
63
|
+
const icon = links.find(l => l.rel === 'icon' && l.type === 'image/x-icon');
|
|
64
|
+
expect(icon?.href).toBe('/cie-eilv/favicon.ico');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('never produces a bare "/" href', () => {
|
|
68
|
+
// Regression guard: a future bug that drops the filename would leave
|
|
69
|
+
// just the prefix. This test fails loudly if that happens.
|
|
70
|
+
const scenarios = [
|
|
71
|
+
{ astroBaseUrl: '/' },
|
|
72
|
+
{ astroBaseUrl: '/vocab/' },
|
|
73
|
+
{ siteConfigBasePath: '/cie-eilv/' },
|
|
74
|
+
{ brandingFaviconBasePath: '/custom/' },
|
|
75
|
+
];
|
|
76
|
+
for (const input of scenarios) {
|
|
77
|
+
const links = buildDefaultFaviconLinks(input);
|
|
78
|
+
for (const link of links) {
|
|
79
|
+
expect(link.href, `bare href for input ${JSON.stringify(input)}`).not.toBe('/');
|
|
80
|
+
expect(link.href.length, `empty href for input ${JSON.stringify(input)}`).toBeGreaterThan(1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('emits the canonical 16 default favicon tags', () => {
|
|
86
|
+
const links = buildDefaultFaviconLinks({ astroBaseUrl: '/' });
|
|
87
|
+
// 5 icons (ico + svg + 3 PNG sizes) + 11 apple-touch-icon sizes = 16
|
|
88
|
+
expect(links.length).toBe(16);
|
|
89
|
+
expect(links.filter(l => l.rel === 'icon').length).toBe(5);
|
|
90
|
+
expect(links.filter(l => l.rel === 'apple-touch-icon').length).toBe(11);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('renderFaviconLinkTag', () => {
|
|
95
|
+
it('renders all attributes in deterministic order', () => {
|
|
96
|
+
const html = renderFaviconLinkTag({
|
|
97
|
+
rel: 'icon',
|
|
98
|
+
type: 'image/png',
|
|
99
|
+
sizes: '32x32',
|
|
100
|
+
href: '/favicon-32x32.png',
|
|
101
|
+
});
|
|
102
|
+
expect(html).toBe('<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('omits optional attributes when not set', () => {
|
|
106
|
+
const html = renderFaviconLinkTag({
|
|
107
|
+
rel: 'apple-touch-icon',
|
|
108
|
+
sizes: '180x180',
|
|
109
|
+
href: '/apple-touch-icon.png',
|
|
110
|
+
});
|
|
111
|
+
expect(html).toBe('<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('never emits an empty href', () => {
|
|
115
|
+
// Build a complete link tag and verify the href attribute has content.
|
|
116
|
+
const links = buildDefaultFaviconLinks({ siteConfigBasePath: '/sub-path/' });
|
|
117
|
+
for (const link of links) {
|
|
118
|
+
const html = renderFaviconLinkTag(link);
|
|
119
|
+
const hrefMatch = html.match(/href="([^"]+)"/);
|
|
120
|
+
expect(hrefMatch, `no href in: ${html}`).not.toBeNull();
|
|
121
|
+
expect(hrefMatch![1].length, `empty href in: ${html}`).toBeGreaterThan(0);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -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
|
package/src/config/types.ts
CHANGED
|
@@ -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 {
|
|
@@ -38,8 +50,18 @@ export interface SiteBranding {
|
|
|
38
50
|
primaryColor?: string;
|
|
39
51
|
darkColor?: string;
|
|
40
52
|
fonts?: {
|
|
53
|
+
/** Most prominent display text on each page — home hero, concept name
|
|
54
|
+
* on the detail page, group/dataset title. No category is dictated;
|
|
55
|
+
* pick `serif`, `sans-serif`, or `monospace` for any slot. */
|
|
56
|
+
title?: FontConfig;
|
|
57
|
+
/** Section headings (h2–h6). */
|
|
58
|
+
heading?: FontConfig;
|
|
59
|
+
/** Backward-compat alias for `heading` (deprecated). */
|
|
41
60
|
header?: FontConfig;
|
|
61
|
+
/** Paragraph text, lists, table cells. */
|
|
42
62
|
body?: FontConfig;
|
|
63
|
+
/** Code blocks, inline code, kbd. */
|
|
64
|
+
mono?: FontConfig;
|
|
43
65
|
};
|
|
44
66
|
logo?: LogoConfig;
|
|
45
67
|
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
|
-
|
|
25
|
-
|
|
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?.
|
|
99
|
-
loadFont(b.fonts.
|
|
100
|
-
root.style.setProperty('--font-
|
|
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',
|
|
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,28 +22,35 @@ 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
|
-
//
|
|
18
|
-
//
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
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
55
|
// Favicon configuration (issue #173).
|
|
41
56
|
//
|
|
@@ -55,7 +70,20 @@ const fontBody = brandingFonts?.body?.family
|
|
|
55
70
|
const faviconCfg = siteConfig?.branding?.favicon ?? {};
|
|
56
71
|
const skipDefaultFaviconLinks = faviconCfg.skip_default_links === true;
|
|
57
72
|
const customFaviconLinks: string = faviconCfg.links_html ?? '';
|
|
58
|
-
|
|
73
|
+
|
|
74
|
+
// BASE_PATH-aware favicon URL prefix. Walks the configuration cascade:
|
|
75
|
+
// branding.favicon.base_path → siteConfig.basePath → import.meta.env.BASE_URL → '/'.
|
|
76
|
+
// Always ends with '/' so `${faviconBase}favicon.ico` resolves correctly
|
|
77
|
+
// whether the deployment is at '/' or '/cie-eilv/'.
|
|
78
|
+
//
|
|
79
|
+
// See src/utils/favicon-base.ts for the shared resolver + regression tests.
|
|
80
|
+
const faviconBaseInput = {
|
|
81
|
+
brandingFaviconBasePath: typeof faviconCfg === 'object' ? faviconCfg.base_path : undefined,
|
|
82
|
+
siteConfigBasePath: siteConfig?.basePath,
|
|
83
|
+
astroBaseUrl: import.meta.env.BASE_URL,
|
|
84
|
+
};
|
|
85
|
+
const faviconBase = resolveFaviconBase(faviconBaseInput);
|
|
86
|
+
const faviconLinks = buildDefaultFaviconLinks(faviconBaseInput).map(renderFaviconLinkTag);
|
|
59
87
|
---
|
|
60
88
|
|
|
61
89
|
<!DOCTYPE html>
|
|
@@ -63,24 +91,7 @@ const faviconBase = faviconCfg.base_path ?? '/';
|
|
|
63
91
|
<head>
|
|
64
92
|
<meta charset="utf-8" />
|
|
65
93
|
{!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
|
-
</>
|
|
94
|
+
<Fragment set:html={faviconLinks.join('\n ')} />
|
|
84
95
|
)}
|
|
85
96
|
{customFaviconLinks && <Fragment set:html={customFaviconLinks} />}
|
|
86
97
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
@@ -93,7 +104,7 @@ const faviconBase = faviconCfg.base_path ?? '/';
|
|
|
93
104
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
94
105
|
<link rel="preload" as="style" href={fontsUrl} onload="this.rel='stylesheet'">
|
|
95
106
|
<noscript><link href={fontsUrl} rel="stylesheet"></noscript>
|
|
96
|
-
<Fragment set:html={`<style>:root{--font-header:${
|
|
107
|
+
<Fragment set:html={`<style>:root{--font-title:${fontTitle};--font-heading:${fontHeading};--font-header:${fontHeading};--font-body:${fontBody};--font-mono:${fontMono}}</style>`} />
|
|
97
108
|
<script is:inline>
|
|
98
109
|
(function() {
|
|
99
110
|
const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('theme='));
|
package/src/styles/tailwind.css
CHANGED
|
@@ -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,79 @@
|
|
|
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
|
+
*
|
|
8
|
+
* Why this exists: a deployment at `/cie-eilv/` has its favicon files
|
|
9
|
+
* at `/cie-eilv/favicon.ico`, NOT `/favicon.ico`. Hardcoding `/` as
|
|
10
|
+
* the URL prefix (which 0.7.106 did) caused 404s on every sub-path
|
|
11
|
+
* deployment. This builder walks the configuration cascade via
|
|
12
|
+
* `resolveFaviconBase` and emits correctly-prefixed link tags.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { resolveFaviconBase } from './favicon-base';
|
|
16
|
+
|
|
17
|
+
export interface FaviconLinkTag {
|
|
18
|
+
rel: string;
|
|
19
|
+
type?: string;
|
|
20
|
+
sizes?: string;
|
|
21
|
+
href: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Default favicon file set emitted by the CLI's `favicons` pipeline. */
|
|
25
|
+
const DEFAULT_FAVICON_FILES: readonly FaviconLinkTag[] = [
|
|
26
|
+
{ rel: 'icon', type: 'image/x-icon', href: 'favicon.ico' },
|
|
27
|
+
{ rel: 'icon', type: 'image/svg+xml', href: 'favicon.svg' },
|
|
28
|
+
{ rel: 'icon', type: 'image/png', sizes: '16x16', href: 'favicon-16x16.png' },
|
|
29
|
+
{ rel: 'icon', type: 'image/png', sizes: '32x32', href: 'favicon-32x32.png' },
|
|
30
|
+
{ rel: 'icon', type: 'image/png', sizes: '48x48', href: 'favicon-48x48.png' },
|
|
31
|
+
{ rel: 'apple-touch-icon', sizes: '57x57', href: 'apple-touch-icon-57x57.png' },
|
|
32
|
+
{ rel: 'apple-touch-icon', sizes: '60x60', href: 'apple-touch-icon-60x60.png' },
|
|
33
|
+
{ rel: 'apple-touch-icon', sizes: '72x72', href: 'apple-touch-icon-72x72.png' },
|
|
34
|
+
{ rel: 'apple-touch-icon', sizes: '76x76', href: 'apple-touch-icon-76x76.png' },
|
|
35
|
+
{ rel: 'apple-touch-icon', sizes: '114x114',href: 'apple-touch-icon-114x114.png' },
|
|
36
|
+
{ rel: 'apple-touch-icon', sizes: '120x120',href: 'apple-touch-icon-120x120.png' },
|
|
37
|
+
{ rel: 'apple-touch-icon', sizes: '144x144',href: 'apple-touch-icon-144x144.png' },
|
|
38
|
+
{ rel: 'apple-touch-icon', sizes: '152x152',href: 'apple-touch-icon-152x152.png' },
|
|
39
|
+
{ rel: 'apple-touch-icon', sizes: '167x167',href: 'apple-touch-icon-167x167.png' },
|
|
40
|
+
{ rel: 'apple-touch-icon', sizes: '180x180',href: 'apple-touch-icon-180x180.png' },
|
|
41
|
+
{ rel: 'apple-touch-icon', sizes: '1024x1024',href: 'apple-touch-icon-1024x1024.png' },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
export interface BuildFaviconLinksInput {
|
|
45
|
+
/** Explicit consumer override via `branding.favicon.base_path`. */
|
|
46
|
+
brandingFaviconBasePath?: string;
|
|
47
|
+
/** Consumer's declared sub-path via `site-config.yml:basePath`. */
|
|
48
|
+
siteConfigBasePath?: string;
|
|
49
|
+
/** Build-time BASE_PATH from Astro/Vite (`import.meta.env.BASE_URL`). */
|
|
50
|
+
astroBaseUrl?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build the list of default favicon link tags with BASE_PATH-aware URLs.
|
|
55
|
+
*
|
|
56
|
+
* Returns the tag descriptors; the caller (Default.astro) renders them
|
|
57
|
+
* into <link> elements.
|
|
58
|
+
*/
|
|
59
|
+
export function buildDefaultFaviconLinks(input: BuildFaviconLinksInput): FaviconLinkTag[] {
|
|
60
|
+
const base = resolveFaviconBase(input);
|
|
61
|
+
return DEFAULT_FAVICON_FILES.map(tag => ({
|
|
62
|
+
...tag,
|
|
63
|
+
href: `${base}${tag.href}`,
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Render a single FaviconLinkTag to an HTML <link> string.
|
|
69
|
+
*
|
|
70
|
+
* Attribute order: rel, type (if present), sizes (if present), href.
|
|
71
|
+
* Deterministic for snapshot testing.
|
|
72
|
+
*/
|
|
73
|
+
export function renderFaviconLinkTag(tag: FaviconLinkTag): string {
|
|
74
|
+
const attrs: string[] = [`rel="${tag.rel}"`];
|
|
75
|
+
if (tag.type) attrs.push(`type="${tag.type}"`);
|
|
76
|
+
if (tag.sizes) attrs.push(`sizes="${tag.sizes}"`);
|
|
77
|
+
attrs.push(`href="${tag.href}"`);
|
|
78
|
+
return `<link ${attrs.join(' ')} />`;
|
|
79
|
+
}
|
|
@@ -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-
|
|
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>
|
package/src/views/GroupView.vue
CHANGED
|
@@ -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-
|
|
99
|
+
<h1 class="font-title text-3xl text-ink-800 dark:text-ink-50">
|
|
100
100
|
{{ group.label }}
|
|
101
101
|
</h1>
|
|
102
102
|
</div>
|
package/src/views/HomeView.vue
CHANGED
|
@@ -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-
|
|
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>
|
package/tailwind.config.js
CHANGED
|
@@ -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: {
|