diff --git a/bin/package-lock.json b/bin/package-lock.json index 2e6ef4b..a84f592 100644 --- a/bin/package-lock.json +++ b/bin/package-lock.json @@ -5,7 +5,425 @@ "packages": { "": { "dependencies": { - "gray-matter": "^4.0.3" + "gray-matter": "^4.0.3", + "tsx": "^4.19.2", + "yaml": "^2.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, "node_modules/argparse": { @@ -17,6 +435,47 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -42,6 +501,32 @@ "node": ">=0.10.0" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/gray-matter": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", @@ -88,6 +573,15 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", @@ -115,6 +609,40 @@ "engines": { "node": ">=0.10.0" } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/skills/agent-md-refactor/README.md b/skills/agent-md-refactor/README.md new file mode 100644 index 0000000..9de3b3d --- /dev/null +++ b/skills/agent-md-refactor/README.md @@ -0,0 +1,227 @@ +# Agent MD Refactor + +A Claude Code skill that transforms bloated agent instruction files into clean, organized documentation using progressive disclosure principles. + +Based on https://x.com/mattpocockuk/status/2012906065856270504 (Matt Pocock's Prompt Idea) + +## Purpose + +Over time, agent instruction files like `CLAUDE.md`, `AGENTS.md`, or `COPILOT.md` tend to grow into unwieldy documents containing hundreds of lines of mixed instructions. This creates several problems: + +- **Context waste**: Every task loads the entire file, even when most instructions are irrelevant +- **Maintenance burden**: Finding and updating specific instructions becomes difficult +- **Contradictions**: Conflicting guidelines accumulate without being noticed +- **Signal-to-noise ratio**: Important rules get buried among obvious or vague statements + +This skill solves these problems by applying **progressive disclosure** - keeping only essential, universal instructions in the root file while organizing everything else into focused, linked documentation files. + +## When to Use + +Use this skill when you need to clean up agent instruction files. Common trigger phrases include: + +- "refactor my AGENTS.md" / "refactor my CLAUDE.md" +- "split my agent instructions" +- "organize my CLAUDE.md file" +- "my AGENTS.md is too long" +- "progressive disclosure for my instructions" +- "clean up my agent config" + +**Good candidates for refactoring:** + +- Root agent files exceeding 50-100 lines +- Files mixing multiple unrelated topics (testing, code style, architecture, etc.) +- Documents that have grown organically without structure +- Files containing contradictory or redundant instructions + +## How It Works + +The skill follows a systematic 5-phase process: + +### Phase 1: Find Contradictions + +Before restructuring, the skill identifies conflicting instructions that need resolution. Examples include contradictory style guidelines ("use semicolons" vs "no semicolons") or incompatible workflow instructions. Each contradiction is surfaced with a question for the user to resolve. + +### Phase 2: Identify the Essentials + +Extracts only what truly belongs in the root file - information that applies to every single task: + +| Keep in Root | Move Out | +|-------------|----------| +| One-sentence project description | Language-specific conventions | +| Non-standard package manager | Testing guidelines | +| Custom build/test commands | Code style details | +| Critical overrides | Framework patterns | +| Universal rules (100% of tasks) | Documentation standards | + +### Phase 3: Group the Rest + +Organizes remaining instructions into logical categories like: + +- `typescript.md` - Type patterns, strict mode rules +- `testing.md` - Test frameworks, coverage, mocking +- `code-style.md` - Formatting, naming, structure +- `git-workflow.md` - Commits, branches, PRs +- `architecture.md` - Patterns, folder structure + +### Phase 4: Create the File Structure + +Generates the new file hierarchy with properly linked documentation: + +``` +project-root/ +├── CLAUDE.md # Minimal root with links +└── .claude/ # Categorized instructions + ├── typescript.md + ├── testing.md + ├── code-style.md + └── architecture.md +``` + +### Phase 5: Flag for Deletion + +Identifies instructions that should be removed entirely: + +- **Redundant**: "Use TypeScript" in a TypeScript project +- **Too vague**: "Write clean code" without specifics +- **Overly obvious**: "Don't introduce bugs" +- **Default behavior**: "Use descriptive variable names" +- **Outdated**: References to deprecated APIs + +## Key Features + +- **Contradiction detection**: Surfaces conflicting instructions before restructuring +- **Intelligent categorization**: Groups related instructions into logical files +- **Root file minimization**: Targets under 50 lines for the main file +- **Deletion recommendations**: Identifies instructions wasting context tokens +- **Template-driven output**: Consistent structure across all generated files +- **Link verification**: Ensures all references between files are valid + +## Usage Examples + +### Basic Refactoring + +``` +User: refactor my CLAUDE.md + +Claude: I'll analyze your CLAUDE.md file and refactor it using progressive +disclosure principles... +``` + +### Specific File + +``` +User: my AGENTS.md is too long, can you split it up? + +Claude: I'll review your AGENTS.md and organize it into focused, linked files... +``` + +### After a Project Grows + +``` +User: organize my agent config - it's gotten out of control + +Claude: I'll apply the 5-phase refactoring process to clean up your +agent instructions... +``` + +## Output + +After running the skill, you get: + +**Minimal root file (~50 lines or less):** +```markdown +# Project Name + +One-sentence description of the project. + +## Quick Reference + +- **Package Manager:** pnpm +- **Build:** `pnpm build` +- **Test:** `pnpm test` + +## Detailed Instructions + +- [TypeScript Conventions](.claude/typescript.md) +- [Testing Guidelines](.claude/testing.md) +- [Code Style](.claude/code-style.md) +``` + +**Organized linked files with consistent structure:** +```markdown +# Testing Guidelines + +## Overview +Brief context for when these guidelines apply. + +## Rules + +### Unit Tests +- Specific, actionable instruction +- Another specific instruction + +## Examples + +### Good +[code example] + +### Avoid +[code example] +``` + +**Deletion report:** +```markdown +## Flagged for Deletion + +| Instruction | Reason | +|-------------|--------| +| "Write clean, maintainable code" | Too vague to be actionable | +| "Use TypeScript" | Redundant - project is already TS | +``` + +## Best Practices + +### Before Refactoring + +1. **Commit current state** - Have a clean git state so you can review changes +2. **Identify your goals** - Know what problems you want to solve +3. **Gather all instruction files** - Some projects have instructions scattered across multiple locations + +### During Refactoring + +1. **Resolve contradictions first** - Do not proceed until conflicts are addressed +2. **Be aggressive about root minimization** - When in doubt, move it out +3. **Aim for 3-8 linked files** - Not too granular, not too broad +4. **Delete liberally** - Vague instructions waste tokens without providing value + +### After Refactoring + +1. **Verify all links work** - Test that referenced files exist +2. **Check for lost instructions** - Ensure nothing important was dropped +3. **Test with real tasks** - Run a few typical tasks to verify the agent can find needed instructions + +## Anti-Patterns to Avoid + +| Avoid | Why | Instead | +|-------|-----|---------| +| Keeping everything in root | Bloated, hard to maintain | Split into linked files | +| Too many categories | Fragmentation, navigation overhead | Consolidate related topics | +| Vague instructions | Wastes tokens, no value | Be specific or delete | +| Duplicating defaults | Agent already knows | Only override when needed | +| Deep nesting | Hard to navigate | Flat structure with links | + +## Verification Checklist + +After refactoring, verify: + +- [ ] Root file is under 50 lines +- [ ] Root contains ONLY universal information +- [ ] All links to sub-files work correctly +- [ ] No contradictions remain between files +- [ ] Every instruction is specific and actionable +- [ ] No instructions were lost (unless intentionally deleted) +- [ ] Each linked file is self-contained for its topic + +## License + +MIT diff --git a/skills/agent-md-refactor/SKILL.md b/skills/agent-md-refactor/SKILL.md new file mode 100644 index 0000000..a05ec0c --- /dev/null +++ b/skills/agent-md-refactor/SKILL.md @@ -0,0 +1,295 @@ +--- +name: agent-md-refactor +description: >- + Refactor bloated AGENTS.md, CLAUDE.md, or similar agent instruction files to + follow progressive disclosure principles. Splits monolithic files into + organized, linked documentation. +license: MIT +metadata: + category: development + source: + repository: 'https://github.com/softaworks/agent-toolkit' + path: skills/agent-md-refactor +--- + +# Agent MD Refactor + +Refactor bloated agent instruction files (AGENTS.md, CLAUDE.md, COPILOT.md, etc.) to follow **progressive disclosure principles** - keeping essentials at root and organizing the rest into linked, categorized files. + +--- + +## Triggers + +Use this skill when: +- "refactor my AGENTS.md" / "refactor my CLAUDE.md" +- "split my agent instructions" +- "organize my CLAUDE.md file" +- "my AGENTS.md is too long" +- "progressive disclosure for my instructions" +- "clean up my agent config" + +--- + +## Quick Reference + +| Phase | Action | Output | +|-------|--------|--------| +| 1. Analyze | Find contradictions | List of conflicts to resolve | +| 2. Extract | Identify essentials | Core instructions for root file | +| 3. Categorize | Group remaining instructions | Logical categories | +| 4. Structure | Create file hierarchy | Root + linked files | +| 5. Prune | Flag for deletion | Redundant/vague instructions | + +--- + +## Process + +### Phase 1: Find Contradictions + +Identify any instructions that conflict with each other. + +**Look for:** +- Contradictory style guidelines (e.g., "use semicolons" vs "no semicolons") +- Conflicting workflow instructions +- Incompatible tool preferences +- Mutually exclusive patterns + +**For each contradiction found:** +```markdown +## Contradiction Found + +**Instruction A:** [quote] +**Instruction B:** [quote] + +**Question:** Which should take precedence, or should both be conditional? +``` + +Ask the user to resolve before proceeding. + +--- + +### Phase 2: Identify the Essentials + +Extract ONLY what belongs in the root agent file. The root should be minimal - information that applies to **every single task**. + +**Essential content (keep in root):** +| Category | Example | +|----------|---------| +| Project description | One sentence: "A React dashboard for analytics" | +| Package manager | Only if not npm (e.g., "Uses pnpm") | +| Non-standard commands | Custom build/test/typecheck commands | +| Critical overrides | Things that MUST override defaults | +| Universal rules | Applies to 100% of tasks | + +**NOT essential (move to linked files):** +- Language-specific conventions +- Testing guidelines +- Code style details +- Framework patterns +- Documentation standards +- Git workflow details + +--- + +### Phase 3: Group the Rest + +Organize remaining instructions into logical categories. + +**Common categories:** +| Category | Contents | +|----------|----------| +| `typescript.md` | TS conventions, type patterns, strict mode rules | +| `testing.md` | Test frameworks, coverage, mocking patterns | +| `code-style.md` | Formatting, naming, comments, structure | +| `git-workflow.md` | Commits, branches, PRs, reviews | +| `architecture.md` | Patterns, folder structure, dependencies | +| `api-design.md` | REST/GraphQL conventions, error handling | +| `security.md` | Auth patterns, input validation, secrets | +| `performance.md` | Optimization rules, caching, lazy loading | + +**Grouping rules:** +1. Each file should be self-contained for its topic +2. Aim for 3-8 files (not too granular, not too broad) +3. Name files clearly: `{topic}.md` +4. Include only actionable instructions + +--- + +### Phase 4: Create the File Structure + +**Output structure:** +``` +project-root/ +├── CLAUDE.md (or AGENTS.md) # Minimal root with links +└── .claude/ # Or docs/agent-instructions/ + ├── typescript.md + ├── testing.md + ├── code-style.md + ├── git-workflow.md + └── architecture.md +``` + +**Root file template:** +```markdown +# Project Name + +One-sentence description of the project. + +## Quick Reference + +- **Package Manager:** pnpm +- **Build:** `pnpm build` +- **Test:** `pnpm test` +- **Typecheck:** `pnpm typecheck` + +## Detailed Instructions + +For specific guidelines, see: +- [TypeScript Conventions](.claude/typescript.md) +- [Testing Guidelines](.claude/testing.md) +- [Code Style](.claude/code-style.md) +- [Git Workflow](.claude/git-workflow.md) +- [Architecture Patterns](.claude/architecture.md) +``` + +**Each linked file template:** +```markdown +# {Topic} Guidelines + +## Overview +Brief context for when these guidelines apply. + +## Rules + +### Rule Category 1 +- Specific, actionable instruction +- Another specific instruction + +### Rule Category 2 +- Specific, actionable instruction + +## Examples + +### Good +\`\`\`typescript +// Example of correct pattern +\`\`\` + +### Avoid +\`\`\`typescript +// Example of what not to do +\`\`\` +``` + +--- + +### Phase 5: Flag for Deletion + +Identify instructions that should be removed entirely. + +**Delete if:** +| Criterion | Example | Why Delete | +|-----------|---------|------------| +| Redundant | "Use TypeScript" (in a .ts project) | Agent already knows | +| Too vague | "Write clean code" | Not actionable | +| Overly obvious | "Don't introduce bugs" | Wastes context | +| Default behavior | "Use descriptive variable names" | Standard practice | +| Outdated | References deprecated APIs | No longer applies | + +**Output format:** +```markdown +## Flagged for Deletion + +| Instruction | Reason | +|-------------|--------| +| "Write clean, maintainable code" | Too vague to be actionable | +| "Use TypeScript" | Redundant - project is already TS | +| "Don't commit secrets" | Agent already knows this | +| "Follow best practices" | Meaningless without specifics | +``` + +--- + +## Execution Checklist + +``` +[ ] Phase 1: All contradictions identified and resolved +[ ] Phase 2: Root file contains ONLY essentials +[ ] Phase 3: All remaining instructions categorized +[ ] Phase 4: File structure created with proper links +[ ] Phase 5: Redundant/vague instructions removed +[ ] Verify: Each linked file is self-contained +[ ] Verify: Root file is under 50 lines +[ ] Verify: All links work correctly +``` + +--- + +## Anti-Patterns + +| Avoid | Why | Instead | +|-------|-----|---------| +| Keeping everything in root | Bloated, hard to maintain | Split into linked files | +| Too many categories | Fragmentation | Consolidate related topics | +| Vague instructions | Wastes tokens, no value | Be specific or delete | +| Duplicating defaults | Agent already knows | Only override when needed | +| Deep nesting | Hard to navigate | Flat structure with links | + +--- + +## Examples + +### Before (Bloated Root) +```markdown +# CLAUDE.md + +This is a React project. + +## Code Style +- Use 2 spaces +- Use semicolons +- Prefer const over let +- Use arrow functions +... (200 more lines) + +## Testing +- Use Jest +- Coverage > 80% +... (100 more lines) + +## TypeScript +- Enable strict mode +... (150 more lines) +``` + +### After (Progressive Disclosure) +```markdown +# CLAUDE.md + +React dashboard for real-time analytics visualization. + +## Commands +- `pnpm dev` - Start development server +- `pnpm test` - Run tests with coverage +- `pnpm build` - Production build + +## Guidelines +- [Code Style](.claude/code-style.md) +- [Testing](.claude/testing.md) +- [TypeScript](.claude/typescript.md) +``` + +--- + +## Verification + +After refactoring, verify: + +1. **Root file is minimal** - Under 50 lines, only universal info +2. **Links work** - All referenced files exist +3. **No contradictions** - Instructions are consistent +4. **Actionable content** - Every instruction is specific +5. **Complete coverage** - No instructions were lost (unless flagged for deletion) +6. **Self-contained files** - Each linked file stands alone + +--- diff --git a/skills/angular-component/SKILL.md b/skills/angular-component/SKILL.md new file mode 100644 index 0000000..7c67419 --- /dev/null +++ b/skills/angular-component/SKILL.md @@ -0,0 +1,298 @@ +--- +name: angular-component +description: >- + Create modern Angular standalone components following v20+ best practices. Use + for building UI components with signal-based inputs/outputs, OnPush change + detection, host bindings, content projection, and lifecycle hooks. Triggers on + component creation, refactoring class-based inputs to signals, adding host + bindings, or implementing accessible interactive components. +metadata: + category: development + source: + repository: 'https://github.com/analogjs/angular-skills' + path: skills/angular-component +--- + +# Angular Component + +Create standalone components for Angular v20+. Components are standalone by default—do NOT set `standalone: true`. + +## Component Structure + +```typescript +import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core'; + +@Component({ + selector: 'app-user-card', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'class': 'user-card', + '[class.active]': 'isActive()', + '(click)': 'handleClick()', + }, + template: ` + +

{{ name() }}

+ @if (showEmail()) { +

{{ email() }}

+ } + `, + styles: ` + :host { display: block; } + :host.active { border: 2px solid blue; } + `, +}) +export class UserCard { + // Required input + name = input.required(); + + // Optional input with default + email = input(''); + showEmail = input(false); + + // Input with transform + isActive = input(false, { transform: booleanAttribute }); + + // Computed from inputs + avatarUrl = computed(() => `https://api.example.com/avatar/${this.name()}`); + + // Output + selected = output(); + + handleClick() { + this.selected.emit(this.name()); + } +} +``` + +## Signal Inputs + +```typescript +// Required - must be provided by parent +name = input.required(); + +// Optional with default value +count = input(0); + +// Optional without default (undefined allowed) +label = input(); + +// With alias for template binding +size = input('medium', { alias: 'buttonSize' }); + +// With transform function +disabled = input(false, { transform: booleanAttribute }); +value = input(0, { transform: numberAttribute }); +``` + +## Signal Outputs + +```typescript +import { output, outputFromObservable } from '@angular/core'; + +// Basic output +clicked = output(); +selected = output(); + +// With alias +valueChange = output({ alias: 'change' }); + +// From Observable (for RxJS interop) +scroll$ = new Subject(); +scrolled = outputFromObservable(this.scroll$); + +// Emit values +this.clicked.emit(); +this.selected.emit(item); +``` + +## Host Bindings + +Use the `host` object in `@Component`—do NOT use `@HostBinding` or `@HostListener` decorators. + +```typescript +@Component({ + selector: 'app-button', + host: { + // Static attributes + 'role': 'button', + + // Dynamic class bindings + '[class.primary]': 'variant() === "primary"', + '[class.disabled]': 'disabled()', + + // Dynamic style bindings + '[style.--btn-color]': 'color()', + + // Attribute bindings + '[attr.aria-disabled]': 'disabled()', + '[attr.tabindex]': 'disabled() ? -1 : 0', + + // Event listeners + '(click)': 'onClick($event)', + '(keydown.enter)': 'onClick($event)', + '(keydown.space)': 'onClick($event)', + }, + template: ``, +}) +export class Button { + variant = input<'primary' | 'secondary'>('primary'); + disabled = input(false, { transform: booleanAttribute }); + color = input('#007bff'); + + clicked = output(); + + onClick(event: Event) { + if (!this.disabled()) { + this.clicked.emit(); + } + } +} +``` + +## Content Projection + +```typescript +@Component({ + selector: 'app-card', + template: ` +
+ +
+
+ +
+
+ +
+ `, +}) +export class Card {} + +// Usage: +// +//

Title

+//

Main content

+// +//
+``` + +## Lifecycle Hooks + +```typescript +import { OnDestroy, OnInit, afterNextRender, afterRender } from '@angular/core'; + +export class My implements OnInit, OnDestroy { + constructor() { + // For DOM manipulation after render (SSR-safe) + afterNextRender(() => { + // Runs once after first render + }); + + afterRender(() => { + // Runs after every render + }); + } + + ngOnInit() { /* Component initialized */ } + ngOnDestroy() { /* Cleanup */ } +} +``` + +## Accessibility Requirements + +Components MUST: +- Pass AXE accessibility checks +- Meet WCAG AA standards +- Include proper ARIA attributes for interactive elements +- Support keyboard navigation +- Maintain visible focus indicators + +```typescript +@Component({ + selector: 'app-toggle', + host: { + 'role': 'switch', + '[attr.aria-checked]': 'checked()', + '[attr.aria-label]': 'label()', + 'tabindex': '0', + '(click)': 'toggle()', + '(keydown.enter)': 'toggle()', + '(keydown.space)': 'toggle(); $event.preventDefault()', + }, + template: ``, +}) +export class Toggle { + label = input.required(); + checked = input(false, { transform: booleanAttribute }); + checkedChange = output(); + + toggle() { + this.checkedChange.emit(!this.checked()); + } +} +``` + +## Template Syntax + +Use native control flow—do NOT use `*ngIf`, `*ngFor`, `*ngSwitch`. + +```html + +@if (isLoading()) { + +} @else if (error()) { + +} @else { + +} + + +@for (item of items(); track item.id) { + +} @empty { +

No items found

+} + + +@switch (status()) { + @case ('pending') { Pending } + @case ('active') { Active } + @default { Unknown } +} +``` + +## Class and Style Bindings + +Do NOT use `ngClass` or `ngStyle`. Use direct bindings: + +```html + +
Single class
+
Class string
+ + +
Styled text
+
With unit
+``` + +## Images + +Use `NgOptimizedImage` for static images: + +```typescript +import { NgOptimizedImage } from '@angular/common'; + +@Component({ + imports: [NgOptimizedImage], + template: ` + + + `, +}) +export class Hero { + imageUrl = input.required(); +} +``` + +For detailed patterns, see [references/component-patterns.md](references/component-patterns.md). diff --git a/skills/angular-component/references/component-patterns.md b/skills/angular-component/references/component-patterns.md new file mode 100644 index 0000000..68e4e33 --- /dev/null +++ b/skills/angular-component/references/component-patterns.md @@ -0,0 +1,358 @@ +# Angular Component Patterns + +## Table of Contents +- [Model Inputs (Two-Way Binding)](#model-inputs-two-way-binding) +- [View Queries](#view-queries) +- [Content Queries](#content-queries) +- [Dependency Injection in Components](#dependency-injection-in-components) +- [Component Communication Patterns](#component-communication-patterns) +- [Dynamic Components](#dynamic-components) + +## Model Inputs (Two-Way Binding) + +For two-way binding with `[(value)]` syntax: + +```typescript +import { Component, model } from '@angular/core'; + +@Component({ + selector: 'app-slider', + host: { + '(input)': 'onInput($event)', + }, + template: ` + + {{ value() }} + `, +}) +export class Slider { + // Model creates both input and output + value = model(0); + min = input(0); + max = input(100); + + onInput(event: Event) { + const target = event.target as HTMLInputElement; + this.value.set(Number(target.value)); + } +} + +// Usage: +``` + +Required model: + +```typescript +value = model.required(); +``` + +## View Queries + +Query elements and components in the template: + +```typescript +import { Component, viewChild, viewChildren, ElementRef } from '@angular/core'; + +@Component({ + selector: 'app-gallery', + template: ` + + `, +}) +export class Gallery { + images = input.required(); + + // Query single element + container = viewChild.required>('container'); + + // Query single component (optional) + firstCard = viewChild(ImageCard); + + // Query all matching components + allCards = viewChildren(ImageCard); +} +``` + +## Content Queries + +Query projected content: + +```typescript +import { Component, contentChild, contentChildren, effect, signal } from '@angular/core'; + +@Component({ + selector: 'app-tabs', + template: ` +
+ @for (tab of tabs(); track tab.label()) { + + } +
+
+ +
+ `, +}) +export class Tabs { + // Query all projected Tab children + tabs = contentChildren(Tab); + + // Query single projected element + header = contentChild('tabHeader'); + + activeTab = signal(undefined); + + constructor() { + // Set first tab as active when tabs are available + effect(() => { + const firstTab = this.tabs()[0]; + if (firstTab && !this.activeTab()) { + this.activeTab.set(firstTab); + } + }); + } + + selectTab(tab: Tab) { + this.activeTab.set(tab); + } +} + +@Component({ + selector: 'app-tab', + template: ``, + host: { + '[class.active]': 'isActive()', + '[style.display]': 'isActive() ? "block" : "none"', + }, +}) +export class Tab { + label = input.required(); + isActive = input(false); +} +``` + +## Dependency Injection in Components + +Use `inject()` function instead of constructor injection: + +```typescript +import { Component, inject } from '@angular/core'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-dashboard', + template: `...`, +}) +export class Dashboard { + private router = inject(Router); + private userService = inject(User); + private config = inject(APP_CONFIG); + + // Optional injection + private analytics = inject(Analytics, { optional: true }); + + // Self-only injection + private localService = inject(Local, { self: true }); + + navigateToProfile() { + this.router.navigate(['/profile']); + } +} +``` + +## Component Communication Patterns + +### Parent to Child (Inputs) + +```typescript +// Parent +@Component({ + template: ``, +}) +export class Parent { + parentData = signal({ name: 'Test' }); + config = { theme: 'dark' }; +} + +// Child +@Component({ selector: 'app-child' }) +export class Child { + data = input.required(); + config = input(); +} +``` + +### Child to Parent (Outputs) + +```typescript +// Child +@Component({ + selector: 'app-child', + template: ``, +}) +export class Child { + saved = output(); + + save() { + this.saved.emit({ id: 1, name: 'Item' }); + } +} + +// Parent +@Component({ + template: ``, +}) +export class Parent { + onSaved(data: Data) { + console.log('Saved:', data); + } +} +``` + +### Shared Service Pattern + +```typescript +// Shared state service +@Injectable({ providedIn: 'root' }) +export class Cart { + private items = signal([]); + + readonly items$ = this.items.asReadonly(); + readonly total = computed(() => + this.items().reduce((sum, item) => sum + item.price, 0) + ); + + addItem(item: CartItem) { + this.items.update(items => [...items, item]); + } + + removeItem(id: string) { + this.items.update(items => items.filter(i => i.id !== id)); + } +} + +// Component A +@Component({ template: `` }) +export class Product { + private cart = inject(Cart); + product = input.required(); + + add() { + this.cart.addItem({ ...this.product(), quantity: 1 }); + } +} + +// Component B +@Component({ template: `Total: {{ cart.total() }}` }) +export class CartSummary { + cart = inject(Cart); +} +``` + +## Dynamic Components + +Using `@defer` for lazy loading: + +```typescript +@Component({ + template: ` + @defer (on viewport) { + + } @placeholder { +
Loading chart...
+ } @loading (minimum 500ms) { + + } @error { +

Failed to load chart

+ } + `, +}) +export class Dashboard { + chartData = input.required(); +} +``` + +Defer triggers: +- `on viewport` - When element enters viewport +- `on idle` - When browser is idle +- `on interaction` - On user interaction (click, focus) +- `on hover` - On mouse hover +- `on immediate` - Immediately after non-deferred content +- `on timer(500ms)` - After specified delay +- `when condition` - When expression becomes true + +```typescript +@Component({ + template: ` + @defer (on interaction; prefetch on idle) { + + } @placeholder { + + } + `, +}) +export class Post { + postId = input.required(); +} +``` + +## Attribute Directives on Components + +```typescript +@Directive({ + selector: '[appHighlight]', + host: { + '[style.backgroundColor]': 'color()', + }, +}) +export class Highlight { + color = input('yellow', { alias: 'appHighlight' }); +} + +// Usage on component +@Component({ + imports: [Highlight], + template: ``, +}) +export class Page {} +``` + +## Error Boundaries + +```typescript +@Component({ + selector: 'app-error-boundary', + template: ` + @if (hasError()) { +
+

Something went wrong

+ +
+ } @else { + + } + `, +}) +export class ErrorBoundary { + hasError = signal(false); + private errorHandler = inject(ErrorHandler); + + retry() { + this.hasError.set(false); + } +} +``` diff --git a/skills/angular-di/SKILL.md b/skills/angular-di/SKILL.md new file mode 100644 index 0000000..7d73407 --- /dev/null +++ b/skills/angular-di/SKILL.md @@ -0,0 +1,397 @@ +--- +name: angular-di +description: >- + Implement dependency injection in Angular v20+ using inject(), injection + tokens, and provider configuration. Use for service architecture, providing + dependencies at different levels, creating injectable tokens, and managing + singleton vs scoped services. Triggers on service creation, configuring + providers, using injection tokens, or understanding DI hierarchy. +metadata: + category: development + source: + repository: 'https://github.com/analogjs/angular-skills' + path: skills/angular-di +--- + +# Angular Dependency Injection + +Configure and use dependency injection in Angular v20+ with `inject()` and providers. + +## Basic Injection + +### Using inject() + +Prefer `inject()` over constructor injection: + +```typescript +import { Component, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { User } from './user.service'; + +@Component({ + selector: 'app-user-list', + template: `...`, +}) +export class UserList { + // Inject dependencies + private http = inject(HttpClient); + private userService = inject(User); + + // Can use immediately + users = this.userService.getUsers(); +} +``` + +### Injectable Services + +```typescript +import { Injectable, inject, signal } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +@Injectable({ + providedIn: 'root', // Singleton at root level +}) +export class User { + private http = inject(HttpClient); + + private users = signal([]); + readonly users$ = this.users.asReadonly(); + + async loadUsers() { + const users = await firstValueFrom( + this.http.get('/api/users') + ); + this.users.set(users); + } +} +``` + +## Provider Scopes + +### Root Level (Singleton) + +```typescript +// Recommended: providedIn +@Injectable({ + providedIn: 'root', +}) +export class Auth {} + +// Alternative: in app.config.ts +export const appConfig: ApplicationConfig = { + providers: [ + Auth, + ], +}; +``` + +### Component Level (Instance per Component) + +```typescript +@Component({ + selector: 'app-editor', + providers: [EditorState], // New instance for each component + template: `...`, +}) +export class Editor { + private editorState = inject(EditorState); +} +``` + +### Route Level + +```typescript +export const routes: Routes = [ + { + path: 'admin', + providers: [Admin], // Shared within this route tree + children: [ + { path: '', component: AdminDashboard }, + { path: 'users', component: AdminUsers }, + ], + }, +]; +``` + +## Injection Tokens + +### Creating Tokens + +```typescript +import { InjectionToken } from '@angular/core'; + +// Simple value token +export const API_URL = new InjectionToken('API_URL'); + +// Object token +export interface AppConfig { + apiUrl: string; + features: { + darkMode: boolean; + analytics: boolean; + }; +} + +export const APP_CONFIG = new InjectionToken('APP_CONFIG'); + +// Token with factory (self-providing) +export const WINDOW = new InjectionToken('Window', { + providedIn: 'root', + factory: () => window, +}); + +export const LOCAL_STORAGE = new InjectionToken('LocalStorage', { + providedIn: 'root', + factory: () => localStorage, +}); +``` + +### Providing Token Values + +```typescript +// app.config.ts +export const appConfig: ApplicationConfig = { + providers: [ + { provide: API_URL, useValue: 'https://api.example.com' }, + { + provide: APP_CONFIG, + useValue: { + apiUrl: 'https://api.example.com', + features: { darkMode: true, analytics: true }, + }, + }, + ], +}; +``` + +### Injecting Tokens + +```typescript +@Injectable({ providedIn: 'root' }) +export class Api { + private apiUrl = inject(API_URL); + private config = inject(APP_CONFIG); + private window = inject(WINDOW); + + getBaseUrl(): string { + return this.apiUrl; + } +} +``` + +## Provider Types + +### useClass + +```typescript +// Provide implementation +{ provide: Logger, useClass: ConsoleLogger } + +// Conditional implementation +{ + provide: Logger, + useClass: environment.production + ? ProductionLogger + : ConsoleLogger, +} +``` + +### useValue + +```typescript +// Static values +{ provide: API_URL, useValue: 'https://api.example.com' } + +// Configuration objects +{ provide: APP_CONFIG, useValue: { theme: 'dark', language: 'en' } } +``` + +### useFactory + +```typescript +// Factory with dependencies +{ + provide: User, + useFactory: (http: HttpClient, config: AppConfig) => { + return new User(http, config.apiUrl); + }, + deps: [HttpClient, APP_CONFIG], +} + +// Async factory (not recommended - use provideAppInitializer) +{ + provide: CONFIG, + useFactory: () => fetch('/config.json').then(r => r.json()), +} +``` + +### useExisting + +```typescript +// Alias to existing provider +{ provide: AbstractLogger, useExisting: ConsoleLogger } + +// Multiple tokens pointing to same instance +providers: [ + ConsoleLogger, + { provide: Logger, useExisting: ConsoleLogger }, + { provide: ErrorLogger, useExisting: ConsoleLogger }, +] +``` + +## Injection Options + +### Optional Injection + +```typescript +@Component({...}) +export class My { + // Returns null if not provided + private analytics = inject(Analytics, { optional: true }); + + trackEvent(name: string) { + this.analytics?.track(name); + } +} +``` + +### Self, SkipSelf, Host + +```typescript +@Component({ + providers: [Local], +}) +export class Parent { + // Only look in this component's injector + private local = inject(Local, { self: true }); +} + +@Component({...}) +export class Child { + // Skip this component, look in parent + private parentService = inject(ParentSvc, { skipSelf: true }); + + // Only look up to host component + private hostService = inject(Host, { host: true }); +} +``` + +## Multi Providers + +Collect multiple values for same token: + +```typescript +// Token for multiple validators +export const VALIDATORS = new InjectionToken('Validators'); + +// Provide multiple values +providers: [ + { provide: VALIDATORS, useClass: RequiredValidator, multi: true }, + { provide: VALIDATORS, useClass: EmailValidator, multi: true }, + { provide: VALIDATORS, useClass: MinLengthValidator, multi: true }, +] + +// Inject as array +@Injectable() +export class Validation { + private validators = inject(VALIDATORS); // Validator[] + + validate(value: string): ValidationError[] { + return this.validators + .map(v => v.validate(value)) + .filter(Boolean); + } +} +``` + +### HTTP Interceptors (Multi Provider) + +```typescript +// Interceptors use multi providers internally +export const appConfig: ApplicationConfig = { + providers: [ + provideHttpClient( + withInterceptors([ + authInterceptor, + loggingInterceptor, + errorInterceptor, + ]) + ), + ], +}; +``` + +## App Initializers + +Run async code before app starts using `provideAppInitializer`: + +```typescript +import { provideAppInitializer, inject } from '@angular/core'; + +export const appConfig: ApplicationConfig = { + providers: [ + Config, + provideAppInitializer(() => { + const configService = inject(Config); + return configService.loadConfig(); + }), + ], +}; +``` + +### Multiple Initializers + +```typescript +providers: [ + provideAppInitializer(() => { + const config = inject(Config); + return config.load(); + }), + provideAppInitializer(() => { + const auth = inject(Auth); + return auth.checkSession(); + }), +] +``` + +## Environment Injector + +Create injectors programmatically: + +```typescript +import { createEnvironmentInjector, EnvironmentInjector, inject } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class Plugin { + private parentInjector = inject(EnvironmentInjector); + + loadPlugin(providers: Provider[]): EnvironmentInjector { + return createEnvironmentInjector(providers, this.parentInjector); + } +} +``` + +## runInInjectionContext + +Run code with injection context: + +```typescript +import { runInInjectionContext, EnvironmentInjector, inject } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class Utility { + private injector = inject(EnvironmentInjector); + + executeWithDI(fn: () => T): T { + return runInInjectionContext(this.injector, fn); + } +} + +// Usage +utilityService.executeWithDI(() => { + const http = inject(HttpClient); + // Use http... +}); +``` + +For advanced patterns, see [references/di-patterns.md](references/di-patterns.md). diff --git a/skills/angular-di/references/di-patterns.md b/skills/angular-di/references/di-patterns.md new file mode 100644 index 0000000..03a9397 --- /dev/null +++ b/skills/angular-di/references/di-patterns.md @@ -0,0 +1,519 @@ +# Angular Dependency Injection Patterns + +## Table of Contents +- [Service Patterns](#service-patterns) +- [Abstract Classes as Tokens](#abstract-classes-as-tokens) +- [Hierarchical Injection](#hierarchical-injection) +- [Dynamic Providers](#dynamic-providers) +- [Testing with DI](#testing-with-di) +- [DestroyRef and Cleanup](#destroyref-and-cleanup) + +## Service Patterns + +### Facade Service + +Combine multiple services into a single API: + +```typescript +@Injectable({ providedIn: 'root' }) +export class ShopFacade { + private productService = inject(Product); + private cartService = inject(Cart); + private orderService = inject(Order); + + // Expose combined state + readonly products = this.productService.products; + readonly cart = this.cartService.items; + readonly cartTotal = this.cartService.total; + + // Unified actions + addToCart(productId: string, quantity: number) { + const product = this.productService.getById(productId); + if (product) { + this.cartService.add(product, quantity); + } + } + + async checkout() { + const items = this.cartService.items(); + const order = await this.orderService.create(items); + this.cartService.clear(); + return order; + } +} +``` + +### State Service Pattern + +```typescript +interface UserState { + user: User | null; + loading: boolean; + error: string | null; +} + +@Injectable({ providedIn: 'root' }) +export class UserState { + private state = signal({ + user: null, + loading: false, + error: null, + }); + + // Selectors + readonly user = computed(() => this.state().user); + readonly loading = computed(() => this.state().loading); + readonly error = computed(() => this.state().error); + readonly isAuthenticated = computed(() => this.state().user !== null); + + // Actions + setUser(user: User) { + this.state.update(s => ({ ...s, user, loading: false, error: null })); + } + + setLoading() { + this.state.update(s => ({ ...s, loading: true, error: null })); + } + + setError(error: string) { + this.state.update(s => ({ ...s, loading: false, error })); + } + + clear() { + this.state.set({ user: null, loading: false, error: null }); + } +} +``` + +### Repository Pattern + +```typescript +// Generic repository interface +export abstract class Repository { + abstract getAll(): Promise; + abstract getById(id: string): Promise; + abstract create(item: Omit): Promise; + abstract update(id: string, item: Partial): Promise; + abstract delete(id: string): Promise; +} + +// HTTP implementation +@Injectable() +export class HttpUserRepo extends Repository { + private http = inject(HttpClient); + private apiUrl = inject(API_URL); + + async getAll(): Promise { + return firstValueFrom(this.http.get(`${this.apiUrl}/users`)); + } + + async getById(id: string): Promise { + return firstValueFrom( + this.http.get(`${this.apiUrl}/users/${id}`).pipe( + catchError(() => of(null)) + ) + ); + } + + async create(user: Omit): Promise { + return firstValueFrom(this.http.post(`${this.apiUrl}/users`, user)); + } + + async update(id: string, user: Partial): Promise { + return firstValueFrom(this.http.patch(`${this.apiUrl}/users/${id}`, user)); + } + + async delete(id: string): Promise { + await firstValueFrom(this.http.delete(`${this.apiUrl}/users/${id}`)); + } +} + +// Provide implementation +{ provide: Repository, useClass: HttpUserRepo } +``` + +## Abstract Classes as Tokens + +Use abstract classes for better type safety: + +```typescript +// Abstract service definition +export abstract class Logger { + abstract log(message: string): void; + abstract error(message: string, error?: Error): void; + abstract warn(message: string): void; +} + +// Console implementation +@Injectable() +export class ConsoleLog extends Logger { + log(message: string) { + console.log(`[LOG] ${message}`); + } + + error(message: string, error?: Error) { + console.error(`[ERROR] ${message}`, error); + } + + warn(message: string) { + console.warn(`[WARN] ${message}`); + } +} + +// Remote implementation +@Injectable() +export class RemoteLog extends Logger { + private http = inject(HttpClient); + + log(message: string) { + this.send('log', message); + } + + error(message: string, error?: Error) { + this.send('error', message, error); + } + + warn(message: string) { + this.send('warn', message); + } + + private send(level: string, message: string, error?: Error) { + this.http.post('/api/logs', { level, message, error: error?.message }).subscribe(); + } +} + +// Provide based on environment +{ + provide: Logger, + useClass: environment.production ? RemoteLog : ConsoleLog, +} + +// Inject using abstract class +@Injectable({ providedIn: 'root' }) +export class User { + private logger = inject(Logger); + + createUser(user: UserData) { + this.logger.log(`Creating user: ${user.email}`); + // ... + } +} +``` + +## Hierarchical Injection + +### Component Tree Injection + +```typescript +// Parent provides service +@Component({ + selector: 'app-form-container', + providers: [FormState], + template: ` + + + + `, +}) +export class FormContainer { + private formState = inject(FormState); +} + +// Children share same instance +@Component({ + selector: 'app-form-body', + template: `...`, +}) +export class FormBody { + // Gets same instance as parent + private formState = inject(FormState); +} + +// Grandchildren also share +@Component({ + selector: 'app-form-field', + template: `...`, +}) +export class FormField { + // Gets same instance from ancestor + private formState = inject(FormState); +} +``` + +### viewProviders vs providers + +```typescript +@Component({ + selector: 'app-tabs', + // providers: Available to component AND content children + providers: [TabsSvc], + + // viewProviders: Available to component AND view children only + // NOT available to content children () + viewProviders: [InternalTabs], + + template: ` +
+ +
+ `, +}) +export class Tabs {} +``` + +## Dynamic Providers + +### Feature Flags + +```typescript +export const FEATURE_FLAGS = new InjectionToken('FeatureFlags'); + +interface FeatureFlags { + newDashboard: boolean; + betaFeatures: boolean; + experimentalApi: boolean; +} + +// Load from API +{ + provide: FEATURE_FLAGS, + useFactory: async () => { + const response = await fetch('/api/features'); + return response.json(); + }, +} + +// Use in components +@Component({...}) +export class Dashboard { + private features = inject(FEATURE_FLAGS); + + showNewDashboard = this.features.newDashboard; +} +``` + +### Platform-Specific Services + +```typescript +export abstract class Storage { + abstract get(key: string): string | null; + abstract set(key: string, value: string): void; + abstract remove(key: string): void; +} + +@Injectable() +export class BrowserStorage extends Storage { + get(key: string) { return localStorage.getItem(key); } + set(key: string, value: string) { localStorage.setItem(key, value); } + remove(key: string) { localStorage.removeItem(key); } +} + +@Injectable() +export class ServerStorage extends Storage { + private store = new Map(); + + get(key: string) { return this.store.get(key) ?? null; } + set(key: string, value: string) { this.store.set(key, value); } + remove(key: string) { this.store.delete(key); } +} + +// Provide based on platform +import { PLATFORM_ID, isPlatformBrowser } from '@angular/common'; + +{ + provide: Storage, + useFactory: (platformId: object) => { + return isPlatformBrowser(platformId) + ? new BrowserStorage() + : new ServerStorage(); + }, + deps: [PLATFORM_ID], +} +``` + +## Testing with DI + +### Mocking Services + +```typescript +describe('UserCmpt', () => { + let userServiceSpy: jasmine.SpyObj; + + beforeEach(async () => { + userServiceSpy = jasmine.createSpyObj('User', ['getUser', 'updateUser']); + userServiceSpy.getUser.and.returnValue(of({ id: '1', name: 'Test' })); + + await TestBed.configureTestingModule({ + imports: [UserCmpt], + providers: [ + { provide: User, useValue: userServiceSpy }, + ], + }).compileComponents(); + }); + + it('should load user', () => { + const fixture = TestBed.createComponent(UserCmpt); + fixture.detectChanges(); + + expect(userServiceSpy.getUser).toHaveBeenCalled(); + }); +}); +``` + +### Overriding Providers + +```typescript +describe('with different config', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App], + }) + .overrideProvider(APP_CONFIG, { + useValue: { apiUrl: 'http://test-api.com' }, + }) + .compileComponents(); + }); +}); +``` + +### Testing Injection Tokens + +```typescript +describe('API_URL token', () => { + it('should provide correct URL', () => { + TestBed.configureTestingModule({ + providers: [ + { provide: API_URL, useValue: 'https://api.test.com' }, + ], + }); + + const apiUrl = TestBed.inject(API_URL); + expect(apiUrl).toBe('https://api.test.com'); + }); +}); +``` + +## DestroyRef and Cleanup + +### Automatic Cleanup + +```typescript +import { DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +@Component({...}) +export class Data { + private destroyRef = inject(DestroyRef); + private dataService = inject(DataSvc); + + constructor() { + // Auto-unsubscribe when component destroys + this.dataService.data$ + .pipe(takeUntilDestroyed()) + .subscribe(data => { + console.log(data); + }); + } + + // Or use DestroyRef directly + ngOnInit() { + const subscription = this.dataService.updates$.subscribe(); + + this.destroyRef.onDestroy(() => { + subscription.unsubscribe(); + console.log('Cleaned up!'); + }); + } +} +``` + +### In Services + +```typescript +@Injectable() +export class WebSocket { + private destroyRef = inject(DestroyRef); + private socket: WebSocket | null = null; + + constructor() { + this.destroyRef.onDestroy(() => { + this.socket?.close(); + }); + } + + connect(url: string) { + this.socket = new WebSocket(url); + } +} +``` + +### takeUntilDestroyed Outside Constructor + +```typescript +@Component({...}) +export class My { + private destroyRef = inject(DestroyRef); + + loadData() { + // Pass destroyRef when using outside constructor + this.http.get('/api/data') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(); + } +} +``` + +## Injection Context Utilities + +### assertInInjectionContext + +```typescript +import { assertInInjectionContext, inject } from '@angular/core'; + +export function injectLogger(): Logger { + assertInInjectionContext(injectLogger); + return inject(Logger); +} + +// Usage - must be called in injection context +@Component({...}) +export class My2 { + private logger = injectLogger(); // OK + + someMethod() { + // injectLogger(); // ERROR - not in injection context + } +} +``` + +### Custom inject Functions + +```typescript +// Create reusable injection utilities +export function injectRouteParam(param: string): Signal { + assertInInjectionContext(injectRouteParam); + + const route = inject(ActivatedRoute); + return toSignal( + route.paramMap.pipe(map(params => params.get(param))), + { initialValue: null } + ); +} + +export function injectQueryParam(param: string): Signal { + assertInInjectionContext(injectQueryParam); + + const route = inject(ActivatedRoute); + return toSignal( + route.queryParamMap.pipe(map(params => params.get(param))), + { initialValue: null } + ); +} + +// Usage +@Component({...}) +export class UserCmpt { + userId = injectRouteParam('id'); + tab = injectQueryParam('tab'); +} +``` diff --git a/skills/angular-directives/SKILL.md b/skills/angular-directives/SKILL.md new file mode 100644 index 0000000..5e781d1 --- /dev/null +++ b/skills/angular-directives/SKILL.md @@ -0,0 +1,449 @@ +--- +name: angular-directives +description: >- + Create custom directives in Angular v20+ for DOM manipulation and behavior + extension. Use for attribute directives that modify element + behavior/appearance, structural directives for portals/overlays, and host + directives for composition. Triggers on creating reusable DOM behaviors, + extending element functionality, or composing behaviors across components. + Note - use native @if/@for/@switch for control flow, not custom structural + directives. +metadata: + category: development + source: + repository: 'https://github.com/analogjs/angular-skills' + path: skills/angular-directives +--- + +# Angular Directives + +Create custom directives for reusable DOM manipulation and behavior in Angular v20+. + +## Attribute Directives + +Modify the appearance or behavior of an element: + +```typescript +import { Directive, input, effect, inject, ElementRef } from '@angular/core'; + +@Directive({ + selector: '[appHighlight]', +}) +export class Highlight { + private el = inject(ElementRef); + + // Input with alias matching selector + color = input('yellow', { alias: 'appHighlight' }); + + constructor() { + effect(() => { + this.el.nativeElement.style.backgroundColor = this.color(); + }); + } +} + +// Usage:

Highlighted text

+// Usage:

Default yellow highlight

+``` + +### Using host Property + +Prefer `host` over `@HostBinding`/`@HostListener`: + +```typescript +@Directive({ + selector: '[appTooltip]', + host: { + '(mouseenter)': 'show()', + '(mouseleave)': 'hide()', + '[attr.aria-describedby]': 'tooltipId', + }, +}) +export class Tooltip { + text = input.required({ alias: 'appTooltip' }); + position = input<'top' | 'bottom' | 'left' | 'right'>('top'); + + tooltipId = `tooltip-${crypto.randomUUID()}`; + private tooltipEl: HTMLElement | null = null; + private el = inject(ElementRef); + + show() { + this.tooltipEl = document.createElement('div'); + this.tooltipEl.id = this.tooltipId; + this.tooltipEl.className = `tooltip tooltip-${this.position()}`; + this.tooltipEl.textContent = this.text(); + this.tooltipEl.setAttribute('role', 'tooltip'); + document.body.appendChild(this.tooltipEl); + this.positionTooltip(); + } + + hide() { + this.tooltipEl?.remove(); + this.tooltipEl = null; + } + + private positionTooltip() { + // Position logic based on this.position() and this.el + } +} + +// Usage: +``` + +### Class and Style Manipulation + +```typescript +@Directive({ + selector: '[appButton]', + host: { + 'class': 'btn', + '[class.btn-primary]': 'variant() === "primary"', + '[class.btn-secondary]': 'variant() === "secondary"', + '[class.btn-sm]': 'size() === "small"', + '[class.btn-lg]': 'size() === "large"', + '[class.disabled]': 'disabled()', + '[attr.disabled]': 'disabled() || null', + }, +}) +export class Button { + variant = input<'primary' | 'secondary'>('primary'); + size = input<'small' | 'medium' | 'large'>('medium'); + disabled = input(false, { transform: booleanAttribute }); +} + +// Usage: +``` + +### Event Handling + +```typescript +@Directive({ + selector: '[appClickOutside]', + host: { + '(document:click)': 'onDocumentClick($event)', + }, +}) +export class ClickOutside { + private el = inject(ElementRef); + + clickOutside = output(); + + onDocumentClick(event: MouseEvent) { + if (!this.el.nativeElement.contains(event.target as Node)) { + this.clickOutside.emit(); + } + } +} + +// Usage:
...
+``` + +### Keyboard Shortcuts + +```typescript +@Directive({ + selector: '[appShortcut]', + host: { + '(document:keydown)': 'onKeydown($event)', + }, +}) +export class Shortcut { + key = input.required({ alias: 'appShortcut' }); + ctrl = input(false, { transform: booleanAttribute }); + shift = input(false, { transform: booleanAttribute }); + alt = input(false, { transform: booleanAttribute }); + + triggered = output(); + + onKeydown(event: KeyboardEvent) { + const keyMatch = event.key.toLowerCase() === this.key().toLowerCase(); + const ctrlMatch = this.ctrl() ? event.ctrlKey || event.metaKey : !event.ctrlKey && !event.metaKey; + const shiftMatch = this.shift() ? event.shiftKey : !event.shiftKey; + const altMatch = this.alt() ? event.altKey : !event.altKey; + + if (keyMatch && ctrlMatch && shiftMatch && altMatch) { + event.preventDefault(); + this.triggered.emit(event); + } + } +} + +// Usage: +``` + +## Structural Directives + +Use structural directives for DOM manipulation beyond control flow (portals, overlays, dynamic insertion points). For conditionals and loops, use native `@if`, `@for`, `@switch`. + +### Portal Directive + +Render content in a different DOM location: + +```typescript +import { Directive, inject, TemplateRef, ViewContainerRef, OnInit, OnDestroy, input } from '@angular/core'; + +@Directive({ + selector: '[appPortal]', +}) +export class Portal implements OnInit, OnDestroy { + private templateRef = inject(TemplateRef); + private viewContainerRef = inject(ViewContainerRef); + private viewRef: EmbeddedViewRef | null = null; + + // Target container selector or element + target = input('body', { alias: 'appPortal' }); + + ngOnInit() { + const container = this.getContainer(); + if (container) { + this.viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef); + this.viewRef.rootNodes.forEach(node => container.appendChild(node)); + } + } + + ngOnDestroy() { + this.viewRef?.destroy(); + } + + private getContainer(): HTMLElement | null { + const target = this.target(); + if (typeof target === 'string') { + return document.querySelector(target); + } + return target; + } +} + +// Usage: Render modal at body level +//
+// +//
+``` + +### Lazy Render Directive + +Defer rendering until condition is met (one-time): + +```typescript +@Directive({ + selector: '[appLazyRender]', +}) +export class LazyRender { + private templateRef = inject(TemplateRef); + private viewContainer = inject(ViewContainerRef); + private rendered = false; + + condition = input.required({ alias: 'appLazyRender' }); + + constructor() { + effect(() => { + // Only render once when condition becomes true + if (this.condition() && !this.rendered) { + this.viewContainer.createEmbeddedView(this.templateRef); + this.rendered = true; + } + }); + } +} + +// Usage: Render heavy component only when tab is first activated +//
+// +//
+``` + +### Template Outlet with Context + +```typescript +interface TemplateContext { + $implicit: T; + item: T; + index: number; +} + +@Directive({ + selector: '[appTemplateOutlet]', +}) +export class TemplateOutlet { + private viewContainer = inject(ViewContainerRef); + private currentView: EmbeddedViewRef> | null = null; + + template = input.required>>({ alias: 'appTemplateOutlet' }); + context = input.required({ alias: 'appTemplateOutletContext' }); + index = input(0, { alias: 'appTemplateOutletIndex' }); + + constructor() { + effect(() => { + const template = this.template(); + const context = this.context(); + const index = this.index(); + + if (this.currentView) { + this.currentView.context.$implicit = context; + this.currentView.context.item = context; + this.currentView.context.index = index; + this.currentView.markForCheck(); + } else { + this.currentView = this.viewContainer.createEmbeddedView(template, { + $implicit: context, + item: context, + index, + }); + } + }); + } +} + +// Usage: Custom list with template +// +//
{{ i }}: {{ item.name }}
+//
+// +``` + +## Host Directives + +Compose directives on components or other directives: + +```typescript +// Reusable behavior directives +@Directive({ + selector: '[focusable]', + host: { + 'tabindex': '0', + '(focus)': 'onFocus()', + '(blur)': 'onBlur()', + '[class.focused]': 'isFocused()', + }, +}) +export class Focusable { + isFocused = signal(false); + + onFocus() { this.isFocused.set(true); } + onBlur() { this.isFocused.set(false); } +} + +@Directive({ + selector: '[disableable]', + host: { + '[class.disabled]': 'disabled()', + '[attr.aria-disabled]': 'disabled()', + }, +}) +export class Disableable { + disabled = input(false, { transform: booleanAttribute }); +} + +// Component using host directives +@Component({ + selector: 'app-custom-button', + hostDirectives: [ + Focusable, + { + directive: Disableable, + inputs: ['disabled'], + }, + ], + host: { + 'role': 'button', + '(click)': 'onClick($event)', + '(keydown.enter)': 'onClick($event)', + '(keydown.space)': 'onClick($event)', + }, + template: ``, +}) +export class CustomButton { + private disableable = inject(Disableable); + + clicked = output(); + + onClick(event: Event) { + if (!this.disableable.disabled()) { + this.clicked.emit(); + } + } +} + +// Usage: Click me +``` + +### Exposing Host Directive Outputs + +```typescript +@Directive({ + selector: '[hoverable]', + host: { + '(mouseenter)': 'onEnter()', + '(mouseleave)': 'onLeave()', + '[class.hovered]': 'isHovered()', + }, +}) +export class Hoverable { + isHovered = signal(false); + + hoverChange = output(); + + onEnter() { + this.isHovered.set(true); + this.hoverChange.emit(true); + } + + onLeave() { + this.isHovered.set(false); + this.hoverChange.emit(false); + } +} + +@Component({ + selector: 'app-card', + hostDirectives: [ + { + directive: Hoverable, + outputs: ['hoverChange'], + }, + ], + template: ``, +}) +export class Card {} + +// Usage: ... +``` + +## Directive Composition API + +Combine multiple behaviors: + +```typescript +// Base directives +@Directive({ selector: '[withRipple]' }) +export class Ripple { + // Ripple effect implementation +} + +@Directive({ selector: '[withElevation]' }) +export class Elevation { + elevation = input(2); +} + +// Composed component +@Component({ + selector: 'app-material-button', + hostDirectives: [ + Ripple, + { + directive: Elevation, + inputs: ['elevation'], + }, + { + directive: Disableable, + inputs: ['disabled'], + }, + ], + template: ``, +}) +export class MaterialButton {} +``` + +For advanced patterns, see [references/directive-patterns.md](references/directive-patterns.md). diff --git a/skills/angular-directives/references/directive-patterns.md b/skills/angular-directives/references/directive-patterns.md new file mode 100644 index 0000000..06c02d3 --- /dev/null +++ b/skills/angular-directives/references/directive-patterns.md @@ -0,0 +1,570 @@ +# Angular Directive Patterns + +## Table of Contents +- [DOM Manipulation](#dom-manipulation) +- [Form Directives](#form-directives) +- [Intersection Observer](#intersection-observer) +- [Resize Observer](#resize-observer) +- [Drag and Drop](#drag-and-drop) +- [Permission Directive](#permission-directive) + +## DOM Manipulation + +### Auto-Focus Directive + +```typescript +@Directive({ + selector: '[appAutoFocus]', +}) +export class AutoFocus { + private el = inject(ElementRef); + + enabled = input(true, { alias: 'appAutoFocus', transform: booleanAttribute }); + delay = input(0); + + constructor() { + afterNextRender(() => { + if (this.enabled()) { + setTimeout(() => { + this.el.nativeElement.focus(); + }, this.delay()); + } + }); + } +} + +// Usage: +// Usage: +``` + +### Text Selection Directive + +```typescript +@Directive({ + selector: '[appSelectAll]', + host: { + '(focus)': 'onFocus()', + '(click)': 'onClick($event)', + }, +}) +export class SelectAll { + private el = inject(ElementRef); + + onFocus() { + // Delay to ensure value is set + setTimeout(() => this.el.nativeElement.select(), 0); + } + + onClick(event: MouseEvent) { + // Select all on first click if not already focused + if (document.activeElement !== this.el.nativeElement) { + this.el.nativeElement.select(); + } + } +} + +// Usage: +``` + +### Copy to Clipboard + +```typescript +@Directive({ + selector: '[appCopyToClipboard]', + host: { + '(click)': 'copy()', + '[style.cursor]': '"pointer"', + }, +}) +export class CopyToClipboard { + text = input.required({ alias: 'appCopyToClipboard' }); + + copied = output(); + error = output(); + + async copy() { + try { + await navigator.clipboard.writeText(this.text()); + this.copied.emit(); + } catch (err) { + this.error.emit(err as Error); + } + } +} + +// Usage: +// +``` + +## Form Directives + +### Trim Input + +```typescript +@Directive({ + selector: 'input[appTrim], textarea[appTrim]', + host: { + '(blur)': 'onBlur()', + }, +}) +export class Trim { + private el = inject(ElementRef); + private ngControl = inject(NgControl, { optional: true, self: true }); + + onBlur() { + const value = this.el.nativeElement.value; + const trimmed = value.trim(); + + if (value !== trimmed) { + this.el.nativeElement.value = trimmed; + this.ngControl?.control?.setValue(trimmed); + } + } +} + +// Usage: +``` + +### Input Mask + +```typescript +@Directive({ + selector: '[appMask]', + host: { + '(input)': 'onInput($event)', + '(keydown)': 'onKeydown($event)', + }, +}) +export class Mask { + private el = inject(ElementRef); + + // Mask pattern: 9 = digit, A = letter, * = any + mask = input.required({ alias: 'appMask' }); + + onInput(event: InputEvent) { + const input = this.el.nativeElement; + const value = input.value; + const masked = this.applyMask(value); + + if (value !== masked) { + input.value = masked; + } + } + + onKeydown(event: KeyboardEvent) { + // Allow navigation keys + if (['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(event.key)) { + return; + } + + const input = this.el.nativeElement; + const position = input.selectionStart ?? 0; + const maskChar = this.mask()[position]; + + if (!maskChar) { + event.preventDefault(); + return; + } + + if (!this.isValidChar(event.key, maskChar)) { + event.preventDefault(); + } + } + + private applyMask(value: string): string { + const mask = this.mask(); + let result = ''; + let valueIndex = 0; + + for (let i = 0; i < mask.length && valueIndex < value.length; i++) { + const maskChar = mask[i]; + const inputChar = value[valueIndex]; + + if (maskChar === '9' || maskChar === 'A' || maskChar === '*') { + if (this.isValidChar(inputChar, maskChar)) { + result += inputChar; + valueIndex++; + } else { + valueIndex++; + i--; + } + } else { + result += maskChar; + if (inputChar === maskChar) { + valueIndex++; + } + } + } + + return result; + } + + private isValidChar(char: string, maskChar: string): boolean { + switch (maskChar) { + case '9': return /\d/.test(char); + case 'A': return /[a-zA-Z]/.test(char); + case '*': return /[a-zA-Z0-9]/.test(char); + default: return char === maskChar; + } + } +} + +// Usage: +``` + +### Character Counter + +```typescript +@Directive({ + selector: '[appCharCount]', +}) +export class CharCount { + private el = inject(ElementRef); + + maxLength = input.required({ alias: 'appCharCount' }); + + currentLength = signal(0); + remaining = computed(() => this.maxLength() - this.currentLength()); + isOverLimit = computed(() => this.remaining() < 0); + + constructor() { + effect(() => { + this.currentLength.set(this.el.nativeElement.value.length); + }); + + // Listen for input changes + afterNextRender(() => { + this.el.nativeElement.addEventListener('input', () => { + this.currentLength.set(this.el.nativeElement.value.length); + }); + }); + } +} + +// Usage with template: +// +// {{ counter.remaining() }} characters remaining +``` + +## Intersection Observer + +### Lazy Load Directive + +```typescript +@Directive({ + selector: '[appLazyLoad]', +}) +export class LazyLoad implements OnDestroy { + private el = inject(ElementRef); + private observer: IntersectionObserver | null = null; + + src = input.required({ alias: 'appLazyLoad' }); + placeholder = input('/assets/placeholder.png'); + + loaded = output(); + + constructor() { + afterNextRender(() => { + this.setupObserver(); + }); + } + + private setupObserver() { + this.observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + this.loadImage(); + this.observer?.disconnect(); + } + }); + }, + { rootMargin: '50px' } + ); + + this.observer.observe(this.el.nativeElement); + + // Set placeholder + if (this.el.nativeElement instanceof HTMLImageElement) { + this.el.nativeElement.src = this.placeholder(); + } + } + + private loadImage() { + const element = this.el.nativeElement; + + if (element instanceof HTMLImageElement) { + element.src = this.src(); + element.onload = () => this.loaded.emit(); + } else { + element.style.backgroundImage = `url(${this.src()})`; + this.loaded.emit(); + } + } + + ngOnDestroy() { + this.observer?.disconnect(); + } +} + +// Usage: Lazy loaded image +``` + +### Infinite Scroll + +```typescript +@Directive({ + selector: '[appInfiniteScroll]', +}) +export class InfiniteScroll implements OnDestroy { + private el = inject(ElementRef); + private observer: IntersectionObserver | null = null; + + threshold = input(0.1); + disabled = input(false); + + scrolled = output(); + + constructor() { + afterNextRender(() => { + this.setupObserver(); + }); + + effect(() => { + if (this.disabled()) { + this.observer?.disconnect(); + } else { + this.setupObserver(); + } + }); + } + + private setupObserver() { + this.observer?.disconnect(); + + this.observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !this.disabled()) { + this.scrolled.emit(); + } + }, + { threshold: this.threshold() } + ); + + this.observer.observe(this.el.nativeElement); + } + + ngOnDestroy() { + this.observer?.disconnect(); + } +} + +// Usage: +//
+// @for (item of items(); track item.id) { +//
{{ item.name }}
+// } +//
+// Loading... +//
+//
+``` + +## Resize Observer + +```typescript +@Directive({ + selector: '[appResize]', +}) +export class Resize implements OnDestroy { + private el = inject(ElementRef); + private observer: ResizeObserver | null = null; + + width = signal(0); + height = signal(0); + + resized = output<{ width: number; height: number }>(); + + constructor() { + afterNextRender(() => { + this.observer = new ResizeObserver((entries) => { + const entry = entries[0]; + const { width, height } = entry.contentRect; + + this.width.set(width); + this.height.set(height); + this.resized.emit({ width, height }); + }); + + this.observer.observe(this.el.nativeElement); + }); + } + + ngOnDestroy() { + this.observer?.disconnect(); + } +} + +// Usage: +//
+// Size: {{ resize.width() }}x{{ resize.height() }} +//
+``` + +## Drag and Drop + +```typescript +@Directive({ + selector: '[appDraggable]', + host: { + 'draggable': 'true', + '[class.dragging]': 'isDragging()', + '(dragstart)': 'onDragStart($event)', + '(dragend)': 'onDragEnd($event)', + }, +}) +export class Draggable { + data = input(null, { alias: 'appDraggable' }); + effectAllowed = input('move'); + + isDragging = signal(false); + + dragStart = output(); + dragEnd = output(); + + onDragStart(event: DragEvent) { + this.isDragging.set(true); + + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = this.effectAllowed(); + event.dataTransfer.setData('application/json', JSON.stringify(this.data())); + } + + this.dragStart.emit(event); + } + + onDragEnd(event: DragEvent) { + this.isDragging.set(false); + this.dragEnd.emit(event); + } +} + +@Directive({ + selector: '[appDropZone]', + host: { + '[class.drag-over]': 'isDragOver()', + '(dragover)': 'onDragOver($event)', + '(dragleave)': 'onDragLeave($event)', + '(drop)': 'onDrop($event)', + }, +}) +export class DropZone { + isDragOver = signal(false); + + dropped = output(); + + onDragOver(event: DragEvent) { + event.preventDefault(); + this.isDragOver.set(true); + } + + onDragLeave(event: DragEvent) { + this.isDragOver.set(false); + } + + onDrop(event: DragEvent) { + event.preventDefault(); + this.isDragOver.set(false); + + const data = event.dataTransfer?.getData('application/json'); + if (data) { + this.dropped.emit(JSON.parse(data)); + } + } +} + +// Usage: +//
Drag me
+//
Drop here
+``` + +## Permission Directive + +```typescript +@Directive({ + selector: '[appHasPermission]', +}) +export class HasPermission { + private templateRef = inject(TemplateRef); + private viewContainer = inject(ViewContainerRef); + private authService = inject(Auth); + private hasView = false; + + permission = input.required({ alias: 'appHasPermission' }); + mode = input<'any' | 'all'>('any'); + + constructor() { + effect(() => { + const hasPermission = this.checkPermission(); + + if (hasPermission && !this.hasView) { + this.viewContainer.createEmbeddedView(this.templateRef); + this.hasView = true; + } else if (!hasPermission && this.hasView) { + this.viewContainer.clear(); + this.hasView = false; + } + }); + } + + private checkPermission(): boolean { + const required = this.permission(); + const permissions = Array.isArray(required) ? required : [required]; + const userPermissions = this.authService.permissions(); + + if (this.mode() === 'all') { + return permissions.every(p => userPermissions.includes(p)); + } + + return permissions.some(p => userPermissions.includes(p)); + } +} + +// Usage: +// +//
Edit & Delete
+``` + +## Export Directive Reference + +```typescript +@Directive({ + selector: '[appToggle]', + exportAs: 'appToggle', +}) +export class Toggle { + isOpen = signal(false); + + toggle() { + this.isOpen.update(v => !v); + } + + open() { + this.isOpen.set(true); + } + + close() { + this.isOpen.set(false); + } +} + +// Usage: +//
+// +// @if (toggle.isOpen()) { +//
Content
+// } +//
+``` diff --git a/skills/angular-forms/SKILL.md b/skills/angular-forms/SKILL.md new file mode 100644 index 0000000..f97d54a --- /dev/null +++ b/skills/angular-forms/SKILL.md @@ -0,0 +1,446 @@ +--- +name: angular-forms +description: >- + Build signal-based forms in Angular v21+ using the new Signal Forms API. Use + for form creation with automatic two-way binding, schema-based validation, + field state management, and dynamic forms. Triggers on form implementation, + adding validation, creating multi-step forms, or building forms with + conditional fields. Signal Forms are experimental but recommended for new + Angular projects. Don't use for template-driven forms without signals or + third-party form libraries like Formly or ngx-formly. +metadata: + category: development + source: + repository: 'https://github.com/analogjs/angular-skills' + path: skills/angular-forms +--- + +# Angular Signal Forms + +Build type-safe, reactive forms using Angular's Signal Forms API. Signal Forms provide automatic two-way binding, schema-based validation, and reactive field state. + +**Note:** Signal Forms are experimental in Angular v21. For production apps requiring stability, see [references/form-patterns.md](references/form-patterns.md) for Reactive Forms patterns. + +## Basic Setup + +```typescript +import { Component, signal } from '@angular/core'; +import { form, FormField, required, email } from '@angular/forms/signals'; + +interface LoginData { + email: string; + password: string; +} + +@Component({ + selector: 'app-login', + imports: [FormField], + template: ` +
+ + @if (loginForm.email().touched() && loginForm.email().invalid()) { +

{{ loginForm.email().errors()[0].message }}

+ } + + + @if (loginForm.password().touched() && loginForm.password().invalid()) { +

{{ loginForm.password().errors()[0].message }}

+ } + + +
+ `, +}) +export class Login { + // Form model - a writable signal + loginModel = signal({ + email: '', + password: '', + }); + + // Create form with validation schema + loginForm = form(this.loginModel, (schemaPath) => { + required(schemaPath.email, { message: 'Email is required' }); + email(schemaPath.email, { message: 'Enter a valid email address' }); + required(schemaPath.password, { message: 'Password is required' }); + }); + + onSubmit(event: Event) { + event.preventDefault(); + if (this.loginForm().valid()) { + const credentials = this.loginModel(); + console.log('Submitting:', credentials); + } + } +} +``` + +## Form Models + +Form models are writable signals that serve as the single source of truth: + +```typescript +// Define interface for type safety +interface UserProfile { + name: string; + email: string; + age: number | null; + preferences: { + newsletter: boolean; + theme: 'light' | 'dark'; + }; +} + +// Create model signal with initial values +const userModel = signal({ + name: '', + email: '', + age: null, + preferences: { + newsletter: false, + theme: 'light', + }, +}); + +// Create form from model +const userForm = form(userModel); + +// Access nested fields via dot notation +userForm.name // FieldTree +userForm.preferences.theme // FieldTree<'light' | 'dark'> +``` + +### Reading Values + +```typescript +// Read entire model +const data = this.userModel(); + +// Read field value via field state +const name = this.userForm.name().value(); +const theme = this.userForm.preferences.theme().value(); +``` + +### Updating Values + +```typescript +// Replace entire model +this.userModel.set({ + name: 'Alice', + email: 'alice@example.com', + age: 30, + preferences: { newsletter: true, theme: 'dark' }, +}); + +// Update single field +this.userForm.name().value.set('Bob'); +this.userForm.age().value.update(age => (age ?? 0) + 1); +``` + +## Field State + +Each field provides reactive signals for validation, interaction, and availability: + +```typescript +const emailField = this.form.email(); + +// Validation state +emailField.valid() // true if passes all validation +emailField.invalid() // true if has validation errors +emailField.errors() // array of error objects +emailField.pending() // true if async validation in progress + +// Interaction state +emailField.touched() // true after focus + blur +emailField.dirty() // true after user modification + +// Availability state +emailField.disabled() // true if field is disabled +emailField.hidden() // true if field should be hidden +emailField.readonly() // true if field is readonly + +// Value +emailField.value() // current field value (signal) +``` + +### Form-Level State + +The form itself is also a field with aggregated state: + +```typescript +// Form is valid when all interactive fields are valid +this.form().valid() + +// Form is touched when any field is touched +this.form().touched() + +// Form is dirty when any field is modified +this.form().dirty() +``` + +## Validation + +### Built-in Validators + +```typescript +import { + form, required, email, min, max, + minLength, maxLength, pattern +} from '@angular/forms/signals'; + +const userForm = form(this.userModel, (schemaPath) => { + // Required field + required(schemaPath.name, { message: 'Name is required' }); + + // Email format + email(schemaPath.email, { message: 'Invalid email' }); + + // Numeric range + min(schemaPath.age, 18, { message: 'Must be 18+' }); + max(schemaPath.age, 120, { message: 'Invalid age' }); + + // String/array length + minLength(schemaPath.password, 8, { message: 'Min 8 characters' }); + maxLength(schemaPath.bio, 500, { message: 'Max 500 characters' }); + + // Regex pattern + pattern(schemaPath.phone, /^\d{3}-\d{3}-\d{4}$/, { + message: 'Format: 555-123-4567', + }); +}); +``` + +### Conditional Validation + +```typescript +const orderForm = form(this.orderModel, (schemaPath) => { + required(schemaPath.promoCode, { + message: 'Promo code required for discounts', + when: ({ valueOf }) => valueOf(schemaPath.applyDiscount), + }); +}); +``` + +### Custom Validators + +```typescript +import { validate } from '@angular/forms/signals'; + +const signupForm = form(this.signupModel, (schemaPath) => { + // Custom validation logic + validate(schemaPath.username, ({ value }) => { + if (value().includes(' ')) { + return { kind: 'noSpaces', message: 'Username cannot contain spaces' }; + } + return null; + }); +}); +``` + +### Cross-Field Validation + +```typescript +const passwordForm = form(this.passwordModel, (schemaPath) => { + required(schemaPath.password); + required(schemaPath.confirmPassword); + + // Compare fields + validate(schemaPath.confirmPassword, ({ value, valueOf }) => { + if (value() !== valueOf(schemaPath.password)) { + return { kind: 'mismatch', message: 'Passwords do not match' }; + } + return null; + }); +}); +``` + +### Async Validation + +```typescript +import { validateHttp } from '@angular/forms/signals'; + +const signupForm = form(this.signupModel, (schemaPath) => { + validateHttp(schemaPath.username, { + request: ({ value }) => `/api/check-username?u=${value()}`, + onSuccess: (response: { taken: boolean }) => { + if (response.taken) { + return { kind: 'taken', message: 'Username already taken' }; + } + return null; + }, + onError: () => ({ + kind: 'networkError', + message: 'Could not verify username', + }), + }); +}); +``` + +## Conditional Fields + +### Hidden Fields + +```typescript +import { hidden } from '@angular/forms/signals'; + +const profileForm = form(this.profileModel, (schemaPath) => { + hidden(schemaPath.publicUrl, ({ valueOf }) => !valueOf(schemaPath.isPublic)); +}); +``` + +```html +@if (!profileForm.publicUrl().hidden()) { + +} +``` + +### Disabled Fields + +```typescript +import { disabled } from '@angular/forms/signals'; + +const orderForm = form(this.orderModel, (schemaPath) => { + disabled(schemaPath.couponCode, ({ valueOf }) => valueOf(schemaPath.total) < 50); +}); +``` + +### Readonly Fields + +```typescript +import { readonly } from '@angular/forms/signals'; + +const accountForm = form(this.accountModel, (schemaPath) => { + readonly(schemaPath.username); // Always readonly +}); +``` + +## Form Submission + +```typescript +import { submit } from '@angular/forms/signals'; + +@Component({ + template: ` +
+ + + +
+ `, +}) +export class Login { + model = signal({ email: '', password: '' }); + form = form(this.model, (schemaPath) => { + required(schemaPath.email); + required(schemaPath.password); + }); + + onSubmit(event: Event) { + event.preventDefault(); + + // submit() marks all fields touched and runs callback if valid + submit(this.form, async () => { + await this.authService.login(this.model()); + }); + } +} +``` + +## Arrays and Dynamic Fields + +```typescript +interface Order { + items: Array<{ product: string; quantity: number }>; +} + +@Component({ + template: ` + @for (item of orderForm.items; track $index; let i = $index) { +
+ + + +
+ } + + `, +}) +export class Order { + orderModel = signal({ + items: [{ product: '', quantity: 1 }], + }); + + orderForm = form(this.orderModel, (schemaPath) => { + applyEach(schemaPath.items, (item) => { + required(item.product, { message: 'Product required' }); + min(item.quantity, 1, { message: 'Min quantity is 1' }); + }); + }); + + addItem() { + this.orderModel.update(m => ({ + ...m, + items: [...m.items, { product: '', quantity: 1 }], + })); + } + + removeItem(index: number) { + this.orderModel.update(m => ({ + ...m, + items: m.items.filter((_, i) => i !== index), + })); + } +} +``` + +## Displaying Errors + +```html + + +@if (form.email().touched() && form.email().invalid()) { +
    + @for (error of form.email().errors(); track error) { +
  • {{ error.message }}
  • + } +
+} + +@if (form.email().pending()) { + Validating... +} +``` + +## Styling Based on State + +```html + +``` + +## Reset Form + +```typescript +async onSubmit() { + if (!this.form().valid()) return; + + await this.api.submit(this.model()); + + // Clear interaction state + this.form().reset(); + + // Clear values + this.model.set({ email: '', password: '' }); +} +``` + +For Reactive Forms patterns (production-stable), see [references/form-patterns.md](references/form-patterns.md). diff --git a/skills/angular-forms/references/form-patterns.md b/skills/angular-forms/references/form-patterns.md new file mode 100644 index 0000000..522f9b8 --- /dev/null +++ b/skills/angular-forms/references/form-patterns.md @@ -0,0 +1,405 @@ +# Angular Form Patterns + +## Table of Contents +- [Reactive Forms (Production-Stable)](#reactive-forms-production-stable) +- [Typed Reactive Forms](#typed-reactive-forms) +- [FormBuilder Patterns](#formbuilder-patterns) +- [Dynamic Forms with FormArray](#dynamic-forms-with-formarray) +- [Custom Validators](#custom-validators) +- [Form State Management](#form-state-management) + +## Reactive Forms (Production-Stable) + +For production applications requiring stability guarantees, use Reactive Forms: + +```typescript +import { Component, inject } from '@angular/core'; +import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; + +@Component({ + selector: 'app-login', + imports: [ReactiveFormsModule], + template: ` +
+ + @if (form.controls.email.errors?.['required'] && form.controls.email.touched) { + Email is required + } + + + + +
+ `, +}) +export class Login { + private fb = inject(FormBuilder); + + form = this.fb.group({ + email: ['', [Validators.required, Validators.email]], + password: ['', [Validators.required, Validators.minLength(8)]], + }); + + onSubmit() { + if (this.form.valid) { + console.log(this.form.value); + } + } +} +``` + +## Typed Reactive Forms + +### Typed FormControl + +```typescript +import { FormControl } from '@angular/forms'; + +// Inferred type: FormControl +const name = new FormControl(''); + +// Non-nullable (no reset to null) +const email = new FormControl('', { nonNullable: true }); +// Type: FormControl + +// With validators +const username = new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.minLength(3)], +}); +``` + +### Typed FormGroup + +```typescript +import { FormGroup, FormControl } from '@angular/forms'; + +interface UserForm { + name: FormControl; + email: FormControl; + age: FormControl; +} + +const form = new FormGroup({ + name: new FormControl('', { nonNullable: true }), + email: new FormControl('', { nonNullable: true }), + age: new FormControl(null), +}); + +// Typed value access +const name: string = form.controls.name.value; +``` + +### NonNullableFormBuilder + +```typescript +import { inject } from '@angular/core'; +import { NonNullableFormBuilder } from '@angular/forms'; + +@Component({...}) +export class Profile { + private fb = inject(NonNullableFormBuilder); + + form = this.fb.group({ + name: ['', Validators.required], // FormControl + email: ['', [Validators.required, Validators.email]], + preferences: this.fb.group({ + newsletter: [false], // FormControl + theme: ['light' as 'light' | 'dark'], // FormControl<'light' | 'dark'> + }), + }); +} +``` + +## FormBuilder Patterns + +### Nested FormGroups + +```typescript +@Component({ + imports: [ReactiveFormsModule], + template: ` +
+ + +
+ + + +
+ + +
+ `, +}) +export class Profile { + private fb = inject(NonNullableFormBuilder); + + form = this.fb.group({ + name: ['', Validators.required], + address: this.fb.group({ + street: [''], + city: ['', Validators.required], + zip: ['', [Validators.required, Validators.pattern(/^\d{5}$/)]], + }), + }); +} +``` + +## Dynamic Forms with FormArray + +```typescript +import { FormArray } from '@angular/forms'; + +@Component({ + imports: [ReactiveFormsModule], + template: ` +
+
+ @for (item of items.controls; track $index; let i = $index) { +
+ + + +
+ } +
+ +
+ `, +}) +export class Order { + private fb = inject(NonNullableFormBuilder); + + form = this.fb.group({ + items: this.fb.array([this.createItem()]), + }); + + get items() { + return this.form.controls.items; + } + + createItem() { + return this.fb.group({ + product: ['', Validators.required], + quantity: [1, [Validators.required, Validators.min(1)]], + }); + } + + addItem() { + this.items.push(this.createItem()); + } + + removeItem(index: number) { + this.items.removeAt(index); + } +} +``` + +## Custom Validators + +### Sync Validator + +```typescript +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; + +export function forbiddenValue(forbidden: string): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + return control.value === forbidden + ? { forbiddenValue: { value: control.value } } + : null; + }; +} + +// Usage +name: ['', [Validators.required, forbiddenValue('admin')]], +``` + +### Cross-Field Validator + +```typescript +export function passwordMatch(): ValidatorFn { + return (group: AbstractControl): ValidationErrors | null => { + const password = group.get('password')?.value; + const confirm = group.get('confirmPassword')?.value; + return password === confirm ? null : { passwordMismatch: true }; + }; +} + +// Usage +form = this.fb.group({ + password: ['', [Validators.required, Validators.minLength(8)]], + confirmPassword: ['', Validators.required], +}, { validators: passwordMatch() }); +``` + +### Async Validator + +```typescript +import { AsyncValidatorFn } from '@angular/forms'; +import { map, catchError, of } from 'rxjs'; + +export function uniqueEmail(userService: User): AsyncValidatorFn { + return (control: AbstractControl) => { + return userService.checkEmail(control.value).pipe( + map(exists => exists ? { emailTaken: true } : null), + catchError(() => of(null)) + ); + }; +} + +// Usage +email: ['', + [Validators.required, Validators.email], // sync validators + [uniqueEmail(this.userService)] // async validators +], +``` + +## Form State Management + +### State Properties + +```typescript +// Check states +form.valid // All validations pass +form.invalid // Has validation errors +form.pending // Async validation in progress +form.dirty // Value changed by user +form.pristine // Value not changed +form.touched // Control has been focused +form.untouched // Control never focused + +// Update values +form.setValue({ name: 'John', email: 'john@example.com' }); // Must include all +form.patchValue({ name: 'John' }); // Partial update + +// Reset +form.reset(); +form.reset({ name: 'Default' }); + +// Disable/Enable +form.disable(); +form.enable(); +form.controls.email.disable(); + +// Mark states +form.markAllAsTouched(); // Show all errors +form.markAsPristine(); +form.markAsDirty(); +``` + +### Value Changes Observable + +```typescript +// Subscribe to value changes +form.valueChanges.subscribe(value => { + console.log('Form value:', value); +}); + +// Single control with debounce +form.controls.email.valueChanges.pipe( + debounceTime(300), + distinctUntilChanged() +).subscribe(email => { + this.validateEmail(email); +}); + +// Status changes +form.statusChanges.subscribe(status => { + console.log('Form status:', status); // VALID, INVALID, PENDING +}); +``` + +### Unified Events (Angular v18+) + +```typescript +import { + ValueChangeEvent, StatusChangeEvent, + PristineChangeEvent,TouchedChangeEvent, + FormSubmittedEvent, FormResetEvent +} from '@angular/forms'; + +form.events.subscribe(event => { + if (event instanceof ValueChangeEvent) { + console.log('Value changed:', event.value); + } + if (event instanceof StatusChangeEvent) { + console.log('Status changed:', event.status); + } + if (event instanceof PristineChangeEvent) { + console.log('Pristine changed:', event.pristine); + } + if (event instanceof TouchedChangeEvent) { + console.log('Touched changed:', event.touched); + } + if (event instanceof FormSubmittedEvent) { + console.log('Form submitted'); + } + if (event instanceof FormResetEvent) { + console.log('Form reset'); + } +}); +``` + +## Error Display Pattern + +```typescript +@Component({ + template: ` + + + @if (form.controls.email.invalid && form.controls.email.touched) { +
+ @if (form.controls.email.errors?.['required']) { + Email is required + } + @if (form.controls.email.errors?.['email']) { + Invalid email format + } +
+ } + `, +}) +export class Form { + // Helper for cleaner templates + hasError(controlName: string, errorKey: string): boolean { + const control = this.form.get(controlName); + return control?.hasError(errorKey) && control?.touched || false; + } +} +``` + +## Form Submission Pattern + +```typescript +@Component({ + template: ` +
+ + +
+ `, +}) +export class Form { + isSubmitting = false; + + async onSubmit() { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + + this.isSubmitting = true; + try { + await this.api.submit(this.form.getRawValue()); + this.form.reset(); + } catch (error) { + // Handle error + } finally { + this.isSubmitting = false; + } + } +} +``` diff --git a/skills/angular-forms/references/formvalueControl-patterns.md b/skills/angular-forms/references/formvalueControl-patterns.md new file mode 100644 index 0000000..ef9f4af --- /dev/null +++ b/skills/angular-forms/references/formvalueControl-patterns.md @@ -0,0 +1,110 @@ +# Angular Signal Forms - ( FormValueControl ) + +## Table of Contents +- [Signal Form FormValueControl](#formValueControl) + +## Signal Forms FormValueControl + +``` typescript + +interface Rating { + rating : number +} + +import { form, FormField, FormValueControl, ValidationError, WithOptionalField } from '@angular/forms/signals'; +import { MatIconModule } from '@angular/material/icon'; +import { MatError } from '@angular/material/form-field'; + + +@Component({ + selector: 'app-rating', + imports : [MatIconModule,MatError], + template: ` +
+ @for (star of starArray(); track $index) { + + {{ getStarIcon(star) }} + + } + @if (errors().at(0)?.message) { + + {{ errors().at(0)?.message }} + + } +
+ `, + styles: ``, +}) +export class Rating implements FormValueControl { + // Required: The value of the control, exposed as a two-way binding. + readonly value = model(0); + // Optional: Bindings for other form control states. + readonly readonly = input(false); + readonly invalid = input(false); + readonly errors: InputSignal[]> = input< + readonly WithOptionalField[] + >([]); + + starArray: Signal = signal( + Array(5) + .fill(0) + .map((_, i) => i + 1), + ); + + getStarIcon(index: number): string { + const floorRating = Math.floor(this.value()); + if (index <= floorRating) { + return 'star'; // Full star + } else { + return 'star_border'; // Empty star + } + } + rate(index: number): void { + if (!this.readonly()) { + this.value.set(index); + } + } +} + + +import { FormField } from '@angular/forms/signals'; + +@Component({ + selector: 'app-signal-forms', + imports : [FormField, Rating], + template: ` +
+
+ + + + + {{ratingForm.rating().value()}} +
+
+ `, + styles: ``, +}) +export class SignalForms { + readonly ratingModel = signal({ + rating: 0, + }); + + readonly ratingForm = form(this.ratingModel) + + submit(event: Event): void { + event.preventDefault(); + console.log(this.ratingForm.rating().value()); + } +} + + + +``` + diff --git a/skills/angular-http/SKILL.md b/skills/angular-http/SKILL.md new file mode 100644 index 0000000..bd3de3c --- /dev/null +++ b/skills/angular-http/SKILL.md @@ -0,0 +1,376 @@ +--- +name: angular-http +description: >- + Implement HTTP data fetching in Angular v20+ using resource(), httpResource(), + and HttpClient. Use for API calls, data loading with signals, request/response + handling, and interceptors. Triggers on data fetching, API integration, + loading states, error handling, or converting Observable-based HTTP to + signal-based patterns. +metadata: + category: development + source: + repository: 'https://github.com/analogjs/angular-skills' + path: skills/angular-http +--- + +# Angular HTTP & Data Fetching + +Fetch data in Angular using signal-based `resource()`, `httpResource()`, and the traditional `HttpClient`. + +## httpResource() - Signal-Based HTTP + +`httpResource()` wraps HttpClient with signal-based state management: + +```typescript +import { Component, signal } from '@angular/core'; +import { httpResource } from '@angular/common/http'; + +interface User { + id: number; + name: string; + email: string; +} + +@Component({ + selector: 'app-user-profile', + template: ` + @if (userResource.isLoading()) { +

Loading...

+ } @else if (userResource.error()) { +

Error: {{ userResource.error()?.message }}

+ + } @else if (userResource.hasValue()) { +

{{ userResource.value().name }}

+

{{ userResource.value().email }}

+ } + `, +}) +export class UserProfile { + userId = signal('123'); + + // Reactive HTTP resource - refetches when userId changes + userResource = httpResource(() => `/api/users/${this.userId()}`); +} +``` + +### httpResource Options + +```typescript +// Simple GET request +userResource = httpResource(() => `/api/users/${this.userId()}`); + +// With full request options +userResource = httpResource(() => ({ + url: `/api/users/${this.userId()}`, + method: 'GET', + headers: { 'Authorization': `Bearer ${this.token()}` }, + params: { include: 'profile' }, +})); + +// With default value +usersResource = httpResource(() => '/api/users', { + defaultValue: [], +}); + +// Skip request when params undefined +userResource = httpResource(() => { + const id = this.userId(); + return id ? `/api/users/${id}` : undefined; +}); +``` + +### Resource State + +```typescript +// Status signals +userResource.value() // Current value or undefined +userResource.hasValue() // Boolean - has resolved value +userResource.error() // Error or undefined +userResource.isLoading() // Boolean - currently loading +userResource.status() // 'idle' | 'loading' | 'reloading' | 'resolved' | 'error' | 'local' + +// Actions +userResource.reload() // Manually trigger reload +userResource.set(value) // Set local value +userResource.update(fn) // Update local value +``` + +## resource() - Generic Async Data + +For non-HTTP async operations or custom fetch logic: + +```typescript +import { resource, signal } from '@angular/core'; + +@Component({...}) +export class Search { + query = signal(''); + + searchResource = resource({ + // Reactive params - triggers reload when changed + params: () => ({ q: this.query() }), + + // Async loader function + loader: async ({ params, abortSignal }) => { + if (!params.q) return []; + + const response = await fetch(`/api/search?q=${params.q}`, { + signal: abortSignal, + }); + return response.json() as Promise; + }, + }); +} +``` + +### Resource with Default Value + +```typescript +todosResource = resource({ + defaultValue: [] as Todo[], + params: () => ({ filter: this.filter() }), + loader: async ({ params }) => { + const res = await fetch(`/api/todos?filter=${params.filter}`); + return res.json(); + }, +}); + +// value() returns Todo[] (never undefined) +``` + +### Conditional Loading + +```typescript +const userId = signal(null); + +userResource = resource({ + params: () => { + const id = userId(); + // Return undefined to skip loading + return id ? { id } : undefined; + }, + loader: async ({ params }) => { + return fetch(`/api/users/${params.id}`).then(r => r.json()); + }, +}); +// Status is 'idle' when params returns undefined +``` + +## HttpClient - Traditional Approach + +For complex scenarios or when you need Observable operators: + +```typescript +import { Component, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { toSignal } from '@angular/core/rxjs-interop'; + +@Component({...}) +export class Users { + private http = inject(HttpClient); + + // Convert Observable to Signal + users = toSignal( + this.http.get('/api/users'), + { initialValue: [] } + ); + + // Or use Observable directly + users$ = this.http.get('/api/users'); +} +``` + +### HTTP Methods + +```typescript +private http = inject(HttpClient); + +// GET +getUser(id: string) { + return this.http.get(`/api/users/${id}`); +} + +// POST +createUser(user: CreateUserDto) { + return this.http.post('/api/users', user); +} + +// PUT +updateUser(id: string, user: UpdateUserDto) { + return this.http.put(`/api/users/${id}`, user); +} + +// PATCH +patchUser(id: string, changes: Partial) { + return this.http.patch(`/api/users/${id}`, changes); +} + +// DELETE +deleteUser(id: string) { + return this.http.delete(`/api/users/${id}`); +} +``` + +### Request Options + +```typescript +this.http.get('/api/users', { + headers: { + 'Authorization': 'Bearer token', + 'Content-Type': 'application/json', + }, + params: { + page: '1', + limit: '10', + sort: 'name', + }, + observe: 'response', // Get full HttpResponse + responseType: 'json', +}); +``` + +## Interceptors + +### Functional Interceptor (Recommended) + +```typescript +// auth.interceptor.ts +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; + +export const authInterceptor: HttpInterceptorFn = (req, next) => { + const authService = inject(Auth); + const token = authService.token(); + + if (token) { + req = req.clone({ + setHeaders: { Authorization: `Bearer ${token}` }, + }); + } + + return next(req); +}; + +// error.interceptor.ts +export const errorInterceptor: HttpInterceptorFn = (req, next) => { + return next(req).pipe( + catchError((error: HttpErrorResponse) => { + if (error.status === 401) { + inject(Router).navigate(['/login']); + } + return throwError(() => error); + }) + ); +}; + +// logging.interceptor.ts +export const loggingInterceptor: HttpInterceptorFn = (req, next) => { + const started = Date.now(); + return next(req).pipe( + tap({ + next: () => console.log(`${req.method} ${req.url} - ${Date.now() - started}ms`), + error: (err) => console.error(`${req.method} ${req.url} failed`, err), + }) + ); +}; +``` + +### Register Interceptors + +```typescript +// app.config.ts +import { provideHttpClient, withInterceptors } from '@angular/common/http'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideHttpClient( + withInterceptors([ + authInterceptor, + errorInterceptor, + loggingInterceptor, + ]) + ), + ], +}; +``` + +## Error Handling + +### With httpResource + +```typescript +@Component({ + template: ` + @if (userResource.error(); as error) { +
+

{{ getErrorMessage(error) }}

+ +
+ } + `, +}) +export class UserCmpt { + userResource = httpResource(() => `/api/users/${this.userId()}`); + + getErrorMessage(error: unknown): string { + if (error instanceof HttpErrorResponse) { + return error.error?.message || `Error ${error.status}: ${error.statusText}`; + } + return 'An unexpected error occurred'; + } +} +``` + +### With HttpClient + +```typescript +import { catchError, retry } from 'rxjs'; + +getUser(id: string) { + return this.http.get(`/api/users/${id}`).pipe( + retry(2), // Retry up to 2 times + catchError((error: HttpErrorResponse) => { + console.error('Error fetching user:', error); + return throwError(() => new Error('Failed to load user')); + }) + ); +} +``` + +## Loading States Pattern + +```typescript +@Component({ + template: ` + @switch (dataResource.status()) { + @case ('idle') { +

Enter a search term

+ } + @case ('loading') { + + } + @case ('reloading') { + + + } + @case ('resolved') { + + } + @case ('error') { + + } + } + `, +}) +export class Data { + query = signal(''); + dataResource = httpResource(() => + this.query() ? `/api/search?q=${this.query()}` : undefined + ); +} +``` + +For advanced patterns, see [references/http-patterns.md](references/http-patterns.md). diff --git a/skills/angular-http/references/http-patterns.md b/skills/angular-http/references/http-patterns.md new file mode 100644 index 0000000..1791060 --- /dev/null +++ b/skills/angular-http/references/http-patterns.md @@ -0,0 +1,448 @@ +# Angular HTTP Patterns + +## Table of Contents +- [Service Layer Pattern](#service-layer-pattern) +- [Caching Strategies](#caching-strategies) +- [Pagination](#pagination) +- [File Upload](#file-upload) +- [Request Cancellation](#request-cancellation) +- [Testing HTTP](#testing-http) + +## Service Layer Pattern + +Encapsulate HTTP logic in services: + +```typescript +import { Injectable, inject, signal, computed } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { httpResource } from '@angular/common/http'; + +export interface User { + id: string; + name: string; + email: string; +} + +@Injectable({ providedIn: 'root' }) +export class User { + private http = inject(HttpClient); + private baseUrl = '/api/users'; + + // Current user ID for reactive fetching + private currentUserId = signal(null); + + // Reactive resource that updates when currentUserId changes + currentUser = httpResource(() => { + const id = this.currentUserId(); + return id ? `${this.baseUrl}/${id}` : undefined; + }); + + // Set current user to fetch + selectUser(id: string) { + this.currentUserId.set(id); + } + + // CRUD operations + getAll() { + return this.http.get(this.baseUrl); + } + + getById(id: string) { + return this.http.get(`${this.baseUrl}/${id}`); + } + + create(user: Omit) { + return this.http.post(this.baseUrl, user); + } + + update(id: string, user: Partial) { + return this.http.patch(`${this.baseUrl}/${id}`, user); + } + + delete(id: string) { + return this.http.delete(`${this.baseUrl}/${id}`); + } +} +``` + +## Caching Strategies + +### Simple In-Memory Cache + +```typescript +@Injectable({ providedIn: 'root' }) +export class CachedUser { + private http = inject(HttpClient); + private cache = new Map(); + private cacheDuration = 5 * 60 * 1000; // 5 minutes + + getUser(id: string): Observable { + const cached = this.cache.get(id); + + if (cached && Date.now() - cached.timestamp < this.cacheDuration) { + return of(cached.data); + } + + return this.http.get(`/api/users/${id}`).pipe( + tap(user => { + this.cache.set(id, { data: user, timestamp: Date.now() }); + }) + ); + } + + invalidateCache(id?: string) { + if (id) { + this.cache.delete(id); + } else { + this.cache.clear(); + } + } +} +``` + +### Signal-Based Cache + +```typescript +@Injectable({ providedIn: 'root' }) +export class UserCache { + private http = inject(HttpClient); + + // Cache as signal + private usersCache = signal>(new Map()); + + // Computed for easy access + users = computed(() => Array.from(this.usersCache().values())); + + getUser(id: string): User | undefined { + return this.usersCache().get(id); + } + + async fetchUser(id: string): Promise { + const cached = this.getUser(id); + if (cached) return cached; + + const user = await firstValueFrom( + this.http.get(`/api/users/${id}`) + ); + + this.usersCache.update(cache => { + const newCache = new Map(cache); + newCache.set(id, user); + return newCache; + }); + + return user; + } +} +``` + +## Pagination + +### Paginated Resource + +```typescript +interface PaginatedResponse { + data: T[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +@Component({ + template: ` + @if (usersResource.isLoading()) { + + } @else if (usersResource.hasValue()) { +
    + @for (user of usersResource.value().data; track user.id) { +
  • {{ user.name }}
  • + } +
+ + + } + `, +}) +export class UsersList { + page = signal(1); + pageSize = signal(10); + + usersResource = httpResource>(() => ({ + url: '/api/users', + params: { + page: this.page().toString(), + pageSize: this.pageSize().toString(), + }, + })); + + nextPage() { + this.page.update(p => p + 1); + } + + prevPage() { + this.page.update(p => Math.max(1, p - 1)); + } +} +``` + +### Infinite Scroll + +```typescript +@Component({ + template: ` +
    + @for (user of allUsers(); track user.id) { +
  • {{ user.name }}
  • + } +
+ + @if (isLoading()) { + + } + + @if (hasMore()) { + + } + `, +}) +export class InfiniteUsers { + private http = inject(HttpClient); + + private page = signal(1); + private users = signal([]); + private totalPages = signal(1); + + allUsers = this.users.asReadonly(); + isLoading = signal(false); + hasMore = computed(() => this.page() < this.totalPages()); + + constructor() { + this.loadPage(1); + } + + loadMore() { + this.loadPage(this.page() + 1); + } + + private async loadPage(page: number) { + this.isLoading.set(true); + + try { + const response = await firstValueFrom( + this.http.get>('/api/users', { + params: { page: page.toString(), pageSize: '20' }, + }) + ); + + this.users.update(users => [...users, ...response.data]); + this.page.set(page); + this.totalPages.set(response.totalPages); + } finally { + this.isLoading.set(false); + } + } +} +``` + +## File Upload + +### Single File Upload + +```typescript +@Component({ + template: ` + + + @if (uploadProgress() !== null) { + + } + `, +}) +export class FileUpload { + private http = inject(HttpClient); + + uploadProgress = signal(null); + + onFileSelected(event: Event) { + const file = (event.target as HTMLInputElement).files?.[0]; + if (!file) return; + + const formData = new FormData(); + formData.append('file', file); + + this.http.post('/api/upload', formData, { + reportProgress: true, + observe: 'events', + }).subscribe(event => { + if (event.type === HttpEventType.UploadProgress && event.total) { + this.uploadProgress.set(Math.round(100 * event.loaded / event.total)); + } else if (event.type === HttpEventType.Response) { + this.uploadProgress.set(null); + console.log('Upload complete:', event.body); + } + }); + } +} +``` + +### Multiple Files + +```typescript +uploadFiles(files: FileList) { + const formData = new FormData(); + + for (let i = 0; i < files.length; i++) { + formData.append('files', files[i]); + } + + return this.http.post<{ urls: string[] }>('/api/upload-multiple', formData); +} +``` + +## Request Cancellation + +### With resource() + +```typescript +// resource() automatically handles cancellation via abortSignal +searchResource = resource({ + params: () => ({ q: this.query() }), + loader: async ({ params, abortSignal }) => { + const response = await fetch(`/api/search?q=${params.q}`, { + signal: abortSignal, // Cancels if params change + }); + return response.json(); + }, +}); +``` + +### With HttpClient + +```typescript +@Component({...}) +export class Search implements OnDestroy { + private http = inject(HttpClient); + private destroyRef = inject(DestroyRef); + + query = signal(''); + results = signal([]); + + private searchSubscription?: Subscription; + + search() { + // Cancel previous request + this.searchSubscription?.unsubscribe(); + + this.searchSubscription = this.http + .get(`/api/search?q=${this.query()}`) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(results => this.results.set(results)); + } +} +``` + +### Debounced Search + +```typescript +@Component({...}) +export class SearchDebounced { + query = signal(''); + + private http = inject(HttpClient); + + results = toSignal( + toObservable(this.query).pipe( + debounceTime(300), + distinctUntilChanged(), + filter(q => q.length >= 2), + switchMap(q => this.http.get(`/api/search?q=${q}`)), + catchError(() => of([])) + ), + { initialValue: [] } + ); +} +``` + +## Testing HTTP + +### Testing httpResource + +```typescript +describe('UserCmpt', () => { + let component: UserCmpt; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [UserCmpt], + providers: [provideHttpClientTesting()], + }); + + component = TestBed.createComponent(UserCmpt).componentInstance; + httpMock = TestBed.inject(HttpTestingController); + }); + + it('should load user', () => { + component.userId.set('123'); + + const req = httpMock.expectOne('/api/users/123'); + req.flush({ id: '123', name: 'Test User' }); + + expect(component.userResource.value()?.name).toBe('Test User'); + }); + + afterEach(() => { + httpMock.verify(); + }); +}); +``` + +### Testing Services + +```typescript +describe('User', () => { + let service: User; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + User, + provideHttpClient(), + provideHttpClientTesting(), + ], + }); + + service = TestBed.inject(User); + httpMock = TestBed.inject(HttpTestingController); + }); + + it('should create user', () => { + const newUser = { name: 'Test', email: 'test@example.com' }; + + service.create(newUser).subscribe(user => { + expect(user.id).toBeDefined(); + expect(user.name).toBe('Test'); + }); + + const req = httpMock.expectOne('/api/users'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(newUser); + + req.flush({ id: '1', ...newUser }); + }); +}); +``` diff --git a/skills/angular-routing/SKILL.md b/skills/angular-routing/SKILL.md new file mode 100644 index 0000000..cf768f3 --- /dev/null +++ b/skills/angular-routing/SKILL.md @@ -0,0 +1,405 @@ +--- +name: angular-routing +description: >- + Implement routing in Angular v20+ applications with lazy loading, functional + guards, resolvers, and route parameters. Use for navigation setup, protected + routes, route-based data loading, and nested routing. Triggers on route + configuration, adding authentication guards, implementing lazy loading, or + reading route parameters with signals. +metadata: + category: development + source: + repository: 'https://github.com/analogjs/angular-skills' + path: skills/angular-routing +--- + +# Angular Routing + +Configure routing in Angular v20+ with lazy loading, functional guards, and signal-based route parameters. + +## Basic Setup + +```typescript +// app.routes.ts +import { Routes } from '@angular/router'; + +export const routes: Routes = [ + { path: '', redirectTo: '/home', pathMatch: 'full' }, + { path: 'home', component: Home }, + { path: 'about', component: About }, + { path: '**', component: NotFound }, +]; + +// app.config.ts +import { ApplicationConfig } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRouter(routes), + ], +}; + +// app.component.ts +import { Component } from '@angular/core'; +import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet, RouterLink, RouterLinkActive], + template: ` + + + `, +}) +export class App {} +``` + +## Lazy Loading + +Load feature modules on demand: + +```typescript +// app.routes.ts +export const routes: Routes = [ + { path: '', redirectTo: '/home', pathMatch: 'full' }, + { path: 'home', component: Home }, + + // Lazy load entire feature + { + path: 'admin', + loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes), + }, + + // Lazy load single component + { + path: 'settings', + loadComponent: () => import('./settings/settings.component').then(m => m.Settings), + }, +]; + +// admin/admin.routes.ts +export const adminRoutes: Routes = [ + { path: '', component: AdminDashboard }, + { path: 'users', component: AdminUsers }, + { path: 'settings', component: AdminSettings }, +]; +``` + +## Route Parameters + +### With Signal Inputs (Recommended) + +```typescript +// Route config +{ path: 'users/:id', component: UserDetail } + +// Component - use input() for route params +import { Component, input, computed } from '@angular/core'; + +@Component({ + selector: 'app-user-detail', + template: ` +

User {{ id() }}

+ `, +}) +export class UserDetail { + // Route param as signal input + id = input.required(); + + // Computed based on route param + userId = computed(() => parseInt(this.id(), 10)); +} +``` + +Enable with `withComponentInputBinding()`: + +```typescript +// app.config.ts +import { provideRouter, withComponentInputBinding } from '@angular/router'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRouter(routes, withComponentInputBinding()), + ], +}; +``` + +### Query Parameters + +```typescript +// Route: /search?q=angular&page=1 + +@Component({...}) +export class Search { + // Query params as inputs + q = input(''); + page = input('1'); + + currentPage = computed(() => parseInt(this.page(), 10)); +} +``` + +### With ActivatedRoute (Alternative) + +```typescript +import { Component, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { map } from 'rxjs'; + +@Component({...}) +export class UserDetail { + private route = inject(ActivatedRoute); + + // Convert route params to signal + id = toSignal( + this.route.paramMap.pipe(map(params => params.get('id'))), + { initialValue: null } + ); + + // Query params + query = toSignal( + this.route.queryParamMap.pipe(map(params => params.get('q'))), + { initialValue: '' } + ); +} +``` + +## Functional Guards + +### Auth Guard + +```typescript +// guards/auth.guard.ts +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; + +export const authGuard: CanActivateFn = (route, state) => { + const authService = inject(Auth); + const router = inject(Router); + + if (authService.isAuthenticated()) { + return true; + } + + // Redirect to login with return URL + return router.createUrlTree(['/login'], { + queryParams: { returnUrl: state.url }, + }); +}; + +// Usage in routes +{ + path: 'dashboard', + component: Dashboard, + canActivate: [authGuard], +} +``` + +### Role Guard + +```typescript +export const roleGuard = (allowedRoles: string[]): CanActivateFn => { + return (route, state) => { + const authService = inject(Auth); + const router = inject(Router); + + const userRole = authService.currentUser()?.role; + + if (userRole && allowedRoles.includes(userRole)) { + return true; + } + + return router.createUrlTree(['/unauthorized']); + }; +}; + +// Usage +{ + path: 'admin', + component: Admin, + canActivate: [authGuard, roleGuard(['admin', 'superadmin'])], +} +``` + +### Can Deactivate Guard + +```typescript +export interface CanDeactivate { + canDeactivate: () => boolean | Promise; +} + +export const unsavedChangesGuard: CanDeactivateFn = (component) => { + if (component.canDeactivate()) { + return true; + } + + return confirm('You have unsaved changes. Leave anyway?'); +}; + +// Component implementation +@Component({...}) +export class Edit implements CanDeactivate { + form = inject(FormBuilder).group({...}); + + canDeactivate(): boolean { + return !this.form.dirty; + } +} + +// Route +{ + path: 'edit/:id', + component: Edit, + canDeactivate: [unsavedChangesGuard], +} +``` + +## Resolvers + +Pre-fetch data before route activation: + +```typescript +// resolvers/user.resolver.ts +import { inject } from '@angular/core'; +import { ResolveFn } from '@angular/router'; + +export const userResolver: ResolveFn = (route) => { + const userService = inject(User); + const id = route.paramMap.get('id')!; + return userService.getById(id); +}; + +// Route config +{ + path: 'users/:id', + component: UserDetail, + resolve: { user: userResolver }, +} + +// Component - access resolved data via input +@Component({...}) +export class UserDetail { + user = input.required(); +} +``` + +## Nested Routes + +```typescript +// Parent route with children +export const routes: Routes = [ + { + path: 'products', + component: ProductsLayout, + children: [ + { path: '', component: ProductList }, + { path: ':id', component: ProductDetail }, + { path: ':id/edit', component: ProductEdit }, + ], + }, +]; + +// ProductsLayout +@Component({ + imports: [RouterOutlet], + template: ` +

Products

+ + `, +}) +export class ProductsLayout {} +``` + +## Programmatic Navigation + +```typescript +import { Component, inject } from '@angular/core'; +import { Router } from '@angular/router'; + +@Component({...}) +export class Product { + private router = inject(Router); + + // Navigate to route + goToProducts() { + this.router.navigate(['/products']); + } + + // Navigate with params + goToProduct(id: string) { + this.router.navigate(['/products', id]); + } + + // Navigate with query params + search(query: string) { + this.router.navigate(['/search'], { + queryParams: { q: query, page: 1 }, + }); + } + + // Navigate relative to current route + goToEdit() { + this.router.navigate(['edit'], { relativeTo: this.route }); + } + + // Replace current history entry + replaceUrl() { + this.router.navigate(['/new-page'], { replaceUrl: true }); + } +} +``` + +## Route Data + +```typescript +// Static route data +{ + path: 'admin', + component: Admin, + data: { + title: 'Admin Dashboard', + roles: ['admin'], + }, +} + +// Access in component +@Component({...}) +export class AdminCmpt { + title = input(); // From route data + roles = input(); // From route data +} + +// Or via ActivatedRoute +private route = inject(ActivatedRoute); +data = toSignal(this.route.data); +``` + +## Router Events + +```typescript +import { Router, NavigationStart, NavigationEnd } from '@angular/router'; +import { filter } from 'rxjs'; + +@Component({...}) +export class AppMain { + private router = inject(Router); + + isNavigating = signal(false); + + constructor() { + this.router.events.pipe( + filter(e => e instanceof NavigationStart || e instanceof NavigationEnd) + ).subscribe(event => { + this.isNavigating.set(event instanceof NavigationStart); + }); + } +} +``` + +For advanced patterns, see [references/routing-patterns.md](references/routing-patterns.md). diff --git a/skills/angular-routing/references/routing-patterns.md b/skills/angular-routing/references/routing-patterns.md new file mode 100644 index 0000000..7ddd6ee --- /dev/null +++ b/skills/angular-routing/references/routing-patterns.md @@ -0,0 +1,472 @@ +# Angular Routing Patterns + +## Table of Contents +- [Route Configuration Options](#route-configuration-options) +- [Authentication Flow](#authentication-flow) +- [Breadcrumbs](#breadcrumbs) +- [Tab Navigation](#tab-navigation) +- [Modal Routes](#modal-routes) +- [Preloading Strategies](#preloading-strategies) + +## Route Configuration Options + +### Full Route Options + +```typescript +{ + path: 'users/:id', + component: UserCmpt, + + // Lazy loading alternatives + loadComponent: () => import('./user.component').then(m => m.UserCmpt), + loadChildren: () => import('./user.routes').then(m => m.userRoutes), + + // Guards + canActivate: [authGuard], + canActivateChild: [authGuard], + canDeactivate: [unsavedChangesGuard], + canMatch: [featureFlagGuard], + + // Data + resolve: { user: userResolver }, + data: { title: 'User Profile', animation: 'userPage' }, + + // Children + children: [...], + + // Outlet + outlet: 'sidebar', + + // Path matching + pathMatch: 'full', // or 'prefix' + + // Title + title: 'User Profile', + // Or dynamic title + title: userTitleResolver, +} +``` + +### Dynamic Title Resolver + +```typescript +export const userTitleResolver: ResolveFn = (route) => { + const userService = inject(User); + const id = route.paramMap.get('id')!; + return userService.getById(id).pipe( + map(user => `${user.name} - Profile`) + ); +}; +``` + +## Authentication Flow + +### Complete Auth Setup + +```typescript +// auth.service.ts +@Injectable({ providedIn: 'root' }) +export class Auth { + private _user = signal(null); + private _token = signal(null); + + readonly user = this._user.asReadonly(); + readonly isAuthenticated = computed(() => this._user() !== null); + + private router = inject(Router); + private http = inject(HttpClient); + + async login(credentials: Credentials): Promise { + try { + const response = await firstValueFrom( + this.http.post('/api/login', credentials) + ); + + this._token.set(response.token); + this._user.set(response.user); + localStorage.setItem('token', response.token); + + return true; + } catch { + return false; + } + } + + logout(): void { + this._user.set(null); + this._token.set(null); + localStorage.removeItem('token'); + this.router.navigate(['/login']); + } + + async checkAuth(): Promise { + const token = localStorage.getItem('token'); + if (!token) return false; + + try { + const user = await firstValueFrom( + this.http.get('/api/me') + ); + this._user.set(user); + this._token.set(token); + return true; + } catch { + localStorage.removeItem('token'); + return false; + } + } +} + +// auth.guard.ts +export const authGuard: CanActivateFn = async (route, state) => { + const authService = inject(Auth); + const router = inject(Router); + + // Check if already authenticated + if (authService.isAuthenticated()) { + return true; + } + + // Try to restore session + const isValid = await authService.checkAuth(); + if (isValid) { + return true; + } + + // Redirect to login + return router.createUrlTree(['/login'], { + queryParams: { returnUrl: state.url }, + }); +}; + +// login.component.ts +@Component({ + template: ` +
+ + + +
+ `, +}) +export class Login { + private authService = inject(Auth); + private router = inject(Router); + private route = inject(ActivatedRoute); + + email = ''; + password = ''; + + async login() { + const success = await this.authService.login({ + email: this.email, + password: this.password, + }); + + if (success) { + const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/'; + this.router.navigateByUrl(returnUrl); + } + } +} +``` + +## Breadcrumbs + +```typescript +// breadcrumb.service.ts +@Injectable({ providedIn: 'root' }) +export class Breadcrumb { + private router = inject(Router); + private route = inject(ActivatedRoute); + + breadcrumbs = toSignal( + this.router.events.pipe( + filter(event => event instanceof NavigationEnd), + map(() => this.buildBreadcrumbs(this.route.root)) + ), + { initialValue: [] } + ); + + private buildBreadcrumbs( + route: ActivatedRoute, + url: string = '', + breadcrumbs: Breadcrumb[] = [] + ): Breadcrumb[] { + const children = route.children; + + if (children.length === 0) { + return breadcrumbs; + } + + for (const child of children) { + const routeUrl = child.snapshot.url + .map(segment => segment.path) + .join('/'); + + if (routeUrl) { + url += `/${routeUrl}`; + } + + const label = child.snapshot.data['breadcrumb']; + if (label) { + breadcrumbs.push({ label, url }); + } + + return this.buildBreadcrumbs(child, url, breadcrumbs); + } + + return breadcrumbs; + } +} + +// Route config with breadcrumb data +export const routes: Routes = [ + { + path: 'products', + data: { breadcrumb: 'Products' }, + children: [ + { path: '', component: ProductList }, + { + path: ':id', + data: { breadcrumb: 'Product Details' }, + component: ProductDetail, + }, + ], + }, +]; + +// breadcrumb.component.ts +@Component({ + selector: 'app-breadcrumb', + template: ` + + `, +}) +export class BreadcrumbCmpt { + breadcrumbService = inject(Breadcrumb); +} +``` + +## Tab Navigation + +```typescript +// tabs-layout.component.ts +@Component({ + imports: [RouterOutlet, RouterLink, RouterLinkActive], + template: ` +
+ @for (tab of tabs; track tab.path) { + + {{ tab.label }} + + } +
+
+ +
+ `, +}) +export class TabsLayout { + tabs = [ + { path: './', label: 'Overview', exact: true }, + { path: 'details', label: 'Details', exact: false }, + { path: 'settings', label: 'Settings', exact: false }, + ]; +} + +// Routes +{ + path: 'account', + component: TabsLayout, + children: [ + { path: '', component: AccountOverview }, + { path: 'details', component: AccountDetails }, + { path: 'settings', component: AccountSettings }, + ], +} +``` + +## Modal Routes + +Using auxiliary outlets for modals: + +```typescript +// Routes +export const routes: Routes = [ + { path: 'products', component: ProductList }, + { path: 'product-modal/:id', component: ProductModal, outlet: 'modal' }, +]; + +// App template +@Component({ + template: ` + + + `, +}) +export class App {} + +// Open modal +this.router.navigate([{ outlets: { modal: ['product-modal', productId] } }]); + +// Close modal +this.router.navigate([{ outlets: { modal: null } }]); + +// Link to open modal + + View Details + +``` + +## Preloading Strategies + +### Built-in Strategies + +```typescript +import { + provideRouter, + withPreloading, + PreloadAllModules, + NoPreloading +} from '@angular/router'; + +// Preload all lazy modules +provideRouter(routes, withPreloading(PreloadAllModules)) + +// No preloading (default) +provideRouter(routes, withPreloading(NoPreloading)) +``` + +### Custom Preloading Strategy + +```typescript +// selective-preload.strategy.ts +@Injectable({ providedIn: 'root' }) +export class SelectivePreloadStrategy implements PreloadingStrategy { + preload(route: Route, load: () => Observable): Observable { + // Only preload routes marked with data.preload = true + if (route.data?.['preload']) { + return load(); + } + return of(null); + } +} + +// Routes +{ + path: 'dashboard', + loadComponent: () => import('./dashboard.component'), + data: { preload: true }, // Will be preloaded +} + +// Config +provideRouter(routes, withPreloading(SelectivePreloadStrategy)) +``` + +### Network-Aware Preloading + +```typescript +@Injectable({ providedIn: 'root' }) +export class NetworkAwarePreloadStrategy implements PreloadingStrategy { + preload(route: Route, load: () => Observable): Observable { + // Check network conditions + const connection = (navigator as any).connection; + + if (connection) { + // Don't preload on slow connections + if (connection.saveData || connection.effectiveType === '2g') { + return of(null); + } + } + + // Preload if marked + if (route.data?.['preload']) { + return load(); + } + + return of(null); + } +} +``` + +## Route Animations + +```typescript +// app.routes.ts +export const routes: Routes = [ + { path: 'home', component: Home, data: { animation: 'HomePage' } }, + { path: 'about', component: About, data: { animation: 'AboutPage' } }, +]; + +// app.component.ts +@Component({ + imports: [RouterOutlet], + template: ` +
+ +
+ `, + animations: [ + trigger('routeAnimations', [ + transition('HomePage <=> AboutPage', [ + style({ position: 'relative' }), + query(':enter, :leave', [ + style({ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + }), + ]), + query(':enter', [style({ left: '-100%' })]), + query(':leave', animateChild()), + group([ + query(':leave', [animate('300ms ease-out', style({ left: '100%' }))]), + query(':enter', [animate('300ms ease-out', style({ left: '0%' }))]), + ]), + ]), + ]), + ], +}) +export class AppMain { + getRouteAnimationData() { + return this.route.firstChild?.snapshot.data['animation']; + } +} +``` + +## Scroll Position Restoration + +```typescript +// app.config.ts +import { + provideRouter, + withInMemoryScrolling, + withRouterConfig +} from '@angular/router'; + +provideRouter( + routes, + withInMemoryScrolling({ + scrollPositionRestoration: 'enabled', // or 'top' + anchorScrolling: 'enabled', + }), + withRouterConfig({ + onSameUrlNavigation: 'reload', + }) +) +``` diff --git a/skills/angular-signals/SKILL.md b/skills/angular-signals/SKILL.md new file mode 100644 index 0000000..b61e7b4 --- /dev/null +++ b/skills/angular-signals/SKILL.md @@ -0,0 +1,312 @@ +--- +name: angular-signals +description: >- + Implement signal-based reactive state management in Angular v20+. Use for + creating reactive state with signal(), derived state with computed(), + dependent state with linkedSignal(), and side effects with effect(). Triggers + on state management questions, converting from BehaviorSubject/Observable + patterns to signals, or implementing reactive data flows. +metadata: + category: development + source: + repository: 'https://github.com/analogjs/angular-skills' + path: skills/angular-signals +--- + +# Angular Signals + +Signals are Angular's reactive primitive for state management. They provide synchronous, fine-grained reactivity. + +## Core Signal APIs + +### signal() - Writable State + +```typescript +import { signal } from '@angular/core'; + +// Create writable signal +const count = signal(0); + +// Read value +console.log(count()); // 0 + +// Set new value +count.set(5); + +// Update based on current value +count.update(c => c + 1); + +// With explicit type +const user = signal(null); +user.set({ id: 1, name: 'Alice' }); +``` + +### computed() - Derived State + +```typescript +import { signal, computed } from '@angular/core'; + +const firstName = signal('John'); +const lastName = signal('Doe'); + +// Derived signal - automatically updates when dependencies change +const fullName = computed(() => `${firstName()} ${lastName()}`); + +console.log(fullName()); // "John Doe" +firstName.set('Jane'); +console.log(fullName()); // "Jane Doe" + +// Computed with complex logic +const items = signal([]); +const filter = signal(''); + +const filteredItems = computed(() => { + const query = filter().toLowerCase(); + return items().filter(item => + item.name.toLowerCase().includes(query) + ); +}); + +const totalPrice = computed(() => + filteredItems().reduce((sum, item) => sum + item.price, 0) +); +``` + +### linkedSignal() - Dependent State with Reset + +```typescript +import { signal, linkedSignal } from '@angular/core'; + +const options = signal(['A', 'B', 'C']); + +// Resets to first option when options change +const selected = linkedSignal(() => options()[0]); + +console.log(selected()); // "A" +selected.set('B'); // User selects B +console.log(selected()); // "B" +options.set(['X', 'Y']); // Options change +console.log(selected()); // "X" - auto-reset to first + +// With previous value access +const items = signal([]); + +const selectedItem = linkedSignal({ + source: () => items(), + computation: (newItems, previous) => { + // Try to preserve selection if item still exists + const prevItem = previous?.value; + if (prevItem && newItems.some(i => i.id === prevItem.id)) { + return prevItem; + } + return newItems[0] ?? null; + }, +}); +``` + +### effect() - Side Effects + +```typescript +import { signal, effect, inject, DestroyRef } from '@angular/core'; + +@Component({...}) +export class Search { + query = signal(''); + + constructor() { + // Effect runs when query changes + effect(() => { + console.log('Search query:', this.query()); + }); + + // Effect with cleanup + effect((onCleanup) => { + const timer = setInterval(() => { + console.log('Current query:', this.query()); + }, 1000); + + onCleanup(() => clearInterval(timer)); + }); + } +} +``` + +**Effect rules:** +- Run in injection context (constructor or with `runInInjectionContext`) +- Automatically cleaned up when component destroys + +## Component State Pattern + +```typescript +@Component({ + selector: 'app-todo-list', + template: ` + + + +
    + @for (todo of filteredTodos(); track todo.id) { +
  • + {{ todo.text }} + +
  • + } +
+ +

{{ remaining() }} remaining

+ `, +}) +export class TodoList { + // State + todos = signal([]); + newTodo = signal(''); + filter = signal<'all' | 'active' | 'done'>('all'); + + // Derived state + canAdd = computed(() => this.newTodo().trim().length > 0); + + filteredTodos = computed(() => { + const todos = this.todos(); + switch (this.filter()) { + case 'active': return todos.filter(t => !t.done); + case 'done': return todos.filter(t => t.done); + default: return todos; + } + }); + + remaining = computed(() => + this.todos().filter(t => !t.done).length + ); + + // Actions + addTodo() { + const text = this.newTodo().trim(); + if (text) { + this.todos.update(todos => [ + ...todos, + { id: crypto.randomUUID(), text, done: false } + ]); + this.newTodo.set(''); + } + } + + toggleTodo(id: string) { + this.todos.update(todos => + todos.map(t => t.id === id ? { ...t, done: !t.done } : t) + ); + } +} +``` + +## RxJS Interop + +### toSignal() - Observable to Signal + +```typescript +import { toSignal } from '@angular/core/rxjs-interop'; +import { interval } from 'rxjs'; + +@Component({...}) +export class Timer { + private http = inject(HttpClient); + + // From observable - requires initial value or allowUndefined + counter = toSignal(interval(1000), { initialValue: 0 }); + + // From HTTP - undefined until loaded + users = toSignal(this.http.get('/api/users')); + + // With requireSync for synchronous observables (BehaviorSubject) + private user$ = new BehaviorSubject(null); + currentUser = toSignal(this.user$, { requireSync: true }); +} +``` + +### toObservable() - Signal to Observable + +```typescript +import { toObservable } from '@angular/core/rxjs-interop'; +import { switchMap, debounceTime } from 'rxjs'; + +@Component({...}) +export class Search { + query = signal(''); + + private http = inject(HttpClient); + + // Convert signal to observable for RxJS operators + results = toSignal( + toObservable(this.query).pipe( + debounceTime(300), + switchMap(q => this.http.get(`/api/search?q=${q}`)) + ), + { initialValue: [] } + ); +} +``` + +## Signal Equality + +```typescript +// Custom equality function +const user = signal( + { id: 1, name: 'Alice' }, + { equal: (a, b) => a.id === b.id } +); + +// Only triggers updates when ID changes +user.set({ id: 1, name: 'Alice Updated' }); // No update +user.set({ id: 2, name: 'Bob' }); // Triggers update +``` + +## Untracked Reads + +```typescript +import { untracked } from '@angular/core'; + +const a = signal(1); +const b = signal(2); + +// Only depends on 'a', not 'b' +const result = computed(() => { + const aVal = a(); + const bVal = untracked(() => b()); + return aVal + bVal; +}); +``` + +## Service State Pattern + +```typescript +@Injectable({ providedIn: 'root' }) +export class Auth { + // Private writable state + private _user = signal(null); + private _loading = signal(false); + + // Public read-only signals + readonly user = this._user.asReadonly(); + readonly loading = this._loading.asReadonly(); + readonly isAuthenticated = computed(() => this._user() !== null); + + private http = inject(HttpClient); + + async login(credentials: Credentials): Promise { + this._loading.set(true); + try { + const user = await firstValueFrom( + this.http.post('/api/login', credentials) + ); + this._user.set(user); + } finally { + this._loading.set(false); + } + } + + logout(): void { + this._user.set(null); + } +} +``` + +For advanced patterns including resource(), see [references/signal-patterns.md](references/signal-patterns.md). diff --git a/skills/angular-signals/references/signal-patterns.md b/skills/angular-signals/references/signal-patterns.md new file mode 100644 index 0000000..ec6f3fc --- /dev/null +++ b/skills/angular-signals/references/signal-patterns.md @@ -0,0 +1,404 @@ +# Angular Signal Patterns + +## Table of Contents +- [Resource API](#resource-api) +- [Signal Store Pattern](#signal-store-pattern) +- [Form State with Signals](#form-state-with-signals) +- [Async Operations](#async-operations) +- [Testing Signals](#testing-signals) + +## Resource API + +The `resource()` API handles async data fetching with signals: + +```typescript +import { resource, signal, computed } from '@angular/core'; + +@Component({...}) +export class UserProfile { + userId = signal(''); + + // Resource fetches data when params change + userResource = resource({ + params: () => ({ id: this.userId() }), + loader: async ({ params, abortSignal }) => { + const response = await fetch(`/api/users/${params.id}`, { + signal: abortSignal, + }); + return response.json() as Promise; + }, + }); + + // Access resource state + user = computed(() => this.userResource.value()); + isLoading = computed(() => this.userResource.isLoading()); + error = computed(() => this.userResource.error()); +} +``` + +### Resource Status + +```typescript +const userResource = resource({...}); + +// Status signals +userResource.value(); // Current value or undefined +userResource.hasValue(); // Boolean - has resolved value +userResource.error(); // Error or undefined +userResource.isLoading(); // Boolean - currently loading +userResource.status(); // 'idle' | 'loading' | 'reloading' | 'resolved' | 'error' | 'local' + +// Manual reload +userResource.reload(); + +// Local updates +userResource.set(newValue); +userResource.update(current => ({ ...current, name: 'Updated' })); +``` + +### Resource with Default Value + +```typescript +const todosResource = resource({ + defaultValue: [] as Todo[], + params: () => ({ filter: this.filter() }), + loader: async ({ params }) => { + const response = await fetch(`/api/todos?filter=${params.filter}`); + return response.json(); + }, +}); + +// value() returns Todo[] (never undefined due to defaultValue) +``` + +### Conditional Loading + +```typescript +const userId = signal(null); + +const userResource = resource({ + params: () => { + const id = userId(); + // Return undefined to skip loading + return id ? { id } : undefined; + }, + loader: async ({ params }) => { + return fetch(`/api/users/${params.id}`).then(r => r.json()); + }, +}); +// Status is 'idle' when params returns undefined +``` + +## Signal Store Pattern + +For complex state, create a dedicated store: + +```typescript +interface ProductState { + products: Product[]; + selectedId: string | null; + filter: string; + loading: boolean; + error: string | null; +} + +@Injectable({ providedIn: 'root' }) +export class ProductSt { + // Private state + private state = signal({ + products: [], + selectedId: null, + filter: '', + loading: false, + error: null, + }); + + // Selectors (computed signals) + readonly products = computed(() => this.state().products); + readonly selectedId = computed(() => this.state().selectedId); + readonly filter = computed(() => this.state().filter); + readonly loading = computed(() => this.state().loading); + readonly error = computed(() => this.state().error); + + readonly filteredProducts = computed(() => { + const { products, filter } = this.state(); + if (!filter) return products; + return products.filter(p => + p.name.toLowerCase().includes(filter.toLowerCase()) + ); + }); + + readonly selectedProduct = computed(() => { + const { products, selectedId } = this.state(); + return products.find(p => p.id === selectedId) ?? null; + }); + + private http = inject(HttpClient); + + // Actions + setFilter(filter: string): void { + this.state.update(s => ({ ...s, filter })); + } + + selectProduct(id: string | null): void { + this.state.update(s => ({ ...s, selectedId: id })); + } + + async loadProducts(): Promise { + this.state.update(s => ({ ...s, loading: true, error: null })); + + try { + const products = await firstValueFrom( + this.http.get('/api/products') + ); + this.state.update(s => ({ ...s, products, loading: false })); + } catch (err) { + this.state.update(s => ({ + ...s, + loading: false, + error: 'Failed to load products' + })); + } + } + + async addProduct(product: Omit): Promise { + const newProduct = await firstValueFrom( + this.http.post('/api/products', product) + ); + this.state.update(s => ({ + ...s, + products: [...s.products, newProduct], + })); + } +} +``` + +## Form State with Signals + +```typescript +interface FormState { + value: T; + touched: boolean; + dirty: boolean; + valid: boolean; + errors: string[]; +} + +function createFormField( + initialValue: T, + validators: ((value: T) => string | null)[] = [] +) { + const value = signal(initialValue); + const touched = signal(false); + const dirty = signal(false); + + const errors = computed(() => { + return validators + .map(v => v(value())) + .filter((e): e is string => e !== null); + }); + + const valid = computed(() => errors().length === 0); + + return { + value, + touched: touched.asReadonly(), + dirty: dirty.asReadonly(), + errors, + valid, + + setValue(newValue: T) { + value.set(newValue); + dirty.set(true); + }, + + markTouched() { + touched.set(true); + }, + + reset() { + value.set(initialValue); + touched.set(false); + dirty.set(false); + }, + }; +} + +// Usage +@Component({...}) +export class Signup { + email = createFormField('', [ + v => !v ? 'Email is required' : null, + v => !v.includes('@') ? 'Invalid email' : null, + ]); + + password = createFormField('', [ + v => !v ? 'Password is required' : null, + v => v.length < 8 ? 'Password must be at least 8 characters' : null, + ]); + + formValid = computed(() => + this.email.valid() && this.password.valid() + ); +} +``` + +## Async Operations + +### Debounced Search + +```typescript +@Component({...}) +export class Search { + query = signal(''); + + private http = inject(HttpClient); + + // Debounced search using toObservable + results = toSignal( + toObservable(this.query).pipe( + debounceTime(300), + distinctUntilChanged(), + filter(q => q.length >= 2), + switchMap(q => this.http.get(`/api/search?q=${q}`)), + catchError(() => of([])) + ), + { initialValue: [] } + ); + + // Loading state + private searching = signal(false); + readonly isSearching = this.searching.asReadonly(); + + constructor() { + // Track loading state + effect(() => { + const q = this.query(); + if (q.length >= 2) { + this.searching.set(true); + } + }); + + effect(() => { + this.results(); // Subscribe to results + this.searching.set(false); + }); + } +} +``` + +### Optimistic Updates + +```typescript +@Injectable({ providedIn: 'root' }) +export class Todo { + private todos = signal([]); + readonly items = this.todos.asReadonly(); + + private http = inject(HttpClient); + + async toggleTodo(id: string): Promise { + // Optimistic update + const previousTodos = this.todos(); + this.todos.update(todos => + todos.map(t => t.id === id ? { ...t, done: !t.done } : t) + ); + + try { + await firstValueFrom( + this.http.patch(`/api/todos/${id}/toggle`, {}) + ); + } catch { + // Rollback on error + this.todos.set(previousTodos); + } + } +} +``` + +## Testing Signals + +```typescript +describe('Counter', () => { + it('should increment count', () => { + const component = new Counter(); + + expect(component.count()).toBe(0); + + component.increment(); + expect(component.count()).toBe(1); + + component.increment(); + expect(component.count()).toBe(2); + }); + + it('should compute doubled value', () => { + const component = new Counter(); + + expect(component.doubled()).toBe(0); + + component.count.set(5); + expect(component.doubled()).toBe(10); + }); +}); + +describe('ProductSt', () => { + let store: ProductSt; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + ProductSt, + provideHttpClient(), + provideHttpClientTesting(), + ], + }); + + store = TestBed.inject(ProductSt); + httpMock = TestBed.inject(HttpTestingController); + }); + + it('should filter products', () => { + // Set initial state + store['state'].set({ + products: [ + { id: '1', name: 'Apple' }, + { id: '2', name: 'Banana' }, + ], + selectedId: null, + filter: '', + loading: false, + error: null, + }); + + expect(store.filteredProducts().length).toBe(2); + + store.setFilter('app'); + expect(store.filteredProducts().length).toBe(1); + expect(store.filteredProducts()[0].name).toBe('Apple'); + }); +}); +``` + +## Signal Debugging + +```typescript +// Debug effect to log signal changes +effect(() => { + console.log('State changed:', { + count: this.count(), + items: this.items(), + filter: this.filter(), + }); +}); + +// Conditional debugging +const DEBUG = signal(false); + +effect(() => { + if (untracked(() => DEBUG())) { + console.log('Debug:', this.state()); + } +}); +``` diff --git a/skills/angular-ssr/SKILL.md b/skills/angular-ssr/SKILL.md new file mode 100644 index 0000000..1a08b3d --- /dev/null +++ b/skills/angular-ssr/SKILL.md @@ -0,0 +1,444 @@ +--- +name: angular-ssr +description: >- + Implement server-side rendering and hydration in Angular v20+ using + @angular/ssr. Use for SSR setup, hydration strategies, prerendering static + pages, and handling browser-only APIs. Triggers on SSR configuration, fixing + hydration mismatches, prerendering routes, or making code SSR-compatible. +metadata: + category: development + source: + repository: 'https://github.com/analogjs/angular-skills' + path: skills/angular-ssr +--- + +# Angular SSR + +Implement server-side rendering, hydration, and prerendering in Angular v20+. + +## Setup + +### Add SSR to Existing Project + +```bash +ng add @angular/ssr +``` + +This adds: +- `@angular/ssr` package +- `server.ts` - Express server +- `src/main.server.ts` - Server bootstrap +- `src/app/app.config.server.ts` - Server providers +- Updates `angular.json` with SSR configuration + +### Project Structure + +``` +src/ +├── app/ +│ ├── app.config.ts # Browser config +│ ├── app.config.server.ts # Server config +│ └── app.routes.ts +├── main.ts # Browser bootstrap +├── main.server.ts # Server bootstrap +server.ts # Express server +``` + +## Configuration + +### app.config.server.ts + +```typescript +import { ApplicationConfig, mergeApplicationConfig } from '@angular/core'; +import { provideServerRendering } from '@angular/platform-server'; +import { provideServerRoutesConfig } from '@angular/ssr'; +import { appConfig } from './app.config'; +import { serverRoutes } from './app.routes.server'; + +const serverConfig: ApplicationConfig = { + providers: [ + provideServerRendering(), + provideServerRoutesConfig(serverRoutes), + ], +}; + +export const config = mergeApplicationConfig(appConfig, serverConfig); +``` + +### Server Routes Configuration + +```typescript +// app.routes.server.ts +import { RenderMode, ServerRoute } from '@angular/ssr'; + +export const serverRoutes: ServerRoute[] = [ + { + path: '', + renderMode: RenderMode.Prerender, // Static at build time + }, + { + path: 'products', + renderMode: RenderMode.Prerender, + }, + { + path: 'products/:id', + renderMode: RenderMode.Server, // Dynamic SSR + }, + { + path: 'dashboard', + renderMode: RenderMode.Client, // Client-only (SPA) + }, + { + path: '**', + renderMode: RenderMode.Server, + }, +]; +``` + +### Render Modes + +| Mode | Description | Use Case | +|------|-------------|----------| +| `RenderMode.Prerender` | Static HTML at build time | Marketing pages, blogs | +| `RenderMode.Server` | Dynamic SSR per request | User-specific content | +| `RenderMode.Client` | Client-side only (SPA) | Authenticated dashboards | + +## Hydration + +### Default Hydration + +Hydration is enabled by default with `provideClientHydration()`: + +```typescript +// app.config.ts +import { provideClientHydration } from '@angular/platform-browser'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideClientHydration(), + // ... + ], +}; +``` + +### Incremental Hydration + +Defer hydration of specific components: + +```typescript +@Component({ + template: ` + + @defer (hydrate on viewport) { + + } @placeholder { +
Loading comments...
+ } + + + @defer (hydrate on interaction) { + + } + + + @defer (hydrate on idle) { + + } + + + @defer (hydrate never) { + + } + `, +}) +export class Post { + postId = input.required(); + chartData = input.required(); +} +``` + +### Hydration Triggers + +| Trigger | Description | +|---------|-------------| +| `hydrate on viewport` | When element enters viewport | +| `hydrate on interaction` | On click, focus, or input | +| `hydrate on idle` | When browser is idle | +| `hydrate on immediate` | Immediately after load | +| `hydrate on timer(ms)` | After specified delay | +| `hydrate when condition` | When expression is true | +| `hydrate never` | Never hydrate (static) | + +### Event Replay + +Capture user events before hydration completes: + +```typescript +import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideClientHydration(withEventReplay()), + ], +}; +``` + +## Browser-Only Code + +### Platform Detection + +```typescript +import { PLATFORM_ID, inject } from '@angular/core'; +import { isPlatformBrowser, isPlatformServer } from '@angular/common'; + +@Component({...}) +export class My { + private platformId = inject(PLATFORM_ID); + + ngOnInit() { + if (isPlatformBrowser(this.platformId)) { + // Browser-only code + window.addEventListener('scroll', this.onScroll); + } + } +} +``` + +### afterNextRender / afterRender + +Run code only in browser after rendering: + +```typescript +import { afterNextRender, afterRender } from '@angular/core'; + +@Component({...}) +export class Chart { + constructor() { + // Runs once after first render (browser only) + afterNextRender(() => { + this.initChart(); + }); + + // Runs after every render (browser only) + afterRender(() => { + this.updateChart(); + }); + } + + private initChart() { + // Safe to use DOM APIs here + const canvas = document.getElementById('chart'); + new Chart(canvas, this.config); + } +} +``` + +### Inject Browser APIs Safely + +```typescript +// tokens.ts +import { InjectionToken, PLATFORM_ID, inject } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; + +export const WINDOW = new InjectionToken('Window', { + providedIn: 'root', + factory: () => { + const platformId = inject(PLATFORM_ID); + return isPlatformBrowser(platformId) ? window : null; + }, +}); + +export const LOCAL_STORAGE = new InjectionToken('LocalStorage', { + providedIn: 'root', + factory: () => { + const platformId = inject(PLATFORM_ID); + return isPlatformBrowser(platformId) ? localStorage : null; + }, +}); + +// Usage +@Injectable({ providedIn: 'root' }) +export class Storage { + private storage = inject(LOCAL_STORAGE); + + get(key: string): string | null { + return this.storage?.getItem(key) ?? null; + } + + set(key: string, value: string): void { + this.storage?.setItem(key, value); + } +} +``` + +## Prerendering + +### Static Routes + +```typescript +// app.routes.server.ts +export const serverRoutes: ServerRoute[] = [ + { path: '', renderMode: RenderMode.Prerender }, + { path: 'about', renderMode: RenderMode.Prerender }, + { path: 'contact', renderMode: RenderMode.Prerender }, + { path: 'blog', renderMode: RenderMode.Prerender }, +]; +``` + +### Dynamic Routes with getPrerenderParams + +```typescript +// app.routes.server.ts +import { RenderMode, ServerRoute, PrerenderFallback } from '@angular/ssr'; + +export const serverRoutes: ServerRoute[] = [ + { + path: 'products/:id', + renderMode: RenderMode.Prerender, + async getPrerenderParams() { + // Fetch product IDs to prerender + const response = await fetch('https://api.example.com/products'); + const products = await response.json(); + return products.map((p: Product) => ({ id: p.id })); + }, + fallback: PrerenderFallback.Server, // SSR for non-prerendered + }, + { + path: 'blog/:slug', + renderMode: RenderMode.Prerender, + async getPrerenderParams() { + const posts = await fetchBlogPosts(); + return posts.map(post => ({ slug: post.slug })); + }, + fallback: PrerenderFallback.Client, // SPA for non-prerendered + }, +]; +``` + +### Prerender Fallback Options + +| Fallback | Description | +|----------|-------------| +| `PrerenderFallback.Server` | SSR for non-prerendered routes | +| `PrerenderFallback.Client` | Client-side rendering | +| `PrerenderFallback.None` | 404 for non-prerendered routes | + +## HTTP Caching + +### TransferState + +Automatically transfer HTTP responses from server to client: + +```typescript +import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideClientHydration( + withHttpTransferCacheOptions({ + includePostRequests: true, + includeRequestsWithAuthHeaders: false, + filter: (req) => !req.url.includes('/api/realtime'), + }) + ), + ], +}; +``` + +### Manual TransferState + +```typescript +import { TransferState, makeStateKey } from '@angular/core'; + +const PRODUCTS_KEY = makeStateKey('products'); + +@Injectable({ providedIn: 'root' }) +export class Product { + private http = inject(HttpClient); + private transferState = inject(TransferState); + private platformId = inject(PLATFORM_ID); + + getProducts(): Observable { + // Check if data was transferred from server + if (this.transferState.hasKey(PRODUCTS_KEY)) { + const products = this.transferState.get(PRODUCTS_KEY, []); + this.transferState.remove(PRODUCTS_KEY); + return of(products); + } + + return this.http.get('/api/products').pipe( + tap(products => { + // Store for transfer on server + if (isPlatformServer(this.platformId)) { + this.transferState.set(PRODUCTS_KEY, products); + } + }) + ); + } +} +``` + +## Build and Deploy + +### Build Commands + +```bash +# Build with SSR +ng build + +# Output structure +dist/ +├── my-app/ +│ ├── browser/ # Client assets +│ └── server/ # Server bundle +``` + +### Run SSR Server + +```bash +# Development +npm run serve:ssr:my-app + +# Production +node dist/my-app/server/server.mjs +``` + +### Deploy to Node.js Host + +```javascript +// server.ts (generated) +import { APP_BASE_HREF } from '@angular/common'; +import { CommonEngine } from '@angular/ssr/node'; +import express from 'express'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import bootstrap from './src/main.server'; + +const serverDistFolder = dirname(fileURLToPath(import.meta.url)); +const browserDistFolder = resolve(serverDistFolder, '../browser'); +const indexHtml = join(serverDistFolder, 'index.server.html'); + +const app = express(); +const commonEngine = new CommonEngine(); + +app.get('*', express.static(browserDistFolder, { maxAge: '1y', index: false })); + +app.get('*', (req, res, next) => { + commonEngine + .render({ + bootstrap, + documentFilePath: indexHtml, + url: req.originalUrl, + publicPath: browserDistFolder, + providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }], + }) + .then((html) => res.send(html)) + .catch((err) => next(err)); +}); + +app.listen(4000, () => { + console.log('Server listening on http://localhost:4000'); +}); +``` + +For advanced patterns, see [references/ssr-patterns.md](references/ssr-patterns.md). diff --git a/skills/angular-ssr/references/ssr-patterns.md b/skills/angular-ssr/references/ssr-patterns.md new file mode 100644 index 0000000..49ff709 --- /dev/null +++ b/skills/angular-ssr/references/ssr-patterns.md @@ -0,0 +1,497 @@ +# Angular SSR Patterns + +## Table of Contents +- [Hydration Debugging](#hydration-debugging) +- [SEO Optimization](#seo-optimization) +- [Authentication with SSR](#authentication-with-ssr) +- [Caching Strategies](#caching-strategies) +- [Error Handling](#error-handling) +- [Performance Optimization](#performance-optimization) + +## Hydration Debugging + +### Common Hydration Mismatches + +```typescript +// Problem: Different content on server vs client +@Component({ + template: `

Current time: {{ currentTime }}

`, +}) +export class Time { + // BAD: Different value on server and client + currentTime = new Date().toLocaleTimeString(); +} + +// Solution: Use afterNextRender or skip SSR +@Component({ + template: `

Current time: {{ currentTime() }}

`, +}) +export class Time { + currentTime = signal(''); + + constructor() { + afterNextRender(() => { + this.currentTime.set(new Date().toLocaleTimeString()); + }); + } +} +``` + +### Skip Hydration for Dynamic Content + +```typescript +@Component({ + template: ` + +
+ +
+ `, +}) +export class Page {} +``` + +### Debug Hydration Issues + +```typescript +// Enable hydration debugging in development +import { provideClientHydration, withNoDomReuse } from '@angular/platform-browser'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideClientHydration( + // Disable DOM reuse to see hydration errors clearly + ...(isDevMode() ? [withNoDomReuse()] : []) + ), + ], +}; +``` + +## SEO Optimization + +### Meta Tags Service + +```typescript +import { Injectable, inject } from '@angular/core'; +import { Meta, Title } from '@angular/platform-browser'; +import { DOCUMENT } from '@angular/common'; + +@Injectable({ providedIn: 'root' }) +export class Seo { + private meta = inject(Meta); + private title = inject(Title); + private document = inject(DOCUMENT); + + updateMetaTags(config: { + title: string; + description: string; + image?: string; + url?: string; + type?: string; + }) { + // Basic meta + this.title.setTitle(config.title); + this.meta.updateTag({ name: 'description', content: config.description }); + + // Open Graph + this.meta.updateTag({ property: 'og:title', content: config.title }); + this.meta.updateTag({ property: 'og:description', content: config.description }); + this.meta.updateTag({ property: 'og:type', content: config.type || 'website' }); + + if (config.image) { + this.meta.updateTag({ property: 'og:image', content: config.image }); + } + + if (config.url) { + this.meta.updateTag({ property: 'og:url', content: config.url }); + this.updateCanonicalUrl(config.url); + } + + // Twitter Card + this.meta.updateTag({ name: 'twitter:card', content: 'summary_large_image' }); + this.meta.updateTag({ name: 'twitter:title', content: config.title }); + this.meta.updateTag({ name: 'twitter:description', content: config.description }); + + if (config.image) { + this.meta.updateTag({ name: 'twitter:image', content: config.image }); + } + } + + private updateCanonicalUrl(url: string) { + let link: HTMLLinkElement | null = this.document.querySelector('link[rel="canonical"]'); + + if (!link) { + link = this.document.createElement('link'); + link.setAttribute('rel', 'canonical'); + this.document.head.appendChild(link); + } + + link.setAttribute('href', url); + } + + setJsonLd(data: object) { + let script: HTMLScriptElement | null = this.document.querySelector('script[type="application/ld+json"]'); + + if (!script) { + script = this.document.createElement('script'); + script.type = 'application/ld+json'; + this.document.head.appendChild(script); + } + + script.textContent = JSON.stringify(data); + } +} + +// Usage in component +@Component({...}) +export class Product { + private seo = inject(Seo); + product = input.required(); + + constructor() { + effect(() => { + const product = this.product(); + this.seo.updateMetaTags({ + title: `${product.name} | My Store`, + description: product.description, + image: product.imageUrl, + url: `https://mystore.com/products/${product.id}`, + type: 'product', + }); + + this.seo.setJsonLd({ + '@context': 'https://schema.org', + '@type': 'Product', + name: product.name, + description: product.description, + image: product.imageUrl, + offers: { + '@type': 'Offer', + price: product.price, + priceCurrency: 'USD', + }, + }); + }); + } +} +``` + +### Route-Based SEO with Resolvers + +```typescript +// seo.resolver.ts +export const seoResolver: ResolveFn = async (route) => { + const productId = route.paramMap.get('id')!; + const productService = inject(Product); + const product = await productService.getById(productId); + + return { + title: `${product.name} | My Store`, + description: product.description, + image: product.imageUrl, + }; +}; + +// Routes +{ + path: 'products/:id', + component: Product, + resolve: { seo: seoResolver }, +} + +// Component +@Component({...}) +export class Product { + private seo = inject(Seo); + seoData = input.required(); // From resolver + + constructor() { + effect(() => { + this.seo.updateMetaTags(this.seoData()); + }); + } +} +``` + +## Authentication with SSR + +### Cookie-Based Auth + +```typescript +// Server-side cookie reading +import { REQUEST } from '@angular/ssr/tokens'; + +@Injectable({ providedIn: 'root' }) +export class Auth { + private request = inject(REQUEST, { optional: true }); + private platformId = inject(PLATFORM_ID); + + getToken(): string | null { + if (isPlatformServer(this.platformId) && this.request) { + // Read from request cookies on server + const cookies = this.request.headers.cookie || ''; + const match = cookies.match(/auth_token=([^;]+)/); + return match ? match[1] : null; + } + + if (isPlatformBrowser(this.platformId)) { + // Read from document cookies on client + const match = document.cookie.match(/auth_token=([^;]+)/); + return match ? match[1] : null; + } + + return null; + } +} +``` + +### Skip SSR for Authenticated Routes + +```typescript +// app.routes.server.ts +export const serverRoutes: ServerRoute[] = [ + // Public routes - prerender + { path: '', renderMode: RenderMode.Prerender }, + { path: 'products', renderMode: RenderMode.Prerender }, + + // Authenticated routes - client only + { path: 'dashboard', renderMode: RenderMode.Client }, + { path: 'profile', renderMode: RenderMode.Client }, + { path: 'settings', renderMode: RenderMode.Client }, +]; +``` + +## Caching Strategies + +### HTTP Cache Headers + +```typescript +// server.ts +import { REQUEST, RESPONSE_INIT } from '@angular/ssr/tokens'; + +// In route configuration or component +@Component({...}) +export class ProductList { + private responseInit = inject(RESPONSE_INIT, { optional: true }); + + constructor() { + // Set cache headers for SSR response + if (this.responseInit) { + this.responseInit.headers = { + ...this.responseInit.headers, + 'Cache-Control': 'public, max-age=3600, s-maxage=86400', + }; + } + } +} +``` + +### CDN Caching with Vary Headers + +```typescript +// server.ts - Express middleware +app.use((req, res, next) => { + // Vary by cookie for authenticated content + res.setHeader('Vary', 'Cookie'); + next(); +}); +``` + +### Stale-While-Revalidate + +```typescript +// Set SWR headers for dynamic content +this.responseInit.headers = { + 'Cache-Control': 'public, max-age=60, stale-while-revalidate=3600', +}; +``` + +## Error Handling + +### SSR Error Boundaries + +```typescript +// error-handler.ts +import { ErrorHandler, Injectable, inject } from '@angular/core'; +import { PLATFORM_ID } from '@angular/core'; +import { isPlatformServer } from '@angular/common'; + +@Injectable() +export class SsrError implements ErrorHandler { + private platformId = inject(PLATFORM_ID); + + handleError(error: Error) { + if (isPlatformServer(this.platformId)) { + // Log server errors + console.error('SSR Error:', error); + // Could send to monitoring service + } else { + // Client-side error handling + console.error('Client Error:', error); + } + } +} + +// Provide in app.config.ts +{ provide: ErrorHandler, useClass: SsrError } +``` + +### Graceful Degradation + +```typescript +@Component({ + template: ` + @if (dataError()) { + + + } @else { + + } + `, +}) +export class PageCmpt { + private dataService = inject(Data); + + data = signal(null); + dataError = signal(false); + + constructor() { + this.loadData(); + } + + private async loadData() { + try { + const data = await this.dataService.getData(); + this.data.set(data); + } catch { + this.dataError.set(true); + } + } +} +``` + +## Performance Optimization + +### Lazy Hydration Strategy + +```typescript +@Component({ + template: ` + +
+ +
+ + +
+ @defer (hydrate on viewport) { + + } +
+ + + @defer (hydrate on idle) { + + } + + + @defer (hydrate on interaction) { + + } + + + @defer (hydrate never) { + + } + `, +}) +export class ProductPage {} +``` + +### Preload Critical Data + +```typescript +// app.routes.server.ts +export const serverRoutes: ServerRoute[] = [ + { + path: 'products/:id', + renderMode: RenderMode.Server, + async getPrerenderParams() { + // Prerender top 100 products + const topProducts = await fetchTopProducts(100); + return topProducts.map(p => ({ id: p.id })); + }, + }, +]; +``` + +### Streaming SSR (Experimental) + +```typescript +// Enable streaming for faster TTFB +import { provideServerRendering } from '@angular/platform-server'; + +const serverConfig: ApplicationConfig = { + providers: [ + provideServerRendering(), + // Streaming is automatic with @defer blocks + ], +}; +``` + +## Testing SSR + +### Test Server Rendering + +```typescript +import { renderApplication } from '@angular/platform-server'; +import { App } from './app.component'; +import { config } from './app.config.server'; + +describe('SSR', () => { + it('should render home page', async () => { + const html = await renderApplication(App, { + appId: 'my-app', + providers: config.providers, + url: '/', + }); + + expect(html).toContain('

Welcome

'); + expect(html).toContain(''); + }); + + it('should render product page with data', async () => { + const html = await renderApplication(App, { + appId: 'my-app', + providers: config.providers, + url: '/products/123', + }); + + expect(html).toContain('Product Name'); + expect(html).not.toContain('Loading...'); + }); +}); +``` + +### Test Hydration + +```typescript +import { TestBed } from '@angular/core/testing'; +import { provideClientHydration } from '@angular/platform-browser'; + +describe('Hydration', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideClientHydration()], + }); + }); + + it('should hydrate without errors', () => { + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + // No hydration mismatch errors should be thrown + expect(fixture.componentInstance).toBeTruthy(); + }); +}); +``` diff --git a/skills/angular-testing/SKILL.md b/skills/angular-testing/SKILL.md new file mode 100644 index 0000000..1137fde --- /dev/null +++ b/skills/angular-testing/SKILL.md @@ -0,0 +1,469 @@ +--- +name: angular-testing +description: >- + Write unit and integration tests for Angular v20+ applications using Vitest or + Jasmine with TestBed and modern testing patterns. Use for testing components + with signals, OnPush change detection, services with inject(), and HTTP + interactions. Triggers on test creation, testing signal-based components, + mocking dependencies, or setting up test infrastructure. Don't use for E2E + testing with Cypress or Playwright, or for testing non-Angular + JavaScript/TypeScript code. +metadata: + category: development + source: + repository: 'https://github.com/analogjs/angular-skills' + path: skills/angular-testing +--- + +# Angular Testing + +Test Angular v20+ applications with Vitest (recommended) or Jasmine, focusing on signal-based components and modern patterns. + +## Vitest Setup (Angular v20+) + +Angular v20+ has native Vitest support through the `@angular/build` package. + +```bash +npm install -D vitest jsdom +``` + +Configure in angular.json: + +```json +{ + "projects": { + "your-app": { + "architect": { + "test": { + "builder": "@angular/build:unit-test", + "options": { + "tsConfig": "tsconfig.spec.json", + "buildTarget": "your-app:build" + } + } + } + } + } +} +``` + +Run tests: + +```bash +ng test # Run tests +ng test --watch # Watch mode +ng test --code-coverage # With coverage +``` + +For Vitest migration from Jasmine and advanced configuration, see [references/vitest-migration.md](references/vitest-migration.md). + +## Basic Component Test + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Counter } from './counter.component'; + +describe('Counter', () => { + let component: Counter; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Counter], // Standalone component + }).compileComponents(); + + fixture = TestBed.createComponent(Counter); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should increment count', () => { + expect(component.count()).toBe(0); + component.increment(); + expect(component.count()).toBe(1); + }); + + it('should display count in template', () => { + component.count.set(5); + fixture.detectChanges(); + + const element = fixture.nativeElement.querySelector('.count'); + expect(element.textContent).toContain('5'); + }); +}); +``` + +## Testing Signals + +### Direct Signal Testing + +```typescript +import { signal, computed } from '@angular/core'; + +describe('Signal logic', () => { + it('should update computed when signal changes', () => { + const count = signal(0); + const doubled = computed(() => count() * 2); + + expect(doubled()).toBe(0); + + count.set(5); + expect(doubled()).toBe(10); + + count.update(c => c + 1); + expect(doubled()).toBe(12); + }); +}); +``` + +### Testing Component Signals + +```typescript +@Component({ + selector: 'app-todo-list', + template: ` +
    + @for (todo of filteredTodos(); track todo.id) { +
  • {{ todo.text }}
  • + } +
+

{{ remaining() }} remaining

+ `, +}) +export class TodoList { + todos = signal([]); + filter = signal<'all' | 'active' | 'done'>('all'); + + filteredTodos = computed(() => { + const todos = this.todos(); + switch (this.filter()) { + case 'active': return todos.filter(t => !t.done); + case 'done': return todos.filter(t => t.done); + default: return todos; + } + }); + + remaining = computed(() => this.todos().filter(t => !t.done).length); +} + +describe('TodoList', () => { + let component: TodoList; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TodoList], + }).compileComponents(); + + fixture = TestBed.createComponent(TodoList); + component = fixture.componentInstance; + }); + + it('should filter active todos', () => { + component.todos.set([ + { id: '1', text: 'Task 1', done: false }, + { id: '2', text: 'Task 2', done: true }, + { id: '3', text: 'Task 3', done: false }, + ]); + + component.filter.set('active'); + + expect(component.filteredTodos().length).toBe(2); + expect(component.remaining()).toBe(2); + }); +}); +``` + +## Testing OnPush Components + +OnPush components require explicit change detection: + +```typescript +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + template: `{{ data().name }}`, +}) +export class OnPushCmpt { + data = input.required<{ name: string }>(); +} + +describe('OnPushCmpt', () => { + it('should update when input signal changes', () => { + const fixture = TestBed.createComponent(OnPushCmpt); + + // Set input using setInput (for signal inputs) + fixture.componentRef.setInput('data', { name: 'Initial' }); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Initial'); + + // Update input + fixture.componentRef.setInput('data', { name: 'Updated' }); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Updated'); + }); +}); +``` + +## Testing Services + +### Basic Service Test + +```typescript +@Injectable({ providedIn: 'root' }) +export class CounterService { + private _count = signal(0); + readonly count = this._count.asReadonly(); + + increment() { this._count.update(c => c + 1); } + reset() { this._count.set(0); } +} + +describe('CounterService', () => { + let service: CounterService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(CounterService); + }); + + it('should increment count', () => { + expect(service.count()).toBe(0); + service.increment(); + expect(service.count()).toBe(1); + }); +}); +``` + +### Service with HTTP + +```typescript +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; + +describe('UserService', () => { + let service: UserService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + ], + }); + + service = TestBed.inject(UserService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); // Verify no outstanding requests + }); + + it('should fetch user by id', () => { + const mockUser = { id: '1', name: 'Test User' }; + + service.getUser('1').subscribe(user => { + expect(user).toEqual(mockUser); + }); + + const req = httpMock.expectOne('/api/users/1'); + expect(req.request.method).toBe('GET'); + req.flush(mockUser); + }); +}); +``` + +## Mocking Dependencies + +### Using Vitest Mocks + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +describe('UserProfile', () => { + const mockUserService = { + getUser: vi.fn(), + updateUser: vi.fn(), + user: signal(null), + }; + + beforeEach(async () => { + vi.clearAllMocks(); + mockUserService.getUser.mockReturnValue(of({ id: '1', name: 'Test' })); + + await TestBed.configureTestingModule({ + imports: [UserProfile], + providers: [ + { provide: UserService, useValue: mockUserService }, + ], + }).compileComponents(); + }); + + it('should call getUser on init', () => { + const fixture = TestBed.createComponent(UserProfile); + fixture.detectChanges(); + + expect(mockUserService.getUser).toHaveBeenCalledWith('1'); + }); +}); +``` + +### Mock Signal-Based Service + +```typescript +const mockAuth = { + user: signal(null), + isAuthenticated: computed(() => mockAuth.user() !== null), + login: vi.fn(), + logout: vi.fn(), +}; + +beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProtectedPage], + providers: [ + { provide: AuthService, useValue: mockAuth }, + ], + }).compileComponents(); +}); + +it('should show content when authenticated', () => { + mockAuth.user.set({ id: '1', name: 'Test User' }); + + const fixture = TestBed.createComponent(ProtectedPage); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.protected-content')).toBeTruthy(); +}); +``` + +## Testing Inputs and Outputs + +```typescript +@Component({ + selector: 'app-item', + template: `
{{ item().name }}
`, +}) +export class ItemCmpt { + item = input.required(); + selected = output(); + + select() { + this.selected.emit(this.item()); + } +} + +describe('ItemCmpt', () => { + it('should emit selected event on click', () => { + const fixture = TestBed.createComponent(ItemCmpt); + const item: Item = { id: '1', name: 'Test Item' }; + + fixture.componentRef.setInput('item', item); + fixture.detectChanges(); + + let emittedItem: Item | undefined; + fixture.componentInstance.selected.subscribe(i => emittedItem = i); + + fixture.nativeElement.querySelector('div').click(); + + expect(emittedItem).toEqual(item); + }); +}); +``` + +## Testing Async Operations + +### Using fakeAsync + +```typescript +import { fakeAsync, tick, flush } from '@angular/core/testing'; + +it('should debounce search', fakeAsync(() => { + const fixture = TestBed.createComponent(SearchCmpt); + fixture.detectChanges(); + + fixture.componentInstance.query.set('test'); + + tick(300); // Advance time for debounce + fixture.detectChanges(); + + expect(fixture.componentInstance.results().length).toBeGreaterThan(0); + + flush(); // Flush remaining timers +})); +``` + +### Using waitForAsync + +```typescript +import { waitForAsync } from '@angular/core/testing'; + +it('should load data', waitForAsync(() => { + const fixture = TestBed.createComponent(DataCmpt); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + expect(fixture.componentInstance.data()).toBeDefined(); + }); +})); +``` + +## Testing HTTP Resources + +```typescript +@Component({ + template: ` + @if (userResource.isLoading()) { +

Loading...

+ } @else if (userResource.hasValue()) { +

{{ userResource.value().name }}

+ } + `, +}) +export class UserCmpt { + userId = signal('1'); + userResource = httpResource(() => `/api/users/${this.userId()}`); +} + +describe('UserCmpt', () => { + let httpMock: HttpTestingController; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UserCmpt], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + ], + }).compileComponents(); + + httpMock = TestBed.inject(HttpTestingController); + }); + + it('should display user name after loading', () => { + const fixture = TestBed.createComponent(UserCmpt); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Loading'); + + const req = httpMock.expectOne('/api/users/1'); + req.flush({ id: '1', name: 'John Doe' }); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('John Doe'); + }); +}); +``` + +For advanced testing patterns including component harnesses, router testing, form testing, and directive testing, see [references/testing-patterns.md](references/testing-patterns.md). + +For Vitest migration from Jasmine, see [references/vitest-migration.md](references/vitest-migration.md). diff --git a/skills/angular-testing/references/testing-patterns.md b/skills/angular-testing/references/testing-patterns.md new file mode 100644 index 0000000..4dc4291 --- /dev/null +++ b/skills/angular-testing/references/testing-patterns.md @@ -0,0 +1,707 @@ +# Angular Testing Patterns + +## Table of Contents +- [Vitest Advanced Patterns](#vitest-advanced-patterns) +- [Component Harnesses](#component-harnesses) +- [Testing Router](#testing-router) +- [Testing Forms](#testing-forms) +- [Testing Directives](#testing-directives) +- [Testing Pipes](#testing-pipes) +- [E2E Testing Setup](#e2e-testing-setup) + +## Vitest Advanced Patterns + +### Snapshot Testing + +```typescript +import { describe, it, expect } from 'vitest'; + +describe('UserCard', () => { + it('should match snapshot', () => { + const fixture = TestBed.createComponent(UserCard); + fixture.componentRef.setInput('user', { id: '1', name: 'John', email: 'john@example.com' }); + fixture.detectChanges(); + + expect(fixture.nativeElement.innerHTML).toMatchSnapshot(); + }); +}); +``` + +### Parameterized Tests + +```typescript +import { describe, it, expect } from 'vitest'; + +describe('Validator', () => { + it.each([ + { input: '', expected: false }, + { input: 'test', expected: false }, + { input: 'test@example.com', expected: true }, + { input: 'invalid@', expected: false }, + ])('should validate email "$input" as $expected', ({ input, expected }) => { + expect(isValidEmail(input)).toBe(expected); + }); +}); +``` + +### Testing with Fake Timers + +```typescript +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +describe('Debounced Search', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should debounce search input', async () => { + const fixture = TestBed.createComponent(Search); + fixture.detectChanges(); + + fixture.componentInstance.query.set('test'); + + // Search not called yet + expect(fixture.componentInstance.results()).toEqual([]); + + // Advance timers + vi.advanceTimersByTime(300); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.componentInstance.results().length).toBeGreaterThan(0); + }); +}); +``` + +### Module Mocking + +```typescript +import { describe, it, expect, vi } from 'vitest'; + +// Mock entire module +vi.mock('./analytics.service', () => ({ + Analytics: class { + track = vi.fn(); + identify = vi.fn(); + }, +})); + +describe('with mocked analytics', () => { + it('should track events', () => { + const fixture = TestBed.createComponent(Dashboard); + const analytics = TestBed.inject(Analytics); + + fixture.detectChanges(); + + expect(analytics.track).toHaveBeenCalledWith('dashboard_viewed'); + }); +}); +``` + +### Testing Async/Await + +```typescript +import { describe, it, expect, vi } from 'vitest'; + +describe('User', () => { + it('should load user data', async () => { + const mockUser = { id: '1', name: 'Test' }; + const httpMock = TestBed.inject(HttpTestingController); + const service = TestBed.inject(User); + + const userPromise = service.loadUser('1'); + + httpMock.expectOne('/api/users/1').flush(mockUser); + + const user = await userPromise; + expect(user).toEqual(mockUser); + }); +}); +``` + +### Coverage Configuration + +```typescript +// vite.config.ts +export default defineConfig({ + test: { + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'src/test-setup.ts', + '**/*.spec.ts', + '**/*.d.ts', + ], + thresholds: { + statements: 80, + branches: 80, + functions: 80, + lines: 80, + }, + }, + }, +}); +``` + +### Vitest UI Mode + +```bash +# Run with UI +npx vitest --ui + +# Open UI at specific port +npx vitest --ui --port 51204 +``` + +### Concurrent Tests + +```typescript +import { describe, it, expect } from 'vitest'; + +// Run tests in this describe block concurrently +describe.concurrent('API calls', () => { + it('should fetch users', async () => { + // ... + }); + + it('should fetch products', async () => { + // ... + }); + + it('should fetch orders', async () => { + // ... + }); +}); +``` + +### Test Fixtures + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; + +// Shared test fixtures +const createTestUser = (overrides = {}) => ({ + id: '1', + name: 'Test User', + email: 'test@example.com', + ...overrides, +}); + +const createTestProduct = (overrides = {}) => ({ + id: '1', + name: 'Test Product', + price: 99.99, + ...overrides, +}); + +describe('Order', () => { + it('should calculate total', () => { + const fixture = TestBed.createComponent(Order); + fixture.componentRef.setInput('user', createTestUser()); + fixture.componentRef.setInput('products', [ + createTestProduct({ price: 10 }), + createTestProduct({ id: '2', price: 20 }), + ]); + fixture.detectChanges(); + + expect(fixture.componentInstance.total()).toBe(30); + }); +}); +``` + +## Component Harnesses + +Use Angular CDK component harnesses for more maintainable tests: + +### Creating a Harness + +```typescript +import { ComponentHarness, HarnessPredicate } from '@angular/cdk/testing'; + +export class CounterHarn extends ComponentHarness { + static hostSelector = 'app-counter'; + + // Locators + private getIncrementButton = this.locatorFor('button.increment'); + private getDecrementButton = this.locatorFor('button.decrement'); + private getCountDisplay = this.locatorFor('.count'); + + // Actions + async increment(): Promise { + const button = await this.getIncrementButton(); + await button.click(); + } + + async decrement(): Promise { + const button = await this.getDecrementButton(); + await button.click(); + } + + // Queries + async getCount(): Promise { + const display = await this.getCountDisplay(); + const text = await display.text(); + return parseInt(text, 10); + } + + // Filter factory + static with(options: { count?: number } = {}): HarnessPredicate { + return new HarnessPredicate(CounterHarn, options) + .addOption('count', options.count, async (harness, count) => { + return (await harness.getCount()) === count; + }); + } +} +``` + +### Using Harnesses in Tests + +```typescript +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; + +describe('Counter with Harness', () => { + let loader: HarnessLoader; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Counter], + }).compileComponents(); + + const fixture = TestBed.createComponent(Counter); + loader = TestbedHarnessEnvironment.loader(fixture); + }); + + it('should increment count', async () => { + const counter = await loader.getHarness(CounterHarn); + + expect(await counter.getCount()).toBe(0); + + await counter.increment(); + expect(await counter.getCount()).toBe(1); + + await counter.increment(); + expect(await counter.getCount()).toBe(2); + }); + + it('should find counter with specific count', async () => { + const counter = await loader.getHarness(CounterHarn); + await counter.increment(); + await counter.increment(); + + // Find counter with count of 2 + const counterWith2 = await loader.getHarness(CounterHarn.with({ count: 2 })); + expect(counterWith2).toBeTruthy(); + }); +}); +``` + +## Testing Router + +### RouterTestingHarness + +```typescript +import { RouterTestingHarness } from '@angular/router/testing'; +import { provideRouter } from '@angular/router'; + +describe('Router Navigation', () => { + let harness: RouterTestingHarness; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + provideRouter([ + { path: '', component: Home }, + { path: 'users/:id', component: UserCmpt }, + ]), + ], + }).compileComponents(); + + harness = await RouterTestingHarness.create(); + }); + + it('should navigate to user page', async () => { + const component = await harness.navigateByUrl('/users/123', UserCmpt); + + expect(component.id()).toBe('123'); + }); + + it('should display user name', async () => { + await harness.navigateByUrl('/users/123'); + + expect(harness.routeNativeElement?.textContent).toContain('User 123'); + }); +}); +``` + +### Testing Guards + +```typescript +describe('AuthGuard', () => { + let authService: jasmine.SpyObj; + + beforeEach(() => { + authService = jasmine.createSpyObj('Auth', ['isAuthenticated']); + + TestBed.configureTestingModule({ + providers: [ + { provide: Auth, useValue: authService }, + provideRouter([ + { path: 'login', component: Login }, + { + path: 'dashboard', + component: Dashboard, + canActivate: [authGuard], + }, + ]), + ], + }); + }); + + it('should allow access when authenticated', async () => { + authService.isAuthenticated.and.returnValue(true); + + const harness = await RouterTestingHarness.create(); + await harness.navigateByUrl('/dashboard'); + + expect(harness.routeNativeElement?.textContent).toContain('Dashboard'); + }); + + it('should redirect to login when not authenticated', async () => { + authService.isAuthenticated.and.returnValue(false); + + const harness = await RouterTestingHarness.create(); + await harness.navigateByUrl('/dashboard'); + + expect(TestBed.inject(Router).url).toBe('/login'); + }); +}); +``` + +## Testing Forms + +### Testing Signal Forms + +```typescript +import { form, FormField, required, email } from '@angular/forms/signals'; + +@Component({ + imports: [FormField], + template: ` +
+ + + +
+ `, +}) +export class Login { + model = signal({ email: '', password: '' }); + loginForm = form(this.model, (schemaPath) => { + required(schemaPath.email); + email(schemaPath.email); + required(schemaPath.password); + }); + + submitted = signal(false); + + onSubmit(event: Event) { + event.preventDefault(); + if (this.loginForm().valid()) { + this.submitted.set(true); + } + } +} + +describe('Login', () => { + let fixture: ComponentFixture; + let component: Login; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Login], + }).compileComponents(); + + fixture = TestBed.createComponent(Login); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be invalid when empty', () => { + expect(component.loginForm().invalid()).toBeTrue(); + }); + + it('should be valid with correct data', () => { + component.model.set({ + email: 'test@example.com', + password: 'password123', + }); + + expect(component.loginForm().valid()).toBeTrue(); + }); + + it('should show email error for invalid email', () => { + component.loginForm.email().value.set('invalid'); + fixture.detectChanges(); + + expect(component.loginForm.email().invalid()).toBeTrue(); + expect(component.loginForm.email().errors().some(e => e.kind === 'email')).toBeTrue(); + }); + + it('should disable submit button when invalid', () => { + const button = fixture.nativeElement.querySelector('button'); + expect(button.disabled).toBeTrue(); + }); +}); +``` + +### Testing Reactive Forms + +```typescript +describe('ReactiveForm', () => { + it('should validate form', () => { + const fixture = TestBed.createComponent(ProfileForm); + const component = fixture.componentInstance; + + expect(component.form.valid).toBeFalse(); + + component.form.patchValue({ + name: 'John', + email: 'john@example.com', + }); + + expect(component.form.valid).toBeTrue(); + }); + + it('should show validation errors', () => { + const fixture = TestBed.createComponent(ProfileForm); + fixture.detectChanges(); + + const emailControl = fixture.componentInstance.form.controls.email; + emailControl.setValue('invalid'); + emailControl.markAsTouched(); + fixture.detectChanges(); + + const errorElement = fixture.nativeElement.querySelector('.error'); + expect(errorElement.textContent).toContain('Invalid email'); + }); +}); +``` + +## Testing Directives + +### Attribute Directive + +```typescript +@Directive({ + selector: '[appHighlight]', + host: { + '[style.backgroundColor]': 'color()', + }, +}) +export class Highlight { + color = input('yellow', { alias: 'appHighlight' }); +} + +describe('Highlight', () => { + @Component({ + imports: [Highlight], + template: `

Test

`, + }) + class Test {} + + it('should apply background color', () => { + const fixture = TestBed.createComponent(Test); + fixture.detectChanges(); + + const p = fixture.nativeElement.querySelector('p'); + expect(p.style.backgroundColor).toBe('lightblue'); + }); +}); +``` + +### Structural Directive + +```typescript +@Directive({ + selector: '[appIf]', +}) +export class If { + private templateRef = inject(TemplateRef); + private viewContainer = inject(ViewContainerRef); + + condition = input.required({ alias: 'appIf' }); + + constructor() { + effect(() => { + if (this.condition()) { + this.viewContainer.createEmbeddedView(this.templateRef); + } else { + this.viewContainer.clear(); + } + }); + } +} + +describe('If', () => { + @Component({ + imports: [If], + template: `

Visible

`, + }) + class TestCmpt { + show = signal(false); + } + + it('should show content when condition is true', () => { + const fixture = TestBed.createComponent(Test); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('p')).toBeNull(); + + fixture.componentInstance.show.set(true); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('p')).toBeTruthy(); + }); +}); +``` + +## Testing Pipes + +```typescript +@Pipe({ name: 'truncate' }) +export class Truncate implements PipeTransform { + transform(value: string, length: number = 50): string { + if (value.length <= length) return value; + return value.substring(0, length) + '...'; + } +} + +describe('Truncate', () => { + let pipe: Truncate; + + beforeEach(() => { + pipe = new Truncate(); + }); + + it('should not truncate short strings', () => { + expect(pipe.transform('Hello', 10)).toBe('Hello'); + }); + + it('should truncate long strings', () => { + expect(pipe.transform('Hello World', 5)).toBe('Hello...'); + }); + + it('should use default length', () => { + const longString = 'a'.repeat(60); + const result = pipe.transform(longString); + expect(result.length).toBe(53); // 50 + '...' + }); +}); +``` + +## E2E Testing Setup + +### Playwright Configuration + +```typescript +// playwright.config.ts +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:4200', + trace: 'on-first-retry', + }, + webServer: { + command: 'npm run start', + url: 'http://localhost:4200', + reuseExistingServer: !process.env.CI, + }, +}); +``` + +### E2E Test Example + +```typescript +// e2e/login.spec.ts +import { test, expect } from '@playwright/test'; + +test.describe('Login', () => { + test('should login successfully', async ({ page }) => { + await page.goto('/login'); + + await page.fill('input[name="email"]', 'test@example.com'); + await page.fill('input[name="password"]', 'password123'); + await page.click('button[type="submit"]'); + + await expect(page).toHaveURL('/dashboard'); + await expect(page.locator('h1')).toContainText('Welcome'); + }); + + test('should show error for invalid credentials', async ({ page }) => { + await page.goto('/login'); + + await page.fill('input[name="email"]', 'wrong@example.com'); + await page.fill('input[name="password"]', 'wrongpassword'); + await page.click('button[type="submit"]'); + + await expect(page.locator('.error')).toBeVisible(); + await expect(page.locator('.error')).toContainText('Invalid credentials'); + }); +}); +``` + +## Test Utilities + +### Custom Test Helpers + +```typescript +// test-utils.ts +export function setSignalInput( + fixture: ComponentFixture, + inputName: string, + value: T +): void { + fixture.componentRef.setInput(inputName, value); + fixture.detectChanges(); +} + +export async function waitForSignal( + signal: () => T, + predicate: (value: T) => boolean, + timeout = 5000 +): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + const value = signal(); + if (predicate(value)) return value; + await new Promise(resolve => setTimeout(resolve, 10)); + } + throw new Error('Timeout waiting for signal'); +} + +// Usage +it('should load data', async () => { + const fixture = TestBed.createComponent(Data); + fixture.detectChanges(); + + await waitForSignal( + () => fixture.componentInstance.data(), + data => data !== undefined + ); + + expect(fixture.componentInstance.data()).toBeDefined(); +}); +``` diff --git a/skills/angular-testing/references/vitest-migration.md b/skills/angular-testing/references/vitest-migration.md new file mode 100644 index 0000000..b56a1c1 --- /dev/null +++ b/skills/angular-testing/references/vitest-migration.md @@ -0,0 +1,164 @@ +# Vitest Setup and Migration Guide + +## Vitest vs Jasmine Comparison + +| Feature | Vitest | Jasmine/Karma | +|---------|--------|---------------| +| Speed | Faster (native ESM) | Slower | +| Watch mode | Instant feedback | Slower rebuilds | +| Mocking | `vi.fn()`, `vi.mock()` | `jasmine.createSpy()` | +| Assertions | `expect()` (Chai-style) | `expect()` (Jasmine) | +| UI | Built-in UI mode | Karma browser | +| Config | `angular.json` | `karma.conf.js` | + +## Migration from Jasmine to Vitest + +### Spy Migration + +```typescript +// Jasmine +const spy = jasmine.createSpy('callback'); +spy.and.returnValue('value'); +expect(spy).toHaveBeenCalledWith('arg'); + +// Vitest +const spy = vi.fn(); +spy.mockReturnValue('value'); +expect(spy).toHaveBeenCalledWith('arg'); +``` + +### SpyOn Migration + +```typescript +// Jasmine +spyOn(service, 'method').and.returnValue(of(data)); + +// Vitest +vi.spyOn(service, 'method').mockReturnValue(of(data)); +``` + +### createSpyObj Migration + +```typescript +// Jasmine +const mockService = jasmine.createSpyObj('UserService', ['getUser', 'updateUser']); +mockService.getUser.and.returnValue(of({ id: '1', name: 'Test' })); + +// Vitest +const mockService = { + getUser: vi.fn(), + updateUser: vi.fn(), +}; +mockService.getUser.mockReturnValue(of({ id: '1', name: 'Test' })); +``` + +### Async Testing Migration + +```typescript +// Jasmine - using done callback +it('should load data', (done) => { + service.loadData().subscribe(data => { + expect(data).toBeDefined(); + done(); + }); +}); + +// Vitest - using async/await +it('should load data', async () => { + const data = await firstValueFrom(service.loadData()); + expect(data).toBeDefined(); +}); +``` + +### Clock/Timer Migration + +```typescript +// Jasmine +jasmine.clock().install(); +jasmine.clock().tick(1000); +jasmine.clock().uninstall(); + +// Vitest +vi.useFakeTimers(); +vi.advanceTimersByTime(1000); +vi.useRealTimers(); +``` + +## Vitest Configuration Details + +### Full angular.json Configuration + +```json +{ + "projects": { + "your-app": { + "architect": { + "test": { + "builder": "@angular/build:unit-test", + "options": { + "tsConfig": "tsconfig.spec.json", + "buildTarget": "your-app:build" + } + } + } + } + } +} +``` + +### tsconfig.spec.json + +```json +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["vitest/globals"] + }, + "include": ["src/**/*.spec.ts"] +} +``` + +### Optional vite.config.ts + +For advanced configuration, create a `vite.config.ts`: + +```typescript +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + include: ['src/**/*.spec.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'src/test-setup.ts', + '**/*.spec.ts', + '**/*.d.ts', + ], + }, + }, +}); +``` + +## Running Vitest + +```bash +# Run tests +ng test + +# Watch mode +ng test --watch + +# Coverage +ng test --code-coverage + +# Run specific file pattern +ng test --include='**/user*.spec.ts' + +# CI mode (single run) +ng test --watch=false +``` diff --git a/skills/angular-tooling/SKILL.md b/skills/angular-tooling/SKILL.md new file mode 100644 index 0000000..bed1e4e --- /dev/null +++ b/skills/angular-tooling/SKILL.md @@ -0,0 +1,363 @@ +--- +name: angular-tooling +description: >- + Use Angular CLI and development tools effectively in Angular v20+ projects. + Use for project setup, code generation, building, testing, and configuration. + Triggers on creating new projects, generating components/services/modules, + configuring builds, running tests, or optimizing production builds. Don't use + for Nx workspace commands, custom Webpack configurations, or non-Angular CLI + build systems like Vite standalone or esbuild direct usage. +metadata: + category: development + source: + repository: 'https://github.com/analogjs/angular-skills' + path: skills/angular-tooling +--- + +# Angular Tooling + +Use Angular CLI and development tools for efficient Angular v20+ development. + +## Project Setup + +### Create New Project + +```bash +# Create new standalone project (default in v20+) +ng new my-app + +# With specific options +ng new my-app --style=scss --routing --ssr=false + +# Skip tests +ng new my-app --skip-tests + +# Minimal setup +ng new my-app --minimal --inline-style --inline-template +``` + +### Project Structure + +``` +my-app/ +├── src/ +│ ├── app/ +│ │ ├── app.component.ts +│ │ ├── app.config.ts +│ │ └── app.routes.ts +│ ├── index.html +│ ├── main.ts +│ └── styles.scss +├── public/ # Static assets +├── angular.json # CLI configuration +├── package.json +├── tsconfig.json +└── tsconfig.app.json +``` + +## Code Generation + +### Components + +```bash +# Generate component +ng generate component features/user-profile +ng g c features/user-profile # Short form + +# With options +ng g c shared/button --inline-template --inline-style +ng g c features/dashboard --skip-tests +ng g c features/settings --change-detection=OnPush + +# Flat (no folder) +ng g c shared/icon --flat + +# Dry run (preview) +ng g c features/checkout --dry-run +``` + +### Services + +```bash +# Generate service (providedIn: 'root' by default) +ng g service services/auth +ng g s services/user + +# Skip tests +ng g s services/api --skip-tests +``` + +### Other Schematics + +```bash +# Directive +ng g directive directives/highlight +ng g d directives/tooltip + +# Pipe +ng g pipe pipes/truncate +ng g p pipes/date-format + +# Guard (functional by default) +ng g guard guards/auth + +# Interceptor (functional by default) +ng g interceptor interceptors/auth + +# Interface +ng g interface models/user + +# Enum +ng g enum models/status + +# Class +ng g class models/product +``` + +### Generate with Path Alias + +```bash +# Components in feature folders +ng g c @features/products/product-list +ng g c @shared/ui/button +``` + +## Development Server + +```bash +# Start dev server +ng serve +ng s # Short form + +# With options +ng serve --port 4201 +ng serve --open # Open browser +ng serve --host 0.0.0.0 # Expose to network + +# Production mode locally +ng serve --configuration=production + +# With SSL +ng serve --ssl --ssl-key ./ssl/key.pem --ssl-cert ./ssl/cert.pem +``` + +## Building + +### Development Build + +```bash +ng build +``` + +### Production Build + +```bash +ng build --configuration=production +ng build -c production # Short form + +# With specific options +ng build -c production --source-map=false +ng build -c production --named-chunks +``` + +### Build Output + +``` +dist/my-app/ +├── browser/ +│ ├── index.html +│ ├── main-[hash].js +│ ├── polyfills-[hash].js +│ └── styles-[hash].css +└── server/ # If SSR enabled + └── main.js +``` + +## Testing + +### Unit Tests + +```bash +# Run tests +ng test +ng t # Short form + +# Single run (CI) +ng test --watch=false --browsers=ChromeHeadless + +# With coverage +ng test --code-coverage + +# Specific file +ng test --include=**/user.service.spec.ts +``` + +### E2E Tests + +```bash +# Run e2e (if configured) +ng e2e +``` + +## Linting + +```bash +# Run linter +ng lint + +# Fix auto-fixable issues +ng lint --fix +``` + +## Configuration + +### angular.json Key Sections + +```json +{ + "projects": { + "my-app": { + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/my-app", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "tsconfig.app.json", + "assets": ["{ \"glob\": \"**/*\", \"input\": \"public\" }"], + "styles": ["src/styles.scss"], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + } + } + } + } + } +} +``` + +### Environment Configuration + +```typescript +// src/environments/environment.ts +export const environment = { + production: false, + apiUrl: 'http://localhost:3000/api', +}; + +// src/environments/environment.prod.ts +export const environment = { + production: true, + apiUrl: 'https://api.example.com', +}; +``` + +Configure in angular.json: + +```json +{ + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ] + } + } +} +``` + +## Adding Libraries + +### Angular Libraries + +```bash +# Add Angular Material +ng add @angular/material + +# Add Angular PWA +ng add @angular/pwa + +# Add Angular SSR +ng add @angular/ssr + +# Add Angular Localize +ng add @angular/localize +``` + +### Third-Party Libraries + +```bash +# Install and configure +npm install @ngrx/signals + +# Some libraries have schematics +ng add @ngrx/store +``` + +## Update Angular + +```bash +# Check for updates +ng update + +# Update Angular core and CLI +ng update @angular/core @angular/cli + +# Update all packages +ng update --all + +# Force update (skip peer dependency checks) +ng update @angular/core @angular/cli --force +``` + +## Performance Analysis + +```bash +# Build with stats +ng build -c production --stats-json + +# Analyze bundle (install esbuild-visualizer) +npx esbuild-visualizer --metadata dist/my-app/browser/stats.json --open +``` + +## Caching + +```bash +# Enable persistent build cache (default in v20+) +# Configured in angular.json: +{ + "cli": { + "cache": { + "enabled": true, + "path": ".angular/cache", + "environment": "all" + } + } +} + +# Clear cache +rm -rf .angular/cache +``` + +For advanced configuration, see [references/tooling-patterns.md](references/tooling-patterns.md). diff --git a/skills/angular-tooling/references/tooling-patterns.md b/skills/angular-tooling/references/tooling-patterns.md new file mode 100644 index 0000000..b514366 --- /dev/null +++ b/skills/angular-tooling/references/tooling-patterns.md @@ -0,0 +1,448 @@ +# Angular Tooling Patterns + +## Table of Contents +- [Custom Schematics](#custom-schematics) +- [Build Optimization](#build-optimization) +- [Multi-Project Workspace](#multi-project-workspace) +- [CI/CD Configuration](#cicd-configuration) +- [Path Aliases](#path-aliases) +- [Proxy Configuration](#proxy-configuration) + +## Custom Schematics + +### Generate Schematic Collection + +```bash +# Install schematics CLI +npm install -g @angular-devkit/schematics-cli + +# Create schematic collection +schematics blank --name=my-schematics +``` + +### Simple Component Schematic + +```typescript +// src/my-component/index.ts +import { Rule, SchematicContext, Tree, apply, url, template, move, mergeWith } from '@angular-devkit/schematics'; +import { strings } from '@angular-devkit/core'; + +export function myComponent(options: { name: string; path: string }): Rule { + return (tree: Tree, context: SchematicContext) => { + const templateSource = apply(url('./files'), [ + template({ + ...options, + ...strings, + }), + move(options.path), + ]); + + return mergeWith(templateSource)(tree, context); + }; +} +``` + +### Use Custom Schematics + +```bash +# Link locally +npm link ./my-schematics + +# Use +ng generate my-schematics:my-component --name=test --path=src/app +``` + +## Build Optimization + +### Budget Configuration + +```json +{ + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + }, + { + "type": "anyScript", + "maximumWarning": "100kB", + "maximumError": "200kB" + } + ] +} +``` + +### Differential Loading + +Automatic in v20+ - builds for modern browsers by default. + +```json +// .browserslistrc +last 2 Chrome versions +last 2 Firefox versions +last 2 Safari versions +last 2 Edge versions +``` + +### Code Splitting + +```typescript +// Lazy load routes for automatic code splitting +export const routes: Routes = [ + { + path: 'admin', + loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes), + }, + { + path: 'reports', + loadComponent: () => import('./reports/reports.component').then(m => m.Reports), + }, +]; +``` + +### Tree Shaking + +Ensure proper imports for tree shaking: + +```typescript +// Good - tree shakeable +import { map, filter } from 'rxjs'; + +// Avoid - imports entire library +import * as rxjs from 'rxjs'; +``` + +### Preload Strategy + +```typescript +// app.config.ts +import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRouter(routes, withPreloading(PreloadAllModules)), + ], +}; +``` + +## Multi-Project Workspace + +### Create Workspace + +```bash +# Create empty workspace +ng new my-workspace --create-application=false + +cd my-workspace + +# Add applications +ng generate application main-app +ng generate application admin-app + +# Add library +ng generate library shared-ui +ng generate library data-access +``` + +### Workspace Structure + +``` +my-workspace/ +├── projects/ +│ ├── main-app/ +│ │ └── src/ +│ ├── admin-app/ +│ │ └── src/ +│ ├── shared-ui/ +│ │ └── src/ +│ └── data-access/ +│ └── src/ +├── angular.json +└── package.json +``` + +### Build Specific Project + +```bash +ng build main-app +ng build shared-ui +ng serve admin-app +``` + +### Library Configuration + +```json +// projects/shared-ui/ng-package.json +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/shared-ui", + "lib": { + "entryFile": "src/public-api.ts" + } +} +``` + +### Using Library in App + +```typescript +// After building library: ng build shared-ui +import { Button } from 'shared-ui'; + +@Component({ + imports: [Button], + template: `Click`, +}) +export class App {} +``` + +## CI/CD Configuration + +### GitHub Actions + +```yaml +# .github/workflows/ci.yml +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Test + run: npm run test -- --watch=false --browsers=ChromeHeadless --code-coverage + + - name: Build + run: npm run build -- -c production + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + files: ./coverage/lcov.info +``` + +### GitLab CI + +```yaml +# .gitlab-ci.yml +image: node:20 + +cache: + paths: + - node_modules/ + - .angular/cache/ + +stages: + - install + - test + - build + +install: + stage: install + script: + - npm ci + +test: + stage: test + script: + - npm run lint + - npm run test -- --watch=false --browsers=ChromeHeadless + +build: + stage: build + script: + - npm run build -- -c production + artifacts: + paths: + - dist/ +``` + +## Path Aliases + +### Configure tsconfig.json + +```json +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@app/*": ["src/app/*"], + "@env/*": ["src/environments/*"], + "@shared/*": ["src/app/shared/*"], + "@features/*": ["src/app/features/*"], + "@core/*": ["src/app/core/*"] + } + } +} +``` + +### Usage + +```typescript +// Instead of relative imports +import { User } from '../../../core/services/user.service'; + +// Use path alias +import { User } from '@core/services/user.service'; +``` + +## Proxy Configuration + +### Development Proxy + +```json +// proxy.conf.json +{ + "/api": { + "target": "http://localhost:3000", + "secure": false, + "changeOrigin": true + }, + "/auth": { + "target": "http://localhost:4000", + "secure": false, + "pathRewrite": { + "^/auth": "" + } + } +} +``` + +### Configure in angular.json + +```json +{ + "serve": { + "options": { + "proxyConfig": "proxy.conf.json" + } + } +} +``` + +### Or via CLI + +```bash +ng serve --proxy-config proxy.conf.json +``` + +## Custom Builders + +### Using esbuild (Default in v20+) + +```json +{ + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "browser": "src/main.ts" + } + } + } +} +``` + +### SSR Configuration + +```bash +# Add SSR +ng add @angular/ssr +``` + +```json +{ + "architect": { + "build": { + "options": { + "server": "src/main.server.ts", + "prerender": true, + "ssr": { + "entry": "server.ts" + } + } + } + } +} +``` + +## Debugging + +### Source Maps + +```json +{ + "configurations": { + "development": { + "sourceMap": true + }, + "production": { + "sourceMap": { + "scripts": true, + "styles": false, + "hidden": true, + "vendor": false + } + } + } +} +``` + +### Verbose Logging + +```bash +ng build --verbose +ng serve --verbose +``` + +### Debug Tests + +```bash +# Run tests with debugging +ng test --browsers=Chrome + +# In Chrome DevTools, open Sources tab and set breakpoints +``` + +## Package Scripts + +```json +{ + "scripts": { + "start": "ng serve", + "build": "ng build", + "build:prod": "ng build -c production", + "test": "ng test", + "test:ci": "ng test --watch=false --browsers=ChromeHeadless --code-coverage", + "lint": "ng lint", + "lint:fix": "ng lint --fix", + "analyze": "ng build -c production --stats-json && npx esbuild-visualizer --metadata dist/my-app/browser/stats.json --open", + "update": "ng update" + } +} +``` diff --git a/skills/artifacts-builder/SKILL.md b/skills/artifacts-builder/SKILL.md index b97776d..3f2f3ce 100644 --- a/skills/artifacts-builder/SKILL.md +++ b/skills/artifacts-builder/SKILL.md @@ -1,7 +1,7 @@ --- name: artifacts-builder description: >- - Suite of tools for creating elaborate, multi-component HTML + Suite of tools for creating elaborate, multi-component claude.ai HTML artifacts using modern frontend web technologies (React, Tailwind CSS, shadcn/ui). Use for complex artifacts requiring state management, routing, or shadcn/ui components - not for simple single-file HTML/JSX artifacts. @@ -15,7 +15,7 @@ metadata: # Artifacts Builder -To build powerful frontend HTML artifacts, follow these steps: +To build powerful frontend claude.ai artifacts, follow these steps: 1. Initialize the frontend repo using `scripts/init-artifact.sh` 2. Develop your artifact by editing the generated code 3. Bundle all code into a single HTML file using `scripts/bundle-artifact.sh` @@ -58,7 +58,7 @@ To bundle the React app into a single HTML artifact: bash scripts/bundle-artifact.sh ``` -This creates `bundle.html` - a self-contained artifact with all JavaScript, CSS, and dependencies inlined. This file can be directly shared in Kilo sessions as an artifact. +This creates `bundle.html` - a self-contained artifact with all JavaScript, CSS, and dependencies inlined. This file can be directly shared in Claude conversations as an artifact. **Requirements**: Your project must have an `index.html` in the root directory. diff --git a/skills/artifacts-builder/scripts/bundle-artifact.sh b/skills/artifacts-builder/scripts/bundle-artifact.sh index 0375a3b..c13d229 100755 --- a/skills/artifacts-builder/scripts/bundle-artifact.sh +++ b/skills/artifacts-builder/scripts/bundle-artifact.sh @@ -50,5 +50,5 @@ echo "" echo "✅ Bundle complete!" echo "📄 Output: bundle.html ($FILE_SIZE)" echo "" -echo "You can now use this single HTML file as an artifact in Kilo sessions." +echo "You can now use this single HTML file as an artifact in Claude conversations." echo "To test locally: open bundle.html in your browser" \ No newline at end of file diff --git a/skills/canvas-design/SKILL.md b/skills/canvas-design/SKILL.md index 7aca68e..026db9b 100644 --- a/skills/canvas-design/SKILL.md +++ b/skills/canvas-design/SKILL.md @@ -55,7 +55,7 @@ To capture the VISUAL essence, express how the philosophy manifests through: **CRITICAL GUIDELINES:** - **Avoid redundancy**: Each design aspect should be mentioned once. Avoid repeating points about color theory, spatial relationships, or typographic principles unless adding new depth. - **Emphasize craftsmanship REPEATEDLY**: The philosophy MUST stress multiple times that the final work should appear as though it took countless hours to create, was labored over with care, and comes from someone at the absolute top of their field. This framing is essential - repeat phrases like "meticulously crafted," "the product of deep expertise," "painstaking attention," "master-level execution." -- **Leave creative space**: Remain specific about the aesthetic direction, but concise enough that the next phase has room to make interpretive choices also at a extremely high level of craftmanship. +- **Leave creative space**: Remain specific about the aesthetic direction, but concise enough that the next Claude has room to make interpretive choices also at a extremely high level of craftmanship. The philosophy must guide the next version to express ideas VISUALLY, not through text. Information lives in design, not paragraphs. @@ -87,7 +87,7 @@ Visual expression: Grid-based precision, bold photography or stark graphics, dra - **VISUAL PHILOSOPHY**: Create an aesthetic worldview to be expressed through design - **MINIMAL TEXT**: Always emphasize that text is sparse, essential-only, integrated as visual element - never lengthy - **SPATIAL EXPRESSION**: Ideas communicate through space, form, color, composition - not paragraphs -- **ARTISTIC FREEDOM**: The next phase interprets the philosophy visually - provide creative room +- **ARTISTIC FREEDOM**: The next Claude interprets the philosophy visually - provide creative room - **PURE DESIGN**: This is about making ART OBJECTS, not documents with decoration - **EXPERT CRAFTSMANSHIP**: Repeatedly emphasize the final work must look meticulously crafted, labored over with care, the product of countless hours by someone at the top of their field diff --git a/skills/content-research-writer/SKILL.md b/skills/content-research-writer/SKILL.md index 0791893..1aa1a6e 100644 --- a/skills/content-research-writer/SKILL.md +++ b/skills/content-research-writer/SKILL.md @@ -52,7 +52,7 @@ Create your draft file: touch article-draft.md ``` -Open Kilo Code from this directory and start writing. +Open Claude Code from this directory and start writing. ### Basic Workflow @@ -490,7 +490,7 @@ Ready for the next section! ## Pro Tips -1. **Work in VS Code**: Better than web chat for long-form writing +1. **Work in VS Code**: Better than web Claude for long-form writing 2. **One section at a time**: Get feedback incrementally 3. **Save research separately**: Keep a research.md file 4. **Version your drafts**: article-v1.md, article-v2.md, etc. diff --git a/skills/figma-implement-design/SKILL.md b/skills/figma-implement-design/SKILL.md index 1dacc69..538a326 100644 --- a/skills/figma-implement-design/SKILL.md +++ b/skills/figma-implement-design/SKILL.md @@ -1,12 +1,15 @@ --- name: figma-implement-design -description: "Translate Figma nodes into production-ready code with 1:1 visual fidelity using the Figma MCP workflow (design context, screenshots, assets, and project-convention translation). Trigger when the user provides Figma URLs or node IDs, or asks to implement designs or components that must match Figma specs. Requires a working Figma MCP server connection." -license: Apache-2.0 +description: >- + Translate Figma nodes into production-ready code with 1:1 visual fidelity + using the Figma MCP workflow (design context, screenshots, assets, and + project-convention translation). Trigger when the user provides Figma URLs or + node IDs, or asks to implement designs or components that must match Figma + specs. Requires a working Figma MCP server connection. metadata: - author: openai category: development source: - repository: https://github.com/openai/skills + repository: 'https://github.com/openai/skills' path: skills/.curated/figma-implement-design --- @@ -32,30 +35,16 @@ This skill provides a structured workflow for translating Figma designs into pro ### Step 0: Set up Figma MCP (if not already configured) -If any MCP call fails because Figma MCP is not connected, pause and help the user set it up. +If any MCP call fails because Figma MCP is not connected, pause and set it up: -The Figma MCP server runs locally via the Figma Desktop app. Ensure the Figma Desktop app is running, then add the Figma Desktop MCP server to your config: +1. Add the Figma MCP: + - `codex mcp add figma --url https://mcp.figma.com/mcp` +2. Enable remote MCP client: + - Set `[features].rmcp_client = true` in `config.toml` **or** run `codex --enable rmcp_client` +3. Log in with OAuth: + - `codex mcp login figma` -```json -{ - "mcp": { - "Figma Desktop": { - "type": "remote", - "url": "http://127.0.0.1:3845/mcp" - } - } -} -``` - -**VS Code Extension:** Open Kilo Code Settings > Agent Behaviour > MCP Servers, then click "Edit Global MCP" (or "Edit Project MCP" for project-specific config) and add the config above. - -**CLI:** Add the `mcp` block to your `kilo.json` config file. Config locations: -- **Global:** `~/.config/kilo/kilo.json` -- **Project:** `./kilo.json` or `./.kilo/kilo.json` in your project root - -Project-level configuration takes precedence over global settings. - -After adding the server, restart Kilo Code so it can connect to the Figma MCP server, then continue with Step 1. +After successful login, the user will have to restart codex. You should finish your answer and tell them so when they try again they can continue with Step 1. ### Step 1: Get Node ID diff --git a/skills/figma-implement-design/agents/openai.yaml b/skills/figma-implement-design/agents/openai.yaml new file mode 100644 index 0000000..04fe2b0 --- /dev/null +++ b/skills/figma-implement-design/agents/openai.yaml @@ -0,0 +1,14 @@ +interface: + display_name: "Figma Implement Design" + short_description: "Turn Figma designs into production-ready code" + icon_small: "./assets/figma-small.svg" + icon_large: "./assets/figma.png" + default_prompt: "Implement this Figma design in this codebase, matching layout, states, and responsive behavior." + +dependencies: + tools: + - type: "mcp" + value: "figma" + description: "Figma MCP server" + transport: "streamable_http" + url: "https://mcp.figma.com/mcp" diff --git a/skills/figma-implement-design/assets/figma-small.svg b/skills/figma-implement-design/assets/figma-small.svg new file mode 100644 index 0000000..b2c61ba --- /dev/null +++ b/skills/figma-implement-design/assets/figma-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/skills/figma-implement-design/assets/figma.png b/skills/figma-implement-design/assets/figma.png new file mode 100644 index 0000000..ebeedbf Binary files /dev/null and b/skills/figma-implement-design/assets/figma.png differ diff --git a/skills/figma-implement-design/assets/icon.svg b/skills/figma-implement-design/assets/icon.svg new file mode 100644 index 0000000..5184f7c --- /dev/null +++ b/skills/figma-implement-design/assets/icon.svg @@ -0,0 +1,28 @@ + + + + + + + diff --git a/skills/file-organizer/SKILL.md b/skills/file-organizer/SKILL.md index b820a50..f2a820d 100644 --- a/skills/file-organizer/SKILL.md +++ b/skills/file-organizer/SKILL.md @@ -43,7 +43,7 @@ This skill acts as your personal organization assistant, helping you maintain a cd ~ ``` -Then run Kilo Code and ask for help: +Then run Claude Code and ask for help: ``` Help me organize my Downloads folder @@ -410,7 +410,7 @@ Documents folder. 3. **Consistent Naming**: Use "YYYY-MM-DD - Description" format for important files 4. **Archive Aggressively**: Move old projects to Archive instead of deleting 5. **Keep Active Separate**: Maintain clear boundaries between active and archived work -6. **Trust the Process**: Let Kilo Code handle the cognitive load of where things go +6. **Trust the Process**: Let Claude handle the cognitive load of where things go ## Best Practices diff --git a/skills/find-skills/SKILL.md b/skills/find-skills/SKILL.md new file mode 100644 index 0000000..929f128 --- /dev/null +++ b/skills/find-skills/SKILL.md @@ -0,0 +1,142 @@ +--- +name: find-skills +description: >- + Helps users discover and install agent skills when they ask questions like + "how do I do X", "find a skill for X", "is there a skill that can...", or + express interest in extending capabilities. This skill should be used when the + user is looking for functionality that might exist as an installable skill. +metadata: + category: development + source: + repository: 'https://github.com/vercel-labs/skills' + path: skills/find-skills +--- + +# Find Skills + +This skill helps you discover and install skills from the open agent skills ecosystem. + +## When to Use This Skill + +Use this skill when the user: + +- Asks "how do I do X" where X might be a common task with an existing skill +- Says "find a skill for X" or "is there a skill for X" +- Asks "can you do X" where X is a specialized capability +- Expresses interest in extending agent capabilities +- Wants to search for tools, templates, or workflows +- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.) + +## What is the Skills CLI? + +The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools. + +**Key commands:** + +- `npx skills find [query]` - Search for skills interactively or by keyword +- `npx skills add ` - Install a skill from GitHub or other sources +- `npx skills check` - Check for skill updates +- `npx skills update` - Update all installed skills + +**Browse skills at:** https://skills.sh/ + +## How to Help Users Find Skills + +### Step 1: Understand What They Need + +When a user asks for help with something, identify: + +1. The domain (e.g., React, testing, design, deployment) +2. The specific task (e.g., writing tests, creating animations, reviewing PRs) +3. Whether this is a common enough task that a skill likely exists + +### Step 2: Search for Skills + +Run the find command with a relevant query: + +```bash +npx skills find [query] +``` + +For example: + +- User asks "how do I make my React app faster?" → `npx skills find react performance` +- User asks "can you help me with PR reviews?" → `npx skills find pr review` +- User asks "I need to create a changelog" → `npx skills find changelog` + +The command will return results like: + +``` +Install with npx skills add + +vercel-labs/agent-skills@vercel-react-best-practices +└ https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices +``` + +### Step 3: Present Options to the User + +When you find relevant skills, present them to the user with: + +1. The skill name and what it does +2. The install command they can run +3. A link to learn more at skills.sh + +Example response: + +``` +I found a skill that might help! The "vercel-react-best-practices" skill provides +React and Next.js performance optimization guidelines from Vercel Engineering. + +To install it: +npx skills add vercel-labs/agent-skills@vercel-react-best-practices + +Learn more: https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices +``` + +### Step 4: Offer to Install + +If the user wants to proceed, you can install the skill for them: + +```bash +npx skills add -g -y +``` + +The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts. + +## Common Skill Categories + +When searching, consider these common categories: + +| Category | Example Queries | +| --------------- | ---------------------------------------- | +| Web Development | react, nextjs, typescript, css, tailwind | +| Testing | testing, jest, playwright, e2e | +| DevOps | deploy, docker, kubernetes, ci-cd | +| Documentation | docs, readme, changelog, api-docs | +| Code Quality | review, lint, refactor, best-practices | +| Design | ui, ux, design-system, accessibility | +| Productivity | workflow, automation, git | + +## Tips for Effective Searches + +1. **Use specific keywords**: "react testing" is better than just "testing" +2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd" +3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills` + +## When No Skills Are Found + +If no relevant skills exist: + +1. Acknowledge that no existing skill was found +2. Offer to help with the task directly using your general capabilities +3. Suggest the user could create their own skill with `npx skills init` + +Example: + +``` +I searched for skills related to "xyz" but didn't find any matches. +I can still help you with this task directly! Would you like me to proceed? + +If this is something you do often, you could create your own skill: +npx skills init my-xyz-skill +``` diff --git a/skills/frontend-design/LICENSE.txt b/skills/frontend-design/LICENSE.txt new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/skills/frontend-design/LICENSE.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/skills/frontend-design/SKILL.md b/skills/frontend-design/SKILL.md new file mode 100644 index 0000000..722466b --- /dev/null +++ b/skills/frontend-design/SKILL.md @@ -0,0 +1,53 @@ +--- +name: frontend-design +description: >- + Create distinctive, production-grade frontend interfaces with high design + quality. Use this skill when the user asks to build web components, pages, + artifacts, posters, or applications (examples include websites, landing pages, + dashboards, React components, HTML/CSS layouts, or when styling/beautifying + any web UI). Generates creative, polished code and UI design that avoids + generic AI aesthetics. +license: Complete terms in LICENSE.txt +metadata: + category: development + source: + repository: 'https://github.com/anthropics/skills' + path: skills/frontend-design +--- + +This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices. + +The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints. + +## Design Thinking + +Before coding, understand the context and commit to a BOLD aesthetic direction: +- **Purpose**: What problem does this interface solve? Who uses it? +- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction. +- **Constraints**: Technical requirements (framework, performance, accessibility). +- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember? + +**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity. + +Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is: +- Production-grade and functional +- Visually striking and memorable +- Cohesive with a clear aesthetic point-of-view +- Meticulously refined in every detail + +## Frontend Aesthetics Guidelines + +Focus on: +- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font. +- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. +- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise. +- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density. +- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays. + +NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character. + +Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations. + +**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well. + +Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision. diff --git a/skills/gemini-king-mode/LICENSE.txt b/skills/gemini-king-mode/LICENSE.txt new file mode 100644 index 0000000..72d13f8 --- /dev/null +++ b/skills/gemini-king-mode/LICENSE.txt @@ -0,0 +1,10 @@ +Attribution and license status notice + +Source: +https://github.com/aicodeking/yt-tutorial/blob/main/gemini-king-mode.md + +As of 2026-02-15, no repository-level LICENSE file was found at: +- https://raw.githubusercontent.com/aicodeking/yt-tutorial/main/LICENSE +- https://raw.githubusercontent.com/aicodeking/yt-tutorial/main/LICENSE.md + +Upstream licensing terms are unspecified. Review and confirm usage rights before redistribution. diff --git a/skills/gemini-king-mode/SKILL.md b/skills/gemini-king-mode/SKILL.md new file mode 100644 index 0000000..11705a2 --- /dev/null +++ b/skills/gemini-king-mode/SKILL.md @@ -0,0 +1,57 @@ +--- +name: gemini-king-mode +description: Frontend-focused instruction profile for producing bold, intentional UI output with concise default responses and an ULTRATHINK deep-analysis mode. This skill should be used when the user asks for Gemini King Mode behavior, avant-garde frontend direction, or ULTRATHINK-style exhaustive UI reasoning. +license: Unknown (upstream repository does not publish a LICENSE file) +metadata: + category: development + source: + repository: https://github.com/aicodeking/yt-tutorial + path: gemini-king-mode.md +--- + +# SYSTEM ROLE & BEHAVIORAL PROTOCOLS + +**ROLE:** Senior Frontend Architect & Avant-Garde UI Designer. +**EXPERIENCE:** 15+ years. Master of visual hierarchy, whitespace, and UX engineering. + +## 1. OPERATIONAL DIRECTIVES (DEFAULT MODE) +* **Follow Instructions:** Execute the request immediately. Do not deviate. +* **Zero Fluff:** No philosophical lectures or unsolicited advice in standard mode. +* **Stay Focused:** Concise answers only. No wandering. +* **Output First:** Prioritize code and visual solutions. + +## 2. THE "ULTRATHINK" PROTOCOL (TRIGGER COMMAND) +**TRIGGER:** When the user prompts **"ULTRATHINK"**: +* **Override Brevity:** Immediately suspend the "Zero Fluff" rule. +* **Maximum Depth:** You must engage in exhaustive, deep-level reasoning. +* **Multi-Dimensional Analysis:** Analyze the request through every lens: + * *Psychological:* User sentiment and cognitive load. + * *Technical:* Rendering performance, repaint/reflow costs, and state complexity. + * *Accessibility:* WCAG AAA strictness. + * *Scalability:* Long-term maintenance and modularity. +* **Prohibition:** **NEVER** use surface-level logic. If the reasoning feels easy, dig deeper until the logic is irrefutable. + +## 3. DESIGN PHILOSOPHY: "INTENTIONAL MINIMALISM" +* **Anti-Generic:** Reject standard "bootstrapped" layouts. If it looks like a template, it is wrong. +* **Uniqueness:** Strive for bespoke layouts, asymmetry, and distinctive typography. +* **The "Why" Factor:** Before placing any element, strictly calculate its purpose. If it has no purpose, delete it. +* **Minimalism:** Reduction is the ultimate sophistication. + +## 4. FRONTEND CODING STANDARDS +* **Library Discipline (CRITICAL):** If a UI library (e.g., Shadcn UI, Radix, MUI) is detected or active in the project, **YOU MUST USE IT**. + * **Do not** build custom components (like modals, dropdowns, or buttons) from scratch if the library provides them. + * **Do not** pollute the codebase with redundant CSS. + * *Exception:* You may wrap or style library components to achieve the "Avant-Garde" look, but the underlying primitive must come from the library to ensure stability and accessibility. +* **Stack:** Modern (React/Vue/Svelte), Tailwind/Custom CSS, semantic HTML5. +* **Visuals:** Focus on micro-interactions, perfect spacing, and "invisible" UX. + +## 5. RESPONSE FORMAT + +**IF NORMAL:** +1. **Rationale:** (1 sentence on why the elements were placed there). +2. **The Code.** + +**IF "ULTRATHINK" IS ACTIVE:** +1. **Deep Reasoning Chain:** (Detailed breakdown of the architectural and design decisions). +2. **Edge Case Analysis:** (What could go wrong and how we prevented it). +3. **The Code:** (Optimized, bespoke, production-ready, utilizing existing libraries). diff --git a/skills/internal-comms/SKILL.md b/skills/internal-comms/SKILL.md index f2fcc3c..bb1138c 100644 --- a/skills/internal-comms/SKILL.md +++ b/skills/internal-comms/SKILL.md @@ -2,7 +2,7 @@ name: internal-comms description: >- A set of resources to help me write all kinds of internal communications, - using the formats that my company likes to use. The agent should use this skill + using the formats that my company likes to use. Claude should use this skill whenever asked to write some sort of internal communications (status reports, leadership updates, 3P updates, company newsletters, FAQs, incident reports, project updates, etc.). diff --git a/skills/langsmith-fetch/SKILL.md b/skills/langsmith-fetch/SKILL.md index d4b9f51..2b07616 100644 --- a/skills/langsmith-fetch/SKILL.md +++ b/skills/langsmith-fetch/SKILL.md @@ -477,7 +477,7 @@ langsmith-fetch traces --limit 10 --include-metadata --- -## Notes for Kilo +## Notes for Claude - Always check if `langsmith-fetch` is installed before running commands - Verify environment variables are set diff --git a/skills/marketplace.yaml b/skills/marketplace.yaml index 312eff7..01d0e05 100644 --- a/skills/marketplace.yaml +++ b/skills/marketplace.yaml @@ -18,8 +18,8 @@ items: - id: internal-comms description: >- A set of resources to help me write all kinds of internal communications, using the formats that my company likes - to use. The agent should use this skill whenever asked to write some sort of internal communications (status - reports, leadership updates, 3P updates, company newsletters, FAQs, incident reports, project updates, etc.). + to use. Claude should use this skill whenever asked to write some sort of internal communications (status reports, + leadership updates, 3P updates, company newsletters, FAQs, incident reports, project updates, etc.). category: business-marketing githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/internal-comms rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/internal-comms/SKILL.md @@ -95,11 +95,120 @@ items: githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/video-downloader rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/video-downloader/SKILL.md content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/video-downloader.tar.gz + - id: agent-md-refactor + description: >- + Refactor bloated AGENTS.md, CLAUDE.md, or similar agent instruction files to follow progressive disclosure + principles. Splits monolithic files into organized, linked documentation. + category: development + githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/agent-md-refactor + rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/agent-md-refactor/SKILL.md + content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/agent-md-refactor.tar.gz + - id: angular-component + description: >- + Create modern Angular standalone components following v20+ best practices. Use for building UI components with + signal-based inputs/outputs, OnPush change detection, host bindings, content projection, and lifecycle hooks. + Triggers on component creation, refactoring class-based inputs to signals, adding host bindings, or implementing + accessible interactive components. + category: development + githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/angular-component + rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/angular-component/SKILL.md + content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/angular-component.tar.gz + - id: angular-di + description: >- + Implement dependency injection in Angular v20+ using inject(), injection tokens, and provider configuration. Use + for service architecture, providing dependencies at different levels, creating injectable tokens, and managing + singleton vs scoped services. Triggers on service creation, configuring providers, using injection tokens, or + understanding DI hierarchy. + category: development + githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/angular-di + rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/angular-di/SKILL.md + content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/angular-di.tar.gz + - id: angular-directives + description: >- + Create custom directives in Angular v20+ for DOM manipulation and behavior extension. Use for attribute directives + that modify element behavior/appearance, structural directives for portals/overlays, and host directives for + composition. Triggers on creating reusable DOM behaviors, extending element functionality, or composing behaviors + across components. Note - use native @if/@for/@switch for control flow, not custom structural directives. + category: development + githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/angular-directives + rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/angular-directives/SKILL.md + content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/angular-directives.tar.gz + - id: angular-forms + description: >- + Build signal-based forms in Angular v21+ using the new Signal Forms API. Use for form creation with automatic + two-way binding, schema-based validation, field state management, and dynamic forms. Triggers on form + implementation, adding validation, creating multi-step forms, or building forms with conditional fields. Signal + Forms are experimental but recommended for new Angular projects. Don't use for template-driven forms without + signals or third-party form libraries like Formly or ngx-formly. + category: development + githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/angular-forms + rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/angular-forms/SKILL.md + content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/angular-forms.tar.gz + - id: angular-http + description: >- + Implement HTTP data fetching in Angular v20+ using resource(), httpResource(), and HttpClient. Use for API calls, + data loading with signals, request/response handling, and interceptors. Triggers on data fetching, API + integration, loading states, error handling, or converting Observable-based HTTP to signal-based patterns. + category: development + githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/angular-http + rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/angular-http/SKILL.md + content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/angular-http.tar.gz + - id: angular-routing + description: >- + Implement routing in Angular v20+ applications with lazy loading, functional guards, resolvers, and route + parameters. Use for navigation setup, protected routes, route-based data loading, and nested routing. Triggers on + route configuration, adding authentication guards, implementing lazy loading, or reading route parameters with + signals. + category: development + githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/angular-routing + rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/angular-routing/SKILL.md + content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/angular-routing.tar.gz + - id: angular-signals + description: >- + Implement signal-based reactive state management in Angular v20+. Use for creating reactive state with signal(), + derived state with computed(), dependent state with linkedSignal(), and side effects with effect(). Triggers on + state management questions, converting from BehaviorSubject/Observable patterns to signals, or implementing + reactive data flows. + category: development + githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/angular-signals + rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/angular-signals/SKILL.md + content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/angular-signals.tar.gz + - id: angular-ssr + description: >- + Implement server-side rendering and hydration in Angular v20+ using @angular/ssr. Use for SSR setup, hydration + strategies, prerendering static pages, and handling browser-only APIs. Triggers on SSR configuration, fixing + hydration mismatches, prerendering routes, or making code SSR-compatible. + category: development + githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/angular-ssr + rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/angular-ssr/SKILL.md + content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/angular-ssr.tar.gz + - id: angular-testing + description: >- + Write unit and integration tests for Angular v20+ applications using Vitest or Jasmine with TestBed and modern + testing patterns. Use for testing components with signals, OnPush change detection, services with inject(), and + HTTP interactions. Triggers on test creation, testing signal-based components, mocking dependencies, or setting up + test infrastructure. Don't use for E2E testing with Cypress or Playwright, or for testing non-Angular + JavaScript/TypeScript code. + category: development + githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/angular-testing + rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/angular-testing/SKILL.md + content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/angular-testing.tar.gz + - id: angular-tooling + description: >- + Use Angular CLI and development tools effectively in Angular v20+ projects. Use for project setup, code + generation, building, testing, and configuration. Triggers on creating new projects, generating + components/services/modules, configuring builds, running tests, or optimizing production builds. Don't use for Nx + workspace commands, custom Webpack configurations, or non-Angular CLI build systems like Vite standalone or + esbuild direct usage. + category: development + githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/angular-tooling + rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/angular-tooling/SKILL.md + content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/angular-tooling.tar.gz - id: artifacts-builder description: >- - Suite of tools for creating elaborate, multi-component HTML artifacts using modern frontend web technologies - (React, Tailwind CSS, shadcn/ui). Use for complex artifacts requiring state management, routing, or shadcn/ui - components - not for simple single-file HTML/JSX artifacts. + Suite of tools for creating elaborate, multi-component claude.ai HTML artifacts using modern frontend web + technologies (React, Tailwind CSS, shadcn/ui). Use for complex artifacts requiring state management, routing, or + shadcn/ui components - not for simple single-file HTML/JSX artifacts. category: development githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/artifacts-builder rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/artifacts-builder/SKILL.md @@ -132,6 +241,34 @@ items: githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/figma-implement-design rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/figma-implement-design/SKILL.md content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/figma-implement-design.tar.gz + - id: find-skills + description: >- + Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", + "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the + user is looking for functionality that might exist as an installable skill. + category: development + githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/find-skills + rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/find-skills/SKILL.md + content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/find-skills.tar.gz + - id: frontend-design + description: >- + Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user + asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing + pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates + creative, polished code and UI design that avoids generic AI aesthetics. + category: development + githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/frontend-design + rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/frontend-design/SKILL.md + content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/frontend-design.tar.gz + - id: gemini-king-mode + description: >- + Frontend-focused instruction profile for producing bold, intentional UI output with concise default responses and + an ULTRATHINK deep-analysis mode. This skill should be used when the user asks for Gemini King Mode behavior, + avant-garde frontend direction, or ULTRATHINK-style exhaustive UI reasoning. + category: development + githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/gemini-king-mode + rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/gemini-king-mode/SKILL.md + content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/gemini-king-mode.tar.gz - id: langsmith-fetch description: >- Debug LangChain and LangGraph agents by fetching execution traces from LangSmith Studio. Use when debugging agent @@ -153,19 +290,28 @@ items: - id: skill-creator description: >- Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an - existing skill) that extends Kilo's capabilities with specialized knowledge, workflows, or tool integrations. + existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations. category: development githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/skill-creator rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/skill-creator/SKILL.md content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/skill-creator.tar.gz - id: skill-share description: >- - A skill that creates new agent skills and automatically shares them on Slack using Rube for seamless team + A skill that creates new Claude skills and automatically shares them on Slack using Rube for seamless team collaboration and skill discovery. category: development githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/skill-share rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/skill-share/SKILL.md content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/skill-share.tar.gz + - id: vercel-composition-patterns + description: >- + React composition patterns that scale. Use when refactoring components with boolean prop proliferation, building + flexible component libraries, or designing reusable APIs. Triggers on tasks involving compound components, render + props, context providers, or component architecture. Includes React 19 API changes. + category: development + githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/vercel-composition-patterns + rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/vercel-composition-patterns/SKILL.md + content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/vercel-composition-patterns.tar.gz - id: vercel-deploy description: >- Deploy applications and websites to Vercel. Use this skill when the user requests deployment actions such as diff --git a/skills/meeting-insights-analyzer/SKILL.md b/skills/meeting-insights-analyzer/SKILL.md index 015fc5e..308c86b 100644 --- a/skills/meeting-insights-analyzer/SKILL.md +++ b/skills/meeting-insights-analyzer/SKILL.md @@ -54,7 +54,7 @@ This skill transforms your meeting transcripts into actionable insights about yo ### Basic Setup 1. Download your meeting transcripts to a folder (e.g., `~/meetings/`) -2. Navigate to that folder in Kilo Code +2. Navigate to that folder in Claude Code 3. Ask for the analysis you want ### Quick Start Examples @@ -289,7 +289,7 @@ today." **From Granola** (free with Lenny's newsletter subscription): - Granola auto-transcribes your meetings - Export transcripts to a folder: [Instructions on how] -- Point Kilo Code to that folder +- Point Claude Code to that folder **From Zoom**: - Enable cloud recording with transcription @@ -299,7 +299,7 @@ today." **From Google Meet**: - Use Google Docs auto-transcription - Save transcript docs to a folder -- Download as .txt files or give Kilo Code access +- Download as .txt files or give Claude Code access **From Fireflies.ai, Otter.ai, etc.**: - Export transcripts in bulk diff --git a/skills/skill-creator/SKILL.md b/skills/skill-creator/SKILL.md index ba9b87a..9fa5efd 100644 --- a/skills/skill-creator/SKILL.md +++ b/skills/skill-creator/SKILL.md @@ -2,7 +2,7 @@ name: skill-creator description: >- Guide for creating effective skills. This skill should be used when users want - to create a new skill (or update an existing skill) that extends Kilo's + to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations. license: Complete terms in LICENSE.txt metadata: @@ -18,9 +18,9 @@ This skill provides guidance for creating effective skills. ## About Skills -Skills are modular, self-contained packages that extend the agent's capabilities by providing +Skills are modular, self-contained packages that extend Claude's capabilities by providing specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific -domains or tasks—they transform a general-purpose agent into a specialized agent +domains or tasks—they transform Claude from a general-purpose agent into a specialized agent equipped with procedural knowledge that no model can fully possess. ### What Skills Provide @@ -49,7 +49,7 @@ skill-name/ #### SKILL.md (required) -**Metadata Quality:** The `name` and `description` in YAML frontmatter determine when the agent will use the skill. Be specific about what the skill does and when to use it. Use the third-person (e.g. "This skill should be used when..." instead of "Use this skill when..."). +**Metadata Quality:** The `name` and `description` in YAML frontmatter determine when Claude will use the skill. Be specific about what the skill does and when to use it. Use the third-person (e.g. "This skill should be used when..." instead of "Use this skill when..."). #### Bundled Resources (optional) @@ -60,27 +60,27 @@ Executable code (Python/Bash/etc.) for tasks that require deterministic reliabil - **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed - **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks - **Benefits**: Token efficient, deterministic, may be executed without loading into context -- **Note**: Scripts may still need to be read by the agent for patching or environment-specific adjustments +- **Note**: Scripts may still need to be read by Claude for patching or environment-specific adjustments ##### References (`references/`) -Documentation and reference material intended to be loaded as needed into context to inform the agent's process and thinking. +Documentation and reference material intended to be loaded as needed into context to inform Claude's process and thinking. -- **When to include**: For documentation that the agent should reference while working +- **When to include**: For documentation that Claude should reference while working - **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications - **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides -- **Benefits**: Keeps SKILL.md lean, loaded only when the agent determines it's needed +- **Benefits**: Keeps SKILL.md lean, loaded only when Claude determines it's needed - **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md - **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files. ##### Assets (`assets/`) -Files not intended to be loaded into context, but rather used within the output the agent produces. +Files not intended to be loaded into context, but rather used within the output Claude produces. - **When to include**: When the skill needs files that will be used in the final output - **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography - **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified -- **Benefits**: Separates output resources from documentation, enables the agent to use files without loading them into context +- **Benefits**: Separates output resources from documentation, enables Claude to use files without loading them into context ### Progressive Disclosure Design Principle @@ -88,7 +88,7 @@ Skills use a three-level loading system to manage context efficiently: 1. **Metadata (name + description)** - Always in context (~100 words) 2. **SKILL.md body** - When skill triggers (<5k words) -3. **Bundled resources** - As needed by the agent (Unlimited*) +3. **Bundled resources** - As needed by Claude (Unlimited*) *Unlimited because scripts can be executed without reading into context window. @@ -162,7 +162,7 @@ After initialization, customize or remove the generated SKILL.md and example fil ### Step 4: Edit the Skill -When editing the (newly-generated or existing) skill, remember that the skill is being created for another agent instance to use. Focus on including information that would be beneficial and non-obvious to the agent. Consider what procedural knowledge, domain-specific details, or reusable assets would help another agent instance execute these tasks more effectively. +When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Claude to use. Focus on including information that would be beneficial and non-obvious to Claude. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Claude instance execute these tasks more effectively. #### Start with Reusable Skill Contents @@ -178,7 +178,7 @@ To complete SKILL.md, answer the following questions: 1. What is the purpose of the skill, in a few sentences? 2. When should the skill be used? -3. In practice, how should the agent use the skill? All reusable skill contents developed above should be referenced so that the agent knows how to use them. +3. In practice, how should Claude use the skill? All reusable skill contents developed above should be referenced so that Claude knows how to use them. ### Step 5: Packaging a Skill diff --git a/skills/skill-creator/scripts/init_skill.py b/skills/skill-creator/scripts/init_skill.py index 8dff8cc..329ad4e 100755 --- a/skills/skill-creator/scripts/init_skill.py +++ b/skills/skill-creator/scripts/init_skill.py @@ -75,20 +75,20 @@ **Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations. -**Note:** Scripts may be executed without loading into context, but can still be read for patching or environment adjustments. +**Note:** Scripts may be executed without loading into context, but can still be read by Claude for patching or environment adjustments. ### references/ -Documentation and reference material intended to be loaded into context to inform the agent's process and thinking. +Documentation and reference material intended to be loaded into context to inform Claude's process and thinking. **Examples from other skills:** - Product management: `communication.md`, `context_building.md` - detailed workflow guides - BigQuery: API reference documentation and query examples - Finance: Schema documentation, company policies -**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information to reference while working. +**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Claude should reference while working. ### assets/ -Files not intended to be loaded into context, but rather used within the output the agent produces. +Files not intended to be loaded into context, but rather used within the output Claude produces. **Examples from other skills:** - Brand styling: PowerPoint template files (.pptx), logo files diff --git a/skills/skill-share/SKILL.md b/skills/skill-share/SKILL.md index 8b9ae4c..2c8e1ee 100644 --- a/skills/skill-share/SKILL.md +++ b/skills/skill-share/SKILL.md @@ -1,7 +1,7 @@ --- name: skill-share description: >- - A skill that creates new agent skills and automatically shares them on Slack + A skill that creates new Claude skills and automatically shares them on Slack using Rube for seamless team collaboration and skill discovery. license: Complete terms in LICENSE.txt metadata: @@ -14,7 +14,7 @@ metadata: ## When to use this skill Use this skill when you need to: -- **Create new agent skills** with proper structure and metadata +- **Create new Claude skills** with proper structure and metadata - **Generate skill packages** ready for distribution - **Automatically share created skills** on Slack channels for team visibility - **Validate skill structure** before sharing @@ -64,7 +64,7 @@ This skill is ideal for: ## Example Usage ``` -When you ask Kilo Code to create a skill called "pdf-analyzer": +When you ask Claude to create a skill called "pdf-analyzer": 1. Creates /skill-pdf-analyzer/ with SKILL.md template 2. Generates structured directories (scripts/, references/, assets/) 3. Validates the skill structure diff --git a/skills/vercel-composition-patterns/AGENTS.md b/skills/vercel-composition-patterns/AGENTS.md new file mode 100644 index 0000000..558bf9a --- /dev/null +++ b/skills/vercel-composition-patterns/AGENTS.md @@ -0,0 +1,946 @@ +# React Composition Patterns + +**Version 1.0.0** +Engineering +January 2026 + +> **Note:** +> This document is mainly for agents and LLMs to follow when maintaining, +> generating, or refactoring React codebases using composition. Humans +> may also find it useful, but guidance here is optimized for automation +> and consistency by AI-assisted workflows. + +--- + +## Abstract + +Composition patterns for building flexible, maintainable React components. Avoid boolean prop proliferation by using compound components, lifting state, and composing internals. These patterns make codebases easier for both humans and AI agents to work with as they scale. + +--- + +## Table of Contents + +1. [Component Architecture](#1-component-architecture) — **HIGH** + - 1.1 [Avoid Boolean Prop Proliferation](#11-avoid-boolean-prop-proliferation) + - 1.2 [Use Compound Components](#12-use-compound-components) +2. [State Management](#2-state-management) — **MEDIUM** + - 2.1 [Decouple State Management from UI](#21-decouple-state-management-from-ui) + - 2.2 [Define Generic Context Interfaces for Dependency Injection](#22-define-generic-context-interfaces-for-dependency-injection) + - 2.3 [Lift State into Provider Components](#23-lift-state-into-provider-components) +3. [Implementation Patterns](#3-implementation-patterns) — **MEDIUM** + - 3.1 [Create Explicit Component Variants](#31-create-explicit-component-variants) + - 3.2 [Prefer Composing Children Over Render Props](#32-prefer-composing-children-over-render-props) +4. [React 19 APIs](#4-react-19-apis) — **MEDIUM** + - 4.1 [React 19 API Changes](#41-react-19-api-changes) + +--- + +## 1. Component Architecture + +**Impact: HIGH** + +Fundamental patterns for structuring components to avoid prop +proliferation and enable flexible composition. + +### 1.1 Avoid Boolean Prop Proliferation + +**Impact: CRITICAL (prevents unmaintainable component variants)** + +Don't add boolean props like `isThread`, `isEditing`, `isDMThread` to customize + +component behavior. Each boolean doubles possible states and creates + +unmaintainable conditional logic. Use composition instead. + +**Incorrect: boolean props create exponential complexity** + +```tsx +function Composer({ + onSubmit, + isThread, + channelId, + isDMThread, + dmId, + isEditing, + isForwarding, +}: Props) { + return ( +
+
+ + {isDMThread ? ( + + ) : isThread ? ( + + ) : null} + {isEditing ? ( + + ) : isForwarding ? ( + + ) : ( + + )} +