sync: SEO canonical URLs, curation improvements, batch scripts, stats fix

- Dynamic canonical URLs and alternates for all pages (SEO fix)
- Shared seo.ts utility for canonical path generation
- Improved duplicate detection: standalone > aggregator priority, forks/created_at tie-breakers
- browseReadyFilter now includes unique aggregator skills
- Stats aligned with browseReadyFilter (was showing 16k instead of 62k+)
- Re-review mechanism: content_hash changes trigger needs-re-review
- review_status field added to skills schema
- Batch security scan and quality score scripts
- Indexer Dockerfile updated for curation deps
- Removed roadmap from README

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-02-21 23:40:03 +03:30
parent 77917bf91f
commit 3f599a23fb
30 changed files with 1092 additions and 50 deletions

View File

@@ -2,6 +2,20 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { Code2, Globe, Shield } from 'lucide-react';
import { getPageAlternates } from '@/lib/seo';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/about'),
};
}
export default async function AboutPage({
params,

View File

@@ -3,6 +3,8 @@ import Link from 'next/link';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { Github, Heart, Users, Code, GitFork, ExternalLink, Database, Clock } from 'lucide-react';
import { getPageAlternates } from '@/lib/seo';
export const dynamic = 'force-dynamic';
@@ -65,6 +67,18 @@ function formatLicenseName(license: string, locale: string): string {
return names ? names[locale as 'en' | 'fa'] || names.en : license;
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/attribution'),
};
}
export default async function AttributionPage({
params,
}: {

View File

@@ -5,6 +5,8 @@ import { createDb, skillQueries, categoryQueries } from '@skillhub/db';
import { BrowseFilters, SearchBar, Pagination, ActiveFilters, EmptyState } from '@/components/BrowseFilters';
import { SkillCard } from '@/components/SkillCard';
import { toPersianNumber } from '@/lib/format-number';
import { getPageAlternates } from '@/lib/seo';
// Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic';
@@ -79,6 +81,18 @@ async function getSkills(params: {
}
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/browse'),
};
}
export default async function BrowsePage({ params, searchParams }: BrowsePageProps) {
const { locale } = await params;
setRequestLocale(locale);

View File

@@ -31,6 +31,8 @@ import {
} from 'lucide-react';
import { createDb, categoryQueries } from '@skillhub/db';
import { formatNumber } from '@/lib/format-number';
import { getPageAlternates } from '@/lib/seo';
// Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic';
@@ -96,6 +98,18 @@ async function getHierarchicalCategories() {
}
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/categories'),
};
}
export default async function CategoriesPage({
params,
}: {

View File

@@ -2,9 +2,23 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { ClaimForm } from '@/components/ClaimForm';
import { getPageAlternates } from '@/lib/seo';
export const dynamic = 'force-dynamic';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/claim'),
};
}
export default async function ClaimPage({
params,
}: {

View File

@@ -1,4 +1,5 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { getPageAlternates } from '@/lib/seo';
import Link from 'next/link';
import type { Metadata } from 'next';
import {
@@ -31,6 +32,7 @@ export async function generateMetadata({
return {
title: t('metadata.title'),
description: t('metadata.description'),
alternates: getPageAlternates(locale, '/claude-plugin'),
openGraph: {
title: t('metadata.title'),
description: t('metadata.description'),

View File

@@ -1,4 +1,18 @@
import { redirect } from 'next/navigation';
import { getPageAlternates } from '@/lib/seo';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/contact'),
};
}
export default async function ContactPage({
params,

View File

@@ -5,6 +5,20 @@ import { ApiEndpointSection } from '@/components/ApiEndpointSection';
import type { EndpointDef } from '@/components/ApiEndpointSection';
import Link from 'next/link';
import { ArrowLeft, ArrowRight, Search, FileCode, Users, Compass, Mail } from 'lucide-react';
import { getPageAlternates } from '@/lib/seo';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/docs/api'),
};
}
export default async function ApiDocsPage({
params,

View File

@@ -3,6 +3,20 @@ import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import Link from 'next/link';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { getPageAlternates } from '@/lib/seo';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/docs/cli'),
};
}
export default async function CliDocsPage({
params,

View File

@@ -6,6 +6,8 @@ import Link from 'next/link';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { createDb, skills, sql } from '@skillhub/db';
import { formatPromptSkillCount } from '@/lib/format-number';
import { getPageAlternates } from '@/lib/seo';
export const dynamic = 'force-dynamic';
@@ -15,7 +17,7 @@ async function getSkillCount(): Promise<string> {
const result = await db
.select({ count: sql<number>`count(*)::int` })
.from(skills)
.where(sql`${skills.isDuplicate} = false AND (${skills.skillType} IS NULL OR ${skills.skillType} != 'aggregator')`);
.where(sql`${skills.isDuplicate} = false`);
return formatPromptSkillCount(result[0]?.count ?? 16000);
} catch {
return '16,000+';
@@ -56,6 +58,18 @@ Search for unfamiliar tech or complex tasks. Only install "Pass" security status
};
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/docs/getting-started'),
};
}
export default async function GettingStartedPage({
params,
}: {

View File

@@ -3,6 +3,20 @@ import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { BookOpen, Terminal, Code } from 'lucide-react';
import Link from 'next/link';
import { getPageAlternates } from '@/lib/seo';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/docs'),
};
}
export default async function DocsPage({
params,

View File

@@ -6,6 +6,8 @@ import { Footer } from '@/components/Footer';
import { FavoritesList } from '@/components/FavoritesList';
import { FavoritesSignIn } from '@/components/FavoritesSignIn';
import { createDb, userQueries } from '@skillhub/db';
import { getPageAlternates } from '@/lib/seo';
// Force dynamic rendering
export const dynamic = 'force-dynamic';
@@ -14,6 +16,18 @@ interface FavoritesPageProps {
params: Promise<{ locale: string }>;
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/favorites'),
};
}
export default async function FavoritesPage({ params }: FavoritesPageProps) {
const { locale } = await params;
setRequestLocale(locale);

View File

@@ -5,6 +5,8 @@ import { createDb, skillQueries } from '@skillhub/db';
import { toPersianNumber } from '@/lib/format-number';
import { Pagination } from '@/components/BrowseFilters';
import { SkillCard } from '@/components/SkillCard';
import { getPageAlternates } from '@/lib/seo';
// Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic';
@@ -37,6 +39,18 @@ async function getFeaturedSkills(page: number, limit: number) {
}
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/featured'),
};
}
export default async function FeaturedPage({
params,
searchParams,

View File

@@ -7,6 +7,8 @@ import { Clock, RefreshCw } from 'lucide-react';
import { createDb, skillQueries } from '@skillhub/db';
import { toPersianNumber } from '@/lib/format-number';
import { Pagination } from '@/components/BrowseFilters';
import { getPageAlternates } from '@/lib/seo';
// Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic';
@@ -75,6 +77,18 @@ async function getTabCounts() {
}
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/new'),
};
}
export default async function NewSkillsPage({
params,
searchParams,

View File

@@ -7,9 +7,23 @@ import { createDb, skillQueries } from '@skillhub/db';
import { formatCompactNumber, toPersianNumber } from '@/lib/format-number';
import { ExternalLink, Download, Package, Eye, GitFork, ArrowUpDown, FolderGit2 } from 'lucide-react';
import Link from 'next/link';
import type { Metadata } from 'next';
import { getPageAlternates } from '@/lib/seo';
export const dynamic = 'force-dynamic';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; username: string }>;
}): Promise<Metadata> {
const { locale, username } = await params;
return {
title: `${decodeURIComponent(username)} | SkillHub`,
alternates: getPageAlternates(locale, `/owner/${username}`),
};
}
const ITEMS_PER_PAGE = 24;
type SortOption = 'popularity' | 'downloads' | 'stars';
@@ -183,11 +197,10 @@ export default async function OwnerPage({ params, searchParams }: OwnerPageProps
<span className="text-sm text-text-secondary">{t('repo.filter')}:</span>
<Link
href={buildUrl({ repo: '', page: 1 })}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
!activeRepo
className={`px-3 py-1 text-xs rounded-full transition-colors ${!activeRepo
? 'bg-primary text-primary-foreground font-medium'
: 'bg-surface-elevated text-text-secondary hover:bg-surface-subtle border border-border'
}`}
}`}
>
{t('repo.all')} ({locale === 'fa' ? toPersianNumber(stats.totalSkills) : stats.totalSkills})
</Link>
@@ -195,11 +208,10 @@ export default async function OwnerPage({ params, searchParams }: OwnerPageProps
<Link
key={r.repo}
href={buildUrl({ repo: r.repo, page: 1 })}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
activeRepo === r.repo
className={`px-3 py-1 text-xs rounded-full transition-colors ${activeRepo === r.repo
? 'bg-primary text-primary-foreground font-medium'
: 'bg-surface-elevated text-text-secondary hover:bg-surface-subtle border border-border'
}`}
}`}
>
{r.repo} ({locale === 'fa' ? toPersianNumber(r.skillCount) : r.skillCount})
</Link>
@@ -227,11 +239,10 @@ export default async function OwnerPage({ params, searchParams }: OwnerPageProps
<Link
key={opt.value}
href={buildUrl({ sort: opt.value, page: 1 })}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
sort === opt.value
className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${sort === opt.value
? 'bg-primary text-primary-foreground font-medium'
: 'bg-surface-elevated text-text-secondary hover:bg-surface-subtle border border-border'
}`}
}`}
>
{opt.label}
</Link>

View File

@@ -7,6 +7,8 @@ import { HeroSearch } from '@/components/HeroSearch';
import { SkillCard } from '@/components/SkillCard';
import { createDb, categoryQueries, skillQueries, skills, sql } from '@skillhub/db';
import { formatCompactNumber } from '@/lib/format-number';
import { getPageAlternates } from '@/lib/seo';
// Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic';
@@ -16,8 +18,8 @@ async function getStats() {
try {
const db = createDb();
// Browse-ready filter: exclude duplicates and aggregators
const browseReady = sql`${skills.isDuplicate} = false AND (${skills.skillType} IS NULL OR ${skills.skillType} != 'aggregator')`;
// Browse-ready filter: exclude duplicates (matches browseReadyFilter in queries.ts)
const browseReady = sql`${skills.isDuplicate} = false`;
// Get total skills count (browse-ready, SKILL.md only)
const skillsResult = await db
@@ -82,6 +84,18 @@ async function getFeaturedSkills() {
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/'),
};
}
export default async function HomePage({
params,
}: {

View File

@@ -3,6 +3,8 @@ import Link from 'next/link';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { Shield, Database, Cookie, Users, Lock, Mail, ArrowRight, ArrowLeft } from 'lucide-react';
import { getPageAlternates } from '@/lib/seo';
export const dynamic = 'force-dynamic';
@@ -15,6 +17,18 @@ const sectionIcons = {
contact: Mail,
};
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/privacy'),
};
}
export default async function PrivacyPage({
params,
}: {

View File

@@ -16,10 +16,32 @@ import { createDb, skillQueries } from '@skillhub/db';
import { FORMAT_LABELS } from 'skillhub-core';
import { formatCompactNumber } from '@/lib/format-number';
import { shouldCountView } from '@/lib/cache';
import type { Metadata } from 'next';
import { getPageAlternates } from '@/lib/seo';
// Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; id: string[] }>;
}): Promise<Metadata> {
const { locale, id } = await params;
const skillId = id.join('/');
// Optionally fetch skill name for title
const dbSkill = await getSkill(skillId);
const title = dbSkill ? `${dbSkill.name} | SkillHub` : 'SkillHub';
const description = dbSkill ? dbSkill.description : undefined;
return {
title,
description,
alternates: getPageAlternates(locale, `/skill/${skillId}`),
};
}
interface SkillPageProps {
params: Promise<{ locale: string; id: string[] }>;
}
@@ -71,9 +93,9 @@ export default async function SkillPage({ params }: SkillPageProps) {
const db = createDb();
shouldCountView(dbSkill.id, clientIp).then((shouldCount) => {
if (shouldCount) {
skillQueries.incrementViews(db, dbSkill.id).catch(() => {});
skillQueries.incrementViews(db, dbSkill.id).catch(() => { });
}
}).catch(() => {});
}).catch(() => { });
}
// Map database response to expected format

View File

@@ -2,6 +2,8 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { Mail, Bitcoin, ExternalLink } from 'lucide-react';
import { getPageAlternates } from '@/lib/seo';
export const dynamic = 'force-dynamic';
@@ -9,6 +11,18 @@ interface SupportPageProps {
params: Promise<{ locale: string }>;
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/support'),
};
}
export default async function SupportPage({ params }: SupportPageProps) {
const { locale } = await params;
setRequestLocale(locale);

View File

@@ -3,6 +3,8 @@ import Link from 'next/link';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { FileText, UserCheck, AlertTriangle, Scale, Trash2, RefreshCw, ArrowRight, ArrowLeft } from 'lucide-react';
import { getPageAlternates } from '@/lib/seo';
export const dynamic = 'force-dynamic';
@@ -15,6 +17,18 @@ const sectionIcons = {
changes: RefreshCw,
};
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/terms'),
};
}
export default async function TermsPage({
params,
}: {

View File

@@ -34,8 +34,8 @@ export async function GET(request: NextRequest) {
});
}
// Browse-ready filter: exclude duplicates and aggregators
const browseReady = sql`${skills.isDuplicate} = false AND (${skills.skillType} IS NULL OR ${skills.skillType} != 'aggregator')`;
// Browse-ready filter: exclude duplicates (matches browseReadyFilter in queries.ts)
const browseReady = sql`${skills.isDuplicate} = false`;
// Browse-ready skill stats (skills count + contributors)
const statsResult = await db

View File

@@ -1,17 +1,8 @@
import type { MetadataRoute } from 'next';
import { createDb, skillQueries, categoryQueries } from '@skillhub/db';
const BASE_URL =
process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
import { getCanonicalPath, primaryDomain as BASE_URL } from '@/lib/seo';
const locales = ['en', 'fa'] as const;
const DEFAULT_LOCALE = 'en';
/** Get canonical URL path for a locale (no prefix for default locale, matching localePrefix: 'as-needed') */
function canonicalPath(locale: string, route: string): string {
if (locale === DEFAULT_LOCALE) return route || '/';
return `/${locale}${route}`;
}
const staticRoutes = [
'',
@@ -43,13 +34,13 @@ function makeEntry(
}
): MetadataRoute.Sitemap[number] {
return {
url: `${BASE_URL}${canonicalPath(locale, route)}`,
url: `${BASE_URL}${getCanonicalPath(locale, route)}`,
lastModified: options?.lastModified,
changeFrequency: options?.changeFrequency,
priority: options?.priority,
alternates: {
languages: Object.fromEntries(
locales.map((l) => [l, `${BASE_URL}${canonicalPath(l, route)}`])
locales.map((l) => [l, `${BASE_URL}${getCanonicalPath(l, route)}`])
),
},
};