@glossarist/concept-browser 0.7.22 → 0.7.24

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 (38) hide show
  1. package/index.html +2 -1
  2. package/package.json +1 -1
  3. package/scripts/build-edges.js +50 -5
  4. package/scripts/generate-data.mjs +33 -6
  5. package/src/App.vue +10 -12
  6. package/src/__tests__/concept-view.test.ts +7 -1
  7. package/src/__tests__/dataset-adapter.test.ts +87 -0
  8. package/src/__tests__/dataset-view.test.ts +1 -0
  9. package/src/__tests__/factory-lazy.test.ts +183 -0
  10. package/src/__tests__/graph-engine-fixes.test.ts +104 -0
  11. package/src/__tests__/ontology-registry.test.ts +4 -4
  12. package/src/__tests__/performance-v2.test.ts +77 -0
  13. package/src/__tests__/performance.test.ts +95 -0
  14. package/src/__tests__/search-utils.test.ts +59 -0
  15. package/src/__tests__/test-helpers.ts +4 -0
  16. package/src/__tests__/utils-barrel.test.ts +15 -0
  17. package/src/__tests__/vocabulary-layered.test.ts +291 -0
  18. package/src/adapters/DatasetAdapter.ts +41 -1
  19. package/src/adapters/factory.ts +35 -4
  20. package/src/adapters/ontology-registry.ts +1 -1
  21. package/src/adapters/types.ts +12 -0
  22. package/src/components/AppSidebar.vue +17 -343
  23. package/src/components/ConceptDetail.vue +121 -70
  24. package/src/components/GraphPanel.vue +14 -6
  25. package/src/components/OntologySidebarSection.vue +338 -0
  26. package/src/config/use-site-config.ts +20 -9
  27. package/src/data/taxonomies.json +12 -6
  28. package/src/directives/v-math.ts +2 -3
  29. package/src/graph/GraphEngine.ts +22 -5
  30. package/src/i18n/index.ts +1 -1
  31. package/src/stores/vocabulary.ts +65 -105
  32. package/src/utils/index.ts +1 -0
  33. package/src/utils/relationship-categories.ts +3 -2
  34. package/src/utils/search.ts +15 -0
  35. package/src/views/ConceptView.vue +0 -2
  36. package/src/views/DatasetView.vue +64 -39
  37. package/src/views/HomeView.vue +0 -1
  38. package/vite.config.ts +94 -6
@@ -1,11 +1,12 @@
1
1
  import { defineStore } from 'pinia';
2
- import { ref, computed, toRaw } from 'vue';
2
+ import { ref, shallowRef, computed } from 'vue';
3
3
  import { getFactory } from '../adapters/factory';
4
4
  import type { DatasetAdapter } from '../adapters/DatasetAdapter';
5
5
  import type { Manifest, SearchHit, GraphEdge } from '../adapters/types';
6
6
  import type { Concept } from 'glossarist';
7
7
  import { conceptUri } from '../adapters/model-bridge';
8
8
  import { GraphEngine } from '../graph';
9
+ import { deduplicateSearchHits } from '../utils/search';
9
10
 
10
11
  export const useVocabularyStore = defineStore('vocabulary', () => {
11
12
  // State
@@ -16,7 +17,7 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
16
17
  const currentConceptId = ref<string>('');
17
18
  const loading = ref(false);
18
19
  const error = ref<string | null>(null);
19
- const graph = ref(new GraphEngine());
20
+ const graph = shallowRef(new GraphEngine());
20
21
  const conceptEdges = ref<GraphEdge[]>([]);
21
22
  const initialized = ref(false);
22
23
 
@@ -76,75 +77,19 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
76
77
  manifests.value.set(registerId, adapter.manifest);
77
78
  }
78
79
 
79
- // Load pre-computed edges (lightweight)
80
- await loadEdges(adapter);
81
-
82
80
  touchGraph();
83
-
84
- // Seed graph nodes lazily — don't block UI for large datasets
85
- seedGraphNodes(registerId, adapter);
86
81
  } catch (e: unknown) {
87
82
  error.value = `Failed to load dataset ${registerId}: ${e instanceof Error ? e.message : String(e)}`;
88
83
  throw e;
89
84
  }
90
85
  }
91
86
 
92
- function seedGraphNodes(registerId: string, adapter: DatasetAdapter, sync = false) {
93
- const entries = adapter.getConcepts();
94
-
95
- if (sync) {
96
- for (const entry of entries) {
97
- if (!entry) continue;
98
- graph.value.addNode({
99
- uri: factory.router.buildUri(registerId, entry.id),
100
- register: registerId,
101
- conceptId: entry.id,
102
- designations: entry.designations,
103
- status: entry.status,
104
- loaded: false,
105
- });
106
- }
107
- touchGraph();
108
- return;
109
- }
110
-
111
- const batchSize = 500;
112
- let offset = 0;
113
- const schedule = typeof requestIdleCallback !== 'undefined'
114
- ? requestIdleCallback
115
- : (cb: () => void) => setTimeout(cb, 0);
116
-
117
- function processBatch() {
118
- const end = Math.min(offset + batchSize, entries.length);
119
- for (let i = offset; i < end; i++) {
120
- const entry = entries[i];
121
- if (!entry) continue;
122
- graph.value.addNode({
123
- uri: factory.router.buildUri(registerId, entry.id),
124
- register: registerId,
125
- conceptId: entry.id,
126
- designations: entry.designations,
127
- status: entry.status,
128
- loaded: false,
129
- });
130
- }
131
- offset = end;
132
- if (offset < entries.length) {
133
- schedule(processBatch);
134
- } else {
135
- touchGraph();
136
- }
137
- }
138
-
139
- schedule(processBatch);
140
- }
141
-
142
87
  async function loadAllGraphData() {
143
88
  if (!initialized.value) {
144
89
  await discoverDatasets();
145
90
  }
146
91
 
147
- const engine = toRaw(graph.value);
92
+ const engine = graph.value;
148
93
  const adapters = factory.getAdapters();
149
94
 
150
95
  await Promise.allSettled(adapters.map(async (adapter) => {
@@ -197,11 +142,12 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
197
142
  adapter.loadEdgeIndex(),
198
143
  adapter.loadDomainNodes(),
199
144
  ]);
145
+ const engine = graph.value;
200
146
  for (const dn of domainNodes) {
201
- graph.value.addNode(dn);
147
+ engine.addNode(dn);
202
148
  }
203
149
  for (const edge of edges) {
204
- graph.value.addEdge(edge);
150
+ engine.addEdge(edge);
205
151
  }
206
152
  edgeStatus.value[adapter.registerId] = { loaded: true, count: edges.length };
207
153
  } catch {
@@ -209,19 +155,19 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
209
155
  }
210
156
  }
211
157
 
212
- async function ensureAllEdgesLoaded() {
213
- for (const [id, adapter] of datasets.value) {
214
- if (!edgeStatus.value[id]?.loaded) {
215
- try {
216
- const edges = await adapter.loadEdgeIndex();
217
- for (const edge of edges) {
218
- graph.value.addEdge(edge);
219
- }
220
- edgeStatus.value[id] = { loaded: true, count: edges.length };
221
- } catch {
222
- edgeStatus.value[id] = { loaded: false, count: 0 };
223
- }
224
- }
158
+ async function ensureEdgesForDataset(registerId: string) {
159
+ const adapter = datasets.value.get(registerId);
160
+ if (adapter && !edgeStatus.value[registerId]?.loaded) {
161
+ await loadEdges(adapter);
162
+ }
163
+
164
+ const index = await factory.loadCrossRefIndex();
165
+ const refs = index[registerId] || [];
166
+ for (const refId of refs) {
167
+ if (edgeStatus.value[refId]?.loaded) continue;
168
+ const refAdapter = datasets.value.get(refId);
169
+ if (!refAdapter) continue;
170
+ await loadEdges(refAdapter);
225
171
  }
226
172
  }
227
173
 
@@ -234,16 +180,18 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
234
180
  const adapter = datasets.value.get(registerId);
235
181
  if (!adapter) throw new Error(`Dataset ${registerId} not loaded`);
236
182
 
237
- const concept = await adapter.fetchConcept(conceptId);
183
+ // Fetch concept and cross-dataset edges in parallel
184
+ const [concept] = await Promise.all([
185
+ adapter.fetchConcept(conceptId),
186
+ ensureEdgesForDataset(registerId),
187
+ ]);
238
188
  currentConcept.value = concept;
239
189
 
240
- // Extract and register edges for this specific concept
241
190
  const edges = adapter.extractEdges(concept);
242
191
  const domainEdges = adapter.extractDomainEdges(concept);
243
192
  const uriBase = adapter.manifest?.uriBase || 'https://glossarist.org';
244
193
  const uri = conceptUri(concept, registerId, uriBase);
245
194
 
246
- // Update graph node with full data
247
195
  const designations: Record<string, string> = {};
248
196
  const indexEntry = adapter.getIndexEntry(conceptId);
249
197
  if (indexEntry) {
@@ -258,7 +206,8 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
258
206
  }
259
207
  }
260
208
 
261
- graph.value.addNode({
209
+ const engine = graph.value;
210
+ engine.addNode({
262
211
  uri,
263
212
  register: registerId,
264
213
  conceptId,
@@ -268,14 +217,14 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
268
217
  });
269
218
 
270
219
  for (const edge of edges) {
271
- graph.value.addEdge(edge);
220
+ engine.addEdge(edge);
272
221
  }
273
222
 
274
223
  for (const edge of domainEdges) {
275
- graph.value.addEdge(edge);
276
- const existing = graph.value.getNode(edge.target);
224
+ engine.addEdge(edge);
225
+ const existing = engine.getNode(edge.target);
277
226
  if (!existing || !existing.loaded) {
278
- graph.value.addNode({
227
+ engine.addNode({
279
228
  uri: edge.target,
280
229
  register: registerId,
281
230
  conceptId: '',
@@ -287,13 +236,10 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
287
236
  }
288
237
  }
289
238
 
290
- // Ensure edges from all datasets are loaded for cross-dataset supersession
291
- await ensureAllEdgesLoaded();
292
-
293
239
  touchGraph();
294
240
  conceptEdges.value = [
295
- ...graph.value.getEdges(uri),
296
- ...graph.value.getIncomingEdges(uri),
241
+ ...engine.getEdges(uri),
242
+ ...engine.getIncomingEdges(uri),
297
243
  ];
298
244
  } catch (e: unknown) {
299
245
  error.value = `Failed to load concept ${conceptId}: ${e instanceof Error ? e.message : String(e)}`;
@@ -326,32 +272,46 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
326
272
  await discoverDatasets();
327
273
  }
328
274
 
329
- const allHits: SearchHit[] = [];
275
+ const MIN_RESULTS = 20;
276
+
277
+ // Pass 1: search loaded data only
278
+ const loadedHits: SearchHit[] = [];
279
+ const unloadedAdapters: DatasetAdapter[] = [];
280
+
330
281
  for (const adapter of datasets.value.values()) {
331
- if (adapter.manifest) {
332
- if (!adapter.index) {
282
+ if (!adapter.manifest) continue;
283
+ if (!adapter.index) {
284
+ try {
333
285
  await adapter.loadIndex();
286
+ } catch {
287
+ continue;
334
288
  }
335
- await adapter.ensureAllChunksLoaded();
336
- allHits.push(...adapter.search(query));
289
+ }
290
+ const hits = adapter.search(query);
291
+ if (hits.length > 0) {
292
+ loadedHits.push(...hits);
293
+ } else {
294
+ unloadedAdapters.push(adapter);
337
295
  }
338
296
  }
339
297
 
340
- // Deduplicate: keep the best hit per (registerId, conceptId)
341
- const best = new Map<string, SearchHit>();
342
- for (const hit of allHits) {
343
- const key = `${hit.registerId}:${hit.conceptId}`;
344
- const existing = best.get(key);
345
- if (!existing) {
346
- best.set(key, hit);
347
- } else {
348
- // Prefer designation match over id match, then prefer shorter language code (eng first)
349
- if (hit.matchField === 'designation' && existing.matchField === 'id') {
350
- best.set(key, hit);
351
- }
298
+ const pass1 = deduplicateSearchHits(loadedHits);
299
+ if (pass1.length >= MIN_RESULTS) return pass1;
300
+
301
+ // Pass 2: load chunks lazily for datasets that found nothing in index
302
+ let allHits = [...loadedHits];
303
+ for (const adapter of unloadedAdapters) {
304
+ if (deduplicateSearchHits(allHits).length >= MIN_RESULTS) break;
305
+ try {
306
+ await adapter.ensureAllChunksLoaded();
307
+ const hits = adapter.search(query);
308
+ allHits.push(...hits);
309
+ } catch {
310
+ // Skip datasets that fail to load
352
311
  }
353
312
  }
354
- return [...best.values()];
313
+
314
+ return deduplicateSearchHits(allHits);
355
315
  }
356
316
 
357
317
  async function getRandomConcept(): Promise<{ registerId: string; conceptId: string } | null> {
@@ -1 +1,2 @@
1
1
  export { langName, langLabel, DEFAULT_LANG } from './lang';
2
+ export { deduplicateSearchHits } from './search';
@@ -20,7 +20,7 @@ export const RELATIONSHIP_CATEGORIES: RelationshipCategory[] = [
20
20
  {
21
21
  id: 'mapping',
22
22
  label: 'Equivalence',
23
- types: ['equivalent', 'close_match', 'broad_match', 'narrow_match', 'related_match'],
23
+ types: ['equivalent', 'exact_match', 'close_match', 'broad_match', 'narrow_match', 'related_match'],
24
24
  color: 'text-emerald-600 bg-emerald-50',
25
25
  },
26
26
  {
@@ -53,7 +53,7 @@ export const RELATIONSHIP_CATEGORIES: RelationshipCategory[] = [
53
53
  {
54
54
  id: 'spatiotemporal',
55
55
  label: 'Spatiotemporal',
56
- types: ['sequentially_related_concept', 'spatially_related_concept', 'temporally_related_concept'],
56
+ types: ['sequentially_related', 'spatially_related', 'temporally_related'],
57
57
  color: 'text-teal-600 bg-teal-50',
58
58
  },
59
59
  {
@@ -119,6 +119,7 @@ export const INVERSE_RELATIONSHIPS: Record<string, string> = {
119
119
 
120
120
  // Symmetric (self-inverse)
121
121
  equivalent: 'equivalent',
122
+ exact_match: 'exact_match',
122
123
  compare: 'compare',
123
124
  contrast: 'contrast',
124
125
  close_match: 'close_match',
@@ -0,0 +1,15 @@
1
+ import type { SearchHit } from '../adapters/types';
2
+
3
+ export function deduplicateSearchHits(hits: SearchHit[]): SearchHit[] {
4
+ const best = new Map<string, SearchHit>();
5
+ for (const hit of hits) {
6
+ const key = `${hit.registerId}:${hit.conceptId}`;
7
+ const existing = best.get(key);
8
+ if (!existing) {
9
+ best.set(key, hit);
10
+ } else if (hit.matchField === 'designation' && existing.matchField === 'id') {
11
+ best.set(key, hit);
12
+ }
13
+ }
14
+ return [...best.values()];
15
+ }
@@ -61,8 +61,6 @@ async function loadAdjacent() {
61
61
  adjacent.value = adapter.getAdjacentConcepts(props.conceptId);
62
62
  }
63
63
 
64
- watch(() => props.conceptId, () => { loadAdjacent(); });
65
-
66
64
  function goAdjacent(id: string) {
67
65
  router.push({ name: 'concept', params: { registerId: props.registerId, conceptId: id } });
68
66
  window.scrollTo({ top: 0, behavior: 'smooth' });
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
2
+ import { computed, ref, watch, onMounted, onUnmounted, onBeforeUnmount } from 'vue';
3
3
  import { useRoute, useRouter } from 'vue-router';
4
4
  import { useVocabularyStore } from '../stores/vocabulary';
5
5
  import { useDsStyle } from '../utils/dataset-style';
@@ -9,7 +9,7 @@ import { langName, langLabel, sortLanguages } from '../utils/lang';
9
9
  import ConceptCard from '../components/ConceptCard.vue';
10
10
  import { useI18n, locale } from '../i18n';
11
11
  import { useSiteConfig } from '../config/use-site-config';
12
- import type { SectionNode, ManifestSection } from '../adapters/types';
12
+ import type { SectionNode } from '../adapters/types';
13
13
 
14
14
  const props = defineProps<{ registerId: string }>();
15
15
 
@@ -27,6 +27,41 @@ const localizedDescription = computed(() => localizedDatasetField(props.register
27
27
  const adapter = computed(() => store.datasets.get(props.registerId));
28
28
  const chunkLoading = ref(false);
29
29
 
30
+ // Background chunk preloading via requestIdleCallback
31
+ let idlePreloadHandle: ReturnType<typeof requestIdleCallback> | ReturnType<typeof setTimeout> | null = null;
32
+
33
+ watch(adapter, (a) => {
34
+ if (idlePreloadHandle !== null) return;
35
+ if (!a || !a.index) return;
36
+
37
+ const schedule = typeof requestIdleCallback !== 'undefined'
38
+ ? requestIdleCallback
39
+ : (cb: () => void) => setTimeout(cb, 0);
40
+
41
+ idlePreloadHandle = schedule(() => {
42
+ if (allChunksLoaded.value || !a.index) {
43
+ idlePreloadHandle = null;
44
+ return;
45
+ }
46
+ const count = a.getConceptCount();
47
+ if (count <= 200) {
48
+ a.ensureAllChunksLoaded().then(() => {
49
+ allChunksLoaded.value = true;
50
+ }).catch(() => {});
51
+ } else {
52
+ a.ensureChunksForRange(0, 100).catch(() => {});
53
+ }
54
+ idlePreloadHandle = null;
55
+ }, { timeout: 2000 } as any);
56
+ });
57
+
58
+ onBeforeUnmount(() => {
59
+ if (idlePreloadHandle !== null) {
60
+ (typeof cancelIdleCallback !== 'undefined' ? cancelIdleCallback : clearTimeout)(idlePreloadHandle as any);
61
+ idlePreloadHandle = null;
62
+ }
63
+ });
64
+
30
65
  function formatSize(bytes: number): string {
31
66
  if (bytes < 1024) return `${bytes} B`;
32
67
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -87,37 +122,26 @@ function onGlobalKeydown(e: KeyboardEvent) {
87
122
  onMounted(() => window.addEventListener('keydown', onGlobalKeydown));
88
123
  onUnmounted(() => window.removeEventListener('keydown', onGlobalKeydown));
89
124
 
90
- // When filtering, ensure all chunks are loaded for accurate search
91
- watch(filter, async (q) => {
125
+ async function ensureAllChunksForFilter(needsLoad: boolean) {
92
126
  page.value = 1;
93
- if (q.trim().length >= 2 && !allChunksLoaded.value && adapter.value) {
127
+ if (needsLoad && !allChunksLoaded.value && adapter.value) {
94
128
  chunkLoading.value = true;
95
129
  await adapter.value.ensureAllChunksLoaded();
96
130
  allChunksLoaded.value = true;
97
131
  chunkLoading.value = false;
98
132
  }
133
+ }
134
+
135
+ watch(filter, async (q) => {
136
+ await ensureAllChunksForFilter(q.trim().length >= 2);
99
137
  });
100
138
 
101
- // When section filter changes, reset page and load all chunks
102
139
  watch(sectionQuery, async () => {
103
- page.value = 1;
104
- if (sectionQuery.value && !allChunksLoaded.value && adapter.value) {
105
- chunkLoading.value = true;
106
- await adapter.value.ensureAllChunksLoaded();
107
- allChunksLoaded.value = true;
108
- chunkLoading.value = false;
109
- }
140
+ await ensureAllChunksForFilter(!!sectionQuery.value);
110
141
  });
111
142
 
112
- // When language filter changes, reset page and load all chunks
113
143
  watch(selectedLang, async (lang) => {
114
- page.value = 1;
115
- if (lang && !allChunksLoaded.value && adapter.value) {
116
- chunkLoading.value = true;
117
- await adapter.value.ensureAllChunksLoaded();
118
- allChunksLoaded.value = true;
119
- chunkLoading.value = false;
120
- }
144
+ await ensureAllChunksForFilter(!!lang);
121
145
  });
122
146
 
123
147
  // Dense array: only loaded (non-undefined) entries
@@ -133,38 +157,39 @@ const filtered = computed(() => {
133
157
  const sec = sectionQuery.value;
134
158
  return loadedConcepts.value.filter(c => {
135
159
  if (lang && !(lang in (c.designations ?? {}))) return false;
136
- if (sec && !conceptMatchesSection(c.id, sec)) return false;
160
+ if (sec && !conceptMatchesSection(c, sec)) return false;
137
161
  if (!q) return true;
138
162
  return (c.eng || '').toLowerCase().includes(q) || c.id.toLowerCase().includes(q);
139
163
  });
140
164
  });
141
165
 
142
- function conceptMatchesSection(conceptId: string, sectionPrefix: string): boolean {
143
- // section-X matches concept IDs starting with X.
144
- // e.g. section-1 matches 1.1, 1.2, etc.
145
- // section-103-01 matches 103-01-01, 103-01-02, etc.
166
+ function conceptMatchesSection(concept: import('../adapters/types').ConceptSummary, sectionPrefix: string): boolean {
146
167
  const prefix = sectionPrefix.replace(/^section-/, '');
147
- return conceptId.startsWith(prefix + '.') || conceptId.startsWith(prefix + '-');
168
+ // Check explicit groups (e.g. G18 sections derived from domains)
169
+ if (concept.groups?.length && concept.groups.includes(prefix)) return true;
170
+ // Check concept ID prefix matching (e.g. VIML/VIM numbered sections)
171
+ return concept.id.startsWith(prefix + '.') || concept.id.startsWith(prefix + '-');
148
172
  }
149
173
 
150
174
  function getSections(): SectionNode[] {
151
- const m = manifest.value;
152
- if (!m?.sections?.length) return [];
153
- return m.sections.map(s => enrichSection(s));
154
- }
155
-
156
- function enrichSection(s: ManifestSection): SectionNode {
157
- const node: SectionNode = { id: s.id, names: s.names || {}, conceptCount: 0 };
158
- if (s.children && s.children.length > 0) {
159
- node.children = s.children.map(c => enrichSection(c));
160
- }
161
- return node;
175
+ if (!adapter.value) return [];
176
+ return adapter.value.getSectionTree();
162
177
  }
163
178
 
164
179
  function sectionName(section: SectionNode): string {
165
180
  return section.names[locale.value] || section.names.eng || section.id;
166
181
  }
167
182
 
183
+ const sectionDisplayName = computed(() => {
184
+ if (!sectionQuery.value) return '';
185
+ const prefix = sectionQuery.value.replace(/^section-/, '');
186
+ const sections = getSections();
187
+ const found = sections.find(s => s.id === prefix);
188
+ if (!found) return prefix;
189
+ const name = sectionName(found);
190
+ return name !== found.id ? `${found.id} — ${name}` : name;
191
+ });
192
+
168
193
  // Alphabetical grouping
169
194
  const alphabetGroups = computed(() => {
170
195
  if (viewMode.value !== 'alphabetical') return [];
@@ -367,7 +392,7 @@ function clearSection() {
367
392
  <div v-if="sectionQuery" class="flex items-center gap-2 mb-4">
368
393
  <span class="text-sm text-ink-500">{{ t('dataset.sectionFilter') }}:</span>
369
394
  <span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-amber-50 text-amber-700 text-sm font-medium">
370
- {{ sectionQuery }}
395
+ {{ sectionDisplayName }}
371
396
  <button @click="clearSection" class="text-amber-400 hover:text-amber-600 transition-colors">
372
397
  <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
373
398
  </button>
@@ -24,7 +24,6 @@ async function exploreRandom() {
24
24
  }
25
25
  const result = await store.getRandomConcept();
26
26
  if (result) {
27
- await store.viewConcept(result.registerId, result.conceptId);
28
27
  router.push({ name: 'concept', params: { registerId: result.registerId, conceptId: result.conceptId } });
29
28
  }
30
29
  } finally {
package/vite.config.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { defineConfig } from 'vite'
1
+ import { defineConfig, type Plugin } from 'vite'
2
2
  import vue from '@vitejs/plugin-vue'
3
- import { resolve, dirname } from 'path'
3
+ import { resolve, dirname, extname, join } from 'path'
4
+ import { readFileSync, existsSync, statSync, createReadStream } from 'fs'
4
5
  import { fileURLToPath } from 'url'
5
6
  import yaml from 'js-yaml'
6
7
 
@@ -41,6 +42,40 @@ function faviconPlugin() {
41
42
  }
42
43
  }
43
44
 
45
+ function inlineDataPlugin() {
46
+ let publicDir: string | undefined
47
+ const cache: Map<string, string> = new Map()
48
+ return {
49
+ name: 'inline-data',
50
+ configResolved(config: any) {
51
+ publicDir = config.publicDir
52
+ },
53
+ transformIndexHtml(html: string) {
54
+ if (!publicDir) return html
55
+ const tags: any[] = []
56
+ for (const [id, path] of [
57
+ ['datasets-json', 'datasets.json'],
58
+ ['site-config-json', 'site-config.json'],
59
+ ] as const) {
60
+ try {
61
+ let data = cache.get(path)
62
+ if (!data) {
63
+ data = readFileSync(resolve(publicDir!, path), 'utf-8')
64
+ cache.set(path, data)
65
+ }
66
+ tags.push({
67
+ tag: 'script',
68
+ attrs: { type: 'application/json', id },
69
+ children: data,
70
+ injectTo: 'body' as const,
71
+ })
72
+ } catch { /* file may not exist during first build */ }
73
+ }
74
+ return { html, tags }
75
+ },
76
+ }
77
+ }
78
+
44
79
  function brandingPlugin() {
45
80
  return {
46
81
  name: 'branding-inject',
@@ -53,8 +88,8 @@ function brandingPlugin() {
53
88
  const fontsUrl = process.env.SITE_FONTS_URL
54
89
  if (fontsUrl) {
55
90
  result = result.replace(
56
- /<link[^>]*href="https:\/\/fonts\.googleapis\.com\/css2\?[^"]*"[^>]*>/,
57
- `<link href="${fontsUrl}" rel="stylesheet">`
91
+ /<link[^>]*href="https:\/\/fonts\.googleapis\.com\/css2\?[^"]*"[^>]*>(?:\s*<noscript>[^<]*<\/noscript>)?/,
92
+ `<link rel="preload" as="style" href="${fontsUrl}" onload="this.rel='stylesheet'"><noscript><link href="${fontsUrl}" rel="stylesheet"></noscript>`
58
93
  )
59
94
  }
60
95
  return result
@@ -62,15 +97,68 @@ function brandingPlugin() {
62
97
  }
63
98
  }
64
99
 
100
+ const dataDir = resolve(cwd, 'public/data')
101
+ const publicDir = resolve(cwd, 'public')
102
+
103
+ const mimeTypes: Record<string, string> = {
104
+ '.json': 'application/json',
105
+ '.yaml': 'text/yaml',
106
+ '.yml': 'text/yaml',
107
+ '.ttl': 'text/turtle',
108
+ '.jsonld': 'application/ld+json',
109
+ '.tbx': 'application/xml',
110
+ }
111
+
112
+ // Serves /data/ files via middleware so the dev server doesn't need to watch
113
+ // the 15,000+ files in public/data/ (which causes fsevents to consume ~400% CPU).
114
+ function dataServePlugin(): Plugin {
115
+ return {
116
+ name: 'data-serve',
117
+ configureServer(server) {
118
+ server.middlewares.use((req, res, next) => {
119
+ if (!req.url?.startsWith('/data/')) return next()
120
+ const filePath = join(dataDir, req.url.slice('/data/'.length))
121
+ if (!filePath.startsWith(dataDir + '/') && filePath !== dataDir) return next()
122
+ if (!existsSync(filePath)) return next()
123
+ try {
124
+ const stat = statSync(filePath)
125
+ if (!stat.isFile()) return next()
126
+ const ext = extname(filePath)
127
+ res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream')
128
+ res.setHeader('Content-Length', stat.size)
129
+ createReadStream(filePath).pipe(res)
130
+ } catch { next() }
131
+ })
132
+ },
133
+ }
134
+ }
135
+
65
136
  export default defineConfig({
66
137
  base: process.env.BASE_PATH || '/',
67
138
  root: __dirname,
68
- publicDir: resolve(cwd, 'public'),
139
+ publicDir,
69
140
  build: {
70
141
  outDir: resolve(cwd, 'dist'),
71
142
  emptyOutDir: true,
72
143
  },
73
- plugins: [yamlPlugin(), faviconPlugin(), brandingPlugin(), vue()],
144
+ server: {
145
+ watch: {
146
+ ignored: [
147
+ // concept-browser's own non-source dirs (121K+ files in dist/public)
148
+ resolve(__dirname, 'dist') + '/**',
149
+ resolve(__dirname, 'public') + '/**',
150
+ resolve(__dirname, '.datasets') + '/**',
151
+ resolve(__dirname, '.gcr') + '/**',
152
+ resolve(__dirname, '.gcr-staging') + '/**',
153
+ // oiml-vocab's data dir (15K+ files)
154
+ resolve(cwd, 'public/data') + '/**',
155
+ ],
156
+ },
157
+ },
158
+ optimizeDeps: {
159
+ exclude: ['@plurimath/plurimath'],
160
+ },
161
+ plugins: [yamlPlugin(), faviconPlugin(), brandingPlugin(), dataServePlugin(), inlineDataPlugin(), vue()],
74
162
  resolve: {
75
163
  alias: {
76
164
  '@': resolve(__dirname, 'src'),