@glossarist/concept-browser 0.7.108 → 0.7.110
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 +20 -9
- package/cli/index.mjs +52 -11
- package/cli/index.ts +58 -17
- package/package.json +1 -1
- package/src/__tests__/utils/favicon-config-access.test.ts +147 -0
- package/src/__tests__/utils/favicon-links.test.ts +92 -31
- package/src/config/types.ts +22 -7
- package/src/layouts/Default.astro +50 -17
- package/src/utils/favicon-config-access.ts +68 -0
- package/src/utils/favicon-links.ts +63 -14
package/README.md
CHANGED
|
@@ -235,22 +235,33 @@ Then in `site-config.yml`:
|
|
|
235
235
|
branding:
|
|
236
236
|
favicon:
|
|
237
237
|
base_path: / # URL prefix (BASE_PATH-aware; default '/')
|
|
238
|
-
skip_default_links: true # suppress the default <link> block
|
|
239
238
|
source_dir: assets/favicons # canonical files (all of them, in one directory)
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
246
254
|
```
|
|
247
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
|
+
|
|
248
258
|
| Field | Type | Effect |
|
|
249
259
|
|---|---|---|
|
|
250
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. |
|
|
251
|
-
| `
|
|
252
|
-
| `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. |
|
|
253
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. |
|
|
254
265
|
|
|
255
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.
|
|
256
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.
|
|
@@ -109,19 +112,57 @@ Environment:
|
|
|
109
112
|
const faviconCfgIsObject =
|
|
110
113
|
faviconCfg !== null && typeof faviconCfg === 'object' && !Array.isArray(faviconCfg);
|
|
111
114
|
|
|
115
|
+
// Defensive field access: loadSiteConfig normalizes snake_case YAML keys
|
|
116
|
+
// to camelCase at load time, so `source_dir` arrives as `sourceDir`. But
|
|
117
|
+
// the canonical FaviconConfig schema documents snake_case (matching the
|
|
118
|
+
// YAML wire format), and Astro's getSiteConfig path does NOT normalize.
|
|
119
|
+
// Accept both forms so a future change to either loader can't break this.
|
|
120
|
+
// See src/utils/favicon-config-access.ts for the SSOT + tests.
|
|
121
|
+
const cfgBasePath = faviconCfgIsObject ? (faviconCfg.basePath ?? faviconCfg.base_path) : undefined;
|
|
122
|
+
const cfgSkipDefault = faviconCfgIsObject ? (faviconCfg.skipDefaultLinks ?? faviconCfg.skip_default_links) === true : false;
|
|
123
|
+
const cfgLinksHtml = faviconCfgIsObject ? (faviconCfg.linksHtml ?? faviconCfg.links_html) : undefined;
|
|
124
|
+
const cfgSourceDir = faviconCfgIsObject ? (faviconCfg.sourceDir ?? faviconCfg.source_dir) : undefined;
|
|
125
|
+
const cfgIcons = faviconCfgIsObject ? faviconCfg.icons : undefined;
|
|
126
|
+
|
|
112
127
|
let faviconHtml = '';
|
|
113
128
|
let skipDefaultGeneration = false;
|
|
114
129
|
|
|
115
130
|
if (faviconCfgIsObject) {
|
|
116
131
|
// Object form — honor each declared field.
|
|
117
|
-
if (
|
|
118
|
-
|
|
132
|
+
if (cfgIcons && Array.isArray(cfgIcons) && cfgIcons.length > 0) {
|
|
133
|
+
// Data-driven: build <link> tags from the icons array.
|
|
134
|
+
const basePathForIcons =
|
|
135
|
+
cfgBasePath ||
|
|
136
|
+
process.env.BASE_PATH?.replace(/\/+$/, '') ||
|
|
137
|
+
'';
|
|
138
|
+
faviconHtml = cfgIcons.map((icon) => {
|
|
139
|
+
const attrs = [`rel="${icon.rel || 'icon'}"`];
|
|
140
|
+
if (icon.type) attrs.push(`type="${icon.type}"`);
|
|
141
|
+
if (icon.sizes) attrs.push(`sizes="${icon.sizes}"`);
|
|
142
|
+
// Bare filenames get BASE_PATH-prefixed; absolute URLs / root-relative
|
|
143
|
+
// paths are emitted as-is.
|
|
144
|
+
let href = icon.href || '';
|
|
145
|
+
if (href && !/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith('//') && !href.startsWith('/')) {
|
|
146
|
+
href = basePathForIcons ? `${basePathForIcons}/${href}` : `/${href}`;
|
|
147
|
+
}
|
|
148
|
+
attrs.push(`href="${href}"`);
|
|
149
|
+
return `<link ${attrs.join(' ')} />`;
|
|
150
|
+
}).join('\n ');
|
|
151
|
+
skipDefaultGeneration = true;
|
|
152
|
+
}
|
|
153
|
+
if (cfgLinksHtml) {
|
|
154
|
+
if (faviconHtml) {
|
|
155
|
+
console.warn(' Warning: branding.favicon.icons and links_html are both set; using icons (data form).');
|
|
156
|
+
} else {
|
|
157
|
+
console.warn(' Warning: branding.favicon.links_html is deprecated. Use branding.favicon.icons (data) instead.');
|
|
158
|
+
faviconHtml = String(cfgLinksHtml);
|
|
159
|
+
}
|
|
119
160
|
skipDefaultGeneration = true;
|
|
120
161
|
}
|
|
121
|
-
if (
|
|
122
|
-
const srcDir = resolve(process.cwd(),
|
|
162
|
+
if (cfgSourceDir) {
|
|
163
|
+
const srcDir = resolve(process.cwd(), cfgSourceDir);
|
|
123
164
|
if (fs.existsSync(srcDir) && fs.statSync(srcDir).isDirectory()) {
|
|
124
|
-
console.log(`\n=== FAVICONS (copy from ${
|
|
165
|
+
console.log(`\n=== FAVICONS (copy from ${cfgSourceDir}) ===\n`);
|
|
125
166
|
// When the consumer provides a canonical favicon set, also delete
|
|
126
167
|
// the default-generated cruft (apple-touch-icon-*.png, etc.) so
|
|
127
168
|
// it doesn't linger in public/ and pollute the deployment.
|
|
@@ -162,12 +203,12 @@ Environment:
|
|
|
162
203
|
copied++;
|
|
163
204
|
}
|
|
164
205
|
}
|
|
165
|
-
console.log(` Copied ${copied} canonical favicon file(s) from ${
|
|
206
|
+
console.log(` Copied ${copied} canonical favicon file(s) from ${cfgSourceDir}`);
|
|
166
207
|
} else {
|
|
167
208
|
console.warn(` Warning: branding.favicon.source_dir not found: ${srcDir}`);
|
|
168
209
|
}
|
|
169
210
|
}
|
|
170
|
-
if (
|
|
211
|
+
if (cfgSkipDefault) {
|
|
171
212
|
skipDefaultGeneration = true;
|
|
172
213
|
}
|
|
173
214
|
}
|
|
@@ -224,7 +265,7 @@ Environment:
|
|
|
224
265
|
fs.writeFileSync(resolve(publicDir, 'favicon-links.html'), faviconHtml);
|
|
225
266
|
// Apply base_path prefix (issue #173 + BASE_PATH-aware deployments).
|
|
226
267
|
const basePath =
|
|
227
|
-
|
|
268
|
+
cfgBasePath ||
|
|
228
269
|
process.env.BASE_PATH?.replace(/\/+$/, '') ||
|
|
229
270
|
'';
|
|
230
271
|
if (basePath) {
|
package/cli/index.ts
CHANGED
|
@@ -66,20 +66,29 @@ 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.
|
|
71
|
-
*
|
|
77
|
+
/** Path prefix for all favicon URLs. Accepts both forms because
|
|
78
|
+
* loadSiteConfig normalizes snake_case → camelCase at load time. */
|
|
72
79
|
base_path?: string;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
* `links_html`. Default: false (emit default tags). */
|
|
80
|
+
basePath?: string;
|
|
81
|
+
/** Skip emitting the default <link> block. */
|
|
76
82
|
skip_default_links?: boolean;
|
|
77
|
-
|
|
78
|
-
|
|
83
|
+
skipDefaultLinks?: boolean;
|
|
84
|
+
/** Consumer-declared icons (DATA, not HTML). */
|
|
85
|
+
icons?: FaviconIcon[];
|
|
86
|
+
/** @deprecated Use `icons` instead. */
|
|
79
87
|
links_html?: string;
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
linksHtml?: string;
|
|
89
|
+
/** Consumer-side directory of canonical favicon files. */
|
|
82
90
|
source_dir?: string;
|
|
91
|
+
sourceDir?: string;
|
|
83
92
|
}
|
|
84
93
|
|
|
85
94
|
interface BrandingConfig {
|
|
@@ -133,14 +142,46 @@ async function runBuildPipeline(config: SiteConfig | null): Promise<void> {
|
|
|
133
142
|
|
|
134
143
|
if (faviconCfgObject) {
|
|
135
144
|
const cfg: FaviconConfig = faviconCfgObject;
|
|
136
|
-
|
|
137
|
-
|
|
145
|
+
// Defensive field access: loadSiteConfig normalizes snake_case → camelCase
|
|
146
|
+
// at load time. Accept both forms.
|
|
147
|
+
const cfgBasePath: string | undefined = cfg.basePath ?? cfg.base_path;
|
|
148
|
+
const cfgSkipDefault: boolean = (cfg.skipDefaultLinks ?? cfg.skip_default_links) === true;
|
|
149
|
+
const cfgLinksHtml: string | undefined = cfg.linksHtml ?? cfg.links_html;
|
|
150
|
+
const cfgSourceDir: string | undefined = cfg.sourceDir ?? cfg.source_dir;
|
|
151
|
+
const cfgIcons: FaviconIcon[] | undefined = cfg.icons;
|
|
152
|
+
|
|
153
|
+
if (cfgIcons && cfgIcons.length > 0) {
|
|
154
|
+
// Data-driven: build <link> tags from the icons array.
|
|
155
|
+
const basePathForIcons: string =
|
|
156
|
+
cfgBasePath ||
|
|
157
|
+
process.env.BASE_PATH?.replace(/\/+$/, '') ||
|
|
158
|
+
'';
|
|
159
|
+
faviconHtml = cfgIcons.map((icon: FaviconIcon): string => {
|
|
160
|
+
const attrs: string[] = [`rel="${icon.rel || 'icon'}"`];
|
|
161
|
+
if (icon.type) attrs.push(`type="${icon.type}"`);
|
|
162
|
+
if (icon.sizes) attrs.push(`sizes="${icon.sizes}"`);
|
|
163
|
+
let href: string = icon.href || '';
|
|
164
|
+
if (href && !/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith('//') && !href.startsWith('/')) {
|
|
165
|
+
href = basePathForIcons ? `${basePathForIcons}/${href}` : `/${href}`;
|
|
166
|
+
}
|
|
167
|
+
attrs.push(`href="${href}"`);
|
|
168
|
+
return `<link ${attrs.join(' ')} />`;
|
|
169
|
+
}).join('\n ');
|
|
170
|
+
skipDefaultGeneration = true;
|
|
171
|
+
}
|
|
172
|
+
if (cfgLinksHtml) {
|
|
173
|
+
if (faviconHtml) {
|
|
174
|
+
console.warn(' Warning: branding.favicon.icons and links_html are both set; using icons (data form).');
|
|
175
|
+
} else {
|
|
176
|
+
console.warn(' Warning: branding.favicon.links_html is deprecated. Use branding.favicon.icons (data) instead.');
|
|
177
|
+
faviconHtml = String(cfgLinksHtml);
|
|
178
|
+
}
|
|
138
179
|
skipDefaultGeneration = true;
|
|
139
180
|
}
|
|
140
|
-
if (
|
|
141
|
-
const srcDir: string = resolve(process.cwd(),
|
|
181
|
+
if (cfgSourceDir) {
|
|
182
|
+
const srcDir: string = resolve(process.cwd(), cfgSourceDir);
|
|
142
183
|
if (fs.existsSync(srcDir) && fs.statSync(srcDir).isDirectory()) {
|
|
143
|
-
console.log(`\n=== FAVICONS (copy from ${
|
|
184
|
+
console.log(`\n=== FAVICONS (copy from ${cfgSourceDir}) ===\n`);
|
|
144
185
|
// When the consumer provides a canonical favicon set, also delete
|
|
145
186
|
// the default-generated cruft so it doesn't linger in public/.
|
|
146
187
|
const cruftFiles: readonly string[] = [
|
|
@@ -179,12 +220,12 @@ async function runBuildPipeline(config: SiteConfig | null): Promise<void> {
|
|
|
179
220
|
copied++;
|
|
180
221
|
}
|
|
181
222
|
}
|
|
182
|
-
console.log(` Copied ${copied} canonical favicon file(s) from ${
|
|
223
|
+
console.log(` Copied ${copied} canonical favicon file(s) from ${cfgSourceDir}`);
|
|
183
224
|
} else {
|
|
184
225
|
console.warn(` Warning: branding.favicon.source_dir not found: ${srcDir}`);
|
|
185
226
|
}
|
|
186
227
|
}
|
|
187
|
-
if (
|
|
228
|
+
if (cfgSkipDefault) {
|
|
188
229
|
skipDefaultGeneration = true;
|
|
189
230
|
}
|
|
190
231
|
}
|
|
@@ -238,7 +279,7 @@ async function runBuildPipeline(config: SiteConfig | null): Promise<void> {
|
|
|
238
279
|
if (faviconHtml) {
|
|
239
280
|
fs.writeFileSync(resolve(publicDir, 'favicon-links.html'), faviconHtml);
|
|
240
281
|
const basePath: string =
|
|
241
|
-
(faviconCfgObject
|
|
282
|
+
(faviconCfgObject ? (faviconCfgObject.basePath ?? faviconCfgObject.base_path) : undefined) ||
|
|
242
283
|
process.env.BASE_PATH?.replace(/\/+$/, '') ||
|
|
243
284
|
'';
|
|
244
285
|
if (basePath) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.110",
|
|
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,147 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
getFaviconBasePath,
|
|
4
|
+
getFaviconSkipDefaultLinks,
|
|
5
|
+
getFaviconLinksHtml,
|
|
6
|
+
getFaviconSourceDir,
|
|
7
|
+
getFaviconIcons,
|
|
8
|
+
} from '../../utils/favicon-config-access';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Regression test for the snake_case/camelCase mismatch reported against
|
|
12
|
+
* 0.7.109 (user-filed bug, 2026-08-01):
|
|
13
|
+
*
|
|
14
|
+
* "source_dir reads snake_case — CLI reads faviconCfg.source_dir but
|
|
15
|
+
* loadSiteConfig normalizes to sourceDir. Same bug as skip_default_links,
|
|
16
|
+
* base_path, links_html. The canonical files never get copied."
|
|
17
|
+
*
|
|
18
|
+
* Root cause: two loaders, two conventions.
|
|
19
|
+
* - scripts/load-site-config.mjs normalizes snake_case → camelCase.
|
|
20
|
+
* - src/lib/site-config.ts (Astro) does NOT normalize.
|
|
21
|
+
*
|
|
22
|
+
* Fix: the accessors below accept BOTH forms. They check camelCase first
|
|
23
|
+
* (the post-normalization form) and fall back to snake_case (the raw YAML
|
|
24
|
+
* wire form). Either loader's output works.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
describe('getFaviconBasePath', () => {
|
|
28
|
+
it('returns undefined for null/undefined', () => {
|
|
29
|
+
expect(getFaviconBasePath(null)).toBeUndefined();
|
|
30
|
+
expect(getFaviconBasePath(undefined)).toBeUndefined();
|
|
31
|
+
expect(getFaviconBasePath({})).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('reads camelCase basePath', () => {
|
|
35
|
+
expect(getFaviconBasePath({ basePath: '/vocab/' })).toBe('/vocab/');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('reads snake_case base_path', () => {
|
|
39
|
+
expect(getFaviconBasePath({ base_path: '/vocab/' })).toBe('/vocab/');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('camelCase wins when both forms are set', () => {
|
|
43
|
+
expect(getFaviconBasePath({ basePath: '/camel/', base_path: '/snake/' })).toBe('/camel/');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('getFaviconSkipDefaultLinks', () => {
|
|
48
|
+
it('returns false for null/undefined', () => {
|
|
49
|
+
expect(getFaviconSkipDefaultLinks(null)).toBe(false);
|
|
50
|
+
expect(getFaviconSkipDefaultLinks(undefined)).toBe(false);
|
|
51
|
+
expect(getFaviconSkipDefaultLinks({})).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('reads camelCase skipDefaultLinks', () => {
|
|
55
|
+
expect(getFaviconSkipDefaultLinks({ skipDefaultLinks: true })).toBe(true);
|
|
56
|
+
expect(getFaviconSkipDefaultLinks({ skipDefaultLinks: false })).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('reads snake_case skip_default_links', () => {
|
|
60
|
+
expect(getFaviconSkipDefaultLinks({ skip_default_links: true })).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('getFaviconLinksHtml', () => {
|
|
65
|
+
it('returns undefined for null/undefined', () => {
|
|
66
|
+
expect(getFaviconLinksHtml(null)).toBeUndefined();
|
|
67
|
+
expect(getFaviconLinksHtml({})).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('reads camelCase linksHtml', () => {
|
|
71
|
+
expect(getFaviconLinksHtml({ linksHtml: '<link>' })).toBe('<link>');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('reads snake_case links_html', () => {
|
|
75
|
+
expect(getFaviconLinksHtml({ links_html: '<link>' })).toBe('<link>');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('getFaviconSourceDir', () => {
|
|
80
|
+
it('returns undefined for null/undefined', () => {
|
|
81
|
+
expect(getFaviconSourceDir(null)).toBeUndefined();
|
|
82
|
+
expect(getFaviconSourceDir({})).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('reads camelCase sourceDir', () => {
|
|
86
|
+
expect(getFaviconSourceDir({ sourceDir: 'assets/favicons' })).toBe('assets/favicons');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* THE LOAD-BEARING TEST — pins the exact bug the user reported.
|
|
91
|
+
* "source_dir reads snake_case — CLI reads faviconCfg.source_dir but
|
|
92
|
+
* loadSiteConfig normalizes to sourceDir. The canonical files never
|
|
93
|
+
* get copied."
|
|
94
|
+
*
|
|
95
|
+
* After loadSiteConfig normalization, the config object has sourceDir
|
|
96
|
+
* (camelCase), NOT source_dir. The accessor must return the value.
|
|
97
|
+
*/
|
|
98
|
+
it('reads snake_case source_dir (the user-reported regression form)', () => {
|
|
99
|
+
expect(getFaviconSourceDir({ source_dir: 'assets/favicons' })).toBe('assets/favicons');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('camelCase wins when both forms are set', () => {
|
|
103
|
+
expect(getFaviconSourceDir({ sourceDir: 'a', source_dir: 'b' })).toBe('a');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('getFaviconIcons', () => {
|
|
108
|
+
it('returns undefined for null/undefined', () => {
|
|
109
|
+
expect(getFaviconIcons(null)).toBeUndefined();
|
|
110
|
+
expect(getFaviconIcons({})).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('reads icons (same key in both forms)', () => {
|
|
114
|
+
const icons = [{ rel: 'icon', href: 'favicon.ico' }];
|
|
115
|
+
expect(getFaviconIcons({ icons })).toBe(icons);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('regression — both loader outputs work end-to-end', () => {
|
|
120
|
+
// Simulate the two loader paths:
|
|
121
|
+
// - Astro's getSiteConfig (no normalization): snake_case keys preserved
|
|
122
|
+
// - CLI's loadSiteConfig (normalizes): camelCase keys
|
|
123
|
+
const snakeCaseForm = {
|
|
124
|
+
base_path: '/cie-eilv/',
|
|
125
|
+
skip_default_links: true,
|
|
126
|
+
source_dir: 'assets/favicons',
|
|
127
|
+
links_html: '<link>',
|
|
128
|
+
icons: [{ rel: 'icon', href: 'favicon.ico' }],
|
|
129
|
+
};
|
|
130
|
+
const camelCaseForm = {
|
|
131
|
+
basePath: '/cie-eilv/',
|
|
132
|
+
skipDefaultLinks: true,
|
|
133
|
+
sourceDir: 'assets/favicons',
|
|
134
|
+
linksHtml: '<link>',
|
|
135
|
+
icons: [{ rel: 'icon', href: 'favicon.ico' }],
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
for (const [label, cfg] of [['snake_case (Astro)', snakeCaseForm], ['camelCase (CLI)', camelCaseForm]] as const) {
|
|
139
|
+
it(`accessors return identical values for ${label}`, () => {
|
|
140
|
+
expect(getFaviconBasePath(cfg)).toBe('/cie-eilv/');
|
|
141
|
+
expect(getFaviconSkipDefaultLinks(cfg)).toBe(true);
|
|
142
|
+
expect(getFaviconSourceDir(cfg)).toBe('assets/favicons');
|
|
143
|
+
expect(getFaviconLinksHtml(cfg)).toBe('<link>');
|
|
144
|
+
expect(getFaviconIcons(cfg)).toEqual([{ rel: 'icon', href: 'favicon.ico' }]);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
});
|
|
@@ -1,25 +1,23 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { buildFaviconLinks, renderFaviconLinkTag, type FaviconIcon } from '../../utils/favicon-links';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Regression test for the sub-path deployment 404 bug
|
|
5
|
+
* Regression test for the sub-path deployment 404 bug + the
|
|
6
|
+
* data-driven icons API.
|
|
6
7
|
*
|
|
7
|
-
* Bug (filed by user, 2026-08-01): deployments at sub-paths
|
|
8
|
+
* Bug 1 (filed by user, 2026-08-01): deployments at sub-paths
|
|
8
9
|
* (e.g. `/cie-eilv/`) saw console 404s for `favicon-32x32.png`,
|
|
9
10
|
* `favicon-16x16.png`, `favicon-48x48.png` because the emitted
|
|
10
|
-
* `<link href>` pointed at the ROOT
|
|
11
|
-
* of the deployment sub-path (`/cie-eilv/favicon-32x32.png`).
|
|
11
|
+
* `<link href>` pointed at the ROOT instead of the deployment sub-path.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* This test exercises the full link-emission path: config in →
|
|
18
|
-
* resolved base → link tags with correct hrefs.
|
|
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.
|
|
19
17
|
*/
|
|
20
|
-
describe('
|
|
18
|
+
describe('buildFaviconLinks — BASE_PATH regression', () => {
|
|
21
19
|
it('emits /-prefixed hrefs for root deployment', () => {
|
|
22
|
-
const links =
|
|
20
|
+
const links = buildFaviconLinks({ astroBaseUrl: '/' });
|
|
23
21
|
const png16 = links.find(l => l.sizes === '16x16');
|
|
24
22
|
const png32 = links.find(l => l.sizes === '32x32');
|
|
25
23
|
const png48 = links.find(l => l.sizes === '48x48');
|
|
@@ -30,13 +28,9 @@ describe('buildDefaultFaviconLinks — BASE_PATH regression', () => {
|
|
|
30
28
|
|
|
31
29
|
/**
|
|
32
30
|
* 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
31
|
*/
|
|
38
32
|
it('emits /cie-eilv/-prefixed hrefs for sub-path deployment', () => {
|
|
39
|
-
const links =
|
|
33
|
+
const links = buildFaviconLinks({
|
|
40
34
|
siteConfigBasePath: '/cie-eilv/',
|
|
41
35
|
astroBaseUrl: '/',
|
|
42
36
|
});
|
|
@@ -49,24 +43,18 @@ describe('buildDefaultFaviconLinks — BASE_PATH regression', () => {
|
|
|
49
43
|
});
|
|
50
44
|
|
|
51
45
|
it('handles deep sub-paths', () => {
|
|
52
|
-
const links =
|
|
53
|
-
siteConfigBasePath: '/vocab/iala/',
|
|
54
|
-
});
|
|
46
|
+
const links = buildFaviconLinks({ siteConfigBasePath: '/vocab/iala/' });
|
|
55
47
|
const icon = links.find(l => l.rel === 'icon' && l.type === 'image/x-icon');
|
|
56
48
|
expect(icon?.href).toBe('/vocab/iala/favicon.ico');
|
|
57
49
|
});
|
|
58
50
|
|
|
59
51
|
it('handles basePath without trailing slash', () => {
|
|
60
|
-
const links =
|
|
61
|
-
siteConfigBasePath: '/cie-eilv', // no trailing slash
|
|
62
|
-
});
|
|
52
|
+
const links = buildFaviconLinks({ siteConfigBasePath: '/cie-eilv' });
|
|
63
53
|
const icon = links.find(l => l.rel === 'icon' && l.type === 'image/x-icon');
|
|
64
54
|
expect(icon?.href).toBe('/cie-eilv/favicon.ico');
|
|
65
55
|
});
|
|
66
56
|
|
|
67
57
|
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
58
|
const scenarios = [
|
|
71
59
|
{ astroBaseUrl: '/' },
|
|
72
60
|
{ astroBaseUrl: '/vocab/' },
|
|
@@ -74,7 +62,7 @@ describe('buildDefaultFaviconLinks — BASE_PATH regression', () => {
|
|
|
74
62
|
{ brandingFaviconBasePath: '/custom/' },
|
|
75
63
|
];
|
|
76
64
|
for (const input of scenarios) {
|
|
77
|
-
const links =
|
|
65
|
+
const links = buildFaviconLinks(input);
|
|
78
66
|
for (const link of links) {
|
|
79
67
|
expect(link.href, `bare href for input ${JSON.stringify(input)}`).not.toBe('/');
|
|
80
68
|
expect(link.href.length, `empty href for input ${JSON.stringify(input)}`).toBeGreaterThan(1);
|
|
@@ -83,14 +71,88 @@ describe('buildDefaultFaviconLinks — BASE_PATH regression', () => {
|
|
|
83
71
|
});
|
|
84
72
|
|
|
85
73
|
it('emits the canonical 16 default favicon tags', () => {
|
|
86
|
-
const links =
|
|
87
|
-
// 5 icons (ico + svg + 3 PNG sizes) + 11 apple-touch-icon sizes = 16
|
|
74
|
+
const links = buildFaviconLinks({ astroBaseUrl: '/' });
|
|
88
75
|
expect(links.length).toBe(16);
|
|
89
76
|
expect(links.filter(l => l.rel === 'icon').length).toBe(5);
|
|
90
77
|
expect(links.filter(l => l.rel === 'apple-touch-icon').length).toBe(11);
|
|
91
78
|
});
|
|
92
79
|
});
|
|
93
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
|
+
|
|
94
156
|
describe('renderFaviconLinkTag', () => {
|
|
95
157
|
it('renders all attributes in deterministic order', () => {
|
|
96
158
|
const html = renderFaviconLinkTag({
|
|
@@ -112,8 +174,7 @@ describe('renderFaviconLinkTag', () => {
|
|
|
112
174
|
});
|
|
113
175
|
|
|
114
176
|
it('never emits an empty href', () => {
|
|
115
|
-
|
|
116
|
-
const links = buildDefaultFaviconLinks({ siteConfigBasePath: '/sub-path/' });
|
|
177
|
+
const links = buildFaviconLinks({ siteConfigBasePath: '/sub-path/' });
|
|
117
178
|
for (const link of links) {
|
|
118
179
|
const html = renderFaviconLinkTag(link);
|
|
119
180
|
const hrefMatch = html.match(/href="([^"]+)"/);
|
package/src/config/types.ts
CHANGED
|
@@ -30,16 +30,31 @@ export interface LogoConfig {
|
|
|
30
30
|
localDark?: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export interface FaviconIcon {
|
|
34
|
+
/** The `rel` attribute: "icon", "shortcut icon", "apple-touch-icon", "manifest". */
|
|
35
|
+
rel: string;
|
|
36
|
+
/** Icon file path. Bare filenames ("favicon.ico") get BASE_PATH-prefixed
|
|
37
|
+
* automatically; absolute URLs and root-relative paths are emitted as-is. */
|
|
38
|
+
href: string;
|
|
39
|
+
/** Optional MIME type hint (e.g., "image/png", "image/svg+xml"). */
|
|
40
|
+
type?: string;
|
|
41
|
+
/** Optional sizes attribute (e.g., "180x180", "any"). */
|
|
42
|
+
sizes?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
export interface FaviconConfig {
|
|
34
|
-
/** Path prefix for all favicon URLs. Defaults to '/'
|
|
35
|
-
* when deploying under BASE_PATH != '/'. */
|
|
46
|
+
/** Path prefix for all favicon URLs. Defaults to BASE_PATH-aware '/'. */
|
|
36
47
|
base_path?: string;
|
|
37
|
-
/** Skip emitting the default <link
|
|
38
|
-
*
|
|
39
|
-
* `links_html`. Default: false (emit default tags). */
|
|
48
|
+
/** Skip emitting the default <link> block. Use when the consumer provides
|
|
49
|
+
* their own icons via `icons` or wants zero favicon <link> tags. */
|
|
40
50
|
skip_default_links?: boolean;
|
|
41
|
-
/**
|
|
42
|
-
*
|
|
51
|
+
/** Consumer-declared icons (DATA, not HTML). Replaces the default 16-link
|
|
52
|
+
* set when non-empty. Each entry renders to a single <link> tag with
|
|
53
|
+
* BASE_PATH-aware href. */
|
|
54
|
+
icons?: FaviconIcon[];
|
|
55
|
+
/** @deprecated Use `icons` instead. Raw HTML emitted verbatim — impossible
|
|
56
|
+
* to validate, can't be safely BASE_PATH-rewritten. Kept for backward
|
|
57
|
+
* compat; emits a console warning when used. */
|
|
43
58
|
links_html?: string;
|
|
44
59
|
/** Consumer-side directory (relative to cwd) holding canonical favicon files.
|
|
45
60
|
* The CLI's favicon step copies these into public/, overriding defaults. */
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type FontConfigLike,
|
|
10
10
|
} from '../utils/font-stack';
|
|
11
11
|
import { resolveFaviconBase } from '../utils/favicon-base';
|
|
12
|
-
import {
|
|
12
|
+
import { buildFaviconLinks, renderFaviconLinkTag } from '../utils/favicon-links';
|
|
13
13
|
|
|
14
14
|
interface Props {
|
|
15
15
|
title: string;
|
|
@@ -52,24 +52,52 @@ const fontHeading = fontStack(headingFont, DEFAULT_CATEGORY.heading) ?? DEFAULT_
|
|
|
52
52
|
const fontBody = fontStack(brandingFonts?.body, DEFAULT_CATEGORY.body) ?? DEFAULT_FONTS.body;
|
|
53
53
|
const fontMono = fontStack(brandingFonts?.mono, DEFAULT_CATEGORY.mono) ?? DEFAULT_FONTS.mono;
|
|
54
54
|
|
|
55
|
-
// Favicon configuration (
|
|
55
|
+
// Favicon configuration (issues #173, #177, and the data-driven refactor).
|
|
56
56
|
//
|
|
57
|
-
// Consumers provide their own favicon set in one of
|
|
57
|
+
// Consumers provide their own favicon set in one of these ways:
|
|
58
58
|
//
|
|
59
|
-
// 1.
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
59
|
+
// 1. Declare icons as DATA (recommended):
|
|
60
|
+
// branding:
|
|
61
|
+
// favicon:
|
|
62
|
+
// icons:
|
|
63
|
+
// - rel: icon
|
|
64
|
+
// type: image/svg+xml
|
|
65
|
+
// href: favicon.svg
|
|
66
|
+
// - rel: icon
|
|
67
|
+
// type: image/png
|
|
68
|
+
// sizes: 96x96
|
|
69
|
+
// href: favicon-96x96.png
|
|
70
|
+
// - rel: shortcut icon
|
|
71
|
+
// href: favicon.ico
|
|
72
|
+
// - rel: apple-touch-icon
|
|
73
|
+
// sizes: 180x180
|
|
74
|
+
// href: apple-touch-icon.png
|
|
75
|
+
// - rel: manifest
|
|
76
|
+
// href: site.webmanifest
|
|
77
|
+
// The hrefs are filenames (or absolute URLs) — the layout applies
|
|
78
|
+
// the correct BASE_PATH prefix automatically.
|
|
63
79
|
//
|
|
64
|
-
// 2.
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
// that verbatim and skips the default tag block.
|
|
80
|
+
// 2. Drop canonical files at `branding.favicon.source_dir` (relative
|
|
81
|
+
// to the consumer's cwd). The CLI copies these into public/, and
|
|
82
|
+
// the layout emits the default 16-link set with BASE_PATH-aware URLs.
|
|
68
83
|
//
|
|
69
|
-
//
|
|
84
|
+
// 3. Set `branding.favicon.skip_default_links: true` to emit nothing
|
|
85
|
+
// (consumer takes full control via their own layout patching).
|
|
86
|
+
//
|
|
87
|
+
// `links_html` is DEPRECATED — raw HTML can't be safely BASE_PATH-rewritten
|
|
88
|
+
// or validated. It still works for backward compat, with a console warning.
|
|
70
89
|
const faviconCfg = siteConfig?.branding?.favicon ?? {};
|
|
71
|
-
const
|
|
72
|
-
|
|
90
|
+
const faviconCfgIsObject =
|
|
91
|
+
faviconCfg !== null && typeof faviconCfg === 'object' && !Array.isArray(faviconCfg);
|
|
92
|
+
const skipDefaultFaviconLinks = faviconCfgIsObject && faviconCfg.skip_default_links === true;
|
|
93
|
+
const consumerIcons = faviconCfgIsObject ? (faviconCfg.icons ?? null) : null;
|
|
94
|
+
const deprecatedLinksHtml: string = faviconCfgIsObject ? (faviconCfg.links_html ?? '') : '';
|
|
95
|
+
if (deprecatedLinksHtml) {
|
|
96
|
+
console.warn(
|
|
97
|
+
'branding.favicon.links_html is deprecated. Use branding.favicon.icons (data) instead — ' +
|
|
98
|
+
'see src/utils/favicon-links.ts for the schema. links_html will be removed in a future release.',
|
|
99
|
+
);
|
|
100
|
+
}
|
|
73
101
|
|
|
74
102
|
// BASE_PATH-aware favicon URL prefix. Walks the configuration cascade:
|
|
75
103
|
// branding.favicon.base_path → siteConfig.basePath → import.meta.env.BASE_URL → '/'.
|
|
@@ -83,17 +111,22 @@ const faviconBaseInput = {
|
|
|
83
111
|
astroBaseUrl: import.meta.env.BASE_URL,
|
|
84
112
|
};
|
|
85
113
|
const faviconBase = resolveFaviconBase(faviconBaseInput);
|
|
86
|
-
|
|
114
|
+
// Build the favicon <link> block from data (consumer-declared icons or
|
|
115
|
+
// the default 16-link set). BASE_PATH is applied to every href.
|
|
116
|
+
const faviconLinks = buildFaviconLinks({
|
|
117
|
+
...faviconBaseInput,
|
|
118
|
+
icons: consumerIcons ?? undefined,
|
|
119
|
+
}).map(renderFaviconLinkTag);
|
|
87
120
|
---
|
|
88
121
|
|
|
89
122
|
<!DOCTYPE html>
|
|
90
123
|
<html lang="en">
|
|
91
124
|
<head>
|
|
92
125
|
<meta charset="utf-8" />
|
|
93
|
-
{!skipDefaultFaviconLinks && (
|
|
126
|
+
{!skipDefaultFaviconLinks && faviconLinks.length > 0 && (
|
|
94
127
|
<Fragment set:html={faviconLinks.join('\n ')} />
|
|
95
128
|
)}
|
|
96
|
-
{
|
|
129
|
+
{deprecatedLinksHtml && <Fragment set:html={deprecatedLinksHtml} />}
|
|
97
130
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
98
131
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
99
132
|
<meta name="apple-mobile-web-app-title" content={siteConfig.title} />
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defensive favicon config accessor.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
*
|
|
6
|
+
* `loadSiteConfig` (scripts/load-site-config.mjs) normalizes YAML keys
|
|
7
|
+
* from snake_case to camelCase at load time. So a YAML config with
|
|
8
|
+
* `branding.favicon.source_dir: assets/favicons` arrives at the CLI as
|
|
9
|
+
* `cfg.sourceDir`. But the canonical `FaviconConfig` type documents the
|
|
10
|
+
* snake_case form (matching the YAML wire format), and Astro's
|
|
11
|
+
* `getSiteConfig` does NOT normalize — so `Default.astro` sees the raw
|
|
12
|
+
* snake_case form.
|
|
13
|
+
*
|
|
14
|
+
* The 0.7.107–0.7.109 CLI used `cfg.source_dir` directly, which silently
|
|
15
|
+
* returned `undefined` after the loader's normalization. Canonical files
|
|
16
|
+
* never got copied. Regression caught by user 2026-08-01.
|
|
17
|
+
*
|
|
18
|
+
* The fix: this helper reads BOTH forms. Each accessor takes the config
|
|
19
|
+
* object and returns whichever form is present. The class of "loader
|
|
20
|
+
* changed normalization, broke the CLI" is now permanently closed.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface RawFaviconConfig {
|
|
24
|
+
// snake_case (YAML wire format / Astro path)
|
|
25
|
+
base_path?: string;
|
|
26
|
+
skip_default_links?: boolean;
|
|
27
|
+
links_html?: string;
|
|
28
|
+
source_dir?: string;
|
|
29
|
+
icons?: unknown;
|
|
30
|
+
// camelCase (loadSiteConfig normalized form / TS convention)
|
|
31
|
+
basePath?: string;
|
|
32
|
+
skipDefaultLinks?: boolean;
|
|
33
|
+
linksHtml?: string;
|
|
34
|
+
sourceDir?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pick<T>(...vals: Array<T | undefined>): T | undefined {
|
|
38
|
+
for (const v of vals) {
|
|
39
|
+
if (v !== undefined && v !== null) return v;
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getFaviconBasePath(cfg: RawFaviconConfig | null | undefined): string | undefined {
|
|
45
|
+
if (!cfg) return undefined;
|
|
46
|
+
return pick(cfg.basePath, cfg.base_path);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getFaviconSkipDefaultLinks(cfg: RawFaviconConfig | null | undefined): boolean {
|
|
50
|
+
if (!cfg) return false;
|
|
51
|
+
return pick(cfg.skipDefaultLinks, cfg.skip_default_links) === true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getFaviconLinksHtml(cfg: RawFaviconConfig | null | undefined): string | undefined {
|
|
55
|
+
if (!cfg) return undefined;
|
|
56
|
+
return pick(cfg.linksHtml, cfg.links_html);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getFaviconSourceDir(cfg: RawFaviconConfig | null | undefined): string | undefined {
|
|
60
|
+
if (!cfg) return undefined;
|
|
61
|
+
return pick(cfg.sourceDir, cfg.source_dir);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getFaviconIcons(cfg: RawFaviconConfig | null | undefined): unknown {
|
|
65
|
+
if (!cfg) return undefined;
|
|
66
|
+
// `icons` has no snake_case form — same key in both worlds.
|
|
67
|
+
return cfg.icons;
|
|
68
|
+
}
|
|
@@ -4,16 +4,39 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Used by:
|
|
6
6
|
* - `src/layouts/Default.astro` (build-time SSG)
|
|
7
|
+
* - `cli/index.mjs` (consumer-side favicon pipeline)
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* Design principle: declare icons as DATA, not HTML. Consumers describe
|
|
10
|
+
* their favicon set via `branding.favicon.icons: [{ rel, href, type?,
|
|
11
|
+
* sizes? }]`. The href is a filename (or path relative to the deployment
|
|
12
|
+
* root); the system applies the correct BASE_PATH prefix and renders
|
|
13
|
+
* well-formed `<link>` tags. No raw HTML in YAML.
|
|
14
|
+
*
|
|
15
|
+
* Why data over HTML: HTML-in-YAML is impossible to validate, can't be
|
|
16
|
+
* BASE_PATH-rewritten safely (regex on user-supplied markup is brittle),
|
|
17
|
+
* and forces the consumer to know the exact attribute order/shape the
|
|
18
|
+
* browser expects. Data lets us validate, normalize, and render.
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
21
|
import { resolveFaviconBase } from './favicon-base';
|
|
16
22
|
|
|
23
|
+
export interface FaviconIcon {
|
|
24
|
+
/** The `rel` attribute. Most common: "icon", "shortcut icon",
|
|
25
|
+
* "apple-touch-icon", "manifest". */
|
|
26
|
+
rel: string;
|
|
27
|
+
/** The icon file path. May be either:
|
|
28
|
+
* - A bare filename ("favicon.ico") — gets prefixed with the resolved
|
|
29
|
+
* base path automatically.
|
|
30
|
+
* - An absolute URL ("https://...") or root-relative path ("/x.png")
|
|
31
|
+
* — emitted as-is, no prefixing.
|
|
32
|
+
*/
|
|
33
|
+
href: string;
|
|
34
|
+
/** Optional MIME type hint. */
|
|
35
|
+
type?: string;
|
|
36
|
+
/** Optional `sizes` attribute (e.g., "180x180", "any"). */
|
|
37
|
+
sizes?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
17
40
|
export interface FaviconLinkTag {
|
|
18
41
|
rel: string;
|
|
19
42
|
type?: string;
|
|
@@ -21,8 +44,9 @@ export interface FaviconLinkTag {
|
|
|
21
44
|
href: string;
|
|
22
45
|
}
|
|
23
46
|
|
|
24
|
-
/** Default favicon file set emitted by the CLI's `favicons` pipeline.
|
|
25
|
-
|
|
47
|
+
/** Default favicon file set emitted by the CLI's `favicons` pipeline.
|
|
48
|
+
* Used when the consumer doesn't declare `branding.favicon.icons`. */
|
|
49
|
+
const DEFAULT_FAVICON_FILES: readonly FaviconIcon[] = [
|
|
26
50
|
{ rel: 'icon', type: 'image/x-icon', href: 'favicon.ico' },
|
|
27
51
|
{ rel: 'icon', type: 'image/svg+xml', href: 'favicon.svg' },
|
|
28
52
|
{ rel: 'icon', type: 'image/png', sizes: '16x16', href: 'favicon-16x16.png' },
|
|
@@ -48,22 +72,47 @@ export interface BuildFaviconLinksInput {
|
|
|
48
72
|
siteConfigBasePath?: string;
|
|
49
73
|
/** Build-time BASE_PATH from Astro/Vite (`import.meta.env.BASE_URL`). */
|
|
50
74
|
astroBaseUrl?: string;
|
|
75
|
+
/** Consumer-declared icons. When set, replaces the default set. */
|
|
76
|
+
icons?: readonly FaviconIcon[];
|
|
51
77
|
}
|
|
52
78
|
|
|
53
79
|
/**
|
|
54
|
-
* Build the list of
|
|
80
|
+
* Build the list of favicon link tags with BASE_PATH-aware URLs.
|
|
55
81
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
82
|
+
* - If `input.icons` is provided (non-empty), uses that set; otherwise
|
|
83
|
+
* falls back to the default 16-link set.
|
|
84
|
+
* - For each icon, the href is normalized:
|
|
85
|
+
* - Filenames ("favicon.ico") → prefixed with the resolved base path.
|
|
86
|
+
* - Absolute URLs ("https://...") → emitted as-is.
|
|
87
|
+
* - Root-relative paths ("/x.png") → emitted as-is.
|
|
58
88
|
*/
|
|
59
|
-
export function
|
|
89
|
+
export function buildFaviconLinks(input: BuildFaviconLinksInput): FaviconLinkTag[] {
|
|
60
90
|
const base = resolveFaviconBase(input);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
91
|
+
const icons = input.icons && input.icons.length > 0 ? input.icons : DEFAULT_FAVICON_FILES;
|
|
92
|
+
return icons.map(icon => ({
|
|
93
|
+
rel: icon.rel,
|
|
94
|
+
type: icon.type,
|
|
95
|
+
sizes: icon.sizes,
|
|
96
|
+
href: prefixHref(icon.href, base),
|
|
64
97
|
}));
|
|
65
98
|
}
|
|
66
99
|
|
|
100
|
+
/** Back-compat alias — older name for the same operation. */
|
|
101
|
+
export const buildDefaultFaviconLinks = buildFaviconLinks;
|
|
102
|
+
|
|
103
|
+
/** Decide whether to prefix the href with the base path.
|
|
104
|
+
* Absolute URLs and root-relative paths are emitted as-is; bare filenames
|
|
105
|
+
* and relative paths are prefixed. */
|
|
106
|
+
function prefixHref(href: string, base: string): string {
|
|
107
|
+
if (!href) return base;
|
|
108
|
+
// Absolute URL: https://, http://, //, data:, etc.
|
|
109
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//')) return href;
|
|
110
|
+
// Root-relative: /anything
|
|
111
|
+
if (href.startsWith('/')) return href;
|
|
112
|
+
// Bare filename or relative path: prefix with base.
|
|
113
|
+
return `${base}${href}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
67
116
|
/**
|
|
68
117
|
* Render a single FaviconLinkTag to an HTML <link> string.
|
|
69
118
|
*
|