@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 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
- links_html: |- # raw HTML emitted verbatim in <head>
241
- <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
242
- <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
243
- <link rel="shortcut icon" href="/favicon.ico" />
244
- <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
245
- <link rel="manifest" href="/site.webmanifest" />
239
+ icons: # DATA — declare each icon, not HTML
240
+ - rel: icon
241
+ type: image/svg+xml
242
+ href: favicon.svg
243
+ - rel: icon
244
+ type: image/png
245
+ sizes: 96x96
246
+ href: favicon-96x96.png
247
+ - rel: shortcut icon
248
+ href: favicon.ico
249
+ - rel: apple-touch-icon
250
+ sizes: 180x180
251
+ href: apple-touch-icon.png
252
+ - rel: manifest
253
+ href: site.webmanifest
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
- | `links_html` | string | Raw HTML (one or more `<link>` tags) emitted verbatim in `<head>`. Use this to declare which of the source_dir files the browser should load, with what `sizes`/`type`. |
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 `links_html` and `source_dir` for fully custom branding. |
261
+ | `icons` | `FaviconIcon[]` | **DATA — recommended.** Array of `{ rel, href, type?, sizes? }` entries. Each renders to one `<link>` tag with BASE_PATH-aware href. Replaces the default 16-link set. |
262
+ | `skip_default_links` | boolean | When true, the CLI does NOT call the `favicons` package and does NOT emit the default `<link>` tags. Pair with `icons` and `source_dir` for fully custom branding. |
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
- // - `links_html`: raw HTML (one or more <link> tags) emitted verbatim.
101
- // Skips the `favicons` package entirely; consumer is responsible
102
- // for providing the actual icon files in public/.
100
+ // - `icons`: DATA — array of { rel, href, type?, sizes? } entries.
101
+ // The hrefs are filenames; the CLI applies BASE_PATH and renders
102
+ // well-formed <link> tags. Recommended over `links_html`.
103
+ // - `links_html` (deprecated): raw HTML emitted verbatim.
104
+ // Skips the `favicons` package entirely; impossible to validate
105
+ // or safely BASE_PATH-rewrite. Use `icons` instead.
103
106
  // - `source_dir`: directory of canonical favicon files copied as-is
104
107
  // into public/ (overriding any defaults). Useful when the consumer
105
108
  // already has a RealFaviconGenerator output set.
@@ -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 (faviconCfg.links_html) {
118
- faviconHtml = String(faviconCfg.links_html);
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 (faviconCfg.source_dir) {
122
- const srcDir = resolve(process.cwd(), faviconCfg.source_dir);
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 ${faviconCfg.source_dir}) ===\n`);
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 ${faviconCfg.source_dir}`);
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 (faviconCfg.skip_default_links) {
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
- (faviconCfgIsObject && faviconCfg.base_path) ||
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. Defaults to '/', set to a sub-path
71
- * when deploying under BASE_PATH != '/'. */
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
- /** Skip emitting the default <link rel="icon"> and <link rel="apple-touch-icon">
74
- * tags. Use when the consumer provides their own RFG-compatible markup via
75
- * `links_html`. Default: false (emit default tags). */
80
+ basePath?: string;
81
+ /** Skip emitting the default <link> block. */
76
82
  skip_default_links?: boolean;
77
- /** Raw HTML string (one or more <link> tags) to emit in <head> instead of
78
- * the default favicon block. RealFaviconGenerator output goes here. */
83
+ skipDefaultLinks?: boolean;
84
+ /** Consumer-declared icons (DATA, not HTML). */
85
+ icons?: FaviconIcon[];
86
+ /** @deprecated Use `icons` instead. */
79
87
  links_html?: string;
80
- /** Consumer-side directory (relative to cwd) holding canonical favicon files.
81
- * The CLI copies these into public/, overriding defaults. */
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
- if (cfg.links_html) {
137
- faviconHtml = String(cfg.links_html);
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 (cfg.source_dir) {
141
- const srcDir: string = resolve(process.cwd(), cfg.source_dir);
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 ${cfg.source_dir}) ===\n`);
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 ${cfg.source_dir}`);
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 (cfg.skip_default_links) {
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?.base_path) ||
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.108",
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 { buildDefaultFaviconLinks, renderFaviconLinkTag } from '../../utils/favicon-links';
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 (`/favicon-32x32.png`) instead
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
- * 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.
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('buildDefaultFaviconLinks — BASE_PATH regression', () => {
18
+ describe('buildFaviconLinks — BASE_PATH regression', () => {
21
19
  it('emits /-prefixed hrefs for root deployment', () => {
22
- const links = buildDefaultFaviconLinks({ astroBaseUrl: '/' });
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 = buildDefaultFaviconLinks({
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 = buildDefaultFaviconLinks({
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 = buildDefaultFaviconLinks({
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 = buildDefaultFaviconLinks(input);
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 = buildDefaultFaviconLinks({ astroBaseUrl: '/' });
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
- // Build a complete link tag and verify the href attribute has content.
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="([^"]+)"/);
@@ -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 '/', set to a sub-path
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 rel="icon"> and <link rel="apple-touch-icon">
38
- * tags. Use when the consumer provides their own RFG-compatible markup via
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
- /** Raw HTML string (one or more <link> tags) to emit in <head> instead of
42
- * the default favicon block. RealFaviconGenerator output goes here. */
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 { buildDefaultFaviconLinks, renderFaviconLinkTag } from '../utils/favicon-links';
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 (issue #173).
55
+ // Favicon configuration (issues #173, #177, and the data-driven refactor).
56
56
  //
57
- // Consumers provide their own favicon set in one of two ways:
57
+ // Consumers provide their own favicon set in one of these ways:
58
58
  //
59
- // 1. Drop canonical files at `branding.favicon.source_dir` (relative
60
- // to the consumer's cwd). The CLI's existing favicon step already
61
- // copies these into public/ overriding the defaults — this layout
62
- // just needs to emit the right <link> tags.
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. Set `branding.favicon.skip_default_links: true` and inject
65
- // custom <link> tags via the `branding.favicon.links_html`
66
- // string (e.g. RealFaviconGenerator output). The layout emits
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
- // When neither is set, today's default behavior is preserved.
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 skipDefaultFaviconLinks = faviconCfg.skip_default_links === true;
72
- const customFaviconLinks: string = faviconCfg.links_html ?? '';
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
- const faviconLinks = buildDefaultFaviconLinks(faviconBaseInput).map(renderFaviconLinkTag);
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
- {customFaviconLinks && <Fragment set:html={customFaviconLinks} />}
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
- * 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.
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
- const DEFAULT_FAVICON_FILES: readonly FaviconLinkTag[] = [
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 default favicon link tags with BASE_PATH-aware URLs.
80
+ * Build the list of favicon link tags with BASE_PATH-aware URLs.
55
81
  *
56
- * Returns the tag descriptors; the caller (Default.astro) renders them
57
- * into <link> elements.
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 buildDefaultFaviconLinks(input: BuildFaviconLinksInput): FaviconLinkTag[] {
89
+ export function buildFaviconLinks(input: BuildFaviconLinksInput): FaviconLinkTag[] {
60
90
  const base = resolveFaviconBase(input);
61
- return DEFAULT_FAVICON_FILES.map(tag => ({
62
- ...tag,
63
- href: `${base}${tag.href}`,
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
  *