@glossarist/concept-browser 0.7.106 → 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 +146 -1
- package/cli/index.mjs +133 -36
- package/cli/index.ts +137 -37
- package/package.json +1 -1
- package/src/__tests__/cli/favicon-config.test.ts +61 -0
- 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
|
@@ -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
|
+
}
|