@glossarist/concept-browser 0.7.139 → 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 (44) hide show
  1. package/package.json +1 -1
  2. package/scripts/__tests__/standards-index.test.ts +121 -0
  3. package/scripts/generate-data.js +51 -3
  4. package/scripts/generate-data.ts +37 -3
  5. package/src/__tests__/app-footer.test.ts +36 -0
  6. package/src/__tests__/app-header.test.ts +32 -5
  7. package/src/__tests__/composables/use-same-standard-terms.test.ts +397 -0
  8. package/src/__tests__/concept-view.test.ts +103 -0
  9. package/src/__tests__/dataset-adapter.test.ts +150 -0
  10. package/src/__tests__/dataset-view.test.ts +41 -1
  11. package/src/__tests__/model-bridge-citation.test.ts +20 -0
  12. package/src/__tests__/same-standard-pane.test.ts +174 -0
  13. package/src/__tests__/scripts/generate-data.test.ts +74 -0
  14. package/src/__tests__/search-across-datasets.test.ts +102 -0
  15. package/src/__tests__/search-bar.test.ts +304 -0
  16. package/src/__tests__/search-results.test.ts +54 -0
  17. package/src/__tests__/search-utils.test.ts +64 -1
  18. package/src/__tests__/search-view.test.ts +194 -2
  19. package/src/__tests__/source-standard.test.ts +70 -0
  20. package/src/__tests__/ui-store.test.ts +26 -0
  21. package/src/__tests__/vocabulary-layered.test.ts +7 -7
  22. package/src/adapters/DatasetAdapter.ts +16 -8
  23. package/src/adapters/jsonld-types.ts +1 -0
  24. package/src/adapters/model-bridge.ts +1 -0
  25. package/src/adapters/standards-index.ts +7 -0
  26. package/src/adapters/types.ts +1 -0
  27. package/src/components/AppFooter.vue +7 -0
  28. package/src/components/AppHeader.vue +6 -5
  29. package/src/components/SameStandardPane.vue +70 -0
  30. package/src/components/SearchBar.vue +73 -18
  31. package/src/components/SearchResults.vue +46 -5
  32. package/src/components/site/SiteFooter.astro +6 -0
  33. package/src/composables/use-same-standard-terms.ts +106 -0
  34. package/src/config/types.ts +11 -0
  35. package/src/config/use-site-config.ts +1 -0
  36. package/src/i18n/locales/eng.yml +10 -1
  37. package/src/i18n/locales/fra.yml +10 -1
  38. package/src/stores/ui.ts +16 -1
  39. package/src/stores/vocabulary.ts +27 -31
  40. package/src/utils/search.ts +14 -1
  41. package/src/utils/source-standard.ts +34 -0
  42. package/src/views/ConceptView.vue +43 -12
  43. package/src/views/DatasetView.vue +25 -5
  44. package/src/views/SearchView.vue +9 -12
@@ -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()];
@@ -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