@glossarist/concept-browser 0.7.77 → 0.7.80

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 (58) hide show
  1. package/astro.config.mjs +1 -0
  2. package/package.json +2 -2
  3. package/scripts/bridge-to-astro.mjs +6 -1
  4. package/scripts/build-edges.js +154 -1
  5. package/scripts/generate-data.mjs +138 -54
  6. package/src/__tests__/build-edges-redundancy.test.ts +101 -0
  7. package/src/__tests__/composables/use-concept-diff.test.ts +240 -0
  8. package/src/__tests__/composables/use-concept-edges-v2.test.ts +132 -0
  9. package/src/__tests__/composables/use-dataset-stats.test.ts +96 -0
  10. package/src/__tests__/config/page-types.test.ts +82 -0
  11. package/src/__tests__/model-bridge-bridges.test.ts +3 -2
  12. package/src/__tests__/model-bridge-citation.test.ts +1 -1
  13. package/src/__tests__/ontology-registry.test.ts +2 -2
  14. package/src/__tests__/partitive-relation-styling.test.ts +82 -0
  15. package/src/__tests__/partitive-relation.test.ts +285 -0
  16. package/src/__tests__/sphere-math.test.ts +56 -0
  17. package/src/adapters/GraphDataSource.ts +22 -5
  18. package/src/adapters/UriRouter.ts +5 -0
  19. package/src/adapters/dataset-stats.ts +13 -0
  20. package/src/adapters/model-bridge.ts +1 -1
  21. package/src/adapters/non-verbal/glossarist-augment.d.ts +155 -4
  22. package/src/adapters/types.ts +36 -0
  23. package/src/components/AppSidebar.vue +14 -57
  24. package/src/components/ConceptDetail.vue +29 -0
  25. package/src/components/ConceptDiffModal.vue +120 -0
  26. package/src/components/ConceptDiffView.vue +192 -0
  27. package/src/components/ConceptEditionRail.vue +61 -36
  28. package/src/components/PartitiveRelationList.vue +139 -0
  29. package/src/components/RelationSphere.vue +2 -36
  30. package/src/components/groups/DatasetGroupRenderer.vue +14 -4
  31. package/src/components/groups/DefaultGroupSidebar.vue +28 -11
  32. package/src/components/groups/LineageGroupSidebar.vue +17 -29
  33. package/src/composables/use-concept-diff.ts +231 -0
  34. package/src/composables/use-concept-edges.ts +98 -5
  35. package/src/composables/use-dataset-stats.ts +52 -0
  36. package/src/config/page-types.ts +67 -0
  37. package/src/config/types.ts +6 -1
  38. package/src/config/use-site-config.ts +13 -29
  39. package/src/data/taxonomies.json +24 -1
  40. package/src/i18n/locales/eng.yml +6 -0
  41. package/src/utils/partitive-relation-styling.ts +62 -0
  42. package/src/utils/sphere-math.ts +54 -0
  43. package/src/views/DatasetView.vue +5 -3
  44. package/src/views/StatsView.vue +9 -22
  45. package/src/islands/BibliographyIsland.vue +0 -26
  46. package/src/islands/ConceptDetailIsland.vue +0 -27
  47. package/src/islands/ConceptDiffIsland.vue +0 -214
  48. package/src/islands/ConceptIsland.vue +0 -102
  49. package/src/islands/GraphIsland.vue +0 -15
  50. package/src/islands/GroupRendererIsland.vue +0 -15
  51. package/src/islands/HeaderIsland.vue +0 -10
  52. package/src/islands/NonVerbalIsland.vue +0 -75
  53. package/src/islands/RelationSphereIsland.vue +0 -20
  54. package/src/islands/SearchIsland.vue +0 -23
  55. package/src/islands/SeriesCardIsland.vue +0 -12
  56. package/src/islands/ShortcutsIsland.vue +0 -13
  57. package/src/islands/SidebarIsland.vue +0 -15
  58. package/src/islands/SupersessionDiffIsland.vue +0 -267
@@ -1,12 +1,15 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
3
  * LineageGroupSidebar — timeline-style entries for edition series.
4
- * Replaces the inline v-if="group.kind === 'lineage'" template in
5
- * AppSidebar. Open/closed: new group kinds get their own component.
4
+ * Invoked via DatasetGroupRenderer dispatch when group.kind === 'lineage'.
5
+ *
6
+ * Slot `expanded` receives `{ entry, isCurrent }` per entry so callers
7
+ * can render shared per-entry content (sub-pages, sections, etc.)
8
+ * without touching this component.
6
9
  */
7
10
  import { useRouter } from 'vue-router';
8
11
 
9
- const props = defineProps<{
12
+ defineProps<{
10
13
  entries: Array<{
11
14
  id: string;
12
15
  title: string;
@@ -22,24 +25,27 @@ const props = defineProps<{
22
25
 
23
26
  const router = useRouter();
24
27
 
25
- function navigate(id: string) {
26
- if (id === props.currentDataset) return;
28
+ function navigate(id: string, current: string) {
29
+ if (id === current) return;
27
30
  router.push({ name: 'dataset', params: { registerId: id } });
28
31
  }
29
32
  </script>
30
33
 
31
34
  <template>
32
- <div class="series-timeline">
35
+ <div
36
+ v-for="ds in entries"
37
+ :key="ds.id"
38
+ class="rounded-lg transition-all duration-150"
39
+ :class="currentDataset === ds.id ? 'bg-surface' : ''"
40
+ >
33
41
  <button
34
- v-for="ds in entries"
35
- :key="ds.id"
36
42
  type="button"
37
43
  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"
38
44
  :class="currentDataset === ds.id
39
45
  ? 'bg-amber-50/70 dark:bg-amber-400/10 border-l-[3px] text-ink-900 dark:text-ink-50 font-semibold'
40
46
  : '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'"
41
- :style="currentDataset === ds.id ? { borderLeftColor: 'var(--group-kind-lineage-light, #B8935A)' } : {}"
42
- @click="navigate(ds.id)"
47
+ :style="currentDataset === ds.id ? { borderLeftColor: 'var(--gold-accent, #B8935A)' } : {}"
48
+ @click="navigate(ds.id, currentDataset)"
43
49
  >
44
50
  <span class="flex-1 truncate text-[13.5px] font-medium leading-snug">{{ ds.ref || ds.title || ds.id }}</span>
45
51
  <span
@@ -52,24 +58,6 @@ function navigate(id: string) {
52
58
  title="Current edition"
53
59
  >✦</span>
54
60
  </button>
61
+ <slot name="expanded" :entry="ds" :is-current="currentDataset === ds.id" />
55
62
  </div>
56
63
  </template>
57
-
58
- <style scoped>
59
- .series-entry {
60
- position: relative;
61
- }
62
- .current-star {
63
- display: inline-flex;
64
- align-items: center;
65
- justify-content: center;
66
- font-size: 16px;
67
- line-height: 1;
68
- color: var(--group-kind-lineage-light, #B8935A);
69
- filter: drop-shadow(0 0 4px rgba(184, 147, 90, 0.45));
70
- }
71
- :global(.dark) .current-star {
72
- color: var(--group-kind-lineage-dark, #D4AF6E);
73
- filter: drop-shadow(0 0 4px rgba(212, 175, 110, 0.55));
74
- }
75
- </style>
@@ -0,0 +1,231 @@
1
+ /**
2
+ * useConceptDiff — diff two concepts and derive render-ready sections.
3
+ *
4
+ * Consolidates logic that was previously duplicated across
5
+ * ConceptDiffIsland.vue and SupersessionDiffIsland.vue (DRY).
6
+ *
7
+ * Pipeline:
8
+ * raw wire data → DiffableConcept → diffConcepts → ConceptDiff
9
+ * ↓
10
+ * deriveDiffSections
11
+ * computeSimilarity
12
+ *
13
+ * `similarity` is not a field on ConceptDiff (it doesn't exist in
14
+ * glossarist-js's type). It is computed from stats:
15
+ * similarity = 1 - (added + removed + changed) / total
16
+ */
17
+
18
+ import { ref } from 'vue';
19
+ import { diffConcepts } from 'glossarist/diff';
20
+ import type { ConceptDiff } from 'glossarist/diff';
21
+
22
+ // ── Inputs ────────────────────────────────────────────────────────────────
23
+
24
+ /** Wire shape of a concept as stored in fetched JSON or content collections. */
25
+ export interface ConceptLikeData {
26
+ conceptId?: string;
27
+ id?: string;
28
+ uri?: string;
29
+ status?: string;
30
+ languages?: string[];
31
+ localizations?: Record<string, unknown>;
32
+ }
33
+
34
+ /** Normalized concept shape consumed by diffConcepts. */
35
+ export interface DiffableConcept {
36
+ readonly id: string;
37
+ readonly termid: string;
38
+ readonly status?: string;
39
+ readonly uri?: string;
40
+ readonly languages: readonly string[];
41
+ localization(lang: string): {
42
+ languageCode: string;
43
+ terms: unknown[];
44
+ definitions: unknown[];
45
+ notes: unknown[];
46
+ examples: unknown[];
47
+ } | null;
48
+ }
49
+
50
+ // ── Outputs ───────────────────────────────────────────────────────────────
51
+
52
+ export type DiffHunk = { type: 'added' | 'removed' | 'equal'; text: string };
53
+
54
+ export interface DefinitionDiff {
55
+ hunks?: readonly DiffHunk[];
56
+ type?: 'added' | 'removed';
57
+ value?: string;
58
+ }
59
+
60
+ export interface DesignationsDiff {
61
+ items: Array<{ type?: string; text: string }>;
62
+ }
63
+
64
+ export interface SimpleChangesDiff {
65
+ items: Array<{ type?: string; text: string }>;
66
+ }
67
+
68
+ export interface DiffSections {
69
+ definition?: DefinitionDiff;
70
+ designations?: DesignationsDiff;
71
+ terms?: { hunks?: readonly DiffHunk[]; oldValue?: string; newValue?: string };
72
+ notes?: SimpleChangesDiff;
73
+ examples?: SimpleChangesDiff;
74
+ }
75
+
76
+ // ── Adapters ──────────────────────────────────────────────────────────────
77
+
78
+ export function toDiffableConcept(data: ConceptLikeData): DiffableConcept {
79
+ const langs = data.languages
80
+ ?? (data.localizations ? Object.keys(data.localizations) : []);
81
+ const id = String(data.conceptId ?? data.id ?? '');
82
+ return {
83
+ id,
84
+ termid: id,
85
+ status: data.status,
86
+ uri: data.uri,
87
+ languages: langs,
88
+ localization(lang: string) {
89
+ const locs = (data.localizations ?? {}) as Record<string, any>;
90
+ const loc = locs[lang];
91
+ if (!loc) return null;
92
+ return {
93
+ languageCode: loc.languageCode ?? lang,
94
+ terms: loc.terms ?? [],
95
+ definitions: loc.definitions ?? [],
96
+ notes: loc.notes ?? [],
97
+ examples: loc.examples ?? [],
98
+ };
99
+ },
100
+ };
101
+ }
102
+
103
+ // ── Pure derivations ──────────────────────────────────────────────────────
104
+
105
+ interface ReadableConceptDiff {
106
+ localization?(lang: string): any;
107
+ localizations?: Record<string, any>;
108
+ stats?: { added?: number; removed?: number; changed?: number; total?: number };
109
+ }
110
+
111
+ function asReadable(diff: ConceptDiff): ReadableConceptDiff {
112
+ return diff as unknown as ReadableConceptDiff;
113
+ }
114
+
115
+ function extractDesignationText(value: any): string {
116
+ if (value == null) return '';
117
+ if (typeof value === 'string') return value;
118
+ return value.designation ?? value.text ?? String(value);
119
+ }
120
+
121
+ function extractContentText(value: any): string {
122
+ if (value == null) return '';
123
+ if (typeof value === 'string') return value;
124
+ return value.content ?? value.text ?? String(value);
125
+ }
126
+
127
+ /**
128
+ * Derive render-ready DiffSections from a ConceptDiff for a single language.
129
+ * Pure function — same diff + lang always yields the same sections.
130
+ */
131
+ export function deriveDiffSections(diff: ConceptDiff | null, lang: string): DiffSections {
132
+ if (!diff) return {};
133
+ const d = asReadable(diff);
134
+ const loc = d.localization?.(lang) ?? d.localizations?.[lang];
135
+ if (!loc) return {};
136
+ const out: DiffSections = {};
137
+
138
+ const defChanged = loc.definitions?.changed?.[0];
139
+ if (defChanged?.textDiff?.hunks?.length) {
140
+ out.definition = { hunks: defChanged.textDiff.hunks };
141
+ } else if (loc.definitions?.added?.length) {
142
+ out.definition = { type: 'added', value: extractContentText(loc.definitions.added[0].value) };
143
+ } else if (loc.definitions?.removed?.length) {
144
+ out.definition = { type: 'removed', value: extractContentText(loc.definitions.removed[0].value) };
145
+ }
146
+
147
+ const hasDesignationChanges =
148
+ loc.designations?.added?.length
149
+ || loc.designations?.removed?.length
150
+ || loc.designations?.changed?.length;
151
+ if (hasDesignationChanges) {
152
+ const items: Array<{ type?: string; text: string }> = [];
153
+ for (const d of loc.designations.added ?? []) {
154
+ items.push({ type: 'added', text: extractDesignationText(d.value) });
155
+ }
156
+ for (const d of loc.designations.removed ?? []) {
157
+ items.push({ type: 'removed', text: extractDesignationText(d.value) });
158
+ }
159
+ for (const d of loc.designations.changed ?? []) {
160
+ items.push({ type: 'removed', text: extractDesignationText(d.oldValue) });
161
+ items.push({ type: 'added', text: extractDesignationText(d.newValue) });
162
+ }
163
+ out.designations = { items };
164
+ }
165
+
166
+ if (loc.notes?.added?.length || loc.notes?.removed?.length) {
167
+ out.notes = {
168
+ items: [
169
+ ...(loc.notes.added ?? []).map((n: any) => ({ type: 'added', text: extractContentText(n.value) })),
170
+ ...(loc.notes.removed ?? []).map((n: any) => ({ type: 'removed', text: extractContentText(n.value) })),
171
+ ],
172
+ };
173
+ }
174
+
175
+ if (loc.examples?.added?.length || loc.examples?.removed?.length) {
176
+ out.examples = {
177
+ items: [
178
+ ...(loc.examples.added ?? []).map((e: any) => ({ type: 'added', text: extractContentText(e.value) })),
179
+ ...(loc.examples.removed ?? []).map((e: any) => ({ type: 'removed', text: extractContentText(e.value) })),
180
+ ],
181
+ };
182
+ }
183
+
184
+ return out;
185
+ }
186
+
187
+ /**
188
+ * Similarity score in [0, 1]. Returns null when no comparable stats.
189
+ *
190
+ * Computed as 1 − (added + removed + changed) / total — i.e. the
191
+ * fraction of the concept that did NOT change. `added` counts as a
192
+ * change because it represents content present in only one version.
193
+ */
194
+ export function computeSimilarity(diff: ConceptDiff | null): number | null {
195
+ if (!diff) return null;
196
+ const stats = asReadable(diff).stats;
197
+ if (!stats || !stats.total) return null;
198
+ const changed = (stats.added ?? 0) + (stats.removed ?? 0) + (stats.changed ?? 0);
199
+ return Math.max(0, Math.min(1, 1 - changed / stats.total));
200
+ }
201
+
202
+ // ── Composable ────────────────────────────────────────────────────────────
203
+
204
+ export function useConceptDiff() {
205
+ const diffResult = ref<ConceptDiff | null>(null);
206
+ const loading = ref(false);
207
+ const error = ref<string | null>(null);
208
+
209
+ function diff(oldData: ConceptLikeData | null, newData: ConceptLikeData | null): void {
210
+ loading.value = true;
211
+ error.value = null;
212
+ try {
213
+ diffResult.value = diffConcepts(
214
+ oldData ? toDiffableConcept(oldData) : null,
215
+ newData ? toDiffableConcept(newData) : null,
216
+ );
217
+ } catch (e: unknown) {
218
+ error.value = e instanceof Error ? e.message : String(e);
219
+ diffResult.value = null;
220
+ } finally {
221
+ loading.value = false;
222
+ }
223
+ }
224
+
225
+ function clear(): void {
226
+ diffResult.value = null;
227
+ error.value = null;
228
+ }
229
+
230
+ return { diffResult, loading, error, diff, clear };
231
+ }
@@ -1,9 +1,23 @@
1
1
  import { computed, type ComputedRef } from 'vue';
2
2
  import type { Router } from 'vue-router';
3
- import type { Concept, RelatedConcept } from 'glossarist';
4
- import type { Manifest, GraphEdge } from '../adapters/types';
3
+ import type {
4
+ Concept,
5
+ RelatedConcept,
6
+ PartitiveRelation as GlsPartitiveRelation,
7
+ PartitiveMember as GlsPartitiveMember,
8
+ TypeSharedPlurality as GlsTypeSharedPlurality,
9
+ ConceptRef,
10
+ } from 'glossarist';
11
+ import type {
12
+ Manifest,
13
+ GraphEdge,
14
+ PartitiveRelationWire,
15
+ PartitiveMemberWire,
16
+ TypeSharedPluralityWire,
17
+ } from '../adapters/types';
5
18
  import { getFactory } from '../adapters/factory';
6
19
  import { conceptUri } from '../adapters/model-bridge';
20
+ import { UriRouter } from '../adapters/UriRouter';
7
21
  import { useVocabularyStore } from '../stores/vocabulary';
8
22
  import { useDsStyle } from '../utils/dataset-style';
9
23
  import { categorizeRelationship, relationshipLabel, INVERSE_RELATIONSHIPS } from '../utils/relationship-categories';
@@ -111,9 +125,80 @@ export function useConceptEdges(
111
125
  content: '',
112
126
  };
113
127
  });
114
- return [...direct, ...derived];
128
+ return [...direct, ...derived].filter(edge => {
129
+ if (edge.type === 'broader_partitive' || edge.type === 'narrower_partitive') {
130
+ const refUri = resolveRefUri(edge.ref as { source: string | null; id: string | null } | null);
131
+ if (!refUri) return true;
132
+ const rels = conceptPartitiveRelations.value;
133
+ return !rels.some(rel =>
134
+ rel.comprehensive === refUri
135
+ || rel.partitives.some(m => m.uri === refUri),
136
+ );
137
+ }
138
+ return true;
139
+ });
115
140
  });
116
141
 
142
+ // Concept-level partitive relations (ISO 704 one-to-many decompositions).
143
+ // Reads the glossarist-js native model (`concept.partitiveRelations`)
144
+ // and projects each relation into a URI-resolved wire shape for display.
145
+ // Independent of binary `conceptRelated`.
146
+ // v2 shape per concept-model/TODO.partitive-relation-v2.
147
+ const conceptPartitiveRelations = computed<PartitiveRelationWire[]>(() => {
148
+ const source = conceptUriValue.value;
149
+ const relations: readonly GlsPartitiveRelation[] = concept.value.partitiveRelations;
150
+ return relations
151
+ .map((rel): PartitiveRelationWire | null => {
152
+ const comprehensive = resolveConceptRefUri(rel.comprehensive);
153
+ if (!comprehensive) return null;
154
+ const partitives: PartitiveMemberWire[] = rel.partitives
155
+ .map((m: GlsPartitiveMember): PartitiveMemberWire | null => {
156
+ const uri = resolveConceptRefUri(m.ref);
157
+ if (!uri || uri === source) return null;
158
+ return { uri, certainty: m.certainty };
159
+ })
160
+ .filter((m: PartitiveMemberWire | null): m is PartitiveMemberWire => m !== null);
161
+ if (partitives.length === 0) return null;
162
+
163
+ return {
164
+ source,
165
+ comprehensive,
166
+ partitives,
167
+ completeness: rel.completeness,
168
+ plurality: projectPlurality(rel.plurality),
169
+ criterion: rel.criterion ?? undefined,
170
+ register: registerId.value,
171
+ };
172
+ })
173
+ .filter((r: PartitiveRelationWire | null): r is PartitiveRelationWire => r !== null);
174
+ });
175
+
176
+ function projectPlurality(p: GlsTypeSharedPlurality | null): TypeSharedPluralityWire | null {
177
+ if (!p) return null;
178
+ const sharedTypeRef: ConceptRef | null = p.sharedType;
179
+ const sharedType = sharedTypeRef
180
+ ? [sharedTypeRef.source, sharedTypeRef.id].filter(Boolean).join(':') || null
181
+ : null;
182
+ return {
183
+ isShared: p.isShared,
184
+ isUncertain: p.isUncertain,
185
+ sharedType,
186
+ };
187
+ }
188
+
189
+ function resolveConceptRefUri(ref: ConceptRef | null): string | null {
190
+ if (!ref) return null;
191
+ return resolveRefUri({ source: ref.source ?? null, id: ref.id ?? null });
192
+ }
193
+
194
+ function resolveRefUri(ref: { source: string | null; id: string | null } | null): string | null {
195
+ const target = resolveRelatedRef(ref);
196
+ if (!target) return null;
197
+ const m = store.manifests.get(target.registerId);
198
+ if (!m) return null;
199
+ return UriRouter.buildConceptUri(m.uriBase, target.registerId, target.conceptId);
200
+ }
201
+
117
202
  function resolveRelatedRef(ref: { source: string | null; id: string | null } | null) {
118
203
  return factory.resolveRelatedRef(ref, registerId.value);
119
204
  }
@@ -134,8 +219,15 @@ export function useConceptEdges(
134
219
  return resolvedRefs.value.get(key) ?? { target: resolveRelatedRef(ref) };
135
220
  }
136
221
 
137
- function relatedLabel(dr: { content?: string | null; ref?: { source: string | null; id: string | null } | null }): string {
138
- if (dr.content) return dr.content;
222
+ function relatedLabel(dr: { content?: Record<string, string> | string | null; ref?: { source: string | null; id: string | null } | null }): string {
223
+ if (dr.content) {
224
+ // Content is a localized hash { eng: "...", fra: "..." }.
225
+ // Legacy data may still carry a plain string; tolerate both.
226
+ const c = dr.content as Record<string, string> | string;
227
+ if (typeof c === 'string') return c;
228
+ const values = Object.values(c);
229
+ if (values.length > 0) return values[0];
230
+ }
139
231
  const resolved = dr.ref ? getResolvedRef(dr.ref).target : null;
140
232
  if (resolved) {
141
233
  const m = store.manifests.get(resolved.registerId);
@@ -173,6 +265,7 @@ export function useConceptEdges(
173
265
  edgeBadgeColor,
174
266
  inverseEdgeType,
175
267
  conceptRelated,
268
+ conceptPartitiveRelations,
176
269
  resolveRelatedRef,
177
270
  getResolvedRef,
178
271
  relatedLabel,
@@ -0,0 +1,52 @@
1
+ /**
2
+ * useDatasetStats — loads stats.json for a dataset register.
3
+ *
4
+ * Single source of truth for the DatasetStats wire shape; both
5
+ * DatasetView (summary badges) and StatsView (full table) consume it.
6
+ */
7
+ import { ref, watch, type Ref } from 'vue';
8
+ import type { DatasetStats } from '../adapters/dataset-stats';
9
+
10
+ export function useDatasetStats(
11
+ registerId: Ref<string> | (() => string),
12
+ baseUrl: string = import.meta.env.BASE_URL,
13
+ ) {
14
+ const stats = ref<DatasetStats | null>(null);
15
+ const loading = ref(false);
16
+ const error = ref<string | null>(null);
17
+
18
+ const resolved = typeof registerId === 'function' ? registerId() : registerId.value;
19
+
20
+ async function load(id: string) {
21
+ if (!id) return;
22
+ loading.value = true;
23
+ error.value = null;
24
+ try {
25
+ const resp = await fetch(`${baseUrl}data/${id}/stats.json`);
26
+ if (resp.ok) {
27
+ stats.value = await resp.json() as DatasetStats;
28
+ } else {
29
+ stats.value = null;
30
+ }
31
+ } catch (e: unknown) {
32
+ stats.value = null;
33
+ error.value = e instanceof Error ? e.message : String(e);
34
+ } finally {
35
+ loading.value = false;
36
+ }
37
+ }
38
+
39
+ if (typeof registerId === 'function') {
40
+ // Re-fetch on app boot only; callers can call reload() to refresh.
41
+ load(resolved);
42
+ } else {
43
+ watch(registerId, id => load(id), { immediate: true });
44
+ }
45
+
46
+ function reload() {
47
+ const id = typeof registerId === 'function' ? registerId() : registerId.value;
48
+ load(id);
49
+ }
50
+
51
+ return { stats, loading, error, reload };
52
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Page type registry — OCP-compliant metadata for synthesized pages.
3
+ *
4
+ * Adding a new built-in page type = add one entry to PAGE_TYPES.
5
+ * `synthesizeGlobalPages` and `synthesizeDatasetPages` iterate the
6
+ * registry; no switches to edit.
7
+ *
8
+ * PageConfig.type stays a string — it tags the page's semantic kind
9
+ * for consumers (sidebar, future renderers). The router is the SSOT
10
+ * for route → view dispatch, not this registry.
11
+ *
12
+ * Mirrors the group-renderers.ts pattern: registry + lookup helper.
13
+ */
14
+
15
+ export interface PageTypeDefinition {
16
+ readonly type: string;
17
+ readonly route: string;
18
+ readonly title: string;
19
+ readonly icon: string;
20
+ readonly scope: 'global' | 'dataset';
21
+ /** When defined, the page is only synthesized when features[flag] is truthy
22
+ * (or, if the flag starts with '!', when features[flag.slice(1)] is falsy). */
23
+ readonly featureFlag?: string;
24
+ /** Synthesize automatically unless a declared page already uses this route. */
25
+ readonly autoSynthesize?: boolean;
26
+ }
27
+
28
+ export const PAGE_TYPES: readonly PageTypeDefinition[] = [
29
+ { type: 'home', route: '', title: 'Home', icon: 'home', scope: 'global', autoSynthesize: true },
30
+ { type: 'concepts', route: '', title: 'Concepts', icon: 'list', scope: 'dataset', autoSynthesize: true },
31
+ { type: 'search', route: 'search', title: 'Search', icon: 'search', scope: 'global', featureFlag: '!search', autoSynthesize: true },
32
+ { type: 'graph', route: 'graph', title: 'Graph', icon: 'graph', scope: 'global', featureFlag: '!graph', autoSynthesize: true },
33
+ { type: 'ontology', route: 'ontology',title: 'Ontology', icon: 'schema', scope: 'global', featureFlag: '!ontology', autoSynthesize: true },
34
+ { type: 'news', route: 'news', title: 'News', icon: 'newspaper', scope: 'global', featureFlag: 'news', autoSynthesize: true },
35
+ { type: 'stats', route: 'stats', title: 'Statistics', icon: 'chart', scope: 'dataset', featureFlag: '!stats', autoSynthesize: true },
36
+ { type: 'sources', route: 'sources', title: 'Sources', icon: 'database', scope: 'dataset', autoSynthesize: true },
37
+ { type: 'about', route: 'about', title: 'About', icon: 'info', scope: 'dataset', featureFlag: '!about', autoSynthesize: true },
38
+ ] as const;
39
+
40
+ export type BuiltinPageType = typeof PAGE_TYPES[number]['type'];
41
+
42
+ const PAGE_TYPE_INDEX: ReadonlyMap<string, PageTypeDefinition> = new Map(
43
+ PAGE_TYPES.map(p => [p.type, p]),
44
+ );
45
+
46
+ export function pageTypeOf(type: string): PageTypeDefinition | undefined {
47
+ return PAGE_TYPE_INDEX.get(type);
48
+ }
49
+
50
+ function featureEnabled(features: Record<string, unknown> | undefined, flag: string): boolean {
51
+ if (flag.startsWith('!')) {
52
+ return features?.[flag.slice(1)] !== false;
53
+ }
54
+ return Boolean(features?.[flag]);
55
+ }
56
+
57
+ /** Synthesize the auto-on pages for a given scope, respecting feature flags
58
+ * and skipping routes already declared by the user. */
59
+ export function synthesizePages(
60
+ scope: 'global' | 'dataset',
61
+ features: Record<string, unknown> | undefined,
62
+ declaredRoutes: ReadonlySet<string>,
63
+ ): PageTypeDefinition[] {
64
+ return PAGE_TYPES.filter(p => p.scope === scope && p.autoSynthesize)
65
+ .filter(p => !p.featureFlag || featureEnabled(features, p.featureFlag))
66
+ .filter(p => !declaredRoutes.has(p.route));
67
+ }
@@ -137,7 +137,12 @@ export const FORMAT_LABELS: Record<string, string> = {
137
137
 
138
138
  // === Pages ===
139
139
 
140
- export type PageType = 'page' | 'news' | 'contributors' | 'about' | 'stats' | 'custom';
140
+ /**
141
+ * Page type tag. Any string is allowed — built-in tags are listed in
142
+ * `page-types.ts` PAGE_TYPES registry. Adding a new built-in type is
143
+ * a registry entry, not an edit to this union.
144
+ */
145
+ export type PageType = string;
141
146
 
142
147
  export interface PageConfig {
143
148
  type: PageType;
@@ -1,6 +1,7 @@
1
1
  import { ref, computed } from 'vue';
2
2
  import type { PageConfig, SiteColors } from './types';
3
3
  import type { DatasetGroup } from './types';
4
+ import { synthesizePages } from './page-types';
4
5
  import { locale } from '../i18n';
5
6
 
6
7
  export interface RuntimeSiteConfig {
@@ -128,43 +129,26 @@ function synthesizeGlobalPages(features?: Record<string, unknown>, pages?: PageC
128
129
  const declared = pages?.filter(p => !p.datasetScoped) ?? [];
129
130
  const declaredRoutes = new Set(declared.map(p => p.route));
130
131
 
131
- const result: PageConfig[] = [
132
- { type: 'custom', route: '', title: 'Home', icon: 'home' },
133
- ];
134
- if (features?.search !== false && !declaredRoutes.has('search')) {
135
- result.push({ type: 'custom', route: 'search', title: 'Search', icon: 'search' });
136
- }
137
- if (features?.graph !== false && !declaredRoutes.has('graph')) {
138
- result.push({ type: 'custom', route: 'graph', title: 'Graph', icon: 'graph' });
139
- }
140
- if (features?.ontology !== false && !declaredRoutes.has('ontology')) {
141
- result.push({ type: 'custom', route: 'ontology', title: 'Ontology', icon: 'schema' });
142
- }
143
- if (features?.news && !declaredRoutes.has('news')) {
144
- result.push({ type: 'news', route: 'news', title: 'News', icon: 'newspaper' });
145
- }
132
+ const synthesized = synthesizePages('global', features, declaredRoutes)
133
+ .map(p => ({ type: p.type, route: p.route, title: p.title, icon: p.icon }));
146
134
 
147
- return [...result, ...declared];
135
+ return [...synthesized, ...declared];
148
136
  }
149
137
 
150
138
  function synthesizeDatasetPages(features?: Record<string, unknown>, pages?: PageConfig[]): PageConfig[] {
151
139
  const declared = pages?.filter(p => p.datasetScoped) ?? [];
152
140
  const declaredRoutes = new Set(declared.map(p => p.route));
153
141
 
154
- const result: PageConfig[] = [
155
- { type: 'custom', route: '', title: 'Concepts', icon: 'list', datasetScoped: true },
156
- ];
157
- if (features?.stats !== false && !declaredRoutes.has('stats')) {
158
- result.push({ type: 'stats', route: 'stats', title: 'Statistics', icon: 'chart', datasetScoped: true });
159
- }
160
- if (!declaredRoutes.has('sources')) {
161
- result.push({ type: 'sources', route: 'sources', title: 'Sources', icon: 'database', datasetScoped: true });
162
- }
163
- if (features?.about !== false && !declaredRoutes.has('about')) {
164
- result.push({ type: 'about', route: 'about', title: 'About', icon: 'info', datasetScoped: true });
165
- }
142
+ const synthesized = synthesizePages('dataset', features, declaredRoutes)
143
+ .map(p => ({
144
+ type: p.type,
145
+ route: p.route,
146
+ title: p.title,
147
+ icon: p.icon,
148
+ datasetScoped: true as const,
149
+ }));
166
150
 
167
- return [...result, ...declared];
151
+ return [...synthesized, ...declared];
168
152
  }
169
153
 
170
154
  export function useSiteConfig() {