@glossarist/concept-browser 0.7.109 → 0.7.110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/index.mjs CHANGED
@@ -112,18 +112,30 @@ Environment:
112
112
  const faviconCfgIsObject =
113
113
  faviconCfg !== null && typeof faviconCfg === 'object' && !Array.isArray(faviconCfg);
114
114
 
115
+ // Defensive field access: loadSiteConfig normalizes snake_case YAML keys
116
+ // to camelCase at load time, so `source_dir` arrives as `sourceDir`. But
117
+ // the canonical FaviconConfig schema documents snake_case (matching the
118
+ // YAML wire format), and Astro's getSiteConfig path does NOT normalize.
119
+ // Accept both forms so a future change to either loader can't break this.
120
+ // See src/utils/favicon-config-access.ts for the SSOT + tests.
121
+ const cfgBasePath = faviconCfgIsObject ? (faviconCfg.basePath ?? faviconCfg.base_path) : undefined;
122
+ const cfgSkipDefault = faviconCfgIsObject ? (faviconCfg.skipDefaultLinks ?? faviconCfg.skip_default_links) === true : false;
123
+ const cfgLinksHtml = faviconCfgIsObject ? (faviconCfg.linksHtml ?? faviconCfg.links_html) : undefined;
124
+ const cfgSourceDir = faviconCfgIsObject ? (faviconCfg.sourceDir ?? faviconCfg.source_dir) : undefined;
125
+ const cfgIcons = faviconCfgIsObject ? faviconCfg.icons : undefined;
126
+
115
127
  let faviconHtml = '';
116
128
  let skipDefaultGeneration = false;
117
129
 
118
130
  if (faviconCfgIsObject) {
119
131
  // Object form — honor each declared field.
120
- if (faviconCfg.icons && Array.isArray(faviconCfg.icons) && faviconCfg.icons.length > 0) {
132
+ if (cfgIcons && Array.isArray(cfgIcons) && cfgIcons.length > 0) {
121
133
  // Data-driven: build <link> tags from the icons array.
122
134
  const basePathForIcons =
123
- faviconCfg.base_path ||
135
+ cfgBasePath ||
124
136
  process.env.BASE_PATH?.replace(/\/+$/, '') ||
125
137
  '';
126
- faviconHtml = faviconCfg.icons.map((icon) => {
138
+ faviconHtml = cfgIcons.map((icon) => {
127
139
  const attrs = [`rel="${icon.rel || 'icon'}"`];
128
140
  if (icon.type) attrs.push(`type="${icon.type}"`);
129
141
  if (icon.sizes) attrs.push(`sizes="${icon.sizes}"`);
@@ -138,19 +150,19 @@ Environment:
138
150
  }).join('\n ');
139
151
  skipDefaultGeneration = true;
140
152
  }
141
- if (faviconCfg.links_html) {
153
+ if (cfgLinksHtml) {
142
154
  if (faviconHtml) {
143
155
  console.warn(' Warning: branding.favicon.icons and links_html are both set; using icons (data form).');
144
156
  } else {
145
157
  console.warn(' Warning: branding.favicon.links_html is deprecated. Use branding.favicon.icons (data) instead.');
146
- faviconHtml = String(faviconCfg.links_html);
158
+ faviconHtml = String(cfgLinksHtml);
147
159
  }
148
160
  skipDefaultGeneration = true;
149
161
  }
150
- if (faviconCfg.source_dir) {
151
- const srcDir = resolve(process.cwd(), faviconCfg.source_dir);
162
+ if (cfgSourceDir) {
163
+ const srcDir = resolve(process.cwd(), cfgSourceDir);
152
164
  if (fs.existsSync(srcDir) && fs.statSync(srcDir).isDirectory()) {
153
- console.log(`\n=== FAVICONS (copy from ${faviconCfg.source_dir}) ===\n`);
165
+ console.log(`\n=== FAVICONS (copy from ${cfgSourceDir}) ===\n`);
154
166
  // When the consumer provides a canonical favicon set, also delete
155
167
  // the default-generated cruft (apple-touch-icon-*.png, etc.) so
156
168
  // it doesn't linger in public/ and pollute the deployment.
@@ -191,12 +203,12 @@ Environment:
191
203
  copied++;
192
204
  }
193
205
  }
194
- console.log(` Copied ${copied} canonical favicon file(s) from ${faviconCfg.source_dir}`);
206
+ console.log(` Copied ${copied} canonical favicon file(s) from ${cfgSourceDir}`);
195
207
  } else {
196
208
  console.warn(` Warning: branding.favicon.source_dir not found: ${srcDir}`);
197
209
  }
198
210
  }
199
- if (faviconCfg.skip_default_links) {
211
+ if (cfgSkipDefault) {
200
212
  skipDefaultGeneration = true;
201
213
  }
202
214
  }
@@ -253,7 +265,7 @@ Environment:
253
265
  fs.writeFileSync(resolve(publicDir, 'favicon-links.html'), faviconHtml);
254
266
  // Apply base_path prefix (issue #173 + BASE_PATH-aware deployments).
255
267
  const basePath =
256
- (faviconCfgIsObject && faviconCfg.base_path) ||
268
+ cfgBasePath ||
257
269
  process.env.BASE_PATH?.replace(/\/+$/, '') ||
258
270
  '';
259
271
  if (basePath) {
package/cli/index.ts CHANGED
@@ -74,16 +74,21 @@ interface FaviconIcon {
74
74
  }
75
75
 
76
76
  interface FaviconConfig {
77
- /** Path prefix for all favicon URLs. Defaults to BASE_PATH-aware '/'. */
77
+ /** Path prefix for all favicon URLs. Accepts both forms because
78
+ * loadSiteConfig normalizes snake_case → camelCase at load time. */
78
79
  base_path?: string;
80
+ basePath?: string;
79
81
  /** Skip emitting the default <link> block. */
80
82
  skip_default_links?: boolean;
81
- /** Consumer-declared icons (DATA, not HTML). Replaces the default set. */
83
+ skipDefaultLinks?: boolean;
84
+ /** Consumer-declared icons (DATA, not HTML). */
82
85
  icons?: FaviconIcon[];
83
- /** @deprecated Use `icons` instead. Raw HTML — kept for backward compat. */
86
+ /** @deprecated Use `icons` instead. */
84
87
  links_html?: string;
88
+ linksHtml?: string;
85
89
  /** Consumer-side directory of canonical favicon files. */
86
90
  source_dir?: string;
91
+ sourceDir?: string;
87
92
  }
88
93
 
89
94
  interface BrandingConfig {
@@ -137,13 +142,21 @@ async function runBuildPipeline(config: SiteConfig | null): Promise<void> {
137
142
 
138
143
  if (faviconCfgObject) {
139
144
  const cfg: FaviconConfig = faviconCfgObject;
140
- if (cfg.icons && cfg.icons.length > 0) {
145
+ // Defensive field access: loadSiteConfig normalizes snake_case → camelCase
146
+ // at load time. Accept both forms.
147
+ const cfgBasePath: string | undefined = cfg.basePath ?? cfg.base_path;
148
+ const cfgSkipDefault: boolean = (cfg.skipDefaultLinks ?? cfg.skip_default_links) === true;
149
+ const cfgLinksHtml: string | undefined = cfg.linksHtml ?? cfg.links_html;
150
+ const cfgSourceDir: string | undefined = cfg.sourceDir ?? cfg.source_dir;
151
+ const cfgIcons: FaviconIcon[] | undefined = cfg.icons;
152
+
153
+ if (cfgIcons && cfgIcons.length > 0) {
141
154
  // Data-driven: build <link> tags from the icons array.
142
155
  const basePathForIcons: string =
143
- cfg.base_path ||
156
+ cfgBasePath ||
144
157
  process.env.BASE_PATH?.replace(/\/+$/, '') ||
145
158
  '';
146
- faviconHtml = cfg.icons.map((icon: FaviconIcon): string => {
159
+ faviconHtml = cfgIcons.map((icon: FaviconIcon): string => {
147
160
  const attrs: string[] = [`rel="${icon.rel || 'icon'}"`];
148
161
  if (icon.type) attrs.push(`type="${icon.type}"`);
149
162
  if (icon.sizes) attrs.push(`sizes="${icon.sizes}"`);
@@ -156,19 +169,19 @@ async function runBuildPipeline(config: SiteConfig | null): Promise<void> {
156
169
  }).join('\n ');
157
170
  skipDefaultGeneration = true;
158
171
  }
159
- if (cfg.links_html) {
172
+ if (cfgLinksHtml) {
160
173
  if (faviconHtml) {
161
174
  console.warn(' Warning: branding.favicon.icons and links_html are both set; using icons (data form).');
162
175
  } else {
163
176
  console.warn(' Warning: branding.favicon.links_html is deprecated. Use branding.favicon.icons (data) instead.');
164
- faviconHtml = String(cfg.links_html);
177
+ faviconHtml = String(cfgLinksHtml);
165
178
  }
166
179
  skipDefaultGeneration = true;
167
180
  }
168
- if (cfg.source_dir) {
169
- const srcDir: string = resolve(process.cwd(), cfg.source_dir);
181
+ if (cfgSourceDir) {
182
+ const srcDir: string = resolve(process.cwd(), cfgSourceDir);
170
183
  if (fs.existsSync(srcDir) && fs.statSync(srcDir).isDirectory()) {
171
- console.log(`\n=== FAVICONS (copy from ${cfg.source_dir}) ===\n`);
184
+ console.log(`\n=== FAVICONS (copy from ${cfgSourceDir}) ===\n`);
172
185
  // When the consumer provides a canonical favicon set, also delete
173
186
  // the default-generated cruft so it doesn't linger in public/.
174
187
  const cruftFiles: readonly string[] = [
@@ -207,12 +220,12 @@ async function runBuildPipeline(config: SiteConfig | null): Promise<void> {
207
220
  copied++;
208
221
  }
209
222
  }
210
- console.log(` Copied ${copied} canonical favicon file(s) from ${cfg.source_dir}`);
223
+ console.log(` Copied ${copied} canonical favicon file(s) from ${cfgSourceDir}`);
211
224
  } else {
212
225
  console.warn(` Warning: branding.favicon.source_dir not found: ${srcDir}`);
213
226
  }
214
227
  }
215
- if (cfg.skip_default_links) {
228
+ if (cfgSkipDefault) {
216
229
  skipDefaultGeneration = true;
217
230
  }
218
231
  }
@@ -266,7 +279,7 @@ async function runBuildPipeline(config: SiteConfig | null): Promise<void> {
266
279
  if (faviconHtml) {
267
280
  fs.writeFileSync(resolve(publicDir, 'favicon-links.html'), faviconHtml);
268
281
  const basePath: string =
269
- (faviconCfgObject?.base_path) ||
282
+ (faviconCfgObject ? (faviconCfgObject.basePath ?? faviconCfgObject.base_path) : undefined) ||
270
283
  process.env.BASE_PATH?.replace(/\/+$/, '') ||
271
284
  '';
272
285
  if (basePath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.109",
3
+ "version": "0.7.110",
4
4
  "description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ getFaviconBasePath,
4
+ getFaviconSkipDefaultLinks,
5
+ getFaviconLinksHtml,
6
+ getFaviconSourceDir,
7
+ getFaviconIcons,
8
+ } from '../../utils/favicon-config-access';
9
+
10
+ /**
11
+ * Regression test for the snake_case/camelCase mismatch reported against
12
+ * 0.7.109 (user-filed bug, 2026-08-01):
13
+ *
14
+ * "source_dir reads snake_case — CLI reads faviconCfg.source_dir but
15
+ * loadSiteConfig normalizes to sourceDir. Same bug as skip_default_links,
16
+ * base_path, links_html. The canonical files never get copied."
17
+ *
18
+ * Root cause: two loaders, two conventions.
19
+ * - scripts/load-site-config.mjs normalizes snake_case → camelCase.
20
+ * - src/lib/site-config.ts (Astro) does NOT normalize.
21
+ *
22
+ * Fix: the accessors below accept BOTH forms. They check camelCase first
23
+ * (the post-normalization form) and fall back to snake_case (the raw YAML
24
+ * wire form). Either loader's output works.
25
+ */
26
+
27
+ describe('getFaviconBasePath', () => {
28
+ it('returns undefined for null/undefined', () => {
29
+ expect(getFaviconBasePath(null)).toBeUndefined();
30
+ expect(getFaviconBasePath(undefined)).toBeUndefined();
31
+ expect(getFaviconBasePath({})).toBeUndefined();
32
+ });
33
+
34
+ it('reads camelCase basePath', () => {
35
+ expect(getFaviconBasePath({ basePath: '/vocab/' })).toBe('/vocab/');
36
+ });
37
+
38
+ it('reads snake_case base_path', () => {
39
+ expect(getFaviconBasePath({ base_path: '/vocab/' })).toBe('/vocab/');
40
+ });
41
+
42
+ it('camelCase wins when both forms are set', () => {
43
+ expect(getFaviconBasePath({ basePath: '/camel/', base_path: '/snake/' })).toBe('/camel/');
44
+ });
45
+ });
46
+
47
+ describe('getFaviconSkipDefaultLinks', () => {
48
+ it('returns false for null/undefined', () => {
49
+ expect(getFaviconSkipDefaultLinks(null)).toBe(false);
50
+ expect(getFaviconSkipDefaultLinks(undefined)).toBe(false);
51
+ expect(getFaviconSkipDefaultLinks({})).toBe(false);
52
+ });
53
+
54
+ it('reads camelCase skipDefaultLinks', () => {
55
+ expect(getFaviconSkipDefaultLinks({ skipDefaultLinks: true })).toBe(true);
56
+ expect(getFaviconSkipDefaultLinks({ skipDefaultLinks: false })).toBe(false);
57
+ });
58
+
59
+ it('reads snake_case skip_default_links', () => {
60
+ expect(getFaviconSkipDefaultLinks({ skip_default_links: true })).toBe(true);
61
+ });
62
+ });
63
+
64
+ describe('getFaviconLinksHtml', () => {
65
+ it('returns undefined for null/undefined', () => {
66
+ expect(getFaviconLinksHtml(null)).toBeUndefined();
67
+ expect(getFaviconLinksHtml({})).toBeUndefined();
68
+ });
69
+
70
+ it('reads camelCase linksHtml', () => {
71
+ expect(getFaviconLinksHtml({ linksHtml: '<link>' })).toBe('<link>');
72
+ });
73
+
74
+ it('reads snake_case links_html', () => {
75
+ expect(getFaviconLinksHtml({ links_html: '<link>' })).toBe('<link>');
76
+ });
77
+ });
78
+
79
+ describe('getFaviconSourceDir', () => {
80
+ it('returns undefined for null/undefined', () => {
81
+ expect(getFaviconSourceDir(null)).toBeUndefined();
82
+ expect(getFaviconSourceDir({})).toBeUndefined();
83
+ });
84
+
85
+ it('reads camelCase sourceDir', () => {
86
+ expect(getFaviconSourceDir({ sourceDir: 'assets/favicons' })).toBe('assets/favicons');
87
+ });
88
+
89
+ /**
90
+ * THE LOAD-BEARING TEST — pins the exact bug the user reported.
91
+ * "source_dir reads snake_case — CLI reads faviconCfg.source_dir but
92
+ * loadSiteConfig normalizes to sourceDir. The canonical files never
93
+ * get copied."
94
+ *
95
+ * After loadSiteConfig normalization, the config object has sourceDir
96
+ * (camelCase), NOT source_dir. The accessor must return the value.
97
+ */
98
+ it('reads snake_case source_dir (the user-reported regression form)', () => {
99
+ expect(getFaviconSourceDir({ source_dir: 'assets/favicons' })).toBe('assets/favicons');
100
+ });
101
+
102
+ it('camelCase wins when both forms are set', () => {
103
+ expect(getFaviconSourceDir({ sourceDir: 'a', source_dir: 'b' })).toBe('a');
104
+ });
105
+ });
106
+
107
+ describe('getFaviconIcons', () => {
108
+ it('returns undefined for null/undefined', () => {
109
+ expect(getFaviconIcons(null)).toBeUndefined();
110
+ expect(getFaviconIcons({})).toBeUndefined();
111
+ });
112
+
113
+ it('reads icons (same key in both forms)', () => {
114
+ const icons = [{ rel: 'icon', href: 'favicon.ico' }];
115
+ expect(getFaviconIcons({ icons })).toBe(icons);
116
+ });
117
+ });
118
+
119
+ describe('regression — both loader outputs work end-to-end', () => {
120
+ // Simulate the two loader paths:
121
+ // - Astro's getSiteConfig (no normalization): snake_case keys preserved
122
+ // - CLI's loadSiteConfig (normalizes): camelCase keys
123
+ const snakeCaseForm = {
124
+ base_path: '/cie-eilv/',
125
+ skip_default_links: true,
126
+ source_dir: 'assets/favicons',
127
+ links_html: '<link>',
128
+ icons: [{ rel: 'icon', href: 'favicon.ico' }],
129
+ };
130
+ const camelCaseForm = {
131
+ basePath: '/cie-eilv/',
132
+ skipDefaultLinks: true,
133
+ sourceDir: 'assets/favicons',
134
+ linksHtml: '<link>',
135
+ icons: [{ rel: 'icon', href: 'favicon.ico' }],
136
+ };
137
+
138
+ for (const [label, cfg] of [['snake_case (Astro)', snakeCaseForm], ['camelCase (CLI)', camelCaseForm]] as const) {
139
+ it(`accessors return identical values for ${label}`, () => {
140
+ expect(getFaviconBasePath(cfg)).toBe('/cie-eilv/');
141
+ expect(getFaviconSkipDefaultLinks(cfg)).toBe(true);
142
+ expect(getFaviconSourceDir(cfg)).toBe('assets/favicons');
143
+ expect(getFaviconLinksHtml(cfg)).toBe('<link>');
144
+ expect(getFaviconIcons(cfg)).toEqual([{ rel: 'icon', href: 'favicon.ico' }]);
145
+ });
146
+ }
147
+ });
@@ -9,7 +9,7 @@ import {
9
9
  type FontConfigLike,
10
10
  } from '../utils/font-stack';
11
11
  import { resolveFaviconBase } from '../utils/favicon-base';
12
- import { buildDefaultFaviconLinks, renderFaviconLinkTag } from '../utils/favicon-links';
12
+ import { buildFaviconLinks, renderFaviconLinkTag } from '../utils/favicon-links';
13
13
 
14
14
  interface Props {
15
15
  title: string;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Defensive favicon config accessor.
3
+ *
4
+ * WHY THIS EXISTS
5
+ *
6
+ * `loadSiteConfig` (scripts/load-site-config.mjs) normalizes YAML keys
7
+ * from snake_case to camelCase at load time. So a YAML config with
8
+ * `branding.favicon.source_dir: assets/favicons` arrives at the CLI as
9
+ * `cfg.sourceDir`. But the canonical `FaviconConfig` type documents the
10
+ * snake_case form (matching the YAML wire format), and Astro's
11
+ * `getSiteConfig` does NOT normalize — so `Default.astro` sees the raw
12
+ * snake_case form.
13
+ *
14
+ * The 0.7.107–0.7.109 CLI used `cfg.source_dir` directly, which silently
15
+ * returned `undefined` after the loader's normalization. Canonical files
16
+ * never got copied. Regression caught by user 2026-08-01.
17
+ *
18
+ * The fix: this helper reads BOTH forms. Each accessor takes the config
19
+ * object and returns whichever form is present. The class of "loader
20
+ * changed normalization, broke the CLI" is now permanently closed.
21
+ */
22
+
23
+ export interface RawFaviconConfig {
24
+ // snake_case (YAML wire format / Astro path)
25
+ base_path?: string;
26
+ skip_default_links?: boolean;
27
+ links_html?: string;
28
+ source_dir?: string;
29
+ icons?: unknown;
30
+ // camelCase (loadSiteConfig normalized form / TS convention)
31
+ basePath?: string;
32
+ skipDefaultLinks?: boolean;
33
+ linksHtml?: string;
34
+ sourceDir?: string;
35
+ }
36
+
37
+ function pick<T>(...vals: Array<T | undefined>): T | undefined {
38
+ for (const v of vals) {
39
+ if (v !== undefined && v !== null) return v;
40
+ }
41
+ return undefined;
42
+ }
43
+
44
+ export function getFaviconBasePath(cfg: RawFaviconConfig | null | undefined): string | undefined {
45
+ if (!cfg) return undefined;
46
+ return pick(cfg.basePath, cfg.base_path);
47
+ }
48
+
49
+ export function getFaviconSkipDefaultLinks(cfg: RawFaviconConfig | null | undefined): boolean {
50
+ if (!cfg) return false;
51
+ return pick(cfg.skipDefaultLinks, cfg.skip_default_links) === true;
52
+ }
53
+
54
+ export function getFaviconLinksHtml(cfg: RawFaviconConfig | null | undefined): string | undefined {
55
+ if (!cfg) return undefined;
56
+ return pick(cfg.linksHtml, cfg.links_html);
57
+ }
58
+
59
+ export function getFaviconSourceDir(cfg: RawFaviconConfig | null | undefined): string | undefined {
60
+ if (!cfg) return undefined;
61
+ return pick(cfg.sourceDir, cfg.source_dir);
62
+ }
63
+
64
+ export function getFaviconIcons(cfg: RawFaviconConfig | null | undefined): unknown {
65
+ if (!cfg) return undefined;
66
+ // `icons` has no snake_case form — same key in both worlds.
67
+ return cfg.icons;
68
+ }