@glossarist/concept-browser 0.7.138 → 0.7.140

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 (49) hide show
  1. package/package.json +1 -1
  2. package/scripts/__tests__/standards-index.test.ts +121 -0
  3. package/scripts/build-edges.js +20 -3
  4. package/scripts/build-edges.ts +1 -6
  5. package/scripts/generate-data.js +51 -3
  6. package/scripts/generate-data.ts +37 -3
  7. package/src/__fixtures__/isotc211-domain-slugs.json +201 -0
  8. package/src/__tests__/app-footer.test.ts +36 -0
  9. package/src/__tests__/app-header.test.ts +32 -5
  10. package/src/__tests__/composables/use-same-standard-terms.test.ts +397 -0
  11. package/src/__tests__/concept-view.test.ts +103 -0
  12. package/src/__tests__/dataset-adapter.test.ts +151 -1
  13. package/src/__tests__/dataset-view.test.ts +41 -1
  14. package/src/__tests__/model-bridge-citation.test.ts +20 -0
  15. package/src/__tests__/same-standard-pane.test.ts +174 -0
  16. package/src/__tests__/scripts/generate-data.test.ts +74 -0
  17. package/src/__tests__/search-across-datasets.test.ts +102 -0
  18. package/src/__tests__/search-bar.test.ts +304 -0
  19. package/src/__tests__/search-results.test.ts +54 -0
  20. package/src/__tests__/search-utils.test.ts +64 -1
  21. package/src/__tests__/search-view.test.ts +194 -2
  22. package/src/__tests__/slugify.test.ts +74 -1
  23. package/src/__tests__/source-standard.test.ts +70 -0
  24. package/src/__tests__/ui-store.test.ts +26 -0
  25. package/src/__tests__/vocabulary-layered.test.ts +7 -7
  26. package/src/adapters/DatasetAdapter.ts +16 -8
  27. package/src/adapters/jsonld-types.ts +1 -0
  28. package/src/adapters/model-bridge.ts +1 -0
  29. package/src/adapters/standards-index.ts +7 -0
  30. package/src/adapters/types.ts +1 -0
  31. package/src/components/AppFooter.vue +7 -0
  32. package/src/components/AppHeader.vue +6 -5
  33. package/src/components/SameStandardPane.vue +70 -0
  34. package/src/components/SearchBar.vue +73 -18
  35. package/src/components/SearchResults.vue +46 -5
  36. package/src/components/site/SiteFooter.astro +6 -0
  37. package/src/composables/use-same-standard-terms.ts +106 -0
  38. package/src/config/types.ts +11 -0
  39. package/src/config/use-site-config.ts +1 -0
  40. package/src/i18n/locales/eng.yml +10 -1
  41. package/src/i18n/locales/fra.yml +10 -1
  42. package/src/stores/ui.ts +16 -1
  43. package/src/stores/vocabulary.ts +27 -31
  44. package/src/utils/search.ts +14 -1
  45. package/src/utils/slugify.ts +25 -1
  46. package/src/utils/source-standard.ts +34 -0
  47. package/src/views/ConceptView.vue +43 -12
  48. package/src/views/DatasetView.vue +25 -5
  49. package/src/views/SearchView.vue +9 -12
@@ -8,6 +8,8 @@ import { conceptUri } from '../adapters/model-bridge';
8
8
  import { GraphEngine } from '../graph';
9
9
  import { UriRouter } from '../adapters/UriRouter';
10
10
  import { deduplicateSearchHits } from '../utils/search';
11
+ import { useSiteConfig } from '../config/use-site-config';
12
+ import { DEFAULT_LANG } from '../utils/lang';
11
13
  import { UnknownDatasetError } from '../errors';
12
14
 
13
15
  export const useVocabularyStore = defineStore('vocabulary', () => {
@@ -19,6 +21,7 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
19
21
  const currentConceptId = ref<string>('');
20
22
  const loading = ref(false);
21
23
  const error = ref<string | null>(null);
24
+ const searchWarning = ref<string | null>(null);
22
25
  const graph = shallowRef(new GraphEngine());
23
26
  const conceptEdges = ref<GraphEdge[]>([]);
24
27
  const initialized = ref(false);
@@ -32,6 +35,14 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
32
35
 
33
36
  const factory = getFactory();
34
37
 
38
+ const { config } = useSiteConfig();
39
+ const searchPriorityLanguages = computed(() => {
40
+ const defaults = config.value?.defaults;
41
+ return defaults?.languageOrder?.length
42
+ ? defaults.languageOrder
43
+ : [defaults?.language ?? DEFAULT_LANG];
44
+ });
45
+
35
46
  // Computed
36
47
  const currentManifest = computed(() =>
37
48
  manifests.value.get(currentRegisterId.value)
@@ -263,46 +274,29 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
263
274
  await discoverDatasets();
264
275
  }
265
276
 
266
- const MIN_RESULTS = 20;
267
-
268
- // Pass 1: search loaded data only
269
- const loadedHits: SearchHit[] = [];
270
- const unloadedAdapters: DatasetAdapter[] = [];
271
-
277
+ const allHits: SearchHit[] = [];
278
+ let incomplete = false;
272
279
  for (const adapter of datasets.value.values()) {
273
280
  if (!adapter.manifest) continue;
274
- if (!adapter.index) {
275
- try {
281
+ try {
282
+ if (!adapter.index) {
276
283
  await adapter.loadIndex();
284
+ }
285
+ try {
286
+ await adapter.ensureAllChunksLoaded();
277
287
  } catch {
278
- continue;
288
+ // Preserve loaded hits, but make the incomplete result set visible.
289
+ incomplete = true;
279
290
  }
280
- }
281
- const hits = adapter.search(query);
282
- if (hits.length > 0) {
283
- loadedHits.push(...hits);
284
- } else {
285
- unloadedAdapters.push(adapter);
286
- }
287
- }
288
-
289
- const pass1 = deduplicateSearchHits(loadedHits);
290
- if (pass1.length >= MIN_RESULTS) return pass1;
291
-
292
- // Pass 2: load chunks lazily for datasets that found nothing in index
293
- let allHits = [...loadedHits];
294
- for (const adapter of unloadedAdapters) {
295
- if (deduplicateSearchHits(allHits).length >= MIN_RESULTS) break;
296
- try {
297
- await adapter.ensureAllChunksLoaded();
298
- const hits = adapter.search(query);
299
- allHits.push(...hits);
291
+ allHits.push(...adapter.search(query));
300
292
  } catch {
301
- // Skip datasets that fail to load
293
+ // Other datasets may still provide useful results.
294
+ incomplete = true;
302
295
  }
303
296
  }
304
297
 
305
- return deduplicateSearchHits(allHits);
298
+ searchWarning.value = incomplete ? 'search.incomplete' : null;
299
+ return deduplicateSearchHits(allHits, searchPriorityLanguages.value);
306
300
  }
307
301
 
308
302
  async function getRandomConcept(): Promise<{ registerId: string; conceptId: string } | null> {
@@ -324,6 +318,7 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
324
318
  currentConceptId,
325
319
  loading,
326
320
  error,
321
+ searchWarning,
327
322
  graph,
328
323
  graphVersion,
329
324
  conceptEdges,
@@ -338,6 +333,7 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
338
333
  viewConcept,
339
334
  navigateToUri,
340
335
  searchAcrossDatasets,
336
+ searchPriorityLanguages,
341
337
  loadAllGraphData,
342
338
  getRandomConcept,
343
339
  };
@@ -1,6 +1,14 @@
1
1
  import type { SearchHit } from '../adapters/types';
2
2
 
3
- export function deduplicateSearchHits(hits: SearchHit[]): SearchHit[] {
3
+ export function orderSearchHits(hits: SearchHit[], priorityLangs: string[]): SearchHit[] {
4
+ const index = new Map(priorityLangs.map((language, i) => [language, i]));
5
+ const tier = (hit: SearchHit) => hit.language === '' ? -1 : (index.get(hit.language) ?? priorityLangs.length);
6
+ // Stable sorting preserves the adapter's score/alphabetical order within each tier.
7
+ return [...hits].sort((a, b) => tier(a) - tier(b));
8
+ }
9
+
10
+ export function deduplicateSearchHits(hits: SearchHit[], priorityLangs: string[] = []): SearchHit[] {
11
+ const index = new Map(priorityLangs.map((language, i) => [language, i]));
4
12
  const best = new Map<string, SearchHit>();
5
13
  for (const hit of hits) {
6
14
  const key = `${hit.registerId}:${hit.conceptId}`;
@@ -9,6 +17,11 @@ export function deduplicateSearchHits(hits: SearchHit[]): SearchHit[] {
9
17
  best.set(key, hit);
10
18
  } else if (hit.matchField === 'designation' && existing.matchField === 'id') {
11
19
  best.set(key, hit);
20
+ } else if (
21
+ hit.matchField === 'designation' && existing.matchField === 'designation' &&
22
+ (index.get(hit.language) ?? priorityLangs.length) < (index.get(existing.language) ?? priorityLangs.length)
23
+ ) {
24
+ best.set(key, hit);
12
25
  }
13
26
  }
14
27
  return [...best.values()];
@@ -1,3 +1,27 @@
1
+ // Slug for a domain label. Keeps letters and digits of every script so that
2
+ // non-Latin and accented labels stay distinct and readable. Pure-ASCII input
3
+ // that contains a letter or digit yields exactly what the earlier ASCII-only
4
+ // implementation did, so already-published URLs do not move.
5
+
6
+ const KEEP = /[^\p{L}\p{N}\p{M}_\s-]/gu;
7
+ const HAS_WORD = /[\p{L}\p{N}]/u;
8
+
9
+ // FNV-1a, 32 bit, over UTF-16 code units. Sync and dependency-free so it runs
10
+ // in the browser and in the Node build scripts alike.
11
+ function shortHash(text: string): string {
12
+ let h = 0x811c9dc5;
13
+ for (let i = 0; i < text.length; i++) {
14
+ h ^= text.charCodeAt(i);
15
+ h = Math.imul(h, 0x01000193) >>> 0;
16
+ }
17
+ return h.toString(16).padStart(8, '0');
18
+ }
19
+
1
20
  export function slugify(text: string): string {
2
- return text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/[\s/]+/g, '-');
21
+ if (text === '') return '';
22
+ const folded = text.normalize('NFKC').toLowerCase();
23
+ const slug = folded.replace(KEEP, '').replace(/[\s/]+/g, '-');
24
+ // No letter or digit left (e.g. "()", "---"): a hash keeps such labels
25
+ // from collapsing into one empty node.
26
+ return HAS_WORD.test(slug) ? slug : `x-${shortHash(folded.trim())}`;
3
27
  }
@@ -0,0 +1,34 @@
1
+ import type { Concept } from 'glossarist';
2
+
3
+ interface StandardRef {
4
+ readonly source?: string | null;
5
+ readonly id?: string | null;
6
+ }
7
+
8
+ /** Combine source and id, retaining edition years and ignoring clause/locality fields. */
9
+ export function standardLabel(ref: StandardRef | null | undefined): string | null {
10
+ const parts = [ref?.source, ref?.id]
11
+ .map(part => part?.trim().replace(/\s+/g, ' ') ?? '')
12
+ .filter(Boolean);
13
+ return parts.join(' ') || null;
14
+ }
15
+
16
+ /** Case-insensitive, whitespace-normalized identity shared by build and browser. */
17
+ export function standardKey(ref: StandardRef | null | undefined): string | null {
18
+ return standardLabel(ref)?.toLowerCase() ?? null;
19
+ }
20
+
21
+ /** Unique authoritative standards, in concept-then-localization encounter order. */
22
+ export function collectStandardKeys(concept: Pick<Concept, 'sources' | 'languages' | 'localization'>): string[] {
23
+ const sources = [
24
+ ...concept.sources,
25
+ ...concept.languages.flatMap(lang => concept.localization(lang)?.sources ?? []),
26
+ ];
27
+ const keys = new Set<string>();
28
+ for (const source of sources) {
29
+ if (source.type !== 'authoritative') continue;
30
+ const key = standardKey(source.origin?.ref);
31
+ if (key !== null) keys.add(key);
32
+ }
33
+ return [...keys];
34
+ }
@@ -3,9 +3,11 @@ import { computed, watch, ref, onMounted, onUnmounted } from 'vue';
3
3
  import { useRouter } from 'vue-router';
4
4
  import { useVocabularyStore } from '../stores/vocabulary';
5
5
  import ConceptDetail from '../components/ConceptDetail.vue';
6
+ import SameStandardPane from '../components/SameStandardPane.vue';
6
7
  import RelationSphere from '../components/RelationSphere.vue';
7
8
  import ShortcutsModal from '../components/ShortcutsModal.vue';
8
9
  import { useConceptEdges } from '../composables/use-concept-edges';
10
+ import { useSameStandardTerms } from '../composables/use-same-standard-terms';
9
11
  import { useI18n } from '../i18n';
10
12
 
11
13
  const { t } = useI18n();
@@ -16,6 +18,8 @@ const props = defineProps<{
16
18
  }>();
17
19
 
18
20
  const store = useVocabularyStore();
21
+ const sameStandardGroups = useSameStandardTerms();
22
+ const hasSameStandardTerms = computed(() => sameStandardGroups.value.some(group => group.terms.length > 0));
19
23
  const router = useRouter();
20
24
  const conceptLoading = ref(false);
21
25
  const localError = ref<string | null>(null);
@@ -86,11 +90,16 @@ async function copyPermalink() {
86
90
  async function loadAdjacent() {
87
91
  const adapter = store.datasets.get(props.registerId);
88
92
  if (!adapter?.index) return;
89
- const idx = adapter.getConceptPosition(props.conceptId);
90
- if (idx >= 0) {
91
- await adapter.ensureChunksForRange(Math.max(0, idx - 1), 3);
93
+ try {
94
+ const idx = adapter.getConceptPosition(props.conceptId);
95
+ if (idx >= 0) {
96
+ await adapter.ensureChunksForRange(Math.max(0, idx - 1), 3);
97
+ }
98
+ adjacent.value = adapter.getAdjacentConcepts(props.conceptId);
99
+ } catch (e) {
100
+ adjacent.value = { prev: null, next: null };
101
+ console.error('Failed to load adjacent concepts:', e);
92
102
  }
93
- adjacent.value = adapter.getAdjacentConcepts(props.conceptId);
94
103
  }
95
104
 
96
105
  function goAdjacent(id: string) {
@@ -293,14 +302,23 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown));
293
302
  @navigate="onSphereNavigate"
294
303
  />
295
304
  <!-- Detail mode -->
296
- <ConceptDetail
297
- v-else
298
- :concept="concept as any"
299
- :manifest="manifest"
300
- :edges="edges"
301
- :adjacent="adjacent"
302
- :register-id="registerId"
303
- />
305
+ <div v-else class="detail-layout" :class="{ 'has-standard-pane': hasSameStandardTerms }">
306
+ <div class="detail-main">
307
+ <ConceptDetail
308
+ :concept="concept as any"
309
+ :manifest="manifest"
310
+ :edges="edges"
311
+ :adjacent="adjacent"
312
+ :register-id="registerId"
313
+ />
314
+ </div>
315
+ <SameStandardPane
316
+ v-if="hasSameStandardTerms"
317
+ :groups="sameStandardGroups"
318
+ :register-id="activeRegisterId"
319
+ :concept-id="concept.id"
320
+ />
321
+ </div>
304
322
  </div>
305
323
  </template>
306
324
 
@@ -333,6 +351,19 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown));
333
351
  display: flex;
334
352
  flex-direction: column;
335
353
  }
354
+ .detail-layout {
355
+ display: grid;
356
+ gap: 1.5rem;
357
+ align-items: start;
358
+ }
359
+ .detail-main {
360
+ min-width: 0;
361
+ }
362
+ @media (min-width: 1024px) {
363
+ .detail-layout.has-standard-pane {
364
+ grid-template-columns: minmax(0, 1fr) 18rem;
365
+ }
366
+ }
336
367
  .fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
337
368
  .fade-enter-from, .fade-leave-to { opacity: 0; }
338
369
  </style>
@@ -156,6 +156,8 @@ async function ensureAllChunksForFilter(needsLoad: boolean) {
156
156
  try {
157
157
  await a.ensureAllChunksLoaded();
158
158
  allChunksLoaded.value = true;
159
+ } catch (e) {
160
+ localError.value = e instanceof Error ? e.message : String(e);
159
161
  } finally {
160
162
  chunkLoading.value = false;
161
163
  }
@@ -271,15 +273,33 @@ const totalPages = computed(() => {
271
273
  });
272
274
 
273
275
  // Load chunks needed for current page
274
- watch(page, async () => {
276
+ async function loadPageChunks() {
275
277
  if (!adapter.value || isFiltering.value) return;
276
278
  const start = (page.value - 1) * perPage;
277
279
  if (!adapter.value.isRangeLoaded(start, perPage)) {
278
280
  chunkLoading.value = true;
279
- await adapter.value.ensureChunksForRange(start, perPage);
280
- chunkLoading.value = false;
281
+ try {
282
+ await adapter.value.ensureChunksForRange(start, perPage);
283
+ } catch (e) {
284
+ localError.value = e instanceof Error ? e.message : String(e);
285
+ } finally {
286
+ chunkLoading.value = false;
287
+ }
281
288
  }
282
- }, { immediate: true });
289
+ }
290
+
291
+ watch(page, loadPageChunks, { immediate: true });
292
+
293
+ async function retryLoad() {
294
+ localError.value = null;
295
+ if (!adapter.value?.index) {
296
+ await ensureLoaded();
297
+ } else if (isFiltering.value) {
298
+ await ensureAllChunksForFilter(true);
299
+ } else {
300
+ await loadPageChunks();
301
+ }
302
+ }
283
303
 
284
304
  // Visible page numbers for pagination (avoids iterating 445+ pages)
285
305
  const visiblePages = computed(() => {
@@ -383,7 +403,7 @@ function clearSection() {
383
403
  <p class="text-red-700 font-medium mb-1">{{ t('dataset.failedToLoad') }}</p>
384
404
  <p class="text-sm text-red-600/80 mb-4">{{ localError }}</p>
385
405
  <div class="flex gap-2 justify-center">
386
- <button @click="ensureLoaded" class="btn-primary">{{ t('dataset.retry') }}</button>
406
+ <button @click="retryLoad" class="btn-primary">{{ t('dataset.retry') }}</button>
387
407
  <router-link :to="{ name: 'home' }" class="btn-secondary">{{ t('dataset.backToHome') }}</router-link>
388
408
  </div>
389
409
  </div>
@@ -1,25 +1,22 @@
1
1
  <script setup lang="ts">
2
- import { ref, onMounted, watch } from 'vue';
3
- import { useRoute, useRouter } from 'vue-router';
2
+ import { onBeforeUnmount, watch } from 'vue';
3
+ import { useRoute } from 'vue-router';
4
4
  import SearchBar from '../components/SearchBar.vue';
5
5
  import { useUiStore } from '../stores/ui';
6
6
  import { useI18n } from '../i18n';
7
7
 
8
8
  const route = useRoute();
9
- const router = useRouter();
10
9
  const ui = useUiStore();
11
10
  const { t } = useI18n();
12
11
 
13
- onMounted(() => {
14
- if (route.query.q && typeof route.query.q === 'string') {
15
- ui.searchQuery = route.query.q;
16
- }
17
- });
12
+ watch(() => [route.name, route.query.q], () => {
13
+ ui.searchQuery = route.name === 'search' && typeof route.query.q === 'string'
14
+ ? route.query.q.trim()
15
+ : '';
16
+ }, { immediate: true });
18
17
 
19
- watch(() => route.query.q, (q) => {
20
- if (typeof q === 'string' && q) {
21
- ui.searchQuery = q;
22
- }
18
+ onBeforeUnmount(() => {
19
+ ui.searchQuery = '';
23
20
  });
24
21
  </script>
25
22