@aleph-alpha/chat-kit 3.1.2 → 3.2.0

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/dist/{CkInput.vue_vue_type_script_setup_true_lang-DKzc21Yp.js → CkInput.vue_vue_type_script_setup_true_lang-B-4xnH2c.js} +1 -1
  2. package/dist/adapters/responses-api.d.ts.map +1 -1
  3. package/dist/adapters/responses-api.js +63 -37
  4. package/dist/chat-kit.css +1 -0
  5. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts +6 -0
  6. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts.map +1 -1
  7. package/dist/components/index.js +1 -1
  8. package/dist/composables/fileUpload/types.d.ts +3 -0
  9. package/dist/composables/fileUpload/types.d.ts.map +1 -1
  10. package/dist/composables/fileUpload/useFileUpload.d.ts.map +1 -1
  11. package/dist/composables/fileUpload/useFileUploadWrapper.d.ts.map +1 -1
  12. package/dist/composables/index.js +1 -1
  13. package/dist/handlers/index.d.ts +1 -0
  14. package/dist/handlers/index.d.ts.map +1 -1
  15. package/dist/handlers/index.js +3 -1
  16. package/dist/handlers/useMarkdownParser.d.ts +1 -0
  17. package/dist/handlers/useMarkdownParser.d.ts.map +1 -1
  18. package/dist/helpers/unified/remarkNormalizeMath.d.ts +24 -0
  19. package/dist/helpers/unified/remarkNormalizeMath.d.ts.map +1 -0
  20. package/dist/{index-BgQf4bMm.js → index-DAmWGYNH.js} +38 -6
  21. package/dist/index.js +3 -3
  22. package/dist/useChatKitLabels-D5gNv8se.js +28704 -0
  23. package/package.json +9 -4
  24. package/src/adapters/responses-api.spec.ts +156 -0
  25. package/src/adapters/responses-api.ts +125 -58
  26. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.ts +50 -0
  27. package/src/composables/fileUpload/types.ts +6 -0
  28. package/src/composables/fileUpload/useFileUpload.ts +7 -1
  29. package/src/composables/fileUpload/useFileUploadWrapper.spec.ts +74 -24
  30. package/src/composables/fileUpload/useFileUploadWrapper.ts +44 -4
  31. package/src/handlers/index.ts +4 -0
  32. package/src/handlers/useMarkdownParser.spec.ts +65 -0
  33. package/src/handlers/useMarkdownParser.ts +79 -6
  34. package/src/helpers/unified/remarkNormalizeMath.spec.ts +84 -0
  35. package/src/helpers/unified/remarkNormalizeMath.ts +134 -0
  36. package/dist/useChatKitLabels-CLsWWwgG.js +0 -12751
  37. /package/dist/{purify.es-md74IFlY.js → katex.min-md74IFlY.js} +0 -0
@@ -1,6 +1,34 @@
1
+ import { flushPromises } from '@vue/test-utils';
2
+ import { ref } from 'vue';
1
3
  import { createDocumentUploadProvider } from './useFileUploadWrapper';
2
4
  import type { DocumentUploadConfig } from './types';
3
5
 
6
+ const uploadMock = vi.hoisted(() => ({
7
+ upload: vi.fn().mockResolvedValue({ results: [] }),
8
+ }));
9
+
10
+ vi.mock('./useFileUpload', () => ({
11
+ useFileUpload: vi.fn((options) => {
12
+ const scopedFiles = ref([]);
13
+ return {
14
+ files: ref(new Map()),
15
+ getSupportedFileTypes: () => ({
16
+ maxFilesPerUpload: options.maxFilesPerUpload ?? 20,
17
+ allowedMimeTypes: ['application/pdf'],
18
+ allowedExtensions: ['.pdf'],
19
+ maxFileSizeBytes: 100 * 1024 * 1024,
20
+ }),
21
+ scopeBy: () => ({ files: scopedFiles }),
22
+ upload: uploadMock.upload,
23
+ runWorkflow: vi.fn().mockResolvedValue({ close: vi.fn() }),
24
+ setStoreId: vi.fn(),
25
+ setWorkflowId: vi.fn(),
26
+ updateFileMetadata: vi.fn(),
27
+ removeFile: vi.fn(),
28
+ };
29
+ }),
30
+ }));
31
+
4
32
  function createFile(name: string, size = 100): File {
5
33
  return new File(['x'.repeat(size)], name, { type: 'application/pdf' });
6
34
  }
@@ -31,6 +59,7 @@ function buildConfig(
31
59
  return {
32
60
  baseUrl: 'https://test.example.com',
33
61
  workflowId: 'wf-1',
62
+ persist: false,
34
63
  searchStoreConfig: {
35
64
  models: [
36
65
  {
@@ -48,16 +77,35 @@ function buildConfig(
48
77
  };
49
78
  }
50
79
 
80
+ async function settleAsyncUploads() {
81
+ await flushPromises();
82
+ }
83
+
51
84
  describe('useFileUploadWrapper handleFilesSelected', () => {
52
85
  let fetchMock: ReturnType<typeof vi.fn>;
53
86
 
54
87
  beforeEach(() => {
88
+ uploadMock.upload.mockClear();
89
+ uploadMock.upload.mockResolvedValue({ results: [] });
90
+
55
91
  fetchMock = vi.fn();
56
92
  vi.stubGlobal('fetch', fetchMock);
57
93
 
58
94
  fetchMock.mockImplementation((url: string, init?: RequestInit) => {
59
95
  const method = init?.method ?? 'GET';
60
96
 
97
+ if (method === 'GET' && url.includes('/workflows')) {
98
+ return createMockFetchResponse({
99
+ items: [
100
+ {
101
+ id: 'workflow-api-1',
102
+ name: 'wf-1',
103
+ created_at: '2024-01-01T00:00:00Z',
104
+ },
105
+ ],
106
+ total: 1,
107
+ });
108
+ }
61
109
  if (method === 'GET' && url.includes('search-stores')) {
62
110
  return createMockFetchResponse({
63
111
  items: [
@@ -70,27 +118,12 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
70
118
  total: 1,
71
119
  });
72
120
  }
73
- if (method === 'POST' && url.includes('/files')) {
74
- return createMockFetchResponse([{ id: 'remote-1' }]);
75
- }
76
- if (method === 'POST' && url.includes('/workflows')) {
77
- return createMockFetchResponse({ id: 'run-1' });
78
- }
79
- if (url.includes('/event-stream')) {
80
- return createMockFetchResponse(null, 200).then((res) => ({
81
- ...res,
82
- body: new ReadableStream({
83
- start(controller) {
84
- controller.close();
85
- },
86
- }),
87
- }));
88
- }
89
121
  return createMockFetchResponse({});
90
122
  });
91
123
  });
92
124
 
93
- afterEach(() => {
125
+ afterEach(async () => {
126
+ await settleAsyncUploads();
94
127
  vi.unstubAllGlobals();
95
128
  });
96
129
 
@@ -115,26 +148,31 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
115
148
  const { handleFilesSelected } = provider.useDocumentUpload('conv-1');
116
149
 
117
150
  const onFilesExceeded = vi.fn();
118
- const event = makeInputEvent([createFile('a.pdf'), createFile('b.pdf')]);
151
+ const files = [createFile('a.pdf'), createFile('b.pdf')];
152
+ const event = makeInputEvent(files);
119
153
 
120
154
  handleFilesSelected(event, { onFilesExceeded });
121
155
 
122
156
  expect(onFilesExceeded).not.toHaveBeenCalled();
157
+ await settleAsyncUploads();
158
+ expect(uploadMock.upload).toHaveBeenCalledOnce();
159
+ expect(uploadMock.upload.mock.calls[0]?.[0].files).toEqual(files);
123
160
  });
124
161
 
125
- it('truncates files to available slots and calls onFilesExceeded', () => {
162
+ it('truncates files to available slots and calls onFilesExceeded', async () => {
126
163
  const provider = createDocumentUploadProvider(
127
164
  buildConfig({ maxFilesPerUpload: 2 }),
128
165
  );
129
166
  const { handleFilesSelected } = provider.useDocumentUpload('conv-1');
130
167
 
131
168
  const onFilesExceeded = vi.fn();
132
- const event = makeInputEvent([
169
+ const files = [
133
170
  createFile('a.pdf'),
134
171
  createFile('b.pdf'),
135
172
  createFile('c.pdf'),
136
173
  createFile('d.pdf'),
137
- ]);
174
+ ];
175
+ const event = makeInputEvent(files);
138
176
 
139
177
  handleFilesSelected(event, { onFilesExceeded });
140
178
 
@@ -143,9 +181,13 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
143
181
  allowed: 2,
144
182
  kept: 2,
145
183
  });
184
+ await settleAsyncUploads();
185
+ expect(uploadMock.upload.mock.calls[0]?.[0].files).toEqual(
186
+ files.slice(0, 2),
187
+ );
146
188
  });
147
189
 
148
- it('resets input value after handling', () => {
190
+ it('resets input value after handling', async () => {
149
191
  const provider = createDocumentUploadProvider(
150
192
  buildConfig({ maxFilesPerUpload: 5 }),
151
193
  );
@@ -159,6 +201,7 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
159
201
  handleFilesSelected(event);
160
202
 
161
203
  expect(valueSetter).toHaveBeenCalledWith('');
204
+ await settleAsyncUploads();
162
205
  });
163
206
 
164
207
  it('does not upload when no slots are available', async () => {
@@ -177,9 +220,12 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
177
220
  allowed: 0,
178
221
  kept: 0,
179
222
  });
223
+ await settleAsyncUploads();
224
+ expect(uploadMock.upload).not.toHaveBeenCalled();
225
+ expect(fetchMock).not.toHaveBeenCalled();
180
226
  });
181
227
 
182
- it('does not call onFilesExceeded when exactly at the limit', () => {
228
+ it('does not call onFilesExceeded when exactly at the limit', async () => {
183
229
  const provider = createDocumentUploadProvider(
184
230
  buildConfig({ maxFilesPerUpload: 3 }),
185
231
  );
@@ -195,9 +241,11 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
195
241
  handleFilesSelected(event, { onFilesExceeded });
196
242
 
197
243
  expect(onFilesExceeded).not.toHaveBeenCalled();
244
+ await settleAsyncUploads();
245
+ expect(uploadMock.upload).toHaveBeenCalledOnce();
198
246
  });
199
247
 
200
- it('works without onFilesExceeded callback (no crash)', () => {
248
+ it('works without onFilesExceeded callback (no crash)', async () => {
201
249
  const provider = createDocumentUploadProvider(
202
250
  buildConfig({ maxFilesPerUpload: 1 }),
203
251
  );
@@ -206,5 +254,7 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
206
254
  const event = makeInputEvent([createFile('a.pdf'), createFile('b.pdf')]);
207
255
 
208
256
  expect(() => handleFilesSelected(event)).not.toThrow();
257
+ await settleAsyncUploads();
258
+ expect(uploadMock.upload).toHaveBeenCalledOnce();
209
259
  });
210
260
  });
@@ -13,6 +13,7 @@ import type {
13
13
  UploadEvent,
14
14
  } from './types';
15
15
  import { useFileUpload } from './useFileUpload';
16
+ import { defaultEndpoints } from './types';
16
17
 
17
18
  function buildHttpClient(
18
19
  baseUrl: string,
@@ -176,11 +177,21 @@ export function createDocumentUploadProvider(
176
177
  };
177
178
 
178
179
  const httpClient = buildHttpClient(baseUrl, defaultHeaders);
180
+ const endpoints = defaultEndpoints;
179
181
 
180
182
  // ---- Search store resolution (one per user) ----
181
183
 
182
184
  let resolvedStoreId: string | null = null;
183
185
  let resolvePromise: Promise<string> | null = null;
186
+ const resolvedWorkflowIdByStore = new Map<string, string>();
187
+
188
+ async function createWorkflowForStore(storeId: string): Promise<string> {
189
+ const workflowRes = await httpClient.post<{ id: string }>(
190
+ endpoints.createWorkflow(storeId),
191
+ workflowConfig,
192
+ );
193
+ return workflowRes.id;
194
+ }
184
195
 
185
196
  async function createSearchStoreWithWorkflow(): Promise<string> {
186
197
  const name = `store-${new Date().toISOString().replace(/[:.]/g, '-')}`;
@@ -190,10 +201,8 @@ export function createDocumentUploadProvider(
190
201
  });
191
202
  const newStoreId = storeRes.id;
192
203
 
193
- await httpClient.post(
194
- `search-stores/${newStoreId}/workflows`,
195
- workflowConfig,
196
- );
204
+ const workflowApiId = await createWorkflowForStore(newStoreId);
205
+ resolvedWorkflowIdByStore.set(newStoreId, workflowApiId);
197
206
 
198
207
  return newStoreId;
199
208
  }
@@ -207,6 +216,35 @@ export function createDocumentUploadProvider(
207
216
  total: number;
208
217
  }
209
218
 
219
+ interface WorkflowListResponse {
220
+ items: Array<{
221
+ id: string;
222
+ name: string;
223
+ created_at: string;
224
+ }>;
225
+ total: number;
226
+ }
227
+
228
+ async function fetchOrCreateWorkflow(storeId: string): Promise<string> {
229
+ const cached = resolvedWorkflowIdByStore.get(storeId);
230
+ if (cached) return cached;
231
+
232
+ const response = await httpClient.get<WorkflowListResponse>(
233
+ endpoints.workflows(storeId),
234
+ );
235
+
236
+ if (response.items.length > 0) {
237
+ const match = response.items.find((item) => item.name === workflowId);
238
+ const selected = match ?? response.items[0]!;
239
+ resolvedWorkflowIdByStore.set(storeId, selected.id);
240
+ return selected.id;
241
+ }
242
+
243
+ const workflowApiId = await createWorkflowForStore(storeId);
244
+ resolvedWorkflowIdByStore.set(storeId, workflowApiId);
245
+ return workflowApiId;
246
+ }
247
+
210
248
  async function fetchOrCreateStore(): Promise<string> {
211
249
  try {
212
250
  const response =
@@ -308,7 +346,9 @@ export function createDocumentUploadProvider(
308
346
  if (!files.length) return;
309
347
 
310
348
  const userStoreId = await resolveSearchStore();
349
+ const workflowApiId = await fetchOrCreateWorkflow(userStoreId);
311
350
  upload.setStoreId(userStoreId);
351
+ upload.setWorkflowId(workflowApiId);
312
352
  storeId.value = userStoreId;
313
353
 
314
354
  const conversationId = toValue(conversationIdRef);
@@ -1,5 +1,9 @@
1
1
  export { useMessageHandler } from './useMessageHandler';
2
2
  export { useMarkdownParser } from './useMarkdownParser';
3
+ export {
4
+ remarkNormalizeMath,
5
+ normalizeMathDelimiters,
6
+ } from '../helpers/unified/remarkNormalizeMath';
3
7
  export {
4
8
  provideChatKitLabels,
5
9
  useChatKitLabels,
@@ -109,4 +109,69 @@ describe('useMarkdownParser', () => {
109
109
 
110
110
  expect(html.value).toBe('<p>Hello world</p>');
111
111
  });
112
+
113
+ it('renders inline math with single dollar signs', () => {
114
+ const source = ref('The formula $E = mc^2$ is famous.');
115
+ const { html } = useMarkdownParser(source, ref(undefined));
116
+
117
+ expect(html.value).toContain('class="katex"');
118
+ expect(html.value).toContain('E = mc^2');
119
+ });
120
+
121
+ it('renders display math with double dollar signs', () => {
122
+ const source = ref('$$\n\\int_0^1 x^2 dx\n$$');
123
+ const { html } = useMarkdownParser(source, ref(undefined));
124
+
125
+ expect(html.value).toContain('class="katex-display"');
126
+ });
127
+
128
+ it('preserves MathML elements through sanitization', () => {
129
+ const source = ref('$x^2$');
130
+ const { html } = useMarkdownParser(source, ref(undefined));
131
+
132
+ expect(html.value).toContain('<math');
133
+ expect(html.value).toContain('<semantics>');
134
+ });
135
+
136
+ it('does not treat a lone dollar sign as math', () => {
137
+ const source = ref('The price is $5.');
138
+ const { html } = useMarkdownParser(source, ref(undefined));
139
+
140
+ expect(html.value).not.toContain('class="katex"');
141
+ expect(html.value).toContain('$5');
142
+ });
143
+
144
+ it('promotes inline `$$...$$` to block math via the normalizer plugin', () => {
145
+ const source = ref('Inline before $$E=mc^2$$ inline after');
146
+ const { html } = useMarkdownParser(source, ref(undefined));
147
+
148
+ expect(html.value).toContain('class="katex-display"');
149
+ expect(html.value).toContain('E=mc^2');
150
+ expect(html.value).toContain('Inline before');
151
+ expect(html.value).toContain('inline after');
152
+ });
153
+
154
+ it('renders LaTeX-style `\\(...\\)` as inline math', () => {
155
+ const source = ref('The expression \\(a+b\\) is small.');
156
+ const { html } = useMarkdownParser(source, ref(undefined));
157
+
158
+ expect(html.value).toContain('class="katex"');
159
+ expect(html.value).not.toContain('class="katex-display"');
160
+ });
161
+
162
+ it('renders LaTeX-style `\\[...\\]` as block math', () => {
163
+ const source = ref('See \\[a+b\\] above.');
164
+ const { html } = useMarkdownParser(source, ref(undefined));
165
+
166
+ expect(html.value).toContain('class="katex-display"');
167
+ });
168
+
169
+ it('does not render `$$...$$` inside a fenced code block as math', () => {
170
+ const source = ref('```\n$$x$$\n```');
171
+ const { html } = useMarkdownParser(source, ref(undefined));
172
+
173
+ expect(html.value).not.toContain('class="katex"');
174
+ expect(html.value).toContain('<code>');
175
+ expect(html.value).toContain('$$x$$');
176
+ });
112
177
  });
@@ -2,18 +2,91 @@ import { computed, type MaybeRefOrGetter, toValue } from 'vue';
2
2
  import { unified, type PluggableList } from 'unified';
3
3
  import remarkParse from 'remark-parse';
4
4
  import remarkGfm from 'remark-gfm';
5
+ import remarkMath from 'remark-math';
5
6
  import remarkRehype from 'remark-rehype';
7
+ import rehypeKatex from 'rehype-katex';
6
8
  import rehypeStringify from 'rehype-stringify';
7
- import DOMPurify from 'dompurify';
9
+ import DOMPurify, { type Config } from 'dompurify';
10
+ import 'katex/dist/katex.min.css';
11
+ import { remarkNormalizeMath } from '../helpers/unified/remarkNormalizeMath';
8
12
 
9
13
  type SyncProcessor = { processSync: (value: string) => { toString(): string } };
10
14
 
15
+ const KATEX_MATHML_TAGS = [
16
+ 'math',
17
+ 'semantics',
18
+ 'annotation',
19
+ 'mrow',
20
+ 'mi',
21
+ 'mo',
22
+ 'mn',
23
+ 'ms',
24
+ 'mtext',
25
+ 'mspace',
26
+ 'msup',
27
+ 'msub',
28
+ 'msubsup',
29
+ 'mfrac',
30
+ 'mover',
31
+ 'munder',
32
+ 'munderover',
33
+ 'msqrt',
34
+ 'mroot',
35
+ 'mtable',
36
+ 'mtr',
37
+ 'mtd',
38
+ 'menclose',
39
+ 'mpadded',
40
+ 'mphantom',
41
+ ] as const;
42
+
43
+ const KATEX_MATHML_ATTRS = [
44
+ 'xmlns',
45
+ 'encoding',
46
+ 'mathvariant',
47
+ 'displaystyle',
48
+ 'scriptlevel',
49
+ 'fence',
50
+ 'separator',
51
+ 'stretchy',
52
+ 'symmetric',
53
+ 'lspace',
54
+ 'rspace',
55
+ 'movablelimits',
56
+ 'accent',
57
+ 'accentunder',
58
+ 'linethickness',
59
+ 'columnalign',
60
+ 'rowalign',
61
+ 'columnspan',
62
+ 'rowspan',
63
+ 'width',
64
+ 'height',
65
+ 'depth',
66
+ 'minsize',
67
+ 'maxsize',
68
+ 'aria-hidden',
69
+ ] as const;
70
+
71
+ const SANITIZE_CONFIG: Config = {
72
+ ADD_TAGS: [...KATEX_MATHML_TAGS],
73
+ ADD_ATTR: [...KATEX_MATHML_ATTRS],
74
+ };
75
+
11
76
  function buildProcessor(plugins: PluggableList): SyncProcessor {
12
- let proc = unified().use(remarkParse).use(remarkGfm);
77
+ // `remarkNormalizeMath` wraps `this.parser`, so it must be registered after
78
+ // `remark-parse`. The wrap rewrites the markdown source before `remark-math`
79
+ // sees it, promoting single-line `$$…$$` and LaTeX `\(…\)` / `\[…\]` into
80
+ // delimiters that `remark-math` recognizes.
81
+ let proc = unified()
82
+ .use(remarkParse)
83
+ .use(remarkNormalizeMath)
84
+ .use(remarkGfm)
85
+ .use(remarkMath);
13
86
  for (const plugin of plugins) {
14
87
  proc = proc.use(plugin as any);
15
88
  }
16
- return proc.use(remarkRehype).use(rehypeStringify);
89
+ return proc.use(remarkRehype).use(rehypeKatex).use(rehypeStringify);
17
90
  }
18
91
 
19
92
  export function useMarkdownParser(
@@ -30,16 +103,16 @@ export function useMarkdownParser(
30
103
  return cachedProcessor;
31
104
  }
32
105
 
33
- const html = computed(() => {
106
+ const html = computed<string>(() => {
34
107
  const markdown = toValue(source);
35
108
  if (!markdown) return '';
36
109
  try {
37
110
  const raw = String(
38
111
  getProcessor(toValue(plugins) ?? []).processSync(markdown),
39
112
  );
40
- return DOMPurify.sanitize(raw);
113
+ return DOMPurify.sanitize(raw, SANITIZE_CONFIG) as string;
41
114
  } catch {
42
- return DOMPurify.sanitize(markdown);
115
+ return DOMPurify.sanitize(markdown, SANITIZE_CONFIG) as string;
43
116
  }
44
117
  });
45
118
 
@@ -0,0 +1,84 @@
1
+ import { normalizeMathDelimiters } from '../../helpers/unified/remarkNormalizeMath';
2
+
3
+ describe('normalizeMathDelimiters', () => {
4
+ it('returns an empty string for empty input', () => {
5
+ expect(normalizeMathDelimiters('')).toBe('');
6
+ });
7
+
8
+ it('leaves text without math untouched', () => {
9
+ const input = 'Hello world, nothing to see here.';
10
+ expect(normalizeMathDelimiters(input)).toBe(input);
11
+ });
12
+
13
+ it('promotes inline `$$...$$` to a block math fence', () => {
14
+ const out = normalizeMathDelimiters('before $$E=mc^2$$ after');
15
+ expect(out).toBe('before \n\n$$\nE=mc^2\n$$\n\n after');
16
+ });
17
+
18
+ it('promotes multiple inline `$$...$$` occurrences on the same line', () => {
19
+ const out = normalizeMathDelimiters('$$a$$ and $$b$$');
20
+ expect(out).toBe('\n\n$$\na\n$$\n\n and \n\n$$\nb\n$$\n\n');
21
+ });
22
+
23
+ it('leaves a properly fenced multi-line block math expression unchanged', () => {
24
+ const input = '$$\n\\int_0^1 x^2 dx\n$$';
25
+ expect(normalizeMathDelimiters(input)).toBe(input);
26
+ });
27
+
28
+ it('converts LaTeX-style `\\(...\\)` to inline math', () => {
29
+ const out = normalizeMathDelimiters('The expression \\(a+b\\) is small.');
30
+ expect(out).toBe('The expression $a+b$ is small.');
31
+ });
32
+
33
+ it('converts LaTeX-style `\\[...\\]` to a block math fence', () => {
34
+ const out = normalizeMathDelimiters('See \\[a+b\\] above.');
35
+ expect(out).toBe('See \n\n$$\na+b\n$$\n\n above.');
36
+ });
37
+
38
+ it('handles multi-line `\\[...\\]` bodies', () => {
39
+ const out = normalizeMathDelimiters('\\[\n a+b \n\\]');
40
+ expect(out).toBe('\n\n$$\na+b\n$$\n\n');
41
+ });
42
+
43
+ it('does not transform `$$...$$` inside a fenced code block', () => {
44
+ const input = '```\n$$x$$\n```';
45
+ expect(normalizeMathDelimiters(input)).toBe(input);
46
+ });
47
+
48
+ it('does not transform `$$...$$` inside a tilde-fenced code block', () => {
49
+ const input = '~~~\n$$x$$\n~~~';
50
+ expect(normalizeMathDelimiters(input)).toBe(input);
51
+ });
52
+
53
+ it('does not transform `$$...$$` inside an inline code span', () => {
54
+ const input = 'see `$$x$$` here';
55
+ expect(normalizeMathDelimiters(input)).toBe(input);
56
+ });
57
+
58
+ it('does not transform LaTeX delimiters inside a fenced code block', () => {
59
+ const input = '```\nuse \\(x\\) and \\[y\\] verbatim\n```';
60
+ expect(normalizeMathDelimiters(input)).toBe(input);
61
+ });
62
+
63
+ it('still transforms math outside the code block when both coexist', () => {
64
+ const input = '```\n$$inside$$\n```\noutside $$x$$ after';
65
+ const out = normalizeMathDelimiters(input);
66
+ expect(out).toContain('```\n$$inside$$\n```');
67
+ expect(out).toContain('\n\n$$\nx\n$$\n\n');
68
+ });
69
+
70
+ it('leaves a lone dollar sign alone', () => {
71
+ const input = 'The price is $5.';
72
+ expect(normalizeMathDelimiters(input)).toBe(input);
73
+ });
74
+
75
+ it('leaves two unrelated dollar amounts alone', () => {
76
+ const input = 'It costs $5 and $10 in total.';
77
+ expect(normalizeMathDelimiters(input)).toBe(input);
78
+ });
79
+
80
+ it('trims internal whitespace inside transformed `$$...$$`', () => {
81
+ const out = normalizeMathDelimiters('$$ x + y $$');
82
+ expect(out).toBe('\n\n$$\nx + y\n$$\n\n');
83
+ });
84
+ });
@@ -0,0 +1,134 @@
1
+ import type { Plugin } from 'unified';
2
+
3
+ const PLACEHOLDER_PREFIX = '\uE000PH_';
4
+ const PLACEHOLDER_SUFFIX = '\uE000';
5
+ const PLACEHOLDER_RE = /\uE000PH_(\d+)\uE000/g;
6
+
7
+ const FENCE_START_RE = /^[ \t]{0,3}(`{3,}|~{3,})/;
8
+ const INLINE_CODE_RE = /`[^`\n]+`/g;
9
+
10
+ const LATEX_BLOCK_RE = /\\\[([\s\S]+?)\\\]/g;
11
+ const LATEX_INLINE_RE = /\\\(([^\n]+?)\\\)/g;
12
+ const SINGLE_LINE_DOUBLE_DOLLAR_RE = /\$\$([^\n$]+?)\$\$/g;
13
+
14
+ type Stash = (value: string) => string;
15
+
16
+ function isClosingFence(
17
+ line: string,
18
+ fenceChar: string,
19
+ minLen: number,
20
+ ): boolean {
21
+ let i = 0;
22
+ let indent = 0;
23
+ while (
24
+ i < line.length &&
25
+ (line[i] === ' ' || line[i] === '\t') &&
26
+ indent < 4
27
+ ) {
28
+ indent++;
29
+ i++;
30
+ }
31
+ if (indent >= 4) return false;
32
+ let runLen = 0;
33
+ while (i < line.length && line[i] === fenceChar) {
34
+ runLen++;
35
+ i++;
36
+ }
37
+ if (runLen < minLen) return false;
38
+ while (i < line.length) {
39
+ if (line[i] !== ' ' && line[i] !== '\t') return false;
40
+ i++;
41
+ }
42
+ return true;
43
+ }
44
+
45
+ /**
46
+ * Walk the source line-by-line and stash fenced code blocks (```/~~~) so
47
+ * their contents are not rewritten by subsequent math regexes. A line-based
48
+ * scanner avoids the catastrophic-backtracking risk of a single regex over the
49
+ * whole document.
50
+ */
51
+ function protectFencedCode(input: string, stash: Stash): string {
52
+ const lines = input.split('\n');
53
+ const out: string[] = [];
54
+ let i = 0;
55
+ while (i < lines.length) {
56
+ const match = FENCE_START_RE.exec(lines[i]);
57
+ if (match) {
58
+ const fence = match[1];
59
+ const fenceChar = fence[0];
60
+ const minLen = fence.length;
61
+ const block: string[] = [lines[i]];
62
+ i++;
63
+ while (i < lines.length) {
64
+ const current = lines[i];
65
+ block.push(current);
66
+ i++;
67
+ if (isClosingFence(current, fenceChar, minLen)) break;
68
+ }
69
+ out.push(stash(block.join('\n')));
70
+ } else {
71
+ out.push(lines[i]);
72
+ i++;
73
+ }
74
+ }
75
+ return out.join('\n');
76
+ }
77
+
78
+ /**
79
+ * Normalize math delimiters in a markdown source string so they survive
80
+ * `remark-math`'s CommonMark-style parsing:
81
+ *
82
+ * - LaTeX-style block delimiters `\[ ... \]` become a fenced `$$ ... $$` block.
83
+ * - LaTeX-style inline delimiters `\( ... \)` become inline `$ ... $`.
84
+ * - Single-line `$$ ... $$` (which `remark-math` treats as inline math)
85
+ * is promoted to a proper block math fence on its own lines.
86
+ *
87
+ * Content inside fenced (``` / ~~~) or inline (`) code spans is preserved
88
+ * verbatim and is not subject to any of these rewrites.
89
+ */
90
+ export function normalizeMathDelimiters(input: string): string {
91
+ if (!input) return input;
92
+
93
+ const placeholders: string[] = [];
94
+ const stash: Stash = (value) => {
95
+ const id = placeholders.length;
96
+ placeholders.push(value);
97
+ return `${PLACEHOLDER_PREFIX}${id}${PLACEHOLDER_SUFFIX}`;
98
+ };
99
+
100
+ let s = protectFencedCode(input, stash);
101
+ s = s.replace(INLINE_CODE_RE, stash);
102
+
103
+ s = s.replace(
104
+ LATEX_BLOCK_RE,
105
+ (_m, body: string) => `\n\n$$\n${body.trim()}\n$$\n\n`,
106
+ );
107
+ s = s.replace(LATEX_INLINE_RE, (_m, body: string) => `$${body.trim()}$`);
108
+ s = s.replace(
109
+ SINGLE_LINE_DOUBLE_DOLLAR_RE,
110
+ (_m, body: string) => `\n\n$$\n${body.trim()}\n$$\n\n`,
111
+ );
112
+
113
+ s = s.replace(PLACEHOLDER_RE, (_m, id: string) => placeholders[Number(id)]);
114
+ return s;
115
+ }
116
+
117
+ type ParserLike = (document: string, file: unknown) => unknown;
118
+
119
+ /**
120
+ * Unified plugin that wraps `this.parser` so the markdown source is run
121
+ * through {@link normalizeMathDelimiters} before parsing.
122
+ *
123
+ * Register this plugin *after* `remark-parse`, otherwise `this.parser` is not
124
+ * yet defined when the plugin is invoked at freeze time and the wrap is a
125
+ * no-op.
126
+ */
127
+ export const remarkNormalizeMath: Plugin<[]> = function () {
128
+ const processor = this as unknown as { parser?: ParserLike };
129
+ const originalParser = processor.parser;
130
+ if (typeof originalParser !== 'function') return;
131
+
132
+ processor.parser = (document, file) =>
133
+ originalParser(normalizeMathDelimiters(document), file);
134
+ };