Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<string[]> {
const files: string[] = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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(/<!--[\s\S]*?-->/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(
/<div\s+class="[^"]*(?:example-|tabgroup-)[^"]*">([\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+</g, '><');

// 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<string>();
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 ds-{name}> or <a ds-{name}>
const attrRegex = new RegExp(
`<(?:button|a)\\s[^>]*\\bds-${escapeRegex(kebabName)}\\b`,
'i',
);
if (attrRegex.test(content)) {
return {
style: 'attribute',
selector: `ds-${kebabName}`,
note: 'Applied as attribute on `<button>` or `<a>`',
};
}

// Check for element usage: <ds-{name} followed by space, >, or newline
const elemRegex = new RegExp(
`<ds-${escapeRegex(kebabName)}(?=[\\s>/\\[])`,
'i',
);
if (elemRegex.test(content)) {
return { style: 'element', selector: `ds-${kebabName}` };
}

return { style: 'unknown', selector: `ds-${kebabName}` };
}

/**
* Detect Angular form integration patterns in templates.
*/
export function detectFormIntegration(content: string): string[] {
const patterns: string[] = [];
if (/\[\(ngModel\)\]/.test(content))
patterns.push('ngModel (template-driven)');
if (/formControlName/.test(content))
patterns.push('formControlName (reactive forms)');
if (/formControl[^N]/.test(content))
patterns.push('formControl (reactive forms)');
return patterns;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { getDsStoryDataTools } from './get-ds-story-data.tool.js';
Loading
Loading