@ai-composer/dom 0.0.0-stage → 0.1.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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +5 -2
  3. package/dist/icons.d.ts +11 -0
  4. package/dist/icons.d.ts.map +1 -0
  5. package/dist/icons.js +46 -0
  6. package/dist/icons.js.map +1 -0
  7. package/dist/index.d.ts +20 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +20 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/mount.d.ts +41 -0
  12. package/dist/mount.d.ts.map +1 -0
  13. package/dist/mount.js +168 -0
  14. package/dist/mount.js.map +1 -0
  15. package/dist/package.json +25 -0
  16. package/dist/parse.d.ts +13 -0
  17. package/dist/parse.d.ts.map +1 -0
  18. package/dist/parse.js +133 -0
  19. package/dist/parse.js.map +1 -0
  20. package/dist/popup.d.ts +34 -0
  21. package/dist/popup.d.ts.map +1 -0
  22. package/dist/popup.js +121 -0
  23. package/dist/popup.js.map +1 -0
  24. package/dist/render.d.ts +17 -0
  25. package/dist/render.d.ts.map +1 -0
  26. package/dist/render.js +84 -0
  27. package/dist/render.js.map +1 -0
  28. package/dist/selection.d.ts +13 -0
  29. package/dist/selection.d.ts.map +1 -0
  30. package/dist/selection.js +110 -0
  31. package/dist/selection.js.map +1 -0
  32. package/dist/suggestions.d.ts +23 -0
  33. package/dist/suggestions.d.ts.map +1 -0
  34. package/dist/suggestions.js +113 -0
  35. package/dist/suggestions.js.map +1 -0
  36. package/dist/surface.d.ts +40 -0
  37. package/dist/surface.d.ts.map +1 -0
  38. package/dist/surface.js +372 -0
  39. package/dist/surface.js.map +1 -0
  40. package/dist/template.d.ts +26 -0
  41. package/dist/template.d.ts.map +1 -0
  42. package/dist/template.js +47 -0
  43. package/dist/template.js.map +1 -0
  44. package/package.json +21 -3
  45. package/project.json +56 -0
  46. package/src/icons.ts +51 -0
  47. package/src/index.ts +50 -0
  48. package/src/mount.ts +218 -0
  49. package/src/parse.ts +135 -0
  50. package/src/popup.ts +153 -0
  51. package/src/render.ts +88 -0
  52. package/src/selection.ts +141 -0
  53. package/src/suggestions.ts +146 -0
  54. package/src/surface.test.ts +266 -0
  55. package/src/surface.ts +433 -0
  56. package/src/template.ts +80 -0
  57. package/tsconfig.json +15 -0
  58. package/tsconfig.lib.json +15 -0
  59. package/tsconfig.spec.json +16 -0
  60. package/vitest.config.ts +16 -0
package/src/surface.ts ADDED
@@ -0,0 +1,433 @@
1
+ /**
2
+ * The editable surface: binds a contentEditable host to an editor using the
3
+ * model-first pipeline:
4
+ *
5
+ * user input → parse DOM → editor.applyViewUpdate() (model is authoritative)
6
+ * model change → render → restore caret from model selection
7
+ *
8
+ * All programmatic APIs (setValue/insertNode/undo…) write the model; the
9
+ * surface re-projects it. See ADR-0001.
10
+ */
11
+
12
+ import {
13
+ createSelection,
14
+ documentToText,
15
+ ensureNodeKey,
16
+ fromGlobalOffset,
17
+ getRange,
18
+ sanitizeDocument,
19
+ toGlobalOffset,
20
+ type AIComposerDocument,
21
+ type AIComposer,
22
+ type AIComposerNode,
23
+ type SubmitKey,
24
+ type Unsubscribe,
25
+ } from '@ai-composer/core';
26
+ import { parseEditableHost, htmlToPlainText, CLIPBOARD_MIME } from './parse';
27
+ import { renderDocument } from './render';
28
+ import { applyModelSelection, domSelectionToModel } from './selection';
29
+
30
+ export interface EditableSurfaceOptions {
31
+ /** Multiline hint for a11y and Enter handling (chat/expanded modes). */
32
+ multiline?: boolean;
33
+ /** Override the editor config's submit key behavior. */
34
+ submitKey?: SubmitKey;
35
+ /** Explicit id (used for aria-controls wiring). */
36
+ id?: string;
37
+ /** Additional class names for the host. */
38
+ className?: string;
39
+ /** Accessible label; defaults to the placeholder. */
40
+ ariaLabel?: string;
41
+ }
42
+
43
+ export interface EditableSurface {
44
+ readonly host: HTMLElement;
45
+ /** Manually re-render from the model (used by undo/redo view hook). */
46
+ render(): void;
47
+ focus(options?: { at?: 'start' | 'end' }): void;
48
+ blur(): void;
49
+ /** Multiline hint (a11y + newline handling) — updated on mode switches. */
50
+ setMultiline(multiline: boolean): void;
51
+ /** Submit-key override (mode presets) — cleared with `undefined`. */
52
+ setSubmitKey(key: SubmitKey | undefined): void;
53
+ destroy(): void;
54
+ }
55
+
56
+ export const EDITABLE_SURFACE_CLASS = 'aic-input';
57
+
58
+ let surfaceCounter = 0;
59
+
60
+ export function createEditableSurface(
61
+ editor: AIComposer,
62
+ host: HTMLElement,
63
+ options: EditableSurfaceOptions = {},
64
+ ): EditableSurface {
65
+ const surfaceId = options.id ?? `aic-input-${(surfaceCounter += 1)}`;
66
+ let submitKeyOverride = options.submitKey;
67
+ const submitKey = (): SubmitKey => submitKeyOverride ?? editor.getConfig().submitKey ?? 'enter';
68
+ const ariaLabel = (): string =>
69
+ options.ariaLabel ?? editor.getConfig().placeholder ?? 'AI Composer';
70
+
71
+ let composing = false;
72
+ let rendering = false;
73
+ let fromDom = false;
74
+ let destroyed = false;
75
+ let multiline = options.multiline ?? true;
76
+ let lastRendered: AIComposerDocument | null = null;
77
+ const disposables: Unsubscribe[] = [];
78
+
79
+ host.id = surfaceId;
80
+ host.classList.add(EDITABLE_SURFACE_CLASS);
81
+ host.setAttribute('data-aic-input', '');
82
+ host.setAttribute('role', 'textbox');
83
+ host.setAttribute('aria-label', ariaLabel());
84
+ host.setAttribute('aria-multiline', String(multiline));
85
+ host.spellcheck = false;
86
+
87
+ // -- rendering -----------------------------------------------------------
88
+
89
+ function render(): void {
90
+ if (destroyed) return;
91
+ rendering = true;
92
+ try {
93
+ renderDocument(host, editor.getValue(), editor.nodes);
94
+ lastRendered = editor.getValue();
95
+ applyModelSelection(host, editor.getValue(), editor.getSelection());
96
+ } finally {
97
+ rendering = false;
98
+ }
99
+ }
100
+
101
+ function syncFromState(): void {
102
+ if (destroyed) return;
103
+ const state = editor.getState();
104
+
105
+ const editable = !state.disabled && !state.readonly;
106
+ if (host.getAttribute('contenteditable') !== (editable ? 'true' : 'false')) {
107
+ host.setAttribute('contenteditable', editable ? 'true' : 'false');
108
+ }
109
+ host.toggleAttribute('data-aic-readonly', state.readonly);
110
+ host.toggleAttribute('data-aic-disabled', state.disabled);
111
+ host.setAttribute('aria-label', ariaLabel());
112
+ if (state.placeholder) {
113
+ host.setAttribute('data-placeholder', state.placeholder);
114
+ }
115
+ host.toggleAttribute('data-aic-empty', state.empty);
116
+
117
+ if (state.value !== lastRendered) {
118
+ if (fromDom) {
119
+ // The change originated from this host's own input event — the DOM
120
+ // already reflects it. Re-rendering under the browser's active input
121
+ // processing corrupts caret/insert state (duplicated keystrokes).
122
+ lastRendered = state.value;
123
+ } else {
124
+ render();
125
+ }
126
+ }
127
+ syncMultilineHint(state.empty);
128
+ }
129
+
130
+ /**
131
+ * Multiline detection for styling (compact pill → rounded box) and a11y:
132
+ * true when the model contains a newline OR the content spans more than one
133
+ * line box (soft wrap). Growth then comes from `height: auto`; the cap is
134
+ * `--aic-input-max-height` with an internal scrollbar. Never true for an
135
+ * empty host — the browser's trailing `<br>` artifact would flag it.
136
+ */
137
+ function syncMultilineHint(empty: boolean): void {
138
+ // Only real text newlines count — serialized chips may render as '\n'.
139
+ const hasNewline = editor
140
+ .getValue()
141
+ .nodes.some((node) => node.type === 'text' && node.text.includes('\n'));
142
+ const multiline = !empty && (hasNewline || spansMultipleLines(host));
143
+ // Mirrored onto the root so theme CSS uses plain attribute selectors —
144
+ // `:has()`-based rules are still dropped by some engine versions.
145
+ host.toggleAttribute('data-aic-multiline', multiline);
146
+ host.closest('.aic-root')?.toggleAttribute('data-aic-multiline', multiline);
147
+ }
148
+
149
+ // -- input pipeline --------------------------------------------------------
150
+
151
+ function syncFromDom(): void {
152
+ if (destroyed || rendering || composing) return;
153
+ const parsed = parseEditableHost(host, editor.nodes);
154
+ const selection = domSelectionToModel(host, parsed);
155
+ fromDom = true;
156
+ try {
157
+ editor.applyViewUpdate(parsed, selection ?? undefined);
158
+ } finally {
159
+ fromDom = false;
160
+ }
161
+ }
162
+
163
+ function handleEnter(shiftKey: boolean): void {
164
+ const state = editor.getState();
165
+ const key = submitKey();
166
+
167
+ if (key === 'none' || (key === 'enter' && shiftKey) || (key === 'shift-enter' && !shiftKey)) {
168
+ editor.insertText('\n');
169
+ return;
170
+ }
171
+ if (state.suggestions.length > 0) {
172
+ void editor.acceptSuggestion();
173
+ return;
174
+ }
175
+ void editor.submit();
176
+ }
177
+
178
+ function onBeforeInput(event: Event): void {
179
+ if (destroyed) return;
180
+ const inputType = (event as InputEvent).inputType;
181
+ // Enter is fully handled at keydown; beforeinput is a safety net against
182
+ // native paragraph markup from edge paths (e.g. IME confirm).
183
+ if (inputType === 'insertParagraph') {
184
+ event.preventDefault();
185
+ }
186
+ if (inputType === 'historyUndo') {
187
+ event.preventDefault();
188
+ editor.undo();
189
+ }
190
+ if (inputType === 'historyRedo') {
191
+ event.preventDefault();
192
+ editor.redo();
193
+ }
194
+ }
195
+
196
+ function onInput(): void {
197
+ if (composing) return;
198
+ syncFromDom();
199
+ }
200
+
201
+ function onKeyDown(event: KeyboardEvent): void {
202
+ if (destroyed) return;
203
+ const state = editor.getState();
204
+ const key = event.key;
205
+
206
+ // Suggestion menu navigation
207
+ if (state.suggestions.length > 0) {
208
+ if (key === 'ArrowDown') {
209
+ event.preventDefault();
210
+ editor.moveSuggestionSelection(1);
211
+ return;
212
+ }
213
+ if (key === 'ArrowUp') {
214
+ event.preventDefault();
215
+ editor.moveSuggestionSelection(-1);
216
+ return;
217
+ }
218
+ if (key === 'Enter' || key === 'Tab') {
219
+ event.preventDefault();
220
+ void editor.acceptSuggestion();
221
+ return;
222
+ }
223
+ if (key === 'Escape') {
224
+ event.preventDefault();
225
+ editor.closeTrigger('escape');
226
+ return;
227
+ }
228
+ } else if (key === 'Enter') {
229
+ // Enter semantics live here — keydown carries reliable modifier state.
230
+ event.preventDefault();
231
+ handleEnter(event.shiftKey);
232
+ return;
233
+ }
234
+
235
+ // History shortcuts (native contentEditable undo is disabled)
236
+ const meta = event.metaKey || event.ctrlKey;
237
+ if (meta && key.toLowerCase() === 'z') {
238
+ event.preventDefault();
239
+ if (event.shiftKey) editor.redo();
240
+ else editor.undo();
241
+ return;
242
+ }
243
+ if (meta && key.toLowerCase() === 'y') {
244
+ event.preventDefault();
245
+ editor.redo();
246
+ return;
247
+ }
248
+ }
249
+
250
+ function onPaste(event: Event): void {
251
+ if (destroyed) return;
252
+ event.preventDefault();
253
+ const transfer = (event as ClipboardEvent).clipboardData;
254
+ if (!transfer) return;
255
+
256
+ const internal = transfer.getData(CLIPBOARD_MIME);
257
+ if (internal) {
258
+ try {
259
+ const parsed = JSON.parse(internal) as { nodes?: AIComposerNode[] };
260
+ if (Array.isArray(parsed?.nodes)) {
261
+ const sanitized = sanitizeDocument(
262
+ { nodes: parsed.nodes.map(ensureNodeKey) },
263
+ (type) => editor.nodes.has(type),
264
+ );
265
+ if (sanitized.nodes.length > 0) {
266
+ editor.replaceRange(getRange(editor.getSelection()), sanitized.nodes);
267
+ return;
268
+ }
269
+ }
270
+ } catch {
271
+ // malformed internal payload — fall through to plain text
272
+ }
273
+ }
274
+
275
+ const html = transfer.getData('text/html');
276
+ const text = transfer.getData('text/plain') || (html ? htmlToPlainText(html) : '');
277
+ if (text) editor.insertText(text);
278
+ }
279
+
280
+ function onCopy(event: ClipboardEvent): void {
281
+ const payload = sliceSelection(editor.getValue(), editor.getSelection());
282
+ if (payload.nodes.length === 0) return;
283
+ event.preventDefault();
284
+ const selectedDoc: AIComposerDocument = { nodes: payload.nodes };
285
+ event.clipboardData?.setData('text/plain', documentText(editor, selectedDoc));
286
+ event.clipboardData?.setData(CLIPBOARD_MIME, JSON.stringify({ nodes: payload.nodes }));
287
+ }
288
+
289
+ function onFocus(): void {
290
+ editor.setFocused(true);
291
+ }
292
+
293
+ function onBlur(): void {
294
+ editor.setFocused(false);
295
+ }
296
+
297
+ function onSelectionChange(): void {
298
+ if (destroyed || rendering) return;
299
+ const ownerDocument = host.ownerDocument;
300
+ const domSelection = ownerDocument.getSelection();
301
+ if (!domSelection || !host.contains(domSelection.anchorNode)) return;
302
+ const selection = domSelectionToModel(host, editor.getValue());
303
+ if (selection) {
304
+ try {
305
+ editor.setSelection(selection);
306
+ } catch {
307
+ // Editor may have been destroyed between the timer scheduling and now.
308
+ }
309
+ }
310
+ }
311
+
312
+ // -- wiring ---------------------------------------------------------------
313
+
314
+ host.addEventListener('beforeinput', onBeforeInput);
315
+ host.addEventListener('input', onInput);
316
+ host.addEventListener('keydown', onKeyDown);
317
+ host.addEventListener('paste', onPaste);
318
+ host.addEventListener('copy', onCopy as EventListener);
319
+ host.addEventListener('focus', onFocus);
320
+ host.addEventListener('blur', onBlur);
321
+ host.addEventListener('compositionstart', () => {
322
+ composing = true;
323
+ });
324
+ host.addEventListener('compositionend', () => {
325
+ composing = false;
326
+ syncFromDom();
327
+ });
328
+ host.ownerDocument.addEventListener('selectionchange', onSelectionChange);
329
+
330
+ disposables.push(
331
+ editor.attachView({
332
+ focus: () => surface.focus(),
333
+ blur: () => surface.blur(),
334
+ render: () => render(),
335
+ }),
336
+ );
337
+ disposables.push(editor.subscribe(syncFromState));
338
+ editor.on('destroy', () => surface.destroy());
339
+
340
+ syncFromState();
341
+
342
+ const surface: EditableSurface = {
343
+ host,
344
+ render,
345
+ focus(options2) {
346
+ host.focus({ preventScroll: true });
347
+ const ownerDocument = host.ownerDocument;
348
+ const domSelection = ownerDocument.getSelection();
349
+ if (!domSelection || !host.contains(domSelection.focusNode)) {
350
+ const position = options2?.at ?? 'end';
351
+ const doc = editor.getValue();
352
+ const total = doc.nodes.reduce((sum, n) => sum + (n.type === 'text' ? n.text.length : 1), 0);
353
+ const offset = position === 'start' ? 0 : total;
354
+ editor.setSelection(createSelection(fromGlobalOffset(doc, offset)));
355
+ }
356
+ },
357
+ blur() {
358
+ host.blur();
359
+ },
360
+ setMultiline(next: boolean) {
361
+ multiline = next;
362
+ host.setAttribute('aria-multiline', String(next));
363
+ },
364
+ setSubmitKey(key: SubmitKey | undefined) {
365
+ submitKeyOverride = key;
366
+ },
367
+ destroy() {
368
+ if (destroyed) return;
369
+ destroyed = true;
370
+ host.removeEventListener('beforeinput', onBeforeInput);
371
+ host.removeEventListener('input', onInput);
372
+ host.removeEventListener('keydown', onKeyDown);
373
+ host.removeEventListener('paste', onPaste);
374
+ host.removeEventListener('copy', onCopy as EventListener);
375
+ host.removeEventListener('focus', onFocus);
376
+ host.removeEventListener('blur', onBlur);
377
+ host.ownerDocument.removeEventListener('selectionchange', onSelectionChange);
378
+ for (const dispose of disposables.splice(0)) dispose();
379
+ host.setAttribute('contenteditable', 'false');
380
+ },
381
+ };
382
+
383
+ return surface;
384
+ }
385
+
386
+ function documentText(editor: AIComposer, doc: AIComposerDocument): string {
387
+ return documentToText(doc, { nodes: editor.nodes });
388
+ }
389
+
390
+ /** True when the host's content occupies more than one visual line. */
391
+ function spansMultipleLines(host: HTMLElement): boolean {
392
+ const doc = host.ownerDocument;
393
+ const range = doc.createRange();
394
+ range.selectNodeContents(host);
395
+ if (typeof range.getClientRects !== 'function') return false; // jsdom
396
+ const rects = Array.from(range.getClientRects());
397
+ if (rects.length < 2) return false;
398
+ // Same-line items (inline chips + text runs) overlap vertically, so their
399
+ // combined span stays close to the tallest single rect; wrapped/newline
400
+ // content stacks and roughly doubles it.
401
+ const span =
402
+ Math.max(...rects.map((rect) => rect.bottom)) - Math.min(...rects.map((rect) => rect.top));
403
+ const tallest = Math.max(...rects.map((rect) => rect.height));
404
+ return tallest > 0 && span > tallest * 1.5;
405
+ }
406
+
407
+ /** Clone the nodes intersecting the current selection (used for copy). */
408
+ function sliceSelection(doc: AIComposerDocument, selection: Parameters<typeof getRange>[0]): { nodes: AIComposerNode[] } {
409
+ const { start, end } = getRange(selection);
410
+ const toOffset = (position: { nodeIndex: number; offset: number }): number =>
411
+ toGlobalOffset(doc, position);
412
+ const low = Math.min(toOffset(start), toOffset(end));
413
+ const high = Math.max(toOffset(start), toOffset(end));
414
+
415
+ const nodes: AIComposerNode[] = [];
416
+ let cursor = 0;
417
+ for (const node of doc.nodes) {
418
+ const length = node.type === 'text' ? node.text.length : 1;
419
+ const nodeStart = cursor;
420
+ const nodeEnd = cursor + length;
421
+ cursor = nodeEnd;
422
+ if (nodeEnd <= low || nodeStart >= high) continue;
423
+ if (node.type !== 'text') {
424
+ nodes.push(node);
425
+ } else {
426
+ const from = Math.max(0, low - nodeStart);
427
+ const to = Math.min(length, high - nodeStart);
428
+ const text = node.text.slice(from, to);
429
+ if (text) nodes.push({ ...node, text });
430
+ }
431
+ }
432
+ return { nodes };
433
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Structural templates. Modes are presets, not separate implementations:
3
+ * a template describes which slots exist and how the surface behaves.
4
+ */
5
+
6
+ import type { SubmitKey } from '@ai-composer/core';
7
+
8
+ export type TemplateSlotName =
9
+ | 'root'
10
+ | 'header'
11
+ | 'body'
12
+ | 'attachments'
13
+ | 'input'
14
+ | 'suggestions'
15
+ | 'toolbar'
16
+ | 'actions'
17
+ | 'footer'
18
+ | 'status';
19
+
20
+ export interface AIComposerDomTemplate {
21
+ name: string;
22
+ /** Slots rendered by the default mount, in order. */
23
+ slots: TemplateSlotName[];
24
+ /** Multiline editing surface. */
25
+ multiline: boolean;
26
+ /** Default submit key behavior for the mode. */
27
+ submitKey?: SubmitKey;
28
+ /** Toolbar buttons included by the default mount. */
29
+ toolbarButtons?: Array<'undo' | 'redo' | 'submit'>;
30
+ }
31
+
32
+ export const COMPACT_TEMPLATE: AIComposerDomTemplate = {
33
+ name: 'compact',
34
+ slots: ['body', 'attachments', 'input', 'suggestions', 'toolbar'],
35
+ multiline: true,
36
+ submitKey: 'enter',
37
+ // One line: chips + input + send inside the pill (CSS lays them in a row).
38
+ toolbarButtons: ['submit'],
39
+ };
40
+
41
+ export const DEFAULT_TEMPLATE: AIComposerDomTemplate = {
42
+ name: 'default',
43
+ slots: ['body', 'attachments', 'input', 'suggestions'],
44
+ multiline: true,
45
+ submitKey: 'enter',
46
+ };
47
+
48
+ export const CHAT_TEMPLATE: AIComposerDomTemplate = {
49
+ name: 'chat',
50
+ slots: ['header', 'body', 'attachments', 'input', 'suggestions', 'toolbar', 'actions'],
51
+ multiline: true,
52
+ submitKey: 'enter',
53
+ toolbarButtons: ['submit'],
54
+ };
55
+
56
+ export const EXPANDED_TEMPLATE: AIComposerDomTemplate = {
57
+ name: 'expanded',
58
+ slots: ['header', 'body', 'attachments', 'input', 'suggestions', 'toolbar', 'footer'],
59
+ multiline: true,
60
+ submitKey: 'shift-enter',
61
+ toolbarButtons: ['undo', 'redo', 'submit'],
62
+ };
63
+
64
+ const TEMPLATES: Record<string, AIComposerDomTemplate> = {
65
+ compact: COMPACT_TEMPLATE,
66
+ default: DEFAULT_TEMPLATE,
67
+ chat: CHAT_TEMPLATE,
68
+ expanded: EXPANDED_TEMPLATE,
69
+ };
70
+
71
+ export function templateForMode(mode: string): AIComposerDomTemplate {
72
+ return TEMPLATES[mode] ?? DEFAULT_TEMPLATE;
73
+ }
74
+
75
+ /** Register a custom mode preset (used by applications/other packages). */
76
+ export function registerTemplate(template: AIComposerDomTemplate): void {
77
+ TEMPLATES[template.name] = template;
78
+ }
79
+
80
+ export const SLOT_ATTRIBUTE = 'data-aic-slot';
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "types": []
5
+ },
6
+ "include": [
7
+ "src/**/*.ts",
8
+ "src/**/*.tsx",
9
+ "vitest.config.ts"
10
+ ],
11
+ "exclude": [
12
+ "node_modules",
13
+ "dist"
14
+ ]
15
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "declaration": true,
5
+ "declarationMap": true,
6
+ "stripInternal": true,
7
+ "outDir": "../../dist/packages/dom"
8
+ },
9
+ "exclude": [
10
+ "**/*.test.ts",
11
+ "**/*.test.tsx",
12
+ "vitest.config.ts",
13
+ "src/test-setup.ts"
14
+ ]
15
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "types": [
6
+ "vitest"
7
+ ]
8
+ },
9
+ "include": [
10
+ "src/**/*.ts",
11
+ "src/**/*.tsx"
12
+ ],
13
+ "exclude": [
14
+ "vitest.config.ts"
15
+ ]
16
+ }
@@ -0,0 +1,16 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import { resolve } from 'node:path';
3
+
4
+ export default defineConfig({
5
+ resolve: {
6
+ alias: {
7
+ '@ai-composer/core': resolve(__dirname, '../core/src/index.ts'),
8
+ },
9
+ },
10
+ test: {
11
+ name: 'dom',
12
+ environment: 'jsdom',
13
+ include: ['src/**/*.test.{ts,tsx}'],
14
+ },
15
+ });
16
+