From 1b14ac63d1422f75318fb22098ecf982b9bbd291 Mon Sep 17 00:00:00 2001 From: Edison Augusthy Date: Thu, 4 Jun 2026 13:06:12 +0200 Subject: [PATCH] feat: demo updates --- .github/workflows/deploy-demo.yml | 3 - .github/workflows/release.yml | 24 +- README.md | 481 ++++--------------------- package.json | 3 +- packages/angular-render-scan/README.md | 398 +++++--------------- packages/cli/README.md | 27 ++ packages/cli/bin/cli.js | 2 +- packages/cli/package-lock.json | 1 + packages/cli/package.json | 14 + packages/cli/src/index.ts | 210 +++++++---- 10 files changed, 374 insertions(+), 789 deletions(-) create mode 100644 packages/cli/README.md diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml index 6abcddc..29a27e7 100644 --- a/.github/workflows/deploy-demo.yml +++ b/.github/workflows/deploy-demo.yml @@ -39,9 +39,6 @@ jobs: - name: Build demo run: npm run build:demo -- --configuration production --base-href /angular-render-scan/ - - name: Configure GitHub Pages - uses: actions/configure-pages@v5 - - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c36bc8..1d26820 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,6 +41,10 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" working-directory: packages/angular-render-scan + - name: Sync CLI package version + run: npm version ${{ steps.package.outputs.version }} --no-git-tag-version --allow-same-version + working-directory: packages/cli + - name: Build run: npm run build @@ -51,16 +55,20 @@ jobs: echo "angular-render-scan@${{ steps.package.outputs.version }} already exists on npm." exit 1 fi + if npm view angular-render-scan-cli@${{ steps.package.outputs.version }} version >/dev/null 2>&1; then + echo "angular-render-scan-cli@${{ steps.package.outputs.version }} already exists on npm." + exit 1 + fi - name: Commit version bump run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add packages/angular-render-scan/package.json + git add packages/angular-render-scan/package.json packages/cli/package.json packages/cli/package-lock.json git commit -m "chore(release): v${{ steps.package.outputs.version }} [skip release]" git push origin HEAD:main - - name: Publish to NPM + - name: Publish package to NPM run: | if [ -n "${NODE_AUTH_TOKEN:-}" ]; then npm whoami @@ -72,6 +80,18 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} working-directory: packages/angular-render-scan + - name: Publish CLI to NPM + run: | + if [ -n "${NODE_AUTH_TOKEN:-}" ]; then + npm whoami + else + echo "NPM_TOKEN is not set; npm publish will use trusted publishing/OIDC if it is configured for this package." + fi + npm publish --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + working-directory: packages/cli + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: diff --git a/README.md b/README.md index 7b849f6..7945426 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,19 @@ # Angular Render Scan -Angular Render Scan is a visual debugging overlay for Angular change detection. It is inspired by the React Scan experience: install it, run your app, interact with the UI, and see which Angular components are updating, how often they update, and how long they take. +Angular Render Scan is a visual debugging overlay for Angular change detection. It shows which components update, how often they update, and which checks are slow or wasted. ![Angular Render Scan in Action](docs/assets/angular-render-scan-demo.gif) -[Live Demo](https://edisonaugusthy.github.io/angular-render-scan/) - -## Features - -- **Automatic Angular Telemetry:** Out-of-the-box zero-setup component auto-instrumentation using Angular dev-mode profiler hooks. -- **Heatmap & Outlines:** Highlights are colored dynamically based on DOM mutations: **green** for no-op wasted renders, **blue** for text/attribute mutations, and **prominent red borders** for expensive renders exceeding thresholds, making bottlenecks instantly recognizable. -- **CD Trigger Attribution:** Every change detection cycle is labeled with what triggered it — `zone:click`, `zone:setTimeout`, `zone:xhr`, `signal:write`, `router:navigation`, `manual:markForCheck`, and more. The toolbar shows the source of the last cycle at a glance. -- **OnPush Candidates Surface:** Automatically identifies `ChangeDetectionStrategy.Default` components with high wasted-render percentages and ranks them as OnPush conversion candidates with confidence scoring (high/medium/low). -- **Referential Input Stability Detection:** Tracks `@Input()` values across cycles using deep serialization and flags inputs where a new object reference carries the same value — the primary source of OnPush false positives. -- **Zone Pollution Detector:** Flags CD cycles that have no user interaction, no signal write, and no router navigation as suspected Zone pollution. Shows a live feed of polluted cycles in the toolbar. -- **Change Detection Graph:** Builds a session-level component graph of parent→child render relationships, including edge trigger counts and per-node CD strategy metadata. -- **CD Waterfall View:** Click the SVG sparkline in the toolbar to expand a nested horizontal bar breakdown of component check execution stack offsets and children offsets. -- **Non-Intrusive Budget Alerts Feed:** Standardized budget violations (warning/error millisecond limits and rate alerts) are elegantly grouped and logged in a collapsible alerts feed panel, handling concurrent violations cleanly. -- **Live CPU & Main-Thread Telemetry:** Dotted CPU metric toggles a live popup showing detailed frame-lag latency and total main-thread blocking times. -- **Memory Leak Detector Badge:** Automatically tracks zombie components whose DOM elements were disconnected but not properly destroyed. -- **Click-to-Source IDE Integration:** Inspected details panel provides an "Open in Editor" link that deep links directly to Cursor, VS Code or WebStorm, and automatically copies the class query to your clipboard for instant search. -- **Session Export JSON:** Download a full profiling JSON bundle including CPU, cycle timelines, wasted statistics, OnPush candidates, Zone pollution events, and referential instability reports. -- **Dark Mode & Theme Presets:** Sleek dark mode styles that match `prefers-color-scheme`, customizable dynamically via options. -- **Keyboard Shortcuts:** Keyboard hotkeys mapped to toggle scan, details panel, copy prompts, and clear stats instantly. -- **Production Guard:** Automatic safety guard shutting down package overhead entirely outside developer mode. Zone tracker lazy-loaded so it tree-shakes to zero in production bundles. +[Live Demo](https://edisonaugusthy.github.io/angular-render-scan/) | [npm](https://www.npmjs.com/package/angular-render-scan) + +## What it shows + +- Component render outlines and heatmap colors. +- Slow render and budget violation alerts. +- Change detection trigger labels such as `zone:click`, `signal:write`, and `router:navigation`. +- OnPush candidates, referentially unstable inputs, and suspected Zone pollution. +- A copyable AI performance prompt for slow/error components. +- Session export JSON for deeper debugging. ## Install @@ -31,27 +21,25 @@ Angular Render Scan is a visual debugging overlay for Angular change detection. npm install angular-render-scan ``` -Angular Render Scan expects Angular 9+ as a peer dependency. - -## Zero-Config Setup with the CLI +Angular Render Scan expects Angular 9+. -The fastest way to add Angular Render Scan to any project: +## Setup with the CLI ```sh npx angular-render-scan-cli init ``` -The CLI detects `angular.json`, finds your `main.ts` or `app.config.ts`, installs the npm package, and injects `provideAngularRenderScan()` into your providers — no manual editing required. +The CLI supports Angular CLI and Nx workspaces. It looks for `angular.json`, `workspace.json`, `nx.json`, or `project.json`, finds your `main.ts` or `app.config.ts`, and adds `provideAngularRenderScan()`. ```sh -npx angular-render-scan-cli init --dry-run # preview changes without writing -npx angular-render-scan-cli init --script-tag # use CDN script tag instead of npm +npx angular-render-scan-cli init --dry-run +npx angular-render-scan-cli init --script-tag npx angular-render-scan-cli --help ``` -## Quick Start (Manual) +## Manual Setup -Add `provideAngularRenderScan()` to your Angular bootstrap providers. +Add the provider to your Angular bootstrap config. ```ts import { bootstrapApplication } from '@angular/platform-browser'; @@ -68,123 +56,15 @@ bootstrapApplication(AppComponent, { }); ``` -Open your app in development mode and interact with the UI. Updated components will flash on screen and the toolbar will update live. - -## Script Usage - -The package also exposes a browser global build for script-tag style usage. +For script-tag usage: ```html ``` -The global build starts the overlay with default options. For Angular component-level instrumentation, provider mode is still recommended because it has access to Angular app references and dev-mode hooks. - -## API - -```ts -import { - copyAIPrompt, - getAIPrompt, - getOptions, - scan, - setOptions, - stop -} from 'angular-render-scan'; - -scan(); -setOptions({ enabled: false }); -setOptions({ enabled: true, log: true }); -console.log(getAIPrompt()); -await copyAIPrompt(); -console.log(getOptions()); -stop(); -``` - -### `scan(options?)` - -Starts Angular Render Scan and creates the overlay if it is not already mounted. - -```ts -scan({ - enabled: true, - showToolbar: true, - animationSpeed: 'fast' -}); -``` - -### `setOptions(options)` - -Updates scanner options at runtime. - -```ts -setOptions({ - log: true, - animationSpeed: 'slow' -}); -``` - -### `getOptions()` - -Returns the current resolved options. - -```ts -const options = getOptions(); -``` - -### `stop()` +Provider mode is recommended for Angular component-level instrumentation. -Destroys the overlay and clears scanner state. - -```ts -stop(); -``` - -## Options -```ts -interface AngularRenderScanOptions { - enabled?: boolean; - showToolbar?: boolean; - animationSpeed?: 'slow' | 'fast' | 'off'; - showFPS?: boolean; - log?: boolean; - dangerouslyForceRunInProduction?: boolean; - minDurationMs?: number; - minRenderCount?: number; - include?: Array; - exclude?: Array; - maxLabelCount?: number; - maxRecordedCycles?: number; - showCopyPrompt?: boolean; - promptContext?: string; - theme?: Partial; - editorProtocol?: 'vscode' | 'webstorm' | 'cursor' | string; - darkMode?: 'auto' | 'dark' | 'light'; - - // CD Trigger Attribution - showCdGraph?: boolean; - - // Zone Pollution Detector - maxZonePollutionEvents?: number; // default: 50 - onZonePollution?: (event: ZonePollutionEvent) => void; - - // OnPush Candidates - onPushCandidateThreshold?: number; // wasted-render % threshold, default: 40 - trackComponents?: Array; // limit tracking to specific components - - // Referential Input Stability - trackReferentialStability?: boolean; // default: true - referentialStabilityDepth?: number; // deep-equal max depth, default: 4 - - // Callbacks - onCycleStart?: () => void; - onRender?: (entry: AngularRenderEntry) => void; - onCycleFinish?: (cycle: AngularRenderCycle) => void; - onBudgetViolation?: (violation: BudgetViolation) => void; -} -``` - -### Common Options +## Common Options ```ts provideAngularRenderScan({ @@ -195,285 +75,96 @@ provideAngularRenderScan({ maxLabelCount: 20, maxRecordedCycles: 30, showCopyPrompt: true, + promptContext: 'Angular app using signals and OnPush components', log: false }); ``` +Useful options: + - `enabled`: turns scanning on or off. -- `showToolbar`: shows or hides the floating toolbar. -- `animationSpeed`: controls highlight readability. `'fast'` keeps borders visible for about 1.2s, `'slow'` keeps them visible for about 2.4s, and `'off'` disables visual flashes. +- `showToolbar`: shows the floating toolbar. +- `animationSpeed`: `'fast'`, `'slow'`, or `'off'`. - `showFPS`: shows FPS in the toolbar. - `log`: prints cycle summaries to the console. -- `dangerouslyForceRunInProduction`: allows the scanner to run outside Angular dev mode. -- `minDurationMs`, `minRenderCount`, `include`, `exclude`: filter low-signal render entries. -- `maxLabelCount`: limits how many highlighted components receive labels. -- `maxRecordedCycles`: controls how many recent cycles are included in the copied AI prompt. -- `showCopyPrompt`, `promptContext`: control the copyable AI performance prompt. - -### Basic Debug Config - -```ts -provideAngularRenderScan({ - enabled: true, - showToolbar: true, - animationSpeed: 'slow', - maxLabelCount: 12, - maxRecordedCycles: 20, - promptContext: 'Angular app using signals and OnPush components' -}); -``` - -Use `animationSpeed: 'slow'` when you want more time to read the borders and labels while interacting with the page. - -## Callbacks - -```ts -provideAngularRenderScan({ - onCycleStart() { - console.log('cycle started'); - }, - onRender(entry) { - console.log(entry.name, entry.latestDuration); - }, - onCycleFinish(cycle) { - console.log(cycle.renderedCount, cycle.slowest?.name); - } -}); -``` - -```ts -interface AngularRenderEntry { - id: string; - name: string; - element: Element; - rect: DOMRect; - count: number; - latestDuration: number; - averageDuration: number; - latestCycleId: number; - reason?: 'input' | 'event' | 'tick' | 'dom' | 'unknown'; - changedInputs?: Array<{ name: string; previous: string; current: string }>; - selector?: string; -} - -interface AngularRenderCycle { - id: number; - startedAt: number; - finishedAt: number; - duration: number; - renderedCount: number; - slowest?: AngularRenderEntry; - entries: AngularRenderEntry[]; -} -``` +- `include` / `exclude`: limits which components are tracked. +- `minDurationMs` / `minRenderCount`: filters low-signal entries. +- `maxRecordedCycles`: controls how much history is included in copied prompts. +- `promptContext`: adds app-specific context to copied prompts. +- `dangerouslyForceRunInProduction`: allows scanner runtime outside Angular dev mode. -## Theme - -Use `theme` to tune the highlight colors. - -```ts -provideAngularRenderScan({ - theme: { - fast: [147, 197, 253], - medium: [253, 224, 71], - slow: [239, 68, 68], - labelBackground: [124, 58, 237], - labelBackgroundSlow: [220, 38, 38] - } -}); -``` - -```ts -interface AngularRenderScanTheme { - fast: readonly [number, number, number]; - medium: readonly [number, number, number]; - slow: readonly [number, number, number]; - labelBackground: readonly [number, number, number]; - labelBackgroundSlow: readonly [number, number, number]; -} -``` - -## New API Functions +## Runtime API ```ts import { + copyAIPrompt, + getAIPrompt, + getCdGraph, getOnPushCandidates, + getOptions, getReferentialInstability, getZonePollutionEvents, - getCdGraph + scan, + setOptions, + stop } from 'angular-render-scan'; -// Components that are safe to switch to ChangeDetectionStrategy.OnPush -const candidates = getOnPushCandidates(40); // threshold: wasted-render % - -// Inputs where a new reference carried the same value -const unstable = getReferentialInstability(1); // minUnstableRefs - -// Cycles that fired with no user interaction / signal / router trigger -const pollution = getZonePollutionEvents(); - -// Session-level component dependency graph -const graph = getCdGraph(); -// graph.nodes: per-component stats (cdStrategy, renderCount, wastedChecks, isOnPushCandidate) -// graph.edges: parent→child trigger counts -``` - -### OnPush Candidate shape - -```ts -interface OnPushCandidate { - componentId: string; - selector: string; - wastedRenderPct: number; // percentage of renders that were no-ops - totalChecks: number; - confidence: 'high' | 'medium' | 'low'; - reason: string; -} -``` - -### Zone Pollution Event shape - -```ts -interface ZonePollutionEvent { - timestamp: number; - cycleId: string; - suspectedTrigger: string; // best-guess Zone task description - componentCount: number; -} -``` - -### Referential Instability Report shape - -```ts -interface ReferentialInstabilityReport { - componentId: string; - selector: string; - inputKey: string; - unstableRefCount: number; // how many times a new ref held the same value - totalInputChanges: number; -} -``` +scan(); +setOptions({ enabled: false }); +setOptions({ enabled: true, log: true }); -### Production no-op subpath +console.log(getOptions()); +console.log(getAIPrompt()); +await copyAIPrompt(); -For SSR, unit tests, or any context where you want to explicitly import stubs: +console.log(getOnPushCandidates(40)); +console.log(getReferentialInstability(1)); +console.log(getZonePollutionEvents()); +console.log(getCdGraph()); -```ts -import { getOnPushCandidates } from 'angular-render-scan/noop'; -// All functions return empty arrays / null — safe to call anywhere +stop(); ``` ## Toolbar -The toolbar shows: -- scan on/off switch -- FPS -- latest cycle time -- CD trigger source (last cycle — e.g. `zone:click`, `signal:write`) -- changed component count -- slowest component -- OnPush candidates chip (count — click to open ranked list) -- Zone pollution chip (count — click to open event feed) -- copy slow issues prompt -- clear stats button - -Drag the toolbar to move it. Use `Details` to inspect one component at a time and pin a recommendation panel. - -## Details Mode - -Use the `Details` checkbox in the toolbar to inspect individual components without keyboard modifiers. - -1. Interact with the page so Angular Render Scan captures a render cycle. -2. Check `Details` in the toolbar. -3. Hover over a captured component to show a dashed highlight. -4. Click the component to pin the recommendation panel. -5. Close the panel when finished. - -The recommendation panel shows severity, latest duration, average duration, render count, reason, selector, changed inputs, recent cycles, estimated cost, and component-local Angular recommendations based on the captured issue. For slow components, the panel also shows `Copy Slow Issue Prompt`, which copies a prompt for only that component. +The toolbar shows render count, FPS, latest cycle time, slowest component, trigger source, OnPush candidates, Zone pollution events, alerts, and copy/export actions. -## AI Performance Prompt +Useful shortcuts: -Use `Copy Slow Issues Prompt` in the toolbar, or call `getAIPrompt()` / `copyAIPrompt()`, to generate a self-contained prompt for an AI coding assistant. The prompt includes environment details, recent cycle history, the latest cycle, configured thresholds, and an issue list for components exceeding the performance warning threshold (10ms by default). - -The copied prompt is intentionally focused: it does not copy every render entry. It lists slow components with selector, latest render time, average render time, render count, reason, changed inputs when available, and an estimated cost based on latest duration, cycle share, and observed render count. It does not include raw DOM nodes, component instances, or source code. - -```ts -provideAngularRenderScan({ - promptContext: 'Angular 18 app using signals and OnPush components', - maxRecordedCycles: 20 -}); -``` - -## Keyboard Shortcuts +| Shortcut | Action | +|---|---| +| `Alt+Shift+S` | Toggle scanner | +| `Alt+Shift+D` | Toggle Details Mode | +| `Alt+Shift+C` | Copy AI performance prompt | +| `Alt+Shift+X` | Clear stats | +| `Alt+Shift+T` | Toggle toolbar | +| `Escape` | Close open panels | -The visual overlay responds to the following keyboard shortcuts when enabled: +## Details and AI Prompt -| Shortcut | Description | -|---|---| -| `Alt+Shift+S` | Toggles the active/enabled state of the scanner. | -| `Alt+Shift+D` | Toggles Details Mode (hover inspect and recommendations). | -| `Alt+Shift+C` | Copies the AI performance diagnostic prompt. | -| `Alt+Shift+X` | Clears all telemetry counts and history. | -| `Alt+Shift+T` | Toggles the floating toolbar visibility. | -| `Escape` | Closes any pinned recommendation, CPU breakdown, or CD waterfall panel. | +Enable `Details` in the toolbar, hover a captured component, then click it to pin a recommendation panel. The panel shows timing, render count, reason, selector, changed inputs, recent cycles, and local Angular recommendations. -## Playwright Headless Audit API +Use `Copy Slow Issues Prompt` to copy a focused prompt for an AI coding assistant. It includes recent cycle history, thresholds, and slow/error component evidence without copying DOM nodes, component instances, or source code. -You can programmatically verify Angular performance inside Playwright end-to-end tests using the headless audit API. +## Playwright Audit ```ts import { test, expect } from '@playwright/test'; import { startRenderAudit } from 'angular-render-scan'; -test('verify no performance regressions or wasted checks', async ({ page }) => { +test('no render regression', async ({ page }) => { await page.goto('/'); - // Start the audit session const audit = await startRenderAudit(page); - - // Interact with the page await page.click('button.expensive-operation'); - - // Stop the audit session and fetch the telemetry report const report = await audit.stop(); - // Validate rendering frequency - const cardRenders = await report.rendersFor('ProductCardComponent'); - expect(cardRenders).toBeLessThanOrEqual(2); - - // Validate render duration (ms) - const maxDuration = await report.maxDurationFor('ProductCardComponent'); - expect(maxDuration).toBeLessThan(16.7); // smooth 60fps check - - // Validate overall no-op waste ratio - const wasteRatio = await report.wastedRenderPercentage(); - expect(wasteRatio).toBeLessThan(20); // max 20% wasted checks - - // Validate budget violations - const violations = await report.budgetViolations(); - expect(violations.length).toBe(0); + expect(await report.maxDurationFor('ProductCardComponent')).toBeLessThan(16.7); + expect(await report.wastedRenderPercentage()).toBeLessThan(20); + expect(await report.budgetViolations()).toHaveLength(0); }); ``` -## Manual Marking - -Automatic instrumentation is preferred. If you need a specific manual target, you can still mark an element with `AngularRenderScanMarkDirective`. - -```ts -import { AngularRenderScanMarkDirective } from 'angular-render-scan'; - -@Component({ - standalone: true, - imports: [AngularRenderScanMarkDirective], - template: ` -
- ... -
- ` -}) -export class CartSummaryComponent {} -``` - ## Production Behavior Angular Render Scan is intended for development and demo debugging. Provider mode checks Angular `isDevMode()` and does not run in production unless explicitly enabled. @@ -484,30 +175,24 @@ provideAngularRenderScan({ }); ``` -Use that option carefully. The scanner adds runtime instrumentation, DOM reads, canvas work, and console/debug behavior. +Use that option carefully. The scanner adds runtime instrumentation, DOM reads, canvas work, and debug behavior. ## Demo -Open the hosted demo: +Hosted demo: ```txt https://edisonaugusthy.github.io/angular-render-scan/ ``` -Run the local demo: +Local demo: ```sh npm install npm run dev ``` -Open: - -```txt -http://127.0.0.1:4200/ -``` - -The demo includes signal updates, `OnPush` updates, nested components, and intentionally slow work to show the heatmap behavior. +Open `http://127.0.0.1:4200/`. ## Development @@ -517,26 +202,8 @@ npm run build npm run test:e2e ``` -Useful project docs: - -- [agent.md](agent.md): DDD rules, domain boundaries, type/style guide, and quality bar. -- [feature.md](feature.md): feature spec, domain model, and roadmap. - ## Release -Release runs automatically when changes are pushed to `main`. The workflow bumps -`packages/angular-render-scan/package.json` by one patch version, commits that -version bump back to `main`, publishes the package to npm, and creates a GitHub -release for the new version. - -Before the first automated publish, add a valid npm publish token as the GitHub -Actions secret `NPM_TOKEN`. After the package exists on npm, you can instead -configure npm trusted publishing for repository `edisonaugusthy/angular-render-scan` -and workflow filename `release.yml`. - -To publish manually: +Release runs automatically on pushes to `main`. The workflow bumps the package version, publishes `angular-render-scan` and `angular-render-scan-cli` to npm, and creates a GitHub release. -1. Go to GitHub Actions. -2. Select `Release`. -3. Choose the `main` branch. -4. Click `Run workflow`. +Configure `NPM_TOKEN` or npm trusted publishing for both packages before publishing. diff --git a/package.json b/package.json index ae9c2c2..526c8ef 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,9 @@ "private": true, "type": "module", "scripts": { - "build": "npm run build:scan && npm run build:demo", + "build": "npm run build:scan && npm run build:cli && npm run build:demo", "build:scan": "ng-packagr -p packages/angular-render-scan/ng-package.json -c packages/angular-render-scan/tsconfig.lib.json && tsup --config packages/angular-render-scan/tsup.config.ts && cp packages/angular-render-scan/dist/auto.global.global.js packages/angular-render-scan/dist/auto.global.js", + "build:cli": "npm ci --prefix packages/cli && npm run build --prefix packages/cli", "build:demo": "ng build angular-render-scan-demo", "dev": "ng serve angular-render-scan-demo --host 127.0.0.1 --port 4200", "test": "vitest run", diff --git a/packages/angular-render-scan/README.md b/packages/angular-render-scan/README.md index 6c42ed6..c6ca43b 100644 --- a/packages/angular-render-scan/README.md +++ b/packages/angular-render-scan/README.md @@ -1,24 +1,19 @@ # Angular Render Scan -Angular Render Scan is a visual debugging overlay for Angular change detection. It is inspired by the React Scan experience: install it, run your app, interact with the UI, and see which Angular components are updating, how often they update, and how long they take. +Angular Render Scan is a visual debugging overlay for Angular change detection. It shows which components update, how often they update, and which checks are slow or wasted. ![Angular Render Scan in Action](https://raw.githubusercontent.com/edisonaugusthy/angular-render-scan/main/docs/assets/angular-render-scan-demo.gif) -[Live Demo](https://edisonaugusthy.github.io/angular-render-scan/) +[Live Demo](https://edisonaugusthy.github.io/angular-render-scan/) | [npm](https://www.npmjs.com/package/angular-render-scan) -## Features +## What it shows -- **Automatic Angular Telemetry:** Out-of-the-box zero-setup component auto-instrumentation using Angular dev-mode profiler hooks. -- **Heatmap & Outlines:** Highlights are colored dynamically based on DOM mutations: **green** for no-op wasted renders, **blue** for text/attribute mutations, and **prominent red borders** for expensive renders exceeding thresholds, making bottlenecks instantly recognizable. -- **CD Waterfall View:** Click the SVG sparkline in the toolbar to expand a nested horizontal bar breakdown of component check execution stack offsets and children offsets. -- **Non-Intrusive Budget Alerts Feed:** Standardized budget violations (warning/error millisecond limits and rate alerts) are elegantly grouped and logged in a collapsible alerts feed panel, handling concurrent violations cleanly. -- **Live CPU & Main-Thread Telemetry:** Dotted CPU metric toggles a live popup showing detailed frame-lag latency and total main-thread blocking times. -- **Memory Leak Detector Badge:** Automatically tracks zombie components whose DOM elements were disconnected but not properly destroyed. -- **Click-to-Source IDE Integration:** Inspected details panel provides an "Open in Editor" link that deep links directly to Cursor, VS Code or WebStorm, and automatically copies the class query to your clipboard for instant search. -- **Session Export JSON:** Download a full profiling JSON bundle including CPU, cycle timelines, wasted statistics, and active budget violation logs. -- **Dark Mode & Theme Presets:** Sleek dark mode styles that match `prefers-color-scheme`, customizable dynamically via options. -- **Keyboard Shortcuts:** Keyboard hotkeys mapped to toggle scan, details panel, copy prompts, and clear stats instantly. -- **Production Guard:** Automatic safety guard shutting down package overhead entirely outside developer mode. +- Component render outlines and heatmap colors. +- Slow render and budget violation alerts. +- Change detection trigger labels such as `zone:click`, `signal:write`, and `router:navigation`. +- OnPush candidates, referentially unstable inputs, and suspected Zone pollution. +- A copyable AI performance prompt for slow/error components. +- Session export JSON for deeper debugging. ## Install @@ -26,11 +21,25 @@ Angular Render Scan is a visual debugging overlay for Angular change detection. npm install angular-render-scan ``` -Angular Render Scan expects Angular 9+ as a peer dependency. +Angular Render Scan expects Angular 9+. -## Quick Start +## Setup with the CLI -Add `provideAngularRenderScan()` to your Angular bootstrap providers. +```sh +npx angular-render-scan-cli init +``` + +The CLI supports Angular CLI and Nx workspaces. It looks for `angular.json`, `workspace.json`, `nx.json`, or `project.json`, finds your `main.ts` or `app.config.ts`, and adds `provideAngularRenderScan()`. + +```sh +npx angular-render-scan-cli init --dry-run +npx angular-render-scan-cli init --script-tag +npx angular-render-scan-cli --help +``` + +## Manual Setup + +Add the provider to your Angular bootstrap config. ```ts import { bootstrapApplication } from '@angular/platform-browser'; @@ -47,107 +56,15 @@ bootstrapApplication(AppComponent, { }); ``` -Open your app in development mode and interact with the UI. Updated components will flash on screen and the toolbar will update live. - -## Script Usage - -The package also exposes a browser global build for script-tag style usage. +For script-tag usage: ```html ``` -The global build starts the overlay with default options. For Angular component-level instrumentation, provider mode is still recommended because it has access to Angular app references and dev-mode hooks. - -## API - -```ts -import { - copyAIPrompt, - getAIPrompt, - getOptions, - scan, - setOptions, - stop -} from 'angular-render-scan'; - -scan(); -setOptions({ enabled: false }); -setOptions({ enabled: true, log: true }); -console.log(getAIPrompt()); -await copyAIPrompt(); -console.log(getOptions()); -stop(); -``` - -### `scan(options?)` - -Starts Angular Render Scan and creates the overlay if it is not already mounted. - -```ts -scan({ - enabled: true, - showToolbar: true, - animationSpeed: 'fast' -}); -``` - -### `setOptions(options)` - -Updates scanner options at runtime. - -```ts -setOptions({ - log: true, - animationSpeed: 'slow' -}); -``` - -### `getOptions()` - -Returns the current resolved options. - -```ts -const options = getOptions(); -``` - -### `stop()` - -Destroys the overlay and clears scanner state. - -```ts -stop(); -``` - -## Options +Provider mode is recommended for Angular component-level instrumentation. -```ts -interface AngularRenderScanOptions { - enabled?: boolean; - showToolbar?: boolean; - animationSpeed?: 'slow' | 'fast' | 'off'; - showFPS?: boolean; - log?: boolean; - dangerouslyForceRunInProduction?: boolean; - minDurationMs?: number; - minRenderCount?: number; - include?: Array; - exclude?: Array; - maxLabelCount?: number; - maxRecordedCycles?: number; - showCopyPrompt?: boolean; - promptContext?: string; - theme?: Partial; - editorProtocol?: 'vscode' | 'webstorm' | 'cursor' | string; - darkMode?: 'auto' | 'dark' | 'light'; - onCycleStart?: () => void; - onRender?: (entry: AngularRenderEntry) => void; - onCycleFinish?: (cycle: AngularRenderCycle) => void; - onBudgetViolation?: (violation: BudgetViolation) => void; -} -``` - -### Common Options +## Common Options ```ts provideAngularRenderScan({ @@ -158,213 +75,96 @@ provideAngularRenderScan({ maxLabelCount: 20, maxRecordedCycles: 30, showCopyPrompt: true, + promptContext: 'Angular app using signals and OnPush components', log: false }); ``` +Useful options: + - `enabled`: turns scanning on or off. -- `showToolbar`: shows or hides the floating toolbar. -- `animationSpeed`: controls highlight readability. `'fast'` keeps borders visible for about 1.2s, `'slow'` keeps them visible for about 2.4s, and `'off'` disables visual flashes. +- `showToolbar`: shows the floating toolbar. +- `animationSpeed`: `'fast'`, `'slow'`, or `'off'`. - `showFPS`: shows FPS in the toolbar. - `log`: prints cycle summaries to the console. -- `dangerouslyForceRunInProduction`: allows the scanner to run outside Angular dev mode. -- `minDurationMs`, `minRenderCount`, `include`, `exclude`: filter low-signal render entries. -- `maxLabelCount`: limits how many highlighted components receive labels. -- `maxRecordedCycles`: controls how many recent cycles are included in the copied AI prompt. -- `showCopyPrompt`, `promptContext`: control the copyable AI performance prompt. - -### Basic Debug Config - -```ts -provideAngularRenderScan({ - enabled: true, - showToolbar: true, - animationSpeed: 'slow', - maxLabelCount: 12, - maxRecordedCycles: 20, - promptContext: 'Angular app using signals and OnPush components' -}); -``` +- `include` / `exclude`: limits which components are tracked. +- `minDurationMs` / `minRenderCount`: filters low-signal entries. +- `maxRecordedCycles`: controls how much history is included in copied prompts. +- `promptContext`: adds app-specific context to copied prompts. +- `dangerouslyForceRunInProduction`: allows scanner runtime outside Angular dev mode. -Use `animationSpeed: 'slow'` when you want more time to read the borders and labels while interacting with the page. - -## Callbacks - -```ts -provideAngularRenderScan({ - onCycleStart() { - console.log('cycle started'); - }, - onRender(entry) { - console.log(entry.name, entry.latestDuration); - }, - onCycleFinish(cycle) { - console.log(cycle.renderedCount, cycle.slowest?.name); - } -}); -``` +## Runtime API ```ts -interface AngularRenderEntry { - id: string; - name: string; - element: Element; - rect: DOMRect; - count: number; - latestDuration: number; - averageDuration: number; - latestCycleId: number; - reason?: 'input' | 'event' | 'tick' | 'dom' | 'unknown'; - changedInputs?: Array<{ name: string; previous: string; current: string }>; - selector?: string; -} - -interface AngularRenderCycle { - id: number; - startedAt: number; - finishedAt: number; - duration: number; - renderedCount: number; - slowest?: AngularRenderEntry; - entries: AngularRenderEntry[]; -} -``` +import { + copyAIPrompt, + getAIPrompt, + getCdGraph, + getOnPushCandidates, + getOptions, + getReferentialInstability, + getZonePollutionEvents, + scan, + setOptions, + stop +} from 'angular-render-scan'; -## Theme +scan(); +setOptions({ enabled: false }); +setOptions({ enabled: true, log: true }); -Use `theme` to tune the highlight colors. +console.log(getOptions()); +console.log(getAIPrompt()); +await copyAIPrompt(); -```ts -provideAngularRenderScan({ - theme: { - fast: [147, 197, 253], - medium: [253, 224, 71], - slow: [239, 68, 68], - labelBackground: [124, 58, 237], - labelBackgroundSlow: [220, 38, 38] - } -}); -``` +console.log(getOnPushCandidates(40)); +console.log(getReferentialInstability(1)); +console.log(getZonePollutionEvents()); +console.log(getCdGraph()); -```ts -interface AngularRenderScanTheme { - fast: readonly [number, number, number]; - medium: readonly [number, number, number]; - slow: readonly [number, number, number]; - labelBackground: readonly [number, number, number]; - labelBackgroundSlow: readonly [number, number, number]; -} +stop(); ``` ## Toolbar -The toolbar shows: - -- scan on/off switch -- FPS -- latest cycle time -- changed component count -- slowest component -- copy slow issues prompt -- clear stats button +The toolbar shows render count, FPS, latest cycle time, slowest component, trigger source, OnPush candidates, Zone pollution events, alerts, and copy/export actions. -Drag the toolbar to move it. Use `Details` to inspect one component at a time and pin a recommendation panel. +Useful shortcuts: -## Details Mode - -Use the `Details` checkbox in the toolbar to inspect individual components without keyboard modifiers. - -1. Interact with the page so Angular Render Scan captures a render cycle. -2. Check `Details` in the toolbar. -3. Hover over a captured component to show a dashed highlight. -4. Click the component to pin the recommendation panel. -5. Close the panel when finished. - -The recommendation panel shows severity, latest duration, average duration, render count, reason, selector, changed inputs, recent cycles, estimated cost, and component-local Angular recommendations based on the captured issue. For slow components, the panel also shows `Copy Slow Issue Prompt`, which copies a prompt for only that component. - -## AI Performance Prompt - -Use `Copy Slow Issues Prompt` in the toolbar, or call `getAIPrompt()` / `copyAIPrompt()`, to generate a self-contained prompt for an AI coding assistant. The prompt includes environment details, recent cycle history, the latest cycle, configured thresholds, and an issue list for components exceeding the performance warning threshold (10ms by default). - -The copied prompt is intentionally focused: it does not copy every render entry. It lists slow components with selector, latest render time, average render time, render count, reason, changed inputs when available, and an estimated cost based on latest duration, cycle share, and observed render count. It does not include raw DOM nodes, component instances, or source code. - -```ts -provideAngularRenderScan({ - promptContext: 'Angular 18 app using signals and OnPush components', - maxRecordedCycles: 20 -}); -``` - -## Keyboard Shortcuts +| Shortcut | Action | +|---|---| +| `Alt+Shift+S` | Toggle scanner | +| `Alt+Shift+D` | Toggle Details Mode | +| `Alt+Shift+C` | Copy AI performance prompt | +| `Alt+Shift+X` | Clear stats | +| `Alt+Shift+T` | Toggle toolbar | +| `Escape` | Close open panels | -The visual overlay responds to the following keyboard shortcuts when enabled: +## Details and AI Prompt -| Shortcut | Description | -|---|---| -| `Alt+Shift+S` | Toggles the active/enabled state of the scanner. | -| `Alt+Shift+D` | Toggles Details Mode (hover inspect and recommendations). | -| `Alt+Shift+C` | Copies the AI performance diagnostic prompt. | -| `Alt+Shift+X` | Clears all telemetry counts and history. | -| `Alt+Shift+T` | Toggles the floating toolbar visibility. | -| `Escape` | Closes any pinned recommendation, CPU breakdown, or CD waterfall panel. | +Enable `Details` in the toolbar, hover a captured component, then click it to pin a recommendation panel. The panel shows timing, render count, reason, selector, changed inputs, recent cycles, and local Angular recommendations. -## Playwright Headless Audit API +Use `Copy Slow Issues Prompt` to copy a focused prompt for an AI coding assistant. It includes recent cycle history, thresholds, and slow/error component evidence without copying DOM nodes, component instances, or source code. -You can programmatically verify Angular performance inside Playwright end-to-end tests using the headless audit API. +## Playwright Audit ```ts import { test, expect } from '@playwright/test'; import { startRenderAudit } from 'angular-render-scan'; -test('verify no performance regressions or wasted checks', async ({ page }) => { +test('no render regression', async ({ page }) => { await page.goto('/'); - // Start the audit session const audit = await startRenderAudit(page); - - // Interact with the page await page.click('button.expensive-operation'); - - // Stop the audit session and fetch the telemetry report const report = await audit.stop(); - // Validate rendering frequency - const cardRenders = await report.rendersFor('ProductCardComponent'); - expect(cardRenders).toBeLessThanOrEqual(2); - - // Validate render duration (ms) - const maxDuration = await report.maxDurationFor('ProductCardComponent'); - expect(maxDuration).toBeLessThan(16.7); // smooth 60fps check - - // Validate overall no-op waste ratio - const wasteRatio = await report.wastedRenderPercentage(); - expect(wasteRatio).toBeLessThan(20); // max 20% wasted checks - - // Validate budget violations - const violations = await report.budgetViolations(); - expect(violations.length).toBe(0); + expect(await report.maxDurationFor('ProductCardComponent')).toBeLessThan(16.7); + expect(await report.wastedRenderPercentage()).toBeLessThan(20); + expect(await report.budgetViolations()).toHaveLength(0); }); ``` -## Manual Marking - -Automatic instrumentation is preferred. If you need a specific manual target, you can still mark an element with `AngularRenderScanMarkDirective`. - -```ts -import { AngularRenderScanMarkDirective } from 'angular-render-scan'; - -@Component({ - standalone: true, - imports: [AngularRenderScanMarkDirective], - template: ` -
- ... -
- ` -}) -export class CartSummaryComponent {} -``` - ## Production Behavior Angular Render Scan is intended for development and demo debugging. Provider mode checks Angular `isDevMode()` and does not run in production unless explicitly enabled. @@ -375,30 +175,24 @@ provideAngularRenderScan({ }); ``` -Use that option carefully. The scanner adds runtime instrumentation, DOM reads, canvas work, and console/debug behavior. +Use that option carefully. The scanner adds runtime instrumentation, DOM reads, canvas work, and debug behavior. ## Demo -Open the hosted demo: +Hosted demo: ```txt https://edisonaugusthy.github.io/angular-render-scan/ ``` -Run the local demo: +Local demo: ```sh npm install npm run dev ``` -Open: - -```txt -http://127.0.0.1:4200/ -``` - -The demo includes signal updates, `OnPush` updates, nested components, and intentionally slow work to show the heatmap behavior. +Open `http://127.0.0.1:4200/`. ## Development @@ -408,26 +202,8 @@ npm run build npm run test:e2e ``` -Useful project docs: - -- [agent.md](agent.md): DDD rules, domain boundaries, type/style guide, and quality bar. -- [feature.md](feature.md): feature spec, domain model, and roadmap. - ## Release -Release runs automatically when changes are pushed to `main`. The workflow bumps -`packages/angular-render-scan/package.json` by one patch version, commits that -version bump back to `main`, publishes the package to npm, and creates a GitHub -release for the new version. - -Before the first automated publish, add a valid npm publish token as the GitHub -Actions secret `NPM_TOKEN`. After the package exists on npm, you can instead -configure npm trusted publishing for repository `edisonaugusthy/angular-render-scan` -and workflow filename `release.yml`. - -To publish manually: +Release runs automatically on pushes to `main`. The workflow bumps the package version, publishes `angular-render-scan` and `angular-render-scan-cli` to npm, and creates a GitHub release. -1. Go to GitHub Actions. -2. Select `Release`. -3. Choose the `main` branch. -4. Click `Run workflow`. +Configure `NPM_TOKEN` or npm trusted publishing for both packages before publishing. diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..81ed5f6 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,27 @@ +# angular-render-scan-cli + +CLI installer for [angular-render-scan](https://www.npmjs.com/package/angular-render-scan). + +## Usage + +```sh +npx angular-render-scan-cli init +``` + +The CLI detects Angular CLI and Nx workspaces (`angular.json`, `workspace.json`, `nx.json`, or `project.json`), finds your `main.ts` or `app.config.ts`, installs `angular-render-scan`, and injects `provideAngularRenderScan()` into your Angular providers. + +## Options + +```sh +npx angular-render-scan-cli init --dry-run +npx angular-render-scan-cli init --script-tag +npx angular-render-scan-cli --help +``` + +- `--dry-run`: preview changes without writing files. +- `--script-tag`: add the CDN script tag instead of provider setup. +- `--force`: patch even if `angular-render-scan` is already present. + +## Docs + +https://github.com/edisonaugusthy/angular-render-scan diff --git a/packages/cli/bin/cli.js b/packages/cli/bin/cli.js index aedf38c..4862568 100644 --- a/packages/cli/bin/cli.js +++ b/packages/cli/bin/cli.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Entry point for `npx angular-render-scan` +// Entry point for `npx angular-render-scan-cli` // This thin shim imports the compiled TypeScript and calls run(). import { run } from '../dist/index.js'; diff --git a/packages/cli/package-lock.json b/packages/cli/package-lock.json index e3aa5dd..fb9dbbd 100644 --- a/packages/cli/package-lock.json +++ b/packages/cli/package-lock.json @@ -12,6 +12,7 @@ "picocolors": "^1.1.0" }, "bin": { + "angular-render-scan-cli": "bin/cli.js", "angular-render-scan": "bin/cli.js" }, "devDependencies": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 89afcea..202fa44 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,9 +10,23 @@ "angular-render-scan" ], "license": "MIT", + "homepage": "https://github.com/edisonaugusthy/angular-render-scan#readme", + "bugs": { + "url": "https://github.com/edisonaugusthy/angular-render-scan/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/edisonaugusthy/angular-render-scan.git", + "directory": "packages/cli" + }, "type": "module", "main": "./dist/index.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "prepack": "npm run build" + }, "bin": { + "angular-render-scan-cli": "./bin/cli.js", "angular-render-scan": "./bin/cli.js" }, "files": [ diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index aaeca46..d6c5d8b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,8 +1,8 @@ /** * angular-render-scan CLI * - * Usage: npx angular-render-scan init - * npx angular-render-scan init --force + * Usage: npx angular-render-scan-cli init + * npx angular-render-scan-cli init --force * * Automatically detects your Angular project structure and inserts * provideAngularRenderScan() into your bootstrap providers, or adds @@ -57,34 +57,32 @@ function writeFile(filePath: string, content: string): void { fs.writeFileSync(filePath, content, 'utf-8'); } -function findAngularJson(): string | null { - return findUp('angular.json'); +function findWorkspaceConfig(): string | null { + return findUp('angular.json') ?? findUp('workspace.json') ?? findUp('nx.json') ?? findUp('project.json'); } /** * Find the Angular app entry file. Priority: - * 1. src/main.ts relative to angular.json - * 2. apps/[name]/src/main.ts (Nx monorepo) + * 1. build target main/browser from angular.json, workspace.json, or Nx project.json + * 2. src/main.ts relative to workspace/project root + * 3. apps/[name]/src/main.ts (Nx monorepo) * 3. Any main.ts near the angular.json */ -function findMainTs(angularJsonPath: string): string | null { - const root = path.dirname(angularJsonPath); +function findMainTs(workspaceConfigPath: string): string | null { + const root = path.dirname(workspaceConfigPath); + const configName = path.basename(workspaceConfigPath); - // Try to parse angular.json for the main file + // Try to parse Angular/Nx workspace files for the configured app entrypoint. try { - const angularJson = JSON.parse(readFile(angularJsonPath)); - const projects = angularJson.projects ?? {}; - for (const projKey of Object.keys(projects)) { - const proj = projects[projKey]; - const mainFile = - proj?.architect?.build?.options?.main || - proj?.architect?.build?.options?.browser || - proj?.targets?.build?.options?.main || - proj?.targets?.build?.options?.browser; - if (mainFile) { - const resolved = path.resolve(root, mainFile); - if (fs.existsSync(resolved)) return resolved; - } + const config = JSON.parse(readFile(workspaceConfigPath)); + const directProject = configName === 'project.json' + ? entrypointFromProjectConfig(config, root, root) + : null; + if (directProject) return directProject; + + for (const project of projectConfigsFromWorkspace(config, root)) { + const mainFile = entrypointFromProjectConfig(project.config, root, project.projectRoot); + if (mainFile) return mainFile; } } catch { // Ignore parse errors @@ -94,12 +92,81 @@ function findMainTs(angularJsonPath: string): string | null { const candidates = [ path.join(root, 'src', 'main.ts'), path.join(root, 'src', 'main.server.ts'), - ...glob(path.join(root, 'apps'), 'main.ts', 3) + ...glob(path.join(root, 'apps'), 'main.ts', 4), + ...glob(path.join(root, 'packages'), 'main.ts', 4) ]; return candidates.find(f => fs.existsSync(f)) ?? null; } +type ProjectConfigRef = { + config: any; + projectRoot: string; +}; + +function projectConfigsFromWorkspace(workspaceConfig: any, workspaceRoot: string): ProjectConfigRef[] { + const refs: ProjectConfigRef[] = []; + const projects = workspaceConfig.projects ?? {}; + + for (const [name, value] of Object.entries(projects)) { + if (typeof value === 'string') { + const projectRoot = path.resolve(workspaceRoot, value); + const projectJsonPath = path.join(projectRoot, 'project.json'); + if (fs.existsSync(projectJsonPath)) { + refs.push({ config: JSON.parse(readFile(projectJsonPath)), projectRoot }); + } + continue; + } + + if (value && typeof value === 'object') { + const projectRoot = path.resolve(workspaceRoot, String((value as any).root ?? '')); + refs.push({ config: value, projectRoot }); + } else { + const projectRoot = path.resolve(workspaceRoot, 'apps', name); + const projectJsonPath = path.join(projectRoot, 'project.json'); + if (fs.existsSync(projectJsonPath)) { + refs.push({ config: JSON.parse(readFile(projectJsonPath)), projectRoot }); + } + } + } + + for (const projectJsonPath of glob(workspaceRoot, 'project.json', 5)) { + const projectRoot = path.dirname(projectJsonPath); + if (!refs.some(ref => ref.projectRoot === projectRoot)) { + refs.push({ config: JSON.parse(readFile(projectJsonPath)), projectRoot }); + } + } + + return refs; +} + +function entrypointFromProjectConfig(project: any, workspaceRoot: string, projectRoot: string): string | null { + const targets = project.targets ?? project.architect ?? {}; + const buildTarget = targets.build ?? targets['build-browser'] ?? targets.application ?? null; + const options = buildTarget?.options ?? {}; + const candidates = [ + options.main, + options.browser, + options.server + ].filter((candidate): candidate is string => typeof candidate === 'string'); + + for (const candidate of candidates) { + const resolved = resolveProjectFile(workspaceRoot, projectRoot, candidate); + if (fs.existsSync(resolved)) return resolved; + } + + return null; +} + +function resolveProjectFile(workspaceRoot: string, projectRoot: string, filePath: string): string { + if (path.isAbsolute(filePath)) return filePath; + + const workspaceRelative = path.resolve(workspaceRoot, filePath); + if (fs.existsSync(workspaceRelative)) return workspaceRelative; + + return path.resolve(projectRoot, filePath); +} + function glob(dir: string, filename: string, maxDepth: number): string[] { const results: string[] = []; if (!fs.existsSync(dir) || maxDepth <= 0) return results; @@ -145,11 +212,13 @@ function findAppConfig(mainTsPath: string): string | null { /** * Find index.html for script-tag fallback. */ -function findIndexHtml(angularJsonPath: string): string | null { - const root = path.dirname(angularJsonPath); +function findIndexHtml(workspaceConfigPath: string): string | null { + const root = path.dirname(workspaceConfigPath); const candidates = [ path.join(root, 'src', 'index.html'), - path.join(root, 'index.html') + path.join(root, 'index.html'), + ...glob(path.join(root, 'apps'), 'index.html', 4), + ...glob(path.join(root, 'packages'), 'index.html', 4) ]; return candidates.find(f => fs.existsSync(f)) ?? null; } @@ -164,6 +233,42 @@ function alreadyPatched(content: string): boolean { return content.includes('angular-render-scan') || content.includes('provideAngularRenderScan'); } +function findMatchingSquareBracket(content: string, openingBracketIndex: number): number { + let depth = 0; + + for (let i = openingBracketIndex; i < content.length; i++) { + const char = content[i]; + if (char === '[') depth++; + if (char === ']') depth--; + if (depth === 0) return i; + } + + return -1; +} + +function insertProviderIntoProvidersArray(content: string, providersMatch: RegExpMatchArray): string | null { + if (providersMatch.index === undefined) return null; + + const openingBracketIndex = providersMatch.index + providersMatch[0].lastIndexOf('['); + const closingBracketIndex = findMatchingSquareBracket(content, openingBracketIndex); + if (closingBracketIndex === -1) return null; + + const currentLineStart = content.lastIndexOf('\n', providersMatch.index) + 1; + const baseIndent = content.slice(currentLineStart, providersMatch.index).match(/^\s*/)?.[0] ?? ''; + const providerIndent = `${baseIndent} `; + const inside = content.slice(openingBracketIndex + 1, closingBracketIndex).trim(); + + if (!inside) { + return content.slice(0, openingBracketIndex + 1) + PROVIDER_CALL + content.slice(closingBracketIndex); + } + + return ( + content.slice(0, openingBracketIndex + 1) + + `\n${providerIndent}${PROVIDER_CALL},\n${providerIndent}${inside}\n${baseIndent}` + + content.slice(closingBracketIndex) + ); +} + /** * Insert provideAngularRenderScan() into an app.config.ts that uses * provideRouter / other Angular providers. @@ -192,23 +297,9 @@ function patchAppConfig(content: string): string | null { // Insert into providers array // Handles both single-line and multi-line providers arrays const providersMatch = patched.match(/providers:\s*\[/); - if (!providersMatch || providersMatch.index === undefined) return null; - - const insertIdx = providersMatch.index + providersMatch[0].length; - - // Check if providers array has existing items - const afterBracket = patched.slice(insertIdx).trimStart(); - const isEmpty = afterBracket.startsWith(']'); - - if (isEmpty) { - // Empty providers: [] - patched = patched.slice(0, insertIdx) + PROVIDER_CALL + patched.slice(insertIdx); - } else { - // Has existing providers: [provideRouter(...), ...] - patched = patched.slice(0, insertIdx) + '\n ' + PROVIDER_CALL + ',\n ' + patched.slice(insertIdx).trimStart(); - } + if (!providersMatch) return null; - return patched; + return insertProviderIntoProvidersArray(patched, providersMatch); } /** @@ -233,16 +324,7 @@ function patchMainTs(content: string): string | null { const providersMatch = patched.match(/providers:\s*\[/); if (providersMatch && providersMatch.index !== undefined) { - const insertIdx = providersMatch.index + providersMatch[0].length; - const afterBracket = patched.slice(insertIdx).trimStart(); - const isEmpty = afterBracket.startsWith(']'); - - if (isEmpty) { - patched = patched.slice(0, insertIdx) + PROVIDER_CALL + patched.slice(insertIdx); - } else { - patched = patched.slice(0, insertIdx) + '\n ' + PROVIDER_CALL + ',\n ' + patched.slice(insertIdx).trimStart(); - } - return patched; + return insertProviderIntoProvidersArray(patched, providersMatch); } // No providers array yet — add one in the config object @@ -323,15 +405,15 @@ export async function runInit(args: string[]): Promise { const TOTAL = 4; - // ── Step 1: Find angular.json ────────────────────────────────────────────── + // ── Step 1: Find Angular/Nx workspace config ─────────────────────────────── step(1, TOTAL, 'Locating Angular workspace…'); - const angularJsonPath = findAngularJson(); - if (!angularJsonPath) { - err('Could not find angular.json. Are you inside an Angular project?'); + const workspaceConfigPath = findWorkspaceConfig(); + if (!workspaceConfigPath) { + err('Could not find angular.json, workspace.json, nx.json, or project.json. Are you inside an Angular project?'); process.exit(1); } - ok(`Found angular.json at ${c.gray}${path.relative(process.cwd(), angularJsonPath)}${c.reset}`); - const projectRoot = path.dirname(angularJsonPath); + ok(`Found ${path.basename(workspaceConfigPath)} at ${c.gray}${path.relative(process.cwd(), workspaceConfigPath)}${c.reset}`); + const projectRoot = path.dirname(workspaceConfigPath); // ── Step 2: Check/install package ───────────────────────────────────────── step(2, TOTAL, 'Checking package installation…'); @@ -347,7 +429,7 @@ export async function runInit(args: string[]): Promise { // ── Step 3: Locate source files ──────────────────────────────────────────── step(3, TOTAL, 'Locating Angular entry files…'); - const mainTsPath = findMainTs(angularJsonPath); + const mainTsPath = findMainTs(workspaceConfigPath); if (!mainTsPath) { err('Could not locate main.ts. Please patch manually (see docs).'); process.exit(1); @@ -366,7 +448,7 @@ export async function runInit(args: string[]): Promise { if (scriptTagOnly) { // Script-tag mode: patch index.html - const indexHtmlPath = findIndexHtml(angularJsonPath); + const indexHtmlPath = findIndexHtml(workspaceConfigPath); if (!indexHtmlPath) { err('Could not locate index.html. Please add the script tag manually.'); process.exit(1); @@ -440,7 +522,7 @@ export async function run(): Promise { if (command === '--help' || command === '-h' || command === 'help') { log(''); - log(`${c.bold}Usage:${c.reset} npx angular-render-scan [command] [options]`); + log(`${c.bold}Usage:${c.reset} npx angular-render-scan-cli [command] [options]`); log(''); log(`${c.bold}Commands:${c.reset}`); log(' init Auto-patch your Angular project (default)'); @@ -451,9 +533,9 @@ export async function run(): Promise { log(' --script-tag Use CDN script tag in index.html instead of provider'); log(''); log(`${c.bold}Examples:${c.reset}`); - log(' npx angular-render-scan init'); - log(' npx angular-render-scan init --dry-run'); - log(' npx angular-render-scan init --script-tag'); + log(' npx angular-render-scan-cli init'); + log(' npx angular-render-scan-cli init --dry-run'); + log(' npx angular-render-scan-cli init --script-tag'); log(''); return; }