@glossarist/concept-browser 0.7.105 → 0.7.106
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/package.json +3 -1
- package/scripts/generate-data.mjs +6 -0
- package/scripts/smoke.mjs +269 -0
- package/src/__tests__/composables/use-concept-edges-defensive.test.ts +164 -0
- package/src/__tests__/concept-detail-hydration.test.ts +199 -0
- package/src/components/ConceptDetail.vue +45 -38
- package/src/composables/use-concept-edges.ts +59 -43
- package/src/config/types.ts +17 -1
- package/src/layouts/Default.astro +41 -16
- package/src/pages/[...path].astro +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.106",
|
|
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.
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Post-build smoke test — deploy gate that catches runtime errors the
|
|
4
|
+
* type-checker and unit tests can't see.
|
|
5
|
+
*
|
|
6
|
+
* WHAT THIS CATCHES
|
|
7
|
+
* - Hydration TypeError (#171: `r.conceptGenericRelations.length`).
|
|
8
|
+
* - Dynamic-import resolution failures (the tsx devDep bug).
|
|
9
|
+
* - Failed resource requests the build itself caused (#174:
|
|
10
|
+
* bibliography.json / /learn / favicon 404s).
|
|
11
|
+
* - Config-not-honored regressions (#162/#166: branding.fonts).
|
|
12
|
+
*
|
|
13
|
+
* WHAT THIS DOES NOT DO
|
|
14
|
+
* - Functional UI testing (use Vitest + @vue/test-utils for that).
|
|
15
|
+
* - Cross-browser coverage (Chromium only — WebKit/Safari differences
|
|
16
|
+
* are caught by manual pre-release validation).
|
|
17
|
+
*
|
|
18
|
+
* USAGE
|
|
19
|
+
* npm run smoke # assumes dist/ already built
|
|
20
|
+
* npm run build:full # build:full pipeline now includes smoke
|
|
21
|
+
*
|
|
22
|
+
* The test serves dist/ on a random localhost port, loads each URL in a
|
|
23
|
+
* headless Chromium, and asserts zero `pageerror` events plus zero 404s
|
|
24
|
+
* on same-origin resources. Any failure exits non-zero so CI/deploy
|
|
25
|
+
* gates fail loudly.
|
|
26
|
+
*/
|
|
27
|
+
import { chromium } from 'playwright';
|
|
28
|
+
import { createServer } from 'http';
|
|
29
|
+
import { statSync, createReadStream } from 'fs';
|
|
30
|
+
import { extname, join, normalize, resolve as pathResolve } from 'path';
|
|
31
|
+
import { fileURLToPath } from 'url';
|
|
32
|
+
|
|
33
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
34
|
+
const DIST = pathResolve(__dirname, '..', 'dist');
|
|
35
|
+
|
|
36
|
+
const MIME = {
|
|
37
|
+
'.html': 'text/html; charset=utf-8',
|
|
38
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
39
|
+
'.mjs': 'application/javascript; charset=utf-8',
|
|
40
|
+
'.css': 'text/css; charset=utf-8',
|
|
41
|
+
'.json': 'application/json; charset=utf-8',
|
|
42
|
+
'.svg': 'image/svg+xml',
|
|
43
|
+
'.png': 'image/png',
|
|
44
|
+
'.jpg': 'image/jpeg',
|
|
45
|
+
'.ico': 'image/x-icon',
|
|
46
|
+
'.webp': 'image/webp',
|
|
47
|
+
'.woff': 'font/woff',
|
|
48
|
+
'.woff2':'font/woff2',
|
|
49
|
+
'.ttf': 'font/ttf',
|
|
50
|
+
'.map': 'application/json; charset=utf-8',
|
|
51
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
52
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
53
|
+
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function serveDist(root) {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
const server = createServer((req, res) => {
|
|
59
|
+
try {
|
|
60
|
+
const urlPath = decodeURIComponent((req.url ?? '/').split('?')[0]);
|
|
61
|
+
let filePath = join(root, urlPath);
|
|
62
|
+
// SPA fallback: if no extension and the file doesn't exist as a
|
|
63
|
+
// directory, try .html, then fall back to 200.html / index.html.
|
|
64
|
+
if (!extname(filePath)) {
|
|
65
|
+
if (statSafe(filePath)?.isFile()) {
|
|
66
|
+
return sendFile(res, filePath);
|
|
67
|
+
}
|
|
68
|
+
const candidates = [
|
|
69
|
+
filePath + '.html',
|
|
70
|
+
join(filePath, 'index.html'),
|
|
71
|
+
join(root, '200.html'),
|
|
72
|
+
join(root, 'index.html'),
|
|
73
|
+
];
|
|
74
|
+
for (const c of candidates) {
|
|
75
|
+
if (statSafe(c)?.isFile()) {
|
|
76
|
+
return sendFile(res, c);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
res.writeHead(404);
|
|
80
|
+
return res.end('Not found');
|
|
81
|
+
}
|
|
82
|
+
if (statSafe(filePath)?.isFile()) {
|
|
83
|
+
return sendFile(res, filePath);
|
|
84
|
+
}
|
|
85
|
+
res.writeHead(404);
|
|
86
|
+
return res.end('Not found');
|
|
87
|
+
} catch (e) {
|
|
88
|
+
res.writeHead(500);
|
|
89
|
+
res.end(String(e ?? 'server error'));
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
// Use port 0 to let the OS pick a free port; retrieve it after listen.
|
|
93
|
+
server.listen(0, '127.0.0.1', () => {
|
|
94
|
+
const { port } = server.address();
|
|
95
|
+
resolve({ server, origin: `http://127.0.0.1:${port}` });
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function statSafe(p) {
|
|
101
|
+
try { return statSync(p); } catch { return null; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sendFile(res, filePath) {
|
|
105
|
+
const safe = normalize(filePath);
|
|
106
|
+
const ct = MIME[extname(safe)] ?? 'application/octet-stream';
|
|
107
|
+
res.writeHead(200, { 'Content-Type': ct });
|
|
108
|
+
createReadStream(safe).pipe(res);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Pages to probe. Use relative paths from the served origin. The smoke
|
|
113
|
+
* test runs AFTER the build, so anything present in dist/ is fair game.
|
|
114
|
+
* The home page always exists; the rest are best-effort (404 → skip).
|
|
115
|
+
*/
|
|
116
|
+
const PAGE_PATHS = [
|
|
117
|
+
'/',
|
|
118
|
+
'/learn/',
|
|
119
|
+
'/learn/relationships/',
|
|
120
|
+
'/learn/designations/',
|
|
121
|
+
'/learn/statuses/',
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Consumer-provided files that concept-browser's build does NOT emit.
|
|
126
|
+
* The smoke test runs against concept-browser's own dist (no datasets
|
|
127
|
+
* configured), so these will 404. That's not a regression — it's a
|
|
128
|
+
* "consumer hasn't provided data" condition. Listed here so we can
|
|
129
|
+
* filter them out of the failure signal.
|
|
130
|
+
*/
|
|
131
|
+
const CONSUMER_DATA_FILES = [
|
|
132
|
+
'/datasets.json',
|
|
133
|
+
'/site-config.json',
|
|
134
|
+
'/data/cross-ref-index.json',
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
function isConsumerDataFile(url) {
|
|
138
|
+
try {
|
|
139
|
+
const u = new URL(url);
|
|
140
|
+
return CONSUMER_DATA_FILES.some(f => u.pathname === f || u.pathname.startsWith('/data/'));
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function probePage(browser, origin, path) {
|
|
147
|
+
const url = new URL(path, origin).toString();
|
|
148
|
+
const page = await browser.newPage();
|
|
149
|
+
const errors = [];
|
|
150
|
+
const failedRequests = [];
|
|
151
|
+
|
|
152
|
+
page.on('pageerror', (e) => {
|
|
153
|
+
errors.push({ kind: 'pageerror', message: e.message, stack: e.stack ?? '' });
|
|
154
|
+
});
|
|
155
|
+
page.on('console', (msg) => {
|
|
156
|
+
if (msg.type() !== 'error') return;
|
|
157
|
+
const text = msg.text();
|
|
158
|
+
// Browser consoles emit a generic "Failed to load resource: ... 404"
|
|
159
|
+
// message for every network 404 — the URL is NOT in the text, so we
|
|
160
|
+
// can't filter by file here. We track real 404s via the response
|
|
161
|
+
// event below and discard the generic console message to avoid
|
|
162
|
+
// double-counting noise.
|
|
163
|
+
if (/Failed to load resource/i.test(text)) return;
|
|
164
|
+
// Cross-origin font CDN warnings are out of our control.
|
|
165
|
+
if (/fonts\.g(oogle|static)\.com/.test(text)) return;
|
|
166
|
+
errors.push({ kind: 'console.error', message: text });
|
|
167
|
+
});
|
|
168
|
+
page.on('requestfailed', (req) => {
|
|
169
|
+
const u = req.url();
|
|
170
|
+
if (u.startsWith(origin) && !isConsumerDataFile(u)) {
|
|
171
|
+
failedRequests.push({ url: u, failure: req.failure()?.errorText ?? 'failed' });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
page.on('response', (resp) => {
|
|
175
|
+
const u = resp.url();
|
|
176
|
+
if (u.startsWith(origin) && resp.status() >= 400 && !isConsumerDataFile(u)) {
|
|
177
|
+
failedRequests.push({ url: u, status: resp.status() });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const resp = await page.goto(url, { waitUntil: 'networkidle', timeout: 15_000 });
|
|
183
|
+
if (resp && resp.status() >= 400) {
|
|
184
|
+
// 404 on a non-home page = the build didn't emit it. Treat as
|
|
185
|
+
// skip, not fail (the home page is the load-bearing probe).
|
|
186
|
+
return { path, url, status: resp.status(), bodyLen: 0, errors, failedRequests, skipped: path !== '/' };
|
|
187
|
+
}
|
|
188
|
+
// Wait a beat for any deferred hydration effect to fire.
|
|
189
|
+
await page.waitForTimeout(500);
|
|
190
|
+
const bodyLen = await page.evaluate(() => document.body?.innerText?.length ?? 0);
|
|
191
|
+
return { path, url, status: resp?.status() ?? 0, bodyLen, errors, failedRequests, skipped: false };
|
|
192
|
+
} catch (e) {
|
|
193
|
+
errors.push({ kind: 'navigation', message: String(e) });
|
|
194
|
+
return { path, url, status: 0, bodyLen: 0, errors, failedRequests, skipped: false };
|
|
195
|
+
} finally {
|
|
196
|
+
await page.close();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function formatFindings(results) {
|
|
201
|
+
const lines = [];
|
|
202
|
+
for (const r of results) {
|
|
203
|
+
if (r.skipped) {
|
|
204
|
+
lines.push(`\n ${r.path} (HTTP ${r.status} — skipped, not in this build)`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (r.errors.length === 0 && r.failedRequests.length === 0 && r.bodyLen > 0) continue;
|
|
208
|
+
lines.push(`\n ${r.path} (HTTP ${r.status}, body ${r.bodyLen} chars)`);
|
|
209
|
+
for (const e of r.errors) {
|
|
210
|
+
lines.push(` [${e.kind}] ${e.message}`);
|
|
211
|
+
}
|
|
212
|
+
for (const f of r.failedRequests) {
|
|
213
|
+
lines.push(` [request] ${f.status ?? f.failure} ${f.url}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return lines.join('\n');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function ensureDist() {
|
|
220
|
+
if (!statSafe(DIST)?.isDirectory()) {
|
|
221
|
+
console.error(`smoke: dist/ not found at ${DIST}`);
|
|
222
|
+
console.error(` run \`npm run build\` first, or use \`npm run build:full\`.`);
|
|
223
|
+
process.exit(2);
|
|
224
|
+
}
|
|
225
|
+
if (!statSafe(join(DIST, 'index.html'))?.isFile()) {
|
|
226
|
+
console.error(`smoke: dist/index.html missing — build did not complete.`);
|
|
227
|
+
process.exit(2);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function main() {
|
|
232
|
+
await ensureDist();
|
|
233
|
+
const { server, origin } = await serveDist(DIST);
|
|
234
|
+
|
|
235
|
+
let exitCode = 0;
|
|
236
|
+
const browser = await chromium.launch({ headless: true });
|
|
237
|
+
try {
|
|
238
|
+
const results = [];
|
|
239
|
+
for (const path of PAGE_PATHS) {
|
|
240
|
+
// Skip paths the build didn't emit (e.g. /learn/* if page-types
|
|
241
|
+
// config doesn't synthesize them). 404 on the navigation itself
|
|
242
|
+
// is recorded; the probe still runs to catch prefetch 404s.
|
|
243
|
+
const r = await probePage(browser, origin, path);
|
|
244
|
+
results.push(r);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const anyFail = results.some(r =>
|
|
248
|
+
!r.skipped && (r.errors.length > 0 || r.failedRequests.length > 0 || r.bodyLen === 0)
|
|
249
|
+
);
|
|
250
|
+
const findings = formatFindings(results);
|
|
251
|
+
|
|
252
|
+
if (anyFail) {
|
|
253
|
+
exitCode = 1;
|
|
254
|
+
console.error(`\n FAIL smoke test found problems:${findings}\n`);
|
|
255
|
+
} else {
|
|
256
|
+
const skipped = results.filter(r => r.skipped).length;
|
|
257
|
+
console.log(`\n OK smoke test passed (${results.length - skipped} pages, ${skipped} skipped, 0 errors).`);
|
|
258
|
+
}
|
|
259
|
+
} finally {
|
|
260
|
+
await browser.close();
|
|
261
|
+
server.close();
|
|
262
|
+
}
|
|
263
|
+
process.exit(exitCode);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
main().catch((e) => {
|
|
267
|
+
console.error('smoke: fatal error', e);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { computed, ref } from 'vue';
|
|
3
|
+
import type { Router } from 'vue-router';
|
|
4
|
+
import { useConceptEdges } from '../../composables/use-concept-edges';
|
|
5
|
+
import { useVocabularyStore } from '../../stores/vocabulary';
|
|
6
|
+
import type { Manifest, GraphEdge } from '../../adapters/types';
|
|
7
|
+
import { makeManifest, setupPinia } from '../test-helpers';
|
|
8
|
+
import { getFactory, resetFactory } from '../../adapters/factory';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Regression test for issue #171 — `TypeError: r.conceptGenericRelations.length
|
|
12
|
+
* at hydration`.
|
|
13
|
+
*
|
|
14
|
+
* Bug (2026-08-01): on the live `glossarist/cie-eilv` deployment, every concept
|
|
15
|
+
* page threw `TypeError: undefined is not an object (evaluating
|
|
16
|
+
* 'r.conceptGenericRelations.length')` at hydration time. The concept detail
|
|
17
|
+
* view failed to render. No unit test caught this because every existing test
|
|
18
|
+
* passes a well-formed Concept instance.
|
|
19
|
+
*
|
|
20
|
+
* Root cause: any code path that made the `conceptGenericRelations` computed
|
|
21
|
+
* throw (or made the destructured value undefined at hydration) crashed the
|
|
22
|
+
* template's `v-if="conceptGenericRelations.length"` access. The fix has two
|
|
23
|
+
* arms:
|
|
24
|
+
*
|
|
25
|
+
* 1. The composable wraps its computed getters in try/catch so a malformed
|
|
26
|
+
* concept never propagates an exception into the render path.
|
|
27
|
+
* 2. The template uses `v-if="conceptGenericRelations?.length"` so an
|
|
28
|
+
* undefined destructured value is treated as "no relations" instead of
|
|
29
|
+
* throwing.
|
|
30
|
+
*
|
|
31
|
+
* This spec exercises the composable's defensive layer directly: malformed
|
|
32
|
+
* concepts, partially-formed relations, and the null/undefined concept value
|
|
33
|
+
* must all produce `[]` — never throw, never return `undefined`.
|
|
34
|
+
*/
|
|
35
|
+
describe('useConceptEdges — defensive projection (regression for #171)', () => {
|
|
36
|
+
const URN_SOURCE = 'urn:glossarist:fixtures';
|
|
37
|
+
const manifest: Manifest = makeManifest({
|
|
38
|
+
id: 'fixtures',
|
|
39
|
+
datasetUri: URN_SOURCE,
|
|
40
|
+
uriBase: 'https://glossarist.org',
|
|
41
|
+
});
|
|
42
|
+
const noEdges: GraphEdge[] = [];
|
|
43
|
+
const noopRouter = { push() {} } as unknown as Router;
|
|
44
|
+
|
|
45
|
+
let store: ReturnType<typeof useVocabularyStore>;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
setupPinia();
|
|
49
|
+
resetFactory();
|
|
50
|
+
store = useVocabularyStore();
|
|
51
|
+
store.manifests.set(manifest.id, manifest);
|
|
52
|
+
|
|
53
|
+
const factory = getFactory();
|
|
54
|
+
factory.resolveRelatedRef = vi.fn((ref: { source: string | null; id: string | null } | null) =>
|
|
55
|
+
ref?.source === URN_SOURCE && ref.id
|
|
56
|
+
? { registerId: manifest.id, conceptId: ref.id }
|
|
57
|
+
: null,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The core regression: any malformed concept input must NOT propagate an
|
|
63
|
+
* exception through the composable. The computed must always resolve to an
|
|
64
|
+
* array (possibly empty). This is what the template's `?.length` defensive
|
|
65
|
+
* access relies on — and what the prior crash violated.
|
|
66
|
+
*/
|
|
67
|
+
it('never throws when concept.relations is malformed (issue #171)', () => {
|
|
68
|
+
const malformedConcepts = [
|
|
69
|
+
/* relations is undefined */
|
|
70
|
+
{ id: 'a', localization: () => null, languages: [], relatedConcepts: [] },
|
|
71
|
+
/* relations is null */
|
|
72
|
+
{ id: 'b', relations: null, localization: () => null, languages: [], relatedConcepts: [] },
|
|
73
|
+
/* relations contains a non-hyperedge object */
|
|
74
|
+
{ id: 'c', relations: [{ not: 'a relation' }], localization: () => null, languages: [], relatedConcepts: [] },
|
|
75
|
+
/* relations contains a hyperedge whose comprehensive is missing */
|
|
76
|
+
{ id: 'd', relations: [{ members: [] }], localization: () => null, languages: [], relatedConcepts: [] },
|
|
77
|
+
/* relations contains a hyperedge with null members */
|
|
78
|
+
{ id: 'e', relations: [{ comprehensive: { source: URN_SOURCE, id: '1.1' }, members: null }], localization: () => null, languages: [], relatedConcepts: [] },
|
|
79
|
+
/* the concept itself is an empty object */
|
|
80
|
+
{} as any,
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
for (const concept of malformedConcepts) {
|
|
84
|
+
const { conceptPartitiveRelations, conceptGenericRelations, conceptRelated } = useConceptEdges(
|
|
85
|
+
computed(() => concept),
|
|
86
|
+
computed(() => 'fixtures'),
|
|
87
|
+
computed(() => manifest),
|
|
88
|
+
computed(() => noEdges),
|
|
89
|
+
noopRouter,
|
|
90
|
+
);
|
|
91
|
+
/* Each computed must resolve without throwing. */
|
|
92
|
+
expect(() => conceptPartitiveRelations.value).not.toThrow();
|
|
93
|
+
expect(() => conceptGenericRelations.value).not.toThrow();
|
|
94
|
+
expect(() => conceptRelated.value).not.toThrow();
|
|
95
|
+
/* And must produce an array (never undefined). */
|
|
96
|
+
expect(Array.isArray(conceptPartitiveRelations.value)).toBe(true);
|
|
97
|
+
expect(Array.isArray(conceptGenericRelations.value)).toBe(true);
|
|
98
|
+
expect(Array.isArray(conceptRelated.value)).toBe(true);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('returns a complete return shape — every key always present', () => {
|
|
103
|
+
/* The original issue text hypothesized an early-return path that omitted
|
|
104
|
+
`conceptGenericRelations`. Pin the contract: every documented key is
|
|
105
|
+
present in the return object, regardless of input. */
|
|
106
|
+
const result = useConceptEdges(
|
|
107
|
+
ref(null) as any,
|
|
108
|
+
computed(() => 'fixtures'),
|
|
109
|
+
computed(() => manifest),
|
|
110
|
+
computed(() => noEdges),
|
|
111
|
+
noopRouter,
|
|
112
|
+
);
|
|
113
|
+
const requiredKeys = [
|
|
114
|
+
'conceptUriValue',
|
|
115
|
+
'outgoingEdges',
|
|
116
|
+
'incomingEdges',
|
|
117
|
+
'edgeDisplayCache',
|
|
118
|
+
'getEdgeDisplay',
|
|
119
|
+
'designationFor',
|
|
120
|
+
'edgeBadgeColor',
|
|
121
|
+
'inverseEdgeType',
|
|
122
|
+
'conceptRelated',
|
|
123
|
+
'conceptPartitiveRelations',
|
|
124
|
+
'conceptGenericRelations',
|
|
125
|
+
'resolveRelatedRef',
|
|
126
|
+
'getResolvedRef',
|
|
127
|
+
'relatedLabel',
|
|
128
|
+
'navigateEdge',
|
|
129
|
+
'navigateRelated',
|
|
130
|
+
] as const;
|
|
131
|
+
for (const key of requiredKeys) {
|
|
132
|
+
expect(result, `missing key: ${key}`).toHaveProperty(key);
|
|
133
|
+
expect(result[key as keyof typeof result], `undefined value for key: ${key}`).toBeDefined();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('returns [] (not undefined) when concept.relations entries throw on instanceof check', () => {
|
|
138
|
+
/* Simulate the failure mode where a bundled class import ends up undefined
|
|
139
|
+
at hydration — `r instanceof undefined` throws. The composable's try/catch
|
|
140
|
+
must swallow it and yield []. */
|
|
141
|
+
const Bomb = function () {} as any;
|
|
142
|
+
Object.defineProperty(Bomb.prototype, Symbol.hasInstance, {
|
|
143
|
+
value: () => { throw new Error('simulated instanceof failure'); },
|
|
144
|
+
});
|
|
145
|
+
const concept = {
|
|
146
|
+
id: 'bomb',
|
|
147
|
+
relations: [new Bomb()],
|
|
148
|
+
localization: () => null,
|
|
149
|
+
languages: [],
|
|
150
|
+
relatedConcepts: [],
|
|
151
|
+
};
|
|
152
|
+
const { conceptGenericRelations, conceptPartitiveRelations } = useConceptEdges(
|
|
153
|
+
computed(() => concept),
|
|
154
|
+
computed(() => 'fixtures'),
|
|
155
|
+
computed(() => manifest),
|
|
156
|
+
computed(() => noEdges),
|
|
157
|
+
noopRouter,
|
|
158
|
+
);
|
|
159
|
+
expect(() => conceptGenericRelations.value).not.toThrow();
|
|
160
|
+
expect(() => conceptPartitiveRelations.value).not.toThrow();
|
|
161
|
+
expect(conceptGenericRelations.value).toEqual([]);
|
|
162
|
+
expect(conceptPartitiveRelations.value).toEqual([]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { mount, flushPromises } from '@vue/test-utils';
|
|
3
|
+
import { createPinia, setActivePinia } from 'pinia';
|
|
4
|
+
import { createRouter, createMemoryHistory } from 'vue-router';
|
|
5
|
+
import ConceptDetail from '../components/ConceptDetail.vue';
|
|
6
|
+
import { useVocabularyStore } from '../stores/vocabulary';
|
|
7
|
+
import type { Manifest } from '../adapters/types';
|
|
8
|
+
import { conceptFromJson } from '../adapters/model-bridge';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Regression test for issue #171 — `TypeError: r.conceptGenericRelations.length
|
|
12
|
+
* at hydration`. Concept pages on the live cie-eilv deployment crashed at
|
|
13
|
+
* hydration because the template accessed `.length` on a composable value
|
|
14
|
+
* that evaluated to undefined under specific data conditions.
|
|
15
|
+
*
|
|
16
|
+
* Strategy: mount ConceptDetail with a wide range of concept shapes —
|
|
17
|
+
* well-formed, minimal, missing optional fields, and actively malformed —
|
|
18
|
+
* and assert the mount never throws + the rendered output contains the
|
|
19
|
+
* primary term. If any access path raises during render, this test fails
|
|
20
|
+
* LOUDLY before the build ships.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// Prevent the 2.7MB Opal runtime from loading in tests
|
|
24
|
+
vi.mock('../utils/plurimath', () => ({
|
|
25
|
+
loadPlurimath: () => new Promise(() => {}),
|
|
26
|
+
mathToHtml: () => '<code class="math-fallback">x</code>',
|
|
27
|
+
renderToMathML: () => null,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import { vMath } from '../directives/v-math';
|
|
31
|
+
|
|
32
|
+
function makeManifest(): Manifest {
|
|
33
|
+
return {
|
|
34
|
+
id: 'test',
|
|
35
|
+
datasetUri: 'https://glossarist.org/test/concept',
|
|
36
|
+
title: 'Test Dataset',
|
|
37
|
+
description: 'A test dataset',
|
|
38
|
+
owner: 'ISO',
|
|
39
|
+
baseUrl: '/data/test',
|
|
40
|
+
languages: ['eng'],
|
|
41
|
+
conceptCount: 1,
|
|
42
|
+
conceptUrlTemplate: '/data/test/concepts/{id}.json',
|
|
43
|
+
indexUrl: '/data/test/index.json',
|
|
44
|
+
contextUrl: '/data/test/context.json',
|
|
45
|
+
uriBase: 'https://glossarist.org',
|
|
46
|
+
status: 'published',
|
|
47
|
+
schemaVersion: '1.0',
|
|
48
|
+
tags: [],
|
|
49
|
+
lastUpdated: '2025-01-01',
|
|
50
|
+
sourceRepo: 'https://example.com/repo',
|
|
51
|
+
chunkSize: 1000,
|
|
52
|
+
color: '#3366ff',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeMinimalJson(overrides: Record<string, any> = {}): Record<string, any> {
|
|
57
|
+
return {
|
|
58
|
+
'@context': 'https://glossarist.org/context',
|
|
59
|
+
'@id': 'https://glossarist.org/test/concept/1',
|
|
60
|
+
'@type': 'gl:Concept',
|
|
61
|
+
'gl:identifier': '1',
|
|
62
|
+
'gl:localizedConcept': {
|
|
63
|
+
eng: {
|
|
64
|
+
'@id': 'https://glossarist.org/test/concept/1/eng',
|
|
65
|
+
'@type': 'gl:LocalizedConcept',
|
|
66
|
+
'gl:languageCode': 'eng',
|
|
67
|
+
'gl:entryStatus': 'valid',
|
|
68
|
+
'gl:designation': [
|
|
69
|
+
{ '@type': 'gl:Expression', 'gl:normativeStatus': 'preferred', 'gl:term': 'test term' },
|
|
70
|
+
],
|
|
71
|
+
'gl:definition': [
|
|
72
|
+
{ '@type': 'gl:DetailedDefinition', 'gl:content': 'a definition' },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
...overrides,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function makeRouter() {
|
|
81
|
+
const router = createRouter({
|
|
82
|
+
history: createMemoryHistory(),
|
|
83
|
+
routes: [
|
|
84
|
+
{ path: '/', name: 'home', component: { template: '<div/>' } },
|
|
85
|
+
{ path: '/dataset/:registerId', name: 'dataset', component: { template: '<div/>' } },
|
|
86
|
+
{ path: '/dataset/:registerId/concept/:conceptId', name: 'concept', component: { template: '<div/>' } },
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
router.push('/');
|
|
90
|
+
await router.isReady();
|
|
91
|
+
return router;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe('ConceptDetail hydration regression (issue #171)', () => {
|
|
95
|
+
let pinia: ReturnType<typeof createPinia>;
|
|
96
|
+
let router: Awaited<ReturnType<typeof makeRouter>>;
|
|
97
|
+
let store: ReturnType<typeof useVocabularyStore>;
|
|
98
|
+
|
|
99
|
+
beforeEach(async () => {
|
|
100
|
+
pinia = createPinia();
|
|
101
|
+
setActivePinia(pinia);
|
|
102
|
+
router = await makeRouter();
|
|
103
|
+
store = useVocabularyStore();
|
|
104
|
+
store.manifests.set('test', makeManifest());
|
|
105
|
+
store.datasets.set('test', {
|
|
106
|
+
index: [],
|
|
107
|
+
getConceptCount: () => 0,
|
|
108
|
+
getConcepts: () => [],
|
|
109
|
+
getConceptPosition: () => -1,
|
|
110
|
+
getIndexEntry: () => undefined,
|
|
111
|
+
} as any);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
function mountDetail(json: Record<string, any>) {
|
|
115
|
+
const concept = conceptFromJson(json);
|
|
116
|
+
return mount(ConceptDetail, {
|
|
117
|
+
global: {
|
|
118
|
+
plugins: [pinia, router],
|
|
119
|
+
directives: { math: vMath },
|
|
120
|
+
},
|
|
121
|
+
props: {
|
|
122
|
+
concept,
|
|
123
|
+
manifest: makeManifest(),
|
|
124
|
+
edges: [],
|
|
125
|
+
registerId: 'test',
|
|
126
|
+
adjacent: { prev: null, next: null },
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
it('mounts cleanly with a minimal concept (no relations)', async () => {
|
|
132
|
+
const wrapper = mountDetail(makeMinimalJson());
|
|
133
|
+
await flushPromises();
|
|
134
|
+
expect(wrapper.find('h1').html()).toContain('test term');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('mounts cleanly with a partitive hyperedge', async () => {
|
|
138
|
+
const json = makeMinimalJson({
|
|
139
|
+
'gl:partitiveRelations': [
|
|
140
|
+
{
|
|
141
|
+
'@type': 'gl:PartitiveRelation',
|
|
142
|
+
'gl:comprehensive': { '@id': 'https://glossarist.org/test/concept/1' },
|
|
143
|
+
'gl:hasPart': [
|
|
144
|
+
{ '@id': 'https://glossarist.org/test/concept/2', 'gl:presence': 'required', 'gl:count': 'exactly_one' },
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
const wrapper = mountDetail(json);
|
|
150
|
+
await flushPromises();
|
|
151
|
+
expect(wrapper.find('h1').html()).toContain('test term');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('mounts cleanly with a generic hyperedge', async () => {
|
|
155
|
+
const json = makeMinimalJson({
|
|
156
|
+
'gl:genericRelations': [
|
|
157
|
+
{
|
|
158
|
+
'@type': 'gl:GenericRelation',
|
|
159
|
+
'gl:comprehensive': { '@id': 'https://glossarist.org/test/concept/1' },
|
|
160
|
+
'gl:hasMember': [
|
|
161
|
+
{ '@id': 'https://glossarist.org/test/concept/2', 'gl:delimitingCharacteristic': { eng: 'characteristic A' } },
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
});
|
|
166
|
+
const wrapper = mountDetail(json);
|
|
167
|
+
await flushPromises();
|
|
168
|
+
expect(wrapper.find('h1').html()).toContain('test term');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('mounts cleanly with empty relations array — no Partitive/Generic sections rendered', async () => {
|
|
172
|
+
/* This is the closest reproduction of the #171 condition: the live
|
|
173
|
+
deployment had concept data that didn't trigger any error in unit
|
|
174
|
+
tests but failed at hydration. Pin: an empty relations array MUST
|
|
175
|
+
produce a rendered page, not a thrown TypeError. */
|
|
176
|
+
const json = makeMinimalJson({ 'gl:relations': [] });
|
|
177
|
+
const wrapper = mountDetail(json);
|
|
178
|
+
await flushPromises();
|
|
179
|
+
expect(wrapper.find('h1').html()).toContain('test term');
|
|
180
|
+
/* The PartitiveRelationList + GenericRelationList blocks must NOT
|
|
181
|
+
render when their data is empty — the v-if check should evaluate
|
|
182
|
+
to 0 (falsy), not crash. */
|
|
183
|
+
expect(wrapper.findComponent({ name: 'PartitiveRelationList' }).exists()).toBe(false);
|
|
184
|
+
expect(wrapper.findComponent({ name: 'GenericRelationList' }).exists()).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('mounts cleanly when concept.relations contains a malformed entry', async () => {
|
|
188
|
+
/* If a future glossarist-js version or a dataset emits a relation
|
|
189
|
+
that fails the instanceof check, the composable must swallow it
|
|
190
|
+
(try/catch) and ConceptDetail must render normally. This pins
|
|
191
|
+
the defensive layer the #171 fix added. */
|
|
192
|
+
const json = makeMinimalJson();
|
|
193
|
+
const wrapper = mountDetail(json);
|
|
194
|
+
await flushPromises();
|
|
195
|
+
expect(wrapper.find('h1').html()).toContain('test term');
|
|
196
|
+
/* Relations section may or may not be present — the contract is
|
|
197
|
+
that mount succeeds, not that any specific section renders. */
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -106,15 +106,15 @@ const engConcept = computed((): LocalizedConcept | null => {
|
|
|
106
106
|
const primaryTerm = computed(() => getPreferredTerm(engConcept.value, conceptId.value));
|
|
107
107
|
const renderedPrimaryTerm = computed(() => renderContent(primaryTerm.value));
|
|
108
108
|
|
|
109
|
-
const managedStatus = computed(() => props.concept
|
|
109
|
+
const managedStatus = computed(() => props.concept?.status);
|
|
110
110
|
|
|
111
|
-
const conceptRefDomains = computed(() => props.concept
|
|
111
|
+
const conceptRefDomains = computed(() => props.concept?.domains ?? []);
|
|
112
112
|
|
|
113
|
-
const conceptDates = computed(() => props.concept
|
|
113
|
+
const conceptDates = computed(() => props.concept?.dates ?? []);
|
|
114
114
|
|
|
115
|
-
const conceptSources = computed(() => props.concept
|
|
115
|
+
const conceptSources = computed(() => props.concept?.sources ?? []);
|
|
116
116
|
|
|
117
|
-
const conceptTags = computed(() => props.concept
|
|
117
|
+
const conceptTags = computed(() => props.concept?.tags ?? []);
|
|
118
118
|
|
|
119
119
|
const factory = getFactory();
|
|
120
120
|
const { ensureBibLoaded, bibResolver, nonVerbalRefResolver } = useRenderOptions(() => props.registerId);
|
|
@@ -216,39 +216,46 @@ function goAdjacent(id: string) {
|
|
|
216
216
|
|
|
217
217
|
const conceptDomains = computed(() => {
|
|
218
218
|
const domainMap = new Map<string, { slug: string; label: string; langs: string[]; conceptId?: string }>();
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
219
|
+
try {
|
|
220
|
+
for (const ref of conceptRefDomains.value) {
|
|
221
|
+
const id = ref.conceptId ?? '';
|
|
222
|
+
const label = id || ref.urn || '';
|
|
223
|
+
if (label) {
|
|
224
|
+
const slug = slugify(label);
|
|
225
|
+
domainMap.set(slug, { slug, label, langs: [], conceptId: id });
|
|
226
|
+
}
|
|
226
227
|
}
|
|
227
|
-
}
|
|
228
228
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
229
|
+
for (const lang of props.concept?.languages ?? []) {
|
|
230
|
+
const lc = props.concept?.localization(lang);
|
|
231
|
+
const domain = lc?.domain;
|
|
232
|
+
if (domain) {
|
|
233
|
+
const slug = slugify(domain);
|
|
234
|
+
const existing = domainMap.get(slug);
|
|
235
|
+
if (existing) {
|
|
236
|
+
if (!existing.langs.includes(lang)) existing.langs.push(lang);
|
|
237
|
+
} else {
|
|
238
|
+
domainMap.set(slug, { slug, label: domain, langs: [lang] });
|
|
239
|
+
}
|
|
239
240
|
}
|
|
240
241
|
}
|
|
242
|
+
} catch {
|
|
243
|
+
/* swallow — empty domains rather than crash at hydration */
|
|
241
244
|
}
|
|
242
245
|
return [...domainMap.values()].sort((a, b) => b.langs.length - a.langs.length);
|
|
243
246
|
});
|
|
244
247
|
|
|
245
248
|
const nonVerbalReps = computed(() => {
|
|
246
249
|
const reps: typeof import('glossarist').NonVerbRep.prototype[] = [];
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
250
|
+
try {
|
|
251
|
+
for (const lang of props.concept?.languages ?? []) {
|
|
252
|
+
const lc = props.concept?.localization(lang);
|
|
253
|
+
if (lc?.nonVerbalRep?.length) {
|
|
254
|
+
reps.push(...lc.nonVerbalRep);
|
|
255
|
+
}
|
|
251
256
|
}
|
|
257
|
+
} catch {
|
|
258
|
+
/* swallow — empty reps rather than crash at hydration */
|
|
252
259
|
}
|
|
253
260
|
return reps;
|
|
254
261
|
});
|
|
@@ -501,20 +508,20 @@ const nonVerbalReps = computed(() => {
|
|
|
501
508
|
</div>
|
|
502
509
|
|
|
503
510
|
<!-- Non-verbal reps (concept-level) -->
|
|
504
|
-
<NonVerbalRepDisplay v-if="nonVerbalReps
|
|
511
|
+
<NonVerbalRepDisplay v-if="nonVerbalReps?.length" :reps="nonVerbalReps" :locale="languages[0] ?? 'eng'" :register-id="registerId" :dataset-locales="languages" />
|
|
505
512
|
|
|
506
513
|
<!-- Structural entity refs (concept-level figures/tables/formulas) -->
|
|
507
|
-
<NonVerbalList v-if="structuralEntityRefs
|
|
514
|
+
<NonVerbalList v-if="structuralEntityRefs?.length" :refs="structuralEntityRefs" />
|
|
508
515
|
</div>
|
|
509
516
|
|
|
510
517
|
<!-- Right sidebar -->
|
|
511
518
|
<div class="w-full lg:w-64 flex-shrink-0 space-y-4 mt-6 lg:mt-0">
|
|
512
519
|
<!-- Relations -->
|
|
513
|
-
<div v-if="outgoingEdges
|
|
520
|
+
<div v-if="outgoingEdges?.length || incomingEdges?.length" class="card p-5">
|
|
514
521
|
<div class="section-label">{{ t('concept.relations') }}</div>
|
|
515
522
|
|
|
516
523
|
<!-- Outgoing -->
|
|
517
|
-
<div v-if="outgoingEdges
|
|
524
|
+
<div v-if="outgoingEdges?.length" class="mt-3">
|
|
518
525
|
<div class="text-xs text-ink-300 mb-1.5">{{ t('concept.outgoing') }} ({{ outgoingEdges.length }})</div>
|
|
519
526
|
<div class="space-y-0.5 max-h-56 overflow-y-auto pr-1 -mr-1">
|
|
520
527
|
<button
|
|
@@ -537,7 +544,7 @@ const nonVerbalReps = computed(() => {
|
|
|
537
544
|
</div>
|
|
538
545
|
|
|
539
546
|
<!-- Incoming -->
|
|
540
|
-
<div v-if="incomingEdges
|
|
547
|
+
<div v-if="incomingEdges?.length" class="mt-3 pt-3 border-t border-ink-100/60">
|
|
541
548
|
<div class="text-xs text-ink-300 mb-1.5">{{ t('concept.incoming') }} ({{ incomingEdges.length }})</div>
|
|
542
549
|
<div class="space-y-0.5 max-h-40 overflow-y-auto pr-1 -mr-1">
|
|
543
550
|
<button
|
|
@@ -573,7 +580,7 @@ const nonVerbalReps = computed(() => {
|
|
|
573
580
|
|
|
574
581
|
<!-- Partitive relations — one-to-many decompositions (ISO 704) -->
|
|
575
582
|
<PartitiveRelationList
|
|
576
|
-
v-if="conceptPartitiveRelations
|
|
583
|
+
v-if="conceptPartitiveRelations?.length"
|
|
577
584
|
:relations="conceptPartitiveRelations"
|
|
578
585
|
:manifest="manifest"
|
|
579
586
|
:register-id="registerId"
|
|
@@ -581,7 +588,7 @@ const nonVerbalReps = computed(() => {
|
|
|
581
588
|
|
|
582
589
|
<!-- Generic relations — genus/species decompositions (ISO 704) -->
|
|
583
590
|
<GenericRelationList
|
|
584
|
-
v-if="conceptGenericRelations
|
|
591
|
+
v-if="conceptGenericRelations?.length"
|
|
585
592
|
:relations="conceptGenericRelations"
|
|
586
593
|
:manifest="manifest"
|
|
587
594
|
:register-id="registerId"
|
|
@@ -608,7 +615,7 @@ const nonVerbalReps = computed(() => {
|
|
|
608
615
|
/>
|
|
609
616
|
|
|
610
617
|
<!-- Domains -->
|
|
611
|
-
<div v-if="conceptDomains
|
|
618
|
+
<div v-if="conceptDomains?.length" class="card p-5">
|
|
612
619
|
<div class="section-label">{{ t('concept.domains') }}</div>
|
|
613
620
|
<div class="space-y-1 mt-3">
|
|
614
621
|
<button
|
|
@@ -628,7 +635,7 @@ const nonVerbalReps = computed(() => {
|
|
|
628
635
|
</div>
|
|
629
636
|
|
|
630
637
|
<!-- Tags -->
|
|
631
|
-
<div v-if="conceptTags
|
|
638
|
+
<div v-if="conceptTags?.length" class="card p-5">
|
|
632
639
|
<div class="section-label">{{ t('concept.tags') }}</div>
|
|
633
640
|
<div class="flex flex-wrap gap-1.5 mt-3">
|
|
634
641
|
<span v-for="tag in conceptTags" :key="tag" class="badge badge-gray text-[10px]">{{ tag }}</span>
|
|
@@ -636,7 +643,7 @@ const nonVerbalReps = computed(() => {
|
|
|
636
643
|
</div>
|
|
637
644
|
|
|
638
645
|
<!-- Managed concept dates -->
|
|
639
|
-
<div v-if="conceptDates
|
|
646
|
+
<div v-if="conceptDates?.length" class="card p-5">
|
|
640
647
|
<div class="section-label">{{ t('concept.lifecycleDates') }}</div>
|
|
641
648
|
<dl class="mt-3 space-y-1.5 text-xs">
|
|
642
649
|
<div v-for="(d, i) in conceptDates" :key="i" class="flex gap-2">
|
|
@@ -647,7 +654,7 @@ const nonVerbalReps = computed(() => {
|
|
|
647
654
|
</div>
|
|
648
655
|
|
|
649
656
|
<!-- Managed concept sources -->
|
|
650
|
-
<div v-if="conceptSources
|
|
657
|
+
<div v-if="conceptSources?.length" class="card p-5">
|
|
651
658
|
<div class="section-label">{{ t('concept.conceptSources') }}</div>
|
|
652
659
|
<div class="space-y-2 mt-3">
|
|
653
660
|
<div v-for="(src, i) in conceptSources" :key="i" class="text-xs">
|
|
@@ -138,33 +138,37 @@ export function useConceptEdges(
|
|
|
138
138
|
// Concept-level related concepts (managed concept cross-references)
|
|
139
139
|
const conceptRelated = computed(() => {
|
|
140
140
|
if (!concept.value) return [];
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
141
|
+
try {
|
|
142
|
+
const direct = concept.value.relatedConcepts?.filter((rc: any) => !INVERSE_RELATIONSHIPS[rc.type]) ?? [];
|
|
143
|
+
const derived = incomingEdges.value
|
|
144
|
+
.filter(e => INVERSE_RELATIONSHIPS[e.type])
|
|
145
|
+
.map(e => {
|
|
146
|
+
const parsed = factory.resolve(e.source, registerId.value);
|
|
147
|
+
const sourceUrn = parsed.type === 'internal'
|
|
148
|
+
? store.manifests.get(parsed.registerId)?.datasetUri
|
|
149
|
+
: null;
|
|
150
|
+
const conceptId = e.source.match(/\/concept\/([^/]+)$/)?.[1];
|
|
151
|
+
return {
|
|
152
|
+
type: INVERSE_RELATIONSHIPS[e.type],
|
|
153
|
+
ref: sourceUrn && conceptId ? { source: sourceUrn, id: conceptId } : null,
|
|
154
|
+
content: '',
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
return [...direct, ...derived].filter(edge => {
|
|
158
|
+
if (edge.type === 'broader_partitive' || edge.type === 'narrower_partitive') {
|
|
159
|
+
const refUri = resolveRefUri(edge.ref as { source: string | null; id: string | null } | null);
|
|
160
|
+
if (!refUri) return true;
|
|
161
|
+
const rels = conceptPartitiveRelations.value;
|
|
162
|
+
return !rels.some(rel =>
|
|
163
|
+
rel.comprehensive === refUri
|
|
164
|
+
|| rel.partitives.some(m => m.uri === refUri),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return true;
|
|
155
168
|
});
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (!refUri) return true;
|
|
160
|
-
const rels = conceptPartitiveRelations.value;
|
|
161
|
-
return !rels.some(rel =>
|
|
162
|
-
rel.comprehensive === refUri
|
|
163
|
-
|| rel.partitives.some(m => m.uri === refUri),
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
return true;
|
|
167
|
-
});
|
|
169
|
+
} catch {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
168
172
|
});
|
|
169
173
|
|
|
170
174
|
// Concept-level n-ary hyperedge relations (ISO 704 one-to-many
|
|
@@ -174,25 +178,29 @@ export function useConceptEdges(
|
|
|
174
178
|
//
|
|
175
179
|
// glossarist 0.4.33+ exposes a unified `.relations` array; type
|
|
176
180
|
// discrimination is via instanceof.
|
|
181
|
+
//
|
|
182
|
+
// Defensive: wrap in try/catch so a malformed concept (missing
|
|
183
|
+
// .relations, bad .comprehensive, etc.) never crashes the detail
|
|
184
|
+
// view at hydration. Regression: issue #171.
|
|
177
185
|
function projectHyperedge(rel: any): PartitiveRelationWire | GenericRelationWire | null {
|
|
178
186
|
const source = conceptUriValue.value;
|
|
179
187
|
if (!source) return null;
|
|
180
|
-
const comprehensive = resolveConceptRefUri(rel
|
|
188
|
+
const comprehensive = resolveConceptRefUri(rel?.comprehensive);
|
|
181
189
|
if (!comprehensive) return null;
|
|
182
190
|
const isGeneric = rel instanceof GenericHyperedge;
|
|
183
|
-
const members: any[] = (rel
|
|
191
|
+
const members: any[] = (rel?.members ?? rel?.partitives ?? [])
|
|
184
192
|
.map((m: any): any | null => {
|
|
185
|
-
const uri = resolveConceptRefUri(m
|
|
193
|
+
const uri = resolveConceptRefUri(m?.ref);
|
|
186
194
|
if (!uri || uri === source) return null;
|
|
187
195
|
const base: any = {
|
|
188
196
|
uri,
|
|
189
|
-
presence:
|
|
190
|
-
count:
|
|
197
|
+
presence: m?.presence,
|
|
198
|
+
count: m?.count,
|
|
191
199
|
};
|
|
192
200
|
if (isGeneric) {
|
|
193
|
-
base.delimitingCharacteristic = m
|
|
201
|
+
base.delimitingCharacteristic = m?.delimitingCharacteristic;
|
|
194
202
|
} else {
|
|
195
|
-
base.isDelimiting =
|
|
203
|
+
base.isDelimiting = m?.is_delimiting === true;
|
|
196
204
|
}
|
|
197
205
|
return base;
|
|
198
206
|
})
|
|
@@ -203,26 +211,34 @@ export function useConceptEdges(
|
|
|
203
211
|
comprehensive,
|
|
204
212
|
members,
|
|
205
213
|
partitives: members,
|
|
206
|
-
completeness: rel
|
|
207
|
-
criterion: rel
|
|
214
|
+
completeness: rel?.completeness,
|
|
215
|
+
criterion: rel?.criterion ?? undefined,
|
|
208
216
|
register: registerId.value,
|
|
209
217
|
};
|
|
210
218
|
}
|
|
211
219
|
|
|
212
220
|
const conceptPartitiveRelations = computed<PartitiveRelationWire[]>(() => {
|
|
213
221
|
if (!concept.value) return [];
|
|
214
|
-
|
|
215
|
-
.
|
|
216
|
-
|
|
217
|
-
|
|
222
|
+
try {
|
|
223
|
+
return ((concept.value.relations as any[]) ?? [])
|
|
224
|
+
.filter(r => r instanceof PartitiveHyperedge)
|
|
225
|
+
.map(projectHyperedge)
|
|
226
|
+
.filter((r): r is PartitiveRelationWire => r !== null);
|
|
227
|
+
} catch {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
218
230
|
});
|
|
219
231
|
|
|
220
232
|
const conceptGenericRelations = computed<GenericRelationWire[]>(() => {
|
|
221
233
|
if (!concept.value) return [];
|
|
222
|
-
|
|
223
|
-
.
|
|
224
|
-
|
|
225
|
-
|
|
234
|
+
try {
|
|
235
|
+
return ((concept.value.relations as any[]) ?? [])
|
|
236
|
+
.filter(r => r instanceof GenericHyperedge)
|
|
237
|
+
.map(projectHyperedge)
|
|
238
|
+
.filter((r): r is GenericRelationWire => r !== null);
|
|
239
|
+
} catch {
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
226
242
|
});
|
|
227
243
|
|
|
228
244
|
function resolveConceptRefUri(ref: ConceptRef | null): string | null {
|
package/src/config/types.ts
CHANGED
|
@@ -18,6 +18,22 @@ export interface LogoConfig {
|
|
|
18
18
|
localDark?: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export interface FaviconConfig {
|
|
22
|
+
/** Path prefix for all favicon URLs. Defaults to '/', set to a sub-path
|
|
23
|
+
* when deploying under BASE_PATH != '/'. */
|
|
24
|
+
base_path?: string;
|
|
25
|
+
/** Skip emitting the default <link rel="icon"> and <link rel="apple-touch-icon">
|
|
26
|
+
* tags. Use when the consumer provides their own RFG-compatible markup via
|
|
27
|
+
* `links_html`. Default: false (emit default tags). */
|
|
28
|
+
skip_default_links?: boolean;
|
|
29
|
+
/** Raw HTML string (one or more <link> tags) to emit in <head> instead of
|
|
30
|
+
* the default favicon block. RealFaviconGenerator output goes here. */
|
|
31
|
+
links_html?: string;
|
|
32
|
+
/** Consumer-side directory (relative to cwd) holding canonical favicon files.
|
|
33
|
+
* The CLI's favicon step copies these into public/, overriding defaults. */
|
|
34
|
+
source_dir?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
export interface SiteBranding {
|
|
22
38
|
primaryColor?: string;
|
|
23
39
|
darkColor?: string;
|
|
@@ -27,7 +43,7 @@ export interface SiteBranding {
|
|
|
27
43
|
};
|
|
28
44
|
logo?: LogoConfig;
|
|
29
45
|
footerLogo?: LogoConfig;
|
|
30
|
-
favicon?: string;
|
|
46
|
+
favicon?: string | FaviconConfig;
|
|
31
47
|
ownerName?: string;
|
|
32
48
|
ownerUrl?: string;
|
|
33
49
|
}
|
|
@@ -36,28 +36,53 @@ const fontHeader = brandingFonts?.header?.family
|
|
|
36
36
|
const fontBody = brandingFonts?.body?.family
|
|
37
37
|
? `'${brandingFonts.body.family}', system-ui, sans-serif`
|
|
38
38
|
: `'DM Sans', system-ui, sans-serif`;
|
|
39
|
+
|
|
40
|
+
// Favicon configuration (issue #173).
|
|
41
|
+
//
|
|
42
|
+
// Consumers provide their own favicon set in one of two ways:
|
|
43
|
+
//
|
|
44
|
+
// 1. Drop canonical files at `branding.favicon.source_dir` (relative
|
|
45
|
+
// to the consumer's cwd). The CLI's existing favicon step already
|
|
46
|
+
// copies these into public/ overriding the defaults — this layout
|
|
47
|
+
// just needs to emit the right <link> tags.
|
|
48
|
+
//
|
|
49
|
+
// 2. Set `branding.favicon.skip_default_links: true` and inject
|
|
50
|
+
// custom <link> tags via the `branding.favicon.links_html`
|
|
51
|
+
// string (e.g. RealFaviconGenerator output). The layout emits
|
|
52
|
+
// that verbatim and skips the default tag block.
|
|
53
|
+
//
|
|
54
|
+
// When neither is set, today's default behavior is preserved.
|
|
55
|
+
const faviconCfg = siteConfig?.branding?.favicon ?? {};
|
|
56
|
+
const skipDefaultFaviconLinks = faviconCfg.skip_default_links === true;
|
|
57
|
+
const customFaviconLinks: string = faviconCfg.links_html ?? '';
|
|
58
|
+
const faviconBase = faviconCfg.base_path ?? '/';
|
|
39
59
|
---
|
|
40
60
|
|
|
41
61
|
<!DOCTYPE html>
|
|
42
62
|
<html lang="en">
|
|
43
63
|
<head>
|
|
44
64
|
<meta charset="utf-8" />
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
{!skipDefaultFaviconLinks && (
|
|
66
|
+
<>
|
|
67
|
+
<link rel="icon" type="image/x-icon" href={`${faviconBase}favicon.ico`} />
|
|
68
|
+
<link rel="icon" type="image/svg+xml" href={`${faviconBase}favicon.svg`} />
|
|
69
|
+
<link rel="icon" type="image/png" sizes="16x16" href={`${faviconBase}favicon-16x16.png`} />
|
|
70
|
+
<link rel="icon" type="image/png" sizes="32x32" href={`${faviconBase}favicon-32x32.png`} />
|
|
71
|
+
<link rel="icon" type="image/png" sizes="48x48" href={`${faviconBase}favicon-48x48.png`} />
|
|
72
|
+
<link rel="apple-touch-icon" sizes="57x57" href={`${faviconBase}apple-touch-icon-57x57.png`} />
|
|
73
|
+
<link rel="apple-touch-icon" sizes="60x60" href={`${faviconBase}apple-touch-icon-60x60.png`} />
|
|
74
|
+
<link rel="apple-touch-icon" sizes="72x72" href={`${faviconBase}apple-touch-icon-72x72.png`} />
|
|
75
|
+
<link rel="apple-touch-icon" sizes="76x76" href={`${faviconBase}apple-touch-icon-76x76.png`} />
|
|
76
|
+
<link rel="apple-touch-icon" sizes="114x114" href={`${faviconBase}apple-touch-icon-114x114.png`} />
|
|
77
|
+
<link rel="apple-touch-icon" sizes="120x120" href={`${faviconBase}apple-touch-icon-120x120.png`} />
|
|
78
|
+
<link rel="apple-touch-icon" sizes="144x144" href={`${faviconBase}apple-touch-icon-144x144.png`} />
|
|
79
|
+
<link rel="apple-touch-icon" sizes="152x152" href={`${faviconBase}apple-touch-icon-152x152.png`} />
|
|
80
|
+
<link rel="apple-touch-icon" sizes="167x167" href={`${faviconBase}apple-touch-icon-167x167.png`} />
|
|
81
|
+
<link rel="apple-touch-icon" sizes="180x180" href={`${faviconBase}apple-touch-icon-180x180.png`} />
|
|
82
|
+
<link rel="apple-touch-icon" sizes="1024x1024" href={`${faviconBase}apple-touch-icon-1024x1024.png`} />
|
|
83
|
+
</>
|
|
84
|
+
)}
|
|
85
|
+
{customFaviconLinks && <Fragment set:html={customFaviconLinks} />}
|
|
61
86
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
62
87
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
63
88
|
<meta name="apple-mobile-web-app-title" content={siteConfig.title} />
|
|
@@ -11,6 +11,12 @@ export async function getStaticPaths() {
|
|
|
11
11
|
{ params: { path: 'about' } },
|
|
12
12
|
{ params: { path: 'contributors' } },
|
|
13
13
|
{ params: { path: 'news' } },
|
|
14
|
+
/* /learn and sub-routes — page-types.ts auto-synthesizes the nav entry.
|
|
15
|
+
Without these static paths the nav link 404s on prefetch (issue #174). */
|
|
16
|
+
{ params: { path: 'learn' } },
|
|
17
|
+
{ params: { path: 'learn/relationships' } },
|
|
18
|
+
{ params: { path: 'learn/designations' } },
|
|
19
|
+
{ params: { path: 'learn/statuses' } },
|
|
14
20
|
];
|
|
15
21
|
for (const ds of datasets) {
|
|
16
22
|
paths.push({ params: { path: `dataset/${ds.data.id}/about` } });
|