@glossarist/concept-browser 0.7.34 → 0.7.37

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 (37) hide show
  1. package/package.json +2 -2
  2. package/scripts/build-edges.js +16 -8
  3. package/scripts/generate-data.mjs +284 -86
  4. package/src/__tests__/citation-display.test.ts +165 -3
  5. package/src/__tests__/cite-ref.test.ts +112 -0
  6. package/src/__tests__/concept-detail-interaction.test.ts +1 -5
  7. package/src/__tests__/{math.test.ts → content-renderer.test.ts} +113 -29
  8. package/src/__tests__/escape.test.ts +76 -0
  9. package/src/__tests__/graph-data-source.test.ts +155 -0
  10. package/src/__tests__/model-bridge-bridges.test.ts +150 -0
  11. package/src/__tests__/model-bridge-citation.test.ts +163 -0
  12. package/src/__tests__/reference-resolver-cite.test.ts +122 -0
  13. package/src/__tests__/reference-resolver.test.ts +12 -7
  14. package/src/__tests__/resolve-view.test.ts +1 -1
  15. package/src/__tests__/sidebar-nav-highlighting.test.ts +178 -0
  16. package/src/__tests__/source-refs.test.ts +9 -6
  17. package/src/__tests__/test-helpers.ts +20 -0
  18. package/src/__tests__/uri-router.test.ts +39 -12
  19. package/src/adapters/DatasetAdapter.ts +35 -143
  20. package/src/adapters/GraphDataSource.ts +178 -0
  21. package/src/adapters/ReferenceResolver.ts +101 -47
  22. package/src/adapters/UriRouter.ts +82 -10
  23. package/src/adapters/factory.ts +35 -28
  24. package/src/adapters/model-bridge.ts +121 -71
  25. package/src/adapters/types.ts +3 -0
  26. package/src/components/AppSidebar.vue +7 -4
  27. package/src/components/CitationDisplay.vue +86 -30
  28. package/src/components/ConceptDetail.vue +24 -126
  29. package/src/components/LanguageDetail.vue +6 -6
  30. package/src/composables/use-concept-content.ts +8 -8
  31. package/src/composables/use-ontology-nav.ts +129 -130
  32. package/src/composables/use-render-options.ts +1 -1
  33. package/src/graph/GraphEngine.ts +65 -0
  34. package/src/stores/vocabulary.ts +12 -73
  35. package/src/utils/content-renderer.ts +312 -0
  36. package/src/utils/markdown-lite.ts +2 -2
  37. package/src/utils/math.ts +0 -189
@@ -5,12 +5,19 @@ import { createPinia, setActivePinia } from 'pinia';
5
5
  import CitationDisplay from '../components/CitationDisplay.vue';
6
6
  import { getFactory, resetFactory } from '../adapters/factory';
7
7
  import { ReferenceResolver } from '../adapters/ReferenceResolver';
8
+ import { UriRouter } from '../adapters/UriRouter';
9
+
10
+ function createTestResolver() {
11
+ const uriRouter = new UriRouter();
12
+ const resolver = new ReferenceResolver(uriRouter);
13
+ return { uriRouter, resolver };
14
+ }
8
15
 
9
16
  // Minimal Citation type matching the glossarist Citation interface
10
17
  function makeCitation(source: string, referenceFrom: string, type = 'clause') {
11
18
  return {
12
19
  ref: { source },
13
- locality: { type, referenceFrom },
20
+ locality: { type, reference_from: referenceFrom },
14
21
  };
15
22
  }
16
23
 
@@ -21,8 +28,8 @@ describe('CitationDisplay — source reference linking', () => {
21
28
  resetFactory();
22
29
  const factory = getFactory();
23
30
  // Register dataset patterns
24
- factory.resolver.registerDataset('vim-2012', ['urn:oiml:pub:v:2:2012*']);
25
- factory.resolver.registerDataset('viml-2022', ['urn:oiml:pub:v:1:2022*']);
31
+ factory.uriRouter.registerDataset('vim-2012', '', '', ['urn:oiml:pub:v:2:2012*']);
32
+ factory.uriRouter.registerDataset('viml-2022', '', '', ['urn:oiml:pub:v:1:2022*']);
26
33
  // Register source refs
27
34
  factory.resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
28
35
  factory.resolver.registerSourceRef('OIML V2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
@@ -141,3 +148,158 @@ describe('CitationDisplay — source reference linking', () => {
141
148
  expect(wrapper.html()).not.toContain('citation-preview');
142
149
  });
143
150
  });
151
+
152
+ describe('CitationDisplay — classification-based rendering', () => {
153
+ let router: any;
154
+
155
+ beforeEach(async () => {
156
+ resetFactory();
157
+ const factory = getFactory();
158
+ factory.uriRouter.registerDataset('vim-2012', '', '', ['urn:oiml:pub:v:2:2012*']);
159
+ factory.uriRouter.registerDataset('viml-2022', '', '', ['urn:oiml:pub:v:1:2022*']);
160
+ factory.resolver.registerSourceRef('OIML V2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
161
+
162
+ router = createRouter({
163
+ history: createMemoryHistory(),
164
+ routes: [
165
+ { path: '/', component: { template: '<div/>' } },
166
+ { name: 'concept', path: '/dataset/:registerId/concept/:conceptId', component: { template: '<div/>' } },
167
+ ],
168
+ });
169
+ await router.push('/');
170
+ setActivePinia(createPinia());
171
+ });
172
+
173
+ function mountCitation(citation: any, registerId?: string) {
174
+ return mount(CitationDisplay, {
175
+ props: { citation, registerId },
176
+ global: { plugins: [router] },
177
+ });
178
+ }
179
+
180
+ it('classifies resolved citation as internal-citation with button', () => {
181
+ const wrapper = mountCitation(makeCitation('OIML V2-200:2012', '2.2'), 'viml-2022');
182
+ expect(wrapper.find('button.concept-link').exists()).toBe(true);
183
+ });
184
+
185
+ it('classifies citation with link as self-contained-citation with anchor', () => {
186
+ const citation = {
187
+ ref: { source: 'ISO 9000:2015' },
188
+ locality: { type: 'clause', reference_from: '3.1' },
189
+ link: 'https://iso.org/standard/62085.html',
190
+ };
191
+ const wrapper = mountCitation(citation);
192
+ // Should have an anchor link for the source text
193
+ const links = wrapper.findAll('a.concept-link');
194
+ expect(links.length).toBeGreaterThanOrEqual(1);
195
+ });
196
+
197
+ it('classifies known source without resolution as external-citation', () => {
198
+ const citation = {
199
+ ref: { source: 'Some Standard', id: '4.2.1' },
200
+ locality: null,
201
+ };
202
+ const wrapper = mountCitation(citation);
203
+ // No button, no anchor link for the source — just plain text
204
+ expect(wrapper.find('button.concept-link').exists()).toBe(false);
205
+ expect(wrapper.text()).toContain('Some Standard');
206
+ });
207
+
208
+ it('classifies citation without ref source as unresolved-citation', () => {
209
+ const wrapper = mountCitation({ ref: null, locality: null });
210
+ expect(wrapper.find('button').exists()).toBe(false);
211
+ expect(wrapper.find('a.concept-link').exists()).toBe(false);
212
+ });
213
+ });
214
+
215
+ describe('ReferenceResolver — resolveCite', () => {
216
+ let resolver: ReferenceResolver;
217
+ let uriRouter: UriRouter;
218
+
219
+ beforeEach(() => {
220
+ ({ uriRouter, resolver } = createTestResolver());
221
+ uriRouter.registerDataset('vim-2012', '', '', ['urn:oiml:pub:v:2:2012*']);
222
+ uriRouter.registerDataset('viml-2022', '', '', ['urn:oiml:pub:v:1:2022*']);
223
+ resolver.registerSourceRef('OIML V2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
224
+ });
225
+
226
+ it('resolves cite with matching source to internal-citation', () => {
227
+ const result = resolver.resolveCite({
228
+ ref: { source: 'OIML V2-200:2012' },
229
+ locality: { type: 'clause', reference_from: '2.2' },
230
+ }, 'viml-2022');
231
+
232
+ expect(result.classification).toBe('internal-citation');
233
+ expect(result.resolved).toEqual({ registerId: 'vim-2012', conceptId: '2.2' });
234
+ });
235
+
236
+ it('classifies cite with link but no resolution as self-contained-citation', () => {
237
+ const result = resolver.resolveCite({
238
+ ref: { source: 'ISO 9000:2015' },
239
+ locality: null,
240
+ link: 'https://iso.org/standard/62085.html',
241
+ });
242
+
243
+ expect(result.classification).toBe('self-contained-citation');
244
+ expect(result.resolved).toBeNull();
245
+ });
246
+
247
+ it('classifies cite with known ref but no resolution as external-citation', () => {
248
+ const result = resolver.resolveCite({
249
+ ref: { source: 'Unknown Standard', id: '3.1' },
250
+ locality: null,
251
+ });
252
+
253
+ expect(result.classification).toBe('external-citation');
254
+ expect(result.resolved).toBeNull();
255
+ });
256
+
257
+ it('classifies cite without ref as unresolved-citation', () => {
258
+ const result = resolver.resolveCite(null);
259
+ expect(result.classification).toBe('unresolved-citation');
260
+ });
261
+
262
+ it('classifies cite with null citation ref as unresolved-citation', () => {
263
+ const result = resolver.resolveCite({
264
+ ref: null,
265
+ locality: null,
266
+ });
267
+ expect(result.classification).toBe('unresolved-citation');
268
+ });
269
+ });
270
+
271
+ describe('ReferenceResolver — classifyCitation via resolveCite', () => {
272
+ let resolver: ReferenceResolver;
273
+ let uriRouter: UriRouter;
274
+
275
+ beforeEach(() => {
276
+ ({ uriRouter, resolver } = createTestResolver());
277
+ uriRouter.registerDataset('vim-2012', '', '', ['urn:oiml:pub:v:2:2012*']);
278
+ resolver.registerSourceRef('VIM', 'vim-2012', 'urn:oiml:pub:v:2:2012');
279
+ });
280
+
281
+ it('returns internal-citation for resolvable citation', () => {
282
+ expect(resolver.resolveCite(
283
+ { ref: { source: 'VIM' }, locality: { reference_from: '2.2' } },
284
+ 'other-ds',
285
+ ).classification).toBe('internal-citation');
286
+ });
287
+
288
+ it('returns self-contained-citation for citation with link', () => {
289
+ expect(resolver.resolveCite(
290
+ { ref: { source: 'Unknown' }, locality: null, link: 'https://example.com' },
291
+ ).classification).toBe('self-contained-citation');
292
+ });
293
+
294
+ it('returns external-citation for known source without resolution', () => {
295
+ expect(resolver.resolveCite(
296
+ { ref: { source: 'Some Standard' }, locality: null },
297
+ ).classification).toBe('external-citation');
298
+ });
299
+
300
+ it('returns unresolved-citation for citation without ref source', () => {
301
+ expect(resolver.resolveCite(
302
+ { ref: null, locality: null },
303
+ ).classification).toBe('unresolved-citation');
304
+ });
305
+ });
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseMention } from 'glossarist';
3
+
4
+ describe('parseMention — cite-ref detection', () => {
5
+ it('parses cite:key form', () => {
6
+ const result = parseMention('cite:iso-10303-2');
7
+ expect(result.kind).toBe('cite-ref');
8
+ expect(result.key).toBe('iso-10303-2');
9
+ expect(result.label).toBeNull();
10
+ });
11
+
12
+ it('parses cite:key,label form', () => {
13
+ const result = parseMention('cite:vim-term,entity data type');
14
+ expect(result.kind).toBe('cite-ref');
15
+ expect(result.key).toBe('vim-term');
16
+ expect(result.label).toBe('entity data type');
17
+ });
18
+
19
+ it('parses cite:key with quoted label', () => {
20
+ const result = parseMention('cite:foo,"quoted, label"');
21
+ expect(result.kind).toBe('cite-ref');
22
+ expect(result.key).toBe('foo');
23
+ expect(result.label).toBe('quoted, label');
24
+ });
25
+
26
+ it('parses bare numeric id as numeric', () => {
27
+ const result = parseMention('3.1.1');
28
+ expect(result.kind).toBe('numeric');
29
+ expect(result.id).toBe('3.1.1');
30
+ });
31
+
32
+ it('parses dashed numeric id as numeric', () => {
33
+ const result = parseMention('103-01-02');
34
+ expect(result.kind).toBe('numeric');
35
+ expect(result.id).toBe('103-01-02');
36
+ });
37
+
38
+ it('classifies URN mentions as urn-ref (glossarist >= 0.3.7)', () => {
39
+ const result = parseMention('urn:iso:std:iso:10303:-2:ed-1:en:term:foo,bar');
40
+ expect(result.kind).toBe('urn-ref');
41
+ expect(result.uri).toBe('urn:iso:std:iso:10303:-2:ed-1:en:term:foo');
42
+ expect(result.label).toBe('bar');
43
+ });
44
+
45
+ it('classifies IEV-style mentions as designation (glossarist >= 0.3.7)', () => {
46
+ const result = parseMention('term, IEV:103-01-02');
47
+ expect(result.kind).toBe('designation');
48
+ expect(result.id).toBe('term');
49
+ expect(result.label).toBe('IEV:103-01-02');
50
+ });
51
+ });
52
+
53
+ describe('extractInlineRefs — cite-ref integration', () => {
54
+ // Test the build-time extractInlineRefs function indirectly by
55
+ // verifying parseMention handles the forms the build script uses.
56
+ it('cite:key with trailing comma and empty label', () => {
57
+ const result = parseMention('cite:foo,');
58
+ expect(result.kind).toBe('cite-ref');
59
+ expect(result.key).toBe('foo');
60
+ });
61
+
62
+ it('cite:key without comma has no label', () => {
63
+ const result = parseMention('cite:bar');
64
+ expect(result.kind).toBe('cite-ref');
65
+ expect(result.key).toBe('bar');
66
+ expect(result.label).toBeNull();
67
+ });
68
+ });
69
+
70
+ describe('ReferenceResolver — resolveCite integration', () => {
71
+ it('resolves cite-ref citation with source match to internal', async () => {
72
+ const { ReferenceResolver } = await import('../adapters/ReferenceResolver');
73
+ const { UriRouter } = await import('../adapters/UriRouter');
74
+ const uriRouter = new UriRouter();
75
+ const resolver = new ReferenceResolver(uriRouter);
76
+ uriRouter.registerDataset('vim-2012', '', '', ['urn:oiml:pub:v:2:2012*']);
77
+ resolver.registerSourceRef('VIM', 'vim-2012', 'urn:oiml:pub:v:2:2012');
78
+
79
+ const citation = {
80
+ ref: { source: 'VIM' },
81
+ locality: { type: 'definition', reference_from: '2.2' },
82
+ };
83
+
84
+ const result = resolver.resolveCite(citation);
85
+ expect(result.classification).toBe('internal-citation');
86
+ expect(result.resolved).toEqual({ registerId: 'vim-2012', conceptId: '2.2' });
87
+ });
88
+
89
+ it('classifies citation with link but no resolution as self-contained', async () => {
90
+ const { ReferenceResolver } = await import('../adapters/ReferenceResolver');
91
+ const { UriRouter } = await import('../adapters/UriRouter');
92
+ const resolver = new ReferenceResolver(new UriRouter());
93
+
94
+ const citation = {
95
+ ref: { source: 'Unknown' },
96
+ locality: null,
97
+ link: 'https://example.com',
98
+ };
99
+
100
+ const result = resolver.resolveCite(citation);
101
+ expect(result.classification).toBe('self-contained-citation');
102
+ });
103
+
104
+ it('classifies citation without ref source as unresolved', async () => {
105
+ const { ReferenceResolver } = await import('../adapters/ReferenceResolver');
106
+ const { UriRouter } = await import('../adapters/UriRouter');
107
+ const resolver = new ReferenceResolver(new UriRouter());
108
+
109
+ const result = resolver.resolveCite({ ref: null, locality: null });
110
+ expect(result.classification).toBe('unresolved-citation');
111
+ });
112
+ });
@@ -242,11 +242,7 @@ describe('ConceptDetail interactions', () => {
242
242
  // Register the URI pattern via factory so it resolves as internal
243
243
  const { getFactory } = await import('../adapters/factory');
244
244
  const factory = getFactory();
245
- factory.router.registerDataset('test', '/data/test', {
246
- ...makeManifest(),
247
- uriBase: 'https://glossarist.org',
248
- });
249
- factory.resolver.registerDataset('test', ['https://glossarist.org/test/concept/*']);
245
+ factory.uriRouter.registerDataset('test', '', '', ['https://glossarist.org/test/concept/*']);
250
246
 
251
247
  const wrapper = mountDetail(json);
252
248
  await switchToDefinition(wrapper);
@@ -1,33 +1,33 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { renderMath, cleanContent } from '../utils/math';
2
+ import { renderContent, cleanContent } from '../utils/content-renderer';
3
3
 
4
- describe('renderMath', () => {
4
+ describe('renderContent', () => {
5
5
  it('passes through plain text unchanged', () => {
6
- expect(renderMath('hello world')).toBe('hello world');
6
+ expect(renderContent('hello world')).toBe('hello world');
7
7
  });
8
8
 
9
9
  it('passes through pre-rendered MathML unchanged', () => {
10
10
  const preRendered = 'value <span class="math-inline"><math><mi>x</mi></math></span> here';
11
- expect(renderMath(preRendered)).toBe(preRendered);
11
+ expect(renderContent(preRendered)).toBe(preRendered);
12
12
  });
13
13
 
14
14
  it('still converts italic in mixed pre-rendered MathML content', () => {
15
15
  const preRendered = '<span class="math-inline"><math><mi>x</mi></math></span> and *italic*';
16
- expect(renderMath(preRendered)).toBe(
16
+ expect(renderContent(preRendered)).toBe(
17
17
  '<span class="math-inline"><math><mi>x</mi></math></span> and <em>italic</em>',
18
18
  );
19
19
  });
20
20
 
21
21
  it('converts *text* to <em> (italic) for non-pre-rendered content', () => {
22
- expect(renderMath('some *italic* text')).toBe('some <em>italic</em> text');
22
+ expect(renderContent('some *italic* text')).toBe('some <em>italic</em> text');
23
23
  });
24
24
 
25
25
  it('converts ~text~ to <sub> (subscript)', () => {
26
- expect(renderMath('H~2~O')).toBe('H<sub>2</sub>O');
26
+ expect(renderContent('H~2~O')).toBe('H<sub>2</sub>O');
27
27
  });
28
28
 
29
29
  it('converts bullet lines to <ul><li>', () => {
30
- const result = renderMath('* first item\n\n* second item');
30
+ const result = renderContent('* first item\n\n* second item');
31
31
  expect(result).toContain('<ul class="concept-list">');
32
32
  expect(result).toContain('<li>first item</li>');
33
33
  expect(result).toContain('<li>second item</li>');
@@ -35,7 +35,7 @@ describe('renderMath', () => {
35
35
 
36
36
  it('converts AsciiDoc pipe-delimited tables to <table>', () => {
37
37
  const input = 'Intro text\n\n|===\n| a | b | c\n| d | e | f\n|===';
38
- const result = renderMath(input);
38
+ const result = renderContent(input);
39
39
  expect(result).toContain('<table class="concept-table">');
40
40
  expect(result).toContain('<thead><tr><th>a</th><th>b</th><th>c</th></tr></thead>');
41
41
  expect(result).toContain('<tbody><tr><td>d</td><td>e</td><td>f</td></tr></tbody>');
@@ -44,7 +44,7 @@ describe('renderMath', () => {
44
44
 
45
45
  it('resolves URN inline refs via xrefResolver', () => {
46
46
  const resolver = (uri: string, term: string) => `[${term}→${uri}]`;
47
- const result = renderMath(
47
+ const result = renderContent(
48
48
  'a {{urn:iso:std:iso:14812:3.1.1.1,entity}} reference',
49
49
  resolver,
50
50
  );
@@ -53,7 +53,7 @@ describe('renderMath', () => {
53
53
 
54
54
  it('resolves single-braced URN inline refs', () => {
55
55
  const resolver = (uri: string, term: string) => `[${term}→${uri}]`;
56
- const result = renderMath(
56
+ const result = renderContent(
57
57
  'a {urn:iso:std:iso:14812:3.1.1.1,entity} reference',
58
58
  resolver,
59
59
  );
@@ -61,18 +61,18 @@ describe('renderMath', () => {
61
61
  });
62
62
 
63
63
  it('shows term without resolver', () => {
64
- const result = renderMath('a {{urn:iso:std:iso:14812:3.1.1.1,entity}} ref');
64
+ const result = renderContent('a {{urn:iso:std:iso:14812:3.1.1.1,entity}} ref');
65
65
  expect(result).toBe('a entity ref');
66
66
  });
67
67
 
68
68
  it('uses display text from three-part URN refs', () => {
69
- const result = renderMath('a {{urn:iso:std:iso:14812:3.1.1.6,person,Person}} ref');
69
+ const result = renderContent('a {{urn:iso:std:iso:14812:3.1.1.6,person,Person}} ref');
70
70
  expect(result).toBe('a Person ref');
71
71
  });
72
72
 
73
73
  it('resolves three-part URN refs with display text via xrefResolver', () => {
74
74
  const resolver = (uri: string, term: string) => `[${term}→${uri}]`;
75
- const result = renderMath(
75
+ const result = renderContent(
76
76
  '{{urn:iso:std:iso:14812:3.1.1.6,person,Person}}, object, event',
77
77
  resolver,
78
78
  );
@@ -81,38 +81,40 @@ describe('renderMath', () => {
81
81
 
82
82
  it('resolves single-braced three-part URN refs', () => {
83
83
  const resolver = (uri: string, term: string) => `[${term}→${uri}]`;
84
- const result = renderMath(
84
+ const result = renderContent(
85
85
  '{urn:iso:std:iso:14812:3.5.3.4,user,users} are people',
86
86
  resolver,
87
87
  );
88
88
  expect(result).toBe('[users→urn:iso:std:iso:14812:3.5.3.4] are people');
89
89
  });
90
90
 
91
- it('strips remaining {{...}} to just the term', () => {
92
- const result = renderMath('see {{some term, unknown ref}}');
93
- expect(result).toBe('see some term');
91
+ it('displays render term (second part) for unresolved two-arg mentions', () => {
92
+ const result = renderContent('see {{some term, unknown ref}}');
93
+ // Per spec: {{identifier,render term}} — identifier first, render term last
94
+ // Without a resolver, the render term is what should display
95
+ expect(result).toBe('see unknown ref');
94
96
  });
95
97
 
96
98
  it('resolves cross-refs even in pre-rendered content', () => {
97
99
  const resolver = (uri: string, term: string) => `[${term}→${uri}]`;
98
100
  const preRendered = '<span class="math-inline"><math><mi>x</mi></math></span> and {{urn:iso:std:iso:14812:3.1.1.1,entity}}';
99
- expect(renderMath(preRendered, resolver)).toBe(
101
+ expect(renderContent(preRendered, resolver)).toBe(
100
102
  '<span class="math-inline"><math><mi>x</mi></math></span> and [entity→urn:iso:std:iso:14812:3.1.1.1]',
101
103
  );
102
104
  });
103
105
 
104
106
  it('handles empty input', () => {
105
- expect(renderMath('')).toBe('');
107
+ expect(renderContent('')).toBe('');
106
108
  });
107
109
 
108
110
  it('handles null-ish input', () => {
109
- expect(renderMath(null as any)).toBe('');
110
- expect(renderMath(undefined as any)).toBe('');
111
+ expect(renderContent(null as any)).toBe('');
112
+ expect(renderContent(undefined as any)).toBe('');
111
113
  });
112
114
 
113
115
  // stem:[...] placeholder tests
114
116
  it('outputs math-pending placeholder for stem:[expr]', () => {
115
- const result = renderMath('value stem:[x^2] here');
117
+ const result = renderContent('value stem:[x^2] here');
116
118
  expect(result).toContain('class="math-pending"');
117
119
  expect(result).toContain('data-expr="x^2"');
118
120
  expect(result).toContain('data-format="asciimath"');
@@ -120,20 +122,20 @@ describe('renderMath', () => {
120
122
  });
121
123
 
122
124
  it('outputs math-pending with math-bold for *stem:[expr]', () => {
123
- const result = renderMath('*stem:[alpha]');
125
+ const result = renderContent('*stem:[alpha]');
124
126
  expect(result).toContain('class="math-pending math-bold"');
125
127
  expect(result).toContain('data-expr="alpha"');
126
128
  });
127
129
 
128
130
  it('outputs math-pending placeholder for latexmath:[expr]', () => {
129
- const result = renderMath('equation latexmath:[\\frac{a}{b}] end');
131
+ const result = renderContent('equation latexmath:[\\frac{a}{b}] end');
130
132
  expect(result).toContain('class="math-pending"');
131
133
  expect(result).toContain('data-expr="\\frac{a}{b}"');
132
134
  expect(result).toContain('data-format="latex"');
133
135
  });
134
136
 
135
137
  it('handles multiple stem: expressions in one string', () => {
136
- const result = renderMath('stem:[m] out of stem:[n] redundancy');
138
+ const result = renderContent('stem:[m] out of stem:[n] redundancy');
137
139
  const matches = result.match(/class="math-pending"/g);
138
140
  expect(matches).toHaveLength(2);
139
141
  expect(result).toContain('data-expr="m"');
@@ -141,20 +143,58 @@ describe('renderMath', () => {
141
143
  });
142
144
 
143
145
  it('handles nested brackets in stem:', () => {
144
- const result = renderMath('stem:[a_[i]]');
146
+ const result = renderContent('stem:[a_[i]]');
145
147
  expect(result).toContain('data-expr="a_[i]"');
146
148
  });
147
149
 
148
150
  it('handles stem: in designation text', () => {
149
- const result = renderMath('stem:[n]-ary digit');
151
+ const result = renderContent('stem:[n]-ary digit');
150
152
  expect(result).toContain('data-expr="n"');
151
153
  expect(result).toContain('-ary digit');
152
154
  });
153
155
 
154
156
  it('escapes special HTML in stem: expressions', () => {
155
- const result = renderMath('stem:[a<b]');
157
+ const result = renderContent('stem:[a<b]');
156
158
  expect(result).toContain('data-expr="a&lt;b"');
157
159
  });
160
+
161
+ // Bold
162
+ it('converts **text** to <strong>', () => {
163
+ expect(renderContent('some **bold** text')).toBe('some <strong>bold</strong> text');
164
+ });
165
+
166
+ it('distinguishes **bold** from *italic*', () => {
167
+ expect(renderContent('**bold** and *italic*')).toBe('<strong>bold</strong> and <em>italic</em>');
168
+ });
169
+
170
+ // cite:key rendering
171
+ it('renders {{cite:key}} as bib-ref span', () => {
172
+ const result = renderContent('see {{cite:iso-10303-2}} for details');
173
+ expect(result).toBe('see <span class="bib-ref">iso-10303-2</span> for details');
174
+ });
175
+
176
+ it('renders {{cite:key,label}} using label', () => {
177
+ const result = renderContent('see {{cite:vim-2.2,entity data type}} for details');
178
+ expect(result).toBe('see <span class="bib-ref">entity data type</span> for details');
179
+ });
180
+
181
+ it('invokes citeResolver for cite:key mentions', () => {
182
+ const resolver = (key: string, label: string | null) => `[CITE:${key}:${label}]`;
183
+ const result = renderContent('{{cite:foo,bar}}', { citeResolver: resolver });
184
+ expect(result).toBe('[CITE:foo:bar]');
185
+ });
186
+
187
+ it('invokes citeResolver for cite:key without label', () => {
188
+ const resolver = (key: string, label: string | null) => `[CITE:${key}]`;
189
+ const result = renderContent('{{cite:foo}}', { citeResolver: resolver });
190
+ expect(result).toBe('[CITE:foo]');
191
+ });
192
+
193
+ it('escapes HTML in cite:key fallback rendering', () => {
194
+ const result = renderContent('{{cite:foo<b>}}');
195
+ expect(result).toContain('foo&lt;b&gt;');
196
+ expect(result).not.toContain('<b>');
197
+ });
158
198
  });
159
199
 
160
200
  describe('cleanContent', () => {
@@ -167,6 +207,18 @@ describe('cleanContent', () => {
167
207
  expect(cleanContent('some *italic* text')).toBe('some italic text');
168
208
  });
169
209
 
210
+ it('strips **text** to plain text', () => {
211
+ expect(cleanContent('some **bold** text')).toBe('some bold text');
212
+ });
213
+
214
+ it('strips {{cite:key}} completely', () => {
215
+ expect(cleanContent('see {{cite:foo}} here')).toBe('see here');
216
+ });
217
+
218
+ it('strips {{cite:key,label}} to label', () => {
219
+ expect(cleanContent('see {{cite:foo,entity data type}} here')).toBe('see entity data type here');
220
+ });
221
+
170
222
  it('converts ~text~ to _text', () => {
171
223
  expect(cleanContent('H~2~O')).toBe('H_2O');
172
224
  });
@@ -207,3 +259,35 @@ describe('cleanContent', () => {
207
259
  expect(cleanContent('value stem:[m] out of stem:[n]')).toBe('value m out of n');
208
260
  });
209
261
  });
262
+
263
+ describe('renderContent — cite-ref edge cases', () => {
264
+ it('renders cite with special characters in key', () => {
265
+ const result = renderContent('{{cite:iso-10303-2-ed1}}');
266
+ expect(result).toBe('<span class="bib-ref">iso-10303-2-ed1</span>');
267
+ });
268
+
269
+ it('renders cite with numeric-only key', () => {
270
+ const result = renderContent('{{cite:42}}');
271
+ expect(result).toBe('<span class="bib-ref">42</span>');
272
+ });
273
+
274
+ it('falls back to key when no label in citeResolver', () => {
275
+ const result = renderContent('{{cite:foo-bar}}');
276
+ expect(result).toBe('<span class="bib-ref">foo-bar</span>');
277
+ });
278
+ });
279
+
280
+ describe('cleanContent — cite-ref edge cases', () => {
281
+ it('strips cite:key with special characters', () => {
282
+ expect(cleanContent('see {{cite:iso-10303-2-ed1}}')).toBe('see ');
283
+ });
284
+
285
+ it('preserves label with comma in cite:key,label', () => {
286
+ expect(cleanContent('see {{cite:foo,entity data type}}')).toBe('see entity data type');
287
+ });
288
+
289
+ it('handles multiple cite refs in sequence', () => {
290
+ expect(cleanContent('{{cite:a}} and {{cite:b,Label B}}'))
291
+ .toBe(' and Label B');
292
+ });
293
+ });
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { escapeHtml, escapeAttr } from '../utils/escape';
3
+
4
+ describe('escapeHtml', () => {
5
+ it('escapes ampersands', () => {
6
+ expect(escapeHtml('a & b')).toBe('a &amp; b');
7
+ });
8
+
9
+ it('escapes less-than', () => {
10
+ expect(escapeHtml('a < b')).toBe('a &lt; b');
11
+ });
12
+
13
+ it('escapes greater-than', () => {
14
+ expect(escapeHtml('a > b')).toBe('a &gt; b');
15
+ });
16
+
17
+ it('escapes all three special characters', () => {
18
+ expect(escapeHtml('<script>alert("xss")</script>')).toBe(
19
+ '&lt;script&gt;alert("xss")&lt;/script&gt;',
20
+ );
21
+ });
22
+
23
+ it('returns empty string for empty input', () => {
24
+ expect(escapeHtml('')).toBe('');
25
+ });
26
+
27
+ it('leaves plain text unchanged', () => {
28
+ expect(escapeHtml('hello world')).toBe('hello world');
29
+ });
30
+
31
+ it('escapes multiple ampersands', () => {
32
+ expect(escapeHtml('a&b&c')).toBe('a&amp;b&amp;c');
33
+ });
34
+
35
+ it('handles already-escaped content (double-escaping)', () => {
36
+ // escapeHtml does not detect already-escaped entities — by design
37
+ expect(escapeHtml('&amp;')).toBe('&amp;amp;');
38
+ });
39
+
40
+ it('handles unicode text', () => {
41
+ expect(escapeHtml('日本語 < 한국어')).toBe('日本語 &lt; 한국어');
42
+ });
43
+
44
+ it('handles long strings', () => {
45
+ const input = 'x'.repeat(10000) + '<' + 'y'.repeat(10000);
46
+ const result = escapeHtml(input);
47
+ expect(result).toContain('&lt;');
48
+ expect(result.length).toBe(input.length + 3); // '<' → '&lt;' adds 3 chars
49
+ });
50
+ });
51
+
52
+ describe('escapeAttr', () => {
53
+ it('escapes double quotes', () => {
54
+ expect(escapeAttr('a "b" c')).toBe('a &quot;b&quot; c');
55
+ });
56
+
57
+ it('escapes HTML entities and quotes', () => {
58
+ expect(escapeAttr('<a href="x">')).toBe('&lt;a href=&quot;x&quot;&gt;');
59
+ });
60
+
61
+ it('returns empty string for empty input', () => {
62
+ expect(escapeAttr('')).toBe('');
63
+ });
64
+
65
+ it('leaves plain text unchanged', () => {
66
+ expect(escapeAttr('hello')).toBe('hello');
67
+ });
68
+
69
+ it('handles single quotes (passed through)', () => {
70
+ expect(escapeAttr("it's")).toBe("it's");
71
+ });
72
+
73
+ it('handles combined special characters', () => {
74
+ expect(escapeAttr('a&b<c"d')).toBe('a&amp;b&lt;c&quot;d');
75
+ });
76
+ });