@tobilu/qmd 0.9.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/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/README.md +615 -0
- package/package.json +80 -0
- package/qmd +55 -0
- package/src/collections.ts +390 -0
- package/src/formatter.ts +429 -0
- package/src/llm.ts +1208 -0
- package/src/mcp.ts +654 -0
- package/src/qmd.ts +2535 -0
- package/src/store.ts +3072 -0
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tobilu/qmd",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Query Markup Documents - On-device hybrid search for markdown files with BM25, vector search, and LLM reranking",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"qmd": "./qmd"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/**/*.ts",
|
|
11
|
+
"!src/**/*.test.ts",
|
|
12
|
+
"!src/test-preload.ts",
|
|
13
|
+
"qmd",
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"CHANGELOG.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "bun test --preload ./src/test-preload.ts",
|
|
19
|
+
"qmd": "bun src/qmd.ts",
|
|
20
|
+
"index": "bun src/qmd.ts index",
|
|
21
|
+
"vector": "bun src/qmd.ts vector",
|
|
22
|
+
"search": "bun src/qmd.ts search",
|
|
23
|
+
"vsearch": "bun src/qmd.ts vsearch",
|
|
24
|
+
"rerank": "bun src/qmd.ts rerank",
|
|
25
|
+
"link": "bun link",
|
|
26
|
+
"inspector": "npx @modelcontextprotocol/inspector bun src/qmd.ts mcp",
|
|
27
|
+
"release": "./scripts/release.sh"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/tobi/qmd.git"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/tobi/qmd#readme",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/tobi/qmd/issues"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@modelcontextprotocol/sdk": "^1.25.1",
|
|
42
|
+
"node-llama-cpp": "^3.14.5",
|
|
43
|
+
"sqlite-vec": "^0.1.7-alpha.2",
|
|
44
|
+
"yaml": "^2.8.2",
|
|
45
|
+
"zod": "^4.2.1"
|
|
46
|
+
},
|
|
47
|
+
"optionalDependencies": {
|
|
48
|
+
"sqlite-vec-darwin-arm64": "^0.1.7-alpha.2",
|
|
49
|
+
"sqlite-vec-darwin-x64": "^0.1.7-alpha.2",
|
|
50
|
+
"sqlite-vec-linux-x64": "^0.1.7-alpha.2",
|
|
51
|
+
"sqlite-vec-win32-x64": "^0.1.7-alpha.2"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/bun": "latest"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"typescript": "^5.9.3"
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"bun": ">=1.0.0"
|
|
61
|
+
},
|
|
62
|
+
"keywords": [
|
|
63
|
+
"markdown",
|
|
64
|
+
"search",
|
|
65
|
+
"fts",
|
|
66
|
+
"full-text-search",
|
|
67
|
+
"vector",
|
|
68
|
+
"semantic-search",
|
|
69
|
+
"sqlite",
|
|
70
|
+
"bm25",
|
|
71
|
+
"embeddings",
|
|
72
|
+
"rag",
|
|
73
|
+
"mcp",
|
|
74
|
+
"reranking",
|
|
75
|
+
"knowledge-base",
|
|
76
|
+
"local-ai",
|
|
77
|
+
"llm"
|
|
78
|
+
],
|
|
79
|
+
"license": "MIT"
|
|
80
|
+
}
|
package/qmd
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# qmd - Quick Markdown Search
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
# Find bun - prefer PATH, fallback to known locations
|
|
6
|
+
find_bun() {
|
|
7
|
+
# First: check if bun is in PATH and modern enough
|
|
8
|
+
if command -v bun &>/dev/null; then
|
|
9
|
+
local ver=$(bun --version 2>/dev/null || echo "0")
|
|
10
|
+
if [[ "$ver" =~ ^1\. ]]; then
|
|
11
|
+
command -v bun
|
|
12
|
+
return 0
|
|
13
|
+
fi
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
# Fallback: derive paths (need HOME)
|
|
17
|
+
: "${HOME:=$(eval echo ~)}"
|
|
18
|
+
|
|
19
|
+
# If running from .bun tree, use that bun
|
|
20
|
+
if [[ "${BASH_SOURCE[0]}" == */.bun/* ]]; then
|
|
21
|
+
local bun_home="${BASH_SOURCE[0]%%/.bun/*}/.bun"
|
|
22
|
+
if [[ -x "$bun_home/bin/bun" ]]; then
|
|
23
|
+
echo "$bun_home/bin/bun"
|
|
24
|
+
return 0
|
|
25
|
+
fi
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
# Check known locations
|
|
29
|
+
local candidates=(
|
|
30
|
+
"$HOME/.local/share/mise/installs/bun/latest/bin/bun"
|
|
31
|
+
"$HOME/.local/share/mise/shims/bun"
|
|
32
|
+
"$HOME/.asdf/shims/bun"
|
|
33
|
+
"/opt/homebrew/bin/bun"
|
|
34
|
+
"/usr/local/bin/bun"
|
|
35
|
+
"$HOME/.bun/bin/bun"
|
|
36
|
+
)
|
|
37
|
+
for c in "${candidates[@]}"; do
|
|
38
|
+
[[ -x "$c" ]] && { echo "$c"; return 0; }
|
|
39
|
+
done
|
|
40
|
+
|
|
41
|
+
return 1
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
BUN=$(find_bun) || { echo "Error: bun not found. Install from https://bun.sh" >&2; exit 1; }
|
|
45
|
+
|
|
46
|
+
# Resolve symlinks to find script location
|
|
47
|
+
SOURCE="${BASH_SOURCE[0]}"
|
|
48
|
+
while [[ -L "$SOURCE" ]]; do
|
|
49
|
+
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
|
|
50
|
+
SOURCE="$(readlink "$SOURCE")"
|
|
51
|
+
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
|
|
52
|
+
done
|
|
53
|
+
SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
|
|
54
|
+
|
|
55
|
+
exec "$BUN" "$SCRIPT_DIR/src/qmd.ts" "$@"
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collections configuration management
|
|
3
|
+
*
|
|
4
|
+
* This module manages the YAML-based collection configuration at ~/.config/qmd/index.yml.
|
|
5
|
+
* Collections define which directories to index and their associated contexts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
import YAML from "yaml";
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Types
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Context definitions for a collection
|
|
19
|
+
* Key is path prefix (e.g., "/", "/2024", "/Board of Directors")
|
|
20
|
+
* Value is the context description
|
|
21
|
+
*/
|
|
22
|
+
export type ContextMap = Record<string, string>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A single collection configuration
|
|
26
|
+
*/
|
|
27
|
+
export interface Collection {
|
|
28
|
+
path: string; // Absolute path to index
|
|
29
|
+
pattern: string; // Glob pattern (e.g., "**/*.md")
|
|
30
|
+
context?: ContextMap; // Optional context definitions
|
|
31
|
+
update?: string; // Optional bash command to run during qmd update
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The complete configuration file structure
|
|
36
|
+
*/
|
|
37
|
+
export interface CollectionConfig {
|
|
38
|
+
global_context?: string; // Context applied to all collections
|
|
39
|
+
collections: Record<string, Collection>; // Collection name -> config
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Collection with its name (for return values)
|
|
44
|
+
*/
|
|
45
|
+
export interface NamedCollection extends Collection {
|
|
46
|
+
name: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ============================================================================
|
|
50
|
+
// Configuration paths
|
|
51
|
+
// ============================================================================
|
|
52
|
+
|
|
53
|
+
// Current index name (default: "index")
|
|
54
|
+
let currentIndexName: string = "index";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Set the current index name for config file lookup
|
|
58
|
+
* Config file will be ~/.config/qmd/{indexName}.yml
|
|
59
|
+
*/
|
|
60
|
+
export function setConfigIndexName(name: string): void {
|
|
61
|
+
currentIndexName = name;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getConfigDir(): string {
|
|
65
|
+
// Allow override via QMD_CONFIG_DIR for testing
|
|
66
|
+
if (process.env.QMD_CONFIG_DIR) {
|
|
67
|
+
return process.env.QMD_CONFIG_DIR;
|
|
68
|
+
}
|
|
69
|
+
return join(homedir(), ".config", "qmd");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getConfigFilePath(): string {
|
|
73
|
+
return join(getConfigDir(), `${currentIndexName}.yml`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Ensure config directory exists
|
|
78
|
+
*/
|
|
79
|
+
function ensureConfigDir(): void {
|
|
80
|
+
const configDir = getConfigDir();
|
|
81
|
+
if (!existsSync(configDir)) {
|
|
82
|
+
mkdirSync(configDir, { recursive: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ============================================================================
|
|
87
|
+
// Core functions
|
|
88
|
+
// ============================================================================
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Load configuration from ~/.config/qmd/index.yml
|
|
92
|
+
* Returns empty config if file doesn't exist
|
|
93
|
+
*/
|
|
94
|
+
export function loadConfig(): CollectionConfig {
|
|
95
|
+
const configPath = getConfigFilePath();
|
|
96
|
+
if (!existsSync(configPath)) {
|
|
97
|
+
return { collections: {} };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const content = readFileSync(configPath, "utf-8");
|
|
102
|
+
const config = YAML.parse(content) as CollectionConfig;
|
|
103
|
+
|
|
104
|
+
// Ensure collections object exists
|
|
105
|
+
if (!config.collections) {
|
|
106
|
+
config.collections = {};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return config;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
throw new Error(`Failed to parse ${configPath}: ${error}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Save configuration to ~/.config/qmd/index.yml
|
|
117
|
+
*/
|
|
118
|
+
export function saveConfig(config: CollectionConfig): void {
|
|
119
|
+
ensureConfigDir();
|
|
120
|
+
const configPath = getConfigFilePath();
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const yaml = YAML.stringify(config, {
|
|
124
|
+
indent: 2,
|
|
125
|
+
lineWidth: 0, // Don't wrap lines
|
|
126
|
+
});
|
|
127
|
+
writeFileSync(configPath, yaml, "utf-8");
|
|
128
|
+
} catch (error) {
|
|
129
|
+
throw new Error(`Failed to write ${configPath}: ${error}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Get a specific collection by name
|
|
135
|
+
* Returns null if not found
|
|
136
|
+
*/
|
|
137
|
+
export function getCollection(name: string): NamedCollection | null {
|
|
138
|
+
const config = loadConfig();
|
|
139
|
+
const collection = config.collections[name];
|
|
140
|
+
|
|
141
|
+
if (!collection) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { name, ...collection };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* List all collections
|
|
150
|
+
*/
|
|
151
|
+
export function listCollections(): NamedCollection[] {
|
|
152
|
+
const config = loadConfig();
|
|
153
|
+
return Object.entries(config.collections).map(([name, collection]) => ({
|
|
154
|
+
name,
|
|
155
|
+
...collection,
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Add or update a collection
|
|
161
|
+
*/
|
|
162
|
+
export function addCollection(
|
|
163
|
+
name: string,
|
|
164
|
+
path: string,
|
|
165
|
+
pattern: string = "**/*.md"
|
|
166
|
+
): void {
|
|
167
|
+
const config = loadConfig();
|
|
168
|
+
|
|
169
|
+
config.collections[name] = {
|
|
170
|
+
path,
|
|
171
|
+
pattern,
|
|
172
|
+
context: config.collections[name]?.context, // Preserve existing context
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
saveConfig(config);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Remove a collection
|
|
180
|
+
*/
|
|
181
|
+
export function removeCollection(name: string): boolean {
|
|
182
|
+
const config = loadConfig();
|
|
183
|
+
|
|
184
|
+
if (!config.collections[name]) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
delete config.collections[name];
|
|
189
|
+
saveConfig(config);
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Rename a collection
|
|
195
|
+
*/
|
|
196
|
+
export function renameCollection(oldName: string, newName: string): boolean {
|
|
197
|
+
const config = loadConfig();
|
|
198
|
+
|
|
199
|
+
if (!config.collections[oldName]) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (config.collections[newName]) {
|
|
204
|
+
throw new Error(`Collection '${newName}' already exists`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
config.collections[newName] = config.collections[oldName];
|
|
208
|
+
delete config.collections[oldName];
|
|
209
|
+
saveConfig(config);
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ============================================================================
|
|
214
|
+
// Context management
|
|
215
|
+
// ============================================================================
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get global context
|
|
219
|
+
*/
|
|
220
|
+
export function getGlobalContext(): string | undefined {
|
|
221
|
+
const config = loadConfig();
|
|
222
|
+
return config.global_context;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Set global context
|
|
227
|
+
*/
|
|
228
|
+
export function setGlobalContext(context: string | undefined): void {
|
|
229
|
+
const config = loadConfig();
|
|
230
|
+
config.global_context = context;
|
|
231
|
+
saveConfig(config);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Get all contexts for a collection
|
|
236
|
+
*/
|
|
237
|
+
export function getContexts(collectionName: string): ContextMap | undefined {
|
|
238
|
+
const collection = getCollection(collectionName);
|
|
239
|
+
return collection?.context;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Add or update a context for a specific path in a collection
|
|
244
|
+
*/
|
|
245
|
+
export function addContext(
|
|
246
|
+
collectionName: string,
|
|
247
|
+
pathPrefix: string,
|
|
248
|
+
contextText: string
|
|
249
|
+
): boolean {
|
|
250
|
+
const config = loadConfig();
|
|
251
|
+
const collection = config.collections[collectionName];
|
|
252
|
+
|
|
253
|
+
if (!collection) {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!collection.context) {
|
|
258
|
+
collection.context = {};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
collection.context[pathPrefix] = contextText;
|
|
262
|
+
saveConfig(config);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Remove a context from a collection
|
|
268
|
+
*/
|
|
269
|
+
export function removeContext(
|
|
270
|
+
collectionName: string,
|
|
271
|
+
pathPrefix: string
|
|
272
|
+
): boolean {
|
|
273
|
+
const config = loadConfig();
|
|
274
|
+
const collection = config.collections[collectionName];
|
|
275
|
+
|
|
276
|
+
if (!collection?.context?.[pathPrefix]) {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
delete collection.context[pathPrefix];
|
|
281
|
+
|
|
282
|
+
// Remove empty context object
|
|
283
|
+
if (Object.keys(collection.context).length === 0) {
|
|
284
|
+
delete collection.context;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
saveConfig(config);
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* List all contexts across all collections
|
|
293
|
+
*/
|
|
294
|
+
export function listAllContexts(): Array<{
|
|
295
|
+
collection: string;
|
|
296
|
+
path: string;
|
|
297
|
+
context: string;
|
|
298
|
+
}> {
|
|
299
|
+
const config = loadConfig();
|
|
300
|
+
const results: Array<{ collection: string; path: string; context: string }> = [];
|
|
301
|
+
|
|
302
|
+
// Add global context if present
|
|
303
|
+
if (config.global_context) {
|
|
304
|
+
results.push({
|
|
305
|
+
collection: "*",
|
|
306
|
+
path: "/",
|
|
307
|
+
context: config.global_context,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Add collection contexts
|
|
312
|
+
for (const [name, collection] of Object.entries(config.collections)) {
|
|
313
|
+
if (collection.context) {
|
|
314
|
+
for (const [path, context] of Object.entries(collection.context)) {
|
|
315
|
+
results.push({
|
|
316
|
+
collection: name,
|
|
317
|
+
path,
|
|
318
|
+
context,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return results;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Find best matching context for a given collection and path
|
|
329
|
+
* Returns the most specific matching context (longest path prefix match)
|
|
330
|
+
*/
|
|
331
|
+
export function findContextForPath(
|
|
332
|
+
collectionName: string,
|
|
333
|
+
filePath: string
|
|
334
|
+
): string | undefined {
|
|
335
|
+
const config = loadConfig();
|
|
336
|
+
const collection = config.collections[collectionName];
|
|
337
|
+
|
|
338
|
+
if (!collection?.context) {
|
|
339
|
+
return config.global_context;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Find all matching prefixes
|
|
343
|
+
const matches: Array<{ prefix: string; context: string }> = [];
|
|
344
|
+
|
|
345
|
+
for (const [prefix, context] of Object.entries(collection.context)) {
|
|
346
|
+
// Normalize paths for comparison
|
|
347
|
+
const normalizedPath = filePath.startsWith("/") ? filePath : `/${filePath}`;
|
|
348
|
+
const normalizedPrefix = prefix.startsWith("/") ? prefix : `/${prefix}`;
|
|
349
|
+
|
|
350
|
+
if (normalizedPath.startsWith(normalizedPrefix)) {
|
|
351
|
+
matches.push({ prefix: normalizedPrefix, context });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Return most specific match (longest prefix)
|
|
356
|
+
if (matches.length > 0) {
|
|
357
|
+
matches.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
358
|
+
return matches[0]!.context;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Fallback to global context
|
|
362
|
+
return config.global_context;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ============================================================================
|
|
366
|
+
// Utility functions
|
|
367
|
+
// ============================================================================
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Get the config file path (useful for error messages)
|
|
371
|
+
*/
|
|
372
|
+
export function getConfigPath(): string {
|
|
373
|
+
return getConfigFilePath();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Check if config file exists
|
|
378
|
+
*/
|
|
379
|
+
export function configExists(): boolean {
|
|
380
|
+
return existsSync(getConfigFilePath());
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Validate a collection name
|
|
385
|
+
* Collection names must be valid and not contain special characters
|
|
386
|
+
*/
|
|
387
|
+
export function isValidCollectionName(name: string): boolean {
|
|
388
|
+
// Allow alphanumeric, hyphens, underscores
|
|
389
|
+
return /^[a-zA-Z0-9_-]+$/.test(name);
|
|
390
|
+
}
|