@glossarist/concept-browser 0.7.109 → 0.7.111
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 +23 -11
- package/cli/index.ts +27 -14
- package/package.json +2 -2
- package/src/__tests__/utils/external-detection.test.ts +139 -36
- package/src/__tests__/utils/favicon-config-access.test.ts +147 -0
- package/src/components/LanguageDetail.vue +38 -0
- package/src/components/PartitiveRelationList.vue +51 -3
- package/src/layouts/Default.astro +1 -1
- package/src/utils/external-detection.ts +71 -44
- package/src/utils/favicon-config-access.ts +68 -0
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 (
|
|
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
|
-
|
|
135
|
+
cfgBasePath ||
|
|
124
136
|
process.env.BASE_PATH?.replace(/\/+$/, '') ||
|
|
125
137
|
'';
|
|
126
|
-
faviconHtml =
|
|
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 (
|
|
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(
|
|
158
|
+
faviconHtml = String(cfgLinksHtml);
|
|
147
159
|
}
|
|
148
160
|
skipDefaultGeneration = true;
|
|
149
161
|
}
|
|
150
|
-
if (
|
|
151
|
-
const srcDir = resolve(process.cwd(),
|
|
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 ${
|
|
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 ${
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
83
|
+
skipDefaultLinks?: boolean;
|
|
84
|
+
/** Consumer-declared icons (DATA, not HTML). */
|
|
82
85
|
icons?: FaviconIcon[];
|
|
83
|
-
/** @deprecated Use `icons` instead.
|
|
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
|
-
|
|
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
|
-
|
|
156
|
+
cfgBasePath ||
|
|
144
157
|
process.env.BASE_PATH?.replace(/\/+$/, '') ||
|
|
145
158
|
'';
|
|
146
|
-
faviconHtml =
|
|
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 (
|
|
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(
|
|
177
|
+
faviconHtml = String(cfgLinksHtml);
|
|
165
178
|
}
|
|
166
179
|
skipDefaultGeneration = true;
|
|
167
180
|
}
|
|
168
|
-
if (
|
|
169
|
-
const srcDir: string = resolve(process.cwd(),
|
|
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 ${
|
|
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 ${
|
|
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 (
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.7.111",
|
|
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": {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"astro": "^7.0.7",
|
|
39
39
|
"d3": "^7.9.0",
|
|
40
40
|
"favicons": "^7.2.0",
|
|
41
|
-
"glossarist": "^0.4.
|
|
41
|
+
"glossarist": "^0.4.52",
|
|
42
42
|
"js-yaml": "^4.1.0",
|
|
43
43
|
"jszip": "^3.10.1",
|
|
44
44
|
"pinia": "^2.3.1",
|
|
@@ -1,79 +1,155 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import {
|
|
3
3
|
isExternalConcept,
|
|
4
|
+
isExternalMember,
|
|
5
|
+
isExternalComprehensive,
|
|
6
|
+
getExternalMembers,
|
|
4
7
|
hasProvidedBy,
|
|
5
|
-
|
|
8
|
+
hasDanglingExternal,
|
|
6
9
|
formatExternalLabel,
|
|
7
|
-
type
|
|
10
|
+
type ConceptStore,
|
|
11
|
+
type ExternalConceptLike,
|
|
8
12
|
} from '../../utils/external-detection';
|
|
13
|
+
import { checkExtensionalCompleteness } from 'glossarist';
|
|
9
14
|
|
|
10
|
-
|
|
15
|
+
/**
|
|
16
|
+
* External-detection + extensional-completeness contract tests.
|
|
17
|
+
*
|
|
18
|
+
* glossarist@0.4.52 promoted these utilities from concept-browser's
|
|
19
|
+
* local src/utils/external-detection.ts into the upstream library.
|
|
20
|
+
* This spec pins:
|
|
21
|
+
* 1. The local module re-exports the upstream functions (no drift).
|
|
22
|
+
* 2. The upstream signatures work as documented.
|
|
23
|
+
* 3. `formatExternalLabel` (the UI-only helper) stays local.
|
|
24
|
+
* 4. `checkExtensionalCompleteness` flags ISO 704:2022 §6.4.5.1
|
|
25
|
+
* open-ended patterns in extensional definitions.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const extResolved: ExternalConceptLike = { status: 'external', related: [{ type: 'provided_by' }] };
|
|
29
|
+
const extDangling: ExternalConceptLike = { status: 'external', related: [] };
|
|
30
|
+
const internal: ExternalConceptLike = { status: 'valid', related: [] };
|
|
31
|
+
|
|
32
|
+
const store: ConceptStore = {
|
|
11
33
|
lookup(ref) {
|
|
12
|
-
if (ref?.id === 'ext-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
if (ref?.id === 'ext-2') {
|
|
16
|
-
return { status: 'external', relatedConcepts: [] };
|
|
17
|
-
}
|
|
18
|
-
if (ref?.id === 'int-1') {
|
|
19
|
-
return { status: 'valid', relatedConcepts: [] };
|
|
20
|
-
}
|
|
34
|
+
if (ref?.id === 'ext-resolved') return extResolved;
|
|
35
|
+
if (ref?.id === 'ext-dangling') return extDangling;
|
|
36
|
+
if (ref?.id === 'int-1') return internal;
|
|
21
37
|
return null;
|
|
22
38
|
},
|
|
23
39
|
};
|
|
24
40
|
|
|
25
|
-
describe('external-detection', () => {
|
|
41
|
+
describe('external-detection (re-exports from glossarist@0.4.52)', () => {
|
|
26
42
|
describe('isExternalConcept', () => {
|
|
27
|
-
it('returns true when
|
|
28
|
-
expect(isExternalConcept(
|
|
43
|
+
it('returns true when concept.status === "external"', () => {
|
|
44
|
+
expect(isExternalConcept(extResolved)).toBe(true);
|
|
45
|
+
expect(isExternalConcept(extDangling)).toBe(true);
|
|
29
46
|
});
|
|
30
47
|
|
|
31
|
-
it('returns false
|
|
32
|
-
expect(isExternalConcept(
|
|
48
|
+
it('returns false for internal concepts', () => {
|
|
49
|
+
expect(isExternalConcept(internal)).toBe(false);
|
|
33
50
|
});
|
|
34
51
|
|
|
35
|
-
it('returns false
|
|
36
|
-
expect(isExternalConcept(
|
|
52
|
+
it('returns false for null/undefined', () => {
|
|
53
|
+
expect(isExternalConcept(null)).toBe(false);
|
|
54
|
+
expect(isExternalConcept(undefined)).toBe(false);
|
|
37
55
|
});
|
|
56
|
+
});
|
|
38
57
|
|
|
39
|
-
|
|
40
|
-
|
|
58
|
+
describe('isExternalMember', () => {
|
|
59
|
+
it('returns true when the member ref resolves to an external concept', () => {
|
|
60
|
+
expect(isExternalMember({ ref: { source: 's', id: 'ext-resolved' } }, store)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns false when the member ref resolves to an internal concept', () => {
|
|
64
|
+
expect(isExternalMember({ ref: { source: 's', id: 'int-1' } }, store)).toBe(false);
|
|
41
65
|
});
|
|
42
66
|
|
|
43
67
|
it('returns false when store is null', () => {
|
|
44
|
-
expect(
|
|
68
|
+
expect(isExternalMember({ ref: { source: 's', id: 'ext-resolved' } }, null)).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns false when ref is missing', () => {
|
|
72
|
+
expect(isExternalMember({}, store)).toBe(false);
|
|
73
|
+
expect(isExternalMember(null, store)).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('isExternalComprehensive', () => {
|
|
78
|
+
it('returns true when the comprehensive ref resolves to external', () => {
|
|
79
|
+
const hyperedge = {
|
|
80
|
+
comprehensive: { source: 's', id: 'ext-resolved' },
|
|
81
|
+
members: [],
|
|
82
|
+
};
|
|
83
|
+
expect(isExternalComprehensive(hyperedge, store)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns false when the comprehensive ref resolves to internal', () => {
|
|
87
|
+
const hyperedge = {
|
|
88
|
+
comprehensive: { source: 's', id: 'int-1' },
|
|
89
|
+
members: [],
|
|
90
|
+
};
|
|
91
|
+
expect(isExternalComprehensive(hyperedge, store)).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('getExternalMembers', () => {
|
|
96
|
+
it('returns only the members whose refs resolve to external', () => {
|
|
97
|
+
const hyperedge = {
|
|
98
|
+
comprehensive: { source: 's', id: 'int-1' },
|
|
99
|
+
members: [
|
|
100
|
+
{ ref: { source: 's', id: 'ext-resolved' } },
|
|
101
|
+
{ ref: { source: 's', id: 'int-1' } },
|
|
102
|
+
{ ref: { source: 's', id: 'ext-dangling' } },
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
const external = getExternalMembers(hyperedge, store);
|
|
106
|
+
expect(external).toHaveLength(2);
|
|
107
|
+
expect(external.map(m => (m.ref as { id?: string } | undefined)?.id)).toEqual(['ext-resolved', 'ext-dangling']);
|
|
45
108
|
});
|
|
46
109
|
});
|
|
47
110
|
|
|
48
111
|
describe('hasProvidedBy', () => {
|
|
49
|
-
it('returns true when the
|
|
50
|
-
expect(hasProvidedBy(
|
|
112
|
+
it('returns true when the concept has a provided_by edge', () => {
|
|
113
|
+
expect(hasProvidedBy(extResolved)).toBe(true);
|
|
51
114
|
});
|
|
52
115
|
|
|
53
|
-
it('returns false when the
|
|
54
|
-
expect(hasProvidedBy(
|
|
116
|
+
it('returns false when the concept has no provided_by edge', () => {
|
|
117
|
+
expect(hasProvidedBy(extDangling)).toBe(false);
|
|
55
118
|
});
|
|
56
119
|
|
|
57
|
-
it('returns false
|
|
58
|
-
expect(hasProvidedBy(
|
|
120
|
+
it('returns false for null/undefined', () => {
|
|
121
|
+
expect(hasProvidedBy(null)).toBe(false);
|
|
122
|
+
expect(hasProvidedBy(undefined)).toBe(false);
|
|
59
123
|
});
|
|
60
124
|
});
|
|
61
125
|
|
|
62
|
-
describe('
|
|
63
|
-
it('returns
|
|
64
|
-
|
|
126
|
+
describe('hasDanglingExternal (hyperedge-level)', () => {
|
|
127
|
+
it('returns true when any external ref lacks a provided_by edge', () => {
|
|
128
|
+
const hyperedge = {
|
|
129
|
+
comprehensive: { source: 's', id: 'ext-dangling' },
|
|
130
|
+
members: [],
|
|
131
|
+
};
|
|
132
|
+
expect(hasDanglingExternal(hyperedge, store)).toBe(true);
|
|
65
133
|
});
|
|
66
134
|
|
|
67
|
-
it('returns
|
|
68
|
-
|
|
135
|
+
it('returns false when every external ref is resolved', () => {
|
|
136
|
+
const hyperedge = {
|
|
137
|
+
comprehensive: { source: 's', id: 'ext-resolved' },
|
|
138
|
+
members: [{ ref: { source: 's', id: 'ext-resolved' } }],
|
|
139
|
+
};
|
|
140
|
+
expect(hasDanglingExternal(hyperedge, store)).toBe(false);
|
|
69
141
|
});
|
|
70
142
|
|
|
71
|
-
it('returns false
|
|
72
|
-
|
|
143
|
+
it('returns false when no refs are external', () => {
|
|
144
|
+
const hyperedge = {
|
|
145
|
+
comprehensive: { source: 's', id: 'int-1' },
|
|
146
|
+
members: [{ ref: { source: 's', id: 'int-1' } }],
|
|
147
|
+
};
|
|
148
|
+
expect(hasDanglingExternal(hyperedge, store)).toBe(false);
|
|
73
149
|
});
|
|
74
150
|
});
|
|
75
151
|
|
|
76
|
-
describe('formatExternalLabel', () => {
|
|
152
|
+
describe('formatExternalLabel (local UI helper, not in upstream)', () => {
|
|
77
153
|
it('wraps the label in parentheses for external concepts', () => {
|
|
78
154
|
expect(formatExternalLabel('precision condition', true)).toBe('(precision condition)');
|
|
79
155
|
});
|
|
@@ -83,3 +159,30 @@ describe('external-detection', () => {
|
|
|
83
159
|
});
|
|
84
160
|
});
|
|
85
161
|
});
|
|
162
|
+
|
|
163
|
+
describe('checkExtensionalCompleteness (ISO 704:2022 §6.4.5.1)', () => {
|
|
164
|
+
it('returns null for non-extensional definitions', () => {
|
|
165
|
+
expect(checkExtensionalCompleteness({ type: 'intensional', content: '...' })).toBeNull();
|
|
166
|
+
expect(checkExtensionalCompleteness({ type: null, content: '...' })).toBeNull();
|
|
167
|
+
expect(checkExtensionalCompleteness(null)).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('returns null for an extensional definition that lists every member', () => {
|
|
171
|
+
expect(checkExtensionalCompleteness({
|
|
172
|
+
type: 'extensional',
|
|
173
|
+
content: '1, 2, 3',
|
|
174
|
+
})).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('returns a warning when an extensional definition uses an open-ended pattern', () => {
|
|
178
|
+
// Open-ended patterns: "etc.", "...", "and so on", "includes but is not limited to"
|
|
179
|
+
const issue = checkExtensionalCompleteness({
|
|
180
|
+
type: 'extensional',
|
|
181
|
+
content: 'length, mass, time, etc.',
|
|
182
|
+
});
|
|
183
|
+
expect(issue).not.toBeNull();
|
|
184
|
+
expect(issue?.severity).toBe('warning');
|
|
185
|
+
expect(issue?.rule).toBe('ISO-704-6.4.5.1');
|
|
186
|
+
expect(issue?.message).toMatch(/completeness|extensional/i);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { Concept, LocalizedConcept, Designation, Expression, Abbreviation as AbbreviationType } from 'glossarist';
|
|
3
|
+
import { checkExtensionalCompleteness, type ExtensionalCompletenessIssue } from 'glossarist';
|
|
3
4
|
import { computed } from 'vue';
|
|
4
5
|
import { langName, langLabel } from '../utils/lang';
|
|
5
6
|
import { renderContent } from '../utils/content-renderer';
|
|
@@ -33,6 +34,33 @@ const definition = computed(() => {
|
|
|
33
34
|
const content = lc.value.definitions.map(d => d.content).filter(Boolean).join('\n\n');
|
|
34
35
|
return content;
|
|
35
36
|
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* ISO 704:2022 §6.4.5.1 extensional-completeness warnings.
|
|
40
|
+
*
|
|
41
|
+
* An extensional definition enumerates every member of the concept's
|
|
42
|
+
* extension. Open-ended phrasing ("etc.", "...", "and so on") signals
|
|
43
|
+
* that the enumeration is incomplete — the definition is invalid per
|
|
44
|
+
* ISO 704. Surface each warning inline so editors can fix the source.
|
|
45
|
+
*
|
|
46
|
+
* The validator gates on `definition.type === 'extensional'`. Today
|
|
47
|
+
* concept-browser's DetailedDefinition model doesn't carry that field,
|
|
48
|
+
* so this returns [] — the wiring is in place for the moment glossarist
|
|
49
|
+
* exposes definition-type tracking (or for consumers that annotate
|
|
50
|
+
* definitions themselves).
|
|
51
|
+
*/
|
|
52
|
+
const extensionalIssues = computed<ExtensionalCompletenessIssue[]>(() => {
|
|
53
|
+
if (!lc.value) return [];
|
|
54
|
+
const issues: ExtensionalCompletenessIssue[] = [];
|
|
55
|
+
for (const d of lc.value.definitions) {
|
|
56
|
+
const issue = checkExtensionalCompleteness(
|
|
57
|
+
d as unknown as { type?: string | null; content?: string | null },
|
|
58
|
+
);
|
|
59
|
+
if (issue) issues.push(issue);
|
|
60
|
+
}
|
|
61
|
+
return issues;
|
|
62
|
+
});
|
|
63
|
+
|
|
36
64
|
const notes = computed(() => lc.value?.notes.map(n => n.content).filter(Boolean) ?? []);
|
|
37
65
|
const examples = computed(() => lc.value?.examples.map(e => e.content).filter(Boolean) ?? []);
|
|
38
66
|
const sources = computed(() => lc.value?.sources ?? []);
|
|
@@ -140,6 +168,16 @@ function handleContentClick(e: MouseEvent) {
|
|
|
140
168
|
<div v-if="definition" class="card p-5">
|
|
141
169
|
<div class="section-label">{{ t('concept.definition') }}</div>
|
|
142
170
|
<div class="text-ink-800 leading-relaxed mt-3" v-html="renderContent(definition, renderOpts)"></div>
|
|
171
|
+
<!-- ISO 704:2022 §6.4.5.1 extensional-completeness warnings -->
|
|
172
|
+
<div
|
|
173
|
+
v-for="(issue, i) in extensionalIssues"
|
|
174
|
+
:key="'ext'+i"
|
|
175
|
+
class="mt-3 p-2 rounded border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 text-xs text-amber-900 dark:text-amber-100"
|
|
176
|
+
:title="`Rule: ${issue.rule}`"
|
|
177
|
+
>
|
|
178
|
+
<span class="font-semibold">⚠ {{ t('concept.extensionalWarning') || 'Extensional completeness' }}:</span>
|
|
179
|
+
{{ issue.message }}
|
|
180
|
+
</div>
|
|
143
181
|
</div>
|
|
144
182
|
|
|
145
183
|
<!-- Notes -->
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
* drop's line style encodes its multiplicity + is_delimiting per
|
|
10
10
|
* ISO 704:2022 (5 multiplicity values × 2 delimiting states).
|
|
11
11
|
*
|
|
12
|
+
* External concepts (status: 'external') are wrapped in parentheses per
|
|
13
|
+
* ISO 704:2022 §5.5.4.3.1, using the upstream `isExternalMember` /
|
|
14
|
+
* `isExternalComprehensive` detection from glossarist@0.4.52.
|
|
15
|
+
*
|
|
12
16
|
* Header badges communicate metadata that the diagram's line styles
|
|
13
17
|
* also encode — for users who can't perceive the visual difference
|
|
14
18
|
* (screen readers, low vision, monochrome displays).
|
|
@@ -21,6 +25,7 @@ import { useI18n, locale } from '../i18n';
|
|
|
21
25
|
import { completenessLabel } from '../utils/partitive-relation-styling';
|
|
22
26
|
import { presenceLabel, countLabel } from '../utils/partitive-multiplicity';
|
|
23
27
|
import { resolveDesignation } from '../utils/resolve-designation';
|
|
28
|
+
import { isExternalMember, isExternalComprehensive, formatExternalLabel } from '../utils/external-detection';
|
|
24
29
|
import PartitiveRelationDiagram, {
|
|
25
30
|
type PartitiveMemberLabeled,
|
|
26
31
|
} from './PartitiveRelationDiagram.vue';
|
|
@@ -36,8 +41,51 @@ const store = useVocabularyStore();
|
|
|
36
41
|
const factory = getFactory();
|
|
37
42
|
const { t } = useI18n();
|
|
38
43
|
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
/** Per-URI cache of external-status lookups, so the rake doesn't re-resolve
|
|
45
|
+
* the same concept on every render pass. */
|
|
46
|
+
const externalCache = new Map<string, boolean>();
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Bridge the concept-browser ref shape to glossarist@0.4.52's ConceptStore
|
|
50
|
+
* interface. The store exposes concepts via `lookup({ source, id })` and
|
|
51
|
+
* returns an object with `.status` + `.related[]` — exactly what the
|
|
52
|
+
* upstream external-detection utilities need.
|
|
53
|
+
*/
|
|
54
|
+
const externalStore = {
|
|
55
|
+
lookup(ref: { source?: string | null; id?: string | null }) {
|
|
56
|
+
if (!ref?.source && !ref?.id) return null;
|
|
57
|
+
// Find the matching concept by traversing the loaded datasets.
|
|
58
|
+
for (const adapter of store.datasets.values()) {
|
|
59
|
+
for (const entry of adapter.getConcepts() ?? []) {
|
|
60
|
+
if (entry && entry.id === ref.id) {
|
|
61
|
+
return {
|
|
62
|
+
status: entry.status,
|
|
63
|
+
related: (entry as any).relatedConcepts ?? (entry as any).related ?? [],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function isExternalUri(uri: string, kind: 'member' | 'comprehensive'): boolean {
|
|
73
|
+
if (externalCache.has(uri)) return externalCache.get(uri)!;
|
|
74
|
+
// Build a member/hyperedge shape the upstream utilities expect.
|
|
75
|
+
const ref = { source: uri, id: uri.split('/').pop() ?? uri };
|
|
76
|
+
let isExt: boolean;
|
|
77
|
+
if (kind === 'comprehensive') {
|
|
78
|
+
isExt = isExternalComprehensive({ comprehensive: ref, members: [] }, externalStore);
|
|
79
|
+
} else {
|
|
80
|
+
isExt = isExternalMember({ ref }, externalStore);
|
|
81
|
+
}
|
|
82
|
+
externalCache.set(uri, isExt);
|
|
83
|
+
return isExt;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function designationFor(uri: string, kind: 'member' | 'comprehensive' = 'member'): string {
|
|
87
|
+
const label = resolveDesignation(uri, store, factory, locale.value);
|
|
88
|
+
return formatExternalLabel(label, isExternalUri(uri, kind));
|
|
41
89
|
}
|
|
42
90
|
|
|
43
91
|
function navigate(uri: string) {
|
|
@@ -67,7 +115,7 @@ function criterionText(criterion?: Record<string, string>): string | null {
|
|
|
67
115
|
function labeledMembers(rel: PartitiveRelationWire): PartitiveMemberLabeled[] {
|
|
68
116
|
return rel.partitives.map(m => ({
|
|
69
117
|
uri: m.uri,
|
|
70
|
-
label: designationFor(m.uri),
|
|
118
|
+
label: designationFor(m.uri, 'member'),
|
|
71
119
|
presence: m.presence,
|
|
72
120
|
count: m.count,
|
|
73
121
|
isDelimiting: m.isDelimiting,
|
|
@@ -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 {
|
|
12
|
+
import { buildFaviconLinks, renderFaviconLinkTag } from '../utils/favicon-links';
|
|
13
13
|
|
|
14
14
|
interface Props {
|
|
15
15
|
title: string;
|
|
@@ -1,70 +1,97 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* External concept detection — ISO 704:2022 external concepts.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The detection logic now lives upstream in glossarist@0.4.52 — this
|
|
5
|
+
* file is a thin re-export so consumers don't have to know which package
|
|
6
|
+
* the functions live in. The signature is also richer upstream: it
|
|
7
|
+
* understands `MemberLike` (with nested `.ref`) and `HyperedgeLike`
|
|
8
|
+
* shapes, matching the actual glossarist-js model.
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* The only thing kept locally is `formatExternalLabel` — it's a pure
|
|
11
|
+
* UI concern (parentheses per ISO 704 §5.5.4.3.1) and doesn't belong
|
|
12
|
+
* in the model library.
|
|
13
|
+
*
|
|
14
|
+
* ISO 704:2022 §5.5.4.3.1: external concepts are shown in parentheses
|
|
15
|
+
* with a dashed border in diagrams.
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
import {
|
|
19
|
+
isExternalConcept,
|
|
20
|
+
isExternalMember as glsIsExternalMember,
|
|
21
|
+
isExternalComprehensive as glsIsExternalComprehensive,
|
|
22
|
+
getExternalMembers as glsGetExternalMembers,
|
|
23
|
+
hasProvidedBy,
|
|
24
|
+
hasDanglingExternal as glsHasDanglingExternal,
|
|
25
|
+
} from 'glossarist';
|
|
26
|
+
import type {
|
|
27
|
+
ExternalConceptLike,
|
|
28
|
+
ConceptStore,
|
|
29
|
+
} from 'glossarist';
|
|
18
30
|
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
export type {
|
|
32
|
+
ExternalConceptLike,
|
|
33
|
+
ConceptStore,
|
|
34
|
+
};
|
|
22
35
|
|
|
23
|
-
export
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
36
|
+
export {
|
|
37
|
+
isExternalConcept,
|
|
38
|
+
hasProvidedBy,
|
|
39
|
+
};
|
|
27
40
|
|
|
28
41
|
/**
|
|
29
|
-
*
|
|
42
|
+
* Defensive wrappers around the upstream hyperedge-aware queries.
|
|
43
|
+
* glossarist@0.4.52 throws on null `store` (it indexes into it
|
|
44
|
+
* unconditionally). UI consumers may have no store available (e.g.,
|
|
45
|
+
* during initial render before the vocabulary store is loaded) —
|
|
46
|
+
* return false / [] instead of propagating the throw.
|
|
30
47
|
*/
|
|
31
|
-
export function
|
|
32
|
-
|
|
33
|
-
store:
|
|
48
|
+
export function isExternalMember(
|
|
49
|
+
member: { ref?: unknown } | null | undefined,
|
|
50
|
+
store: ConceptStore | null | undefined,
|
|
34
51
|
): boolean {
|
|
35
|
-
if (!
|
|
36
|
-
|
|
37
|
-
return concept?.status === 'external';
|
|
52
|
+
if (!member || !store) return false;
|
|
53
|
+
return glsIsExternalMember(member as Parameters<typeof glsIsExternalMember>[0], store);
|
|
38
54
|
}
|
|
39
55
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
* incomplete.
|
|
44
|
-
*/
|
|
45
|
-
export function hasProvidedBy(
|
|
46
|
-
ref: RefLike | null | undefined,
|
|
47
|
-
store: ConceptStoreLike | null | undefined,
|
|
56
|
+
export function isExternalComprehensive(
|
|
57
|
+
hyperedge: { comprehensive?: unknown; members?: readonly unknown[] },
|
|
58
|
+
store: ConceptStore | null | undefined,
|
|
48
59
|
): boolean {
|
|
49
|
-
if (!
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
if (!hyperedge || !store) return false;
|
|
61
|
+
return glsIsExternalComprehensive(
|
|
62
|
+
hyperedge as Parameters<typeof glsIsExternalComprehensive>[0],
|
|
63
|
+
store,
|
|
64
|
+
);
|
|
53
65
|
}
|
|
54
66
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
67
|
+
export function getExternalMembers(
|
|
68
|
+
hyperedge: { comprehensive?: unknown; members?: readonly unknown[] },
|
|
69
|
+
store: ConceptStore | null | undefined,
|
|
70
|
+
): { ref?: unknown }[] {
|
|
71
|
+
if (!hyperedge || !store) return [];
|
|
72
|
+
return glsGetExternalMembers(
|
|
73
|
+
hyperedge as Parameters<typeof glsGetExternalMembers>[0],
|
|
74
|
+
store,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function hasDanglingExternal(
|
|
79
|
+
hyperedge: { comprehensive?: unknown; members?: readonly unknown[] },
|
|
80
|
+
store: ConceptStore | null | undefined,
|
|
61
81
|
): boolean {
|
|
62
|
-
|
|
82
|
+
if (!hyperedge || !store) return false;
|
|
83
|
+
return glsHasDanglingExternal(
|
|
84
|
+
hyperedge as Parameters<typeof glsHasDanglingExternal>[0],
|
|
85
|
+
store,
|
|
86
|
+
);
|
|
63
87
|
}
|
|
64
88
|
|
|
65
89
|
/**
|
|
66
90
|
* Format a label for an external concept — parenthetical per ISO 704.
|
|
67
91
|
* Returns `"(label)"` for external concepts, `label` for internal.
|
|
92
|
+
*
|
|
93
|
+
* Kept locally because it's a pure UI concern (rendering decision), not
|
|
94
|
+
* a model-layer classification.
|
|
68
95
|
*/
|
|
69
96
|
export function formatExternalLabel(
|
|
70
97
|
label: 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
|
+
}
|