
Visual Component Finder
- 12 installs
- 7 repo stars
- Updated June 18, 2026
- duc01226/easyplatform
Matches screenshots to Angular components and identifies which component renders a page via BEM classes, routes, text, and selectors.
About
Matches screenshots to Angular components and finds which component renders a given page, matching on BEM classes, route paths, text, and selectors. A developer uses it to locate the source component behind a rendered UI.
- Maps screenshots to Angular components
- Matches via BEM classes, routes, and selectors at >=85% confidence
Visual Component Finder by the numbers
- 12 all-time installs (skills.sh)
- Ranked #1,643 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/duc01226/easyplatform --skill visual-component-finderAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 12 |
|---|---|
| repo stars | ★ 7 |
| Last updated | June 18, 2026 |
| Repository | duc01226/easyplatform ↗ |
What it does
Matches screenshots to Angular components and identifies which component renders a page via BEM classes, routes, text, and selectors.
Files
[IMPORTANT] Use TaskCreate to break ALL work into small tasks BEFORE starting — including tasks for each file read. This prevents context loss from long files. For simple tasks, AI MUST ATTENTION ask user whether to skip.Find the Angular component(s) matching this screenshot: $ARGUMENTS
Quick Summary
Goal: Match screenshots to existing Angular components in the codebase for code reuse.
Workflow:
1. Analyze — Process screenshot with vision capabilities 2. Search — Grep/glob for matching component patterns in frontend source directories 3. Match — Return component paths with similarity assessment
Key Rules:
- Auto-activated when user provides UI screenshots
- Search shared component library, domain libraries, and app-specific components
- Report exact component file paths and usage examples
Be skeptical. Apply critical thinking, sequential thinking. Every claim needs traced proof, confidence percentages (Idea should be more than 80%).
Prerequisites
- MUST ATTENTION READ
references/anti-hallucination-protocol.mdbefore any output - MUST ATTENTION READ
references/matching-algorithm.mdfor the 6-step protocol
Workflow
Step 1: Load Component Index
Read docs/component-index.json. If missing or stale, regenerate:
# Full scan — rebuild entire index from all source files
python .claude/skills/visual-component-finder/scripts/build-component-index.py
# Incremental — only re-index files changed since last commit (fast)
python .claude/skills/visual-component-finder/scripts/build-component-index.py --git-changes
# Incremental — changes since a specific branch/ref
python .claude/skills/visual-component-finder/scripts/build-component-index.py --git-changes mainWhen to use which:
--git-changes— after pulling new code or switching branches (seconds, not minutes)- No flag (full scan) — first-time build, or when index seems corrupted/stale
Step 2: Analyze Screenshot
Use the Read tool on the screenshot image. Extract visual fingerprint:
- Visible text (headers, labels, button text, table columns)
- Layout pattern (sidebar+content, table, form, card grid, modal)
- URL path (if browser bar visible)
- BEM class patterns (if DevTools open)
- App identification (detect from project config, URL, or port mapping)
Step 3: Match Components
Follow the Signal Checklist in references/matching-algorithm.md:
- Check 6 boolean signals (S1-S6) against index
- Calculate confidence from signal count
- If 0-1 signals matched, run Live Grep Fallback (Step 6)
Step 4: Generate Component Graph
Follow references/component-graph-template.md to output Mermaid relationship diagram.
Step 5: Output Results
Report with: matched component(s), confidence %, evidence per signal, file paths, relationship graph.
Disambiguation
- Intent is to find/modify existing code → THIS skill
- Intent is to create new UI from screenshot → defer to
design-screenshot - Intent is to describe a design → defer to
design-describe
Workflow Positioning
When attached to bugfix/feature/refactor prompts, this skill runs before /scout.
Important Notes
- Every match MUST ATTENTION cite
file:lineevidence — see anti-hallucination protocol - Show ranked candidates when confidence <85%
- Detect reusable components (in
libs/) and trace to page consumers - Index covers all frontend application versions
---
Closing Reminders
- IMPORTANT MUST ATTENTION break work into small todo tasks using
TaskCreateBEFORE starting - IMPORTANT MUST ATTENTION search codebase for 3+ similar patterns before creating new code
- IMPORTANT MUST ATTENTION cite
file:lineevidence for every claim (confidence >80% to act) - IMPORTANT MUST ATTENTION add a final review todo task to verify work quality
MANDATORY IMPORTANT MUST ATTENTION READ the following files before starting:
- IMPORTANT MUST ATTENTION READ
references/anti-hallucination-protocol.mdbefore starting - IMPORTANT MUST ATTENTION READ
references/matching-algorithm.mdbefore starting
Anti-Hallucination Protocol
Mandatory rules to prevent false component identification. Every rule is BLOCKING.
8 Rules (ALL Required)
R1: File Evidence Mandatory
NEVER claim a match without citing file:line. Every reported component must have its .component.ts path verified by actually reading the file.
R2: Check for Duplicates
NEVER assume a single match. Search the selectorIndex for duplicate selectors. If the same selector appears in multiple files, report ALL instances and ask user to disambiguate.
R3: Confidence Declaration
EVERY output MUST ATTENTION include Confidence: X% with an evidence list showing which signals matched and which missed. No exceptions.
R4: Category Diversity
For >=85% confidence claims, at least 2 signals must come from DIFFERENT categories:
- Route-based: S1 (URL/route match)
- Visual-based: S2 (BEM), S5 (child composition), S6 (app ID)
- Text-based: S3 (text content), S4 (selector match)
Signals from a single category alone do NOT qualify for 85%.
R5: Verify File Exists
Before reporting a match, READ the matched .component.ts file to confirm it exists and contains the expected selector. If the file is missing or selector doesn't match, discard the candidate.
R6: Cross-Check Template
READ the component's .html template to confirm that visual elements mentioned in the match evidence actually exist in the template. A BEM class match is only valid if the class appears in the actual template file.
R7: Reusable Component Detection
If the matched component lives in libs/ (shared component library, domain library, platform core):
1. Flag it as a shared/reusable component 2. Use parentSelectors from the index to find page-level consumers 3. Ask user which page context they're working in 4. Include the consumer chain in the component relationship graph
R8: Context Anchor
During extended investigations (10+ tool operations), re-read the original screenshot to prevent context drift. The screenshot is the ground truth — not your accumulated assumptions.
Quick Checklist
Before outputting ANY match result:
- [ ] Read the matched
.component.tsfile (R1, R5) - [ ] Read the matched
.htmltemplate (R6) - [ ] Checked
selectorIndexfor duplicates (R2) - [ ] Confidence % declared with signal breakdown (R3)
- [ ] Category diversity validated for >=85% claims (R4)
- [ ] Reusable component consumer chain traced if in
libs/(R7) - [ ] Screenshot re-checked if 10+ operations elapsed (R8)
When Confidence < 70%
Output this template:
Insufficient confidence to identify component.
Signals matched:
- [list matched signals with evidence]
Signals missed:
- [list unmatched signals]
To improve matching, please provide:
- Browser URL (most helpful)
- Which app is this? (identify the frontend application)
- Navigation path to reach this pageComponent Relationship Graph Template
Generate a Mermaid diagram showing the component hierarchy for the matched component.
Layer Colors
| Layer | Color | Node prefix |
|---|---|---|
| Page component | Blue (#4A90D9) | PAGE |
| Domain component | Green (#2ECC71) | DOMAIN |
| Domain shared (_shared/) | Teal (#1ABC9C) | SHARED |
| Common component (shared library) | Yellow (#F1C40F) | COMMON |
| Framework component | Gray (#95A5A6) | FRAMEWORK |
Template
graph TD
PAGE["Page: {selector}<br/>{routePath}<br/>{app}"]
DOMAIN1["Domain: {selector}<br/>{lib}"]
COMMON1["Common: {selector}<br/>{common-lib}"]
PAGE --> DOMAIN1
DOMAIN1 --> COMMON1
classDef page fill:#4A90D9,color:white
classDef domain fill:#2ECC71,color:white
classDef shared fill:#1ABC9C,color:white
classDef common fill:#F1C40F,color:black
classDef platform fill:#95A5A6,color:white
class PAGE page
class DOMAIN1 domain
class COMMON1 commonHow to Build the Graph
1. Start with the matched component as the root node 2. Read its childSelectors from the index 3. For each child selector, look up the component in selectorIndex 4. Classify each child by layer (page/domain/common/platform) 5. Add edges from parent to child 6. Recurse one level deep for domain components (show their common children) 7. Do NOT recurse into common components (they're leaf nodes in the graph)
Graph Rules
- Max depth: 2 levels (page -> domain -> common)
- Max nodes: 15 (truncate with "... and N more" if exceeded)
- Deduplicate: If the same common component appears under multiple domains, show it once with multiple edges
- Root highlighting: The matched component gets a thicker border or bold label
- Version label: Add (V1) or (V2) suffix if mixing Angular versions
When Matched Component is a Reusable Component
If the match is a libs/ component (not a page), reverse the graph:
graph BT
MATCHED["Matched: {selector}<br/>{lib}"]
CONSUMER1["Consumer: {parent1}<br/>{app1}"]
CONSUMER2["Consumer: {parent2}<br/>{app2}"]
CONSUMER1 --> MATCHED
CONSUMER2 --> MATCHED
classDef matched fill:#E74C3C,color:white
classDef consumer fill:#4A90D9,color:white
class MATCHED matched
class CONSUMER1,CONSUMER2 consumerUse parentSelectors from the index to find consumers. Limit to 10 consumers max.
Visual-to-Code Matching Algorithm
6-step protocol for matching a screenshot to Angular components in the codebase.
Step 1: Extract Visual Fingerprint
From the screenshot, extract:
- Visible text: headers, labels, button text, table column names
- Layout pattern: sidebar+content, data table, form, card grid, modal/dialog, tabs
- Color hints: brand colors help identify the app
- URL path: if browser address bar is visible (highest-value signal)
- BEM class patterns: if DevTools is open in screenshot
- Port number: Check project config for port-to-app mapping (e.g.,
project-structure-reference.md)
Step 2: App Identification
Build the port-to-app mapping from your project's configuration:
# Search for dev server port assignments
grep -r "port" {frontend-apps-dir}/*/project.json 2>/dev/null || grep -r "port" angular.json 2>/dev/null| Signal | App |
|---|---|
| URL port {N} or /{app-route}/ path | {app-name} |
| ... | ... |
Populate this table from your project's actual port configuration.
Step 3: Signal Checklist
For each candidate component, check these 6 boolean signals:
| # | Signal | How to Check |
|---|---|---|
| S1 | Route/URL match | URL path segment matches routePath in index routes tree. Note: Static index may have limited route coverage if the project uses constant references for routes. Use live grep on routes.ts files as fallback. |
| S2 | BEM root class match | CSS class from screenshot matches bemBlock in bemIndex |
| S3 | Unique text match | Header/label text found in component's textContent array |
| S4 | Selector match | Component tag found via selectorIndex or grep |
| S5 | Child composition | Visible sub-widgets match component's childSelectors |
| S6 | App identification | App correctly identified via URL, port, or sidebar layout |
Signal categories (for threshold validation):
- Route-based: S1
- Visual-based: S2, S5, S6
- Text-based: S3, S4
Step 4: Confidence Mapping
| Signals | Confidence | Action |
|---|---|---|
| 6/6 | 95%+ (Very High) | Output as high-confidence match |
| 5/6 | 90% (High) | Output as high-confidence match |
| 4/6 | 85% (Confident) | Output as confident match |
| 3/6 | 70% (Needs Confirmation) | Show top 3 candidates |
| 2/6 | 50% (Low) | Show top 5 + ask for clues |
| 0-1 | <50% (Insufficient) | Trigger Live Grep Fallback |
Category diversity rule: For >=85% claim, signals must come from at least 2 different categories.
Step 5: Output Format
For high-confidence match (>=85%):
**Match: [selector]** (Confidence: X%)
- File: [filePath]
- Template: [templatePath]
- Store: [storePath] (if exists)
- Route: [routePath] (if exists)
- App: [app] ([version])
- Layer: [layer]
**Evidence:**
- S1 [MATCH/MISS]: [details]
- S2 [MATCH/MISS]: [details]
...For needs-confirmation (70-84%): show top 3 candidates with signal breakdown. For low (<70%): show top 5 candidates + ask user for URL, app name, or navigation path.
Step 6: Live Grep Fallback
If no candidate reaches 2+ signals from the index:
1. Grep extracted text strings across all .html templates 2. Grep BEM class fragments across all .scss files 3. Grep unique component selectors from visible custom elements 4. Cross-reference grep hits with component-index.json 5. Re-evaluate signal checklist for newly found candidates
#!/usr/bin/env python3
"""
Build Component Index for Visual Component Finder.
Scans Angular components in directories configured via docs/project-config.json,
extracting:
- Component selectors, BEM classes, route paths, text content
- Child/parent component relationships
- Layer classification (page/domain/common/platform)
Output: docs/component-index.json (or custom path via --output)
Configuration: reads modules[] + componentSystem (v2) or frontendApps.appMap +
componentFinder (v1) from docs/project-config.json.
Falls back to scanning all src/**/*.component.ts if config is missing.
Usage:
python build-component-index.py # Full scan (all components)
python build-component-index.py --git-changes # Incremental: only re-index git-changed files
python build-component-index.py --git-changes main # Incremental: changes since 'main' branch
python build-component-index.py --output path.json # Custom output path
python build-component-index.py --modern-only # Only index modern framework apps
"""
import argparse
import glob
import json
import os
import re
import subprocess
from datetime import datetime, timezone
from pathlib import Path
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
# Directories to skip during scanning
SKIP_DIRS = {"node_modules", "dist", ".angular", ".nx", "e2e", "__tests__", "test"}
# Default output path (relative to project root)
DEFAULT_OUTPUT = "docs/component-index.json"
# Default config path (relative to project root)
PROJECT_CONFIG_PATH = "docs/project-config.json"
# ---------------------------------------------------------------------------
# Regex patterns
# ---------------------------------------------------------------------------
# Extract selector from @Component decorator
RE_SELECTOR = re.compile(r"selector:\s*['\"]([^'\"]+)['\"]")
# Extract templateUrl from @Component decorator
RE_TEMPLATE_URL = re.compile(r"templateUrl:\s*['\"]([^'\"]+)['\"]")
# Extract inline template from @Component decorator (multiline)
RE_INLINE_TEMPLATE = re.compile(
r"template:\s*`([^`]*)`", re.DOTALL
)
# Extract class name and optional base class
RE_CLASS_EXTENDS = re.compile(
r"export\s+class\s+(\w+)\s+extends\s+(\w+)"
)
RE_CLASS_SIMPLE = re.compile(r"export\s+class\s+(\w+)")
# Extract BEM root class from HTML: first class attribute value
RE_BEM_ROOT = re.compile(r'class="([a-z][a-z0-9-]*(?:__[a-z0-9-]+)?)')
# Extract child component selectors from HTML templates
# Matches custom element tags — prefix list is loaded from project config at runtime
RE_CHILD_SELECTORS = None # Built dynamically in load_project_config()
# Extract visible text from HTML templates (headers, labels, buttons)
# Matches text between > and < that isn't whitespace or Angular bindings
RE_VISIBLE_TEXT = re.compile(
r">\s*([A-Z][A-Za-z0-9 ,.'&/()-]{2,50})\s*<"
)
# Route patterns
RE_ROUTE_PATH = re.compile(r"path:\s*['\"]([^'\"]*)['\"]")
RE_ROUTE_COMPONENT = re.compile(r"component:\s*(\w+)")
RE_ROUTE_LAZY_IMPORT = re.compile(
r"import\(['\"]([^'\"]+)['\"]\)"
)
# Common CSS utility classes to exclude from BEM block detection
UTILITY_CSS_CLASSES = {
"flex", "grid", "hidden", "block", "inline", "relative", "absolute", "fixed",
"sticky", "static", "container", "row", "col", "wrap", "overflow",
"d-flex", "d-grid", "d-block", "d-none", "d-inline",
"w-full", "h-full", "m-auto", "p-0", "text-center",
}
# Standard HTML tags to exclude from child selector detection
STANDARD_HTML_TAGS = {
"a", "abbr", "address", "area", "article", "aside", "audio",
"b", "base", "bdi", "bdo", "blockquote", "body", "br", "button",
"canvas", "caption", "cite", "code", "col", "colgroup",
"data", "datalist", "dd", "del", "details", "dfn", "dialog", "div", "dl", "dt",
"em", "embed", "fieldset", "figcaption", "figure", "footer", "form",
"h1", "h2", "h3", "h4", "h5", "h6", "head", "header", "hgroup", "hr", "html",
"i", "iframe", "img", "input", "ins",
"kbd", "label", "legend", "li", "link",
"main", "map", "mark", "menu", "meta", "meter",
"nav", "noscript",
"object", "ol", "optgroup", "option", "output",
"p", "param", "picture", "pre", "progress",
"q", "rp", "rt", "ruby",
"s", "samp", "script", "section", "select", "slot", "small", "source",
"span", "strong", "style", "sub", "summary", "sup",
"table", "tbody", "td", "template", "textarea", "tfoot", "th", "thead",
"time", "title", "tr", "track",
"u", "ul", "var", "video", "wbr",
# Angular-specific structural elements
"ng-container", "ng-template", "ng-content", "router-outlet",
# Angular Material / PrimeNG / common library elements
"mat-icon", "mat-spinner", "mat-tab", "mat-tab-group",
"p-table", "p-column", "p-dropdown", "p-dialog",
"as-split", "as-split-area",
}
def find_project_root():
"""Find the project root by looking for CLAUDE.md or .git."""
current = Path(__file__).resolve()
for parent in [current] + list(current.parents):
if (parent / "CLAUDE.md").exists() or (parent / ".git").exists():
return parent
# Fallback: assume script is at .claude/skills/visual-component-finder/scripts/
return current.parent.parent.parent.parent.parent
# ---------------------------------------------------------------------------
# Project config loading
# ---------------------------------------------------------------------------
# Runtime config populated by load_project_config()
_project_config = None
_scan_dirs = [] # List of (label, abs_path) tuples to scan
_modern_labels = set() # Labels considered "modern" framework
_layer_rules = [] # List of (path_fragment, layer_name) tuples
_selector_prefixes = [] # Component selector prefixes from config
def load_project_config(project_root, verbose=False):
"""Load docs/project-config.json and derive scan dirs, layer rules, etc.
Falls back to scanning all src/**/*.component.ts if config is missing.
"""
global _project_config, _scan_dirs, _modern_labels, _layer_rules, _selector_prefixes, RE_CHILD_SELECTORS
config_path = os.path.join(project_root, PROJECT_CONFIG_PATH)
_project_config = {}
if os.path.exists(config_path):
try:
with open(config_path, "r", encoding="utf-8") as f:
_project_config = json.load(f)
if verbose:
print(f"Loaded project config: {config_path}")
except (json.JSONDecodeError, OSError) as e:
if verbose:
print(f"Warning: Could not parse {config_path}: {e}")
# --- Derive scan directories ---
# v2: Read from modules[] if available
modules = _project_config.get("modules", [])
frontend_modules = [m for m in modules if m.get("kind") in ("frontend-app", "library")]
if frontend_modules:
for mod in frontend_modules:
regex_str = mod.get("pathRegex", "")
dir_path = regex_str.replace("[\\\\/]", "/").replace("[/\\\\]", "/")
abs_dir = os.path.join(project_root, "src", dir_path)
if not os.path.isdir(abs_dir):
abs_dir = os.path.join(project_root, dir_path)
_scan_dirs.append((mod["name"], abs_dir))
_modern_labels = {m["name"] for m in frontend_modules if "modern" in m.get("tags", [])}
else:
# v1 fallback: frontendApps.appMap
fe = _project_config.get("frontendApps", {})
app_map = fe.get("appMap", {})
if app_map:
for label, regex_str in app_map.items():
dir_path = regex_str.replace("[\\\\/]", "/").replace("[/\\\\]", "/")
abs_dir = os.path.join(project_root, "src", dir_path)
if not os.path.isdir(abs_dir):
abs_dir = os.path.join(project_root, dir_path)
_scan_dirs.append((label, abs_dir))
else:
_scan_dirs.append(("src", os.path.join(project_root, "src")))
# Also scan frontend pattern paths (libs, etc.)
fe_patterns = fe.get("patterns", [])
for pat in fe_patterns:
pat_regex = pat.get("pathRegex", "")
if pat_regex:
dir_path = pat_regex.replace("[\\\\/]", "/").replace("[/\\\\]", "/")
abs_dir = os.path.join(project_root, "src", dir_path)
if not os.path.isdir(abs_dir):
abs_dir = os.path.join(project_root, dir_path)
name = pat.get("name", dir_path)
if not any(d[1] == abs_dir for d in _scan_dirs):
_scan_dirs.append((name, abs_dir))
# v1: Modern vs legacy labels
_modern_labels = set(fe.get("modernApps", []))
# --- Layer classification ---
# v2: componentSystem (v1 fallback: componentFinder)
cs = _project_config.get("componentSystem", _project_config.get("componentFinder", {}))
layer_class = cs.get("layerClassification", {})
for layer_name, paths in layer_class.items():
for p in paths:
_layer_rules.append((p.replace("\\", "/"), layer_name))
# --- Selector prefixes ---
_selector_prefixes = cs.get("selectorPrefixes", ["app-"])
# Build the child selector regex from prefixes
if _selector_prefixes:
prefix_pattern = "|".join(re.escape(p) for p in _selector_prefixes)
RE_CHILD_SELECTORS = re.compile(
rf"<((?:{prefix_pattern})[a-z0-9-]+|(?:[a-z]+-[a-z]+-[a-z0-9-]+))[^>]*>"
)
else:
RE_CHILD_SELECTORS = re.compile(r"<([a-z]+-[a-z]+-[a-z0-9-]+)[^>]*>")
if verbose:
print(f"Scan dirs: {len(_scan_dirs)}, Layer rules: {len(_layer_rules)}, "
f"Selector prefixes: {_selector_prefixes}")
def should_skip(path_str):
"""Check if a path contains any directory we should skip."""
parts = Path(path_str).parts
return any(part in SKIP_DIRS for part in parts)
# ---------------------------------------------------------------------------
# Component extraction
# ---------------------------------------------------------------------------
def extract_selector(content):
"""Extract component selector from @Component decorator."""
match = RE_SELECTOR.search(content)
return match.group(1) if match else None
def extract_class_info(content):
"""Extract class name and optional base class."""
match = RE_CLASS_EXTENDS.search(content)
if match:
return match.group(1), match.group(2)
match = RE_CLASS_SIMPLE.search(content)
if match:
return match.group(1), None
return None, None
def get_template_content(component_path, ts_content):
"""Get template HTML content from sibling .html file or inline template."""
component_dir = os.path.dirname(component_path)
# Try templateUrl first
match = RE_TEMPLATE_URL.search(ts_content)
if match:
template_rel = match.group(1)
template_path = os.path.normpath(os.path.join(component_dir, template_rel))
if os.path.exists(template_path):
try:
with open(template_path, "r", encoding="utf-8", errors="replace") as f:
return f.read(), template_rel
except Exception:
pass
# Fallback: try sibling .html file
html_path = component_path.replace(".component.ts", ".component.html")
if os.path.exists(html_path):
try:
with open(html_path, "r", encoding="utf-8", errors="replace") as f:
return f.read(), os.path.basename(html_path)
except Exception:
pass
# Last resort: inline template
match = RE_INLINE_TEMPLATE.search(ts_content)
if match:
return match.group(1), "(inline)"
return None, None
def extract_bem_root(html_content):
"""Extract the BEM root block class from the template's outermost element."""
if not html_content:
return None
for match in RE_BEM_ROOT.finditer(html_content):
class_name = match.group(1)
block = class_name.split("__")[0]
# Skip utility CSS classes (flex, grid, hidden, etc.)
if block in UTILITY_CSS_CLASSES:
continue
return block
return None
def extract_child_selectors(html_content):
"""Extract custom component selectors used in the template."""
if not html_content:
return []
matches = RE_CHILD_SELECTORS.findall(html_content)
# Deduplicate and filter out standard HTML tags
selectors = sorted(set(
s for s in matches
if s not in STANDARD_HTML_TAGS and not s.startswith("ng-")
))
return selectors
def extract_text_content(html_content):
"""Extract unique visible text strings from the template (headers, labels)."""
if not html_content:
return []
matches = RE_VISIBLE_TEXT.findall(html_content)
# Deduplicate, trim, limit to reasonable strings
texts = sorted(set(
t.strip() for t in matches
if len(t.strip()) >= 3
and not t.strip().startswith("{{")
and not t.strip().startswith("*ng")
))
# Limit to first 15 text strings to keep index size manageable
return texts[:15]
def determine_layer(rel_path):
"""Classify component layer based on file path using config-driven rules."""
path_normalized = rel_path.replace("\\", "/").lower()
# Check config-driven layer rules first
for path_fragment, layer_name in _layer_rules:
if path_fragment.lower() in path_normalized:
# Special case: domain-shared detection
if layer_name == "domain" and ("/_shared/" in path_normalized or "/_shared\\" in path_normalized):
return "domain-shared"
return layer_name
# Generic heuristics (no hardcoded project paths)
if "/apps/" in path_normalized and "/routes/" in path_normalized:
return "page"
if "/apps/" in path_normalized:
return "app"
if "/shared/" in path_normalized:
return "shared"
if "/pages/" in path_normalized or "/containers/" in path_normalized:
return "page"
if "/libs/" in path_normalized:
return "common"
return "unknown"
def determine_app(rel_path):
"""Determine which app a component belongs to using modules[] or appMap."""
path_normalized = rel_path.replace("\\", "/")
# v2: check modules[] first
modules = _project_config.get("modules", [])
for mod in modules:
regex_str = mod.get("pathRegex", "")
if regex_str:
try:
if re.search(regex_str, path_normalized, re.IGNORECASE):
return mod["name"]
except re.error:
pass
# v1 fallback: config-driven appMap patterns
fe = _project_config.get("frontendApps", {})
app_map = fe.get("appMap", {})
for label, regex_str in app_map.items():
try:
if re.search(regex_str, path_normalized, re.IGNORECASE):
return label
except re.error:
pass
# Generic heuristic: look for apps/{name}/ or libs/{name}/ patterns
path_parts = path_normalized.split("/")
try:
idx = path_parts.index("apps")
if idx + 1 < len(path_parts):
return path_parts[idx + 1]
except ValueError:
pass
try:
idx = path_parts.index("libs")
if idx + 1 < len(path_parts):
return f"lib:{path_parts[idx + 1]}"
except ValueError:
pass
return "unknown"
def determine_version(rel_path):
"""Determine modern vs legacy based on modules[].tags or modernApps list."""
app = determine_app(rel_path)
app_name = app.replace("lib:", "") if app.startswith("lib:") else app
# v2: check modules[].tags for "modern" or "legacy"
modules = _project_config.get("modules", [])
for mod in modules:
if mod.get("name") == app_name:
tags = mod.get("tags", [])
if "modern" in tags:
return "modern"
if "legacy" in tags:
return "legacy"
# Also check meta.generation
gen = mod.get("meta", {}).get("generation")
if gen in ("modern", "legacy"):
return gen
# v1 fallback
if app_name in _modern_labels:
return "modern"
fe = _project_config.get("frontendApps", {})
if app_name in fe.get("legacyApps", []):
return "legacy"
if _modern_labels:
return "legacy"
return "unknown"
def get_scss_path(component_path):
"""Find sibling .scss file for a component."""
scss_path = component_path.replace(".component.ts", ".component.scss")
if os.path.exists(scss_path):
return os.path.basename(scss_path)
return None
def get_store_path(component_path):
"""Find sibling .store.ts file for a component."""
store_path = component_path.replace(".component.ts", ".store.ts")
if os.path.exists(store_path):
return os.path.basename(store_path)
return None
# ---------------------------------------------------------------------------
# Route extraction
# ---------------------------------------------------------------------------
def extract_routes_from_file(file_path, project_root):
"""Extract route definitions from a routing file."""
try:
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except Exception:
return []
routes = []
# Find path + component pairs
paths = RE_ROUTE_PATH.findall(content)
components = RE_ROUTE_COMPONENT.findall(content)
# Simple pairing: each path matched with nearby component
for i, path in enumerate(paths):
if path and path != "**":
component = components[i] if i < len(components) else None
routes.append({"path": path, "component": component})
return routes
def build_route_index(project_root, modern_only=False):
"""Build route path → component mapping from all routing files.
Uses _scan_dirs populated from project config.
"""
route_map = {}
for label, scan_dir in _scan_dirs:
if not os.path.isdir(scan_dir):
continue
if modern_only and label not in _modern_labels:
continue
# Search for route files in each configured directory
route_patterns = [
os.path.join(scan_dir, "**", "routes.ts"),
os.path.join(scan_dir, "**", "*.routes.ts"),
os.path.join(scan_dir, "**", "*routing*.ts"),
]
for pattern in route_patterns:
for route_file in glob.glob(pattern, recursive=True):
if should_skip(route_file):
continue
routes = extract_routes_from_file(route_file, project_root)
for route in routes:
if route["component"]:
route_map[route["component"]] = route["path"]
return route_map
# ---------------------------------------------------------------------------
# Main indexing logic
# ---------------------------------------------------------------------------
def index_component(component_path, project_root, route_map):
"""Index a single component file, returning its metadata dict."""
try:
with open(component_path, "r", encoding="utf-8", errors="replace") as f:
ts_content = f.read()
except Exception:
return None
selector = extract_selector(ts_content)
if not selector:
return None # Not a valid component
class_name, base_class = extract_class_info(ts_content)
rel_path = os.path.relpath(component_path, project_root)
html_content, template_ref = get_template_content(component_path, ts_content)
bem_block = extract_bem_root(html_content)
child_selectors = extract_child_selectors(html_content)
text_content = extract_text_content(html_content)
layer = determine_layer(rel_path)
app = determine_app(rel_path)
version = determine_version(rel_path)
scss = get_scss_path(component_path)
store = get_store_path(component_path)
# Route path lookup
route_path = route_map.get(class_name)
component_data = {
"selector": selector,
"className": class_name,
"filePath": rel_path.replace("\\", "/"),
"version": version,
"app": app,
"layer": layer,
}
# Optional fields — only include if present (keeps JSON smaller)
if bem_block:
component_data["bemBlock"] = bem_block
if template_ref:
component_data["templatePath"] = template_ref
if scss:
component_data["scssPath"] = scss
if store:
component_data["storePath"] = store
if base_class:
component_data["baseClass"] = base_class
if route_path:
component_data["routePath"] = route_path
if child_selectors:
component_data["childSelectors"] = child_selectors
if text_content:
component_data["textContent"] = text_content
return component_data
def build_reverse_indexes(components):
"""Build reverse lookup indexes: selector→paths, bem→paths."""
selector_index = {}
bem_index = {}
for comp in components:
sel = comp["selector"]
path = comp["filePath"]
if sel not in selector_index:
selector_index[sel] = []
selector_index[sel].append(path)
bem = comp.get("bemBlock")
if bem:
if bem not in bem_index:
bem_index[bem] = []
bem_index[bem].append(path)
return selector_index, bem_index
def build_parent_index(components, selector_index):
"""Build parent selector index: which components use this selector as a child."""
parent_index = {} # selector → list of parent component selectors
for comp in components:
children = comp.get("childSelectors", [])
for child_sel in children:
if child_sel not in parent_index:
parent_index[child_sel] = []
parent_index[child_sel].append(comp["selector"])
# Deduplicate parent lists
for sel in parent_index:
parent_index[sel] = sorted(set(parent_index[sel]))
return parent_index
def build_route_tree(components):
"""Build route path → component file path mapping grouped by app."""
route_tree = {}
for comp in components:
route = comp.get("routePath")
if route:
app = comp["app"]
if app not in route_tree:
route_tree[app] = {}
route_tree[app][route] = comp["filePath"]
return route_tree
def get_git_changed_components(project_root, base_ref="HEAD"):
"""Get list of *.component.ts files changed relative to a git ref.
Includes: modified, added, renamed components in working tree + staged.
If base_ref is 'HEAD', compares working tree against last commit.
If base_ref is a branch/tag, compares current HEAD against that ref.
"""
try:
# Changed in working tree + staged (uncommitted changes)
result = subprocess.run(
["git", "diff", "--name-only", "--diff-filter=AMRC", base_ref, "--", "*.component.ts"],
capture_output=True, text=True, cwd=str(project_root),
)
changed = set(result.stdout.strip().splitlines()) if result.stdout.strip() else set()
# Also include untracked new component files
result_untracked = subprocess.run(
["git", "ls-files", "--others", "--exclude-standard", "--", "*.component.ts"],
capture_output=True, text=True, cwd=str(project_root),
)
untracked = set(result_untracked.stdout.strip().splitlines()) if result_untracked.stdout.strip() else set()
# Detect deleted components
result_deleted = subprocess.run(
["git", "diff", "--name-only", "--diff-filter=D", base_ref, "--", "*.component.ts"],
capture_output=True, text=True, cwd=str(project_root),
)
deleted = set(result_deleted.stdout.strip().splitlines()) if result_deleted.stdout.strip() else set()
# Also check changed HTML templates — their parent component needs re-indexing
result_html = subprocess.run(
["git", "diff", "--name-only", "--diff-filter=AMRC", base_ref, "--", "*.component.html"],
capture_output=True, text=True, cwd=str(project_root),
)
changed_html = set(result_html.stdout.strip().splitlines()) if result_html.stdout.strip() else set()
# Map .component.html -> .component.ts
for html_path in changed_html:
ts_path = html_path.replace(".component.html", ".component.ts")
changed.add(ts_path)
return changed | untracked, deleted
except (subprocess.SubprocessError, FileNotFoundError):
return set(), set()
def load_existing_index(output_path):
"""Load existing component-index.json for incremental update."""
if not os.path.exists(output_path):
return None
try:
with open(output_path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return None
def main():
parser = argparse.ArgumentParser(description="Build component index")
parser.add_argument(
"--output", "-o",
default=None,
help=f"Output file path (default: {{project_root}}/{DEFAULT_OUTPUT})"
)
parser.add_argument(
"--modern-only",
action="store_true",
help="Only index modern framework apps (as defined in project-config.json modernApps)"
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Print progress and warnings"
)
parser.add_argument(
"--git-changes",
nargs="?",
const="HEAD",
default=None,
metavar="REF",
help="Incremental update: only re-index components changed since REF (default: HEAD)"
)
args = parser.parse_args()
project_root = find_project_root()
output_path = args.output or os.path.join(project_root, DEFAULT_OUTPUT)
if args.verbose:
print(f"Project root: {project_root}")
print(f"Output: {output_path}")
# Load project config and initialize scan directories
load_project_config(project_root, verbose=args.verbose)
# Build route map (always needed)
route_map = build_route_index(project_root, modern_only=args.modern_only)
if args.verbose:
print(f"Found {len(route_map)} route-to-component mappings")
# --- Incremental mode: merge changes into existing index ---
if args.git_changes is not None:
existing_index = load_existing_index(output_path)
if existing_index is None:
print("No existing index found. Running full scan instead.")
args.git_changes = None # Fall through to full scan
else:
changed_paths, deleted_paths = get_git_changed_components(project_root, args.git_changes)
if not changed_paths and not deleted_paths:
print("No component changes detected. Index is up to date.")
return
if args.verbose:
print(f"Changed/added: {len(changed_paths)} files")
print(f"Deleted: {len(deleted_paths)} files")
# Normalize deleted paths for comparison
deleted_normalized = {p.replace("\\", "/") for p in deleted_paths}
# Start from existing components, removing deleted + changed (will re-add changed)
changed_normalized = set()
for p in changed_paths:
changed_normalized.add(p.replace("\\", "/"))
components = [
c for c in existing_index.get("components", [])
if c["filePath"] not in deleted_normalized
and c["filePath"] not in changed_normalized
]
removed_count = len(existing_index.get("components", [])) - len(components)
if args.verbose:
print(f"Kept {len(components)} unchanged components, removed {removed_count}")
# Re-index changed/added files
added = 0
for rel_path in changed_paths:
abs_path = os.path.join(project_root, rel_path)
if os.path.exists(abs_path) and not should_skip(abs_path):
result = index_component(abs_path, project_root, route_map)
if result:
components.append(result)
added += 1
print(f"Incremental update: +{added} re-indexed, -{len(deleted_normalized)} deleted, {len(components)} total")
# --- Full scan mode ---
if args.git_changes is None:
component_files = []
# Scan each configured directory
for label, scan_dir in _scan_dirs:
if not os.path.isdir(scan_dir):
if args.verbose:
print(f"Skipping {label}: directory not found ({scan_dir})")
continue
if args.modern_only and label not in _modern_labels:
if args.verbose:
print(f"Skipping {label}: not in modernApps list")
continue
pattern = os.path.join(scan_dir, "**", "*.component.ts")
files = [f for f in glob.glob(pattern, recursive=True) if not should_skip(f)]
component_files.extend(files)
if args.verbose:
print(f"Found {len(files)} component files in {label}")
if args.verbose:
print(f"Total: {len(component_files)} component files")
# Index all components
components = []
skipped = 0
for comp_path in component_files:
result = index_component(comp_path, project_root, route_map)
if result:
components.append(result)
else:
skipped += 1
if args.verbose:
print(f"Indexed {len(components)} components, skipped {skipped}")
# Build reverse indexes
selector_index, bem_index = build_reverse_indexes(components)
parent_index = build_parent_index(components, selector_index)
route_tree = build_route_tree(components)
# Add parentSelectors to components
for comp in components:
parents = parent_index.get(comp["selector"], [])
if parents:
comp["parentSelectors"] = parents
# Compute stats
stats = {
"total": len(components),
"modern": sum(1 for c in components if c["version"] == "modern"),
"legacy": sum(1 for c in components if c["version"] == "legacy"),
"pages": sum(1 for c in components if c["layer"] == "page"),
"domain": sum(1 for c in components if c["layer"] in ("domain", "domain-shared")),
"common": sum(1 for c in components if c["layer"] == "common"),
"platform": sum(1 for c in components if c["layer"] == "platform"),
"withRoutes": sum(1 for c in components if "routePath" in c),
"withBem": sum(1 for c in components if "bemBlock" in c),
"withStore": sum(1 for c in components if "storePath" in c),
}
# Build final output
index = {
"version": "1.0.0",
"generatedAt": datetime.now(timezone.utc).isoformat(),
"stats": stats,
"components": sorted(components, key=lambda c: c["filePath"]),
"routes": route_tree,
"selectorIndex": {k: v for k, v in sorted(selector_index.items())},
"bemIndex": {k: v for k, v in sorted(bem_index.items())},
}
# Ensure output directory exists
output_dir = os.path.dirname(output_path)
if output_dir:
os.makedirs(output_dir, exist_ok=True)
# Write JSON output
with open(output_path, "w", encoding="utf-8") as f:
json.dump(index, f, indent=2, ensure_ascii=False)
# Summary
print(f"Component index generated: {output_path}")
print(f" Total: {stats['total']} components ({stats['modern']} modern, {stats['legacy']} legacy)")
print(f" Pages: {stats['pages']}, Domain: {stats['domain']}, Common: {stats['common']}, Platform: {stats['platform']}")
print(f" With routes: {stats['withRoutes']}, With BEM: {stats['withBem']}, With store: {stats['withStore']}")
if __name__ == "__main__":
main()