@glossarist/concept-browser 0.7.117 → 0.7.119

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.117",
3
+ "version": "0.7.119",
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": {
@@ -42,7 +42,7 @@
42
42
  "astro": "^7.0.7",
43
43
  "d3": "^7.9.0",
44
44
  "favicons": "^7.2.0",
45
- "glossarist": "^0.4.52",
45
+ "glossarist": "^0.4.54",
46
46
  "js-yaml": "^4.1.0",
47
47
  "jszip": "^3.10.1",
48
48
  "n3": "^1.26.0",
@@ -157,6 +157,16 @@ function extractRelated(concept, registerId, uriBase, urnMap) {
157
157
  const edges = [];
158
158
  const sourceUri = concept["@id"];
159
159
  for (const r of concept["gl:related"] || []) {
160
+ if (r["gl:target"]) {
161
+ edges.push({
162
+ source: sourceUri,
163
+ target: r["gl:target"],
164
+ type: r["gl:relationshipType"] || "references",
165
+ label: r["gl:term"] || void 0,
166
+ register: r["gl:target"].match(/\/([^/]+)\/concept\//)?.[1] || registerId
167
+ });
168
+ continue;
169
+ }
160
170
  const ref = r["gl:ref"];
161
171
  if (!ref) continue;
162
172
  const source = ref["gl:source"] || ref["source"];
@@ -382,6 +392,9 @@ function main() {
382
392
  const base = alias.endsWith("*") ? alias.slice(0, -1) : alias;
383
393
  if (base) urnMap.set(base, ds);
384
394
  }
395
+ if (manifest.ref) {
396
+ urnMap.set(manifest.ref, ds);
397
+ }
385
398
  } catch {
386
399
  }
387
400
  }
@@ -173,6 +173,19 @@ function extractRelated(concept, registerId, uriBase, urnMap) {
173
173
  const edges = [];
174
174
  const sourceUri = concept['@id'];
175
175
  for (const r of concept['gl:related'] || []) {
176
+ // Prefer gl:target — already resolved at generate-data build time
177
+ // via refPrefixMap (maps dataset ref labels to dataset IDs).
178
+ if (r['gl:target']) {
179
+ edges.push({
180
+ source: sourceUri,
181
+ target: r['gl:target'],
182
+ type: r['gl:relationshipType'] || 'references',
183
+ label: r['gl:term'] || undefined,
184
+ register: r['gl:target'].match(/\/([^/]+)\/concept\//)?.[1] || registerId,
185
+ });
186
+ continue;
187
+ }
188
+ // Fallback: resolve from gl:ref using urnMap
176
189
  const ref = r['gl:ref'];
177
190
  if (!ref) continue;
178
191
  const source = ref['gl:source'] || ref['source'];
@@ -458,6 +471,12 @@ for (const ds of datasets) {
458
471
  const base = alias.endsWith('*') ? alias.slice(0, -1) : alias;
459
472
  if (base) urnMap.set(base, ds);
460
473
  }
474
+ // Map the dataset's ref label (e.g. "CIE S 017:2020") to its dataset ID.
475
+ // This enables cross-dataset resolution for gl:ref entries that use
476
+ // source labels instead of URIs.
477
+ if (manifest.ref) {
478
+ urnMap.set(manifest.ref, ds);
479
+ }
461
480
  } catch {}
462
481
  }
463
482
  console.log(`URI resolution map: ${[...urnMap.entries()].map(([k,v]) => `${k}→${v}`).join(', ')}\n`);
@@ -420,7 +420,7 @@ async function buildActivityTurtle(input) {
420
420
  }
421
421
 
422
422
  // scripts/lib/vocab-turtle.ts
423
- import { readFileSync as readFileSync2 } from "node:fs";
423
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "node:fs";
424
424
  import { join, dirname as dirname2 } from "node:path";
425
425
  import { fileURLToPath as fileURLToPath2 } from "node:url";
426
426
  import {
@@ -430,9 +430,21 @@ import {
430
430
  PREFIXES as PREFIXES2
431
431
  } from "glossarist/rdf";
432
432
  var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
433
- var VOCAB_PATH = join(__dirname2, "..", "..", "data", "glossarist-vocab.json");
434
- function loadVocab(path4 = VOCAB_PATH) {
435
- return JSON.parse(readFileSync2(path4, "utf8"));
433
+ function findVocabPath() {
434
+ const candidates = [
435
+ join(__dirname2, "..", "..", "data", "glossarist-vocab.json"),
436
+ join(__dirname2, "..", "data", "glossarist-vocab.json")
437
+ ];
438
+ for (const p of candidates) {
439
+ if (existsSync2(p)) return p;
440
+ }
441
+ throw new Error(
442
+ `glossarist-vocab.json not found. Tried:
443
+ ${candidates.map((p) => " " + p).join("\n")}`
444
+ );
445
+ }
446
+ function loadVocab() {
447
+ return JSON.parse(readFileSync2(findVocabPath(), "utf8"));
436
448
  }
437
449
  function writerPrefixes3() {
438
450
  const out = {};
@@ -1090,6 +1102,9 @@ function buildPatternIndex(datasets, registerCache2) {
1090
1102
  function buildRefMaps(config2, registerCache2) {
1091
1103
  const refPrefixMap = {};
1092
1104
  const patternIndex = buildPatternIndex(config2.datasets, registerCache2);
1105
+ for (const ds of config2.datasets) {
1106
+ if (ds.ref) refPrefixMap[ds.ref] = ds.id;
1107
+ }
1093
1108
  for (const route of config2.routing || []) {
1094
1109
  if (route.uri && route.uri.includes("iec") && route.uri.includes("60050")) {
1095
1110
  const mapped = route.targetDataset;
@@ -1316,6 +1331,13 @@ function yamlToJsonLd(conceptYaml, register, refMaps2) {
1316
1331
  if (r.ref.id) ref["gl:id"] = r.ref.id;
1317
1332
  if (r.ref.text) ref["gl:text"] = r.ref.text;
1318
1333
  rel["gl:ref"] = ref;
1334
+ if (r.ref.source && r.ref.id && refMaps2?.refPrefixMap?.[r.ref.source]) {
1335
+ const datasetId = refMaps2.refPrefixMap[r.ref.source];
1336
+ rel["gl:target"] = buildConceptUri(refMaps2.uriBase, datasetId, r.ref.id);
1337
+ }
1338
+ }
1339
+ if (r.target) {
1340
+ rel["gl:target"] = r.target;
1319
1341
  }
1320
1342
  return rel;
1321
1343
  });
@@ -221,6 +221,14 @@ function buildRefMaps(config, registerCache) {
221
221
  const refPrefixMap = {};
222
222
  const patternIndex = buildPatternIndex(config.datasets, registerCache);
223
223
 
224
+ // Auto-map each dataset's `ref` field to its dataset ID.
225
+ // This enables cross-dataset link resolution: a concept in cie-2020
226
+ // with ref: { source: "CIE S 017:2011", id: "17-245" } resolves to
227
+ // cie-2011/concept/17-245 because cie-2011 has ref: "CIE S 017:2011".
228
+ for (const ds of config.datasets) {
229
+ if (ds.ref) refPrefixMap[ds.ref] = ds.id;
230
+ }
231
+
224
232
  for (const route of config.routing || []) {
225
233
  if (route.uri && route.uri.includes('iec') && route.uri.includes('60050')) {
226
234
  const mapped = route.targetDataset;
@@ -508,6 +516,18 @@ function yamlToJsonLd(conceptYaml, register, refMaps) {
508
516
  if (r.ref.id) ref['gl:id'] = r.ref.id;
509
517
  if (r.ref.text) ref['gl:text'] = r.ref.text;
510
518
  rel['gl:ref'] = ref;
519
+
520
+ // Resolve cross-dataset refs to gl:target URIs.
521
+ // refPrefixMap maps source labels (e.g. "CIE S 017:2011") to
522
+ // dataset IDs (e.g. "cie-2011"). When the source matches a
523
+ // co-deployed dataset, we can build a navigable concept URI.
524
+ if (r.ref.source && r.ref.id && refMaps?.refPrefixMap?.[r.ref.source]) {
525
+ const datasetId = refMaps.refPrefixMap[r.ref.source];
526
+ rel['gl:target'] = buildConceptUri(refMaps.uriBase, datasetId, r.ref.id);
527
+ }
528
+ }
529
+ if (r.target) {
530
+ rel['gl:target'] = r.target;
511
531
  }
512
532
  return rel;
513
533
  });
@@ -7,7 +7,7 @@
7
7
  // to absolute IRIs, then re-rendered via glossarist-js's n3 writer with
8
8
  // the canonical prefix map ).
9
9
 
10
- import { readFileSync } from 'node:fs';
10
+ import { readFileSync, existsSync } from 'node:fs';
11
11
  import { join, dirname } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import {
@@ -18,10 +18,25 @@ import {
18
18
  } from 'glossarist/rdf';
19
19
 
20
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
- const VOCAB_PATH = join(__dirname, '..', '..', 'data', 'glossarist-vocab.json');
22
21
 
23
- function loadVocab(path = VOCAB_PATH) {
24
- return JSON.parse(readFileSync(path, 'utf8'));
22
+ // When running from source (tsx), this file is at scripts/lib/ and
23
+ // the data dir is two levels up. When bundled by esbuild into
24
+ // scripts/*.js, __dirname is scripts/ and the data dir is one level up.
25
+ function findVocabPath(): string {
26
+ const candidates = [
27
+ join(__dirname, '..', '..', 'data', 'glossarist-vocab.json'),
28
+ join(__dirname, '..', 'data', 'glossarist-vocab.json'),
29
+ ];
30
+ for (const p of candidates) {
31
+ if (existsSync(p)) return p;
32
+ }
33
+ throw new Error(
34
+ `glossarist-vocab.json not found. Tried:\n${candidates.map(p => ' ' + p).join('\n')}`,
35
+ );
36
+ }
37
+
38
+ function loadVocab() {
39
+ return JSON.parse(readFileSync(findVocabPath(), 'utf8'));
25
40
  }
26
41
 
27
42
  // n3's writer prefixes must omit the trailing # or / — those go in
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { conceptFromJson } from '../../adapters/model-bridge';
3
+
4
+ function makeJsonLdWithRelated(target: string | null) {
5
+ const related = target
6
+ ? {
7
+ 'gl:relationshipType': 'superseded_by',
8
+ 'gl:target': target,
9
+ 'gl:ref': { 'gl:source': 'CIE S 017:2020', 'gl:id': '17-21-097' },
10
+ }
11
+ : {
12
+ 'gl:relationshipType': 'superseded_by',
13
+ 'gl:ref': { 'gl:source': 'CIE S 017:2020', 'gl:id': '17-21-097' },
14
+ };
15
+ return {
16
+ '@type': 'skos:Concept',
17
+ '@id': 'https://example.com/cie-2011/concept/17-50',
18
+ 'gl:identifier': '17-50',
19
+ 'gl:localizedConcept': {
20
+ eng: {
21
+ 'gl:languageCode': 'eng',
22
+ 'gl:designation': [{ '@type': 'gl:Expression', 'gl:term': 'test term', 'gl:normativeStatus': 'preferred' }],
23
+ 'gl:definition': [{ 'gl:content': 'test definition' }],
24
+ },
25
+ },
26
+ 'gl:related': [related],
27
+ };
28
+ }
29
+
30
+ describe('cross-dataset related concept resolution — gl:target flows through model-bridge natively', () => {
31
+ it('concept.relatedConcepts entries with gl:target have native target field', () => {
32
+ const json = makeJsonLdWithRelated('https://example.com/cie-2020/concept/17-21-097');
33
+ const concept = conceptFromJson(json as any);
34
+ expect(concept.relatedConcepts).toHaveLength(1);
35
+ expect(concept.relatedConcepts[0].type).toBe('superseded_by');
36
+ expect(concept.relatedConcepts[0].target).toBe('https://example.com/cie-2020/concept/17-21-097');
37
+ });
38
+
39
+ it('concept.relatedConcepts entries without gl:target have null target', () => {
40
+ const json = makeJsonLdWithRelated(null);
41
+ const concept = conceptFromJson(json as any);
42
+ expect(concept.relatedConcepts).toHaveLength(1);
43
+ expect(concept.relatedConcepts[0].target).toBeNull();
44
+ });
45
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * End-to-end consumer simulation test.
3
+ *
4
+ * Simulates what a real consumer experiences: creates a temp "consumer
5
+ * project" with the compiled package in node_modules, then runs scripts
6
+ * with plain node. Catches packaging bugs that dev-mode tests miss.
7
+ */
8
+
9
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
10
+ import { spawnSync } from 'node:child_process';
11
+ import {
12
+ mkdirSync, writeFileSync, cpSync, rmSync, existsSync, readFileSync, readdirSync,
13
+ } from 'node:fs';
14
+ import { resolve, join, dirname } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+ const ROOT = resolve(__dirname, '..', '..');
19
+ const SCRIPTS_DIR = join(ROOT, 'scripts');
20
+ const CONSUMER_DIR = join(ROOT, '.tmp-consumer-test');
21
+
22
+ describe('end-to-end consumer simulation — compiled package works under plain node', () => {
23
+ beforeAll(() => {
24
+ const build = spawnSync('node', [join(ROOT, 'scripts', 'build-scripts.mjs')], {
25
+ encoding: 'utf8', cwd: ROOT,
26
+ });
27
+ expect(build.status).toBe(0);
28
+
29
+ rmSync(CONSUMER_DIR, { recursive: true, force: true });
30
+ const pkgDest = join(CONSUMER_DIR, 'node_modules', '@glossarist', 'concept-browser');
31
+ mkdirSync(pkgDest, { recursive: true });
32
+ cpSync(join(ROOT, 'scripts'), join(pkgDest, 'scripts'), { recursive: true });
33
+ cpSync(join(ROOT, 'data'), join(pkgDest, 'data'), { recursive: true });
34
+ cpSync(join(ROOT, 'cli'), join(pkgDest, 'cli'), { recursive: true });
35
+ writeFileSync(join(pkgDest, 'package.json'), JSON.stringify({
36
+ name: '@glossarist/concept-browser', version: '0.0.0-test', type: 'module',
37
+ bin: { 'concept-browser': './cli/index.mjs' },
38
+ }));
39
+ }, 60000);
40
+
41
+ afterAll(() => {
42
+ rmSync(CONSUMER_DIR, { recursive: true, force: true });
43
+ });
44
+
45
+ it('compiled scripts can find package data files from node_modules location', () => {
46
+ const scriptDir = join(CONSUMER_DIR, 'node_modules', '@glossarist', 'concept-browser', 'scripts');
47
+ const pathFromBundled = join(scriptDir, '..', 'data', 'glossarist-vocab.json');
48
+ expect(existsSync(pathFromBundled)).toBe(true);
49
+ });
50
+
51
+ it('compiled scripts list matches source scripts (no missing outputs)', () => {
52
+ const sourceTs = readdirSync(SCRIPTS_DIR)
53
+ .filter(f => f.endsWith('.ts') && !f.includes('__tests__') && f !== 'smoke.ts')
54
+ .map(f => f.replace(/\.ts$/, ''));
55
+ const compiledJs = readdirSync(SCRIPTS_DIR)
56
+ .filter(f => f.endsWith('.js'))
57
+ .map(f => f.replace(/\.js$/, ''));
58
+ for (const name of sourceTs) {
59
+ expect(compiledJs).toContain(name);
60
+ }
61
+ });
62
+
63
+ it('validate-yaml.js validates against the shipped schema from node_modules', () => {
64
+ const consumerPkg = join(CONSUMER_DIR, 'node_modules', '@glossarist', 'concept-browser');
65
+ const scriptPath = join(consumerPkg, 'scripts', 'validate-yaml.js');
66
+ const schemaPath = join(consumerPkg, 'data', 'concept-model', 'shapes', 'glossarist.concept.yaml.schema.json');
67
+ const code = [
68
+ 'import { validateYaml } from ' + JSON.stringify(scriptPath) + ';',
69
+ 'import { readFileSync } from "fs";',
70
+ 'const schema = JSON.parse(readFileSync(' + JSON.stringify(schemaPath) + ', "utf8"));',
71
+ 'const r = validateYaml("termid: test", schema);',
72
+ 'console.log("valid=" + r.valid);',
73
+ ].join('\n');
74
+ const result = spawnSync('node', ['--input-type=module', '-e', code], {
75
+ encoding: 'utf8', cwd: ROOT,
76
+ });
77
+ expect(result.status).toBe(0);
78
+ expect(result.stdout).toContain('valid=true');
79
+ });
80
+
81
+ it('no compiled script references a devDependency-only package', () => {
82
+ const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
83
+ const deps = new Set(Object.keys(pkg.dependencies || {}));
84
+ const devDeps = new Set(Object.keys(pkg.devDependencies || {}));
85
+ const violations: string[] = [];
86
+ for (const file of readdirSync(SCRIPTS_DIR).filter(f => f.endsWith('.js'))) {
87
+ const src = readFileSync(join(SCRIPTS_DIR, file), 'utf8');
88
+ const importRe = new RegExp('(?:from\\s+|import\\s*\\(\\s*)["\']([^"\'./@][^"\']*)["\']', 'g');
89
+ let m;
90
+ while ((m = importRe.exec(src)) !== null) {
91
+ const name = m[1].startsWith('@') ? m[1].split('/').slice(0, 2).join('/') : m[1].split('/')[0];
92
+ if (name.startsWith('node:') || name === 'node') continue;
93
+ if (devDeps.has(name) && !deps.has(name)) violations.push(file + ' -> ' + name);
94
+ }
95
+ }
96
+ expect(violations).toEqual([]);
97
+ });
98
+ });
@@ -97,4 +97,14 @@ describe('published-package contract — compiled .js scripts load under plain n
97
97
  }
98
98
  }
99
99
  });
100
+
101
+ it('compiled scripts can find package data files (glossarist-vocab.json path regression)', () => {
102
+ // When esbuild bundles scripts/lib/vocab-turtle.ts into scripts/generate-data.js,
103
+ // __dirname changes from scripts/lib/ to scripts/. The path to data/ must
104
+ // resolve correctly in both modes. This test verifies the fallback logic.
105
+ const dataDir = join(ROOT, 'data');
106
+ expect(existsSync(join(dataDir, 'glossarist-vocab.json'))).toBe(true);
107
+ expect(existsSync(join(dataDir, 'colors.json'))).toBe(true);
108
+ expect(existsSync(join(dataDir, 'concept-model', 'shapes', 'glossarist.shacl.ttl'))).toBe(true);
109
+ });
100
110
  });
@@ -367,6 +367,13 @@ function mapRelatedFromJsonLd(r: JsonLdRelated): Record<string, unknown> {
367
367
  result.type = r[GL.RELATIONSHIP_TYPE];
368
368
  }
369
369
 
370
+ // gl:target is the resolved concept URI for cross-dataset relations
371
+ // (e.g., superseded_by → cie-2020 concept). Without this, the UI
372
+ // renders related concepts as plain text with no clickable link.
373
+ if (r[GL.TARGET]) {
374
+ result.target = r[GL.TARGET];
375
+ }
376
+
370
377
  if (r[GL.REF]) {
371
378
  const ref = mapRefFromJsonLd(r[GL.REF]);
372
379
  if (ref) result.ref = ref;