@glossarist/concept-browser 0.7.43 → 0.7.45

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 (60) hide show
  1. package/cli/index.mjs +12 -13
  2. package/package.json +3 -2
  3. package/scripts/__tests__/fetch-datasets.test.mjs +105 -0
  4. package/scripts/fetch-datasets.mjs +53 -51
  5. package/scripts/generate-data.mjs +41 -19
  6. package/scripts/lib/build/image-assets.mjs +190 -0
  7. package/scripts/lib/build/non-verbal-consumer.mjs +221 -0
  8. package/scripts/lib/local-path-safety.mjs +68 -0
  9. package/src/__tests__/bibliography-adapter.test.ts +79 -0
  10. package/src/__tests__/content-renderer-nvr-mentions.test.ts +57 -0
  11. package/src/__tests__/locale.test.ts +46 -0
  12. package/src/__tests__/model-bridge-entity-refs.test.ts +114 -0
  13. package/src/__tests__/non-verbal-anchor.test.ts +33 -0
  14. package/src/__tests__/non-verbal-cross-ref.test.ts +146 -0
  15. package/src/__tests__/non-verbal-highlight.test.ts +56 -0
  16. package/src/__tests__/non-verbal-kind.test.ts +77 -0
  17. package/src/__tests__/non-verbal-list.test.ts +67 -0
  18. package/src/__tests__/non-verbal-rep-display.test.ts +85 -0
  19. package/src/__tests__/non-verbal-scroll-guard.test.ts +116 -0
  20. package/src/__tests__/use-concept-entities.test.ts +76 -0
  21. package/src/adapters/bibliography-adapter.ts +49 -0
  22. package/src/adapters/factory.ts +14 -0
  23. package/src/adapters/model-bridge.ts +51 -0
  24. package/src/adapters/non-verbal/figure-bridge.ts +101 -0
  25. package/src/adapters/non-verbal/formula-bridge.ts +48 -0
  26. package/src/adapters/non-verbal/index.ts +55 -0
  27. package/src/adapters/non-verbal/kind.ts +46 -0
  28. package/src/adapters/non-verbal/prefix.ts +67 -0
  29. package/src/adapters/non-verbal/source-bridge.ts +81 -0
  30. package/src/adapters/non-verbal/table-bridge.ts +98 -0
  31. package/src/adapters/non-verbal/types.ts +133 -0
  32. package/src/adapters/non-verbal-resolver.ts +101 -0
  33. package/src/components/ConceptDetail.vue +17 -4
  34. package/src/components/LanguageDetail.vue +0 -3
  35. package/src/components/NonVerbalRepDisplay.vue +82 -24
  36. package/src/components/figure/FigureDisplay.vue +132 -0
  37. package/src/components/figure/FigureImages.vue +111 -0
  38. package/src/components/figure/figure-image-pick.ts +56 -0
  39. package/src/components/figure/figure-layout.ts +26 -0
  40. package/src/components/formula/FormulaDisplay.vue +90 -0
  41. package/src/components/formula/FormulaExpression.vue +70 -0
  42. package/src/components/non-verbal/NonVerbalCaption.vue +104 -0
  43. package/src/components/non-verbal/NonVerbalFallback.vue +69 -0
  44. package/src/components/non-verbal/NonVerbalList.vue +118 -0
  45. package/src/components/non-verbal/NonVerbalSources.vue +61 -0
  46. package/src/components/table/TableDisplay.vue +99 -0
  47. package/src/components/table/TableMarkup.vue +63 -0
  48. package/src/components/table/TableStructured.vue +66 -0
  49. package/src/composables/use-concept-entities.ts +70 -0
  50. package/src/composables/use-non-verbal-cross-ref.ts +79 -0
  51. package/src/composables/use-non-verbal-entity.ts +58 -0
  52. package/src/composables/use-reduced-motion.ts +26 -0
  53. package/src/composables/use-render-options.ts +30 -33
  54. package/src/router/index.ts +3 -0
  55. package/src/router/non-verbal-scroll-guard.ts +56 -0
  56. package/src/style.css +17 -0
  57. package/src/utils/content-renderer.ts +76 -64
  58. package/src/utils/locale.ts +92 -0
  59. package/src/utils/non-verbal-anchor.ts +51 -0
  60. package/src/utils/non-verbal-highlight.ts +27 -0
@@ -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,68 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Assert that a dataset's `localPath` is safe to use as an in-place source.
6
+ *
7
+ * "Safe" means: the resolved physical location of `localPath` is disjoint
8
+ * from the staging directory (`.datasets/<id>`). If they overlap, staging
9
+ * operations (rm, extract, clone) would destroy the user's source data —
10
+ * the data-loss bug reported in v0.7.43.
11
+ *
12
+ * Returns the resolved absolute path on success; throws on any hazard.
13
+ *
14
+ * @param {string} datasetId
15
+ * @param {string} localPath - relative to `root` (or absolute)
16
+ * @param {{ root?: string, datasetsDir?: string }} [opts]
17
+ * @returns {string} resolved absolute path
18
+ */
19
+ /**
20
+ * Compute the canonical physical path of `p`, resolving symlinks on the
21
+ * existing prefix. If `p` itself exists, this is just `realpathSync(p)`.
22
+ * If not, we walk up to the nearest existing ancestor, realpath it, and
23
+ * re-append the non-existent tail. This is needed because macOS tmpdir
24
+ * (`/var/folders/...`) is a symlink to `/private/var/folders/...`; without
25
+ * this, prefix comparisons across the symlink boundary silently fail.
26
+ */
27
+ function physicalPath(p) {
28
+ if (fs.existsSync(p)) return fs.realpathSync(p);
29
+ const parent = path.dirname(p);
30
+ const parentReal = fs.existsSync(parent) ? fs.realpathSync(parent) : physicalPath(parent);
31
+ return path.join(parentReal, path.basename(p));
32
+ }
33
+
34
+ export function assertLocalPathSafe(datasetId, localPath, { root = process.cwd(), datasetsDir } = {}) {
35
+ const datasetsRoot = datasetsDir || path.join(root, '.datasets');
36
+ const localResolved = path.resolve(root, localPath);
37
+
38
+ if (!fs.existsSync(localResolved)) {
39
+ throw new Error(`localPath for ${datasetId} does not exist: ${localResolved}`);
40
+ }
41
+
42
+ const localReal = fs.realpathSync(localResolved);
43
+ const stagedAbs = path.join(datasetsRoot, datasetId);
44
+ const stagedReal = physicalPath(stagedAbs);
45
+
46
+ if (localReal === stagedReal) {
47
+ throw new Error(
48
+ `localPath for ${datasetId} resolves to the same physical location as .datasets/${datasetId} ` +
49
+ `(${localReal}). Refusing to operate — source and staging would clobber. ` +
50
+ `Use a path outside .datasets/.`
51
+ );
52
+ }
53
+ if (localReal.startsWith(stagedReal + path.sep)) {
54
+ throw new Error(
55
+ `localPath for ${datasetId} is nested inside .datasets/${datasetId}. ` +
56
+ `Refusing to operate — staging operations would destroy source data. ` +
57
+ `Use a path outside .datasets/.`
58
+ );
59
+ }
60
+ if (stagedReal.startsWith(localReal + path.sep)) {
61
+ throw new Error(
62
+ `localPath for ${datasetId} contains .datasets/${datasetId}. ` +
63
+ `Refusing to operate — staging operations would destroy source data. ` +
64
+ `Use a path outside localPath.`
65
+ );
66
+ }
67
+ return localReal;
68
+ }
@@ -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
+ });
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { conceptFromJson } from '../adapters/model-bridge';
3
+ import type { Concept } from 'glossarist';
4
+
5
+ interface ConceptWithEntityRefs {
6
+ readonly figures: ReadonlyArray<{ entityId: string | null; display: string | null }>;
7
+ readonly tables: ReadonlyArray<{ entityId: string | null; display: string | null }>;
8
+ readonly formulas: ReadonlyArray<{ entityId: string | null; display: string | null }>;
9
+ }
10
+
11
+ function refsOf(c: Concept): ConceptWithEntityRefs {
12
+ return c as unknown as ConceptWithEntityRefs;
13
+ }
14
+
15
+ function conceptWithRefs(): Concept {
16
+ return conceptFromJson({
17
+ '@context': 'https://glossarist.org/ns/context.jsonld',
18
+ '@id': 'https://www.geolexica.org/isotc204/concept/3.1.1.1',
19
+ '@type': 'gl:Concept',
20
+ 'gl:identifier': '3.1.1.1',
21
+ 'gl:localizedConcept': {
22
+ eng: {
23
+ '@id': 'https://www.geolexica.org/isotc204/concept/3.1.1.1/eng',
24
+ '@type': 'gl:LocalizedConcept',
25
+ 'gl:languageCode': 'eng',
26
+ 'gl:entryStatus': 'valid',
27
+ 'gl:designation': [{ '@type': 'gl:Expression', 'gl:term': 'entity' }],
28
+ },
29
+ },
30
+ 'gl:figureRef': [
31
+ 'mixed-reflection',
32
+ { '@id': '../figure/dispersion-prism' },
33
+ { '@id': '../figure/standard-wavelengths', 'gl:display': 'Figure 3' },
34
+ ],
35
+ 'gl:tableRef': [{ '@id': '../table/wavelength-table' }],
36
+ 'gl:formulaRef': ['e-mc2'],
37
+ });
38
+ }
39
+
40
+ describe('conceptFromJson — structural entity refs', () => {
41
+ it('extracts bare-string figure refs', () => {
42
+ const c = refsOf(conceptWithRefs());
43
+ const ids = c.figures.map(f => f.entityId);
44
+ expect(ids).toContain('mixed-reflection');
45
+ });
46
+
47
+ it('extracts @id-only figure refs by last path segment', () => {
48
+ const c = refsOf(conceptWithRefs());
49
+ const ids = c.figures.map(f => f.entityId);
50
+ expect(ids).toContain('dispersion-prism');
51
+ });
52
+
53
+ it('preserves gl:display as the reference display override', () => {
54
+ const c = refsOf(conceptWithRefs());
55
+ const withDisplay = c.figures.find(f => f.entityId === 'standard-wavelengths');
56
+ expect(withDisplay?.display).toBe('Figure 3');
57
+ });
58
+
59
+ it('extracts table refs from gl:tableRef', () => {
60
+ const c = refsOf(conceptWithRefs());
61
+ expect(c.tables.map(t => t.entityId)).toContain('wavelength-table');
62
+ });
63
+
64
+ it('extracts formula refs from gl:formulaRef', () => {
65
+ const c = refsOf(conceptWithRefs());
66
+ expect(c.formulas.map(f => f.entityId)).toContain('e-mc2');
67
+ });
68
+
69
+ it('returns empty arrays when no ref fields are present', () => {
70
+ const c = refsOf(conceptFromJson({
71
+ '@context': 'https://glossarist.org/ns/context.jsonld',
72
+ '@id': 'https://example.org/x/concept/1',
73
+ '@type': 'gl:Concept',
74
+ 'gl:identifier': '1',
75
+ 'gl:localizedConcept': {
76
+ eng: {
77
+ '@type': 'gl:LocalizedConcept',
78
+ 'gl:languageCode': 'eng',
79
+ 'gl:entryStatus': 'valid',
80
+ 'gl:designation': [{ '@type': 'gl:Expression', 'gl:term': 'x' }],
81
+ },
82
+ },
83
+ }));
84
+ expect(c.figures).toEqual([]);
85
+ expect(c.tables).toEqual([]);
86
+ expect(c.formulas).toEqual([]);
87
+ });
88
+
89
+ it('skips malformed entries (empty string, null, missing @id)', () => {
90
+ const c = refsOf(conceptFromJson({
91
+ '@context': 'https://glossarist.org/ns/context.jsonld',
92
+ '@id': 'https://example.org/x/concept/1',
93
+ '@type': 'gl:Concept',
94
+ 'gl:identifier': '1',
95
+ 'gl:localizedConcept': {
96
+ eng: {
97
+ '@type': 'gl:LocalizedConcept',
98
+ 'gl:languageCode': 'eng',
99
+ 'gl:entryStatus': 'valid',
100
+ 'gl:designation': [{ '@type': 'gl:Expression', 'gl:term': 'x' }],
101
+ },
102
+ },
103
+ 'gl:figureRef': [
104
+ '',
105
+ null,
106
+ {},
107
+ { '@id': '' },
108
+ { ref: '' },
109
+ ' ',
110
+ ],
111
+ }));
112
+ expect(c.figures).toEqual([]);
113
+ });
114
+ });
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ anchorId,
4
+ ANCHOR_KIND_SELECTORS,
5
+ } from '../utils/non-verbal-anchor';
6
+
7
+ describe('non-verbal-anchor', () => {
8
+ describe('anchorId', () => {
9
+ it('joins kind + datasetId + entityId with hyphens', () => {
10
+ expect(anchorId('figure', 'iala-2023', 'mixed-reflection'))
11
+ .toBe('figure-iala-2023-mixed-reflection');
12
+ });
13
+
14
+ it('uses the kind name as prefix (not an alias)', () => {
15
+ expect(anchorId('table', 'iso', 'standard-wavelengths'))
16
+ .toBe('table-iso-standard-wavelengths');
17
+ expect(anchorId('formula', 'iso', 'e-mc2'))
18
+ .toBe('formula-iso-e-mc2');
19
+ });
20
+ });
21
+
22
+ describe('ANCHOR_KIND_SELECTORS', () => {
23
+ it('contains one selector per kind', () => {
24
+ expect(ANCHOR_KIND_SELECTORS).toContain('a[href^="#figure-"]');
25
+ expect(ANCHOR_KIND_SELECTORS).toContain('a[href^="#table-"]');
26
+ expect(ANCHOR_KIND_SELECTORS).toContain('a[href^="#formula-"]');
27
+ });
28
+
29
+ it('has exactly three selectors (one per kind)', () => {
30
+ expect(ANCHOR_KIND_SELECTORS.length).toBe(3);
31
+ });
32
+ });
33
+ });