@glossarist/concept-browser 0.7.106 → 0.7.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -109,7 +109,152 @@ 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
+ ### Fonts
113
+
114
+ Brand typography is fully slot-based and category-agnostic. There are **four slots** — `title`, `heading`, `body`, `mono` — and each slot accepts **any category** (`serif`, `sans-serif`, `monospace`). Nothing dictates that "headings must be serif" or "body must be sans-serif". Pick whatever combination matches your brand.
115
+
116
+ #### Slots
117
+
118
+ | Slot | Applies to | Default family | Default category |
119
+ |---|---|---|---|
120
+ | `title` | The single most-prominent text on each page (home hero, concept name on detail page, dataset/group title) | DM Serif Display | serif |
121
+ | `heading` | h2–h6 section headings | DM Serif Display | serif |
122
+ | `body` | Paragraph text, lists, table cells | DM Sans | sans-serif |
123
+ | `mono` | Code blocks, inline code, kbd | JetBrains Mono | monospace |
124
+
125
+ The defaults preserve the Glossarist visual identity — override any of them to match your brand.
126
+
127
+ #### Per-slot category override
128
+
129
+ Set `category` on any slot to control the fallback chain (the browser uses it when the primary family fails to load or is still loading):
130
+
131
+ ```yaml
132
+ branding:
133
+ fonts:
134
+ title:
135
+ family: Inter
136
+ source: google
137
+ category: sans-serif # sans-serif title (was serif by default)
138
+ weights: [400, 600, 700]
139
+ heading:
140
+ family: Inter
141
+ source: google
142
+ category: sans-serif
143
+ weights: [600, 700]
144
+ body:
145
+ family: Merriweather
146
+ source: google
147
+ category: serif # serif body (was sans-serif by default)
148
+ weights: [400, 700]
149
+ mono:
150
+ family: Fira Code
151
+ source: google
152
+ category: monospace
153
+ weights: [400, 500]
154
+ ```
155
+
156
+ This produces:
157
+
158
+ | Slot | CSS variable | Stack |
159
+ |---|---|---|
160
+ | title | `--font-title` | `'Inter', system-ui, sans-serif` |
161
+ | heading | `--font-heading` (and `--font-header` for backward compat) | `'Inter', system-ui, sans-serif` |
162
+ | body | `--font-body` | `'Merriweather', Georgia, serif` |
163
+ | mono | `--font-mono` | `'Fira Code', ui-monospace, "JetBrains Mono", Menlo, Monaco, monospace` |
164
+
165
+ #### Source options
166
+
167
+ | `source` | Behavior |
168
+ |---|---|
169
+ | `google` | Build emits a Google Fonts CSS `@import` for the declared `family` + `weights`. |
170
+ | `url` | Build emits a `@font-face` block loading from `url`. |
171
+ | `local` | Consumer ships the font files in `public/`; no build-time fetch. |
172
+
173
+ #### Backward compatibility
174
+
175
+ The legacy `branding.fonts.header` slot still works — it's a deprecated alias for `branding.fonts.heading`. Existing configs don't break. The CSS variable `--font-header` is still emitted (as an alias for `--font-heading`) so existing stylesheets that reference it continue to work.
176
+
177
+ #### Mixed-category example: all sans-serif
178
+
179
+ ```yaml
180
+ branding:
181
+ fonts:
182
+ title: { family: Inter, source: google, category: sans-serif }
183
+ heading: { family: Inter, source: google, category: sans-serif }
184
+ body: { family: Inter, source: google, category: sans-serif }
185
+ mono: { family: JetBrains Mono, source: google, category: monospace }
186
+ ```
187
+
188
+ #### Mixed-category example: traditional serif
189
+
190
+ ```yaml
191
+ branding:
192
+ fonts:
193
+ title: { family: Playfair Display, source: google, category: serif }
194
+ heading: { family: Lora, source: google, category: serif }
195
+ body: { family: Source Sans Pro, source: google, category: sans-serif }
196
+ mono: { family: Source Code Pro, source: google, category: monospace }
197
+ ```
198
+
199
+ ### Favicons
200
+
201
+ `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.
202
+
203
+ #### String form (legacy, still supported)
204
+
205
+ 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:
206
+
207
+ ```yaml
208
+ branding:
209
+ favicon: assets/my-brand.svg
210
+ ```
211
+
212
+ #### Object form (recommended for branded deployments)
213
+
214
+ 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.
215
+
216
+ 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`:
217
+
218
+ ```
219
+ my-deployment/
220
+ ├── site-config.yml
221
+ └── assets/
222
+ └── favicons/ ← branding.favicon.source_dir points here
223
+ ├── favicon.svg
224
+ ├── favicon.ico
225
+ ├── favicon-96x96.png
226
+ ├── apple-touch-icon.png
227
+ ├── web-app-manifest-192x192.png
228
+ ├── web-app-manifest-512x512.png
229
+ └── site.webmanifest ← optional; CLI regenerates with BASE_PATH
230
+ ```
231
+
232
+ Then in `site-config.yml`:
233
+
234
+ ```yaml
235
+ branding:
236
+ favicon:
237
+ base_path: / # URL prefix (BASE_PATH-aware; default '/')
238
+ skip_default_links: true # suppress the default <link> block
239
+ 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" />
246
+ ```
247
+
248
+ | Field | Type | Effect |
249
+ |---|---|---|
250
+ | `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. |
253
+ | `base_path` | string | URL prefix prepended to every emitted link. Useful for BASE_PATH-scoped deployments (e.g. `/vocab/`). |
254
+
255
+ 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
+
257
+ If neither form is set, the Glossarist default favicon is used.
113
258
 
114
259
  ### About pages
115
260
 
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.108",
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
+ });