@synth-coder/memhub 0.2.3 → 0.2.4
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/.eslintrc.cjs +45 -45
- package/.factory/commands/opsx-apply.md +150 -150
- package/.factory/commands/opsx-archive.md +155 -155
- package/.factory/commands/opsx-explore.md +171 -171
- package/.factory/commands/opsx-propose.md +104 -104
- package/.factory/skills/openspec-apply-change/SKILL.md +156 -156
- package/.factory/skills/openspec-archive-change/SKILL.md +114 -114
- package/.factory/skills/openspec-explore/SKILL.md +288 -288
- package/.factory/skills/openspec-propose/SKILL.md +110 -110
- package/.github/workflows/ci.yml +110 -74
- package/.github/workflows/release.yml +67 -0
- package/.iflow/commands/opsx-apply.md +152 -152
- package/.iflow/commands/opsx-archive.md +157 -157
- package/.iflow/commands/opsx-explore.md +173 -173
- package/.iflow/commands/opsx-propose.md +106 -106
- package/.iflow/skills/openspec-apply-change/SKILL.md +156 -156
- package/.iflow/skills/openspec-archive-change/SKILL.md +114 -114
- package/.iflow/skills/openspec-explore/SKILL.md +288 -288
- package/.iflow/skills/openspec-propose/SKILL.md +110 -110
- package/.prettierrc +11 -11
- package/AGENTS.md +167 -169
- package/README.md +276 -195
- package/README.zh-CN.md +245 -193
- package/dist/src/cli/agents/claude-code.d.ts +5 -0
- package/dist/src/cli/agents/claude-code.d.ts.map +1 -0
- package/dist/src/cli/agents/claude-code.js +14 -0
- package/dist/src/cli/agents/claude-code.js.map +1 -0
- package/dist/src/cli/agents/cline.d.ts +5 -0
- package/dist/src/cli/agents/cline.d.ts.map +1 -0
- package/dist/src/cli/agents/cline.js +14 -0
- package/dist/src/cli/agents/cline.js.map +1 -0
- package/dist/src/cli/agents/codex.d.ts +5 -0
- package/dist/src/cli/agents/codex.d.ts.map +1 -0
- package/dist/src/cli/agents/codex.js +14 -0
- package/dist/src/cli/agents/codex.js.map +1 -0
- package/dist/src/cli/agents/cursor.d.ts +5 -0
- package/dist/src/cli/agents/cursor.d.ts.map +1 -0
- package/dist/src/cli/agents/cursor.js +14 -0
- package/dist/src/cli/agents/cursor.js.map +1 -0
- package/dist/src/cli/agents/factory-droid.d.ts +5 -0
- package/dist/src/cli/agents/factory-droid.d.ts.map +1 -0
- package/dist/src/cli/agents/factory-droid.js +14 -0
- package/dist/src/cli/agents/factory-droid.js.map +1 -0
- package/dist/src/cli/agents/gemini-cli.d.ts +5 -0
- package/dist/src/cli/agents/gemini-cli.d.ts.map +1 -0
- package/dist/src/cli/agents/gemini-cli.js +14 -0
- package/dist/src/cli/agents/gemini-cli.js.map +1 -0
- package/dist/src/cli/agents/index.d.ts +14 -0
- package/dist/src/cli/agents/index.d.ts.map +1 -0
- package/dist/src/cli/agents/index.js +30 -0
- package/dist/src/cli/agents/index.js.map +1 -0
- package/dist/src/cli/agents/windsurf.d.ts +5 -0
- package/dist/src/cli/agents/windsurf.d.ts.map +1 -0
- package/dist/src/cli/agents/windsurf.js +14 -0
- package/dist/src/cli/agents/windsurf.js.map +1 -0
- package/dist/src/cli/index.d.ts +8 -0
- package/dist/src/cli/index.d.ts.map +1 -0
- package/dist/src/cli/index.js +168 -0
- package/dist/src/cli/index.js.map +1 -0
- package/dist/src/cli/init.d.ts +34 -0
- package/dist/src/cli/init.d.ts.map +1 -0
- package/dist/src/cli/init.js +160 -0
- package/dist/src/cli/init.js.map +1 -0
- package/dist/src/cli/instructions.d.ts +29 -0
- package/dist/src/cli/instructions.d.ts.map +1 -0
- package/dist/src/cli/instructions.js +141 -0
- package/dist/src/cli/instructions.js.map +1 -0
- package/dist/src/cli/types.d.ts +22 -0
- package/dist/src/cli/types.d.ts.map +1 -0
- package/dist/src/cli/types.js +86 -0
- package/dist/src/cli/types.js.map +1 -0
- package/dist/src/contracts/mcp.js +34 -34
- package/dist/src/contracts/schemas.js.map +1 -1
- package/dist/src/server/mcp-server.d.ts.map +1 -1
- package/dist/src/server/mcp-server.js +7 -14
- package/dist/src/server/mcp-server.js.map +1 -1
- package/dist/src/services/embedding-service.d.ts.map +1 -1
- package/dist/src/services/embedding-service.js +1 -1
- package/dist/src/services/embedding-service.js.map +1 -1
- package/dist/src/services/memory-service.d.ts.map +1 -1
- package/dist/src/services/memory-service.js.map +1 -1
- package/dist/src/storage/markdown-storage.d.ts.map +1 -1
- package/dist/src/storage/markdown-storage.js +1 -1
- package/dist/src/storage/markdown-storage.js.map +1 -1
- package/dist/src/storage/vector-index.d.ts.map +1 -1
- package/dist/src/storage/vector-index.js +4 -5
- package/dist/src/storage/vector-index.js.map +1 -1
- package/docs/README.md +21 -0
- package/docs/mcp-tools.md +136 -0
- package/docs/user-guide.md +182 -0
- package/package.json +61 -59
- package/src/cli/agents/claude-code.ts +14 -0
- package/src/cli/agents/cline.ts +14 -0
- package/src/cli/agents/codex.ts +14 -0
- package/src/cli/agents/cursor.ts +14 -0
- package/src/cli/agents/factory-droid.ts +14 -0
- package/src/cli/agents/gemini-cli.ts +14 -0
- package/src/cli/agents/index.ts +36 -0
- package/src/cli/agents/windsurf.ts +14 -0
- package/src/cli/index.ts +192 -0
- package/src/cli/init.ts +218 -0
- package/src/cli/instructions.ts +156 -0
- package/src/cli/types.ts +112 -0
- package/src/contracts/index.ts +12 -12
- package/src/contracts/mcp.ts +223 -223
- package/src/contracts/schemas.ts +307 -307
- package/src/contracts/types.ts +410 -410
- package/src/index.ts +8 -8
- package/src/server/index.ts +5 -5
- package/src/server/mcp-server.ts +169 -186
- package/src/services/embedding-service.ts +114 -114
- package/src/services/index.ts +5 -5
- package/src/services/memory-service.ts +656 -663
- package/src/storage/frontmatter-parser.ts +243 -243
- package/src/storage/index.ts +6 -6
- package/src/storage/markdown-storage.ts +228 -236
- package/src/storage/vector-index.ts +159 -160
- package/src/utils/index.ts +5 -5
- package/src/utils/slugify.ts +63 -63
- package/test/cli/init.test.ts +380 -0
- package/test/contracts/schemas.test.ts +313 -313
- package/test/contracts/types.test.ts +21 -21
- package/test/frontmatter-parser-more.test.ts +94 -94
- package/test/server/mcp-server.test.ts +211 -210
- package/test/services/memory-service-edge.test.ts +248 -248
- package/test/services/memory-service.test.ts +291 -279
- package/test/storage/frontmatter-parser.test.ts +223 -223
- package/test/storage/markdown-storage.test.ts +226 -217
- package/test/storage/storage-edge.test.ts +238 -238
- package/test/storage/vector-index.test.ts +149 -153
- package/test/utils/slugify-edge.test.ts +94 -94
- package/test/utils/slugify.test.ts +72 -68
- package/tsconfig.json +25 -25
- package/tsconfig.test.json +8 -8
- package/vitest.config.ts +29 -29
- package/docs/architecture-diagrams.md +0 -368
- package/docs/architecture.md +0 -381
- package/docs/contracts.md +0 -190
- package/docs/prompt-template.md +0 -33
- package/docs/proposals/mcp-typescript-sdk-refactor.md +0 -568
- package/docs/proposals/proposal-close-gates.md +0 -58
- package/docs/tool-calling-policy.md +0 -101
- package/docs/vector-search.md +0 -306
|
@@ -1,160 +1,159 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* VectorIndex - LanceDB-backed vector search index for memories.
|
|
3
|
-
*
|
|
4
|
-
* This is a search cache only. Markdown files remain the source of truth.
|
|
5
|
-
* The index can be rebuilt from Markdown files at any time.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import * as lancedb from '@lancedb/lancedb';
|
|
9
|
-
import { mkdir, access } from 'fs/promises';
|
|
10
|
-
import { join } from 'path';
|
|
11
|
-
import { constants } from 'fs';
|
|
12
|
-
import type { Memory } from '../contracts/types.js';
|
|
13
|
-
|
|
14
|
-
const TABLE_NAME = 'memories';
|
|
15
|
-
|
|
16
|
-
/** Escape single quotes in id strings to prevent SQL injection */
|
|
17
|
-
function escapeId(id: string): string {
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Row stored in the LanceDB table.
|
|
23
|
-
* The `vector` field is the only one required by LanceDB; all others are metadata filters.
|
|
24
|
-
*/
|
|
25
|
-
export interface VectorRow {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface VectorSearchResult {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* LanceDB vector index wrapper.
|
|
44
|
-
* Data lives at `{storagePath}/.lancedb/`.
|
|
45
|
-
*/
|
|
46
|
-
export class VectorIndex {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* VectorIndex - LanceDB-backed vector search index for memories.
|
|
3
|
+
*
|
|
4
|
+
* This is a search cache only. Markdown files remain the source of truth.
|
|
5
|
+
* The index can be rebuilt from Markdown files at any time.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as lancedb from '@lancedb/lancedb';
|
|
9
|
+
import { mkdir, access } from 'fs/promises';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
import { constants } from 'fs';
|
|
12
|
+
import type { Memory } from '../contracts/types.js';
|
|
13
|
+
|
|
14
|
+
const TABLE_NAME = 'memories';
|
|
15
|
+
|
|
16
|
+
/** Escape single quotes in id strings to prevent SQL injection */
|
|
17
|
+
function escapeId(id: string): string {
|
|
18
|
+
return id.replace(/'/g, "''");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Row stored in the LanceDB table.
|
|
23
|
+
* The `vector` field is the only one required by LanceDB; all others are metadata filters.
|
|
24
|
+
*/
|
|
25
|
+
export interface VectorRow {
|
|
26
|
+
id: string;
|
|
27
|
+
vector: number[];
|
|
28
|
+
title: string;
|
|
29
|
+
category: string;
|
|
30
|
+
tags: string; // JSON-serialised string[]
|
|
31
|
+
importance: number;
|
|
32
|
+
createdAt: string;
|
|
33
|
+
updatedAt: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface VectorSearchResult {
|
|
37
|
+
id: string;
|
|
38
|
+
/** Cosine distance (lower = more similar). Converted to 0-1 score by caller. */
|
|
39
|
+
_distance: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* LanceDB vector index wrapper.
|
|
44
|
+
* Data lives at `{storagePath}/.lancedb/`.
|
|
45
|
+
*/
|
|
46
|
+
export class VectorIndex {
|
|
47
|
+
private readonly dbPath: string;
|
|
48
|
+
private db: lancedb.Connection | null = null;
|
|
49
|
+
private table: lancedb.Table | null = null;
|
|
50
|
+
private initPromise: Promise<void> | null = null;
|
|
51
|
+
|
|
52
|
+
constructor(storagePath: string) {
|
|
53
|
+
this.dbPath = join(storagePath, '.lancedb');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Idempotent initialisation — safe to call multiple times. */
|
|
57
|
+
async initialize(): Promise<void> {
|
|
58
|
+
if (this.table) return;
|
|
59
|
+
|
|
60
|
+
if (!this.initPromise) {
|
|
61
|
+
this.initPromise = this._init();
|
|
62
|
+
}
|
|
63
|
+
await this.initPromise;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async _init(): Promise<void> {
|
|
67
|
+
// Ensure the directory exists
|
|
68
|
+
try {
|
|
69
|
+
await access(this.dbPath, constants.F_OK);
|
|
70
|
+
} catch {
|
|
71
|
+
await mkdir(this.dbPath, { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this.db = await lancedb.connect(this.dbPath);
|
|
75
|
+
|
|
76
|
+
const existingTables = await this.db.tableNames();
|
|
77
|
+
if (existingTables.includes(TABLE_NAME)) {
|
|
78
|
+
this.table = await this.db.openTable(TABLE_NAME);
|
|
79
|
+
} else {
|
|
80
|
+
// Create table with a dummy row so schema is established, then delete it
|
|
81
|
+
const dummy: VectorRow = {
|
|
82
|
+
id: '__init__',
|
|
83
|
+
vector: new Array(384).fill(0) as number[],
|
|
84
|
+
title: '',
|
|
85
|
+
category: '',
|
|
86
|
+
tags: '[]',
|
|
87
|
+
importance: 0,
|
|
88
|
+
createdAt: '',
|
|
89
|
+
updatedAt: '',
|
|
90
|
+
};
|
|
91
|
+
// LanceDB expects Record<string, unknown>[] but our VectorRow is typed more strictly
|
|
92
|
+
// Cast is safe here as VectorRow is a subset of Record<string, unknown>
|
|
93
|
+
this.table = await this.db.createTable(TABLE_NAME, [
|
|
94
|
+
dummy as unknown as Record<string, unknown>,
|
|
95
|
+
]);
|
|
96
|
+
await this.table.delete(`id = '__init__'`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Upserts a memory row into the index.
|
|
102
|
+
* LanceDB doesn't have a native upsert so we delete-then-add.
|
|
103
|
+
*/
|
|
104
|
+
async upsert(memory: Memory, vector: number[]): Promise<void> {
|
|
105
|
+
await this.initialize();
|
|
106
|
+
const table = this.table!;
|
|
107
|
+
|
|
108
|
+
// Remove existing row (if any)
|
|
109
|
+
await table.delete(`id = '${escapeId(memory.id)}'`);
|
|
110
|
+
|
|
111
|
+
const row: VectorRow = {
|
|
112
|
+
id: memory.id,
|
|
113
|
+
vector,
|
|
114
|
+
title: memory.title,
|
|
115
|
+
category: memory.category,
|
|
116
|
+
tags: JSON.stringify(memory.tags),
|
|
117
|
+
importance: memory.importance,
|
|
118
|
+
createdAt: memory.createdAt,
|
|
119
|
+
updatedAt: memory.updatedAt,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// LanceDB expects Record<string, unknown>[] but our VectorRow is typed more strictly
|
|
123
|
+
await table.add([row as unknown as Record<string, unknown>]);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Removes a memory from the index by ID.
|
|
128
|
+
*/
|
|
129
|
+
async delete(id: string): Promise<void> {
|
|
130
|
+
await this.initialize();
|
|
131
|
+
await this.table!.delete(`id = '${escapeId(id)}'`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Searches for the nearest neighbours to `vector`.
|
|
136
|
+
*
|
|
137
|
+
* @param vector - Query embedding (must be 384-dim)
|
|
138
|
+
* @param limit - Max results to return
|
|
139
|
+
* @returns Array ordered by ascending distance (most similar first)
|
|
140
|
+
*/
|
|
141
|
+
async search(vector: number[], limit = 10): Promise<VectorSearchResult[]> {
|
|
142
|
+
await this.initialize();
|
|
143
|
+
|
|
144
|
+
const results = await this.table!.vectorSearch(vector).limit(limit).toArray();
|
|
145
|
+
|
|
146
|
+
return results.map((row: Record<string, unknown>) => ({
|
|
147
|
+
id: row['id'] as string,
|
|
148
|
+
_distance: row['_distance'] as number,
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Returns the number of rows in the index.
|
|
154
|
+
*/
|
|
155
|
+
async count(): Promise<number> {
|
|
156
|
+
await this.initialize();
|
|
157
|
+
return this.table!.countRows();
|
|
158
|
+
}
|
|
159
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Utility exports
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
export * from './slugify.js';
|
|
1
|
+
/**
|
|
2
|
+
* Utility exports
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export * from './slugify.js';
|
package/src/utils/slugify.ts
CHANGED
|
@@ -1,63 +1,63 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Slugify utility - Converts strings to URL-friendly slugs
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Converts a string to a URL-friendly slug
|
|
7
|
-
* - Converts to lowercase
|
|
8
|
-
* - Replaces spaces with hyphens
|
|
9
|
-
* - Removes special characters
|
|
10
|
-
* - Collapses multiple hyphens
|
|
11
|
-
* - Trims leading/trailing hyphens
|
|
12
|
-
*
|
|
13
|
-
* @param input - The string to convert
|
|
14
|
-
* @returns The slugified string
|
|
15
|
-
*/
|
|
16
|
-
export function slugify(input: string): string {
|
|
17
|
-
if (!input || input.trim().length === 0) {
|
|
18
|
-
return 'untitled';
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// Convert to lowercase and replace non-alphanumeric characters with hyphens
|
|
22
|
-
const slug = input
|
|
23
|
-
.toLowerCase()
|
|
24
|
-
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
|
|
25
|
-
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
|
26
|
-
.replace(/-+/g, '-') // Collapse multiple hyphens
|
|
27
|
-
.replace(/^-+|-+$/g, ''); // Trim leading/trailing hyphens
|
|
28
|
-
|
|
29
|
-
// If empty after cleaning (e.g., only special characters), return 'untitled'
|
|
30
|
-
if (slug.length === 0) {
|
|
31
|
-
return 'untitled';
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Truncate to max 100 characters
|
|
35
|
-
if (slug.length > 100) {
|
|
36
|
-
return slug.substring(0, 100).replace(/-+$/, ''); // Don't end with hyphen
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
return slug;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Generates a unique slug by appending a timestamp or counter if needed
|
|
44
|
-
*
|
|
45
|
-
* @param title - The title to slugify
|
|
46
|
-
* @param existingSlugs - Array of existing slugs to check against
|
|
47
|
-
* @returns A unique slug
|
|
48
|
-
*/
|
|
49
|
-
export function generateUniqueSlug(title: string, existingSlugs: readonly string[] = []): string {
|
|
50
|
-
const slug = slugify(title);
|
|
51
|
-
let counter = 1;
|
|
52
|
-
let uniqueSlug = slug;
|
|
53
|
-
|
|
54
|
-
while (existingSlugs.includes(uniqueSlug)) {
|
|
55
|
-
const suffix = `-${counter}`;
|
|
56
|
-
const maxBaseLength = 100 - suffix.length;
|
|
57
|
-
const baseSlug = slug.substring(0, maxBaseLength).replace(/-+$/, '');
|
|
58
|
-
uniqueSlug = `${baseSlug}${suffix}`;
|
|
59
|
-
counter++;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return uniqueSlug;
|
|
63
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Slugify utility - Converts strings to URL-friendly slugs
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Converts a string to a URL-friendly slug
|
|
7
|
+
* - Converts to lowercase
|
|
8
|
+
* - Replaces spaces with hyphens
|
|
9
|
+
* - Removes special characters
|
|
10
|
+
* - Collapses multiple hyphens
|
|
11
|
+
* - Trims leading/trailing hyphens
|
|
12
|
+
*
|
|
13
|
+
* @param input - The string to convert
|
|
14
|
+
* @returns The slugified string
|
|
15
|
+
*/
|
|
16
|
+
export function slugify(input: string): string {
|
|
17
|
+
if (!input || input.trim().length === 0) {
|
|
18
|
+
return 'untitled';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Convert to lowercase and replace non-alphanumeric characters with hyphens
|
|
22
|
+
const slug = input
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
|
|
25
|
+
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
|
26
|
+
.replace(/-+/g, '-') // Collapse multiple hyphens
|
|
27
|
+
.replace(/^-+|-+$/g, ''); // Trim leading/trailing hyphens
|
|
28
|
+
|
|
29
|
+
// If empty after cleaning (e.g., only special characters), return 'untitled'
|
|
30
|
+
if (slug.length === 0) {
|
|
31
|
+
return 'untitled';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Truncate to max 100 characters
|
|
35
|
+
if (slug.length > 100) {
|
|
36
|
+
return slug.substring(0, 100).replace(/-+$/, ''); // Don't end with hyphen
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return slug;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Generates a unique slug by appending a timestamp or counter if needed
|
|
44
|
+
*
|
|
45
|
+
* @param title - The title to slugify
|
|
46
|
+
* @param existingSlugs - Array of existing slugs to check against
|
|
47
|
+
* @returns A unique slug
|
|
48
|
+
*/
|
|
49
|
+
export function generateUniqueSlug(title: string, existingSlugs: readonly string[] = []): string {
|
|
50
|
+
const slug = slugify(title);
|
|
51
|
+
let counter = 1;
|
|
52
|
+
let uniqueSlug = slug;
|
|
53
|
+
|
|
54
|
+
while (existingSlugs.includes(uniqueSlug)) {
|
|
55
|
+
const suffix = `-${counter}`;
|
|
56
|
+
const maxBaseLength = 100 - suffix.length;
|
|
57
|
+
const baseSlug = slug.substring(0, maxBaseLength).replace(/-+$/, '');
|
|
58
|
+
uniqueSlug = `${baseSlug}${suffix}`;
|
|
59
|
+
counter++;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return uniqueSlug;
|
|
63
|
+
}
|