@glossarist/concept-browser 0.7.133 → 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.133",
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",
@@ -167,3 +167,117 @@ describe('drawGenericPipes — pipe-and-thread contract', () => {
167
167
  expect(svg.querySelectorAll('line.pipe-seg').length).toBe(0);
168
168
  });
169
169
  });
170
+
171
+ describe('drawGenericPipes — external + ellipsis ghosts (concept-model PR #91)', () => {
172
+ const pos = new Map<string, { x: number; y: number }>([
173
+ ['vim-2012/1.9', { x: 0, y: 0 }],
174
+ ['vim-2012/1.15', { x: 100, y: -50 }],
175
+ ]);
176
+
177
+ function makeGhostRelation(): GenericRelationWire {
178
+ return {
179
+ source: 'https://example.org/vim-2012/concept/1.9',
180
+ comprehensive: 'https://example.org/vim-2012/concept/1.9',
181
+ completeness: 'complete',
182
+ criterion: { eng: 'by system membership' },
183
+ register: 'vim-2012',
184
+ members: [
185
+ { uri: 'https://example.org/vim-2012/concept/1.15', presence: 'required', count: 'at_least_one',
186
+ delimitingCharacteristic: { eng: 'off-system measurement unit' } },
187
+ { uri: 'urn:gloss:ext:in-system measurement unit', presence: 'optional', count: 'any',
188
+ external: true, text: 'in-system measurement unit',
189
+ delimitingCharacteristic: { eng: 'in-system measurement unit' } },
190
+ ],
191
+ };
192
+ }
193
+
194
+ function makeEllipsisRelation(): GenericRelationWire {
195
+ return {
196
+ source: 'https://example.org/vim-2012/concept/1.9',
197
+ comprehensive: 'https://example.org/vim-2012/concept/1.9',
198
+ completeness: 'partial',
199
+ criterion: { eng: 'by epistemic status' },
200
+ register: 'vim-2012',
201
+ members: [
202
+ { uri: 'https://example.org/vim-2012/concept/1.15', presence: 'required', count: 'at_least_one',
203
+ delimitingCharacteristic: { eng: 'x' } },
204
+ { uri: 'urn:gloss:ellipsis', presence: 'required', count: 'exactly_one', ellipsis: true },
205
+ ],
206
+ };
207
+ }
208
+
209
+ it('renders an external member as a dashed pill with parenthesized text', () => {
210
+ const svg = makeSvg();
211
+ drawGenericPipes(svg, pos, {
212
+ isDark: false,
213
+ color: '#b45309',
214
+ isMuted: false,
215
+ relations: [makeGhostRelation()],
216
+ locale: 'eng',
217
+ });
218
+ const pill = svg.querySelector('rect.pipe-ghost-external');
219
+ expect(pill).toBeDefined();
220
+ expect(pill!.getAttribute('stroke-dasharray')).toBe('3 2');
221
+ const label = svg.querySelector('text.pipe-ghost-external-label');
222
+ expect(label).toBeDefined();
223
+ expect(label!.textContent).toBe('(in-system measurement unit)');
224
+ });
225
+
226
+ it('renders an ellipsis member as a muted ... ghost', () => {
227
+ const svg = makeSvg();
228
+ drawGenericPipes(svg, pos, {
229
+ isDark: false,
230
+ color: '#b45309',
231
+ isMuted: false,
232
+ relations: [makeEllipsisRelation()],
233
+ locale: 'eng',
234
+ });
235
+ const ell = svg.querySelector('text.pipe-ghost-ellipsis');
236
+ expect(ell).toBeDefined();
237
+ expect(ell!.textContent).toBe('...');
238
+ });
239
+
240
+ it('keeps a 1-resolved + 1-ghost rake alive (ghosts count as members)', () => {
241
+ const svg = makeSvg();
242
+ drawGenericPipes(svg, pos, {
243
+ isDark: false,
244
+ color: '#b45309',
245
+ isMuted: false,
246
+ relations: [makeGhostRelation()],
247
+ locale: 'eng',
248
+ });
249
+ /* 1 resolved member + 1 ghost = 2 layout members → 1 pipe + 2 threads. */
250
+ expect(svg.querySelectorAll('line.pipe-seg').length).toBe(3);
251
+ });
252
+
253
+ it('draws ghost threads dashed so they read as non-node placeholders', () => {
254
+ const svg = makeSvg();
255
+ drawGenericPipes(svg, pos, {
256
+ isDark: false,
257
+ color: '#b45309',
258
+ isMuted: false,
259
+ relations: [makeEllipsisRelation()],
260
+ locale: 'eng',
261
+ });
262
+ const dashed = Array.from(svg.querySelectorAll('line.pipe-seg'))
263
+ .filter(l => l.getAttribute('stroke-dasharray') === '3 3');
264
+ expect(dashed.length).toBe(1);
265
+ });
266
+
267
+ it('drops a rake whose members are ALL ghosts with no positioned anchor', () => {
268
+ const svg = makeSvg();
269
+ const emptyPos = new Map<string, { x: number; y: number }>([
270
+ ['vim-2012/1.9', { x: 0, y: 0 }],
271
+ ]);
272
+ drawGenericPipes(svg, emptyPos, {
273
+ isDark: false,
274
+ color: '#b45309',
275
+ isMuted: false,
276
+ relations: [makeGhostRelation()],
277
+ locale: 'eng',
278
+ });
279
+ /* 1.15 not in pos → zero positioned members → layout collapses onto
280
+ comp; ghosts have no anchor distinct from comp, so nothing draws. */
281
+ expect(svg.querySelectorAll('line.pipe-seg').length).toBe(0);
282
+ });
283
+ });
@@ -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
+ });
@@ -60,6 +60,6 @@ describe('GL wire-key SSOT', () => {
60
60
  // The point of this test: when adding a key, you remember to update
61
61
  // the count; when removing, you notice the test fails. Catches
62
62
  // accidental drift in either direction.
63
- expect(Object.keys(GL).length).toBe(91);
63
+ expect(Object.keys(GL).length).toBe(93);
64
64
  });
65
65
  });
@@ -43,6 +43,8 @@ export interface JsonLdRef {
43
43
  'gl:id'?: string;
44
44
  'gl:version'?: string;
45
45
  'gl:text'?: string;
46
+ 'gl:external'?: boolean;
47
+ 'gl:ellipsis'?: boolean;
46
48
  source?: string;
47
49
  id?: string;
48
50
  version?: string;
@@ -298,7 +298,9 @@ function mapRefFromJsonLd(rawRef: JsonLdRef | string | undefined): Record<string
298
298
  refObj.id = rawRef[GL.LOCAL_ID] ?? rawRef.id;
299
299
  refObj.version = rawRef[GL.VERSION] ?? rawRef.version;
300
300
  if (rawRef[GL.TEXT]) refObj.text = rawRef[GL.TEXT];
301
- return (refObj.source ?? refObj.id ?? refObj.version ?? refObj.text) != null
301
+ if (rawRef[GL.EXTERNAL] === true) refObj.external = true;
302
+ if (rawRef[GL.ELLIPSIS] === true) refObj.ellipsis = true;
303
+ return (refObj.source ?? refObj.id ?? refObj.version ?? refObj.text ?? refObj.external ?? refObj.ellipsis) != null
302
304
  ? refObj : null;
303
305
  }
304
306
 
@@ -204,6 +204,14 @@ export interface GenericMemberWire {
204
204
  * distinguishes this specific concept from its coordinate concepts.
205
205
  * Required on every GenericMember. LocalizedString shape. */
206
206
  delimitingCharacteristic: Record<string, string>;
207
+ /** Inline external concept (concept-model PR #91). No dataset node —
208
+ * rendered as a dashed `(text)` ghost, never resolved to a URI. */
209
+ external?: boolean;
210
+ /** Author-acknowledged gap (concept-model PR #91). Rendered as a
211
+ * muted `...` ghost. */
212
+ ellipsis?: boolean;
213
+ /** Display text for external members (the parenthetical label). */
214
+ text?: string;
207
215
  }
208
216
 
209
217
  export interface GraphNode {
@@ -108,6 +108,12 @@ export const GL = {
108
108
  VERSION: 'gl:version',
109
109
  TEXT: 'gl:text',
110
110
 
111
+ // ConceptRef external/ellipsis flags (concept-model PR #91).
112
+ // external = parenthetical (ISO 704:2022 §5.5.4.3.1); renders as `(text)`.
113
+ // ellipsis = author-acknowledged gap; renders as `...`.
114
+ EXTERNAL: 'gl:external',
115
+ ELLIPSIS: 'gl:ellipsis',
116
+
111
117
  // Locally-scoped id within source/origin/ref (NOT JSON-LD's @id)
112
118
  LOCAL_ID: 'gl:id',
113
119
 
@@ -80,7 +80,16 @@ export function drawGenericPipes(
80
80
  if (!compPos) continue;
81
81
 
82
82
  const memberPositions: PipeLayoutMember[] = [];
83
+ const ghostMembers: Array<{ member: GenericRelationWire['members'][number]; pos: NodePos }> = [];
83
84
  for (const m of rel.members) {
85
+ /* Ghost members (external / ellipsis, concept-model PR #91) carry
86
+ synthetic urn:gloss:* URIs that never resolve to a node — they
87
+ get virtual positions on an arc beyond the middle node and are
88
+ drawn as dashed ghosts after the layout. */
89
+ if (m.ellipsis === true || m.external === true) {
90
+ ghostMembers.push({ member: m, pos: { x: 0, y: 0 } });
91
+ continue;
92
+ }
84
93
  const parsed = UriRouter.parseUri(m.uri);
85
94
  if (!parsed) continue;
86
95
  const nid = `${parsed.registerId}/${parsed.conceptId}`;
@@ -89,7 +98,31 @@ export function drawGenericPipes(
89
98
  memberPositions.push({ pos: p, delimitingCharacteristic: m.delimitingCharacteristic });
90
99
  }
91
100
 
92
- const layout = computePipeLayout(compPos, memberPositions);
101
+ /* Ghosts need a middle-node estimate to fan around. With ≥2
102
+ positioned members the layout gives it directly; with exactly 1
103
+ use the comp→member midpoint (computePipeLayout requires ≥2 and
104
+ would return null). Zero positioned members means the ghosts have
105
+ no anchor — drop the rake. */
106
+ if (memberPositions.length === 0 && ghostMembers.length > 0) continue;
107
+ let provisionalMiddle: NodePos | null = null;
108
+ if (memberPositions.length >= 2) {
109
+ const l = computePipeLayout(compPos, memberPositions);
110
+ provisionalMiddle = l ? l.middleNode : null;
111
+ } else if (memberPositions.length === 1) {
112
+ provisionalMiddle = {
113
+ x: (compPos.x + memberPositions[0].pos.x) / 2,
114
+ y: (compPos.y + memberPositions[0].pos.y) / 2,
115
+ };
116
+ }
117
+ if (!provisionalMiddle) continue;
118
+ assignGhostPositions(provisionalMiddle, compPos, memberPositions, ghostMembers);
119
+
120
+ const allPositions = [
121
+ ...memberPositions,
122
+ ...ghostMembers.map(g => ({ pos: g.pos, delimitingCharacteristic: g.member.delimitingCharacteristic })),
123
+ ];
124
+
125
+ const layout = computePipeLayout(compPos, allPositions);
93
126
  if (!layout) continue;
94
127
 
95
128
  /* 1. Thick parent pipe: comp → middle node. The characteristic
@@ -98,9 +131,25 @@ export function drawGenericPipes(
98
131
 
99
132
  /* 2. Thin threads: middle → each member. No per-thread labels —
100
133
  the characteristic is a single hyperedge-level field shared
101
- by all members, rendered once on the pipe body. */
102
- for (const thread of layout.threads) {
134
+ by all members, rendered once on the pipe body.
135
+ Ghost members are excluded here (step 2b draws their dashed
136
+ threads separately) — layout.threads preserves the
137
+ allPositions order: positioned members first, ghosts last. */
138
+ layout.threads.forEach((thread, i) => {
139
+ if (i >= memberPositions.length) return;
103
140
  drawSegment(svg, opts.color, thread.start, thread.end, THREAD_WIDTH);
141
+ });
142
+
143
+ /* 2b. Ghost members — external concepts and ellipsis gaps. Dashed
144
+ threads + dashed pills so they read as "present but not a
145
+ node of this dataset" (ISO 704:2022 §5.5.4.3.1). */
146
+ for (const ghost of ghostMembers) {
147
+ drawSegment(svg, opts.color, layout.middleNode, ghost.pos, THREAD_WIDTH, { dashed: true, opacity: 0.55 });
148
+ if (ghost.member.ellipsis === true) {
149
+ drawEllipsisGhost(svg, ghost.pos, opts.color);
150
+ } else {
151
+ drawExternalGhost(svg, ghost.pos, ghost.member.text ?? '', opts.color, opts.isDark);
152
+ }
104
153
  }
105
154
 
106
155
  /* 3. Square junction at the middle node (distinct from the rake's
@@ -129,7 +178,7 @@ export function drawGenericPipes(
129
178
  }
130
179
  }
131
180
 
132
- function drawSegment(svg: SVGSVGElement, color: string, from: NodePos, to: NodePos, width: number): void {
181
+ function drawSegment(svg: SVGSVGElement, color: string, from: NodePos, to: NodePos, width: number, style?: { dashed?: boolean; opacity?: number }): void {
133
182
  const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
134
183
  line.setAttribute('class', 'pipe-seg');
135
184
  line.setAttribute('x1', String(from.x));
@@ -139,10 +188,93 @@ function drawSegment(svg: SVGSVGElement, color: string, from: NodePos, to: NodeP
139
188
  line.setAttribute('stroke', color);
140
189
  line.setAttribute('stroke-width', String(width));
141
190
  line.setAttribute('stroke-linecap', 'round');
142
- line.setAttribute('opacity', '0.9');
191
+ line.setAttribute('opacity', String(style?.opacity ?? 0.9));
192
+ if (style?.dashed) line.setAttribute('stroke-dasharray', '3 3');
143
193
  svg.appendChild(line);
144
194
  }
145
195
 
196
+ /**
197
+ * Place ghost members (external / ellipsis) on an arc beyond the middle
198
+ * node, on the side away from the comprehensive, fanned to match the
199
+ * spread of the positioned members. Pure 2D — ghosts never enter the
200
+ * 3D force layout.
201
+ */
202
+ function assignGhostPositions(
203
+ middleNode: NodePos,
204
+ compPos: NodePos,
205
+ positioned: readonly { pos: NodePos }[],
206
+ ghosts: Array<{ pos: NodePos }>,
207
+ ): void {
208
+ if (ghosts.length === 0) return;
209
+ /* Fan radius: a bit beyond the average positioned-member distance
210
+ from the middle node so ghosts sit outside the resolved set. */
211
+ const avgDist = positioned.length > 0
212
+ ? positioned.reduce((s, m) => s + Math.hypot(m.pos.x - middleNode.x, m.pos.y - middleNode.y), 0) / positioned.length
213
+ : 40;
214
+ const radius = Math.max(avgDist, 40);
215
+ /* Base direction: away from the comprehensive. */
216
+ const awayX = middleNode.x - compPos.x;
217
+ const awayY = middleNode.y - compPos.y;
218
+ const awayLen = Math.hypot(awayX, awayY) || 1;
219
+ const baseAngle = Math.atan2(awayY / awayLen, awayX / awayLen);
220
+ const spread = 0.5; /* radians between consecutive ghosts */
221
+ ghosts.forEach((g, i) => {
222
+ const offset = ghosts.length === 1 ? 0 : (i - (ghosts.length - 1) / 2) * spread;
223
+ const a = baseAngle + offset;
224
+ g.pos.x = middleNode.x + Math.cos(a) * radius;
225
+ g.pos.y = middleNode.y + Math.sin(a) * radius;
226
+ });
227
+ }
228
+
229
+ /** Ellipsis ghost: a muted "..." — the author knew more members exist. */
230
+ function drawEllipsisGhost(svg: SVGSVGElement, at: NodePos, color: string): void {
231
+ const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
232
+ text.setAttribute('class', 'pipe-ghost-ellipsis');
233
+ text.setAttribute('x', String(at.x));
234
+ text.setAttribute('y', String(at.y + 3));
235
+ text.setAttribute('text-anchor', 'middle');
236
+ text.setAttribute('fill', color);
237
+ text.setAttribute('font-size', '11');
238
+ text.setAttribute('font-family', 'JetBrains Mono, monospace');
239
+ text.setAttribute('font-weight', '600');
240
+ text.setAttribute('opacity', '0.8');
241
+ text.textContent = '...';
242
+ svg.appendChild(text);
243
+ }
244
+
245
+ /** External ghost: dashed-border pill with the parenthetical label. */
246
+ function drawExternalGhost(svg: SVGSVGElement, at: NodePos, label: string, color: string, isDark: boolean): void {
247
+ const fontSize = 9;
248
+ const text = label ? `(${label})` : '(external)';
249
+ const w = Math.max(text.length * (fontSize * 0.55) + 10, 34);
250
+ const h = 15;
251
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
252
+ rect.setAttribute('class', 'pipe-ghost-external');
253
+ rect.setAttribute('x', String(at.x - w / 2));
254
+ rect.setAttribute('y', String(at.y - h / 2));
255
+ rect.setAttribute('width', String(w));
256
+ rect.setAttribute('height', String(h));
257
+ rect.setAttribute('rx', '4');
258
+ rect.setAttribute('fill', isDark ? 'rgba(26,18,8,0.85)' : 'rgba(255,255,255,0.92)');
259
+ rect.setAttribute('stroke', color);
260
+ rect.setAttribute('stroke-width', '1');
261
+ rect.setAttribute('stroke-dasharray', '3 2');
262
+ rect.setAttribute('opacity', '0.92');
263
+ svg.appendChild(rect);
264
+
265
+ const t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
266
+ t.setAttribute('class', 'pipe-ghost-external-label');
267
+ t.setAttribute('x', String(at.x));
268
+ t.setAttribute('y', String(at.y + 3));
269
+ t.setAttribute('text-anchor', 'middle');
270
+ t.setAttribute('fill', color);
271
+ t.setAttribute('font-size', String(fontSize));
272
+ t.setAttribute('font-family', 'JetBrains Mono, monospace');
273
+ t.setAttribute('font-style', 'italic');
274
+ t.textContent = text;
275
+ svg.appendChild(t);
276
+ }
277
+
146
278
  function drawSquare(svg: SVGSVGElement, cx: number, cy: number, half: number, fill: string, stroke: string): void {
147
279
  const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
148
280
  rect.setAttribute('class', 'pipe-junction');
@@ -190,7 +190,26 @@ export function useConceptEdges(
190
190
  const isGeneric = rel instanceof GenericHyperedge;
191
191
  const members: any[] = (rel?.members ?? rel?.partitives ?? [])
192
192
  .map((m: any): any | null => {
193
- const uri = resolveConceptRefUri(m?.ref);
193
+ const ref = m?.ref;
194
+ /* Ghost members (concept-model PR #91): external parenthetical
195
+ concepts and ellipsis gaps have no dataset node, so they get a
196
+ synthetic urn: URI plus flags. Renderers draw them as ghosts
197
+ without resolving the URI. */
198
+ if (ref?.ellipsis === true) {
199
+ return { uri: 'urn:gloss:ellipsis', presence: m?.presence, count: m?.count, ellipsis: true };
200
+ }
201
+ if (ref?.external === true || ref?.isExternal === true) {
202
+ const text = ref?.text ?? '';
203
+ return {
204
+ uri: `urn:gloss:ext:${text}`,
205
+ presence: m?.presence,
206
+ count: m?.count,
207
+ external: true,
208
+ text,
209
+ ...(isGeneric ? { delimitingCharacteristic: m?.delimitingCharacteristic } : {}),
210
+ };
211
+ }
212
+ const uri = resolveConceptRefUri(ref);
194
213
  if (!uri || uri === source) return null;
195
214
  const base: any = {
196
215
  uri,
@@ -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 ===