@glossarist/concept-browser 0.7.140 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.140",
3
+ "version": "0.7.141",
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": {
@@ -3,28 +3,6 @@ function buildConceptUri(uriBase, registerId, conceptId) {
3
3
  return `${uriBase}/dataset/${registerId}/concept/${conceptId}`;
4
4
  }
5
5
 
6
- // scripts/extract-source-refs.js
7
- function extractSourceRefs(concept, registerId) {
8
- const refs = /* @__PURE__ */ new Set();
9
- for (const src of concept["gl:source"] || []) {
10
- const origin = src["gl:origin"];
11
- if (origin) {
12
- const ref = origin["gl:ref"];
13
- if (ref?.["gl:source"]) refs.add(ref["gl:source"]);
14
- }
15
- }
16
- for (const lc of Object.values(concept["gl:localizedConcept"] || {})) {
17
- for (const src of lc["gl:source"] || []) {
18
- const origin = src["gl:origin"];
19
- if (origin) {
20
- const ref = origin["gl:ref"];
21
- if (ref?.["gl:source"]) refs.add(ref["gl:source"]);
22
- }
23
- }
24
- }
25
- return [...refs].map((source) => ({ source, registerId }));
26
- }
27
-
28
6
  // scripts/build-edges.ts
29
7
  import { readFileSync, writeFileSync, readdirSync, existsSync, realpathSync } from "fs";
30
8
  import { join, dirname } from "path";
@@ -285,7 +263,7 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
285
263
  warnBinaryPartitiveRedundancy(edges, relations, registerId);
286
264
  allEdges.push(...edges);
287
265
  allPartitiveRelations.push(...relations);
288
- allSourceRefs.push(...extractSourceRefs(data, registerId));
266
+ allSourceRefs.push(...(void 0)(data, registerId));
289
267
  for (const edge of edges) {
290
268
  if (edge.type === "domain" || edge.type === "section") {
291
269
  domainConceptCount.set(edge.target, (domainConceptCount.get(edge.target) || 0) + 1);
@@ -1,6 +1,8 @@
1
1
  // scripts/generate-data.ts
2
2
  import fs2 from "fs";
3
+ import os from "os";
3
4
  import path3 from "path";
5
+ import { Worker as WorkerThread } from "node:worker_threads";
4
6
  import { createHash as createHash2 } from "node:crypto";
5
7
  import yaml4 from "js-yaml";
6
8
  import {
@@ -1252,18 +1254,104 @@ function getPrimaryDesignation(conceptYaml) {
1252
1254
  }
1253
1255
  return descs;
1254
1256
  }
1257
+ async function inlineEmission(job, register, uriBase) {
1258
+ try {
1259
+ const outputs = await emitOutputSet([job.conceptInstance], {
1260
+ registerId: register,
1261
+ uriBase,
1262
+ formats: ["jsonld"]
1263
+ });
1264
+ return {
1265
+ termid: job.termid,
1266
+ ttl: conceptToTurtle(job.conceptInstance, { uriBase, registerId: register }),
1267
+ jsonld: outputs.jsonld,
1268
+ tbx: conceptsToTbx([job.conceptInstance], { registerId: register }) || null
1269
+ };
1270
+ } catch (e) {
1271
+ return { termid: job.termid, error: String(e && e.message || e) };
1272
+ }
1273
+ }
1274
+ function createEmissionPool(register, uriBase) {
1275
+ const configured = Number(process.env.GCB_WORKERS);
1276
+ if (Number.isFinite(configured) && configured <= 0) return null;
1277
+ const size = Number.isFinite(configured) ? configured : Math.max(1, Math.min(4, (os.availableParallelism ? os.availableParallelism() : os.cpus().length) - 1));
1278
+ const workerPath = path3.join(__dirname3, "lib", "workers", "emit-concept-worker.mjs");
1279
+ let pool;
1280
+ try {
1281
+ pool = Array.from({ length: size }, () => new WorkerThread(workerPath, { workerData: { registerId: register, uriBase } }));
1282
+ } catch {
1283
+ return null;
1284
+ }
1285
+ const active = /* @__PURE__ */ new Map();
1286
+ const pending = [];
1287
+ const idle = /* @__PURE__ */ new Set();
1288
+ function dispatch() {
1289
+ while (pending.length > 0) {
1290
+ const worker = idle.values().next();
1291
+ if (worker.done) return;
1292
+ idle.delete(worker.value);
1293
+ const entry = pending.shift();
1294
+ active.set(worker.value, entry);
1295
+ worker.value.postMessage({ termid: entry.job.termid, sourceText: entry.job.sourceText });
1296
+ }
1297
+ }
1298
+ for (const worker of pool) {
1299
+ idle.add(worker);
1300
+ worker.on("message", (msg) => {
1301
+ const entry = active.get(worker);
1302
+ active.delete(worker);
1303
+ idle.add(worker);
1304
+ entry?.resolve(msg);
1305
+ dispatch();
1306
+ });
1307
+ const fail = (err) => {
1308
+ const entry = active.get(worker);
1309
+ active.delete(worker);
1310
+ idle.delete(worker);
1311
+ pool = pool.filter((w) => w !== worker);
1312
+ entry?.reject(err);
1313
+ dispatch();
1314
+ };
1315
+ worker.on("error", fail);
1316
+ worker.on("exit", () => fail(new Error("emission worker exited")));
1317
+ }
1318
+ return {
1319
+ emit(job) {
1320
+ return new Promise((resolve2, reject) => {
1321
+ pending.push({ job, resolve: resolve2, reject });
1322
+ dispatch();
1323
+ });
1324
+ },
1325
+ async close() {
1326
+ await Promise.allSettled(pool.map((w) => w.terminate()));
1327
+ }
1328
+ };
1329
+ }
1255
1330
  async function processDataset(dir, register, opts) {
1256
1331
  const files = fs2.readdirSync(dir).filter((f) => f.endsWith(".yaml")).sort((a, b) => naturalSort(a.replace(".yaml", ""), b.replace(".yaml", "")));
1257
1332
  console.log(`Processing ${register}: ${files.length} files`);
1258
1333
  const conceptsDir = path3.join(DATA, register, "concepts");
1259
- if (fs2.existsSync(conceptsDir)) {
1260
- for (const f of fs2.readdirSync(conceptsDir)) fs2.unlinkSync(path3.join(conceptsDir, f));
1334
+ fs2.mkdirSync(conceptsDir, { recursive: true });
1335
+ const EMISSION_CACHE_VERSION = 1;
1336
+ const EMISSION_EXTENSIONS = ["json", "ttl", "jsonld", "tbx", "yaml"];
1337
+ const cacheDir = path3.join(ROOT, ".gcb-cache");
1338
+ const cachePath = path3.join(cacheDir, `${register}.json`);
1339
+ const emissionCache = {
1340
+ version: EMISSION_CACHE_VERSION,
1341
+ files: {}
1342
+ };
1343
+ try {
1344
+ const loaded = JSON.parse(fs2.readFileSync(cachePath, "utf8"));
1345
+ if (loaded?.version === EMISSION_CACHE_VERSION && loaded.files) emissionCache.files = loaded.files;
1346
+ } catch {
1261
1347
  }
1262
1348
  const concepts = [];
1263
1349
  const conceptInstances = [];
1264
1350
  const langTermCounts = {};
1265
1351
  const langDefCounts = {};
1266
1352
  const availableFormats = ["ttl", "jsonld", "yaml", "tbx", "csv"];
1353
+ const producedOutputs = /* @__PURE__ */ new Set();
1354
+ const emissionCacheFiles = {};
1267
1355
  const designationLookup = /* @__PURE__ */ new Map();
1268
1356
  for (const file of files) {
1269
1357
  try {
@@ -1376,38 +1464,44 @@ async function processDataset(dir, register, opts) {
1376
1464
  }
1377
1465
  }
1378
1466
  ];
1467
+ const loopStart = Date.now();
1468
+ const emissionJobs = [];
1379
1469
  for (let i = 0; i < files.length; i++) {
1380
1470
  const file = files[i];
1471
+ if (i > 0 && i % 250 === 0) console.log(` ${i}/${files.length} concepts`);
1381
1472
  try {
1382
- const conceptYaml = loadConceptFile(path3.join(dir, file));
1473
+ const filePath = path3.join(dir, file);
1474
+ const sourceText = fs2.readFileSync(filePath, "utf8");
1475
+ const sourceHash = createHash2("sha256").update(sourceText).digest("hex");
1476
+ const conceptYaml = loadConceptFile(filePath);
1383
1477
  if (!conceptYaml || !conceptYaml.termid) continue;
1384
1478
  const termid = String(conceptYaml.termid);
1385
- const jsonld = yamlToJsonLd(conceptYaml, register, dsRefMaps);
1386
- writeJson(path3.join(conceptsDir, `${termid}.json`), jsonld);
1479
+ const cached = emissionCache.files[file];
1480
+ const unchanged = cached?.hash === sourceHash && EMISSION_EXTENSIONS.every((ext) => fs2.existsSync(path3.join(conceptsDir, `${termid}.${ext}`)));
1387
1481
  let conceptInstance = null;
1388
1482
  try {
1389
- conceptInstance = parseConceptYaml(fs2.readFileSync(path3.join(dir, file), "utf8"), termid);
1483
+ conceptInstance = parseConceptYaml(sourceText, termid);
1390
1484
  } catch (e) {
1391
1485
  console.warn(` ${termid}: canonical parse failed (${e.message}); skipping glossarist exports`);
1392
1486
  }
1393
- if (conceptInstance) {
1394
- conceptInstances.push(conceptInstance);
1395
- fs2.writeFileSync(
1396
- path3.join(conceptsDir, `${termid}.ttl`),
1397
- conceptToTurtle(conceptInstance, { uriBase: refMaps.uriBase, registerId: register })
1398
- );
1399
- const conceptJsonLd = await emitOutputSet([conceptInstance], {
1400
- registerId: register,
1401
- uriBase: refMaps.uriBase,
1402
- formats: ["jsonld"]
1403
- });
1404
- fs2.writeFileSync(path3.join(conceptsDir, `${termid}.jsonld`), conceptJsonLd.jsonld);
1405
- const tbxContent = conceptsToTbx([conceptInstance], { registerId: register });
1406
- if (tbxContent) {
1407
- fs2.writeFileSync(path3.join(conceptsDir, `${termid}.tbx`), tbxContent);
1487
+ if (conceptInstance) conceptInstances.push(conceptInstance);
1488
+ if (!unchanged) {
1489
+ const jsonld = yamlToJsonLd(conceptYaml, register, dsRefMaps);
1490
+ writeJson(path3.join(conceptsDir, `${termid}.json`), jsonld);
1491
+ fs2.copyFileSync(filePath, path3.join(conceptsDir, `${termid}.yaml`));
1492
+ if (conceptInstance) {
1493
+ emissionJobs.push({ file, termid, sourceText, sourceHash, conceptInstance });
1408
1494
  }
1495
+ } else {
1496
+ emissionCacheFiles[file] = { hash: sourceHash, termid };
1497
+ }
1498
+ producedOutputs.add(`${termid}.json`);
1499
+ producedOutputs.add(`${termid}.yaml`);
1500
+ if (conceptInstance) {
1501
+ producedOutputs.add(`${termid}.ttl`);
1502
+ producedOutputs.add(`${termid}.jsonld`);
1503
+ producedOutputs.add(`${termid}.tbx`);
1409
1504
  }
1410
- fs2.copyFileSync(path3.join(dir, file), path3.join(conceptsDir, `${termid}.yaml`));
1411
1505
  concepts.push({
1412
1506
  id: termid,
1413
1507
  designations: getPrimaryDesignation(conceptYaml),
@@ -1421,12 +1515,49 @@ async function processDataset(dir, register, opts) {
1421
1515
  console.warn(` Skipping ${file}: ${e.message}`);
1422
1516
  }
1423
1517
  }
1518
+ if (emissionJobs.length > 0) {
1519
+ const jobsByTermid = new Map(emissionJobs.map((j) => [j.termid, j]));
1520
+ const pool = createEmissionPool(register, refMaps.uriBase);
1521
+ const emissionStart = Date.now();
1522
+ try {
1523
+ const results = await Promise.all(emissionJobs.map((job) => {
1524
+ if (!pool) return inlineEmission(job, register, refMaps.uriBase);
1525
+ return pool.emit(job).catch(() => inlineEmission(job, register, refMaps.uriBase));
1526
+ }));
1527
+ for (const result of results) {
1528
+ if (result.error) {
1529
+ console.warn(` ${result.termid}: canonical parse failed (${result.error}); skipping glossarist exports`);
1530
+ const job2 = jobsByTermid.get(result.termid);
1531
+ if (job2) delete emissionCacheFiles[job2.file];
1532
+ for (const ext of ["ttl", "jsonld", "tbx"]) producedOutputs.delete(`${result.termid}.${ext}`);
1533
+ continue;
1534
+ }
1535
+ fs2.writeFileSync(path3.join(conceptsDir, `${result.termid}.ttl`), result.ttl);
1536
+ fs2.writeFileSync(path3.join(conceptsDir, `${result.termid}.jsonld`), result.jsonld);
1537
+ if (result.tbx) fs2.writeFileSync(path3.join(conceptsDir, `${result.termid}.tbx`), result.tbx);
1538
+ const job = jobsByTermid.get(result.termid);
1539
+ if (job) emissionCacheFiles[job.file] = { hash: job.sourceHash, termid: job.termid };
1540
+ }
1541
+ console.log(` Interchange exports: ${((Date.now() - emissionStart) / 1e3).toFixed(1)}s (${emissionJobs.length} concepts, ${pool ? "parallel" : "inline"})`);
1542
+ } finally {
1543
+ if (pool) await pool.close();
1544
+ }
1545
+ }
1546
+ for (const entry of fs2.readdirSync(conceptsDir)) {
1547
+ if (!producedOutputs.has(entry)) fs2.unlinkSync(path3.join(conceptsDir, entry));
1548
+ }
1549
+ fs2.mkdirSync(cacheDir, { recursive: true });
1550
+ fs2.writeFileSync(cachePath, JSON.stringify({ version: EMISSION_CACHE_VERSION, files: emissionCacheFiles }));
1551
+ console.log(` Per-concept emissions: ${((Date.now() - loopStart) / 1e3).toFixed(1)}s`);
1552
+ const aggregateStart = Date.now();
1424
1553
  const aggregate = await emitOutputSet(conceptInstances, {
1425
1554
  registerId: register,
1426
1555
  uriBase: refMaps.uriBase,
1556
+ formats: ["csv", "jsonld"],
1427
1557
  languageOrder: opts.languageOrder || opts.languages,
1428
1558
  conceptUriPrefix: buildConceptUriPrefix(refMaps.siteOrigin, register)
1429
1559
  });
1560
+ console.log(` Aggregate csv+jsonld: ${((Date.now() - aggregateStart) / 1e3).toFixed(1)}s`);
1430
1561
  fs2.writeFileSync(path3.join(DATA, register, `${register}.csv`), aggregate.csv);
1431
1562
  fs2.writeFileSync(path3.join(DATA, register, `${register}.jsonld`), aggregate.jsonld);
1432
1563
  const CHUNK_SIZE = 500;
@@ -1580,7 +1711,9 @@ async function processDataset(dir, register, opts) {
1580
1711
  if (opts.status) manifest.editionStatus = opts.status;
1581
1712
  if (opts.ordering) manifest.ordering = opts.ordering;
1582
1713
  if (opts.sections && opts.sections.length > 0) manifest.sections = opts.sections;
1714
+ const rdfStart = Date.now();
1583
1715
  await writeDatasetRdf(register, manifest, concepts, refMaps, opts);
1716
+ console.log(` Dataset RDF: ${((Date.now() - rdfStart) / 1e3).toFixed(1)}s`);
1584
1717
  addBulkFormat(`${register}.ttl`);
1585
1718
  manifest.bulkFormats = opts.bulkExports?.length ? bulkFormats.filter((f) => opts.bulkExports.includes(f.format)) : bulkFormats;
1586
1719
  writeJson(path3.join(DATA, register, "manifest.json"), manifest);
@@ -1,5 +1,7 @@
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';
3
5
  import { createHash } from 'node:crypto';
4
6
  import yaml from 'js-yaml';
5
7
  import {
@@ -690,21 +692,129 @@ function getPrimaryDesignation(conceptYaml) {
690
692
  return descs;
691
693
  }
692
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
+
693
785
  async function processDataset(dir, register, opts) {
694
786
  const files = fs.readdirSync(dir).filter(f => f.endsWith('.yaml')).sort((a, b) => naturalSort(a.replace('.yaml', ''), b.replace('.yaml', '')));
695
787
 
696
788
  console.log(`Processing ${register}: ${files.length} files`);
697
789
 
698
790
  const conceptsDir = path.join(DATA, register, 'concepts');
699
- // Clean previous output to avoid stale files accumulating across runs
700
- if (fs.existsSync(conceptsDir)) {
701
- for (const f of fs.readdirSync(conceptsDir)) fs.unlinkSync(path.join(conceptsDir, f));
702
- }
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
+
703
811
  const concepts = [];
704
812
  const conceptInstances: Concept[] = [];
705
813
  const langTermCounts = {};
706
814
  const langDefCounts = {};
707
815
  const availableFormats = ['ttl', 'jsonld', 'yaml', 'tbx', 'csv'];
816
+ const producedOutputs = new Set<string>();
817
+ const emissionCacheFiles: Record<string, { hash: string; termid: string }> = {};
708
818
 
709
819
  // Pre-scan: build designation → concept ID lookup for same-dataset designation refs
710
820
  const designationLookup = new Map();
@@ -830,46 +940,54 @@ async function processDataset(dir, register, opts) {
830
940
  },
831
941
  ];
832
942
 
943
+ const loopStart = Date.now();
944
+ const emissionJobs: EmissionJob[] = [];
945
+
833
946
  for (let i = 0; i < files.length; i++) {
834
947
  const file = files[i];
948
+ if (i > 0 && i % 250 === 0) console.log(` ${i}/${files.length} concepts`);
835
949
  try {
836
- 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);
837
955
  if (!conceptYaml || !conceptYaml.termid) continue;
838
956
 
839
957
  const termid = String(conceptYaml.termid);
840
- const jsonld = yamlToJsonLd(conceptYaml, register, dsRefMaps);
841
- writeJson(path.join(conceptsDir, `${termid}.json`), jsonld);
842
- // Canonical interchange exports — routed through glossarist's
843
- // output API (one model walk; predicates generated from the
844
- // concept-model context, wire shape from schemas/v3). The hand-rolled
845
- // gl:-shaped emitters in scripts/lib/concept-formats.ts are retired.
846
- // Note: TBX now includes <term> entries — the retired emitter
847
- // 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.
848
965
  let conceptInstance: Concept | null = null;
849
966
  try {
850
- conceptInstance = parseConceptYaml(fs.readFileSync(path.join(dir, file), 'utf8'), termid);
967
+ conceptInstance = parseConceptYaml(sourceText, termid);
851
968
  } catch (e) {
852
969
  console.warn(` ${termid}: canonical parse failed (${e.message}); skipping glossarist exports`);
853
970
  }
854
- if (conceptInstance) {
855
- conceptInstances.push(conceptInstance);
856
- fs.writeFileSync(
857
- path.join(conceptsDir, `${termid}.ttl`),
858
- conceptToTurtle(conceptInstance, { uriBase: refMaps.uriBase, registerId: register }),
859
- );
860
- const conceptJsonLd = await emitOutputSet([conceptInstance], {
861
- registerId: register,
862
- uriBase: refMaps.uriBase,
863
- formats: ['jsonld'],
864
- });
865
- fs.writeFileSync(path.join(conceptsDir, `${termid}.jsonld`), conceptJsonLd.jsonld!);
866
- const tbxContent = conceptsToTbx([conceptInstance], { registerId: register });
867
- if (tbxContent) {
868
- 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 });
869
979
  }
980
+ } else {
981
+ emissionCacheFiles[file] = { hash: sourceHash, termid };
870
982
  }
871
983
 
872
- 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
+ }
873
991
 
874
992
  concepts.push({
875
993
  id: termid,
@@ -886,17 +1004,64 @@ async function processDataset(dir, register, opts) {
886
1004
  }
887
1005
  }
888
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
+
889
1050
  // Aggregate distributions: single-file exports of the whole register,
890
1051
  // emitted by glossarist's output-set API from the canonical model walk.
891
1052
  // The CSV uri column is a link a human opens — it carries the SPA route
892
1053
  // shape on the deployment's configured host (siteOrigin), not the
893
- // 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();
894
1057
  const aggregate = await emitOutputSet(conceptInstances, {
895
1058
  registerId: register,
896
1059
  uriBase: refMaps.uriBase,
1060
+ formats: ['csv', 'jsonld'],
897
1061
  languageOrder: opts.languageOrder || opts.languages,
898
1062
  conceptUriPrefix: buildConceptUriPrefix(refMaps.siteOrigin, register),
899
1063
  });
1064
+ console.log(` Aggregate csv+jsonld: ${((Date.now() - aggregateStart) / 1000).toFixed(1)}s`);
900
1065
  fs.writeFileSync(path.join(DATA, register, `${register}.csv`), aggregate.csv!);
901
1066
  fs.writeFileSync(path.join(DATA, register, `${register}.jsonld`), aggregate.jsonld!);
902
1067
 
@@ -1074,7 +1239,9 @@ async function processDataset(dir, register, opts) {
1074
1239
  // Dataset-level RDF (WS J2/J5): dcat:Dataset + skos:ConceptScheme + skos:Collection per section.
1075
1240
  // Runs before the manifest write so the ttl distribution is on disk when
1076
1241
  // bulkFormats is assembled.
1242
+ const rdfStart = Date.now();
1077
1243
  await writeDatasetRdf(register, manifest, concepts, refMaps, opts);
1244
+ console.log(` Dataset RDF: ${((Date.now() - rdfStart) / 1000).toFixed(1)}s`);
1078
1245
  addBulkFormat(`${register}.ttl`);
1079
1246
  manifest.bulkFormats = opts.bulkExports?.length
1080
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
+ });
@@ -32,7 +32,11 @@ describe('generate-data chunk hashes', () => {
32
32
  cwd: root,
33
33
  env: { ...process.env, SITE_CONFIG: configPath },
34
34
  stdio: 'pipe',
35
- timeout: 30_000,
35
+ // Three sequential full generations run in this test; each
36
+ // subprocess pays tsx startup, worker-pool spin-up, and aggregate
37
+ // emission. The suite runs tests in parallel, so leave generous
38
+ // headroom for CPU contention.
39
+ timeout: 120_000,
36
40
  });
37
41
  const indexes = ['index.json', 'index-meta.json'].map(file =>
38
42
  JSON.parse(readFileSync(join(outputDir, file), 'utf8')),
@@ -70,5 +74,5 @@ describe('generate-data chunk hashes', () => {
70
74
  } finally {
71
75
  rmSync(root, { recursive: true, force: true });
72
76
  }
73
- }, 60_000);
77
+ }, 300_000);
74
78
  });