@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.
- package/package.json +2 -2
- package/scripts/build-edges.js +16 -8
- package/scripts/generate-data.mjs +284 -86
- package/src/__tests__/citation-display.test.ts +165 -3
- package/src/__tests__/cite-ref.test.ts +112 -0
- package/src/__tests__/concept-detail-interaction.test.ts +1 -5
- package/src/__tests__/{math.test.ts → content-renderer.test.ts} +113 -29
- package/src/__tests__/escape.test.ts +76 -0
- package/src/__tests__/graph-data-source.test.ts +155 -0
- package/src/__tests__/model-bridge-bridges.test.ts +150 -0
- package/src/__tests__/model-bridge-citation.test.ts +163 -0
- package/src/__tests__/reference-resolver-cite.test.ts +122 -0
- package/src/__tests__/reference-resolver.test.ts +12 -7
- package/src/__tests__/resolve-view.test.ts +1 -1
- package/src/__tests__/sidebar-nav-highlighting.test.ts +178 -0
- package/src/__tests__/source-refs.test.ts +9 -6
- package/src/__tests__/test-helpers.ts +20 -0
- package/src/__tests__/uri-router.test.ts +39 -12
- package/src/adapters/DatasetAdapter.ts +35 -143
- package/src/adapters/GraphDataSource.ts +178 -0
- package/src/adapters/ReferenceResolver.ts +101 -47
- package/src/adapters/UriRouter.ts +82 -10
- package/src/adapters/factory.ts +35 -28
- package/src/adapters/model-bridge.ts +121 -71
- package/src/adapters/types.ts +3 -0
- package/src/components/AppSidebar.vue +7 -4
- package/src/components/CitationDisplay.vue +86 -30
- package/src/components/ConceptDetail.vue +24 -126
- package/src/components/LanguageDetail.vue +6 -6
- package/src/composables/use-concept-content.ts +8 -8
- package/src/composables/use-ontology-nav.ts +129 -130
- package/src/composables/use-render-options.ts +1 -1
- package/src/graph/GraphEngine.ts +65 -0
- package/src/stores/vocabulary.ts +12 -73
- package/src/utils/content-renderer.ts +312 -0
- package/src/utils/markdown-lite.ts +2 -2
- 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.
|
|
25
|
-
factory.
|
|
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.
|
|
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 {
|
|
2
|
+
import { renderContent, cleanContent } from '../utils/content-renderer';
|
|
3
3
|
|
|
4
|
-
describe('
|
|
4
|
+
describe('renderContent', () => {
|
|
5
5
|
it('passes through plain text unchanged', () => {
|
|
6
|
-
expect(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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('
|
|
92
|
-
const result =
|
|
93
|
-
|
|
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(
|
|
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(
|
|
107
|
+
expect(renderContent('')).toBe('');
|
|
106
108
|
});
|
|
107
109
|
|
|
108
110
|
it('handles null-ish input', () => {
|
|
109
|
-
expect(
|
|
110
|
-
expect(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
157
|
+
const result = renderContent('stem:[a<b]');
|
|
156
158
|
expect(result).toContain('data-expr="a<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<b>');
|
|
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 & b');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('escapes less-than', () => {
|
|
10
|
+
expect(escapeHtml('a < b')).toBe('a < b');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('escapes greater-than', () => {
|
|
14
|
+
expect(escapeHtml('a > b')).toBe('a > b');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('escapes all three special characters', () => {
|
|
18
|
+
expect(escapeHtml('<script>alert("xss")</script>')).toBe(
|
|
19
|
+
'<script>alert("xss")</script>',
|
|
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&b&c');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('handles already-escaped content (double-escaping)', () => {
|
|
36
|
+
// escapeHtml does not detect already-escaped entities — by design
|
|
37
|
+
expect(escapeHtml('&')).toBe('&amp;');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('handles unicode text', () => {
|
|
41
|
+
expect(escapeHtml('日本語 < 한국어')).toBe('日本語 < 한국어');
|
|
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('<');
|
|
48
|
+
expect(result.length).toBe(input.length + 3); // '<' → '<' adds 3 chars
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('escapeAttr', () => {
|
|
53
|
+
it('escapes double quotes', () => {
|
|
54
|
+
expect(escapeAttr('a "b" c')).toBe('a "b" c');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('escapes HTML entities and quotes', () => {
|
|
58
|
+
expect(escapeAttr('<a href="x">')).toBe('<a href="x">');
|
|
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&b<c"d');
|
|
75
|
+
});
|
|
76
|
+
});
|