@glossarist/concept-browser 0.7.51 → 0.7.53

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 (159) hide show
  1. package/cli/index.mjs +32 -0
  2. package/env.d.ts +15 -0
  3. package/package.json +12 -2
  4. package/scripts/__tests__/doctor.test.mjs +147 -0
  5. package/scripts/doctor.mjs +327 -0
  6. package/scripts/generate-data.mjs +136 -0
  7. package/scripts/generate-ontology-data.mjs +3 -3
  8. package/scripts/generate-ontology-schema.mjs +3 -3
  9. package/scripts/lib/agents-turtle.mjs +64 -0
  10. package/scripts/lib/bibliography-turtle.mjs +54 -0
  11. package/scripts/lib/build-activity-turtle.mjs +92 -0
  12. package/scripts/lib/build-cache.mjs +70 -0
  13. package/scripts/lib/dataset-turtle.mjs +79 -0
  14. package/scripts/lib/turtle-escape.mjs +0 -0
  15. package/scripts/lib/version-turtle.mjs +56 -0
  16. package/scripts/lib/vocab-turtle.mjs +64 -0
  17. package/scripts/normalize-yaml.mjs +99 -0
  18. package/scripts/sync-concept-model.mjs +86 -0
  19. package/scripts/validate-shacl.mjs +194 -0
  20. package/src/App.vue +2 -0
  21. package/src/__fixtures__/concept-shape.ttl +20 -0
  22. package/src/__fixtures__/shacl/bad/concept.ttl +7 -0
  23. package/src/__fixtures__/shacl/empty/.gitkeep +0 -0
  24. package/src/__fixtures__/shacl/good/concept.ttl +8 -0
  25. package/src/__tests__/__fixtures__/concepts.ts +221 -0
  26. package/src/__tests__/adapters/concept-identity.test.ts +76 -0
  27. package/src/__tests__/components/error-boundary.test.ts +109 -0
  28. package/src/__tests__/composables/use-dataset-series.test.ts +262 -0
  29. package/src/__tests__/concept-rdf/agents-emitter.test.ts +110 -0
  30. package/src/__tests__/concept-rdf/bibliography-emitter.test.ts +159 -0
  31. package/src/__tests__/concept-rdf/build-activity-emitter.test.ts +119 -0
  32. package/src/__tests__/concept-rdf/coerce-date.test.ts +97 -0
  33. package/src/__tests__/concept-rdf/concept-emitter.test.ts +258 -0
  34. package/src/__tests__/concept-rdf/dataset-emitter.test.ts +224 -0
  35. package/src/__tests__/concept-rdf/differential.test.ts +96 -0
  36. package/src/__tests__/concept-rdf/group-emitter.test.ts +109 -0
  37. package/src/__tests__/concept-rdf/image-variant-emitter.test.ts +135 -0
  38. package/src/__tests__/concept-rdf/jsonld-writer.test.ts +109 -0
  39. package/src/__tests__/concept-rdf/nonverbal-rep.test.ts +177 -0
  40. package/src/__tests__/concept-rdf/property-based.test.ts +179 -0
  41. package/src/__tests__/concept-rdf/provenance.test.ts +110 -0
  42. package/src/__tests__/concept-rdf/quad-isomorphism.test.ts +43 -0
  43. package/src/__tests__/concept-rdf/quad-isomorphism.ts +47 -0
  44. package/src/__tests__/concept-rdf/rdf-components.test.ts +145 -0
  45. package/src/__tests__/concept-rdf/rdf-graph.test.ts +115 -0
  46. package/src/__tests__/concept-rdf/round-trip.test.ts +243 -0
  47. package/src/__tests__/concept-rdf/scoped-examples.test.ts +126 -0
  48. package/src/__tests__/concept-rdf/sections-builder.test.ts +94 -0
  49. package/src/__tests__/concept-rdf/shacl-conformance.test.ts +110 -0
  50. package/src/__tests__/concept-rdf/shape-consistency.test.ts +138 -0
  51. package/src/__tests__/concept-rdf/snapshot-generation.test.ts +75 -0
  52. package/src/__tests__/concept-rdf/table-formula-emitter.test.ts +142 -0
  53. package/src/__tests__/concept-rdf/turtle-writer.test.ts +114 -0
  54. package/src/__tests__/concept-rdf/use-rdf-document.test.ts +246 -0
  55. package/src/__tests__/concept-rdf/version-emitter.test.ts +120 -0
  56. package/src/__tests__/concept-rdf/vocabulary-ssot.test.ts +100 -0
  57. package/src/__tests__/concept-rdf-view.test.ts +136 -0
  58. package/src/__tests__/config/group-renderers.test.ts +35 -0
  59. package/src/__tests__/config/group-types.test.ts +76 -0
  60. package/src/__tests__/dataset-style.test.ts +12 -7
  61. package/src/__tests__/errors/errors.test.ts +142 -0
  62. package/src/__tests__/format-downloads.test.ts +47 -65
  63. package/src/__tests__/markdown-lite.test.ts +19 -0
  64. package/src/__tests__/perf/bundle-layout.test.ts +50 -0
  65. package/src/__tests__/perf/serialization-perf.test.ts +121 -0
  66. package/src/__tests__/scripts/agents-turtle.test.ts +61 -0
  67. package/src/__tests__/scripts/bibliography-turtle.test.ts +59 -0
  68. package/src/__tests__/scripts/build-activity-turtle.test.ts +75 -0
  69. package/src/__tests__/scripts/build-cache.test.ts +78 -0
  70. package/src/__tests__/scripts/build-integration.test.ts +134 -0
  71. package/src/__tests__/scripts/dataset-turtle.test.ts +94 -0
  72. package/src/__tests__/scripts/normalize-yaml.test.ts +98 -0
  73. package/src/__tests__/scripts/stryker-config.test.ts +33 -0
  74. package/src/__tests__/scripts/turtle-escape.test.ts +63 -0
  75. package/src/__tests__/scripts/version-turtle.test.ts +72 -0
  76. package/src/__tests__/scripts/vocab-turtle.test.ts +63 -0
  77. package/src/__tests__/use-format-registry.test.ts +125 -0
  78. package/src/__tests__/utils/bcp47.test.ts +166 -0
  79. package/src/__tests__/utils/color-theme.test.ts +143 -0
  80. package/src/__tests__/utils/url-safety.test.ts +65 -0
  81. package/src/__tests__/validate-shacl.test.ts +100 -0
  82. package/src/adapters/DatasetAdapter.ts +11 -5
  83. package/src/adapters/GraphDataSource.ts +2 -1
  84. package/src/adapters/UriRouter.ts +2 -1
  85. package/src/adapters/concept-identity.ts +69 -0
  86. package/src/adapters/factory.ts +3 -2
  87. package/src/adapters/model-bridge.ts +2 -1
  88. package/src/adapters/non-verbal/glossarist-augment.d.ts +7 -0
  89. package/src/adapters/non-verbal-resolver.ts +2 -1
  90. package/src/components/AppSidebar.vue +189 -93
  91. package/src/components/ConceptDetail.vue +8 -0
  92. package/src/components/ConceptEditionRail.vue +222 -0
  93. package/src/components/ConceptRdfView.vue +37 -377
  94. package/src/components/DatasetSeriesCard.vue +270 -0
  95. package/src/components/ErrorBoundary.vue +95 -0
  96. package/src/components/FormatDownloads.vue +17 -13
  97. package/src/components/HomeSeriesSection.vue +277 -0
  98. package/src/components/RelationSphere.vue +1672 -0
  99. package/src/components/SidebarSeriesSection.vue +239 -0
  100. package/src/components/concept-rdf/RdfInstanceHeader.vue +47 -0
  101. package/src/components/concept-rdf/RdfInstanceSection.vue +54 -0
  102. package/src/components/concept-rdf/RdfPrefixLegend.vue +27 -0
  103. package/src/components/concept-rdf/RdfSourcePanel.vue +72 -0
  104. package/src/components/concept-rdf/agents-emitter.ts +82 -0
  105. package/src/components/concept-rdf/bibliography-emitter.ts +83 -0
  106. package/src/components/concept-rdf/build-activity-emitter.ts +89 -0
  107. package/src/components/concept-rdf/concept-emitter.ts +443 -0
  108. package/src/components/concept-rdf/dataset-emitter.ts +95 -0
  109. package/src/components/concept-rdf/group-emitter.ts +69 -0
  110. package/src/components/concept-rdf/image-variant-emitter.ts +46 -0
  111. package/src/components/concept-rdf/jsonld-writer.ts +82 -0
  112. package/src/components/concept-rdf/predicates.ts +261 -0
  113. package/src/components/concept-rdf/provenance.ts +80 -0
  114. package/src/components/concept-rdf/rdf-graph.ts +211 -0
  115. package/src/components/concept-rdf/rdf-prefixes.ts +23 -0
  116. package/src/components/concept-rdf/sections-builder.ts +62 -0
  117. package/src/components/concept-rdf/table-formula-emitter.ts +101 -0
  118. package/src/components/concept-rdf/turtle-writer.ts +116 -0
  119. package/src/components/concept-rdf/use-rdf-document.ts +72 -0
  120. package/src/components/concept-rdf/version-emitter.ts +65 -0
  121. package/src/components/concept-rdf/vocabulary-emitter.ts +62 -0
  122. package/src/components/groups/DatasetGroupRenderer.vue +32 -0
  123. package/src/components/groups/DefaultGroupSidebar.vue +50 -0
  124. package/src/components/groups/LineageGroupSidebar.vue +75 -0
  125. package/src/composables/use-color-theme.ts +82 -0
  126. package/src/composables/use-format-registry.ts +42 -0
  127. package/src/composables/useDatasetSeries.ts +258 -0
  128. package/src/composables/useSphereProjection.ts +125 -0
  129. package/src/config/group-renderers.ts +27 -0
  130. package/src/config/group-types.ts +92 -0
  131. package/src/config/types.ts +81 -2
  132. package/src/config/use-site-config.ts +2 -1
  133. package/src/errors.ts +136 -0
  134. package/src/i18n/locales/eng.yml +24 -0
  135. package/src/i18n/locales/fra.yml +24 -0
  136. package/src/stores/vocabulary.ts +3 -1
  137. package/src/style.css +17 -2
  138. package/src/types/agents-version-turtle.d.ts +27 -0
  139. package/src/types/bibliography-turtle.d.ts +12 -0
  140. package/src/types/build-activity-turtle.d.ts +16 -0
  141. package/src/types/build-cache.d.ts +20 -0
  142. package/src/types/dataset-turtle.d.ts +32 -0
  143. package/src/types/normalize-yaml.d.ts +16 -0
  144. package/src/types/turtle-escape.d.ts +6 -0
  145. package/src/types/vocab-turtle.d.ts +13 -0
  146. package/src/utils/asciidoc-lite.ts +11 -6
  147. package/src/utils/bcp47.ts +141 -0
  148. package/src/utils/color-theme-integration.ts +11 -0
  149. package/src/utils/color-theme.ts +129 -0
  150. package/src/utils/dataset-style.ts +31 -6
  151. package/src/utils/locale.ts +6 -14
  152. package/src/utils/markdown-lite.ts +6 -1
  153. package/src/utils/relation-sphere-styling.ts +63 -0
  154. package/src/utils/relationship-categories.ts +30 -0
  155. package/src/utils/url-safety.ts +30 -0
  156. package/src/views/ConceptView.vue +187 -9
  157. package/src/views/DatasetView.vue +6 -0
  158. package/src/views/HomeView.vue +5 -0
  159. package/vite.config.ts +7 -0
@@ -12,6 +12,10 @@ import { toSectionTree } from '../utils/section-tree';
12
12
  import { formatSectionLabel, sectionName as sectionLocalized } from '../utils/section-display';
13
13
 
14
14
  const OntologySidebarSection = defineAsyncComponent(() => import('./OntologySidebarSection.vue'));
15
+ import { resolveGroupKind } from '../config/group-types';
16
+ import type { DatasetGroupKind } from '../config/types';
17
+ import { useDatasetSeries } from '../composables/useDatasetSeries';
18
+ const useDatasetSeriesRef = () => useDatasetSeries().series;
15
19
 
16
20
  const store = useVocabularyStore();
17
21
  const ui = useUiStore();
@@ -28,12 +32,13 @@ const isOntologyRoute = computed(() =>
28
32
  );
29
33
 
30
34
  const datasetEntries = computed(() => {
31
- const entries: { id: string; title: string; loaded: boolean; conceptCount: number }[] = [];
35
+ const entries: { id: string; title: string; ref?: string; loaded: boolean; conceptCount: number }[] = [];
32
36
  for (const [id, adapter] of store.datasets) {
33
37
  const m = store.manifests.get(id);
34
38
  entries.push({
35
39
  id,
36
40
  title: m?.title ?? id.toUpperCase(),
41
+ ref: m?.ref,
37
42
  loaded: !!m,
38
43
  conceptCount: m?.conceptCount ?? 0,
39
44
  });
@@ -49,8 +54,9 @@ interface SidebarGroup {
49
54
  id: string;
50
55
  label: string;
51
56
  description?: string;
52
- color?: string;
53
- entries: { id: string; title: string; loaded: boolean; conceptCount: number }[];
57
+ color?: string | { light: string; dark: string };
58
+ kind: DatasetGroupKind;
59
+ entries: { id: string; title: string; ref?: string; loaded: boolean; conceptCount: number; year?: number; status?: string; isCurrent?: boolean }[];
54
60
  }
55
61
 
56
62
  const groupedDatasetEntries = computed<SidebarGroup[]>(() => {
@@ -61,10 +67,29 @@ const groupedDatasetEntries = computed<SidebarGroup[]>(() => {
61
67
  const assigned = new Set<string>();
62
68
  const result: SidebarGroup[] = [];
63
69
 
70
+ /* Build a quick lookup of series metadata (year, status) from manifests */
71
+ const seriesMeta = new Map<string, { year?: number; status?: string; isCurrent?: boolean }>();
72
+ for (const s of seriesList.value) {
73
+ for (const m of s.members) {
74
+ seriesMeta.set(m.id, { year: m.year, status: m.status, isCurrent: m.isCurrent });
75
+ }
76
+ }
77
+
64
78
  for (const g of groups) {
79
+ const kind = resolveGroupKind(g);
65
80
  const entries = g.datasets
66
- .map(id => entryMap.get(id))
67
- .filter((e): e is typeof entryMap extends Map<string, infer V> ? V : never => !!e);
81
+ .map(id => {
82
+ const e = entryMap.get(id);
83
+ if (!e) return null;
84
+ const meta = seriesMeta.get(id);
85
+ return {
86
+ ...e,
87
+ year: meta?.year,
88
+ status: meta?.status,
89
+ isCurrent: meta?.isCurrent,
90
+ };
91
+ })
92
+ .filter((e): e is NonNullable<typeof e> => e !== null);
68
93
  for (const e of entries) assigned.add(e.id);
69
94
  const trLabel = g.translations?.[locale.value]?.label;
70
95
  result.push({
@@ -72,18 +97,22 @@ const groupedDatasetEntries = computed<SidebarGroup[]>(() => {
72
97
  label: trLabel || g.label,
73
98
  description: g.description,
74
99
  color: g.color,
100
+ kind,
75
101
  entries,
76
102
  });
77
103
  }
78
104
 
79
105
  const ungrouped = datasetEntries.value.filter(e => !assigned.has(e.id));
80
106
  if (ungrouped.length) {
81
- result.push({ id: '__ungrouped__', label: '', entries: ungrouped });
107
+ result.push({ id: '__ungrouped__', label: '', kind: 'default', entries: ungrouped });
82
108
  }
83
109
 
84
110
  return result;
85
111
  });
86
112
 
113
+ /* Auto-derive series list from useDatasetSeries — used to enrich entries */
114
+ const seriesList = useDatasetSeriesRef();
115
+
87
116
  const collapsedGroups = ref<Set<string>>(new Set());
88
117
 
89
118
  function toggleGroup(groupId: string) {
@@ -246,110 +275,140 @@ const activeSectionId = computed(() => {
246
275
  <button
247
276
  v-if="group.label"
248
277
  @click="toggleGroup(group.id)"
249
- class="sidebar-group-label w-full flex items-start gap-1.5 px-2 py-1.5 rounded-lg text-xs font-semibold transition-colors hover:bg-ink-50"
250
- :style="group.color ? { color: group.color } : {}"
278
+ class="sidebar-group-label w-full flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-xs font-semibold transition-colors hover:bg-ink-50 dark:hover:bg-ink-700/60"
251
279
  >
252
- <span class="w-3 text-[10px] mt-0.5 flex-shrink-0">{{ isGroupExpanded(group.id) ? '▾' : '▸' }}</span>
253
- <span class="flex-1 text-left leading-snug">{{ group.label }}</span>
280
+ <span class="w-3 text-[10px] mt-0.5 flex-shrink-0 text-ink-300 dark:text-ink-400">{{ isGroupExpanded(group.id) ? '▾' : '▸' }}</span>
281
+ <span class="flex-1 text-left leading-snug text-ink-700 dark:text-ink-200 font-serif">{{ group.label }}</span>
254
282
  </button>
255
283
 
256
284
  <!-- Group entries -->
257
285
  <div v-if="isGroupExpanded(group.id)" class="space-y-1" :class="group.label ? 'ml-1' : ''">
258
- <div
259
- v-for="ds in group.entries"
260
- :key="ds.id"
261
- class="rounded-lg transition-all duration-150"
262
- :class="currentDataset === ds.id ? 'bg-surface' : ''"
263
- >
264
- <button
265
- @click="goToDataset(ds.id)"
266
- class="w-full text-left px-3 py-2 rounded-lg text-sm border-l-2"
267
- :class="[
268
- currentDataset === ds.id
269
- ? 'text-ink-800'
270
- : 'border-transparent text-ink-600 hover:bg-ink-50 hover:text-ink-800'
271
- ]"
272
- :style="currentDataset === ds.id ? { borderLeftColor: getColor(ds.id), borderLeftWidth: '2px' } : {}"
286
+ <!-- LINEAGE series: timeline-style entries -->
287
+ <template v-if="group.kind === 'lineage'">
288
+ <div class="series-timeline">
289
+ <button
290
+ v-for="ds in group.entries"
291
+ :key="ds.id"
292
+ @click="goToDataset(ds.id)"
293
+ class="series-entry w-full text-left flex items-center gap-2 pl-6 pr-3 py-1.5 rounded-md text-sm border-l-2 transition-all duration-150"
294
+ :class="currentDataset === ds.id
295
+ ? 'bg-amber-50/70 dark:bg-amber-400/10 border-l-[3px] text-ink-900 dark:text-ink-50 font-semibold'
296
+ : 'border-transparent text-ink-600 dark:text-ink-300 hover:bg-ink-50 dark:hover:bg-ink-700/40 hover:text-ink-900 dark:hover:text-ink-50'"
297
+ :style="currentDataset === ds.id ? { borderLeftColor: 'var(--gold-accent, #B8935A)' } : {}"
298
+ >
299
+ <span class="flex-1 truncate text-[13.5px] font-medium leading-snug">{{ ds.ref || ds.title || ds.id }}</span>
300
+ <span
301
+ v-if="ds.status && ds.status !== 'valid'"
302
+ class="text-[9px] uppercase tracking-wide italic text-ink-400 dark:text-ink-400"
303
+ >{{ ds.status }}</span>
304
+ <span
305
+ v-if="ds.isCurrent"
306
+ class="current-star flex-shrink-0"
307
+ title="Current edition"
308
+ >✦</span>
309
+ </button>
310
+ </div>
311
+ </template>
312
+
313
+ <!-- REGULAR group: original entry style with expansion -->
314
+ <template v-else>
315
+ <div
316
+ v-for="ds in group.entries"
317
+ :key="ds.id"
318
+ class="rounded-lg transition-all duration-150"
319
+ :class="currentDataset === ds.id ? 'bg-surface' : ''"
273
320
  >
274
- <div class="font-medium truncate leading-snug">{{ localizedDatasetField(ds.id, 'title', ds.title) }}</div>
275
- <div v-if="ds.loaded" class="text-xs mt-0.5" :class="currentDataset === ds.id ? 'text-ink-400' : 'text-ink-300'">
276
- {{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
277
- </div>
278
- </button>
321
+ <button
322
+ @click="goToDataset(ds.id)"
323
+ class="w-full text-left px-3 py-2 rounded-lg text-sm border-l-2"
324
+ :class="[
325
+ currentDataset === ds.id
326
+ ? 'text-ink-800 dark:text-ink-50'
327
+ : 'border-transparent text-ink-600 dark:text-ink-300 hover:bg-ink-50 dark:hover:bg-ink-700 hover:text-ink-800 dark:hover:text-ink-50'
328
+ ]"
329
+ :style="currentDataset === ds.id ? { borderLeftColor: getColor(ds.id), borderLeftWidth: '2px' } : {}"
330
+ >
331
+ <div class="font-medium truncate leading-snug">{{ localizedDatasetField(ds.id, 'title', ds.title) }}</div>
332
+ <div v-if="ds.loaded" class="text-xs mt-0.5" :class="currentDataset === ds.id ? 'text-ink-400 dark:text-ink-300' : 'text-ink-300 dark:text-ink-400'">
333
+ {{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
334
+ </div>
335
+ </button>
279
336
 
280
- <!-- Expanded dataset: sub-pages + provenance -->
281
- <div v-if="currentDataset === ds.id && (filteredDatasetPages.length || provenance.owner)" class="px-2 pb-2">
282
- <nav v-if="filteredDatasetPages.length" class="space-y-0.5 mt-1">
283
- <router-link
284
- v-for="page in filteredDatasetPages"
285
- :key="page.route || 'concepts'"
286
- :to="pageRoute(page)"
287
- class="btn-ghost w-full text-left flex items-center gap-2 text-sm"
288
- :class="isActive(page) ? 'active' : ''"
289
- @click="closeMobile"
290
- >
291
- <NavIcon :name="page.icon" />
292
- {{ navTitle(page) }}
293
- </router-link>
294
- </nav>
295
-
296
- <!-- Sections tree -->
297
- <div v-if="getDatasetSections(ds.id).length" class="mt-2 pt-2 border-t border-ink-100/60">
298
- <button @click="toggleSectionNode(ds.id + '-sections')"
299
- class="w-full flex items-center gap-1.5 px-2 py-1 rounded-lg text-[10px] uppercase tracking-wide text-ink-400 hover:text-ink-600 hover:bg-ink-50 transition-colors"
300
- >
301
- <span class="w-3 text-[10px]">{{ expandedSectionNodes.has(ds.id + '-sections') ? '▾' : '▸' }}</span>
302
- <span class="flex-1 text-left">{{ t('nav.sections') }}</span>
303
- <span class="badge text-[9px] bg-amber-50 text-amber-600 px-1 py-0.5">{{ getDatasetSections(ds.id).length }}</span>
304
- </button>
305
- <div v-if="expandedSectionNodes.has(ds.id + '-sections')" class="mt-0.5 max-h-64 overflow-y-auto">
306
- <button
307
- @click="clearSectionFilter()"
308
- class="w-full flex items-center gap-1.5 px-2 py-1 rounded-lg text-[11px] transition-colors"
309
- :class="!activeSectionId ? 'bg-ink-800/8 text-blue-700 font-medium' : 'text-ink-500 hover:bg-ink-50'"
337
+ <!-- Expanded dataset: sub-pages + sections + provenance -->
338
+ <div v-if="currentDataset === ds.id && (filteredDatasetPages.length || provenance.owner)" class="px-2 pb-2">
339
+ <nav v-if="filteredDatasetPages.length" class="space-y-0.5 mt-1">
340
+ <router-link
341
+ v-for="page in filteredDatasetPages"
342
+ :key="page.route || 'concepts'"
343
+ :to="pageRoute(page)"
344
+ class="btn-ghost w-full text-left flex items-center gap-2 text-sm"
345
+ :class="isActive(page) ? 'active' : ''"
346
+ @click="closeMobile"
310
347
  >
311
- <span class="w-3 text-ink-200">&#183;</span>
312
- <span class="flex-1 text-left">{{ t('dataset.all') }}</span>
348
+ <NavIcon :name="page.icon" />
349
+ {{ navTitle(page) }}
350
+ </router-link>
351
+ </nav>
352
+
353
+ <!-- Sections tree -->
354
+ <div v-if="getDatasetSections(ds.id).length" class="mt-2 pt-2 border-t border-ink-100/60">
355
+ <button @click="toggleSectionNode(ds.id + '-sections')"
356
+ class="w-full flex items-center gap-1.5 px-2 py-1 rounded-lg text-[10px] uppercase tracking-wide text-ink-400 hover:text-ink-600 hover:bg-ink-50 transition-colors"
357
+ >
358
+ <span class="w-3 text-[10px]">{{ expandedSectionNodes.has(ds.id + '-sections') ? '▾' : '▸' }}</span>
359
+ <span class="flex-1 text-left">{{ t('nav.sections') }}</span>
360
+ <span class="badge text-[9px] bg-amber-50 text-amber-600 px-1 py-0.5">{{ getDatasetSections(ds.id).length }}</span>
313
361
  </button>
314
- <template v-for="section in getDatasetSections(ds.id)" :key="section.id">
315
- <button @click="goToSection(ds.id, 'section-' + section.id)"
362
+ <div v-if="expandedSectionNodes.has(ds.id + '-sections')" class="mt-0.5 max-h-64 overflow-y-auto">
363
+ <button
364
+ @click="clearSectionFilter()"
316
365
  class="w-full flex items-center gap-1.5 px-2 py-1 rounded-lg text-[11px] transition-colors"
317
- :class="activeSectionId === 'section-' + section.id ? 'bg-ink-800/8 text-blue-700 font-medium' : 'text-ink-500 hover:bg-ink-50'"
366
+ :class="!activeSectionId ? 'bg-ink-800/8 text-blue-700 font-medium' : 'text-ink-500 hover:bg-ink-50'"
318
367
  >
319
- <span v-if="section.children?.length" class="text-[10px] text-ink-300 w-3 cursor-pointer" @click.stop="toggleSectionNode(ds.id + '-s-' + section.id)">{{ expandedSectionNodes.has(ds.id + '-s-' + section.id) ? '▾' : '▸' }}</span>
320
- <span v-else class="w-3 text-ink-200">&#183;</span>
321
- <span class="flex-1 text-left truncate">{{ sectionDisplay(section) }}</span>
368
+ <span class="w-3 text-ink-200">&#183;</span>
369
+ <span class="flex-1 text-left">{{ t('dataset.all') }}</span>
322
370
  </button>
323
- <div v-if="section.children?.length && expandedSectionNodes.has(ds.id + '-s-' + section.id)" class="ml-3">
324
- <button v-for="child in section.children" :key="child.id"
325
- @click="goToSection(ds.id, 'section-' + child.id)"
371
+ <template v-for="section in getDatasetSections(ds.id)" :key="section.id">
372
+ <button @click="goToSection(ds.id, 'section-' + section.id)"
326
373
  class="w-full flex items-center gap-1.5 px-2 py-1 rounded-lg text-[11px] transition-colors"
327
- :class="activeSectionId === 'section-' + child.id ? 'bg-ink-800/8 text-blue-700 font-medium' : 'text-ink-400 hover:bg-ink-50'"
374
+ :class="activeSectionId === 'section-' + section.id ? 'bg-ink-800/8 text-blue-700 font-medium' : 'text-ink-500 hover:bg-ink-50'"
328
375
  >
329
- <span class="w-3 text-ink-200">&#183;</span>
330
- <span class="flex-1 text-left truncate">{{ sectionDisplay(child) }}</span>
376
+ <span v-if="section.children?.length" class="text-[10px] text-ink-300 w-3 cursor-pointer" @click.stop="toggleSectionNode(ds.id + '-s-' + section.id)">{{ expandedSectionNodes.has(ds.id + '-s-' + section.id) ? '▾' : '▸' }}</span>
377
+ <span v-else class="w-3 text-ink-200">&#183;</span>
378
+ <span class="flex-1 text-left truncate">{{ sectionDisplay(section) }}</span>
331
379
  </button>
332
- </div>
333
- </template>
334
- </div>
335
- </div>
336
- <div v-if="provenance.owner" class="mt-3 pt-3 border-t border-ink-100/60">
337
- <div class="text-[11px] text-ink-300 space-y-1.5 px-1">
338
- <div v-if="provenance.ref" class="text-xs font-semibold text-ink-700">
339
- {{ provenance.ref }}
380
+ <div v-if="section.children?.length && expandedSectionNodes.has(ds.id + '-s-' + section.id)" class="ml-3">
381
+ <button v-for="child in section.children" :key="child.id"
382
+ @click="goToSection(ds.id, 'section-' + child.id)"
383
+ class="w-full flex items-center gap-1.5 px-2 py-1 rounded-lg text-[11px] transition-colors"
384
+ :class="activeSectionId === 'section-' + child.id ? 'bg-ink-800/8 text-blue-700 font-medium' : 'text-ink-400 hover:bg-ink-50'"
385
+ >
386
+ <span class="w-3 text-ink-200">&#183;</span>
387
+ <span class="flex-1 text-left truncate">{{ sectionDisplay(child) }}</span>
388
+ </button>
389
+ </div>
390
+ </template>
340
391
  </div>
341
- <div class="flex items-center gap-1">
342
- <span class="text-ink-400">{{ t('sidebar.publishedBy') }}</span>
343
- <a v-if="provenance.ownerUrl" :href="provenance.ownerUrl" target="_blank" rel="noopener" class="concept-link font-medium">{{ provenance.owner }}</a>
344
- <span v-else class="text-ink-600 font-medium">{{ provenance.owner }}</span>
345
- </div>
346
- <div v-if="provenance.sourceRepo">
347
- <a :href="provenance.sourceRepo" target="_blank" rel="noopener" class="concept-link">{{ t('sidebar.viewSource') }}</a>
392
+ </div>
393
+
394
+ <div v-if="provenance.owner" class="mt-3 pt-3 border-t border-ink-100/60">
395
+ <div class="text-[11px] text-ink-300 space-y-1.5 px-1">
396
+ <div v-if="provenance.ref" class="text-xs font-semibold text-ink-700">
397
+ {{ provenance.ref }}
398
+ </div>
399
+ <div class="flex items-center gap-1">
400
+ <span class="text-ink-400">{{ t('sidebar.publishedBy') }}</span>
401
+ <a v-if="provenance.ownerUrl" :href="provenance.ownerUrl" target="_blank" rel="noopener" class="concept-link font-medium">{{ provenance.owner }}</a>
402
+ <span v-else class="text-ink-600 font-medium">{{ provenance.owner }}</span>
403
+ </div>
404
+ <div v-if="provenance.sourceRepo">
405
+ <a :href="provenance.sourceRepo" target="_blank" rel="noopener" class="concept-link">{{ t('sidebar.viewSource') }}</a>
406
+ </div>
348
407
  </div>
349
408
  </div>
350
409
  </div>
351
410
  </div>
352
- </div>
411
+ </template>
353
412
  </div>
354
413
  </div>
355
414
  </template>
@@ -367,13 +426,13 @@ const activeSectionId = computed(() => {
367
426
  class="w-full text-left px-3 py-2.5 rounded-lg text-sm border-l-2"
368
427
  :class="[
369
428
  currentDataset === ds.id
370
- ? 'text-ink-800'
371
- : 'border-transparent text-ink-600 hover:bg-ink-50 hover:text-ink-800'
429
+ ? 'text-ink-800 dark:text-ink-50'
430
+ : 'border-transparent text-ink-600 dark:text-ink-300 hover:bg-ink-50 dark:hover:bg-ink-700 hover:text-ink-800 dark:hover:text-ink-50'
372
431
  ]"
373
432
  :style="currentDataset === ds.id ? { borderLeftColor: getColor(ds.id), borderLeftWidth: '2px' } : {}"
374
433
  >
375
434
  <div class="font-medium truncate leading-snug">{{ localizedDatasetField(ds.id, 'title', ds.title) }}</div>
376
- <div v-if="ds.loaded" class="text-xs mt-0.5" :class="currentDataset === ds.id ? 'text-ink-400' : 'text-ink-300'">
435
+ <div v-if="ds.loaded" class="text-xs mt-0.5" :class="currentDataset === ds.id ? 'text-ink-400 dark:text-ink-300' : 'text-ink-300 dark:text-ink-400'">
377
436
  {{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
378
437
  </div>
379
438
  </button>
@@ -458,3 +517,40 @@ const activeSectionId = computed(() => {
458
517
  </div>
459
518
  </aside>
460
519
  </template>
520
+
521
+ <style scoped>
522
+ /* Series timeline entries — used when a dataset group has `series: true`.
523
+ Renders editions as compact year-tagged rows instead of the full dataset
524
+ entry, since within a series the year IS the identity. */
525
+ .series-timeline {
526
+ position: relative;
527
+ padding-left: 0;
528
+ margin-top: 4px;
529
+ }
530
+ /* No vertical rail line — the star indicator is enough cue. */
531
+
532
+ .series-entry {
533
+ position: relative;
534
+ /* All visual states (bg, text color, border) are inline Tailwind classes
535
+ so dark: variants apply with reliable specificity. */
536
+ }
537
+
538
+ /* Star (✦ U+2726, four-pointed) indicates ONE thing only: "is this the
539
+ current/newest valid edition?" Data property — never reflects viewing
540
+ state. Sits at the right edge of the entry so the ref text aligns to
541
+ the left consistently across current and non-current editions. */
542
+ .current-star {
543
+ display: inline-flex;
544
+ align-items: center;
545
+ justify-content: center;
546
+ font-size: 16px;
547
+ line-height: 1;
548
+ color: var(--gold-accent, #B8935A);
549
+ filter: drop-shadow(0 0 4px rgba(184, 147, 90, 0.45));
550
+ }
551
+ :global(.dark) .current-star {
552
+ color: var(--gold-accent, #D4AF6E);
553
+ filter: drop-shadow(0 0 4px rgba(212, 175, 110, 0.55));
554
+ }
555
+
556
+ </style>
@@ -24,6 +24,7 @@ import { useSiteConfig } from '../config/use-site-config';
24
24
  import ConceptTimeline from './ConceptTimeline.vue';
25
25
  import ConceptRdfView from './ConceptRdfView.vue';
26
26
  import FormatDownloads from './FormatDownloads.vue';
27
+ import ConceptEditionRail from './ConceptEditionRail.vue';
27
28
  import NonVerbalRepDisplay from './NonVerbalRepDisplay.vue';
28
29
  import NonVerbalList from './non-verbal/NonVerbalList.vue';
29
30
  import CitationDisplay from './CitationDisplay.vue';
@@ -554,6 +555,13 @@ const nonVerbalReps = computed(() => {
554
555
  </Transition>
555
556
  </div>
556
557
 
558
+ <!-- Edition series — supersession chain across vocabulary editions -->
559
+ <ConceptEditionRail
560
+ :concept-uri="conceptUri(props.concept, props.registerId, props.manifest.uriBase)"
561
+ :register-id="registerId"
562
+ :concept-id="conceptId"
563
+ />
564
+
557
565
  <!-- Domains -->
558
566
  <div v-if="conceptDomains.length" class="card p-5">
559
567
  <div class="section-label">{{ t('concept.domains') }}</div>
@@ -0,0 +1,222 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ConceptEditionRail — sidebar card showing a concept's supersession chain
4
+ * across vocabulary editions. Rendered INSIDE ConceptDetail's right sidebar
5
+ * (between Relations and other cards), styled identically to its neighbors.
6
+ *
7
+ * Reads `supersedes` edges from the graph engine and walks the full chain
8
+ * in both directions. Filter out malformed URIs that sometimes appear in
9
+ * stub-data scenarios.
10
+ */
11
+ import { computed } from 'vue';
12
+ import { useRouter } from 'vue-router';
13
+ import { useVocabularyStore } from '../stores/vocabulary';
14
+ import { useDatasetSeries, type DatasetSeriesMember } from '../composables/useDatasetSeries';
15
+ import { useI18n } from '../i18n';
16
+ import { UriRouter } from '../adapters/UriRouter';
17
+
18
+ const { t } = useI18n();
19
+
20
+ const props = defineProps<{
21
+ conceptUri: string;
22
+ registerId: string;
23
+ conceptId: string;
24
+ }>();
25
+
26
+ const store = useVocabularyStore();
27
+ const router = useRouter();
28
+
29
+ const { seriesForActive } = useDatasetSeries(() => props.registerId);
30
+ const series = computed(() => seriesForActive.value);
31
+
32
+ interface EditionEntry {
33
+ member: DatasetSeriesMember;
34
+ conceptUri: string;
35
+ conceptId: string;
36
+ edgeType: 'self' | 'supersedes' | 'superseded_by';
37
+ hops: number;
38
+ isCurrentEdition: boolean; // newest valid edition in the series
39
+ }
40
+
41
+ /** Resolve a concept URI via the project's UriRouter SSOT. Returns null for
42
+ * malformed or non-concept URIs (URN-form refs, external IRIs, stub data). */
43
+ function parseStrict(uri: string): { registerId: string; conceptId: string } | null {
44
+ return UriRouter.parseUri(uri);
45
+ }
46
+
47
+ /** Bidirectional BFS through supersedes edges from the start URI. */
48
+ function walkChain(startUri: string): Map<string, { type: 'supersedes' | 'superseded_by'; hops: number }> {
49
+ const out = new Map<string, { type: 'supersedes' | 'superseded_by'; hops: number }>();
50
+
51
+ /* Forward (concepts THIS supersedes — older editions it replaced) */
52
+ const forwardQueue: Array<{ uri: string; hops: number }> = [{ uri: startUri, hops: 0 }];
53
+ const forwardSeen = new Set<string>([startUri]);
54
+ while (forwardQueue.length > 0) {
55
+ const { uri, hops } = forwardQueue.shift()!;
56
+ for (const e of store.graph.getEdges(uri)) {
57
+ if (e.type === 'supersedes' && !forwardSeen.has(e.target)) {
58
+ forwardSeen.add(e.target);
59
+ out.set(e.target, { type: 'supersedes', hops: hops + 1 });
60
+ forwardQueue.push({ uri: e.target, hops: hops + 1 });
61
+ }
62
+ }
63
+ }
64
+
65
+ /* Backward (concepts that SUPERSEDED this — newer editions) */
66
+ const backwardQueue: Array<{ uri: string; hops: number }> = [{ uri: startUri, hops: 0 }];
67
+ const backwardSeen = new Set<string>([startUri]);
68
+ while (backwardQueue.length > 0) {
69
+ const { uri, hops } = backwardQueue.shift()!;
70
+ for (const e of store.graph.getIncomingEdges(uri)) {
71
+ if (e.type === 'supersedes' && !backwardSeen.has(e.source)) {
72
+ backwardSeen.add(e.source);
73
+ out.set(e.source, { type: 'superseded_by', hops: hops + 1 });
74
+ backwardQueue.push({ uri: e.source, hops: hops + 1 });
75
+ }
76
+ }
77
+ }
78
+
79
+ return out;
80
+ }
81
+
82
+ const editionChain = computed<EditionEntry[]>(() => {
83
+ const s = series.value;
84
+ const entries: EditionEntry[] = [];
85
+
86
+ /* Always include self — use props directly so we don't depend on URI parsing. */
87
+ const selfMember: DatasetSeriesMember = s?.members.find(m => m.id === props.registerId) ?? {
88
+ id: props.registerId,
89
+ ref: props.registerId,
90
+ year: undefined,
91
+ status: 'valid',
92
+ isCurrent: false,
93
+ isActive: true,
94
+ conceptCount: undefined,
95
+ registerId: props.registerId,
96
+ };
97
+ entries.push({
98
+ member: selfMember,
99
+ conceptUri: props.conceptUri,
100
+ conceptId: props.conceptId,
101
+ edgeType: 'self',
102
+ hops: 0,
103
+ isCurrentEdition: !!selfMember.isCurrent,
104
+ });
105
+
106
+ /* Walk the chain. Drop malformed URIs (no real concept id). */
107
+ const chain = walkChain(props.conceptUri);
108
+ for (const [uri, info] of chain) {
109
+ if (uri === props.conceptUri) continue;
110
+ const parsed = parseStrict(uri);
111
+ if (!parsed) continue;
112
+ if (entries.some(e => e.conceptUri === uri)) continue;
113
+
114
+ const member = s?.members.find(m => m.id === parsed.registerId) ?? {
115
+ id: parsed.registerId,
116
+ ref: parsed.registerId,
117
+ year: undefined,
118
+ status: 'unknown',
119
+ isCurrent: false,
120
+ isActive: false,
121
+ conceptCount: undefined,
122
+ registerId: parsed.registerId,
123
+ };
124
+
125
+ entries.push({
126
+ member,
127
+ conceptUri: uri,
128
+ conceptId: parsed.conceptId,
129
+ edgeType: info.type,
130
+ hops: info.hops,
131
+ isCurrentEdition: !!member.isCurrent,
132
+ });
133
+ }
134
+
135
+ /* Sort by year ascending (oldest first). */
136
+ entries.sort((a, b) => (a.member.year ?? 9999) - (b.member.year ?? 9999));
137
+
138
+ return entries;
139
+ });
140
+
141
+ const hasChain = computed(() => editionChain.value.length > 1);
142
+
143
+ function navigate(entry: EditionEntry) {
144
+ if (entry.edgeType === 'self') return;
145
+ router.push({
146
+ name: 'concept',
147
+ params: { registerId: entry.member.id, conceptId: entry.conceptId },
148
+ });
149
+ }
150
+
151
+ function edgeLabel(entry: EditionEntry): string {
152
+ switch (entry.edgeType) {
153
+ case 'supersedes': return t('edge.supersedes');
154
+ case 'superseded_by': return t('edge.superseded_by');
155
+ default: return '';
156
+ }
157
+ }
158
+ </script>
159
+
160
+ <template>
161
+ <div v-if="series" class="card p-5">
162
+ <div class="section-label">{{ t('concept.editionSeries') }}</div>
163
+ <div class="mt-1 text-xs text-ink-400 italic">{{ series.title }}</div>
164
+
165
+ <div class="mt-3 space-y-1">
166
+ <button
167
+ v-for="entry in editionChain"
168
+ :key="entry.conceptUri"
169
+ type="button"
170
+ class="concept-link block w-full text-left rounded-md px-1.5 py-1.5 transition-colors"
171
+ :class="entry.edgeType === 'self'
172
+ ? 'bg-blue-50 dark:bg-blue-900/20'
173
+ : 'hover:bg-ink-50 dark:hover:bg-ink-700/40'"
174
+ :disabled="entry.edgeType === 'self'"
175
+ @click="navigate(entry)"
176
+ >
177
+ <div class="flex items-center gap-1 mb-0.5">
178
+ <span
179
+ v-if="entry.isCurrentEdition"
180
+ class="badge text-[9px] flex-shrink-0"
181
+ :class="entry.edgeType === 'self' ? 'badge-blue' : 'badge-gray'"
182
+ style="background: rgba(184, 147, 90, 0.18); color: #8C6A3A; border: 1px solid rgba(184, 147, 90, 0.35);"
183
+ :title="t('concept.currentEdition')"
184
+ >✦ {{ t('concept.currentEdition') }}</span>
185
+ <span
186
+ v-if="entry.edgeType !== 'self'"
187
+ class="badge text-[9px] flex-shrink-0 badge-gray"
188
+ >{{ edgeLabel(entry) }}</span>
189
+ <span
190
+ v-if="entry.edgeType === 'self'"
191
+ class="badge text-[9px] flex-shrink-0 badge-blue"
192
+ >{{ t('concept.viewing') }}</span>
193
+ </div>
194
+ <div class="flex items-baseline gap-2">
195
+ <span class="font-mono text-xs text-ink-500 dark:text-ink-400 flex-shrink-0">
196
+ {{ entry.member.year ?? '—' }}
197
+ </span>
198
+ <span class="text-sm text-ink-700 dark:text-ink-200 leading-snug truncate">
199
+ {{ entry.member.ref }}
200
+ </span>
201
+ </div>
202
+ <div
203
+ v-if="entry.edgeType !== 'self' || entry.conceptId !== props.conceptId"
204
+ class="font-mono text-[10px] text-ink-300 dark:text-ink-500 mt-0.5 leading-tight"
205
+ >
206
+ {{ entry.member.id }} · {{ entry.conceptId }}
207
+ </div>
208
+ </button>
209
+ </div>
210
+
211
+ <div v-if="!hasChain" class="mt-3 pt-3 border-t border-ink-100/60 dark:border-ink-700/40">
212
+ <div class="text-xs text-ink-400 italic">
213
+ {{ t('concept.noChain') }}
214
+ </div>
215
+ </div>
216
+ </div>
217
+ </template>
218
+
219
+ <style scoped>
220
+ /* No scoped styles — uses global `card`, `section-label`, `badge`, `concept-link` classes
221
+ to match the rest of ConceptDetail's sidebar. */
222
+ </style>