fix(repo): Harden catalog sync and release integrity
Tighten the repo-state automation so canonical bot commits remain predictable while leaving main clean after each sync. Make the public catalog UI more honest by hiding dev-only sync, turning stars into explicit browser-local saves, aligning risk types, and removing hardcoded catalog counts. Add shared public asset URL helpers, risk suggestion plumbing, safer unpack/sync guards, and CI coverage gates so release and maintainer workflows catch drift earlier.
This commit is contained in:
46
.github/workflows/ci.yml
vendored
46
.github/workflows/ci.yml
vendored
@@ -75,7 +75,7 @@ jobs:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: pip install pyyaml
|
||||
run: pip install -r tools/requirements.txt
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -103,13 +103,15 @@ jobs:
|
||||
|
||||
- name: Audit npm dependencies
|
||||
run: npm audit --audit-level=high
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
ENABLE_NETWORK_TESTS: "1"
|
||||
run: npm run test
|
||||
|
||||
- name: Run web app coverage
|
||||
run: npm run app:test:coverage
|
||||
|
||||
- name: Run docs security checks
|
||||
run: npm run security:docs
|
||||
|
||||
@@ -126,7 +128,7 @@ jobs:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: pip install pyyaml
|
||||
run: pip install -r tools/requirements.txt
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -176,12 +178,17 @@ jobs:
|
||||
main-validation-and-sync:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: canonical-main-sync
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
@@ -189,7 +196,7 @@ jobs:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: pip install pyyaml
|
||||
run: pip install -r tools/requirements.txt
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -216,37 +223,55 @@ jobs:
|
||||
|
||||
- name: Audit npm dependencies
|
||||
run: npm audit --audit-level=high
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
ENABLE_NETWORK_TESTS: "1"
|
||||
run: npm run test
|
||||
|
||||
- name: Run web app coverage
|
||||
run: npm run app:test:coverage
|
||||
|
||||
- name: Run docs security checks
|
||||
run: npm run security:docs
|
||||
|
||||
- name: Set up GitHub credentials
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git
|
||||
git fetch origin main
|
||||
|
||||
- name: Auto-commit canonical artifacts
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t managed_files < <(node tools/scripts/generated_files.js --include-mixed)
|
||||
if [ "${#managed_files[@]}" -eq 0 ]; then
|
||||
echo "No managed files resolved from generated_files contract."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git diff --quiet && exit 0
|
||||
if git diff --quiet && [ -z "$(git ls-files --others --exclude-standard)" ]; then
|
||||
echo "No canonical repo-state drift detected."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git add -- "${managed_files[@]}" || true
|
||||
|
||||
git diff --cached --quiet && exit 0
|
||||
if git diff --cached --quiet; then
|
||||
echo "Repo-state sync produced unmanaged drift only."
|
||||
git status --short
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$(git diff --name-only)" ] || [ -n "$(git ls-files --others --exclude-standard)" ]; then
|
||||
echo "Repo-state sync produced unmanaged drift alongside canonical changes."
|
||||
git status --short
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git commit -m "chore: sync repo state [ci skip]"
|
||||
git pull origin main --rebase
|
||||
@@ -255,13 +280,14 @@ jobs:
|
||||
- name: Check for uncommitted drift
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
if ! git diff --quiet; then
|
||||
echo "❌ Detected uncommitted changes produced by registry/readme/catalog scripts."
|
||||
if ! git diff --quiet || [ -n "$(git ls-files --others --exclude-standard)" ]; then
|
||||
echo "❌ Detected leftover drift after the canonical bot sync."
|
||||
echo
|
||||
echo "Main must be self-healing after the auto-sync step."
|
||||
echo "The bot may only commit managed canonical files and must leave a clean tree."
|
||||
echo "To fix locally, run the canonical maintainer flow:"
|
||||
echo " npm run release:preflight"
|
||||
echo " npm run sync:repo-state"
|
||||
echo " git status"
|
||||
git status --short
|
||||
exit 1
|
||||
fi
|
||||
|
||||
8
.github/workflows/publish-npm.yml
vendored
8
.github/workflows/publish-npm.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: pip install pyyaml
|
||||
run: pip install -r tools/requirements.txt
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -36,6 +36,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Audit npm dependencies
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
- name: Validate references
|
||||
run: npm run validate:references
|
||||
|
||||
@@ -45,6 +48,9 @@ jobs:
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
- name: Run web app coverage
|
||||
run: npm run app:test:coverage
|
||||
|
||||
- name: Run docs security checks
|
||||
run: npm run security:docs
|
||||
|
||||
|
||||
21
.github/workflows/repo-hygiene.yml
vendored
21
.github/workflows/repo-hygiene.yml
vendored
@@ -11,10 +11,15 @@ permissions:
|
||||
jobs:
|
||||
sync-repo-state:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: canonical-main-sync
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
@@ -22,7 +27,7 @@ jobs:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: pip install pyyaml
|
||||
run: pip install -r tools/requirements.txt
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -33,6 +38,9 @@ jobs:
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Audit npm dependencies
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
- name: Run repo-state sync
|
||||
run: npm run sync:repo-state
|
||||
|
||||
@@ -46,17 +54,24 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git diff --quiet; then
|
||||
if git diff --quiet && [ -z "$(git ls-files --others --exclude-standard)" ]; then
|
||||
echo "No repo-state drift detected."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git fetch origin main
|
||||
git add -- "${managed_files[@]}" || true
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "Repo hygiene produced unmanaged drift."
|
||||
echo "Repo hygiene produced unmanaged drift only."
|
||||
git status --short
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$(git diff --name-only)" ] || [ -n "$(git ls-files --others --exclude-standard)" ]; then
|
||||
echo "Repo hygiene produced unmanaged drift alongside canonical changes."
|
||||
git status --short
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,16 +1,93 @@
|
||||
# React + Vite
|
||||
# Antigravity Web App
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
This app is the static catalog and skill browser for `antigravity-awesome-skills`. It ships the generated registry, renders searchable skill detail pages, and publishes the public site to GitHub Pages.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
## What This App Does
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
- Loads the generated skill catalog and related metadata from tracked assets in `public/`.
|
||||
- Renders home, category, bundle, and skill detail routes for the published library.
|
||||
- Adds SEO metadata, sitemap-backed URLs, and static asset resolution for GitHub Pages.
|
||||
- Supports a local-only "refresh skills" developer flow through the Vite dev server plugin.
|
||||
|
||||
## React Compiler
|
||||
## Architecture
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
- `src/pages/` contains top-level route screens such as `Home.tsx` and `SkillDetail.tsx`.
|
||||
- `src/context/` holds catalog loading and shared app state.
|
||||
- `src/hooks/` contains feature-specific client hooks such as star state and filters.
|
||||
- `src/utils/` contains URL, SEO, and content helpers.
|
||||
- `public/` contains generated catalog artifacts copied from the repo root as part of maintainer sync flows.
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
The app intentionally assumes a static hosting model in production. Anything that depends on `/api/*` is development-only unless it is backed by a real serverless or backend implementation.
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
## Development
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
npm run app:install
|
||||
npm run app:dev
|
||||
```
|
||||
|
||||
Or directly from this directory:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Useful root-level commands:
|
||||
|
||||
```bash
|
||||
npm run app:build
|
||||
npm run sync:web-assets
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The app reads configuration from `.env` files in `apps/web-app/`.
|
||||
|
||||
- `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY`: optional read access for read-only community save counts.
|
||||
- `VITE_ENABLE_SKILLS_SYNC=true`: explicitly exposes the local maintainer-only sync button during development.
|
||||
- `VITE_SYNC_SKILLS_TOKEN`: local development token accepted by the Vite refresh plugin.
|
||||
- `VITE_SITE_URL`: optional override for canonical URL generation when testing non-default hosts.
|
||||
|
||||
Saving a skill is intentionally browser-local for now. The UI should not imply a shared write path until the project has a real backend contract for persistence, abuse controls, and deployment.
|
||||
|
||||
## Deploy Model
|
||||
|
||||
Production deploys use GitHub Pages and publish the built `dist/` output from this app. That means:
|
||||
|
||||
- production is static
|
||||
- Vite `configureServer` hooks are not available in production
|
||||
- any refresh or sync endpoint exposed by a dev plugin must be hidden or replaced by a real backend before being treated as a public feature
|
||||
|
||||
Maintainers should treat `public/skills.json.backup`, `public/sitemap.xml`, and other generated assets as derived artifacts synced from the repo root during release and hygiene workflows.
|
||||
|
||||
## Catalog Data Flow
|
||||
|
||||
The high-level maintainer flow is:
|
||||
|
||||
1. update skill sources under `skills/`
|
||||
2. regenerate canonical registry artifacts from the repo root
|
||||
3. sync tracked web assets into `apps/web-app/public/`
|
||||
4. build this app for Pages
|
||||
|
||||
`npm run sync:repo-state` and `npm run sync:release-state` are the safest entrypoints because they keep the root catalog and the web assets aligned.
|
||||
|
||||
## Testing
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
cd apps/web-app && npm run test
|
||||
cd apps/web-app && npm run test:coverage
|
||||
npm run test
|
||||
```
|
||||
|
||||
The repo-level test suite also contains workflow and documentation guardrails outside `src/`, so changes to this app can fail tests in `tools/scripts/tests/` even when the React code itself is untouched.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If the app shows stale catalog data, run `npm run sync:web-assets` from the repo root and rebuild.
|
||||
- If a feature works in `npm run app:dev` but not on GitHub Pages, check whether it depends on a dev-only Vite plugin or non-static runtime behavior.
|
||||
- If canonical URLs or asset links look wrong, inspect the shared path/base URL helpers before patching individual pages.
|
||||
|
||||
615
apps/web-app/package-lock.json
generated
615
apps/web-app/package-lock.json
generated
@@ -32,6 +32,7 @@
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
@@ -66,6 +67,20 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
|
||||
@@ -379,6 +394,16 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
@@ -1145,6 +1170,34 @@
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^5.1.2",
|
||||
"string-width-cjs": "npm:string-width@^4.2.0",
|
||||
"strip-ansi": "^7.0.1",
|
||||
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
|
||||
"wrap-ansi": "^8.1.0",
|
||||
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/schema": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
|
||||
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1233,6 +1286,17 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||
@@ -2554,6 +2618,40 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
|
||||
"integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.3.0",
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"ast-v8-to-istanbul": "^0.3.3",
|
||||
"debug": "^4.4.1",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-lib-source-maps": "^5.0.6",
|
||||
"istanbul-reports": "^3.1.7",
|
||||
"magic-string": "^0.30.17",
|
||||
"magicast": "^0.3.5",
|
||||
"std-env": "^3.9.0",
|
||||
"test-exclude": "^7.0.1",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "3.2.4",
|
||||
"vitest": "3.2.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
@@ -2725,7 +2823,6 @@
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -2773,6 +2870,25 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul": {
|
||||
"version": "0.3.12",
|
||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz",
|
||||
"integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.31",
|
||||
"estree-walker": "^3.0.3",
|
||||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
@@ -3312,6 +3428,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.286",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
|
||||
@@ -3319,6 +3442,13 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.19.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
|
||||
@@ -3830,6 +3960,23 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
"signal-exit": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
@@ -3974,6 +4121,28 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
"jackspeak": "^3.1.2",
|
||||
"minimatch": "^9.0.4",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^1.11.1"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -3987,6 +4156,32 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "16.5.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
|
||||
@@ -4187,6 +4382,13 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
@@ -4344,6 +4546,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
@@ -4403,6 +4615,76 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"make-dir": "^4.0.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-source-maps": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
|
||||
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.23",
|
||||
"debug": "^4.1.1",
|
||||
"istanbul-lib-coverage": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-reports": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"html-escaper": "^2.0.0",
|
||||
"istanbul-lib-report": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
@@ -4901,6 +5183,47 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/magicast": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
|
||||
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.25.4",
|
||||
"@babel/types": "^7.25.4",
|
||||
"source-map-js": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-table": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
||||
@@ -5849,6 +6172,16 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.34.2",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.2.tgz",
|
||||
@@ -5960,6 +6293,13 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -6031,6 +6371,30 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^10.2.0",
|
||||
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry/node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -6592,6 +6956,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -6626,6 +7003,60 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eastasianwidth": "^0.2.0",
|
||||
"emoji-regex": "^9.2.2",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs": {
|
||||
"name": "string-width",
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/stringify-entities": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||
@@ -6640,6 +7071,49 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi-cjs": {
|
||||
"name": "strip-ansi",
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-indent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||
@@ -6755,6 +7229,60 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz",
|
||||
"integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@istanbuljs/schema": "^0.1.2",
|
||||
"glob": "^10.4.1",
|
||||
"minimatch": "^10.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/minimatch": {
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -7420,6 +7948,91 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.1.0",
|
||||
"string-width": "^5.0.1",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs": {
|
||||
"name": "wrap-ansi",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
|
||||
@@ -2,247 +2,247 @@
|
||||
<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>http://localhost/</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/phase-gated-debugging</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/saas-multi-tenant</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/akf-trust-metadata</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/clarvia-aeo-check</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/adhx</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/app-store-changelog</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/github</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/ios-debugger-agent</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/macos-menubar-tuist-app</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/macos-spm-app-packaging</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/orchestrate-batch-refactor</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/project-skill-audit</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/react-component-performance</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/simplify-code</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/swift-concurrency-expert</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/swiftui-liquid-glass</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/swiftui-performance-audit</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/swiftui-ui-patterns</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/swiftui-view-refactor</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/aegisops-ai</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/snowflake-development</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/jobgpt</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/moyu</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/xvary-stock-research</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/gdb-cli</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/ad-creative</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/ai-seo</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/churn-prevention</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/claude-api</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/cold-email</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/content-strategy</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/defuddle</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/internal-comms</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/json-canvas</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/lead-magnets</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/obsidian-bases</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/obsidian-cli</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/obsidian-markdown</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/product-marketing-context</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/revops</loc>
|
||||
<lastmod>2026-03-28</lastmod>
|
||||
<lastmod>2026-03-29</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
|
||||
@@ -35,7 +35,7 @@ export const SkillCard = React.memo(({ skill, starCount }: SkillCardProps) => {
|
||||
</div>
|
||||
<SkillStarButton
|
||||
skillId={skill.id}
|
||||
initialCount={starCount}
|
||||
communityCount={starCount}
|
||||
variant="default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,60 +4,74 @@ import { useSkillStars } from '../hooks/useSkillStars';
|
||||
|
||||
interface SkillStarButtonProps {
|
||||
skillId: string;
|
||||
initialCount?: number;
|
||||
onStarClick?: () => void;
|
||||
communityCount?: number;
|
||||
onSaveClick?: () => void;
|
||||
variant?: 'default' | 'compact';
|
||||
}
|
||||
|
||||
/**
|
||||
* Star button component for skills
|
||||
* Uses useSkillStars hook for state management
|
||||
* Local-save button for skills with an optional read-only community count.
|
||||
*/
|
||||
export function SkillStarButton({
|
||||
skillId,
|
||||
initialCount = 0,
|
||||
onStarClick,
|
||||
communityCount = 0,
|
||||
onSaveClick,
|
||||
variant = 'default'
|
||||
}: SkillStarButtonProps): React.ReactElement {
|
||||
const { starCount, hasStarred, handleStarClick, isLoading } = useSkillStars(skillId);
|
||||
|
||||
// Use optimistic count from hook, fall back to initial
|
||||
const displayCount = starCount || initialCount;
|
||||
const { hasSaved, handleSaveClick, isSaving } = useSkillStars(skillId);
|
||||
const actionLabel = hasSaved ? 'Saved locally' : 'Save locally';
|
||||
const communityLabel = communityCount > 0
|
||||
? `${communityCount.toLocaleString('en-US')} community saves`
|
||||
: null;
|
||||
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (hasStarred || isLoading) return;
|
||||
if (hasSaved || isSaving) return;
|
||||
|
||||
await handleStarClick();
|
||||
onStarClick?.();
|
||||
await handleSaveClick();
|
||||
onSaveClick?.();
|
||||
};
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{communityLabel && (
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-xs font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||
{communityLabel}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="flex items-center space-x-1.5 px-3 py-1 bg-yellow-50 dark:bg-yellow-900/10 hover:bg-yellow-100 dark:hover:bg-yellow-900/30 text-yellow-700 dark:text-yellow-500 rounded-full text-xs font-bold border border-yellow-200 dark:border-yellow-700/50 transition-colors disabled:opacity-50"
|
||||
disabled={hasStarred || isLoading}
|
||||
title={hasStarred ? 'You already upvoted' : 'Upvote skill'}
|
||||
disabled={hasSaved || isSaving}
|
||||
title={hasSaved ? 'Saved in this browser' : 'Save this skill in this browser'}
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${hasStarred ? 'fill-yellow-500 stroke-yellow-500' : ''}`} />
|
||||
<span>{displayCount} Upvotes</span>
|
||||
<Star className={`h-3.5 w-3.5 ${hasSaved ? 'fill-yellow-500 stroke-yellow-500' : ''}`} />
|
||||
<span>{actionLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-2 z-10">
|
||||
{communityLabel && (
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||
{communityLabel}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="flex items-center space-x-1 px-2 py-1 rounded-md bg-slate-50 dark:bg-slate-800/50 hover:bg-yellow-50 dark:hover:bg-yellow-900/20 text-slate-500 hover:text-yellow-600 dark:hover:text-yellow-500 transition-colors border border-slate-200 dark:border-slate-800 z-10 disabled:opacity-50"
|
||||
disabled={hasStarred || isLoading}
|
||||
title={hasStarred ? 'You already upvoted' : 'Upvote skill'}
|
||||
className="flex items-center space-x-1 px-2.5 py-1.5 rounded-md bg-slate-50 dark:bg-slate-800/50 hover:bg-yellow-50 dark:hover:bg-yellow-900/20 text-slate-500 hover:text-yellow-600 dark:hover:text-yellow-500 transition-colors border border-slate-200 dark:border-slate-800 disabled:opacity-50"
|
||||
disabled={hasSaved || isSaving}
|
||||
title={hasSaved ? 'Saved in this browser' : 'Save this skill in this browser'}
|
||||
>
|
||||
<Star className={`h-4 w-4 ${hasStarred ? 'fill-yellow-400 stroke-yellow-400' : ''} ${isLoading ? 'animate-pulse' : ''}`} />
|
||||
<span className="text-xs font-semibold">{displayCount}</span>
|
||||
<Star className={`h-4 w-4 ${hasSaved ? 'fill-yellow-400 stroke-yellow-400' : ''} ${isSaving ? 'animate-pulse' : ''}`} />
|
||||
<span className="text-xs font-semibold">{actionLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import type { Skill, StarMap } from '../types';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { getSkillsIndexCandidateUrls } from '../utils/publicAssetUrls';
|
||||
|
||||
interface SkillContextType {
|
||||
skills: Skill[];
|
||||
@@ -10,67 +11,8 @@ interface SkillContextType {
|
||||
refreshSkills: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface SkillsIndexUrlInput {
|
||||
baseUrl: string;
|
||||
origin: string;
|
||||
pathname: string;
|
||||
documentBaseUrl?: string;
|
||||
}
|
||||
|
||||
const SkillContext = createContext<SkillContextType | undefined>(undefined);
|
||||
|
||||
function normalizeBasePath(baseUrl: string): string {
|
||||
const normalizedSegments = baseUrl
|
||||
.trim()
|
||||
.split('/')
|
||||
.filter((segment) => segment.length > 0 && segment !== '.');
|
||||
|
||||
const normalizedPath = normalizedSegments.length > 0
|
||||
? `/${normalizedSegments.join('/')}`
|
||||
: '/';
|
||||
|
||||
return normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/`;
|
||||
}
|
||||
|
||||
function appendBackupCandidates(urls: string[]): string[] {
|
||||
const candidates = new Set<string>();
|
||||
|
||||
urls.forEach((url) => {
|
||||
candidates.add(url);
|
||||
|
||||
if (url.endsWith('skills.json')) {
|
||||
candidates.add(`${url}.backup`);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
export function getSkillsIndexCandidateUrls({
|
||||
baseUrl,
|
||||
origin,
|
||||
pathname,
|
||||
documentBaseUrl,
|
||||
}: SkillsIndexUrlInput): string[] {
|
||||
const normalizedPathname = pathname.startsWith('/') ? pathname : `/${pathname}`;
|
||||
const baseCandidate = new URL(
|
||||
'skills.json',
|
||||
documentBaseUrl || new URL(normalizeBasePath(baseUrl), origin),
|
||||
).href;
|
||||
const pathSegments = normalizedPathname.split('/').filter(Boolean);
|
||||
const pathCandidates = pathSegments.map((_, index) => {
|
||||
const prefix = `/${pathSegments.slice(0, index + 1).join('/')}/`;
|
||||
return `${origin}${prefix}skills.json`;
|
||||
});
|
||||
|
||||
return appendBackupCandidates([
|
||||
baseCandidate,
|
||||
new URL('skills.json', new URL(normalizeBasePath(baseUrl), origin)).href,
|
||||
`${origin}/skills.json`,
|
||||
...pathCandidates,
|
||||
]);
|
||||
}
|
||||
|
||||
export function SkillProvider({ children }: { children: React.ReactNode }) {
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [stars, setStars] = useState<StarMap>({});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
|
||||
import { getSkillsIndexCandidateUrls, SkillProvider, useSkills } from '../SkillContext';
|
||||
import { SkillProvider, useSkills } from '../SkillContext';
|
||||
|
||||
// Keep tests deterministic by skipping real Supabase requests.
|
||||
vi.mock('../../lib/supabase', () => ({
|
||||
@@ -23,44 +23,6 @@ function SkillsProbe() {
|
||||
);
|
||||
}
|
||||
|
||||
describe('getSkillsIndexCandidateUrls', () => {
|
||||
it('keeps stable candidates for gh-pages base path', () => {
|
||||
expect(
|
||||
getSkillsIndexCandidateUrls({
|
||||
baseUrl: '/antigravity-awesome-skills/',
|
||||
origin: 'https://sickn33.github.io',
|
||||
pathname: '/antigravity-awesome-skills/skill/some-id',
|
||||
documentBaseUrl: 'https://sickn33.github.io/antigravity-awesome-skills/',
|
||||
}),
|
||||
).toEqual([
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skills.json',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skills.json.backup',
|
||||
'https://sickn33.github.io/skills.json',
|
||||
'https://sickn33.github.io/skills.json.backup',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skill/skills.json',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skill/skills.json.backup',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skill/some-id/skills.json',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skill/some-id/skills.json.backup',
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes dot-relative BASE_URL values', () => {
|
||||
expect(
|
||||
getSkillsIndexCandidateUrls({
|
||||
baseUrl: './',
|
||||
origin: 'https://sickn33.github.io',
|
||||
pathname: '/antigravity-awesome-skills/',
|
||||
documentBaseUrl: 'https://sickn33.github.io/antigravity-awesome-skills/',
|
||||
}),
|
||||
).toEqual([
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skills.json',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skills.json.backup',
|
||||
'https://sickn33.github.io/skills.json',
|
||||
'https://sickn33.github.io/skills.json.backup',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SkillProvider', () => {
|
||||
beforeEach(() => {
|
||||
(global.fetch as Mock).mockReset();
|
||||
|
||||
@@ -9,7 +9,7 @@ export function createMockSkill(overrides?: Partial<Skill>): Skill {
|
||||
name: 'Test Skill',
|
||||
description: 'A test skill for testing purposes',
|
||||
category: 'testing',
|
||||
risk: 'low',
|
||||
risk: 'safe',
|
||||
source: 'test',
|
||||
date_added: '2024-01-01',
|
||||
path: 'skills/test/SKILL.md',
|
||||
|
||||
@@ -2,81 +2,58 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useSkillStars } from '../useSkillStars';
|
||||
|
||||
const STORAGE_KEY = 'user_stars';
|
||||
|
||||
const supabaseMocks = vi.hoisted(() => {
|
||||
const maybeSingle = vi.fn();
|
||||
const upsert = vi.fn();
|
||||
const select = vi.fn(() => ({ eq: vi.fn(() => ({ maybeSingle })) }));
|
||||
const from = vi.fn(() => ({ select, upsert }));
|
||||
|
||||
return { maybeSingle, upsert, select, from };
|
||||
});
|
||||
|
||||
vi.mock('../../lib/supabase', () => ({
|
||||
supabase: {
|
||||
from: supabaseMocks.from,
|
||||
},
|
||||
}));
|
||||
const STORAGE_KEY = 'saved_skills';
|
||||
const LEGACY_STORAGE_KEY = 'user_stars';
|
||||
|
||||
describe('useSkillStars', () => {
|
||||
beforeEach(() => {
|
||||
// Clear localStorage mock before each test
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
supabaseMocks.from.mockReturnValue({ select: supabaseMocks.select, upsert: supabaseMocks.upsert });
|
||||
supabaseMocks.select.mockReturnValue({ eq: vi.fn(() => ({ maybeSingle: supabaseMocks.maybeSingle })) });
|
||||
supabaseMocks.maybeSingle.mockResolvedValue({ data: null, error: null });
|
||||
supabaseMocks.upsert.mockResolvedValue({ error: null });
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should initialize with zero stars when no skillId provided', () => {
|
||||
it('should initialize as unsaved when no skillId is provided', () => {
|
||||
const { result } = renderHook(() => useSkillStars(undefined));
|
||||
|
||||
expect(result.current.starCount).toBe(0);
|
||||
expect(result.current.hasStarred).toBe(false);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.hasSaved).toBe(false);
|
||||
expect(result.current.isSaving).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize with zero stars for new skill', () => {
|
||||
it('should initialize as unsaved for a new skill', () => {
|
||||
const { result } = renderHook(() => useSkillStars('new-skill'));
|
||||
|
||||
expect(result.current.starCount).toBe(0);
|
||||
expect(result.current.hasStarred).toBe(false);
|
||||
expect(result.current.hasSaved).toBe(false);
|
||||
});
|
||||
|
||||
it('should overlay a local star on top of the shared count', async () => {
|
||||
it('should read saved state from the new storage key', async () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'test-skill': true }));
|
||||
supabaseMocks.maybeSingle.mockResolvedValue({ data: { star_count: 7 }, error: null });
|
||||
|
||||
const { result } = renderHook(() => useSkillStars('test-skill'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.starCount).toBe(8);
|
||||
expect(result.current.hasSaved).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.hasStarred).toBe(true);
|
||||
});
|
||||
|
||||
it('should read starred status from localStorage on init', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'test-skill': true }));
|
||||
it('should read saved state from the legacy storage key', async () => {
|
||||
localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify({ 'test-skill': true }));
|
||||
|
||||
const { result } = renderHook(() => useSkillStars('test-skill'));
|
||||
|
||||
expect(result.current.hasStarred).toBe(true);
|
||||
await waitFor(() => {
|
||||
expect(result.current.hasSaved).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle corrupted localStorage gracefully', () => {
|
||||
// Mock getItem to return invalid JSON
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
localStorage.setItem(STORAGE_KEY, 'invalid-json');
|
||||
|
||||
const { result } = renderHook(() => useSkillStars('test-skill'));
|
||||
|
||||
expect(result.current.hasStarred).toBe(false);
|
||||
expect(result.current.hasSaved).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to parse user_stars from localStorage:',
|
||||
`Failed to parse ${STORAGE_KEY} from localStorage:`,
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
@@ -84,80 +61,69 @@ describe('useSkillStars', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleStarClick', () => {
|
||||
it('should not allow starring without skillId', async () => {
|
||||
describe('handleSaveClick', () => {
|
||||
it('should not allow saving without skillId', async () => {
|
||||
const { result } = renderHook(() => useSkillStars(undefined));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleStarClick();
|
||||
await result.current.handleSaveClick();
|
||||
});
|
||||
|
||||
expect(result.current.starCount).toBe(0);
|
||||
expect(result.current.hasSaved).toBe(false);
|
||||
expect(localStorage.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not allow double-starring same skill', async () => {
|
||||
it('should not allow double-saving the same skill', async () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'skill-1': true }));
|
||||
|
||||
const { result } = renderHook(() => useSkillStars('skill-1'));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleStarClick();
|
||||
await result.current.handleSaveClick();
|
||||
});
|
||||
|
||||
// Star count should remain unchanged (already starred)
|
||||
expect(result.current.starCount).toBe(0);
|
||||
expect(result.current.hasStarred).toBe(true);
|
||||
expect(result.current.hasSaved).toBe(true);
|
||||
});
|
||||
|
||||
it('should optimistically update star count', async () => {
|
||||
it('should optimistically mark the skill as saved', async () => {
|
||||
const { result } = renderHook(() => useSkillStars('optimistic-skill'));
|
||||
|
||||
// Initial state
|
||||
expect(result.current.starCount).toBe(0);
|
||||
expect(result.current.hasSaved).toBe(false);
|
||||
|
||||
// Click star
|
||||
await act(async () => {
|
||||
await result.current.handleStarClick();
|
||||
await result.current.handleSaveClick();
|
||||
});
|
||||
|
||||
// Should be optimistically updated
|
||||
await waitFor(() => {
|
||||
expect(result.current.starCount).toBe(1);
|
||||
expect(result.current.hasStarred).toBe(true);
|
||||
expect(result.current.hasSaved).toBe(true);
|
||||
});
|
||||
expect(supabaseMocks.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should persist starred status to localStorage', async () => {
|
||||
it('should persist saved status to localStorage', async () => {
|
||||
const { result } = renderHook(() => useSkillStars('persist-skill'));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleStarClick();
|
||||
await result.current.handleSaveClick();
|
||||
});
|
||||
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ 'persist-skill': true })
|
||||
);
|
||||
expect(supabaseMocks.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set loading state during operation', async () => {
|
||||
it('should expose a settled saving state after completion', async () => {
|
||||
const { result } = renderHook(() => useSkillStars('loading-skill'));
|
||||
|
||||
// Wait for initial render
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isSaving).toBe(false);
|
||||
});
|
||||
|
||||
// Click star - the loading state may change very quickly due to the async nature
|
||||
await act(async () => {
|
||||
await result.current.handleStarClick();
|
||||
await result.current.handleSaveClick();
|
||||
});
|
||||
|
||||
// After completion, loading should be false
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isSaving).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,21 +131,19 @@ describe('useSkillStars', () => {
|
||||
it('should handle setItem errors gracefully', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
// Mock setItem to throw
|
||||
localStorage.setItem = vi.fn(() => {
|
||||
throw new Error('Storage quota exceeded');
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSkillStars('error-skill'));
|
||||
|
||||
// Should still optimistically update UI
|
||||
await act(async () => {
|
||||
await result.current.handleStarClick();
|
||||
await result.current.handleSaveClick();
|
||||
});
|
||||
|
||||
expect(result.current.starCount).toBe(1);
|
||||
expect(result.current.hasSaved).toBe(true);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to save user_stars to localStorage:',
|
||||
`Failed to save ${STORAGE_KEY} to localStorage:`,
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
@@ -191,16 +155,15 @@ describe('useSkillStars', () => {
|
||||
it('should return all expected properties', () => {
|
||||
const { result } = renderHook(() => useSkillStars('test'));
|
||||
|
||||
expect(result.current).toHaveProperty('starCount');
|
||||
expect(result.current).toHaveProperty('hasStarred');
|
||||
expect(result.current).toHaveProperty('handleStarClick');
|
||||
expect(result.current).toHaveProperty('isLoading');
|
||||
expect(result.current).toHaveProperty('hasSaved');
|
||||
expect(result.current).toHaveProperty('handleSaveClick');
|
||||
expect(result.current).toHaveProperty('isSaving');
|
||||
});
|
||||
|
||||
it('should expose handleStarClick as function', () => {
|
||||
it('should expose handleSaveClick as function', () => {
|
||||
const { result } = renderHook(() => useSkillStars('test'));
|
||||
|
||||
expect(typeof result.current.handleStarClick).toBe('function');
|
||||
expect(typeof result.current.handleSaveClick).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,41 +1,28 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
|
||||
const maybeSingle = vi.fn().mockResolvedValue({ data: null, error: null });
|
||||
const upsert = vi.fn().mockResolvedValue({ error: null });
|
||||
const select = vi.fn(() => ({ eq: vi.fn(() => ({ maybeSingle })) }));
|
||||
const from = vi.fn(() => ({ select, upsert }));
|
||||
|
||||
vi.mock('../../lib/supabase', () => ({
|
||||
supabase: {
|
||||
from,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('useSkillStars shared writes', () => {
|
||||
describe('useSkillStars local-only persistence', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
from.mockReturnValue({ select, upsert });
|
||||
select.mockReturnValue({ eq: vi.fn(() => ({ maybeSingle })) });
|
||||
maybeSingle.mockResolvedValue({ data: null, error: null });
|
||||
upsert.mockResolvedValue({ error: null });
|
||||
});
|
||||
|
||||
it('does not upsert shared star counts when frontend writes are disabled', async () => {
|
||||
it('only records a local save in browser storage', async () => {
|
||||
const { useSkillStars } = await import('../useSkillStars');
|
||||
const { result } = renderHook(() => useSkillStars('shared-stars-disabled'));
|
||||
const { result } = renderHook(() => useSkillStars('saved-locally'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isSaving).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleStarClick();
|
||||
await result.current.handleSaveClick();
|
||||
});
|
||||
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
expect(result.current.hasStarred).toBe(true);
|
||||
expect(result.current.starCount).toBe(1);
|
||||
expect(result.current.hasSaved).toBe(true);
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
'saved_skills',
|
||||
JSON.stringify({ 'saved-locally': true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,34 +1,40 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
const STORAGE_KEY = 'user_stars';
|
||||
const STORAGE_KEY = 'saved_skills';
|
||||
const LEGACY_STORAGE_KEY = 'user_stars';
|
||||
|
||||
interface UserStars {
|
||||
[skillId: string]: boolean;
|
||||
}
|
||||
|
||||
interface UseSkillStarsReturn {
|
||||
starCount: number;
|
||||
hasStarred: boolean;
|
||||
handleStarClick: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
hasSaved: boolean;
|
||||
handleSaveClick: () => Promise<void>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse localStorage data with error handling
|
||||
*/
|
||||
function getUserStarsFromStorage(): UserStars {
|
||||
function parseStoredStars(storageKey: string): UserStars {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (!stored) return {};
|
||||
const parsed = JSON.parse(stored);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : {};
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse user_stars from localStorage:', error);
|
||||
console.warn(`Failed to parse ${storageKey} from localStorage:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function getUserStarsFromStorage(): UserStars {
|
||||
return {
|
||||
...parseStoredStars(LEGACY_STORAGE_KEY),
|
||||
...parseStoredStars(STORAGE_KEY),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely save to localStorage with error handling
|
||||
*/
|
||||
@@ -36,82 +42,53 @@ function saveUserStarsToStorage(stars: UserStars): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stars));
|
||||
} catch (error) {
|
||||
console.warn('Failed to save user_stars to localStorage:', error);
|
||||
console.warn(`Failed to save ${STORAGE_KEY} to localStorage:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage skill starring functionality
|
||||
* Handles localStorage persistence, optimistic UI updates, and Supabase sync
|
||||
* Hook to manage local skill saves in the browser.
|
||||
*/
|
||||
export function useSkillStars(skillId: string | undefined): UseSkillStarsReturn {
|
||||
const [starCount, setStarCount] = useState<number>(0);
|
||||
const [hasStarred, setHasStarred] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [hasSaved, setHasSaved] = useState<boolean>(false);
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
|
||||
// Initialize star count from Supabase and check if user has starred
|
||||
useEffect(() => {
|
||||
if (!skillId) return;
|
||||
if (!skillId) {
|
||||
setHasSaved(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const initializeStars = async () => {
|
||||
// Check localStorage for user's starred status
|
||||
const userStars = getUserStarsFromStorage();
|
||||
setHasStarred(!!userStars[skillId]);
|
||||
|
||||
// Fetch star count from Supabase if available
|
||||
if (supabase) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('skill_stars')
|
||||
.select('star_count')
|
||||
.eq('skill_id', skillId)
|
||||
.maybeSingle();
|
||||
|
||||
if (!error && data) {
|
||||
setStarCount(data.star_count + (userStars[skillId] ? 1 : 0));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch star count:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initializeStars();
|
||||
setHasSaved(!!userStars[skillId]);
|
||||
}, [skillId]);
|
||||
|
||||
/**
|
||||
* Handle star button click
|
||||
* Prevents double-starring, updates optimistically, persists local state
|
||||
* Save a skill locally in this browser without pretending to update shared metrics.
|
||||
*/
|
||||
const handleStarClick = useCallback(async () => {
|
||||
if (!skillId || isLoading) return;
|
||||
const handleSaveClick = useCallback(async () => {
|
||||
if (!skillId || isSaving) return;
|
||||
|
||||
// Check if user has already starred (prevent spam)
|
||||
const userStars = getUserStarsFromStorage();
|
||||
if (userStars[skillId]) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setIsSaving(true);
|
||||
setHasSaved(true);
|
||||
|
||||
try {
|
||||
// Optimistically update UI
|
||||
setStarCount(prev => prev + 1);
|
||||
setHasStarred(true);
|
||||
|
||||
// Persist to localStorage
|
||||
const updatedStars = { ...userStars, [skillId]: true };
|
||||
saveUserStarsToStorage(updatedStars);
|
||||
} catch (error) {
|
||||
console.error('Failed to star skill:', error);
|
||||
console.error('Failed to save skill locally:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [skillId, isLoading]);
|
||||
}, [skillId, isSaving]);
|
||||
|
||||
return {
|
||||
starCount,
|
||||
hasStarred,
|
||||
handleStarClick,
|
||||
isLoading
|
||||
hasSaved,
|
||||
handleSaveClick,
|
||||
isSaving
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createClient, SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
// Public Supabase credentials for the shared community stars database.
|
||||
// The anon key is a public key by design — security is enforced via RLS policies.
|
||||
// .env values override these defaults if provided.
|
||||
// Public Supabase credentials for the read-only community save counts feed.
|
||||
// The browser UI does not perform shared writes; .env values can override these defaults.
|
||||
const supabaseUrl =
|
||||
(import.meta as ImportMeta & { env: Record<string, string> }).env.VITE_SUPABASE_URL
|
||||
|| 'https://gczhgcbtjbvfrgfmpbmv.supabase.co'
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSkills } from '../context/SkillContext';
|
||||
import { SkillCard } from '../components/SkillCard';
|
||||
import type { SyncMessage, CategoryStats } from '../types';
|
||||
import { usePageMeta } from '../hooks/usePageMeta';
|
||||
import { APP_HOME_CATALOG_COUNT, buildHomeMeta, getHomeFaqItems } from '../utils/seo';
|
||||
import { buildHomeMeta, getHomeFaqItems } from '../utils/seo';
|
||||
|
||||
const conceptCards = [
|
||||
{
|
||||
@@ -49,6 +49,11 @@ const integrationGuides = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const syncFeatureEnabled = (
|
||||
(import.meta as ImportMeta & { env: Record<string, string | undefined> }).env.VITE_ENABLE_SKILLS_SYNC
|
||||
=== 'true'
|
||||
);
|
||||
|
||||
export function Home(): React.ReactElement {
|
||||
const { skills, stars, loading, error, refreshSkills } = useSkills();
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -62,6 +67,7 @@ export function Home(): React.ReactElement {
|
||||
const docsLink = 'https://github.com/sickn33/antigravity-awesome-skills/blob/main/docs/users/usage.md';
|
||||
const installLink = 'https://www.npmjs.com/package/antigravity-awesome-skills';
|
||||
const faqItems = getHomeFaqItems();
|
||||
const catalogCountLabel = skills.length > 0 ? skills.length.toLocaleString('en-US') : 'installable';
|
||||
|
||||
usePageMeta(buildHomeMeta(skills.length));
|
||||
|
||||
@@ -197,7 +203,7 @@ export function Home(): React.ReactElement {
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100 mb-2">Explore Skills</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
Discover {Math.max(skills.length, APP_HOME_CATALOG_COUNT)}+ agentic capabilities for your AI assistant.
|
||||
Discover {catalogCountLabel} agentic capabilities for your AI assistant.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -211,6 +217,7 @@ export function Home(): React.ReactElement {
|
||||
{syncMsg.text}
|
||||
</span>
|
||||
)}
|
||||
{syncFeatureEnabled ? (
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
@@ -219,8 +226,18 @@ export function Home(): React.ReactElement {
|
||||
<RefreshCw className={`h-4 w-4 ${syncing ? 'animate-spin' : ''}`} />
|
||||
<span>{syncing ? 'Syncing...' : 'Sync Skills'}</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="rounded-full bg-slate-100 dark:bg-slate-800 px-3 py-1.5 text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
Public catalog mode
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!syncFeatureEnabled && (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 -mt-4">
|
||||
Catalog sync is a maintainer-only workflow in local builds, so the public Pages site always shows the last published catalog.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:space-x-4 md:space-y-0 bg-white dark:bg-slate-900 p-4 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm sticky top-0 z-40">
|
||||
<div className="relative flex-1">
|
||||
@@ -259,7 +276,7 @@ export function Home(): React.ReactElement {
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
>
|
||||
<option value="default">Default</option>
|
||||
<option value="stars">⭐ Most Stars</option>
|
||||
<option value="stars">⭐ Community saves</option>
|
||||
<option value="newest">🆕 Newest</option>
|
||||
<option value="az">🔤 A → Z</option>
|
||||
</select>
|
||||
|
||||
@@ -5,59 +5,13 @@ import { SkillStarButton } from '../components/SkillStarButton';
|
||||
import { useSkills } from '../context/SkillContext';
|
||||
import { usePageMeta } from '../hooks/usePageMeta';
|
||||
import { buildSkillFallbackMeta, buildSkillMeta, selectTopSkills } from '../utils/seo';
|
||||
import { getSkillMarkdownCandidateUrls } from '../utils/publicAssetUrls';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
|
||||
// Lazy load heavy markdown component
|
||||
const Markdown = lazy(() => import('react-markdown'));
|
||||
|
||||
interface SkillMarkdownUrlInput {
|
||||
baseUrl: string;
|
||||
origin: string;
|
||||
pathname: string;
|
||||
documentBaseUrl?: string;
|
||||
skillPath: string;
|
||||
}
|
||||
|
||||
function normalizeBasePath(baseUrl: string): string {
|
||||
const normalizedSegments = baseUrl
|
||||
.trim()
|
||||
.split('/')
|
||||
.filter((segment) => segment.length > 0 && segment !== '.');
|
||||
|
||||
const normalizedPath = normalizedSegments.length > 0
|
||||
? `/${normalizedSegments.join('/')}`
|
||||
: '/';
|
||||
|
||||
return normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/`;
|
||||
}
|
||||
|
||||
export function getSkillMarkdownCandidateUrls({
|
||||
baseUrl,
|
||||
origin,
|
||||
pathname,
|
||||
documentBaseUrl,
|
||||
skillPath,
|
||||
}: SkillMarkdownUrlInput): string[] {
|
||||
const normalizedSkillPath = skillPath
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/\/SKILL\.md$/i, '');
|
||||
const assetPath = `${normalizedSkillPath}/SKILL.md`;
|
||||
const normalizedPathname = pathname.startsWith('/') ? pathname : `/${pathname}`;
|
||||
const pathSegments = normalizedPathname.split('/').filter(Boolean);
|
||||
const pathCandidates = pathSegments.map((_, index) => {
|
||||
const prefix = `/${pathSegments.slice(0, index + 1).join('/')}/`;
|
||||
return `${origin}${prefix}${assetPath}`;
|
||||
});
|
||||
|
||||
return Array.from(new Set([
|
||||
new URL(assetPath, documentBaseUrl || new URL(normalizeBasePath(baseUrl), origin)).href,
|
||||
new URL(assetPath, new URL(normalizeBasePath(baseUrl), origin)).href,
|
||||
`${origin}/${assetPath}`,
|
||||
...pathCandidates,
|
||||
]));
|
||||
}
|
||||
|
||||
function looksLikeHtmlDocument(text: string): boolean {
|
||||
const trimmed = text.trim().toLowerCase();
|
||||
return trimmed.startsWith('<!doctype html') || trimmed.startsWith('<html');
|
||||
@@ -143,7 +97,7 @@ export function SkillDetail(): React.ReactElement {
|
||||
}, [id, skill, isPriority, canonicalPath])
|
||||
);
|
||||
|
||||
const starCount = useMemo(() => (id ? stars[id] || 0 : 0), [stars, id]);
|
||||
const communityCount = useMemo(() => (id ? stars[id] || 0 : 0), [stars, id]);
|
||||
const { frontmatter, body: markdownBody } = useMemo(() => splitFrontmatter(content), [content]);
|
||||
const frontmatterRows = useMemo(() => parseFrontmatterRows(frontmatter), [frontmatter]);
|
||||
|
||||
@@ -300,7 +254,7 @@ export function SkillDetail(): React.ReactElement {
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-white">
|
||||
@{skill.name}
|
||||
</h1>
|
||||
<SkillStarButton skillId={skill.id} initialCount={starCount} variant="compact" />
|
||||
<SkillStarButton skillId={skill.id} communityCount={communityCount} variant="compact" />
|
||||
</div>
|
||||
<p className="mt-2 text-lg text-slate-600 dark:text-slate-400">
|
||||
{skill.description}
|
||||
|
||||
@@ -178,31 +178,23 @@ describe('Home', () => {
|
||||
});
|
||||
|
||||
describe('User Settings and Sync', () => {
|
||||
it('should sync local stars when sync button is clicked', async () => {
|
||||
it('hides sync actions on the public catalog and explains why', async () => {
|
||||
const mockSkills = [createMockSkill({ id: 'skill-1' })];
|
||||
const refreshSkills = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: mockSkills,
|
||||
stars: { 'skill-1': 5 },
|
||||
loading: false,
|
||||
error: null,
|
||||
refreshSkills,
|
||||
refreshSkills: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />, { useProvider: false });
|
||||
|
||||
const syncButton = screen.getByRole('button', { name: /Sync/i });
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, count: 1 })
|
||||
});
|
||||
|
||||
fireEvent.click(syncButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refreshSkills).toHaveBeenCalled();
|
||||
expect(screen.queryByRole('button', { name: /Sync Skills/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Public catalog mode/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/maintainer-only workflow/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useSkills } from '../../context/SkillContext';
|
||||
let capturedRehypePlugins: unknown[] | undefined;
|
||||
|
||||
vi.mock('../../components/SkillStarButton', () => ({
|
||||
SkillStarButton: () => <button data-testid="star-button">0 Upvotes</button>,
|
||||
SkillStarButton: () => <button data-testid="star-button">Save locally</button>,
|
||||
}));
|
||||
|
||||
vi.mock('../../context/SkillContext', async (importOriginal) => {
|
||||
|
||||
@@ -4,13 +4,12 @@ import { SkillDetail } from '../SkillDetail';
|
||||
import { renderWithRouter } from '../../utils/testUtils';
|
||||
import { createMockSkill } from '../../factories/skill';
|
||||
import { useSkills } from '../../context/SkillContext';
|
||||
import { getSkillMarkdownCandidateUrls } from '../SkillDetail';
|
||||
|
||||
// Mock the SkillStarButton component
|
||||
vi.mock('../../components/SkillStarButton', () => ({
|
||||
SkillStarButton: ({ skillId, initialCount }: { skillId: string; initialCount?: number }) => (
|
||||
<button data-testid="star-button" data-skill-id={skillId} data-count={initialCount}>
|
||||
{initialCount || 0} Upvotes
|
||||
SkillStarButton: ({ skillId, communityCount }: { skillId: string; communityCount?: number }) => (
|
||||
<button data-testid="star-button" data-skill-id={skillId} data-community-count={communityCount}>
|
||||
Save locally
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
@@ -36,25 +35,6 @@ describe('SkillDetail', () => {
|
||||
window.history.pushState({}, '', '/');
|
||||
});
|
||||
|
||||
describe('Markdown URL resolution', () => {
|
||||
it('builds stable markdown candidates for gh-pages routes', () => {
|
||||
expect(
|
||||
getSkillMarkdownCandidateUrls({
|
||||
baseUrl: '/antigravity-awesome-skills/',
|
||||
origin: 'https://sickn33.github.io',
|
||||
pathname: '/antigravity-awesome-skills/skill/react-patterns',
|
||||
documentBaseUrl: 'https://sickn33.github.io/antigravity-awesome-skills/',
|
||||
skillPath: 'skills/react-patterns',
|
||||
}),
|
||||
).toEqual([
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skills/react-patterns/SKILL.md',
|
||||
'https://sickn33.github.io/skills/react-patterns/SKILL.md',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skill/skills/react-patterns/SKILL.md',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skill/react-patterns/skills/react-patterns/SKILL.md',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading state', () => {
|
||||
it('should show loading spinner when context is loading', async () => {
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
@@ -131,7 +111,7 @@ describe('SkillDetail', () => {
|
||||
'@react-patterns | Antigravity Awesome Skills',
|
||||
);
|
||||
expect(global.fetch).toHaveBeenCalled();
|
||||
});
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('falls back to the next markdown candidate when the first response is html', async () => {
|
||||
@@ -345,7 +325,7 @@ describe('SkillDetail', () => {
|
||||
await waitFor(() => {
|
||||
const starBtn = screen.getByTestId('star-button');
|
||||
expect(starBtn).toBeInTheDocument();
|
||||
expect(starBtn).toHaveAttribute('data-count', '10');
|
||||
expect(starBtn).toHaveAttribute('data-community-count', '10');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export type RiskLevel = 'none' | 'safe' | 'critical' | 'offensive' | 'unknown';
|
||||
|
||||
/**
|
||||
* Skill data type from skills.json
|
||||
*/
|
||||
@@ -6,7 +8,7 @@ export interface Skill {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
risk?: 'low' | 'medium' | 'high' | 'critical' | 'unknown';
|
||||
risk?: RiskLevel;
|
||||
source?: string;
|
||||
date_added?: string;
|
||||
path: string;
|
||||
|
||||
61
apps/web-app/src/utils/__tests__/publicAssetUrls.test.ts
Normal file
61
apps/web-app/src/utils/__tests__/publicAssetUrls.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getAbsolutePublicAssetUrl,
|
||||
getSkillMarkdownCandidateUrls,
|
||||
getSkillsIndexCandidateUrls,
|
||||
normalizeBasePath,
|
||||
} from '../publicAssetUrls';
|
||||
|
||||
describe('public asset URL helpers', () => {
|
||||
it('normalizes dot-relative BASE_URL values', () => {
|
||||
expect(normalizeBasePath('./')).toBe('/');
|
||||
expect(normalizeBasePath('/antigravity-awesome-skills/')).toBe('/antigravity-awesome-skills/');
|
||||
});
|
||||
|
||||
it('builds stable skills index candidates for gh-pages routes', () => {
|
||||
expect(
|
||||
getSkillsIndexCandidateUrls({
|
||||
baseUrl: '/antigravity-awesome-skills/',
|
||||
origin: 'https://sickn33.github.io',
|
||||
pathname: '/antigravity-awesome-skills/skill/some-id',
|
||||
documentBaseUrl: 'https://sickn33.github.io/antigravity-awesome-skills/',
|
||||
}),
|
||||
).toEqual([
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skills.json',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skills.json.backup',
|
||||
'https://sickn33.github.io/skills.json',
|
||||
'https://sickn33.github.io/skills.json.backup',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skill/skills.json',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skill/skills.json.backup',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skill/some-id/skills.json',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skill/some-id/skills.json.backup',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds stable markdown candidates for gh-pages routes', () => {
|
||||
expect(
|
||||
getSkillMarkdownCandidateUrls({
|
||||
baseUrl: '/antigravity-awesome-skills/',
|
||||
origin: 'https://sickn33.github.io',
|
||||
pathname: '/antigravity-awesome-skills/skill/react-patterns',
|
||||
documentBaseUrl: 'https://sickn33.github.io/antigravity-awesome-skills/',
|
||||
skillPath: 'skills/react-patterns',
|
||||
}),
|
||||
).toEqual([
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skills/react-patterns/SKILL.md',
|
||||
'https://sickn33.github.io/skills/react-patterns/SKILL.md',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skill/skills/react-patterns/SKILL.md',
|
||||
'https://sickn33.github.io/antigravity-awesome-skills/skill/react-patterns/skills/react-patterns/SKILL.md',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves absolute public asset URLs from the shared base path logic', () => {
|
||||
expect(
|
||||
getAbsolutePublicAssetUrl('/skill/react-patterns', {
|
||||
baseUrl: '/antigravity-awesome-skills/',
|
||||
origin: 'https://sickn33.github.io',
|
||||
}),
|
||||
).toBe('https://sickn33.github.io/antigravity-awesome-skills/skill/react-patterns');
|
||||
});
|
||||
});
|
||||
@@ -28,8 +28,8 @@ describe('SEO helpers', () => {
|
||||
it('builds homepage metadata with the canonical catalog message', () => {
|
||||
const meta = buildHomeMeta(10);
|
||||
|
||||
expect(meta.title).toContain('1,326+');
|
||||
expect(meta.description).toContain('1,326+ installable agentic skills');
|
||||
expect(meta.title).toContain('10 installable AI skills');
|
||||
expect(meta.description).toContain('10 installable agentic skills');
|
||||
expect(meta.canonicalPath).toBe('/');
|
||||
expect(meta.ogTitle).toBe(meta.title);
|
||||
expect(meta.ogImage).toBe(DEFAULT_SOCIAL_IMAGE);
|
||||
|
||||
119
apps/web-app/src/utils/publicAssetUrls.ts
Normal file
119
apps/web-app/src/utils/publicAssetUrls.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
export interface PublicAssetUrlInput {
|
||||
baseUrl: string;
|
||||
origin: string;
|
||||
pathname: string;
|
||||
documentBaseUrl?: string;
|
||||
}
|
||||
|
||||
export type SkillsIndexUrlInput = PublicAssetUrlInput;
|
||||
|
||||
export interface SkillMarkdownUrlInput extends PublicAssetUrlInput {
|
||||
skillPath: string;
|
||||
}
|
||||
|
||||
function stripLeadingSlashes(path: string): string {
|
||||
return path.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
function normalizePathname(pathname: string): string {
|
||||
return pathname.startsWith('/') ? pathname : `/${pathname}`;
|
||||
}
|
||||
|
||||
function getResolvedDocumentBaseUrl({
|
||||
baseUrl,
|
||||
origin,
|
||||
documentBaseUrl,
|
||||
}: Pick<PublicAssetUrlInput, 'baseUrl' | 'origin' | 'documentBaseUrl'>): URL {
|
||||
if (documentBaseUrl) {
|
||||
return new URL(documentBaseUrl);
|
||||
}
|
||||
|
||||
return new URL(normalizeBasePath(baseUrl), origin);
|
||||
}
|
||||
|
||||
function getPathCandidateUrls(pathname: string, assetPath: string, origin: string): string[] {
|
||||
const pathSegments = normalizePathname(pathname).split('/').filter(Boolean);
|
||||
|
||||
return pathSegments.map((_, index) => {
|
||||
const prefix = `/${pathSegments.slice(0, index + 1).join('/')}/`;
|
||||
return `${origin}${prefix}${assetPath}`;
|
||||
});
|
||||
}
|
||||
|
||||
function uniqueUrls(urls: string[]): string[] {
|
||||
return Array.from(new Set(urls));
|
||||
}
|
||||
|
||||
function appendBackupCandidates(urls: string[]): string[] {
|
||||
const candidates = new Set<string>();
|
||||
|
||||
urls.forEach((url) => {
|
||||
candidates.add(url);
|
||||
|
||||
if (url.endsWith('skills.json')) {
|
||||
candidates.add(`${url}.backup`);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
export function normalizeBasePath(baseUrl: string): string {
|
||||
const normalizedSegments = baseUrl
|
||||
.trim()
|
||||
.split('/')
|
||||
.filter((segment) => segment.length > 0 && segment !== '.');
|
||||
|
||||
const normalizedPath = normalizedSegments.length > 0
|
||||
? `/${normalizedSegments.join('/')}`
|
||||
: '/';
|
||||
|
||||
return normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/`;
|
||||
}
|
||||
|
||||
export function getAbsolutePublicAssetUrl(
|
||||
assetPath: string,
|
||||
{
|
||||
baseUrl,
|
||||
origin,
|
||||
}: Pick<PublicAssetUrlInput, 'baseUrl' | 'origin'>,
|
||||
): string {
|
||||
const resolvedAssetPath = stripLeadingSlashes(assetPath.trim());
|
||||
return new URL(resolvedAssetPath || '.', new URL(normalizeBasePath(baseUrl), origin)).href;
|
||||
}
|
||||
|
||||
export function getSkillsIndexCandidateUrls({
|
||||
baseUrl,
|
||||
origin,
|
||||
pathname,
|
||||
documentBaseUrl,
|
||||
}: SkillsIndexUrlInput): string[] {
|
||||
const assetPath = 'skills.json';
|
||||
|
||||
return appendBackupCandidates(uniqueUrls([
|
||||
new URL(assetPath, getResolvedDocumentBaseUrl({ baseUrl, origin, documentBaseUrl })).href,
|
||||
new URL(assetPath, new URL(normalizeBasePath(baseUrl), origin)).href,
|
||||
`${origin}/${assetPath}`,
|
||||
...getPathCandidateUrls(pathname, assetPath, origin),
|
||||
]));
|
||||
}
|
||||
|
||||
export function getSkillMarkdownCandidateUrls({
|
||||
baseUrl,
|
||||
origin,
|
||||
pathname,
|
||||
documentBaseUrl,
|
||||
skillPath,
|
||||
}: SkillMarkdownUrlInput): string[] {
|
||||
const normalizedSkillPath = skillPath
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/\/SKILL\.md$/i, '');
|
||||
const assetPath = `${normalizedSkillPath}/SKILL.md`;
|
||||
|
||||
return uniqueUrls([
|
||||
new URL(assetPath, getResolvedDocumentBaseUrl({ baseUrl, origin, documentBaseUrl })).href,
|
||||
new URL(assetPath, new URL(normalizeBasePath(baseUrl), origin)).href,
|
||||
`${origin}/${assetPath}`,
|
||||
...getPathCandidateUrls(pathname, assetPath, origin),
|
||||
]);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SeoJsonLdValue, SeoMeta, TwitterCard, Skill } from '../types';
|
||||
import { getAbsolutePublicAssetUrl } from './publicAssetUrls';
|
||||
|
||||
export const APP_HOME_CATALOG_COUNT = 1326;
|
||||
export const DEFAULT_TOP_SKILL_COUNT = 40;
|
||||
export const DEFAULT_SOCIAL_IMAGE = 'social-card.svg';
|
||||
const SITE_NAME = 'Antigravity Awesome Skills';
|
||||
@@ -46,15 +46,17 @@ export function getCanonicalUrl(canonicalPath: string, siteBaseUrl?: string): st
|
||||
}
|
||||
|
||||
export function getAssetCanonicalUrl(canonicalPath: string): string {
|
||||
const baseUrl = import.meta.env.BASE_URL || '/';
|
||||
const normalizedBase = toCanonicalPath(baseUrl);
|
||||
const appBase = normalizedBase === '/' ? '' : normalizedBase;
|
||||
return `${window.location.origin}${appBase}${toCanonicalPath(canonicalPath)}`;
|
||||
return getAbsolutePublicAssetUrl(toCanonicalPath(canonicalPath), {
|
||||
baseUrl: import.meta.env.BASE_URL || '/',
|
||||
origin: window.location.origin,
|
||||
});
|
||||
}
|
||||
|
||||
export function getAbsoluteAssetUrl(assetPath: string): string {
|
||||
const normalizedAsset = toCanonicalPath(assetPath);
|
||||
return getAssetCanonicalUrl(normalizedAsset);
|
||||
return getAbsolutePublicAssetUrl(toCanonicalPath(assetPath), {
|
||||
baseUrl: import.meta.env.BASE_URL || '/',
|
||||
origin: window.location.origin,
|
||||
});
|
||||
}
|
||||
|
||||
function getCatalogBaseUrl(canonicalUrl: string): string {
|
||||
@@ -93,12 +95,15 @@ function buildWebSiteSchema(canonicalUrl: string): Record<string, unknown> {
|
||||
}
|
||||
|
||||
function buildSoftwareSourceCodeSchema(canonicalUrl: string, visibleCount: number): Record<string, unknown> {
|
||||
const visibleCountLabel = `${visibleCount.toLocaleString('en-US')}+`;
|
||||
const visibleCountLabel = visibleCount > 0
|
||||
? `${visibleCount.toLocaleString('en-US')} agentic skills`
|
||||
: 'agentic skills';
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareSourceCode',
|
||||
name: SITE_NAME,
|
||||
description: `Installable GitHub library of ${visibleCountLabel} agentic skills for AI coding assistants.`,
|
||||
description: `Installable GitHub library of ${visibleCountLabel} for AI coding assistants.`,
|
||||
url: canonicalUrl,
|
||||
codeRepository: 'https://github.com/sickn33/antigravity-awesome-skills',
|
||||
programmingLanguage: {
|
||||
@@ -275,10 +280,14 @@ export function isTopSkill(skillId: string, skills: ReadonlyArray<Skill>, limit
|
||||
}
|
||||
|
||||
export function buildHomeMeta(skillCount: number): SeoMeta {
|
||||
const visibleCount = Math.max(skillCount, APP_HOME_CATALOG_COUNT);
|
||||
const visibleCountLabel = `${visibleCount.toLocaleString('en-US')}+`;
|
||||
const title = `Antigravity Awesome Skills | ${visibleCountLabel} installable AI skills catalog`;
|
||||
const description = `Explore ${visibleCountLabel} installable agentic skills for Claude Code, Cursor, Codex CLI, Gemini CLI, and Antigravity. Browse bundles, workflows, FAQs, and integration guides in one place.`;
|
||||
const visibleCount = Math.max(skillCount, 0);
|
||||
const visibleCountLabel = visibleCount > 0
|
||||
? `${visibleCount.toLocaleString('en-US')} installable AI skills`
|
||||
: 'installable AI skills';
|
||||
const title = `Antigravity Awesome Skills | ${visibleCountLabel} catalog`;
|
||||
const description = visibleCount > 0
|
||||
? `Explore ${visibleCount.toLocaleString('en-US')} installable agentic skills for Claude Code, Cursor, Codex CLI, Gemini CLI, and Antigravity. Browse bundles, workflows, FAQs, and integration guides in one place.`
|
||||
: 'Explore installable agentic skills for Claude Code, Cursor, Codex CLI, Gemini CLI, and Antigravity. Browse bundles, workflows, FAQs, and integration guides in one place.';
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
|
||||
@@ -9,6 +9,28 @@ export default mergeConfig(
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
css: true,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html'],
|
||||
include: [
|
||||
'src/components/**/*.{ts,tsx}',
|
||||
'src/context/**/*.{ts,tsx}',
|
||||
'src/hooks/**/*.{ts,tsx}',
|
||||
'src/pages/**/*.{ts,tsx}',
|
||||
'src/utils/**/*.{ts,tsx}',
|
||||
],
|
||||
exclude: [
|
||||
'src/**/*.test.{ts,tsx}',
|
||||
'src/**/__tests__/**',
|
||||
'src/test/**',
|
||||
],
|
||||
thresholds: {
|
||||
statements: 75,
|
||||
lines: 75,
|
||||
functions: 70,
|
||||
branches: 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -37,6 +37,7 @@ This document summarizes the repository coherence audit performed after the `app
|
||||
- missing examples and missing limitations sections,
|
||||
- overly long `SKILL.md` files that should probably be split into `references/`,
|
||||
- plus the existing structural/safety checks (frontmatter, risk, `When to Use`, offensive disclaimer, dangling links).
|
||||
- The report also includes a non-blocking `suggested_risk` for skills that are still marked `unknown` or appear to be misclassified, so maintainers can resolve risk classification during PR review without changing the contributor gate.
|
||||
- Use `npm run audit:skills` for the maintainer view and `npm run audit:skills -- --json-out ... --markdown-out ...` when you want artifacts for triage or cleanup tracking.
|
||||
|
||||
### 3. Riferimenti incrociati
|
||||
|
||||
@@ -75,6 +75,16 @@ npm publish
|
||||
Normally this still happens via the existing GitHub release workflow after the GitHub release is published.
|
||||
That workflow now reruns `sync:release-state`, refreshes tracked web assets, fails on canonical drift via `git diff --exit-code`, executes tests and docs security checks, builds the web app, and dry-runs the npm package before `npm publish`.
|
||||
|
||||
## Canonical Sync Bot
|
||||
|
||||
`main` still uses the repository's auto-sync model for canonical generated artifacts, but with a narrow contract:
|
||||
|
||||
- PRs stay source-only.
|
||||
- After merge, the `main` workflow may commit generated canonical files directly to `main` with `[ci skip]`.
|
||||
- The bot commit is only allowed to stage files resolved from `tools/scripts/generated_files.js --include-mixed`.
|
||||
- If repo-state sync leaves any unmanaged tracked or untracked drift, the workflow fails instead of pushing a partial fix.
|
||||
- The scheduled hygiene workflow follows the same contract and shares the same concurrency group so only one canonical sync writer runs at a time.
|
||||
|
||||
## Rollback Notes
|
||||
|
||||
- If the release tag is wrong, delete the tag locally and remotely before republishing.
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
"update:skills": "node tools/scripts/run-python.js tools/scripts/generate_index.py && node tools/scripts/copy-file.js skills_index.json apps/web-app/public/skills.json && node tools/scripts/copy-file.js skills_index.json apps/web-app/public/skills.json.backup",
|
||||
"app:setup": "node tools/scripts/setup_web.js",
|
||||
"app:install": "cd apps/web-app && npm ci",
|
||||
"app:test": "cd apps/web-app && npm run test -- --run",
|
||||
"app:test:coverage": "cd apps/web-app && npm run test:coverage -- --run",
|
||||
"app:dev": "npm run app:setup && cd apps/web-app && npm run dev",
|
||||
"app:build": "npm run app:setup && cd apps/web-app && npm run build",
|
||||
"app:preview": "cd apps/web-app && npm run preview"
|
||||
|
||||
@@ -2,22 +2,47 @@
|
||||
"""Unpack and format XML contents of Office files (.docx, .pptx, .xlsx)"""
|
||||
|
||||
import random
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _is_zip_symlink(member: zipfile.ZipInfo) -> bool:
|
||||
return stat.S_ISLNK(member.external_attr >> 16)
|
||||
|
||||
|
||||
def _is_safe_destination(output_root: Path, member_name: str) -> bool:
|
||||
destination = output_root / member_name
|
||||
return destination.resolve().is_relative_to(output_root.resolve())
|
||||
|
||||
|
||||
def _extract_member(archive: zipfile.ZipFile, member: zipfile.ZipInfo, output_root: Path):
|
||||
destination = output_root / member.filename
|
||||
if member.is_dir():
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
return
|
||||
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with archive.open(member, "r") as source, open(destination, "wb") as target:
|
||||
shutil.copyfileobj(source, target)
|
||||
|
||||
|
||||
def extract_archive_safely(input_file: str | Path, output_dir: str | Path):
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
output_root = output_path.resolve()
|
||||
|
||||
with zipfile.ZipFile(input_file) as archive:
|
||||
for member in archive.infolist():
|
||||
destination = output_path / member.filename
|
||||
if not destination.resolve().is_relative_to(output_path.resolve()):
|
||||
if _is_zip_symlink(member):
|
||||
raise ValueError(f"Unsafe archive entry: {member.filename}")
|
||||
if not _is_safe_destination(output_root, member.filename):
|
||||
raise ValueError(f"Unsafe archive entry: {member.filename}")
|
||||
|
||||
archive.extractall(output_path)
|
||||
for member in archive.infolist():
|
||||
_extract_member(archive, member, output_path)
|
||||
|
||||
|
||||
def pretty_print_xml(output_path: Path):
|
||||
|
||||
@@ -2,22 +2,47 @@
|
||||
"""Unpack and format XML contents of Office files (.docx, .pptx, .xlsx)"""
|
||||
|
||||
import random
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _is_zip_symlink(member: zipfile.ZipInfo) -> bool:
|
||||
return stat.S_ISLNK(member.external_attr >> 16)
|
||||
|
||||
|
||||
def _is_safe_destination(output_root: Path, member_name: str) -> bool:
|
||||
destination = output_root / member_name
|
||||
return destination.resolve().is_relative_to(output_root.resolve())
|
||||
|
||||
|
||||
def _extract_member(archive: zipfile.ZipFile, member: zipfile.ZipInfo, output_root: Path):
|
||||
destination = output_root / member.filename
|
||||
if member.is_dir():
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
return
|
||||
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with archive.open(member, "r") as source, open(destination, "wb") as target:
|
||||
shutil.copyfileobj(source, target)
|
||||
|
||||
|
||||
def extract_archive_safely(input_file: str | Path, output_dir: str | Path):
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
output_root = output_path.resolve()
|
||||
|
||||
with zipfile.ZipFile(input_file) as archive:
|
||||
for member in archive.infolist():
|
||||
destination = output_path / member.filename
|
||||
if not destination.resolve().is_relative_to(output_path.resolve()):
|
||||
if _is_zip_symlink(member):
|
||||
raise ValueError(f"Unsafe archive entry: {member.filename}")
|
||||
if not _is_safe_destination(output_root, member.filename):
|
||||
raise ValueError(f"Unsafe archive entry: {member.filename}")
|
||||
|
||||
archive.extractall(output_path)
|
||||
for member in archive.infolist():
|
||||
_extract_member(archive, member, output_path)
|
||||
|
||||
|
||||
def pretty_print_xml(output_path: Path):
|
||||
|
||||
@@ -121,7 +121,7 @@ antigravity-awesome-skills — installer
|
||||
|
||||
npx antigravity-awesome-skills [install] [options]
|
||||
|
||||
Clones the skills repo into your agent's skills directory.
|
||||
Shallow-clones the skills repo into your agent's skills directory.
|
||||
|
||||
Options:
|
||||
--cursor Install to ~/.cursor/skills (Cursor)
|
||||
@@ -131,8 +131,8 @@ Options:
|
||||
--kiro Install to ~/.kiro/skills (Kiro CLI)
|
||||
--antigravity Install to ~/.gemini/antigravity/skills (Antigravity)
|
||||
--path <dir> Install to <dir> (default: ~/.gemini/antigravity/skills)
|
||||
--version <ver> After clone, checkout tag v<ver> (e.g. 4.6.0 -> v4.6.0)
|
||||
--tag <tag> After clone, checkout this tag (e.g. v4.6.0)
|
||||
--version <ver> Clone tag v<ver> (e.g. 4.6.0 -> v4.6.0)
|
||||
--tag <tag> Clone this tag or branch (e.g. v4.6.0)
|
||||
|
||||
Examples:
|
||||
npx antigravity-awesome-skills
|
||||
@@ -286,6 +286,15 @@ function run(cmd, args, opts = {}) {
|
||||
if (r.status !== 0) process.exit(r.status == null ? 1 : r.status);
|
||||
}
|
||||
|
||||
function buildCloneArgs(repo, tempDir, ref = null) {
|
||||
const args = ["clone", "--depth", "1"];
|
||||
if (ref) {
|
||||
args.push("--branch", ref);
|
||||
}
|
||||
args.push(repo, tempDir);
|
||||
return args;
|
||||
}
|
||||
|
||||
function installForTarget(tempDir, target) {
|
||||
if (fs.existsSync(target.path)) {
|
||||
ensureTargetIsDirectory(target.path);
|
||||
@@ -353,6 +362,13 @@ function getPostInstallMessages(targets) {
|
||||
function main() {
|
||||
const opts = parseArgs();
|
||||
const { tagArg, versionArg } = opts;
|
||||
const ref =
|
||||
tagArg ||
|
||||
(versionArg
|
||||
? versionArg.startsWith("v")
|
||||
? versionArg
|
||||
: `v${versionArg}`
|
||||
: null);
|
||||
|
||||
if (opts.help) {
|
||||
printHelp();
|
||||
@@ -372,21 +388,10 @@ function main() {
|
||||
|
||||
try {
|
||||
console.log("Cloning repository…");
|
||||
run("git", ["clone", REPO, tempDir]);
|
||||
|
||||
const ref =
|
||||
tagArg ||
|
||||
(versionArg
|
||||
? versionArg.startsWith("v")
|
||||
? versionArg
|
||||
: `v${versionArg}`
|
||||
: null);
|
||||
if (ref) {
|
||||
console.log(`Checking out ${ref}…`);
|
||||
process.chdir(tempDir);
|
||||
run("git", ["checkout", ref]);
|
||||
process.chdir(originalCwd);
|
||||
console.log(`Cloning repository at ${ref}…`);
|
||||
}
|
||||
run("git", buildCloneArgs(REPO, tempDir, ref));
|
||||
|
||||
console.log(`\nInstalling for ${targets.length} target(s):`);
|
||||
for (const target of targets) {
|
||||
@@ -419,6 +424,7 @@ if (require.main === module) {
|
||||
module.exports = {
|
||||
copyRecursiveSync,
|
||||
getPostInstallMessages,
|
||||
buildCloneArgs,
|
||||
getInstallEntries,
|
||||
installSkillsIntoTarget,
|
||||
installForTarget,
|
||||
|
||||
@@ -12,6 +12,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from _project_paths import find_repo_root
|
||||
from risk_classifier import suggest_risk
|
||||
from validate_skills import configure_utf8_output, has_when_to_use_section, parse_frontmatter
|
||||
|
||||
|
||||
@@ -34,6 +35,7 @@ SECURITY_DISCLAIMER_PATTERN = re.compile(r"AUTHORIZED USE ONLY", re.IGNORECASE)
|
||||
VALID_RISK_LEVELS = {"none", "safe", "critical", "offensive", "unknown"}
|
||||
DEFAULT_MARKDOWN_TOP_FINDINGS = 15
|
||||
DEFAULT_MARKDOWN_TOP_SKILLS = 20
|
||||
DEFAULT_MARKDOWN_TOP_RISK_SUGGESTIONS = 20
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -48,8 +50,6 @@ class Finding:
|
||||
"code": self.code,
|
||||
"message": self.message,
|
||||
}
|
||||
|
||||
|
||||
def has_examples(content: str) -> bool:
|
||||
return bool(FENCED_CODE_BLOCK_PATTERN.search(content)) or any(
|
||||
pattern.search(content) for pattern in EXAMPLES_HEADING_PATTERNS
|
||||
@@ -110,6 +110,7 @@ def build_skill_report(skill_root: Path, skills_dir: Path) -> dict[str, object]:
|
||||
risk = metadata.get("risk")
|
||||
source = metadata.get("source")
|
||||
date_added = metadata.get("date_added")
|
||||
risk_suggestion = suggest_risk(content, metadata)
|
||||
|
||||
if name != skill_root.name:
|
||||
findings.append(
|
||||
@@ -162,6 +163,17 @@ def build_skill_report(skill_root: Path, skills_dir: Path) -> dict[str, object]:
|
||||
)
|
||||
)
|
||||
|
||||
if risk_suggestion.risk not in ("unknown", "none"):
|
||||
risk_needs_review = risk is None or risk == "unknown" or risk != risk_suggestion.risk
|
||||
if risk_needs_review:
|
||||
findings.append(
|
||||
Finding(
|
||||
"info" if risk in (None, "unknown") else "warning",
|
||||
"risk_suggestion",
|
||||
f"Suggested risk is {risk_suggestion.risk} based on: {', '.join(risk_suggestion.reasons[:3])}.",
|
||||
)
|
||||
)
|
||||
|
||||
if source is None:
|
||||
findings.append(Finding("warning", "missing_source", "Missing source attribution."))
|
||||
|
||||
@@ -211,10 +223,25 @@ def build_skill_report(skill_root: Path, skills_dir: Path) -> dict[str, object]:
|
||||
)
|
||||
)
|
||||
|
||||
return finalize_skill_report(rel_dir, rel_file, findings)
|
||||
return finalize_skill_report(
|
||||
rel_dir,
|
||||
rel_file,
|
||||
findings,
|
||||
risk=risk,
|
||||
suggested_risk=risk_suggestion.risk,
|
||||
suggested_risk_reasons=list(risk_suggestion.reasons),
|
||||
)
|
||||
|
||||
|
||||
def finalize_skill_report(skill_id: str, rel_file: str, findings: list[Finding]) -> dict[str, object]:
|
||||
def finalize_skill_report(
|
||||
skill_id: str,
|
||||
rel_file: str,
|
||||
findings: list[Finding],
|
||||
*,
|
||||
risk: str | None = None,
|
||||
suggested_risk: str = "unknown",
|
||||
suggested_risk_reasons: list[str] | None = None,
|
||||
) -> dict[str, object]:
|
||||
severity_counts = Counter(finding.severity for finding in findings)
|
||||
if severity_counts["error"] > 0:
|
||||
status = "error"
|
||||
@@ -230,6 +257,9 @@ def finalize_skill_report(skill_id: str, rel_file: str, findings: list[Finding])
|
||||
"error_count": severity_counts["error"],
|
||||
"warning_count": severity_counts["warning"],
|
||||
"info_count": severity_counts["info"],
|
||||
"risk": risk,
|
||||
"suggested_risk": suggested_risk,
|
||||
"suggested_risk_reasons": suggested_risk_reasons or [],
|
||||
"findings": [finding.to_dict() for finding in findings],
|
||||
}
|
||||
|
||||
@@ -250,19 +280,30 @@ def audit_skills(skills_dir: str | Path) -> dict[str, object]:
|
||||
|
||||
code_counts = Counter()
|
||||
severity_counts = Counter()
|
||||
risk_suggestion_counts = Counter()
|
||||
for report in reports:
|
||||
for finding in report["findings"]:
|
||||
code_counts[finding["code"]] += 1
|
||||
severity_counts[finding["severity"]] += 1
|
||||
if report["suggested_risk"] not in (None, "unknown", "none"):
|
||||
risk_suggestion_counts[report["suggested_risk"]] += 1
|
||||
|
||||
summary = {
|
||||
"skills_scanned": len(reports),
|
||||
"skills_ok": sum(report["status"] == "ok" for report in reports),
|
||||
"skills_with_errors": sum(report["status"] == "error" for report in reports),
|
||||
"skills_with_warnings_only": sum(report["status"] == "warning" for report in reports),
|
||||
"skills_with_suggested_risk": sum(
|
||||
report["suggested_risk"] not in ("unknown", "none")
|
||||
for report in reports
|
||||
),
|
||||
"errors": severity_counts["error"],
|
||||
"warnings": severity_counts["warning"],
|
||||
"infos": severity_counts["info"],
|
||||
"risk_suggestions": [
|
||||
{"risk": risk, "count": count}
|
||||
for risk, count in risk_suggestion_counts.most_common()
|
||||
],
|
||||
"top_finding_codes": [
|
||||
{"code": code, "count": count}
|
||||
for code, count in code_counts.most_common()
|
||||
@@ -284,6 +325,15 @@ def write_markdown_report(report: dict[str, object], destination: str | Path) ->
|
||||
top_skills = [
|
||||
skill for skill in skills if skill["status"] != "ok"
|
||||
][:DEFAULT_MARKDOWN_TOP_SKILLS]
|
||||
risk_suggestions = [
|
||||
skill
|
||||
for skill in skills
|
||||
if skill.get("suggested_risk") not in (None, "unknown", "none")
|
||||
and (
|
||||
skill.get("risk") in (None, "unknown")
|
||||
or skill.get("risk") != skill.get("suggested_risk")
|
||||
)
|
||||
][:DEFAULT_MARKDOWN_TOP_RISK_SUGGESTIONS]
|
||||
|
||||
lines = [
|
||||
"# Skills Audit Report",
|
||||
@@ -296,14 +346,27 @@ def write_markdown_report(report: dict[str, object], destination: str | Path) ->
|
||||
f"- Skills ready: **{summary['skills_ok']}**",
|
||||
f"- Skills with errors: **{summary['skills_with_errors']}**",
|
||||
f"- Skills with warnings only: **{summary['skills_with_warnings_only']}**",
|
||||
f"- Skills with suggested risk: **{summary['skills_with_suggested_risk']}**",
|
||||
f"- Total errors: **{summary['errors']}**",
|
||||
f"- Total warnings: **{summary['warnings']}**",
|
||||
f"- Total info findings: **{summary['infos']}**",
|
||||
]
|
||||
|
||||
if summary.get("risk_suggestions"):
|
||||
summary_text = ", ".join(
|
||||
f"{item['risk']}: {item['count']}" for item in summary["risk_suggestions"]
|
||||
)
|
||||
lines.append(f"- Suggested risks: **{summary_text}**")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Top Finding Codes",
|
||||
"",
|
||||
"| Code | Count |",
|
||||
"| --- | ---: |",
|
||||
]
|
||||
)
|
||||
|
||||
if top_findings:
|
||||
lines.extend(f"| `{item['code']}` | {item['count']} |" for item in top_findings)
|
||||
@@ -328,6 +391,24 @@ def write_markdown_report(report: dict[str, object], destination: str | Path) ->
|
||||
else:
|
||||
lines.append("| _none_ | ok | 0 | 0 |")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Risk Suggestions",
|
||||
"",
|
||||
"| Skill | Current | Suggested | Why |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
|
||||
if risk_suggestions:
|
||||
lines.extend(
|
||||
f"| `{skill['id']}` | {skill.get('risk') or 'unknown'} | {skill.get('suggested_risk') or 'unknown'} | {', '.join(skill.get('suggested_risk_reasons', [])[:3]) or '_n/a_'} |"
|
||||
for skill in risk_suggestions
|
||||
)
|
||||
else:
|
||||
lines.append("| _none_ | _none_ | _none_ | _n/a_ |")
|
||||
|
||||
Path(destination).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
@@ -338,8 +419,16 @@ def print_summary(report: dict[str, object]) -> None:
|
||||
print(f" Ready: {summary['skills_ok']}")
|
||||
print(f" Warning only: {summary['skills_with_warnings_only']}")
|
||||
print(f" With errors: {summary['skills_with_errors']}")
|
||||
print(f" With suggested risk: {summary['skills_with_suggested_risk']}")
|
||||
print(f" Total warnings: {summary['warnings']}")
|
||||
print(f" Total errors: {summary['errors']}")
|
||||
print(f" Total info findings: {summary['infos']}")
|
||||
if summary.get("risk_suggestions"):
|
||||
risk_summary = ", ".join(
|
||||
f"{item['risk']}: {item['count']}"
|
||||
for item in summary["risk_suggestions"]
|
||||
)
|
||||
print(f" Suggested risks: {risk_summary}")
|
||||
|
||||
top_findings = summary["top_finding_codes"][:10]
|
||||
if top_findings:
|
||||
|
||||
@@ -815,7 +815,7 @@ def parse_frontmatter(content):
|
||||
Parses YAML frontmatter, sanitizing unquoted values containing @.
|
||||
Handles single values and comma-separated lists by quoting the entire line.
|
||||
"""
|
||||
fm_match = re.search(r'^---\s*\n(.*?)\n---', content, re.DOTALL)
|
||||
fm_match = re.search(r'^---\s*\n(.*?)\n?---(?:\s*\n|$)', content, re.DOTALL)
|
||||
if not fm_match:
|
||||
return {}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
from _project_paths import find_repo_root
|
||||
from risk_classifier import suggest_risk
|
||||
|
||||
def get_project_root():
|
||||
"""Get the project root directory."""
|
||||
@@ -32,9 +33,10 @@ def parse_frontmatter(content):
|
||||
except yaml.YAMLError:
|
||||
return None
|
||||
|
||||
def generate_skills_report(output_file=None, sort_by='date'):
|
||||
def generate_skills_report(output_file=None, sort_by='date', project_root=None):
|
||||
"""Generate a report of all skills with their metadata."""
|
||||
skills_dir = os.path.join(get_project_root(), 'skills')
|
||||
root = str(project_root or get_project_root())
|
||||
skills_dir = os.path.join(root, 'skills')
|
||||
skills_data = []
|
||||
|
||||
for root, dirs, files in os.walk(skills_dir):
|
||||
@@ -53,13 +55,21 @@ def generate_skills_report(output_file=None, sort_by='date'):
|
||||
if metadata is None:
|
||||
continue
|
||||
|
||||
suggested_risk = suggest_risk(content, metadata)
|
||||
|
||||
date_added = metadata.get('date_added', None)
|
||||
if date_added is not None:
|
||||
date_added = date_added.isoformat() if hasattr(date_added, 'isoformat') else str(date_added)
|
||||
|
||||
skill_info = {
|
||||
'id': metadata.get('id', skill_name),
|
||||
'name': metadata.get('name', skill_name),
|
||||
'description': metadata.get('description', ''),
|
||||
'date_added': metadata.get('date_added', None),
|
||||
'date_added': date_added,
|
||||
'source': metadata.get('source', 'unknown'),
|
||||
'risk': metadata.get('risk', 'unknown'),
|
||||
'suggested_risk': suggested_risk.risk,
|
||||
'suggested_risk_reasons': list(suggested_risk.reasons),
|
||||
'category': metadata.get('category', metadata.get('id', '').split('-')[0] if '-' in metadata.get('id', '') else 'other'),
|
||||
}
|
||||
|
||||
@@ -80,6 +90,11 @@ def generate_skills_report(output_file=None, sort_by='date'):
|
||||
'total_skills': len(skills_data),
|
||||
'skills_with_dates': sum(1 for s in skills_data if s['date_added']),
|
||||
'skills_without_dates': sum(1 for s in skills_data if not s['date_added']),
|
||||
'skills_with_suggested_risk': sum(1 for s in skills_data if s['suggested_risk'] != 'unknown'),
|
||||
'suggested_risk_counts': {
|
||||
risk: sum(1 for skill in skills_data if skill['suggested_risk'] == risk)
|
||||
for risk in sorted({skill['suggested_risk'] for skill in skills_data if skill['suggested_risk'] != 'unknown'})
|
||||
},
|
||||
'coverage_percentage': round(
|
||||
sum(1 for s in skills_data if s['date_added']) / len(skills_data) * 100 if skills_data else 0,
|
||||
1
|
||||
|
||||
99
tools/scripts/risk_classifier.py
Normal file
99
tools/scripts/risk_classifier.py
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RiskSuggestion:
|
||||
risk: str
|
||||
reasons: tuple[str, ...]
|
||||
|
||||
|
||||
OFFENSIVE_HINTS = [
|
||||
(re.compile(r"AUTHORIZED USE ONLY", re.IGNORECASE), "explicit offensive disclaimer"),
|
||||
(
|
||||
re.compile(
|
||||
r"\b(?:pentest(?:ing)?|penetration testing|red team(?:ing)?|exploit(?:ation)?|malware|phishing|sql injection|xss|csrf|jailbreak|sandbox escape|credential theft|exfiltrat\w*|prompt injection)\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"offensive security language",
|
||||
),
|
||||
]
|
||||
|
||||
CRITICAL_HINTS = [
|
||||
(re.compile(r"\bcurl\b[^\n]*\|\s*(?:bash|sh)\b", re.IGNORECASE), "curl pipes into a shell"),
|
||||
(re.compile(r"\bwget\b[^\n]*\|\s*(?:bash|sh)\b", re.IGNORECASE), "wget pipes into a shell"),
|
||||
(re.compile(r"\birm\b[^\n]*\|\s*iex\b", re.IGNORECASE), "PowerShell invoke-expression"),
|
||||
(re.compile(r"\brm\s+-rf\b", re.IGNORECASE), "destructive filesystem delete"),
|
||||
(re.compile(r"\bgit\s+(?:commit|push|merge|reset)\b", re.IGNORECASE), "git mutation"),
|
||||
(re.compile(r"\b(?:npm|pnpm|yarn|bun)\s+publish\b", re.IGNORECASE), "package publication"),
|
||||
(re.compile(r"\b(?:kubectl\s+apply|terraform\s+apply|ansible-playbook|docker\s+push)\b", re.IGNORECASE), "deployment or infrastructure mutation"),
|
||||
(
|
||||
re.compile(r"\b(?:POST|PUT|PATCH|DELETE)\b", re.IGNORECASE),
|
||||
"mutating HTTP verb",
|
||||
),
|
||||
(
|
||||
re.compile(r"\b(?:insert|update|upsert|delete|drop|truncate|alter)\b", re.IGNORECASE),
|
||||
"state-changing data operation",
|
||||
),
|
||||
(
|
||||
re.compile(r"\b(?:api key|api[_ -]?key|token|secret|password|bearer token|oauth token)\b", re.IGNORECASE),
|
||||
"secret or token handling",
|
||||
),
|
||||
(
|
||||
re.compile(
|
||||
r"\b(?:write|overwrite|append|create|modify|remove|rename|move)\b[^\n]{0,60}\b(?:file|files|directory|repo|repository|config|skill|document|artifact|database|table|record|row|branch|release|production|server|endpoint|resource)\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"state-changing instruction",
|
||||
),
|
||||
]
|
||||
|
||||
SAFE_HINTS = [
|
||||
(
|
||||
re.compile(
|
||||
r"\b(?:echo|cat|ls|rg|grep|find|sed\s+-n|git\s+status|git\s+diff|pytest|npm\s+test|ruff|eslint|tsc)\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"non-mutating command example",
|
||||
),
|
||||
(re.compile(r"^```", re.MULTILINE), "contains fenced examples"),
|
||||
(
|
||||
re.compile(r"\b(?:read|inspect|analyze|audit|validate|test|search|summarize|monitor|review|list|fetch|get|query|lint)\b", re.IGNORECASE),
|
||||
"read-only or diagnostic language",
|
||||
),
|
||||
(
|
||||
re.compile(r"\b(?:api|http|graphql|webhook|endpoint|cli|sdk|docs?|database|log|logs)\b", re.IGNORECASE),
|
||||
"technical or integration language",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _collect_reasons(text: str, patterns: list[tuple[re.Pattern[str], str]]) -> list[str]:
|
||||
return [reason for pattern, reason in patterns if pattern.search(text)]
|
||||
|
||||
|
||||
def suggest_risk(content: str, metadata: Mapping[str, object] | None = None) -> RiskSuggestion:
|
||||
text = content if isinstance(content, str) else str(content or "")
|
||||
if metadata:
|
||||
if isinstance(metadata.get("description"), str):
|
||||
text = f"{metadata['description']}\n{text}"
|
||||
if isinstance(metadata.get("name"), str):
|
||||
text = f"{metadata['name']}\n{text}"
|
||||
|
||||
offensive_reasons = _collect_reasons(text, OFFENSIVE_HINTS)
|
||||
if offensive_reasons:
|
||||
return RiskSuggestion("offensive", tuple(offensive_reasons))
|
||||
|
||||
critical_reasons = _collect_reasons(text, CRITICAL_HINTS)
|
||||
if critical_reasons:
|
||||
return RiskSuggestion("critical", tuple(critical_reasons))
|
||||
|
||||
safe_reasons = _collect_reasons(text, SAFE_HINTS)
|
||||
if safe_reasons:
|
||||
return RiskSuggestion("safe", tuple(safe_reasons))
|
||||
|
||||
return RiskSuggestion("none", ())
|
||||
@@ -207,6 +207,9 @@ def find_plugin_skills(source_dir: Path, already_synced_names: set):
|
||||
skill_dir = skill_file.parent
|
||||
skill_name = skill_dir.name
|
||||
|
||||
if not is_safe_regular_file(skill_file, source_dir):
|
||||
continue
|
||||
|
||||
if skill_name not in already_synced_names:
|
||||
results.append({
|
||||
"relative_path": Path("plugins") / skill_name,
|
||||
|
||||
@@ -98,6 +98,31 @@ assert.match(
|
||||
/GH_TOKEN: \$\{\{ github\.token \}\}/,
|
||||
"main CI should provide GH_TOKEN for contributor synchronization",
|
||||
);
|
||||
assert.match(
|
||||
ciWorkflow,
|
||||
/main-validation-and-sync:[\s\S]*?concurrency:[\s\S]*?group: canonical-main-sync[\s\S]*?cancel-in-progress: false/,
|
||||
"main validation should serialize canonical sync writers",
|
||||
);
|
||||
assert.match(
|
||||
ciWorkflow,
|
||||
/pip install -r tools\/requirements\.txt/g,
|
||||
"CI workflows should install Python dependencies from tools/requirements.txt",
|
||||
);
|
||||
assert.match(
|
||||
ciWorkflow,
|
||||
/- name: Audit npm dependencies[\s\S]*?run: npm audit --audit-level=high/,
|
||||
"CI should run npm audit at high severity",
|
||||
);
|
||||
assert.match(
|
||||
ciWorkflow,
|
||||
/main-validation-and-sync:[\s\S]*?- name: Audit npm dependencies[\s\S]*?run: npm audit --audit-level=high/,
|
||||
"main validation should enforce npm audit before syncing canonical state",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
ciWorkflow,
|
||||
/main-validation-and-sync:[\s\S]*?continue-on-error: true/,
|
||||
"main validation should not treat high-severity npm audit findings as non-blocking",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
ciWorkflow,
|
||||
/^ - name: Generate index$/m,
|
||||
@@ -113,16 +138,46 @@ assert.doesNotMatch(
|
||||
/^ - name: Build catalog$/m,
|
||||
"main CI should not keep the old standalone Build catalog step",
|
||||
);
|
||||
assert.match(
|
||||
ciWorkflow,
|
||||
/git commit -m "chore: sync repo state \[ci skip\]"/,
|
||||
"main CI should keep bot-generated canonical sync commits out of the normal CI loop",
|
||||
);
|
||||
assert.match(
|
||||
ciWorkflow,
|
||||
/git ls-files --others --exclude-standard/,
|
||||
"main CI should fail if canonical sync leaves unmanaged untracked drift",
|
||||
);
|
||||
assert.match(
|
||||
ciWorkflow,
|
||||
/git diff --name-only/,
|
||||
"main CI should fail if canonical sync leaves unmanaged tracked drift",
|
||||
);
|
||||
|
||||
assert.ok(fs.existsSync(hygieneWorkflowPath), "repo hygiene workflow should exist");
|
||||
|
||||
const hygieneWorkflow = readText(".github/workflows/repo-hygiene.yml");
|
||||
assert.match(hygieneWorkflow, /^on:\n workflow_dispatch:\n schedule:/m, "repo hygiene workflow should support schedule and manual runs");
|
||||
assert.match(
|
||||
hygieneWorkflow,
|
||||
/concurrency:\n\s+group: canonical-main-sync\n\s+cancel-in-progress: false/,
|
||||
"repo hygiene workflow should serialize canonical sync writers with main CI",
|
||||
);
|
||||
assert.match(
|
||||
hygieneWorkflow,
|
||||
/GH_TOKEN: \$\{\{ github\.token \}\}/,
|
||||
"repo hygiene workflow should provide GH_TOKEN for gh-based contributor sync",
|
||||
);
|
||||
assert.match(
|
||||
hygieneWorkflow,
|
||||
/pip install -r tools\/requirements\.txt/,
|
||||
"repo hygiene workflow should install Python dependencies from tools/requirements.txt",
|
||||
);
|
||||
assert.match(
|
||||
hygieneWorkflow,
|
||||
/run: npm audit --audit-level=high/,
|
||||
"repo hygiene workflow should block on high-severity npm audit findings before syncing",
|
||||
);
|
||||
assert.match(
|
||||
hygieneWorkflow,
|
||||
/run: npm run sync:repo-state/,
|
||||
@@ -133,8 +188,33 @@ assert.match(
|
||||
/generated_files\.js --include-mixed/,
|
||||
"repo hygiene workflow should resolve and stage the mixed generated files contract",
|
||||
);
|
||||
assert.match(
|
||||
hygieneWorkflow,
|
||||
/git commit -m "chore: scheduled repo hygiene sync \[ci skip\]"/,
|
||||
"repo hygiene workflow should keep bot-generated sync commits out of the normal CI loop",
|
||||
);
|
||||
assert.match(
|
||||
hygieneWorkflow,
|
||||
/git ls-files --others --exclude-standard/,
|
||||
"repo hygiene workflow should fail if canonical sync leaves unmanaged untracked drift",
|
||||
);
|
||||
assert.match(
|
||||
hygieneWorkflow,
|
||||
/git diff --name-only/,
|
||||
"repo hygiene workflow should fail if canonical sync leaves unmanaged tracked drift",
|
||||
);
|
||||
|
||||
assert.match(publishWorkflow, /run: npm ci/, "npm publish workflow should install dependencies");
|
||||
assert.match(
|
||||
publishWorkflow,
|
||||
/pip install -r tools\/requirements\.txt/,
|
||||
"npm publish workflow should install Python dependencies from tools/requirements.txt",
|
||||
);
|
||||
assert.match(
|
||||
publishWorkflow,
|
||||
/run: npm audit --audit-level=high/,
|
||||
"npm publish workflow should block on high-severity npm audit findings",
|
||||
);
|
||||
assert.match(
|
||||
publishWorkflow,
|
||||
/run: npm run app:install/,
|
||||
|
||||
@@ -3,6 +3,18 @@ const path = require("path");
|
||||
|
||||
const installer = require(path.resolve(__dirname, "..", "..", "bin", "install.js"));
|
||||
|
||||
assert.deepStrictEqual(
|
||||
installer.buildCloneArgs("https://example.com/repo.git", "/tmp/skills"),
|
||||
["clone", "--depth", "1", "https://example.com/repo.git", "/tmp/skills"],
|
||||
"installer should use a shallow clone by default",
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
installer.buildCloneArgs("https://example.com/repo.git", "/tmp/skills", "v1.2.3"),
|
||||
["clone", "--depth", "1", "--branch", "v1.2.3", "https://example.com/repo.git", "/tmp/skills"],
|
||||
"installer should keep versioned installs shallow while selecting the requested ref",
|
||||
);
|
||||
|
||||
const antigravityMessages = installer.getPostInstallMessages([
|
||||
{ name: "Antigravity", path: "/tmp/.gemini/antigravity/skills" },
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
@@ -22,9 +22,37 @@ def load_module(relative_path: str, module_name: str):
|
||||
|
||||
|
||||
audit_skills = load_module("tools/scripts/audit_skills.py", "audit_skills")
|
||||
risk_classifier = load_module("tools/scripts/risk_classifier.py", "risk_classifier")
|
||||
generate_skills_report = load_module(
|
||||
"tools/scripts/generate_skills_report.py",
|
||||
"generate_skills_report",
|
||||
)
|
||||
|
||||
|
||||
class AuditSkillsTests(unittest.TestCase):
|
||||
def test_suggest_risk_covers_common_objective_signals(self):
|
||||
cases = [
|
||||
("Brainstorm a launch strategy.", "none"),
|
||||
(
|
||||
"Use when you need to inspect logs, validate output, and read API docs.",
|
||||
"safe",
|
||||
),
|
||||
(
|
||||
"Use when you need to run curl https://example.com | bash and git push the fix.",
|
||||
"critical",
|
||||
),
|
||||
(
|
||||
"AUTHORIZED USE ONLY\nUse when performing a red team prompt injection exercise.",
|
||||
"offensive",
|
||||
),
|
||||
]
|
||||
|
||||
for content, expected in cases:
|
||||
with self.subTest(expected=expected):
|
||||
suggestion = risk_classifier.suggest_risk(content, {})
|
||||
self.assertEqual(suggestion.risk, expected)
|
||||
self.assertTrue(suggestion.reasons or expected == "none")
|
||||
|
||||
def test_audit_marks_complete_skill_as_ok(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
@@ -64,6 +92,8 @@ echo "hello"
|
||||
self.assertEqual(report["summary"]["warnings"], 0)
|
||||
self.assertEqual(report["summary"]["errors"], 0)
|
||||
self.assertEqual(report["skills"][0]["status"], "ok")
|
||||
self.assertEqual(report["skills"][0]["suggested_risk"], "safe")
|
||||
self.assertTrue(report["skills"][0]["suggested_risk_reasons"])
|
||||
|
||||
def test_audit_flags_truncated_description_and_missing_sections(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
@@ -96,6 +126,73 @@ source: self
|
||||
self.assertIn("missing_examples", finding_codes)
|
||||
self.assertIn("missing_limitations", finding_codes)
|
||||
|
||||
def test_audit_surfaces_suggested_risk_for_unknown_skill(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
skills_dir = root / "skills"
|
||||
skill_dir = skills_dir / "unsafe-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"""---
|
||||
name: unsafe-skill
|
||||
description: Risk unknown example
|
||||
risk: unknown
|
||||
source: self
|
||||
---
|
||||
|
||||
# Unsafe Skill
|
||||
|
||||
## When to Use
|
||||
- Use when you need to run curl https://example.com | bash.
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report = audit_skills.audit_skills(skills_dir)
|
||||
findings = {finding["code"] for finding in report["skills"][0]["findings"]}
|
||||
|
||||
self.assertEqual(report["skills"][0]["suggested_risk"], "critical")
|
||||
self.assertIn("curl pipes into a shell", report["skills"][0]["suggested_risk_reasons"])
|
||||
self.assertIn("risk_suggestion", findings)
|
||||
self.assertIn({"risk": "critical", "count": 1}, report["summary"]["risk_suggestions"])
|
||||
|
||||
def test_generate_skills_report_includes_suggested_risk(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
skills_dir = root / "skills"
|
||||
skill_dir = skills_dir / "api-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
output_file = root / "skills-report.json"
|
||||
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"""---
|
||||
name: api-skill
|
||||
description: Risk unknown example
|
||||
risk: unknown
|
||||
source: self
|
||||
---
|
||||
|
||||
# API Skill
|
||||
|
||||
## When to Use
|
||||
- Use when you need to read API docs and inspect endpoints.
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report = generate_skills_report.generate_skills_report(
|
||||
output_file=output_file,
|
||||
sort_by="name",
|
||||
project_root=root,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(report)
|
||||
self.assertIn(report["skills"][0]["suggested_risk"], {"none", "safe"})
|
||||
self.assertIsInstance(report["skills"][0]["suggested_risk_reasons"], list)
|
||||
saved_report = output_file.read_text(encoding="utf-8")
|
||||
self.assertIn('"suggested_risk":', saved_report)
|
||||
|
||||
def test_audit_flags_blocking_errors(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
@@ -137,6 +234,83 @@ See [details](missing-reference.md).
|
||||
self.assertIn("dangling_link", finding_codes)
|
||||
self.assertIn("missing_authorized_use_only", finding_codes)
|
||||
|
||||
def test_audit_suggests_risk_without_blocking_unknown(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
skills_dir = root / "skills"
|
||||
safe_skill = skills_dir / "analysis-skill"
|
||||
mismatch_skill = skills_dir / "review-skill"
|
||||
safe_skill.mkdir(parents=True)
|
||||
mismatch_skill.mkdir(parents=True)
|
||||
|
||||
(safe_skill / "SKILL.md").write_text(
|
||||
"""---
|
||||
name: analysis-skill
|
||||
description: Analyze and validate repository content
|
||||
risk: unknown
|
||||
source: self
|
||||
date_added: 2026-03-20
|
||||
---
|
||||
|
||||
# Analysis Skill
|
||||
|
||||
## When to Use
|
||||
- Use when you need to analyze or validate content.
|
||||
|
||||
## Examples
|
||||
- Inspect the repository content and validate findings.
|
||||
|
||||
## Limitations
|
||||
- Read-only.
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
(mismatch_skill / "SKILL.md").write_text(
|
||||
"""---
|
||||
name: review-skill
|
||||
description: Review prompt injection scenarios
|
||||
risk: safe
|
||||
source: self
|
||||
date_added: 2026-03-20
|
||||
---
|
||||
|
||||
# Review Skill
|
||||
|
||||
## When to Use
|
||||
- Use when you need to test prompt injection defenses.
|
||||
|
||||
## Examples
|
||||
```bash
|
||||
echo "prompt injection"
|
||||
```
|
||||
|
||||
## Limitations
|
||||
- Demo only.
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report = audit_skills.audit_skills(skills_dir)
|
||||
by_id = {skill["id"]: skill for skill in report["skills"]}
|
||||
analysis_findings = {finding["code"] for finding in by_id["analysis-skill"]["findings"]}
|
||||
review_findings = {finding["code"] for finding in by_id["review-skill"]["findings"]}
|
||||
|
||||
self.assertEqual(by_id["analysis-skill"]["status"], "ok")
|
||||
self.assertEqual(by_id["analysis-skill"]["suggested_risk"], "safe")
|
||||
self.assertIn("risk_suggestion", analysis_findings)
|
||||
self.assertEqual(by_id["review-skill"]["status"], "warning")
|
||||
self.assertEqual(by_id["review-skill"]["suggested_risk"], "offensive")
|
||||
self.assertIn("risk_suggestion", review_findings)
|
||||
|
||||
markdown_path = root / "audit.md"
|
||||
audit_skills.write_markdown_report(report, markdown_path)
|
||||
markdown = markdown_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("## Risk Suggestions", markdown)
|
||||
self.assertIn("analysis-skill", markdown)
|
||||
self.assertIn("review-skill", markdown)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -38,6 +38,23 @@ class FrontmatterParsingSecurityTests(unittest.TestCase):
|
||||
self.assertIsNone(metadata)
|
||||
self.assertTrue(any("mapping" in error.lower() for error in errors))
|
||||
|
||||
def test_validate_skills_empty_frontmatter_is_schema_checked(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
skills_dir = root / "skills"
|
||||
skill_dir = skills_dir / "demo"
|
||||
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("---\n---\n# Demo\n", encoding="utf-8")
|
||||
|
||||
results = validate_skills.collect_validation_results(str(skills_dir))
|
||||
|
||||
self.assertTrue(any("Missing 'name'" in error for error in results["errors"]))
|
||||
self.assertTrue(any("Missing 'description'" in error for error in results["errors"]))
|
||||
self.assertFalse(
|
||||
any("Missing or malformed YAML frontmatter" in error for error in results["errors"])
|
||||
)
|
||||
|
||||
def test_validate_skills_normalizes_unquoted_yaml_dates(self):
|
||||
content = "---\nname: demo\ndescription: ok\ndate_added: 2026-03-15\n---\nbody\n"
|
||||
metadata, errors = validate_skills.parse_frontmatter(content)
|
||||
|
||||
@@ -2,6 +2,7 @@ import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import stat
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -20,8 +21,13 @@ def load_module(relative_path: str, module_name: str):
|
||||
|
||||
class OfficeUnpackSecurityTests(unittest.TestCase):
|
||||
def test_extract_archive_safely_blocks_zip_slip(self):
|
||||
module = load_module("skills/docx/ooxml/scripts/unpack.py", "docx_unpack")
|
||||
for relative_path, module_name in [
|
||||
("skills/docx-official/ooxml/scripts/unpack.py", "docx_unpack"),
|
||||
("skills/pptx-official/ooxml/scripts/unpack.py", "pptx_unpack"),
|
||||
]:
|
||||
module = load_module(relative_path, module_name)
|
||||
|
||||
with self.subTest(module=relative_path):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
archive_path = temp_path / "payload.zip"
|
||||
@@ -36,6 +42,31 @@ class OfficeUnpackSecurityTests(unittest.TestCase):
|
||||
|
||||
self.assertFalse((temp_path / "escape.txt").exists())
|
||||
|
||||
def test_extract_archive_safely_blocks_zip_symlinks(self):
|
||||
for relative_path, module_name in [
|
||||
("skills/docx-official/ooxml/scripts/unpack.py", "docx_unpack_symlink"),
|
||||
("skills/pptx-official/ooxml/scripts/unpack.py", "pptx_unpack_symlink"),
|
||||
]:
|
||||
module = load_module(relative_path, module_name)
|
||||
|
||||
with self.subTest(module=relative_path):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
archive_path = temp_path / "payload.zip"
|
||||
output_dir = temp_path / "output"
|
||||
|
||||
with zipfile.ZipFile(archive_path, "w") as archive:
|
||||
symlink_info = zipfile.ZipInfo("word/link")
|
||||
symlink_info.create_system = 3
|
||||
symlink_info.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
archive.writestr(symlink_info, "../escape.txt")
|
||||
archive.writestr("word/document.xml", "<w:document/>")
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
module.extract_archive_safely(archive_path, output_dir)
|
||||
|
||||
self.assertFalse((temp_path / "escape.txt").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -95,6 +95,33 @@ class SyncMicrosoftSkillsSecurityTests(unittest.TestCase):
|
||||
target.unlink()
|
||||
outside.rmdir()
|
||||
|
||||
def test_find_plugin_skills_ignores_symlinked_skill_markdown(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
github_plugins = root / ".github" / "plugins"
|
||||
github_plugins.mkdir(parents=True)
|
||||
|
||||
safe_plugin = github_plugins / "safe-plugin"
|
||||
safe_plugin.mkdir()
|
||||
(safe_plugin / "SKILL.md").write_text("---\nname: safe-plugin\n---\n", encoding="utf-8")
|
||||
|
||||
linked_plugin = github_plugins / "linked-plugin"
|
||||
linked_plugin.mkdir()
|
||||
|
||||
outside = Path(tempfile.mkdtemp())
|
||||
try:
|
||||
target = outside / "SKILL.md"
|
||||
target.write_text("---\nname: escaped\n---\n", encoding="utf-8")
|
||||
(linked_plugin / "SKILL.md").symlink_to(target)
|
||||
|
||||
entries = sms.find_plugin_skills(root, set())
|
||||
relative_paths = {str(entry["relative_path"]) for entry in entries}
|
||||
|
||||
self.assertEqual(relative_paths, {"plugins/safe-plugin"})
|
||||
finally:
|
||||
target.unlink()
|
||||
outside.rmdir()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
26
tools/scripts/tests/web_app_readme.test.js
Normal file
26
tools/scripts/tests/web_app_readme.test.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..", "..", "..");
|
||||
const readme = fs.readFileSync(path.join(repoRoot, "apps", "web-app", "README.md"), "utf8");
|
||||
|
||||
assert.doesNotMatch(
|
||||
readme,
|
||||
/^# React \+ Vite$/m,
|
||||
"web app README should be project-specific, not the default Vite template",
|
||||
);
|
||||
|
||||
for (const section of [
|
||||
"## What This App Does",
|
||||
"## Development",
|
||||
"## Environment Variables",
|
||||
"## Deploy Model",
|
||||
"## Testing",
|
||||
]) {
|
||||
assert.match(
|
||||
readme,
|
||||
new RegExp(`^${section.replace(/[.*+?^${}()|[\]\\\\]/g, "\\$&")}$`, "m"),
|
||||
`web app README should document ${section}`,
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ def parse_frontmatter(content, rel_path=None):
|
||||
Parse frontmatter using PyYAML for robustness.
|
||||
Returns a dict of key-values and a list of error messages.
|
||||
"""
|
||||
fm_match = re.search(r'^---\s*\n(.*?)\n---', content, re.DOTALL)
|
||||
fm_match = re.search(r'^---\s*\n(.*?)\n?---(?:\s*\n|$)', content, re.DOTALL)
|
||||
if not fm_match:
|
||||
return None, ["Missing or malformed YAML frontmatter"]
|
||||
|
||||
@@ -109,7 +109,7 @@ def collect_validation_results(skills_dir, strict_mode=False):
|
||||
|
||||
# 1. Frontmatter Check
|
||||
metadata, fm_errors = parse_frontmatter(content, rel_path)
|
||||
if not metadata:
|
||||
if metadata is None:
|
||||
errors.append(f"❌ {rel_path}: Missing or malformed YAML frontmatter")
|
||||
continue # Cannot proceed without metadata
|
||||
|
||||
|
||||
Reference in New Issue
Block a user