@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.
Files changed (99) hide show
  1. package/dist/{CkInput.vue_vue_type_script_setup_true_lang-LPrW7CSQ.js → CkInput.vue_vue_type_script_setup_true_lang-BAcBFs6I.js} +302 -71
  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 +6 -0
  5. package/dist/components/base/CkAgentMessage/CkAgentMessage.stories.d.ts +7 -0
  6. package/dist/components/base/CkAgentMessage/CkAgentMessage.stories.d.ts.map +1 -1
  7. package/dist/components/base/CkCitation/CkCitation.stories.d.ts +47 -0
  8. package/dist/components/base/CkCitation/CkCitation.stories.d.ts.map +1 -0
  9. package/dist/components/base/CkCitation/CkCitation.vue.d.ts +15 -0
  10. package/dist/components/base/CkCitation/CkCitation.vue.d.ts.map +1 -0
  11. package/dist/components/base/CkCitation/index.d.ts +3 -0
  12. package/dist/components/base/CkCitation/index.d.ts.map +1 -0
  13. package/dist/components/base/CkCitation/types.d.ts +79 -0
  14. package/dist/components/base/CkCitation/types.d.ts.map +1 -0
  15. package/dist/components/base/CkCitationCard/CkCitationCard.stories.d.ts +51 -0
  16. package/dist/components/base/CkCitationCard/CkCitationCard.stories.d.ts.map +1 -0
  17. package/dist/components/base/CkCitationCard/CkCitationCard.vue.d.ts +13 -0
  18. package/dist/components/base/CkCitationCard/CkCitationCard.vue.d.ts.map +1 -0
  19. package/dist/components/base/CkCitationCard/index.d.ts +3 -0
  20. package/dist/components/base/CkCitationCard/index.d.ts.map +1 -0
  21. package/dist/components/base/CkCitationCard/types.d.ts +51 -0
  22. package/dist/components/base/CkCitationCard/types.d.ts.map +1 -0
  23. package/dist/components/base/CkDropZoneOverlay/CkDropZoneOverlay.vue.d.ts +1 -1
  24. package/dist/components/base/CkFeedbackDialog/CkFeedbackDialog.vue.d.ts +2 -2
  25. package/dist/components/base/CkFileItemsList/CkFileItemsList.vue.d.ts +1 -1
  26. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts +15 -0
  27. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts.map +1 -1
  28. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue.d.ts +16 -2
  29. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue.d.ts.map +1 -1
  30. package/dist/components/base/CkMarkdownRenderer/index.d.ts +1 -1
  31. package/dist/components/base/CkMarkdownRenderer/index.d.ts.map +1 -1
  32. package/dist/components/base/CkMarkdownRenderer/types.d.ts +25 -0
  33. package/dist/components/base/CkMarkdownRenderer/types.d.ts.map +1 -1
  34. package/dist/components/base/CkMarkdownRenderer/useCitationRendering.d.ts +17 -0
  35. package/dist/components/base/CkMarkdownRenderer/useCitationRendering.d.ts.map +1 -0
  36. package/dist/components/base/CkPromptSendButton/CkPromptSendButton.vue.d.ts +1 -1
  37. package/dist/components/base/index.d.ts +5 -1
  38. package/dist/components/base/index.d.ts.map +1 -1
  39. package/dist/components/composed/CkInput/CkInput.vue.d.ts +1 -1
  40. package/dist/components/index.d.ts +2 -2
  41. package/dist/components/index.d.ts.map +1 -1
  42. package/dist/components/index.js +25 -23
  43. package/dist/composables/fileUpload/types.d.ts +3 -0
  44. package/dist/composables/fileUpload/types.d.ts.map +1 -1
  45. package/dist/composables/fileUpload/useFileUpload.d.ts.map +1 -1
  46. package/dist/composables/fileUpload/useFileUploadWrapper.d.ts.map +1 -1
  47. package/dist/composables/index.js +1 -1
  48. package/dist/handlers/index.d.ts +3 -0
  49. package/dist/handlers/index.d.ts.map +1 -1
  50. package/dist/handlers/index.js +4 -1
  51. package/dist/handlers/useChatKitLabels.d.ts +11 -0
  52. package/dist/handlers/useChatKitLabels.d.ts.map +1 -1
  53. package/dist/handlers/useMarkdownParser.d.ts.map +1 -1
  54. package/dist/helpers/unified/remarkCitations.d.ts +17 -0
  55. package/dist/helpers/unified/remarkCitations.d.ts.map +1 -0
  56. package/dist/helpers/unified/remarkNormalizeMath.d.ts +24 -0
  57. package/dist/helpers/unified/remarkNormalizeMath.d.ts.map +1 -0
  58. package/dist/{index-DVxCNAs3.js → index-C6njJ8EB.js} +96 -49
  59. package/dist/index.d.ts +2 -2
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +28 -26
  62. package/dist/{useChatKitLabels-Ctz1dHpx.js → useChatKitLabels-BM2I7-9o.js} +126 -3
  63. package/package.json +6 -3
  64. package/src/adapters/responses-api.spec.ts +156 -0
  65. package/src/adapters/responses-api.ts +125 -58
  66. package/src/components/base/CkAgentMessage/CkAgentMessage.stories.ts +112 -0
  67. package/src/components/base/CkCitation/CkCitation.spec.ts +79 -0
  68. package/src/components/base/CkCitation/CkCitation.stories.ts +136 -0
  69. package/src/components/base/CkCitation/CkCitation.vue +145 -0
  70. package/src/components/base/CkCitation/index.ts +2 -0
  71. package/src/components/base/CkCitation/types.ts +80 -0
  72. package/src/components/base/CkCitationCard/CkCitationCard.spec.ts +77 -0
  73. package/src/components/base/CkCitationCard/CkCitationCard.stories.ts +144 -0
  74. package/src/components/base/CkCitationCard/CkCitationCard.vue +76 -0
  75. package/src/components/base/CkCitationCard/index.ts +2 -0
  76. package/src/components/base/CkCitationCard/types.ts +51 -0
  77. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.spec.ts +124 -0
  78. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.ts +118 -0
  79. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue +70 -28
  80. package/src/components/base/CkMarkdownRenderer/index.ts +1 -0
  81. package/src/components/base/CkMarkdownRenderer/types.ts +26 -0
  82. package/src/components/base/CkMarkdownRenderer/useCitationRendering.spec.ts +94 -0
  83. package/src/components/base/CkMarkdownRenderer/useCitationRendering.ts +52 -0
  84. package/src/components/base/index.ts +12 -0
  85. package/src/components/index.ts +8 -0
  86. package/src/composables/fileUpload/types.ts +6 -0
  87. package/src/composables/fileUpload/useFileUpload.spec.ts +2 -61
  88. package/src/composables/fileUpload/useFileUpload.ts +9 -10
  89. package/src/composables/fileUpload/useFileUploadWrapper.spec.ts +241 -10
  90. package/src/composables/fileUpload/useFileUploadWrapper.ts +126 -53
  91. package/src/handlers/index.ts +6 -0
  92. package/src/handlers/useChatKitLabels.ts +13 -0
  93. package/src/handlers/useMarkdownParser.spec.ts +34 -0
  94. package/src/handlers/useMarkdownParser.ts +15 -3
  95. package/src/helpers/unified/remarkCitations.spec.ts +112 -0
  96. package/src/helpers/unified/remarkCitations.ts +55 -0
  97. package/src/helpers/unified/remarkNormalizeMath.spec.ts +84 -0
  98. package/src/helpers/unified/remarkNormalizeMath.ts +134 -0
  99. 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 user) ----
180
+ // ---- Search store resolution (one per conversation) ----
181
181
 
182
- let resolvedStoreId: string | null = null;
183
- let resolvePromise: Promise<string> | null = null;
182
+ const storeIdByConversation = new Map<string, string>();
183
+ const resolvePromises = new Map<string, Promise<string>>();
184
184
 
185
- async function createSearchStoreWithWorkflow(): Promise<string> {
186
- const name = `store-${new Date().toISOString().replace(/[:.]/g, '-')}`;
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
- async function fetchOrCreateStore(): Promise<string> {
211
- try {
212
- const response =
213
- await httpClient.get<SearchStoreListResponse>('search-stores');
198
+ interface WorkflowListResponse {
199
+ items: Array<{ id: string; name?: string }>;
200
+ }
214
201
 
215
- const expectedModel = searchStoreConfig.models[0]?.embedding_model;
216
- const match = response.items.find((store) =>
217
- store.models.some((m) => m.embedding_model === expectedModel),
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
- if (match) {
221
- resolvedStoreId = match.id;
222
- return resolvedStoreId;
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
- resolvedStoreId = await createSearchStoreWithWorkflow();
226
- return resolvedStoreId;
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 resolveSearchStore(): Promise<string> {
234
- if (resolvedStoreId) return resolvedStoreId;
235
- if (resolvePromise) return resolvePromise;
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
- resolvePromise = fetchOrCreateStore();
238
- return resolvePromise;
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>(resolvedStoreId ?? '');
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, oldId: 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 userStoreId = await resolveSearchStore();
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 > availableSlots) {
391
- const kept = Math.max(0, availableSlots);
463
+ if (fileList.length > resolvedMaxFiles) {
464
+ const kept = Math.max(0, resolvedMaxFiles);
392
465
  options?.onFilesExceeded?.({
393
466
  selected: fileList.length,
394
- allowed: availableSlots,
467
+ allowed: resolvedMaxFiles,
395
468
  kept,
396
469
  });
397
470
  fileList = fileList.slice(0, kept);
@@ -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
- let proc = unified().use(remarkParse).use(remarkGfm).use(remarkMath);
77
- for (const plugin of plugins) {
78
- proc = proc.use(plugin as any);
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
+ });