@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.
- 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
- package/dist/adapters/responses-api.d.ts.map +1 -1
- package/dist/adapters/responses-api.js +63 -37
- package/dist/chat-kit.css +1 -0
- package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts +6 -0
- package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/composables/fileUpload/types.d.ts +3 -0
- package/dist/composables/fileUpload/types.d.ts.map +1 -1
- package/dist/composables/fileUpload/useFileUpload.d.ts.map +1 -1
- package/dist/composables/fileUpload/useFileUploadWrapper.d.ts.map +1 -1
- package/dist/composables/index.js +1 -1
- package/dist/handlers/index.d.ts +1 -0
- package/dist/handlers/index.d.ts.map +1 -1
- package/dist/handlers/index.js +3 -1
- package/dist/handlers/useMarkdownParser.d.ts +1 -0
- package/dist/handlers/useMarkdownParser.d.ts.map +1 -1
- package/dist/helpers/unified/remarkNormalizeMath.d.ts +24 -0
- package/dist/helpers/unified/remarkNormalizeMath.d.ts.map +1 -0
- package/dist/{index-BgQf4bMm.js → index-DAmWGYNH.js} +38 -6
- package/dist/index.js +3 -3
- package/dist/useChatKitLabels-D5gNv8se.js +28704 -0
- package/package.json +9 -4
- package/src/adapters/responses-api.spec.ts +156 -0
- package/src/adapters/responses-api.ts +125 -58
- package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.ts +50 -0
- package/src/composables/fileUpload/types.ts +6 -0
- package/src/composables/fileUpload/useFileUpload.ts +7 -1
- package/src/composables/fileUpload/useFileUploadWrapper.spec.ts +74 -24
- package/src/composables/fileUpload/useFileUploadWrapper.ts +44 -4
- package/src/handlers/index.ts +4 -0
- package/src/handlers/useMarkdownParser.spec.ts +65 -0
- package/src/handlers/useMarkdownParser.ts +79 -6
- package/src/helpers/unified/remarkNormalizeMath.spec.ts +84 -0
- package/src/helpers/unified/remarkNormalizeMath.ts +134 -0
- package/dist/useChatKitLabels-CLsWWwgG.js +0 -12751
- /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
|
|
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
|
|
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
|
|
194
|
-
|
|
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);
|
package/src/handlers/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|