@glossarist/concept-browser 0.7.108 → 0.7.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.
@@ -114,8 +117,34 @@ Environment:
114
117
 
115
118
  if (faviconCfgIsObject) {
116
119
  // Object form — honor each declared field.
120
+ if (faviconCfg.icons && Array.isArray(faviconCfg.icons) && faviconCfg.icons.length > 0) {
121
+ // Data-driven: build <link> tags from the icons array.
122
+ const basePathForIcons =
123
+ faviconCfg.base_path ||
124
+ process.env.BASE_PATH?.replace(/\/+$/, '') ||
125
+ '';
126
+ faviconHtml = faviconCfg.icons.map((icon) => {
127
+ const attrs = [`rel="${icon.rel || 'icon'}"`];
128
+ if (icon.type) attrs.push(`type="${icon.type}"`);
129
+ if (icon.sizes) attrs.push(`sizes="${icon.sizes}"`);
130
+ // Bare filenames get BASE_PATH-prefixed; absolute URLs / root-relative
131
+ // paths are emitted as-is.
132
+ let href = icon.href || '';
133
+ if (href && !/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith('//') && !href.startsWith('/')) {
134
+ href = basePathForIcons ? `${basePathForIcons}/${href}` : `/${href}`;
135
+ }
136
+ attrs.push(`href="${href}"`);
137
+ return `<link ${attrs.join(' ')} />`;
138
+ }).join('\n ');
139
+ skipDefaultGeneration = true;
140
+ }
117
141
  if (faviconCfg.links_html) {
118
- faviconHtml = String(faviconCfg.links_html);
142
+ if (faviconHtml) {
143
+ console.warn(' Warning: branding.favicon.icons and links_html are both set; using icons (data form).');
144
+ } else {
145
+ console.warn(' Warning: branding.favicon.links_html is deprecated. Use branding.favicon.icons (data) instead.');
146
+ faviconHtml = String(faviconCfg.links_html);
147
+ }
119
148
  skipDefaultGeneration = true;
120
149
  }
121
150
  if (faviconCfg.source_dir) {
package/cli/index.ts CHANGED
@@ -66,19 +66,23 @@ interface BrandingFonts {
66
66
  weights?: number[];
67
67
  }
68
68
 
69
+ interface FaviconIcon {
70
+ rel?: string;
71
+ href: string;
72
+ type?: string;
73
+ sizes?: string;
74
+ }
75
+
69
76
  interface FaviconConfig {
70
- /** Path prefix for all favicon URLs. Defaults to '/', set to a sub-path
71
- * when deploying under BASE_PATH != '/'. */
77
+ /** Path prefix for all favicon URLs. Defaults to BASE_PATH-aware '/'. */
72
78
  base_path?: string;
73
- /** Skip emitting the default <link 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). */
79
+ /** Skip emitting the default <link> block. */
76
80
  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. */
81
+ /** Consumer-declared icons (DATA, not HTML). Replaces the default set. */
82
+ icons?: FaviconIcon[];
83
+ /** @deprecated Use `icons` instead. Raw HTML — kept for backward compat. */
79
84
  links_html?: string;
80
- /** Consumer-side directory (relative to cwd) holding canonical favicon files.
81
- * The CLI copies these into public/, overriding defaults. */
85
+ /** Consumer-side directory of canonical favicon files. */
82
86
  source_dir?: string;
83
87
  }
84
88
 
@@ -133,8 +137,32 @@ async function runBuildPipeline(config: SiteConfig | null): Promise<void> {
133
137
 
134
138
  if (faviconCfgObject) {
135
139
  const cfg: FaviconConfig = faviconCfgObject;
140
+ if (cfg.icons && cfg.icons.length > 0) {
141
+ // Data-driven: build <link> tags from the icons array.
142
+ const basePathForIcons: string =
143
+ cfg.base_path ||
144
+ process.env.BASE_PATH?.replace(/\/+$/, '') ||
145
+ '';
146
+ faviconHtml = cfg.icons.map((icon: FaviconIcon): string => {
147
+ const attrs: string[] = [`rel="${icon.rel || 'icon'}"`];
148
+ if (icon.type) attrs.push(`type="${icon.type}"`);
149
+ if (icon.sizes) attrs.push(`sizes="${icon.sizes}"`);
150
+ let href: string = icon.href || '';
151
+ if (href && !/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith('//') && !href.startsWith('/')) {
152
+ href = basePathForIcons ? `${basePathForIcons}/${href}` : `/${href}`;
153
+ }
154
+ attrs.push(`href="${href}"`);
155
+ return `<link ${attrs.join(' ')} />`;
156
+ }).join('\n ');
157
+ skipDefaultGeneration = true;
158
+ }
136
159
  if (cfg.links_html) {
137
- faviconHtml = String(cfg.links_html);
160
+ if (faviconHtml) {
161
+ console.warn(' Warning: branding.favicon.icons and links_html are both set; using icons (data form).');
162
+ } else {
163
+ console.warn(' Warning: branding.favicon.links_html is deprecated. Use branding.favicon.icons (data) instead.');
164
+ faviconHtml = String(cfg.links_html);
165
+ }
138
166
  skipDefaultGeneration = true;
139
167
  }
140
168
  if (cfg.source_dir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.108",
3
+ "version": "0.7.109",
4
4
  "description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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. */
@@ -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} />
@@ -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
  *