@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.139",
3
+ "version": "0.7.140",
4
4
  "description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,121 @@
1
+ // @vitest-environment node
2
+ import { describe, expect, it } from 'vitest';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join, resolve } from 'node:path';
7
+
8
+ describe('generate-data standards index', () => {
9
+ it('emits unique authoritative standards per register and preserves source stats', () => {
10
+ const root = mkdtempSync(join(tmpdir(), 'generate-data-standards-'));
11
+ const conceptsDir = join(root, 'source', 'concepts');
12
+ const outputDir = join(root, 'public', 'data', 'fixture');
13
+ const configPath = join(root, 'site-config.yml');
14
+ const generator = resolve('scripts/generate-data.ts');
15
+ const tsx = resolve('node_modules/tsx/dist/loader.mjs');
16
+
17
+ try {
18
+ mkdirSync(conceptsDir, { recursive: true });
19
+ writeFileSync(configPath, JSON.stringify({
20
+ id: 'fixture', title: 'Fixture', uri_base: 'https://example.test',
21
+ datasets: [{ id: 'fixture', local_path: 'source', languages: ['eng', 'fra'] }],
22
+ }));
23
+ const writeConcept = (id: string, sources: unknown[], engSources: unknown[] = []) => {
24
+ writeFileSync(join(conceptsDir, `${id}.yaml`), JSON.stringify({
25
+ termid: id,
26
+ _sources: sources,
27
+ eng: {
28
+ terms: [{ designation: `Fixture term ${id}` }],
29
+ sources: engSources,
30
+ },
31
+ }));
32
+ };
33
+ writeConcept('1', [
34
+ { type: 'authoritative', origin: { ref: { source: 'ISO', id: '19165-2:2020', clause: '3.1' } } },
35
+ { type: 'authoritative', origin: { ref: { source: 'ISO', id: '19165-2:2020', clause: '4.2' } } },
36
+ { type: 'authoritative', origin: { ref: { source: 'IEC', id: '17000:2020' } } },
37
+ { type: 'lineage', origin: { ref: { source: 'ISO', id: '704:2022' } } },
38
+ ], [
39
+ { type: 'authoritative', origin: { ref: { source: 'iso', id: '19165-2:2020', clause: '5.1' } } },
40
+ ]);
41
+ writeConcept('2', [
42
+ { type: 'authoritative', origin: { ref: { source: ' ISO ', id: ' 19165-2:2020 ', clause: '6.3' } } },
43
+ ]);
44
+ writeConcept('3', [
45
+ { type: 'lineage', origin: { ref: { source: 'ISO', id: '999:2020' } } },
46
+ ], [
47
+ { type: 'authoritative', origin: { ref: { source: 'ISO', id: '1087:2019' } } },
48
+ ]);
49
+
50
+ execFileSync(process.execPath, ['--import', tsx, generator], {
51
+ cwd: root,
52
+ env: { ...process.env, SITE_CONFIG: configPath },
53
+ stdio: 'pipe',
54
+ timeout: 30_000,
55
+ });
56
+
57
+ const standards = JSON.parse(readFileSync(join(outputDir, 'standards.json'), 'utf8'));
58
+ expect(standards).toEqual({
59
+ 'iso 19165-2:2020': { label: 'ISO 19165-2:2020', conceptIds: ['1', '2'] },
60
+ 'iec 17000:2020': { label: 'IEC 17000:2020', conceptIds: ['1'] },
61
+ 'iso 1087:2019': { label: 'ISO 1087:2019', conceptIds: ['3'] },
62
+ });
63
+
64
+ const stats = JSON.parse(readFileSync(join(outputDir, 'stats.json'), 'utf8'));
65
+ expect(stats.sourceCount).toBe(4);
66
+ expect(stats.sources).toEqual(expect.arrayContaining([
67
+ expect.objectContaining({ ref: 'ISO', conceptCount: 2, conceptIds: ['1', '3'] }),
68
+ expect.objectContaining({ ref: 'IEC', conceptCount: 1, conceptIds: ['1'] }),
69
+ ]));
70
+ } finally {
71
+ rmSync(root, { recursive: true, force: true });
72
+ }
73
+ }, 60_000);
74
+
75
+ it('includes authoritative standards from languages absent from the dataset config', () => {
76
+ const root = mkdtempSync(join(tmpdir(), 'generate-data-unconfigured-language-'));
77
+ const conceptsDir = join(root, 'source', 'concepts');
78
+ const configPath = join(root, 'site-config.yml');
79
+ try {
80
+ mkdirSync(conceptsDir, { recursive: true });
81
+ writeFileSync(configPath, JSON.stringify({
82
+ id: 'fixture', title: 'Fixture', uri_base: 'https://example.test',
83
+ datasets: [{ id: 'fixture', local_path: 'source', languages: ['eng'] }],
84
+ }));
85
+ writeFileSync(join(conceptsDir, '1.yaml'), JSON.stringify({
86
+ termid: '1',
87
+ eng: {
88
+ terms: [{ designation: 'Term' }],
89
+ sources: [{ type: 'authoritative', origin: { ref: { source: 'ISO' } } }],
90
+ },
91
+ deu: {
92
+ terms: [{ designation: 'Begriff' }],
93
+ sources: [
94
+ { type: 'authoritative', origin: { ref: { source: 'DIN', id: '1' } } },
95
+ { type: 'lineage', origin: { ref: { source: 'DIN', id: '2' } } },
96
+ ],
97
+ },
98
+ }));
99
+ execFileSync(process.execPath, [
100
+ '--import', resolve('node_modules/tsx/dist/loader.mjs'), resolve('scripts/generate-data.ts'),
101
+ ], {
102
+ cwd: root,
103
+ env: { ...process.env, SITE_CONFIG: configPath },
104
+ stdio: 'pipe',
105
+ timeout: 30_000,
106
+ });
107
+ const outputDir = join(root, 'public', 'data', 'fixture');
108
+ expect(JSON.parse(readFileSync(join(outputDir, 'standards.json'), 'utf8'))).toEqual({
109
+ iso: { label: 'ISO', conceptIds: ['1'] },
110
+ 'din 1': { label: 'DIN 1', conceptIds: ['1'] },
111
+ });
112
+ const stats = JSON.parse(readFileSync(join(outputDir, 'stats.json'), 'utf8'));
113
+ expect(stats.sourceCount).toBe(1);
114
+ expect(stats.sources).toEqual([
115
+ { ref: 'ISO', types: ['authoritative'], conceptCount: 1, conceptIds: ['1'] },
116
+ ]);
117
+ } finally {
118
+ rmSync(root, { recursive: true, force: true });
119
+ }
120
+ }, 60_000);
121
+ });
@@ -1,6 +1,7 @@
1
1
  // scripts/generate-data.ts
2
2
  import fs2 from "fs";
3
3
  import path3 from "path";
4
+ import { createHash as createHash2 } from "node:crypto";
4
5
  import yaml4 from "js-yaml";
5
6
  import {
6
7
  naturalSort,
@@ -89,6 +90,28 @@ function loadSiteConfig(args = []) {
89
90
  return { config: config2, configPath };
90
91
  }
91
92
 
93
+ // src/utils/source-standard.ts
94
+ function standardLabel(ref) {
95
+ const parts = [ref?.source, ref?.id].map((part) => part?.trim().replace(/\s+/g, " ") ?? "").filter(Boolean);
96
+ return parts.join(" ") || null;
97
+ }
98
+ function standardKey(ref) {
99
+ return standardLabel(ref)?.toLowerCase() ?? null;
100
+ }
101
+ function collectStandardKeys(concept) {
102
+ const sources = [
103
+ ...concept.sources,
104
+ ...concept.languages.flatMap((lang) => concept.localization(lang)?.sources ?? [])
105
+ ];
106
+ const keys = /* @__PURE__ */ new Set();
107
+ for (const source of sources) {
108
+ if (source.type !== "authoritative") continue;
109
+ const key = standardKey(source.origin?.ref);
110
+ if (key !== null) keys.add(key);
111
+ }
112
+ return [...keys];
113
+ }
114
+
92
115
  // scripts/lib/concept-groups.ts
93
116
  function getGroups(conceptYaml) {
94
117
  if (conceptYaml.eng && conceptYaml.eng.groups) return conceptYaml.eng.groups;
@@ -1275,7 +1298,26 @@ async function processDataset(dir, register, opts) {
1275
1298
  withPlurality: 0
1276
1299
  }
1277
1300
  };
1301
+ const standards = /* @__PURE__ */ new Map();
1278
1302
  const STATS_PROCESSORS = [
1303
+ function collectStandards(cy, termid) {
1304
+ const concept = {
1305
+ sources: cy._sources || [],
1306
+ languages: Object.keys(cy).filter((lang) => !lang.startsWith("_") && cy[lang] && typeof cy[lang] === "object" && !Array.isArray(cy[lang])),
1307
+ localization: (lang) => cy[lang]
1308
+ };
1309
+ const sources = [
1310
+ ...concept.sources,
1311
+ ...concept.languages.flatMap((lang) => concept.localization(lang).sources || [])
1312
+ ];
1313
+ for (const key of collectStandardKeys(concept)) {
1314
+ if (!standards.has(key)) {
1315
+ const source = sources.find((source2) => source2.type === "authoritative" && standardKey(source2.origin?.ref) === key);
1316
+ standards.set(key, { label: standardLabel(source.origin.ref), conceptIds: [] });
1317
+ }
1318
+ standards.get(key).conceptIds.push(termid);
1319
+ }
1320
+ },
1279
1321
  function collectSources(cy, termid, s) {
1280
1322
  const refs = /* @__PURE__ */ new Set();
1281
1323
  const allSources = [
@@ -1393,12 +1435,17 @@ async function processDataset(dir, register, opts) {
1393
1435
  const chunk = concepts.slice(i, i + CHUNK_SIZE);
1394
1436
  const chunkIndex = Math.floor(i / CHUNK_SIZE);
1395
1437
  const chunkFile = `index-${String(chunkIndex).padStart(4, "0")}.json`;
1396
- writeJson(path3.join(DATA, register, "chunks", chunkFile), {
1438
+ const chunkData = {
1397
1439
  registerId: register,
1398
1440
  chunkIndex,
1399
1441
  concepts: chunk
1400
- });
1401
- chunks.push({ file: chunkFile, count: chunk.length });
1442
+ };
1443
+ const chunkContent = JSON.stringify(chunkData);
1444
+ const chunkPath = path3.join(DATA, register, "chunks", chunkFile);
1445
+ fs2.mkdirSync(path3.dirname(chunkPath), { recursive: true });
1446
+ fs2.writeFileSync(chunkPath, chunkContent);
1447
+ const hash = createHash2("sha256").update(chunkContent).digest("hex").slice(0, 12);
1448
+ chunks.push({ file: chunkFile, count: chunk.length, hash });
1402
1449
  }
1403
1450
  const summary = concepts.map((c) => ({
1404
1451
  id: c.id,
@@ -1490,6 +1537,7 @@ async function processDataset(dir, register, opts) {
1490
1537
  conceptIds: s.conceptIds
1491
1538
  })).sort((a, b) => b.conceptCount - a.conceptCount);
1492
1539
  const totalRelationships = Object.values(stats.relTypeCounts).reduce((a, b) => a + b, 0);
1540
+ writeJson(path3.join(DATA, register, "standards.json"), Object.fromEntries(standards));
1493
1541
  writeJson(path3.join(DATA, register, "stats.json"), {
1494
1542
  sourceCount: sourceStats.length,
1495
1543
  sources: sourceStats,
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { createHash } from 'node:crypto';
3
4
  import yaml from 'js-yaml';
4
5
  import {
5
6
  naturalSort,
@@ -15,6 +16,8 @@ import {
15
16
  type Concept,
16
17
  } from 'glossarist';
17
18
  import { loadSiteConfig } from './load-site-config';
19
+ import { collectStandardKeys, standardKey, standardLabel } from '../src/utils/source-standard';
20
+ import type { StandardsIndex } from '../src/adapters/standards-index';
18
21
  import { getGroups } from './lib/concept-groups';
19
22
  import { consumeDatasetEntities } from './lib/build/non-verbal-consumer';
20
23
  import { copyImageAssets } from './lib/build/image-assets';
@@ -740,7 +743,31 @@ async function processDataset(dir, register, opts) {
740
743
  },
741
744
  };
742
745
 
746
+ const standards = new Map<string, StandardsIndex[string]>();
747
+
743
748
  const STATS_PROCESSORS = [
749
+ function collectStandards(cy, termid) {
750
+ // Harmonized metadata uses underscore-prefixed fields; object-valued
751
+ // language entries include every localization, even if not configured.
752
+ const concept = {
753
+ sources: cy._sources || [],
754
+ languages: Object.keys(cy).filter(lang =>
755
+ !lang.startsWith('_') && cy[lang] && typeof cy[lang] === 'object' && !Array.isArray(cy[lang])),
756
+ localization: (lang: string) => cy[lang],
757
+ };
758
+ const sources = [
759
+ ...concept.sources,
760
+ ...concept.languages.flatMap(lang => concept.localization(lang).sources || []),
761
+ ];
762
+ for (const key of collectStandardKeys(concept)) {
763
+ if (!standards.has(key)) {
764
+ const source = sources.find(source =>
765
+ source.type === 'authoritative' && standardKey(source.origin?.ref) === key);
766
+ standards.set(key, { label: standardLabel(source.origin.ref)!, conceptIds: [] });
767
+ }
768
+ standards.get(key)!.conceptIds.push(termid);
769
+ }
770
+ },
744
771
  function collectSources(cy, termid, s) {
745
772
  const refs = new Set();
746
773
  const allSources = [
@@ -879,12 +906,17 @@ async function processDataset(dir, register, opts) {
879
906
  const chunk = concepts.slice(i, i + CHUNK_SIZE);
880
907
  const chunkIndex = Math.floor(i / CHUNK_SIZE);
881
908
  const chunkFile = `index-${String(chunkIndex).padStart(4, '0')}.json`;
882
- writeJson(path.join(DATA, register, 'chunks', chunkFile), {
909
+ const chunkData = {
883
910
  registerId: register,
884
911
  chunkIndex,
885
912
  concepts: chunk,
886
- });
887
- chunks.push({ file: chunkFile, count: chunk.length });
913
+ };
914
+ const chunkContent = JSON.stringify(chunkData);
915
+ const chunkPath = path.join(DATA, register, 'chunks', chunkFile);
916
+ fs.mkdirSync(path.dirname(chunkPath), { recursive: true });
917
+ fs.writeFileSync(chunkPath, chunkContent);
918
+ const hash = createHash('sha256').update(chunkContent).digest('hex').slice(0, 12);
919
+ chunks.push({ file: chunkFile, count: chunk.length, hash });
888
920
  }
889
921
 
890
922
  const summary = concepts.map(c => ({
@@ -993,6 +1025,8 @@ async function processDataset(dir, register, opts) {
993
1025
  .sort((a, b) => b.conceptCount - a.conceptCount);
994
1026
  const totalRelationships = Object.values(stats.relTypeCounts).reduce((a: number, b: number) => a + b, 0);
995
1027
 
1028
+ writeJson(path.join(DATA, register, 'standards.json'), Object.fromEntries(standards));
1029
+
996
1030
  writeJson(path.join(DATA, register, 'stats.json'), {
997
1031
  sourceCount: sourceStats.length,
998
1032
  sources: sourceStats,
@@ -35,4 +35,40 @@ describe('AppFooter', () => {
35
35
  expect(link).toBeDefined();
36
36
  expect(link!.attributes('href')).toBe('https://glossarist.org');
37
37
  });
38
+
39
+ it('omits hosted by when branding.hostedBy is absent', () => {
40
+ const wrapper = mountFooter();
41
+ expect(wrapper.text()).not.toContain('Hosted by');
42
+ });
43
+
44
+ it('renders hosted by with logo when branding.hostedBy is configured', async () => {
45
+ const inline = document.createElement('script');
46
+ inline.id = 'site-config-json';
47
+ inline.type = 'application/json';
48
+ inline.textContent = JSON.stringify({
49
+ title: 'Test Site',
50
+ branding: {
51
+ hostedBy: {
52
+ name: 'Enosema Foundation',
53
+ url: 'https://www.enosema.org',
54
+ logo: 'https://www.enosema.org/logos/logo-text-white.svg',
55
+ },
56
+ },
57
+ });
58
+ document.head.appendChild(inline);
59
+
60
+ const { useSiteConfig } = await import('../config/use-site-config');
61
+ await useSiteConfig().loadConfig();
62
+
63
+ const wrapper = mountFooter();
64
+ expect(wrapper.text()).toContain('Hosted by');
65
+ const link = wrapper.findAll('a').find(a => a.attributes('href') === 'https://www.enosema.org');
66
+ expect(link).toBeDefined();
67
+ expect(link!.attributes('href')).toBe('https://www.enosema.org');
68
+ const img = link!.find('img');
69
+ expect(img.attributes('src')).toBe('https://www.enosema.org/logos/logo-text-white.svg');
70
+ expect(img.attributes('alt')).toBe('Enosema Foundation');
71
+ inline.remove();
72
+ });
73
+
38
74
  });
@@ -3,6 +3,8 @@ import { mount, flushPromises } from '@vue/test-utils';
3
3
  import { createPinia, setActivePinia } from 'pinia';
4
4
  import { createRouter, createMemoryHistory } from 'vue-router';
5
5
  import AppHeader from '../components/AppHeader.vue';
6
+ import { useUiStore } from '../stores/ui';
7
+ import { locale } from '../i18n';
6
8
  import { useVocabularyStore } from '../stores/vocabulary';
7
9
  import type { Manifest } from '../adapters/types';
8
10
 
@@ -45,6 +47,7 @@ describe('AppHeader', () => {
45
47
  let router: Awaited<ReturnType<typeof createTestRouter>>;
46
48
 
47
49
  beforeEach(async () => {
50
+ locale.value = 'eng';
48
51
  pinia = createPinia();
49
52
  setActivePinia(pinia);
50
53
  router = await createTestRouter();
@@ -66,10 +69,28 @@ describe('AppHeader', () => {
66
69
  expect(wrapper.text()).toContain('Glossarist');
67
70
  });
68
71
 
69
- it('renders search input', () => {
72
+ it('renders quick search on the home route', () => {
70
73
  const wrapper = mountHeader();
71
- const input = wrapper.find('input[aria-label="Search concepts"]');
74
+ const input = wrapper.find('input[aria-label="Quick search"]');
72
75
  expect(input.exists()).toBe(true);
76
+ expect(input.attributes('placeholder')).toBe('Quick search...');
77
+ });
78
+
79
+ it('hides quick search on the search route and restores it elsewhere', async () => {
80
+ await router.push('/search?q=road');
81
+ const wrapper = mountHeader();
82
+ expect(wrapper.find('form').exists()).toBe(false);
83
+
84
+ await router.push('/');
85
+ expect(wrapper.find('form').exists()).toBe(true);
86
+ });
87
+
88
+ it('labels quick search in French', () => {
89
+ locale.value = 'fra';
90
+ const wrapper = mountHeader();
91
+ const input = wrapper.get('input');
92
+ expect(input.attributes('aria-label')).toBe('Recherche rapide');
93
+ expect(input.attributes('placeholder')).toBe('Recherche rapide…');
73
94
  });
74
95
 
75
96
  it('renders mobile hamburger button', () => {
@@ -89,13 +110,19 @@ describe('AppHeader', () => {
89
110
 
90
111
  it('navigates to search on form submit with query', async () => {
91
112
  const wrapper = mountHeader();
92
- const input = wrapper.find('input[aria-label="Search concepts"]');
93
- await input.setValue('road');
113
+ const input = wrapper.find('input[aria-label="Quick search"]');
114
+ await input.setValue(' road & rail ');
94
115
  const form = wrapper.find('form');
95
116
  await form.trigger('submit');
96
117
  await flushPromises();
97
118
  expect(router.currentRoute.value.name).toBe('search');
98
- expect(router.currentRoute.value.query.q).toBe('road');
119
+ expect(router.currentRoute.value.query).toEqual({ q: 'road & rail' });
120
+ expect(router.currentRoute.value.path).toBe('/search');
121
+ expect(useUiStore().searchQuery).toBe('');
122
+ expect(wrapper.find('form').exists()).toBe(false);
123
+
124
+ await router.push('/');
125
+ expect(wrapper.get('input').element.value).toBe('');
99
126
  });
100
127
 
101
128
  it('does not navigate on empty search', async () => {