@rog0x/mcp-string-tools 1.0.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.
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.transformString = transformString;
4
+ function removeDuplicateLines(text) {
5
+ const lines = text.split("\n");
6
+ const seen = new Set();
7
+ const unique = [];
8
+ let removed = 0;
9
+ for (const line of lines) {
10
+ if (!seen.has(line)) {
11
+ seen.add(line);
12
+ unique.push(line);
13
+ }
14
+ else {
15
+ removed++;
16
+ }
17
+ }
18
+ return {
19
+ original: text,
20
+ transformed: unique.join("\n"),
21
+ operation: "remove_duplicate_lines",
22
+ details: { linesRemoved: removed, linesRemaining: unique.length },
23
+ };
24
+ }
25
+ function removeExtraWhitespace(text) {
26
+ const transformed = text
27
+ .replace(/[^\S\n]+/g, " ")
28
+ .replace(/\n{3,}/g, "\n\n")
29
+ .replace(/^ +| +$/gm, "")
30
+ .trim();
31
+ return {
32
+ original: text,
33
+ transformed,
34
+ operation: "remove_extra_whitespace",
35
+ };
36
+ }
37
+ function extractEmails(text) {
38
+ const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
39
+ const matches = text.match(emailRegex) || [];
40
+ return {
41
+ original: text,
42
+ transformed: matches.join("\n"),
43
+ operation: "extract_emails",
44
+ details: { count: matches.length, emails: matches },
45
+ };
46
+ }
47
+ function extractUrls(text) {
48
+ const urlRegex = /https?:\/\/[^\s<>"{}|\\^`[\]]+/g;
49
+ const matches = text.match(urlRegex) || [];
50
+ return {
51
+ original: text,
52
+ transformed: matches.join("\n"),
53
+ operation: "extract_urls",
54
+ details: { count: matches.length, urls: matches },
55
+ };
56
+ }
57
+ function extractPhoneNumbers(text) {
58
+ const phoneRegex = /(?:\+?1[-.\s]?)?(?:\(?\d{3}\)?[-.\s]?)?\d{3}[-.\s]?\d{4}/g;
59
+ const matches = text.match(phoneRegex) || [];
60
+ return {
61
+ original: text,
62
+ transformed: matches.join("\n"),
63
+ operation: "extract_phone_numbers",
64
+ details: { count: matches.length, phoneNumbers: matches },
65
+ };
66
+ }
67
+ function maskSensitiveData(text, maskChar = "*") {
68
+ let transformed = text;
69
+ let maskedCount = 0;
70
+ // Mask emails: keep first 2 chars + domain
71
+ transformed = transformed.replace(/([a-zA-Z0-9._%+-]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g, (_match, local, domain) => {
72
+ maskedCount++;
73
+ const keep = local.slice(0, 2);
74
+ return keep + maskChar.repeat(Math.max(local.length - 2, 3)) + "@" + domain;
75
+ });
76
+ // Mask phone numbers: keep last 4
77
+ transformed = transformed.replace(/(?:\+?1[-.\s]?)?(?:\(?\d{3}\)?[-.\s]?)?\d{3}[-.\s]?\d{4}/g, (match) => {
78
+ maskedCount++;
79
+ const digits = match.replace(/\D/g, "");
80
+ const last4 = digits.slice(-4);
81
+ return maskChar.repeat(digits.length - 4) + last4;
82
+ });
83
+ // Mask credit card numbers: keep last 4
84
+ transformed = transformed.replace(/\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/g, (match) => {
85
+ maskedCount++;
86
+ const digits = match.replace(/\D/g, "");
87
+ const last4 = digits.slice(-4);
88
+ return maskChar.repeat(12) + last4;
89
+ });
90
+ return {
91
+ original: text,
92
+ transformed,
93
+ operation: "mask_sensitive_data",
94
+ details: { maskedCount },
95
+ };
96
+ }
97
+ function truncateWithEllipsis(text, maxLength = 100, ellipsis = "...") {
98
+ if (text.length <= maxLength) {
99
+ return {
100
+ original: text,
101
+ transformed: text,
102
+ operation: "truncate",
103
+ details: { truncated: false },
104
+ };
105
+ }
106
+ const cutoff = maxLength - ellipsis.length;
107
+ // Try to cut at word boundary
108
+ const lastSpace = text.lastIndexOf(" ", cutoff);
109
+ const breakAt = lastSpace > cutoff * 0.7 ? lastSpace : cutoff;
110
+ return {
111
+ original: text,
112
+ transformed: text.slice(0, breakAt).trimEnd() + ellipsis,
113
+ operation: "truncate",
114
+ details: {
115
+ truncated: true,
116
+ originalLength: text.length,
117
+ newLength: breakAt + ellipsis.length,
118
+ },
119
+ };
120
+ }
121
+ function wrapText(text, width = 80) {
122
+ const lines = text.split("\n");
123
+ const wrapped = [];
124
+ for (const line of lines) {
125
+ if (line.length <= width) {
126
+ wrapped.push(line);
127
+ continue;
128
+ }
129
+ const words = line.split(" ");
130
+ let current = "";
131
+ for (const word of words) {
132
+ if (current.length === 0) {
133
+ current = word;
134
+ }
135
+ else if (current.length + 1 + word.length <= width) {
136
+ current += " " + word;
137
+ }
138
+ else {
139
+ wrapped.push(current);
140
+ current = word;
141
+ }
142
+ }
143
+ if (current.length > 0) {
144
+ wrapped.push(current);
145
+ }
146
+ }
147
+ return {
148
+ original: text,
149
+ transformed: wrapped.join("\n"),
150
+ operation: "wrap_text",
151
+ details: { width, lineCount: wrapped.length },
152
+ };
153
+ }
154
+ function transformString(options) {
155
+ const { operation, text, maskChar, maxLength, wrapWidth, ellipsis } = options;
156
+ switch (operation) {
157
+ case "remove_duplicate_lines":
158
+ return removeDuplicateLines(text);
159
+ case "remove_extra_whitespace":
160
+ return removeExtraWhitespace(text);
161
+ case "extract_emails":
162
+ return extractEmails(text);
163
+ case "extract_urls":
164
+ return extractUrls(text);
165
+ case "extract_phone_numbers":
166
+ return extractPhoneNumbers(text);
167
+ case "mask_sensitive_data":
168
+ return maskSensitiveData(text, maskChar);
169
+ case "truncate":
170
+ return truncateWithEllipsis(text, maxLength, ellipsis);
171
+ case "wrap_text":
172
+ return wrapText(text, wrapWidth);
173
+ default:
174
+ throw new Error(`Unknown operation: ${operation}. Available: remove_duplicate_lines, remove_extra_whitespace, extract_emails, extract_urls, extract_phone_numbers, mask_sensitive_data, truncate, wrap_text`);
175
+ }
176
+ }
177
+ //# sourceMappingURL=string-transformer.js.map
@@ -0,0 +1,8 @@
1
+ export interface TemplateResult {
2
+ template: string;
3
+ rendered: string;
4
+ variablesUsed: string[];
5
+ variablesMissing: string[];
6
+ }
7
+ export declare function renderTemplate(template: string, data: Record<string, unknown>): TemplateResult;
8
+ //# sourceMappingURL=template-engine.d.ts.map
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderTemplate = renderTemplate;
4
+ function getNestedValue(obj, path) {
5
+ const parts = path.split(".");
6
+ let current = obj;
7
+ for (const part of parts) {
8
+ if (current === null || current === undefined || typeof current !== "object") {
9
+ return undefined;
10
+ }
11
+ current = current[part];
12
+ }
13
+ return current;
14
+ }
15
+ function isTruthy(value) {
16
+ if (Array.isArray(value))
17
+ return value.length > 0;
18
+ if (typeof value === "object" && value !== null)
19
+ return Object.keys(value).length > 0;
20
+ return Boolean(value);
21
+ }
22
+ function processConditionals(template, data) {
23
+ // {{#if varName}}...content...{{/if}}
24
+ // {{#if varName}}...content...{{#else}}...alt...{{/if}}
25
+ const ifRegex = /\{\{#if\s+([^}]+)\}\}([\s\S]*?)\{\{\/if\}\}/g;
26
+ let result = template;
27
+ let prevResult = "";
28
+ // Iterate until stable (handles nested conditionals)
29
+ while (result !== prevResult) {
30
+ prevResult = result;
31
+ result = result.replace(ifRegex, (_match, varName, body) => {
32
+ const trimmedVar = varName.trim();
33
+ const value = getNestedValue(data, trimmedVar);
34
+ const elseParts = body.split(/\{\{#else\}\}/);
35
+ const trueBranch = elseParts[0];
36
+ const falseBranch = elseParts.length > 1 ? elseParts[1] : "";
37
+ return isTruthy(value) ? trueBranch : falseBranch;
38
+ });
39
+ }
40
+ return result;
41
+ }
42
+ function processLoops(template, data) {
43
+ // {{#each items}}...{{this}}...{{@index}}...{{/each}}
44
+ const eachRegex = /\{\{#each\s+([^}]+)\}\}([\s\S]*?)\{\{\/each\}\}/g;
45
+ let result = template;
46
+ let prevResult = "";
47
+ while (result !== prevResult) {
48
+ prevResult = result;
49
+ result = result.replace(eachRegex, (_match, varName, body) => {
50
+ const trimmedVar = varName.trim();
51
+ const value = getNestedValue(data, trimmedVar);
52
+ if (!Array.isArray(value)) {
53
+ return "";
54
+ }
55
+ return value
56
+ .map((item, index) => {
57
+ let rendered = body;
58
+ // Replace {{this}} with current item
59
+ rendered = rendered.replace(/\{\{this\}\}/g, String(item));
60
+ // Replace {{@index}} with current index
61
+ rendered = rendered.replace(/\{\{@index\}\}/g, String(index));
62
+ // If item is object, allow {{this.prop}}
63
+ if (typeof item === "object" && item !== null) {
64
+ rendered = rendered.replace(/\{\{this\.([^}]+)\}\}/g, (_m, prop) => {
65
+ const val = item[prop.trim()];
66
+ return val !== undefined ? String(val) : "";
67
+ });
68
+ }
69
+ return rendered;
70
+ })
71
+ .join("");
72
+ });
73
+ }
74
+ return result;
75
+ }
76
+ function processVariables(template, data) {
77
+ const used = [];
78
+ const missing = [];
79
+ const rendered = template.replace(/\{\{([^#/][^}]*)\}\}/g, (_match, varName) => {
80
+ const trimmed = varName.trim();
81
+ // Skip special variables
82
+ if (trimmed === "this" || trimmed.startsWith("@") || trimmed.startsWith("this.")) {
83
+ return _match;
84
+ }
85
+ const value = getNestedValue(data, trimmed);
86
+ if (value !== undefined) {
87
+ used.push(trimmed);
88
+ return String(value);
89
+ }
90
+ else {
91
+ missing.push(trimmed);
92
+ return "";
93
+ }
94
+ });
95
+ return { rendered, used, missing };
96
+ }
97
+ function renderTemplate(template, data) {
98
+ // Process in order: conditionals, loops, then variables
99
+ let result = processConditionals(template, data);
100
+ result = processLoops(result, data);
101
+ const { rendered, used, missing } = processVariables(result, data);
102
+ return {
103
+ template,
104
+ rendered,
105
+ variablesUsed: [...new Set(used)],
106
+ variablesMissing: [...new Set(missing)],
107
+ };
108
+ }
109
+ //# sourceMappingURL=template-engine.js.map
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@rog0x/mcp-string-tools",
3
+ "version": "1.0.0",
4
+ "description": "Advanced string manipulation tools for AI agents via MCP",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "start": "node dist/index.js"
10
+ },
11
+ "keywords": ["mcp", "string", "tools", "ai", "agents"],
12
+ "license": "MIT",
13
+ "dependencies": {
14
+ "@modelcontextprotocol/sdk": "^1.12.1"
15
+ },
16
+ "devDependencies": {
17
+ "typescript": "^5.7.3",
18
+ "@types/node": "^22.13.10"
19
+ }
20
+ }
package/src/index.ts ADDED
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { z } from "zod";
6
+
7
+ import { analyzeString } from "./tools/string-analyzer.js";
8
+ import { transformString } from "./tools/string-transformer.js";
9
+ import { diffStrings } from "./tools/string-diff.js";
10
+ import { renderTemplate } from "./tools/template-engine.js";
11
+ import { generateSlug } from "./tools/slug-generator.js";
12
+
13
+ const server = new McpServer({
14
+ name: "mcp-string-tools",
15
+ version: "1.0.0",
16
+ });
17
+
18
+ // Tool 1: String Analyzer
19
+ server.tool(
20
+ "analyze_string",
21
+ "Analyze text to get character count, word count, sentence count, paragraph count, reading time, Flesch-Kincaid reading level, unique words, most common words, and longest word.",
22
+ {
23
+ text: z.string().describe("The text to analyze"),
24
+ },
25
+ async ({ text }) => {
26
+ const result = analyzeString(text);
27
+ return {
28
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
29
+ };
30
+ }
31
+ );
32
+
33
+ // Tool 2: String Transformer
34
+ server.tool(
35
+ "transform_string",
36
+ "Transform text with operations: remove_duplicate_lines, remove_extra_whitespace, extract_emails, extract_urls, extract_phone_numbers, mask_sensitive_data, truncate, wrap_text.",
37
+ {
38
+ text: z.string().describe("The text to transform"),
39
+ operation: z
40
+ .enum([
41
+ "remove_duplicate_lines",
42
+ "remove_extra_whitespace",
43
+ "extract_emails",
44
+ "extract_urls",
45
+ "extract_phone_numbers",
46
+ "mask_sensitive_data",
47
+ "truncate",
48
+ "wrap_text",
49
+ ])
50
+ .describe("The transformation operation to apply"),
51
+ mask_char: z
52
+ .string()
53
+ .optional()
54
+ .default("*")
55
+ .describe("Character used for masking (mask_sensitive_data only)"),
56
+ max_length: z
57
+ .number()
58
+ .optional()
59
+ .default(100)
60
+ .describe("Maximum length for truncation (truncate only)"),
61
+ wrap_width: z
62
+ .number()
63
+ .optional()
64
+ .default(80)
65
+ .describe("Column width for text wrapping (wrap_text only)"),
66
+ ellipsis: z
67
+ .string()
68
+ .optional()
69
+ .default("...")
70
+ .describe("Ellipsis string for truncation (truncate only)"),
71
+ },
72
+ async ({ text, operation, mask_char, max_length, wrap_width, ellipsis }) => {
73
+ const result = transformString({
74
+ text,
75
+ operation,
76
+ maskChar: mask_char,
77
+ maxLength: max_length,
78
+ wrapWidth: wrap_width,
79
+ ellipsis,
80
+ });
81
+ return {
82
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
83
+ };
84
+ }
85
+ );
86
+
87
+ // Tool 3: String Diff
88
+ server.tool(
89
+ "diff_strings",
90
+ "Compare two strings showing additions, deletions, and unchanged segments with positions. Supports character-level, word-level, and line-level diff modes.",
91
+ {
92
+ old_text: z.string().describe("The original text"),
93
+ new_text: z.string().describe("The new/modified text"),
94
+ mode: z
95
+ .enum(["character", "word", "line"])
96
+ .optional()
97
+ .default("word")
98
+ .describe("Diff granularity: character, word, or line"),
99
+ },
100
+ async ({ old_text, new_text, mode }) => {
101
+ const result = diffStrings(old_text, new_text, mode);
102
+ return {
103
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
104
+ };
105
+ }
106
+ );
107
+
108
+ // Tool 4: Template Engine
109
+ server.tool(
110
+ "render_template",
111
+ "Render a template string by replacing {{variables}} with values from a data object. Supports {{#if var}}...{{#else}}...{{/if}} conditionals and {{#each arr}}...{{/each}} loops with {{this}}, {{@index}}, and {{this.prop}}.",
112
+ {
113
+ template: z.string().describe("Template string with {{variable}} placeholders"),
114
+ data: z
115
+ .string()
116
+ .describe(
117
+ "JSON string of key-value pairs to substitute into the template"
118
+ ),
119
+ },
120
+ async ({ template, data }) => {
121
+ let parsedData: Record<string, unknown>;
122
+ try {
123
+ parsedData = JSON.parse(data);
124
+ } catch {
125
+ return {
126
+ content: [
127
+ {
128
+ type: "text",
129
+ text: JSON.stringify({ error: "Invalid JSON in data parameter" }),
130
+ },
131
+ ],
132
+ isError: true,
133
+ };
134
+ }
135
+ const result = renderTemplate(template, parsedData);
136
+ return {
137
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
138
+ };
139
+ }
140
+ );
141
+
142
+ // Tool 5: Slug Generator
143
+ server.tool(
144
+ "generate_slug",
145
+ "Generate a URL-friendly slug from text. Removes special characters, lowercases, replaces spaces with hyphens, transliterates accented characters. Configurable separator and max length.",
146
+ {
147
+ text: z.string().describe("The text to convert to a slug"),
148
+ separator: z
149
+ .string()
150
+ .optional()
151
+ .default("-")
152
+ .describe("Character to use as word separator (default: -)"),
153
+ max_length: z
154
+ .number()
155
+ .optional()
156
+ .default(0)
157
+ .describe("Maximum slug length (0 = unlimited)"),
158
+ lowercase: z
159
+ .boolean()
160
+ .optional()
161
+ .default(true)
162
+ .describe("Convert to lowercase (default: true)"),
163
+ },
164
+ async ({ text, separator, max_length, lowercase }) => {
165
+ const result = generateSlug({
166
+ text,
167
+ separator,
168
+ maxLength: max_length,
169
+ lowercase,
170
+ });
171
+ return {
172
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
173
+ };
174
+ }
175
+ );
176
+
177
+ async function main(): Promise<void> {
178
+ const transport = new StdioServerTransport();
179
+ await server.connect(transport);
180
+ console.error("mcp-string-tools server running on stdio");
181
+ }
182
+
183
+ main().catch((err) => {
184
+ console.error("Fatal error:", err);
185
+ process.exit(1);
186
+ });
@@ -0,0 +1,108 @@
1
+ export interface SlugOptions {
2
+ text: string;
3
+ separator?: string;
4
+ maxLength?: number;
5
+ lowercase?: boolean;
6
+ }
7
+
8
+ export interface SlugResult {
9
+ original: string;
10
+ slug: string;
11
+ length: number;
12
+ wasTruncated: boolean;
13
+ }
14
+
15
+ const TRANSLITERATION_MAP: Record<string, string> = {
16
+ "\u00e0": "a", "\u00e1": "a", "\u00e2": "a", "\u00e3": "a", "\u00e4": "a", "\u00e5": "a",
17
+ "\u00e6": "ae",
18
+ "\u00e7": "c",
19
+ "\u00e8": "e", "\u00e9": "e", "\u00ea": "e", "\u00eb": "e",
20
+ "\u00ec": "i", "\u00ed": "i", "\u00ee": "i", "\u00ef": "i",
21
+ "\u00f0": "d",
22
+ "\u00f1": "n",
23
+ "\u00f2": "o", "\u00f3": "o", "\u00f4": "o", "\u00f5": "o", "\u00f6": "o", "\u00f8": "o",
24
+ "\u00f9": "u", "\u00fa": "u", "\u00fb": "u", "\u00fc": "u",
25
+ "\u00fd": "y", "\u00ff": "y",
26
+ "\u00fe": "th",
27
+ "\u00df": "ss",
28
+ "\u0100": "a", "\u0101": "a", "\u0102": "a", "\u0103": "a", "\u0104": "a", "\u0105": "a",
29
+ "\u0106": "c", "\u0107": "c", "\u0108": "c", "\u0109": "c", "\u010a": "c", "\u010b": "c", "\u010c": "c", "\u010d": "c",
30
+ "\u010e": "d", "\u010f": "d", "\u0110": "d", "\u0111": "d",
31
+ "\u0112": "e", "\u0113": "e", "\u0114": "e", "\u0115": "e", "\u0116": "e", "\u0117": "e", "\u0118": "e", "\u0119": "e", "\u011a": "e", "\u011b": "e",
32
+ "\u011c": "g", "\u011d": "g", "\u011e": "g", "\u011f": "g", "\u0120": "g", "\u0121": "g", "\u0122": "g", "\u0123": "g",
33
+ "\u0124": "h", "\u0125": "h", "\u0126": "h", "\u0127": "h",
34
+ "\u0128": "i", "\u0129": "i", "\u012a": "i", "\u012b": "i", "\u012c": "i", "\u012d": "i", "\u012e": "i", "\u012f": "i", "\u0130": "i", "\u0131": "i",
35
+ "\u0139": "l", "\u013a": "l", "\u013b": "l", "\u013c": "l", "\u013d": "l", "\u013e": "l", "\u0141": "l", "\u0142": "l",
36
+ "\u0143": "n", "\u0144": "n", "\u0145": "n", "\u0146": "n", "\u0147": "n", "\u0148": "n",
37
+ "\u014c": "o", "\u014d": "o", "\u014e": "o", "\u014f": "o", "\u0150": "o", "\u0151": "o", "\u0152": "oe", "\u0153": "oe",
38
+ "\u0154": "r", "\u0155": "r", "\u0156": "r", "\u0157": "r", "\u0158": "r", "\u0159": "r",
39
+ "\u015a": "s", "\u015b": "s", "\u015c": "s", "\u015d": "s", "\u015e": "s", "\u015f": "s", "\u0160": "s", "\u0161": "s",
40
+ "\u0162": "t", "\u0163": "t", "\u0164": "t", "\u0165": "t", "\u0166": "t", "\u0167": "t",
41
+ "\u0168": "u", "\u0169": "u", "\u016a": "u", "\u016b": "u", "\u016c": "u", "\u016d": "u", "\u016e": "u", "\u016f": "u", "\u0170": "u", "\u0171": "u", "\u0172": "u", "\u0173": "u",
42
+ "\u0174": "w", "\u0175": "w",
43
+ "\u0176": "y", "\u0177": "y", "\u0178": "y",
44
+ "\u0179": "z", "\u017a": "z", "\u017b": "z", "\u017c": "z", "\u017d": "z", "\u017e": "z",
45
+ };
46
+
47
+ function transliterate(text: string): string {
48
+ let result = "";
49
+ for (const char of text) {
50
+ const lower = char.toLowerCase();
51
+ if (TRANSLITERATION_MAP[lower]) {
52
+ const replacement = TRANSLITERATION_MAP[lower];
53
+ // Preserve case for first letter
54
+ if (char === char.toUpperCase() && char !== char.toLowerCase()) {
55
+ result += replacement.charAt(0).toUpperCase() + replacement.slice(1);
56
+ } else {
57
+ result += replacement;
58
+ }
59
+ } else {
60
+ result += char;
61
+ }
62
+ }
63
+ return result;
64
+ }
65
+
66
+ export function generateSlug(options: SlugOptions): SlugResult {
67
+ const {
68
+ text,
69
+ separator = "-",
70
+ maxLength = 0,
71
+ lowercase = true,
72
+ } = options;
73
+
74
+ let slug = transliterate(text);
75
+
76
+ if (lowercase) {
77
+ slug = slug.toLowerCase();
78
+ }
79
+
80
+ // Replace non-alphanumeric chars (except separator) with separator
81
+ const escapedSep = separator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
82
+ slug = slug.replace(/[^a-zA-Z0-9]+/g, separator);
83
+
84
+ // Remove leading/trailing separators
85
+ const trimRegex = new RegExp(`^${escapedSep}+|${escapedSep}+$`, "g");
86
+ slug = slug.replace(trimRegex, "");
87
+
88
+ // Collapse multiple consecutive separators
89
+ const multiRegex = new RegExp(`${escapedSep}{2,}`, "g");
90
+ slug = slug.replace(multiRegex, separator);
91
+
92
+ let wasTruncated = false;
93
+
94
+ if (maxLength > 0 && slug.length > maxLength) {
95
+ wasTruncated = true;
96
+ slug = slug.slice(0, maxLength);
97
+ // Don't end on a separator
98
+ const endTrimRegex = new RegExp(`${escapedSep}+$`);
99
+ slug = slug.replace(endTrimRegex, "");
100
+ }
101
+
102
+ return {
103
+ original: text,
104
+ slug,
105
+ length: slug.length,
106
+ wasTruncated,
107
+ };
108
+ }