@glossarist/concept-browser 0.7.134 → 0.7.136

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.
@@ -1,7 +1,19 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import yaml from 'js-yaml';
4
- import { naturalSort, Register, parseMention, parseMentions, InvalidMentionError } from 'glossarist';
4
+ import {
5
+ naturalSort,
6
+ Register,
7
+ parseMention,
8
+ parseMentions,
9
+ InvalidMentionError,
10
+ parseConceptYaml,
11
+ conceptToTurtle,
12
+ conceptsToTbx,
13
+ conceptsToCsv,
14
+ emitOutputSet,
15
+ type Concept,
16
+ } from 'glossarist';
5
17
  import { loadSiteConfig } from './load-site-config';
6
18
  import { getGroups } from './lib/concept-groups';
7
19
  import { consumeDatasetEntities } from './lib/build/non-verbal-consumer';
@@ -23,7 +35,7 @@ import type {
23
35
  YamlNewsFrontmatter,
24
36
  YamlContentPage,
25
37
  } from './lib/yaml-types';
26
- import { buildConceptUri } from './lib/concept-uri';
38
+ import { buildConceptUri, buildConceptUriPrefix } from './lib/concept-uri';
27
39
  import {
28
40
  loadConceptFile,
29
41
  readYaml,
@@ -41,13 +53,6 @@ import {
41
53
  refsToJsonLd,
42
54
  citationToJsonLd,
43
55
  } from './lib/jsonld-emitter';
44
- import {
45
- escapeTurtle,
46
- escapeXml,
47
- conceptJsonToTurtle,
48
- conceptJsonToSkosJsonLd,
49
- conceptJsonToTbx,
50
- } from './lib/concept-formats';
51
56
 
52
57
  // MECE partitive multiplicity: 2 independent axes (ISO 704:2022).
53
58
  // presence × count
@@ -689,9 +694,10 @@ async function processDataset(dir, register, opts) {
689
694
  for (const f of fs.readdirSync(conceptsDir)) fs.unlinkSync(path.join(conceptsDir, f));
690
695
  }
691
696
  const concepts = [];
697
+ const conceptInstances: Concept[] = [];
692
698
  const langTermCounts = {};
693
699
  const langDefCounts = {};
694
- const availableFormats = ['ttl', 'jsonld', 'yaml', 'tbx'];
700
+ const availableFormats = ['ttl', 'jsonld', 'yaml', 'tbx', 'csv'];
695
701
 
696
702
  // Pre-scan: build designation → concept ID lookup for same-dataset designation refs
697
703
  const designationLookup = new Map();
@@ -802,16 +808,34 @@ async function processDataset(dir, register, opts) {
802
808
  const termid = String(conceptYaml.termid);
803
809
  const jsonld = yamlToJsonLd(conceptYaml, register, dsRefMaps);
804
810
  writeJson(path.join(conceptsDir, `${termid}.json`), jsonld);
805
-
806
- const ttlContent = conceptJsonToTurtle(jsonld);
807
- fs.writeFileSync(path.join(conceptsDir, `${termid}.ttl`), ttlContent);
808
-
809
- const skosJsonLd = conceptJsonToSkosJsonLd(jsonld);
810
- fs.writeFileSync(path.join(conceptsDir, `${termid}.jsonld`), skosJsonLd);
811
-
812
- const tbxContent = conceptJsonToTbx(jsonld);
813
- if (tbxContent) {
814
- fs.writeFileSync(path.join(conceptsDir, `${termid}.tbx`), tbxContent);
811
+ // Canonical interchange exports — routed through glossarist's
812
+ // output API (one model walk; predicates generated from the
813
+ // concept-model context, wire shape from schemas/v3). The hand-rolled
814
+ // gl:-shaped emitters in scripts/lib/concept-formats.ts are retired.
815
+ // Note: TBX now includes <term> entries — the retired emitter
816
+ // computed termEntries but never wrote them into the document.
817
+ let conceptInstance: Concept | null = null;
818
+ try {
819
+ conceptInstance = parseConceptYaml(fs.readFileSync(path.join(dir, file), 'utf8'), termid);
820
+ } catch (e) {
821
+ console.warn(` ${termid}: canonical parse failed (${e.message}); skipping glossarist exports`);
822
+ }
823
+ if (conceptInstance) {
824
+ conceptInstances.push(conceptInstance);
825
+ fs.writeFileSync(
826
+ path.join(conceptsDir, `${termid}.ttl`),
827
+ conceptToTurtle(conceptInstance, { uriBase: refMaps.uriBase, registerId: register }),
828
+ );
829
+ const conceptJsonLd = await emitOutputSet([conceptInstance], {
830
+ registerId: register,
831
+ uriBase: refMaps.uriBase,
832
+ formats: ['jsonld'],
833
+ });
834
+ fs.writeFileSync(path.join(conceptsDir, `${termid}.jsonld`), conceptJsonLd.jsonld!);
835
+ const tbxContent = conceptsToTbx([conceptInstance], { registerId: register });
836
+ if (tbxContent) {
837
+ fs.writeFileSync(path.join(conceptsDir, `${termid}.tbx`), tbxContent);
838
+ }
815
839
  }
816
840
 
817
841
  fs.copyFileSync(path.join(dir, file), path.join(conceptsDir, `${termid}.yaml`));
@@ -831,6 +855,16 @@ async function processDataset(dir, register, opts) {
831
855
  }
832
856
  }
833
857
 
858
+ // Aggregate distributions: single-file exports of the whole register,
859
+ // emitted by glossarist's output-set API from the canonical model walk.
860
+ const aggregate = await emitOutputSet(conceptInstances, {
861
+ registerId: register,
862
+ uriBase: refMaps.uriBase,
863
+ languageOrder: opts.languageOrder || opts.languages,
864
+ });
865
+ fs.writeFileSync(path.join(DATA, register, `${register}.csv`), aggregate.csv!);
866
+ fs.writeFileSync(path.join(DATA, register, `${register}.jsonld`), aggregate.jsonld!);
867
+
834
868
  const CHUNK_SIZE = 500;
835
869
  const chunks = [];
836
870
  for (let i = 0; i < concepts.length; i += CHUNK_SIZE) {
@@ -877,7 +911,7 @@ async function processDataset(dir, register, opts) {
877
911
  fs.writeFileSync(
878
912
  path.join(DATA, register, 'graph-nodes.json'),
879
913
  JSON.stringify({
880
- uriPrefix: `${refMaps.uriBase}/${register}/concept/`,
914
+ uriPrefix: buildConceptUriPrefix(refMaps.uriBase, register),
881
915
  registerId: register,
882
916
  nodes: graphNodeEntries,
883
917
  }),
@@ -908,29 +942,37 @@ async function processDataset(dir, register, opts) {
908
942
  };
909
943
  }
910
944
 
911
- // Copy bulk format files from compiled/ directory (full GCR)
945
+ // Aggregate bulk exports offered on the dataset page: single-file
946
+ // distributions generated by this build plus any files shipped in the
947
+ // dataset's compiled/ directory. site-config `bulk_exports` restricts
948
+ // which formats are offered; absent/empty config offers all available.
949
+ const BULK_FORMAT_BY_EXT = {
950
+ '.ttl': 'turtle',
951
+ '.jsonld': 'jsonld',
952
+ '.xml': 'tbx',
953
+ '.jsonl': 'jsonl',
954
+ '.yaml': 'yaml',
955
+ '.csv': 'csv',
956
+ };
957
+ const bulkFormats = [];
958
+ const addBulkFormat = file => {
959
+ const dest = path.join(DATA, register, file);
960
+ if (!fs.existsSync(dest) || bulkFormats.some(f => f.file === file)) return;
961
+ const formatName = BULK_FORMAT_BY_EXT[path.extname(file)] || path.extname(file).slice(1);
962
+ bulkFormats.push({ file, format: formatName, size: fs.statSync(dest).size });
963
+ };
964
+ for (const file of [`${register}.csv`, `${register}.jsonld`]) addBulkFormat(file);
965
+
912
966
  const sourceRoot = path.dirname(dir);
913
967
  const compiledDir = path.join(sourceRoot, 'compiled');
914
- const bulkFormats = [];
915
968
  if (fs.existsSync(compiledDir)) {
969
+ let copied = 0;
916
970
  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`);
971
+ fs.copyFileSync(path.join(compiledDir, file), path.join(DATA, register, file));
972
+ addBulkFormat(file);
973
+ copied += 1;
933
974
  }
975
+ if (copied) console.log(` Copied ${copied} bulk format files from compiled/`);
934
976
  }
935
977
 
936
978
  const sourceStats = Array.from(stats.sourceMap.values())
@@ -977,7 +1019,6 @@ async function processDataset(dir, register, opts) {
977
1019
  year: opts.year ?? undefined,
978
1020
  languageStats: langStats,
979
1021
  availableFormats,
980
- bulkFormats,
981
1022
  hasBibliography: opts.hasBibliography,
982
1023
  hasImages: opts.hasImages,
983
1024
  };
@@ -987,10 +1028,16 @@ async function processDataset(dir, register, opts) {
987
1028
  if (opts.status) manifest.editionStatus = opts.status;
988
1029
  if (opts.ordering) manifest.ordering = opts.ordering;
989
1030
  if (opts.sections && opts.sections.length > 0) manifest.sections = opts.sections;
990
- writeJson(path.join(DATA, register, 'manifest.json'), manifest);
991
1031
 
992
1032
  // Dataset-level RDF (WS J2/J5): dcat:Dataset + skos:ConceptScheme + skos:Collection per section.
1033
+ // Runs before the manifest write so the ttl distribution is on disk when
1034
+ // bulkFormats is assembled.
993
1035
  await writeDatasetRdf(register, manifest, concepts, refMaps, opts);
1036
+ addBulkFormat(`${register}.ttl`);
1037
+ manifest.bulkFormats = opts.bulkExports?.length
1038
+ ? bulkFormats.filter(f => opts.bulkExports.includes(f.format))
1039
+ : bulkFormats;
1040
+ writeJson(path.join(DATA, register, 'manifest.json'), manifest);
994
1041
 
995
1042
  // Copy bibliography.yaml → bibliography.json
996
1043
  // ALWAYS emit bibliography.json — empty stub `{ bibliography: [] }` when no
@@ -1058,6 +1105,11 @@ for (const ds of config.datasets) {
1058
1105
 
1059
1106
  const refMaps = buildRefMaps(config, registerCache);
1060
1107
 
1108
+ // site-config `bulk_exports`: which aggregate formats the dataset pages
1109
+ // offer for download. 'ttl' is accepted as an alias of the canonical
1110
+ // format id 'turtle'. Absent/empty → offer all available formats.
1111
+ const bulkExports = (config.bulkExports || []).map(f => (f === 'ttl' ? 'turtle' : f));
1112
+
1061
1113
  for (let i = 0; i < config.datasets.length; i++) {
1062
1114
  const ds = config.datasets[i];
1063
1115
 
@@ -1110,6 +1162,7 @@ for (let i = 0; i < config.datasets.length; i++) {
1110
1162
  sections: reg?.sections ? reg.sections.map(s => s.toJSON()) : [],
1111
1163
  hasBibliography: fs.existsSync(path.join(datasetDir(ds), 'bibliography.yaml')),
1112
1164
  hasImages: fs.existsSync(path.join(datasetDir(ds), 'images')),
1165
+ bulkExports,
1113
1166
  });
1114
1167
  const resolvedOwner = firstNonEmpty(reg?.owner, ds.owner) ?? '';
1115
1168
  const resolvedTags = firstNonEmpty(reg?.tags, ds.tags) ?? [];
@@ -15,3 +15,14 @@
15
15
  export function buildConceptUri(uriBase: string, registerId: string, conceptId: string | number): string {
16
16
  return `${uriBase}/dataset/${registerId}/concept/${conceptId}`;
17
17
  }
18
+
19
+ /** Prefix of every concept URI of a register (for keying node maps and
20
+ * matching edge endpoints against a register's URI space). */
21
+ export function buildConceptUriPrefix(uriBase: string, registerId: string): string {
22
+ return `${uriBase}/dataset/${registerId}/concept/`;
23
+ }
24
+
25
+ /** Prefix of any URI belonging to a register (for the cross-ref index). */
26
+ export function buildDatasetUriPrefix(uriBase: string, registerId: string): string {
27
+ return `${uriBase}/dataset/${registerId}/`;
28
+ }
@@ -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,106 @@
1
+ // CSV aggregate export — now routed through glossarist's canonical
2
+ // output API (glossarist@0.4.58, glossarist-js#132). These specs pin the
3
+ // production contract that shipped in concept-browser#214: column order,
4
+ // preferred/alt split, RFC 4180 quoting, BOM + CRLF, languageOrder,
5
+ // and no-drop rows for concepts without localizations.
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import { Concept, CSV_COLUMNS, conceptsToCsv } from 'glossarist';
9
+
10
+ function parseCsv(csv: string): string[][] {
11
+ const body = csv.replace(/^/, '');
12
+ const rows: string[][] = [];
13
+ let row: string[] = [];
14
+ let field = '';
15
+ let quoted = false;
16
+ for (let i = 0; i < body.length; i++) {
17
+ const ch = body[i];
18
+ if (quoted) {
19
+ if (ch === '"') {
20
+ if (body[i + 1] === '"') { field += '"'; i++; }
21
+ else quoted = false;
22
+ } else {
23
+ field += ch;
24
+ }
25
+ } else if (ch === '"') {
26
+ quoted = true;
27
+ } else if (ch === ',') {
28
+ row.push(field); field = '';
29
+ } else if (ch === '\r' && body[i + 1] === '\n') {
30
+ row.push(field); rows.push(row); row = []; field = ''; i++;
31
+ } else {
32
+ field += ch;
33
+ }
34
+ }
35
+ return rows;
36
+ }
37
+
38
+ const baseConcept = () => new Concept({
39
+ id: '1-1-010',
40
+ termid: '1-1-010',
41
+ status: 'valid',
42
+ uri: 'https://example.com/vocab/concept/1-1-010',
43
+ domains: [{ conceptId: 'section-1' }],
44
+ sources: [{ type: 'authoritative', origin: { ref: { source: 'IALA Dictionary' }, link: 'https://example.com/source' } }],
45
+ localizations: {
46
+ eng: {
47
+ terms: [
48
+ { designation: 'Buoyage and beaconage', normative_status: 'preferred' },
49
+ { designation: 'Sea-marking (G.B.)', normative_status: 'admitted' },
50
+ ],
51
+ definition: [{ content: 'Ensemble of beacons, buoys, seamarks and small lights.' }],
52
+ notes: [{ content: 'Note one.' }, { content: 'Note two.' }],
53
+ },
54
+ fra: {
55
+ terms: [{ designation: 'Balisage', normative_status: 'preferred' }],
56
+ definition: [{ content: 'Ensemble de balises.' }],
57
+ },
58
+ },
59
+ });
60
+
61
+ describe('conceptsToCsv (glossarist output API)', () => {
62
+ it('emits UTF-8 BOM, CRLF line endings, and the model-driven header', () => {
63
+ const csv = conceptsToCsv([baseConcept()]);
64
+ expect(csv.startsWith('')).toBe(true);
65
+ expect(csv.endsWith('\r\n')).toBe(true);
66
+ const rows = parseCsv(csv);
67
+ expect(rows[0]!.join(',')).toBe(CSV_COLUMNS.join(','));
68
+ });
69
+
70
+ it('one row per concept × language; preferred designation first', () => {
71
+ const rows = parseCsv(conceptsToCsv([baseConcept()]));
72
+ expect(rows).toHaveLength(3);
73
+ const eng = rows[1]!;
74
+ expect(eng[0]).toBe('1-1-010');
75
+ expect(eng[4]).toBe('eng');
76
+ expect(eng[5]).toBe('Buoyage and beaconage');
77
+ expect(eng[6]).toBe('Sea-marking (G.B.)');
78
+ });
79
+
80
+ it('definition/notes columns carry the full content', () => {
81
+ const rows = parseCsv(conceptsToCsv([baseConcept()]));
82
+ expect(rows[1]![7]).toBe('Ensemble of beacons, buoys, seamarks and small lights.');
83
+ expect(rows[1]![8]).toBe('Note one.\nNote two.');
84
+ expect(rows[2]![7]).toBe('Ensemble de balises.');
85
+ });
86
+
87
+ it('sources and source_links from origin ref/link', () => {
88
+ const rows = parseCsv(conceptsToCsv([baseConcept()]));
89
+ expect(rows[1]![10]).toBe('IALA Dictionary');
90
+ expect(rows[1]![11]).toBe('https://example.com/source');
91
+ });
92
+
93
+ it('languageOrder reorders rows; unlisted languages append after', () => {
94
+ const rows = parseCsv(conceptsToCsv([baseConcept()], { languageOrder: ['fra', 'deu'] }));
95
+ expect(rows[1]![4]).toBe('fra');
96
+ expect(rows[2]![4]).toBe('eng');
97
+ });
98
+
99
+ it('concept without localizations still gets a row (no silent drops)', () => {
100
+ const bare = new Concept({ id: '222-02-02', termid: '222-02-02' });
101
+ const rows = parseCsv(conceptsToCsv([bare]));
102
+ expect(rows).toHaveLength(2);
103
+ expect(rows[1]![0]).toBe('222-02-02');
104
+ expect(rows[1]![4]).toBe('');
105
+ });
106
+ });
@@ -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 ===