@glossarist/concept-browser 0.3.2 → 0.3.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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": {
@@ -3,7 +3,6 @@ import path from 'path';
3
3
  import yaml from 'js-yaml';
4
4
  import { naturalSort } from 'glossarist';
5
5
  import { loadSiteConfig } from './load-site-config.mjs';
6
- import { preRenderMath } from './math-prerender.mjs';
7
6
 
8
7
  const __dirname = path.dirname(new URL(import.meta.url).pathname);
9
8
  const ROOT = process.cwd();
@@ -56,7 +55,7 @@ function termToDesignation(term) {
56
55
  : term.type === 'abbreviation' ? 'gl:Abbreviation'
57
56
  : 'gl:Designation',
58
57
  'gl:normativeStatus': term.normative_status || 'preferred',
59
- 'gl:term': preRenderMath(term.designation),
58
+ 'gl:term': term.designation,
60
59
  };
61
60
  if (term.gender) doc['gl:gender'] = term.gender;
62
61
  if (term.plurality) doc['gl:plurality'] = term.plurality;
@@ -69,7 +68,7 @@ function defsToJsonLd(defs) {
69
68
  return defs
70
69
  .map(d => ({
71
70
  '@type': 'gl:DetailedDefinition',
72
- 'gl:content': preRenderMath(d.content || ''),
71
+ 'gl:content': d.content || '',
73
72
  }))
74
73
  .filter(d => d['gl:content']);
75
74
  }
@@ -256,7 +255,7 @@ function getPrimaryDesignation(conceptYaml) {
256
255
  if (lc && lc.terms && lc.terms.length > 0) {
257
256
  const preferredExpr = lc.terms.find(t => t.normative_status === 'preferred' && t.type === 'expression');
258
257
  const preferred = preferredExpr || lc.terms.find(t => t.normative_status === 'preferred') || lc.terms[0];
259
- descs[lang] = preRenderMath(preferred.designation);
258
+ descs[lang] = preferred.designation;
260
259
  }
261
260
  }
262
261
  return descs;
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderAsciiDocLite } from '../utils/asciidoc-lite';
3
+
4
+ describe('renderAsciiDocLite', () => {
5
+ it('returns empty string for empty input', () => {
6
+ expect(renderAsciiDocLite('')).toBe('');
7
+ });
8
+
9
+ it('wraps plain text in <p>', () => {
10
+ expect(renderAsciiDocLite('Hello world')).toBe('<p>Hello world</p>');
11
+ });
12
+
13
+ it('creates separate paragraphs on blank lines', () => {
14
+ const result = renderAsciiDocLite('First\n\nSecond');
15
+ expect(result).toContain('<p>First</p>');
16
+ expect(result).toContain('<p>Second</p>');
17
+ });
18
+
19
+ it('renders headings (level + 1, h1 reserved)', () => {
20
+ expect(renderAsciiDocLite('== Heading 2')).toContain('<h3>Heading 2</h3>');
21
+ expect(renderAsciiDocLite('=== Heading 3')).toContain('<h4>Heading 3</h4>');
22
+ expect(renderAsciiDocLite('===== Heading 5')).toContain('<h6>Heading 5</h6>');
23
+ });
24
+
25
+ it('renders bold text', () => {
26
+ expect(renderAsciiDocLite('some *bold* text')).toContain('<strong>bold</strong>');
27
+ });
28
+
29
+ it('renders italic text', () => {
30
+ expect(renderAsciiDocLite('some _italic_ text')).toContain('<em>italic</em>');
31
+ });
32
+
33
+ it('renders monospace text', () => {
34
+ expect(renderAsciiDocLite('use `code` here')).toContain('<code>code</code>');
35
+ });
36
+
37
+ it('renders AsciiDoc links with label', () => {
38
+ const result = renderAsciiDocLite('see https://example.com[label] here');
39
+ expect(result).toContain('<a href="https://example.com"');
40
+ expect(result).toContain('>label</a>');
41
+ });
42
+
43
+ it('renders bare URLs as links', () => {
44
+ const result = renderAsciiDocLite('visit https://example.com now');
45
+ expect(result).toContain('<a href="https://example.com"');
46
+ });
47
+
48
+ it('renders unordered lists', () => {
49
+ const result = renderAsciiDocLite('* item one\n* item two');
50
+ expect(result).toContain('<ul>');
51
+ expect(result).toContain('<li');
52
+ expect(result).toContain('item one');
53
+ expect(result).toContain('item two');
54
+ });
55
+
56
+ it('renders ordered lists', () => {
57
+ const result = renderAsciiDocLite('. first\n. second');
58
+ expect(result).toContain('<ol>');
59
+ expect(result).toContain('first');
60
+ expect(result).toContain('second');
61
+ });
62
+
63
+ it('renders source blocks with ---- delimiter', () => {
64
+ const result = renderAsciiDocLite('----\nlet x = 1;\n----');
65
+ expect(result).toContain('<pre><code>');
66
+ expect(result).toContain('let x = 1;');
67
+ });
68
+
69
+ it('renders source blocks with .... delimiter', () => {
70
+ const result = renderAsciiDocLite('....\nsome text\n....');
71
+ expect(result).toContain('<pre><code>');
72
+ expect(result).toContain('some text');
73
+ });
74
+
75
+ it('escapes HTML in source blocks', () => {
76
+ const result = renderAsciiDocLite('----\n<a href="evil">\n----');
77
+ expect(result).toContain('&lt;a href=&quot;evil&quot;&gt;');
78
+ expect(result).not.toContain('<a href="evil">');
79
+ });
80
+
81
+ it('handles multi-line paragraphs', () => {
82
+ const result = renderAsciiDocLite('line one\nline two\n\nnew paragraph');
83
+ expect(result).toContain('<p>line one line two</p>');
84
+ expect(result).toContain('<p>new paragraph</p>');
85
+ });
86
+
87
+ it('handles nested list levels', () => {
88
+ const result = renderAsciiDocLite('* top\n** nested');
89
+ expect(result).toContain('list-level-1');
90
+ expect(result).toContain('list-level-2');
91
+ });
92
+ });
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderMarkdown } from '../utils/markdown-lite';
3
+
4
+ describe('renderMarkdown', () => {
5
+ it('returns empty string for empty input', () => {
6
+ expect(renderMarkdown('')).toBe('');
7
+ });
8
+
9
+ it('wraps plain text in <p>', () => {
10
+ expect(renderMarkdown('Hello world')).toBe('<p>Hello world</p>');
11
+ });
12
+
13
+ it('creates separate paragraphs on blank lines', () => {
14
+ const result = renderMarkdown('First\n\nSecond');
15
+ expect(result).toContain('<p>First</p>');
16
+ expect(result).toContain('<p>Second</p>');
17
+ });
18
+
19
+ it('renders headings (level + 1, h1 reserved)', () => {
20
+ expect(renderMarkdown('## Heading 2')).toContain('<h3>Heading 2</h3>');
21
+ expect(renderMarkdown('### Heading 3')).toContain('<h4>Heading 3</h4>');
22
+ expect(renderMarkdown('#### Heading 4')).toContain('<h5>Heading 4</h5>');
23
+ });
24
+
25
+ it('renders bold text', () => {
26
+ expect(renderMarkdown('some **bold** text')).toContain('<strong>bold</strong>');
27
+ });
28
+
29
+ it('renders italic text', () => {
30
+ expect(renderMarkdown('some *italic* text')).toContain('<em>italic</em>');
31
+ });
32
+
33
+ it('renders inline code', () => {
34
+ expect(renderMarkdown('use `code` here')).toContain('<code>code</code>');
35
+ });
36
+
37
+ it('renders markdown links', () => {
38
+ const result = renderMarkdown('[label](https://example.com)');
39
+ expect(result).toContain('<a href="https://example.com"');
40
+ expect(result).toContain('>label</a>');
41
+ });
42
+
43
+ it('renders unordered lists', () => {
44
+ const result = renderMarkdown('- one\n- two');
45
+ expect(result).toContain('<ul>');
46
+ expect(result).toContain('<li>one</li>');
47
+ expect(result).toContain('<li>two</li>');
48
+ });
49
+
50
+ it('renders ordered lists', () => {
51
+ const result = renderMarkdown('1. first\n2. second');
52
+ expect(result).toContain('<ol>');
53
+ expect(result).toContain('<li>first</li>');
54
+ });
55
+
56
+ it('renders blockquotes', () => {
57
+ const result = renderMarkdown('> quoted text');
58
+ expect(result).toContain('<blockquote>');
59
+ expect(result).toContain('quoted text');
60
+ });
61
+
62
+ it('renders code fences', () => {
63
+ const result = renderMarkdown('```\nlet x = 1;\n```');
64
+ expect(result).toContain('<pre><code>');
65
+ expect(result).toContain('let x = 1;');
66
+ });
67
+
68
+ it('renders code fences with language', () => {
69
+ const result = renderMarkdown('```js\nconst x = 1;\n```');
70
+ expect(result).toContain('class="language-js"');
71
+ });
72
+
73
+ it('escapes HTML in code fences', () => {
74
+ const result = renderMarkdown('```\n<a href="evil">\n```');
75
+ expect(result).toContain('&lt;a href="evil"&gt;');
76
+ });
77
+
78
+ it('renders horizontal rules', () => {
79
+ const result = renderMarkdown('---');
80
+ expect(result).toContain('<hr>');
81
+ });
82
+
83
+ it('handles multi-line paragraphs', () => {
84
+ const result = renderMarkdown('line one\nline two\n\nnew paragraph');
85
+ expect(result).toContain('<p>line one line two</p>');
86
+ expect(result).toContain('<p>new paragraph</p>');
87
+ });
88
+ });
@@ -100,6 +100,52 @@ describe('renderMath', () => {
100
100
  expect(renderMath(null as any)).toBe('');
101
101
  expect(renderMath(undefined as any)).toBe('');
102
102
  });
103
+
104
+ // stem:[...] placeholder tests
105
+ it('outputs math-pending placeholder for stem:[expr]', () => {
106
+ const result = renderMath('value stem:[x^2] here');
107
+ expect(result).toContain('class="math-pending"');
108
+ expect(result).toContain('data-expr="x^2"');
109
+ expect(result).toContain('data-format="asciimath"');
110
+ expect(result).toContain('x^2');
111
+ });
112
+
113
+ it('outputs math-pending with math-bold for *stem:[expr]', () => {
114
+ const result = renderMath('*stem:[alpha]');
115
+ expect(result).toContain('class="math-pending math-bold"');
116
+ expect(result).toContain('data-expr="alpha"');
117
+ });
118
+
119
+ it('outputs math-pending placeholder for latexmath:[expr]', () => {
120
+ const result = renderMath('equation latexmath:[\\frac{a}{b}] end');
121
+ expect(result).toContain('class="math-pending"');
122
+ expect(result).toContain('data-expr="\\frac{a}{b}"');
123
+ expect(result).toContain('data-format="latex"');
124
+ });
125
+
126
+ it('handles multiple stem: expressions in one string', () => {
127
+ const result = renderMath('stem:[m] out of stem:[n] redundancy');
128
+ const matches = result.match(/class="math-pending"/g);
129
+ expect(matches).toHaveLength(2);
130
+ expect(result).toContain('data-expr="m"');
131
+ expect(result).toContain('data-expr="n"');
132
+ });
133
+
134
+ it('handles nested brackets in stem:', () => {
135
+ const result = renderMath('stem:[a_[i]]');
136
+ expect(result).toContain('data-expr="a_[i]"');
137
+ });
138
+
139
+ it('handles stem: in designation text', () => {
140
+ const result = renderMath('stem:[n]-ary digit');
141
+ expect(result).toContain('data-expr="n"');
142
+ expect(result).toContain('-ary digit');
143
+ });
144
+
145
+ it('escapes special HTML in stem: expressions', () => {
146
+ const result = renderMath('stem:[a<b]');
147
+ expect(result).toContain('data-expr="a&lt;b"');
148
+ });
103
149
  });
104
150
 
105
151
  describe('cleanContent', () => {
@@ -135,4 +181,20 @@ describe('cleanContent', () => {
135
181
  expect(cleanContent('')).toBe('');
136
182
  expect(cleanContent(null as any)).toBe('');
137
183
  });
184
+
185
+ it('strips stem: notation to plain expression', () => {
186
+ expect(cleanContent('stem:[x^2]')).toBe('x^2');
187
+ });
188
+
189
+ it('strips *stem: bold notation', () => {
190
+ expect(cleanContent('*stem:[alpha]')).toBe('alpha');
191
+ });
192
+
193
+ it('strips latexmath: notation', () => {
194
+ expect(cleanContent('latexmath:[\\frac{a}{b}]')).toBe('\\frac{a}{b}');
195
+ });
196
+
197
+ it('strips stem: in running text', () => {
198
+ expect(cleanContent('value stem:[m] out of stem:[n]')).toBe('value m out of n');
199
+ });
138
200
  });
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ // Mock @plurimath/plurimath to avoid loading the 2.7MB Opal runtime in tests
4
+ vi.mock('@plurimath/plurimath', () => ({
5
+ default: class MockPlurimath {
6
+ constructor(private data: string, private format: string) {}
7
+ toMathml() {
8
+ if (this.data === 'ERROR') throw new Error('parse error');
9
+ return `<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><mi>${this.data}</mi></math>`;
10
+ }
11
+ toAsciimath() { return this.data; }
12
+ toLatex() { return this.data; }
13
+ toHtml() { return this.data; }
14
+ toOmml() { return this.data; }
15
+ toDisplay() { return this.data; }
16
+ },
17
+ }));
18
+
19
+ import { loadPlurimath, renderToMathML, mathToHtml } from '../utils/plurimath';
20
+
21
+ describe('loadPlurimath', () => {
22
+ it('loads and returns the Plurimath class', async () => {
23
+ const Plurimath = await loadPlurimath();
24
+ expect(Plurimath).toBeDefined();
25
+ const p = new Plurimath('x', 'asciimath');
26
+ expect(p.toMathml()).toContain('<math');
27
+ });
28
+
29
+ it('returns the same instance on subsequent calls', async () => {
30
+ const a = await loadPlurimath();
31
+ const b = await loadPlurimath();
32
+ expect(a).toBe(b);
33
+ });
34
+ });
35
+
36
+ describe('renderToMathML', () => {
37
+ it('returns MathML with inline display after loading', async () => {
38
+ await loadPlurimath();
39
+ const result = renderToMathML('x^2', 'asciimath');
40
+ expect(result).toContain('<math');
41
+ expect(result).toContain('display="inline"');
42
+ expect(result).not.toContain('display="block"');
43
+ });
44
+
45
+ it('returns null on parse error', async () => {
46
+ await loadPlurimath();
47
+ expect(renderToMathML('ERROR', 'asciimath')).toBeNull();
48
+ });
49
+ });
50
+
51
+ describe('mathToHtml', () => {
52
+ it('wraps MathML in math-inline span', async () => {
53
+ await loadPlurimath();
54
+ const result = mathToHtml('x', 'asciimath', false);
55
+ expect(result).toContain('class="math-inline"');
56
+ expect(result).toContain('<math');
57
+ });
58
+
59
+ it('adds math-bold class when bold is true', async () => {
60
+ await loadPlurimath();
61
+ const result = mathToHtml('x', 'asciimath', true);
62
+ expect(result).toContain('class="math-inline math-bold"');
63
+ });
64
+
65
+ it('returns fallback code element on error', async () => {
66
+ await loadPlurimath();
67
+ const result = mathToHtml('ERROR', 'asciimath', false);
68
+ expect(result).toContain('class="math-fallback"');
69
+ expect(result).toContain('ERROR');
70
+ });
71
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Mock plurimath module before importing v-math
4
+ vi.mock('../utils/plurimath', () => ({
5
+ loadPlurimath: vi.fn(() => {
6
+ // Simulate loading — resolve immediately
7
+ return Promise.resolve(MockPlurimath);
8
+ }),
9
+ mathToHtml: vi.fn((expr: string, format: string, bold: boolean) => {
10
+ if (expr === 'SKIP') return `<code class="math-fallback">${expr}</code>`;
11
+ return `<span class="math-inline${bold ? ' math-bold' : ''}"><math><mi>${expr}</mi></math></span>`;
12
+ }),
13
+ }));
14
+
15
+ class MockPlurimath {
16
+ constructor() {}
17
+ }
18
+
19
+ import { vMath } from '../directives/v-math';
20
+ import { loadPlurimath, mathToHtml } from '../utils/plurimath';
21
+
22
+ describe('v-math directive', () => {
23
+ let container: HTMLElement;
24
+
25
+ beforeEach(() => {
26
+ container = document.createElement('div');
27
+ vi.clearAllMocks();
28
+ });
29
+
30
+ it('does nothing when no math-pending elements exist', () => {
31
+ container.innerHTML = '<p>plain text</p>';
32
+ vMath.mounted!(container, {} as any);
33
+ expect(container.innerHTML).toBe('<p>plain text</p>');
34
+ });
35
+
36
+ it('triggers loadPlurimath when math-pending elements exist', async () => {
37
+ container.innerHTML = '<span class="math-pending" data-expr="x^2" data-format="asciimath">x^2</span>';
38
+ vMath.mounted!(container, {} as any);
39
+ expect(loadPlurimath).toHaveBeenCalled();
40
+ });
41
+
42
+ it('replaces math-pending elements after loading', async () => {
43
+ container.innerHTML = '<span class="math-pending" data-expr="x^2" data-format="asciimath">x^2</span>';
44
+ vMath.mounted!(container, {} as any);
45
+
46
+ // Wait for loadPlurimath promise to resolve and upgrade to run
47
+ await vi.waitFor(() => {
48
+ expect(mathToHtml).toHaveBeenCalledWith('x^2', 'asciimath', false);
49
+ });
50
+ });
51
+
52
+ it('handles bold math-pending elements', async () => {
53
+ container.innerHTML = '<span class="math-pending math-bold" data-expr="alpha" data-format="asciimath">alpha</span>';
54
+ vMath.mounted!(container, {} as any);
55
+
56
+ await vi.waitFor(() => {
57
+ expect(mathToHtml).toHaveBeenCalledWith('alpha', 'asciimath', true);
58
+ });
59
+ });
60
+
61
+ it('skips elements without data-expr', async () => {
62
+ container.innerHTML = '<span class="math-pending">no expr</span>';
63
+ vMath.mounted!(container, {} as any);
64
+
65
+ await vi.waitFor(() => {
66
+ expect(mathToHtml).not.toHaveBeenCalled();
67
+ });
68
+ });
69
+
70
+ it('uses default format asciimath when data-format is missing', async () => {
71
+ container.innerHTML = '<span class="math-pending" data-expr="x">x</span>';
72
+ vMath.mounted!(container, {} as any);
73
+
74
+ await vi.waitFor(() => {
75
+ expect(mathToHtml).toHaveBeenCalledWith('x', 'asciimath', false);
76
+ });
77
+ });
78
+ });
@@ -318,7 +318,7 @@ function plainTruncate(html: string, max: number = 120): string {
318
318
  </script>
319
319
 
320
320
  <template>
321
- <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
321
+ <div v-math class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
322
322
  <!-- Header -->
323
323
  <div class="mb-6">
324
324
  <!-- Breadcrumb + nav row -->
@@ -110,7 +110,7 @@ function handleContentClick(e: MouseEvent) {
110
110
  </script>
111
111
 
112
112
  <template>
113
- <div class="space-y-5" @click="handleContentClick">
113
+ <div v-math class="space-y-5" @click="handleContentClick">
114
114
  <!-- Language selector -->
115
115
  <div class="flex flex-wrap gap-1.5">
116
116
  <button
@@ -0,0 +1,33 @@
1
+ import { type Directive } from 'vue';
2
+ import { loadPlurimath, mathToHtml } from '../utils/plurimath';
3
+
4
+ let loaded = false;
5
+
6
+ function upgrade(el: HTMLElement) {
7
+ const pending = el.querySelectorAll('.math-pending');
8
+ if (!pending.length) return;
9
+
10
+ if (!loaded) {
11
+ loadPlurimath().then(() => {
12
+ loaded = true;
13
+ upgrade(el);
14
+ });
15
+ return;
16
+ }
17
+
18
+ pending.forEach((span) => {
19
+ const expr = (span as HTMLElement).dataset.expr;
20
+ const format = (span as HTMLElement).dataset.format || 'asciimath';
21
+ const bold = span.classList.contains('math-bold');
22
+ if (!expr) return;
23
+ const html = mathToHtml(expr, format, bold);
24
+ const wrapper = document.createElement('span');
25
+ wrapper.innerHTML = html;
26
+ span.replaceWith(wrapper.firstElementChild || wrapper);
27
+ });
28
+ }
29
+
30
+ export const vMath: Directive<HTMLElement> = {
31
+ updated(el) { upgrade(el); },
32
+ mounted(el) { upgrade(el); },
33
+ };
package/src/main.ts CHANGED
@@ -2,9 +2,11 @@ import { createApp } from 'vue';
2
2
  import { createPinia } from 'pinia';
3
3
  import App from './App.vue';
4
4
  import router from './router';
5
+ import { vMath } from './directives/v-math';
5
6
  import './style.css';
6
7
 
7
8
  const app = createApp(App);
8
9
  app.use(createPinia());
9
10
  app.use(router);
11
+ app.directive('math', vMath);
10
12
  app.mount('#app');
package/src/utils/math.ts CHANGED
@@ -8,6 +8,53 @@ export interface RenderOptions {
8
8
  figResolver?: FigResolver;
9
9
  }
10
10
 
11
+ function escapeAttr(s: string): string {
12
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
13
+ }
14
+
15
+ function replaceBracketed(text: string, prefix: string, handler: (content: string, bold: boolean) => string): string {
16
+ let result = '';
17
+ let i = 0;
18
+ const boldPrefix = '*' + prefix;
19
+ while (i < text.length) {
20
+ if (text.startsWith(boldPrefix + '[', i)) {
21
+ i += boldPrefix.length + 1;
22
+ let j = i;
23
+ let d = 1;
24
+ while (j < text.length && d > 0) {
25
+ if (text[j] === '[') d++;
26
+ else if (text[j] === ']') d--;
27
+ j++;
28
+ }
29
+ const content = text.slice(i, j - 1);
30
+ let end = j;
31
+ if (end < text.length && text[end] === '*') end++;
32
+ result += handler(content, true);
33
+ i = end;
34
+ } else if (text.startsWith(prefix + '[', i)) {
35
+ i += prefix.length + 1;
36
+ let j = i;
37
+ let d = 1;
38
+ while (j < text.length && d > 0) {
39
+ if (text[j] === '[') d++;
40
+ else if (text[j] === ']') d--;
41
+ j++;
42
+ }
43
+ const content = text.slice(i, j - 1);
44
+ result += handler(content, false);
45
+ i = j;
46
+ } else {
47
+ result += text[i];
48
+ i++;
49
+ }
50
+ }
51
+ return result;
52
+ }
53
+
54
+ function mathPlaceholder(expr: string, format: string, bold: boolean): string {
55
+ return `<span class="math-pending${bold ? ' math-bold' : ''}" data-expr="${escapeAttr(expr)}" data-format="${format}">${escapeAttr(expr)}</span>`;
56
+ }
57
+
11
58
  function convertLists(text: string): string {
12
59
  let result = text.replace(/(?:^|\n)((?:[ \t]*\* [^\n]+)(?:\n[ \t]*\* [^\n]+)*)/g, (_, block) => {
13
60
  if (/^\*stem:\[/.test(block.trimStart())) return _;
@@ -37,6 +84,13 @@ function convertLists(text: string): string {
37
84
  return result;
38
85
  }
39
86
 
87
+ function escapeHtml(text: string): string {
88
+ return text
89
+ .replace(/&/g, '&amp;')
90
+ .replace(/</g, '&lt;')
91
+ .replace(/>/g, '&gt;');
92
+ }
93
+
40
94
  export function renderMath(text: string, xrefResolverOrOpts?: XrefResolver | RenderOptions): string {
41
95
  if (!text) return '';
42
96
  let result = text;
@@ -45,7 +99,10 @@ export function renderMath(text: string, xrefResolverOrOpts?: XrefResolver | Ren
45
99
  ? { xrefResolver: xrefResolverOrOpts }
46
100
  : (xrefResolverOrOpts ?? {});
47
101
 
48
- // Math (stem/latexmath) is pre-rendered at build time. Only process text formatting.
102
+ // Math expressions: output placeholders for v-math directive to upgrade
103
+ result = replaceBracketed(result, 'stem:', (expr, bold) => mathPlaceholder(expr, 'asciimath', bold));
104
+ result = replaceBracketed(result, 'latexmath:', (expr, bold) => mathPlaceholder(expr, 'latex', bold));
105
+
49
106
  result = convertLists(result);
50
107
  result = result.replace(/\*([^*]+)\*/g, '<em>$1</em>');
51
108
  result = result.replace(/~([^~]+)~/g, '<sub>$1</sub>');
@@ -85,17 +142,10 @@ export function renderMath(text: string, xrefResolverOrOpts?: XrefResolver | Ren
85
142
  return result;
86
143
  }
87
144
 
88
- function escapeHtml(text: string): string {
89
- return text
90
- .replace(/&/g, '&amp;')
91
- .replace(/</g, '&lt;')
92
- .replace(/>/g, '&gt;');
93
- }
94
-
95
145
  export function cleanContent(text: string): string {
96
146
  if (!text) return '';
97
147
  let result = text
98
- .replace(/<[^>]+>/g, '') // strip pre-rendered HTML/MathML
148
+ .replace(/<[^>]+>/g, '')
99
149
  .replace(/\*([^*]+)\*/g, '$1')
100
150
  .replace(/~([^~]+)~/g, '_$1')
101
151
  .replace(/\n[ \t]*\* /g, '; ')
@@ -103,6 +153,8 @@ export function cleanContent(text: string): string {
103
153
  .replace(/<<(fig_[^>]+)>>/g, '$1')
104
154
  .replace(/\{\{urn:[^,}]+,([^,}]+)(?:,[^}]+)?\}\}/g, '$1')
105
155
  .replace(/\{urn:[^,}]+,([^,}]+)(?:,[^}]+)?\}/g, '$1')
106
- .replace(/\{\{([^,}]+)(?:,\s*[^}]+)?\}\}/g, '$1');
156
+ .replace(/\{\{([^,}]+)(?:,\s*[^}]+)?\}\}/g, '$1')
157
+ .replace(/(?:\*?)stem:\[([^\]]*)\]/g, '$1')
158
+ .replace(/(?:\*?)latexmath:\[([^\]]*)\]/g, '$1');
107
159
  return result;
108
160
  }
@@ -0,0 +1,46 @@
1
+ type PlurimathCtor = new (data: string, format: string) => {
2
+ toAsciimath(): string;
3
+ toLatex(): string;
4
+ toMathml(): string;
5
+ toHtml(): string;
6
+ toOmml(): string;
7
+ toDisplay(lang: string): string;
8
+ };
9
+
10
+ let Plurimath: PlurimathCtor | null = null;
11
+ let loading: Promise<PlurimathCtor> | null = null;
12
+
13
+ export async function loadPlurimath(): Promise<PlurimathCtor> {
14
+ if (Plurimath) return Plurimath;
15
+ if (loading) return loading;
16
+ loading = import('@plurimath/plurimath').then(m => {
17
+ Plurimath = m.default as PlurimathCtor;
18
+ return Plurimath;
19
+ });
20
+ return loading;
21
+ }
22
+
23
+ export function renderToMathML(expr: string, format: string): string | null {
24
+ if (!Plurimath) return null;
25
+ try {
26
+ const p = new Plurimath(expr, format);
27
+ return p.toMathml().replace('display="block"', 'display="inline"').trim();
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function escapeHtml(text: string): string {
34
+ return text
35
+ .replace(/&/g, '&amp;')
36
+ .replace(/</g, '&lt;')
37
+ .replace(/>/g, '&gt;');
38
+ }
39
+
40
+ export function mathToHtml(expr: string, format: string, bold: boolean): string {
41
+ const mathml = renderToMathML(expr, format);
42
+ if (mathml) {
43
+ return `<span class="math-inline${bold ? ' math-bold' : ''}">${mathml}</span>`;
44
+ }
45
+ return `<code class="math-fallback">${escapeHtml(expr)}</code>`;
46
+ }
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, onMounted } from 'vue';
2
+ import { computed, ref } from 'vue';
3
3
  import { useVocabularyStore } from '../stores/vocabulary';
4
4
  import { useRouter } from 'vue-router';
5
5
  import { useDsStyle } from '../utils/dataset-style';
@@ -8,20 +8,9 @@ import { useSiteConfig } from '../config/use-site-config';
8
8
  const store = useVocabularyStore();
9
9
  const router = useRouter();
10
10
  const { getStyle } = useDsStyle();
11
- const { config: siteConfig, loadConfig } = useSiteConfig();
11
+ const { config: siteConfig } = useSiteConfig();
12
12
  const exploring = ref(false);
13
13
 
14
- onMounted(async () => {
15
- await loadConfig();
16
- if (siteConfig.value?.defaultDataset) {
17
- const targetId = siteConfig.value.defaultDataset;
18
- if (!store.initialized) await store.discoverDatasets();
19
- if (store.datasets.has(targetId)) {
20
- router.replace({ name: 'dataset', params: { registerId: targetId } });
21
- }
22
- }
23
- });
24
-
25
14
  async function exploreRandom() {
26
15
  exploring.value = true;
27
16
  try {
@@ -115,58 +104,88 @@ function goToGraph() { router.push({ name: 'graph' }); }
115
104
  </div>
116
105
  </div>
117
106
 
118
- <!-- Dataset cards -->
119
- <div class="flex items-center justify-between mb-5">
120
- <div class="section-label mb-0">Available Datasets</div>
121
- <span class="text-xs text-ink-300">Click to browse</span>
122
- </div>
123
- <div :class="[
124
- filteredDatasets.length === 1 ? 'max-w-md' : '',
125
- filteredDatasets.length === 2 ? 'max-w-3xl' : '',
126
- 'grid gap-4',
127
- filteredDatasets.length === 1 ? 'grid-cols-1' : 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
128
- ]">
107
+ <!-- Dataset section -->
108
+ <template v-if="filteredDatasets.length === 1">
129
109
  <button
130
- v-for="(ds, idx) in filteredDatasets"
131
- :key="ds.id"
132
- @click="goToDataset(ds.id)"
133
- class="card-hover p-6 text-left group animate-entrance"
134
- :style="{ borderLeft: `3px solid ${getStyle(ds.id).color}`, animationDelay: `${idx * 60}ms` }"
110
+ @click="goToDataset(filteredDatasets[0].id)"
111
+ class="card-hover p-8 text-left group animate-entrance max-w-md"
112
+ :style="{ borderLeft: `3px solid ${getStyle(filteredDatasets[0].id).color}` }"
135
113
  >
136
- <div class="flex items-start gap-3 mb-4">
137
- <span class="w-2.5 h-2.5 rounded-full mt-1.5 flex-shrink-0" :style="{ backgroundColor: getStyle(ds.id).color }"></span>
138
- <div class="min-w-0">
139
- <h2 class="font-serif text-xl text-ink-800 leading-snug group-hover:text-ink-900 transition-colors">
140
- {{ ds.manifest.title }}
141
- </h2>
142
- </div>
114
+ <div class="flex items-center gap-3 mb-3">
115
+ <span class="w-3 h-3 rounded-full flex-shrink-0" :style="{ backgroundColor: getStyle(filteredDatasets[0].id).color }"></span>
116
+ <h2 class="font-serif text-xl text-ink-800 group-hover:text-ink-900 transition-colors">
117
+ {{ filteredDatasets[0].manifest.title }}
118
+ </h2>
143
119
  </div>
144
-
145
- <p class="text-sm text-ink-400 mb-5 line-clamp-2 leading-relaxed pl-[22px]">
146
- {{ ds.manifest.description }}
120
+ <p v-if="filteredDatasets[0].manifest.description" class="text-sm text-ink-400 mb-4 line-clamp-2 leading-relaxed pl-6">
121
+ {{ filteredDatasets[0].manifest.description }}
147
122
  </p>
148
-
149
- <div class="flex items-center gap-3 pl-[22px] mb-3">
150
- <span :style="{ color: getStyle(ds.id).color }" class="text-sm font-semibold tabular-nums">{{ ds.manifest.conceptCount.toLocaleString() }}</span>
123
+ <div class="flex items-center gap-3 pl-6 mb-4">
124
+ <span :style="{ color: getStyle(filteredDatasets[0].id).color }" class="text-sm font-semibold tabular-nums">{{ filteredDatasets[0].manifest.conceptCount.toLocaleString() }}</span>
151
125
  <span class="text-xs text-ink-300">concepts</span>
152
126
  <span class="text-ink-200 text-xs">&middot;</span>
153
- <span class="text-sm text-ink-500 tabular-nums">{{ ds.manifest.languages.length }}</span>
127
+ <span class="text-sm text-ink-500 tabular-nums">{{ filteredDatasets[0].manifest.languages.length }}</span>
154
128
  <span class="text-xs text-ink-300">languages</span>
155
129
  </div>
156
-
157
- <div class="flex flex-wrap gap-1.5 pl-[22px] mb-3">
158
- <span v-for="tag in (ds.manifest.tags ?? []).slice(0, 3)" :key="tag" class="badge text-[10px]" :style="{ backgroundColor: getStyle(ds.id).light, color: getStyle(ds.id).dark }">
159
- {{ tag }}
130
+ <div class="pl-6">
131
+ <span class="btn-primary inline-flex items-center gap-2">
132
+ Browse concepts
133
+ <svg class="w-4 h-4 group-hover:translate-x-0.5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
160
134
  </span>
161
135
  </div>
162
-
163
- <div class="flex items-center justify-between pl-[22px]">
164
- <span class="text-[11px] text-ink-300">{{ ds.manifest.owner }}</span>
165
- <svg class="w-4 h-4 text-ink-200 group-hover:text-ink-400 group-hover:translate-x-0.5 transition-all" fill="none" stroke="currentColor" viewBox="0 0 24 24">
166
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
167
- </svg>
168
- </div>
169
136
  </button>
170
- </div>
137
+ </template>
138
+ <template v-else-if="filteredDatasets.length > 1">
139
+ <div class="flex items-center justify-between mb-5">
140
+ <div class="section-label mb-0">Available Datasets</div>
141
+ <span class="text-xs text-ink-300">Click to browse</span>
142
+ </div>
143
+ <div :class="[
144
+ filteredDatasets.length === 2 ? 'max-w-3xl' : '',
145
+ 'grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
146
+ ]">
147
+ <button
148
+ v-for="(ds, idx) in filteredDatasets"
149
+ :key="ds.id"
150
+ @click="goToDataset(ds.id)"
151
+ class="card-hover p-6 text-left group animate-entrance"
152
+ :style="{ borderLeft: `3px solid ${getStyle(ds.id).color}`, animationDelay: `${idx * 60}ms` }"
153
+ >
154
+ <div class="flex items-start gap-3 mb-4">
155
+ <span class="w-2.5 h-2.5 rounded-full mt-1.5 flex-shrink-0" :style="{ backgroundColor: getStyle(ds.id).color }"></span>
156
+ <div class="min-w-0">
157
+ <h2 class="font-serif text-xl text-ink-800 leading-snug group-hover:text-ink-900 transition-colors">
158
+ {{ ds.manifest.title }}
159
+ </h2>
160
+ </div>
161
+ </div>
162
+
163
+ <p class="text-sm text-ink-400 mb-5 line-clamp-2 leading-relaxed pl-[22px]">
164
+ {{ ds.manifest.description }}
165
+ </p>
166
+
167
+ <div class="flex items-center gap-3 pl-[22px] mb-3">
168
+ <span :style="{ color: getStyle(ds.id).color }" class="text-sm font-semibold tabular-nums">{{ ds.manifest.conceptCount.toLocaleString() }}</span>
169
+ <span class="text-xs text-ink-300">concepts</span>
170
+ <span class="text-ink-200 text-xs">&middot;</span>
171
+ <span class="text-sm text-ink-500 tabular-nums">{{ ds.manifest.languages.length }}</span>
172
+ <span class="text-xs text-ink-300">languages</span>
173
+ </div>
174
+
175
+ <div class="flex flex-wrap gap-1.5 pl-[22px] mb-3">
176
+ <span v-for="tag in (ds.manifest.tags ?? []).slice(0, 3)" :key="tag" class="badge text-[10px]" :style="{ backgroundColor: getStyle(ds.id).light, color: getStyle(ds.id).dark }">
177
+ {{ tag }}
178
+ </span>
179
+ </div>
180
+
181
+ <div class="flex items-center justify-between pl-[22px]">
182
+ <span class="text-[11px] text-ink-300">{{ ds.manifest.owner }}</span>
183
+ <svg class="w-4 h-4 text-ink-200 group-hover:text-ink-400 group-hover:translate-x-0.5 transition-all" fill="none" stroke="currentColor" viewBox="0 0 24 24">
184
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
185
+ </svg>
186
+ </div>
187
+ </button>
188
+ </div>
189
+ </template>
171
190
  </div>
172
191
  </template>
@@ -1,80 +0,0 @@
1
- import Plurimath from '@plurimath/plurimath';
2
-
3
- function renderToMathML(math, format) {
4
- try {
5
- const p = new Plurimath(math, format);
6
- return p.toMathml().replace('display="block"', 'display="inline"').trim();
7
- } catch {
8
- return null;
9
- }
10
- }
11
-
12
- function escapeHtml(text) {
13
- return text
14
- .replace(/&/g, '&amp;')
15
- .replace(/</g, '&lt;')
16
- .replace(/>/g, '&gt;');
17
- }
18
-
19
- function replaceBracketed(text, prefix, render) {
20
- let result = '';
21
- let i = 0;
22
- const boldPrefix = '*' + prefix;
23
- while (i < text.length) {
24
- if (text.startsWith(boldPrefix + '[', i)) {
25
- i += boldPrefix.length + 1;
26
- let j = i;
27
- let d = 1;
28
- while (j < text.length && d > 0) {
29
- if (text[j] === '[') d++;
30
- else if (text[j] === ']') d--;
31
- j++;
32
- }
33
- const content = text.slice(i, j - 1);
34
- let end = j;
35
- if (end < text.length && text[end] === '*') end++;
36
- result += render(content, true);
37
- i = end;
38
- } else if (text.startsWith(prefix + '[', i)) {
39
- i += prefix.length + 1;
40
- let j = i;
41
- let d = 1;
42
- while (j < text.length && d > 0) {
43
- if (text[j] === '[') d++;
44
- else if (text[j] === ']') d--;
45
- j++;
46
- }
47
- const content = text.slice(i, j - 1);
48
- result += render(content, false);
49
- i = j;
50
- } else {
51
- result += text[i];
52
- i++;
53
- }
54
- }
55
- return result;
56
- }
57
-
58
- function renderStem(math, bold) {
59
- const mathml = renderToMathML(math, 'asciimath');
60
- if (mathml) {
61
- return `<span class="math-inline${bold ? ' math-bold' : ''}">${mathml}</span>`;
62
- }
63
- return `<code class="math-fallback">${escapeHtml(math)}</code>`;
64
- }
65
-
66
- function renderLatexmath(math, bold) {
67
- const mathml = renderToMathML(math, 'latex');
68
- if (mathml) {
69
- return `<span class="math-inline${bold ? ' math-bold' : ''}">${mathml}</span>`;
70
- }
71
- return `<code class="math-fallback">${escapeHtml(math)}</code>`;
72
- }
73
-
74
- export function preRenderMath(text) {
75
- if (!text) return '';
76
- let result = text;
77
- result = replaceBracketed(result, 'stem:', renderStem);
78
- result = replaceBracketed(result, 'latexmath:', renderLatexmath);
79
- return result;
80
- }