@glossarist/concept-browser 0.3.7 → 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 (54) 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__/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
@@ -0,0 +1,315 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Parse the glossarist OWL ontology (TTL) into a structured JSON schema
4
+ * for the Ontospy-style browser view.
5
+ *
6
+ * Reads: ../concept-model/ontologies/glossarist.ttl
7
+ * Writes: src/data/ontology-schema.json
8
+ */
9
+
10
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
11
+ import { resolve, dirname, join } from 'path';
12
+ import { fileURLToPath } from 'url';
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const ROOT = resolve(__dirname, '..');
16
+ const ONTOLOGY_TTL = resolve(ROOT, '..', 'concept-model', 'ontologies', 'glossarist.ttl');
17
+ const OUTPUT = resolve(ROOT, 'src', 'data', 'ontology-schema.json');
18
+
19
+ const KNOWN_PREFIXES = {
20
+ gloss: 'https://www.glossarist.org/ontologies/',
21
+ owl: 'http://www.w3.org/2002/07/owl#',
22
+ rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
23
+ rdfs: 'http://www.w3.org/2000/01/rdf-schema#',
24
+ skos: 'http://www.w3.org/2004/02/skos/core#',
25
+ xl: 'http://www.w3.org/2008/05/skos-xl#',
26
+ 'iso-thes': 'http://purl.org/iso25964/skos-thes#',
27
+ dcterms: 'http://purl.org/dc/terms/',
28
+ prov: 'http://www.w3.org/ns/prov#',
29
+ xsd: 'http://www.w3.org/2001/XMLSchema#',
30
+ };
31
+
32
+ function expandPrefixed(term) {
33
+ for (const [prefix, uri] of Object.entries(KNOWN_PREFIXES)) {
34
+ if (term.startsWith(prefix + ':')) {
35
+ return uri + term.slice(prefix.length + 1);
36
+ }
37
+ }
38
+ return term;
39
+ }
40
+
41
+ function compactIri(iri) {
42
+ for (const [prefix, uri] of Object.entries(KNOWN_PREFIXES)) {
43
+ if (iri.startsWith(uri)) {
44
+ return prefix + ':' + iri.slice(uri.length);
45
+ }
46
+ }
47
+ return iri;
48
+ }
49
+
50
+ /**
51
+ * Minimal TTL subject-block splitter. Handles nested [] and () and quoted strings.
52
+ */
53
+ function splitSubjectBlocks(text) {
54
+ const blocks = [];
55
+ let depth = 0;
56
+ let start = -1;
57
+ let inTripleQuote = false;
58
+
59
+ for (let i = 0; i < text.length; i++) {
60
+ const ch = text[i];
61
+
62
+ if (inTripleQuote) {
63
+ if (ch === '"' && text.slice(i, i + 3) === '"""') {
64
+ inTripleQuote = false;
65
+ i += 2;
66
+ }
67
+ continue;
68
+ }
69
+
70
+ if (ch === '"' && text.slice(i, i + 3) === '"""') {
71
+ inTripleQuote = true;
72
+ i += 2;
73
+ continue;
74
+ }
75
+
76
+ if (ch === '"') {
77
+ i++;
78
+ while (i < text.length && text[i] !== '"') {
79
+ if (text[i] === '\\') i++;
80
+ i++;
81
+ }
82
+ continue;
83
+ }
84
+
85
+ if (ch === '[' || ch === '(') depth++;
86
+ if (ch === ']' || ch === ')') depth--;
87
+
88
+ if (depth === 0 && ch === '.') {
89
+ if (start >= 0) {
90
+ blocks.push(text.slice(start, i));
91
+ start = -1;
92
+ }
93
+ } else if (start < 0 && /\S/.test(ch)) {
94
+ start = i;
95
+ }
96
+ }
97
+
98
+ return blocks;
99
+ }
100
+
101
+ function extractLiteral(block, predicate) {
102
+ const tripleQuoted = new RegExp(predicate + '\\s+"""([^]*?)"""@en');
103
+ let m = block.match(tripleQuoted);
104
+ if (m) return m[1].replace(/\s+/g, ' ').trim();
105
+
106
+ const singleQuoted = new RegExp(predicate + '\\s+"([^"]*?)"@en');
107
+ m = block.match(singleQuoted);
108
+ if (m) return m[1];
109
+
110
+ // Without @en
111
+ const plain = new RegExp(predicate + '\\s+"""([^]*?)"""');
112
+ m = block.match(plain);
113
+ if (m) return m[1].replace(/\s+/g, ' ').trim();
114
+
115
+ const plainSingle = new RegExp(predicate + '\\s+"([^"]*?)"');
116
+ m = block.match(plainSingle);
117
+ return m ? m[1] : null;
118
+ }
119
+
120
+ function extractResource(block, predicate) {
121
+ const re = new RegExp(predicate + '\\s+([^\\s,;]+)');
122
+ const m = block.match(re);
123
+ if (!m) return null;
124
+ let val = m[1].replace(/[;.]+$/, '');
125
+ if (val === 'a') return null;
126
+ return val;
127
+ }
128
+
129
+ function extractAllResources(block, predicate) {
130
+ const results = [];
131
+ const re = new RegExp(predicate + '\\s+', 'g');
132
+ let match;
133
+ while ((match = re.exec(block)) !== null) {
134
+ const rest = block.slice(match.index + match[0].length).trimStart();
135
+ // Read comma-separated resources until ; or .
136
+ const tokens = rest.split(/[\s;.\n]+/)[0];
137
+ if (tokens && tokens !== 'a') {
138
+ results.push(tokens.replace(/[;,]+$/, ''));
139
+ }
140
+ }
141
+ return [...new Set(results)];
142
+ }
143
+
144
+ function parseOntology(ttlText) {
145
+ const rawLines = ttlText.split('\n');
146
+ // Remove comment lines but keep content
147
+ const cleaned = rawLines.map(l => l.replace(/#[^\n]*/g, '')).join('\n');
148
+
149
+ const blocks = splitSubjectBlocks(cleaned);
150
+
151
+ const classes = [];
152
+ const properties = [];
153
+
154
+ for (const block of blocks) {
155
+ const trimmed = block.trim();
156
+ if (!trimmed) continue;
157
+
158
+ // Parse subject
159
+ const subjectMatch = trimmed.match(/^([^\s]+)/);
160
+ if (!subjectMatch) continue;
161
+ const subject = subjectMatch[1];
162
+
163
+ // Skip ontology declaration, prefix declarations
164
+ if (subject === '@prefix' || subject.startsWith('@')) continue;
165
+ if (subject.includes('glossarist>') && !subject.startsWith('gloss:')) continue;
166
+
167
+ // Determine type
168
+ const typeMatch = trimmed.match(/\ba\s+(.+?)(?:\s*[;.\n]|$)/);
169
+ if (!typeMatch) continue;
170
+ const typeStr = typeMatch[1];
171
+
172
+ const isClass = /\bowl:Class\b/.test(typeStr);
173
+ const isObjectProperty = /\bowl:ObjectProperty\b/.test(typeStr);
174
+ const isDatatypeProperty = /\bowl:DatatypeProperty\b/.test(typeStr);
175
+
176
+ if (!isClass && !isObjectProperty && !isDatatypeProperty) continue;
177
+
178
+ const label = extractLiteral(trimmed, 'rdfs:label');
179
+ const comment = extractLiteral(trimmed, 'rdfs:comment');
180
+ const iri = expandPrefixed(subject);
181
+ const compact = compactIri(iri);
182
+
183
+ if (isClass) {
184
+ const subClassOf = extractResource(trimmed, 'rdfs:subClassOf');
185
+ const disjointWith = extractResource(trimmed, 'owl:disjointWith');
186
+
187
+ classes.push({
188
+ iri,
189
+ compact,
190
+ label: label || subject.replace('gloss:', ''),
191
+ comment,
192
+ subClassOf: subClassOf ? compactIri(expandPrefixed(subClassOf)) : null,
193
+ disjointWith: disjointWith ? compactIri(expandPrefixed(disjointWith)) : null,
194
+ });
195
+ } else {
196
+ const domain = extractResource(trimmed, 'rdfs:domain');
197
+ const range = extractResource(trimmed, 'rdfs:range');
198
+ const inverseOf = extractResource(trimmed, 'owl:inverseOf');
199
+
200
+ // For unionOf domains/ranges, detect the bracket pattern
201
+ // rdfs:domain [ a owl:Class ; owl:unionOf ( gloss:A gloss:B ) ] ;
202
+ let domainUnion = null;
203
+ let rangeUnion = null;
204
+
205
+ const unionDomainMatch = trimmed.match(/rdfs:domain\s+\[\s*a\s+owl:Class\s*;\s*owl:unionOf\s*\(([^)]+)\)\s*\]/);
206
+ if (unionDomainMatch) {
207
+ domainUnion = unionDomainMatch[1].trim().split(/\s+/).map(t => compactIri(expandPrefixed(t)));
208
+ }
209
+
210
+ const unionRangeMatch = trimmed.match(/rdfs:range\s+\[\s*a\s+owl:Class\s*;\s*owl:unionOf\s*\(([^)]+)\)\s*\]/);
211
+ if (unionRangeMatch) {
212
+ rangeUnion = unionRangeMatch[1].trim().split(/\s+/).map(t => compactIri(expandPrefixed(t)));
213
+ }
214
+
215
+ properties.push({
216
+ iri,
217
+ compact,
218
+ label: label || subject.replace('gloss:', ''),
219
+ comment,
220
+ type: isObjectProperty ? 'object' : 'datatype',
221
+ domain: domain ? compactIri(expandPrefixed(domain)) : null,
222
+ domainUnion: domainUnion,
223
+ range: range ? compactIri(expandPrefixed(range)) : null,
224
+ rangeUnion: rangeUnion,
225
+ inverseOf: inverseOf ? compactIri(expandPrefixed(inverseOf)) : null,
226
+ });
227
+ }
228
+ }
229
+
230
+ return { classes, properties };
231
+ }
232
+
233
+ function buildClassHierarchy(classes) {
234
+ const map = new Map();
235
+ for (const c of classes) {
236
+ map.set(c.compact, c);
237
+ c.children = [];
238
+ c.ancestors = [];
239
+ }
240
+
241
+ // Build children
242
+ for (const c of classes) {
243
+ if (c.subClassOf && map.has(c.subClassOf)) {
244
+ map.get(c.subClassOf).children.push(c.compact);
245
+ }
246
+ }
247
+
248
+ // Build ancestor chains
249
+ for (const c of classes) {
250
+ const chain = [];
251
+ let current = c.subClassOf;
252
+ while (current && map.has(current)) {
253
+ chain.push(current);
254
+ current = map.get(current).subClassOf;
255
+ }
256
+ // Add non-glossarist ancestors
257
+ if (current) chain.push(current);
258
+ c.ancestors = chain;
259
+ }
260
+
261
+ // Find roots (no subClassOf or subClassOf points outside our ontology)
262
+ const roots = classes
263
+ .filter(c => !c.subClassOf || !map.has(c.subClassOf))
264
+ .map(c => c.compact);
265
+
266
+ return { roots, map: Object.fromEntries(map) };
267
+ }
268
+
269
+ function groupPropertiesByDomain(properties) {
270
+ const groups = {};
271
+ for (const p of properties) {
272
+ const domains = p.domainUnion || (p.domain ? [p.domain] : ['(unspecified)']);
273
+ for (const d of domains) {
274
+ if (!groups[d]) groups[d] = { object: [], datatype: [] };
275
+ groups[d][p.type].push(p.compact);
276
+ }
277
+ }
278
+ return groups;
279
+ }
280
+
281
+ function main() {
282
+ if (!existsSync(ONTOLOGY_TTL)) {
283
+ console.error(`Ontology file not found: ${ONTOLOGY_TTL}`);
284
+ console.error('Ensure concept-model is available at ../concept-model/');
285
+ process.exit(1);
286
+ }
287
+
288
+ const ttlText = readFileSync(ONTOLOGY_TTL, 'utf-8');
289
+ const { classes, properties } = parseOntology(ttlText);
290
+
291
+ const hierarchy = buildClassHierarchy(classes);
292
+ const propsByDomain = groupPropertiesByDomain(properties);
293
+
294
+ const output = {
295
+ ontologyIri: 'https://www.glossarist.org/ontologies/glossarist',
296
+ ontologyLabel: 'Glossarist Ontology',
297
+ classes: hierarchy.map,
298
+ classHierarchyRoots: hierarchy.roots,
299
+ properties: Object.fromEntries(properties.map(p => [p.compact, p])),
300
+ propertiesByDomain: propsByDomain,
301
+ stats: {
302
+ classCount: classes.length,
303
+ objectPropertyCount: properties.filter(p => p.type === 'object').length,
304
+ datatypePropertyCount: properties.filter(p => p.type === 'datatype').length,
305
+ },
306
+ };
307
+
308
+ mkdirSync(dirname(OUTPUT), { recursive: true });
309
+ writeFileSync(OUTPUT, JSON.stringify(output, null, 2) + '\n');
310
+
311
+ console.log(`Parsed ${output.stats.classCount} classes, ${output.stats.objectPropertyCount} object properties, ${output.stats.datatypePropertyCount} datatype properties`);
312
+ console.log(`Wrote ${OUTPUT}`);
313
+ }
314
+
315
+ main();
@@ -31,7 +31,7 @@ function makeManifest(): Manifest {
31
31
  }
32
32
 
33
33
  function makeEntry(overrides: Partial<ConceptSummary> = {}): ConceptSummary {
34
- return { id: '3.1.1.1', eng: 'test term', status: 'valid', ...overrides };
34
+ return { id: '3.1.1.1', designations: { eng: 'test term' }, eng: 'test term', status: 'valid', ...overrides };
35
35
  }
36
36
 
37
37
  async function createTestRouter() {
@@ -4,7 +4,8 @@ import { createPinia, setActivePinia } from 'pinia';
4
4
  import { createRouter, createMemoryHistory } from 'vue-router';
5
5
  import ConceptDetail from '../components/ConceptDetail.vue';
6
6
  import { useVocabularyStore } from '../stores/vocabulary';
7
- import type { Manifest, ConceptDocument, LocalizedConcept } from '../adapters/types';
7
+ import type { Manifest } from '../adapters/types';
8
+ import { conceptFromJson } from '../adapters/model-bridge';
8
9
  // Prevent the 2.7MB Opal runtime from loading in tests
9
10
  vi.mock('../utils/plurimath', () => ({
10
11
  loadPlurimath: () => new Promise(() => {}),
@@ -39,7 +40,7 @@ function makeManifest(): Manifest {
39
40
  };
40
41
  }
41
42
 
42
- function makeConcept(): ConceptDocument {
43
+ function makeConceptJson(overrides: Record<string, any> = {}) {
43
44
  return {
44
45
  '@context': 'https://glossarist.org/context',
45
46
  '@id': 'https://glossarist.org/test/concept/1',
@@ -79,6 +80,7 @@ function makeConcept(): ConceptDocument {
79
80
  ],
80
81
  },
81
82
  },
83
+ ...overrides,
82
84
  };
83
85
  }
84
86
 
@@ -109,7 +111,8 @@ describe('ConceptDetail interactions', () => {
109
111
  store.datasets.set('test', { index: [], getConceptCount: () => 0, getConcepts: () => [], getConceptPosition: () => -1, getIndexEntry: () => undefined } as any);
110
112
  });
111
113
 
112
- function mountDetail(concept = makeConcept()) {
114
+ function mountDetail(conceptJson: Record<string, any> = makeConceptJson()) {
115
+ const concept = conceptFromJson(conceptJson);
113
116
  return mount(ConceptDetail, {
114
117
  global: {
115
118
  plugins: [pinia, router],
@@ -130,49 +133,64 @@ describe('ConceptDetail interactions', () => {
130
133
  expect(wrapper.find('h1').html()).toContain('test term');
131
134
  });
132
135
 
136
+ async function switchToDefinition(wrapper: ReturnType<typeof mountDetail>) {
137
+ const tabs = wrapper.findAll('button[role="tab"]');
138
+ const defTab = tabs.find(t => t.text().includes('Definition'));
139
+ if (defTab) {
140
+ await defTab.trigger('click');
141
+ await flushPromises();
142
+ }
143
+ }
144
+
133
145
  it('renders concept ID badge', () => {
134
146
  const wrapper = mountDetail();
135
147
  expect(wrapper.text()).toContain('1');
136
148
  });
137
149
 
138
- it('renders language sections for eng and fra', () => {
150
+ it('renders language sections for eng and fra', async () => {
139
151
  const wrapper = mountDetail();
152
+ await switchToDefinition(wrapper);
140
153
  expect(wrapper.text()).toContain('English');
141
154
  expect(wrapper.text()).toContain('French');
142
155
  });
143
156
 
144
- it('renders italic text in definition', () => {
157
+ it('renders italic text in definition', async () => {
145
158
  const wrapper = mountDetail();
159
+ await switchToDefinition(wrapper);
146
160
  expect(wrapper.html()).toContain('<em>italic</em>');
147
161
  });
148
162
 
149
- it('renders stem: notation as math-pending placeholder', () => {
163
+ it('renders stem: notation as math-pending placeholder', async () => {
150
164
  const wrapper = mountDetail();
165
+ await switchToDefinition(wrapper);
151
166
  expect(wrapper.html()).toContain('math-pending');
152
167
  expect(wrapper.html()).toContain('data-expr="x"');
153
168
  });
154
169
 
155
- it('renders notes section', () => {
170
+ it('renders notes section', async () => {
156
171
  const wrapper = mountDetail();
172
+ await switchToDefinition(wrapper);
157
173
  expect(wrapper.text()).toContain('Note 1');
158
174
  expect(wrapper.text()).toContain('a note');
159
175
  });
160
176
 
161
- it('renders examples section', () => {
177
+ it('renders examples section', async () => {
162
178
  const wrapper = mountDetail();
179
+ await switchToDefinition(wrapper);
163
180
  expect(wrapper.text()).toContain('Example 1');
164
181
  expect(wrapper.text()).toContain('an example');
165
182
  });
166
183
 
167
- it('renders designation types as badges', () => {
184
+ it('renders designation types as badges', async () => {
168
185
  const wrapper = mountDetail();
169
- expect(wrapper.text()).toContain('Symbol');
186
+ await switchToDefinition(wrapper);
187
+ expect(wrapper.text()).toContain('symbol');
170
188
  });
171
189
 
172
190
  it('collapses non-eng languages when 6+ languages present', async () => {
173
- const concept = makeConcept();
191
+ const json = makeConceptJson() as Record<string, any>;
174
192
  for (const lang of ['deu', 'spa', 'kor', 'jpn']) {
175
- concept['gl:localizedConcept']![lang] = {
193
+ json['gl:localizedConcept'][lang] = {
176
194
  '@id': `https://glossarist.org/test/concept/1/${lang}`,
177
195
  '@type': 'gl:LocalizedConcept',
178
196
  'gl:languageCode': lang,
@@ -184,13 +202,15 @@ describe('ConceptDetail interactions', () => {
184
202
  ],
185
203
  };
186
204
  }
187
- const wrapper = mountDetail(concept);
205
+ const wrapper = mountDetail(json);
206
+ await switchToDefinition(wrapper);
188
207
  await flushPromises();
189
208
  expect(wrapper.text()).toContain('6 languages');
190
209
  });
191
210
 
192
211
  it('toggles language section on click', async () => {
193
212
  const wrapper = mountDetail();
213
+ await switchToDefinition(wrapper);
194
214
  const buttons = wrapper.findAll('button');
195
215
  const fraButton = buttons.find(b => b.text().includes('French'));
196
216
  expect(fraButton).toBeDefined();
@@ -201,6 +221,7 @@ describe('ConceptDetail interactions', () => {
201
221
 
202
222
  it('switches between definition and history tabs', async () => {
203
223
  const wrapper = mountDetail();
224
+ await switchToDefinition(wrapper);
204
225
  expect(wrapper.text()).toContain('a definition with');
205
226
 
206
227
  const tabs = wrapper.findAll('button[role="tab"]');
@@ -213,9 +234,8 @@ describe('ConceptDetail interactions', () => {
213
234
  });
214
235
 
215
236
  it('renders cross-reference link and navigates on click', async () => {
216
- const concept = makeConcept();
217
- const eng = concept['gl:localizedConcept']!.eng!;
218
- eng['gl:definition'] = [
237
+ const json = makeConceptJson();
238
+ json['gl:localizedConcept'].eng['gl:definition'] = [
219
239
  { '@type': 'gl:DetailedDefinition', 'gl:content': 'see {{urn:iso:std:iso:14812:3.1.1.1,entity}} here' },
220
240
  ];
221
241
 
@@ -228,7 +248,8 @@ describe('ConceptDetail interactions', () => {
228
248
  });
229
249
  factory.resolver.registerDataset('test', ['https://glossarist.org/test/concept/*']);
230
250
 
231
- const wrapper = mountDetail(concept);
251
+ const wrapper = mountDetail(json);
252
+ await switchToDefinition(wrapper);
232
253
  await flushPromises();
233
254
 
234
255
  const xref = wrapper.find('.xref-link');
@@ -244,8 +265,9 @@ describe('ConceptDetail interactions', () => {
244
265
  expect(wrapper.text()).toContain('valid');
245
266
  });
246
267
 
247
- it('renders the language quick-jump sidebar with all languages', () => {
268
+ it('renders the language quick-jump sidebar with all languages', async () => {
248
269
  const wrapper = mountDetail();
270
+ await switchToDefinition(wrapper);
249
271
  expect(wrapper.text()).toContain('Languages (2)');
250
272
  });
251
273
  });
@@ -1,37 +1,33 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { FORMAT_REGISTRY, conceptToTurtle, conceptToSkosJsonLd } from '../utils/concept-formats';
3
- import type { ConceptDocument } from '../adapters/types';
3
+ import { Concept } from 'glossarist';
4
4
 
5
- function makeConcept(overrides: Partial<ConceptDocument> = {}): ConceptDocument {
6
- return {
7
- '@context': 'https://glossarist.org/ns/context.jsonld',
8
- '@id': 'https://glossarist.org/test/concept/1',
9
- '@type': 'gl:Concept',
10
- 'gl:identifier': '1',
11
- 'gl:localizedConcept': {
5
+ function makeConcept(overrides: Record<string, unknown> = {}): Concept {
6
+ return Concept.fromJSON({
7
+ id: '1',
8
+ uri: 'https://glossarist.org/test/concept/1',
9
+ localizations: {
12
10
  eng: {
13
- '@id': 'https://glossarist.org/test/concept/1/eng',
14
- '@type': 'gl:LocalizedConcept',
15
- 'gl:languageCode': 'eng',
16
- 'gl:designation': [
17
- { '@type': 'gl:Expression', 'gl:normativeStatus': 'preferred', 'gl:term': 'test term' },
18
- { '@type': 'gl:Expression', 'gl:normativeStatus': 'admitted', 'gl:term': 'alt term' },
11
+ language_code: 'eng',
12
+ entry_status: 'valid',
13
+ terms: [
14
+ { type: 'expression', designation: 'test term', normative_status: 'preferred' },
15
+ { type: 'expression', designation: 'alt term', normative_status: 'admitted' },
19
16
  ],
20
- 'gl:definition': [{ '@type': 'gl:DetailedDefinition', 'gl:content': 'a definition' }],
21
- 'gl:notes': [{ '@type': 'gl:DetailedDefinition', 'gl:content': 'a note' }],
17
+ definition: [{ content: 'a definition' }],
18
+ notes: [{ content: 'a note' }],
22
19
  },
23
20
  deu: {
24
- '@id': 'https://glossarist.org/test/concept/1/deu',
25
- '@type': 'gl:LocalizedConcept',
26
- 'gl:languageCode': 'deu',
27
- 'gl:designation': [
28
- { '@type': 'gl:Expression', 'gl:normativeStatus': 'preferred', 'gl:term': 'Testbegriff' },
21
+ language_code: 'deu',
22
+ entry_status: 'valid',
23
+ terms: [
24
+ { type: 'expression', designation: 'Testbegriff', normative_status: 'preferred' },
29
25
  ],
30
- 'gl:definition': [{ '@type': 'gl:DetailedDefinition', 'gl:content': 'eine Definition' }],
26
+ definition: [{ content: 'eine Definition' }],
31
27
  },
32
28
  },
33
29
  ...overrides,
34
- };
30
+ });
35
31
  }
36
32
 
37
33
  describe('FORMAT_REGISTRY', () => {
@@ -64,17 +60,24 @@ describe('conceptToTurtle', () => {
64
60
  });
65
61
 
66
62
  it('escapes special characters in Turtle', () => {
67
- const concept = makeConcept();
68
- concept['gl:localizedConcept']!.eng!['gl:definition'] = [
69
- { '@type': 'gl:DetailedDefinition', 'gl:content': 'has "quotes" and \\backslash' },
70
- ];
63
+ const concept = Concept.fromJSON({
64
+ id: '1',
65
+ uri: 'https://glossarist.org/test/concept/1',
66
+ localizations: {
67
+ eng: {
68
+ language_code: 'eng',
69
+ terms: [{ type: 'expression', designation: 'test', normative_status: 'preferred' }],
70
+ definition: [{ content: 'has "quotes" and \\backslash' }],
71
+ },
72
+ },
73
+ });
71
74
  const ttl = conceptToTurtle(concept);
72
75
  expect(ttl).toContain('\\"quotes\\"');
73
76
  expect(ttl).toContain('\\\\backslash');
74
77
  });
75
78
 
76
79
  it('handles empty concept gracefully', () => {
77
- const ttl = conceptToTurtle({} as ConceptDocument);
80
+ const ttl = conceptToTurtle(Concept.fromJSON({}));
78
81
  expect(ttl).toContain('a skos:Concept');
79
82
  expect(ttl).toContain('skos:notation ""');
80
83
  });
@@ -100,8 +103,7 @@ describe('conceptToSkosJsonLd', () => {
100
103
  });
101
104
 
102
105
  it('omits empty language maps', () => {
103
- const concept = makeConcept();
104
- concept['gl:localizedConcept'] = {};
106
+ const concept = Concept.fromJSON({ id: '1', uri: 'https://glossarist.org/test/concept/1' });
105
107
  const parsed = JSON.parse(conceptToSkosJsonLd(concept));
106
108
  expect(parsed['skos:prefLabel']).toBeUndefined();
107
109
  expect(parsed['skos:definition']).toBeUndefined();