@glossarist/concept-browser 0.7.34 → 0.7.37

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.
Files changed (37) hide show
  1. package/package.json +2 -2
  2. package/scripts/build-edges.js +16 -8
  3. package/scripts/generate-data.mjs +284 -86
  4. package/src/__tests__/citation-display.test.ts +165 -3
  5. package/src/__tests__/cite-ref.test.ts +112 -0
  6. package/src/__tests__/concept-detail-interaction.test.ts +1 -5
  7. package/src/__tests__/{math.test.ts → content-renderer.test.ts} +113 -29
  8. package/src/__tests__/escape.test.ts +76 -0
  9. package/src/__tests__/graph-data-source.test.ts +155 -0
  10. package/src/__tests__/model-bridge-bridges.test.ts +150 -0
  11. package/src/__tests__/model-bridge-citation.test.ts +163 -0
  12. package/src/__tests__/reference-resolver-cite.test.ts +122 -0
  13. package/src/__tests__/reference-resolver.test.ts +12 -7
  14. package/src/__tests__/resolve-view.test.ts +1 -1
  15. package/src/__tests__/sidebar-nav-highlighting.test.ts +178 -0
  16. package/src/__tests__/source-refs.test.ts +9 -6
  17. package/src/__tests__/test-helpers.ts +20 -0
  18. package/src/__tests__/uri-router.test.ts +39 -12
  19. package/src/adapters/DatasetAdapter.ts +35 -143
  20. package/src/adapters/GraphDataSource.ts +178 -0
  21. package/src/adapters/ReferenceResolver.ts +101 -47
  22. package/src/adapters/UriRouter.ts +82 -10
  23. package/src/adapters/factory.ts +35 -28
  24. package/src/adapters/model-bridge.ts +121 -71
  25. package/src/adapters/types.ts +3 -0
  26. package/src/components/AppSidebar.vue +7 -4
  27. package/src/components/CitationDisplay.vue +86 -30
  28. package/src/components/ConceptDetail.vue +24 -126
  29. package/src/components/LanguageDetail.vue +6 -6
  30. package/src/composables/use-concept-content.ts +8 -8
  31. package/src/composables/use-ontology-nav.ts +129 -130
  32. package/src/composables/use-render-options.ts +1 -1
  33. package/src/graph/GraphEngine.ts +65 -0
  34. package/src/stores/vocabulary.ts +12 -73
  35. package/src/utils/content-renderer.ts +312 -0
  36. package/src/utils/markdown-lite.ts +2 -2
  37. package/src/utils/math.ts +0 -189
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.34",
3
+ "version": "0.7.37",
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": {
@@ -25,7 +25,7 @@
25
25
  "autoprefixer": "^10.4.21",
26
26
  "d3": "^7.9.0",
27
27
  "favicons": "^7.2.0",
28
- "glossarist": "^0.3.3",
28
+ "glossarist": "^0.3.7",
29
29
  "js-yaml": "^4.1.0",
30
30
  "pinia": "^2.3.1",
31
31
  "postcss": "^8.5.3",
@@ -31,14 +31,16 @@ function extractReferences(concept, registerId) {
31
31
  if (lc['gl:references']) {
32
32
  for (const ref of lc['gl:references']) {
33
33
  if (ref['@id'] && ref['@id'] !== sourceUri) {
34
- edges.push({
34
+ const edge = {
35
35
  source: sourceUri,
36
36
  target: ref['@id'],
37
- type: 'references',
37
+ type: ref['@id'].startsWith('cite:') ? 'citation' : 'references',
38
38
  label: ref['gl:term'] || undefined,
39
39
  register: registerId,
40
40
  lang,
41
- });
41
+ };
42
+ if (ref['gl:sourceId']) edge.sourceId = ref['gl:sourceId'];
43
+ edges.push(edge);
42
44
  }
43
45
  }
44
46
  }
@@ -263,7 +265,7 @@ const datasets = readdirSync(DATA_DIR).filter(f => {
263
265
  }
264
266
  });
265
267
 
266
- // Build URN→datasetId map from all manifests
268
+ // Build URI→datasetId prefix map from all manifests
267
269
  const urnMap = new Map();
268
270
  const manifestCache = new Map();
269
271
  for (const ds of datasets) {
@@ -271,14 +273,17 @@ for (const ds of datasets) {
271
273
  try {
272
274
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
273
275
  manifestCache.set(ds, manifest);
274
- if (manifest.datasetUri) urnMap.set(manifest.datasetUri, ds);
276
+ if (manifest.datasetUri) {
277
+ const base = manifest.datasetUri.endsWith('*') ? manifest.datasetUri.slice(0, -1) : manifest.datasetUri;
278
+ if (base) urnMap.set(base, ds);
279
+ }
275
280
  for (const alias of manifest.uriAliases ?? []) {
276
281
  const base = alias.endsWith('*') ? alias.slice(0, -1) : alias;
277
- if (base.startsWith('urn:')) urnMap.set(base, ds);
282
+ if (base) urnMap.set(base, ds);
278
283
  }
279
284
  } catch {}
280
285
  }
281
- console.log(`URN resolution map: ${[...urnMap.entries()].map(([k,v]) => `${k}→${v}`).join(', ')}\n`);
286
+ console.log(`URI resolution map: ${[...urnMap.entries()].map(([k,v]) => `${k}→${v}`).join(', ')}\n`);
282
287
 
283
288
  const allDatasetEdges = new Map();
284
289
  const allSourceRefs = [];
@@ -310,7 +315,10 @@ for (const [ds, manifest] of manifestCache) {
310
315
  for (const alias of manifest.refAliases ?? []) {
311
316
  knownSourceStrings.add(alias);
312
317
  }
313
- if (manifest.datasetUri) knownSourceStrings.add(manifest.datasetUri);
318
+ if (manifest.datasetUri) {
319
+ const base = manifest.datasetUri.endsWith('*') ? manifest.datasetUri.slice(0, -1) : manifest.datasetUri;
320
+ knownSourceStrings.add(base);
321
+ }
314
322
  for (const alias of manifest.uriAliases ?? []) {
315
323
  const base = alias.endsWith('*') ? alias.slice(0, -1) : alias;
316
324
  knownSourceStrings.add(base);
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import yaml from 'js-yaml';
4
- import { naturalSort, Register } from 'glossarist';
4
+ import { naturalSort, Register, parseMention } from 'glossarist';
5
5
  import { loadSiteConfig } from './load-site-config.mjs';
6
6
 
7
7
  const __dirname = path.dirname(new URL(import.meta.url).pathname);
@@ -19,6 +19,11 @@ function readYaml(filePath) {
19
19
  return yaml.load(fs.readFileSync(filePath, 'utf8'));
20
20
  }
21
21
 
22
+ /** Strip HTML tags and normalize whitespace for plain-text display. */
23
+ function stripHtml(s) {
24
+ return s.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
25
+ }
26
+
22
27
  function loadConceptFile(filePath) {
23
28
  const content = fs.readFileSync(filePath, 'utf8');
24
29
  const docs = yaml.loadAll(content, null, { schema: yaml.DEFAULT_SCHEMA });
@@ -136,36 +141,43 @@ function defsToJsonLd(defs) {
136
141
  .filter(d => d['gl:content']);
137
142
  }
138
143
 
144
+ function refToJsonLd(ref) {
145
+ if (!ref) return undefined;
146
+ const refObj = { '@type': 'gl:Ref' };
147
+ if (typeof ref === 'string') {
148
+ refObj['gl:source'] = ref;
149
+ } else {
150
+ if (ref.source) refObj['gl:source'] = ref.source;
151
+ if (ref.id) refObj['gl:id'] = ref.id;
152
+ if (ref.version) refObj['gl:version'] = ref.version;
153
+ }
154
+ return refObj;
155
+ }
156
+
157
+ function localityToJsonLd(loc) {
158
+ if (!loc) return undefined;
159
+ const locObj = {};
160
+ if (loc.type) locObj['gl:localityType'] = loc.type;
161
+ if (loc.reference_from) locObj['gl:referenceFrom'] = loc.reference_from;
162
+ if (loc.referenceFrom) locObj['gl:referenceFrom'] = loc.referenceFrom;
163
+ if (loc.reference_to) locObj['gl:referenceTo'] = loc.reference_to;
164
+ if (loc.referenceTo) locObj['gl:referenceTo'] = loc.referenceTo;
165
+ return Object.keys(locObj).length > 0 ? locObj : undefined;
166
+ }
167
+
139
168
  function sourcesToJsonLd(sources) {
140
169
  if (!sources || !Array.isArray(sources)) return [];
141
170
  return sources.map(s => {
142
171
  const doc = { '@type': 'gl:ConceptSource' };
172
+ if (s.id) doc['gl:id'] = s.id;
143
173
  if (s.type) doc['gl:sourceType'] = s.type;
144
174
  if (s.status) doc['gl:sourceStatus'] = s.status;
145
175
  if (s.origin) {
146
176
  const origin = { '@type': 'gl:Citation' };
147
- if (s.origin.ref) {
148
- const ref = s.origin.ref;
149
- const refObj = { '@type': 'gl:Ref' };
150
- if (typeof ref === 'string') {
151
- refObj['gl:source'] = ref;
152
- } else {
153
- if (ref.source) refObj['gl:source'] = ref.source;
154
- if (ref.id) refObj['gl:id'] = ref.id;
155
- if (ref.version) refObj['gl:version'] = ref.version;
156
- }
157
- origin['gl:ref'] = refObj;
158
- }
159
- if (s.origin.locality) {
160
- const loc = s.origin.locality;
161
- const locObj = {};
162
- if (loc.type) locObj['gl:localityType'] = loc.type;
163
- if (loc.reference_from) locObj['gl:referenceFrom'] = loc.reference_from;
164
- if (loc.referenceFrom) locObj['gl:referenceFrom'] = loc.referenceFrom;
165
- if (loc.reference_to) locObj['gl:referenceTo'] = loc.reference_to;
166
- if (loc.referenceTo) locObj['gl:referenceTo'] = loc.referenceTo;
167
- origin['gl:locality'] = locObj;
168
- }
177
+ const ref = refToJsonLd(s.origin.ref);
178
+ if (ref) origin['gl:ref'] = ref;
179
+ const loc = localityToJsonLd(s.origin.locality);
180
+ if (loc) origin['gl:locality'] = loc;
169
181
  if (s.origin.link) origin['gl:link'] = s.origin.link;
170
182
  doc['gl:origin'] = origin;
171
183
  }
@@ -176,7 +188,12 @@ function sourcesToJsonLd(sources) {
176
188
  function refsToJsonLd(refs, refMaps) {
177
189
  if (!refs || !Array.isArray(refs)) return [];
178
190
  return refs.map(r => {
179
- if (r.id) return { '@id': r.id, 'gl:term': r.term };
191
+ if (r.id) {
192
+ const ref = { '@id': r.id, 'gl:term': r.term };
193
+ if (r.sourceId) ref['gl:sourceId'] = r.sourceId;
194
+ if (r.citation) ref['gl:citation'] = citationToJsonLd(r.citation);
195
+ return ref;
196
+ }
180
197
  if (r.term && refMaps) {
181
198
  const uri = resolveRefUri(r.term, refMaps);
182
199
  if (uri) return { '@id': uri, 'gl:term': r.term };
@@ -185,34 +202,70 @@ function refsToJsonLd(refs, refMaps) {
185
202
  }).filter(r => r['@id']);
186
203
  }
187
204
 
188
- function resolveRefUri(term, refMaps) {
189
- const base = refMaps.uriBase;
190
- const urnPrefix = 'urn:iso:std:iso:';
191
- if (term.startsWith(urnPrefix)) {
192
- const rest = term.slice(urnPrefix.length);
193
- const match = rest.match(/^(\d+):(.+)$/);
194
- if (match) {
195
- const dsId = refMaps.urnStandardMap[match[1]];
196
- if (dsId) return `${base}/${dsId}/concept/${match[2]}`;
205
+ function citationToJsonLd(citation) {
206
+ const obj = {};
207
+ const ref = refToJsonLd(citation.ref);
208
+ if (ref) obj['gl:ref'] = ref;
209
+ const loc = localityToJsonLd(citation.locality);
210
+ if (loc) obj['gl:locality'] = loc;
211
+ if (citation.link) obj['gl:link'] = citation.link;
212
+ return obj;
213
+ }
214
+
215
+ function buildPatternIndex(datasets, registerCache) {
216
+ const entries = [];
217
+
218
+ for (const ds of datasets) {
219
+ const reg = registerCache[ds.id] || null;
220
+ const patterns = new Set();
221
+
222
+ // Site-config patterns (primary)
223
+ if (ds.uri) patterns.add(ds.uri);
224
+ for (const alias of ds.uriAliases || []) patterns.add(alias);
225
+
226
+ // Register.yaml patterns (supplementary)
227
+ if (reg) {
228
+ if (reg.urn && reg.urn.endsWith('*')) patterns.add(reg.urn);
229
+ for (const alias of reg.urnAliases || []) patterns.add(alias);
197
230
  }
231
+
232
+ for (const pattern of patterns) {
233
+ if (!pattern.endsWith('*')) continue;
234
+ const prefix = pattern.slice(0, -1);
235
+ if (prefix) entries.push({ prefix, datasetId: ds.id });
236
+ }
237
+ }
238
+
239
+ // Sort longest prefix first for correct longest-prefix matching
240
+ entries.sort((a, b) => b.prefix.length - a.prefix.length);
241
+
242
+ function resolve(uri) {
243
+ for (const { prefix, datasetId } of entries) {
244
+ if (uri.startsWith(prefix)) {
245
+ return { datasetId, conceptId: uri.slice(prefix.length) };
246
+ }
247
+ }
248
+ return null;
198
249
  }
250
+
251
+ return { resolve, entries };
252
+ }
253
+
254
+ function resolveRefUri(term, refMaps) {
255
+ const resolved = refMaps.patternIndex.resolve(term);
256
+ if (resolved) return `${refMaps.uriBase}/${resolved.datasetId}/concept/${resolved.conceptId}`;
257
+
199
258
  const ievMatch = term.match(/^IEV:(\d+[-\d]+)$/);
200
259
  if (ievMatch) {
201
260
  const dsId = refMaps.refPrefixMap['IEV'];
202
- if (dsId) return `${base}/${dsId}/concept/${ievMatch[1]}`;
261
+ if (dsId) return `${refMaps.uriBase}/${dsId}/concept/${ievMatch[1]}`;
203
262
  }
204
263
  return null;
205
264
  }
206
265
 
207
- function buildRefMaps(config) {
266
+ function buildRefMaps(config, registerCache) {
208
267
  const refPrefixMap = {};
209
- const urnStandardMap = {};
210
-
211
- for (const ds of config.datasets) {
212
- const uri = ds.uri || '';
213
- const urnMatch = uri.match(/^urn:iso:std:iso:(\d+):\*$/);
214
- if (urnMatch) urnStandardMap[urnMatch[1]] = ds.id;
215
- }
268
+ const patternIndex = buildPatternIndex(config.datasets, registerCache);
216
269
 
217
270
  for (const route of config.routing || []) {
218
271
  if (route.uri && route.uri.includes('iec') && route.uri.includes('60050')) {
@@ -223,56 +276,172 @@ function buildRefMaps(config) {
223
276
 
224
277
  const xref = config.crossReferences || {};
225
278
  if (xref.refPrefixMap) Object.assign(refPrefixMap, xref.refPrefixMap);
226
- if (xref.urnStandardMap) Object.assign(urnStandardMap, xref.urnStandardMap);
227
279
 
228
280
  const uriBase = config.uriBase || `https://${config.domain}`;
229
- return { refPrefixMap, urnStandardMap, uriBase, register: null };
281
+ return { patternIndex, refPrefixMap, uriBase, register: null };
230
282
  }
231
283
 
232
- function extractInlineRefs(localizedData, refMaps) {
233
- const refs = [];
234
- const texts = [];
235
- const { refPrefixMap, urnStandardMap, uriBase } = refMaps;
236
284
 
237
- if (localizedData.definition) {
238
- const defs = Array.isArray(localizedData.definition) ? localizedData.definition : [localizedData.definition];
239
- for (const d of defs) texts.push(typeof d === 'string' ? d : (d.content || ''));
240
- }
241
- if (localizedData.notes) {
242
- for (const n of localizedData.notes) texts.push(typeof n === 'string' ? n : (n.content || ''));
243
- }
244
- if (localizedData.examples) {
245
- for (const e of localizedData.examples) texts.push(typeof e === 'string' ? e : (e.content || ''));
285
+ // ── Mention handlers (OCP: add new mention kinds by adding handlers) ─────
286
+
287
+ /**
288
+ * Resolve an IEV:NNN-NN-NN display form to a concept URI.
289
+ */
290
+ function resolveIevRef(display, term, refPrefixMap, uriBase) {
291
+ if (!display.startsWith('IEV:')) return null;
292
+ const datasetId = refPrefixMap['IEV'];
293
+ if (!datasetId) return null;
294
+ return { id: `${uriBase}/${datasetId}/concept/${display.slice(4)}`, term };
295
+ }
296
+
297
+ /**
298
+ * Resolve a URI identifier via the pattern index.
299
+ */
300
+ function resolvePatternRef(identifier, display, refPrefixMap, patternIndex, uriBase) {
301
+ const r = patternIndex.resolve(identifier);
302
+ if (!r) return null;
303
+ return { id: `${uriBase}/${r.datasetId}/concept/${r.conceptId}`, term: display };
304
+ }
305
+
306
+ /**
307
+ * Handle a cite-ref mention: links to a ConceptSource entry.
308
+ */
309
+ function handleCiteRef(parsed, allSources) {
310
+ const sourceEntry = allSources.find(s => s.id === parsed.key);
311
+ if (sourceEntry) {
312
+ return {
313
+ id: `cite:${sourceEntry.id}`,
314
+ term: parsed.label || sourceEntry.origin?.toString?.() || sourceEntry.id,
315
+ sourceId: sourceEntry.id,
316
+ citation: sourceEntry.origin || null,
317
+ };
246
318
  }
247
- const fullText = texts.join(' ');
319
+ return {
320
+ id: `cite:${parsed.key}`,
321
+ term: parsed.label || parsed.key,
322
+ sourceId: parsed.key,
323
+ citation: null,
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Handle a numeric mention: bare concept ID in same dataset.
329
+ */
330
+ function handleNumeric(parsed, register, uriBase) {
331
+ if (!register) return null;
332
+ const term = parsed.label ?? parsed.id;
333
+ return { id: `${uriBase}/${register}/concept/${parsed.id}`, term };
334
+ }
335
+
336
+ /**
337
+ * Handle a designation mention (glossarist >= 0.3.7):
338
+ * {{designation,render term}} — resolve designation to concept ID in same dataset.
339
+ * Falls back to the raw designation as the concept ID if no lookup table exists.
340
+ */
341
+ function handleDesignation(parsed, refMaps) {
342
+ const register = refMaps.register;
343
+ if (!register) return null;
344
+ const designation = parsed.id;
345
+ const display = parsed.label ?? designation;
346
+ const conceptId = refMaps.designationLookup?.get(designation.toLowerCase());
347
+ return {
348
+ id: `${refMaps.uriBase}/${register}/concept/${conceptId ?? designation}`,
349
+ term: display,
350
+ };
351
+ }
248
352
 
249
- for (const m of fullText.matchAll(/\{\{([^,}]+),\s*IEV:([^}]+)\}\}/g)) {
250
- const datasetId = refPrefixMap['IEV'];
251
- if (datasetId) refs.push({ id: `${uriBase}/${datasetId}/concept/${m[2]}`, term: m[1].trim() });
353
+ /**
354
+ * Handle an unresolved double-brace mention: try two-arg form.
355
+ * Format: {{conceptId, displayTerm}} — concept ID first, render term last.
356
+ */
357
+ function handleUnresolved(body, refMaps) {
358
+ const commaMatch = body.match(/^([^,}]+),\s*(.+)$/);
359
+ if (!commaMatch) return null;
360
+ const identifier = commaMatch[1].trim();
361
+ const display = commaMatch[2].trim();
362
+
363
+ // IEV shortform: {{IEV:shortform, display_term}}
364
+ const iev = resolveIevRef(identifier, display, refMaps.refPrefixMap, refMaps.uriBase);
365
+ if (iev) return iev;
366
+
367
+ // URI pattern match
368
+ const pattern = resolvePatternRef(identifier, display, refMaps.refPrefixMap, refMaps.patternIndex, refMaps.uriBase);
369
+ if (pattern) return pattern;
370
+
371
+ // Same-dataset: {{conceptId, displayTerm}} where conceptId is numeric/X.Y
372
+ const register = refMaps.register;
373
+ if (register && (/^\d/.test(identifier) || /^[A-Z]\.\d/.test(identifier))) {
374
+ return { id: `${refMaps.uriBase}/${register}/concept/${identifier}`, term: display };
252
375
  }
376
+ return null;
377
+ }
253
378
 
254
- for (const m of fullText.matchAll(/\{urn:iso:std:iso:(\d+):([^,}]+),([^,}]+)(?:,([^}]+))?\}/g)) {
255
- const datasetId = urnStandardMap[m[1]];
256
- if (datasetId) refs.push({ id: `${uriBase}/${datasetId}/concept/${m[2]}`, term: (m[4] || m[3]).trim() });
379
+ // ── Inline reference extraction ───────────────────────────────────────────
380
+
381
+ function collectTextContent(localizedData) {
382
+ const texts = [];
383
+ const textFields = ['definition', 'notes', 'examples'];
384
+ for (const field of textFields) {
385
+ const items = localizedData[field];
386
+ if (!items) continue;
387
+ const arr = Array.isArray(items) ? items : [items];
388
+ for (const item of arr) {
389
+ texts.push(typeof item === 'string' ? item : (item.content || ''));
390
+ }
257
391
  }
392
+ return texts.join(' ');
393
+ }
258
394
 
259
- for (const m of fullText.matchAll(/\{\{urn:iso:std:iso:(\d+):([^,}]+),([^,}]+)(?:,([^}]+))?\}\}/g)) {
260
- const datasetId = urnStandardMap[m[1]];
261
- if (datasetId) refs.push({ id: `${uriBase}/${datasetId}/concept/${m[2]}`, term: (m[4] || m[3]).trim() });
395
+ function extractInlineRefs(localizedData, refMaps, conceptSources = []) {
396
+ const refs = [];
397
+ const { refPrefixMap, patternIndex, uriBase } = refMaps;
398
+ const fullText = collectTextContent(localizedData);
399
+ const allSources = [...(localizedData.sources || []), ...conceptSources];
400
+
401
+ // Single-brace mentions: {uri,display} (not {{...}})
402
+ for (const m of fullText.matchAll(/\{([^,}]+),([^,}]+)(?:,([^}]+))?\}/g)) {
403
+ const identifier = m[1].trim();
404
+ const display = m[2].trim();
405
+ const altDisplay = (m[3] || '').trim();
406
+ if (!identifier || !display) continue;
407
+
408
+ const iev = resolveIevRef(display, identifier, refPrefixMap, uriBase);
409
+ if (iev) { refs.push(iev); continue; }
410
+
411
+ const pattern = resolvePatternRef(identifier, altDisplay || display, refPrefixMap, patternIndex, uriBase);
412
+ if (pattern) { refs.push(pattern); }
262
413
  }
263
414
 
264
- // Generic {{term, concept_id}} same-dataset cross-reference (e.g. VIML)
265
- const register = refMaps.register;
266
- for (const m of fullText.matchAll(/\{\{([^,}]+),\s*([A-Za-z0-9.]+)\}\}/g)) {
267
- const termName = m[1].trim();
268
- const conceptId = m[2].trim();
269
- // Skip if already matched by IEV or URN patterns
270
- if (refPrefixMap && refPrefixMap[termName]) continue;
271
- if (/^\d/.test(conceptId) || /^[A-Z]\.\d/.test(conceptId)) {
272
- refs.push({ id: `${uriBase}/${register}/concept/${conceptId}`, term: termName });
415
+ // Double-brace mentions: dispatched by parseMention kind
416
+ for (const m of fullText.matchAll(/\{\{([^{}]+?)\}\}/g)) {
417
+ const body = m[1];
418
+ const parsed = parseMention(body);
419
+
420
+ let ref = null;
421
+ if (parsed.kind === 'cite-ref') {
422
+ ref = handleCiteRef(parsed, allSources);
423
+ } else if (parsed.kind === 'numeric') {
424
+ ref = handleNumeric(parsed, refMaps.register, uriBase);
425
+ } else if (parsed.kind === 'urn-ref') {
426
+ // {{urn:...,render term}} — resolve URN via pattern index (cross-dataset)
427
+ const uri = parsed.uri;
428
+ const term = parsed.label ?? uri;
429
+ const pattern = refMaps.patternIndex.resolve(uri);
430
+ if (pattern) {
431
+ ref = { id: `${refMaps.uriBase}/${pattern.datasetId}/concept/${pattern.conceptId}`, term };
432
+ } else {
433
+ ref = { id: uri, term };
434
+ }
435
+ } else if (parsed.kind === 'designation') {
436
+ // {{designation,render term}} — same-dataset designation reference
437
+ ref = handleDesignation(parsed, refMaps);
438
+ } else {
439
+ ref = handleUnresolved(body, refMaps);
273
440
  }
441
+ if (ref) refs.push(ref);
274
442
  }
275
443
 
444
+ // Deduplicate by id
276
445
  const seen = new Set();
277
446
  return refs.filter(r => {
278
447
  if (seen.has(r.id)) return false;
@@ -333,7 +502,7 @@ function yamlToJsonLd(conceptYaml, register, refMaps) {
333
502
  if (lc.references && lc.references.length > 0) {
334
503
  lDoc['gl:references'] = refsToJsonLd(lc.references, refMaps);
335
504
  } else if (refMaps) {
336
- const inlineRefs = extractInlineRefs(lc, refMaps);
505
+ const inlineRefs = extractInlineRefs(lc, refMaps, conceptYaml._sources);
337
506
  if (inlineRefs.length > 0) {
338
507
  lDoc['gl:references'] = refsToJsonLd(inlineRefs, refMaps);
339
508
  }
@@ -632,7 +801,28 @@ function processDataset(dir, register, opts) {
632
801
  const langTermCounts = {};
633
802
  const langDefCounts = {};
634
803
  const availableFormats = ['ttl', 'jsonld', 'yaml', 'tbx'];
635
- const dsRefMaps = { ...refMaps, register };
804
+
805
+ // Pre-scan: build designation → concept ID lookup for same-dataset designation refs
806
+ const designationLookup = new Map();
807
+ for (const file of files) {
808
+ try {
809
+ const conceptYaml = loadConceptFile(path.join(dir, file));
810
+ if (!conceptYaml?.termid) continue;
811
+ const termid = String(conceptYaml.termid);
812
+ for (const lang of Object.keys(conceptYaml)) {
813
+ const lc = conceptYaml[lang];
814
+ if (!lc || typeof lc !== 'object' || !Array.isArray(lc.terms)) continue;
815
+ for (const term of lc.terms) {
816
+ const designation = term.designation;
817
+ if (typeof designation === 'string' && designation && !designationLookup.has(designation.toLowerCase())) {
818
+ designationLookup.set(designation.toLowerCase(), termid);
819
+ }
820
+ }
821
+ }
822
+ } catch {}
823
+ }
824
+
825
+ const dsRefMaps = { ...refMaps, register, designationLookup };
636
826
 
637
827
  for (let i = 0; i < files.length; i++) {
638
828
  const file = files[i];
@@ -707,15 +897,22 @@ function processDataset(dir, register, opts) {
707
897
  }));
708
898
 
709
899
  // Strip HTML from index summary for text display
710
- const plainSummary = summary.map(c => ({
711
- ...c,
712
- eng: c.eng.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(),
713
- }));
900
+ const plainSummary = summary.map(c => {
901
+ const designations = {};
902
+ for (const [lang, term] of Object.entries(c.designations)) {
903
+ if (term) designations[lang] = stripHtml(term);
904
+ }
905
+ return {
906
+ ...c,
907
+ designations,
908
+ eng: stripHtml(c.eng),
909
+ };
910
+ });
714
911
 
715
912
  const graphNodeEntries = concepts.map(c => {
716
913
  const cleanDesignations = {};
717
914
  for (const [l, t] of Object.entries(c.designations)) {
718
- if (t) cleanDesignations[l] = t.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
915
+ if (t) cleanDesignations[l] = stripHtml(t);
719
916
  }
720
917
  return [c.id, cleanDesignations, c.status];
721
918
  });
@@ -845,12 +1042,11 @@ function processDataset(dir, register, opts) {
845
1042
  console.log('Generating Glossarist vocabulary browser data...\n');
846
1043
 
847
1044
  const { config } = loadSiteConfig();
848
- const refMaps = buildRefMaps(config);
849
1045
  const counts = {};
850
1046
  const registry = [];
851
1047
  const registerCache = {};
852
1048
 
853
- // Pre-load all register.yaml files
1049
+ // Pre-load all register.yaml files (needed before buildRefMaps for URI pattern indexing)
854
1050
  for (const ds of config.datasets) {
855
1051
  const registerDir = path.join(ROOT, '.datasets', ds.id);
856
1052
  const registerYamlPath = path.join(registerDir, 'register.yaml');
@@ -864,6 +1060,8 @@ for (const ds of config.datasets) {
864
1060
  }
865
1061
  }
866
1062
 
1063
+ const refMaps = buildRefMaps(config, registerCache);
1064
+
867
1065
  for (let i = 0; i < config.datasets.length; i++) {
868
1066
  const ds = config.datasets[i];
869
1067
 
@@ -937,8 +1135,8 @@ writeJson(path.join(PUBLIC, 'datasets.json'), registry);
937
1135
  const configuredIds = new Set(config.datasets.map(d => d.id));
938
1136
  if (fs.existsSync(DATA)) {
939
1137
  for (const entry of fs.readdirSync(DATA)) {
940
- if (!configuredIds.has(entry)) {
941
1138
  const stalePath = path.join(DATA, entry);
1139
+ if (!configuredIds.has(entry) && fs.statSync(stalePath).isDirectory()) {
942
1140
  fs.rmSync(stalePath, { recursive: true, force: true });
943
1141
  console.log(` Removed stale data directory: ${entry}`);
944
1142
  }