@glossarist/concept-browser 0.1.0

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 (68) hide show
  1. package/README.md +319 -0
  2. package/cli/index.mjs +119 -0
  3. package/env.d.ts +7 -0
  4. package/index.html +16 -0
  5. package/package.json +78 -0
  6. package/postcss.config.js +6 -0
  7. package/scripts/build-edges.js +112 -0
  8. package/scripts/fetch-datasets.mjs +195 -0
  9. package/scripts/generate-404.js +15 -0
  10. package/scripts/generate-data.mjs +606 -0
  11. package/scripts/load-site-config.mjs +56 -0
  12. package/src/App.vue +98 -0
  13. package/src/__tests__/data-integration.test.ts +135 -0
  14. package/src/__tests__/data-integrity.test.ts +101 -0
  15. package/src/__tests__/dataset-adapter.test.ts +336 -0
  16. package/src/__tests__/dataset-style.test.ts +37 -0
  17. package/src/__tests__/graph.test.ts +187 -0
  18. package/src/__tests__/lang.test.ts +29 -0
  19. package/src/__tests__/math.test.ts +113 -0
  20. package/src/__tests__/reference-resolver.test.ts +122 -0
  21. package/src/__tests__/site-config.test.ts +52 -0
  22. package/src/__tests__/uri-router.test.ts +76 -0
  23. package/src/adapters/DatasetAdapter.ts +270 -0
  24. package/src/adapters/ReferenceResolver.ts +95 -0
  25. package/src/adapters/UriRouter.ts +41 -0
  26. package/src/adapters/factory.ts +78 -0
  27. package/src/adapters/types.ts +162 -0
  28. package/src/components/AppHeader.vue +99 -0
  29. package/src/components/AppSidebar.vue +133 -0
  30. package/src/components/ConceptCard.vue +65 -0
  31. package/src/components/ConceptDetail.vue +540 -0
  32. package/src/components/ConceptTimeline.vue +410 -0
  33. package/src/components/FormatDownloads.vue +46 -0
  34. package/src/components/GraphPanel.vue +499 -0
  35. package/src/components/LanguageDetail.vue +211 -0
  36. package/src/components/NavIcon.vue +20 -0
  37. package/src/components/SearchBar.vue +241 -0
  38. package/src/composables/use-dataset-loader.ts +27 -0
  39. package/src/config/types.ts +130 -0
  40. package/src/config/use-site-config.ts +144 -0
  41. package/src/graph/GraphEngine.ts +137 -0
  42. package/src/graph/index.ts +1 -0
  43. package/src/main.ts +11 -0
  44. package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  45. package/src/router/index.ts +43 -0
  46. package/src/router/page-routes.ts +35 -0
  47. package/src/stores/ui.ts +59 -0
  48. package/src/stores/vocabulary.ts +309 -0
  49. package/src/style.css +314 -0
  50. package/src/utils/asciidoc-lite.ts +123 -0
  51. package/src/utils/concept-formats.ts +157 -0
  52. package/src/utils/dataset-style.ts +54 -0
  53. package/src/utils/index.ts +1 -0
  54. package/src/utils/lang.ts +32 -0
  55. package/src/utils/math.ts +100 -0
  56. package/src/views/AboutView.vue +122 -0
  57. package/src/views/ConceptView.vue +119 -0
  58. package/src/views/ContributorsView.vue +110 -0
  59. package/src/views/DatasetView.vue +249 -0
  60. package/src/views/GraphView.vue +65 -0
  61. package/src/views/HomeView.vue +168 -0
  62. package/src/views/NewsView.vue +146 -0
  63. package/src/views/ResolveView.vue +63 -0
  64. package/src/views/SearchView.vue +33 -0
  65. package/src/views/StatsView.vue +121 -0
  66. package/tailwind.config.js +43 -0
  67. package/tsconfig.json +24 -0
  68. package/vite.config.ts +27 -0
@@ -0,0 +1,606 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import yaml from 'js-yaml';
4
+ import { naturalSort } from 'glossarist';
5
+ import { loadSiteConfig } from './load-site-config.mjs';
6
+
7
+ const __dirname = path.dirname(new URL(import.meta.url).pathname);
8
+ const ROOT = process.cwd();
9
+ const PUBLIC = path.join(ROOT, 'public');
10
+ const DATA = path.join(PUBLIC, 'data');
11
+
12
+ const DS_PALETTE = [
13
+ '#3366ff', '#0d9488', '#d97706', '#8b5cf6',
14
+ '#ec4899', '#059669', '#dc2626', '#6366f1',
15
+ '#0891b2', '#65a30d',
16
+ ];
17
+
18
+ function readYaml(filePath) {
19
+ return yaml.load(fs.readFileSync(filePath, 'utf8'));
20
+ }
21
+
22
+ function loadConceptFile(filePath) {
23
+ const content = fs.readFileSync(filePath, 'utf8');
24
+ const docs = yaml.loadAll(content, null, { schema: yaml.DEFAULT_SCHEMA });
25
+
26
+ if (docs.length === 1 && docs[0].termid !== undefined) {
27
+ return docs[0];
28
+ }
29
+
30
+ if (docs.length >= 1 && docs[0].data && docs[0].data.identifier !== undefined) {
31
+ const mc = docs[0];
32
+ const result = { termid: String(mc.data.identifier) };
33
+ for (const doc of docs.slice(1)) {
34
+ if (!doc || !doc.data || !doc.data.language_code) continue;
35
+ const lang = doc.data.language_code;
36
+ const lcData = { ...doc.data };
37
+ delete lcData.language_code;
38
+ result[lang] = lcData;
39
+ }
40
+ return result;
41
+ }
42
+
43
+ return docs[0];
44
+ }
45
+
46
+ function writeJson(filePath, data) {
47
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
48
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
49
+ }
50
+
51
+ function termToDesignation(term) {
52
+ const doc = {
53
+ '@type': term.type === 'expression' ? 'gl:Expression'
54
+ : term.type === 'symbol' ? 'gl:Symbol'
55
+ : term.type === 'abbreviation' ? 'gl:Abbreviation'
56
+ : 'gl:Designation',
57
+ 'gl:normativeStatus': term.normative_status || 'preferred',
58
+ 'gl:term': term.designation,
59
+ };
60
+ if (term.gender) doc['gl:gender'] = term.gender;
61
+ if (term.plurality) doc['gl:plurality'] = term.plurality;
62
+ if (term.international !== undefined) doc['gl:international'] = term.international;
63
+ return doc;
64
+ }
65
+
66
+ function defsToJsonLd(defs) {
67
+ if (!defs || !Array.isArray(defs)) return [];
68
+ return defs
69
+ .map(d => ({
70
+ '@type': 'gl:DetailedDefinition',
71
+ 'gl:content': d.content || '',
72
+ }))
73
+ .filter(d => d['gl:content']);
74
+ }
75
+
76
+ function sourcesToJsonLd(sources) {
77
+ if (!sources || !Array.isArray(sources)) return [];
78
+ return sources.map(s => {
79
+ const doc = { '@type': 'gl:ConceptSource' };
80
+ if (s.type) doc['gl:sourceType'] = s.type;
81
+ if (s.status) doc['gl:sourceStatus'] = s.status;
82
+ if (s.origin) {
83
+ const origin = { '@type': 'gl:Citation' };
84
+ if (s.origin.ref) origin['gl:ref'] = s.origin.ref;
85
+ if (s.origin.clause) origin['gl:clause'] = s.origin.clause;
86
+ if (s.origin.link) origin['gl:link'] = s.origin.link;
87
+ doc['gl:origin'] = origin;
88
+ }
89
+ return doc;
90
+ });
91
+ }
92
+
93
+ function refsToJsonLd(refs) {
94
+ if (!refs || !Array.isArray(refs)) return [];
95
+ return refs.map(r => ({
96
+ '@id': r.id,
97
+ 'gl:term': r.term,
98
+ })).filter(r => r['@id']);
99
+ }
100
+
101
+ const LANG_CODES = ['eng', 'ara', 'deu', 'fra', 'spa', 'ita', 'jpn', 'kor', 'pol', 'por', 'srp', 'swe', 'zho', 'rus', 'fin', 'dan', 'nld', 'msa', 'nob', 'nno', 'zho'];
102
+
103
+ function yamlToJsonLd(conceptYaml, register) {
104
+ const termid = String(conceptYaml.termid);
105
+ const doc = {
106
+ '@context': 'https://glossarist.org/ns/context.jsonld',
107
+ '@id': `https://glossarist.org/${register}/concept/${termid}`,
108
+ '@type': 'gl:Concept',
109
+ 'gl:identifier': termid,
110
+ };
111
+
112
+ const localizations = {};
113
+ for (const lang of LANG_CODES) {
114
+ const lc = conceptYaml[lang];
115
+ if (!lc) continue;
116
+
117
+ const lDoc = {
118
+ '@id': `https://glossarist.org/${register}/concept/${termid}/${lang}`,
119
+ '@type': 'gl:LocalizedConcept',
120
+ 'gl:languageCode': lang,
121
+ };
122
+
123
+ if (lc.entry_status) lDoc['gl:entryStatus'] = lc.entry_status;
124
+ if (lc.terms && lc.terms.length > 0) lDoc['gl:designation'] = lc.terms.map(termToDesignation);
125
+ if (lc.definition) lDoc['gl:definition'] = defsToJsonLd(lc.definition);
126
+ if (lc.notes && lc.notes.length > 0) lDoc['gl:notes'] = defsToJsonLd(lc.notes);
127
+ if (lc.examples && lc.examples.length > 0) lDoc['gl:examples'] = defsToJsonLd(lc.examples);
128
+ if (lc.sources && lc.sources.length > 0) lDoc['gl:source'] = sourcesToJsonLd(lc.sources);
129
+ if (lc.lineage_source_similarity !== undefined) lDoc['gl:lineageSourceSimilarity'] = lc.lineage_source_similarity;
130
+ if (lc.release) lDoc['gl:release'] = lc.release;
131
+ if (lc.review_date) lDoc['gl:reviewDate'] = lc.review_date;
132
+ if (lc.review_decision_date) lDoc['gl:reviewDecisionDate'] = lc.review_decision_date;
133
+ if (lc.review_decision_event) lDoc['gl:reviewDecisionEvent'] = lc.review_decision_event;
134
+ if (lc.review_status) lDoc['gl:reviewStatus'] = lc.review_status;
135
+ if (lc.review_decision) lDoc['gl:reviewDecision'] = lc.review_decision;
136
+ if (lc.review_decision_notes) lDoc['gl:reviewDecisionNotes'] = lc.review_decision_notes;
137
+ if (lc.dates && lc.dates.length > 0) {
138
+ lDoc['gl:dates'] = lc.dates.map(d => ({
139
+ 'gl:dateType': d.type,
140
+ 'gl:date': d.date,
141
+ }));
142
+ }
143
+ if (lc.references && lc.references.length > 0) {
144
+ lDoc['gl:references'] = refsToJsonLd(lc.references);
145
+ }
146
+
147
+ localizations[lang] = lDoc;
148
+ }
149
+
150
+ if (Object.keys(localizations).length > 0) {
151
+ doc['gl:localizedConcept'] = localizations;
152
+ }
153
+
154
+ return doc;
155
+ }
156
+
157
+ function getPrimaryDesignation(conceptYaml) {
158
+ const descs = {};
159
+ for (const lang of LANG_CODES) {
160
+ const lc = conceptYaml[lang];
161
+ if (lc && lc.terms && lc.terms.length > 0) {
162
+ const preferredExpr = lc.terms.find(t => t.normative_status === 'preferred' && t.type === 'expression');
163
+ const preferred = preferredExpr || lc.terms.find(t => t.normative_status === 'preferred') || lc.terms[0];
164
+ descs[lang] = preferred.designation;
165
+ }
166
+ }
167
+ return descs;
168
+ }
169
+
170
+ function getGroups(conceptYaml) {
171
+ if (conceptYaml.eng && conceptYaml.eng.groups) return conceptYaml.eng.groups;
172
+ const termid = String(conceptYaml.termid);
173
+ if (/^\d{3}-/.test(termid)) return [termid.substring(0, 3)];
174
+ if (/^\d+\.\d+\.\d+/.test(termid)) {
175
+ const parts = termid.split('.');
176
+ return [`${parts[0]}.${parts[1]}.${parts[2]}`];
177
+ }
178
+ return [];
179
+ }
180
+
181
+ function escapeTurtle(s) {
182
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
183
+ }
184
+
185
+ function conceptJsonToTurtle(concept) {
186
+ const uri = concept['@id'] || '';
187
+ const id = concept['gl:identifier'] || '';
188
+ const lines = [
189
+ '@prefix skos: <http://www.w3.org/2004/02/skos/core#> .',
190
+ '@prefix dcterms: <http://purl.org/dc/terms/> .',
191
+ '',
192
+ ];
193
+
194
+ const props = [' a skos:Concept'];
195
+ props.push(` skos:notation "${escapeTurtle(id)}"`);
196
+
197
+ for (const [lang, lc] of Object.entries(concept['gl:localizedConcept'] || {})) {
198
+ if (lc['gl:designation']) {
199
+ for (const d of lc['gl:designation']) {
200
+ const term = d['gl:term'];
201
+ if (!term) continue;
202
+ const pred = d['gl:normativeStatus'] === 'preferred' ? 'skos:prefLabel' : 'skos:altLabel';
203
+ props.push(` ${pred} "${escapeTurtle(term)}"@${lang}`);
204
+ }
205
+ }
206
+ if (lc['gl:definition']) {
207
+ for (const d of lc['gl:definition']) {
208
+ if (d['gl:content']) props.push(` skos:definition "${escapeTurtle(d['gl:content'])}"@${lang}`);
209
+ }
210
+ }
211
+ if (lc['gl:notes']) {
212
+ for (const d of lc['gl:notes']) {
213
+ if (d['gl:content']) props.push(` skos:scopeNote "${escapeTurtle(d['gl:content'])}"@${lang}`);
214
+ }
215
+ }
216
+ }
217
+
218
+ lines.push(`<${uri}>`);
219
+ lines.push(props.join(' ;\n'));
220
+ lines.push(' .');
221
+ return lines.join('\n');
222
+ }
223
+
224
+ function processDataset(dir, register, opts) {
225
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.yaml')).sort((a, b) => naturalSort(a.replace('.yaml', ''), b.replace('.yaml', '')));
226
+
227
+ console.log(`Processing ${register}: ${files.length} files`);
228
+
229
+ const conceptsDir = path.join(DATA, register, 'concepts');
230
+ const concepts = [];
231
+ const langTermCounts = {};
232
+ const langDefCounts = {};
233
+ const availableFormats = ['ttl', 'yaml'];
234
+
235
+ for (let i = 0; i < files.length; i++) {
236
+ const file = files[i];
237
+ try {
238
+ const conceptYaml = loadConceptFile(path.join(dir, file));
239
+ if (!conceptYaml || !conceptYaml.termid) continue;
240
+
241
+ const termid = String(conceptYaml.termid);
242
+ const jsonld = yamlToJsonLd(conceptYaml, register);
243
+ writeJson(path.join(conceptsDir, `${termid}.json`), jsonld);
244
+
245
+ // Generate Turtle format
246
+ const ttlContent = conceptJsonToTurtle(jsonld);
247
+ fs.writeFileSync(path.join(conceptsDir, `${termid}.ttl`), ttlContent);
248
+
249
+ // Copy source YAML
250
+ fs.copyFileSync(path.join(dir, file), path.join(conceptsDir, `${termid}.yaml`));
251
+
252
+ concepts.push({
253
+ id: termid,
254
+ designations: getPrimaryDesignation(conceptYaml),
255
+ groups: getGroups(conceptYaml),
256
+ status: conceptYaml.eng?.entry_status || 'valid',
257
+ });
258
+
259
+ for (const lang of opts.languages) {
260
+ const lc = conceptYaml[lang];
261
+ if (lc) {
262
+ if (lc.terms && lc.terms.length > 0) {
263
+ langTermCounts[lang] = (langTermCounts[lang] || 0) + 1;
264
+ }
265
+ if (lc.definition && (Array.isArray(lc.definition) ? lc.definition.some(d => d.content) : lc.definition)) {
266
+ langDefCounts[lang] = (langDefCounts[lang] || 0) + 1;
267
+ }
268
+ }
269
+ }
270
+ } catch (e) {
271
+ console.warn(` Skipping ${file}: ${e.message}`);
272
+ }
273
+ }
274
+
275
+ const CHUNK_SIZE = 500;
276
+ const chunks = [];
277
+ for (let i = 0; i < concepts.length; i += CHUNK_SIZE) {
278
+ const chunk = concepts.slice(i, i + CHUNK_SIZE);
279
+ const chunkIndex = Math.floor(i / CHUNK_SIZE);
280
+ const chunkFile = `index-${String(chunkIndex).padStart(4, '0')}.json`;
281
+ writeJson(path.join(DATA, register, 'chunks', chunkFile), {
282
+ registerId: register,
283
+ chunkIndex,
284
+ concepts: chunk,
285
+ });
286
+ chunks.push({ file: chunkFile, count: chunk.length });
287
+ }
288
+
289
+ const summary = concepts.map(c => ({
290
+ id: c.id,
291
+ eng: c.designations.eng || Object.values(c.designations)[0] || '',
292
+ status: c.status,
293
+ }));
294
+
295
+ const graphNodeEntries = concepts.map(c => {
296
+ let term = '', lang = '';
297
+ if (c.designations.eng) { term = c.designations.eng; lang = 'eng'; }
298
+ else { for (const [l, t] of Object.entries(c.designations)) { if (t) { term = t; lang = l; break; } } }
299
+ return [c.id, term, lang, c.status];
300
+ });
301
+ fs.mkdirSync(path.join(DATA, register), { recursive: true });
302
+ fs.writeFileSync(
303
+ path.join(DATA, register, 'graph-nodes.json'),
304
+ JSON.stringify({
305
+ uriPrefix: `https://glossarist.org/${register}/concept/`,
306
+ registerId: register,
307
+ nodes: graphNodeEntries,
308
+ }),
309
+ );
310
+
311
+ writeJson(path.join(DATA, register, 'index.json'), {
312
+ registerId: register,
313
+ schemaVersion: '1.0.0',
314
+ conceptCount: concepts.length,
315
+ chunkSize: CHUNK_SIZE,
316
+ chunks,
317
+ concepts: summary,
318
+ });
319
+
320
+ writeJson(path.join(DATA, register, 'index-meta.json'), {
321
+ registerId: register,
322
+ schemaVersion: '1.0.0',
323
+ conceptCount: concepts.length,
324
+ chunkSize: CHUNK_SIZE,
325
+ chunks,
326
+ });
327
+
328
+ const langStats = {};
329
+ for (const lang of opts.languages) {
330
+ langStats[lang] = {
331
+ terms: langTermCounts[lang] || 0,
332
+ definitions: langDefCounts[lang] || 0,
333
+ };
334
+ }
335
+
336
+ const manifest = {
337
+ id: register,
338
+ datasetUri: opts.datasetUri,
339
+ uriAliases: opts.uriAliases,
340
+ title: opts.title,
341
+ description: opts.description,
342
+ owner: opts.owner,
343
+ baseUrl: `/data/${register}`,
344
+ languages: opts.languages,
345
+ conceptCount: concepts.length,
346
+ conceptUrlTemplate: '{baseUrl}/concepts/{conceptId}.json',
347
+ indexUrl: '{baseUrl}/index.json',
348
+ contextUrl: 'https://glossarist.org/ns/context.jsonld',
349
+ uriBase: 'https://glossarist.org',
350
+ status: 'valid',
351
+ schemaVersion: '1.0.0',
352
+ tags: opts.tags,
353
+ lastUpdated: new Date().toISOString().split('T')[0],
354
+ sourceRepo: opts.sourceRepo,
355
+ chunkSize: CHUNK_SIZE,
356
+ color: opts.color,
357
+ languageStats: langStats,
358
+ availableFormats,
359
+ };
360
+ if (opts.languageOrder) manifest.languageOrder = opts.languageOrder;
361
+ writeJson(path.join(DATA, register, 'manifest.json'), manifest);
362
+
363
+ console.log(` Generated ${concepts.length} concepts, manifest, ${chunks.length} index chunks`);
364
+ return concepts.length;
365
+ }
366
+
367
+ // --- Main ---
368
+ console.log('Generating Glossarist vocabulary browser data...\n');
369
+
370
+ const { config } = loadSiteConfig();
371
+ const counts = {};
372
+ const registry = [];
373
+
374
+ for (let i = 0; i < config.datasets.length; i++) {
375
+ const ds = config.datasets[i];
376
+
377
+ const dir = path.join(ROOT, '.datasets', ds.id, 'concepts');
378
+ if (!fs.existsSync(dir)) {
379
+ console.warn(`Skipping ${ds.id}: source directory not found (${dir})`);
380
+ console.warn(` Run: npm run fetch-datasets`);
381
+ continue;
382
+ }
383
+
384
+ const registerDir = path.join(ROOT, '.datasets', ds.id);
385
+ const registerYamlPath = path.join(registerDir, 'register.yaml');
386
+ let registerMeta = {};
387
+ if (fs.existsSync(registerYamlPath)) {
388
+ try { registerMeta = readYaml(registerYamlPath) || {}; } catch {}
389
+ }
390
+
391
+ const dsLanguages = ds.languages || (registerMeta.subregisters ? Object.keys(registerMeta.subregisters) : ['eng']);
392
+
393
+ counts[ds.id] = processDataset(dir, ds.id, {
394
+ title: ds.title || registerMeta.name || ds.id,
395
+ description: ds.description || registerMeta.description || '',
396
+ owner: ds.owner,
397
+ languages: dsLanguages,
398
+ sourceRepo: ds.sourceRepo,
399
+ languageOrder: ds.languageOrder,
400
+ tags: ds.tags,
401
+ color: ds.color || DS_PALETTE[i % DS_PALETTE.length],
402
+ datasetUri: ds.uri,
403
+ uriAliases: ds.uriAliases,
404
+ });
405
+ registry.push({ id: ds.id, manifestUrl: `/data/${ds.id}/manifest.json` });
406
+ }
407
+ writeJson(path.join(PUBLIC, 'datasets.json'), registry);
408
+
409
+ // Generate routing.json from site config
410
+ writeJson(path.join(PUBLIC, 'routing.json'), config.routing || []);
411
+ console.log('Generated routing.json');
412
+
413
+ // Copy/download logos
414
+ async function processLogo(logoConfig, filename) {
415
+ if (!logoConfig) return;
416
+ const destDir = path.join(PUBLIC, 'logos');
417
+ fs.mkdirSync(destDir, { recursive: true });
418
+ const destPath = path.join(destDir, filename);
419
+
420
+ // Local file in deployment repo
421
+ if (logoConfig.localPath) {
422
+ const src = path.resolve(process.cwd(), logoConfig.localPath);
423
+ if (fs.existsSync(src)) {
424
+ fs.copyFileSync(src, destPath);
425
+ console.log(` Copied logo: ${src} → ${destPath}`);
426
+ return;
427
+ }
428
+ console.warn(` Logo not found at: ${src}`);
429
+ }
430
+
431
+ // Remote URL
432
+ if (logoConfig.remoteUrl) {
433
+ try {
434
+ console.log(` Downloading logo: ${logoConfig.remoteUrl}`);
435
+ const resp = await fetch(logoConfig.remoteUrl);
436
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
437
+ const buf = Buffer.from(await resp.arrayBuffer());
438
+ fs.writeFileSync(destPath, buf);
439
+ console.log(` Saved logo: ${destPath}`);
440
+ } catch (e) {
441
+ console.warn(` Logo download failed: ${e.message}`);
442
+ }
443
+ }
444
+ }
445
+
446
+ await processLogo(config.branding?.logo, `${config.id}-logo.svg`);
447
+ await processLogo(config.branding?.footerLogo, `${config.id}-footer-logo.svg`);
448
+
449
+ // === Page processors ===
450
+
451
+ function processNewsPage(config, page) {
452
+ const newsDir = page.source
453
+ ? path.resolve(process.cwd(), page.source)
454
+ : config.newsDir
455
+ ? path.resolve(process.cwd(), config.newsDir)
456
+ : null;
457
+
458
+ if (!newsDir || !fs.existsSync(newsDir)) {
459
+ if (newsDir) console.warn(`News directory not found: ${newsDir}`);
460
+ return;
461
+ }
462
+
463
+ const index = [];
464
+ const newsOutDir = path.join(PUBLIC, 'news');
465
+ fs.mkdirSync(newsOutDir, { recursive: true });
466
+ const postFiles = fs.readdirSync(newsDir).filter(f => f.endsWith('.adoc') || f.endsWith('.md')).sort().reverse();
467
+
468
+ for (const file of postFiles) {
469
+ const content = fs.readFileSync(path.join(newsDir, file), 'utf8');
470
+ const frontmatter = {};
471
+ const bodyLines = [];
472
+
473
+ let inFm = false;
474
+ const lines = content.split('\n');
475
+ if (lines[0] === '---') {
476
+ inFm = true;
477
+ for (let i = 1; i < lines.length; i++) {
478
+ if (lines[i] === '---') { inFm = false; continue; }
479
+ if (inFm) {
480
+ const m = lines[i].match(/^(\w[\w\s]*):\s*(.*)/);
481
+ if (m) frontmatter[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
482
+ } else {
483
+ bodyLines.push(lines[i]);
484
+ }
485
+ }
486
+ } else {
487
+ bodyLines.push(...lines);
488
+ }
489
+
490
+ const slug = file.replace(/^\d{4}-\d{2}-\d{2}-/, '').replace(/\.(adoc|md)$/, '');
491
+ const body = bodyLines.join('\n').trim();
492
+
493
+ const ext = path.extname(file);
494
+ const destFile = path.join(newsOutDir, `${slug}${ext}`);
495
+ fs.copyFileSync(path.join(newsDir, file), destFile);
496
+
497
+ index.push({
498
+ slug,
499
+ title: frontmatter.title || slug,
500
+ date: frontmatter.date || '',
501
+ categories: frontmatter.categories ? frontmatter.categories.split(',').map(s => s.trim()) : [],
502
+ file: `/news/${slug}${ext}`,
503
+ excerpt: body.split('\n').find(l => l.trim())?.slice(0, 200) || '',
504
+ });
505
+ }
506
+
507
+ writeJson(path.join(PUBLIC, 'news.json'), index);
508
+ console.log(`Generated news index: ${index.length} posts, ${postFiles.length} files copied to public/news/`);
509
+ }
510
+
511
+ function processContributorsPage(config, page) {
512
+ const contributors = { register: config.id, contributors: [] };
513
+
514
+ for (const ds of config.datasets) {
515
+ const infoYamlPath = path.join(ROOT, '.datasets', ds.id, 'info.yaml');
516
+ if (!fs.existsSync(infoYamlPath)) continue;
517
+
518
+ try {
519
+ const info = readYaml(infoYamlPath);
520
+ if (info.header) {
521
+ contributors.owner = info.header['register-owner'];
522
+ contributors.manager = info.header['register-manager'];
523
+ }
524
+ if (info.languages) {
525
+ for (const [lang, data] of Object.entries(info.languages)) {
526
+ contributors.contributors.push({
527
+ language: lang,
528
+ registerName: data['register-name'] || '',
529
+ organization: data['submitting-organisation-name'] || '',
530
+ contact: data['submitting-organisation-contact'] || '',
531
+ email: data['submitting-organisation-contact-email'] || '',
532
+ uri: data['uniform-resource-identifier-uri'] || '',
533
+ country: data['operating-language-country'] || '',
534
+ });
535
+ }
536
+ }
537
+ } catch (e) {
538
+ console.warn(` Skipping contributors for ${ds.id}: ${e.message}`);
539
+ }
540
+ }
541
+
542
+ if (contributors.contributors.length > 0 || contributors.owner) {
543
+ writeJson(path.join(PUBLIC, 'contributors.json'), contributors);
544
+ console.log(`Generated contributors: ${contributors.contributors.length} languages`);
545
+ }
546
+ }
547
+
548
+ const pageProcessors = {
549
+ news: processNewsPage,
550
+ contributors: processContributorsPage,
551
+ };
552
+
553
+ function synthesizePages(config) {
554
+ const pages = [];
555
+ if (config.newsDir) pages.push({ type: 'news', route: 'news', title: 'News', icon: 'newspaper' });
556
+ return pages;
557
+ }
558
+
559
+ function processPages(config) {
560
+ const pages = config.pages || synthesizePages(config);
561
+ for (const page of pages) {
562
+ const processor = pageProcessors[page.type];
563
+ if (processor) processor(config, page);
564
+ }
565
+ return pages;
566
+ }
567
+
568
+ const processedPages = processPages(config);
569
+
570
+ // Generate site-config.json from site config
571
+ const siteBranding = { ...config.branding };
572
+ // Rewrite logo paths to downloaded filenames
573
+ if (siteBranding.logo?.remoteUrl) {
574
+ siteBranding.logo = { ...siteBranding.logo, path: `/logos/${config.id}-logo.svg` };
575
+ delete siteBranding.logo.remoteUrl;
576
+ }
577
+ if (siteBranding.footerLogo?.remoteUrl) {
578
+ siteBranding.footerLogo = { ...siteBranding.footerLogo, path: `/logos/${config.id}-footer-logo.svg` };
579
+ delete siteBranding.footerLogo.remoteUrl;
580
+ }
581
+
582
+ writeJson(path.join(PUBLIC, 'site-config.json'), {
583
+ id: config.id,
584
+ domain: config.domain,
585
+ title: config.title,
586
+ subtitle: config.subtitle,
587
+ description: config.description,
588
+ datasets: config.datasets.map(d => d.id),
589
+ defaultDataset: config.datasets.length === 1 ? config.datasets[0].id : undefined,
590
+ branding: siteBranding,
591
+ analytics: config.analytics,
592
+ features: config.features,
593
+ social: config.social,
594
+ nav: config.nav,
595
+ footerNav: config.footerNav,
596
+ defaults: config.defaults,
597
+ email: config.email,
598
+ pages: processedPages.length > 0 ? processedPages : undefined,
599
+ });
600
+ console.log('Generated site-config.json');
601
+
602
+ const total = Object.values(counts).reduce((s, n) => s + n, 0);
603
+ console.log(`\nDone! Generated data for ${total} concepts across ${registry.length} datasets.`);
604
+ for (const [id, count] of Object.entries(counts)) {
605
+ console.log(` ${id}: ${count} concepts`);
606
+ }
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Site config discovery and loading.
5
+ *
6
+ * Resolution order:
7
+ * 1. SITE_CONFIG env var → file path directly
8
+ * 2. SITE_ID env var → configs/{SITE_ID}.yml
9
+ * 3. --site CLI flag → same as SITE_ID
10
+ * 4. Fallback → site-config.yml in project root
11
+ */
12
+
13
+ import { readFileSync, existsSync } from 'fs';
14
+ import { resolve, dirname } from 'path';
15
+ import { fileURLToPath } from 'url';
16
+ import yaml from 'js-yaml';
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+ const projectRoot = resolve(__dirname, '..');
20
+
21
+ function findConfigFile(args = []) {
22
+ if (process.env.SITE_CONFIG) {
23
+ return resolve(process.env.SITE_CONFIG);
24
+ }
25
+
26
+ const siteId = process.env.SITE_ID || args.find(a => !a.startsWith('-')) || null;
27
+ if (siteId) {
28
+ // Check project configs/ dir first
29
+ const p = resolve(projectRoot, 'configs', `${siteId}.yml`);
30
+ if (existsSync(p)) return p;
31
+ // Check CWD (deployment repo may have configs locally)
32
+ for (const name of [`${siteId}.yml`, 'site-config.yml']) {
33
+ const cwdP = resolve(process.cwd(), name);
34
+ if (existsSync(cwdP)) return cwdP;
35
+ }
36
+ throw new Error(`Site config not found for '${siteId}'. Checked configs/${siteId}.yml, ${siteId}.yml, site-config.yml in CWD`);
37
+ }
38
+
39
+ // Check CWD first (deployment repos), then project root
40
+ for (const dir of [process.cwd(), projectRoot]) {
41
+ const p = resolve(dir, 'site-config.yml');
42
+ if (existsSync(p)) return p;
43
+ }
44
+
45
+ throw new Error('No site config found. Set SITE_CONFIG, SITE_ID, or create site-config.yml');
46
+ }
47
+
48
+ export function loadSiteConfig(args = []) {
49
+ const configPath = findConfigFile(args);
50
+ const raw = yaml.load(readFileSync(configPath, 'utf-8'));
51
+ return { config: raw, configPath };
52
+ }
53
+
54
+ export function getProjectRoot() {
55
+ return projectRoot;
56
+ }