@glossarist/concept-browser 0.3.4 → 0.4.0

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 (81) hide show
  1. package/README.md +3 -2
  2. package/cli/index.mjs +2 -1
  3. package/env.d.ts +5 -0
  4. package/package.json +4 -3
  5. package/scripts/build-edges.js +78 -10
  6. package/scripts/generate-data.mjs +152 -20
  7. package/scripts/generate-ontology-data.mjs +184 -0
  8. package/scripts/generate-ontology-schema.mjs +315 -0
  9. package/src/__tests__/about-view.test.ts +98 -0
  10. package/src/__tests__/app-footer.test.ts +38 -0
  11. package/src/__tests__/app-header.test.ts +130 -0
  12. package/src/__tests__/app-sidebar.test.ts +159 -0
  13. package/src/__tests__/asciidoc-lite.test.ts +1 -1
  14. package/src/__tests__/concept-card.test.ts +115 -0
  15. package/src/__tests__/concept-detail-interaction.test.ts +273 -0
  16. package/src/__tests__/concept-formats.test.ts +32 -30
  17. package/src/__tests__/concept-timeline.test.ts +200 -0
  18. package/src/__tests__/concept-view.test.ts +88 -0
  19. package/src/__tests__/contributors-view.test.ts +103 -0
  20. package/src/__tests__/dataset-adapter.test.ts +172 -23
  21. package/src/__tests__/dataset-view.test.ts +232 -0
  22. package/src/__tests__/designation-registry.test.ts +161 -0
  23. package/src/__tests__/format-downloads.test.ts +98 -0
  24. package/src/__tests__/graph-view.test.ts +69 -0
  25. package/src/__tests__/graph.test.ts +62 -0
  26. package/src/__tests__/home-interaction.test.ts +157 -0
  27. package/src/__tests__/language-detail.test.ts +203 -0
  28. package/src/__tests__/nav-icon.test.ts +48 -0
  29. package/src/__tests__/news-view.test.ts +87 -0
  30. package/src/__tests__/ontology-registry.test.ts +109 -0
  31. package/src/__tests__/page-view.test.ts +83 -0
  32. package/src/__tests__/relationship-categories.test.ts +62 -0
  33. package/src/__tests__/resolve-view.test.ts +77 -0
  34. package/src/__tests__/router.test.ts +65 -0
  35. package/src/__tests__/search-bar.test.ts +219 -0
  36. package/src/__tests__/search-view.test.ts +41 -0
  37. package/src/__tests__/stats-view.test.ts +77 -0
  38. package/src/__tests__/test-helpers.ts +171 -0
  39. package/src/__tests__/ui-store.test.ts +100 -0
  40. package/src/__tests__/v-math.test.ts +8 -7
  41. package/src/adapters/DatasetAdapter.ts +188 -63
  42. package/src/adapters/model-bridge.ts +277 -0
  43. package/src/adapters/ontology-registry.ts +75 -0
  44. package/src/adapters/ontology-schema.ts +100 -0
  45. package/src/adapters/types.ts +53 -78
  46. package/src/components/AppSidebar.vue +1 -1
  47. package/src/components/CitationDisplay.vue +35 -0
  48. package/src/components/ConceptDetail.vue +349 -146
  49. package/src/components/ConceptRdfView.vue +397 -0
  50. package/src/components/ConceptTimeline.vue +57 -60
  51. package/src/components/GraphPanel.vue +96 -31
  52. package/src/components/LanguageDetail.vue +46 -61
  53. package/src/components/NavIcon.vue +1 -0
  54. package/src/components/NonVerbalRepDisplay.vue +38 -0
  55. package/src/components/RelationshipList.vue +99 -0
  56. package/src/composables/use-render-options.ts +1 -4
  57. package/src/config/use-site-config.ts +3 -0
  58. package/src/data/ontology-schema.json +1551 -0
  59. package/src/data/taxonomies.json +543 -0
  60. package/src/graph/GraphEngine.ts +7 -4
  61. package/src/router/index.ts +6 -1
  62. package/src/shims/empty.ts +1 -0
  63. package/src/shims/node-crypto.ts +6 -0
  64. package/src/shims/node-path.ts +10 -0
  65. package/src/stores/vocabulary.ts +82 -32
  66. package/src/style.css +74 -20
  67. package/src/utils/asciidoc-lite.ts +17 -19
  68. package/src/utils/concept-formats.ts +22 -20
  69. package/src/utils/concept-helpers.ts +54 -0
  70. package/src/utils/designation-registry.ts +124 -0
  71. package/src/utils/escape.ts +7 -0
  72. package/src/utils/markdown-lite.ts +1 -3
  73. package/src/utils/math.ts +2 -11
  74. package/src/utils/plurimath.ts +2 -7
  75. package/src/utils/relationship-categories.ts +84 -0
  76. package/src/views/ConceptView.vue +22 -1
  77. package/src/views/DatasetView.vue +7 -2
  78. package/src/views/OntologySchemaView.vue +302 -0
  79. package/src/views/PageView.vue +28 -17
  80. package/src/views/StatsView.vue +34 -12
  81. package/vite.config.ts +8 -0
@@ -19,6 +19,8 @@ class MockPlurimath {
19
19
  import { vMath } from '../directives/v-math';
20
20
  import { loadPlurimath, mathToHtml } from '../utils/plurimath';
21
21
 
22
+ const directive = vMath as import('vue').ObjectDirective<HTMLElement>;
23
+
22
24
  describe('v-math directive', () => {
23
25
  let container: HTMLElement;
24
26
 
@@ -29,21 +31,20 @@ describe('v-math directive', () => {
29
31
 
30
32
  it('does nothing when no math-pending elements exist', () => {
31
33
  container.innerHTML = '<p>plain text</p>';
32
- vMath.mounted!(container, {} as any);
34
+ directive.mounted!(container, {} as any, {} as any, {} as any);
33
35
  expect(container.innerHTML).toBe('<p>plain text</p>');
34
36
  });
35
37
 
36
38
  it('triggers loadPlurimath when math-pending elements exist', async () => {
37
39
  container.innerHTML = '<span class="math-pending" data-expr="x^2" data-format="asciimath">x^2</span>';
38
- vMath.mounted!(container, {} as any);
40
+ directive.mounted!(container, {} as any, {} as any, {} as any);
39
41
  expect(loadPlurimath).toHaveBeenCalled();
40
42
  });
41
43
 
42
44
  it('replaces math-pending elements after loading', async () => {
43
45
  container.innerHTML = '<span class="math-pending" data-expr="x^2" data-format="asciimath">x^2</span>';
44
- vMath.mounted!(container, {} as any);
46
+ directive.mounted!(container, {} as any, {} as any, {} as any);
45
47
 
46
- // Wait for loadPlurimath promise to resolve and upgrade to run
47
48
  await vi.waitFor(() => {
48
49
  expect(mathToHtml).toHaveBeenCalledWith('x^2', 'asciimath', false);
49
50
  });
@@ -51,7 +52,7 @@ describe('v-math directive', () => {
51
52
 
52
53
  it('handles bold math-pending elements', async () => {
53
54
  container.innerHTML = '<span class="math-pending math-bold" data-expr="alpha" data-format="asciimath">alpha</span>';
54
- vMath.mounted!(container, {} as any);
55
+ directive.mounted!(container, {} as any, {} as any, {} as any);
55
56
 
56
57
  await vi.waitFor(() => {
57
58
  expect(mathToHtml).toHaveBeenCalledWith('alpha', 'asciimath', true);
@@ -60,7 +61,7 @@ describe('v-math directive', () => {
60
61
 
61
62
  it('skips elements without data-expr', async () => {
62
63
  container.innerHTML = '<span class="math-pending">no expr</span>';
63
- vMath.mounted!(container, {} as any);
64
+ directive.mounted!(container, {} as any, {} as any, {} as any);
64
65
 
65
66
  await vi.waitFor(() => {
66
67
  expect(mathToHtml).not.toHaveBeenCalled();
@@ -69,7 +70,7 @@ describe('v-math directive', () => {
69
70
 
70
71
  it('uses default format asciimath when data-format is missing', async () => {
71
72
  container.innerHTML = '<span class="math-pending" data-expr="x">x</span>';
72
- vMath.mounted!(container, {} as any);
73
+ directive.mounted!(container, {} as any, {} as any, {} as any);
73
74
 
74
75
  await vi.waitFor(() => {
75
76
  expect(mathToHtml).toHaveBeenCalledWith('x', 'asciimath', false);
@@ -3,12 +3,29 @@ import type {
3
3
  ConceptIndex,
4
4
  ConceptSummary,
5
5
  ConceptEntry,
6
- ConceptDocument,
7
6
  SearchHit,
8
7
  GraphEdge,
8
+ GraphNode,
9
9
  } from './types';
10
+ import type { Concept, LocalizedConcept, Designation } from 'glossarist';
11
+ import { conceptFromJson, conceptUri } from './model-bridge';
10
12
  import { UriRouter } from './UriRouter';
11
13
 
14
+ function slugify(text: string): string {
15
+ return text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/[\s/]+/g, '-');
16
+ }
17
+
18
+ function resolveRefTarget(rc: any, uriBase: string, registerId: string): string {
19
+ if (!rc.ref) return '';
20
+ const ref = rc.ref;
21
+ if (ref.id) {
22
+ const reg = (ref.source && !ref.source.startsWith('http')) ? ref.source : registerId;
23
+ return `${uriBase}/${reg}/concept/${ref.id}`;
24
+ }
25
+ if (ref.source && ref.source.startsWith('http')) return ref.source;
26
+ return ref.source || '';
27
+ }
28
+
12
29
  export class DatasetAdapter {
13
30
  private positionIndex = new Map<string, number>();
14
31
  readonly registerId: string;
@@ -16,7 +33,7 @@ export class DatasetAdapter {
16
33
  manifest: Manifest | null = null;
17
34
  index: ConceptIndex | null = null;
18
35
 
19
- private conceptCache = new Map<string, ConceptDocument>();
36
+ private conceptCache = new Map<string, Concept>();
20
37
  private summaryMap = new Map<string, ConceptSummary>();
21
38
  private loadedChunks = new Set<number>();
22
39
  private indexMeta: { conceptCount: number; chunkSize: number; chunks: { file: string; count: number }[] } | null = null;
@@ -43,14 +60,53 @@ export class DatasetAdapter {
43
60
 
44
61
  const resp = await fetch(`${this.baseUrl}/index.json`);
45
62
  if (!resp.ok) throw new Error(`Failed to load index for ${this.registerId}: ${resp.status}`);
46
- this.index = (await resp.json()) as ConceptIndex;
63
+ const data = await resp.json();
64
+
65
+ // Handle both old format (with eng/status fields) and new format (with designations map)
66
+ this.index = this.normalizeIndex(data);
67
+ this.buildSummaryIndex();
68
+ return this.index;
69
+ }
70
+
71
+ private normalizeIndex(data: any): ConceptIndex {
72
+ const concepts: ConceptSummary[] = (data.concepts || []).map((c: any) => {
73
+ if (c.designations && typeof c.designations === 'object') {
74
+ return {
75
+ id: c.id,
76
+ designations: c.designations,
77
+ eng: c.eng || c.designations.eng || Object.values(c.designations)[0] || '',
78
+ status: c.status,
79
+ };
80
+ }
81
+ // Legacy format: c.eng is a string, no designations map
82
+ return {
83
+ id: c.id,
84
+ designations: c.eng ? { eng: c.eng } : {},
85
+ eng: c.eng || '',
86
+ status: c.status,
87
+ };
88
+ });
89
+
90
+ return {
91
+ registerId: data.registerId,
92
+ schemaVersion: data.schemaVersion,
93
+ conceptCount: data.conceptCount,
94
+ chunkSize: data.chunkSize,
95
+ chunks: data.chunks || [],
96
+ concepts,
97
+ };
98
+ }
99
+
100
+ private buildSummaryIndex() {
47
101
  this.summaryMap.clear();
48
102
  this.positionIndex.clear();
49
- for (let i = 0; i < this.index.concepts.length; i++) {
50
- this.summaryMap.set(this.index.concepts[i].id, this.index.concepts[i]);
51
- this.positionIndex.set(this.index.concepts[i].id, i);
103
+ for (let i = 0; i < this.index!.concepts.length; i++) {
104
+ const entry = this.index!.concepts[i];
105
+ if (entry) {
106
+ this.summaryMap.set(entry.id, entry);
107
+ this.positionIndex.set(entry.id, i);
108
+ }
52
109
  }
53
- return this.index;
54
110
  }
55
111
 
56
112
  private async loadIndexChunked(): Promise<ConceptIndex> {
@@ -61,13 +117,9 @@ export class DatasetAdapter {
61
117
  } else {
62
118
  const resp = await fetch(`${this.baseUrl}/index.json`);
63
119
  if (!resp.ok) throw new Error(`Failed to load index for ${this.registerId}`);
64
- this.index = (await resp.json()) as ConceptIndex;
65
- this.summaryMap.clear();
66
- this.positionIndex.clear();
67
- for (let i = 0; i < this.index.concepts.length; i++) {
68
- this.summaryMap.set(this.index.concepts[i].id, this.index.concepts[i]);
69
- this.positionIndex.set(this.index.concepts[i].id, i);
70
- }
120
+ const data = await resp.json();
121
+ this.index = this.normalizeIndex(data);
122
+ this.buildSummaryIndex();
71
123
  return this.index;
72
124
  }
73
125
 
@@ -77,7 +129,6 @@ export class DatasetAdapter {
77
129
  chunks: meta.chunks,
78
130
  };
79
131
 
80
- // Pre-allocate array so positions match concept order — undefined = not loaded yet
81
132
  this.index = {
82
133
  registerId: meta.registerId,
83
134
  schemaVersion: meta.schemaVersion,
@@ -88,7 +139,6 @@ export class DatasetAdapter {
88
139
  };
89
140
 
90
141
  await this.loadChunkAsSummaries(0);
91
-
92
142
  return this.index;
93
143
  }
94
144
 
@@ -116,12 +166,14 @@ export class DatasetAdapter {
116
166
 
117
167
  for (let i = 0; i < entries.length; i++) {
118
168
  const entry = entries[i];
169
+ const designations = entry.designations || (entry.groups ? {} : { eng: '' });
119
170
  const summary: ConceptSummary = {
120
171
  id: entry.id,
121
- eng: entry.designations?.eng || Object.values(entry.designations || {})[0] || '',
172
+ designations,
173
+ eng: designations.eng || Object.values(designations)[0] || '',
122
174
  status: entry.status,
123
175
  };
124
- (this.index!.concepts as (ConceptSummary | undefined)[])[startPos + i] = summary;
176
+ this.index!.concepts[startPos + i] = summary;
125
177
  this.summaryMap.set(entry.id, summary);
126
178
  this.positionIndex.set(entry.id, startPos + i);
127
179
  }
@@ -144,7 +196,6 @@ export class DatasetAdapter {
144
196
  if (!this.indexMeta) return;
145
197
  const { chunks } = this.indexMeta;
146
198
  const toLoad = chunks.map((_, i) => i).filter(i => !this.loadedChunks.has(i));
147
- // Load in parallel batches of 5 to avoid overwhelming the browser
148
199
  for (let i = 0; i < toLoad.length; i += 5) {
149
200
  const batch = toLoad.slice(i, i + 5);
150
201
  await Promise.all(batch.map(c => this.loadChunkAsSummaries(c)));
@@ -153,29 +204,30 @@ export class DatasetAdapter {
153
204
 
154
205
  isRangeLoaded(offset: number, limit: number): boolean {
155
206
  if (!this.index?.concepts) return false;
156
- const arr = this.index.concepts as (ConceptSummary | undefined)[];
207
+ const arr = this.index.concepts;
157
208
  for (let i = offset; i < Math.min(offset + limit, arr.length); i++) {
158
209
  if (arr[i] === undefined) return false;
159
210
  }
160
211
  return true;
161
212
  }
162
213
 
163
- async fetchConcept(conceptId: string): Promise<ConceptDocument> {
214
+ async fetchConcept(conceptId: string): Promise<Concept> {
164
215
  const cached = this.conceptCache.get(conceptId);
165
216
  if (cached) return cached;
166
217
 
167
218
  const resp = await fetch(`${this.baseUrl}/concepts/${conceptId}.json`);
168
219
  if (!resp.ok) throw new Error(`Concept ${conceptId} not found in ${this.registerId}`);
169
- const doc = (await resp.json()) as ConceptDocument;
170
- this.conceptCache.set(conceptId, doc);
171
- return doc;
220
+ const json = await resp.json();
221
+ const concept = conceptFromJson(json);
222
+ this.conceptCache.set(conceptId, concept);
223
+ return concept;
172
224
  }
173
225
 
174
226
  getIndexEntry(conceptId: string): ConceptSummary | undefined {
175
227
  return this.summaryMap.get(conceptId);
176
228
  }
177
229
 
178
- getConcepts(): ConceptSummary[] {
230
+ getConcepts(): (ConceptSummary | undefined)[] {
179
231
  return this.index?.concepts ?? [];
180
232
  }
181
233
 
@@ -188,16 +240,14 @@ export class DatasetAdapter {
188
240
  }
189
241
 
190
242
  getAdjacentConcepts(conceptId: string): { prev: string | null; next: string | null } {
191
- const concepts = this.index?.concepts as (ConceptSummary | undefined)[] | undefined;
243
+ const concepts = this.index?.concepts;
192
244
  if (!concepts) return { prev: null, next: null };
193
245
  const idx = this.getConceptPosition(conceptId);
194
246
  if (idx === -1) return { prev: null, next: null };
195
- // Scan backward for prev (skip undefined)
196
247
  let prev: string | null = null;
197
248
  for (let i = idx - 1; i >= 0; i--) {
198
249
  if (concepts[i]) { prev = concepts[i]!.id; break; }
199
250
  }
200
- // Scan forward for next (skip undefined)
201
251
  let next: string | null = null;
202
252
  for (let i = idx + 1; i < concepts.length; i++) {
203
253
  if (concepts[i]) { next = concepts[i]!.id; break; }
@@ -205,53 +255,92 @@ export class DatasetAdapter {
205
255
  return { prev, next };
206
256
  }
207
257
 
208
- search(query: string, lang: string = 'eng'): SearchHit[] {
258
+ search(query: string): SearchHit[] {
209
259
  const q = query.toLowerCase();
210
- const hits: SearchHit[] = [];
211
- const arr = this.index?.concepts as (ConceptSummary | undefined)[] | undefined;
212
- if (!arr) return hits;
260
+ const arr = this.index?.concepts;
261
+ if (!arr) return [];
262
+
263
+ type ScoredHit = SearchHit & { _score: number };
264
+ const scored: ScoredHit[] = [];
213
265
 
214
266
  for (const entry of arr) {
215
267
  if (!entry) continue;
216
- const term = entry.eng || '';
217
- const termMatch = term.toLowerCase().includes(q);
218
- const idMatch = entry.id.toLowerCase().includes(q);
219
- if (termMatch || idMatch) {
220
- const matchField = termMatch ? 'designation' as const : 'id' as const;
221
- let snippet: string | undefined;
222
- if (!termMatch && idMatch) {
223
- snippet = `ID: ${entry.id}`;
224
- }
225
- hits.push({
268
+
269
+ // ID search — exact match highest, then starts with, then contains
270
+ const idLow = entry.id.toLowerCase();
271
+ if (idLow.includes(q)) {
272
+ const score = idLow === q ? 4 : idLow.startsWith(q) ? 3 : 2;
273
+ scored.push({
226
274
  conceptId: entry.id,
227
275
  registerId: this.registerId,
228
- designation: term,
229
- language: lang,
230
- matchField,
231
- snippet,
276
+ designation: entry.eng || '',
277
+ language: '',
278
+ matchField: 'id',
279
+ snippet: `ID: ${entry.id}`,
280
+ _score: score,
232
281
  });
282
+ continue;
283
+ }
284
+
285
+ // Multi-language designation search
286
+ for (const [language, term] of Object.entries(entry.designations)) {
287
+ if (!term) continue;
288
+ const tLow = term.toLowerCase();
289
+ if (tLow.includes(q)) {
290
+ const score = tLow === q ? 4 : tLow.startsWith(q) ? 3 : 1;
291
+ scored.push({
292
+ conceptId: entry.id,
293
+ registerId: this.registerId,
294
+ designation: term,
295
+ language,
296
+ matchField: 'designation',
297
+ _score: score,
298
+ });
299
+ }
233
300
  }
234
301
  }
235
- return hits;
302
+
303
+ // Sort by score descending, then alphabetically
304
+ scored.sort((a, b) => b._score - a._score || a.designation.localeCompare(b.designation));
305
+ return scored;
236
306
  }
237
307
 
238
- extractEdges(concept: ConceptDocument): GraphEdge[] {
308
+ extractEdges(concept: Concept): GraphEdge[] {
239
309
  const edges: GraphEdge[] = [];
240
- const sourceUri = concept['@id'];
241
-
242
- for (const [_lang, lc] of Object.entries(concept['gl:localizedConcept'] || {})) {
243
- if (lc['gl:references']) {
244
- for (const ref of lc['gl:references']) {
245
- if (ref['@id'] && ref['@id'] !== sourceUri) {
246
- const parsed = UriRouter.parseUri(ref['@id']);
247
- edges.push({
248
- source: sourceUri,
249
- target: ref['@id'],
250
- type: 'references',
251
- label: ref['gl:term'],
252
- register: parsed?.registerId ?? this.registerId,
253
- });
254
- }
310
+ const uriBase = this.manifest?.uriBase || 'https://glossarist.org';
311
+ const sourceUri = concept.uri || `${uriBase}/${this.registerId}/concept/${concept.id}`;
312
+
313
+ // Managed concept level relationships
314
+ for (const rc of concept.relatedConcepts) {
315
+ const target = resolveRefTarget(rc, uriBase, this.registerId);
316
+ if (target && target !== sourceUri) {
317
+ const parsed = UriRouter.parseUri(target);
318
+ edges.push({
319
+ source: sourceUri,
320
+ target,
321
+ type: rc.type || 'references',
322
+ label: rc.content || undefined,
323
+ register: parsed?.registerId ?? this.registerId,
324
+ });
325
+ }
326
+ }
327
+
328
+ // Per-localization references (from inline extraction in generate-data)
329
+ for (const lang of concept.languages) {
330
+ const lc = concept.localization(lang);
331
+ if (!lc) continue;
332
+ for (const rc of lc.related) {
333
+ const target = resolveRefTarget(rc, uriBase, this.registerId);
334
+ if (target && target !== sourceUri) {
335
+ const parsed = UriRouter.parseUri(target);
336
+ edges.push({
337
+ source: sourceUri,
338
+ target,
339
+ type: rc.type || 'references',
340
+ label: rc.content || undefined,
341
+ register: parsed?.registerId ?? this.registerId,
342
+ lang,
343
+ });
255
344
  }
256
345
  }
257
346
  }
@@ -259,6 +348,42 @@ export class DatasetAdapter {
259
348
  return edges;
260
349
  }
261
350
 
351
+ extractDomainEdges(concept: Concept): GraphEdge[] {
352
+ const edges: GraphEdge[] = [];
353
+ const uriBase = this.manifest?.uriBase || 'https://glossarist.org';
354
+ const sourceUri = concept.uri || `${uriBase}/${this.registerId}/concept/${concept.id}`;
355
+
356
+ for (const lang of concept.languages) {
357
+ const lc = concept.localization(lang);
358
+ if (lc?.domain) {
359
+ edges.push({
360
+ source: sourceUri,
361
+ target: `${uriBase}/${this.registerId}/domain/${slugify(lc.domain)}`,
362
+ type: 'domain',
363
+ label: lc.domain,
364
+ register: this.registerId,
365
+ lang,
366
+ });
367
+ }
368
+ }
369
+ return edges;
370
+ }
371
+
372
+ async loadDomainNodes(): Promise<GraphNode[]> {
373
+ const resp = await fetch(`${this.baseUrl}/domain-nodes.json`);
374
+ if (!resp.ok) return [];
375
+ const data = await resp.json();
376
+ return (data.domainNodes || []).map((dn: any) => ({
377
+ uri: dn.uri,
378
+ register: dn.registerId,
379
+ conceptId: dn.uri.split('/domain/')[1] || '',
380
+ designations: { eng: dn.label },
381
+ status: 'domain',
382
+ loaded: true,
383
+ nodeType: 'domain' as const,
384
+ }));
385
+ }
386
+
262
387
  async loadEdgeIndex(): Promise<GraphEdge[]> {
263
388
  const resp = await fetch(`${this.baseUrl}/edges.json`);
264
389
  if (!resp.ok) return [];