libsql-search 0.1.2
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/LICENSE +21 -0
- package/README.md +477 -0
- package/dist/index.d.ts +134 -0
- package/dist/index.esm.js +389 -0
- package/dist/index.js +400 -0
- package/package.json +74 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var transformers = require('@xenova/transformers');
|
|
4
|
+
var promises = require('fs/promises');
|
|
5
|
+
var path = require('path');
|
|
6
|
+
var matter = require('gray-matter');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Multi-provider embedding generation
|
|
10
|
+
* Supports local (Xenova), Gemini, and OpenAI
|
|
11
|
+
*/
|
|
12
|
+
const providerCache = {};
|
|
13
|
+
/**
|
|
14
|
+
* Generate embeddings using the specified provider
|
|
15
|
+
*/
|
|
16
|
+
async function generateEmbedding(text, options = {}) {
|
|
17
|
+
const { provider = 'local', apiKey, dimensions = 768, maxLength = 8000 } = options;
|
|
18
|
+
const truncated = text.substring(0, maxLength);
|
|
19
|
+
switch (provider) {
|
|
20
|
+
case 'local':
|
|
21
|
+
return generateLocalEmbedding(truncated, dimensions);
|
|
22
|
+
case 'gemini':
|
|
23
|
+
return generateGeminiEmbedding(truncated, apiKey);
|
|
24
|
+
case 'openai':
|
|
25
|
+
return generateOpenAIEmbedding(truncated, apiKey, dimensions);
|
|
26
|
+
default:
|
|
27
|
+
throw new Error(`Unknown embedding provider: ${provider}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Generate embeddings using local model (Xenova/all-MiniLM-L6-v2)
|
|
32
|
+
* Returns 384 dimensions, padded to target dimensions
|
|
33
|
+
*/
|
|
34
|
+
async function generateLocalEmbedding(text, targetDimensions) {
|
|
35
|
+
if (!providerCache.local) {
|
|
36
|
+
console.log('Loading local embedding model (Xenova/all-MiniLM-L6-v2)...');
|
|
37
|
+
providerCache.local = await transformers.pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
|
|
38
|
+
console.log('Local model loaded successfully');
|
|
39
|
+
}
|
|
40
|
+
const output = await providerCache.local(text, {
|
|
41
|
+
pooling: 'mean',
|
|
42
|
+
normalize: true
|
|
43
|
+
});
|
|
44
|
+
const embedding = Array.from(output.data);
|
|
45
|
+
return padEmbedding(embedding, targetDimensions);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Generate embeddings using Google Gemini API
|
|
49
|
+
* Returns 768 dimensions natively
|
|
50
|
+
*/
|
|
51
|
+
async function generateGeminiEmbedding(text, apiKey) {
|
|
52
|
+
const key = apiKey || process.env.GEMINI_API_KEY;
|
|
53
|
+
if (!key) {
|
|
54
|
+
throw new Error('GEMINI_API_KEY is required for Gemini embeddings');
|
|
55
|
+
}
|
|
56
|
+
if (!providerCache.gemini) {
|
|
57
|
+
// Dynamic import to keep it optional
|
|
58
|
+
const { GoogleGenerativeAI } = await import('@google/generative-ai');
|
|
59
|
+
const genAI = new GoogleGenerativeAI(key);
|
|
60
|
+
providerCache.gemini = genAI.getGenerativeModel({ model: 'text-embedding-004' });
|
|
61
|
+
}
|
|
62
|
+
const result = await providerCache.gemini.embedContent(text);
|
|
63
|
+
return result.embedding.values;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Generate embeddings using OpenAI API
|
|
67
|
+
* Supports text-embedding-3-small (1536 dims) and text-embedding-3-large (3072 dims)
|
|
68
|
+
*/
|
|
69
|
+
async function generateOpenAIEmbedding(text, apiKey, dimensions = 1536) {
|
|
70
|
+
const key = apiKey || process.env.OPENAI_API_KEY;
|
|
71
|
+
if (!key) {
|
|
72
|
+
throw new Error('OPENAI_API_KEY is required for OpenAI embeddings');
|
|
73
|
+
}
|
|
74
|
+
const model = dimensions <= 1536 ? 'text-embedding-3-small' : 'text-embedding-3-large';
|
|
75
|
+
const response = await fetch('https://api.openai.com/v1/embeddings', {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: {
|
|
78
|
+
'Content-Type': 'application/json',
|
|
79
|
+
'Authorization': `Bearer ${key}`
|
|
80
|
+
},
|
|
81
|
+
body: JSON.stringify({
|
|
82
|
+
input: text,
|
|
83
|
+
model,
|
|
84
|
+
dimensions
|
|
85
|
+
})
|
|
86
|
+
});
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
const error = await response.text();
|
|
89
|
+
throw new Error(`OpenAI API error: ${error}`);
|
|
90
|
+
}
|
|
91
|
+
const data = await response.json();
|
|
92
|
+
return data.data[0].embedding;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Pad or truncate embedding to target dimensions
|
|
96
|
+
*/
|
|
97
|
+
function padEmbedding(embedding, targetDimensions) {
|
|
98
|
+
if (embedding.length === targetDimensions) {
|
|
99
|
+
return embedding;
|
|
100
|
+
}
|
|
101
|
+
if (embedding.length > targetDimensions) {
|
|
102
|
+
return embedding.slice(0, targetDimensions);
|
|
103
|
+
}
|
|
104
|
+
const padded = new Array(targetDimensions).fill(0);
|
|
105
|
+
padded.splice(0, embedding.length, ...embedding);
|
|
106
|
+
return padded;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Prepare text for embedding by combining multiple fields
|
|
110
|
+
*/
|
|
111
|
+
function prepareTextForEmbedding(fields) {
|
|
112
|
+
const parts = [];
|
|
113
|
+
if (fields.title)
|
|
114
|
+
parts.push(fields.title);
|
|
115
|
+
if (fields.description)
|
|
116
|
+
parts.push(fields.description);
|
|
117
|
+
if (fields.tags && fields.tags.length > 0) {
|
|
118
|
+
parts.push(`Tags: ${fields.tags.join(', ')}`);
|
|
119
|
+
}
|
|
120
|
+
if (fields.content)
|
|
121
|
+
parts.push(fields.content);
|
|
122
|
+
return parts.filter(Boolean).join('\n\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Content indexer for markdown and other formats
|
|
127
|
+
*/
|
|
128
|
+
/**
|
|
129
|
+
* Index markdown content from a directory
|
|
130
|
+
*/
|
|
131
|
+
async function indexContent(options) {
|
|
132
|
+
const { client, contentPath, embeddingOptions = {}, fileExtensions = ['.md', '.markdown'], exclude = ['node_modules', '.git', 'dist', 'build'], tableName = 'articles', onProgress } = options;
|
|
133
|
+
// Find all content files
|
|
134
|
+
const files = await findFiles(contentPath, contentPath, fileExtensions, exclude);
|
|
135
|
+
if (files.length === 0) {
|
|
136
|
+
console.warn(`No files found in ${contentPath}`);
|
|
137
|
+
return { success: 0, failed: 0, total: 0 };
|
|
138
|
+
}
|
|
139
|
+
// Clear existing content
|
|
140
|
+
await client.execute(`DELETE FROM ${tableName}`);
|
|
141
|
+
// Process each file
|
|
142
|
+
let success = 0;
|
|
143
|
+
let failed = 0;
|
|
144
|
+
for (let i = 0; i < files.length; i++) {
|
|
145
|
+
const file = files[i];
|
|
146
|
+
if (onProgress) {
|
|
147
|
+
onProgress(i + 1, files.length, file.relativePath);
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
const document = await processFile(file, embeddingOptions);
|
|
151
|
+
await insertDocument(client, document, tableName);
|
|
152
|
+
success++;
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
console.error(`Failed to index ${file.relativePath}:`, error);
|
|
156
|
+
failed++;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { success, failed, total: files.length };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Find all files matching extensions
|
|
163
|
+
*/
|
|
164
|
+
async function findFiles(dir, baseDir, extensions, exclude) {
|
|
165
|
+
const files = [];
|
|
166
|
+
const entries = await promises.readdir(dir, { withFileTypes: true });
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
const fullPath = path.join(dir, entry.name);
|
|
169
|
+
if (entry.isDirectory()) {
|
|
170
|
+
if (!entry.name.startsWith('.') && !exclude.includes(entry.name)) {
|
|
171
|
+
const subFiles = await findFiles(fullPath, baseDir, extensions, exclude);
|
|
172
|
+
files.push(...subFiles);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
else if (extensions.includes(path.extname(entry.name))) {
|
|
176
|
+
const relativePath = path.relative(baseDir, fullPath);
|
|
177
|
+
const folder = path.dirname(relativePath);
|
|
178
|
+
files.push({
|
|
179
|
+
fullPath,
|
|
180
|
+
relativePath,
|
|
181
|
+
folder: folder === '.' ? 'root' : folder
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return files;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Process a single file into an indexed document
|
|
189
|
+
*/
|
|
190
|
+
async function processFile(file, embeddingOptions) {
|
|
191
|
+
const content = await promises.readFile(file.fullPath, 'utf-8');
|
|
192
|
+
const { data: frontMatter, content: markdown } = matter(content);
|
|
193
|
+
// Generate slug from relative path
|
|
194
|
+
const slug = file.relativePath
|
|
195
|
+
.replace(/\.(md|markdown)$/, '')
|
|
196
|
+
.replace(/\\/g, '/');
|
|
197
|
+
// Extract metadata
|
|
198
|
+
const title = frontMatter.title || file.relativePath
|
|
199
|
+
.split('/').pop()
|
|
200
|
+
?.replace(/\.(md|markdown)$/, '')
|
|
201
|
+
.replace(/-/g, ' ') || 'Untitled';
|
|
202
|
+
const tags = Array.isArray(frontMatter.tags) ? frontMatter.tags : [];
|
|
203
|
+
// Generate embedding
|
|
204
|
+
const embeddingText = prepareTextForEmbedding({
|
|
205
|
+
title,
|
|
206
|
+
description: frontMatter.description,
|
|
207
|
+
content: markdown,
|
|
208
|
+
tags
|
|
209
|
+
});
|
|
210
|
+
const embedding = await generateEmbedding(embeddingText, embeddingOptions);
|
|
211
|
+
return {
|
|
212
|
+
slug,
|
|
213
|
+
title,
|
|
214
|
+
content: markdown,
|
|
215
|
+
folder: file.folder,
|
|
216
|
+
tags,
|
|
217
|
+
embedding,
|
|
218
|
+
metadata: frontMatter
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Insert document into database
|
|
223
|
+
*/
|
|
224
|
+
async function insertDocument(client, document, tableName) {
|
|
225
|
+
await client.execute({
|
|
226
|
+
sql: `INSERT INTO ${tableName}
|
|
227
|
+
(slug, title, content, folder, tags, embedding, created_at, updated_at)
|
|
228
|
+
VALUES (?, ?, ?, ?, ?, vector(?), datetime('now'), datetime('now'))`,
|
|
229
|
+
args: [
|
|
230
|
+
document.slug,
|
|
231
|
+
document.title,
|
|
232
|
+
document.content,
|
|
233
|
+
document.folder,
|
|
234
|
+
JSON.stringify(document.tags),
|
|
235
|
+
JSON.stringify(document.embedding)
|
|
236
|
+
]
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Create the articles table if it doesn't exist
|
|
241
|
+
*/
|
|
242
|
+
async function createTable(client, tableName = 'articles', dimensions = 768) {
|
|
243
|
+
await client.execute(`
|
|
244
|
+
CREATE TABLE IF NOT EXISTS ${tableName} (
|
|
245
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
246
|
+
slug TEXT UNIQUE NOT NULL,
|
|
247
|
+
title TEXT NOT NULL,
|
|
248
|
+
content TEXT NOT NULL,
|
|
249
|
+
folder TEXT NOT NULL DEFAULT 'root',
|
|
250
|
+
tags TEXT DEFAULT '[]',
|
|
251
|
+
embedding F32_BLOB(${dimensions}),
|
|
252
|
+
created_at TEXT NOT NULL,
|
|
253
|
+
updated_at TEXT NOT NULL
|
|
254
|
+
)
|
|
255
|
+
`);
|
|
256
|
+
await client.execute(`
|
|
257
|
+
CREATE INDEX IF NOT EXISTS ${tableName}_embedding_idx
|
|
258
|
+
ON ${tableName}(libsql_vector_idx(embedding))
|
|
259
|
+
`);
|
|
260
|
+
await client.execute(`
|
|
261
|
+
CREATE INDEX IF NOT EXISTS ${tableName}_folder_idx
|
|
262
|
+
ON ${tableName}(folder)
|
|
263
|
+
`);
|
|
264
|
+
await client.execute(`
|
|
265
|
+
CREATE INDEX IF NOT EXISTS ${tableName}_slug_idx
|
|
266
|
+
ON ${tableName}(slug)
|
|
267
|
+
`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Vector search functionality
|
|
272
|
+
*/
|
|
273
|
+
/**
|
|
274
|
+
* Perform semantic search using vector similarity
|
|
275
|
+
*/
|
|
276
|
+
async function search(options) {
|
|
277
|
+
const { client, query, limit = 10, tableName = 'articles', embeddingOptions = {} } = options;
|
|
278
|
+
// Generate embedding for query
|
|
279
|
+
const queryEmbedding = await generateEmbedding(query, embeddingOptions);
|
|
280
|
+
// Perform vector search
|
|
281
|
+
const results = await client.execute({
|
|
282
|
+
sql: `
|
|
283
|
+
SELECT
|
|
284
|
+
id,
|
|
285
|
+
slug,
|
|
286
|
+
title,
|
|
287
|
+
content,
|
|
288
|
+
folder,
|
|
289
|
+
tags,
|
|
290
|
+
created_at,
|
|
291
|
+
vector_distance_cos(embedding, vector(?)) as distance
|
|
292
|
+
FROM ${tableName}
|
|
293
|
+
WHERE embedding IS NOT NULL
|
|
294
|
+
ORDER BY distance
|
|
295
|
+
LIMIT ?
|
|
296
|
+
`,
|
|
297
|
+
args: [JSON.stringify(queryEmbedding), limit]
|
|
298
|
+
});
|
|
299
|
+
// Parse and format results
|
|
300
|
+
return results.rows.map(row => ({
|
|
301
|
+
id: row.id,
|
|
302
|
+
slug: row.slug,
|
|
303
|
+
title: row.title,
|
|
304
|
+
content: row.content,
|
|
305
|
+
folder: row.folder,
|
|
306
|
+
tags: JSON.parse(row.tags || '[]'),
|
|
307
|
+
distance: row.distance,
|
|
308
|
+
created_at: row.created_at
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Get all articles (for building static pages, navigation, etc.)
|
|
313
|
+
*/
|
|
314
|
+
async function getAllArticles(client, tableName = 'articles') {
|
|
315
|
+
const results = await client.execute(`
|
|
316
|
+
SELECT id, slug, title, folder, tags, created_at, updated_at
|
|
317
|
+
FROM ${tableName}
|
|
318
|
+
ORDER BY title
|
|
319
|
+
`);
|
|
320
|
+
return results.rows.map(row => ({
|
|
321
|
+
id: row.id,
|
|
322
|
+
slug: row.slug,
|
|
323
|
+
title: row.title,
|
|
324
|
+
folder: row.folder,
|
|
325
|
+
tags: JSON.parse(row.tags || '[]'),
|
|
326
|
+
created_at: row.created_at,
|
|
327
|
+
updated_at: row.updated_at
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Get a single article by slug
|
|
332
|
+
*/
|
|
333
|
+
async function getArticleBySlug(client, slug, tableName = 'articles') {
|
|
334
|
+
const results = await client.execute({
|
|
335
|
+
sql: `
|
|
336
|
+
SELECT id, slug, title, content, folder, tags, created_at, updated_at
|
|
337
|
+
FROM ${tableName}
|
|
338
|
+
WHERE slug = ?
|
|
339
|
+
LIMIT 1
|
|
340
|
+
`,
|
|
341
|
+
args: [slug]
|
|
342
|
+
});
|
|
343
|
+
if (results.rows.length === 0) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
const row = results.rows[0];
|
|
347
|
+
return {
|
|
348
|
+
id: row.id,
|
|
349
|
+
slug: row.slug,
|
|
350
|
+
title: row.title,
|
|
351
|
+
content: row.content,
|
|
352
|
+
folder: row.folder,
|
|
353
|
+
tags: JSON.parse(row.tags || '[]'),
|
|
354
|
+
created_at: row.created_at,
|
|
355
|
+
updated_at: row.updated_at
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Get articles by folder
|
|
360
|
+
*/
|
|
361
|
+
async function getArticlesByFolder(client, folder, tableName = 'articles') {
|
|
362
|
+
const results = await client.execute({
|
|
363
|
+
sql: `
|
|
364
|
+
SELECT id, slug, title, folder, tags
|
|
365
|
+
FROM ${tableName}
|
|
366
|
+
WHERE folder = ?
|
|
367
|
+
ORDER BY title
|
|
368
|
+
`,
|
|
369
|
+
args: [folder]
|
|
370
|
+
});
|
|
371
|
+
return results.rows.map(row => ({
|
|
372
|
+
id: row.id,
|
|
373
|
+
slug: row.slug,
|
|
374
|
+
title: row.title,
|
|
375
|
+
folder: row.folder,
|
|
376
|
+
tags: JSON.parse(row.tags || '[]')
|
|
377
|
+
}));
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Get all unique folders
|
|
381
|
+
*/
|
|
382
|
+
async function getFolders(client, tableName = 'articles') {
|
|
383
|
+
const results = await client.execute(`
|
|
384
|
+
SELECT DISTINCT folder
|
|
385
|
+
FROM ${tableName}
|
|
386
|
+
ORDER BY folder
|
|
387
|
+
`);
|
|
388
|
+
return results.rows.map(row => row.folder);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
exports.createTable = createTable;
|
|
392
|
+
exports.generateEmbedding = generateEmbedding;
|
|
393
|
+
exports.getAllArticles = getAllArticles;
|
|
394
|
+
exports.getArticleBySlug = getArticleBySlug;
|
|
395
|
+
exports.getArticlesByFolder = getArticlesByFolder;
|
|
396
|
+
exports.getFolders = getFolders;
|
|
397
|
+
exports.indexContent = indexContent;
|
|
398
|
+
exports.padEmbedding = padEmbedding;
|
|
399
|
+
exports.prepareTextForEmbedding = prepareTextForEmbedding;
|
|
400
|
+
exports.search = search;
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "libsql-search",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Semantic search for static sites using libSQL/Turso with multi-provider embeddings",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.esm.js",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"keywords": [
|
|
20
|
+
"libsql",
|
|
21
|
+
"turso",
|
|
22
|
+
"vector-search",
|
|
23
|
+
"semantic-search",
|
|
24
|
+
"embeddings",
|
|
25
|
+
"static-sites",
|
|
26
|
+
"markdown",
|
|
27
|
+
"astro",
|
|
28
|
+
"nextjs"
|
|
29
|
+
],
|
|
30
|
+
"author": "llbbl",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "https://github.com/llbbl/libsql-search.git"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/llbbl/libsql-search/issues"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/llbbl/libsql-search#readme",
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@libsql/client": "^0.15.0"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@xenova/transformers": "^2.17.2",
|
|
48
|
+
"gray-matter": "^4.0.3"
|
|
49
|
+
},
|
|
50
|
+
"optionalDependencies": {
|
|
51
|
+
"@google/generative-ai": "^0.24.1"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@libsql/client": "^0.15.15",
|
|
55
|
+
"@rollup/plugin-commonjs": "^28.0.7",
|
|
56
|
+
"@rollup/plugin-node-resolve": "^16.0.3",
|
|
57
|
+
"@rollup/plugin-typescript": "^12.1.4",
|
|
58
|
+
"@types/node": "^20.19.21",
|
|
59
|
+
"@vitest/ui": "^3.2.4",
|
|
60
|
+
"rollup": "^4.52.4",
|
|
61
|
+
"rollup-plugin-dts": "^6.2.3",
|
|
62
|
+
"tslib": "^2.8.1",
|
|
63
|
+
"typescript": "^5.9.3",
|
|
64
|
+
"vitest": "^3.2.4"
|
|
65
|
+
},
|
|
66
|
+
"scripts": {
|
|
67
|
+
"build": "rollup -c",
|
|
68
|
+
"dev": "rollup -c --watch",
|
|
69
|
+
"test": "vitest",
|
|
70
|
+
"test:ui": "vitest --ui",
|
|
71
|
+
"test:run": "vitest run",
|
|
72
|
+
"test:coverage": "vitest run --coverage"
|
|
73
|
+
}
|
|
74
|
+
}
|