@glossarist/concept-browser 0.7.134 → 0.7.135

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/README.md CHANGED
@@ -425,6 +425,26 @@ data:
425
425
 
426
426
  Or use a Glossarist GCR package — set `gcr_package` in your dataset config and the CLI downloads it automatically.
427
427
 
428
+ ## Bulk exports
429
+
430
+ The build emits single-file aggregate distributions per dataset into `public/data/{id}/`:
431
+
432
+ | File | Format | Contents |
433
+ |------|--------|----------|
434
+ | `{id}.csv` | CSV (UTF-8 BOM + CRLF, RFC 4180) | One row per concept × language — termid, uri, status, section, language, term, alt terms, definition, notes, examples, sources, source links. Opens directly in Excel. |
435
+ | `{id}.jsonld` | SKOS JSON-LD | All concepts in one `@graph` document |
436
+ | `{id}.ttl` | Turtle | dcat:Dataset + ConceptScheme metadata |
437
+
438
+ Files from the dataset's GCR `compiled/` directory are also copied and listed. The dataset page's **Downloads** card offers everything found, unless you restrict it with `bulk_exports` in `site-config.yml`:
439
+
440
+ ```yaml
441
+ bulk_exports:
442
+ - csv
443
+ - ttl
444
+ ```
445
+
446
+ Values: `ttl`, `jsonld`, `csv`, `tbx`, `jsonl`, `yaml`. Absent or empty = offer all available.
447
+
428
448
  ## Deployment
429
449
 
430
450
  The build outputs a static site to `dist/`. Deploy it anywhere:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.134",
3
+ "version": "0.7.135",
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": {
@@ -30,6 +30,7 @@ var SNAKE_TO_CAMEL = {
30
30
  footer_nav: "footerNav",
31
31
  footer_logo: "footerLogo",
32
32
  news_dir: "newsDir",
33
+ bulk_exports: "bulkExports",
33
34
  // Dataset-level
34
35
  gcr_package: "gcrPackage",
35
36
  source_repo: "sourceRepo",
@@ -26,6 +26,7 @@ var SNAKE_TO_CAMEL = {
26
26
  footer_nav: "footerNav",
27
27
  footer_logo: "footerLogo",
28
28
  news_dir: "newsDir",
29
+ bulk_exports: "bulkExports",
29
30
  // Dataset-level
30
31
  gcr_package: "gcrPackage",
31
32
  source_repo: "sourceRepo",
@@ -838,15 +839,15 @@ function conceptJsonToTurtle(concept) {
838
839
  lines.push(" .");
839
840
  return lines.join("\n");
840
841
  }
841
- function conceptJsonToSkosJsonLd(concept) {
842
+ var SKOS_JSON_LD_CONTEXT = {
843
+ skos: "http://www.w3.org/2004/02/skos/core#",
844
+ dcterms: "http://purl.org/dc/terms/",
845
+ "@language": { "@container": "@language" }
846
+ };
847
+ function buildSkosJsonLdObject(concept) {
842
848
  const uri = concept["@id"] || "";
843
849
  const id = concept["gl:identifier"] || "";
844
850
  const doc = {
845
- "@context": {
846
- skos: "http://www.w3.org/2004/02/skos/core#",
847
- dcterms: "http://purl.org/dc/terms/",
848
- "@language": { "@container": "@language" }
849
- },
850
851
  "@id": uri,
851
852
  "@type": "skos:Concept",
852
853
  "skos:notation": id
@@ -870,7 +871,101 @@ function conceptJsonToSkosJsonLd(concept) {
870
871
  if (Object.keys(altLabels).length) doc["skos:altLabel"] = altLabels;
871
872
  if (Object.keys(definitions).length) doc["skos:definition"] = definitions;
872
873
  if (Object.keys(scopeNotes).length) doc["skos:scopeNote"] = scopeNotes;
873
- return JSON.stringify(doc);
874
+ return doc;
875
+ }
876
+ function conceptJsonToSkosJsonLd(concept) {
877
+ return JSON.stringify({
878
+ "@context": SKOS_JSON_LD_CONTEXT,
879
+ ...buildSkosJsonLdObject(concept)
880
+ });
881
+ }
882
+ function conceptsToSkosJsonLdGraph(concepts) {
883
+ return JSON.stringify(
884
+ {
885
+ "@context": SKOS_JSON_LD_CONTEXT,
886
+ "@graph": concepts.map(buildSkosJsonLdObject)
887
+ },
888
+ null,
889
+ 2
890
+ );
891
+ }
892
+ var CSV_COLUMNS = [
893
+ "termid",
894
+ "uri",
895
+ "status",
896
+ "section",
897
+ "language",
898
+ "term",
899
+ "alt_terms",
900
+ "definition",
901
+ "notes",
902
+ "examples",
903
+ "sources",
904
+ "source_links"
905
+ ];
906
+ function csvEscape(value) {
907
+ if (/[",\r\n]/.test(value)) {
908
+ return `"${value.replace(/"/g, '""')}"`;
909
+ }
910
+ return value;
911
+ }
912
+ function joinLines(items) {
913
+ return items.filter(Boolean).join("\n");
914
+ }
915
+ function localizedLanguages(localized, languageOrder) {
916
+ const present = Object.keys(localized);
917
+ if (!languageOrder?.length) return present;
918
+ const ordered = languageOrder.filter((lang) => present.includes(lang));
919
+ return ordered.concat(present.filter((lang) => !ordered.includes(lang)));
920
+ }
921
+ function sourceRefString(src) {
922
+ const ref = src?.["gl:origin"]?.["gl:ref"];
923
+ if (!ref) return "";
924
+ return [ref["gl:source"], ref["gl:id"]].filter(Boolean).join(": ");
925
+ }
926
+ function sourceLinkString(src) {
927
+ return src?.["gl:origin"]?.["gl:link"] || "";
928
+ }
929
+ function conceptsToCsv(concepts, opts = {}) {
930
+ const rows = [CSV_COLUMNS.join(",")];
931
+ for (const concept of concepts) {
932
+ const localized = concept["gl:localizedConcept"] || {};
933
+ const domains = (concept["gl:domain"] || []).map((d) => d["gl:conceptId"]).filter(Boolean).join("; ");
934
+ const managedSources = concept["gl:source"] || [];
935
+ const shared = {
936
+ termid: concept["gl:identifier"] || "",
937
+ uri: concept["@id"] || "",
938
+ status: concept["gl:status"] || "",
939
+ section: domains
940
+ };
941
+ const languages = localizedLanguages(localized, opts.languageOrder);
942
+ if (languages.length === 0) {
943
+ rows.push([shared.termid, shared.uri, shared.status, shared.section, "", "", "", "", "", "", "", ""].map(csvEscape).join(","));
944
+ continue;
945
+ }
946
+ for (const lang of languages) {
947
+ const lc = localized[lang] || {};
948
+ const designations = (lc["gl:designation"] || []).filter((d) => d["gl:term"]);
949
+ const preferred = designations.find((d) => d["gl:normativeStatus"] === "preferred") || designations[0];
950
+ const altTerms = designations.filter((d) => d !== preferred).map((d) => d["gl:term"]);
951
+ const localizedSources = lc["gl:source"]?.length ? lc["gl:source"] : managedSources;
952
+ rows.push([
953
+ shared.termid,
954
+ shared.uri,
955
+ shared.status,
956
+ shared.section,
957
+ lang,
958
+ preferred?.["gl:term"] || "",
959
+ altTerms.join("; "),
960
+ joinLines((lc["gl:definition"] || []).map((d) => d["gl:content"])),
961
+ joinLines((lc["gl:notes"] || []).map((d) => d["gl:content"])),
962
+ joinLines((lc["gl:examples"] || []).map((d) => d["gl:content"])),
963
+ localizedSources.map(sourceRefString).filter(Boolean).join("; "),
964
+ localizedSources.map(sourceLinkString).filter(Boolean).join("; ")
965
+ ].map(csvEscape).join(","));
966
+ }
967
+ }
968
+ return "\uFEFF" + rows.join("\r\n") + "\r\n";
874
969
  }
875
970
  function conceptJsonToTbx(concept) {
876
971
  const id = concept["gl:identifier"] || "";
@@ -1417,9 +1512,10 @@ async function processDataset(dir, register, opts) {
1417
1512
  for (const f of fs2.readdirSync(conceptsDir)) fs2.unlinkSync(path3.join(conceptsDir, f));
1418
1513
  }
1419
1514
  const concepts = [];
1515
+ const jsonlds = [];
1420
1516
  const langTermCounts = {};
1421
1517
  const langDefCounts = {};
1422
- const availableFormats = ["ttl", "jsonld", "yaml", "tbx"];
1518
+ const availableFormats = ["ttl", "jsonld", "yaml", "tbx", "csv"];
1423
1519
  const designationLookup = /* @__PURE__ */ new Map();
1424
1520
  for (const file of files) {
1425
1521
  try {
@@ -1521,6 +1617,7 @@ async function processDataset(dir, register, opts) {
1521
1617
  const termid = String(conceptYaml.termid);
1522
1618
  const jsonld = yamlToJsonLd(conceptYaml, register, dsRefMaps);
1523
1619
  writeJson(path3.join(conceptsDir, `${termid}.json`), jsonld);
1620
+ jsonlds.push(jsonld);
1524
1621
  const ttlContent = conceptJsonToTurtle(jsonld);
1525
1622
  fs2.writeFileSync(path3.join(conceptsDir, `${termid}.ttl`), ttlContent);
1526
1623
  const skosJsonLd = conceptJsonToSkosJsonLd(jsonld);
@@ -1543,6 +1640,8 @@ async function processDataset(dir, register, opts) {
1543
1640
  console.warn(` Skipping ${file}: ${e.message}`);
1544
1641
  }
1545
1642
  }
1643
+ fs2.writeFileSync(path3.join(DATA, register, `${register}.csv`), conceptsToCsv(jsonlds, { languageOrder: opts.languageOrder || opts.languages }));
1644
+ fs2.writeFileSync(path3.join(DATA, register, `${register}.jsonld`), conceptsToSkosJsonLdGraph(jsonlds));
1546
1645
  const CHUNK_SIZE = 500;
1547
1646
  const chunks = [];
1548
1647
  for (let i = 0; i < concepts.length; i += CHUNK_SIZE) {
@@ -1612,28 +1711,32 @@ async function processDataset(dir, register, opts) {
1612
1711
  definitions: langDefCounts[lang] || 0
1613
1712
  };
1614
1713
  }
1714
+ const BULK_FORMAT_BY_EXT = {
1715
+ ".ttl": "turtle",
1716
+ ".jsonld": "jsonld",
1717
+ ".xml": "tbx",
1718
+ ".jsonl": "jsonl",
1719
+ ".yaml": "yaml",
1720
+ ".csv": "csv"
1721
+ };
1722
+ const bulkFormats = [];
1723
+ const addBulkFormat = (file) => {
1724
+ const dest = path3.join(DATA, register, file);
1725
+ if (!fs2.existsSync(dest) || bulkFormats.some((f) => f.file === file)) return;
1726
+ const formatName = BULK_FORMAT_BY_EXT[path3.extname(file)] || path3.extname(file).slice(1);
1727
+ bulkFormats.push({ file, format: formatName, size: fs2.statSync(dest).size });
1728
+ };
1729
+ for (const file of [`${register}.csv`, `${register}.jsonld`]) addBulkFormat(file);
1615
1730
  const sourceRoot = path3.dirname(dir);
1616
1731
  const compiledDir = path3.join(sourceRoot, "compiled");
1617
- const bulkFormats = [];
1618
1732
  if (fs2.existsSync(compiledDir)) {
1733
+ let copied = 0;
1619
1734
  for (const file of fs2.readdirSync(compiledDir)) {
1620
- const src = path3.join(compiledDir, file);
1621
- const dest = path3.join(DATA, register, file);
1622
- fs2.copyFileSync(src, dest);
1623
- const ext = path3.extname(file);
1624
- const formatMap = {
1625
- ".ttl": "turtle",
1626
- ".jsonld": "jsonld",
1627
- ".xml": "tbx",
1628
- ".jsonl": "jsonl",
1629
- ".yaml": "yaml"
1630
- };
1631
- const formatName = formatMap[ext] || ext.slice(1);
1632
- bulkFormats.push({ file, format: formatName, size: fs2.statSync(src).size });
1633
- }
1634
- if (bulkFormats.length) {
1635
- console.log(` Copied ${bulkFormats.length} bulk format files`);
1735
+ fs2.copyFileSync(path3.join(compiledDir, file), path3.join(DATA, register, file));
1736
+ addBulkFormat(file);
1737
+ copied += 1;
1636
1738
  }
1739
+ if (copied) console.log(` Copied ${copied} bulk format files from compiled/`);
1637
1740
  }
1638
1741
  const sourceStats = Array.from(stats.sourceMap.values()).map((s) => ({
1639
1742
  ref: s.ref,
@@ -1675,7 +1778,6 @@ async function processDataset(dir, register, opts) {
1675
1778
  year: opts.year ?? void 0,
1676
1779
  languageStats: langStats,
1677
1780
  availableFormats,
1678
- bulkFormats,
1679
1781
  hasBibliography: opts.hasBibliography,
1680
1782
  hasImages: opts.hasImages
1681
1783
  };
@@ -1685,8 +1787,10 @@ async function processDataset(dir, register, opts) {
1685
1787
  if (opts.status) manifest.editionStatus = opts.status;
1686
1788
  if (opts.ordering) manifest.ordering = opts.ordering;
1687
1789
  if (opts.sections && opts.sections.length > 0) manifest.sections = opts.sections;
1688
- writeJson(path3.join(DATA, register, "manifest.json"), manifest);
1689
1790
  await writeDatasetRdf(register, manifest, concepts, refMaps, opts);
1791
+ addBulkFormat(`${register}.ttl`);
1792
+ manifest.bulkFormats = opts.bulkExports?.length ? bulkFormats.filter((f) => opts.bulkExports.includes(f.format)) : bulkFormats;
1793
+ writeJson(path3.join(DATA, register, "manifest.json"), manifest);
1690
1794
  const bibPath = path3.join(sourceRoot, "bibliography.yaml");
1691
1795
  if (fs2.existsSync(bibPath)) {
1692
1796
  const rawBib = readYaml(bibPath);
@@ -1736,6 +1840,7 @@ for (const ds of config.datasets) {
1736
1840
  }
1737
1841
  }
1738
1842
  var refMaps = buildRefMaps(config, registerCache);
1843
+ var bulkExports = (config.bulkExports || []).map((f) => f === "ttl" ? "turtle" : f);
1739
1844
  for (let i = 0; i < config.datasets.length; i++) {
1740
1845
  const ds = config.datasets[i];
1741
1846
  const dir = path3.join(datasetDir(ds), "concepts");
@@ -1769,7 +1874,8 @@ for (let i = 0; i < config.datasets.length; i++) {
1769
1874
  ordering: reg?.ordering || null,
1770
1875
  sections: reg?.sections ? reg.sections.map((s) => s.toJSON()) : [],
1771
1876
  hasBibliography: fs2.existsSync(path3.join(datasetDir(ds), "bibliography.yaml")),
1772
- hasImages: fs2.existsSync(path3.join(datasetDir(ds), "images"))
1877
+ hasImages: fs2.existsSync(path3.join(datasetDir(ds), "images")),
1878
+ bulkExports
1773
1879
  });
1774
1880
  const resolvedOwner = firstNonEmpty(reg?.owner, ds.owner) ?? "";
1775
1881
  const resolvedTags = firstNonEmpty(reg?.tags, ds.tags) ?? [];
@@ -47,6 +47,8 @@ import {
47
47
  conceptJsonToTurtle,
48
48
  conceptJsonToSkosJsonLd,
49
49
  conceptJsonToTbx,
50
+ conceptsToCsv,
51
+ conceptsToSkosJsonLdGraph,
50
52
  } from './lib/concept-formats';
51
53
 
52
54
  // MECE partitive multiplicity: 2 independent axes (ISO 704:2022).
@@ -689,9 +691,10 @@ async function processDataset(dir, register, opts) {
689
691
  for (const f of fs.readdirSync(conceptsDir)) fs.unlinkSync(path.join(conceptsDir, f));
690
692
  }
691
693
  const concepts = [];
694
+ const jsonlds = [];
692
695
  const langTermCounts = {};
693
696
  const langDefCounts = {};
694
- const availableFormats = ['ttl', 'jsonld', 'yaml', 'tbx'];
697
+ const availableFormats = ['ttl', 'jsonld', 'yaml', 'tbx', 'csv'];
695
698
 
696
699
  // Pre-scan: build designation → concept ID lookup for same-dataset designation refs
697
700
  const designationLookup = new Map();
@@ -802,6 +805,7 @@ async function processDataset(dir, register, opts) {
802
805
  const termid = String(conceptYaml.termid);
803
806
  const jsonld = yamlToJsonLd(conceptYaml, register, dsRefMaps);
804
807
  writeJson(path.join(conceptsDir, `${termid}.json`), jsonld);
808
+ jsonlds.push(jsonld);
805
809
 
806
810
  const ttlContent = conceptJsonToTurtle(jsonld);
807
811
  fs.writeFileSync(path.join(conceptsDir, `${termid}.ttl`), ttlContent);
@@ -831,6 +835,10 @@ async function processDataset(dir, register, opts) {
831
835
  }
832
836
  }
833
837
 
838
+ // Aggregate distributions: single-file exports of the whole register
839
+ fs.writeFileSync(path.join(DATA, register, `${register}.csv`), conceptsToCsv(jsonlds, { languageOrder: opts.languageOrder || opts.languages }));
840
+ fs.writeFileSync(path.join(DATA, register, `${register}.jsonld`), conceptsToSkosJsonLdGraph(jsonlds));
841
+
834
842
  const CHUNK_SIZE = 500;
835
843
  const chunks = [];
836
844
  for (let i = 0; i < concepts.length; i += CHUNK_SIZE) {
@@ -908,29 +916,37 @@ async function processDataset(dir, register, opts) {
908
916
  };
909
917
  }
910
918
 
911
- // Copy bulk format files from compiled/ directory (full GCR)
919
+ // Aggregate bulk exports offered on the dataset page: single-file
920
+ // distributions generated by this build plus any files shipped in the
921
+ // dataset's compiled/ directory. site-config `bulk_exports` restricts
922
+ // which formats are offered; absent/empty config offers all available.
923
+ const BULK_FORMAT_BY_EXT = {
924
+ '.ttl': 'turtle',
925
+ '.jsonld': 'jsonld',
926
+ '.xml': 'tbx',
927
+ '.jsonl': 'jsonl',
928
+ '.yaml': 'yaml',
929
+ '.csv': 'csv',
930
+ };
931
+ const bulkFormats = [];
932
+ const addBulkFormat = file => {
933
+ const dest = path.join(DATA, register, file);
934
+ if (!fs.existsSync(dest) || bulkFormats.some(f => f.file === file)) return;
935
+ const formatName = BULK_FORMAT_BY_EXT[path.extname(file)] || path.extname(file).slice(1);
936
+ bulkFormats.push({ file, format: formatName, size: fs.statSync(dest).size });
937
+ };
938
+ for (const file of [`${register}.csv`, `${register}.jsonld`]) addBulkFormat(file);
939
+
912
940
  const sourceRoot = path.dirname(dir);
913
941
  const compiledDir = path.join(sourceRoot, 'compiled');
914
- const bulkFormats = [];
915
942
  if (fs.existsSync(compiledDir)) {
943
+ let copied = 0;
916
944
  for (const file of fs.readdirSync(compiledDir)) {
917
- const src = path.join(compiledDir, file);
918
- const dest = path.join(DATA, register, file);
919
- fs.copyFileSync(src, dest);
920
- const ext = path.extname(file);
921
- const formatMap = {
922
- '.ttl': 'turtle',
923
- '.jsonld': 'jsonld',
924
- '.xml': 'tbx',
925
- '.jsonl': 'jsonl',
926
- '.yaml': 'yaml',
927
- };
928
- const formatName = formatMap[ext] || ext.slice(1);
929
- bulkFormats.push({ file, format: formatName, size: fs.statSync(src).size });
930
- }
931
- if (bulkFormats.length) {
932
- console.log(` Copied ${bulkFormats.length} bulk format files`);
945
+ fs.copyFileSync(path.join(compiledDir, file), path.join(DATA, register, file));
946
+ addBulkFormat(file);
947
+ copied += 1;
933
948
  }
949
+ if (copied) console.log(` Copied ${copied} bulk format files from compiled/`);
934
950
  }
935
951
 
936
952
  const sourceStats = Array.from(stats.sourceMap.values())
@@ -977,7 +993,6 @@ async function processDataset(dir, register, opts) {
977
993
  year: opts.year ?? undefined,
978
994
  languageStats: langStats,
979
995
  availableFormats,
980
- bulkFormats,
981
996
  hasBibliography: opts.hasBibliography,
982
997
  hasImages: opts.hasImages,
983
998
  };
@@ -987,10 +1002,16 @@ async function processDataset(dir, register, opts) {
987
1002
  if (opts.status) manifest.editionStatus = opts.status;
988
1003
  if (opts.ordering) manifest.ordering = opts.ordering;
989
1004
  if (opts.sections && opts.sections.length > 0) manifest.sections = opts.sections;
990
- writeJson(path.join(DATA, register, 'manifest.json'), manifest);
991
1005
 
992
1006
  // Dataset-level RDF (WS J2/J5): dcat:Dataset + skos:ConceptScheme + skos:Collection per section.
1007
+ // Runs before the manifest write so the ttl distribution is on disk when
1008
+ // bulkFormats is assembled.
993
1009
  await writeDatasetRdf(register, manifest, concepts, refMaps, opts);
1010
+ addBulkFormat(`${register}.ttl`);
1011
+ manifest.bulkFormats = opts.bulkExports?.length
1012
+ ? bulkFormats.filter(f => opts.bulkExports.includes(f.format))
1013
+ : bulkFormats;
1014
+ writeJson(path.join(DATA, register, 'manifest.json'), manifest);
994
1015
 
995
1016
  // Copy bibliography.yaml → bibliography.json
996
1017
  // ALWAYS emit bibliography.json — empty stub `{ bibliography: [] }` when no
@@ -1058,6 +1079,11 @@ for (const ds of config.datasets) {
1058
1079
 
1059
1080
  const refMaps = buildRefMaps(config, registerCache);
1060
1081
 
1082
+ // site-config `bulk_exports`: which aggregate formats the dataset pages
1083
+ // offer for download. 'ttl' is accepted as an alias of the canonical
1084
+ // format id 'turtle'. Absent/empty → offer all available formats.
1085
+ const bulkExports = (config.bulkExports || []).map(f => (f === 'ttl' ? 'turtle' : f));
1086
+
1061
1087
  for (let i = 0; i < config.datasets.length; i++) {
1062
1088
  const ds = config.datasets[i];
1063
1089
 
@@ -1110,6 +1136,7 @@ for (let i = 0; i < config.datasets.length; i++) {
1110
1136
  sections: reg?.sections ? reg.sections.map(s => s.toJSON()) : [],
1111
1137
  hasBibliography: fs.existsSync(path.join(datasetDir(ds), 'bibliography.yaml')),
1112
1138
  hasImages: fs.existsSync(path.join(datasetDir(ds), 'images')),
1139
+ bulkExports,
1113
1140
  });
1114
1141
  const resolvedOwner = firstNonEmpty(reg?.owner, ds.owner) ?? '';
1115
1142
  const resolvedTags = firstNonEmpty(reg?.tags, ds.tags) ?? [];
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Concept format emitters — Turtle, SKOS JSON-LD, TBX.
2
+ * Concept format emitters — Turtle, SKOS JSON-LD, TBX, aggregate CSV.
3
3
  *
4
4
  * Extracted from generate-data.ts. Each function takes a JSON-LD concept
5
5
  * object and serializes it to a different interchange format.
@@ -55,16 +55,17 @@ export function conceptJsonToTurtle(concept: Record<string, any>): string {
55
55
  return lines.join('\n');
56
56
  }
57
57
 
58
- export function conceptJsonToSkosJsonLd(concept: Record<string, any>): string {
58
+ const SKOS_JSON_LD_CONTEXT = {
59
+ skos: 'http://www.w3.org/2004/02/skos/core#',
60
+ dcterms: 'http://purl.org/dc/terms/',
61
+ '@language': { '@container': '@language' },
62
+ };
63
+
64
+ function buildSkosJsonLdObject(concept: Record<string, any>): Record<string, any> {
59
65
  const uri = concept['@id'] || '';
60
66
  const id = concept['gl:identifier'] || '';
61
67
 
62
68
  const doc: Record<string, any> = {
63
- '@context': {
64
- skos: 'http://www.w3.org/2004/02/skos/core#',
65
- dcterms: 'http://purl.org/dc/terms/',
66
- '@language': { '@container': '@language' },
67
- },
68
69
  '@id': uri,
69
70
  '@type': 'skos:Concept',
70
71
  'skos:notation': id,
@@ -91,7 +92,126 @@ export function conceptJsonToSkosJsonLd(concept: Record<string, any>): string {
91
92
  if (Object.keys(definitions).length) doc['skos:definition'] = definitions;
92
93
  if (Object.keys(scopeNotes).length) doc['skos:scopeNote'] = scopeNotes;
93
94
 
94
- return JSON.stringify(doc);
95
+ return doc;
96
+ }
97
+
98
+ export function conceptJsonToSkosJsonLd(concept: Record<string, any>): string {
99
+ return JSON.stringify({
100
+ '@context': SKOS_JSON_LD_CONTEXT,
101
+ ...buildSkosJsonLdObject(concept),
102
+ });
103
+ }
104
+
105
+ export function conceptsToSkosJsonLdGraph(concepts: Record<string, any>[]): string {
106
+ return JSON.stringify(
107
+ {
108
+ '@context': SKOS_JSON_LD_CONTEXT,
109
+ '@graph': concepts.map(buildSkosJsonLdObject),
110
+ },
111
+ null,
112
+ 2,
113
+ );
114
+ }
115
+
116
+ export const CSV_COLUMNS = [
117
+ 'termid',
118
+ 'uri',
119
+ 'status',
120
+ 'section',
121
+ 'language',
122
+ 'term',
123
+ 'alt_terms',
124
+ 'definition',
125
+ 'notes',
126
+ 'examples',
127
+ 'sources',
128
+ 'source_links',
129
+ ] as const;
130
+
131
+ function csvEscape(value: string): string {
132
+ if (/[",\r\n]/.test(value)) {
133
+ return `"${value.replace(/"/g, '""')}"`;
134
+ }
135
+ return value;
136
+ }
137
+
138
+ function joinLines(items: string[]): string {
139
+ return items.filter(Boolean).join('\n');
140
+ }
141
+
142
+ function localizedLanguages(localized: Record<string, any>, languageOrder?: string[]): string[] {
143
+ const present = Object.keys(localized);
144
+ if (!languageOrder?.length) return present;
145
+ const ordered = languageOrder.filter(lang => present.includes(lang));
146
+ return ordered.concat(present.filter(lang => !ordered.includes(lang)));
147
+ }
148
+
149
+ function sourceRefString(src: Record<string, any>): string {
150
+ const ref = src?.['gl:origin']?.['gl:ref'];
151
+ if (!ref) return '';
152
+ return [ref['gl:source'], ref['gl:id']].filter(Boolean).join(': ');
153
+ }
154
+
155
+ function sourceLinkString(src: Record<string, any>): string {
156
+ return src?.['gl:origin']?.['gl:link'] || '';
157
+ }
158
+
159
+ /**
160
+ * Aggregate CSV export: one row per (concept × language). Columns follow the
161
+ * Glossarist concept-model fields. UTF-8 BOM + CRLF so Excel opens the file
162
+ * with correct encoding without an import wizard.
163
+ */
164
+ export function conceptsToCsv(
165
+ concepts: Record<string, any>[],
166
+ opts: { languageOrder?: string[] } = {},
167
+ ): string {
168
+ const rows: string[] = [CSV_COLUMNS.join(',')];
169
+
170
+ for (const concept of concepts) {
171
+ const localized = concept['gl:localizedConcept'] || {};
172
+ const domains = (concept['gl:domain'] || [])
173
+ .map((d: any) => d['gl:conceptId'])
174
+ .filter(Boolean)
175
+ .join('; ');
176
+ const managedSources = concept['gl:source'] || [];
177
+ const shared = {
178
+ termid: concept['gl:identifier'] || '',
179
+ uri: concept['@id'] || '',
180
+ status: concept['gl:status'] || '',
181
+ section: domains,
182
+ };
183
+
184
+ const languages = localizedLanguages(localized, opts.languageOrder);
185
+ if (languages.length === 0) {
186
+ rows.push([shared.termid, shared.uri, shared.status, shared.section, '', '', '', '', '', '', '', ''].map(csvEscape).join(','));
187
+ continue;
188
+ }
189
+
190
+ for (const lang of languages) {
191
+ const lc = localized[lang] || {};
192
+ const designations = (lc['gl:designation'] || []).filter((d: any) => d['gl:term']);
193
+ const preferred = designations.find((d: any) => d['gl:normativeStatus'] === 'preferred') || designations[0];
194
+ const altTerms = designations.filter((d: any) => d !== preferred).map((d: any) => d['gl:term']);
195
+ const localizedSources = lc['gl:source']?.length ? lc['gl:source'] : managedSources;
196
+
197
+ rows.push([
198
+ shared.termid,
199
+ shared.uri,
200
+ shared.status,
201
+ shared.section,
202
+ lang,
203
+ preferred?.['gl:term'] || '',
204
+ altTerms.join('; '),
205
+ joinLines((lc['gl:definition'] || []).map((d: any) => d['gl:content'])),
206
+ joinLines((lc['gl:notes'] || []).map((d: any) => d['gl:content'])),
207
+ joinLines((lc['gl:examples'] || []).map((d: any) => d['gl:content'])),
208
+ localizedSources.map(sourceRefString).filter(Boolean).join('; '),
209
+ localizedSources.map(sourceLinkString).filter(Boolean).join('; '),
210
+ ].map(csvEscape).join(','));
211
+ }
212
+ }
213
+
214
+ return '\uFEFF' + rows.join('\r\n') + '\r\n';
95
215
  }
96
216
 
97
217
  export function conceptJsonToTbx(concept: Record<string, any>): string {
@@ -22,6 +22,7 @@ var SNAKE_TO_CAMEL = {
22
22
  footer_nav: "footerNav",
23
23
  footer_logo: "footerLogo",
24
24
  news_dir: "newsDir",
25
+ bulk_exports: "bulkExports",
25
26
  // Dataset-level
26
27
  gcr_package: "gcrPackage",
27
28
  source_repo: "sourceRepo",
@@ -47,6 +47,7 @@ const SNAKE_TO_CAMEL = {
47
47
  footer_nav: 'footerNav',
48
48
  footer_logo: 'footerLogo',
49
49
  news_dir: 'newsDir',
50
+ bulk_exports: 'bulkExports',
50
51
  // Dataset-level
51
52
  gcr_package: 'gcrPackage',
52
53
  source_repo: 'sourceRepo',
@@ -28,6 +28,7 @@ var SNAKE_TO_CAMEL = {
28
28
  footer_nav: "footerNav",
29
29
  footer_logo: "footerLogo",
30
30
  news_dir: "newsDir",
31
+ bulk_exports: "bulkExports",
31
32
  // Dataset-level
32
33
  gcr_package: "gcrPackage",
33
34
  source_repo: "sourceRepo",
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { conceptsToCsv, conceptsToSkosJsonLdGraph, CSV_COLUMNS } from '../../../scripts/lib/concept-formats';
3
+
4
+ function parseCsv(csv: string): string[][] {
5
+ const body = csv.replace(/^\uFEFF/, '');
6
+ const rows: string[][] = [];
7
+ let row: string[] = [];
8
+ let field = '';
9
+ let quoted = false;
10
+ for (let i = 0; i < body.length; i++) {
11
+ const ch = body[i];
12
+ if (quoted) {
13
+ if (ch === '"') {
14
+ if (body[i + 1] === '"') { field += '"'; i++; }
15
+ else quoted = false;
16
+ } else {
17
+ field += ch;
18
+ }
19
+ } else if (ch === '"') {
20
+ quoted = true;
21
+ } else if (ch === ',') {
22
+ row.push(field); field = '';
23
+ } else if (ch === '\r' && body[i + 1] === '\n') {
24
+ row.push(field); rows.push(row); row = []; field = ''; i++;
25
+ } else {
26
+ field += ch;
27
+ }
28
+ }
29
+ return rows;
30
+ }
31
+
32
+ const baseConcept = {
33
+ '@id': 'https://example.com/vocab/concept/1-1-010',
34
+ 'gl:identifier': '1-1-010',
35
+ 'gl:status': 'valid',
36
+ 'gl:domain': [{ 'gl:conceptId': 'section-1' }],
37
+ 'gl:source': [{
38
+ 'gl:sourceType': 'authoritative',
39
+ 'gl:origin': { 'gl:ref': { 'gl:source': 'IALA Dictionary' }, 'gl:link': 'https://example.com/source' },
40
+ }],
41
+ 'gl:localizedConcept': {
42
+ eng: {
43
+ 'gl:designation': [
44
+ { 'gl:normativeStatus': 'preferred', 'gl:term': 'Buoyage and beaconage' },
45
+ { 'gl:normativeStatus': 'admitted', 'gl:term': 'Sea-marking (G.B.)' },
46
+ ],
47
+ 'gl:definition': [{ 'gl:content': 'Ensemble of beacons, buoys, seamarks and small lights.' }],
48
+ 'gl:notes': [{ 'gl:content': 'Note one.' }, { 'gl:content': 'Note two.' }],
49
+ },
50
+ fra: {
51
+ 'gl:designation': [{ 'gl:normativeStatus': 'preferred', 'gl:term': 'Balisage' }],
52
+ 'gl:definition': [{ 'gl:content': 'Ensemble de balises.' }],
53
+ },
54
+ },
55
+ };
56
+
57
+ describe('conceptsToCsv', () => {
58
+ it('emits UTF-8 BOM, CRLF line endings, and the model-driven header', () => {
59
+ const csv = conceptsToCsv([baseConcept]);
60
+ expect(csv.startsWith('\uFEFF')).toBe(true);
61
+ expect(csv.endsWith('\r\n')).toBe(true);
62
+ expect(csv).toContain('\r\n');
63
+ const rows = parseCsv(csv);
64
+ expect(rows[0]).toEqual([...CSV_COLUMNS]);
65
+ });
66
+
67
+ it('emits one row per concept and language', () => {
68
+ const rows = parseCsv(conceptsToCsv([baseConcept]));
69
+ expect(rows).toHaveLength(3);
70
+ expect(rows[1][CSV_COLUMNS.indexOf('termid')]).toBe('1-1-010');
71
+ expect(rows[1][CSV_COLUMNS.indexOf('language')]).toBe('eng');
72
+ expect(rows[2][CSV_COLUMNS.indexOf('language')]).toBe('fra');
73
+ });
74
+
75
+ it('honors languageOrder from the deployment config', () => {
76
+ const rows = parseCsv(conceptsToCsv([baseConcept], { languageOrder: ['fra', 'eng'] }));
77
+ expect(rows[1][CSV_COLUMNS.indexOf('language')]).toBe('fra');
78
+ expect(rows[2][CSV_COLUMNS.indexOf('language')]).toBe('eng');
79
+ });
80
+
81
+ it('keeps languages missing from languageOrder at the end', () => {
82
+ const rows = parseCsv(conceptsToCsv([baseConcept], { languageOrder: ['deu', 'eng'] }));
83
+ expect(rows.slice(1).map(r => r[CSV_COLUMNS.indexOf('language')])).toEqual(['eng', 'fra']);
84
+ });
85
+
86
+ it('separates the preferred term from alt terms', () => {
87
+ const rows = parseCsv(conceptsToCsv([baseConcept]));
88
+ const eng = rows[1];
89
+ expect(eng[CSV_COLUMNS.indexOf('term')]).toBe('Buoyage and beaconage');
90
+ expect(eng[CSV_COLUMNS.indexOf('alt_terms')]).toBe('Sea-marking (G.B.)');
91
+ });
92
+
93
+ it('joins multi-entry notes with a newline inside a quoted cell', () => {
94
+ const rows = parseCsv(conceptsToCsv([baseConcept]));
95
+ expect(rows[1][CSV_COLUMNS.indexOf('notes')]).toBe('Note one.\nNote two.');
96
+ });
97
+
98
+ it('escapes quotes and commas per RFC 4180', () => {
99
+ const concept = JSON.parse(JSON.stringify(baseConcept));
100
+ concept['gl:localizedConcept'].eng['gl:definition'] = [{ 'gl:content': 'Says "hello", loudly' }];
101
+ const csv = conceptsToCsv([concept]);
102
+ expect(csv).toContain('"Says ""hello"", loudly"');
103
+ expect(parseCsv(csv)[1][CSV_COLUMNS.indexOf('definition')]).toBe('Says "hello", loudly');
104
+ });
105
+
106
+ it('falls back to managed sources when the localization has none', () => {
107
+ const rows = parseCsv(conceptsToCsv([baseConcept]));
108
+ for (const row of rows.slice(1)) {
109
+ expect(row[CSV_COLUMNS.indexOf('sources')]).toBe('IALA Dictionary');
110
+ expect(row[CSV_COLUMNS.indexOf('source_links')]).toBe('https://example.com/source');
111
+ }
112
+ });
113
+
114
+ it('still emits a row for concepts with no localizations', () => {
115
+ const bare = {
116
+ '@id': 'https://example.com/vocab/concept/9-9-999',
117
+ 'gl:identifier': '9-9-999',
118
+ 'gl:status': 'retired',
119
+ };
120
+ const rows = parseCsv(conceptsToCsv([bare]));
121
+ expect(rows).toHaveLength(2);
122
+ expect(rows[1][CSV_COLUMNS.indexOf('termid')]).toBe('9-9-999');
123
+ expect(rows[1][CSV_COLUMNS.indexOf('language')]).toBe('');
124
+ expect(rows[1][CSV_COLUMNS.indexOf('term')]).toBe('');
125
+ });
126
+
127
+ it('carries section domains and status columns', () => {
128
+ const rows = parseCsv(conceptsToCsv([baseConcept]));
129
+ expect(rows[1][CSV_COLUMNS.indexOf('section')]).toBe('section-1');
130
+ expect(rows[1][CSV_COLUMNS.indexOf('status')]).toBe('valid');
131
+ });
132
+ });
133
+
134
+ describe('conceptsToSkosJsonLdGraph', () => {
135
+ it('wraps concepts in a shared @context and @graph', () => {
136
+ const parsed = JSON.parse(conceptsToSkosJsonLdGraph([baseConcept]));
137
+ expect(parsed['@context']['skos']).toBe('http://www.w3.org/2004/02/skos/core#');
138
+ expect(parsed['@graph']).toHaveLength(1);
139
+ expect(parsed['@graph'][0]['@id']).toBe(baseConcept['@id']);
140
+ expect(parsed['@graph'][0]['skos:prefLabel']['eng']).toBe('Buoyage and beaconage');
141
+ });
142
+ });
@@ -40,3 +40,4 @@ registerFormat({ id: 'jsonld', extension: 'jsonld', mediaType: 'application/ld+
40
40
  registerFormat({ id: 'yaml', extension: 'yaml', mediaType: 'application/yaml', label: 'YAML', available: 'per-concept', serialize: 'build' });
41
41
  registerFormat({ id: 'tbx', extension: 'tbx.xml', mediaType: 'application/x-tbx', label: 'TBX', available: 'aggregate', serialize: 'build' });
42
42
  registerFormat({ id: 'jsonl', extension: 'jsonl', mediaType: 'application/jsonl+json', label: 'JSON-Lines', available: 'aggregate', serialize: 'build' });
43
+ registerFormat({ id: 'csv', extension: 'csv', mediaType: 'text/csv', label: 'CSV', available: 'aggregate', serialize: 'build' });
@@ -186,6 +186,7 @@ export const FORMAT_LABELS: Record<string, string> = {
186
186
  tbx: 'TBX-XML',
187
187
  jsonl: 'JSONL',
188
188
  yaml: 'YAML',
189
+ csv: 'CSV (Excel)',
189
190
  };
190
191
 
191
192
  // === Pages ===