@glossarist/concept-browser 0.7.44 → 0.7.46

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 (56) hide show
  1. package/package.json +2 -2
  2. package/scripts/generate-data.mjs +22 -13
  3. package/scripts/lib/build/image-assets.mjs +190 -0
  4. package/scripts/lib/build/non-verbal-consumer.mjs +221 -0
  5. package/src/__tests__/bibliography-adapter.test.ts +79 -0
  6. package/src/__tests__/content-renderer-nvr-mentions.test.ts +57 -0
  7. package/src/__tests__/locale.test.ts +46 -0
  8. package/src/__tests__/model-bridge-entity-refs.test.ts +114 -0
  9. package/src/__tests__/non-verbal-anchor.test.ts +33 -0
  10. package/src/__tests__/non-verbal-cross-ref.test.ts +146 -0
  11. package/src/__tests__/non-verbal-highlight.test.ts +56 -0
  12. package/src/__tests__/non-verbal-kind.test.ts +77 -0
  13. package/src/__tests__/non-verbal-list.test.ts +67 -0
  14. package/src/__tests__/non-verbal-rep-display.test.ts +85 -0
  15. package/src/__tests__/non-verbal-scroll-guard.test.ts +116 -0
  16. package/src/__tests__/use-concept-entities.test.ts +76 -0
  17. package/src/adapters/bibliography-adapter.ts +49 -0
  18. package/src/adapters/factory.ts +14 -0
  19. package/src/adapters/model-bridge.ts +51 -0
  20. package/src/adapters/non-verbal/figure-bridge.ts +101 -0
  21. package/src/adapters/non-verbal/formula-bridge.ts +48 -0
  22. package/src/adapters/non-verbal/index.ts +55 -0
  23. package/src/adapters/non-verbal/kind.ts +46 -0
  24. package/src/adapters/non-verbal/prefix.ts +67 -0
  25. package/src/adapters/non-verbal/source-bridge.ts +81 -0
  26. package/src/adapters/non-verbal/table-bridge.ts +98 -0
  27. package/src/adapters/non-verbal/types.ts +133 -0
  28. package/src/adapters/non-verbal-resolver.ts +101 -0
  29. package/src/components/ConceptDetail.vue +17 -4
  30. package/src/components/LanguageDetail.vue +0 -3
  31. package/src/components/NonVerbalRepDisplay.vue +82 -24
  32. package/src/components/figure/FigureDisplay.vue +132 -0
  33. package/src/components/figure/FigureImages.vue +111 -0
  34. package/src/components/figure/figure-image-pick.ts +56 -0
  35. package/src/components/figure/figure-layout.ts +26 -0
  36. package/src/components/formula/FormulaDisplay.vue +90 -0
  37. package/src/components/formula/FormulaExpression.vue +70 -0
  38. package/src/components/non-verbal/NonVerbalCaption.vue +104 -0
  39. package/src/components/non-verbal/NonVerbalFallback.vue +69 -0
  40. package/src/components/non-verbal/NonVerbalList.vue +118 -0
  41. package/src/components/non-verbal/NonVerbalSources.vue +61 -0
  42. package/src/components/table/TableDisplay.vue +99 -0
  43. package/src/components/table/TableMarkup.vue +63 -0
  44. package/src/components/table/TableStructured.vue +66 -0
  45. package/src/composables/use-concept-entities.ts +70 -0
  46. package/src/composables/use-non-verbal-cross-ref.ts +79 -0
  47. package/src/composables/use-non-verbal-entity.ts +58 -0
  48. package/src/composables/use-reduced-motion.ts +26 -0
  49. package/src/composables/use-render-options.ts +30 -33
  50. package/src/router/index.ts +3 -0
  51. package/src/router/non-verbal-scroll-guard.ts +56 -0
  52. package/src/style.css +17 -0
  53. package/src/utils/content-renderer.ts +76 -64
  54. package/src/utils/locale.ts +92 -0
  55. package/src/utils/non-verbal-anchor.ts +51 -0
  56. package/src/utils/non-verbal-highlight.ts +27 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.44",
3
+ "version": "0.7.46",
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": {
@@ -25,7 +25,7 @@
25
25
  "autoprefixer": "^10.4.21",
26
26
  "d3": "^7.9.0",
27
27
  "favicons": "^7.2.0",
28
- "glossarist": "^0.3.7",
28
+ "glossarist": "^0.4.0",
29
29
  "js-yaml": "^4.1.0",
30
30
  "jszip": "^3.10.1",
31
31
  "pinia": "^2.3.1",
@@ -4,6 +4,8 @@ import yaml from 'js-yaml';
4
4
  import { naturalSort, Register, parseMention } from 'glossarist';
5
5
  import { loadSiteConfig } from './load-site-config.mjs';
6
6
  import { getGroups } from './lib/concept-groups.mjs';
7
+ import { consumeDatasetEntities } from './lib/build/non-verbal-consumer.mjs';
8
+ import { copyImageAssets } from './lib/build/image-assets.mjs';
7
9
  const __dirname = path.dirname(new URL(import.meta.url).pathname);
8
10
  const ROOT = process.cwd();
9
11
  const PUBLIC = path.join(ROOT, 'public');
@@ -781,7 +783,7 @@ ${bodyEntries}
781
783
  `;
782
784
  }
783
785
 
784
- function processDataset(dir, register, opts) {
786
+ async function processDataset(dir, register, opts) {
785
787
  const files = fs.readdirSync(dir).filter(f => f.endsWith('.yaml')).sort((a, b) => naturalSort(a.replace('.yaml', ''), b.replace('.yaml', '')));
786
788
 
787
789
  console.log(`Processing ${register}: ${files.length} files`);
@@ -1010,23 +1012,30 @@ function processDataset(dir, register, opts) {
1010
1012
  if (fs.existsSync(bibPath)) {
1011
1013
  const bibData = readYaml(bibPath);
1012
1014
  writeJson(path.join(DATA, register, 'bibliography.json'), bibData);
1013
- console.log(` Copied bibliography (${Object.keys(bibData).length} entries)`);
1015
+ const bibCount = Array.isArray(bibData?.bibliography) ? bibData.bibliography.length : 0;
1016
+ console.log(` Copied bibliography (${bibCount} entries)`);
1014
1017
  }
1015
1018
 
1016
- // Copy images/
1019
+ // Copy images/ with magic-byte validation + manifest emission.
1017
1020
  const imagesSrcDir = path.join(sourceRoot, 'images');
1018
1021
  if (fs.existsSync(imagesSrcDir) && fs.statSync(imagesSrcDir).isDirectory()) {
1019
1022
  const imagesDestDir = path.join(DATA, register, 'images');
1020
- fs.mkdirSync(imagesDestDir, { recursive: true });
1021
- let imgCount = 0;
1022
- for (const file of fs.readdirSync(imagesSrcDir)) {
1023
- const src = path.join(imagesSrcDir, file);
1024
- if (fs.statSync(src).isFile()) {
1025
- fs.copyFileSync(src, path.join(imagesDestDir, file));
1026
- imgCount++;
1027
- }
1023
+ const result = await copyImageAssets(imagesSrcDir, imagesDestDir);
1024
+ console.log(` Copied ${result.count} images (skipped ${result.skipped.length})`);
1025
+ for (const w of result.skipped) {
1026
+ console.warn(` Warning: skipped image ${w}`);
1027
+ }
1028
+ }
1029
+
1030
+ // Consume non-verbal entities (figures/tables/formulas) — JSON-LD preferred,
1031
+ // YAML fallback. Writes per-entity JSON + indexes.
1032
+ const nvResult = await consumeDatasetEntities(sourceRoot, path.join(DATA, register));
1033
+ const nvTotal = nvResult.figures + nvResult.tables + nvResult.formulas;
1034
+ if (nvTotal > 0) {
1035
+ console.log(` Consumed ${nvResult.figures} figures, ${nvResult.tables} tables, ${nvResult.formulas} formulas`);
1036
+ for (const w of nvResult.warnings) {
1037
+ console.warn(` Warning: ${w}`);
1028
1038
  }
1029
- console.log(` Copied ${imgCount} images`);
1030
1039
  }
1031
1040
 
1032
1041
  console.log(` Generated ${concepts.length} concepts, manifest, ${chunks.length} index chunks`);
@@ -1086,7 +1095,7 @@ for (let i = 0; i < config.datasets.length; i++) {
1086
1095
  // Resolve title: site-config override, then ref from register
1087
1096
  const resolvedTitle = ds.title || reg?.ref || ds.id;
1088
1097
 
1089
- counts[ds.id] = processDataset(dir, ds.id, {
1098
+ counts[ds.id] = await processDataset(dir, ds.id, {
1090
1099
  title: resolvedTitle,
1091
1100
  description: resolvedDescription,
1092
1101
  owner: ds.owner || reg?.owner,
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Image asset pipeline — pure Node, no shell commands.
3
+ *
4
+ * Copies image bytes from the source directory to the runtime data
5
+ * directory, validates magic bytes for known formats, parses intrinsic
6
+ * dimensions for raster formats, and emits `images-manifest.json` with
7
+ * SHA-256 hashes + dimensions.
8
+ *
9
+ * If the source directory contains its own `manifest.json`, that file is
10
+ * reused as the SSOT (single source of truth) and only missing entries
11
+ * are computed. This lets glossarist-ruby ship a manifest if it wants to.
12
+ */
13
+
14
+ import { createHash } from 'node:crypto';
15
+ import { readFile, writeFile, mkdir, readdir, stat, copyFile } from 'node:fs/promises';
16
+ import path from 'node:path';
17
+
18
+ /** @typedef {{ src: string, format: string, sha256: string, width?: number, height?: number, bytes: number }} ImageManifestEntry */
19
+
20
+ export const SUPPORTED_FORMATS = new Set(['svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif']);
21
+
22
+ const MAGIC_BYTES = {
23
+ png: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
24
+ jpg: [0xFF, 0xD8, 0xFF],
25
+ gif: [0x47, 0x49, 0x46, 0x38],
26
+ webp: [0x52, 0x49, 0x46, 0x46], // "RIFF" — full match needs bytes 8-11 = "WEBP"
27
+ };
28
+
29
+ const FILENAME_RE = /^[a-z0-9._-]+$/i;
30
+
31
+ /**
32
+ * Validate the file's magic bytes against its declared extension.
33
+ * Returns the canonical format (e.g. "jpg" for ".jpeg"), or null if
34
+ * validation fails.
35
+ *
36
+ * @param {Buffer} buf
37
+ * @param {string} ext
38
+ * @returns {string | null}
39
+ */
40
+ export function detectFormat(buf, ext) {
41
+ const e = ext.toLowerCase();
42
+ if (e === 'svg') return buf.includes('<svg') || buf.includes('<SVG') ? 'svg' : null;
43
+ if (e === 'png' || e === 'jpg' || e === 'jpeg' || e === 'gif' || e === 'webp') {
44
+ const png = MAGIC_BYTES.png;
45
+ if (e === 'png' && buf.length >= png.length && png.every((b, i) => buf[i] === b)) return 'png';
46
+ const jpg = MAGIC_BYTES.jpg;
47
+ if ((e === 'jpg' || e === 'jpeg') && buf.length >= jpg.length && jpg.every((b, i) => buf[i] === b)) return 'jpg';
48
+ const gif = MAGIC_BYTES.gif;
49
+ if (e === 'gif' && buf.length >= gif.length && gif.every((b, i) => buf[i] === b)) return 'gif';
50
+ if (e === 'webp' && buf.length >= 12 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') return 'webp';
51
+ }
52
+ // Unknown formats (avif, etc.) — accept on trust; authors are responsible.
53
+ if (SUPPORTED_FORMATS.has(e)) return e;
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * Parse intrinsic dimensions for PNG/JPEG. Returns null for unknown formats.
59
+ * WebP/AVIF parsing is intentionally V1-light — authors declare via YAML.
60
+ *
61
+ * @param {Buffer} buf
62
+ * @param {string} format
63
+ * @returns {{ width?: number, height?: number }}
64
+ */
65
+ export function readIntrinsicDimensions(buf, format) {
66
+ if (format === 'png' && buf.length >= 24) {
67
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
68
+ }
69
+ if (format === 'jpg' || format === 'jpeg') {
70
+ return readJpegDimensions(buf);
71
+ }
72
+ return {};
73
+ }
74
+
75
+ function readJpegDimensions(buf) {
76
+ // Scan JPEG markers for SOFn (0xFFC0–0xFFCF, excluding 0xFFC4, 0xFFC8, 0xFFCC).
77
+ let i = 2;
78
+ while (i < buf.length - 9) {
79
+ if (buf[i] !== 0xFF) { i++; continue; }
80
+ const marker = buf[i + 1];
81
+ if (marker >= 0xC0 && marker <= 0xCF &&
82
+ marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
83
+ const height = buf.readUInt16BE(i + 5);
84
+ const width = buf.readUInt16BE(i + 7);
85
+ return { width, height };
86
+ }
87
+ const segLen = buf.readUInt16BE(i + 2);
88
+ i += 2 + segLen;
89
+ }
90
+ return {};
91
+ }
92
+
93
+ /**
94
+ * Sanitize an image filename per the wire-format rule
95
+ * `[a-z0-9._-]+`. Returns the sanitized name (lowercased, with unsafe
96
+ * characters replaced by `-`).
97
+ *
98
+ * @param {string} name
99
+ * @returns {string}
100
+ */
101
+ export function sanitizeImageFilename(name) {
102
+ const lowered = name.toLowerCase();
103
+ const sanitized = lowered.replace(/[^a-z0-9._-]/g, '-').replace(/-+/g, '-');
104
+ return sanitized.replace(/^-+|-+$/g, '');
105
+ }
106
+
107
+ function sha256(buf) {
108
+ return createHash('sha256').update(buf).digest('hex');
109
+ }
110
+
111
+ /**
112
+ * Copy image assets from srcDir into destDir, validate, and emit
113
+ * images-manifest.json. Reuses upstream manifest if present.
114
+ *
115
+ * @param {string} srcDir Absolute path to source images/
116
+ * @param {string} destDir Absolute path to public/data/{ds}/images/
117
+ * @returns {Promise<{ count: number, manifest: Record<string, ImageManifestEntry>, skipped: string[] }>}
118
+ */
119
+ export async function copyImageAssets(srcDir, destDir) {
120
+ await mkdir(destDir, { recursive: true });
121
+ const entries = await readdir(srcDir, { withFileTypes: true });
122
+
123
+ // Reuse upstream manifest if present (SSOT).
124
+ const upstreamManifestPath = path.join(srcDir, 'manifest.json');
125
+ let upstream = null;
126
+ try {
127
+ const st = await stat(upstreamManifestPath);
128
+ if (st.isFile()) {
129
+ upstream = JSON.parse(await readFile(upstreamManifestPath, 'utf8'));
130
+ }
131
+ } catch { /* no upstream manifest — compute fresh */ }
132
+
133
+ /** @type {Record<string, ImageManifestEntry>} */
134
+ const manifest = {};
135
+ const skipped = [];
136
+ let count = 0;
137
+
138
+ for (const entry of entries) {
139
+ if (!entry.isFile()) continue;
140
+ if (entry.name === 'manifest.json') continue;
141
+
142
+ const safeName = sanitizeImageFilename(entry.name);
143
+ if (!FILENAME_RE.test(safeName)) {
144
+ skipped.push(entry.name);
145
+ continue;
146
+ }
147
+ const ext = path.extname(safeName).slice(1);
148
+ if (!ext || !SUPPORTED_FORMATS.has(ext)) {
149
+ skipped.push(entry.name);
150
+ continue;
151
+ }
152
+
153
+ const src = path.join(srcDir, entry.name);
154
+ const dest = path.join(destDir, safeName);
155
+ const buf = await readFile(src);
156
+
157
+ const format = detectFormat(buf, ext);
158
+ if (!format) {
159
+ skipped.push(`${entry.name} (format mismatch)`);
160
+ continue;
161
+ }
162
+
163
+ await copyFile(src, dest);
164
+
165
+ /** @type {ImageManifestEntry} */
166
+ let entry_record;
167
+ if (upstream && upstream[safeName]) {
168
+ entry_record = { ...upstream[safeName], src: safeName, format };
169
+ } else {
170
+ const dims = readIntrinsicDimensions(buf, format);
171
+ entry_record = {
172
+ src: safeName,
173
+ format,
174
+ sha256: sha256(buf),
175
+ bytes: buf.length,
176
+ ...dims,
177
+ };
178
+ }
179
+ manifest[safeName] = entry_record;
180
+ count++;
181
+ }
182
+
183
+ await mkdir(destDir, { recursive: true });
184
+ await writeFile(
185
+ path.join(destDir, '..', 'images-manifest.json'),
186
+ JSON.stringify(manifest, null, 2),
187
+ );
188
+
189
+ return { count, manifest, skipped };
190
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Non-verbal entity consumer — reads source entities (JSON-LD preferred,
3
+ * YAML fallback) and writes per-entity JSON + indexes to public/data/{ds}/.
4
+ *
5
+ * Source layout (preferred — produced by `glossarist export`):
6
+ * {sourceRoot}/figures/{id}.json
7
+ * {sourceRoot}/tables/{id}.json
8
+ * {sourceRoot}/formulas/{id}.json
9
+ *
10
+ * Fallback layout (raw glossarist YAML — converted minimally until
11
+ * glossarist-ruby publishes export support for these entities):
12
+ * {sourceRoot}/figures/{id}.yaml
13
+ * {sourceRoot}/tables/{id}.yaml
14
+ * {sourceRoot}/formulas/{id}.yaml
15
+ *
16
+ * Output layout:
17
+ * public/data/{ds}/figures/{id}.json
18
+ * public/data/{ds}/tables/{id}.json
19
+ * public/data/{ds}/formulas/{id}.json
20
+ * public/data/{ds}/figures-index.json { id: filename }
21
+ * public/data/{ds}/tables-index.json
22
+ * public/data/{ds}/formulas-index.json
23
+ */
24
+
25
+ import { readFile, writeFile, mkdir, readdir, stat } from 'node:fs/promises';
26
+ import path from 'node:path';
27
+ import yaml from 'js-yaml';
28
+
29
+ /** @typedef {'figure' | 'table' | 'formula'} NonVerbalKind */
30
+
31
+ /** @type {Record<NonVerbalKind, { dir: string, type: string }>} */
32
+ const KIND_META = {
33
+ figure: { dir: 'figures', type: 'Figure' },
34
+ table: { dir: 'tables', type: 'Table' },
35
+ formula: { dir: 'formulas', type: 'Formula' },
36
+ };
37
+
38
+ /**
39
+ * Convert a raw glossarist YAML entity to a JSON-LD-style document.
40
+ * This is a temporary consumer-side bridge until glossarist-ruby's
41
+ * `glossarist export` ships entity JSON-LD output natively.
42
+ *
43
+ * The output matches the wire format documented in TODO.figures/11-round-trip-spec.md.
44
+ *
45
+ * @param {Record<string, any>} yaml Parsed YAML entity.
46
+ * @param {NonVerbalKind} kind
47
+ * @returns {Record<string, any>} JSON-LD-style document.
48
+ */
49
+ function yamlEntityToJsonLd(yaml, kind) {
50
+ const meta = KIND_META[kind];
51
+ const doc = {
52
+ '@type': `gl:${meta.type}`,
53
+ 'gl:id': yaml.id,
54
+ };
55
+ if (yaml.identifier) doc['gl:identifier'] = yaml.identifier;
56
+ if (yaml.caption) doc['gl:caption'] = yaml.caption;
57
+ if (yaml.alt) doc['gl:altText'] = yaml.alt;
58
+ if (yaml.description) doc['gl:description'] = yaml.description;
59
+ if (Array.isArray(yaml.sources) && yaml.sources.length) {
60
+ doc['gl:source'] = yaml.sources.map(s => yamlSourceToJsonLd(s));
61
+ }
62
+
63
+ if (kind === 'figure') {
64
+ if (Array.isArray(yaml.images)) {
65
+ doc['gl:image'] = yaml.images.map(img => ({
66
+ 'gl:src': img.src,
67
+ 'gl:format': img.format,
68
+ ...(img.role ? { 'gl:role': img.role } : {}),
69
+ ...(img.width != null ? { 'gl:width': img.width } : {}),
70
+ ...(img.height != null ? { 'gl:height': img.height } : {}),
71
+ ...(img.scale != null ? { 'gl:scale': img.scale } : {}),
72
+ }));
73
+ }
74
+ if (Array.isArray(yaml.subfigures) && yaml.subfigures.length) {
75
+ doc['gl:subfigure'] = yaml.subfigures.map(sub => yamlEntityToJsonLd(sub, 'figure'));
76
+ }
77
+ } else if (kind === 'table') {
78
+ if (yaml.content) {
79
+ const c = { ...yaml.content };
80
+ if (c.type) {
81
+ c['gl:type'] = c.type;
82
+ delete c.type;
83
+ }
84
+ doc['gl:content'] = c;
85
+ }
86
+ if (yaml.format) doc['gl:format'] = yaml.format;
87
+ } else if (kind === 'formula') {
88
+ if (yaml.expression) doc['gl:expression'] = yaml.expression;
89
+ if (yaml.notation) doc['gl:notation'] = yaml.notation;
90
+ }
91
+
92
+ return doc;
93
+ }
94
+
95
+ function yamlSourceToJsonLd(s) {
96
+ const out = {};
97
+ if (s.id) out['gl:id'] = s.id;
98
+ if (s.type) out['gl:sourceType'] = s.type;
99
+ if (s.status) out['gl:sourceStatus'] = s.status;
100
+ if (s.modification) out['gl:modification'] = s.modification;
101
+ if (s.origin) {
102
+ const o = {};
103
+ if (s.origin.ref) {
104
+ o['gl:ref'] = typeof s.origin.ref === 'string'
105
+ ? s.origin.ref
106
+ : {
107
+ ...(s.origin.ref.source ? { 'gl:source': s.origin.ref.source } : {}),
108
+ ...(s.origin.ref.id ? { 'gl:id': s.origin.ref.id } : {}),
109
+ ...(s.origin.ref.version ? { 'gl:version': s.origin.ref.version } : {}),
110
+ ...(s.origin.ref.text ? { 'gl:text': s.origin.ref.text } : {}),
111
+ };
112
+ }
113
+ if (s.origin.locality) {
114
+ const l = s.origin.locality;
115
+ o['gl:locality'] = {
116
+ ...(l.type ? { 'gl:localityType': l.type } : {}),
117
+ ...(l.reference_from ? { 'gl:referenceFrom': l.reference_from } : {}),
118
+ ...(l.reference_to ? { 'gl:referenceTo': l.reference_to } : {}),
119
+ };
120
+ }
121
+ if (s.origin.link) o['gl:link'] = s.origin.link;
122
+ if (Object.keys(o).length) out['gl:origin'] = o;
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /**
128
+ * Read entities of one kind from sourceDir. Tries JSON first, falls back to YAML.
129
+ *
130
+ * @param {string} sourceDir
131
+ * @param {NonVerbalKind} kind
132
+ * @returns {Promise<Array<{ id: string, doc: Record<string, any> }>>}
133
+ */
134
+ async function readEntitiesForKind(sourceDir, kind) {
135
+ const meta = KIND_META[kind];
136
+ const dir = path.join(sourceDir, meta.dir);
137
+ let files;
138
+ try {
139
+ files = await readdir(dir);
140
+ } catch {
141
+ return [];
142
+ }
143
+
144
+ const out = [];
145
+ for (const file of files) {
146
+ const full = path.join(dir, file);
147
+ const st = await stat(full);
148
+ if (!st.isFile()) continue;
149
+ const ext = path.extname(file).toLowerCase();
150
+ const id = path.basename(file, ext);
151
+ if (ext === '.json') {
152
+ try {
153
+ const doc = JSON.parse(await readFile(full, 'utf8'));
154
+ out.push({ id, doc });
155
+ } catch (e) {
156
+ console.warn(` Warning: failed to parse ${full}: ${e.message}`);
157
+ }
158
+ } else if (ext === '.yaml' || ext === '.yml') {
159
+ try {
160
+ const raw = yaml.load(await readFile(full, 'utf8'));
161
+ if (!raw || typeof raw !== 'object') continue;
162
+ out.push({ id, doc: yamlEntityToJsonLd(raw, kind) });
163
+ } catch (e) {
164
+ console.warn(` Warning: failed to parse ${full}: ${e.message}`);
165
+ }
166
+ }
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /**
172
+ * Consume non-verbal entities from sourceDir and write to public/data/{ds}/.
173
+ *
174
+ * @param {string} sourceRoot Absolute path to the dataset source root.
175
+ * @param {string} destRoot Absolute path to public/data/{ds}/.
176
+ * @returns {Promise<{ figures: number, tables: number, formulas: number, warnings: string[] }>}
177
+ */
178
+ export async function consumeDatasetEntities(sourceRoot, destRoot) {
179
+ const counts = { figures: 0, tables: 0, formulas: 0 };
180
+ const warnings = [];
181
+
182
+ for (const kind of /** @type {NonVerbalKind[]} */ (['figure', 'table', 'formula'])) {
183
+ const meta = KIND_META[kind];
184
+ const entities = await readEntitiesForKind(sourceRoot, kind);
185
+ if (entities.length === 0) continue;
186
+
187
+ const outDir = path.join(destRoot, meta.dir);
188
+ await mkdir(outDir, { recursive: true });
189
+
190
+ const index = {};
191
+ for (const { id, doc } of entities) {
192
+ const docId = doc['gl:id'] ?? doc['gloss:id'] ?? id;
193
+ if (docId !== id) {
194
+ warnings.push(`${meta.dir}/${id}: gl:id (${docId}) does not match filename`);
195
+ }
196
+ const dest = path.join(outDir, `${id}.json`);
197
+ await writeFile(dest, JSON.stringify(doc, null, 2));
198
+ index[id] = `${meta.dir}/${id}.json`;
199
+
200
+ // Basic validation warnings — non-fatal, surface to authors.
201
+ if (!doc[`gl:${kind === 'figure' ? 'altText' : ''}`] && kind === 'figure') {
202
+ warnings.push(`${meta.dir}/${id}: missing gl:altText (accessibility)`);
203
+ }
204
+ if (!doc['gl:caption']) {
205
+ warnings.push(`${meta.dir}/${id}: missing gl:caption`);
206
+ }
207
+ }
208
+
209
+ const indexDest = path.join(destRoot, `${meta.dir}-index.json`);
210
+ await writeFile(indexDest, JSON.stringify(index, null, 2));
211
+
212
+ counts[meta.dir] = entities.length;
213
+ }
214
+
215
+ return {
216
+ figures: counts.figures,
217
+ tables: counts.tables,
218
+ formulas: counts.formulas,
219
+ warnings,
220
+ };
221
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { BibliographyAdapter } from '../adapters/bibliography-adapter';
3
+
4
+ function makeResponse(status: number, body: unknown): Response {
5
+ return {
6
+ ok: status >= 200 && status < 300,
7
+ status,
8
+ json: async () => body,
9
+ } as Response;
10
+ }
11
+
12
+ describe('BibliographyAdapter', () => {
13
+ beforeEach(() => {
14
+ vi.restoreAllMocks();
15
+ });
16
+
17
+ it('returns null for findById before load', () => {
18
+ const a = new BibliographyAdapter('iso', {
19
+ basePath: '/',
20
+ fetcher: async () => makeResponse(200, { bibliography: [] }),
21
+ });
22
+ expect(a.findById('ref-1')).toBeNull();
23
+ expect(a.all()).toEqual([]);
24
+ });
25
+
26
+ it('loads bibliography once and deduplicates subsequent calls', async () => {
27
+ let calls = 0;
28
+ const fetcher = vi.fn(async () => {
29
+ calls++;
30
+ return makeResponse(200, {
31
+ bibliography: [
32
+ { id: 'ref-1', title: 'ISO Standard', type: 'misc' },
33
+ ],
34
+ });
35
+ });
36
+ const a = new BibliographyAdapter('iso', { basePath: '/', fetcher });
37
+ await a.load();
38
+ await a.load();
39
+ expect(calls).toBe(1);
40
+ expect(a.findById('ref-1')?.id).toBe('ref-1');
41
+ });
42
+
43
+ it('treats 404 as missing bibliography — no throw, findById returns null', async () => {
44
+ const fetcher = vi.fn(async () => makeResponse(404, {}));
45
+ const a = new BibliographyAdapter('iso', { basePath: '/', fetcher });
46
+ await expect(a.load()).resolves.toBeUndefined();
47
+ expect(a.findById('ref-1')).toBeNull();
48
+ });
49
+
50
+ it('swallows network errors — loaded becomes true to prevent retry storms', async () => {
51
+ const fetcher = vi.fn(async () => { throw new Error('boom'); });
52
+ const a = new BibliographyAdapter('iso', { basePath: '/', fetcher });
53
+ await a.load();
54
+ expect(a.all()).toEqual([]);
55
+ await a.load();
56
+ expect(fetcher).toHaveBeenCalledTimes(1);
57
+ });
58
+
59
+ it('clear() resets state so the next load() refetches', async () => {
60
+ let calls = 0;
61
+ const fetcher = vi.fn(async () => {
62
+ calls++;
63
+ return makeResponse(200, { bibliography: [{ id: 'x' }] });
64
+ });
65
+ const a = new BibliographyAdapter('iso', { basePath: '/', fetcher });
66
+ await a.load();
67
+ a.clear();
68
+ expect(a.findById('x')).toBeNull();
69
+ await a.load();
70
+ expect(calls).toBe(2);
71
+ });
72
+
73
+ it('builds URLs from basePath + datasetId', async () => {
74
+ const fetcher = vi.fn(async () => makeResponse(200, { bibliography: [] }));
75
+ const a = new BibliographyAdapter('isotc204', { basePath: '/cb/', fetcher });
76
+ await a.load();
77
+ expect(fetcher).toHaveBeenCalledWith('/cb/data/isotc204/bibliography.json');
78
+ });
79
+ });
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderContent } from '../utils/content-renderer';
3
+ import type { RenderOptions } from '../utils/content-renderer';
4
+ import type { NonVerbalKind } from '../adapters/non-verbal/types';
5
+
6
+ type NvResolver = (kind: NonVerbalKind, id: string, display?: string) => string;
7
+
8
+ function makeOpts(nonVerbalRefResolver?: NvResolver): RenderOptions {
9
+ return { nonVerbalRefResolver };
10
+ }
11
+
12
+ describe('content-renderer — non-verbal mentions', () => {
13
+ it('dispatches {{fig:id}} to nonVerbalRefResolver as figure', () => {
14
+ const calls: Array<[NonVerbalKind, string, string | undefined]> = [];
15
+ const resolver: NvResolver = (kind, id, display) => {
16
+ calls.push([kind, id, display]);
17
+ return `<a href="#figure-ds-${id}">${id}</a>`;
18
+ };
19
+ const out = renderContent('See {{fig:mixed-reflection}} in the diagram.', makeOpts(resolver));
20
+ expect(calls).toEqual([['figure', 'mixed-reflection', undefined]]);
21
+ expect(out).toContain('href="#figure-ds-mixed-reflection"');
22
+ });
23
+
24
+ it('dispatches {{table:id}} to nonVerbalRefResolver as table', () => {
25
+ const resolver: NvResolver = (kind) => `<a href="#${kind}-ds-x">x</a>`;
26
+ const out = renderContent('See {{table:wavelengths}}.', makeOpts(resolver));
27
+ expect(out).toContain('href="#table-ds-x"');
28
+ });
29
+
30
+ it('dispatches {{formula:id}} to nonVerbalRefResolver as formula', () => {
31
+ const resolver: NvResolver = (kind) => `<a href="#${kind}-ds-x">x</a>`;
32
+ const out = renderContent('Use {{formula:e-mc2}}.', makeOpts(resolver));
33
+ expect(out).toContain('href="#formula-ds-x"');
34
+ });
35
+
36
+ it('passes the display override as the third resolver arg', () => {
37
+ const calls: Array<[NonVerbalKind, string, string | undefined]> = [];
38
+ const resolver: NvResolver = (kind, id, display) => {
39
+ calls.push([kind, id, display]);
40
+ return '<span/>';
41
+ };
42
+ renderContent('{{fig:foo, Figure 3}}', makeOpts(resolver));
43
+ expect(calls[0]).toEqual(['figure', 'foo', 'Figure 3']);
44
+ });
45
+
46
+ it('falls back to a typed span when no resolver is configured', () => {
47
+ const out = renderContent('{{fig:foo}}', makeOpts(undefined));
48
+ expect(out).toContain('nv-ref nv-ref--figure');
49
+ expect(out).toContain('foo');
50
+ });
51
+
52
+ it('falls back to entityId as label when display override is absent and no resolver', () => {
53
+ const out = renderContent('{{table:wavelengths}}', makeOpts(undefined));
54
+ expect(out).toContain('wavelengths');
55
+ expect(out).toContain('nv-ref--table');
56
+ });
57
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { pickLocaleText, pickLocaleMap } from '../utils/locale';
3
+
4
+ describe('locale — fetchLocalizedString-backed resolution', () => {
5
+ it('returns the requested locale when present', () => {
6
+ const map = { eng: 'hello', fra: 'bonjour' };
7
+ expect(pickLocaleText(map, 'fra')).toBe('bonjour');
8
+ });
9
+
10
+ it('falls back through the chain when the requested locale is missing', () => {
11
+ const map = { eng: 'hello' };
12
+ expect(pickLocaleText(map, 'fra', ['eng'])).toBe('hello');
13
+ });
14
+
15
+ it('falls back to the first available locale when nothing in the chain matches', () => {
16
+ // Per the i18n-first invariant: never show nothing. If the user asks
17
+ // for a locale that is missing AND the chain misses too, return the
18
+ // first available locale's text rather than empty.
19
+ expect(pickLocaleText({ deu: 'hallo' }, 'fra', ['eng'])).toBe('hallo');
20
+ });
21
+
22
+ it('returns "" when the map is empty', () => {
23
+ expect(pickLocaleText({}, 'fra', ['eng'])).toBe('');
24
+ });
25
+
26
+ it('returns null from pickLocaleMap when the map is empty', () => {
27
+ expect(pickLocaleMap({}, 'fra', ['eng'])).toBeNull();
28
+ });
29
+
30
+ it('returns the resolved locale alongside the text', () => {
31
+ const r = pickLocaleMap({ eng: 'hi', fra: 'salut' }, 'fra', ['eng']);
32
+ expect(r).toEqual({ locale: 'fra', text: 'salut' });
33
+ });
34
+
35
+ it('returns null when map is undefined', () => {
36
+ expect(pickLocaleMap(undefined, 'eng')).toBeNull();
37
+ expect(pickLocaleText(undefined, 'eng')).toBe('');
38
+ });
39
+
40
+ it('passes the fallback chain explicitly (no internal fallback inside fetchLocalizedString)', () => {
41
+ // fetchLocalizedString is called with null fallback for each locale
42
+ // we try. The chain is owned by THIS module, not the leaf primitive.
43
+ const map = { eng: 'hello', fra: 'salut' };
44
+ expect(pickLocaleText(map, 'deu', ['fra', 'eng'])).toBe('salut');
45
+ });
46
+ });