@glossarist/concept-browser 0.7.66 → 0.7.68

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.
Files changed (70) hide show
  1. package/cli/index.mjs +38 -33
  2. package/package.json +2 -2
  3. package/scripts/__tests__/cli-script-contract.test.mjs +39 -0
  4. package/scripts/__tests__/first-non-empty.test.mjs +51 -0
  5. package/scripts/__tests__/process-about-pages.test.mjs +106 -0
  6. package/scripts/build-edges.js +10 -2
  7. package/scripts/fetch-datasets.mjs +61 -54
  8. package/scripts/generate-data.mjs +40 -26
  9. package/scripts/lib/first-non-empty.mjs +19 -0
  10. package/scripts/process-about-pages.mjs +63 -15
  11. package/scripts/validate-shacl.mjs +1 -1
  12. package/src/__tests__/concept-rdf-view.test.ts +21 -13
  13. package/src/__tests__/perf/serialization-perf.test.ts +13 -30
  14. package/src/__tests__/tc204-deployment.test.ts +139 -0
  15. package/src/__tests__/use-rdf-document.test.ts +125 -0
  16. package/src/adapters/types.ts +7 -0
  17. package/src/components/ConceptRdfView.vue +4 -4
  18. package/src/components/use-rdf-document.ts +137 -0
  19. package/src/composables/useDatasetSeries.ts +1 -1
  20. package/src/__tests__/concept-rdf/agents-emitter.test.ts +0 -110
  21. package/src/__tests__/concept-rdf/bibliography-emitter.test.ts +0 -159
  22. package/src/__tests__/concept-rdf/build-activity-emitter.test.ts +0 -119
  23. package/src/__tests__/concept-rdf/coerce-date.test.ts +0 -97
  24. package/src/__tests__/concept-rdf/concept-emitter.test.ts +0 -258
  25. package/src/__tests__/concept-rdf/dataset-emitter.test.ts +0 -224
  26. package/src/__tests__/concept-rdf/differential.test.ts +0 -96
  27. package/src/__tests__/concept-rdf/group-emitter.test.ts +0 -109
  28. package/src/__tests__/concept-rdf/image-variant-emitter.test.ts +0 -135
  29. package/src/__tests__/concept-rdf/jsonld-writer.test.ts +0 -109
  30. package/src/__tests__/concept-rdf/nonverbal-rep.test.ts +0 -177
  31. package/src/__tests__/concept-rdf/property-based.test.ts +0 -179
  32. package/src/__tests__/concept-rdf/provenance.test.ts +0 -110
  33. package/src/__tests__/concept-rdf/quad-isomorphism.test.ts +0 -43
  34. package/src/__tests__/concept-rdf/quad-isomorphism.ts +0 -47
  35. package/src/__tests__/concept-rdf/rdf-components.test.ts +0 -145
  36. package/src/__tests__/concept-rdf/rdf-graph.test.ts +0 -115
  37. package/src/__tests__/concept-rdf/round-trip.test.ts +0 -243
  38. package/src/__tests__/concept-rdf/scoped-examples.test.ts +0 -126
  39. package/src/__tests__/concept-rdf/sections-builder.test.ts +0 -94
  40. package/src/__tests__/concept-rdf/shacl-conformance.test.ts +0 -110
  41. package/src/__tests__/concept-rdf/shape-consistency.test.ts +0 -138
  42. package/src/__tests__/concept-rdf/snapshot-generation.test.ts +0 -75
  43. package/src/__tests__/concept-rdf/table-formula-emitter.test.ts +0 -142
  44. package/src/__tests__/concept-rdf/turtle-writer.test.ts +0 -114
  45. package/src/__tests__/concept-rdf/use-rdf-document.test.ts +0 -246
  46. package/src/__tests__/concept-rdf/version-emitter.test.ts +0 -120
  47. package/src/__tests__/concept-rdf/vocabulary-ssot.test.ts +0 -100
  48. package/src/__tests__/perf/bundle-layout.test.ts +0 -50
  49. package/src/components/concept-rdf/agents-emitter.ts +0 -82
  50. package/src/components/concept-rdf/bibliography-emitter.ts +0 -83
  51. package/src/components/concept-rdf/build-activity-emitter.ts +0 -89
  52. package/src/components/concept-rdf/concept-emitter.ts +0 -443
  53. package/src/components/concept-rdf/dataset-emitter.ts +0 -95
  54. package/src/components/concept-rdf/group-emitter.ts +0 -69
  55. package/src/components/concept-rdf/image-variant-emitter.ts +0 -46
  56. package/src/components/concept-rdf/jsonld-writer.ts +0 -82
  57. package/src/components/concept-rdf/predicates.ts +0 -261
  58. package/src/components/concept-rdf/provenance.ts +0 -80
  59. package/src/components/concept-rdf/rdf-graph.ts +0 -211
  60. package/src/components/concept-rdf/sections-builder.ts +0 -62
  61. package/src/components/concept-rdf/table-formula-emitter.ts +0 -101
  62. package/src/components/concept-rdf/turtle-writer.ts +0 -116
  63. package/src/components/concept-rdf/use-rdf-document.ts +0 -72
  64. package/src/components/concept-rdf/version-emitter.ts +0 -65
  65. package/src/components/concept-rdf/vocabulary-emitter.ts +0 -62
  66. /package/src/components/{concept-rdf/RdfInstanceHeader.vue → RdfInstanceHeader.vue} +0 -0
  67. /package/src/components/{concept-rdf/RdfInstanceSection.vue → RdfInstanceSection.vue} +0 -0
  68. /package/src/components/{concept-rdf/RdfPrefixLegend.vue → RdfPrefixLegend.vue} +0 -0
  69. /package/src/components/{concept-rdf/RdfSourcePanel.vue → RdfSourcePanel.vue} +0 -0
  70. /package/src/components/{concept-rdf/rdf-prefixes.ts → rdf-prefixes.ts} +0 -0
package/cli/index.mjs CHANGED
@@ -30,9 +30,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
30
30
  const pkgRoot = resolve(__dirname, '..');
31
31
 
32
32
  const commands = {
33
- fetch: () => import('../scripts/fetch-datasets.mjs'),
34
- generate: () => import('../scripts/generate-data.mjs'),
35
- edges: () => import('../scripts/build-edges.js'),
33
+ fetch: async () => (await import('../scripts/fetch-datasets.mjs')).main(),
34
+ generate: async () => { await import('../scripts/generate-data.mjs'); },
35
+ edges: async () => (await import('../scripts/build-edges.js')).main(),
36
+ about: async () => (await import('../scripts/process-about-pages.mjs')).main(),
36
37
  };
37
38
 
38
39
  function parseArgs(argv) {
@@ -61,7 +62,8 @@ Commands:
61
62
  fetch Fetch/update datasets from GCR packages or source repos
62
63
  generate Convert YAML concepts to JSON-LD static data
63
64
  edges Build cross-reference edges from generated concepts
64
- build Full pipeline (fetch + generate + edges + vite build)
65
+ about Compile per-dataset and per-group about pages
66
+ build Full pipeline (fetch + generate + edges + about + vite build)
65
67
  site Same as build
66
68
  normalize NFC-normalize YAML files in .datasets/
67
69
  doctor Diagnose the local environment (deps, datasets, shapes, context)
@@ -87,7 +89,7 @@ Environment:
87
89
  if (!process.env.BASE_PATH && config?.basePath) {
88
90
  process.env.BASE_PATH = config.basePath;
89
91
  }
90
- for (const step of ['fetch', 'generate', 'edges']) {
92
+ for (const step of ['fetch', 'generate', 'edges', 'about']) {
91
93
  console.log(`\n=== ${step.toUpperCase()} ===\n`);
92
94
  await commands[step]();
93
95
  }
@@ -191,40 +193,43 @@ Environment:
191
193
  }
192
194
 
193
195
  const runner = commands[cmd];
194
- if (!runner) {
195
- if (cmd === 'normalize') {
196
- const { normalizeYaml } = await import('../scripts/normalize-yaml.mjs');
197
- const check = process.argv.includes('--check');
198
- const paths = process.argv.slice(2).filter(a => !a.startsWith('-') && a !== 'normalize');
199
- const { checked, nonNfc, fixed } = normalizeYaml({ check, paths });
200
- if (check) {
201
- if (nonNfc === 0) {
202
- console.log(`NFC OK: ${checked} file(s) checked, all normalized`);
203
- return;
204
- }
205
- console.error(`NFC check failed: ${nonNfc} of ${checked} file(s) are not NFC-normalized\n`);
206
- for (const f of fixed) console.error(` ${f}`);
207
- process.exit(1);
208
- }
196
+ if (runner) {
197
+ await runner();
198
+ return;
199
+ }
200
+
201
+ if (cmd === 'normalize') {
202
+ const { normalizeYaml } = await import('../scripts/normalize-yaml.mjs');
203
+ const check = process.argv.includes('--check');
204
+ const paths = process.argv.slice(2).filter(a => !a.startsWith('-') && a !== 'normalize');
205
+ const { checked, nonNfc, fixed } = normalizeYaml({ check, paths });
206
+ if (check) {
209
207
  if (nonNfc === 0) {
210
- console.log(`NFC OK: ${checked} file(s) checked, all already normalized`);
211
- } else {
212
- console.log(`Normalized ${nonNfc} of ${checked} file(s)`);
213
- for (const f of fixed) console.log(` ${f}`);
208
+ console.log(`NFC OK: ${checked} file(s) checked, all normalized`);
209
+ return;
214
210
  }
215
- return;
211
+ console.error(`NFC check failed: ${nonNfc} of ${checked} file(s) are not NFC-normalized\n`);
212
+ for (const f of fixed) console.error(` ${f}`);
213
+ process.exit(1);
216
214
  }
217
- if (cmd === 'doctor') {
218
- const { main: doctorMain } = await import('../scripts/doctor.mjs');
219
- await doctorMain();
220
- return;
215
+ if (nonNfc === 0) {
216
+ console.log(`NFC OK: ${checked} file(s) checked, all already normalized`);
217
+ } else {
218
+ console.log(`Normalized ${nonNfc} of ${checked} file(s)`);
219
+ for (const f of fixed) console.log(` ${f}`);
221
220
  }
222
- console.error(`Unknown command: ${cmd}`);
223
- console.error('Run `concept-browser help` for usage.');
224
- process.exit(1);
221
+ return;
225
222
  }
226
223
 
227
- await runner();
224
+ if (cmd === 'doctor') {
225
+ const { main: doctorMain } = await import('../scripts/doctor.mjs');
226
+ await doctorMain();
227
+ return;
228
+ }
229
+
230
+ console.error(`Unknown command: ${cmd}`);
231
+ console.error('Run `concept-browser help` for usage.');
232
+ process.exit(1);
228
233
  }
229
234
 
230
235
  main().catch(e => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.66",
3
+ "version": "0.7.68",
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": {
@@ -31,7 +31,7 @@
31
31
  "autoprefixer": "^10.4.21",
32
32
  "d3": "^7.9.0",
33
33
  "favicons": "^7.2.0",
34
- "glossarist": "^0.4.12",
34
+ "glossarist": "^0.4.15",
35
35
  "js-yaml": "^4.1.0",
36
36
  "jszip": "^3.10.1",
37
37
  "pinia": "^2.3.1",
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { resolve, dirname } from 'node:path';
5
+
6
+ // Verifies every CLI-driven script exports main() and uses a
7
+ // direct-invocation guard. This pins the contract documented in
8
+ // TODO.bugs/03 and TODO.bugs/05: scripts are testable AND
9
+ // auto-run when invoked directly. Without this contract, the CLI
10
+ // would need a separate code path per script (some auto-run via
11
+ // import side-effect, others need explicit m.main()).
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+
15
+ const SCRIPT_PATHS = [
16
+ '../../scripts/fetch-datasets.mjs',
17
+ '../../scripts/build-edges.js',
18
+ '../../scripts/process-about-pages.mjs',
19
+ ];
20
+
21
+ describe('CLI script contract: every script exports main()', () => {
22
+ for (const scriptPath of SCRIPT_PATHS) {
23
+ it(`${scriptPath} exports main as a function`, async () => {
24
+ const mod = await import(scriptPath);
25
+ expect(typeof mod.main).toBe('function');
26
+ });
27
+ }
28
+ });
29
+
30
+ describe('CLI script contract: direct-invocation guard', () => {
31
+ for (const scriptPath of SCRIPT_PATHS) {
32
+ it(`${scriptPath} source uses realpathSync direct-invocation guard`, () => {
33
+ const abs = resolve(__dirname, scriptPath);
34
+ const src = readFileSync(abs, 'utf8');
35
+ expect(src).toMatch(/realpathSync\s*\(\s*process\.argv\[1\]/);
36
+ expect(src).toMatch(/realpathSync\s*\(\s*fileURLToPath\s*\(\s*import\.meta\.url\s*\)\s*\)/);
37
+ });
38
+ }
39
+ });
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { firstNonEmpty } from '../lib/first-non-empty.mjs';
3
+
4
+ describe('firstNonEmpty — register-wins resolution', () => {
5
+ it('returns the first value when both are present (register wins)', () => {
6
+ expect(firstNonEmpty('reg-value', 'ds-value')).toBe('reg-value');
7
+ });
8
+
9
+ it('falls through to site-config when register value is null', () => {
10
+ expect(firstNonEmpty(null, 'ds-value')).toBe('ds-value');
11
+ });
12
+
13
+ it('falls through to site-config when register value is undefined', () => {
14
+ expect(firstNonEmpty(undefined, 'ds-value')).toBe('ds-value');
15
+ });
16
+
17
+ it('falls through to site-config when register value is empty string', () => {
18
+ expect(firstNonEmpty('', 'ds-value')).toBe('ds-value');
19
+ });
20
+
21
+ it('falls through to site-config when register value is empty array', () => {
22
+ expect(firstNonEmpty([], ['ds-tag'])).toEqual(['ds-tag']);
23
+ });
24
+
25
+ it('returns the first value when site-config is empty', () => {
26
+ expect(firstNonEmpty('reg-value', '')).toBe('reg-value');
27
+ expect(firstNonEmpty('reg-value', null)).toBe('reg-value');
28
+ expect(firstNonEmpty('reg-value', undefined)).toBe('reg-value');
29
+ });
30
+
31
+ it('returns the first value when register is array and site-config is empty', () => {
32
+ expect(firstNonEmpty(['reg'], [])).toEqual(['reg']);
33
+ });
34
+
35
+ it('returns the default when both are absent', () => {
36
+ expect(firstNonEmpty(null, undefined, 'default')).toBe('default');
37
+ });
38
+
39
+ it('returns undefined when no arguments are non-empty', () => {
40
+ expect(firstNonEmpty(null, undefined, '', [])).toBeUndefined();
41
+ });
42
+
43
+ it('returns undefined when called with no arguments', () => {
44
+ expect(firstNonEmpty()).toBeUndefined();
45
+ });
46
+
47
+ it('does not skip falsy but meaningful values (e.g. 0, false)', () => {
48
+ expect(firstNonEmpty(0, 1)).toBe(0);
49
+ expect(firstNonEmpty(false, true)).toBe(false);
50
+ });
51
+ });
@@ -0,0 +1,106 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { writeFileSync, mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs';
3
+ import { join, dirname } from 'node:path';
4
+ import { mkdtempSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+
7
+ const scriptPath = join(dirname(new URL(import.meta.url).pathname), '..', 'process-about-pages.mjs');
8
+
9
+ // Process-about-pages discovers per-dataset about content from either
10
+ // .datasets/<id>/about/ (fetched) OR site-config localPath overrides.
11
+ // This spec covers both discovery paths plus the per-group path.
12
+ describe('process-about-pages — discovery paths', () => {
13
+ let root;
14
+ let origCwd;
15
+
16
+ beforeEach(() => {
17
+ root = mkdtempSync(join(tmpdir(), 'about-pages-'));
18
+ origCwd = process.cwd();
19
+ process.chdir(root);
20
+ // The script writes to public/pages under CWD.
21
+ mkdirSync(join(root, 'public'), { recursive: true });
22
+ });
23
+
24
+ afterEach(() => {
25
+ process.chdir(origCwd);
26
+ rmSync(root, { recursive: true, force: true });
27
+ });
28
+
29
+ // Re-import each time with a cache-busting query so main() doesn't
30
+ // auto-run on cached load. The exported main() is invoked explicitly.
31
+ async function runMain() {
32
+ const url = new URL(`file://${scriptPath}?t=${Date.now()}`);
33
+ const mod = await import(url.href);
34
+ return mod.main();
35
+ }
36
+
37
+ function writeSiteConfig(datasets) {
38
+ const lines = ['id: test', 'datasets:'];
39
+ for (const d of datasets) {
40
+ lines.push(` - id: ${d.id}`);
41
+ if (d.localPath) lines.push(` localPath: ${d.localPath}`);
42
+ lines.push(` title: "${d.id}"`);
43
+ }
44
+ writeFileSync(join(root, 'site-config.yml'), lines.join('\n') + '\n');
45
+ }
46
+
47
+ it('discovers about pages under .datasets/<id>/about/', async () => {
48
+ mkdirSync(join(root, '.datasets', 'foo', 'about'), { recursive: true });
49
+ writeFileSync(
50
+ join(root, '.datasets', 'foo', 'about', 'about.md'),
51
+ '# Foo\n\nThe foo dataset.',
52
+ );
53
+
54
+ await runMain();
55
+
56
+ const outPath = join(root, 'public', 'pages', 'dataset-foo-about.json');
57
+ expect(existsSync(outPath)).toBe(true);
58
+ const parsed = JSON.parse(readFileSync(outPath, 'utf8'));
59
+ expect(parsed.title).toBe('Foo');
60
+ expect(parsed.html).toContain('<h1>Foo</h1>');
61
+ });
62
+
63
+ it('discovers about pages via site-config localPath when .datasets/ is absent', async () => {
64
+ const localRoot = mkdtempSync(join(tmpdir(), 'local-ds-'));
65
+ try {
66
+ mkdirSync(join(localRoot, 'about'), { recursive: true });
67
+ writeFileSync(
68
+ join(localRoot, 'about', 'about.md'),
69
+ '# Bar\n\nLocal-path dataset.',
70
+ );
71
+ writeSiteConfig([{ id: 'bar', localPath: localRoot }]);
72
+
73
+ await runMain();
74
+
75
+ const outPath = join(root, 'public', 'pages', 'dataset-bar-about.json');
76
+ expect(existsSync(outPath)).toBe(true);
77
+ const parsed = JSON.parse(readFileSync(outPath, 'utf8'));
78
+ expect(parsed.title).toBe('Bar');
79
+ } finally {
80
+ rmSync(localRoot, { recursive: true, force: true });
81
+ }
82
+ });
83
+
84
+ it('discovers group about pages under site-content/groups/<id>/about/', async () => {
85
+ mkdirSync(join(root, 'site-content', 'groups', 'mygroup', 'about'), { recursive: true });
86
+ writeFileSync(
87
+ join(root, 'site-content', 'groups', 'mygroup', 'about', 'about.md'),
88
+ '# My Group\n\nA group of datasets.',
89
+ );
90
+
91
+ await runMain();
92
+
93
+ const outPath = join(root, 'public', 'pages', 'group-mygroup-about.json');
94
+ expect(existsSync(outPath)).toBe(true);
95
+ const parsed = JSON.parse(readFileSync(outPath, 'utf8'));
96
+ expect(parsed.title).toBe('My Group');
97
+ });
98
+
99
+ it('skips silently when no about directories exist', async () => {
100
+ writeSiteConfig([{ id: 'lonely' }]);
101
+ // No .datasets, no localPath about/, no groups. The script runs to
102
+ // completion without throwing and produces no output pages.
103
+ await expect(runMain()).resolves.not.toThrow();
104
+ expect(existsSync(join(root, 'public', 'pages', 'dataset-lonely-about.json'))).toBe(false);
105
+ });
106
+ });
@@ -6,8 +6,8 @@
6
6
  * Usage: node scripts/build-edges.js
7
7
  */
8
8
  import { extractSourceRefs } from './extract-source-refs.js';
9
- import { readFileSync, writeFileSync, readdirSync, existsSync } from 'fs';
10
- import { join, dirname } from 'path';
9
+ import { readFileSync, writeFileSync, readdirSync, existsSync, realpathSync } from 'fs';
10
+ import { join, dirname, resolve } from 'path';
11
11
  import { fileURLToPath } from 'url';
12
12
  import yaml from 'js-yaml';
13
13
  import { Register } from 'glossarist';
@@ -252,6 +252,7 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
252
252
  }
253
253
 
254
254
  // Main
255
+ export function main() {
255
256
  console.log('Building edge indexes...\n');
256
257
 
257
258
  if (!existsSync(DATA_DIR)) {
@@ -388,3 +389,10 @@ const refCount = Object.values(crossRefIndex).reduce((sum, arr) => sum + arr.len
388
389
  console.log(`Written cross-ref-index.json (${refCount} cross-references across ${datasets.length} datasets)`);
389
390
 
390
391
  console.log('Done.');
392
+ }
393
+
394
+ const isDirectInvocation = process.argv[1]
395
+ && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
396
+ if (isDirectInvocation) {
397
+ main();
398
+ }
@@ -19,10 +19,9 @@ import path from 'path';
19
19
  import JSZip from 'jszip';
20
20
  import { loadGcr } from 'glossarist';
21
21
  import { execFileSync } from 'child_process';
22
+ import { fileURLToPath } from 'url';
22
23
  import { loadSiteConfig } from './load-site-config.mjs';
23
- import { assertLocalPathSafe } from './lib/local-path-safety.mjs';
24
-
25
- const ROOT = process.cwd();
24
+ import { assertLocalPathSafe } from './lib/local-path-safety.mjs';const ROOT = process.cwd();
26
25
  const DATASETS_DIR = path.join(ROOT, '.datasets');
27
26
  const GCR_DIR = path.join(ROOT, '.gcr');
28
27
 
@@ -136,68 +135,76 @@ function cloneOrUpdate(sourceRepo, targetDir) {
136
135
  // --- localPath safety check: see scripts/lib/local-path-safety.mjs ---
137
136
 
138
137
  // --- Main ---
139
- console.log('Fetching glossarist datasets...\n');
138
+ export async function main() {
139
+ console.log('Fetching glossarist datasets...\n');
140
140
 
141
- const { config } = loadSiteConfig();
142
- const gcrMetadata = {};
141
+ const { config } = loadSiteConfig();
142
+ const gcrMetadata = {};
143
143
 
144
- for (const ds of config.datasets) {
145
- console.log(`${ds.id}:`);
144
+ for (const ds of config.datasets) {
145
+ console.log(`${ds.id}:`);
146
146
 
147
- const gcrPath = path.join(GCR_DIR, `${ds.id}.gcr`);
148
- const targetDir = path.join(DATASETS_DIR, ds.id);
147
+ const gcrPath = path.join(GCR_DIR, `${ds.id}.gcr`);
148
+ const targetDir = path.join(DATASETS_DIR, ds.id);
149
149
 
150
- try {
151
- if (fs.existsSync(gcrPath)) {
152
- console.log(` Using local .gcr/${ds.id}.gcr`);
153
- await extractGcr(gcrPath, targetDir);
154
- } else if (ds.gcrPackage) {
155
- console.log(` Using GCR package: ${ds.gcrPackage}`);
156
- try {
157
- await downloadGcr(ds.gcrPackage, gcrPath);
158
- } catch (e) {
159
- console.warn(` GCR download failed: ${e.message}`);
160
- console.warn(` Skipping ${ds.id}`);
150
+ try {
151
+ if (fs.existsSync(gcrPath)) {
152
+ console.log(` Using local .gcr/${ds.id}.gcr`);
153
+ await extractGcr(gcrPath, targetDir);
154
+ } else if (ds.gcrPackage) {
155
+ console.log(` Using GCR package: ${ds.gcrPackage}`);
156
+ try {
157
+ await downloadGcr(ds.gcrPackage, gcrPath);
158
+ } catch (e) {
159
+ console.warn(` GCR download failed: ${e.message}`);
160
+ console.warn(` Skipping ${ds.id}`);
161
+ console.log();
162
+ continue;
163
+ }
164
+ await extractGcr(gcrPath, targetDir);
165
+ } else if (ds.localPath) {
166
+ // localPath means "data is here, use in-place." No copy, no staging.
167
+ // generate-data.mjs reads from localPath directly via datasetDir(ds).
168
+ const localResolved = assertLocalPathSafe(ds.id, ds.localPath);
169
+ console.log(` Using localPath in-place: ${localResolved}`);
170
+ } else if (ds.sourceRepo) {
171
+ cloneOrUpdate(ds.sourceRepo, targetDir);
172
+ } else {
173
+ console.warn(` No source configured, skipping`);
161
174
  console.log();
162
175
  continue;
163
176
  }
164
- await extractGcr(gcrPath, targetDir);
165
- } else if (ds.localPath) {
166
- // localPath means "data is here, use in-place." No copy, no staging.
167
- // generate-data.mjs reads from localPath directly via datasetDir(ds).
168
- const localResolved = assertLocalPathSafe(ds.id, ds.localPath);
169
- console.log(` Using localPath in-place: ${localResolved}`);
170
- } else if (ds.sourceRepo) {
171
- cloneOrUpdate(ds.sourceRepo, targetDir);
172
- } else {
173
- console.warn(` No source configured, skipping`);
174
- console.log();
175
- continue;
176
- }
177
177
 
178
- // Read metadata for dependency validation (from GCR ZIP, not extracted dir)
179
- const meta = await readGcrMetadata(gcrPath);
180
- if (meta) {
181
- gcrMetadata[ds.id] = meta;
182
- console.log(` ${meta.concept_count || '?'} concepts, ${meta.uri_prefix || 'no uri'}`);
178
+ // Read metadata for dependency validation (from GCR ZIP, not extracted dir)
179
+ const meta = await readGcrMetadata(gcrPath);
180
+ if (meta) {
181
+ gcrMetadata[ds.id] = meta;
182
+ console.log(` ${meta.concept_count || '?'} concepts, ${meta.uri_prefix || 'no uri'}`);
183
+ }
184
+ } catch (e) {
185
+ console.warn(` Failed: ${e.message}`);
186
+ console.warn(` Skipping ${ds.id}`);
183
187
  }
184
- } catch (e) {
185
- console.warn(` Failed: ${e.message}`);
186
- console.warn(` Skipping ${ds.id}`);
188
+ console.log();
187
189
  }
188
- console.log();
189
- }
190
190
 
191
- // Dependency validation
192
- console.log('Validating dependencies...\n');
193
- const errors = validateDependencies(config, gcrMetadata);
194
- if (errors.length) {
195
- console.error('Dependency validation FAILED:');
196
- for (const err of errors) {
197
- console.error(` ✗ ${err}`);
191
+ // Dependency validation
192
+ console.log('Validating dependencies...\n');
193
+ const errors = validateDependencies(config, gcrMetadata);
194
+ if (errors.length) {
195
+ console.error('Dependency validation FAILED:');
196
+ for (const err of errors) {
197
+ console.error(` ✗ ${err}`);
198
+ }
199
+ process.exit(1);
198
200
  }
199
- process.exit(1);
201
+ console.log('All dependencies satisfied.\n');
202
+
203
+ console.log('Done.');
200
204
  }
201
- console.log('All dependencies satisfied.\n');
202
205
 
203
- console.log('Done.');
206
+ const isDirectInvocation = process.argv[1]
207
+ && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
208
+ if (isDirectInvocation) {
209
+ await main();
210
+ }
@@ -13,6 +13,7 @@ import { buildAgentsTurtle } from './lib/agents-turtle.mjs';
13
13
  import { buildVersionHistoryTurtle } from './lib/version-turtle.mjs';
14
14
  import { buildBibliographyTurtle } from './lib/bibliography-turtle.mjs';
15
15
  import { ttlLit } from './lib/turtle-escape.mjs';
16
+ import { firstNonEmpty } from './lib/first-non-empty.mjs';
16
17
  const __dirname = path.dirname(new URL(import.meta.url).pathname);
17
18
  const ROOT = process.cwd();
18
19
  const PUBLIC = path.join(ROOT, 'public');
@@ -1080,6 +1081,7 @@ async function processDataset(dir, register, opts) {
1080
1081
  sourceRepo: opts.sourceRepo,
1081
1082
  chunkSize: CHUNK_SIZE,
1082
1083
  color: opts.color,
1084
+ year: opts.year ?? undefined,
1083
1085
  languageStats: langStats,
1084
1086
  availableFormats,
1085
1087
  bulkFormats,
@@ -1169,12 +1171,11 @@ for (let i = 0; i < config.datasets.length; i++) {
1169
1171
  // Use cached register
1170
1172
  const reg = registerCache[ds.id] || null;
1171
1173
 
1172
- // Resolve languages: register.yaml first, then site-config fallback
1173
- const dsLanguages = (reg?.languages?.length ? reg.languages : null)
1174
- || ds.languages
1175
- || ['eng'];
1174
+ // Content fields: register-wins, site-config fallback. Layout-only
1175
+ // fields (uri, color, gcrPackage) stay on site-config.
1176
+ const dsLanguages = firstNonEmpty(reg?.languages, ds.languages, ['eng']);
1176
1177
 
1177
- // Resolve description: register.yaml first, then site-config
1178
+ // Resolve description: register-wins (localized object), site-config fallback
1178
1179
  const defaultLang = dsLanguages[0] || 'eng';
1179
1180
  const regDesc = reg?.description;
1180
1181
  const dsDesc = ds.description;
@@ -1182,28 +1183,40 @@ for (let i = 0; i < config.datasets.length; i++) {
1182
1183
  ? regDesc[defaultLang] || Object.values(regDesc)[0] || ''
1183
1184
  : dsDesc || '';
1184
1185
 
1185
- // Resolve title: site-config override, then ref from register
1186
- const resolvedTitle = ds.title || reg?.ref || ds.id;
1186
+ // Title: register.displayName wins (TODO.refactor/40 — Register.name
1187
+ // is first-class in glossarist-js since v0.4.13). Site-config title
1188
+ // is fallback, then ref (citation proxy), then id.
1189
+ const resolvedTitle = reg?.displayName(defaultLang)
1190
+ ?? ds.title
1191
+ ?? reg?.ref
1192
+ ?? ds.id;
1187
1193
 
1188
1194
  counts[ds.id] = await processDataset(dir, ds.id, {
1189
1195
  title: resolvedTitle,
1190
1196
  description: resolvedDescription,
1191
- owner: ds.owner || reg?.owner,
1197
+ owner: firstNonEmpty(reg?.owner, ds.owner),
1192
1198
  languages: dsLanguages,
1193
- sourceRepo: ds.sourceRepo || reg?.sourceRepo,
1194
- languageOrder: ds.languageOrder || reg?.languageOrder,
1195
- ref: ds.ref || reg?.ref,
1196
- refAliases: ds.refAliases || reg?.refAliases,
1197
- tags: ds.tags || reg?.tags,
1199
+ sourceRepo: firstNonEmpty(reg?.sourceRepo, ds.sourceRepo),
1200
+ languageOrder: firstNonEmpty(reg?.languageOrder, ds.languageOrder),
1201
+ ref: firstNonEmpty(reg?.ref, ds.ref),
1202
+ refAliases: firstNonEmpty(reg?.refAliases, ds.refAliases),
1203
+ year: reg?.year ?? null,
1204
+ tags: firstNonEmpty(reg?.tags, ds.tags),
1198
1205
  color: ds.color || DS_PALETTE[i % DS_PALETTE.length],
1199
- datasetUri: ds.uri || reg?.urn,
1200
- uriAliases: ds.uriAliases || reg?.urnAliases,
1201
- status: ds.editionStatus || reg?.status,
1206
+ datasetUri: firstNonEmpty(reg?.urn, ds.uri),
1207
+ uriAliases: firstNonEmpty(reg?.urnAliases, ds.uriAliases),
1208
+ status: firstNonEmpty(reg?.status, ds.editionStatus),
1202
1209
  ordering: reg?.ordering || null,
1203
1210
  sections: reg?.sections ? reg.sections.map(s => s.toJSON()) : [],
1204
1211
  hasBibliography: fs.existsSync(path.join(datasetDir(ds), 'bibliography.yaml')),
1205
1212
  hasImages: fs.existsSync(path.join(datasetDir(ds), 'images')),
1206
1213
  });
1214
+ const resolvedOwner = firstNonEmpty(reg?.owner, ds.owner) ?? '';
1215
+ const resolvedTags = firstNonEmpty(reg?.tags, ds.tags) ?? [];
1216
+ const resolvedDatasetUri = firstNonEmpty(reg?.urn, ds.uri);
1217
+ const resolvedUriAliases = firstNonEmpty(reg?.urnAliases, ds.uriAliases);
1218
+ const resolvedRef = firstNonEmpty(reg?.ref, ds.ref);
1219
+ const resolvedRefAliases = firstNonEmpty(reg?.refAliases, ds.refAliases);
1207
1220
  registry.push({
1208
1221
  id: ds.id,
1209
1222
  manifestUrl: `/data/${ds.id}/manifest.json`,
@@ -1212,15 +1225,15 @@ for (let i = 0; i < config.datasets.length; i++) {
1212
1225
  description: resolvedDescription,
1213
1226
  conceptCount: counts[ds.id] || 0,
1214
1227
  languages: dsLanguages,
1215
- owner: ds.owner || reg?.owner || '',
1216
- tags: ds.tags || reg?.tags || [],
1228
+ owner: resolvedOwner,
1229
+ tags: resolvedTags,
1217
1230
  color: ds.color || DS_PALETTE[i % DS_PALETTE.length],
1218
1231
  },
1219
- datasetUri: ds.uri || reg?.urn || undefined,
1232
+ datasetUri: resolvedDatasetUri,
1220
1233
  uriBase: config.uriBase || undefined,
1221
- uriAliases: ds.uriAliases || reg?.urnAliases || undefined,
1222
- ref: ds.ref || reg?.ref || undefined,
1223
- refAliases: ds.refAliases || reg?.refAliases || undefined,
1234
+ uriAliases: resolvedUriAliases,
1235
+ ref: resolvedRef,
1236
+ refAliases: resolvedRefAliases,
1224
1237
  });
1225
1238
  }
1226
1239
  writeJson(path.join(PUBLIC, 'datasets.json'), registry);
@@ -1553,21 +1566,22 @@ for (const key of ['logo', 'footerLogo']) {
1553
1566
  }
1554
1567
  }
1555
1568
 
1556
- // Build dataset translations map: merge site-config overrides with register.yaml descriptions
1569
+ // Build dataset translations map: register descriptions win, site-config fills gaps
1557
1570
  const datasetTranslations = {};
1558
1571
  for (const d of config.datasets) {
1559
1572
  const reg = registerCache[d.id];
1560
1573
  const translations = { ...d.translations };
1561
1574
 
1562
- // Merge register.yaml descriptions as translations
1575
+ // Register descriptions are authoritative; site-config translations fill gaps
1563
1576
  if (reg?.description && typeof reg.description === 'object') {
1564
1577
  for (const [lang, desc] of Object.entries(reg.description)) {
1565
1578
  if (!translations[lang]) translations[lang] = {};
1566
- if (!translations[lang].description) translations[lang].description = desc;
1579
+ translations[lang].description = desc;
1567
1580
  }
1568
1581
  }
1569
1582
 
1570
- // Merge register.yaml ref as translated title if not already set
1583
+ // Title falls back to register.ref when site-config doesn't supply one
1584
+ // (TODO.refactor/40 — replace ref with Register.name once it exists)
1571
1585
  if (reg?.ref) {
1572
1586
  const langs = reg.languages || [];
1573
1587
  for (const lang of langs) {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Register-wins resolution for dataset content fields. Returns the
3
+ * first non-empty value among the arguments. Empty arrays and empty
4
+ * strings are treated as absent so a `[]` in register falls through
5
+ * to site-config.
6
+ *
7
+ * Used by generate-data.mjs to apply a single, uniform precedence
8
+ * rule across all dataset metadata fields (TODO.refactor/38).
9
+ */
10
+
11
+ export function firstNonEmpty(...vals) {
12
+ for (const v of vals) {
13
+ if (v == null) continue;
14
+ if (Array.isArray(v) && v.length === 0) continue;
15
+ if (typeof v === 'string' && v === '') continue;
16
+ return v;
17
+ }
18
+ return undefined;
19
+ }