feat(web-app): finalize SEO marketing layer for catalog routes

This commit is contained in:
sickn33
2026-03-19 19:23:30 +01:00
parent bb2547a358
commit c5671d1bc4
20 changed files with 1911 additions and 12 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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",

View 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"
}
]
}

View File

@@ -0,0 +1,7 @@
User-agent: *
Allow: /
Disallow:
# Keep fallback for single-page app routes
Allow: /404.html
Sitemap: ./sitemap.xml

View 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>

View 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

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
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();
}

View 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&amp;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');
});
});

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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();

View 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.');
}

View 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();
});
});

View 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]);
}

View File

@@ -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 && (

View File

@@ -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>

View File

@@ -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', () => {

View File

@@ -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', () => {

View File

@@ -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[];
}

View 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');
});
});

View 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),
],
};
}