feat(web-app): finalize SEO marketing layer for catalog routes
This commit is contained in:
12
.github/workflows/pages.yml
vendored
12
.github/workflows/pages.yml
vendored
@@ -44,12 +44,24 @@ jobs:
|
||||
run: cd apps/web-app && npm run build
|
||||
env:
|
||||
VITE_BASE_PATH: /${{ github.event.repository.name }}/
|
||||
SEO_SITE_URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}
|
||||
|
||||
- name: Validate SEO artifact quality
|
||||
run: cd apps/web-app && npm run verify:seo
|
||||
|
||||
- name: Validate generated sitemap and asset consistency
|
||||
run: |
|
||||
cd apps/web-app
|
||||
test -f dist/robots.txt
|
||||
test -f dist/sitemap.xml
|
||||
test -f dist/manifest.webmanifest
|
||||
|
||||
- name: Prepare artifact (404 + .nojekyll)
|
||||
run: |
|
||||
cd apps/web-app/dist
|
||||
cp index.html 404.html
|
||||
touch .nojekyll
|
||||
test -f 404.html
|
||||
|
||||
- name: Configure GitHub Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
@@ -2,16 +2,25 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="%BASE_URL%vite.svg" />
|
||||
<link rel="manifest" href="%BASE_URL%manifest.webmanifest" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Antigravity Awesome Skills - 950+ agentic skills catalog for Claude Code, Gemini, Cursor, Copilot. Search, filter, and copy prompts instantly." />
|
||||
<meta name="keywords" content="AI skills, Claude Code, Gemini CLI, Cursor, Copilot, agentic skills, coding assistant" />
|
||||
<meta name="author" content="Niccolò Abate (@sickn33)" />
|
||||
<meta property="og:title" content="Antigravity Awesome Skills Catalog" />
|
||||
<meta property="og:description" content="Browse 950+ battle-tested agentic skills for AI coding assistants" />
|
||||
<meta name="description" content="Discover 1,273+ installable AI skills and launch practical prompts in one place." />
|
||||
<meta name="author" content="Antigravity Awesome Skills" />
|
||||
<meta property="og:title" content="Antigravity Awesome Skills | 1,273+ installable AI skills catalog" />
|
||||
<meta property="og:description" content="Discover 1,273+ installable AI skills and launch practical prompts in one place." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="%BASE_URL%" />
|
||||
<meta property="og:image" content="%BASE_URL%social-card.svg" />
|
||||
<meta name="twitter:title" content="Antigravity Awesome Skills | 1,273+ installable AI skills catalog" />
|
||||
<meta name="twitter:description" content="Discover 1,273+ installable AI skills and launch practical prompts in one place." />
|
||||
<meta name="twitter:image" content="%BASE_URL%social-card.svg" />
|
||||
<meta name="twitter:image:alt" content="Antigravity Awesome Skills catalog preview" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta property="og:site_name" content="Antigravity Awesome Skills" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<title>Antigravity Skills | 950+ AI Agentic Skills Catalog</title>
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<title>Antigravity Awesome Skills | 1,273+ installable AI skills catalog</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"generate:sitemap": "node scripts/generate-sitemap.js",
|
||||
"prerender:seo": "node scripts/prerender-routes.js",
|
||||
"prebuild": "npm run generate:sitemap",
|
||||
"verify:seo": "node scripts/verify-seo-assets.js",
|
||||
"build": "tsc && vite build && npm run prerender:seo",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
|
||||
17
apps/web-app/public/manifest.webmanifest
Normal file
17
apps/web-app/public/manifest.webmanifest
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Antigravity Awesome Skills",
|
||||
"short_name": "Antigravity Skills",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f172a",
|
||||
"theme_color": "#0f172a",
|
||||
"description": "A discoverable catalog of installable AI skills for agents and assistants.",
|
||||
"icons": [
|
||||
{
|
||||
"src": "vite.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
]
|
||||
}
|
||||
7
apps/web-app/public/robots.txt
Normal file
7
apps/web-app/public/robots.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow:
|
||||
# Keep fallback for single-page app routes
|
||||
Allow: /404.html
|
||||
|
||||
Sitemap: ./sitemap.xml
|
||||
249
apps/web-app/public/sitemap.xml
Normal file
249
apps/web-app/public/sitemap.xml
Normal file
@@ -0,0 +1,249 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>http://localhost/</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/astro</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/hono</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/openclaw-github-repo-commander</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/pydantic-ai</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/sveltekit</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/goldrush-api</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/progressive-web-app</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/trpc-fullstack</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/vibers-code-review</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/ai-engineering-toolkit</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/ai-native-cli</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/latex-paper-conversion</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/antigravity-skill-orchestrator</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/k6-load-testing</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/recallmax</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/tool-use-guardian</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/acceptance-orchestrator</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/closed-loop-delivery</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/create-issue-gate</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/electron-development</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/llm-structured-output</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/ai-md</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/explain-like-socrates</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/interview-coach</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/keyword-extractor</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/local-llm-expert</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/skill-check</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/yes-md</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/blueprint</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/lex</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/pipecat-friday-agent</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/progressive-estimation</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/sankhya-dashboard-html-jsp-custom-best-pratices</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/seek-and-analyze-video</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/animejs-animation</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/antigravity-design-expert</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/audit-skills</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/daily</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/design-spells</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost/skill/frontend-slides</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
17
apps/web-app/public/social-card.svg
Normal file
17
apps/web-app/public/social-card.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" role="img" aria-labelledby="title desc">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#0f172a" />
|
||||
<stop offset="100%" stop-color="#1e293b" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="630" fill="url(#bg)" />
|
||||
<g fill="#38bdf8">
|
||||
<circle cx="180" cy="135" r="42" opacity="0.22" />
|
||||
<circle cx="1060" cy="540" r="56" opacity="0.18" />
|
||||
</g>
|
||||
<text x="80" y="180" font-family="Arial, Helvetica, sans-serif" font-size="56" fill="#e2e8f0" font-weight="700">Antigravity Awesome Skills</text>
|
||||
<text x="80" y="275" font-family="Arial, Helvetica, sans-serif" font-size="40" fill="#cbd5e1" font-weight="600">1,273+ installable AI skills</text>
|
||||
<text x="80" y="360" font-family="Arial, Helvetica, sans-serif" font-size="36" fill="#94a3b8">Discover, install and use ready-to-run agentic skills.</text>
|
||||
<text x="80" y="470" font-family="Consolas, Monaco, monospace" font-size="44" fill="#7dd3fc">npx antigravity-awesome-skills</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
124
apps/web-app/scripts/generate-sitemap.js
Normal file
124
apps/web-app/scripts/generate-sitemap.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const PUBLIC_DIR = path.join(ROOT_DIR, 'public');
|
||||
const SKILLS_JSON = path.join(PUBLIC_DIR, 'skills.json');
|
||||
const OUTPUT_PATH = path.join(PUBLIC_DIR, 'sitemap.xml');
|
||||
const BASE_PATH =
|
||||
(process.env.VITE_BASE_PATH || '/').trim().replace(/\/+$/, '');
|
||||
const NORMALIZED_BASE_PATH = BASE_PATH && BASE_PATH !== '/' ? BASE_PATH : '';
|
||||
const DEFAULT_SITE_URL = `http://localhost${NORMALIZED_BASE_PATH}`;
|
||||
|
||||
const SITE_URL = (process.env.SEO_SITE_URL || process.env.WEBSITE_BASE_URL || DEFAULT_SITE_URL).replace(/\/$/, '');
|
||||
const TOP_SKILL_COUNT = Number.parseInt(process.env.TOP_SKILL_COUNT || '40', 10);
|
||||
const DEFAULT_LASTMOD = new Date().toISOString().slice(0, 10);
|
||||
|
||||
function getTopSkillCount() {
|
||||
return Number.isFinite(TOP_SKILL_COUNT) ? Math.max(TOP_SKILL_COUNT, 0) : 40;
|
||||
}
|
||||
|
||||
function escapeXml(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function getDateScore(dateValue) {
|
||||
if (!dateValue) return 0;
|
||||
|
||||
const parsed = Date.parse(dateValue);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
function normalizeSkillId(skillId) {
|
||||
return encodeURIComponent(String(skillId).trim());
|
||||
}
|
||||
|
||||
export function selectTopSkillEntries(skills, topCount = TOP_SKILL_COUNT) {
|
||||
const max = Math.max(Number.parseInt(topCount, 10) || 0, 0);
|
||||
if (!Array.isArray(skills) || max === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sorted = [...skills]
|
||||
.map((skill, index) => ({
|
||||
id: skill.id,
|
||||
index,
|
||||
stars: Number(skill?.stars) || 0,
|
||||
date: getDateScore(skill?.date_added),
|
||||
}))
|
||||
.filter((item) => Boolean(item.id))
|
||||
.sort((a, b) => {
|
||||
if (a.stars !== b.stars) return b.stars - a.stars;
|
||||
if (a.date !== b.date) return b.date - a.date;
|
||||
|
||||
const nameCompare = String(a.id).localeCompare(String(b.id), undefined, { sensitivity: 'base' });
|
||||
if (nameCompare !== 0) return nameCompare;
|
||||
|
||||
return a.index - b.index;
|
||||
})
|
||||
.slice(0, max);
|
||||
|
||||
const dedupedEntries = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const item of sorted) {
|
||||
if (!item.id || seen.has(item.id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(item.id);
|
||||
dedupedEntries.push(`/skill/${normalizeSkillId(item.id)}`);
|
||||
if (dedupedEntries.length >= max) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return dedupedEntries;
|
||||
}
|
||||
|
||||
export function generateSitemapXml({ baseUrl, paths, lastmod = DEFAULT_LASTMOD }) {
|
||||
const normalizedBase = String(baseUrl).replace(/\/$/, '');
|
||||
const uniquePaths = [...new Set(paths)];
|
||||
|
||||
const urlsXml = uniquePaths
|
||||
.map((pathName) => {
|
||||
const href = `${normalizedBase}${pathName}`;
|
||||
return ` <url>\n <loc>${escapeXml(href)}</loc>\n <lastmod>${lastmod}</lastmod>\n <changefreq>${pathName === '/' ? 'daily' : 'weekly'}</changefreq>\n <priority>${pathName === '/' ? '1.0' : '0.7'}</priority>\n </url>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">\n${urlsXml}\n</urlset>\n`;
|
||||
}
|
||||
|
||||
function readSkillsCatalog() {
|
||||
if (!fs.existsSync(SKILLS_JSON)) {
|
||||
throw new Error(`Skills catalog not found at ${SKILLS_JSON}`);
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(SKILLS_JSON, 'utf-8');
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
export function buildSitemap(skills, topCount = TOP_SKILL_COUNT, baseUrl = SITE_URL) {
|
||||
const topSkillPaths = selectTopSkillEntries(skills, topCount);
|
||||
return generateSitemapXml({
|
||||
baseUrl,
|
||||
paths: ['/', ...topSkillPaths],
|
||||
});
|
||||
}
|
||||
|
||||
function writeSitemap() {
|
||||
const skills = readSkillsCatalog();
|
||||
const xml = buildSitemap(skills, getTopSkillCount(), SITE_URL);
|
||||
fs.writeFileSync(OUTPUT_PATH, xml, 'utf-8');
|
||||
console.log(`sitemap.xml generated at ${OUTPUT_PATH}`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].endsWith('generate-sitemap.js')) {
|
||||
writeSitemap();
|
||||
}
|
||||
50
apps/web-app/scripts/generate-sitemap.test.js
Normal file
50
apps/web-app/scripts/generate-sitemap.test.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildSitemap, selectTopSkillEntries } from './generate-sitemap.js';
|
||||
|
||||
describe('sitemap generation script helpers', () => {
|
||||
it('builds top skill entries sorted by stars/date/name without duplicates', () => {
|
||||
const catalog = [
|
||||
{ id: 'alpha', stars: 5, date_added: '2026-01-01' },
|
||||
{ id: 'beta', stars: 4, date_added: '2026-01-02' },
|
||||
{ id: 'alpha', stars: 3, date_added: '2026-01-03' },
|
||||
];
|
||||
|
||||
const topEntries = selectTopSkillEntries(catalog, 5);
|
||||
|
||||
expect(topEntries).toEqual(['/skill/alpha', '/skill/beta']);
|
||||
});
|
||||
|
||||
it('builds sitemap XML with homepage and selected skill paths', () => {
|
||||
const catalog = [
|
||||
{ id: 'gamma', stars: 2 },
|
||||
{ id: 'delta', stars: 1 },
|
||||
];
|
||||
|
||||
const xml = buildSitemap(catalog, 1, 'https://example.com');
|
||||
|
||||
expect(xml).toContain('https://example.com/</loc>');
|
||||
expect(xml).toContain('https://example.com/skill/gamma</loc>');
|
||||
expect(xml).not.toContain('/skill/delta');
|
||||
});
|
||||
|
||||
it('escapes XML reserved characters in generated sitemap URLs', () => {
|
||||
const catalog = [{ id: 'safe&id', stars: 10 }];
|
||||
|
||||
const xml = buildSitemap(catalog, 1, 'https://example.com/search?q=ai&lang=en');
|
||||
|
||||
expect(xml).toContain('https://example.com/search?q=ai&lang=en/</loc>');
|
||||
expect(xml).toContain('/safe%26id</loc>');
|
||||
});
|
||||
|
||||
it('returns only homepage when top skill limit is zero', () => {
|
||||
const catalog = [
|
||||
{ id: 'gamma', stars: 2 },
|
||||
{ id: 'delta', stars: 1 },
|
||||
];
|
||||
|
||||
const xml = buildSitemap(catalog, 0, 'https://example.com');
|
||||
|
||||
expect(xml).toContain('https://example.com/</loc>');
|
||||
expect(xml).not.toContain('https://example.com/skill');
|
||||
});
|
||||
});
|
||||
285
apps/web-app/scripts/prerender-routes.js
Normal file
285
apps/web-app/scripts/prerender-routes.js
Normal file
@@ -0,0 +1,285 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { selectTopSkillEntries } from './generate-sitemap.js';
|
||||
|
||||
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const DIST_DIR = path.join(ROOT_DIR, 'dist');
|
||||
const PUBLIC_DIR = path.join(ROOT_DIR, 'public');
|
||||
const TEMPLATE_PATH = path.join(DIST_DIR, 'index.html');
|
||||
const SKILLS_PATH = path.join(PUBLIC_DIR, 'skills.json');
|
||||
|
||||
const HOME_CATALOG_COUNT = 1273;
|
||||
const PRERENDER_SOCIAL_IMAGE = 'social-card.svg';
|
||||
const SITE_NAME = 'Antigravity Awesome Skills';
|
||||
|
||||
function parseCount(value, fallback) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? Math.max(parsed, 0) : fallback;
|
||||
}
|
||||
|
||||
function getSiteBaseUrl() {
|
||||
const seoSiteUrl = (process.env.SEO_SITE_URL || '').trim().replace(/\/+$/, '');
|
||||
if (seoSiteUrl) {
|
||||
return seoSiteUrl;
|
||||
}
|
||||
|
||||
const basePath = (process.env.VITE_BASE_PATH || '/').trim().replace(/\/+$/, '');
|
||||
const normalizedBase = basePath || '/';
|
||||
const withLeadingSlash = normalizedBase.startsWith('/') ? normalizedBase : `/${normalizedBase}`;
|
||||
const withoutTrailing = withLeadingSlash.length > 1 ? withLeadingSlash : '';
|
||||
|
||||
return `http://localhost${withoutTrailing}`;
|
||||
}
|
||||
|
||||
function ensureDirectory(targetPath) {
|
||||
fs.mkdirSync(targetPath, { recursive: true });
|
||||
}
|
||||
|
||||
function normalizeRoute(routePath) {
|
||||
const withLeadingSlash = routePath.startsWith('/') ? routePath : `/${routePath}`;
|
||||
return withLeadingSlash === '//' ? '/' : withLeadingSlash;
|
||||
}
|
||||
|
||||
function routeToUrl(routePath, siteBaseUrl) {
|
||||
const normalizedRoute = normalizeRoute(routePath);
|
||||
const normalizedBase = siteBaseUrl.replace(/\/+$/, '');
|
||||
return `${normalizedBase}${normalizedRoute}`;
|
||||
}
|
||||
|
||||
function routeToFilePath(routePath) {
|
||||
if (routePath === '/') {
|
||||
return path.join(DIST_DIR, 'index.html');
|
||||
}
|
||||
|
||||
const normalized = normalizeRoute(routePath).replace(/^\//, '');
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
|
||||
return path.join(DIST_DIR, ...segments, 'index.html');
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function escapeScriptJson(value) {
|
||||
return String(value).replace(/<\/script/gi, '<\\/script');
|
||||
}
|
||||
|
||||
function replaceHtmlTag(html, pattern, replacement, insertionPoint) {
|
||||
if (pattern.test(html)) {
|
||||
return html.replace(pattern, replacement);
|
||||
}
|
||||
|
||||
return html.replace(insertionPoint, `${replacement}\n${insertionPoint}`);
|
||||
}
|
||||
|
||||
function setMetaTag(html, attributeName, attributeValue, content) {
|
||||
const attributeEscaped = escapeRegExp(attributeValue);
|
||||
const pattern = new RegExp(`<meta\\s+[^>]*${attributeName}=["']${attributeEscaped}["'][^>]*>`, 'i');
|
||||
const replacement = `<meta ${attributeName}="${attributeValue}" content="${escapeHtml(content)}" />`;
|
||||
return replaceHtmlTag(html, pattern, replacement, '</head>');
|
||||
}
|
||||
|
||||
function setLinkTag(html, relation, href) {
|
||||
const pattern = new RegExp(`<link\\s+[^>]*rel=["']${escapeRegExp(relation)}["'][^>]*>`, 'i');
|
||||
const replacement = `<link rel="${relation}" href="${escapeHtml(href)}" />`;
|
||||
return replaceHtmlTag(html, pattern, replacement, '</head>');
|
||||
}
|
||||
|
||||
function setTitleTag(html, title) {
|
||||
const pattern = /<title[^>]*>[\s\S]*?<\/title>/i;
|
||||
const replacement = `<title>${escapeHtml(title)}</title>`;
|
||||
return replaceHtmlTag(html, pattern, replacement, '</head>');
|
||||
}
|
||||
|
||||
function setJsonLdTag(html, payload) {
|
||||
const cleaned = html.replace(/<script\b[^>]*data-seo-jsonld="true"[^>]*>[\s\S]*?<\/script>\s*/g, '');
|
||||
const tag = `<script type="application/ld+json" data-seo-jsonld="true">${escapeScriptJson(JSON.stringify(payload))}</script>`;
|
||||
return cleaned.replace('</head>', `\n${tag}\n</head>`);
|
||||
}
|
||||
|
||||
function safeText(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function buildHomeMeta({ catalogCount, imageUrl, canonicalUrl }) {
|
||||
const visibleCount = Math.max(catalogCount, HOME_CATALOG_COUNT);
|
||||
const title = 'Antigravity Awesome Skills | 1,273+ installable AI skills catalog';
|
||||
const description = `Explore ${visibleCount}+ installable agentic skills and prompt templates. Discover what fits your workflow, copy prompts fast, and launch AI-powered actions with confidence.`;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
canonicalUrl,
|
||||
ogTitle: title,
|
||||
ogDescription: description,
|
||||
ogImage: imageUrl,
|
||||
twitterCard: 'summary_large_image',
|
||||
jsonLd: {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: SITE_NAME,
|
||||
description,
|
||||
url: canonicalUrl,
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: SITE_NAME,
|
||||
url: canonicalUrl.replace(/\/$/, ''),
|
||||
},
|
||||
mainEntity: {
|
||||
'@type': 'ItemList',
|
||||
name: `${SITE_NAME} catalog`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildSkillMeta({ skill, isPriority, imageUrl, canonicalUrl }) {
|
||||
const safeName = safeText(skill.name) || 'Unnamed skill';
|
||||
const safeDescription = safeText(skill.description) || 'Installable AI skill';
|
||||
const safeCategory = safeText(skill.category) || 'Tools';
|
||||
const safeSource = safeText(skill.source) || 'community contributors';
|
||||
const added = skill.date_added ? `Added ${skill.date_added}. ` : '';
|
||||
const trust = isPriority ? ' Prioritized in our catalog for quality and reuse. ' : ' ';
|
||||
|
||||
const title = `${safeName} | ${SITE_NAME}`;
|
||||
const description = `${added}Use the @${safeName} skill for ${safeDescription} (${safeCategory}, ${safeSource}).${trust}Install and run quickly with your CLI workflow.`;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
canonicalUrl,
|
||||
ogTitle: `@${safeName} | ${SITE_NAME}`,
|
||||
ogDescription: description,
|
||||
ogImage: imageUrl,
|
||||
twitterCard: 'summary',
|
||||
jsonLd: {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': canonicalUrl,
|
||||
name: `@${safeName}`,
|
||||
applicationCategory: safeCategory,
|
||||
description,
|
||||
url: canonicalUrl,
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
provider: {
|
||||
'@type': 'Organization',
|
||||
name: SITE_NAME,
|
||||
},
|
||||
keywords: [safeCategory, safeSource],
|
||||
inLanguage: 'en',
|
||||
operatingSystem: 'Cross-platform',
|
||||
isPartOf: {
|
||||
'@type': 'CollectionPage',
|
||||
name: SITE_NAME,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applySeoMeta(templateHtml, meta) {
|
||||
let output = templateHtml;
|
||||
const title = safeText(meta.title);
|
||||
const description = safeText(meta.description);
|
||||
const canonical = safeText(meta.canonicalUrl);
|
||||
const ogTitle = safeText(meta.ogTitle || title);
|
||||
const ogDescription = safeText(meta.ogDescription || description);
|
||||
const ogImage = safeText(meta.ogImage);
|
||||
|
||||
output = setTitleTag(output, title);
|
||||
output = setMetaTag(output, 'name', 'description', description);
|
||||
output = setMetaTag(output, 'property', 'og:type', 'website');
|
||||
output = setMetaTag(output, 'property', 'og:title', ogTitle);
|
||||
output = setMetaTag(output, 'property', 'og:description', ogDescription);
|
||||
output = setMetaTag(output, 'property', 'og:site_name', SITE_NAME);
|
||||
output = setMetaTag(output, 'property', 'og:url', canonical);
|
||||
output = setMetaTag(output, 'name', 'twitter:card', safeText(meta.twitterCard || 'summary'));
|
||||
output = setMetaTag(output, 'name', 'twitter:title', ogTitle);
|
||||
output = setMetaTag(output, 'name', 'twitter:description', ogDescription);
|
||||
output = setMetaTag(output, 'name', 'twitter:image:alt', `${ogTitle} preview`);
|
||||
output = setMetaTag(output, 'property', 'og:image', ogImage);
|
||||
output = setMetaTag(output, 'name', 'twitter:image', ogImage);
|
||||
output = setLinkTag(output, 'canonical', canonical);
|
||||
output = setJsonLdTag(output, meta.jsonLd);
|
||||
return output;
|
||||
}
|
||||
|
||||
function writePrerenderedRoute(routePath, templateHtml, meta) {
|
||||
const filePath = routeToFilePath(routePath);
|
||||
const rendered = applySeoMeta(templateHtml, meta);
|
||||
const directory = path.dirname(filePath);
|
||||
ensureDirectory(directory);
|
||||
fs.writeFileSync(filePath, rendered, 'utf-8');
|
||||
}
|
||||
|
||||
function readCatalog() {
|
||||
if (!fs.existsSync(SKILLS_PATH)) {
|
||||
throw new Error(`Skills catalog not found at ${SKILLS_PATH}`);
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(SKILLS_PATH, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('Skills catalog must be an array.');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(TEMPLATE_PATH)) {
|
||||
throw new Error(`Built index file not found at ${TEMPLATE_PATH}. Run npm run build before prerender.`);
|
||||
}
|
||||
|
||||
const template = fs.readFileSync(TEMPLATE_PATH, 'utf-8');
|
||||
const skills = readCatalog();
|
||||
const siteBaseUrl = getSiteBaseUrl();
|
||||
const topCount = parseCount(process.env.PRERENDER_TOP_SKILL_COUNT || process.env.TOP_SKILL_COUNT, 40);
|
||||
const topSkillPaths = selectTopSkillEntries(skills, topCount);
|
||||
const skillMap = new Map(skills.map((skill) => [skill.id, skill]));
|
||||
const topSkillSet = new Set(topSkillPaths.map((routePath) => routePath.replace(/^\/skill\//, '')));
|
||||
const socialImage = `${siteBaseUrl.replace(/\/+$/, '')}/${PRERENDER_SOCIAL_IMAGE}`;
|
||||
|
||||
const homeCanonical = routeToUrl('/', siteBaseUrl);
|
||||
const homeMeta = buildHomeMeta({
|
||||
catalogCount: skills.length,
|
||||
imageUrl: socialImage,
|
||||
canonicalUrl: homeCanonical,
|
||||
});
|
||||
writePrerenderedRoute('/', template, homeMeta);
|
||||
|
||||
for (const skillRoute of topSkillPaths) {
|
||||
const decodedId = decodeURIComponent(skillRoute.replace(/^\/skill\//, ''));
|
||||
const skill = skillMap.get(decodedId);
|
||||
if (!skill) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const canonicalUrl = routeToUrl(skillRoute, siteBaseUrl);
|
||||
const skillMeta = buildSkillMeta({
|
||||
skill,
|
||||
isPriority: topSkillSet.has(encodeURIComponent(decodedId)),
|
||||
imageUrl: socialImage,
|
||||
canonicalUrl,
|
||||
});
|
||||
writePrerenderedRoute(skillRoute, template, skillMeta);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
248
apps/web-app/scripts/verify-seo-assets.js
Normal file
248
apps/web-app/scripts/verify-seo-assets.js
Normal file
@@ -0,0 +1,248 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export function extractSitemapLocations(xmlText) {
|
||||
const raw = String(xmlText ?? '');
|
||||
const matches = raw.matchAll(/<loc>(.*?)<\/loc>/g);
|
||||
return [...matches].map((match) => match[1].trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function parseCount(value, fallback = 0) {
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
return Number.isFinite(parsed) ? Math.max(parsed, 0) : fallback;
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCliArgs(argv) {
|
||||
const defaultMinSkillUrls = parseCount(
|
||||
process.env.PRERENDER_VERIFY_MIN_SKILL_URLS || process.env.PRERENDER_TOP_SKILL_COUNT || process.env.TOP_SKILL_COUNT,
|
||||
40,
|
||||
);
|
||||
const args = {
|
||||
sitemapPath: 'dist/sitemap.xml',
|
||||
robotsPath: 'dist/robots.txt',
|
||||
manifestPath: 'dist/manifest.webmanifest',
|
||||
indexPath: 'dist/index.html',
|
||||
distDir: 'dist',
|
||||
minSkillUrls: String(defaultMinSkillUrls),
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--artifacts-dir') {
|
||||
const value = argv[i + 1];
|
||||
if (value) {
|
||||
args.sitemapPath = path.join(value, 'sitemap.xml');
|
||||
args.robotsPath = path.join(value, 'robots.txt');
|
||||
args.manifestPath = path.join(value, 'manifest.webmanifest');
|
||||
args.indexPath = path.join(value, 'index.html');
|
||||
args.distDir = value;
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--dist-dir' && argv[i + 1]) {
|
||||
args.distDir = argv[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--sitemap' && argv[i + 1]) {
|
||||
args.sitemapPath = argv[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--robots' && argv[i + 1]) {
|
||||
args.robotsPath = argv[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--manifest' && argv[i + 1]) {
|
||||
args.manifestPath = argv[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--index' && argv[i + 1]) {
|
||||
args.indexPath = argv[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--min-skill-urls' && argv[i + 1]) {
|
||||
args.minSkillUrls = argv[i + 1];
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function extractMetaContent(htmlText, selectorType, selectorValue) {
|
||||
const pattern = new RegExp(
|
||||
`<meta\\s+[^>]*${selectorType}=["']${selectorValue}["'][^>]*\\scontent=["']([^"']+)["'][^>]*>`,
|
||||
'i',
|
||||
);
|
||||
const match = htmlText.match(pattern);
|
||||
return match?.[1]?.trim();
|
||||
}
|
||||
|
||||
function assertMetaContent(htmlText, selectorType, selectorValue) {
|
||||
const content = extractMetaContent(htmlText, selectorType, selectorValue);
|
||||
assert(Boolean(content), `Missing required meta tag ${selectorType}="${selectorValue}".`);
|
||||
assert(content.length > 0, `Meta tag ${selectorType}="${selectorValue}" must have non-empty content.`);
|
||||
}
|
||||
|
||||
export function analyzeSitemap(urlText, { minSkillUrls = 1 } = {}) {
|
||||
const locations = extractSitemapLocations(urlText);
|
||||
const normalizedMinSkillUrls = Number.parseInt(String(minSkillUrls), 10);
|
||||
const effectiveMinSkillUrls = Number.isFinite(normalizedMinSkillUrls)
|
||||
? Math.max(normalizedMinSkillUrls, 0)
|
||||
: 1;
|
||||
|
||||
assert(locations.length > 0, 'Sitemap contains no <loc> entries.');
|
||||
assert(new Set(locations).size === locations.length, 'Sitemap contains duplicated <loc> values.');
|
||||
|
||||
const parsed = locations.map((location) => {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(location);
|
||||
} catch (_err) {
|
||||
throw new Error(`Sitemap contains invalid URL: ${location}`);
|
||||
}
|
||||
|
||||
assert(
|
||||
url.protocol === 'https:' || url.protocol === 'http:',
|
||||
`Sitemap URL must use http(s): ${location}`,
|
||||
);
|
||||
return { raw: location, parsed: url };
|
||||
});
|
||||
|
||||
const paths = parsed.map(({ parsed }) => parsed.pathname);
|
||||
const segmentCounts = paths.map((pathname) => {
|
||||
const normalized = pathname === '/' ? '' : pathname.replace(/\/+$/, '');
|
||||
return normalized ? normalized.split('/').filter(Boolean).length : 0;
|
||||
});
|
||||
const minSegments = Math.min(...segmentCounts);
|
||||
const rootCandidate = parsed.find(
|
||||
({ parsed: parsedUrl }, index) =>
|
||||
(segmentCounts[index] === minSegments && !parsedUrl.pathname.includes('/skill/')) || parsedUrl.pathname === '/',
|
||||
);
|
||||
assert(Boolean(rootCandidate), 'Sitemap does not expose a homepage candidate URL.');
|
||||
|
||||
const rootUrl = new URL(rootCandidate.raw);
|
||||
const normalizedRoot = rootUrl.pathname === '/' ? '' : rootUrl.pathname.replace(/\/+$/, '');
|
||||
const skillPrefix = `${normalizedRoot}/skill/`;
|
||||
const rootPathVariants = new Set([
|
||||
rootUrl.pathname,
|
||||
rootUrl.pathname.endsWith('/') ? rootUrl.pathname.slice(0, -1) : `${rootUrl.pathname}/`,
|
||||
]);
|
||||
|
||||
const isRoot = ({ parsed: parsedUrl }) => rootPathVariants.has(parsedUrl.pathname);
|
||||
const extraRoutes = parsed.filter(({ parsed: parsedUrl }) => !isRoot({ parsed: parsedUrl }));
|
||||
const skillRoutes = extraRoutes.filter(({ parsed: parsedUrl }) =>
|
||||
parsedUrl.pathname.startsWith(skillPrefix),
|
||||
);
|
||||
|
||||
assert(
|
||||
skillRoutes.length >= effectiveMinSkillUrls,
|
||||
`Expected at least ${effectiveMinSkillUrls} skill URLs, got ${skillRoutes.length}.`,
|
||||
);
|
||||
|
||||
assert(
|
||||
extraRoutes.every(({ parsed: parsedUrl }) => parsedUrl.pathname.startsWith(skillPrefix)),
|
||||
'Sitemap contains unsupported non-skill routes.',
|
||||
);
|
||||
|
||||
return {
|
||||
locations,
|
||||
rootPath: rootUrl.pathname,
|
||||
normalizedRootPath: normalizedRoot,
|
||||
skillUrls: skillRoutes.map(({ raw }) => raw),
|
||||
};
|
||||
}
|
||||
|
||||
export function assertSitemap(urlText, { minSkillUrls = 1 } = {}) {
|
||||
analyzeSitemap(urlText, { minSkillUrls });
|
||||
}
|
||||
|
||||
export function assertIndexSocialMeta(htmlText) {
|
||||
assertMetaContent(htmlText, 'property', 'og:image');
|
||||
assertMetaContent(htmlText, 'name', 'twitter:image');
|
||||
assertMetaContent(htmlText, 'name', 'twitter:image:alt');
|
||||
}
|
||||
|
||||
function routePathToDistFile(routePath, normalizedRootPath) {
|
||||
const normalizedPath = (routePath || '/').replace(/\/+$/, '') || '/';
|
||||
const normalizedRoot = normalizedRootPath === '/' ? '' : String(normalizedRootPath || '').replace(/\/+$/, '');
|
||||
const withLeadingRoot = normalizedRoot ? `${normalizedRoot}/` : '';
|
||||
const trimmedRoute = normalizedPath.startsWith(withLeadingRoot) ? normalizedPath.slice(withLeadingRoot.length) || '/' : normalizedPath;
|
||||
const withoutLeadingSlash = trimmedRoute === '/' ? '' : trimmedRoute.replace(/^\//, '');
|
||||
const routeAsFilePath = withoutLeadingSlash ? `${withoutLeadingSlash}/index.html` : 'index.html';
|
||||
return routeAsFilePath;
|
||||
}
|
||||
|
||||
export function assertPrerenderedSkillRoutes(skillUrls, distDir = 'dist', normalizedRootPath = '') {
|
||||
for (const skillUrl of skillUrls) {
|
||||
const parsed = new URL(skillUrl);
|
||||
const filePath = path.join(distDir, routePathToDistFile(parsed.pathname, normalizedRootPath));
|
||||
assert(
|
||||
fs.existsSync(filePath),
|
||||
`Missing prerendered page for skill route: ${parsed.pathname}. Expected ${filePath}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertRobots(robotsText) {
|
||||
const lines = String(robotsText ?? '').split(/\r?\n/).map((line) => line.trim());
|
||||
const allowsRoot = lines.some((line) => line.startsWith('Allow: /'));
|
||||
const hasSitemap = lines.some((line) => /^Sitemap:\s*.+\/?sitemap\.xml$/i.test(line));
|
||||
|
||||
assert(allowsRoot, 'robots.txt must allow root crawling.');
|
||||
assert(hasSitemap, 'robots.txt must expose sitemap location.');
|
||||
}
|
||||
|
||||
export function assertManifest(manifestText) {
|
||||
const manifest = JSON.parse(String(manifestText ?? ''));
|
||||
|
||||
const requiredKeys = ['name', 'short_name', 'theme_color', 'description'];
|
||||
for (const key of requiredKeys) {
|
||||
assert(typeof manifest[key] === 'string' && manifest[key].trim(), `Manifest missing required key: ${key}`);
|
||||
}
|
||||
|
||||
assert(Array.isArray(manifest.icons), 'Manifest must define an icons array.');
|
||||
assert(manifest.icons.length > 0, 'Manifest icons array must not be empty.');
|
||||
}
|
||||
|
||||
function readFile(filePath) {
|
||||
return fs.readFileSync(filePath, 'utf-8');
|
||||
}
|
||||
|
||||
export function runVerification({
|
||||
sitemapPath,
|
||||
robotsPath,
|
||||
manifestPath,
|
||||
indexPath = 'dist/index.html',
|
||||
distDir = 'dist',
|
||||
minSkillUrls,
|
||||
}) {
|
||||
const sitemapReport = analyzeSitemap(readFile(sitemapPath), { minSkillUrls });
|
||||
assertPrerenderedSkillRoutes(sitemapReport.skillUrls, distDir, sitemapReport.normalizedRootPath);
|
||||
assertIndexSocialMeta(readFile(indexPath));
|
||||
assertRobots(readFile(robotsPath));
|
||||
assertManifest(readFile(manifestPath));
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const cliArgs = parseCliArgs(process.argv.slice(2));
|
||||
runVerification(cliArgs);
|
||||
console.log('SEO assets verification passed.');
|
||||
}
|
||||
142
apps/web-app/scripts/verify-seo-assets.test.js
Normal file
142
apps/web-app/scripts/verify-seo-assets.test.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
assertManifest,
|
||||
analyzeSitemap,
|
||||
assertPrerenderedSkillRoutes,
|
||||
assertIndexSocialMeta,
|
||||
assertRobots,
|
||||
assertSitemap,
|
||||
extractSitemapLocations,
|
||||
} from './verify-seo-assets.js';
|
||||
|
||||
describe('seo assets verification helpers', () => {
|
||||
it('extracts sitemap location values in declaration order', () => {
|
||||
const xml = `
|
||||
<urlset>
|
||||
<url><loc>https://example.com/</loc></url>
|
||||
<url><loc>https://example.com/skill/agent-a</loc></url>
|
||||
</urlset>
|
||||
`;
|
||||
|
||||
const locs = extractSitemapLocations(xml);
|
||||
|
||||
expect(locs).toEqual([
|
||||
'https://example.com/',
|
||||
'https://example.com/skill/agent-a',
|
||||
]);
|
||||
});
|
||||
|
||||
it('validates a canonical sitemap with base path and enough top skills', () => {
|
||||
const xml = `
|
||||
<urlset>
|
||||
<url><loc>https://owner.github.io/repo/</loc></url>
|
||||
<url><loc>https://owner.github.io/repo/skill/agent-a</loc></url>
|
||||
<url><loc>https://owner.github.io/repo/skill/agent-b</loc></url>
|
||||
</urlset>
|
||||
`;
|
||||
|
||||
expect(() => assertSitemap(xml, { minSkillUrls: 2 })).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws when sitemap has duplicated URLs', () => {
|
||||
const xml = `
|
||||
<urlset>
|
||||
<url><loc>https://example.com/</loc></url>
|
||||
<url><loc>https://example.com/</loc></url>
|
||||
</urlset>
|
||||
`;
|
||||
|
||||
expect(() => assertSitemap(xml)).toThrow('duplicated');
|
||||
});
|
||||
|
||||
it('requires robots directives', () => {
|
||||
const robots = `
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Sitemap: https://example.com/sitemap.xml
|
||||
`;
|
||||
|
||||
expect(() => assertRobots(robots)).not.toThrow();
|
||||
});
|
||||
|
||||
it('requires social image tags in rendered index html', () => {
|
||||
const html = `
|
||||
<html>
|
||||
<head>
|
||||
<meta property="og:image" content="https://example.com/social-card.svg" />
|
||||
<meta name="twitter:image" content="https://example.com/social-card.svg" />
|
||||
<meta name="twitter:image:alt" content="Catalog social preview" />
|
||||
</head>
|
||||
</html>
|
||||
`;
|
||||
|
||||
expect(() => assertIndexSocialMeta(html)).not.toThrow();
|
||||
});
|
||||
|
||||
it('validates prerendered skill route files when present', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const distDir = path.join(tmpDir, 'dist');
|
||||
const routeFile = path.join(distDir, 'skill', 'agent-a', 'index.html');
|
||||
fs.mkdirSync(path.dirname(routeFile), { recursive: true });
|
||||
fs.writeFileSync(routeFile, '<html></html>');
|
||||
|
||||
const xml = `
|
||||
<urlset>
|
||||
<url><loc>https://owner.github.io/repo/</loc></url>
|
||||
<url><loc>https://owner.github.io/repo/skill/agent-a</loc></url>
|
||||
</urlset>
|
||||
`;
|
||||
|
||||
const report = analyzeSitemap(xml);
|
||||
expect(() => assertPrerenderedSkillRoutes(report.skillUrls, distDir, report.normalizedRootPath)).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws when a prerendered skill file is missing', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const distDir = path.join(tmpDir, 'dist');
|
||||
|
||||
const xml = `
|
||||
<urlset>
|
||||
<url><loc>https://owner.github.io/repo/</loc></url>
|
||||
<url><loc>https://owner.github.io/repo/skill/agent-a</loc></url>
|
||||
</urlset>
|
||||
`;
|
||||
|
||||
const report = analyzeSitemap(xml);
|
||||
expect(() => assertPrerenderedSkillRoutes(report.skillUrls, distDir, report.normalizedRootPath)).toThrow(
|
||||
'Missing prerendered page for skill route',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects missing social image tags', () => {
|
||||
const html = `
|
||||
<html>
|
||||
<head>
|
||||
<meta property="og:image" content="https://example.com/social-card.svg" />
|
||||
<meta name="twitter:image:alt" content="Catalog social preview" />
|
||||
</head>
|
||||
</html>
|
||||
`;
|
||||
|
||||
expect(() => assertIndexSocialMeta(html)).toThrow('twitter:image');
|
||||
});
|
||||
|
||||
it('requires manifest identity and theme fields', () => {
|
||||
const manifest = JSON.stringify(
|
||||
{
|
||||
name: 'Antigravity',
|
||||
short_name: 'AG',
|
||||
theme_color: '#112233',
|
||||
description: 'desc',
|
||||
icons: [{ src: 'icon.svg' }],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
expect(() => assertManifest(manifest)).not.toThrow();
|
||||
});
|
||||
});
|
||||
9
apps/web-app/src/hooks/usePageMeta.ts
Normal file
9
apps/web-app/src/hooks/usePageMeta.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { SeoMeta } from '../types';
|
||||
import { setPageMeta } from '../utils/seo';
|
||||
|
||||
export function usePageMeta(meta: SeoMeta): void {
|
||||
useEffect(() => {
|
||||
setPageMeta(meta);
|
||||
}, [meta.title, meta.description, meta.canonicalPath, meta.ogTitle, meta.ogDescription, meta.twitterCard, meta.ogImage]);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import debounce from 'lodash.debounce';
|
||||
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 } from '../utils/seo';
|
||||
|
||||
export function Home(): React.ReactElement {
|
||||
const { skills, stars, loading, refreshSkills } = useSkills();
|
||||
@@ -14,6 +16,18 @@ export function Home(): React.ReactElement {
|
||||
const [sortBy, setSortBy] = useState('default');
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState<SyncMessage | null>(null);
|
||||
const [commandCopied, setCommandCopied] = useState(false);
|
||||
const installCommand = 'npx antigravity-awesome-skills';
|
||||
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';
|
||||
|
||||
usePageMeta(buildHomeMeta(skills.length));
|
||||
|
||||
const copyInstallCommand = async () => {
|
||||
await navigator.clipboard.writeText(installCommand);
|
||||
setCommandCopied(true);
|
||||
window.setTimeout(() => setCommandCopied(false), 2000);
|
||||
};
|
||||
|
||||
// Debounce search input to avoid excessive filtering on every keystroke
|
||||
const debouncedSetSearch = useCallback(
|
||||
@@ -97,10 +111,54 @@ export function Home(): React.ReactElement {
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-8rem)]">
|
||||
<div className="space-y-8 mb-8">
|
||||
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-6 sm:p-7 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-3">
|
||||
Take action
|
||||
</p>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
Discover, install, and use trusted AI skills in minutes
|
||||
</h2>
|
||||
<p className="mt-3 text-sm sm:text-base leading-relaxed text-slate-600 dark:text-slate-300 max-w-4xl">
|
||||
Antigravity Awesome Skills is a discoverable catalog of installable capabilities for AI assistants.
|
||||
Install once, then test the highest-value skill directly from your terminal without waiting for documentation hops.
|
||||
Search, filter, then copy a ready-to-run prompt in one pass.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-col gap-3 sm:flex-row sm:items-stretch">
|
||||
<button
|
||||
onClick={copyInstallCommand}
|
||||
className="inline-flex items-center justify-center rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-700"
|
||||
>
|
||||
{commandCopied ? 'Copied install command' : 'Copy install command'}
|
||||
</button>
|
||||
<a
|
||||
href={installLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-indigo-600 text-sm font-semibold text-indigo-700 dark:text-indigo-200 px-4 py-2.5 hover:bg-indigo-50 dark:hover:bg-slate-800"
|
||||
>
|
||||
Install with npm
|
||||
</a>
|
||||
<a
|
||||
href={docsLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-slate-200 dark:border-slate-700 text-sm font-semibold text-slate-700 dark:text-slate-200 px-4 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-800"
|
||||
>
|
||||
Read getting started docs
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-slate-500 dark:text-slate-400">
|
||||
Recommended command:
|
||||
<span className="ml-2 rounded-md bg-slate-100 dark:bg-slate-800 px-2 py-1 font-mono">{installCommand}</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:justify-between md:space-y-0">
|
||||
<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 {skills.length} agentic capabilities for your AI assistant.</p>
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
Discover {Math.max(skills.length, APP_HOME_CATALOG_COUNT)}+ agentic capabilities for your AI assistant.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{syncMsg && (
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useParams, Link } from 'react-router-dom';
|
||||
import { ArrowLeft, Copy, Check, FileCode, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { SkillStarButton } from '../components/SkillStarButton';
|
||||
import { useSkills } from '../context/SkillContext';
|
||||
import { usePageMeta } from '../hooks/usePageMeta';
|
||||
import { buildSkillFallbackMeta, buildSkillMeta, selectTopSkills } from '../utils/seo';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
|
||||
@@ -58,8 +60,36 @@ export function SkillDetail(): React.ReactElement {
|
||||
const [copiedFull, setCopiedFull] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [customContext, setCustomContext] = useState('');
|
||||
|
||||
const [commandCopied, setCommandCopied] = useState(false);
|
||||
const installCommand = 'npx antigravity-awesome-skills';
|
||||
const skill = useMemo(() => skills.find(s => s.id === id), [skills, id]);
|
||||
|
||||
const topPrioritySkills = useMemo(() => selectTopSkills(skills), [skills]);
|
||||
const topPrioritySkillSet = useMemo(() => new Set(topPrioritySkills.map(topSkill => topSkill.id)), [topPrioritySkills]);
|
||||
|
||||
const canonicalPath = useMemo(() => {
|
||||
const safeId = id ? id : 'skill';
|
||||
return `/skill/${encodeURIComponent(safeId)}`;
|
||||
}, [id]);
|
||||
|
||||
const isPriority = useMemo(() => {
|
||||
if (!skill) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return topPrioritySkillSet.has(skill.id);
|
||||
}, [skill, topPrioritySkillSet]);
|
||||
|
||||
usePageMeta(
|
||||
useMemo(() => {
|
||||
if (!skill) {
|
||||
return buildSkillFallbackMeta(id || 'skill');
|
||||
}
|
||||
|
||||
return buildSkillMeta(skill, isPriority, canonicalPath);
|
||||
}, [id, skill, isPriority, canonicalPath])
|
||||
);
|
||||
|
||||
const starCount = useMemo(() => (id ? stars[id] || 0 : 0), [stars, id]);
|
||||
const { frontmatter, body: markdownBody } = useMemo(() => splitFrontmatter(content), [content]);
|
||||
const frontmatterRows = useMemo(() => parseFrontmatterRows(frontmatter), [frontmatter]);
|
||||
@@ -104,6 +134,12 @@ export function SkillDetail(): React.ReactElement {
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const copyInstallCommand = async () => {
|
||||
await navigator.clipboard.writeText(installCommand);
|
||||
setCommandCopied(true);
|
||||
window.setTimeout(() => setCommandCopied(false), 2000);
|
||||
};
|
||||
|
||||
const copyFullToClipboard = () => {
|
||||
const finalPrompt = customContext.trim()
|
||||
? `${content}\n\nContext:\n${customContext}`
|
||||
@@ -201,6 +237,26 @@ export function SkillDetail(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-white dark:bg-slate-900 p-6 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm">
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 dark:border-slate-700 dark:bg-slate-900 p-4 mb-4">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400 font-semibold">
|
||||
Use it now
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
Start quickly: install the package, open your workspace, and run this skill prompt directly.
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<code className="inline-block rounded-md bg-slate-900 text-slate-50 px-3 py-2 text-sm font-mono border border-slate-800">
|
||||
{installCommand}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyInstallCommand}
|
||||
className="inline-flex items-center text-sm font-medium text-indigo-700 hover:text-indigo-600 dark:text-indigo-300 dark:hover:text-indigo-200"
|
||||
>
|
||||
{commandCopied ? 'Copied' : 'Copy command'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label htmlFor="context" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Interactive Prompt Builder (Optional)
|
||||
</label>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { Home } from '../Home';
|
||||
import { renderWithRouter } from '../../utils/testUtils';
|
||||
import { createMockSkill } from '../../factories/skill';
|
||||
@@ -70,6 +70,57 @@ describe('Home', () => {
|
||||
expect(screen.getByText('@Skill 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should set homepage SEO metadata', async () => {
|
||||
const mockSkills = [
|
||||
createMockSkill({ id: 'skill-1', name: 'Skill 1' }),
|
||||
];
|
||||
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: mockSkills,
|
||||
stars: {},
|
||||
loading: false,
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />, { useProvider: false });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.title).toContain('Antigravity Awesome Skills');
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: /Copy install command/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/npx antigravity-awesome-skills/i)).toBeInTheDocument();
|
||||
expect(document.querySelector('meta[property="og:title"]')).toHaveAttribute(
|
||||
'content',
|
||||
expect.stringContaining('Antigravity Awesome Skills'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should copy install command from hero CTA', async () => {
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: [],
|
||||
stars: {},
|
||||
loading: false,
|
||||
});
|
||||
|
||||
renderWithRouter(<Home />, { useProvider: false });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Copy install command/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Copy install command/i }));
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('npx antigravity-awesome-skills');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search and Filtering', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { SkillDetail } from '../SkillDetail';
|
||||
import { renderWithRouter } from '../../utils/testUtils';
|
||||
import { createMockSkill } from '../../factories/skill';
|
||||
@@ -104,6 +104,11 @@ describe('SkillDetail', () => {
|
||||
expect(screen.getByText('@react-patterns')).toBeInTheDocument();
|
||||
expect(screen.getByText('React design patterns and best practices')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('markdown-content')).toHaveTextContent('This is the skill content.');
|
||||
expect(document.title).toContain('react-patterns');
|
||||
expect(document.querySelector('meta[name=\"twitter:title\"]')).toHaveAttribute(
|
||||
'content',
|
||||
'@react-patterns | Antigravity Awesome Skills',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,6 +128,7 @@ describe('SkillDetail', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Error Loading Skill/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Skill not found in registry/i)).toBeInTheDocument();
|
||||
expect(document.title).toContain('nonexistent');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -157,6 +163,43 @@ describe('SkillDetail', () => {
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Use @click-test');
|
||||
});
|
||||
|
||||
it('should copy install command when copy command CTA is clicked', async () => {
|
||||
const mockSkill = createMockSkill({ id: 'click-install', name: 'click-install' });
|
||||
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: [mockSkill],
|
||||
stars: {},
|
||||
loading: false,
|
||||
});
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'Content',
|
||||
});
|
||||
|
||||
renderWithRouter(<SkillDetail />, {
|
||||
route: '/skill/click-install',
|
||||
path: '/skill/:id',
|
||||
useProvider: false,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Copy command/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Copy command/i }));
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('npx antigravity-awesome-skills');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Star button integration', () => {
|
||||
|
||||
@@ -33,3 +33,20 @@ export interface SyncMessage {
|
||||
export interface CategoryStats {
|
||||
[category: string]: number;
|
||||
}
|
||||
|
||||
export type TwitterCard = 'summary' | 'summary_large_image';
|
||||
|
||||
export type SeoJsonLd = Record<string, unknown> | Record<string, unknown>[];
|
||||
export type SeoJsonLdFactory = (canonicalUrl: string) => SeoJsonLd;
|
||||
export type SeoJsonLdValue = SeoJsonLd | SeoJsonLdFactory;
|
||||
|
||||
export interface SeoMeta {
|
||||
title: string;
|
||||
description: string;
|
||||
canonicalPath: string;
|
||||
ogTitle?: string;
|
||||
ogDescription?: string;
|
||||
ogImage?: string;
|
||||
twitterCard?: TwitterCard;
|
||||
jsonLd?: SeoJsonLdValue | SeoJsonLdValue[];
|
||||
}
|
||||
|
||||
153
apps/web-app/src/utils/__tests__/seo.test.ts
Normal file
153
apps/web-app/src/utils/__tests__/seo.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Skill } from '../../types';
|
||||
import {
|
||||
DEFAULT_SOCIAL_IMAGE,
|
||||
buildHomeMeta,
|
||||
buildSkillFallbackMeta,
|
||||
buildSkillMeta,
|
||||
getCanonicalUrl,
|
||||
isTopSkill,
|
||||
selectTopSkills,
|
||||
setPageMeta,
|
||||
toCanonicalPath,
|
||||
} from '../seo';
|
||||
|
||||
function createSkill(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'skill-alpha',
|
||||
path: 'skills/skill-alpha',
|
||||
name: 'skill-alpha',
|
||||
category: 'ai',
|
||||
description: 'Base AI skill',
|
||||
source: 'community',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SEO helpers', () => {
|
||||
it('builds homepage metadata with the canonical catalog message', () => {
|
||||
const meta = buildHomeMeta(10);
|
||||
|
||||
expect(meta.title).toContain('1,273+');
|
||||
expect(meta.description).toContain('1273+ installable agentic skills');
|
||||
expect(meta.canonicalPath).toBe('/');
|
||||
expect(meta.ogTitle).toBe(meta.title);
|
||||
expect(meta.ogImage).toBe(DEFAULT_SOCIAL_IMAGE);
|
||||
expect(typeof meta.jsonLd).toBe('function');
|
||||
});
|
||||
|
||||
it('selects top skills using stars then date then name ordering', () => {
|
||||
const catalog = [
|
||||
createSkill({ id: 'skill-oldest', name: 'Alpha', stars: 4, date_added: '2026-01-01' }),
|
||||
createSkill({ id: 'skill-newest', name: 'Beta', stars: 4, date_added: '2026-03-01' }),
|
||||
createSkill({ id: 'skill-popular', name: 'Gamma', stars: 5, date_added: '2026-02-01' }),
|
||||
createSkill({ id: 'skill-missing', name: 'Delta', date_added: null as unknown as string }),
|
||||
createSkill({ id: 'skill-fallback', name: 'Epsilon' }),
|
||||
];
|
||||
|
||||
const top = selectTopSkills(catalog, 3);
|
||||
|
||||
expect(top.map(skill => skill.id)).toEqual(['skill-popular', 'skill-newest', 'skill-oldest']);
|
||||
});
|
||||
|
||||
it('returns valid priority markers for top and non-top skills', () => {
|
||||
const catalog = [
|
||||
createSkill({ id: 'priority', stars: 10, date_added: '2026-03-01' }),
|
||||
createSkill({ id: 'secondary', stars: 5, date_added: '2026-02-01' }),
|
||||
];
|
||||
|
||||
expect(isTopSkill('priority', catalog, 1)).toBe(true);
|
||||
expect(isTopSkill('secondary', catalog, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('builds skill metadata with a rich description', () => {
|
||||
const skill = createSkill({
|
||||
id: 'react',
|
||||
name: 'react-patterns',
|
||||
description: 'Learn practical React patterns.',
|
||||
category: 'frontend',
|
||||
source: 'community',
|
||||
date_added: '2026-03-01',
|
||||
});
|
||||
|
||||
const meta = buildSkillMeta(skill, true, '/skill/react-patterns');
|
||||
|
||||
expect(meta.title).toContain('react-patterns');
|
||||
expect(meta.description).toContain('Added 2026-03-01.');
|
||||
expect(meta.canonicalPath).toBe('/skill/react-patterns');
|
||||
expect(meta.ogTitle).toContain('@react-patterns');
|
||||
expect(meta.ogImage).toBe(DEFAULT_SOCIAL_IMAGE);
|
||||
expect(typeof meta.jsonLd).toBe('function');
|
||||
});
|
||||
|
||||
it('returns coherent fallback metadata for unresolved skill ids', () => {
|
||||
const meta = buildSkillFallbackMeta('sample-skill');
|
||||
|
||||
expect(meta.title).toContain('sample-skill');
|
||||
expect(meta.description).toContain('loading');
|
||||
expect(meta.canonicalPath).toBe('/skill/sample-skill');
|
||||
expect(meta.ogDescription).toContain('loading');
|
||||
expect(meta.ogImage).toBe(DEFAULT_SOCIAL_IMAGE);
|
||||
expect(typeof meta.jsonLd).toBe('function');
|
||||
});
|
||||
|
||||
it('builds safe fallback values for skill metadata with partial data', () => {
|
||||
const partialSkill = {
|
||||
id: 'partial',
|
||||
path: 'skills/partial',
|
||||
name: '',
|
||||
category: '',
|
||||
description: '',
|
||||
source: '',
|
||||
} as Skill;
|
||||
|
||||
const meta = buildSkillMeta(partialSkill, false, '/skill/partial');
|
||||
|
||||
expect(meta.title).toContain('Unnamed skill');
|
||||
expect(meta.description).toContain('Installable AI skill');
|
||||
expect(meta.ogTitle).toContain('Unnamed skill');
|
||||
});
|
||||
|
||||
it('escapes fallback skill ids in canonical path', () => {
|
||||
const meta = buildSkillFallbackMeta('skill with spaces');
|
||||
|
||||
expect(meta.canonicalPath).toBe('/skill/skill%20with%20spaces');
|
||||
});
|
||||
|
||||
it('normalizes canonical paths consistently for seo utilities', () => {
|
||||
expect(toCanonicalPath('/')).toBe('/');
|
||||
expect(toCanonicalPath('skill/react/')).toBe('/skill/react');
|
||||
expect(toCanonicalPath('/skill//react/')).toBe('/skill/react');
|
||||
});
|
||||
|
||||
it('builds canonical urls with optional overrides', () => {
|
||||
expect(getCanonicalUrl('/skill/react', 'https://example.com/site')).toBe('https://example.com/site/skill/react');
|
||||
});
|
||||
|
||||
it('setPageMeta updates the same meta tags on repeated invocations', () => {
|
||||
document.head.innerHTML = '';
|
||||
|
||||
setPageMeta(buildHomeMeta(10));
|
||||
setPageMeta(buildSkillMeta({
|
||||
id: 'react-patterns',
|
||||
name: 'react-patterns',
|
||||
description: 'Description',
|
||||
category: 'frontend',
|
||||
path: 'skills/react-patterns',
|
||||
source: 'community',
|
||||
date_added: '2026-03-01',
|
||||
}, false, '/skill/react-patterns'));
|
||||
|
||||
expect(document.title).toContain('react-patterns');
|
||||
expect(document.querySelectorAll('meta[name="description"]')).toHaveLength(1);
|
||||
expect(document.querySelectorAll('meta[property="og:title"]')).toHaveLength(1);
|
||||
expect(document.querySelectorAll('link[rel="canonical"]')).toHaveLength(1);
|
||||
expect(document.querySelectorAll('meta[property="og:image"]')).toHaveLength(1);
|
||||
expect(document.querySelectorAll('meta[name="twitter:image"]')).toHaveLength(1);
|
||||
expect(document.querySelectorAll('meta[name="twitter:image:alt"]')).toHaveLength(1);
|
||||
expect(document.querySelectorAll('script[data-seo-jsonld="true"]')).toHaveLength(4);
|
||||
expect(document.querySelector('meta[name="robots"]')).toHaveAttribute('content', 'index, follow');
|
||||
expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href')).toContain('/skill/react-patterns');
|
||||
});
|
||||
|
||||
});
|
||||
348
apps/web-app/src/utils/seo.ts
Normal file
348
apps/web-app/src/utils/seo.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import type { SeoJsonLdValue, SeoMeta, TwitterCard, Skill } from '../types';
|
||||
|
||||
export const APP_HOME_CATALOG_COUNT = 1273;
|
||||
export const DEFAULT_TOP_SKILL_COUNT = 40;
|
||||
export const DEFAULT_SOCIAL_IMAGE = 'social-card.svg';
|
||||
const SITE_NAME = 'Antigravity Awesome Skills';
|
||||
|
||||
export function toCanonicalPath(pathname: string): string {
|
||||
if (!pathname || pathname === '/') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const prefixed = pathname.startsWith('/') ? pathname : `/${pathname}`;
|
||||
const compacted = prefixed.replace(/\/{2,}/g, '/');
|
||||
const normalized = compacted.endsWith('/') ? compacted.slice(0, -1) : compacted;
|
||||
return normalized || '/';
|
||||
}
|
||||
|
||||
export function getCanonicalUrl(canonicalPath: string, siteBaseUrl?: string): string {
|
||||
const base = toCanonicalPath(canonicalPath);
|
||||
const siteBase = siteBaseUrl?.trim() || window.location.origin;
|
||||
const normalizedBase = siteBase.replace(/\/+$/, '');
|
||||
return `${normalizedBase}${base === '/' ? '/' : base}`;
|
||||
}
|
||||
|
||||
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)}`;
|
||||
}
|
||||
|
||||
export function getAbsoluteAssetUrl(assetPath: string): string {
|
||||
const normalizedAsset = toCanonicalPath(assetPath);
|
||||
return getAssetCanonicalUrl(normalizedAsset);
|
||||
}
|
||||
|
||||
function getCatalogBaseUrl(canonicalUrl: string): string {
|
||||
try {
|
||||
const parsed = new URL(canonicalUrl);
|
||||
const strippedSkillPath = parsed.pathname.replace(/\/skill\/[^/]+\/?$/, '/');
|
||||
const normalizedPath = strippedSkillPath.endsWith('/') ? strippedSkillPath : `${strippedSkillPath}/`;
|
||||
const normalizedCatalog = normalizedPath === '' ? '/' : normalizedPath;
|
||||
return `${parsed.origin}${normalizedCatalog}`;
|
||||
} catch (_error) {
|
||||
return canonicalUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function buildOrganizationSchema(): Record<string, unknown> {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: SITE_NAME,
|
||||
url: 'https://github.com/sickn33/antigravity-awesome-skills',
|
||||
brand: {
|
||||
'@type': 'Brand',
|
||||
name: SITE_NAME,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildWebSiteSchema(canonicalUrl: string): Record<string, unknown> {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: SITE_NAME,
|
||||
url: getCatalogBaseUrl(canonicalUrl),
|
||||
inLanguage: 'en',
|
||||
};
|
||||
}
|
||||
|
||||
function ensureMetaTag(name: string, content: string, attributeName: 'name' | 'property'): void {
|
||||
const selector = `meta[${attributeName}="${name}"]`;
|
||||
let tag = document.querySelector(selector) as HTMLMetaElement | null;
|
||||
|
||||
if (!tag) {
|
||||
tag = document.createElement('meta');
|
||||
tag.setAttribute(attributeName, name);
|
||||
document.head.appendChild(tag);
|
||||
}
|
||||
|
||||
tag.setAttribute('content', content);
|
||||
}
|
||||
|
||||
function resolveJsonLdValue(value: SeoJsonLdValue, canonicalUrl: string): Array<Record<string, unknown>> | null {
|
||||
if (typeof value === 'function') {
|
||||
const resolved = value(canonicalUrl);
|
||||
|
||||
if (Array.isArray(resolved)) {
|
||||
return resolved as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
return resolved ? [resolved] : null;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
return value ? [value as Record<string, unknown>] : null;
|
||||
}
|
||||
|
||||
function ensureJsonLdTag(rawJsonLd: Record<string, unknown>): void {
|
||||
const serialized = JSON.stringify(rawJsonLd);
|
||||
const tag = document.createElement('script');
|
||||
tag.type = 'application/ld+json';
|
||||
tag.setAttribute('data-seo-jsonld', 'true');
|
||||
tag.textContent = serialized;
|
||||
document.head.appendChild(tag);
|
||||
}
|
||||
|
||||
export function setPageMeta(meta: SeoMeta): void {
|
||||
const title = meta.title.trim();
|
||||
const description = meta.description.trim();
|
||||
const canonicalPath = toCanonicalPath(meta.canonicalPath);
|
||||
const canonical = getAssetCanonicalUrl(canonicalPath);
|
||||
const jsonLdEntries = meta.jsonLd ? (Array.isArray(meta.jsonLd) ? meta.jsonLd : [meta.jsonLd]) : [];
|
||||
const ogTitle = meta.ogTitle?.trim() || title;
|
||||
const ogDescription = meta.ogDescription?.trim() || description;
|
||||
const twitterCard: TwitterCard = meta.twitterCard || 'summary_large_image';
|
||||
const ogImage = (meta.ogImage || DEFAULT_SOCIAL_IMAGE).trim();
|
||||
|
||||
document.title = title;
|
||||
ensureMetaTag('description', description, 'name');
|
||||
|
||||
ensureMetaTag('og:type', 'website', 'property');
|
||||
ensureMetaTag('og:title', ogTitle, 'property');
|
||||
ensureMetaTag('og:description', ogDescription, 'property');
|
||||
ensureMetaTag('og:site_name', SITE_NAME, 'property');
|
||||
ensureMetaTag('og:url', canonical, 'property');
|
||||
|
||||
ensureMetaTag('twitter:card', twitterCard, 'name');
|
||||
ensureMetaTag('twitter:title', ogTitle, 'name');
|
||||
ensureMetaTag('twitter:description', ogDescription, 'name');
|
||||
ensureMetaTag('twitter:image:alt', `${meta.ogTitle || title} preview`, 'name');
|
||||
ensureMetaTag('og:image', getAbsoluteAssetUrl(ogImage), 'property');
|
||||
ensureMetaTag('twitter:image', getAbsoluteAssetUrl(ogImage), 'name');
|
||||
|
||||
let canonicalLink = document.querySelector('link[rel="canonical"]') as HTMLLinkElement | null;
|
||||
|
||||
if (!canonicalLink) {
|
||||
canonicalLink = document.createElement('link');
|
||||
canonicalLink.setAttribute('rel', 'canonical');
|
||||
document.head.appendChild(canonicalLink);
|
||||
}
|
||||
|
||||
canonicalLink.setAttribute('href', canonical);
|
||||
|
||||
const jsonLdElements = Array.from(document.querySelectorAll('script[data-seo-jsonld="true"]')) as HTMLScriptElement[];
|
||||
jsonLdElements.forEach((element) => {
|
||||
element.remove();
|
||||
});
|
||||
|
||||
for (const jsonLdValue of jsonLdEntries) {
|
||||
const resolvedValues = resolveJsonLdValue(jsonLdValue, canonical);
|
||||
if (!resolvedValues) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const resolved of resolvedValues) {
|
||||
ensureJsonLdTag(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
ensureMetaTag('robots', 'index, follow', 'name');
|
||||
}
|
||||
|
||||
export function parseDateString(dateValue: string | undefined): number {
|
||||
if (!dateValue) return 0;
|
||||
const ts = Date.parse(dateValue);
|
||||
return Number.isNaN(ts) ? 0 : ts;
|
||||
}
|
||||
|
||||
export function selectTopSkills(skills: ReadonlyArray<Skill>, limit = DEFAULT_TOP_SKILL_COUNT): Skill[] {
|
||||
const maxLimit = Math.max(limit, 0);
|
||||
|
||||
if (maxLimit === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...skills]
|
||||
.map((skill, index) => {
|
||||
const stars = Number((skill as Skill & { stars?: number }).stars) || 0;
|
||||
const dateWeight = parseDateString(skill.date_added);
|
||||
return {
|
||||
skill,
|
||||
index,
|
||||
stars,
|
||||
dateWeight,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.stars !== b.stars) {
|
||||
return b.stars - a.stars;
|
||||
}
|
||||
|
||||
if (a.dateWeight !== b.dateWeight) {
|
||||
return b.dateWeight - a.dateWeight;
|
||||
}
|
||||
|
||||
const nameCompare = a.skill.name.localeCompare(b.skill.name, undefined, { sensitivity: 'base' });
|
||||
if (nameCompare !== 0) {
|
||||
return nameCompare;
|
||||
}
|
||||
|
||||
return a.index - b.index;
|
||||
})
|
||||
.slice(0, maxLimit)
|
||||
.map(({ skill }) => skill);
|
||||
}
|
||||
|
||||
export function isTopSkill(skillId: string, skills: ReadonlyArray<Skill>, limit = DEFAULT_TOP_SKILL_COUNT): boolean {
|
||||
return selectTopSkills(skills, limit).some((entry) => entry.id === skillId);
|
||||
}
|
||||
|
||||
export function buildHomeMeta(skillCount: number): SeoMeta {
|
||||
const visibleCount = Math.max(skillCount, APP_HOME_CATALOG_COUNT);
|
||||
const title = 'Antigravity Awesome Skills | 1,273+ installable AI skills catalog';
|
||||
const description = `Explore ${visibleCount}+ installable agentic skills and prompt templates. Discover what fits your workflow, copy prompts fast, and launch AI-powered actions with confidence.`;
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
canonicalPath: '/',
|
||||
ogTitle: title,
|
||||
ogDescription: description,
|
||||
ogImage: DEFAULT_SOCIAL_IMAGE,
|
||||
twitterCard: 'summary_large_image',
|
||||
jsonLd: (canonicalUrl: string) => [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: 'Antigravity Awesome Skills',
|
||||
description,
|
||||
url: canonicalUrl,
|
||||
isPartOf: buildWebSiteSchema(canonicalUrl),
|
||||
mainEntity: {
|
||||
'@type': 'ItemList',
|
||||
name: 'Antigravity Awesome Skills catalog',
|
||||
},
|
||||
},
|
||||
buildOrganizationSchema(),
|
||||
buildWebSiteSchema(canonicalUrl),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSkillMeta(skill: Skill, isPriority = false, canonicalPath = '/'): SeoMeta {
|
||||
const safeName = skill.name || 'Unnamed skill';
|
||||
const safeDescription = skill.description || 'Installable AI skill';
|
||||
const safeCategory = skill.category || 'Tools';
|
||||
const safeSource = skill.source || 'community contributors';
|
||||
const added = skill.date_added ? `Added ${skill.date_added}. ` : '';
|
||||
const trust = isPriority ? ` Prioritized in our catalog for quality and reuse. ` : ' ';
|
||||
const title = `${safeName} | Antigravity Awesome Skills`;
|
||||
const description = `${added}Use the @${safeName} skill for ${safeDescription} (${safeCategory}, ${safeSource}).${trust}Install and run quickly with your CLI workflow.`;
|
||||
return {
|
||||
title,
|
||||
description: description.trim(),
|
||||
canonicalPath,
|
||||
ogTitle: `@${safeName} | Antigravity Awesome Skills`,
|
||||
ogDescription: description,
|
||||
ogImage: DEFAULT_SOCIAL_IMAGE,
|
||||
twitterCard: 'summary',
|
||||
jsonLd: (canonicalUrl: string) => [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': canonicalUrl,
|
||||
name: `@${safeName}`,
|
||||
applicationCategory: safeCategory,
|
||||
description: description.trim(),
|
||||
url: canonicalUrl,
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
provider: {
|
||||
'@type': 'Organization',
|
||||
name: 'Antigravity Awesome Skills',
|
||||
},
|
||||
keywords: [safeCategory, safeSource],
|
||||
inLanguage: 'en',
|
||||
operatingSystem: 'Cross-platform',
|
||||
isPartOf: {
|
||||
'@type': 'CollectionPage',
|
||||
name: 'Antigravity Awesome Skills',
|
||||
url: getCatalogBaseUrl(canonicalUrl),
|
||||
},
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: safeName,
|
||||
inLanguage: 'en',
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
'@id': getCatalogBaseUrl(canonicalUrl),
|
||||
name: SITE_NAME,
|
||||
},
|
||||
},
|
||||
buildOrganizationSchema(),
|
||||
buildWebSiteSchema(canonicalUrl),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSkillFallbackMeta(skillId: string): SeoMeta {
|
||||
const safeId = skillId || 'skill';
|
||||
return {
|
||||
title: `${safeId} | Antigravity Awesome Skills`,
|
||||
description: 'Installable AI skill details are loading. Browse the catalog and launch the right skill with the antigravity-awesome-skills CLI.',
|
||||
canonicalPath: `/skill/${encodeURIComponent(safeId)}`,
|
||||
ogTitle: `@${safeId} | Antigravity Awesome Skills`,
|
||||
ogDescription: 'Installable AI skill details are loading. Browse the catalog and launch the right skill quickly.',
|
||||
ogImage: DEFAULT_SOCIAL_IMAGE,
|
||||
twitterCard: 'summary',
|
||||
jsonLd: (canonicalUrl: string) => [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': canonicalUrl,
|
||||
name: `@${safeId}`,
|
||||
description: 'Installable AI skill details are loading. Browse the catalog and launch the right skill quickly.',
|
||||
url: canonicalUrl,
|
||||
provider: {
|
||||
'@type': 'Organization',
|
||||
name: 'Antigravity Awesome Skills',
|
||||
},
|
||||
inLanguage: 'en',
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: `@${safeId}`,
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
'@id': getCatalogBaseUrl(canonicalUrl),
|
||||
name: SITE_NAME,
|
||||
},
|
||||
},
|
||||
buildOrganizationSchema(),
|
||||
buildWebSiteSchema(canonicalUrl),
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user