@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
@@ -1,8 +1,11 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import { mount, flushPromises } from '@vue/test-utils';
3
+ import { createRouter, createMemoryHistory, RouterView } from 'vue-router';
3
4
  import SearchView from '../views/SearchView.vue';
5
+ import { DatasetAdapter } from '../adapters/DatasetAdapter';
4
6
  import { useVocabularyStore } from '../stores/vocabulary';
5
- import { createTestRouter, setupPinia, makeManifest, makeAdapterStub } from './test-helpers';
7
+ import { useUiStore } from '../stores/ui';
8
+ import { createTestRouter, setupPinia, makeManifest, makeAdapterStub, makeSearchHit } from './test-helpers';
6
9
 
7
10
  describe('SearchView', () => {
8
11
  let pinia: ReturnType<typeof setupPinia>;
@@ -12,22 +15,119 @@ describe('SearchView', () => {
12
15
  pinia = setupPinia();
13
16
  router = await createTestRouter('search', '/search');
14
17
  const store = useVocabularyStore();
18
+ store.initialized = true;
15
19
  store.manifests.set('test', makeManifest());
16
20
  store.datasets.set('test', makeAdapterStub());
17
21
  });
18
22
 
23
+ afterEach(() => {
24
+ vi.unstubAllGlobals();
25
+ });
26
+
19
27
  function mountView() {
20
28
  return mount(SearchView, {
21
29
  global: { plugins: [pinia, router], stubs: { SearchBar: true } },
22
30
  });
23
31
  }
24
32
 
33
+ async function mountRoutedSearch(path = '/search') {
34
+ router = createRouter({
35
+ history: createMemoryHistory(),
36
+ routes: [
37
+ { path: '/', name: 'home', component: { template: '<div>Home page</div>' } },
38
+ { path: '/search', name: 'search', component: SearchView },
39
+ ],
40
+ });
41
+ await router.push(path);
42
+ await router.isReady();
43
+ const wrapper = mount({ components: { RouterView }, template: '<RouterView />' }, {
44
+ global: { plugins: [pinia, router] },
45
+ });
46
+ await flushPromises();
47
+ return wrapper;
48
+ }
49
+
25
50
  it('renders breadcrumb navigation', () => {
26
51
  const wrapper = mountView();
27
52
  expect(wrapper.text()).toContain('Home');
28
53
  expect(wrapper.text()).toContain('Search');
29
54
  });
30
55
 
56
+ it.each(['transient', 'persistent'] as const)('warns about a %s chunk failure and retries from the routed search', async (failure) => {
57
+ const manifest = makeManifest({ conceptCount: 2001 });
58
+ const adapter = new DatasetAdapter('test', '/data/test');
59
+ adapter.manifest = manifest;
60
+ const store = useVocabularyStore();
61
+ store.manifests.set('test', manifest);
62
+ store.datasets.set('test', adapter);
63
+
64
+ const chunks = [
65
+ Array.from({ length: 1000 }, (_, i) => ({
66
+ id: i === 0 ? '276' : `first-${i}`,
67
+ designations: { eng: i === 0 ? 'location' : `unrelated first ${i}` },
68
+ status: i === 0 ? 'superseded' : 'valid',
69
+ })),
70
+ Array.from({ length: 1000 }, (_, i) => ({
71
+ id: i === 0 ? '2345' : `second-${i}`,
72
+ designations: { eng: i === 0 ? 'location' : `unrelated second ${i}` },
73
+ status: 'valid',
74
+ })),
75
+ [{ id: '9999', designations: { eng: 'unrelated last entry' }, status: 'valid' }],
76
+ ];
77
+ let failedChunkRequests = 0;
78
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
79
+ const url = String(input);
80
+ if (url === '/data/test/index-meta.json') {
81
+ return {
82
+ ok: true,
83
+ status: 200,
84
+ json: async () => ({
85
+ registerId: 'test', schemaVersion: '1.0.0', conceptCount: 2001, chunkSize: 1000,
86
+ chunks: chunks.map((concepts, i) => ({ file: `index-${i}.json`, count: concepts.length })),
87
+ }),
88
+ } as Response;
89
+ }
90
+ const chunkIndex = chunks.findIndex((_, i) => url === `/data/test/chunks/index-${i}.json`);
91
+ if (chunkIndex < 0) throw new Error(`Unexpected fetch: ${url}`);
92
+ if (chunkIndex === 1 && (++failedChunkRequests === 1 || failure === 'persistent')) {
93
+ return { ok: false, status: 404 } as Response;
94
+ }
95
+ return { ok: true, status: 200, json: async () => ({ concepts: chunks[chunkIndex] }) } as Response;
96
+ });
97
+ vi.stubGlobal('fetch', fetchMock);
98
+
99
+ const wrapper = await mountRoutedSearch('/search?q=location');
100
+ try {
101
+ expect((wrapper.find('input').element as HTMLInputElement).value).toBe('location');
102
+ expect(wrapper.get('[role="alert"]').text()).toContain('Some results could not be loaded');
103
+ expect(wrapper.get('.search-results').text()).toContain('276');
104
+ expect(wrapper.get('.search-results').text()).toContain('superseded');
105
+ expect(wrapper.get('.search-results').text()).not.toContain('2345');
106
+ expect(wrapper.get('.search-results').text()).not.toContain('9999');
107
+
108
+ const retry = wrapper.findAll('button').find(button => button.text() === 'Retry');
109
+ expect(retry).toBeDefined();
110
+ await retry!.trigger('click');
111
+ await flushPromises();
112
+
113
+ expect(failedChunkRequests).toBe(2);
114
+ expect(router.currentRoute.value.fullPath).toBe('/search?q=location');
115
+ expect(wrapper.get('.search-results').text()).toContain('276');
116
+ expect(wrapper.get('.search-results').text()).not.toContain('9999');
117
+ if (failure === 'transient') {
118
+ expect(wrapper.get('.search-results').text()).toContain('2345');
119
+ expect(wrapper.find('[role="alert"]').exists()).toBe(false);
120
+ } else {
121
+ expect(wrapper.get('.search-results').text()).not.toContain('2345');
122
+ expect(wrapper.get('[role="alert"]').text()).toContain('Some results could not be loaded');
123
+ }
124
+ expect(fetchMock.mock.calls.filter(([url]) => url === '/data/test/chunks/index-0.json')).toHaveLength(1);
125
+ expect(fetchMock.mock.calls.filter(([url]) => url === '/data/test/chunks/index-2.json')).toHaveLength(1);
126
+ } finally {
127
+ wrapper.unmount();
128
+ }
129
+ });
130
+
31
131
  it('renders SearchBar component', () => {
32
132
  const wrapper = mountView();
33
133
  expect(wrapper.findComponent({ name: 'SearchBar' }).exists()).toBe(true);
@@ -38,4 +138,96 @@ describe('SearchView', () => {
38
138
  const homeLink = wrapper.findAll('a').find(a => a.text() === 'Home');
39
139
  expect(homeLink).toBeDefined();
40
140
  });
141
+
142
+ it('searches an initial query parameter and restores it when returning to search', async () => {
143
+ const store = useVocabularyStore();
144
+ const search = vi.fn(() => [makeSearchHit({ designation: 'Foo result' })]);
145
+ store.datasets.set('test', makeAdapterStub({ search }));
146
+ const wrapper = await mountRoutedSearch('/search?q=foo');
147
+ const ui = useUiStore();
148
+ await flushPromises();
149
+
150
+ expect(search).toHaveBeenCalledWith('foo');
151
+ expect(search).toHaveBeenCalledTimes(1);
152
+ expect((wrapper.find('input').element as HTMLInputElement).value).toBe('foo');
153
+ expect(wrapper.text()).toContain('Foo result');
154
+
155
+ await router.push('/');
156
+ await flushPromises();
157
+ expect(wrapper.text()).toContain('Home page');
158
+ expect(ui.searchQuery).toBe('');
159
+
160
+ await router.push('/search');
161
+ await flushPromises();
162
+ expect((wrapper.find('input').element as HTMLInputElement).value).toBe('');
163
+ expect(ui.searchQuery).toBe('');
164
+ expect(wrapper.text()).not.toContain('Foo result');
165
+ expect(wrapper.find('.search-results').exists()).toBe(false);
166
+ expect(search).toHaveBeenCalledTimes(1);
167
+
168
+ await router.push('/');
169
+ await flushPromises();
170
+ await router.push('/search?q=bar');
171
+ await flushPromises();
172
+ expect((wrapper.find('input').element as HTMLInputElement).value).toBe('bar');
173
+ expect(search).toHaveBeenLastCalledWith('bar');
174
+ wrapper.unmount();
175
+ });
176
+
177
+ it('ignores a leftover store query when mounting without a query parameter', async () => {
178
+ const ui = useUiStore();
179
+ ui.searchQuery = 'stale';
180
+ const search = vi.spyOn(useVocabularyStore(), 'searchAcrossDatasets');
181
+ const wrapper = await mountRoutedSearch('/search');
182
+
183
+ expect((wrapper.find('input').element as HTMLInputElement).value).toBe('');
184
+ expect(ui.searchQuery).toBe('');
185
+ expect(search).not.toHaveBeenCalled();
186
+ expect(wrapper.find('.search-results').exists()).toBe(false);
187
+ wrapper.unmount();
188
+ });
189
+
190
+ it('clears the search when the query parameter is removed while mounted', async () => {
191
+ const store = useVocabularyStore();
192
+ const search = vi.fn(() => [makeSearchHit({ designation: 'Foo result' })]);
193
+ store.datasets.set('test', makeAdapterStub({ search }));
194
+ const wrapper = await mountRoutedSearch('/search?q=foo');
195
+ const ui = useUiStore();
196
+ await flushPromises();
197
+ expect(wrapper.text()).toContain('Foo result');
198
+
199
+ await router.replace('/search?q=bar');
200
+ await flushPromises();
201
+ expect((wrapper.find('input').element as HTMLInputElement).value).toBe('bar');
202
+ expect(search).toHaveBeenLastCalledWith('bar');
203
+
204
+ await router.replace('/search');
205
+ await flushPromises();
206
+
207
+ expect((wrapper.find('input').element as HTMLInputElement).value).toBe('');
208
+ expect(ui.searchQuery).toBe('');
209
+ expect(wrapper.text()).not.toContain('Foo result');
210
+ expect(wrapper.find('.search-results').exists()).toBe(false);
211
+ wrapper.unmount();
212
+ });
213
+
214
+ it('does not let a pending search restore the URL after leaving search', async () => {
215
+ const store = useVocabularyStore();
216
+ let resolveSearch!: (hits: ReturnType<typeof makeSearchHit>[]) => void;
217
+ const search = vi.spyOn(store, 'searchAcrossDatasets').mockImplementation(() => new Promise(resolve => {
218
+ resolveSearch = resolve;
219
+ }));
220
+ const wrapper = await mountRoutedSearch('/search?q=foo');
221
+ await flushPromises();
222
+ expect(search).toHaveBeenCalledWith('foo');
223
+
224
+ await router.push('/');
225
+ resolveSearch([makeSearchHit({ designation: 'Late result' })]);
226
+ await flushPromises();
227
+
228
+ expect(router.currentRoute.value.fullPath).toBe('/');
229
+ expect(wrapper.text()).toContain('Home page');
230
+ expect(wrapper.text()).not.toContain('Late result');
231
+ wrapper.unmount();
232
+ });
41
233
  });
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { slugify } from '../utils/slugify';
3
+ import publishedSlugs from '../__fixtures__/isotc211-domain-slugs.json';
3
4
 
4
5
  describe('slugify', () => {
5
6
  it('lowercases text', () => {
@@ -11,7 +12,7 @@ describe('slugify', () => {
11
12
  });
12
13
 
13
14
  it('strips non-word characters', () => {
14
- expect(slugify('géométrie (2D)')).toBe('gomtrie-2d');
15
+ expect(slugify('géométrie (2D)')).toBe('géométrie-2d');
15
16
  });
16
17
 
17
18
  it('collapses multiple spaces into single hyphen', () => {
@@ -25,4 +26,76 @@ describe('slugify', () => {
25
26
  it('preserves hyphens in input', () => {
26
27
  expect(slugify('iso-19107')).toBe('iso-19107');
27
28
  });
29
+
30
+ describe('unicode labels', () => {
31
+ it('keeps accented letters', () => {
32
+ expect(slugify('dirección postal')).toBe('dirección-postal');
33
+ });
34
+
35
+ it('keeps non-Latin scripts', () => {
36
+ expect(slugify('геопозиционирование')).toBe('геопозиционирование');
37
+ expect(slugify('地理定位')).toBe('地理定位');
38
+ expect(slugify('지리 위치')).toBe('지리-위치');
39
+ });
40
+
41
+ it('keeps Arabic combining marks', () => {
42
+ expect(slugify('نظام الإحداثيات')).toBe('نظام-الإحداثيات');
43
+ });
44
+
45
+ it('is NFC-stable: composed and decomposed input give one slug', () => {
46
+ expect(slugify('é')).toBe(slugify('é'));
47
+ });
48
+
49
+ it('gives distinct labels distinct slugs', () => {
50
+ const labels = ['UML', 'dirección postal', 'direccion postal', 'геопозиционирование',
51
+ '地理定位', '지리 위치', 'تحديد المواقع'];
52
+ expect(new Set(labels.map(slugify)).size).toBe(labels.length);
53
+ });
54
+
55
+ it('is idempotent', () => {
56
+ for (const l of ['dirección postal', 'геопозиционирование', '()', 'a b']) {
57
+ expect(slugify(slugify(l))).toBe(slugify(l));
58
+ }
59
+ });
60
+ });
61
+
62
+ describe('labels without letters or digits', () => {
63
+ it('never returns an empty or separator-only slug', () => {
64
+ for (const l of ['()', '---', ' ', '!!', '/', '- -']) {
65
+ expect(slugify(l)).toMatch(/^x-[0-9a-f]{8}$/);
66
+ }
67
+ });
68
+
69
+ it('distinguishes different such labels', () => {
70
+ expect(slugify('()')).not.toBe(slugify('[]'));
71
+ expect(slugify('()')).toBe(slugify('()'));
72
+ });
73
+
74
+ it('still maps empty input to empty (callers guard on truthy)', () => {
75
+ expect(slugify('')).toBe('');
76
+ });
77
+ });
78
+
79
+ describe('existing ASCII URLs do not move', () => {
80
+ // Verbatim copy of the pre-unicode implementation.
81
+ const legacy = (t: string) => t.toLowerCase().replace(/[^\w\s-]/g, '').replace(/[\s/]+/g, '-');
82
+
83
+ it('matches every domain slug published in the isotc211 dataset', () => {
84
+ const entries = Object.entries(publishedSlugs as Record<string, string>);
85
+ expect(entries.length).toBeGreaterThan(150);
86
+ for (const [label, slug] of entries) expect(slugify(label)).toBe(slug);
87
+ });
88
+
89
+ it('is byte-identical to the legacy output for ASCII input with a letter or digit', () => {
90
+ let seed = 12345;
91
+ const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
92
+ for (let i = 0; i < 20000; i++) {
93
+ const len = 1 + Math.floor(rnd() * 12);
94
+ let t = '';
95
+ for (let j = 0; j < len; j++) t += String.fromCharCode(9 + Math.floor(rnd() * 118));
96
+ if (!/[A-Za-z0-9]/.test(t)) continue;
97
+ expect(slugify(t)).toBe(legacy(t));
98
+ }
99
+ });
100
+ });
28
101
  });
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Citation, Concept } from 'glossarist';
3
+ import { collectStandardKeys, standardKey, standardLabel } from '../utils/source-standard';
4
+
5
+ describe('standard references', () => {
6
+ it.each([
7
+ [{ source: 'ISO', id: '19165-2:2020' }, 'iso 19165-2:2020', 'ISO 19165-2:2020'],
8
+ [{ id: '19165-2:2020' }, '19165-2:2020', '19165-2:2020'],
9
+ [{ source: 'ISO 19165-2:2020' }, 'iso 19165-2:2020', 'ISO 19165-2:2020'],
10
+ [{ source: ' ISO\t IEC ', id: ' 17000:2020\n' }, 'iso iec 17000:2020', 'ISO IEC 17000:2020'],
11
+ [{ source: ' ', id: '19165-2:2020' }, '19165-2:2020', '19165-2:2020'],
12
+ ])('combines usable source and id: %j', (ref, key, label) => {
13
+ expect(standardKey(ref)).toBe(key);
14
+ expect(standardLabel(ref)).toBe(label);
15
+ });
16
+
17
+ it.each([undefined, null, {}, { source: ' \t', id: '\n' }])('returns null for unusable ref %j', (ref) => {
18
+ expect(standardKey(ref)).toBeNull();
19
+ expect(standardLabel(ref)).toBeNull();
20
+ });
21
+
22
+ it('ignores clauses and localities without changing the input', () => {
23
+ const ref = Object.freeze({ source: 'ISO', id: '19165-2:2020', clause: '3.1', locality: '3.1' });
24
+ const otherClause = { ...ref, clause: '4.2', locality: '4.2' };
25
+ expect(standardKey(ref)).toBe('iso 19165-2:2020');
26
+ expect(standardKey(otherClause)).toBe(standardKey(ref));
27
+ expect(standardLabel(ref)).toBe('ISO 19165-2:2020');
28
+ });
29
+
30
+ it('distinguishes standards and editions but ignores case', () => {
31
+ const ref = { source: 'ISO', id: '19165-2:2020' };
32
+ expect(standardKey({ ...ref, source: 'iso' })).toBe(standardKey(ref));
33
+ expect(standardKey({ ...ref, id: '19165-2:2024' })).not.toBe(standardKey(ref));
34
+ expect(standardKey({ ...ref, id: '19115:2020' })).not.toBe(standardKey(ref));
35
+ });
36
+ });
37
+
38
+ describe('collectStandardKeys', () => {
39
+ it('collects unique authoritative references across the concept and all languages', () => {
40
+ const source = (id: string, type = 'authoritative') => ({ type, origin: { ref: { source: 'ISO', id } } });
41
+ const concept = Concept.fromJSON({
42
+ id: '1',
43
+ sources: [source('19165-2:2020'), source('704:2022', 'lineage'), { type: 'authoritative' }],
44
+ localizations: {
45
+ eng: { sources: [source('19165-2:2020'), source('19165-2:2024')] },
46
+ fra: { sources: [source('1087:2019'), source('999:2020', 'lineage'), source('998:2020', '')] },
47
+ },
48
+ });
49
+ expect(collectStandardKeys(concept)).toEqual(['iso 19165-2:2020', 'iso 19165-2:2024', 'iso 1087:2019']);
50
+ });
51
+
52
+ it('returns no keys for a concept without sources', () => {
53
+ expect(collectStandardKeys(Concept.fromJSON({ id: '1' }))).toEqual([]);
54
+ });
55
+
56
+ it('deduplicates citations of different clauses of the same standard', () => {
57
+ const concept = Concept.fromJSON({
58
+ id: '1',
59
+ sources: ['3.1', '4.2'].map(clause => ({
60
+ type: 'authoritative',
61
+ origin: Citation.fromJSON({
62
+ ref: { source: 'ISO', id: '19165-2:2020' },
63
+ locality: { type: 'clause', reference_from: clause },
64
+ }),
65
+ })),
66
+ });
67
+ expect(concept.sources[0].origin?.locality).not.toEqual(concept.sources[1].origin?.locality);
68
+ expect(collectStandardKeys(concept)).toEqual(['iso 19165-2:2020']);
69
+ });
70
+ });
@@ -97,4 +97,30 @@ describe('useUiStore', () => {
97
97
  ui.setTheme('light');
98
98
  expect(ui.isDark).toBe(false);
99
99
  });
100
+
101
+ it('defaults searchSort to language', () => {
102
+ const ui = useUiStore();
103
+ expect(ui.searchSort).toBe('language');
104
+ });
105
+
106
+ it('setSearchSort updates value and localStorage', () => {
107
+ const ui = useUiStore();
108
+ ui.setSearchSort('relevance');
109
+ expect(ui.searchSort).toBe('relevance');
110
+ expect(localStorage.getItem('searchSort')).toBe('relevance');
111
+ });
112
+
113
+ it('reads stored searchSort from localStorage', () => {
114
+ localStorage.setItem('searchSort', 'relevance');
115
+ setActivePinia(createPinia());
116
+ const ui = useUiStore();
117
+ expect(ui.searchSort).toBe('relevance');
118
+ });
119
+
120
+ it('falls back to language for a junk stored searchSort', () => {
121
+ localStorage.setItem('searchSort', 'bogus');
122
+ setActivePinia(createPinia());
123
+ const ui = useUiStore();
124
+ expect(ui.searchSort).toBe('language');
125
+ });
100
126
  });
@@ -178,7 +178,7 @@ describe('vocabulary store — layered loading', () => {
178
178
  });
179
179
 
180
180
  describe('searchAcrossDatasets — two-pass', () => {
181
- it('returns results from loaded data without loading chunks', async () => {
181
+ it('loads every chunk even when the index already has 20 or more hits', async () => {
182
182
  const store = useVocabularyStore();
183
183
  store.initialized = true;
184
184
 
@@ -207,11 +207,11 @@ describe('vocabulary store — layered loading', () => {
207
207
  const results = await store.searchAcrossDatasets('concept');
208
208
 
209
209
  expect(results.length).toBe(25);
210
- // With ≥20 results, chunks should NOT be loaded
211
- expect(chunksLoaded).toBe(false);
210
+ // Load every chunk even when the index already has at least 20 hits.
211
+ expect(chunksLoaded).toBe(true);
212
212
  });
213
213
 
214
- it('loads chunks for adapters with zero index hits when total is under threshold', async () => {
214
+ it('loads every dataset regardless of whether its index has hits', async () => {
215
215
  const store = useVocabularyStore();
216
216
  store.initialized = true;
217
217
 
@@ -258,9 +258,9 @@ describe('vocabulary store — layered loading', () => {
258
258
  const results = await store.searchAcrossDatasets('item');
259
259
 
260
260
  expect(results.length).toBe(6); // 5 from ds1 index + 1 from ds2 chunks
261
- // Adapter 1 should NOT have loaded chunks (it had index hits)
262
- expect(adapter1ChunksLoaded).toBe(false);
263
- // Adapter 2 should have loaded chunks (it had 0 index hits, total < 20)
261
+ // Adapter 1 loads every chunk even though its index already had hits.
262
+ expect(adapter1ChunksLoaded).toBe(true);
263
+ // Adapter 2 also loads every chunk, exposing its hidden hit.
264
264
  expect(adapter2ChunksLoaded).toBe(true);
265
265
  });
266
266
 
@@ -26,7 +26,7 @@ interface IndexJson {
26
26
  schemaVersion: string;
27
27
  conceptCount: number;
28
28
  chunkSize: number;
29
- chunks: { file: string; count: number }[];
29
+ chunks: { file: string; count: number; hash?: string }[];
30
30
  concepts: IndexConceptJson[];
31
31
  }
32
32
 
@@ -52,7 +52,7 @@ export class DatasetAdapter {
52
52
  private summaryMap = new Map<string, ConceptSummary>();
53
53
  private designationMap = new Map<string, string>();
54
54
  private loadedChunks = new Set<number>();
55
- private indexMeta: { conceptCount: number; chunkSize: number; chunks: { file: string; count: number }[] } | null = null;
55
+ private indexMeta: Pick<IndexJson, 'conceptCount' | 'chunkSize' | 'chunks'> | null = null;
56
56
 
57
57
  constructor(registerId: string, baseUrl: string) {
58
58
  this.registerId = registerId;
@@ -152,7 +152,7 @@ export class DatasetAdapter {
152
152
 
153
153
  private async loadIndexChunked(): Promise<ConceptIndex> {
154
154
  const metaResp = await fetch(`${this.baseUrl}/index-meta.json`);
155
- let meta: { registerId: string; schemaVersion: string; conceptCount: number; chunkSize: number; chunks: { file: string; count: number }[] };
155
+ let meta: Omit<IndexJson, 'concepts'>;
156
156
  if (metaResp.ok) {
157
157
  meta = await metaResp.json();
158
158
  } else {
@@ -183,10 +183,17 @@ export class DatasetAdapter {
183
183
  return this.index;
184
184
  }
185
185
 
186
+ private chunkUrl(chunkIndex: number): string {
187
+ const chunks: IndexJson['chunks'] = this.indexMeta?.chunks ?? this.index?.chunks ?? [];
188
+ const chunk = chunks[chunkIndex];
189
+ const file = chunk?.file ?? `index-${String(chunkIndex).padStart(4, '0')}.json`;
190
+ const query = chunk?.hash ? `?v=${encodeURIComponent(chunk.hash)}` : '';
191
+ return `${this.baseUrl}/chunks/${file}${query}`;
192
+ }
193
+
186
194
  async loadChunk(chunkIndex: number): Promise<ConceptEntry[]> {
187
195
  if (this.loadedChunks.has(chunkIndex)) return [];
188
- const chunkFile = `index-${String(chunkIndex).padStart(4, '0')}.json`;
189
- const resp = await fetch(`${this.baseUrl}/chunks/${chunkFile}`);
196
+ const resp = await fetch(this.chunkUrl(chunkIndex));
190
197
  if (!resp.ok) throw ChunkLoadError.make(this.registerId, chunkIndex, resp.status);
191
198
  const data = await resp.json();
192
199
  this.loadedChunks.add(chunkIndex);
@@ -196,9 +203,8 @@ export class DatasetAdapter {
196
203
  private async loadChunkAsSummaries(chunkIndex: number): Promise<void> {
197
204
  if (this.loadedChunks.has(chunkIndex)) return;
198
205
 
199
- const chunkFile = `index-${String(chunkIndex).padStart(4, '0')}.json`;
200
- const resp = await fetch(`${this.baseUrl}/chunks/${chunkFile}`);
201
- if (!resp.ok) return;
206
+ const resp = await fetch(this.chunkUrl(chunkIndex));
207
+ if (!resp.ok) throw ChunkLoadError.make(this.registerId, chunkIndex, resp.status);
202
208
  const data = await resp.json();
203
209
  this.loadedChunks.add(chunkIndex);
204
210
 
@@ -329,6 +335,7 @@ export class DatasetAdapter {
329
335
  conceptId: entry.id,
330
336
  registerId: this.registerId,
331
337
  designation: entry.eng || '',
338
+ status: entry.status,
332
339
  language: '',
333
340
  matchField: 'id',
334
341
  snippet: `ID: ${entry.id}`,
@@ -347,6 +354,7 @@ export class DatasetAdapter {
347
354
  conceptId: entry.id,
348
355
  registerId: this.registerId,
349
356
  designation: term,
357
+ status: entry.status,
350
358
  language,
351
359
  matchField: 'designation',
352
360
  _score: score,
@@ -166,6 +166,7 @@ export interface JsonLdConcept {
166
166
  'gl:identifier'?: string | number;
167
167
  'gl:term'?: string;
168
168
  'gl:localizedConcept'?: Record<string, JsonLdLocalizedConcept>;
169
+ 'gl:source'?: JsonLdSource[];
169
170
  'gl:related'?: JsonLdRelated[];
170
171
  'gl:partitiveRelations'?: JsonLdPartitiveRelation[];
171
172
  'gl:genericRelations'?: JsonLdPartitiveRelation[];
@@ -623,6 +623,7 @@ function conceptFromJsonLd(doc: JsonLdConcept): Concept {
623
623
  term: doc[GL.TERM] ?? null,
624
624
  uri: doc['@id'] ?? null,
625
625
  localizations: localizations as Record<string, any>,
626
+ sources: (doc[GL.SOURCE] ?? []).map(mapSourceFromJsonLd),
626
627
  related,
627
628
  partitive_relations: partitiveRelations,
628
629
  generic_relations: genericRelations,
@@ -0,0 +1,7 @@
1
+ /** Per-register standards.json, keyed by the normalized cited-standard identity. */
2
+ export type StandardsIndex = Record<string, {
3
+ /** Normalized display label from the first authoritative citation encountered. */
4
+ label: string;
5
+ /** Unique concept IDs in dataset encounter order. */
6
+ conceptIds: string[];
7
+ }>;
@@ -238,6 +238,7 @@ export interface SectionNode {
238
238
  export interface SearchHit {
239
239
  conceptId: string;
240
240
  registerId: string;
241
+ status?: string;
241
242
  designation: string;
242
243
  language: string;
243
244
  matchField: 'designation' | 'id';
@@ -26,6 +26,7 @@ const footerNav = computed(() => config.value?.footerNav ?? []);
26
26
  const copyrightOwner = computed(() => config.value?.copyright || '');
27
27
  const ownerName = computed(() => config.value?.branding?.ownerName || config.value?.title || '');
28
28
  const ownerUrl = computed(() => config.value?.branding?.ownerUrl || '/');
29
+ const hostedBy = computed(() => config.value?.branding?.hostedBy as { name?: string; url: string; logo: string } | undefined);
29
30
  </script>
30
31
 
31
32
  <template>
@@ -58,6 +59,12 @@ const ownerUrl = computed(() => config.value?.branding?.ownerUrl || '/');
58
59
  class="hover:text-ink-700 transition-colors"
59
60
  >{{ link.label }}</a>
60
61
  <span class="text-ink-200">|</span>
62
+ <span v-if="hostedBy" class="text-xs inline-flex items-center gap-1.5">
63
+ <span>Hosted by</span>
64
+ <a :href="hostedBy.url" target="_blank" rel="noopener" class="inline-flex items-center">
65
+ <img :src="hostedBy.logo" :alt="hostedBy.name || 'Host'" class="h-4 w-auto opacity-80" />
66
+ </a>
67
+ </span>
61
68
  <span class="text-xs inline-flex items-center gap-1.5">
62
69
  <img :src="glossaristLogo" alt="Glossarist" class="w-4 h-4 opacity-80" />
63
70
  <a :href="poweredBy.url" target="_blank" rel="noopener" class="concept-link">{{ poweredBy.message }}</a>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { useRouter } from 'vue-router';
2
+ import { useRoute, useRouter } from 'vue-router';
3
3
  import { useUiStore } from '../stores/ui';
4
4
  import { useVocabularyStore } from '../stores/vocabulary';
5
5
  import { useSiteConfig } from '../config/use-site-config';
@@ -9,6 +9,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
9
9
  const glossaristLogo = new URL('../assets/glossarist-logo.svg', import.meta.url).href;
10
10
 
11
11
  const router = useRouter();
12
+ const route = useRoute();
12
13
  const ui = useUiStore();
13
14
  const store = useVocabularyStore();
14
15
  const { config: siteConfig, localizedTitle } = useSiteConfig();
@@ -26,8 +27,8 @@ const currentLangLabel = computed(() => {
26
27
  function doSearch() {
27
28
  const q = searchInput.value.trim();
28
29
  if (q) {
29
- ui.searchQuery = q;
30
30
  router.push({ name: 'search', query: { q } });
31
+ searchInput.value = '';
31
32
  }
32
33
  }
33
34
 
@@ -85,13 +86,13 @@ onBeforeUnmount(() => document.removeEventListener('click', closeLangOnOutside))
85
86
  </button>
86
87
 
87
88
  <!-- Search -->
88
- <form @submit.prevent="doSearch" class="flex-1 max-w-lg mx-2 sm:mx-4">
89
+ <form v-if="route.name !== 'search'" @submit.prevent="doSearch" class="flex-1 max-w-lg mx-2 sm:mx-4">
89
90
  <div class="relative">
90
91
  <input
91
92
  v-model="searchInput"
92
93
  type="text"
93
- :aria-label="t('search.conceptSearch')"
94
- :placeholder="t('search.placeholder')"
94
+ :aria-label="t('search.quickSearch')"
95
+ :placeholder="t('search.quickPlaceholder')"
95
96
  class="w-full pl-9 pr-3 py-2 text-sm bg-surface border border-ink-100 rounded-lg focus:ring-2 focus:ring-ink-200 focus:border-ink-400 outline-none placeholder:text-ink-300 transition-all"
96
97
  />
97
98
  <svg class="absolute left-3 top-2.5 w-4 h-4 text-ink-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">