@glossarist/concept-browser 0.7.139 → 0.7.141

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 (46) hide show
  1. package/package.json +1 -1
  2. package/scripts/__tests__/standards-index.test.ts +121 -0
  3. package/scripts/build-edges.js +1 -23
  4. package/scripts/generate-data.js +206 -25
  5. package/scripts/generate-data.ts +235 -34
  6. package/scripts/lib/workers/emit-concept-worker.mjs +31 -0
  7. package/src/__tests__/app-footer.test.ts +36 -0
  8. package/src/__tests__/app-header.test.ts +32 -5
  9. package/src/__tests__/composables/use-same-standard-terms.test.ts +397 -0
  10. package/src/__tests__/concept-view.test.ts +103 -0
  11. package/src/__tests__/dataset-adapter.test.ts +150 -0
  12. package/src/__tests__/dataset-view.test.ts +41 -1
  13. package/src/__tests__/model-bridge-citation.test.ts +20 -0
  14. package/src/__tests__/same-standard-pane.test.ts +174 -0
  15. package/src/__tests__/scripts/generate-data.test.ts +78 -0
  16. package/src/__tests__/search-across-datasets.test.ts +102 -0
  17. package/src/__tests__/search-bar.test.ts +304 -0
  18. package/src/__tests__/search-results.test.ts +54 -0
  19. package/src/__tests__/search-utils.test.ts +64 -1
  20. package/src/__tests__/search-view.test.ts +194 -2
  21. package/src/__tests__/source-standard.test.ts +70 -0
  22. package/src/__tests__/ui-store.test.ts +26 -0
  23. package/src/__tests__/vocabulary-layered.test.ts +7 -7
  24. package/src/adapters/DatasetAdapter.ts +16 -8
  25. package/src/adapters/jsonld-types.ts +1 -0
  26. package/src/adapters/model-bridge.ts +1 -0
  27. package/src/adapters/standards-index.ts +7 -0
  28. package/src/adapters/types.ts +1 -0
  29. package/src/components/AppFooter.vue +7 -0
  30. package/src/components/AppHeader.vue +6 -5
  31. package/src/components/SameStandardPane.vue +70 -0
  32. package/src/components/SearchBar.vue +73 -18
  33. package/src/components/SearchResults.vue +46 -5
  34. package/src/components/site/SiteFooter.astro +6 -0
  35. package/src/composables/use-same-standard-terms.ts +106 -0
  36. package/src/config/types.ts +11 -0
  37. package/src/config/use-site-config.ts +1 -0
  38. package/src/i18n/locales/eng.yml +10 -1
  39. package/src/i18n/locales/fra.yml +10 -1
  40. package/src/stores/ui.ts +16 -1
  41. package/src/stores/vocabulary.ts +27 -31
  42. package/src/utils/search.ts +14 -1
  43. package/src/utils/source-standard.ts +34 -0
  44. package/src/views/ConceptView.vue +43 -12
  45. package/src/views/DatasetView.vue +25 -5
  46. package/src/views/SearchView.vue +9 -12
@@ -1,5 +1,8 @@
1
1
  import fs from 'fs';
2
+ import os from 'os';
2
3
  import path from 'path';
4
+ import { Worker as WorkerThread } from 'node:worker_threads';
5
+ import { createHash } from 'node:crypto';
3
6
  import yaml from 'js-yaml';
4
7
  import {
5
8
  naturalSort,
@@ -15,6 +18,8 @@ import {
15
18
  type Concept,
16
19
  } from 'glossarist';
17
20
  import { loadSiteConfig } from './load-site-config';
21
+ import { collectStandardKeys, standardKey, standardLabel } from '../src/utils/source-standard';
22
+ import type { StandardsIndex } from '../src/adapters/standards-index';
18
23
  import { getGroups } from './lib/concept-groups';
19
24
  import { consumeDatasetEntities } from './lib/build/non-verbal-consumer';
20
25
  import { copyImageAssets } from './lib/build/image-assets';
@@ -687,21 +692,129 @@ function getPrimaryDesignation(conceptYaml) {
687
692
  return descs;
688
693
  }
689
694
 
695
+ // Worker pool for glossarist-native per-concept emissions (ttl/jsonld/tbx).
696
+ // Set GCB_WORKERS=0 to force inline emission (same outputs, one thread).
697
+ type EmissionJob = { file: string; termid: string; sourceText: string; sourceHash: string; conceptInstance: Concept };
698
+ type EmissionResult = { termid: string; ttl?: string; jsonld?: string; tbx?: string; error?: string };
699
+
700
+ async function inlineEmission(job: EmissionJob, register: string, uriBase: string): Promise<EmissionResult> {
701
+ try {
702
+ const outputs = await emitOutputSet([job.conceptInstance], {
703
+ registerId: register,
704
+ uriBase,
705
+ formats: ['jsonld'],
706
+ });
707
+ return {
708
+ termid: job.termid,
709
+ ttl: conceptToTurtle(job.conceptInstance, { uriBase, registerId: register }),
710
+ jsonld: outputs.jsonld,
711
+ tbx: conceptsToTbx([job.conceptInstance], { registerId: register }) || null,
712
+ };
713
+ } catch (e) {
714
+ return { termid: job.termid, error: String((e && e.message) || e) };
715
+ }
716
+ }
717
+
718
+ function createEmissionPool(register: string, uriBase: string): {
719
+ emit(job: EmissionJob): Promise<EmissionResult>;
720
+ close(): Promise<void>;
721
+ } | null {
722
+ const configured = Number(process.env.GCB_WORKERS);
723
+ if (Number.isFinite(configured) && configured <= 0) return null;
724
+ const size = Number.isFinite(configured)
725
+ ? configured
726
+ : Math.max(1, Math.min(4, (os.availableParallelism ? os.availableParallelism() : os.cpus().length) - 1));
727
+ const workerPath = path.join(__dirname, 'lib', 'workers', 'emit-concept-worker.mjs');
728
+
729
+ let pool: WorkerThread[];
730
+ try {
731
+ pool = Array.from({ length: size }, () => new WorkerThread(workerPath, { workerData: { registerId: register, uriBase } }));
732
+ } catch {
733
+ return null;
734
+ }
735
+
736
+ const active = new Map<WorkerThread, { resolve: (r: EmissionResult) => void; reject: (e: Error) => void }>();
737
+ const pending: { job: EmissionJob; resolve: (r: EmissionResult) => void; reject: (e: Error) => void }[] = [];
738
+ const idle = new Set<WorkerThread>();
739
+
740
+ function dispatch() {
741
+ while (pending.length > 0) {
742
+ const worker = idle.values().next();
743
+ if (worker.done) return;
744
+ idle.delete(worker.value);
745
+ const entry = pending.shift()!;
746
+ active.set(worker.value, entry);
747
+ worker.value.postMessage({ termid: entry.job.termid, sourceText: entry.job.sourceText });
748
+ }
749
+ }
750
+
751
+ for (const worker of pool) {
752
+ idle.add(worker);
753
+ worker.on('message', (msg: EmissionResult) => {
754
+ const entry = active.get(worker);
755
+ active.delete(worker);
756
+ idle.add(worker);
757
+ entry?.resolve(msg);
758
+ dispatch();
759
+ });
760
+ const fail = (err: Error) => {
761
+ const entry = active.get(worker);
762
+ active.delete(worker);
763
+ idle.delete(worker);
764
+ pool = pool.filter(w => w !== worker);
765
+ entry?.reject(err);
766
+ dispatch();
767
+ };
768
+ worker.on('error', fail);
769
+ worker.on('exit', () => fail(new Error('emission worker exited')));
770
+ }
771
+
772
+ return {
773
+ emit(job: EmissionJob): Promise<EmissionResult> {
774
+ return new Promise((resolve, reject) => {
775
+ pending.push({ job, resolve, reject });
776
+ dispatch();
777
+ });
778
+ },
779
+ async close() {
780
+ await Promise.allSettled(pool.map(w => w.terminate()));
781
+ },
782
+ };
783
+ }
784
+
690
785
  async function processDataset(dir, register, opts) {
691
786
  const files = fs.readdirSync(dir).filter(f => f.endsWith('.yaml')).sort((a, b) => naturalSort(a.replace('.yaml', ''), b.replace('.yaml', '')));
692
787
 
693
788
  console.log(`Processing ${register}: ${files.length} files`);
694
789
 
695
790
  const conceptsDir = path.join(DATA, register, 'concepts');
696
- // Clean previous output to avoid stale files accumulating across runs
697
- if (fs.existsSync(conceptsDir)) {
698
- for (const f of fs.readdirSync(conceptsDir)) fs.unlinkSync(path.join(conceptsDir, f));
699
- }
791
+ fs.mkdirSync(conceptsDir, { recursive: true });
792
+
793
+ // Incremental emission cache: per-source-file content hash → last
794
+ // emitted termid. Unchanged sources skip per-concept file emission;
795
+ // orphans (source removed, renamed termid, older cache version) are
796
+ // swept below. Bump EMISSION_CACHE_VERSION whenever the per-concept
797
+ // emission logic changes its output shape.
798
+ const EMISSION_CACHE_VERSION = 1;
799
+ const EMISSION_EXTENSIONS = ['json', 'ttl', 'jsonld', 'tbx', 'yaml'];
800
+ const cacheDir = path.join(ROOT, '.gcb-cache');
801
+ const cachePath = path.join(cacheDir, `${register}.json`);
802
+ const emissionCache: { version: number; files: Record<string, { hash: string; termid: string }> } = {
803
+ version: EMISSION_CACHE_VERSION,
804
+ files: {},
805
+ };
806
+ try {
807
+ const loaded = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
808
+ if (loaded?.version === EMISSION_CACHE_VERSION && loaded.files) emissionCache.files = loaded.files;
809
+ } catch {}
810
+
700
811
  const concepts = [];
701
812
  const conceptInstances: Concept[] = [];
702
813
  const langTermCounts = {};
703
814
  const langDefCounts = {};
704
815
  const availableFormats = ['ttl', 'jsonld', 'yaml', 'tbx', 'csv'];
816
+ const producedOutputs = new Set<string>();
817
+ const emissionCacheFiles: Record<string, { hash: string; termid: string }> = {};
705
818
 
706
819
  // Pre-scan: build designation → concept ID lookup for same-dataset designation refs
707
820
  const designationLookup = new Map();
@@ -740,7 +853,31 @@ async function processDataset(dir, register, opts) {
740
853
  },
741
854
  };
742
855
 
856
+ const standards = new Map<string, StandardsIndex[string]>();
857
+
743
858
  const STATS_PROCESSORS = [
859
+ function collectStandards(cy, termid) {
860
+ // Harmonized metadata uses underscore-prefixed fields; object-valued
861
+ // language entries include every localization, even if not configured.
862
+ const concept = {
863
+ sources: cy._sources || [],
864
+ languages: Object.keys(cy).filter(lang =>
865
+ !lang.startsWith('_') && cy[lang] && typeof cy[lang] === 'object' && !Array.isArray(cy[lang])),
866
+ localization: (lang: string) => cy[lang],
867
+ };
868
+ const sources = [
869
+ ...concept.sources,
870
+ ...concept.languages.flatMap(lang => concept.localization(lang).sources || []),
871
+ ];
872
+ for (const key of collectStandardKeys(concept)) {
873
+ if (!standards.has(key)) {
874
+ const source = sources.find(source =>
875
+ source.type === 'authoritative' && standardKey(source.origin?.ref) === key);
876
+ standards.set(key, { label: standardLabel(source.origin.ref)!, conceptIds: [] });
877
+ }
878
+ standards.get(key)!.conceptIds.push(termid);
879
+ }
880
+ },
744
881
  function collectSources(cy, termid, s) {
745
882
  const refs = new Set();
746
883
  const allSources = [
@@ -803,46 +940,54 @@ async function processDataset(dir, register, opts) {
803
940
  },
804
941
  ];
805
942
 
943
+ const loopStart = Date.now();
944
+ const emissionJobs: EmissionJob[] = [];
945
+
806
946
  for (let i = 0; i < files.length; i++) {
807
947
  const file = files[i];
948
+ if (i > 0 && i % 250 === 0) console.log(` ${i}/${files.length} concepts`);
808
949
  try {
809
- const conceptYaml = loadConceptFile(path.join(dir, file));
950
+ const filePath = path.join(dir, file);
951
+ const sourceText = fs.readFileSync(filePath, 'utf8');
952
+ const sourceHash = createHash('sha256').update(sourceText).digest('hex');
953
+
954
+ const conceptYaml = loadConceptFile(filePath);
810
955
  if (!conceptYaml || !conceptYaml.termid) continue;
811
956
 
812
957
  const termid = String(conceptYaml.termid);
813
- const jsonld = yamlToJsonLd(conceptYaml, register, dsRefMaps);
814
- writeJson(path.join(conceptsDir, `${termid}.json`), jsonld);
815
- // Canonical interchange exports — routed through glossarist's
816
- // output API (one model walk; predicates generated from the
817
- // concept-model context, wire shape from schemas/v3). The hand-rolled
818
- // gl:-shaped emitters in scripts/lib/concept-formats.ts are retired.
819
- // Note: TBX now includes <term> entries — the retired emitter
820
- // computed termEntries but never wrote them into the document.
958
+
959
+ const cached = emissionCache.files[file];
960
+ const unchanged = cached?.hash === sourceHash
961
+ && EMISSION_EXTENSIONS.every(ext => fs.existsSync(path.join(conceptsDir, `${termid}.${ext}`)));
962
+
963
+ // Canonical model instance — required for the aggregate exports on
964
+ // every run, independent of per-file emission caching.
821
965
  let conceptInstance: Concept | null = null;
822
966
  try {
823
- conceptInstance = parseConceptYaml(fs.readFileSync(path.join(dir, file), 'utf8'), termid);
967
+ conceptInstance = parseConceptYaml(sourceText, termid);
824
968
  } catch (e) {
825
969
  console.warn(` ${termid}: canonical parse failed (${e.message}); skipping glossarist exports`);
826
970
  }
827
- if (conceptInstance) {
828
- conceptInstances.push(conceptInstance);
829
- fs.writeFileSync(
830
- path.join(conceptsDir, `${termid}.ttl`),
831
- conceptToTurtle(conceptInstance, { uriBase: refMaps.uriBase, registerId: register }),
832
- );
833
- const conceptJsonLd = await emitOutputSet([conceptInstance], {
834
- registerId: register,
835
- uriBase: refMaps.uriBase,
836
- formats: ['jsonld'],
837
- });
838
- fs.writeFileSync(path.join(conceptsDir, `${termid}.jsonld`), conceptJsonLd.jsonld!);
839
- const tbxContent = conceptsToTbx([conceptInstance], { registerId: register });
840
- if (tbxContent) {
841
- fs.writeFileSync(path.join(conceptsDir, `${termid}.tbx`), tbxContent);
971
+ if (conceptInstance) conceptInstances.push(conceptInstance);
972
+
973
+ if (!unchanged) {
974
+ const jsonld = yamlToJsonLd(conceptYaml, register, dsRefMaps);
975
+ writeJson(path.join(conceptsDir, `${termid}.json`), jsonld);
976
+ fs.copyFileSync(filePath, path.join(conceptsDir, `${termid}.yaml`));
977
+ if (conceptInstance) {
978
+ emissionJobs.push({ file, termid, sourceText, sourceHash, conceptInstance });
842
979
  }
980
+ } else {
981
+ emissionCacheFiles[file] = { hash: sourceHash, termid };
843
982
  }
844
983
 
845
- fs.copyFileSync(path.join(dir, file), path.join(conceptsDir, `${termid}.yaml`));
984
+ producedOutputs.add(`${termid}.json`);
985
+ producedOutputs.add(`${termid}.yaml`);
986
+ if (conceptInstance) {
987
+ producedOutputs.add(`${termid}.ttl`);
988
+ producedOutputs.add(`${termid}.jsonld`);
989
+ producedOutputs.add(`${termid}.tbx`);
990
+ }
846
991
 
847
992
  concepts.push({
848
993
  id: termid,
@@ -859,17 +1004,64 @@ async function processDataset(dir, register, opts) {
859
1004
  }
860
1005
  }
861
1006
 
1007
+ // Glossarist-native per-concept exports (ttl/jsonld/tbx), parallelized
1008
+ // on worker threads; falls back to inline emission when workers are
1009
+ // unavailable (GCB_WORKERS=0 or restricted environments).
1010
+ if (emissionJobs.length > 0) {
1011
+ const jobsByTermid = new Map(emissionJobs.map(j => [j.termid, j]));
1012
+ const pool = createEmissionPool(register, refMaps.uriBase);
1013
+ const emissionStart = Date.now();
1014
+ try {
1015
+ const results = await Promise.all(emissionJobs.map(job => {
1016
+ if (!pool) return inlineEmission(job, register, refMaps.uriBase);
1017
+ return pool.emit(job).catch(() => inlineEmission(job, register, refMaps.uriBase));
1018
+ }));
1019
+ for (const result of results) {
1020
+ if (result.error) {
1021
+ console.warn(` ${result.termid}: canonical parse failed (${result.error}); skipping glossarist exports`);
1022
+ const job = jobsByTermid.get(result.termid);
1023
+ if (job) delete emissionCacheFiles[job.file];
1024
+ for (const ext of ['ttl', 'jsonld', 'tbx']) producedOutputs.delete(`${result.termid}.${ext}`);
1025
+ continue;
1026
+ }
1027
+ fs.writeFileSync(path.join(conceptsDir, `${result.termid}.ttl`), result.ttl);
1028
+ fs.writeFileSync(path.join(conceptsDir, `${result.termid}.jsonld`), result.jsonld);
1029
+ if (result.tbx) fs.writeFileSync(path.join(conceptsDir, `${result.termid}.tbx`), result.tbx);
1030
+ const job = jobsByTermid.get(result.termid);
1031
+ if (job) emissionCacheFiles[job.file] = { hash: job.sourceHash, termid: job.termid };
1032
+ }
1033
+ console.log(` Interchange exports: ${((Date.now() - emissionStart) / 1000).toFixed(1)}s (${emissionJobs.length} concepts, ${pool ? 'parallel' : 'inline'})`);
1034
+ } finally {
1035
+ if (pool) await pool.close();
1036
+ }
1037
+ }
1038
+
1039
+ // Sweep per-concept outputs this run no longer produces (removed or
1040
+ // renamed sources) — replaces the wholesale pre-loop clean, which the
1041
+ // incremental cache needs gone.
1042
+ for (const entry of fs.readdirSync(conceptsDir)) {
1043
+ if (!producedOutputs.has(entry)) fs.unlinkSync(path.join(conceptsDir, entry));
1044
+ }
1045
+ fs.mkdirSync(cacheDir, { recursive: true });
1046
+ fs.writeFileSync(cachePath, JSON.stringify({ version: EMISSION_CACHE_VERSION, files: emissionCacheFiles }));
1047
+
1048
+ console.log(` Per-concept emissions: ${((Date.now() - loopStart) / 1000).toFixed(1)}s`);
1049
+
862
1050
  // Aggregate distributions: single-file exports of the whole register,
863
1051
  // emitted by glossarist's output-set API from the canonical model walk.
864
1052
  // The CSV uri column is a link a human opens — it carries the SPA route
865
1053
  // shape on the deployment's configured host (siteOrigin), not the
866
- // RDF-canonical IRI.
1054
+ // RDF-canonical IRI. Only the two formats consumed downstream are
1055
+ // requested — the output-set API computes all six by default.
1056
+ const aggregateStart = Date.now();
867
1057
  const aggregate = await emitOutputSet(conceptInstances, {
868
1058
  registerId: register,
869
1059
  uriBase: refMaps.uriBase,
1060
+ formats: ['csv', 'jsonld'],
870
1061
  languageOrder: opts.languageOrder || opts.languages,
871
1062
  conceptUriPrefix: buildConceptUriPrefix(refMaps.siteOrigin, register),
872
1063
  });
1064
+ console.log(` Aggregate csv+jsonld: ${((Date.now() - aggregateStart) / 1000).toFixed(1)}s`);
873
1065
  fs.writeFileSync(path.join(DATA, register, `${register}.csv`), aggregate.csv!);
874
1066
  fs.writeFileSync(path.join(DATA, register, `${register}.jsonld`), aggregate.jsonld!);
875
1067
 
@@ -879,12 +1071,17 @@ async function processDataset(dir, register, opts) {
879
1071
  const chunk = concepts.slice(i, i + CHUNK_SIZE);
880
1072
  const chunkIndex = Math.floor(i / CHUNK_SIZE);
881
1073
  const chunkFile = `index-${String(chunkIndex).padStart(4, '0')}.json`;
882
- writeJson(path.join(DATA, register, 'chunks', chunkFile), {
1074
+ const chunkData = {
883
1075
  registerId: register,
884
1076
  chunkIndex,
885
1077
  concepts: chunk,
886
- });
887
- chunks.push({ file: chunkFile, count: chunk.length });
1078
+ };
1079
+ const chunkContent = JSON.stringify(chunkData);
1080
+ const chunkPath = path.join(DATA, register, 'chunks', chunkFile);
1081
+ fs.mkdirSync(path.dirname(chunkPath), { recursive: true });
1082
+ fs.writeFileSync(chunkPath, chunkContent);
1083
+ const hash = createHash('sha256').update(chunkContent).digest('hex').slice(0, 12);
1084
+ chunks.push({ file: chunkFile, count: chunk.length, hash });
888
1085
  }
889
1086
 
890
1087
  const summary = concepts.map(c => ({
@@ -993,6 +1190,8 @@ async function processDataset(dir, register, opts) {
993
1190
  .sort((a, b) => b.conceptCount - a.conceptCount);
994
1191
  const totalRelationships = Object.values(stats.relTypeCounts).reduce((a: number, b: number) => a + b, 0);
995
1192
 
1193
+ writeJson(path.join(DATA, register, 'standards.json'), Object.fromEntries(standards));
1194
+
996
1195
  writeJson(path.join(DATA, register, 'stats.json'), {
997
1196
  sourceCount: sourceStats.length,
998
1197
  sources: sourceStats,
@@ -1040,7 +1239,9 @@ async function processDataset(dir, register, opts) {
1040
1239
  // Dataset-level RDF (WS J2/J5): dcat:Dataset + skos:ConceptScheme + skos:Collection per section.
1041
1240
  // Runs before the manifest write so the ttl distribution is on disk when
1042
1241
  // bulkFormats is assembled.
1242
+ const rdfStart = Date.now();
1043
1243
  await writeDatasetRdf(register, manifest, concepts, refMaps, opts);
1244
+ console.log(` Dataset RDF: ${((Date.now() - rdfStart) / 1000).toFixed(1)}s`);
1044
1245
  addBulkFormat(`${register}.ttl`);
1045
1246
  manifest.bulkFormats = opts.bulkExports?.length
1046
1247
  ? bulkFormats.filter(f => opts.bulkExports.includes(f.format))
@@ -0,0 +1,31 @@
1
+ // Worker thread: glossarist-native per-concept interchange emissions
2
+ // (ttl / jsonld / tbx). Plain .mjs — ESM loader hooks like tsx do not
3
+ // propagate into worker threads, so this file must stay dependency-free
4
+ // of workspace TS; everything resolves from node_modules.
5
+ //
6
+ // Concept model instances are class objects and cannot cross the
7
+ // worker boundary, so the worker re-parses the exact source text the
8
+ // main thread read.
9
+ import { parentPort, workerData } from 'node:worker_threads';
10
+ import { parseConceptYaml, conceptToTurtle, emitOutputSet, conceptsToTbx } from 'glossarist';
11
+
12
+ const { registerId, uriBase } = workerData;
13
+
14
+ parentPort.on('message', async ({ termid, sourceText }) => {
15
+ try {
16
+ const concept = parseConceptYaml(sourceText, termid);
17
+ const outputs = await emitOutputSet([concept], {
18
+ registerId,
19
+ uriBase,
20
+ formats: ['jsonld'],
21
+ });
22
+ parentPort.postMessage({
23
+ termid,
24
+ ttl: conceptToTurtle(concept, { uriBase, registerId }),
25
+ jsonld: outputs.jsonld,
26
+ tbx: conceptsToTbx([concept], { registerId }) || null,
27
+ });
28
+ } catch (e) {
29
+ parentPort.postMessage({ termid, error: String((e && e.message) || e) });
30
+ }
31
+ });
@@ -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 () => {