From 560dae0aae034e223b3ad0c63619ec01cf51b4e7 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 9 Apr 2026 12:43:00 +0300 Subject: [PATCH 1/7] feat: storybook parser --- docs/tools.md | 26 +- .../ds/story-parser/get-ds-story-data.tool.ts | 157 +++ .../src/lib/tools/ds/story-parser/index.ts | 1 + .../lib/tools/ds/story-parser/models/types.ts | 50 + .../__tests__/story-parser.utils.spec.ts | 559 ++++++++ .../utils/story-markdown-formatter.utils.ts | 114 ++ .../story-parser/utils/story-parser.utils.ts | 1152 +++++++++++++++++ .../src/lib/tools/ds/tools.ts | 3 + 8 files changed, 2058 insertions(+), 4 deletions(-) create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/story-parser/get-ds-story-data.tool.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/story-parser/index.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/story-parser/models/types.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/story-parser/utils/__tests__/story-parser.utils.spec.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/story-parser/utils/story-markdown-formatter.utils.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/story-parser/utils/story-parser.utils.ts 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/story-parser/get-ds-story-data.tool.ts b/packages/angular-mcp-server/src/lib/tools/ds/story-parser/get-ds-story-data.tool.ts new file mode 100644 index 0000000..8b391af --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/story-parser/get-ds-story-data.tool.ts @@ -0,0 +1,157 @@ +import { ToolSchemaOptions } from '@push-based/models'; +import { + createHandler, + BaseHandlerOptions, + RESULT_FORMATTERS, +} from '../shared/utils/handler-helpers.js'; +import { COMMON_ANNOTATIONS } from '../shared/models/schema-helpers.js'; +import { + validateComponentName, + componentNameToKebabCase, +} from '../shared/utils/component-validation.js'; +import { resolveCrossPlatformPath } from '../shared/utils/cross-platform-path.js'; +import { parseStoryFile } from './utils/story-parser.utils.js'; +import { formatStoryDataAsMarkdown } from './utils/story-markdown-formatter.utils.js'; +import * as fs from 'fs'; +import * as path from 'path'; + +interface GetDsStoryDataOptions extends BaseHandlerOptions { + componentName: string; + format?: 'markdown' | 'json'; + descriptionLength?: 'short' | 'full'; + excludeTags?: string[]; +} + +export const getDsStoryDataToolSchema: ToolSchemaOptions = { + name: 'get-ds-story-data', + description: + 'Parse Storybook .stories.ts files for a DS component and return structured data with imports, argTypes, meta args, slots, selector style, form integration, and story definitions.', + inputSchema: { + type: 'object', + properties: { + componentName: { + type: 'string', + description: + 'The class name of the DS component (e.g., DsButton, DsBadge)', + }, + format: { + type: 'string', + enum: ['markdown', 'json'], + description: + 'Output format. "markdown" (default) returns human-readable markdown, "json" returns structured JSON.', + default: 'markdown', + }, + descriptionLength: { + type: 'string', + enum: ['short', 'full'], + description: + 'Description verbosity. "short" (default) truncates to first sentence, "full" returns complete descriptions.', + default: 'short', + }, + excludeTags: { + type: 'array', + items: { type: 'string' }, + description: + 'Story tags to exclude from output. Defaults to ["docs-template"] to filter showcase stories. Pass empty array [] to include all.', + default: ['docs-template'], + }, + }, + required: ['componentName'], + }, + annotations: { + title: 'Get Design System Story Data', + ...COMMON_ANNOTATIONS.readOnly, + }, +}; + +/** + * Finds all .stories.ts files in a component directory. + */ +function findStoryFiles(componentDir: string): string[] { + const files: string[] = []; + try { + if (!fs.existsSync(componentDir)) { + return files; + } + const items = fs.readdirSync(componentDir); + for (const item of items) { + const fullPath = path.join(componentDir, item); + const stat = fs.statSync(fullPath); + if (stat.isFile() && item.endsWith('.stories.ts')) { + files.push(fullPath); + } + } + } catch { + // Return empty on any fs error + } + return files; +} + +export const getDsStoryDataHandler = createHandler< + GetDsStoryDataOptions, + string +>( + getDsStoryDataToolSchema.name, + async ({ componentName, format = 'markdown', descriptionLength = 'short', excludeTags = ['docs-template'] }, { cwd, storybookDocsRoot }) => { + validateComponentName(componentName); + + if (!storybookDocsRoot) { + throw new Error( + 'Storybook docs root is not configured. Cannot resolve story files.', + ); + } + + const kebabName = componentNameToKebabCase(componentName); + const docsBasePath = resolveCrossPlatformPath(cwd, storybookDocsRoot); + const componentDir = path.join(docsBasePath, kebabName); + const storyFiles = findStoryFiles(componentDir); + + if (storyFiles.length === 0) { + throw new Error( + `No story file found for component \`${componentName}\` in ${componentDir}`, + ); + } + + // Parse the first story file found + const storyFilePath = storyFiles[0]; + const content = fs.readFileSync(storyFilePath, 'utf-8'); + const data = parseStoryFile(content, storyFilePath, kebabName); + + // Filter stories by excluded tags + if (excludeTags.length > 0) { + const excludeSet = new Set(excludeTags); + data.stories = data.stories.filter( + (s) => !s.tags.some((t) => excludeSet.has(t)), + ); + } + + // Truncate descriptions if short mode + if (descriptionLength === 'short') { + for (const at of data.argTypes) { + if (at.description) { + at.description = truncateToFirstSentence(at.description); + } + } + } + + return format === 'json' + ? JSON.stringify(data, null, 2) + : formatStoryDataAsMarkdown(data); + }, + RESULT_FORMATTERS.success, +); + +export const getDsStoryDataTools = [ + { + schema: getDsStoryDataToolSchema, + handler: getDsStoryDataHandler, + }, +]; + +/** + * Truncate a description to its first sentence. + */ +function truncateToFirstSentence(text: string): string { + const match = text.match(/^[^.!]+[.!]/); + return match ? match[0].trim() : text; +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/story-parser/index.ts b/packages/angular-mcp-server/src/lib/tools/ds/story-parser/index.ts new file mode 100644 index 0000000..f6d262d --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/story-parser/index.ts @@ -0,0 +1 @@ +export { getDsStoryDataTools } from './get-ds-story-data.tool.js'; diff --git a/packages/angular-mcp-server/src/lib/tools/ds/story-parser/models/types.ts b/packages/angular-mcp-server/src/lib/tools/ds/story-parser/models/types.ts new file mode 100644 index 0000000..0a00adf --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/story-parser/models/types.ts @@ -0,0 +1,50 @@ +// ============================================================================ +// get-ds-story-data types +// ============================================================================ + +export interface SelectorInfo { + style: 'element' | 'attribute' | 'unknown'; + selector: string; + note?: string; +} + +export interface ArgTypeEntry { + name: string; + options?: string; + type?: string; + default?: string; + description?: string; +} + +export interface MetaArg { + name: string; + default: string; +} + +export interface ArgsOverride { + name: string; + value: string; +} + +export interface StoryEntry { + name: string; + displayName?: string; + template: string | null; + tags: string[]; + hasPlayFn: boolean; + argsOverrides: ArgsOverride[]; + storyLevelArgs?: ArgTypeEntry[]; +} + +export interface StoryFileData { + componentName: string; + filePath: string; + selector: SelectorInfo; + imports: string[]; + argTypes: ArgTypeEntry[]; + metaArgs: MetaArg[] | null; + metaTags: string[]; + slots: string[]; + formIntegration: string[]; + stories: StoryEntry[]; +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/story-parser/utils/__tests__/story-parser.utils.spec.ts b/packages/angular-mcp-server/src/lib/tools/ds/story-parser/utils/__tests__/story-parser.utils.spec.ts new file mode 100644 index 0000000..288ee8e --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/story-parser/utils/__tests__/story-parser.utils.spec.ts @@ -0,0 +1,559 @@ +import { describe, it, expect } from 'vitest'; +import { + parseStoryFile, + extractImportsAST, + extractImportsRegex, + extractMetaArgsAST, + extractMetaArgsRegex, + extractArgTypesAST, + extractArgTypesRegex, + extractStoriesAST, + extractStoriesRegex, + extractSlotsFromTemplates, + detectSelectorStyle, + detectFormIntegration, + cleanTemplate, + extractTernaryElseBranch, +} from '../story-parser.utils.js'; +import * as ts from 'typescript'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createSourceFile(content: string): ts.SourceFile { + return ts.createSourceFile('test.stories.ts', content, ts.ScriptTarget.Latest, true); +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const BADGE_STORY = ` +import { DsBadge, DS_BADGE_SIZE_ARRAY, DS_BADGE_VARIANT_ARRAY } from '@frontend/ui/badge'; +import { DsNotificationBubble } from '@frontend/ui/notification-bubble'; +import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; +import { expect } from '@storybook/test'; +import { lodashHelper } from 'lodash'; + +type DsBadgeStoryType = DsBadge & { label: string }; + +const meta: Meta = { + title: 'Components/Badge', + component: DsBadge, + argTypes: { + label: { + type: 'string', + table: { defaultValue: { summary: 'Label' } }, + description: 'The text of the badge', + }, + size: { + options: DS_BADGE_SIZE_ARRAY, + table: { defaultValue: { summary: 'medium' } }, + description: 'The size of the badge', + }, + variant: { + options: DS_BADGE_VARIANT_ARRAY, + table: { defaultValue: { summary: 'primary' } }, + description: 'The variant of the badge', + }, + }, + args: { + label: 'Label', + size: 'medium', + variant: 'primary', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (badge) => ({ + template: \` + + \${badge.label} + + \`, + }), +}; + +export const WithIcon: Story = { + tags: ['icon-variant'], + args: { + ...meta.args, + size: 'large', + }, + render: (badge) => ({ + template: \`โ˜…\${badge.label}\`, + }), +}; +`; + +const FORM_INTEGRATION_STORY = ` +import { DsInput } from '@frontend/ui/input'; +import { Meta, StoryObj } from '@storybook/angular'; + +const meta: Meta = { + title: 'Components/Input', + component: DsInput, +}; + +export default meta; + +export const WithNgModel: Story = { + render: () => ({ + template: \`\`, + }), +}; + +export const WithReactiveForms: Story = { + render: () => ({ + template: \`\`, + }), +}; + +export const WithFormControl: Story = { + render: () => ({ + template: \`\`, + }), +}; +`; + +const PLAY_FN_STORY = ` +import { DsSegmentedControl } from '@frontend/ui/segmented-control'; +import { Meta, StoryObj } from '@storybook/angular'; + +const meta: Meta = { + title: 'Components/Segmented Control', +}; + +export default meta; + +export const Default: StoryObj = { + render: () => ({ + template: \`\`, + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole('tab')).toBeTruthy(); + }, +}; +`; + +const EMPTY_META_STORY = ` +import { DsCard } from '@frontend/ui/card'; +import { Meta, StoryObj } from '@storybook/angular'; + +const meta: Meta = { + title: 'Components/Card', +}; + +export default meta; + +export const Default: Story = { + render: () => ({ + template: \`Content\`, + }), +}; +`; + +const MULTILINE_IMPORT_STORY = ` +import { + DsBadge, + DS_BADGE_SIZE_ARRAY, + DS_BADGE_VARIANT_ARRAY +} from '@frontend/ui/badge'; +import { Meta, StoryObj } from '@storybook/angular'; + +const meta: Meta = { title: 'Test' }; +export default meta; +`; + +// --------------------------------------------------------------------------- +// Tests: parseStoryFile (integration) +// --------------------------------------------------------------------------- + +describe('parseStoryFile', () => { + it('should parse a complete story file and return all fields', () => { + const result = parseStoryFile(BADGE_STORY, 'badge.stories.ts', 'badge'); + + expect(result.componentName).toBe('badge'); + expect(result.filePath).toBe('badge.stories.ts'); + expect(result.selector.style).toBe('element'); + expect(result.selector.selector).toBe('ds-badge'); + expect(result.imports.length).toBe(2); + expect(result.argTypes.length).toBe(3); + expect(result.metaArgs).not.toBeNull(); + expect(result.metaArgs!.length).toBe(3); + expect(result.stories.length).toBe(2); + expect(result.slots).toEqual(['icon']); + }); + + it('should return empty arrays and null metaArgs for minimal story', () => { + const result = parseStoryFile(EMPTY_META_STORY, 'card.stories.ts', 'card'); + + expect(result.argTypes).toEqual([]); + expect(result.metaArgs).toBeNull(); + expect(result.formIntegration).toEqual([]); + expect(result.stories.length).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Import extraction +// --------------------------------------------------------------------------- + +describe('extractImportsAST', () => { + it('should filter to only @frontend/ and @design-system/ imports', () => { + const sf = createSourceFile(BADGE_STORY); + const imports = extractImportsAST(sf); + + expect(imports.length).toBe(2); + expect(imports.every(i => i.includes('@frontend/') || i.includes('@design-system/'))).toBe(true); + expect(imports.some(i => i.includes('@storybook/'))).toBe(false); + expect(imports.some(i => i.includes('lodash'))).toBe(false); + }); + + it('should normalize multi-line imports to single line', () => { + const sf = createSourceFile(MULTILINE_IMPORT_STORY); + const imports = extractImportsAST(sf); + + expect(imports.length).toBe(1); + expect(imports[0]).not.toContain('\n'); + expect(imports[0]).toContain('DsBadge'); + expect(imports[0]).toContain('DS_BADGE_SIZE_ARRAY'); + }); +}); + +describe('extractImportsRegex', () => { + it('should filter to only @frontend/ and @design-system/ imports', () => { + const imports = extractImportsRegex(BADGE_STORY); + + expect(imports.length).toBe(2); + expect(imports.every(i => i.includes('@frontend/') || i.includes('@design-system/'))).toBe(true); + }); + + it('should normalize multi-line imports', () => { + const imports = extractImportsRegex(MULTILINE_IMPORT_STORY); + + expect(imports.length).toBe(1); + expect(imports[0]).not.toContain('\n'); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Meta args extraction +// --------------------------------------------------------------------------- + +describe('extractMetaArgsAST', () => { + it('should extract meta args key-value pairs', () => { + const sf = createSourceFile(BADGE_STORY); + const args = extractMetaArgsAST(sf); + + expect(args).not.toBeNull(); + expect(args!.length).toBe(3); + expect(args!.find(a => a.name === 'label')?.default).toBe("'Label'"); + expect(args!.find(a => a.name === 'size')?.default).toBe("'medium'"); + expect(args!.find(a => a.name === 'variant')?.default).toBe("'primary'"); + }); + + it('should return null when no args exist', () => { + const sf = createSourceFile(EMPTY_META_STORY); + const args = extractMetaArgsAST(sf); + + expect(args).toBeNull(); + }); +}); + +describe('extractMetaArgsRegex', () => { + it('should extract meta args key-value pairs', () => { + const args = extractMetaArgsRegex(BADGE_STORY); + + expect(args).not.toBeNull(); + expect(args!.length).toBe(3); + expect(args!.find(a => a.name === 'label')?.default).toBe("'Label'"); + }); + + it('should return null when no args exist', () => { + const args = extractMetaArgsRegex(EMPTY_META_STORY); + expect(args).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: ArgTypes extraction +// --------------------------------------------------------------------------- + +describe('extractArgTypesAST', () => { + it('should extract argType entries with all fields', () => { + const sf = createSourceFile(BADGE_STORY); + const argTypes = extractArgTypesAST(sf); + + expect(argTypes.length).toBe(3); + + const label = argTypes.find(a => a.name === 'label')!; + expect(label.type).toBe('string'); + expect(label.default).toBe('Label'); + expect(label.description).toBe('The text of the badge'); + + const size = argTypes.find(a => a.name === 'size')!; + expect(size.options).toBe('DS_BADGE_SIZE_ARRAY'); + expect(size.default).toBe('medium'); + }); + + it('should return empty array when no argTypes exist', () => { + const sf = createSourceFile(EMPTY_META_STORY); + expect(extractArgTypesAST(sf)).toEqual([]); + }); +}); + +describe('extractArgTypesRegex', () => { + // The regex path uses indentation-sensitive patterns matching real story file formatting. + // argTypes closing } at 2-4 spaces, entry closing } at 6-8 spaces. + const REGEX_FORMATTED_ARGTYPES = +`const meta = { + argTypes: { + label: { + type: 'string', + table: { defaultValue: { summary: 'Label' } }, + description: 'The text of the badge', + }, + size: { + options: DS_BADGE_SIZE_ARRAY, + table: { defaultValue: { summary: 'medium' } }, + description: 'The size of the badge', + }, + }, +}; +export default meta; +`; + + it('should extract argType entries', () => { + const argTypes = extractArgTypesRegex(REGEX_FORMATTED_ARGTYPES); + expect(argTypes.length).toBe(2); + expect(argTypes.some(a => a.name === 'label')).toBe(true); + expect(argTypes.find(a => a.name === 'label')?.type).toBe('string'); + expect(argTypes.find(a => a.name === 'label')?.default).toBe('Label'); + expect(argTypes.find(a => a.name === 'size')?.options).toBe('DS_BADGE_SIZE_ARRAY'); + }); + + it('should return empty array when no argTypes exist', () => { + expect(extractArgTypesRegex(EMPTY_META_STORY)).toEqual([]); + }); +}); + + +// --------------------------------------------------------------------------- +// Tests: Story extraction +// --------------------------------------------------------------------------- + +describe('extractStoriesAST', () => { + it('should extract all exported stories', () => { + const sf = createSourceFile(BADGE_STORY); + const stories = extractStoriesAST(sf, BADGE_STORY); + + expect(stories.length).toBe(2); + expect(stories[0].name).toBe('Default'); + expect(stories[1].name).toBe('WithIcon'); + }); + + it('should extract tags from stories', () => { + const sf = createSourceFile(BADGE_STORY); + const stories = extractStoriesAST(sf, BADGE_STORY); + + expect(stories[0].tags).toEqual([]); + expect(stories[1].tags).toEqual(['icon-variant']); + }); + + it('should extract args overrides excluding spreads', () => { + const sf = createSourceFile(BADGE_STORY); + const stories = extractStoriesAST(sf, BADGE_STORY); + + // WithIcon has ...meta.args (spread, excluded) and size: 'large' (included) + const withIcon = stories[1]; + expect(withIcon.argsOverrides.some(a => a.name === 'size')).toBe(true); + expect(withIcon.argsOverrides.every(a => !a.name.includes('...'))).toBe(true); + }); + + it('should detect play function', () => { + const sf = createSourceFile(PLAY_FN_STORY); + const stories = extractStoriesAST(sf, PLAY_FN_STORY); + + expect(stories[0].hasPlayFn).toBe(true); + }); + + it('should extract and clean templates', () => { + const sf = createSourceFile(BADGE_STORY); + const stories = extractStoriesAST(sf, BADGE_STORY); + + expect(stories[0].template).not.toBeNull(); + expect(stories[0].template).toContain('ds-badge'); + // ${...} should be replaced with ... + expect(stories[0].template).not.toContain('${'); + }); +}); + +describe('extractStoriesRegex', () => { + it('should extract all exported stories', () => { + const stories = extractStoriesRegex(BADGE_STORY); + + expect(stories.length).toBe(2); + expect(stories[0].name).toBe('Default'); + expect(stories[1].name).toBe('WithIcon'); + }); + + it('should extract tags', () => { + const stories = extractStoriesRegex(BADGE_STORY); + expect(stories[1].tags).toEqual(['icon-variant']); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Slot extraction +// --------------------------------------------------------------------------- + +describe('extractSlotsFromTemplates', () => { + it('should extract unique sorted slot names', () => { + const templates = [ + 'โ˜…โ†’', + 'โ˜…โ†', + ]; + expect(extractSlotsFromTemplates(templates)).toEqual(['icon', 'prefix', 'suffix']); + }); + + it('should return empty array when no slots found', () => { + expect(extractSlotsFromTemplates(['
no slots
'])).toEqual([]); + }); + + it('should handle empty templates array', () => { + expect(extractSlotsFromTemplates([])).toEqual([]); + }); + + it('should deduplicate slot names', () => { + const templates = [ + '12', + ]; + expect(extractSlotsFromTemplates(templates)).toEqual(['icon']); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Selector style detection +// --------------------------------------------------------------------------- + +describe('detectSelectorStyle', () => { + it('should detect element-style selector', () => { + const result = detectSelectorStyle('', 'badge'); + expect(result.style).toBe('element'); + expect(result.selector).toBe('ds-badge'); + }); + + it('should detect attribute-style selector', () => { + const result = detectSelectorStyle('', '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) +// --------------------------------------------------------------------------- + +describe('parseStoryFile regex fallback', () => { + it('should still produce output for content that AST can parse', () => { + // Even though AST can parse this, verify the regex path works too + 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..03ce107 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/story-parser/utils/story-markdown-formatter.utils.ts @@ -0,0 +1,114 @@ +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..65de3b6 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/story-parser/utils/story-parser.utils.ts @@ -0,0 +1,1152 @@ +import * as ts from 'typescript'; +import { + StoryFileData, + SelectorInfo, + ArgTypeEntry, + MetaArg, + StoryEntry, + ArgsOverride, +} from '../models/types.js'; + +// ============================================================================ +// Main entry point +// ============================================================================ + +/** + * Parse a Storybook .stories.ts file into structured JSON. + * Uses TypeScript AST as primary extraction with regex fallback. + */ +export function parseStoryFile( + content: string, + filePath: string, + kebabName: string, +): StoryFileData { + try { + const sourceFile = ts.createSourceFile( + filePath, + content, + ts.ScriptTarget.Latest, + true, + ); + return extractFromAST(sourceFile, content, filePath, kebabName); + } catch { + return extractWithRegex(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)) { + at.default = metaMap.get(at.name)!.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); +} + +/** + * Extract content of a template literal starting at the opening backtick. + * Handles nested ${} expressions. + */ +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++; + } + + return result || null; +} + +/** + * Extract balanced { } block starting at the opening brace position. + */ +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; +} + +/** + * 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. + */ +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; +} + + + +// ============================================================================ +// Slot extraction +// ============================================================================ + +/** + * 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(); +} + +// ============================================================================ +// Selector style detection +// ============================================================================ + +/** + * Detect whether a component uses attribute-style or element-style selectors. + */ +export function detectSelectorStyle( + content: string, + kebabName: string, +): SelectorInfo { + // Check for attribute usage: ', 'button'); + const result = detectSelectorStyle( + '', + 'button', + ); expect(result.style).toBe('attribute'); expect(result.selector).toBe('ds-button'); expect(result.note).toContain('attribute'); @@ -463,7 +483,10 @@ describe('detectSelectorStyle', () => { }); it('should detect attribute on elements', () => { - const result = detectSelectorStyle('Link', 'button'); + const result = detectSelectorStyle( + 'Link', + 'button', + ); expect(result.style).toBe('attribute'); }); }); @@ -505,13 +528,17 @@ describe('detectFormIntegration', () => { describe('cleanTemplate', () => { it('should replace ${...} with ...', () => { - const result = cleanTemplate('${badge.label}'); + const result = cleanTemplate( + '${badge.label}', + ); expect(result).not.toContain('${'); expect(result).toContain('...'); }); it('should remove Storybook layout wrapper divs', () => { - const result = cleanTemplate('
Label'); + const result = cleanTemplate( + '
Label', + ); expect(result).not.toContain('display: grid'); expect(result).toContain('ds-badge'); }); 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 index 03ce107..63419c0 100644 --- 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 @@ -10,7 +10,9 @@ export function formatStoryDataAsMarkdown(data: StoryFileData): string { lines.push(''); // Selector - lines.push(`Selector: \`${data.selector.selector}\` (${data.selector.style})`); + lines.push( + `Selector: \`${data.selector.selector}\` (${data.selector.style})`, + ); if (data.selector.note) { lines.push(`Note: ${data.selector.note}`); } 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 index 65de3b6..1850f0d 100644 --- 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 @@ -107,7 +107,6 @@ function extractWithRegex( }; } - // ============================================================================ // Post-processing: cross-reference meta args into argTypes // ============================================================================ @@ -187,7 +186,6 @@ export function extractImportsRegex(content: string): string[] { return imports; } - // ============================================================================ // Meta args extraction // ============================================================================ @@ -325,7 +323,6 @@ export function extractMetaArgsRegex(content: string): MetaArg[] | null { return pairs.length > 0 ? pairs : null; } - // ============================================================================ // Meta tags extraction // ============================================================================ @@ -411,8 +408,7 @@ function extractArgTypeFields( entry: ArgTypeEntry, ): void { for (const prop of obj.properties) { - if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) - continue; + if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue; const key = prop.name.text; @@ -513,7 +509,6 @@ export function extractArgTypesRegex(content: string): ArgTypeEntry[] { return results; } - // ============================================================================ // Story extraction // ============================================================================ @@ -600,10 +595,7 @@ function extractDisplayNameAST( sourceFile: ts.SourceFile, ): string | null { for (const prop of obj.properties) { - if ( - ts.isPropertyAssignment(prop) && - ts.isIdentifier(prop.name) - ) { + 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; @@ -657,10 +649,7 @@ function extractStoryArgsAST( // Skip spread elements if (ts.isSpreadAssignment(argProp)) continue; - if ( - ts.isPropertyAssignment(argProp) && - ts.isIdentifier(argProp.name) - ) { + if (ts.isPropertyAssignment(argProp) && ts.isIdentifier(argProp.name)) { overrides.push({ name: argProp.name.text, value: argProp.initializer.getText(sourceFile).replace(/,\s*$/, ''), @@ -779,7 +768,6 @@ function extractTemplateFromMethodBody( return extractTemplateFromRenderNode(method.body, content); } - export function extractStoriesRegex(content: string): StoryEntry[] { const stories: StoryEntry[] = []; const exportRegex = @@ -858,7 +846,6 @@ function extractStoryArgsRegex(storyBody: string): ArgsOverride[] { return pairs; } - // ============================================================================ // Template utilities // ============================================================================ @@ -893,7 +880,10 @@ export function extractTernaryElseBranch(storyBody: string): string | null { * Extract content of a template literal starting at the opening backtick. * Handles nested ${} expressions. */ -function extractTemplateLiteral(source: string, openIdx: number): string | null { +function extractTemplateLiteral( + source: string, + openIdx: number, +): string | null { let i = openIdx + 1; // skip opening backtick let depth = 0; let result = ''; @@ -974,10 +964,7 @@ function extractTemplateLiteral(source: string, openIdx: number): string | null /** * Extract balanced { } block starting at the opening brace position. */ -function extractBalancedBlock( - source: string, - openIdx: number, -): string | null { +function extractBalancedBlock(source: string, openIdx: number): string | null { let depth = 0; let i = openIdx; @@ -1070,8 +1057,6 @@ function looksLikeHtml(template: string): boolean { return true; } - - // ============================================================================ // Slot extraction // ============================================================================ From 0e19245e354d95ae4a44922be14df265e3876cc6 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 9 Apr 2026 14:08:41 +0300 Subject: [PATCH 3/7] fix: lint fix --- .../tools/ds/story-parser/utils/story-parser.utils.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 index 1850f0d..dc3d9f3 100644 --- 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 @@ -124,7 +124,10 @@ function crossReferenceDefaults( const metaMap = new Map(metaArgs.map((a) => [a.name, a.default])); for (const at of argTypes) { if (!at.default && metaMap.has(at.name)) { - at.default = metaMap.get(at.name)!.replace(/^['"]|['"]$/g, ''); + const val = metaMap.get(at.name); + if (val) { + at.default = val.replace(/^['"]|['"]$/g, ''); + } } } } @@ -570,7 +573,7 @@ export function extractStoriesAST( function extractTagsAST( obj: ts.ObjectLiteralExpression, - sourceFile: ts.SourceFile, + _sourceFile: ts.SourceFile, ): string[] { for (const prop of obj.properties) { if ( @@ -592,7 +595,7 @@ function extractTagsAST( */ function extractDisplayNameAST( obj: ts.ObjectLiteralExpression, - sourceFile: ts.SourceFile, + _sourceFile: ts.SourceFile, ): string | null { for (const prop of obj.properties) { if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { From f8eab64422ede67a49b859b44069e60e6a2af992 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 10 Apr 2026 15:49:12 +0300 Subject: [PATCH 4/7] fix: refactor to shared utils --- .../ds/project/utils/styles-report-helpers.ts | 3 +- .../tools/ds/shared/utils/regex-helpers.ts | 4 +- .../tools/ds/shared/utils/template-helpers.ts | 135 +++++++++ .../lib/tools/ds/story-parser/models/types.ts | 6 +- .../__tests__/story-parser.utils.spec.ts | 6 +- .../story-parser/utils/story-parser.utils.ts | 271 +----------------- .../shared/typescript-ast-utils/src/index.ts | 5 + .../src/lib/source-text.utils.ts | 140 +++++++++ packages/shared/utils/src/index.ts | 1 + packages/shared/utils/src/lib/regex.utils.ts | 9 + 10 files changed, 309 insertions(+), 271 deletions(-) create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/shared/utils/template-helpers.ts create mode 100644 packages/shared/typescript-ast-utils/src/lib/source-text.utils.ts create mode 100644 packages/shared/utils/src/lib/regex.utils.ts 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: