@glossarist/concept-browser 0.7.66 → 0.7.67

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 (67) 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 +38 -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__/use-rdf-document.test.ts +125 -0
  15. package/src/components/ConceptRdfView.vue +4 -4
  16. package/src/components/use-rdf-document.ts +137 -0
  17. package/src/__tests__/concept-rdf/agents-emitter.test.ts +0 -110
  18. package/src/__tests__/concept-rdf/bibliography-emitter.test.ts +0 -159
  19. package/src/__tests__/concept-rdf/build-activity-emitter.test.ts +0 -119
  20. package/src/__tests__/concept-rdf/coerce-date.test.ts +0 -97
  21. package/src/__tests__/concept-rdf/concept-emitter.test.ts +0 -258
  22. package/src/__tests__/concept-rdf/dataset-emitter.test.ts +0 -224
  23. package/src/__tests__/concept-rdf/differential.test.ts +0 -96
  24. package/src/__tests__/concept-rdf/group-emitter.test.ts +0 -109
  25. package/src/__tests__/concept-rdf/image-variant-emitter.test.ts +0 -135
  26. package/src/__tests__/concept-rdf/jsonld-writer.test.ts +0 -109
  27. package/src/__tests__/concept-rdf/nonverbal-rep.test.ts +0 -177
  28. package/src/__tests__/concept-rdf/property-based.test.ts +0 -179
  29. package/src/__tests__/concept-rdf/provenance.test.ts +0 -110
  30. package/src/__tests__/concept-rdf/quad-isomorphism.test.ts +0 -43
  31. package/src/__tests__/concept-rdf/quad-isomorphism.ts +0 -47
  32. package/src/__tests__/concept-rdf/rdf-components.test.ts +0 -145
  33. package/src/__tests__/concept-rdf/rdf-graph.test.ts +0 -115
  34. package/src/__tests__/concept-rdf/round-trip.test.ts +0 -243
  35. package/src/__tests__/concept-rdf/scoped-examples.test.ts +0 -126
  36. package/src/__tests__/concept-rdf/sections-builder.test.ts +0 -94
  37. package/src/__tests__/concept-rdf/shacl-conformance.test.ts +0 -110
  38. package/src/__tests__/concept-rdf/shape-consistency.test.ts +0 -138
  39. package/src/__tests__/concept-rdf/snapshot-generation.test.ts +0 -75
  40. package/src/__tests__/concept-rdf/table-formula-emitter.test.ts +0 -142
  41. package/src/__tests__/concept-rdf/turtle-writer.test.ts +0 -114
  42. package/src/__tests__/concept-rdf/use-rdf-document.test.ts +0 -246
  43. package/src/__tests__/concept-rdf/version-emitter.test.ts +0 -120
  44. package/src/__tests__/concept-rdf/vocabulary-ssot.test.ts +0 -100
  45. package/src/__tests__/perf/bundle-layout.test.ts +0 -50
  46. package/src/components/concept-rdf/agents-emitter.ts +0 -82
  47. package/src/components/concept-rdf/bibliography-emitter.ts +0 -83
  48. package/src/components/concept-rdf/build-activity-emitter.ts +0 -89
  49. package/src/components/concept-rdf/concept-emitter.ts +0 -443
  50. package/src/components/concept-rdf/dataset-emitter.ts +0 -95
  51. package/src/components/concept-rdf/group-emitter.ts +0 -69
  52. package/src/components/concept-rdf/image-variant-emitter.ts +0 -46
  53. package/src/components/concept-rdf/jsonld-writer.ts +0 -82
  54. package/src/components/concept-rdf/predicates.ts +0 -261
  55. package/src/components/concept-rdf/provenance.ts +0 -80
  56. package/src/components/concept-rdf/rdf-graph.ts +0 -211
  57. package/src/components/concept-rdf/sections-builder.ts +0 -62
  58. package/src/components/concept-rdf/table-formula-emitter.ts +0 -101
  59. package/src/components/concept-rdf/turtle-writer.ts +0 -116
  60. package/src/components/concept-rdf/use-rdf-document.ts +0 -72
  61. package/src/components/concept-rdf/version-emitter.ts +0 -65
  62. package/src/components/concept-rdf/vocabulary-emitter.ts +0 -62
  63. /package/src/components/{concept-rdf/RdfInstanceHeader.vue → RdfInstanceHeader.vue} +0 -0
  64. /package/src/components/{concept-rdf/RdfInstanceSection.vue → RdfInstanceSection.vue} +0 -0
  65. /package/src/components/{concept-rdf/RdfPrefixLegend.vue → RdfPrefixLegend.vue} +0 -0
  66. /package/src/components/{concept-rdf/RdfSourcePanel.vue → RdfSourcePanel.vue} +0 -0
  67. /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.67",
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');
@@ -1169,12 +1170,11 @@ for (let i = 0; i < config.datasets.length; i++) {
1169
1170
  // Use cached register
1170
1171
  const reg = registerCache[ds.id] || null;
1171
1172
 
1172
- // Resolve languages: register.yaml first, then site-config fallback
1173
- const dsLanguages = (reg?.languages?.length ? reg.languages : null)
1174
- || ds.languages
1175
- || ['eng'];
1173
+ // Content fields: register-wins, site-config fallback. Layout-only
1174
+ // fields (uri, color, gcrPackage) stay on site-config.
1175
+ const dsLanguages = firstNonEmpty(reg?.languages, ds.languages, ['eng']);
1176
1176
 
1177
- // Resolve description: register.yaml first, then site-config
1177
+ // Resolve description: register-wins (localized object), site-config fallback
1178
1178
  const defaultLang = dsLanguages[0] || 'eng';
1179
1179
  const regDesc = reg?.description;
1180
1180
  const dsDesc = ds.description;
@@ -1182,28 +1182,39 @@ for (let i = 0; i < config.datasets.length; i++) {
1182
1182
  ? regDesc[defaultLang] || Object.values(regDesc)[0] || ''
1183
1183
  : dsDesc || '';
1184
1184
 
1185
- // Resolve title: site-config override, then ref from register
1186
- const resolvedTitle = ds.title || reg?.ref || ds.id;
1185
+ // Title: register.displayName wins (TODO.refactor/40 — Register.name
1186
+ // is first-class in glossarist-js since v0.4.13). Site-config title
1187
+ // is fallback, then ref (citation proxy), then id.
1188
+ const resolvedTitle = reg?.displayName(defaultLang)
1189
+ ?? ds.title
1190
+ ?? reg?.ref
1191
+ ?? ds.id;
1187
1192
 
1188
1193
  counts[ds.id] = await processDataset(dir, ds.id, {
1189
1194
  title: resolvedTitle,
1190
1195
  description: resolvedDescription,
1191
- owner: ds.owner || reg?.owner,
1196
+ owner: firstNonEmpty(reg?.owner, ds.owner),
1192
1197
  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,
1198
+ sourceRepo: firstNonEmpty(reg?.sourceRepo, ds.sourceRepo),
1199
+ languageOrder: firstNonEmpty(reg?.languageOrder, ds.languageOrder),
1200
+ ref: firstNonEmpty(reg?.ref, ds.ref),
1201
+ refAliases: firstNonEmpty(reg?.refAliases, ds.refAliases),
1202
+ tags: firstNonEmpty(reg?.tags, ds.tags),
1198
1203
  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,
1204
+ datasetUri: firstNonEmpty(reg?.urn, ds.uri),
1205
+ uriAliases: firstNonEmpty(reg?.urnAliases, ds.uriAliases),
1206
+ status: firstNonEmpty(reg?.status, ds.editionStatus),
1202
1207
  ordering: reg?.ordering || null,
1203
1208
  sections: reg?.sections ? reg.sections.map(s => s.toJSON()) : [],
1204
1209
  hasBibliography: fs.existsSync(path.join(datasetDir(ds), 'bibliography.yaml')),
1205
1210
  hasImages: fs.existsSync(path.join(datasetDir(ds), 'images')),
1206
1211
  });
1212
+ const resolvedOwner = firstNonEmpty(reg?.owner, ds.owner) ?? '';
1213
+ const resolvedTags = firstNonEmpty(reg?.tags, ds.tags) ?? [];
1214
+ const resolvedDatasetUri = firstNonEmpty(reg?.urn, ds.uri);
1215
+ const resolvedUriAliases = firstNonEmpty(reg?.urnAliases, ds.uriAliases);
1216
+ const resolvedRef = firstNonEmpty(reg?.ref, ds.ref);
1217
+ const resolvedRefAliases = firstNonEmpty(reg?.refAliases, ds.refAliases);
1207
1218
  registry.push({
1208
1219
  id: ds.id,
1209
1220
  manifestUrl: `/data/${ds.id}/manifest.json`,
@@ -1212,15 +1223,15 @@ for (let i = 0; i < config.datasets.length; i++) {
1212
1223
  description: resolvedDescription,
1213
1224
  conceptCount: counts[ds.id] || 0,
1214
1225
  languages: dsLanguages,
1215
- owner: ds.owner || reg?.owner || '',
1216
- tags: ds.tags || reg?.tags || [],
1226
+ owner: resolvedOwner,
1227
+ tags: resolvedTags,
1217
1228
  color: ds.color || DS_PALETTE[i % DS_PALETTE.length],
1218
1229
  },
1219
- datasetUri: ds.uri || reg?.urn || undefined,
1230
+ datasetUri: resolvedDatasetUri,
1220
1231
  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,
1232
+ uriAliases: resolvedUriAliases,
1233
+ ref: resolvedRef,
1234
+ refAliases: resolvedRefAliases,
1224
1235
  });
1225
1236
  }
1226
1237
  writeJson(path.join(PUBLIC, 'datasets.json'), registry);
@@ -1553,21 +1564,22 @@ for (const key of ['logo', 'footerLogo']) {
1553
1564
  }
1554
1565
  }
1555
1566
 
1556
- // Build dataset translations map: merge site-config overrides with register.yaml descriptions
1567
+ // Build dataset translations map: register descriptions win, site-config fills gaps
1557
1568
  const datasetTranslations = {};
1558
1569
  for (const d of config.datasets) {
1559
1570
  const reg = registerCache[d.id];
1560
1571
  const translations = { ...d.translations };
1561
1572
 
1562
- // Merge register.yaml descriptions as translations
1573
+ // Register descriptions are authoritative; site-config translations fill gaps
1563
1574
  if (reg?.description && typeof reg.description === 'object') {
1564
1575
  for (const [lang, desc] of Object.entries(reg.description)) {
1565
1576
  if (!translations[lang]) translations[lang] = {};
1566
- if (!translations[lang].description) translations[lang].description = desc;
1577
+ translations[lang].description = desc;
1567
1578
  }
1568
1579
  }
1569
1580
 
1570
- // Merge register.yaml ref as translated title if not already set
1581
+ // Title falls back to register.ref when site-config doesn't supply one
1582
+ // (TODO.refactor/40 — replace ref with Register.name once it exists)
1571
1583
  if (reg?.ref) {
1572
1584
  const langs = reg.languages || [];
1573
1585
  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
+ }