@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.
- package/package.json +1 -1
- package/scripts/__tests__/standards-index.test.ts +121 -0
- package/scripts/build-edges.js +1 -23
- package/scripts/generate-data.js +206 -25
- package/scripts/generate-data.ts +235 -34
- package/scripts/lib/workers/emit-concept-worker.mjs +31 -0
- package/src/__tests__/app-footer.test.ts +36 -0
- package/src/__tests__/app-header.test.ts +32 -5
- package/src/__tests__/composables/use-same-standard-terms.test.ts +397 -0
- package/src/__tests__/concept-view.test.ts +103 -0
- package/src/__tests__/dataset-adapter.test.ts +150 -0
- package/src/__tests__/dataset-view.test.ts +41 -1
- package/src/__tests__/model-bridge-citation.test.ts +20 -0
- package/src/__tests__/same-standard-pane.test.ts +174 -0
- package/src/__tests__/scripts/generate-data.test.ts +78 -0
- package/src/__tests__/search-across-datasets.test.ts +102 -0
- package/src/__tests__/search-bar.test.ts +304 -0
- package/src/__tests__/search-results.test.ts +54 -0
- package/src/__tests__/search-utils.test.ts +64 -1
- package/src/__tests__/search-view.test.ts +194 -2
- package/src/__tests__/source-standard.test.ts +70 -0
- package/src/__tests__/ui-store.test.ts +26 -0
- package/src/__tests__/vocabulary-layered.test.ts +7 -7
- package/src/adapters/DatasetAdapter.ts +16 -8
- package/src/adapters/jsonld-types.ts +1 -0
- package/src/adapters/model-bridge.ts +1 -0
- package/src/adapters/standards-index.ts +7 -0
- package/src/adapters/types.ts +1 -0
- package/src/components/AppFooter.vue +7 -0
- package/src/components/AppHeader.vue +6 -5
- package/src/components/SameStandardPane.vue +70 -0
- package/src/components/SearchBar.vue +73 -18
- package/src/components/SearchResults.vue +46 -5
- package/src/components/site/SiteFooter.astro +6 -0
- package/src/composables/use-same-standard-terms.ts +106 -0
- package/src/config/types.ts +11 -0
- package/src/config/use-site-config.ts +1 -0
- package/src/i18n/locales/eng.yml +10 -1
- package/src/i18n/locales/fra.yml +10 -1
- package/src/stores/ui.ts +16 -1
- package/src/stores/vocabulary.ts +27 -31
- package/src/utils/search.ts +14 -1
- package/src/utils/source-standard.ts +34 -0
- package/src/views/ConceptView.vue +43 -12
- package/src/views/DatasetView.vue +25 -5
- package/src/views/SearchView.vue +9 -12
package/scripts/generate-data.ts
CHANGED
|
@@ -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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
//
|
|
819
|
-
//
|
|
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(
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
);
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1074
|
+
const chunkData = {
|
|
883
1075
|
registerId: register,
|
|
884
1076
|
chunkIndex,
|
|
885
1077
|
concepts: chunk,
|
|
886
|
-
}
|
|
887
|
-
|
|
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
|
|
72
|
+
it('renders quick search on the home route', () => {
|
|
70
73
|
const wrapper = mountHeader();
|
|
71
|
-
const input = wrapper.find('input[aria-label="
|
|
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="
|
|
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
|
|
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 () => {
|