@aleph-alpha/chat-kit 3.1.3 → 3.3.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-LPrW7CSQ.js → CkInput.vue_vue_type_script_setup_true_lang-BAcBFs6I.js} +302 -71
- package/dist/adapters/responses-api.d.ts.map +1 -1
- package/dist/adapters/responses-api.js +63 -37
- package/dist/chat-kit.css +6 -0
- package/dist/components/base/CkAgentMessage/CkAgentMessage.stories.d.ts +7 -0
- package/dist/components/base/CkAgentMessage/CkAgentMessage.stories.d.ts.map +1 -1
- package/dist/components/base/CkCitation/CkCitation.stories.d.ts +47 -0
- package/dist/components/base/CkCitation/CkCitation.stories.d.ts.map +1 -0
- package/dist/components/base/CkCitation/CkCitation.vue.d.ts +15 -0
- package/dist/components/base/CkCitation/CkCitation.vue.d.ts.map +1 -0
- package/dist/components/base/CkCitation/index.d.ts +3 -0
- package/dist/components/base/CkCitation/index.d.ts.map +1 -0
- package/dist/components/base/CkCitation/types.d.ts +79 -0
- package/dist/components/base/CkCitation/types.d.ts.map +1 -0
- package/dist/components/base/CkCitationCard/CkCitationCard.stories.d.ts +51 -0
- package/dist/components/base/CkCitationCard/CkCitationCard.stories.d.ts.map +1 -0
- package/dist/components/base/CkCitationCard/CkCitationCard.vue.d.ts +13 -0
- package/dist/components/base/CkCitationCard/CkCitationCard.vue.d.ts.map +1 -0
- package/dist/components/base/CkCitationCard/index.d.ts +3 -0
- package/dist/components/base/CkCitationCard/index.d.ts.map +1 -0
- package/dist/components/base/CkCitationCard/types.d.ts +51 -0
- package/dist/components/base/CkCitationCard/types.d.ts.map +1 -0
- package/dist/components/base/CkDropZoneOverlay/CkDropZoneOverlay.vue.d.ts +1 -1
- package/dist/components/base/CkFeedbackDialog/CkFeedbackDialog.vue.d.ts +2 -2
- package/dist/components/base/CkFileItemsList/CkFileItemsList.vue.d.ts +1 -1
- package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts +15 -0
- package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts.map +1 -1
- package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue.d.ts +16 -2
- package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue.d.ts.map +1 -1
- package/dist/components/base/CkMarkdownRenderer/index.d.ts +1 -1
- package/dist/components/base/CkMarkdownRenderer/index.d.ts.map +1 -1
- package/dist/components/base/CkMarkdownRenderer/types.d.ts +25 -0
- package/dist/components/base/CkMarkdownRenderer/types.d.ts.map +1 -1
- package/dist/components/base/CkMarkdownRenderer/useCitationRendering.d.ts +17 -0
- package/dist/components/base/CkMarkdownRenderer/useCitationRendering.d.ts.map +1 -0
- package/dist/components/base/CkPromptSendButton/CkPromptSendButton.vue.d.ts +1 -1
- package/dist/components/base/index.d.ts +5 -1
- package/dist/components/base/index.d.ts.map +1 -1
- package/dist/components/composed/CkInput/CkInput.vue.d.ts +1 -1
- package/dist/components/index.d.ts +2 -2
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +25 -23
- 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 +3 -0
- package/dist/handlers/index.d.ts.map +1 -1
- package/dist/handlers/index.js +4 -1
- package/dist/handlers/useChatKitLabels.d.ts +11 -0
- package/dist/handlers/useChatKitLabels.d.ts.map +1 -1
- package/dist/handlers/useMarkdownParser.d.ts.map +1 -1
- package/dist/helpers/unified/remarkCitations.d.ts +17 -0
- package/dist/helpers/unified/remarkCitations.d.ts.map +1 -0
- package/dist/helpers/unified/remarkNormalizeMath.d.ts +24 -0
- package/dist/helpers/unified/remarkNormalizeMath.d.ts.map +1 -0
- package/dist/{index-DVxCNAs3.js → index-C6njJ8EB.js} +96 -49
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -26
- package/dist/{useChatKitLabels-Ctz1dHpx.js → useChatKitLabels-BM2I7-9o.js} +126 -3
- package/package.json +6 -3
- package/src/adapters/responses-api.spec.ts +156 -0
- package/src/adapters/responses-api.ts +125 -58
- package/src/components/base/CkAgentMessage/CkAgentMessage.stories.ts +112 -0
- package/src/components/base/CkCitation/CkCitation.spec.ts +79 -0
- package/src/components/base/CkCitation/CkCitation.stories.ts +136 -0
- package/src/components/base/CkCitation/CkCitation.vue +145 -0
- package/src/components/base/CkCitation/index.ts +2 -0
- package/src/components/base/CkCitation/types.ts +80 -0
- package/src/components/base/CkCitationCard/CkCitationCard.spec.ts +77 -0
- package/src/components/base/CkCitationCard/CkCitationCard.stories.ts +144 -0
- package/src/components/base/CkCitationCard/CkCitationCard.vue +76 -0
- package/src/components/base/CkCitationCard/index.ts +2 -0
- package/src/components/base/CkCitationCard/types.ts +51 -0
- package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.spec.ts +124 -0
- package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.ts +118 -0
- package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue +70 -28
- package/src/components/base/CkMarkdownRenderer/index.ts +1 -0
- package/src/components/base/CkMarkdownRenderer/types.ts +26 -0
- package/src/components/base/CkMarkdownRenderer/useCitationRendering.spec.ts +94 -0
- package/src/components/base/CkMarkdownRenderer/useCitationRendering.ts +52 -0
- package/src/components/base/index.ts +12 -0
- package/src/components/index.ts +8 -0
- package/src/composables/fileUpload/types.ts +6 -0
- package/src/composables/fileUpload/useFileUpload.spec.ts +2 -61
- package/src/composables/fileUpload/useFileUpload.ts +9 -10
- package/src/composables/fileUpload/useFileUploadWrapper.spec.ts +241 -10
- package/src/composables/fileUpload/useFileUploadWrapper.ts +126 -53
- package/src/handlers/index.ts +6 -0
- package/src/handlers/useChatKitLabels.ts +13 -0
- package/src/handlers/useMarkdownParser.spec.ts +34 -0
- package/src/handlers/useMarkdownParser.ts +15 -3
- package/src/helpers/unified/remarkCitations.spec.ts +112 -0
- package/src/helpers/unified/remarkCitations.ts +55 -0
- package/src/helpers/unified/remarkNormalizeMath.spec.ts +84 -0
- package/src/helpers/unified/remarkNormalizeMath.ts +134 -0
- package/src/index.ts +8 -0
|
@@ -177,25 +177,13 @@ export function createDocumentUploadProvider(
|
|
|
177
177
|
|
|
178
178
|
const httpClient = buildHttpClient(baseUrl, defaultHeaders);
|
|
179
179
|
|
|
180
|
-
// ---- Search store resolution (one per
|
|
180
|
+
// ---- Search store resolution (one per conversation) ----
|
|
181
181
|
|
|
182
|
-
|
|
183
|
-
|
|
182
|
+
const storeIdByConversation = new Map<string, string>();
|
|
183
|
+
const resolvePromises = new Map<string, Promise<string>>();
|
|
184
184
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const storeRes = await httpClient.post<{ id: string }>('search-stores', {
|
|
188
|
-
name,
|
|
189
|
-
...searchStoreConfig,
|
|
190
|
-
});
|
|
191
|
-
const newStoreId = storeRes.id;
|
|
192
|
-
|
|
193
|
-
await httpClient.post(
|
|
194
|
-
`search-stores/${newStoreId}/workflows`,
|
|
195
|
-
workflowConfig,
|
|
196
|
-
);
|
|
197
|
-
|
|
198
|
-
return newStoreId;
|
|
185
|
+
function searchStoreNameForConversation(conversationId: string): string {
|
|
186
|
+
return conversationId;
|
|
199
187
|
}
|
|
200
188
|
|
|
201
189
|
interface SearchStoreListResponse {
|
|
@@ -207,35 +195,110 @@ export function createDocumentUploadProvider(
|
|
|
207
195
|
total: number;
|
|
208
196
|
}
|
|
209
197
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
await httpClient.get<SearchStoreListResponse>('search-stores');
|
|
198
|
+
interface WorkflowListResponse {
|
|
199
|
+
items: Array<{ id: string; name?: string }>;
|
|
200
|
+
}
|
|
214
201
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
202
|
+
function findConfiguredWorkflow(
|
|
203
|
+
items: WorkflowListResponse['items'],
|
|
204
|
+
): { id: string } | undefined {
|
|
205
|
+
const configuredWorkflowName =
|
|
206
|
+
typeof workflowConfig.name === 'string' ? workflowConfig.name : undefined;
|
|
207
|
+
|
|
208
|
+
return items?.find(
|
|
209
|
+
(workflow) =>
|
|
210
|
+
workflow.id === workflowId ||
|
|
211
|
+
workflow.name === workflowId ||
|
|
212
|
+
(configuredWorkflowName != null &&
|
|
213
|
+
workflow.name === configuredWorkflowName),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
219
216
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
217
|
+
async function resolveWorkflowId(storeId: string): Promise<string> {
|
|
218
|
+
const response = await httpClient.get<WorkflowListResponse>(
|
|
219
|
+
`search-stores/${storeId}/workflows`,
|
|
220
|
+
);
|
|
221
|
+
const existing = findConfiguredWorkflow(response.items);
|
|
224
222
|
|
|
225
|
-
|
|
226
|
-
return
|
|
227
|
-
} catch (err) {
|
|
228
|
-
resolvePromise = null;
|
|
229
|
-
throw err;
|
|
223
|
+
if (existing) {
|
|
224
|
+
return existing.id;
|
|
230
225
|
}
|
|
226
|
+
|
|
227
|
+
const created = await httpClient.post<{ id: string }>(
|
|
228
|
+
`search-stores/${storeId}/workflows`,
|
|
229
|
+
workflowConfig,
|
|
230
|
+
);
|
|
231
|
+
return created.id;
|
|
231
232
|
}
|
|
232
233
|
|
|
233
|
-
async function
|
|
234
|
-
|
|
235
|
-
|
|
234
|
+
async function createSearchStore(conversationId: string): Promise<string> {
|
|
235
|
+
const storeRes = await httpClient.post<{ id: string }>('search-stores', {
|
|
236
|
+
name: searchStoreNameForConversation(conversationId),
|
|
237
|
+
...searchStoreConfig,
|
|
238
|
+
});
|
|
236
239
|
|
|
237
|
-
|
|
238
|
-
|
|
240
|
+
await httpClient.post(
|
|
241
|
+
`search-stores/${storeRes.id}/workflows`,
|
|
242
|
+
workflowConfig,
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
return storeRes.id;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function findStoreForConversation(
|
|
249
|
+
items: SearchStoreListResponse['items'],
|
|
250
|
+
conversationId: string,
|
|
251
|
+
) {
|
|
252
|
+
const expectedName = searchStoreNameForConversation(conversationId);
|
|
253
|
+
const expectedModel = searchStoreConfig.models[0]?.embedding_model;
|
|
254
|
+
|
|
255
|
+
return items.find(
|
|
256
|
+
(store) =>
|
|
257
|
+
store.name === expectedName &&
|
|
258
|
+
(!expectedModel ||
|
|
259
|
+
store.models.some((m) => m.embedding_model === expectedModel)),
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function fetchOrCreateStore(conversationId: string): Promise<string> {
|
|
264
|
+
const response =
|
|
265
|
+
await httpClient.get<SearchStoreListResponse>('search-stores');
|
|
266
|
+
|
|
267
|
+
const existing = findStoreForConversation(response.items, conversationId);
|
|
268
|
+
|
|
269
|
+
if (existing) {
|
|
270
|
+
await resolveWorkflowId(existing.id);
|
|
271
|
+
storeIdByConversation.set(conversationId, existing.id);
|
|
272
|
+
return existing.id;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const newStoreId = await createSearchStore(conversationId);
|
|
276
|
+
storeIdByConversation.set(conversationId, newStoreId);
|
|
277
|
+
return newStoreId;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function resolveSearchStore(conversationId: string): Promise<string> {
|
|
281
|
+
if (!conversationId) {
|
|
282
|
+
throw new Error('conversationId is required to resolve a search store.');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const cached = storeIdByConversation.get(conversationId);
|
|
286
|
+
if (cached) return cached;
|
|
287
|
+
|
|
288
|
+
const pending = resolvePromises.get(conversationId);
|
|
289
|
+
if (pending) return pending;
|
|
290
|
+
|
|
291
|
+
const promise = fetchOrCreateStore(conversationId).catch((err) => {
|
|
292
|
+
storeIdByConversation.delete(conversationId);
|
|
293
|
+
throw err;
|
|
294
|
+
});
|
|
295
|
+
resolvePromises.set(conversationId, promise);
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
return await promise;
|
|
299
|
+
} finally {
|
|
300
|
+
resolvePromises.delete(conversationId);
|
|
301
|
+
}
|
|
239
302
|
}
|
|
240
303
|
|
|
241
304
|
// ---- Upload SDK singleton ----
|
|
@@ -281,7 +344,9 @@ export function createDocumentUploadProvider(
|
|
|
281
344
|
const fileInput = ref<HTMLInputElement | null>(null);
|
|
282
345
|
const upload = getUploadInstance();
|
|
283
346
|
const resolvedMaxFiles = upload.getSupportedFileTypes().maxFilesPerUpload;
|
|
284
|
-
const storeId = ref<string>(
|
|
347
|
+
const storeId = ref<string>(
|
|
348
|
+
storeIdByConversation.get(toValue(conversationIdRef)) ?? '',
|
|
349
|
+
);
|
|
285
350
|
|
|
286
351
|
const currentScope = shallowRef<ScopedFileView | null>(
|
|
287
352
|
upload.scopeBy('conversationId', toValue(conversationIdRef)),
|
|
@@ -289,15 +354,20 @@ export function createDocumentUploadProvider(
|
|
|
289
354
|
|
|
290
355
|
watch(
|
|
291
356
|
() => toValue(conversationIdRef),
|
|
292
|
-
(newId: string
|
|
293
|
-
if (oldId && oldId !== newId) {
|
|
294
|
-
const oldScope = upload.scopeBy('conversationId', oldId);
|
|
295
|
-
for (const entry of oldScope.files.value) {
|
|
296
|
-
upload.updateFileMetadata(entry.id, { conversationId: newId });
|
|
297
|
-
}
|
|
298
|
-
}
|
|
357
|
+
async (newId: string) => {
|
|
299
358
|
currentScope.value = upload.scopeBy('conversationId', newId);
|
|
359
|
+
if (!newId) {
|
|
360
|
+
storeId.value = '';
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
storeId.value = await resolveSearchStore(newId);
|
|
366
|
+
} catch {
|
|
367
|
+
storeId.value = '';
|
|
368
|
+
}
|
|
300
369
|
},
|
|
370
|
+
{ immediate: true },
|
|
301
371
|
);
|
|
302
372
|
|
|
303
373
|
function scopedFiles(): FileEntry[] {
|
|
@@ -307,11 +377,15 @@ export function createDocumentUploadProvider(
|
|
|
307
377
|
async function uploadFiles(files: File[]) {
|
|
308
378
|
if (!files.length) return;
|
|
309
379
|
|
|
310
|
-
const
|
|
380
|
+
const conversationId = toValue(conversationIdRef);
|
|
381
|
+
if (!conversationId) return;
|
|
382
|
+
|
|
383
|
+
const userStoreId = await resolveSearchStore(conversationId);
|
|
384
|
+
const workflowApiId = await resolveWorkflowId(userStoreId);
|
|
311
385
|
upload.setStoreId(userStoreId);
|
|
386
|
+
upload.setWorkflowId(workflowApiId);
|
|
312
387
|
storeId.value = userStoreId;
|
|
313
388
|
|
|
314
|
-
const conversationId = toValue(conversationIdRef);
|
|
315
389
|
const result = await upload.upload({
|
|
316
390
|
id: `${conversationId}::${Date.now()}`,
|
|
317
391
|
files,
|
|
@@ -385,13 +459,12 @@ export function createDocumentUploadProvider(
|
|
|
385
459
|
) {
|
|
386
460
|
const input = event.target as HTMLInputElement;
|
|
387
461
|
let fileList = Array.from(input.files ?? []);
|
|
388
|
-
const availableSlots = resolvedMaxFiles - unassignedFiles.value.length;
|
|
389
462
|
|
|
390
|
-
if (fileList.length >
|
|
391
|
-
const kept = Math.max(0,
|
|
463
|
+
if (fileList.length > resolvedMaxFiles) {
|
|
464
|
+
const kept = Math.max(0, resolvedMaxFiles);
|
|
392
465
|
options?.onFilesExceeded?.({
|
|
393
466
|
selected: fileList.length,
|
|
394
|
-
allowed:
|
|
467
|
+
allowed: resolvedMaxFiles,
|
|
395
468
|
kept,
|
|
396
469
|
});
|
|
397
470
|
fileList = fileList.slice(0, kept);
|
package/src/handlers/index.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export { useMessageHandler } from './useMessageHandler';
|
|
2
2
|
export { useMarkdownParser } from './useMarkdownParser';
|
|
3
|
+
export {
|
|
4
|
+
remarkNormalizeMath,
|
|
5
|
+
normalizeMathDelimiters,
|
|
6
|
+
} from '../helpers/unified/remarkNormalizeMath';
|
|
7
|
+
export { remarkCitations } from '../helpers/unified/remarkCitations';
|
|
8
|
+
export type { RemarkCitationsOptions } from '../helpers/unified/remarkCitations';
|
|
3
9
|
export {
|
|
4
10
|
provideChatKitLabels,
|
|
5
11
|
useChatKitLabels,
|
|
@@ -81,6 +81,17 @@ export type ChatKitLabels = {
|
|
|
81
81
|
* can scroll wide tables). Falls back to `defaultChatKitLabels` when omitted.
|
|
82
82
|
*/
|
|
83
83
|
markdownTableLabel?: string;
|
|
84
|
+
/**
|
|
85
|
+
* Accessible label for the external-link action in a citation card
|
|
86
|
+
* (`CkCitation` / `CkCitationCard`). Falls back to `defaultChatKitLabels`.
|
|
87
|
+
*/
|
|
88
|
+
citationOpenSource?: string;
|
|
89
|
+
/**
|
|
90
|
+
* Prefix for a citation marker's accessible name in `CkCitation`, composed
|
|
91
|
+
* as `"{citationSourceLabel} {index}"` (e.g. "Source 1"). Falls back to
|
|
92
|
+
* `defaultChatKitLabels`.
|
|
93
|
+
*/
|
|
94
|
+
citationSourceLabel?: string;
|
|
84
95
|
/** Fallback title used by `CkHistory` when a conversation has no `title`. */
|
|
85
96
|
historyItemUntitled: string;
|
|
86
97
|
/** Accessible name for the trailing kebab trigger on each `CkHistory` row. */
|
|
@@ -123,6 +134,8 @@ export const defaultChatKitLabels: ChatKitLabels = {
|
|
|
123
134
|
modelSelectorListAriaLabel: 'Models',
|
|
124
135
|
modelSelectorPlaceholder: 'Select model',
|
|
125
136
|
markdownTableLabel: 'Table',
|
|
137
|
+
citationOpenSource: 'Open source',
|
|
138
|
+
citationSourceLabel: 'Source',
|
|
126
139
|
historyItemUntitled: 'Untitled conversation',
|
|
127
140
|
historyItemActionAriaLabel: 'Conversation options',
|
|
128
141
|
historyItemEditAction: 'Edit',
|
|
@@ -140,4 +140,38 @@ describe('useMarkdownParser', () => {
|
|
|
140
140
|
expect(html.value).not.toContain('class="katex"');
|
|
141
141
|
expect(html.value).toContain('$5');
|
|
142
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
|
+
});
|
|
143
177
|
});
|
|
@@ -8,6 +8,7 @@ import rehypeKatex from 'rehype-katex';
|
|
|
8
8
|
import rehypeStringify from 'rehype-stringify';
|
|
9
9
|
import DOMPurify, { type Config } from 'dompurify';
|
|
10
10
|
import 'katex/dist/katex.min.css';
|
|
11
|
+
import { remarkNormalizeMath } from '../helpers/unified/remarkNormalizeMath';
|
|
11
12
|
|
|
12
13
|
type SyncProcessor = { processSync: (value: string) => { toString(): string } };
|
|
13
14
|
|
|
@@ -73,9 +74,20 @@ const SANITIZE_CONFIG: Config = {
|
|
|
73
74
|
};
|
|
74
75
|
|
|
75
76
|
function buildProcessor(plugins: PluggableList): SyncProcessor {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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);
|
|
86
|
+
// Apply the whole list at once. unified resolves `[plugin, options]` tuples
|
|
87
|
+
// when they're entries of a list; calling `.use(tuple)` per entry instead
|
|
88
|
+
// misreads the options object as a preset and throws.
|
|
89
|
+
if (plugins.length > 0) {
|
|
90
|
+
proc = proc.use(plugins);
|
|
79
91
|
}
|
|
80
92
|
return proc.use(remarkRehype).use(rehypeKatex).use(rehypeStringify);
|
|
81
93
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { remarkCitations } from './remarkCitations';
|
|
2
|
+
|
|
3
|
+
// The plugin is a pure mdast transformer, so we can exercise it directly on a
|
|
4
|
+
// hand-built tree without spinning up the full unified/remark parser.
|
|
5
|
+
interface MdNode {
|
|
6
|
+
type: string;
|
|
7
|
+
value?: string;
|
|
8
|
+
children?: MdNode[];
|
|
9
|
+
data?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function rootWithText(text: string): MdNode {
|
|
13
|
+
return { type: 'root', children: [{ type: 'text', value: text }] };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// `remarkCitations` is typed as a unified `Plugin` (it expects a `Processor`
|
|
17
|
+
// `this`). In these unit tests we call it as a plain factory, so cast away the
|
|
18
|
+
// `this` context to invoke it directly on a hand-built tree.
|
|
19
|
+
const makeTransformer = remarkCitations as unknown as (options: {
|
|
20
|
+
indices?: ReadonlySet<number>;
|
|
21
|
+
}) => (tree: MdNode) => void;
|
|
22
|
+
|
|
23
|
+
function run(text: string, indices?: ReadonlySet<number>): MdNode[] {
|
|
24
|
+
const tree = rootWithText(text);
|
|
25
|
+
makeTransformer({ indices })(tree);
|
|
26
|
+
return tree.children!;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('remarkCitations', () => {
|
|
30
|
+
it('replaces a known [n] marker with a citation placeholder node', () => {
|
|
31
|
+
const out = run('see this [1] here', new Set([1]));
|
|
32
|
+
expect(out.map((n) => n.type)).toEqual([
|
|
33
|
+
'text',
|
|
34
|
+
'citationReference',
|
|
35
|
+
'text',
|
|
36
|
+
]);
|
|
37
|
+
expect(out[0]).toEqual({ type: 'text', value: 'see this ' });
|
|
38
|
+
expect(out[2]).toEqual({ type: 'text', value: ' here' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('emits a span placeholder with the index as a data attribute', () => {
|
|
42
|
+
const [citation] = run('[1]', new Set([1]));
|
|
43
|
+
expect(citation.data).toEqual({
|
|
44
|
+
hName: 'span',
|
|
45
|
+
hProperties: { 'data-citation-index': 1 },
|
|
46
|
+
hChildren: [{ type: 'text', value: '1' }],
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('handles adjacent markers', () => {
|
|
51
|
+
const out = run('claim [1][2] end', new Set([1, 2]));
|
|
52
|
+
expect(out.map((n) => n.type)).toEqual([
|
|
53
|
+
'text',
|
|
54
|
+
'citationReference',
|
|
55
|
+
'citationReference',
|
|
56
|
+
'text',
|
|
57
|
+
]);
|
|
58
|
+
expect(
|
|
59
|
+
(out[1].data!.hProperties as Record<string, number>)[
|
|
60
|
+
'data-citation-index'
|
|
61
|
+
],
|
|
62
|
+
).toBe(1);
|
|
63
|
+
expect(
|
|
64
|
+
(out[2].data!.hProperties as Record<string, number>)[
|
|
65
|
+
'data-citation-index'
|
|
66
|
+
],
|
|
67
|
+
).toBe(2);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('leaves unsupported markers (no matching source) as literal text', () => {
|
|
71
|
+
const out = run('real [1] but [9] is not', new Set([1]));
|
|
72
|
+
expect(out.map((n) => n.type)).toEqual([
|
|
73
|
+
'text',
|
|
74
|
+
'citationReference',
|
|
75
|
+
'text',
|
|
76
|
+
]);
|
|
77
|
+
// The [9] survives verbatim in the trailing text node.
|
|
78
|
+
expect(out[2].value).toBe(' but [9] is not');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('treats every [n] as a citation when no index set is given', () => {
|
|
82
|
+
const out = run('a [1] b [2]');
|
|
83
|
+
expect(out.filter((n) => n.type === 'citationReference')).toHaveLength(2);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('never converts [0] — citations are 1-based', () => {
|
|
87
|
+
const out = run('the value arr[0] here'); // no allow-list: every [n] except 0
|
|
88
|
+
expect(out.some((n) => n.type === 'citationReference')).toBe(false);
|
|
89
|
+
expect(out.map((n) => n.value ?? '').join('')).toContain('[0]');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('leaves text without markers untouched', () => {
|
|
93
|
+
const out = run('no citations here', new Set([1]));
|
|
94
|
+
expect(out).toEqual([{ type: 'text', value: 'no citations here' }]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('does not descend into code/inlineCode value nodes', () => {
|
|
98
|
+
const tree: MdNode = {
|
|
99
|
+
type: 'root',
|
|
100
|
+
children: [
|
|
101
|
+
{ type: 'inlineCode', value: 'arr[1]' },
|
|
102
|
+
{ type: 'text', value: ' and [1]' },
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
makeTransformer({ indices: new Set([1]) })(tree);
|
|
106
|
+
// inlineCode is untouched; only the text node's [1] becomes a citation.
|
|
107
|
+
expect(tree.children![0]).toEqual({ type: 'inlineCode', value: 'arr[1]' });
|
|
108
|
+
expect(tree.children!.some((n) => n.type === 'citationReference')).toBe(
|
|
109
|
+
true,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Plugin } from 'unified';
|
|
2
|
+
import type { PhrasingContent } from 'mdast';
|
|
3
|
+
import { findAndReplace } from 'mdast-util-find-and-replace';
|
|
4
|
+
|
|
5
|
+
export interface RemarkCitationsOptions {
|
|
6
|
+
/**
|
|
7
|
+
* When provided, only `[n]` markers whose number is in this set are turned
|
|
8
|
+
* into citation placeholders; any other `[n]` is left as literal text
|
|
9
|
+
* (an "unsupported" marker with no backing source). When omitted, every
|
|
10
|
+
* `[n]` is treated as a citation.
|
|
11
|
+
*/
|
|
12
|
+
indices?: ReadonlySet<number>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Matches a bare `[n]` reference, e.g. `[1]`. Because this runs on the parsed
|
|
16
|
+
// mdast tree (via `findAndReplace`, which only visits `text` nodes), `[n]`
|
|
17
|
+
// inside code spans/blocks and bracketed links never reaches it — only a
|
|
18
|
+
// literal `[n]` typed in prose does.
|
|
19
|
+
const CITATION_RE = /\[(\d+)\]/g;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Replaces inline `[n]` citation references in the parsed markdown with a
|
|
23
|
+
* placeholder element (`<span data-citation-index="n">n</span>`) that survives
|
|
24
|
+
* stringify + sanitisation (`data-*` attributes are allowed by default).
|
|
25
|
+
*/
|
|
26
|
+
// The tree type is left as unified's default (a unist `Node`) so the public
|
|
27
|
+
// type signature — and therefore the published `.d.ts` — doesn't depend on
|
|
28
|
+
// `mdast` types; the cast below keeps the internal `findAndReplace` call typed.
|
|
29
|
+
export const remarkCitations: Plugin<[RemarkCitationsOptions?]> = (
|
|
30
|
+
options = {},
|
|
31
|
+
) => {
|
|
32
|
+
const { indices } = options;
|
|
33
|
+
|
|
34
|
+
return (tree) => {
|
|
35
|
+
findAndReplace(tree as Parameters<typeof findAndReplace>[0], [
|
|
36
|
+
CITATION_RE,
|
|
37
|
+
(_match: string, digits: string) => {
|
|
38
|
+
const index = Number(digits);
|
|
39
|
+
if (index < 1) return false; // citations are 1-based
|
|
40
|
+
// Leave unsupported markers (no backing source) as literal text.
|
|
41
|
+
if (indices && !indices.has(index)) return false;
|
|
42
|
+
return {
|
|
43
|
+
type: 'citationReference',
|
|
44
|
+
data: {
|
|
45
|
+
hName: 'span',
|
|
46
|
+
// Use the literal attribute name so the placeholder contract matches
|
|
47
|
+
// the renderer's `getAttribute('data-citation-index')` lookup exactly.
|
|
48
|
+
hProperties: { 'data-citation-index': index },
|
|
49
|
+
hChildren: [{ type: 'text', value: String(index) }],
|
|
50
|
+
},
|
|
51
|
+
} as unknown as PhrasingContent;
|
|
52
|
+
},
|
|
53
|
+
]);
|
|
54
|
+
};
|
|
55
|
+
};
|
|
@@ -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
|
+
});
|