diff --git a/docs/tools.md b/docs/tools.md index 761cd4d..697e2e6 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -109,6 +109,23 @@ This document provides comprehensive guidance for AI agents working with Angular **Output**: Array of deprecated CSS class names **Best Practice**: Cross-reference with violation reports to prioritize fixes +#### `get-ds-story-data` +**Purpose**: Parses Storybook `.stories.ts` files for a DS component and returns a compact summary of its API surface, usage patterns, and story templates +**AI Usage**: Use to understand selector style, input API, slots, form integration, and real-world template examples before generating or migrating component code. Defaults to markdown output with `docs-template` stories filtered out for minimal token cost. +**Key Parameters**: +- `componentName`: DS component class name (e.g., `DsButton`, `DsBadge`) +- `format`: `"markdown"` (default) or `"json"` for structured data +- `descriptionLength`: `"short"` (default, first sentence only) or `"full"` +- `excludeTags`: Story tags to filter out โ€” defaults to `["docs-template"]` to skip showcase stories. Pass `[]` to include all. +**Output**: Contains: +- `selector`: Selector style (`element` or `attribute`) with CSS selector and usage note +- `imports`: Filtered `@frontend/` and `@design-system/` scoped imports +- `argTypes`: Input definitions with type, default, and description +- `slots`: Named content projection slots +- `formIntegration`: Detected Angular forms bindings (ngModel, formControlName, formControl) +- `stories`: Story entries with name, single-line cleaned template, tags, args overrides, and story-level inputs. Templates have inline styles and HTML comments stripped. `[all-meta-args-bound]` indicates `argsToTemplate()` usage. +**Best Practice**: Use alongside `get-ds-component-data` for a complete picture before planning migrations. For components with many stories, the default `excludeTags: ["docs-template"]` filter reduces output by 40-50%. + ### ๐Ÿ”— Analysis & Mapping Tools #### `build-component-usage-graph` @@ -178,10 +195,11 @@ This document provides comprehensive guidance for AI agents working with Angular ### 2. Planning & Preparation Workflow ``` 1. get-ds-component-data โ†’ Get comprehensive component information -2. build-component-usage-graph โ†’ Map component relationships -3. get-component-docs โ†’ Review proper usage patterns -4. get-component-paths โ†’ Verify import paths -5. build_component_contract โ†’ Create baseline contracts +2. get-ds-story-data โ†’ Extract usage patterns and templates from stories +3. build-component-usage-graph โ†’ Map component relationships +4. get-component-docs โ†’ Review proper usage patterns +5. get-component-paths โ†’ Verify import paths +6. build_component_contract โ†’ Create baseline contracts ``` ### 3. Refactoring & Validation Workflow diff --git a/packages/angular-mcp-server/src/lib/tools/ds/project/utils/styles-report-helpers.ts b/packages/angular-mcp-server/src/lib/tools/ds/project/utils/styles-report-helpers.ts index 1c05068..cf87580 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/project/utils/styles-report-helpers.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/project/utils/styles-report-helpers.ts @@ -1,7 +1,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { parseStylesheet, visitEachChild } from '@push-based/styles-ast-utils'; -import { findAllFiles } from '@push-based/utils'; +import { findAllFiles, escapeRegex } from '@push-based/utils'; import type { Rule } from 'postcss'; export interface StyleFileReport { @@ -16,7 +16,6 @@ export interface StyleFileReport { const STYLE_EXT = new Set(['.css', '.scss', '.sass', '.less']); const isStyleFile = (f: string) => STYLE_EXT.has(path.extname(f).toLowerCase()); -const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); export async function findStyleFiles(dir: string): Promise { const files: string[] = []; diff --git a/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/regex-helpers.ts b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/regex-helpers.ts index 1dcc709..fbc651f 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/regex-helpers.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/regex-helpers.ts @@ -3,12 +3,14 @@ * Consolidates regex patterns used across multiple tools to avoid duplication */ +import { escapeRegex } from '@push-based/utils'; + // CSS Processing Regexes export const CSS_REGEXES = { /** * Escapes special regex characters in a string for safe use in regex patterns */ - escape: (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + escape: escapeRegex, /** * Creates a regex to match CSS classes from a list of class names diff --git a/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/template-helpers.ts b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/template-helpers.ts new file mode 100644 index 0000000..7b88571 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/template-helpers.ts @@ -0,0 +1,135 @@ +/** + * Utilities for processing Angular HTML templates. + * These are regex-based helpers for template analysis โ€” not tied to the Angular compiler AST. + */ + +import { escapeRegex } from '@push-based/utils'; + +export interface SelectorInfo { + style: 'element' | 'attribute' | 'unknown'; + selector: string; + note?: string; +} + +/** + * Clean a template string: + * - Replace `${...}` interpolations with `...` placeholders + * - Remove Storybook layout wrapper divs + * - Normalize whitespace + * + * Returns null if the cleaned result doesn't look like valid HTML + * (e.g., programmatically generated templates with JS fragments). + */ +export function cleanTemplate(template: string): string | null { + let t = template; + + // Replace argsToTemplate(args) with a meaningful marker before generic replacement + t = t.replace(/\$\{argsToTemplate\([^)]*\)\}/g, '[all-meta-args-bound]'); + + // Replace ${...} interpolations with placeholder + t = t.replace(/\$\{[^}]*\}/g, '...'); + + // Remove HTML comments + t = t.replace(//g, ''); + + // Remove ALL inline style attributes (storybook layout concerns, not component API) + t = t.replace(/\s*style=["'][^"']*["']/gi, ''); + + // Remove storybook layout wrapper divs (with class but layout-only) โ€” strip opening AND closing tag + t = t.replace( + /([\s\S]*?)<\/div>/gi, + '$1', + ); + + // Collapse to single line: strip all newlines and excess whitespace + t = t.replace(/\s+/g, ' ').trim(); + + // Remove whitespace between tags (but not inside tags) + t = t.replace(/>\s+<'); + + // Detect programmatically generated templates (JS fragments, not HTML) + if (!looksLikeHtml(t)) { + return null; + } + + return t; +} + +/** + * Check if a cleaned template string looks like valid HTML rather than + * JS code fragments from programmatically generated templates. + */ +export function looksLikeHtml(template: string): boolean { + // Must contain at least one HTML tag + if (!/<\w/.test(template)) return false; + + // JS artifacts that indicate a programmatic template + const jsArtifacts = ['.join(', '.map(', '=>', 'function ', 'return ']; + const artifactCount = jsArtifacts.filter((a) => template.includes(a)).length; + if (artifactCount >= 2) return false; + + return true; +} + +/** + * Scan templates for `slot="name"` patterns, deduplicate and sort alphabetically. + */ +export function extractSlotsFromTemplates(templates: string[]): string[] { + const slots = new Set(); + const slotRegex = /slot="([^"]+)"/g; + + for (const template of templates) { + let match: RegExpExecArray | null; + while ((match = slotRegex.exec(template)) !== null) { + slots.add(match[1]); + } + } + + return [...slots].sort(); +} + +/** + * Detect whether a component uses attribute-style or element-style selectors. + */ +export function detectSelectorStyle( + content: string, + kebabName: string, +): SelectorInfo { + // Check for attribute usage: ', + 'button', + ); + expect(result.style).toBe('attribute'); + expect(result.selector).toBe('ds-button'); + expect(result.note).toContain('attribute'); + }); + + it('should return unknown when no selector pattern found', () => { + const result = detectSelectorStyle('
no component
', 'badge'); + expect(result.style).toBe('unknown'); + }); + + it('should detect attribute on
elements', () => { + const result = detectSelectorStyle( + 'Link', + 'button', + ); + expect(result.style).toBe('attribute'); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Form integration detection +// --------------------------------------------------------------------------- + +describe('detectFormIntegration', () => { + it('should detect ngModel', () => { + const result = detectFormIntegration('[(ngModel)]="value"'); + expect(result).toContain('ngModel (template-driven)'); + }); + + it('should detect formControlName', () => { + const result = detectFormIntegration('formControlName="name"'); + expect(result).toContain('formControlName (reactive forms)'); + }); + + it('should detect formControl without matching formControlName', () => { + const result = detectFormIntegration('[formControl]="ctrl"'); + expect(result).toContain('formControl (reactive forms)'); + expect(result).not.toContain('formControlName (reactive forms)'); + }); + + it('should detect all three patterns', () => { + const result = detectFormIntegration(FORM_INTEGRATION_STORY); + expect(result).toHaveLength(3); + }); + + it('should return empty array when no form patterns found', () => { + expect(detectFormIntegration('
no forms
')).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Template cleaning +// --------------------------------------------------------------------------- + +describe('cleanTemplate', () => { + it('should replace ${...} with ...', () => { + const result = cleanTemplate( + '${badge.label}', + ); + expect(result).not.toContain('${'); + expect(result).toContain('...'); + }); + + it('should remove Storybook layout wrapper divs', () => { + const result = cleanTemplate( + '
Label', + ); + expect(result).not.toContain('display: grid'); + expect(result).toContain('ds-badge'); + }); + + it('should normalize whitespace', () => { + const result = cleanTemplate('\n\n\n Label\n\n\n'); + expect(result).not.toMatch(/\n{3,}/); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Ternary else-branch extraction +// --------------------------------------------------------------------------- + +describe('extractTernaryElseBranch', () => { + it('should extract else-branch from ternary with unsupported marker', () => { + const body = ` + template: isUnsupportedCombination + ? \`

Not Supported

\` + : \`\`, + `; + const result = extractTernaryElseBranch(body); + expect(result).not.toBeNull(); + expect(result).toContain('ds-toggle'); + expect(result).not.toContain('Not Supported'); + }); + + it('should return null when no ternary marker found', () => { + const body = 'template: `Label`'; + expect(extractTernaryElseBranch(body)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Regex fallback (malformed TypeScript triggers parseDiagnostics) +// --------------------------------------------------------------------------- + +describe('parseStoryFile regex fallback', () => { + // Deliberately malformed TS that produces parseDiagnostics, + // forcing the regex extraction path. + const MALFORMED_STORY = ` +import { DsBadge } from '@frontend/ui/badge'; +import { Meta, StoryObj } from '@storybook/angular'; + +// Syntax error: missing closing brace on meta +const meta: Meta = { + title: 'Components/Badge', + argTypes: { + label: { + type: 'string', + }, + }, +; // <-- deliberate syntax error + +export default meta; + +export const Default: Story = { + render: () => ({ + template: \`Label\`, + }), +}; +`; + + it('should fall back to regex extraction when input has parse errors', () => { + const result = parseStoryFile( + MALFORMED_STORY, + 'malformed.stories.ts', + 'badge', + ); + + // Regex path should still extract the DS import + expect(result.imports.length).toBeGreaterThan(0); + expect(result.imports[0]).toContain('@frontend/ui/badge'); + }); + + it('should use AST path for well-formed input', () => { + const result = parseStoryFile(BADGE_STORY, 'badge.stories.ts', 'badge'); + expect(result.imports.length).toBeGreaterThan(0); + expect(result.stories.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/angular-mcp-server/src/lib/tools/ds/story-parser/utils/story-markdown-formatter.utils.ts b/packages/angular-mcp-server/src/lib/tools/ds/story-parser/utils/story-markdown-formatter.utils.ts new file mode 100644 index 0000000..63419c0 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/story-parser/utils/story-markdown-formatter.utils.ts @@ -0,0 +1,116 @@ +import { StoryFileData, ArgTypeEntry, StoryEntry } from '../models/types.js'; + +/** + * Formats parsed story data as a compact, token-efficient markdown string. + */ +export function formatStoryDataAsMarkdown(data: StoryFileData): string { + const lines: string[] = []; + + lines.push(`# ${data.componentName}`); + lines.push(''); + + // Selector + lines.push( + `Selector: \`${data.selector.selector}\` (${data.selector.style})`, + ); + if (data.selector.note) { + lines.push(`Note: ${data.selector.note}`); + } + lines.push(''); + + // Imports + if (data.imports.length > 0) { + lines.push('## Imports'); + lines.push(''); + for (const imp of data.imports) { + lines.push(imp); + } + lines.push(''); + } + + // ArgTypes + if (data.argTypes.length > 0) { + lines.push('## ArgTypes'); + lines.push(''); + lines.push(formatArgTypesTable(data.argTypes)); + lines.push(''); + } + + // Slots + if (data.slots.length > 0) { + lines.push(`Slots: ${data.slots.map((s) => `\`${s}\``).join(', ')}`); + lines.push(''); + } + + // Form Integration + if (data.formIntegration.length > 0) { + lines.push(`Form: ${data.formIntegration.join(', ')}`); + lines.push(''); + } + + // Stories + if (data.stories.length > 0) { + lines.push('## Stories'); + lines.push(''); + for (const story of data.stories) { + lines.push(formatStoryEntry(story)); + } + } + + return lines.join('\n'); +} + +function formatArgTypesTable(argTypes: ArgTypeEntry[]): string { + const rows: string[] = []; + rows.push('| Name | Type | Default | Description |'); + rows.push('| --- | --- | --- | --- |'); + for (const at of argTypes) { + const type = at.options ? `enum (${at.options})` : at.type || '-'; + const def = at.default ? `\`${at.default}\`` : '-'; + const desc = at.description || '-'; + rows.push(`| ${at.name} | ${type} | ${def} | ${desc} |`); + } + return rows.join('\n'); +} + +function formatStoryEntry(story: StoryEntry): string { + const parts: string[] = []; + const heading = story.displayName + ? `### ${story.name} (${story.displayName})` + : `### ${story.name}`; + parts.push(heading); + + const meta: string[] = []; + if (story.tags.length > 0) { + meta.push(`tags: ${story.tags.join(', ')}`); + } + if (story.hasPlayFn) { + meta.push('has play fn'); + } + if (story.argsOverrides.length > 0) { + const overrides = story.argsOverrides + .map((o) => `${o.name}: ${o.value}`) + .join(', '); + meta.push(`args: ${overrides}`); + } + if (meta.length > 0) { + parts.push(meta.join(' ยท ')); + } + + if (story.storyLevelArgs && story.storyLevelArgs.length > 0) { + const slArgs = story.storyLevelArgs + .map((a) => { + const type = a.options ? `enum(${a.options})` : a.type || ''; + return `${a.name}${type ? `: ${type}` : ''}`; + }) + .join(', '); + parts.push(`story-level inputs: ${slArgs}`); + } + + if (story.template) { + parts.push(story.template); + } + + parts.push(''); + return parts.join('\n'); +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/story-parser/utils/story-parser.utils.ts b/packages/angular-mcp-server/src/lib/tools/ds/story-parser/utils/story-parser.utils.ts new file mode 100644 index 0000000..1091046 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/story-parser/utils/story-parser.utils.ts @@ -0,0 +1,896 @@ +import * as ts from 'typescript'; +import { + extractTemplateLiteral, + extractBalancedBlock, +} from '@push-based/typescript-ast-utils'; +import { + StoryFileData, + ArgTypeEntry, + MetaArg, + StoryEntry, + ArgsOverride, +} from '../models/types.js'; +import { + cleanTemplate, + extractSlotsFromTemplates, + detectSelectorStyle, + detectFormIntegration, +} from '../../shared/utils/template-helpers.js'; + +// ============================================================================ +// Main entry point +// ============================================================================ + +/** + * Parse a Storybook .stories.ts file into structured JSON. + * Uses TypeScript AST as primary extraction. Falls back to regex + * when the parser reports diagnostics (malformed / incomplete input). + */ +export function parseStoryFile( + content: string, + filePath: string, + kebabName: string, +): StoryFileData { + const sourceFile = ts.createSourceFile( + filePath, + content, + ts.ScriptTarget.Latest, + true, + ); + + // parseDiagnostics is internal but reliably present on every SourceFile. + // Fall back to regex when the parser flagged syntax issues. + const diagnostics = ( + sourceFile as unknown as { parseDiagnostics?: unknown[] } + ).parseDiagnostics; + if (diagnostics && diagnostics.length > 0) { + return extractWithRegex(content, filePath, kebabName); + } + + return extractFromAST(sourceFile, content, filePath, kebabName); +} + +// ============================================================================ +// AST extraction path +// ============================================================================ + +function extractFromAST( + sourceFile: ts.SourceFile, + content: string, + filePath: string, + kebabName: string, +): StoryFileData { + const imports = extractImportsAST(sourceFile); + const metaArgs = extractMetaArgsAST(sourceFile); + const argTypes = extractArgTypesAST(sourceFile); + crossReferenceDefaults(argTypes, metaArgs); + const metaTags = extractMetaTagsAST(sourceFile); + const stories = extractStoriesAST(sourceFile, content); + const templates = stories + .map((s) => s.template) + .filter((t): t is string => t !== null); + const slots = extractSlotsFromTemplates(templates); + const selector = detectSelectorStyle(content, kebabName); + const formIntegration = detectFormIntegration(content); + + return { + componentName: kebabName, + filePath, + selector, + imports, + argTypes, + metaArgs, + metaTags, + slots, + formIntegration, + stories, + }; +} + +// ============================================================================ +// Regex fallback path +// ============================================================================ + +function extractWithRegex( + content: string, + filePath: string, + kebabName: string, +): StoryFileData { + const imports = extractImportsRegex(content); + const metaArgs = extractMetaArgsRegex(content); + const argTypes = extractArgTypesRegex(content); + crossReferenceDefaults(argTypes, metaArgs); + const metaTags = extractMetaTagsRegex(content); + const stories = extractStoriesRegex(content); + const templates = stories + .map((s) => s.template) + .filter((t): t is string => t !== null); + const slots = extractSlotsFromTemplates(templates); + const selector = detectSelectorStyle(content, kebabName); + const formIntegration = detectFormIntegration(content); + + return { + componentName: kebabName, + filePath, + selector, + imports, + argTypes, + metaArgs, + metaTags, + slots, + formIntegration, + stories, + }; +} + +// ============================================================================ +// Post-processing: cross-reference meta args into argTypes +// ============================================================================ + +/** + * Fill in missing argType defaults from meta args. + * When an argType has no `default` but meta.args has a value for that key, use it. + */ +function crossReferenceDefaults( + argTypes: ArgTypeEntry[], + metaArgs: MetaArg[] | null, +): void { + if (!metaArgs || metaArgs.length === 0) return; + + const metaMap = new Map(metaArgs.map((a) => [a.name, a.default])); + for (const at of argTypes) { + if (!at.default && metaMap.has(at.name)) { + const val = metaMap.get(at.name); + if (val) { + at.default = val.replace(/^['"]|['"]$/g, ''); + } + } + } +} + +// ============================================================================ +// Import extraction +// ============================================================================ + +const DS_IMPORT_SCOPES = ['@frontend/', '@design-system/']; + +function isDsScopedImport(moduleSpecifier: string): boolean { + return DS_IMPORT_SCOPES.some((scope) => moduleSpecifier.startsWith(scope)); +} + +function normalizeImportText(importText: string): string { + // Collapse multi-line imports to single line + const namesMatch = importText.match(/import\s+\{([^}]*)\}/s); + const fromMatch = importText.match(/from\s+['"]([^'"]+)['"]/); + if (!namesMatch || !fromMatch) return importText.replace(/\s+/g, ' ').trim(); + + const names = namesMatch[1] + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + .join(', '); + return `import { ${names} } from '${fromMatch[1]}';`; +} + +export function extractImportsAST(sourceFile: ts.SourceFile): string[] { + const imports: string[] = []; + + for (const statement of sourceFile.statements) { + if ( + ts.isImportDeclaration(statement) && + statement.moduleSpecifier && + ts.isStringLiteral(statement.moduleSpecifier) + ) { + const moduleSpec = statement.moduleSpecifier.text; + if (isDsScopedImport(moduleSpec)) { + const rawText = statement.getText(sourceFile); + imports.push(normalizeImportText(rawText)); + } + } + } + + return imports; +} + +export function extractImportsRegex(content: string): string[] { + const imports: string[] = []; + const importRegex = + /import\s+\{[^}]*\}\s+from\s+['"](@frontend\/[^'"]+|@design-system\/[^'"]+)['"]\s*;?/gs; + let match: RegExpExecArray | null; + + while ((match = importRegex.exec(content)) !== null) { + imports.push(normalizeImportText(match[0])); + } + + return imports; +} + +// ============================================================================ +// Meta args extraction +// ============================================================================ + +export function extractMetaArgsAST( + sourceFile: ts.SourceFile, +): MetaArg[] | null { + const metaObject = findMetaObjectAST(sourceFile); + if (!metaObject) return null; + + for (const prop of metaObject.properties) { + if ( + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === 'args' && + ts.isObjectLiteralExpression(prop.initializer) + ) { + return extractArgsFromObjectLiteral(prop.initializer, sourceFile); + } + } + + return null; +} + +function findMetaObjectAST( + sourceFile: ts.SourceFile, +): ts.ObjectLiteralExpression | null { + for (const statement of sourceFile.statements) { + // const meta = { ... } + if (ts.isVariableStatement(statement)) { + for (const decl of statement.declarationList.declarations) { + if ( + ts.isIdentifier(decl.name) && + decl.name.text === 'meta' && + decl.initializer + ) { + // Handle: const meta: Meta = { ... } + if (ts.isObjectLiteralExpression(decl.initializer)) { + return decl.initializer; + } + // Handle: const meta = { ... } satisfies Meta + if ( + ts.isSatisfiesExpression(decl.initializer) && + ts.isObjectLiteralExpression(decl.initializer.expression) + ) { + return decl.initializer.expression; + } + // Handle: const meta = { ... } as Meta + if ( + ts.isAsExpression(decl.initializer) && + ts.isObjectLiteralExpression(decl.initializer.expression) + ) { + return decl.initializer.expression; + } + } + } + } + // export default { ... } + if ( + ts.isExportAssignment(statement) && + ts.isObjectLiteralExpression(statement.expression) + ) { + return statement.expression; + } + // export default { ... } as Meta + if ( + ts.isExportAssignment(statement) && + ts.isAsExpression(statement.expression) && + ts.isObjectLiteralExpression(statement.expression.expression) + ) { + return statement.expression.expression; + } + // export default { ... } satisfies Meta + if ( + ts.isExportAssignment(statement) && + ts.isSatisfiesExpression(statement.expression) && + ts.isObjectLiteralExpression(statement.expression.expression) + ) { + return statement.expression.expression; + } + } + return null; +} + +function extractArgsFromObjectLiteral( + obj: ts.ObjectLiteralExpression, + sourceFile: ts.SourceFile, +): MetaArg[] { + const args: MetaArg[] = []; + for (const prop of obj.properties) { + if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { + args.push({ + name: prop.name.text, + default: prop.initializer.getText(sourceFile).replace(/,\s*$/, ''), + }); + } + if (ts.isShorthandPropertyAssignment(prop)) { + args.push({ + name: prop.name.text, + default: prop.name.text, + }); + } + } + return args; +} + +export function extractMetaArgsRegex(content: string): MetaArg[] | null { + // Find the meta block boundary + const metaStart = content.indexOf('const meta'); + const exportDefault = content.indexOf('export default'); + if (metaStart === -1 && exportDefault === -1) return null; + + const searchStart = metaStart !== -1 ? metaStart : exportDefault; + // For const meta, the meta block ends at export default (or EOF) + const searchEnd = + metaStart !== -1 && exportDefault > metaStart + ? exportDefault + : content.length; + + const metaBlock = content.slice(searchStart, searchEnd); + const argsMatch = metaBlock.match(/\n\s{2,4}args:\s*\{([^}]+)\}/); + if (!argsMatch) return null; + + const pairs: MetaArg[] = []; + const body = argsMatch[1]; + for (const line of body.split('\n')) { + const pairMatch = line.match(/^\s*(\w+):\s*(.+?),?\s*$/); + if (pairMatch) { + pairs.push({ + name: pairMatch[1], + default: pairMatch[2].replace(/,\s*$/, ''), + }); + } + } + return pairs.length > 0 ? pairs : null; +} + +// ============================================================================ +// Meta tags extraction +// ============================================================================ + +function extractMetaTagsAST(sourceFile: ts.SourceFile): string[] { + const metaObject = findMetaObjectAST(sourceFile); + if (!metaObject) return []; + + for (const prop of metaObject.properties) { + if ( + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === 'tags' && + ts.isArrayLiteralExpression(prop.initializer) + ) { + return prop.initializer.elements + .filter(ts.isStringLiteral) + .map((el) => el.text); + } + } + return []; +} + +function extractMetaTagsRegex(content: string): string[] { + // Match tags at meta level (indented 4 spaces, before export default) + const metaEnd = content.indexOf('export default'); + const metaBlock = metaEnd !== -1 ? content.slice(0, metaEnd) : content; + const tagsMatch = metaBlock.match(/\n\s{2,4}tags:\s*\[([^\]]*)\]/); + if (!tagsMatch) return []; + return tagsMatch[1] + .replace(/['"]/g, '') + .split(',') + .map((t) => t.trim()) + .filter(Boolean); +} + +// ============================================================================ +// ArgTypes extraction +// ============================================================================ + +export function extractArgTypesAST(sourceFile: ts.SourceFile): ArgTypeEntry[] { + const metaObject = findMetaObjectAST(sourceFile); + if (!metaObject) return []; + + for (const prop of metaObject.properties) { + if ( + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === 'argTypes' && + ts.isObjectLiteralExpression(prop.initializer) + ) { + return extractArgTypeEntries(prop.initializer, sourceFile); + } + } + + return []; +} + +function extractArgTypeEntries( + obj: ts.ObjectLiteralExpression, + sourceFile: ts.SourceFile, +): ArgTypeEntry[] { + const entries: ArgTypeEntry[] = []; + + for (const prop of obj.properties) { + if ( + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + ts.isObjectLiteralExpression(prop.initializer) + ) { + const entry: ArgTypeEntry = { name: prop.name.text }; + extractArgTypeFields(prop.initializer, sourceFile, entry); + entries.push(entry); + } + } + + return entries; +} + +function extractArgTypeFields( + obj: ts.ObjectLiteralExpression, + sourceFile: ts.SourceFile, + entry: ArgTypeEntry, +): void { + for (const prop of obj.properties) { + if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue; + + const key = prop.name.text; + + if (key === 'options') { + // Preserve constant references (e.g., DS_BUTTON_VARIANTS_ARRAY) + entry.options = prop.initializer.getText(sourceFile); + } else if (key === 'type') { + if (ts.isStringLiteral(prop.initializer)) { + entry.type = prop.initializer.text; + } + } else if (key === 'description') { + if (ts.isStringLiteral(prop.initializer)) { + entry.description = prop.initializer.text; + } else if (ts.isNoSubstitutionTemplateLiteral(prop.initializer)) { + entry.description = prop.initializer.text; + } + } else if ( + key === 'table' && + ts.isObjectLiteralExpression(prop.initializer) + ) { + // Look for table.defaultValue.summary + extractDefaultFromTable(prop.initializer, sourceFile, entry); + } + } +} + +function extractDefaultFromTable( + tableObj: ts.ObjectLiteralExpression, + sourceFile: ts.SourceFile, + entry: ArgTypeEntry, +): void { + for (const prop of tableObj.properties) { + if ( + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === 'defaultValue' && + ts.isObjectLiteralExpression(prop.initializer) + ) { + for (const dvProp of prop.initializer.properties) { + if ( + ts.isPropertyAssignment(dvProp) && + ts.isIdentifier(dvProp.name) && + dvProp.name.text === 'summary' + ) { + if (ts.isStringLiteral(dvProp.initializer)) { + entry.default = dvProp.initializer.text.trim(); + } else if (ts.isNoSubstitutionTemplateLiteral(dvProp.initializer)) { + entry.default = dvProp.initializer.text.trim(); + } + } + } + } + } +} + +export function extractArgTypesRegex(content: string): ArgTypeEntry[] { + const results: ArgTypeEntry[] = []; + + const argTypesMatch = content.match( + /argTypes:\s*\{([\s\S]*?)\n\s{2,4}\},?\s*\n/, + ); + if (!argTypesMatch) return results; + + const block = argTypesMatch[1]; + const entryRegex = /(\w+):\s*\{([\s\S]*?)\n\s{6,8}\},?/g; + let match: RegExpExecArray | null; + + while ((match = entryRegex.exec(block)) !== null) { + const name = match[1]; + const body = match[2]; + const entry: ArgTypeEntry = { name }; + + const optionsMatch = body.match(/options:\s*(\[.*?\]|[A-Z_]\w+)/); + if (optionsMatch) entry.options = optionsMatch[1]; + + const typeMatch = body.match(/type:\s*['"](\w+)['"]/); + if (typeMatch) entry.type = typeMatch[1]; + + const defaultMatch = body.match( + /defaultValue:\s*\{\s*summary:\s*['"]([^'"]*)['"]/, + ); + if (defaultMatch) entry.default = defaultMatch[1].trim(); + + const descMatch = body.match(/description:\s*['"]([^'"]*)['"]/); + if (descMatch) entry.description = descMatch[1]; + + // Handle multi-line description with string concatenation + if (!descMatch) { + const descMultiMatch = body.match( + /description:\s*\n?\s*['"]([^'"]+)['"]/, + ); + if (descMultiMatch) entry.description = descMultiMatch[1]; + } + + results.push(entry); + } + + return results; +} + +// ============================================================================ +// Story extraction +// ============================================================================ + +export function extractStoriesAST( + sourceFile: ts.SourceFile, + content: string, +): StoryEntry[] { + const stories: StoryEntry[] = []; + + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue; + + // Must be an export + const isExported = statement.modifiers?.some( + (m) => m.kind === ts.SyntaxKind.ExportKeyword, + ); + if (!isExported) continue; + + for (const decl of statement.declarationList.declarations) { + if (!ts.isIdentifier(decl.name)) continue; + + // Check type annotation is Story or StoryObj<...> + if (!decl.type) continue; + const typeText = decl.type.getText(sourceFile); + if (typeText !== 'Story' && !typeText.startsWith('StoryObj')) continue; + + const name = decl.name.text; + if (!decl.initializer || !ts.isObjectLiteralExpression(decl.initializer)) + continue; + + const storyObj = decl.initializer; + const tags = extractTagsAST(storyObj, sourceFile); + const hasPlayFn = detectPlayFnAST(storyObj); + const argsOverrides = extractStoryArgsAST(storyObj, sourceFile); + const template = extractTemplateFromStoryAST(storyObj, content); + const storyLevelArgs = extractStoryLevelArgTypesAST(storyObj, sourceFile); + const displayName = extractDisplayNameAST(storyObj, sourceFile); + + const entry: StoryEntry = { + name, + template: template ? cleanTemplate(template) : null, + tags, + hasPlayFn, + argsOverrides, + }; + if (displayName) { + entry.displayName = displayName; + } + if (storyLevelArgs.length > 0) { + entry.storyLevelArgs = storyLevelArgs; + } + stories.push(entry); + } + } + + return stories; +} + +function extractTagsAST( + obj: ts.ObjectLiteralExpression, + _sourceFile: ts.SourceFile, +): string[] { + for (const prop of obj.properties) { + if ( + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === 'tags' && + ts.isArrayLiteralExpression(prop.initializer) + ) { + return prop.initializer.elements + .filter(ts.isStringLiteral) + .map((el) => el.text); + } + } + return []; +} + +/** + * Extract display name from story's top-level `name` property or `parameters.name`. + */ +function extractDisplayNameAST( + obj: ts.ObjectLiteralExpression, + _sourceFile: ts.SourceFile, +): string | null { + for (const prop of obj.properties) { + if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { + // Top-level name: 'Custom Toggle' + if (prop.name.text === 'name' && ts.isStringLiteral(prop.initializer)) { + return prop.initializer.text; + } + // parameters.name + if ( + prop.name.text === 'parameters' && + ts.isObjectLiteralExpression(prop.initializer) + ) { + for (const paramProp of prop.initializer.properties) { + if ( + ts.isPropertyAssignment(paramProp) && + ts.isIdentifier(paramProp.name) && + paramProp.name.text === 'name' && + ts.isStringLiteral(paramProp.initializer) + ) { + return paramProp.initializer.text; + } + } + } + } + } + return null; +} + +function detectPlayFnAST(obj: ts.ObjectLiteralExpression): boolean { + for (const prop of obj.properties) { + if (ts.isPropertyAssignment(prop) || ts.isMethodDeclaration(prop)) { + if (ts.isIdentifier(prop.name) && prop.name.text === 'play') { + return true; + } + } + } + return false; +} + +function extractStoryArgsAST( + obj: ts.ObjectLiteralExpression, + sourceFile: ts.SourceFile, +): ArgsOverride[] { + const overrides: ArgsOverride[] = []; + + for (const prop of obj.properties) { + if ( + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === 'args' && + ts.isObjectLiteralExpression(prop.initializer) + ) { + for (const argProp of prop.initializer.properties) { + // Skip spread elements + if (ts.isSpreadAssignment(argProp)) continue; + + if (ts.isPropertyAssignment(argProp) && ts.isIdentifier(argProp.name)) { + overrides.push({ + name: argProp.name.text, + value: argProp.initializer.getText(sourceFile).replace(/,\s*$/, ''), + }); + } + if (ts.isShorthandPropertyAssignment(argProp)) { + overrides.push({ + name: argProp.name.text, + value: argProp.name.text, + }); + } + } + } + } + + return overrides; +} + +/** + * Extract story-level argTypes that are not in the meta block. + * These represent inputs only defined at the story level (e.g., TruncateTextButton's `truncate`). + * Filters out entries that only override display settings (e.g., table: { disable: true }). + */ +function extractStoryLevelArgTypesAST( + storyObj: ts.ObjectLiteralExpression, + sourceFile: ts.SourceFile, +): ArgTypeEntry[] { + for (const prop of storyObj.properties) { + if ( + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === 'argTypes' && + ts.isObjectLiteralExpression(prop.initializer) + ) { + const entries = extractArgTypeEntries(prop.initializer, sourceFile); + // Only keep entries with meaningful content (not just display overrides) + return entries.filter( + (e) => e.type || e.options || e.default || e.description, + ); + } + } + return []; +} + +/** + * Extract template from a story's render function. + * Falls back to raw text extraction for complex cases like ternary expressions. + */ +function extractTemplateFromStoryAST( + storyObj: ts.ObjectLiteralExpression, + content: string, +): string | null { + // Get the raw text of the story object for ternary/template literal extraction + const storyText = storyObj.getText(); + + // Try ternary else-branch first + const ternaryElse = extractTernaryElseBranch(storyText); + if (ternaryElse) return ternaryElse; + + // Find render function and extract template + for (const prop of storyObj.properties) { + if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { + if (prop.name.text === 'render') { + return extractTemplateFromRenderNode(prop.initializer, content); + } + } + if (ts.isMethodDeclaration(prop) && ts.isIdentifier(prop.name)) { + if (prop.name.text === 'render') { + return extractTemplateFromMethodBody(prop, content); + } + } + } + + return null; +} + +function extractTemplateFromRenderNode( + node: ts.Node, + content: string, +): string | null { + // Walk the AST to find a property assignment with name "template" + let result: string | null = null; + + const visit = (n: ts.Node): void => { + if (result) return; + + if ( + ts.isPropertyAssignment(n) && + ts.isIdentifier(n.name) && + n.name.text === 'template' + ) { + if (ts.isNoSubstitutionTemplateLiteral(n.initializer)) { + result = n.initializer.text; + } else if (ts.isTemplateExpression(n.initializer)) { + // Template with interpolations โ€” extract raw text from source + const start = n.initializer.getStart() + 1; // skip opening backtick + const end = n.initializer.getEnd() - 1; // skip closing backtick + result = content.slice(start, end); + } else if (ts.isStringLiteral(n.initializer)) { + result = n.initializer.text; + } + } + + ts.forEachChild(n, visit); + }; + + visit(node); + return result; +} + +function extractTemplateFromMethodBody( + method: ts.MethodDeclaration, + content: string, +): string | null { + if (!method.body) return null; + return extractTemplateFromRenderNode(method.body, content); +} + +export function extractStoriesRegex(content: string): StoryEntry[] { + const stories: StoryEntry[] = []; + const exportRegex = + /export\s+const\s+(\w+):\s*(?:Story|StoryObj<[^>]+>)\s*=\s*\{/g; + let match: RegExpExecArray | null; + + while ((match = exportRegex.exec(content)) !== null) { + const name = match[1]; + const startIdx = match.index + match[0].length; + const storyBody = extractBalancedBlock(content, startIdx - 1); + if (!storyBody) continue; + + const template = extractTemplateRegex(storyBody); + + const tagsMatch = storyBody.match(/tags:\s*\[([^\]]*)\]/); + const tags = tagsMatch + ? tagsMatch[1] + .replace(/['"]/g, '') + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + : []; + + const hasPlayFn = /play:\s*async/.test(storyBody); + const argsOverrides = extractStoryArgsRegex(storyBody); + + stories.push({ + name, + template: template ? cleanTemplate(template) : null, + tags, + hasPlayFn, + argsOverrides, + }); + } + + return stories; +} + +function extractTemplateRegex(storyBody: string): string | null { + const ternaryElse = extractTernaryElseBranch(storyBody); + if (ternaryElse) return ternaryElse; + + const templateIdx = storyBody.indexOf('template:'); + if (templateIdx === -1) return null; + + let i = templateIdx + 'template:'.length; + while (i < storyBody.length && storyBody[i] !== '`' && storyBody[i] !== "'") + i++; + if (i >= storyBody.length) return null; + + const quote = storyBody[i]; + if (quote === '`') { + return extractTemplateLiteral(storyBody, i); + } + const end = storyBody.indexOf("'", i + 1); + if (end === -1) return null; + return storyBody.slice(i + 1, end); +} + +function extractStoryArgsRegex(storyBody: string): ArgsOverride[] { + const argsMatch = storyBody.match(/\n\s+args:\s*\{([^}]*)\}/); + if (!argsMatch) return []; + + const pairs: ArgsOverride[] = []; + const body = argsMatch[1]; + for (const line of body.split('\n')) { + if (/\.\.\./.test(line)) continue; + const pairMatch = line.match(/^\s*(\w+):\s*(.+?),?\s*$/); + if (pairMatch) { + pairs.push({ + name: pairMatch[1], + value: pairMatch[2].replace(/,\s*$/, ''), + }); + } + } + return pairs; +} + +// ============================================================================ +// Template utilities +// ============================================================================ + +/** + * Extract the else-branch from a ternary inside a template literal. + * Handles "unsupported combination" ternary guards. + */ +export function extractTernaryElseBranch(storyBody: string): string | null { + const markers = [ + 'isUnsupported', + 'isUnsupportedCombination', + 'Not Supported', + ]; + const hasMarker = markers.some((m) => storyBody.includes(m)); + if (!hasMarker) return null; + + // Find the last `: ` followed by a backtick after a template context + const colonIdx = storyBody.lastIndexOf(': `'); + if (colonIdx === -1) return null; + + const templateIdx = storyBody.indexOf('template:'); + if (templateIdx === -1 || colonIdx < templateIdx) return null; + + const backtickStart = storyBody.indexOf('`', colonIdx + 1); + if (backtickStart === -1) return null; + + return extractTemplateLiteral(storyBody, backtickStart); +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/tools.ts b/packages/angular-mcp-server/src/lib/tools/ds/tools.ts index eb61c4f..c79bd99 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/tools.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/tools.ts @@ -15,6 +15,7 @@ import { diffComponentContractTools, listComponentContractsTools, } from './component-contract/index.js'; +import { getDsStoryDataTools } from './story-parser/index.js'; export const dsTools: ToolsConfig[] = [ // Project tools @@ -32,4 +33,6 @@ export const dsTools: ToolsConfig[] = [ ...getDsComponentDataTools, ...listDsComponentsTools, ...getDeprecatedCssClassesTools, + // Story parser tools + ...getDsStoryDataTools, ]; diff --git a/packages/shared/typescript-ast-utils/src/index.ts b/packages/shared/typescript-ast-utils/src/index.ts index 6012acc..69ad886 100644 --- a/packages/shared/typescript-ast-utils/src/index.ts +++ b/packages/shared/typescript-ast-utils/src/index.ts @@ -1,5 +1,10 @@ export * from './lib/constants.js'; export * from './lib/utils.js'; +export * from './lib/source-text.utils.js'; export { removeQuotes } from './lib/utils.js'; export { QUOTE_REGEX } from './lib/constants.js'; +export { + extractTemplateLiteral, + extractBalancedBlock, +} from './lib/source-text.utils.js'; diff --git a/packages/shared/typescript-ast-utils/src/lib/__tests__/source-text.utils.spec.ts b/packages/shared/typescript-ast-utils/src/lib/__tests__/source-text.utils.spec.ts new file mode 100644 index 0000000..61eedd0 --- /dev/null +++ b/packages/shared/typescript-ast-utils/src/lib/__tests__/source-text.utils.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { + extractTemplateLiteral, + extractBalancedBlock, +} from '../source-text.utils.js'; + +describe('extractTemplateLiteral', () => { + it('should extract a simple template literal', () => { + const source = '`hello world`'; + expect(extractTemplateLiteral(source, 0)).toBe('hello world'); + }); + + it('should return empty string for an empty but terminated template', () => { + const source = '``'; + expect(extractTemplateLiteral(source, 0)).toBe(''); + }); + + it('should return null for an unterminated template literal', () => { + const source = '`hello world'; + expect(extractTemplateLiteral(source, 0)).toBeNull(); + }); + + it('should return null for unterminated template with content', () => { + // Previously this returned the partial content "some partial content" + const source = '`some partial content'; + expect(extractTemplateLiteral(source, 0)).toBeNull(); + }); + + it('should handle template literals with interpolations', () => { + const source = '`hello ${name} world`'; + expect(extractTemplateLiteral(source, 0)).toBe('hello ${name} world'); + }); + + it('should handle nested template literals in expressions', () => { + const source = '`outer ${`inner`} end`'; + expect(extractTemplateLiteral(source, 0)).toBe('outer ${} end'); + }); +}); + +describe('extractBalancedBlock', () => { + it('should extract a balanced block', () => { + const source = '{ a: 1 }'; + expect(extractBalancedBlock(source, 0)).toBe('{ a: 1 }'); + }); + + it('should return null for unbalanced block', () => { + const source = '{ a: 1'; + expect(extractBalancedBlock(source, 0)).toBeNull(); + }); + + it('should return null when no opening brace found', () => { + const source = 'no braces here'; + expect(extractBalancedBlock(source, 0)).toBeNull(); + }); +}); diff --git a/packages/shared/typescript-ast-utils/src/lib/source-text.utils.ts b/packages/shared/typescript-ast-utils/src/lib/source-text.utils.ts new file mode 100644 index 0000000..3a50603 --- /dev/null +++ b/packages/shared/typescript-ast-utils/src/lib/source-text.utils.ts @@ -0,0 +1,141 @@ +/** + * Utilities for parsing raw TypeScript/JavaScript source text. + * These operate on string content rather than the TypeScript AST, + * handling constructs like template literals and balanced blocks. + */ + +/** + * Extract the content of a template literal starting at the opening backtick. + * Handles nested `${}` expressions, inner backtick strings, and quoted strings. + * + * @param source - The full source text + * @param openIdx - Index of the opening backtick character + * @returns The template literal content (without backticks), or null if unterminated + */ +export function extractTemplateLiteral( + source: string, + openIdx: number, +): string | null { + let i = openIdx + 1; // skip opening backtick + let depth = 0; + let result = ''; + + while (i < source.length) { + if (depth === 0 && source[i] === '`') { + return result; + } + + if (source[i] === '$' && i + 1 < source.length && source[i + 1] === '{') { + depth++; + result += '${'; + i += 2; + continue; + } + + if (depth > 0 && source[i] === '{') { + depth++; + } + + if (depth > 0 && source[i] === '}') { + depth--; + if (depth === 0) { + result += '}'; + i++; + continue; + } + } + + // Inside a nested expression, skip inner backtick strings + if (depth > 0 && source[i] === '`') { + i++; + let innerDepth = 0; + while (i < source.length) { + if (innerDepth === 0 && source[i] === '`') { + i++; + break; + } + if ( + source[i] === '$' && + i + 1 < source.length && + source[i + 1] === '{' + ) { + innerDepth++; + i += 2; + continue; + } + if (innerDepth > 0 && source[i] === '}') { + innerDepth--; + i++; + continue; + } + if (source[i] === '\\') i++; + i++; + } + continue; + } + + // Inside a nested expression, skip quoted strings + if (depth > 0 && (source[i] === "'" || source[i] === '"')) { + const q = source[i]; + i++; + while (i < source.length && source[i] !== q) { + if (source[i] === '\\') i++; + i++; + } + i++; // skip closing quote + continue; + } + + result += source[i]; + i++; + } + + // Unterminated template literal โ€” no closing backtick found + return null; +} + +/** + * Extract a balanced `{ }` block from source text starting at or after the given position. + * Correctly skips over string literals (backtick, single-quote, double-quote) to avoid + * miscounting braces inside strings. + * + * @param source - The full source text + * @param openIdx - Index at or before the opening `{` + * @returns The balanced block including braces, or null if not found/unbalanced + */ +export function extractBalancedBlock( + source: string, + openIdx: number, +): string | null { + let depth = 0; + let i = openIdx; + + while (i < source.length && source[i] !== '{') i++; + if (i >= source.length) return null; + + const start = i; + for (; i < source.length; i++) { + if (source[i] === '{') depth++; + else if (source[i] === '}') { + depth--; + if (depth === 0) return source.slice(start, i + 1); + } + + // Skip string literals + if (source[i] === '`') { + i++; + while (i < source.length && source[i] !== '`') { + if (source[i] === '\\') i++; + i++; + } + } else if (source[i] === "'" || source[i] === '"') { + const quote = source[i]; + i++; + while (i < source.length && source[i] !== quote) { + if (source[i] === '\\') i++; + i++; + } + } + } + return null; +} diff --git a/packages/shared/utils/src/index.ts b/packages/shared/utils/src/index.ts index 4a73c32..2340b7c 100644 --- a/packages/shared/utils/src/index.ts +++ b/packages/shared/utils/src/index.ts @@ -1,6 +1,7 @@ export * from './lib/utils.js'; export * from './lib/execute-process.js'; export * from './lib/logging.js'; +export * from './lib/regex.utils.js'; export * from './lib/file/find-in-file.js'; export * from './lib/file/file.resolver.js'; export * from './lib/file/default-export-loader.js'; diff --git a/packages/shared/utils/src/lib/regex.utils.ts b/packages/shared/utils/src/lib/regex.utils.ts new file mode 100644 index 0000000..c6f7473 --- /dev/null +++ b/packages/shared/utils/src/lib/regex.utils.ts @@ -0,0 +1,9 @@ +/** + * Escapes special regex characters in a string for safe use in RegExp constructors. + * + * @param str - The string to escape + * @returns The escaped string safe for use in `new RegExp()` + */ +export function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +}