@glossarist/concept-browser 0.7.105 → 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.105",
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": {
@@ -20,6 +20,7 @@
20
20
  "sync:model": "node scripts/sync-concept-model.mjs",
21
21
  "build:full": "npm run generate-ontology && npm run fetch-datasets && npm run generate-data && node scripts/build-edges.js && npm run build",
22
22
  "build:site": "concept-browser --site build",
23
+ "smoke": "node scripts/smoke.mjs",
23
24
  "test": "vitest run",
24
25
  "test:watch": "vitest",
25
26
  "mutation:test": "stryker run stryker.config.mjs",
@@ -58,6 +59,7 @@
58
59
  "fast-check": "^4.8.0",
59
60
  "happy-dom": "^20.9.0",
60
61
  "n3": "^1.26.0",
62
+ "playwright": "^1.62.1",
61
63
  "rdf-validate-shacl": "^0.4.5",
62
64
  "typescript": "~5.7.3",
63
65
  "vitest": "^4.1.5",
@@ -1344,6 +1344,9 @@ async function processDataset(dir, register, opts) {
1344
1344
  await writeDatasetRdf(register, manifest, concepts, refMaps, opts);
1345
1345
 
1346
1346
  // Copy bibliography.yaml → bibliography.json
1347
+ // ALWAYS emit bibliography.json — empty stub `{ bibliography: [] }` when no
1348
+ // source exists — so the concept detail page's unconditional fetch doesn't
1349
+ // 404 (issue #174). The build never emits a request it can't satisfy.
1347
1350
  const bibPath = path.join(sourceRoot, 'bibliography.yaml');
1348
1351
  if (fs.existsSync(bibPath)) {
1349
1352
  const rawBib = readYaml(bibPath);
@@ -1351,6 +1354,9 @@ async function processDataset(dir, register, opts) {
1351
1354
  writeJson(path.join(DATA, register, 'bibliography.json'), bibData);
1352
1355
  const bibCount = bibData.bibliography.length;
1353
1356
  console.log(` Copied bibliography (${bibCount} entries)`);
1357
+ } else {
1358
+ writeJson(path.join(DATA, register, 'bibliography.json'), { bibliography: [] });
1359
+ console.log(` Emitted empty bibliography.json (no source bibliography.yaml)`);
1354
1360
  }
1355
1361
 
1356
1362
  // Copy images/ with magic-byte validation + manifest emission.