@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
@@ -0,0 +1,397 @@
1
+ <script setup lang="ts">
2
+ import type { Concept, LocalizedConcept, Designation, Expression as ExpressionType, Abbreviation as AbbreviationType, GraphicalSymbol as GraphicalSymbolType } from 'glossarist';
3
+ import { computed, ref } from 'vue';
4
+ import { getClass } from '../adapters/ontology-schema';
5
+
6
+ const props = defineProps<{
7
+ concept: Concept;
8
+ registerId: string;
9
+ conceptUriValue: string;
10
+ }>();
11
+
12
+ const rdfFormat = ref<'turtle' | 'jsonld'>('turtle');
13
+ const showSource = ref(false);
14
+ const uriCopied = ref(false);
15
+
16
+ function copyUri() {
17
+ navigator.clipboard.writeText(props.conceptUriValue);
18
+ uriCopied.value = true;
19
+ setTimeout(() => { uriCopied.value = false; }, 2000);
20
+ }
21
+
22
+ function designationClassLabel(type: string): string {
23
+ const map: Record<string, string> = {
24
+ expression: 'gloss:Expression',
25
+ abbreviation: 'gloss:Abbreviation',
26
+ symbol: 'gloss:Symbol',
27
+ letter_symbol: 'gloss:LetterSymbol',
28
+ graphical_symbol: 'gloss:GraphicalSymbol',
29
+ };
30
+ return map[type] ?? 'gloss:Designation';
31
+ }
32
+
33
+ function desigSlug(designation: string, index: number): string {
34
+ const slug = designation.replace(/[^a-zA-Z0-9]/g, '_');
35
+ if (/^_+$/.test(slug)) return `d${index}`;
36
+ return slug;
37
+ }
38
+
39
+ function formatCitation(c: any): string {
40
+ if (!c) return '';
41
+ if (c.source && c.id) return `${c.source} ${c.id}`;
42
+ if (c.ref?.source) {
43
+ const r = c.ref;
44
+ return r.id ? `${r.source} ${r.id}` : r.source;
45
+ }
46
+ return '';
47
+ }
48
+
49
+ // ── Instance data extraction ─────────────────────────────────────────────
50
+
51
+ interface PropValue {
52
+ predicate: string;
53
+ values: string[];
54
+ nested?: boolean;
55
+ }
56
+
57
+ interface ClassInstance {
58
+ classId: string;
59
+ classLabel: string;
60
+ label: string;
61
+ props: PropValue[];
62
+ }
63
+
64
+ function conceptInstance(): ClassInstance {
65
+ const c = props.concept;
66
+ const pv: PropValue[] = [];
67
+ const add = (pred: string, ...vals: string[]) => {
68
+ const filtered = vals.filter(Boolean);
69
+ if (filtered.length) pv.push({ predicate: pred, values: filtered });
70
+ };
71
+ const addNested = (pred: string, ...vals: string[]) => {
72
+ const filtered = vals.filter(Boolean);
73
+ if (filtered.length) pv.push({ predicate: pred, values: filtered, nested: true });
74
+ };
75
+
76
+ add('gloss:identifier', c.id);
77
+ if (c.status) add('gloss:hasStatus', `gloss:status/${c.status}`);
78
+ for (const d of c.domains) addNested('gloss:hasDomain', d.conceptId || d.urn || '');
79
+ for (const s of c.sources) addNested('gloss:hasSource', formatCitation(s.origin));
80
+ for (const d of c.dates) addNested('gloss:hasDate', `${d.type}: ${d.date}`);
81
+ for (const r of c.relatedConcepts) {
82
+ const refLabel = r.content || (r.ref ? `${r.ref.source || ''} ${r.ref.id || ''}`.trim() : '');
83
+ addNested('gloss:hasRelatedConcept', `${r.type}: ${refLabel}`);
84
+ }
85
+ for (const lang of c.languages) addNested('gloss:hasLocalization', `${lang}: ${c.localization(lang)?.primaryDesignation ?? ''}`);
86
+
87
+ return { classId: 'gloss:Concept', classLabel: 'Concept', label: c.id, props: pv };
88
+ }
89
+
90
+ function localizedInstance(lc: LocalizedConcept): ClassInstance {
91
+ const pv: PropValue[] = [];
92
+ const add = (pred: string, ...vals: string[]) => {
93
+ const filtered = vals.filter(Boolean);
94
+ if (filtered.length) pv.push({ predicate: pred, values: filtered });
95
+ };
96
+ const addNested = (pred: string, ...vals: string[]) => {
97
+ const filtered = vals.filter(Boolean);
98
+ if (filtered.length) pv.push({ predicate: pred, values: filtered, nested: true });
99
+ };
100
+
101
+ add('dcterms:language', lc.languageCode ?? '');
102
+ if (lc.entryStatus) add('gloss:hasEntryStatus', `gloss:entstatus/${lc.entryStatus}`);
103
+ addNested('gloss:isLocalizationOf', props.conceptUriValue);
104
+ for (const d of lc.terms) addNested(d.normativeStatus === 'preferred' ? 'skosxl:prefLabel' : 'skosxl:altLabel', d.designation);
105
+ for (const d of lc.definitions) if (d.content) addNested('gloss:hasDefinition', d.content);
106
+ for (const n of lc.notes) if (n.content) addNested('gloss:hasNote', n.content);
107
+ for (const e of lc.examples) if (e.content) addNested('gloss:hasExample', e.content);
108
+ for (const s of lc.sources) addNested('gloss:hasSource', formatCitation(s.origin));
109
+ if (lc.domain) add('gloss:domain', lc.domain);
110
+
111
+ return {
112
+ classId: 'gloss:LocalizedConcept',
113
+ classLabel: 'LocalizedConcept',
114
+ label: `${lc.languageCode}: ${lc.primaryDesignation ?? ''}`,
115
+ props: pv,
116
+ };
117
+ }
118
+
119
+ function designationInstance(d: Designation): ClassInstance {
120
+ const pv: PropValue[] = [];
121
+ const add = (pred: string, ...vals: string[]) => {
122
+ const filtered = vals.filter(Boolean);
123
+ if (filtered.length) pv.push({ predicate: pred, values: filtered });
124
+ };
125
+
126
+ add('xl:literalForm', `${d.designation}${d.language ? '@' + d.language : ''}`);
127
+ if (d.normativeStatus) add('gloss:normativeStatus', `gloss:norm/${d.normativeStatus}`);
128
+ if (d.geographicalArea) add('gloss:geographicalArea', d.geographicalArea);
129
+ if (d.international) add('gloss:isInternational', 'true');
130
+ if (d.absent) add('gloss:isAbsent', 'true');
131
+ if (d.termType) add('gloss:hasTermType', d.termType);
132
+ for (const p of d.pronunciations ?? []) add('gloss:hasPronunciation', p.content || '');
133
+
134
+ if (d.type === 'expression' || d.type === 'abbreviation') {
135
+ const expr = d as ExpressionType;
136
+ if (expr.prefix) add('gloss:prefix', expr.prefix);
137
+ if (expr.usageInfo) add('gloss:usageInfo', expr.usageInfo);
138
+ if (expr.fieldOfApplication) add('gloss:fieldOfApplication', expr.fieldOfApplication);
139
+ for (const gi of expr.grammarInfo ?? []) {
140
+ const parts: string[] = [];
141
+ if (gi.gender) parts.push(`gender:${gi.gender}`);
142
+ if (gi.number) parts.push(`number:${gi.number}`);
143
+ if (gi.partOfSpeech) parts.push(`pos:${gi.partOfSpeech}`);
144
+ if (parts.length) add('gloss:hasGrammarInfo', parts.join(', '));
145
+ }
146
+ }
147
+
148
+ if (d.type === 'abbreviation') {
149
+ const abbr = d as AbbreviationType;
150
+ if (abbr.acronym) add('gloss:isAcronym', 'true');
151
+ if (abbr.initialism) add('gloss:isInitialism', 'true');
152
+ if (abbr.truncation) add('gloss:isTruncation', 'true');
153
+ }
154
+
155
+ if (d.type === 'graphical_symbol') {
156
+ const gs = d as GraphicalSymbolType;
157
+ if (gs.text) add('gloss:text', gs.text);
158
+ if (gs.image) add('gloss:image', gs.image);
159
+ }
160
+
161
+ return {
162
+ classId: designationClassLabel(d.type),
163
+ classLabel: designationClassLabel(d.type).replace('gloss:', ''),
164
+ label: d.designation,
165
+ props: pv,
166
+ };
167
+ }
168
+
169
+ // ── Build all sections ──────────────────────────────────────────────
170
+
171
+ const sections = computed<ClassInstance[]>(() => {
172
+ const result: ClassInstance[] = [];
173
+ result.push(conceptInstance());
174
+
175
+ for (const lang of props.concept.languages) {
176
+ const lc = props.concept.localization(lang);
177
+ if (!lc) continue;
178
+ result.push(localizedInstance(lc));
179
+ for (const d of lc.terms) {
180
+ result.push(designationInstance(d));
181
+ }
182
+ }
183
+
184
+ return result;
185
+ });
186
+
187
+ // ── Type chain for hierarchy ─────────────────────────────────────────
188
+
189
+ const typeChain = computed(() => {
190
+ const conceptCls = getClass('gloss:Concept');
191
+ if (!conceptCls) return ['owl:Thing', 'skos:Concept', 'gloss:Concept'];
192
+ return ['owl:Thing', ...conceptCls.ancestors, 'gloss:Concept'];
193
+ });
194
+
195
+ // ── Turtle source ────────────────────────────────────────────────────
196
+
197
+ const turtleSource = computed(() => {
198
+ const lines: string[] = [];
199
+ const ind = ' ';
200
+ const c = props.concept;
201
+ const uri = props.conceptUriValue;
202
+
203
+ lines.push('@prefix gloss: <https://www.glossarist.org/ontologies/> .');
204
+ lines.push('@prefix skos: <http://www.w3.org/2004/02/skos/core#> .');
205
+ lines.push('@prefix xl: <http://www.w3.org/2008/05/skos-xl#> .');
206
+ lines.push('@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .');
207
+ lines.push('@prefix dcterms: <http://purl.org/dc/terms/> .');
208
+ lines.push('');
209
+
210
+ lines.push(`<${uri}> a gloss:Concept, skos:Concept ;`);
211
+ lines.push(`${ind}gloss:identifier "${c.id}" ;`);
212
+ if (c.status) lines.push(`${ind}gloss:hasStatus gloss:status/${c.status} ;`);
213
+ for (const lang of c.languages) lines.push(`${ind}gloss:hasLocalization <${uri}/${lang}> ;`);
214
+ for (const r of c.relatedConcepts) {
215
+ lines.push(`${ind}gloss:hasRelatedConcept [`);
216
+ lines.push(`${ind}${ind}gloss:relationshipType gloss:rel/${r.type} ;`);
217
+ if (r.content) lines.push(`${ind}${ind}gloss:relationshipContent "${r.content}" ;`);
218
+ if (r.ref) {
219
+ if (r.ref.source) lines.push(`${ind}${ind}gloss:conceptSource "${r.ref.source}" ;`);
220
+ if (r.ref.id) lines.push(`${ind}${ind}gloss:conceptId "${r.ref.id}" ;`);
221
+ }
222
+ lines.push(`${ind}] ;`);
223
+ }
224
+ lines[lines.length - 1] = lines[lines.length - 1].replace(/ ;$/, ' .');
225
+
226
+ for (const lang of c.languages) {
227
+ const lc = c.localization(lang);
228
+ if (!lc) continue;
229
+ lines.push('');
230
+ lines.push(`<${uri}/${lang}> a gloss:LocalizedConcept, skos:Concept ;`);
231
+ lines.push(`${ind}dcterms:language "${lang}" ;`);
232
+ lines.push(`${ind}gloss:isLocalizationOf <${uri}> ;`);
233
+ if (lc.entryStatus) lines.push(`${ind}gloss:hasEntryStatus gloss:entstatus/${lc.entryStatus} ;`);
234
+ for (let di = 0; di < lc.terms.length; di++) {
235
+ const d = lc.terms[di];
236
+ const normPrefix = d.normativeStatus === 'preferred' ? 'skosxl:prefLabel' : 'skosxl:altLabel';
237
+ lines.push(`${ind}${normPrefix} <${uri}/${lang}/desig/${desigSlug(d.designation, di)}> ;`);
238
+ }
239
+ for (const def of lc.definitions) {
240
+ if (def.content) lines.push(`${ind}gloss:hasDefinition [ rdf:value "${def.content}" ] ;`);
241
+ }
242
+ lines[lines.length - 1] = lines[lines.length - 1].replace(/ ;$/, ' .');
243
+
244
+ for (let di = 0; di < lc.terms.length; di++) {
245
+ const d = lc.terms[di];
246
+ const desigUri = `${uri}/${lang}/desig/${desigSlug(d.designation, di)}`;
247
+ const dc = designationClassLabel(d.type);
248
+ lines.push('');
249
+ lines.push(`<${desigUri}> a ${dc}, xl:Label ;`);
250
+ lines.push(`${ind}xl:literalForm "${d.designation}"@${lang} ;`);
251
+ if (d.normativeStatus) lines.push(`${ind}gloss:normativeStatus gloss:norm/${d.normativeStatus} ;`);
252
+ lines[lines.length - 1] = lines[lines.length - 1].replace(/ ;$/, ' .');
253
+ }
254
+ }
255
+
256
+ return lines.join('\n');
257
+ });
258
+
259
+ // ── JSON-LD source ──────────────────────────────────────────────────
260
+
261
+ const jsonldSource = computed(() => {
262
+ const c = props.concept;
263
+ const uri = props.conceptUriValue;
264
+
265
+ const doc: any = {
266
+ '@context': {
267
+ gloss: 'https://www.glossarist.org/ontologies/',
268
+ skos: 'http://www.w3.org/2004/02/skos/core#',
269
+ xl: 'http://www.w3.org/2008/05/skos-xl#',
270
+ dcterms: 'http://purl.org/dc/terms/',
271
+ },
272
+ '@graph': [],
273
+ };
274
+
275
+ const conceptNode: any = {
276
+ '@id': uri,
277
+ '@type': ['gloss:Concept', 'skos:Concept'],
278
+ 'gloss:identifier': c.id,
279
+ };
280
+ if (c.status) conceptNode['gloss:hasStatus'] = { '@id': `gloss:status/${c.status}` };
281
+ conceptNode['gloss:hasLocalization'] = c.languages.map(l => ({ '@id': `${uri}/${l}` }));
282
+ doc['@graph'].push(conceptNode);
283
+
284
+ for (const lang of c.languages) {
285
+ const lc = c.localization(lang);
286
+ if (!lc) continue;
287
+ const lcNode: any = {
288
+ '@id': `${uri}/${lang}`,
289
+ '@type': ['gloss:LocalizedConcept', 'skos:Concept'],
290
+ 'dcterms:language': lang,
291
+ 'gloss:isLocalizationOf': { '@id': uri },
292
+ };
293
+ if (lc.entryStatus) lcNode['gloss:hasEntryStatus'] = { '@id': `gloss:entstatus/${lc.entryStatus}` };
294
+ for (let di = 0; di < lc.terms.length; di++) {
295
+ const d = lc.terms[di];
296
+ const key = d.normativeStatus === 'preferred' ? 'skosxl:prefLabel' : 'skosxl:altLabel';
297
+ lcNode[key] = lcNode[key] || [];
298
+ lcNode[key].push({ '@id': `${uri}/${lang}/desig/${desigSlug(d.designation, di)}` });
299
+ }
300
+ doc['@graph'].push(lcNode);
301
+
302
+ for (let di = 0; di < lc.terms.length; di++) {
303
+ const d = lc.terms[di];
304
+ const desigUri = `${uri}/${lang}/desig/${desigSlug(d.designation, di)}`;
305
+ const desigNode: any = {
306
+ '@id': desigUri,
307
+ '@type': [designationClassLabel(d.type), 'xl:Label'],
308
+ 'xl:literalForm': { '@value': d.designation, '@language': lang },
309
+ };
310
+ if (d.normativeStatus) desigNode['gloss:normativeStatus'] = { '@id': `gloss:norm/${d.normativeStatus}` };
311
+ doc['@graph'].push(desigNode);
312
+ }
313
+ }
314
+
315
+ return JSON.stringify(doc, null, 2);
316
+ });
317
+
318
+ const rdfSource = computed(() => rdfFormat.value === 'turtle' ? turtleSource.value : jsonldSource.value);
319
+ </script>
320
+
321
+ <template>
322
+ <div class="space-y-6">
323
+ <!-- Instance header -->
324
+ <div class="card p-5">
325
+ <div class="flex items-start justify-between gap-3">
326
+ <div class="min-w-0">
327
+ <div class="text-[10px] uppercase tracking-widest text-ink-300 font-medium mb-2">RDF Instance</div>
328
+ <div class="flex items-center gap-2 flex-wrap">
329
+ <code class="text-sm font-mono text-ink-700 break-all">{{ conceptUriValue }}</code>
330
+ <button @click="copyUri" class="p-1.5 rounded text-ink-300 hover:text-ink-600 hover:bg-ink-50 transition-colors flex-shrink-0" :title="uriCopied ? 'Copied!' : 'Copy URI'">
331
+ <svg v-if="!uriCopied" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10a2 2 0 01-2-2v-1m6 4v-3a2 2 0 00-2-2H8"/></svg>
332
+ <svg v-else class="w-3.5 h-3.5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
333
+ </button>
334
+ </div>
335
+ <div class="flex gap-1.5 mt-2.5">
336
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-[11px] font-medium bg-blue-50 text-blue-700 border border-blue-100">gloss:Concept</span>
337
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-[11px] font-medium bg-emerald-50 text-emerald-700 border border-emerald-100">skos:Concept</span>
338
+ </div>
339
+ </div>
340
+ </div>
341
+
342
+ <!-- Mini hierarchy -->
343
+ <div class="mt-4 pt-3 border-t border-ink-100/60">
344
+ <div class="flex items-center gap-1.5 flex-wrap text-xs text-ink-400">
345
+ <template v-for="(t, i) in typeChain" :key="i">
346
+ <span v-if="i > 0" class="text-ink-200 mx-0.5">→</span>
347
+ <code class="text-[11px] text-ink-400">{{ t }}</code>
348
+ </template>
349
+ <span class="text-ink-200 mx-0.5">→</span>
350
+ <code class="text-[11px] text-ink-700 font-semibold bg-ink-50 px-1.5 py-0.5 rounded">{{ concept.id }}</code>
351
+ </div>
352
+ </div>
353
+ </div>
354
+
355
+ <!-- Property-value panels per class -->
356
+ <div v-for="(section, si) in sections" :key="si" class="card p-5">
357
+ <div class="flex items-center gap-2 mb-3">
358
+ <div class="w-1 h-4 rounded-full" :class="section.classId === 'gloss:Concept' ? 'bg-blue-500' : section.classId === 'gloss:LocalizedConcept' ? 'bg-emerald-500' : 'bg-amber-500'"></div>
359
+ <code class="text-xs font-semibold text-ink-700">{{ section.classId }}</code>
360
+ <span class="text-xs text-ink-400">·</span>
361
+ <span class="text-xs text-ink-500">{{ section.label }}</span>
362
+ </div>
363
+
364
+ <div class="space-y-1.5">
365
+ <div v-for="prop in section.props" :key="prop.predicate" class="grid grid-cols-[160px_1fr] gap-x-3 gap-y-0.5 py-1.5 border-b border-ink-100/30 last:border-0">
366
+ <code class="text-xs text-blue-600 font-medium leading-relaxed self-start pt-0.5">{{ prop.predicate }}</code>
367
+ <div class="flex flex-col gap-0.5">
368
+ <template v-for="(val, vi) in prop.values" :key="vi">
369
+ <span v-if="prop.nested" class="text-xs text-ink-600 bg-ink-50/60 px-2 py-1 rounded border-l-2 border-ink-200 leading-relaxed break-words">{{ val }}</span>
370
+ <span v-else class="text-xs text-ink-600 leading-relaxed break-words">{{ val }}</span>
371
+ </template>
372
+ </div>
373
+ </div>
374
+ </div>
375
+ </div>
376
+
377
+ <!-- RDF Source panel -->
378
+ <div class="card overflow-hidden">
379
+ <button @click="showSource = !showSource" class="w-full flex items-center justify-between px-5 py-3.5 text-left hover:bg-ink-50/30 transition-colors">
380
+ <div class="flex items-center gap-2">
381
+ <svg class="w-4 h-4 text-ink-400 transition-transform" :class="showSource ? 'rotate-90' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
382
+ <span class="text-sm font-medium text-ink-700">RDF Source</span>
383
+ </div>
384
+ <div class="flex items-center gap-2">
385
+ <select v-model="rdfFormat" @click.stop class="text-xs border border-ink-200 rounded px-2 py-1 bg-surface text-ink-600 focus:outline-none focus:ring-1 focus:ring-blue-400">
386
+ <option value="turtle">Turtle</option>
387
+ <option value="jsonld">JSON-LD</option>
388
+ </select>
389
+ <span class="text-[10px] text-ink-300">{{ sections.length }} resources</span>
390
+ </div>
391
+ </button>
392
+ <div v-if="showSource" class="border-t border-ink-100/60">
393
+ <pre class="p-4 text-xs font-mono text-ink-700 bg-ink-50/30 overflow-x-auto leading-relaxed max-h-[600px] overflow-y-auto">{{ rdfSource }}</pre>
394
+ </div>
395
+ </div>
396
+ </div>
397
+ </template>
@@ -1,10 +1,11 @@
1
1
  <script setup lang="ts">
2
- import type { LocalizedConcept } from '../adapters/types';
2
+ import type { Concept, LocalizedConcept } from 'glossarist';
3
3
  import { computed } from 'vue';
4
4
  import { langName, langLabel } from '../utils/lang';
5
+ import { entryStatusColor } from '../utils/concept-helpers';
5
6
 
6
7
  const props = defineProps<{
7
- localizedConcepts: Record<string, LocalizedConcept>;
8
+ concept: Concept;
8
9
  activeLang: string;
9
10
  languageOrder?: string[];
10
11
  }>();
@@ -22,7 +23,7 @@ interface TimelineEntry {
22
23
  lang: string;
23
24
  }
24
25
 
25
- const currentLc = computed(() => props.localizedConcepts[props.activeLang]);
26
+ const currentLc = computed(() => props.concept.localization(props.activeLang));
26
27
 
27
28
  // Build timeline entries from the localized concept review/history fields
28
29
  const timelineEntries = computed((): TimelineEntry[] => {
@@ -31,29 +32,28 @@ const timelineEntries = computed((): TimelineEntry[] => {
31
32
 
32
33
  const entries: TimelineEntry[] = [];
33
34
 
34
- // gl:dates array — most structured source
35
- if (lc['gl:dates']?.length) {
36
- for (const d of lc['gl:dates']) {
37
- const dateType = d['gl:dateType'] || 'unknown';
38
- const dateStr = d['gl:date'] || '';
39
- entries.push({
40
- date: dateStr,
41
- dateShort: formatDate(dateStr),
42
- year: extractYear(dateStr),
43
- eventType: dateType,
44
- description: dateTypeLabel(dateType),
45
- lang: props.activeLang,
46
- });
47
- }
35
+ // dates array — most structured source
36
+ for (const d of lc.dates) {
37
+ const dateType = d.type || 'unknown';
38
+ const dateStr = d.date || '';
39
+ entries.push({
40
+ date: dateStr,
41
+ dateShort: formatDate(dateStr),
42
+ year: extractYear(dateStr),
43
+ eventType: dateType,
44
+ description: dateTypeLabel(dateType),
45
+ lang: props.activeLang,
46
+ });
48
47
  }
49
48
 
50
- // Review date
51
- if (lc['gl:reviewDate']) {
52
- if (!entries.some(e => e.date === lc['gl:reviewDate'])) {
49
+ // Review date (from LocalizedConcept model)
50
+ const reviewDate = lc.reviewDate;
51
+ if (reviewDate) {
52
+ if (!entries.some(e => e.date === reviewDate)) {
53
53
  entries.push({
54
- date: lc['gl:reviewDate'],
55
- dateShort: formatDate(lc['gl:reviewDate']),
56
- year: extractYear(lc['gl:reviewDate']),
54
+ date: reviewDate,
55
+ dateShort: formatDate(reviewDate),
56
+ year: extractYear(reviewDate),
57
57
  eventType: 'review',
58
58
  description: 'Review initiated',
59
59
  lang: props.activeLang,
@@ -62,14 +62,15 @@ const timelineEntries = computed((): TimelineEntry[] => {
62
62
  }
63
63
 
64
64
  // Review decision date
65
- if (lc['gl:reviewDecisionDate']) {
66
- if (!entries.some(e => e.date === lc['gl:reviewDecisionDate'] && e.eventType !== 'review')) {
65
+ const reviewDecisionDate = lc.reviewDecisionDate;
66
+ if (reviewDecisionDate) {
67
+ if (!entries.some(e => e.date === reviewDecisionDate && e.eventType !== 'review')) {
67
68
  entries.push({
68
- date: lc['gl:reviewDecisionDate'],
69
- dateShort: formatDate(lc['gl:reviewDecisionDate']),
70
- year: extractYear(lc['gl:reviewDecisionDate']),
69
+ date: reviewDecisionDate,
70
+ dateShort: formatDate(reviewDecisionDate),
71
+ year: extractYear(reviewDecisionDate),
71
72
  eventType: 'decision',
72
- description: lc['gl:reviewDecisionEvent'] || 'Review decision',
73
+ description: lc.reviewDecisionEvent || 'Review decision',
73
74
  lang: props.activeLang,
74
75
  });
75
76
  }
@@ -106,11 +107,14 @@ const reviewMeta = computed(() => {
106
107
  const lc = currentLc.value;
107
108
  if (!lc) return null;
108
109
  const fields: { key: string; label: string; value: string }[] = [];
109
- if (lc['gl:reviewStatus']) fields.push({ key: 'status', label: 'Review Status', value: lc['gl:reviewStatus'] });
110
- if (lc['gl:reviewDecision']) fields.push({ key: 'decision', label: 'Decision', value: lc['gl:reviewDecision'] });
111
- if (lc['gl:reviewDecisionNotes']) fields.push({ key: 'notes', label: 'Change Notes', value: lc['gl:reviewDecisionNotes'] });
112
- if (lc['gl:entryStatus']) fields.push({ key: 'entry', label: 'Entry Status', value: lc['gl:entryStatus'] });
113
- if (lc['gl:release'] != null) fields.push({ key: 'release', label: 'Release', value: String(lc['gl:release']) });
110
+ const reviewStatus = lc.reviewStatus;
111
+ const reviewDecision = lc.reviewDecision;
112
+ const reviewDecisionNotes = lc.reviewDecisionNotes;
113
+ if (reviewStatus) fields.push({ key: 'status', label: 'Review Status', value: reviewStatus });
114
+ if (reviewDecision) fields.push({ key: 'decision', label: 'Decision', value: reviewDecision });
115
+ if (reviewDecisionNotes) fields.push({ key: 'notes', label: 'Change Notes', value: reviewDecisionNotes });
116
+ if (lc.entryStatus) fields.push({ key: 'entry', label: 'Entry Status', value: lc.entryStatus });
117
+ if (lc.release != null) fields.push({ key: 'release', label: 'Release', value: String(lc.release) });
114
118
  return fields.length ? fields : null;
115
119
  });
116
120
 
@@ -118,19 +122,21 @@ const reviewMeta = computed(() => {
118
122
  const reviewEvent = computed(() => {
119
123
  const lc = currentLc.value;
120
124
  if (!lc) return null;
121
- return lc['gl:reviewDecisionEvent'] || null;
125
+ return lc.reviewDecisionEvent || null;
122
126
  });
123
127
 
124
128
  // Which languages have any history data
125
129
  const languagesWithHistory = computed(() => {
126
130
  const langs: string[] = [];
127
- for (const [lang, lc] of Object.entries(props.localizedConcepts)) {
131
+ for (const lang of props.concept.languages) {
132
+ const lc = props.concept.localization(lang);
133
+ if (!lc) continue;
128
134
  if (
129
- lc['gl:dates']?.length ||
130
- lc['gl:reviewDate'] ||
131
- lc['gl:reviewDecisionDate'] ||
132
- lc['gl:reviewDecisionEvent'] ||
133
- lc['gl:reviewDecisionNotes']
135
+ lc.dates.length ||
136
+ lc.reviewDate ||
137
+ lc.reviewDecisionDate ||
138
+ lc.reviewDecisionEvent ||
139
+ lc.reviewDecisionNotes
134
140
  ) {
135
141
  langs.push(lang);
136
142
  }
@@ -151,7 +157,7 @@ const languagesWithHistory = computed(() => {
151
157
  });
152
158
 
153
159
  function formatDate(isoDate: string): string {
154
- if (!isoDate) return '\u2014';
160
+ if (!isoDate) return '';
155
161
  try {
156
162
  const d = new Date(isoDate);
157
163
  return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
@@ -216,30 +222,21 @@ function eventRingColor(type: string): string {
216
222
  return colors[type] || 'ring-ink-100';
217
223
  }
218
224
 
219
- function entryStatusColor(status: string): string {
220
- if (status === 'valid' || status === 'Standard') return 'badge-green';
221
- if (status === 'superseded') return 'bg-red-50 text-red-700';
222
- if (status === 'withdrawn') return 'bg-red-100 text-red-800';
223
- if (status === 'draft') return 'badge-yellow';
224
- return 'badge-gray';
225
- }
226
-
227
225
  function eventIconPath(type: string): string {
228
- // Returns an SVG path for the event type icon
229
226
  switch (type) {
230
227
  case 'accepted':
231
- return 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'; // circle check
228
+ return 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z';
232
229
  case 'amended':
233
- return 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z'; // pencil edit
230
+ return 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z';
234
231
  case 'superseded':
235
232
  case 'withdrawn':
236
- return 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z'; // warning
233
+ return 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z';
237
234
  case 'decision':
238
- return 'M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z'; // badge check
235
+ return 'M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z';
239
236
  case 'review':
240
- return 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'; // search
237
+ return 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z';
241
238
  default:
242
- return 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'; // info
239
+ return 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z';
243
240
  }
244
241
  }
245
242
  </script>
@@ -278,7 +275,7 @@ function eventIconPath(type: string): string {
278
275
  v-if="reviewEvent && hasHistory"
279
276
  class="card p-4 flex items-start gap-3 border-l-2"
280
277
  :class="[
281
- currentLc?.['gl:entryStatus'] === 'superseded' ? 'border-l-red-400' : 'border-l-purple-400'
278
+ currentLc?.entryStatus === 'superseded' ? 'border-l-red-400' : 'border-l-purple-400'
282
279
  ]"
283
280
  >
284
281
  <div class="w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5"
@@ -290,8 +287,8 @@ function eventIconPath(type: string): string {
290
287
  </div>
291
288
  <div class="min-w-0">
292
289
  <div class="text-sm font-medium text-ink-800">{{ reviewEvent }}</div>
293
- <div v-if="currentLc?.['gl:reviewDecisionDate']" class="text-xs text-ink-300 mt-0.5">
294
- {{ formatDate(currentLc['gl:reviewDecisionDate']) }}
290
+ <div v-if="currentLc?.reviewDecisionDate" class="text-xs text-ink-300 mt-0.5">
291
+ {{ formatDate(currentLc.reviewDecisionDate) }}
295
292
  </div>
296
293
  </div>
297
294
  </div>