From c5671d1bc482187e859cfc37abae9dcf86836a0b Mon Sep 17 00:00:00 2001 From: sickn33 Date: Thu, 19 Mar 2026 19:23:30 +0100 Subject: [PATCH] feat(web-app): finalize SEO marketing layer for catalog routes --- .github/workflows/pages.yml | 12 + apps/web-app/index.html | 23 +- apps/web-app/package.json | 6 +- apps/web-app/public/manifest.webmanifest | 17 + apps/web-app/public/robots.txt | 7 + apps/web-app/public/sitemap.xml | 249 +++++++++++++ apps/web-app/public/social-card.svg | 17 + apps/web-app/scripts/generate-sitemap.js | 124 +++++++ apps/web-app/scripts/generate-sitemap.test.js | 50 +++ apps/web-app/scripts/prerender-routes.js | 285 ++++++++++++++ apps/web-app/scripts/verify-seo-assets.js | 248 +++++++++++++ .../web-app/scripts/verify-seo-assets.test.js | 142 +++++++ apps/web-app/src/hooks/usePageMeta.ts | 9 + apps/web-app/src/pages/Home.tsx | 60 ++- apps/web-app/src/pages/SkillDetail.tsx | 58 ++- .../web-app/src/pages/__tests__/Home.test.tsx | 53 ++- .../src/pages/__tests__/SkillDetail.test.tsx | 45 ++- apps/web-app/src/types/index.ts | 17 + apps/web-app/src/utils/__tests__/seo.test.ts | 153 ++++++++ apps/web-app/src/utils/seo.ts | 348 ++++++++++++++++++ 20 files changed, 1911 insertions(+), 12 deletions(-) create mode 100644 apps/web-app/public/manifest.webmanifest create mode 100644 apps/web-app/public/robots.txt create mode 100644 apps/web-app/public/sitemap.xml create mode 100644 apps/web-app/public/social-card.svg create mode 100644 apps/web-app/scripts/generate-sitemap.js create mode 100644 apps/web-app/scripts/generate-sitemap.test.js create mode 100644 apps/web-app/scripts/prerender-routes.js create mode 100644 apps/web-app/scripts/verify-seo-assets.js create mode 100644 apps/web-app/scripts/verify-seo-assets.test.js create mode 100644 apps/web-app/src/hooks/usePageMeta.ts create mode 100644 apps/web-app/src/utils/__tests__/seo.test.ts create mode 100644 apps/web-app/src/utils/seo.ts diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 7fee4177..9103517b 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -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 diff --git a/apps/web-app/index.html b/apps/web-app/index.html index a1ef51d8..a9a3c70f 100644 --- a/apps/web-app/index.html +++ b/apps/web-app/index.html @@ -2,16 +2,25 @@ - + + - - - - - + + + + + + + + + + + + - Antigravity Skills | 950+ AI Agentic Skills Catalog + + Antigravity Awesome Skills | 1,273+ installable AI skills catalog
diff --git a/apps/web-app/package.json b/apps/web-app/package.json index f306bae3..a0d95862 100644 --- a/apps/web-app/package.json +++ b/apps/web-app/package.json @@ -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", diff --git a/apps/web-app/public/manifest.webmanifest b/apps/web-app/public/manifest.webmanifest new file mode 100644 index 00000000..aba8f5e1 --- /dev/null +++ b/apps/web-app/public/manifest.webmanifest @@ -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" + } + ] +} diff --git a/apps/web-app/public/robots.txt b/apps/web-app/public/robots.txt new file mode 100644 index 00000000..0121269f --- /dev/null +++ b/apps/web-app/public/robots.txt @@ -0,0 +1,7 @@ +User-agent: * +Allow: / +Disallow: +# Keep fallback for single-page app routes +Allow: /404.html + +Sitemap: ./sitemap.xml diff --git a/apps/web-app/public/sitemap.xml b/apps/web-app/public/sitemap.xml new file mode 100644 index 00000000..aecf91db --- /dev/null +++ b/apps/web-app/public/sitemap.xml @@ -0,0 +1,249 @@ + + + + http://localhost/ + 2026-03-19 + daily + 1.0 + + + http://localhost/skill/astro + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/hono + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/openclaw-github-repo-commander + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/pydantic-ai + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/sveltekit + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/goldrush-api + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/progressive-web-app + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/trpc-fullstack + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/vibers-code-review + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/ai-engineering-toolkit + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/ai-native-cli + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/latex-paper-conversion + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/antigravity-skill-orchestrator + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/k6-load-testing + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/recallmax + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/tool-use-guardian + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/acceptance-orchestrator + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/closed-loop-delivery + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/create-issue-gate + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/electron-development + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/llm-structured-output + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/ai-md + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/explain-like-socrates + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/interview-coach + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/keyword-extractor + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/local-llm-expert + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/skill-check + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/yes-md + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/blueprint + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/lex + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/pipecat-friday-agent + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/progressive-estimation + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/sankhya-dashboard-html-jsp-custom-best-pratices + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/seek-and-analyze-video + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/animejs-animation + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/antigravity-design-expert + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/audit-skills + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/daily + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/design-spells + 2026-03-19 + weekly + 0.7 + + + http://localhost/skill/frontend-slides + 2026-03-19 + weekly + 0.7 + + diff --git a/apps/web-app/public/social-card.svg b/apps/web-app/public/social-card.svg new file mode 100644 index 00000000..8ceba44c --- /dev/null +++ b/apps/web-app/public/social-card.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + Antigravity Awesome Skills + 1,273+ installable AI skills + Discover, install and use ready-to-run agentic skills. + npx antigravity-awesome-skills + diff --git a/apps/web-app/scripts/generate-sitemap.js b/apps/web-app/scripts/generate-sitemap.js new file mode 100644 index 00000000..d960bbea --- /dev/null +++ b/apps/web-app/scripts/generate-sitemap.js @@ -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, '''); +} + +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 ` \n ${escapeXml(href)}\n ${lastmod}\n ${pathName === '/' ? 'daily' : 'weekly'}\n ${pathName === '/' ? '1.0' : '0.7'}\n `; + }) + .join('\n'); + + return `\n\n${urlsXml}\n\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(); +} diff --git a/apps/web-app/scripts/generate-sitemap.test.js b/apps/web-app/scripts/generate-sitemap.test.js new file mode 100644 index 00000000..1291ec01 --- /dev/null +++ b/apps/web-app/scripts/generate-sitemap.test.js @@ -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/'); + expect(xml).toContain('https://example.com/skill/gamma'); + 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/'); + expect(xml).toContain('/safe%26id'); + }); + + 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/'); + expect(xml).not.toContain('https://example.com/skill'); + }); +}); diff --git a/apps/web-app/scripts/prerender-routes.js b/apps/web-app/scripts/prerender-routes.js new file mode 100644 index 00000000..957f06b9 --- /dev/null +++ b/apps/web-app/scripts/prerender-routes.js @@ -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, '''); +} + +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(`]*${attributeName}=["']${attributeEscaped}["'][^>]*>`, 'i'); + const replacement = ``; + return replaceHtmlTag(html, pattern, replacement, ''); +} + +function setLinkTag(html, relation, href) { + const pattern = new RegExp(`]*rel=["']${escapeRegExp(relation)}["'][^>]*>`, 'i'); + const replacement = ``; + return replaceHtmlTag(html, pattern, replacement, ''); +} + +function setTitleTag(html, title) { + const pattern = /]*>[\s\S]*?<\/title>/i; + const replacement = `${escapeHtml(title)}`; + return replaceHtmlTag(html, pattern, replacement, ''); +} + +function setJsonLdTag(html, payload) { + const cleaned = html.replace(/]*data-seo-jsonld="true"[^>]*>[\s\S]*?<\/script>\s*/g, ''); + const tag = ``; + return cleaned.replace('', `\n${tag}\n`); +} + +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(); diff --git a/apps/web-app/scripts/verify-seo-assets.js b/apps/web-app/scripts/verify-seo-assets.js new file mode 100644 index 00000000..f92070cf --- /dev/null +++ b/apps/web-app/scripts/verify-seo-assets.js @@ -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>/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( + `]*${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 entries.'); + assert(new Set(locations).size === locations.length, 'Sitemap contains duplicated 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.'); +} diff --git a/apps/web-app/scripts/verify-seo-assets.test.js b/apps/web-app/scripts/verify-seo-assets.test.js new file mode 100644 index 00000000..f7a89592 --- /dev/null +++ b/apps/web-app/scripts/verify-seo-assets.test.js @@ -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 = ` + + https://example.com/ + https://example.com/skill/agent-a + + `; + + 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 = ` + + https://owner.github.io/repo/ + https://owner.github.io/repo/skill/agent-a + https://owner.github.io/repo/skill/agent-b + + `; + + expect(() => assertSitemap(xml, { minSkillUrls: 2 })).not.toThrow(); + }); + + it('throws when sitemap has duplicated URLs', () => { + const xml = ` + + https://example.com/ + https://example.com/ + + `; + + 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 = ` + + + + + + + + `; + + 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, ''); + + const xml = ` + + https://owner.github.io/repo/ + https://owner.github.io/repo/skill/agent-a + + `; + + 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 = ` + + https://owner.github.io/repo/ + https://owner.github.io/repo/skill/agent-a + + `; + + 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 = ` + + + + + + + `; + + 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(); + }); +}); diff --git a/apps/web-app/src/hooks/usePageMeta.ts b/apps/web-app/src/hooks/usePageMeta.ts new file mode 100644 index 00000000..55c787ef --- /dev/null +++ b/apps/web-app/src/hooks/usePageMeta.ts @@ -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]); +} diff --git a/apps/web-app/src/pages/Home.tsx b/apps/web-app/src/pages/Home.tsx index 9c25de7f..79b28673 100644 --- a/apps/web-app/src/pages/Home.tsx +++ b/apps/web-app/src/pages/Home.tsx @@ -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(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 (
+
+

+ Take action +

+

+ Discover, install, and use trusted AI skills in minutes +

+

+ 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. +

+
+ + + Install with npm + + + Read getting started docs + +
+

+ Recommended command: + {installCommand} +

+
+

Explore Skills

-

Discover {skills.length} agentic capabilities for your AI assistant.

+

+ Discover {Math.max(skills.length, APP_HOME_CATALOG_COUNT)}+ agentic capabilities for your AI assistant. +

{syncMsg && ( diff --git a/apps/web-app/src/pages/SkillDetail.tsx b/apps/web-app/src/pages/SkillDetail.tsx index bc08e1d2..5f12f0ed 100644 --- a/apps/web-app/src/pages/SkillDetail.tsx +++ b/apps/web-app/src/pages/SkillDetail.tsx @@ -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(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 {
+
+

+ Use it now +

+

+ Start quickly: install the package, open your workspace, and run this skill prompt directly. +

+
+ + {installCommand} + + +
+
+ diff --git a/apps/web-app/src/pages/__tests__/Home.test.tsx b/apps/web-app/src/pages/__tests__/Home.test.tsx index c195e6a7..53777476 100644 --- a/apps/web-app/src/pages/__tests__/Home.test.tsx +++ b/apps/web-app/src/pages/__tests__/Home.test.tsx @@ -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(, { 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(, { 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', () => { diff --git a/apps/web-app/src/pages/__tests__/SkillDetail.test.tsx b/apps/web-app/src/pages/__tests__/SkillDetail.test.tsx index 32265c30..e7ad4df6 100644 --- a/apps/web-app/src/pages/__tests__/SkillDetail.test.tsx +++ b/apps/web-app/src/pages/__tests__/SkillDetail.test.tsx @@ -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(, { + 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', () => { diff --git a/apps/web-app/src/types/index.ts b/apps/web-app/src/types/index.ts index b40fd2c3..35e57f6b 100644 --- a/apps/web-app/src/types/index.ts +++ b/apps/web-app/src/types/index.ts @@ -33,3 +33,20 @@ export interface SyncMessage { export interface CategoryStats { [category: string]: number; } + +export type TwitterCard = 'summary' | 'summary_large_image'; + +export type SeoJsonLd = Record | Record[]; +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[]; +} diff --git a/apps/web-app/src/utils/__tests__/seo.test.ts b/apps/web-app/src/utils/__tests__/seo.test.ts new file mode 100644 index 00000000..574ca92c --- /dev/null +++ b/apps/web-app/src/utils/__tests__/seo.test.ts @@ -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 = {}) { + 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'); + }); + +}); diff --git a/apps/web-app/src/utils/seo.ts b/apps/web-app/src/utils/seo.ts new file mode 100644 index 00000000..914e2f9a --- /dev/null +++ b/apps/web-app/src/utils/seo.ts @@ -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 { + 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 { + 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> | null { + if (typeof value === 'function') { + const resolved = value(canonicalUrl); + + if (Array.isArray(resolved)) { + return resolved as Array>; + } + + return resolved ? [resolved] : null; + } + + if (Array.isArray(value)) { + return value as Array>; + } + + return value ? [value as Record] : null; +} + +function ensureJsonLdTag(rawJsonLd: Record): 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, 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, 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), + ], + }; +}