@glossarist/concept-browser 0.3.7 → 0.4.1

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 (54) hide show
  1. package/README.md +3 -2
  2. package/cli/index.mjs +4 -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__/concept-card.test.ts +1 -1
  10. package/src/__tests__/concept-detail-interaction.test.ts +40 -18
  11. package/src/__tests__/concept-formats.test.ts +32 -30
  12. package/src/__tests__/concept-timeline.test.ts +108 -83
  13. package/src/__tests__/concept-view.test.ts +15 -2
  14. package/src/__tests__/dataset-adapter.test.ts +172 -23
  15. package/src/__tests__/dataset-view.test.ts +6 -5
  16. package/src/__tests__/designation-registry.test.ts +161 -0
  17. package/src/__tests__/graph.test.ts +62 -0
  18. package/src/__tests__/language-detail.test.ts +117 -60
  19. package/src/__tests__/ontology-registry.test.ts +109 -0
  20. package/src/__tests__/relationship-categories.test.ts +62 -0
  21. package/src/__tests__/test-helpers.ts +11 -8
  22. package/src/adapters/DatasetAdapter.ts +171 -48
  23. package/src/adapters/model-bridge.ts +277 -0
  24. package/src/adapters/ontology-registry.ts +75 -0
  25. package/src/adapters/ontology-schema.ts +100 -0
  26. package/src/adapters/types.ts +52 -77
  27. package/src/components/AppSidebar.vue +1 -1
  28. package/src/components/CitationDisplay.vue +35 -0
  29. package/src/components/ConceptDetail.vue +334 -93
  30. package/src/components/ConceptRdfView.vue +397 -0
  31. package/src/components/ConceptTimeline.vue +56 -52
  32. package/src/components/GraphPanel.vue +96 -31
  33. package/src/components/LanguageDetail.vue +45 -37
  34. package/src/components/NavIcon.vue +1 -0
  35. package/src/components/NonVerbalRepDisplay.vue +38 -0
  36. package/src/components/RelationshipList.vue +99 -0
  37. package/src/config/use-site-config.ts +3 -0
  38. package/src/data/ontology-schema.json +1551 -0
  39. package/src/data/taxonomies.json +543 -0
  40. package/src/graph/GraphEngine.ts +7 -4
  41. package/src/router/index.ts +5 -0
  42. package/src/shims/empty.ts +1 -0
  43. package/src/shims/node-crypto.ts +6 -0
  44. package/src/shims/node-path.ts +10 -0
  45. package/src/stores/vocabulary.ts +75 -25
  46. package/src/style.css +74 -20
  47. package/src/utils/concept-formats.ts +22 -20
  48. package/src/utils/concept-helpers.ts +43 -23
  49. package/src/utils/designation-registry.ts +124 -0
  50. package/src/utils/relationship-categories.ts +84 -0
  51. package/src/views/OntologySchemaView.vue +302 -0
  52. package/src/views/PageView.vue +28 -17
  53. package/src/views/StatsView.vue +34 -12
  54. package/vite.config.ts +8 -0
@@ -2,14 +2,16 @@ import { defineStore } from 'pinia';
2
2
  import { ref, computed, toRaw } from 'vue';
3
3
  import { getFactory } from '../adapters/factory';
4
4
  import type { DatasetAdapter } from '../adapters/DatasetAdapter';
5
- import type { Manifest, ConceptDocument, SearchHit, GraphEdge } from '../adapters/types';
5
+ import type { Manifest, SearchHit, GraphEdge } from '../adapters/types';
6
+ import type { Concept } from 'glossarist';
7
+ import { conceptUri } from '../adapters/model-bridge';
6
8
  import { GraphEngine } from '../graph';
7
9
 
8
10
  export const useVocabularyStore = defineStore('vocabulary', () => {
9
11
  // State
10
12
  const datasets = ref<Map<string, DatasetAdapter>>(new Map());
11
13
  const manifests = ref<Map<string, Manifest>>(new Map());
12
- const currentConcept = ref<ConceptDocument | null>(null);
14
+ const currentConcept = ref<Concept | null>(null);
13
15
  const currentRegisterId = ref<string>('');
14
16
  const currentConceptId = ref<string>('');
15
17
  const loading = ref(false);
@@ -97,7 +99,7 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
97
99
  uri: factory.router.buildUri(registerId, entry.id),
98
100
  register: registerId,
99
101
  conceptId: entry.id,
100
- designations: entry.eng ? { eng: entry.eng } : {},
102
+ designations: entry.designations,
101
103
  status: entry.status,
102
104
  loaded: false,
103
105
  });
@@ -121,7 +123,7 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
121
123
  uri: factory.router.buildUri(registerId, entry.id),
122
124
  register: registerId,
123
125
  conceptId: entry.id,
124
- designations: entry.eng ? { eng: entry.eng } : {},
126
+ designations: entry.designations,
125
127
  status: entry.status,
126
128
  loaded: false,
127
129
  });
@@ -143,11 +145,12 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
143
145
 
144
146
  await Promise.allSettled(adapters.map(async (adapter) => {
145
147
  try {
146
- const [nodeResult, edgeResult] = await Promise.allSettled([
148
+ const [nodeResult, edgeResult, domainResult] = await Promise.allSettled([
147
149
  adapter.loadGraphNodes(),
148
150
  !edgeStatus.value[adapter.registerId]?.loaded
149
151
  ? adapter.loadEdgeIndex()
150
152
  : Promise.resolve([] as GraphEdge[]),
153
+ adapter.loadDomainNodes(),
151
154
  ]);
152
155
 
153
156
  if (nodeResult.status === 'fulfilled') {
@@ -170,6 +173,12 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
170
173
  }
171
174
  edgeStatus.value[adapter.registerId] = { loaded: true, count: edgeResult.value.length };
172
175
  }
176
+
177
+ if (domainResult.status === 'fulfilled') {
178
+ for (const dn of domainResult.value) {
179
+ engine.addNode(dn);
180
+ }
181
+ }
173
182
  } catch {
174
183
  // Individual adapter failures are non-critical for graph view
175
184
  }
@@ -180,9 +189,14 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
180
189
 
181
190
  async function loadEdges(adapter: DatasetAdapter) {
182
191
  try {
183
- const edges = await adapter.loadEdgeIndex();
192
+ const [edges, domainNodes] = await Promise.all([
193
+ adapter.loadEdgeIndex(),
194
+ adapter.loadDomainNodes(),
195
+ ]);
196
+ for (const dn of domainNodes) {
197
+ graph.value.addNode(dn);
198
+ }
184
199
  for (const edge of edges) {
185
- // Mark source node as having edges
186
200
  graph.value.addEdge(edge);
187
201
  }
188
202
  edgeStatus.value[adapter.registerId] = { loaded: true, count: edges.length };
@@ -205,26 +219,31 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
205
219
 
206
220
  // Extract and register edges for this specific concept
207
221
  const edges = adapter.extractEdges(concept);
208
- const uri = concept['@id'];
222
+ const domainEdges = adapter.extractDomainEdges(concept);
223
+ const uriBase = adapter.manifest?.uriBase || 'https://glossarist.org';
224
+ const uri = conceptUri(concept, registerId, uriBase);
209
225
 
210
226
  // Update graph node with full data
227
+ const designations: Record<string, string> = {};
228
+ const indexEntry = adapter.getIndexEntry(conceptId);
229
+ if (indexEntry) {
230
+ for (const [lang, term] of Object.entries(indexEntry.designations)) {
231
+ if (term) designations[lang] = term;
232
+ }
233
+ }
234
+ for (const lang of concept.languages) {
235
+ const lc = concept.localization(lang);
236
+ if (lc?.primaryDesignation) {
237
+ designations[lang] = lc.primaryDesignation;
238
+ }
239
+ }
240
+
211
241
  graph.value.addNode({
212
242
  uri,
213
243
  register: registerId,
214
244
  conceptId,
215
- designations: (() => {
216
- const d: Record<string, string> = {};
217
- const entry = adapter.getIndexEntry(conceptId);
218
- if (entry?.eng) d.eng = entry.eng;
219
- for (const [lang, lc] of Object.entries(concept['gl:localizedConcept'] || {})) {
220
- const preferred = lc['gl:designation']?.find(
221
- (dd: any) => dd['gl:normativeStatus'] === 'preferred'
222
- );
223
- if (preferred?.['gl:term']) d[lang] = preferred['gl:term'];
224
- }
225
- return d;
226
- })(),
227
- status: adapter.getIndexEntry(conceptId)?.status ?? 'unknown',
245
+ designations,
246
+ status: indexEntry?.status ?? 'unknown',
228
247
  loaded: true,
229
248
  });
230
249
 
@@ -232,6 +251,22 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
232
251
  graph.value.addEdge(edge);
233
252
  }
234
253
 
254
+ for (const edge of domainEdges) {
255
+ graph.value.addEdge(edge);
256
+ const existing = graph.value.getNode(edge.target);
257
+ if (!existing || !existing.loaded) {
258
+ graph.value.addNode({
259
+ uri: edge.target,
260
+ register: registerId,
261
+ conceptId: '',
262
+ designations: edge.label ? { eng: edge.label } : {},
263
+ status: 'domain',
264
+ loaded: true,
265
+ nodeType: 'domain',
266
+ });
267
+ }
268
+ }
269
+
235
270
  touchGraph();
236
271
  conceptEdges.value = graph.value.getEdges(uri);
237
272
  } catch (e: unknown) {
@@ -260,15 +295,30 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
260
295
  }
261
296
  }
262
297
 
263
- async function searchAcrossDatasets(query: string, lang: string = 'eng'): Promise<SearchHit[]> {
264
- const hits: SearchHit[] = [];
298
+ async function searchAcrossDatasets(query: string): Promise<SearchHit[]> {
299
+ const allHits: SearchHit[] = [];
265
300
  for (const adapter of datasets.value.values()) {
266
301
  if (adapter.index || adapter.manifest) {
267
302
  await adapter.ensureAllChunksLoaded();
268
- hits.push(...adapter.search(query, lang));
303
+ allHits.push(...adapter.search(query));
304
+ }
305
+ }
306
+
307
+ // Deduplicate: keep the best hit per (registerId, conceptId)
308
+ const best = new Map<string, SearchHit>();
309
+ for (const hit of allHits) {
310
+ const key = `${hit.registerId}:${hit.conceptId}`;
311
+ const existing = best.get(key);
312
+ if (!existing) {
313
+ best.set(key, hit);
314
+ } else {
315
+ // Prefer designation match over id match, then prefer shorter language code (eng first)
316
+ if (hit.matchField === 'designation' && existing.matchField === 'id') {
317
+ best.set(key, hit);
318
+ }
269
319
  }
270
320
  }
271
- return hits;
321
+ return [...best.values()];
272
322
  }
273
323
 
274
324
  async function getRandomConcept(): Promise<{ registerId: string; conceptId: string } | null> {
package/src/style.css CHANGED
@@ -290,50 +290,78 @@
290
290
  }
291
291
 
292
292
  /* ===== Dark mode ===== */
293
+
294
+ /*
295
+ Color philosophy:
296
+ Light mode uses an "ink" navy-indigo palette (50=lightest → 900=darkest).
297
+ Dark mode inverts the scale: the darkest light colors become the brightest
298
+ dark colors, maintaining WCAG AA contrast on dark surfaces.
299
+
300
+ Surface hierarchy (light → dark):
301
+ surface (#faf9f6) → surface-alt (#f3f2ee) → surface-raised (#fff) — cards, inputs
302
+ Dark surface hierarchy:
303
+ surface (#0f1020) → surface-alt (#161728) → surface-raised (#1e1f34)
304
+ Raised cards sit one step lighter than the base surface.
305
+ */
306
+
293
307
  .dark body {
294
308
  background-color: #0f1020;
295
309
  color: #dddde6;
296
310
  }
297
311
 
298
- /* Surfaces */
312
+ /* ── Surfaces ── */
299
313
  .dark .bg-surface { background-color: #0f1020 !important; }
300
314
  .dark .bg-surface-alt { background-color: #161728 !important; }
301
- .dark .bg-surface-raised { background-color: #1a1b2e !important; }
302
-
303
- /* Text */
315
+ .dark .bg-surface-raised { background-color: #1e1f34 !important; }
316
+
317
+ /* ── Text (inverted ink scale for dark) ──
318
+ ink-800/700 → primary headings (brightest)
319
+ ink-600 → body text (readable, ≥4.5:1 on raised surfaces)
320
+ ink-500/400 → secondary/hint
321
+ ink-300/200 → muted labels
322
+ ink-100 → borders/decorative only
323
+ */
304
324
  .dark .text-ink { color: #dddde6 !important; }
325
+ .dark .text-ink-900 { color: #eeeef4 !important; }
305
326
  .dark .text-ink-800 { color: #dddde6 !important; }
306
- .dark .text-ink-700 { color: #b8b9cc !important; }
307
- .dark .text-ink-600 { color: #8d8faa !important; }
308
- .dark .text-ink-500 { color: #8d8faa !important; }
309
- .dark .text-ink-400 { color: #636588 !important; }
327
+ .dark .text-ink-700 { color: #c8c9d8 !important; }
328
+ .dark .text-ink-600 { color: #b8b9cc !important; }
329
+ .dark .text-ink-500 { color: #9d9fbb !important; }
330
+ .dark .text-ink-400 { color: #7a7c9a !important; }
310
331
  .dark .text-ink-300 { color: #636588 !important; }
311
332
  .dark .text-ink-200 { color: #484a6e !important; }
312
333
  .dark .text-ink-100 { color: #484a6e !important; }
313
334
 
314
- /* Backgrounds */
335
+ /* ── Backgrounds ── */
315
336
  .dark .bg-ink-50 { background-color: #161728 !important; }
337
+ .dark .bg-ink-50\/30 { background-color: rgba(22, 23, 40, 0.3) !important; }
338
+ .dark .bg-ink-50\/60 { background-color: rgba(22, 23, 40, 0.6) !important; }
316
339
  .dark .bg-ink-100 { background-color: #1e1f34 !important; }
340
+ .dark .bg-ink-200 { background-color: #22243c !important; }
341
+ .dark .bg-ink-800 { background-color: #0a0b18 !important; }
342
+ .dark .bg-ink-800\/8 { background-color: rgba(10, 11, 24, 0.08) !important; }
317
343
 
318
- /* Borders */
344
+ /* ── Borders ── */
319
345
  .dark .border-ink-100 { border-color: #2c2e4a !important; }
346
+ .dark .border-ink-100\/30 { border-color: rgba(44, 46, 74, 0.3) !important; }
320
347
  .dark .border-ink-100\/60 { border-color: rgba(44, 46, 74, 0.6) !important; }
321
348
  .dark .border-ink-100\/80 { border-color: rgba(44, 46, 74, 0.8) !important; }
322
349
  .dark .border-ink-200 { border-color: #36385a !important; }
350
+ .dark .border-ink-800 { border-color: #2c2e4a !important; }
323
351
 
324
- /* Focus rings */
352
+ /* ── Focus rings ── */
325
353
  .dark .focus\:ring-ink-200:focus { --tw-ring-color: #36385a !important; }
326
354
  .dark .focus\:ring-ink-200\/30:focus { --tw-ring-color: rgba(54, 56, 90, 0.3) !important; }
327
- .dark .focus\:border-ink-400:focus { border-color: #636588 !important; }
355
+ .dark .focus\:border-ink-400:focus { border-color: #7a7c9a !important; }
328
356
 
329
- /* Cards */
357
+ /* ── Cards ── */
330
358
  .dark .card {
331
359
  background-color: #1e1f34 !important;
332
360
  border-color: rgba(44, 46, 74, 0.6) !important;
333
361
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2) !important;
334
362
  }
335
363
 
336
- /* Inputs */
364
+ /* ── Inputs ── */
337
365
  .dark input {
338
366
  background-color: #161728 !important;
339
367
  border-color: #2c2e4a !important;
@@ -341,16 +369,42 @@
341
369
  }
342
370
  .dark input::placeholder { color: #484a6e !important; }
343
371
 
344
- /* Skeleton */
372
+ /* ── Skeleton ── */
345
373
  .dark .skeleton {
346
374
  background: linear-gradient(90deg, #1e1f34 25%, #2c2e4a 50%, #1e1f34 75%) !important;
347
375
  }
348
376
 
349
- /* Hover states */
350
- .dark .hover\:bg-ink-50:hover { background-color: #161728 !important; }
377
+ /* ── Hover states ── */
378
+ .dark .hover\:bg-ink-50:hover { background-color: #1e1f34 !important; }
379
+ .dark .hover\:bg-ink-50\/30:hover { background-color: rgba(30, 31, 52, 0.3) !important; }
380
+ .dark .hover\:bg-ink-50\/50:hover { background-color: rgba(30, 31, 52, 0.5) !important; }
351
381
  .dark .hover\:bg-surface-alt:hover { background-color: #161728 !important; }
352
- .dark .hover\:text-ink-700:hover { color: #b8b9cc !important; }
353
- .dark .hover\:text-ink-800:hover { color: #dddde6 !important; }
382
+ .dark .hover\:text-ink-700:hover { color: #dddde6 !important; }
383
+ .dark .hover\:text-ink-800:hover { color: #eeeef4 !important; }
354
384
 
355
- /* Placeholder text */
385
+ /* ── Placeholder text ── */
356
386
  .dark .placeholder\:text-ink-300::placeholder { color: #484a6e !important; }
387
+
388
+ /* ── Semantic colors (badges, accent text) ── */
389
+ .dark .bg-blue-50 { background-color: rgba(59, 130, 246, 0.15) !important; }
390
+ .dark .text-blue-600 { color: #93bbfd !important; }
391
+ .dark .text-blue-700 { color: #7aa8fb !important; }
392
+ .dark .border-blue-100 { border-color: rgba(59, 130, 246, 0.25) !important; }
393
+ .dark .border-blue-500 { border-color: #3b82f6 !important; }
394
+ .dark .bg-emerald-50 { background-color: rgba(16, 185, 129, 0.15) !important; }
395
+ .dark .text-emerald-500 { color: #6ee7b7 !important; }
396
+ .dark .text-emerald-700 { color: #6ee7b7 !important; }
397
+ .dark .border-emerald-100 { border-color: rgba(16, 185, 129, 0.25) !important; }
398
+ .dark .bg-amber-50 { background-color: rgba(245, 158, 11, 0.15) !important; }
399
+ .dark .text-amber-600 { color: #fbbf24 !important; }
400
+ .dark .text-amber-700 { color: #fbbf24 !important; }
401
+ .dark .bg-purple-50 { background-color: rgba(139, 92, 246, 0.15) !important; }
402
+ .dark .text-purple-700 { color: #c4b5fd !important; }
403
+ .dark .bg-red-50 { background-color: rgba(239, 68, 68, 0.15) !important; }
404
+ .dark .text-red-600 { color: #fca5a5 !important; }
405
+
406
+ /* Scrollbar hide utility */
407
+ @layer utilities {
408
+ .scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; }
409
+ .scrollbar-none::-webkit-scrollbar { display: none; }
410
+ }
@@ -1,4 +1,4 @@
1
- import type { ConceptDocument, LocalizedConcept } from '../adapters/types';
1
+ import type { Concept } from 'glossarist';
2
2
 
3
3
  export interface FormatDescriptor {
4
4
  extension: string;
@@ -13,7 +13,7 @@ export const FORMAT_REGISTRY: Record<string, FormatDescriptor> = {
13
13
  yaml: { extension: 'yaml', label: 'YAML', mediaType: 'text/yaml' },
14
14
  };
15
15
 
16
- function getLocalizedData(concept: ConceptDocument) {
16
+ function getLocalizedData(concept: Concept) {
17
17
  const result: Record<string, {
18
18
  prefLabels: string[];
19
19
  altLabels: string[];
@@ -21,19 +21,21 @@ function getLocalizedData(concept: ConceptDocument) {
21
21
  notes: string[];
22
22
  }> = {};
23
23
 
24
- for (const [lang, lc] of Object.entries(concept['gl:localizedConcept'] || {})) {
25
- const descs = lc['gl:designation'] || [];
26
- const prefLabels = descs
27
- .filter(d => d['gl:normativeStatus'] === 'preferred' && d['gl:term'])
28
- .map(d => d['gl:term']!);
29
- const altLabels = descs
30
- .filter(d => d['gl:normativeStatus'] !== 'preferred' && d['gl:term'])
31
- .map(d => d['gl:term']!);
32
- const definitions = (lc['gl:definition'] || [])
33
- .map(d => d['gl:content'] || '')
24
+ for (const lang of concept.languages) {
25
+ const lc = concept.localization(lang);
26
+ if (!lc) continue;
27
+
28
+ const prefLabels = lc.terms
29
+ .filter(d => d.normativeStatus === 'preferred' && d.designation)
30
+ .map(d => d.designation);
31
+ const altLabels = lc.terms
32
+ .filter(d => d.normativeStatus !== 'preferred' && d.designation)
33
+ .map(d => d.designation);
34
+ const definitions = lc.definitions
35
+ .map(d => d.content || '')
34
36
  .filter(Boolean);
35
- const notes = (lc['gl:notes'] || [])
36
- .map(d => d['gl:content'] || '')
37
+ const notes = lc.notes
38
+ .map(d => d.content || '')
37
39
  .filter(Boolean);
38
40
 
39
41
  if (prefLabels.length || definitions.length) {
@@ -48,9 +50,9 @@ function escapeTurtle(s: string): string {
48
50
  return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
49
51
  }
50
52
 
51
- export function conceptToTurtle(concept: ConceptDocument): string {
52
- const uri = concept['@id'] || '';
53
- const id = concept['gl:identifier'] || '';
53
+ export function conceptToTurtle(concept: Concept): string {
54
+ const uri = concept.uri || '';
55
+ const id = concept.id;
54
56
  const data = getLocalizedData(concept);
55
57
 
56
58
  const lines: string[] = [
@@ -84,9 +86,9 @@ export function conceptToTurtle(concept: ConceptDocument): string {
84
86
  return lines.join('\n');
85
87
  }
86
88
 
87
- export function conceptToSkosJsonLd(concept: ConceptDocument): string {
88
- const uri = concept['@id'] || '';
89
- const id = concept['gl:identifier'] || '';
89
+ export function conceptToSkosJsonLd(concept: Concept): string {
90
+ const uri = concept.uri || '';
91
+ const id = concept.id;
90
92
  const data = getLocalizedData(concept);
91
93
 
92
94
  const doc: Record<string, any> = {
@@ -1,34 +1,54 @@
1
- import type { Designation, LocalizedConcept } from '../adapters/types';
1
+ import type { LocalizedConcept } from 'glossarist';
2
+ import { ontology } from '../adapters/ontology-registry';
2
3
 
3
4
  export function entryStatusColor(status: string): string {
4
- if (status === 'valid' || status === 'Standard') return 'badge-green';
5
- if (status === 'superseded') return 'bg-red-50 text-red-700';
6
- if (status === 'withdrawn') return 'bg-red-100 text-red-800';
7
- if (status === 'draft') return 'badge-yellow';
8
- return 'badge-gray';
5
+ const colors: Record<string, string> = {
6
+ valid: 'badge-green',
7
+ not_valid: 'bg-red-50 text-red-700',
8
+ superseded: 'bg-red-50 text-red-700',
9
+ retired: 'badge-gray',
10
+ withdrawn: 'bg-red-100 text-red-800',
11
+ draft: 'badge-yellow',
12
+ Standard: 'badge-green',
13
+ };
14
+ return colors[status] ?? 'badge-gray';
9
15
  }
10
16
 
11
- export function designationTypeLabel(type: string): string {
12
- const labels: Record<string, string> = {
13
- 'gl:Expression': 'Expression',
14
- 'gl:Symbol': 'Symbol',
15
- 'gl:Abbreviation': 'Abbreviation',
16
- 'gl:GraphicalSymbol': 'Graphical',
17
+ export function conceptStatusColor(status: string | null): string {
18
+ if (!status) return 'badge-gray';
19
+ const colors: Record<string, string> = {
20
+ draft: 'badge-yellow',
21
+ submitted: 'badge-blue',
22
+ valid: 'badge-green',
23
+ not_valid: 'bg-red-50 text-red-700',
24
+ invalid: 'bg-red-50 text-red-700',
25
+ superseded: 'bg-red-50 text-red-700',
26
+ retired: 'badge-gray',
17
27
  };
18
- return labels[type] ?? type;
28
+ return colors[status] ?? 'badge-gray';
29
+ }
30
+
31
+ export function conceptStatusLabel(status: string | null): string {
32
+ if (!status) return '';
33
+ return ontology.getLabel('conceptStatus', status) || status;
34
+ }
35
+
36
+ export function conceptStatusDefinition(status: string | null): string | null {
37
+ if (!status) return null;
38
+ return ontology.getDefinition('conceptStatus', status);
39
+ }
40
+
41
+ export function entryStatusLabel(status: string | null): string {
42
+ if (!status) return '';
43
+ return ontology.getLabel('entryStatus', status) || status;
19
44
  }
20
45
 
21
- export function designationTypeColor(type: string): string {
22
- if (type === 'gl:Symbol') return 'badge-purple';
23
- if (type === 'gl:Abbreviation') return 'badge-yellow';
24
- return 'badge-blue';
46
+ export function entryStatusDefinition(status: string | null): string | null {
47
+ if (!status) return null;
48
+ return ontology.getDefinition('entryStatus', status);
25
49
  }
26
50
 
27
51
  export function getPreferredTerm(lc: LocalizedConcept | null | undefined, fallback = '—'): string {
28
- if (!lc?.['gl:designation']?.length) return fallback;
29
- const desigs = lc['gl:designation'];
30
- const preferredExpr = desigs.find(d => d['gl:normativeStatus'] === 'preferred' && d['@type'] === 'gl:Expression');
31
- if (preferredExpr) return preferredExpr['gl:term'];
32
- const preferred = desigs.find(d => d['gl:normativeStatus'] === 'preferred');
33
- return preferred?.['gl:term'] ?? desigs[0]?.['gl:term'] ?? fallback;
52
+ if (!lc) return fallback;
53
+ return lc.primaryDesignation ?? lc.terms[0]?.designation ?? fallback;
34
54
  }
@@ -0,0 +1,124 @@
1
+ import type { Designation, Abbreviation } from 'glossarist';
2
+ import type { GrammarInfo, Pronunciation } from 'glossarist/models';
3
+ import { ontology } from '../adapters/ontology-registry';
4
+
5
+ export interface DesignationTypeInfo {
6
+ label: string;
7
+ color: string;
8
+ definition?: string;
9
+ }
10
+
11
+ const TYPE_COLORS: Record<string, string> = {
12
+ expression: 'bg-sky-50 text-sky-700',
13
+ abbreviation: 'bg-amber-50 text-amber-700',
14
+ symbol: 'bg-violet-50 text-violet-700',
15
+ letter_symbol: 'bg-violet-50 text-violet-700',
16
+ graphical_symbol: 'bg-violet-50 text-violet-700',
17
+ };
18
+
19
+ export function designationTypeInfo(designation: Designation): DesignationTypeInfo {
20
+ const type = designation.type;
21
+ const concept = ontology.getConcept('designationType', type);
22
+ return {
23
+ label: concept?.prefLabel ?? type,
24
+ color: TYPE_COLORS[type] ?? 'bg-gray-50 text-gray-700',
25
+ definition: concept?.definition ?? undefined,
26
+ };
27
+ }
28
+
29
+ export function normativeStatusInfo(status: string | null): { label: string; color: string; definition?: string } {
30
+ if (!status) return { label: '', color: 'bg-gray-50 text-gray-700' };
31
+
32
+ const colors: Record<string, string> = {
33
+ preferred: 'bg-emerald-50 text-emerald-700',
34
+ admitted: 'bg-amber-50 text-amber-700',
35
+ deprecated: 'bg-red-50 text-red-700',
36
+ superseded: 'bg-red-50 text-red-700',
37
+ };
38
+
39
+ const concept = ontology.getConcept('normativeStatus', status);
40
+ return {
41
+ label: concept?.prefLabel ?? status,
42
+ color: colors[status] ?? 'bg-gray-50 text-gray-700',
43
+ definition: concept?.definition ?? undefined,
44
+ };
45
+ }
46
+
47
+ export function sourceStatusInfo(status: string | null): { label: string; color: string; definition?: string } {
48
+ if (!status) return { label: '', color: 'badge-gray' };
49
+
50
+ const concept = ontology.getConcept('sourceStatus', status);
51
+ return {
52
+ label: concept?.prefLabel ?? status,
53
+ color: 'badge-gray',
54
+ definition: concept?.definition ?? undefined,
55
+ };
56
+ }
57
+
58
+ export function sourceTypeInfo(type: string | null): { label: string; color: string; definition?: string } {
59
+ if (!type) return { label: '', color: 'badge-gray' };
60
+
61
+ const colors: Record<string, string> = {
62
+ authoritative: 'badge-purple',
63
+ lineage: 'badge-blue',
64
+ };
65
+
66
+ const concept = ontology.getConcept('sourceType', type);
67
+ return {
68
+ label: concept?.prefLabel ?? type,
69
+ color: colors[type] ?? 'badge-gray',
70
+ definition: concept?.definition ?? undefined,
71
+ };
72
+ }
73
+
74
+ export function termTypeInfo(termType: string | null): { label: string; category: string; definition?: string } {
75
+ if (!termType) return { label: '', category: '' };
76
+ const concept = ontology.getConcept('termType', termType);
77
+ return {
78
+ label: concept?.prefLabel ?? termType,
79
+ category: concept?.broader ?? '',
80
+ definition: concept?.definition ?? undefined,
81
+ };
82
+ }
83
+
84
+ export function abbreviationDetails(designation: Designation): string[] {
85
+ if (designation.type !== 'abbreviation') return [];
86
+ const abbr = designation as Abbreviation;
87
+ const parts: string[] = [];
88
+ if (abbr.acronym) parts.push('acronym');
89
+ if (abbr.initialism) parts.push('initialism');
90
+ if (abbr.truncation) parts.push('truncation');
91
+ return parts;
92
+ }
93
+
94
+ export function grammarBadges(gi: GrammarInfo): { label: string; definition?: string }[] {
95
+ const badges: { label: string; definition?: string }[] = [];
96
+ if (gi.gender) {
97
+ const concept = ontology.getConcept('grammarGender', gi.gender);
98
+ badges.push({ label: concept?.prefLabel ?? gi.gender, definition: concept?.definition ?? undefined });
99
+ }
100
+ if (gi.number) {
101
+ const concept = ontology.getConcept('grammarNumber', gi.number);
102
+ badges.push({ label: concept?.prefLabel ?? gi.number, definition: concept?.definition ?? undefined });
103
+ }
104
+ if (gi.partOfSpeech) badges.push({ label: gi.partOfSpeech });
105
+ for (const pos of ['noun', 'verb', 'adj', 'adverb', 'preposition', 'participle'] as const) {
106
+ if (gi[pos]) badges.push({ label: pos });
107
+ }
108
+ return badges;
109
+ }
110
+
111
+ export function pronunciationLabel(p: Pronunciation): string {
112
+ const parts = [p.content];
113
+ if (p.system) parts.push(`(${p.system})`);
114
+ return parts.filter(Boolean).join(' ');
115
+ }
116
+
117
+ export function pronunciationTooltip(p: Pronunciation): string {
118
+ const parts: string[] = [];
119
+ if (p.language) parts.push(`Language: ${p.language}`);
120
+ if (p.script) parts.push(`Script: ${p.script}`);
121
+ if (p.country) parts.push(`Country: ${p.country}`);
122
+ if (p.system) parts.push(`System: ${p.system}`);
123
+ return parts.join(', ');
124
+ }