@glossarist/concept-browser 0.7.51 → 0.7.52

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 (152) 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/__fixtures__/concept-shape.ttl +20 -0
  21. package/src/__fixtures__/shacl/bad/concept.ttl +7 -0
  22. package/src/__fixtures__/shacl/empty/.gitkeep +0 -0
  23. package/src/__fixtures__/shacl/good/concept.ttl +8 -0
  24. package/src/__tests__/__fixtures__/concepts.ts +221 -0
  25. package/src/__tests__/adapters/concept-identity.test.ts +76 -0
  26. package/src/__tests__/components/error-boundary.test.ts +109 -0
  27. package/src/__tests__/composables/use-dataset-series.test.ts +262 -0
  28. package/src/__tests__/concept-rdf/agents-emitter.test.ts +110 -0
  29. package/src/__tests__/concept-rdf/bibliography-emitter.test.ts +159 -0
  30. package/src/__tests__/concept-rdf/build-activity-emitter.test.ts +119 -0
  31. package/src/__tests__/concept-rdf/coerce-date.test.ts +97 -0
  32. package/src/__tests__/concept-rdf/concept-emitter.test.ts +258 -0
  33. package/src/__tests__/concept-rdf/dataset-emitter.test.ts +224 -0
  34. package/src/__tests__/concept-rdf/differential.test.ts +96 -0
  35. package/src/__tests__/concept-rdf/group-emitter.test.ts +109 -0
  36. package/src/__tests__/concept-rdf/image-variant-emitter.test.ts +135 -0
  37. package/src/__tests__/concept-rdf/jsonld-writer.test.ts +109 -0
  38. package/src/__tests__/concept-rdf/nonverbal-rep.test.ts +177 -0
  39. package/src/__tests__/concept-rdf/property-based.test.ts +179 -0
  40. package/src/__tests__/concept-rdf/provenance.test.ts +110 -0
  41. package/src/__tests__/concept-rdf/quad-isomorphism.test.ts +43 -0
  42. package/src/__tests__/concept-rdf/quad-isomorphism.ts +47 -0
  43. package/src/__tests__/concept-rdf/rdf-components.test.ts +145 -0
  44. package/src/__tests__/concept-rdf/rdf-graph.test.ts +115 -0
  45. package/src/__tests__/concept-rdf/round-trip.test.ts +243 -0
  46. package/src/__tests__/concept-rdf/scoped-examples.test.ts +126 -0
  47. package/src/__tests__/concept-rdf/sections-builder.test.ts +94 -0
  48. package/src/__tests__/concept-rdf/shacl-conformance.test.ts +110 -0
  49. package/src/__tests__/concept-rdf/shape-consistency.test.ts +138 -0
  50. package/src/__tests__/concept-rdf/snapshot-generation.test.ts +75 -0
  51. package/src/__tests__/concept-rdf/table-formula-emitter.test.ts +142 -0
  52. package/src/__tests__/concept-rdf/turtle-writer.test.ts +114 -0
  53. package/src/__tests__/concept-rdf/use-rdf-document.test.ts +246 -0
  54. package/src/__tests__/concept-rdf/version-emitter.test.ts +120 -0
  55. package/src/__tests__/concept-rdf/vocabulary-ssot.test.ts +100 -0
  56. package/src/__tests__/concept-rdf-view.test.ts +136 -0
  57. package/src/__tests__/dataset-style.test.ts +12 -7
  58. package/src/__tests__/errors/errors.test.ts +142 -0
  59. package/src/__tests__/format-downloads.test.ts +47 -65
  60. package/src/__tests__/markdown-lite.test.ts +19 -0
  61. package/src/__tests__/perf/bundle-layout.test.ts +50 -0
  62. package/src/__tests__/perf/serialization-perf.test.ts +121 -0
  63. package/src/__tests__/scripts/agents-turtle.test.ts +61 -0
  64. package/src/__tests__/scripts/bibliography-turtle.test.ts +59 -0
  65. package/src/__tests__/scripts/build-activity-turtle.test.ts +75 -0
  66. package/src/__tests__/scripts/build-cache.test.ts +78 -0
  67. package/src/__tests__/scripts/build-integration.test.ts +134 -0
  68. package/src/__tests__/scripts/dataset-turtle.test.ts +94 -0
  69. package/src/__tests__/scripts/normalize-yaml.test.ts +98 -0
  70. package/src/__tests__/scripts/stryker-config.test.ts +33 -0
  71. package/src/__tests__/scripts/turtle-escape.test.ts +63 -0
  72. package/src/__tests__/scripts/version-turtle.test.ts +72 -0
  73. package/src/__tests__/scripts/vocab-turtle.test.ts +63 -0
  74. package/src/__tests__/use-format-registry.test.ts +125 -0
  75. package/src/__tests__/utils/bcp47.test.ts +166 -0
  76. package/src/__tests__/utils/color-theme.test.ts +143 -0
  77. package/src/__tests__/utils/url-safety.test.ts +65 -0
  78. package/src/__tests__/validate-shacl.test.ts +100 -0
  79. package/src/adapters/DatasetAdapter.ts +11 -5
  80. package/src/adapters/GraphDataSource.ts +2 -1
  81. package/src/adapters/UriRouter.ts +2 -1
  82. package/src/adapters/concept-identity.ts +69 -0
  83. package/src/adapters/factory.ts +3 -2
  84. package/src/adapters/model-bridge.ts +2 -1
  85. package/src/adapters/non-verbal/glossarist-augment.d.ts +7 -0
  86. package/src/adapters/non-verbal-resolver.ts +2 -1
  87. package/src/components/AppSidebar.vue +189 -93
  88. package/src/components/ConceptDetail.vue +8 -0
  89. package/src/components/ConceptEditionRail.vue +222 -0
  90. package/src/components/ConceptRdfView.vue +37 -377
  91. package/src/components/DatasetSeriesCard.vue +270 -0
  92. package/src/components/ErrorBoundary.vue +95 -0
  93. package/src/components/FormatDownloads.vue +17 -13
  94. package/src/components/HomeSeriesSection.vue +277 -0
  95. package/src/components/RelationSphere.vue +1672 -0
  96. package/src/components/SidebarSeriesSection.vue +239 -0
  97. package/src/components/concept-rdf/RdfInstanceHeader.vue +47 -0
  98. package/src/components/concept-rdf/RdfInstanceSection.vue +54 -0
  99. package/src/components/concept-rdf/RdfPrefixLegend.vue +27 -0
  100. package/src/components/concept-rdf/RdfSourcePanel.vue +72 -0
  101. package/src/components/concept-rdf/agents-emitter.ts +82 -0
  102. package/src/components/concept-rdf/bibliography-emitter.ts +83 -0
  103. package/src/components/concept-rdf/build-activity-emitter.ts +89 -0
  104. package/src/components/concept-rdf/concept-emitter.ts +443 -0
  105. package/src/components/concept-rdf/dataset-emitter.ts +95 -0
  106. package/src/components/concept-rdf/group-emitter.ts +69 -0
  107. package/src/components/concept-rdf/image-variant-emitter.ts +46 -0
  108. package/src/components/concept-rdf/jsonld-writer.ts +82 -0
  109. package/src/components/concept-rdf/predicates.ts +261 -0
  110. package/src/components/concept-rdf/provenance.ts +80 -0
  111. package/src/components/concept-rdf/rdf-graph.ts +211 -0
  112. package/src/components/concept-rdf/rdf-prefixes.ts +23 -0
  113. package/src/components/concept-rdf/sections-builder.ts +62 -0
  114. package/src/components/concept-rdf/table-formula-emitter.ts +101 -0
  115. package/src/components/concept-rdf/turtle-writer.ts +116 -0
  116. package/src/components/concept-rdf/use-rdf-document.ts +72 -0
  117. package/src/components/concept-rdf/version-emitter.ts +65 -0
  118. package/src/components/concept-rdf/vocabulary-emitter.ts +62 -0
  119. package/src/composables/use-color-theme.ts +82 -0
  120. package/src/composables/use-format-registry.ts +42 -0
  121. package/src/composables/useDatasetSeries.ts +258 -0
  122. package/src/composables/useSphereProjection.ts +125 -0
  123. package/src/config/group-types.ts +92 -0
  124. package/src/config/types.ts +81 -2
  125. package/src/config/use-site-config.ts +2 -1
  126. package/src/errors.ts +136 -0
  127. package/src/i18n/locales/eng.yml +24 -0
  128. package/src/i18n/locales/fra.yml +24 -0
  129. package/src/stores/vocabulary.ts +3 -1
  130. package/src/style.css +17 -2
  131. package/src/types/agents-version-turtle.d.ts +27 -0
  132. package/src/types/bibliography-turtle.d.ts +12 -0
  133. package/src/types/build-activity-turtle.d.ts +16 -0
  134. package/src/types/build-cache.d.ts +20 -0
  135. package/src/types/dataset-turtle.d.ts +32 -0
  136. package/src/types/normalize-yaml.d.ts +16 -0
  137. package/src/types/turtle-escape.d.ts +6 -0
  138. package/src/types/vocab-turtle.d.ts +13 -0
  139. package/src/utils/asciidoc-lite.ts +11 -6
  140. package/src/utils/bcp47.ts +141 -0
  141. package/src/utils/color-theme-integration.ts +11 -0
  142. package/src/utils/color-theme.ts +129 -0
  143. package/src/utils/dataset-style.ts +31 -6
  144. package/src/utils/locale.ts +6 -14
  145. package/src/utils/markdown-lite.ts +6 -1
  146. package/src/utils/relation-sphere-styling.ts +63 -0
  147. package/src/utils/relationship-categories.ts +30 -0
  148. package/src/utils/url-safety.ts +30 -0
  149. package/src/views/ConceptView.vue +183 -9
  150. package/src/views/DatasetView.vue +6 -0
  151. package/src/views/HomeView.vue +5 -0
  152. package/vite.config.ts +7 -0
@@ -12,6 +12,12 @@ import type {
12
12
  import type { Concept } from 'glossarist';
13
13
  import { conceptFromJson } from './model-bridge';
14
14
  import { GraphDataSource } from './GraphDataSource';
15
+ import {
16
+ ChunkLoadError,
17
+ ConceptNotFoundError,
18
+ IndexLoadError,
19
+ ManifestLoadError,
20
+ } from '../errors';
15
21
 
16
22
  // ── Wire-format types for JSON responses ────────────────────────────────────
17
23
 
@@ -82,7 +88,7 @@ export class DatasetAdapter {
82
88
  async loadManifest(): Promise<Manifest> {
83
89
  if (this.manifestComplete && this.manifest) return this.manifest;
84
90
  const resp = await fetch(`${this.baseUrl}/manifest.json`);
85
- if (!resp.ok) throw new Error(`Failed to load manifest for ${this.registerId}: ${resp.status}`);
91
+ if (!resp.ok) throw ManifestLoadError.make(this.registerId, resp.status);
86
92
  this.manifest = (await resp.json()) as Manifest;
87
93
  this.manifestComplete = true;
88
94
  return this.manifest;
@@ -97,7 +103,7 @@ export class DatasetAdapter {
97
103
  }
98
104
 
99
105
  const resp = await fetch(`${this.baseUrl}/index.json`);
100
- if (!resp.ok) throw new Error(`Failed to load index for ${this.registerId}: ${resp.status}`);
106
+ if (!resp.ok) throw IndexLoadError.make(this.registerId, resp.status);
101
107
  const data = await resp.json();
102
108
 
103
109
  // Handle both old format (with eng/status fields) and new format (with designations map)
@@ -150,7 +156,7 @@ export class DatasetAdapter {
150
156
  meta = await metaResp.json();
151
157
  } else {
152
158
  const resp = await fetch(`${this.baseUrl}/index.json`);
153
- if (!resp.ok) throw new Error(`Failed to load index for ${this.registerId}`);
159
+ if (!resp.ok) throw IndexLoadError.make(this.registerId, resp.status);
154
160
  const data = await resp.json();
155
161
  this.index = this.normalizeIndex(data as IndexJson);
156
162
  this.buildSummaryIndex();
@@ -180,7 +186,7 @@ export class DatasetAdapter {
180
186
  if (this.loadedChunks.has(chunkIndex)) return [];
181
187
  const chunkFile = `index-${String(chunkIndex).padStart(4, '0')}.json`;
182
188
  const resp = await fetch(`${this.baseUrl}/chunks/${chunkFile}`);
183
- if (!resp.ok) throw new Error(`Failed to load chunk ${chunkIndex} for ${this.registerId}`);
189
+ if (!resp.ok) throw ChunkLoadError.make(this.registerId, chunkIndex, resp.status);
184
190
  const data = await resp.json();
185
191
  this.loadedChunks.add(chunkIndex);
186
192
  return data.concepts as ConceptEntry[];
@@ -255,7 +261,7 @@ export class DatasetAdapter {
255
261
  }
256
262
 
257
263
  const resp = await fetch(`${this.baseUrl}/concepts/${conceptId}.json`);
258
- if (!resp.ok) throw new Error(`Concept ${conceptId} not found in ${this.registerId}`);
264
+ if (!resp.ok) throw ConceptNotFoundError.make(this.registerId, conceptId);
259
265
  const json = await resp.json();
260
266
  const concept = conceptFromJson(json);
261
267
  this.conceptCache.set(conceptId, concept);
@@ -4,6 +4,7 @@ import type { DatasetAdapter } from './DatasetAdapter';
4
4
  import { UriRouter } from './UriRouter';
5
5
  import { slugify } from '../utils/slugify';
6
6
  import { toSectionNode, toSectionTree } from '../utils/section-tree';
7
+ import { ConceptIdentity } from './concept-identity';
7
8
 
8
9
  interface DomainNodeJson {
9
10
  uri?: string;
@@ -23,7 +24,7 @@ function resolveRefTarget(rc: RelatedConcept, uriBase: string, registerId: strin
23
24
  if (ref.source && !ref.source.startsWith('http')) {
24
25
  reg = urnMap?.get(ref.source) ?? ref.source;
25
26
  }
26
- return `${uriBase}/${reg}/concept/${ref.id}`;
27
+ return new ConceptIdentity(ref.id, reg, uriBase).uri;
27
28
  }
28
29
  if (ref.source && ref.source.startsWith('http')) return ref.source;
29
30
  return ref.source || '';
@@ -1,4 +1,5 @@
1
1
  import type { Manifest } from './types';
2
+ import { ConceptIdentity } from './concept-identity';
2
3
 
3
4
  // ── URI pattern matching ────────────────────────────────────────────────────
4
5
 
@@ -104,7 +105,7 @@ export class UriRouter {
104
105
  buildUri(registerId: string, conceptId: string): string {
105
106
  const info = this.registerMap.get(registerId);
106
107
  const uriBase = info?.uriBase ?? '';
107
- return `${uriBase}/${registerId}/concept/${conceptId}`;
108
+ return new ConceptIdentity(conceptId, registerId, uriBase).uri;
108
109
  }
109
110
 
110
111
  getRegisteredIds(): string[] {
@@ -0,0 +1,69 @@
1
+ import { InvalidConceptIdentityError, InvalidConceptUriError } from '../errors';
2
+
3
+ export interface ConceptIdentityParts {
4
+ readonly localId: string;
5
+ readonly registerId: string;
6
+ readonly uriBase: string;
7
+ }
8
+
9
+ const CONCEPT_URI_RE = /^(.+)\/([^/]+)\/concept\/(.+)$/;
10
+
11
+ export class ConceptIdentity implements ConceptIdentityParts {
12
+ private readonly _uri: string;
13
+ private readonly _slug: string;
14
+ private readonly _path: string;
15
+
16
+ constructor(
17
+ public readonly localId: string,
18
+ public readonly registerId: string,
19
+ public readonly uriBase: string,
20
+ ) {
21
+ if (!localId || !registerId || !uriBase) {
22
+ throw new InvalidConceptIdentityError(
23
+ 'ConceptIdentity requires non-empty localId, registerId, and uriBase',
24
+ { localId, registerId, uriBase },
25
+ );
26
+ }
27
+ this._uri = `${uriBase}/${registerId}/concept/${localId}`;
28
+ this._slug = localId;
29
+ this._path = `${registerId}/concepts/${localId}`;
30
+ }
31
+
32
+ get uri(): string { return this._uri; }
33
+ get slug(): string { return this._slug; }
34
+ get path(): string { return this._path; }
35
+
36
+ equals(other: ConceptIdentity): boolean {
37
+ return this._uri === other._uri;
38
+ }
39
+
40
+ toString(): string { return this._uri; }
41
+
42
+ toJSON(): ConceptIdentityParts {
43
+ return { localId: this.localId, registerId: this.registerId, uriBase: this.uriBase };
44
+ }
45
+
46
+ localizationUri(lang: string): string {
47
+ return `${this._uri}/${lang}`;
48
+ }
49
+
50
+ designationUri(lang: string, slug: string): string {
51
+ return `${this._uri}/${lang}/desig/${slug}`;
52
+ }
53
+
54
+ domainUri(domainSlug: string): string {
55
+ return `${this.uriBase}/${this.registerId}/domain/${domainSlug}`;
56
+ }
57
+
58
+ static fromUri(uri: string): ConceptIdentity {
59
+ const m = uri.match(CONCEPT_URI_RE);
60
+ if (!m) {
61
+ throw InvalidConceptUriError.make(uri);
62
+ }
63
+ return new ConceptIdentity(m[3], m[2], m[1]);
64
+ }
65
+
66
+ static isConceptUri(uri: string): boolean {
67
+ return CONCEPT_URI_RE.test(uri);
68
+ }
69
+ }
@@ -5,6 +5,7 @@ import { ReferenceResolver } from './ReferenceResolver';
5
5
  import { UriRouter } from './UriRouter';
6
6
  import { NonVerbalEntityResolver } from './non-verbal-resolver';
7
7
  import { BibliographyAdapter } from './bibliography-adapter';
8
+ import { DatasetRegistryLoadError, UnknownDatasetError } from '../errors';
8
9
 
9
10
  export class AdapterFactory {
10
11
  private adapters = new Map<string, DatasetAdapter>();
@@ -36,7 +37,7 @@ export class AdapterFactory {
36
37
  registry = JSON.parse(inline.textContent) as DatasetRegistry[];
37
38
  } else {
38
39
  const resp = await fetch(datasetsUrl);
39
- if (!resp.ok) throw new Error(`Failed to load dataset registry: ${resp.status}`);
40
+ if (!resp.ok) throw DatasetRegistryLoadError.make(resp.status, datasetsUrl);
40
41
  registry = await resp.json() as DatasetRegistry[];
41
42
  }
42
43
 
@@ -132,7 +133,7 @@ export class AdapterFactory {
132
133
 
133
134
  async loadDataset(registerId: string): Promise<DatasetAdapter> {
134
135
  const adapter = this.adapters.get(registerId);
135
- if (!adapter) throw new Error(`Unknown dataset: ${registerId}`);
136
+ if (!adapter) throw UnknownDatasetError.make(registerId);
136
137
 
137
138
  const manifest = await adapter.loadManifest();
138
139
  await adapter.loadIndex();
@@ -36,6 +36,7 @@ import {
36
36
  GRAMMAR_PARTS_OF_SPEECH,
37
37
  } from 'glossarist/models';
38
38
  import type { ConceptSummary } from './types';
39
+ import { ConceptIdentity } from './concept-identity';
39
40
 
40
41
  // ── JSON-LD wire-format types ─────────────────────────────────────────────
41
42
 
@@ -634,5 +635,5 @@ export function conceptToSummary(concept: Concept): ConceptSummary {
634
635
 
635
636
  export function conceptUri(concept: Concept, registerId: string, uriBase: string): string {
636
637
  if (concept.uri) return concept.uri;
637
- return `${uriBase}/${registerId}/concept/${concept.id}`;
638
+ return new ConceptIdentity(concept.id, registerId, uriBase).uri;
638
639
  }
@@ -91,6 +91,13 @@ declare module 'glossarist' {
91
91
  static register(type: string, cls: typeof NonVerbalReference): void;
92
92
  }
93
93
 
94
+ interface NonVerbRep {
95
+ readonly caption: string | null;
96
+ readonly description: string | null;
97
+ readonly alt: string | null;
98
+ readonly images: FigureImage[];
99
+ }
100
+
94
101
  class FigureReference extends NonVerbalReference {
95
102
  static fromJSON(data: Record<string, unknown> | string): FigureReference;
96
103
  }
@@ -19,6 +19,7 @@ import type { NonVerbalKind } from './non-verbal/types';
19
19
  import type { NonVerbalEntity } from 'glossarist';
20
20
  import { KIND_TO_DIR, KIND_TO_BRIDGE } from './non-verbal/kind';
21
21
  import { anchorId } from '../utils/non-verbal-anchor';
22
+ import { NonVerbalEntityNotFoundError } from '../errors';
22
23
 
23
24
  export type { NonVerbalKind } from './non-verbal/types';
24
25
  export type { NonVerbalEntity } from 'glossarist';
@@ -54,7 +55,7 @@ export class NonVerbalEntityResolver {
54
55
  const resp = await this.fetcher(url);
55
56
  if (resp.status === 404) return null;
56
57
  if (!resp.ok) {
57
- throw new Error(`Failed to load ${kind} ${entityId} from ${datasetId}: ${resp.status}`);
58
+ throw NonVerbalEntityNotFoundError.make(datasetId, kind, entityId, resp.status);
58
59
  }
59
60
  const doc = (await resp.json()) as Record<string, unknown>;
60
61
  const entity = KIND_TO_BRIDGE[kind](doc);
@@ -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>