@glossarist/concept-browser 0.7.106 → 0.7.107

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
@@ -109,7 +109,65 @@ branding:
109
109
 
110
110
  If no logo is configured, the Glossarist logo is shown by default. The footer always shows the Glossarist logo ("Powered by Glossarist").
111
111
 
112
- Favicons work the same way — place `favicon.svg`, `favicon.ico`, and `apple-touch-icon*.png` in `public/`. If not provided, the Glossarist favicon is used.
112
+ ### Favicons
113
+
114
+ `branding.favicon` accepts two shapes — a legacy **string** form and an **object** form that lets consumers provide their own canonical favicon set without writing a post-build script.
115
+
116
+ #### String form (legacy, still supported)
117
+
118
+ Path to a single source SVG/PNG. The CLI generates the full variant set (favicon.ico, apple-touch-icon-*.png, etc.) from it using the `favicons` package:
119
+
120
+ ```yaml
121
+ branding:
122
+ favicon: assets/my-brand.svg
123
+ ```
124
+
125
+ #### Object form (recommended for branded deployments)
126
+
127
+ The object form lets you provide your own canonical favicon set (typically RealFaviconGenerator output) and have the CLI install it without a post-build script.
128
+
129
+ A canonical favicon set is **multiple files** — typically `favicon.svg`, `favicon.ico`, `favicon-96x96.png`, `apple-touch-icon.png`, `web-app-manifest-192x192.png`, `web-app-manifest-512x512.png`, and `site.webmanifest`. Put all of them in a directory (e.g. `assets/favicons/`) and reference it via `source_dir`:
130
+
131
+ ```
132
+ my-deployment/
133
+ ├── site-config.yml
134
+ └── assets/
135
+ └── favicons/ ← branding.favicon.source_dir points here
136
+ ├── favicon.svg
137
+ ├── favicon.ico
138
+ ├── favicon-96x96.png
139
+ ├── apple-touch-icon.png
140
+ ├── web-app-manifest-192x192.png
141
+ ├── web-app-manifest-512x512.png
142
+ └── site.webmanifest ← optional; CLI regenerates with BASE_PATH
143
+ ```
144
+
145
+ Then in `site-config.yml`:
146
+
147
+ ```yaml
148
+ branding:
149
+ favicon:
150
+ base_path: / # URL prefix (BASE_PATH-aware; default '/')
151
+ skip_default_links: true # suppress the default <link> block
152
+ source_dir: assets/favicons # canonical files (all of them, in one directory)
153
+ links_html: |- # raw HTML emitted verbatim in <head>
154
+ <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
155
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
156
+ <link rel="shortcut icon" href="/favicon.ico" />
157
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
158
+ <link rel="manifest" href="/site.webmanifest" />
159
+ ```
160
+
161
+ | Field | Type | Effect |
162
+ |---|---|---|
163
+ | `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. |
164
+ | `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`. |
165
+ | `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. |
166
+ | `base_path` | string | URL prefix prepended to every emitted link. Useful for BASE_PATH-scoped deployments (e.g. `/vocab/`). |
167
+
168
+ 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.
169
+
170
+ If neither form is set, the Glossarist default favicon is used.
113
171
 
114
172
  ### About pages
115
173
 
package/cli/index.mjs CHANGED
@@ -88,53 +88,150 @@ Environment:
88
88
  fs.mkdirSync(publicDir, { recursive: true });
89
89
 
90
90
  const branding = config?.branding || {};
91
- const faviconSrc =
92
- (branding.favicon && resolve(process.cwd(), branding.favicon)) ||
93
- (branding.logo?.localPath && resolve(process.cwd(), branding.logo.localPath)) ||
94
- resolve(pkgRoot, 'public', 'favicon.svg');
91
+
92
+ // Favicon resolution (issue #173).
93
+ //
94
+ // `branding.favicon` accepts two shapes:
95
+ //
96
+ // 1. String (legacy) — path to a single source SVG/PNG. The `favicons`
97
+ // package generates the full variant set from it.
98
+ //
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/.
103
+ // - `source_dir`: directory of canonical favicon files copied as-is
104
+ // into public/ (overriding any defaults). Useful when the consumer
105
+ // already has a RealFaviconGenerator output set.
106
+ // - `skip_default_links`: don't emit the default <link> block at all.
107
+ // - `base_path`: URL prefix for the emitted links (BASE_PATH-aware).
108
+ const faviconCfg = branding.favicon;
109
+ const faviconCfgIsObject =
110
+ faviconCfg !== null && typeof faviconCfg === 'object' && !Array.isArray(faviconCfg);
95
111
 
96
112
  let faviconHtml = '';
97
- if (fs.existsSync(faviconSrc)) {
98
- console.log(`\n=== FAVICONS ===\n`);
99
- const favicons = (await import('favicons')).default;
100
- const source = fs.readFileSync(faviconSrc);
101
- const response = await favicons(source, {
102
- appName: config?.title || 'Glossarist',
103
- background: branding.primaryColor || '#2563eb',
104
- theme_color: branding.primaryColor || '#2563eb',
105
- icons: {
106
- android: false,
107
- appleIcon: true,
108
- appleStartup: false,
109
- favicons: true,
110
- windows: false,
111
- yandex: false,
112
- },
113
- });
114
-
115
- for (const img of response.images) {
116
- fs.writeFileSync(resolve(publicDir, img.name), img.contents);
117
- }
118
- for (const file of response.files) {
119
- fs.writeFileSync(resolve(publicDir, file.name), file.contents);
113
+ let skipDefaultGeneration = false;
114
+
115
+ if (faviconCfgIsObject) {
116
+ // Object form — honor each declared field.
117
+ if (faviconCfg.links_html) {
118
+ faviconHtml = String(faviconCfg.links_html);
119
+ skipDefaultGeneration = true;
120
120
  }
121
+ if (faviconCfg.source_dir) {
122
+ const srcDir = resolve(process.cwd(), faviconCfg.source_dir);
123
+ if (fs.existsSync(srcDir) && fs.statSync(srcDir).isDirectory()) {
124
+ console.log(`\n=== FAVICONS (copy from ${faviconCfg.source_dir}) ===\n`);
125
+ // When the consumer provides a canonical favicon set, also delete
126
+ // the default-generated cruft (apple-touch-icon-*.png, etc.) so
127
+ // it doesn't linger in public/ and pollute the deployment.
128
+ // Mirrors glossarist/iala-vocab/scripts/install-favicons.mjs.
129
+ const cruftFiles = [
130
+ 'apple-touch-icon-57x57.png',
131
+ 'apple-touch-icon-60x60.png',
132
+ 'apple-touch-icon-72x72.png',
133
+ 'apple-touch-icon-76x76.png',
134
+ 'apple-touch-icon-114x114.png',
135
+ 'apple-touch-icon-120x120.png',
136
+ 'apple-touch-icon-144x144.png',
137
+ 'apple-touch-icon-152x152.png',
138
+ 'apple-touch-icon-167x167.png',
139
+ 'apple-touch-icon-180x180.png',
140
+ 'apple-touch-icon-1024x1024.png',
141
+ 'apple-touch-icon-precomposed.png',
142
+ 'favicon-16x16.png',
143
+ 'favicon-32x32.png',
144
+ 'favicon-48x48.png',
145
+ 'browserconfig.xml',
146
+ ];
147
+ let removed = 0;
148
+ for (const cruft of cruftFiles) {
149
+ const cruftPath = resolve(publicDir, cruft);
150
+ if (fs.existsSync(cruftPath)) {
151
+ fs.unlinkSync(cruftPath);
152
+ removed++;
153
+ }
154
+ }
155
+ if (removed > 0) console.log(` Removed ${removed} default cruft file(s)`);
121
156
 
122
- if (faviconSrc.endsWith('.svg')) {
123
- fs.copyFileSync(faviconSrc, resolve(publicDir, 'favicon.svg'));
157
+ let copied = 0;
158
+ for (const entry of fs.readdirSync(srcDir)) {
159
+ const fromPath = resolve(srcDir, entry);
160
+ if (fs.statSync(fromPath).isFile()) {
161
+ fs.copyFileSync(fromPath, resolve(publicDir, entry));
162
+ copied++;
163
+ }
164
+ }
165
+ console.log(` Copied ${copied} canonical favicon file(s) from ${faviconCfg.source_dir}`);
166
+ } else {
167
+ console.warn(` Warning: branding.favicon.source_dir not found: ${srcDir}`);
168
+ }
124
169
  }
170
+ if (faviconCfg.skip_default_links) {
171
+ skipDefaultGeneration = true;
172
+ }
173
+ }
174
+
175
+ if (!skipDefaultGeneration) {
176
+ // Legacy / default path — derive a single source file and let the
177
+ // `favicons` package generate the full variant set.
178
+ const faviconSrc =
179
+ (typeof faviconCfg === 'string' && faviconCfg && resolve(process.cwd(), faviconCfg)) ||
180
+ (branding.logo?.localPath && resolve(process.cwd(), branding.logo.localPath)) ||
181
+ resolve(pkgRoot, 'public', 'favicon.svg');
125
182
 
126
- faviconHtml = response.html.join('\n ');
127
- if (faviconSrc.endsWith('.svg')) {
128
- faviconHtml += '\n <link rel="icon" type="image/svg+xml" href="/favicon.svg">';
183
+ if (fs.existsSync(faviconSrc)) {
184
+ console.log(`\n=== FAVICONS ===\n`);
185
+ const favicons = (await import('favicons')).default;
186
+ const source = fs.readFileSync(faviconSrc);
187
+ const response = await favicons(source, {
188
+ appName: config?.title || 'Glossarist',
189
+ background: branding.primaryColor || '#2563eb',
190
+ theme_color: branding.primaryColor || '#2563eb',
191
+ icons: {
192
+ android: false,
193
+ appleIcon: true,
194
+ appleStartup: false,
195
+ favicons: true,
196
+ windows: false,
197
+ yandex: false,
198
+ },
199
+ });
200
+
201
+ for (const img of response.images) {
202
+ fs.writeFileSync(resolve(publicDir, img.name), img.contents);
203
+ }
204
+ for (const file of response.files) {
205
+ fs.writeFileSync(resolve(publicDir, file.name), file.contents);
206
+ }
207
+
208
+ if (faviconSrc.endsWith('.svg')) {
209
+ fs.copyFileSync(faviconSrc, resolve(publicDir, 'favicon.svg'));
210
+ }
211
+
212
+ // Only use the generated HTML if the consumer didn't supply their own.
213
+ if (!faviconHtml) {
214
+ faviconHtml = response.html.join('\n ');
215
+ if (faviconSrc.endsWith('.svg')) {
216
+ faviconHtml += '\n <link rel="icon" type="image/svg+xml" href="/favicon.svg">';
217
+ }
218
+ }
219
+ console.log(` Generated ${response.images.length} favicon files`);
129
220
  }
130
- fs.writeFileSync(resolve(publicDir, 'favicon-links.html'), faviconHtml);
131
- console.log(` Generated ${response.images.length} favicon files`);
132
221
  }
133
222
 
134
223
  if (faviconHtml) {
135
- const basePath = process.env.BASE_PATH?.replace(/\/+$/, '') || '';
224
+ fs.writeFileSync(resolve(publicDir, 'favicon-links.html'), faviconHtml);
225
+ // Apply base_path prefix (issue #173 + BASE_PATH-aware deployments).
226
+ const basePath =
227
+ (faviconCfgIsObject && faviconCfg.base_path) ||
228
+ process.env.BASE_PATH?.replace(/\/+$/, '') ||
229
+ '';
136
230
  if (basePath) {
137
- faviconHtml = faviconHtml.replace(/(href|content)="\/([^"]+)"/g, `$1="${basePath}/$2"`);
231
+ faviconHtml = faviconHtml.replace(
232
+ /(href|content)="\/([^"]+)"/g,
233
+ `$1="${basePath}/$2`,
234
+ );
138
235
  }
139
236
  process.env.FAVICON_HTML = faviconHtml;
140
237
  }
package/cli/index.ts CHANGED
@@ -66,8 +66,26 @@ interface BrandingFonts {
66
66
  weights?: number[];
67
67
  }
68
68
 
69
+ interface FaviconConfig {
70
+ /** Path prefix for all favicon URLs. Defaults to '/', set to a sub-path
71
+ * when deploying under BASE_PATH != '/'. */
72
+ 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). */
76
+ 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. */
79
+ links_html?: string;
80
+ /** Consumer-side directory (relative to cwd) holding canonical favicon files.
81
+ * The CLI copies these into public/, overriding defaults. */
82
+ source_dir?: string;
83
+ }
84
+
69
85
  interface BrandingConfig {
70
- favicon?: string;
86
+ /** Either a string (legacy: path to a source SVG/PNG) or an object form
87
+ * with `source_dir`, `links_html`, `skip_default_links`, `base_path`. */
88
+ favicon?: string | FaviconConfig;
71
89
  logo?: { localPath?: string };
72
90
  primaryColor?: string;
73
91
  fonts?: { header?: BrandingFonts; body?: BrandingFonts };
@@ -99,53 +117,135 @@ async function runBuildPipeline(config: SiteConfig | null): Promise<void> {
99
117
  fs.mkdirSync(publicDir, { recursive: true });
100
118
 
101
119
  const branding: BrandingConfig = config?.branding ?? {};
102
- const faviconSrc: string =
103
- (branding.favicon && resolve(process.cwd(), branding.favicon)) ||
104
- (branding.logo?.localPath && resolve(process.cwd(), branding.logo.localPath)) ||
105
- resolve(pkgRoot, 'public', 'favicon.svg');
120
+
121
+ // Favicon resolution (issue #173, #177).
122
+ // `branding.favicon` accepts two shapes:
123
+ // 1. String (legacy) — path to a single source SVG/PNG.
124
+ // 2. Object — `{ source_dir?, links_html?, skip_default_links?, base_path? }`.
125
+ const faviconCfg: string | FaviconConfig | undefined = branding.favicon;
126
+ const faviconCfgIsObject: boolean =
127
+ faviconCfg !== null && typeof faviconCfg === 'object' && !Array.isArray(faviconCfg);
128
+ const faviconCfgObject: FaviconConfig | undefined =
129
+ faviconCfgIsObject ? (faviconCfg as FaviconConfig) : undefined;
106
130
 
107
131
  let faviconHtml = '';
108
- if (fs.existsSync(faviconSrc)) {
109
- console.log(`\n=== FAVICONS ===\n`);
110
- const favicons = (await import('favicons')).default;
111
- const source: Buffer = fs.readFileSync(faviconSrc);
112
- const response: FaviconResponse = await favicons(source, {
113
- appName: config?.title || 'Glossarist',
114
- background: branding.primaryColor || '#2563eb',
115
- theme_color: branding.primaryColor || '#2563eb',
116
- icons: {
117
- android: false,
118
- appleIcon: true,
119
- appleStartup: false,
120
- favicons: true,
121
- windows: false,
122
- yandex: false,
123
- },
124
- });
125
-
126
- for (const img of response.images) {
127
- fs.writeFileSync(resolve(publicDir, img.name), img.contents);
132
+ let skipDefaultGeneration = false;
133
+
134
+ if (faviconCfgObject) {
135
+ const cfg: FaviconConfig = faviconCfgObject;
136
+ if (cfg.links_html) {
137
+ faviconHtml = String(cfg.links_html);
138
+ skipDefaultGeneration = true;
128
139
  }
129
- for (const file of response.files) {
130
- fs.writeFileSync(resolve(publicDir, file.name), file.contents);
140
+ if (cfg.source_dir) {
141
+ const srcDir: string = resolve(process.cwd(), cfg.source_dir);
142
+ if (fs.existsSync(srcDir) && fs.statSync(srcDir).isDirectory()) {
143
+ console.log(`\n=== FAVICONS (copy from ${cfg.source_dir}) ===\n`);
144
+ // When the consumer provides a canonical favicon set, also delete
145
+ // the default-generated cruft so it doesn't linger in public/.
146
+ const cruftFiles: readonly string[] = [
147
+ 'apple-touch-icon-57x57.png',
148
+ 'apple-touch-icon-60x60.png',
149
+ 'apple-touch-icon-72x72.png',
150
+ 'apple-touch-icon-76x76.png',
151
+ 'apple-touch-icon-114x114.png',
152
+ 'apple-touch-icon-120x120.png',
153
+ 'apple-touch-icon-144x144.png',
154
+ 'apple-touch-icon-152x152.png',
155
+ 'apple-touch-icon-167x167.png',
156
+ 'apple-touch-icon-180x180.png',
157
+ 'apple-touch-icon-1024x1024.png',
158
+ 'apple-touch-icon-precomposed.png',
159
+ 'favicon-16x16.png',
160
+ 'favicon-32x32.png',
161
+ 'favicon-48x48.png',
162
+ 'browserconfig.xml',
163
+ ];
164
+ let removed = 0;
165
+ for (const cruft of cruftFiles) {
166
+ const cruftPath: string = resolve(publicDir, cruft);
167
+ if (fs.existsSync(cruftPath)) {
168
+ fs.unlinkSync(cruftPath);
169
+ removed++;
170
+ }
171
+ }
172
+ if (removed > 0) console.log(` Removed ${removed} default cruft file(s)`);
173
+
174
+ let copied = 0;
175
+ for (const entry of fs.readdirSync(srcDir)) {
176
+ const fromPath: string = resolve(srcDir, entry);
177
+ if (fs.statSync(fromPath).isFile()) {
178
+ fs.copyFileSync(fromPath, resolve(publicDir, entry));
179
+ copied++;
180
+ }
181
+ }
182
+ console.log(` Copied ${copied} canonical favicon file(s) from ${cfg.source_dir}`);
183
+ } else {
184
+ console.warn(` Warning: branding.favicon.source_dir not found: ${srcDir}`);
185
+ }
131
186
  }
132
-
133
- if (faviconSrc.endsWith('.svg')) {
134
- fs.copyFileSync(faviconSrc, resolve(publicDir, 'favicon.svg'));
187
+ if (cfg.skip_default_links) {
188
+ skipDefaultGeneration = true;
135
189
  }
190
+ }
136
191
 
137
- faviconHtml = response.html.join('\n ');
138
- if (faviconSrc.endsWith('.svg')) {
139
- faviconHtml += '\n <link rel="icon" type="image/svg+xml" href="/favicon.svg">';
192
+ if (!skipDefaultGeneration) {
193
+ const faviconSrc: string =
194
+ (typeof faviconCfg === 'string' && faviconCfg && resolve(process.cwd(), faviconCfg)) ||
195
+ (branding.logo?.localPath && resolve(process.cwd(), branding.logo.localPath)) ||
196
+ resolve(pkgRoot, 'public', 'favicon.svg');
197
+
198
+ if (fs.existsSync(faviconSrc)) {
199
+ console.log(`\n=== FAVICONS ===\n`);
200
+ const favicons = (await import('favicons')).default;
201
+ const source: Buffer = fs.readFileSync(faviconSrc);
202
+ const response: FaviconResponse = await favicons(source, {
203
+ appName: config?.title || 'Glossarist',
204
+ background: branding.primaryColor || '#2563eb',
205
+ theme_color: branding.primaryColor || '#2563eb',
206
+ icons: {
207
+ android: false,
208
+ appleIcon: true,
209
+ appleStartup: false,
210
+ favicons: true,
211
+ windows: false,
212
+ yandex: false,
213
+ },
214
+ });
215
+
216
+ for (const img of response.images) {
217
+ fs.writeFileSync(resolve(publicDir, img.name), img.contents);
218
+ }
219
+ for (const file of response.files) {
220
+ fs.writeFileSync(resolve(publicDir, file.name), file.contents);
221
+ }
222
+
223
+ if (faviconSrc.endsWith('.svg')) {
224
+ fs.copyFileSync(faviconSrc, resolve(publicDir, 'favicon.svg'));
225
+ }
226
+
227
+ // Only use the generated HTML if the consumer didn't supply their own.
228
+ if (!faviconHtml) {
229
+ faviconHtml = response.html.join('\n ');
230
+ if (faviconSrc.endsWith('.svg')) {
231
+ faviconHtml += '\n <link rel="icon" type="image/svg+xml" href="/favicon.svg">';
232
+ }
233
+ }
234
+ console.log(` Generated ${response.images.length} favicon files`);
140
235
  }
141
- fs.writeFileSync(resolve(publicDir, 'favicon-links.html'), faviconHtml);
142
- console.log(` Generated ${response.images.length} favicon files`);
143
236
  }
144
237
 
145
238
  if (faviconHtml) {
146
- const basePath: string = process.env.BASE_PATH?.replace(/\/+$/, '') || '';
239
+ fs.writeFileSync(resolve(publicDir, 'favicon-links.html'), faviconHtml);
240
+ const basePath: string =
241
+ (faviconCfgObject?.base_path) ||
242
+ process.env.BASE_PATH?.replace(/\/+$/, '') ||
243
+ '';
147
244
  if (basePath) {
148
- faviconHtml = faviconHtml.replace(/(href|content)="\/([^"]+)"/g, `$1="${basePath}/$2"`);
245
+ faviconHtml = faviconHtml.replace(
246
+ /(href|content)="\/([^"]+)"/g,
247
+ `$1="${basePath}/$2`,
248
+ );
149
249
  }
150
250
  process.env.FAVICON_HTML = faviconHtml;
151
251
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.106",
3
+ "version": "0.7.107",
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,61 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { resolve } from 'path';
3
+
4
+ /**
5
+ * Regression test for issue #177 — `branding.favicon` object form crashes
6
+ * the CLI with ERR_INVALID_ARG_TYPE.
7
+ *
8
+ * The 0.7.106 fix for #173 shipped `branding.favicon` as an object
9
+ * (`{ source_dir, links_html, skip_default_links, base_path }`) in the
10
+ * TypeScript types and in `src/layouts/Default.astro`, but `cli/index.mjs`
11
+ * still called `resolve(process.cwd(), branding.favicon)` — which throws
12
+ * `ERR_INVALID_ARG_TYPE` when `branding.favicon` is an object.
13
+ *
14
+ * Importing `cli/index.mjs` directly runs the full build pipeline, which
15
+ * is too heavy for a unit test. Instead, this spec pins the type-narrowing
16
+ * contract that the CLI's favicon resolution MUST honor: a string is a
17
+ * path, an object is a config, and the two must never be conflated. The
18
+ * integration smoke test (scripts/smoke.mjs) covers the end-to-end path.
19
+ */
20
+
21
+ describe('CLI favicon config (issue #177)', () => {
22
+ it('resolve() throws ERR_INVALID_ARG_TYPE when branding.favicon is an object — proves the bug class', async () => {
23
+ // This is the bug: the legacy code did `resolve(cwd, branding.favicon)`
24
+ // unconditionally. When branding.favicon is an object, Node throws.
25
+ const faviconCfg = { skip_default_links: true, links_html: '<link>' };
26
+ expect(() => resolve(process.cwd(), faviconCfg as any)).toThrow(/paths\[1\].*string|ERR_INVALID_ARG_TYPE/);
27
+ });
28
+
29
+ it('typeof-string guard prevents the crash', () => {
30
+ const faviconCfg = { skip_default_links: true, links_html: '<link>' };
31
+ const safeResolve = () =>
32
+ typeof faviconCfg === 'string' && faviconCfg
33
+ ? resolve(process.cwd(), faviconCfg)
34
+ : null;
35
+ expect(safeResolve).not.toThrow();
36
+ expect(safeResolve()).toBe(null);
37
+ });
38
+
39
+ it('accepts string form (legacy) — single source path', () => {
40
+ const faviconCfg = 'assets/my-brand.svg';
41
+ const resolved =
42
+ typeof faviconCfg === 'string' && faviconCfg
43
+ ? resolve(process.cwd(), faviconCfg)
44
+ : null;
45
+ expect(resolved).toBe(resolve(process.cwd(), 'assets/my-brand.svg'));
46
+ });
47
+
48
+ it('accepts object form — no path resolution attempted on the object itself', () => {
49
+ const faviconCfg = {
50
+ source_dir: 'assets/favicons',
51
+ links_html: '<link rel="icon" href="/favicon.svg" />',
52
+ skip_default_links: true,
53
+ base_path: '/',
54
+ };
55
+ expect(typeof faviconCfg).toBe('object');
56
+ // The object form must be inspected for fields, not passed to resolve().
57
+ expect((faviconCfg as any).source_dir).toBe('assets/favicons');
58
+ expect((faviconCfg as any).links_html).toContain('<link');
59
+ expect((faviconCfg as any).skip_default_links).toBe(true);
60
+ });
61
+ });