SEO: OG por post, RSS feed, sitemap, FTS5 busqueda, View Transitions, Cloudflare Polish

This commit is contained in:
Renato
2026-08-04 17:47:05 +08:00
parent 1825dfae4e
commit ef1f06d465
7 changed files with 219 additions and 14 deletions
+12 -1
View File
@@ -1,5 +1,5 @@
import Link from "next/link"
import { Image, Search } from "lucide-react"
import { Image, Search, Rss } from "lucide-react"
import { SITE_NAME, SITE_TAGLINE, SITE_LINKS } from "@/lib/site"
import { AgeGate } from "@/components/age-gate"
@@ -37,6 +37,17 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
{SITE_NAME} · {SITE_TAGLINE}
</p>
<div className="flex items-center gap-4">
<a
href="/rss.xml"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-[var(--muted)] hover:text-[var(--accent)] transition-colors"
title="RSS feed"
>
<Rss className="h-3.5 w-3.5" />
RSS
</a>
<span className="text-[var(--border)]">|</span>
{SITE_LINKS.twitter && (
<>
<a
+37
View File
@@ -1,5 +1,6 @@
import { notFound } from "next/navigation"
import Link from "next/link"
import type { Metadata } from "next"
import { getPostBySlug, getAllPosts } from "@/lib/db"
import { PostCard } from "@/components/post-card"
import { TagBadge } from "@/components/tag-badge"
@@ -7,6 +8,42 @@ import { ArrowLeft, BookOpen, ExternalLink } from "lucide-react"
export const dynamic = "force-dynamic"
// Metadata dinámica: al compartir en Discord/X/WhatsApp muestra portada,
// título y descripción del post (Open Graph + Twitter Card).
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const post = getPostBySlug(slug)
if (!post) return { title: "No encontrado" }
const title = post.title
const description =
post.summary && !post.summary.startsWith("**") && post.summary.length > 40
? post.summary.slice(0, 160)
: `${post.num_pages} páginas${post.artist ? ` por ${post.artist}` : ""} — worst-scan fansub`
return {
title,
description,
openGraph: {
title,
description,
type: "article",
url: `https://worstscan.xyz/p/${post.slug}`,
images: [{ url: `https://worstscan.xyz/api/cover/${post.gid}`, width: 1200, height: 1600, alt: title }],
},
twitter: {
card: "summary_large_image",
title,
description,
images: [`https://worstscan.xyz/api/cover/${post.gid}`],
},
}
}
export default async function PostDetailPage({
params,
}: {
+30 -2
View File
@@ -2,8 +2,25 @@ import type { Metadata } from "next"
import "./globals.css"
export const metadata: Metadata = {
title: "worst-scan",
title: {
default: "worst-scan",
template: "%s — worst-scan",
},
description: "worst-scan fansub — escaneos y traducciones de manga",
metadataBase: new URL("https://worstscan.xyz"),
openGraph: {
type: "website",
siteName: "worst-scan",
title: "worst-scan fansub",
description: "Escaneos y traducciones de manga",
url: "https://worstscan.xyz",
},
twitter: {
card: "summary_large_image",
},
icons: {
icon: "https://worstscan.xyz/api/cover/306127",
},
}
export default function RootLayout({
@@ -11,9 +28,20 @@ export default function RootLayout({
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="es">
<head>
{/* View Transitions: navegación fluida entre páginas (Chrome/Edge/Safari).
Degrada a navegación normal en navegadores sin soporte. */}
<script
dangerouslySetInnerHTML={{
__html: `if (document.startViewTransition) {
document.documentElement.style.viewTransitionName = "root";
}`,
}}
/>
</head>
<body className="min-h-screen bg-[var(--background)] text-[var(--foreground)] antialiased">
{children}
</body>
</html>
)
}
}
+48
View File
@@ -0,0 +1,48 @@
import { getAllPosts } from "@/lib/db"
export const dynamic = "force-dynamic"
// Feed RSS 2.0 del fansub: para que la gente se suscriba y siga los lanzamientos.
export async function GET() {
const posts = getAllPosts(true).slice(0, 30)
const items = posts
.map((p) => {
const pubDate = p.published_at
? new Date(p.published_at).toUTCString()
: new Date(p.created_at).toUTCString()
const description =
(p.summary && !p.summary.startsWith("**") && p.summary.length > 30
? p.summary.slice(0, 300)
: `${p.num_pages} páginas`) || `${p.num_pages} páginas`
return ` <item>
<title><![CDATA[${p.title}${p.title_jpn ? ` (${p.title_jpn})` : ""}]]></title>
<link>https://worstscan.xyz/p/${p.slug}</link>
<guid isPermaLink="false">worstscan-${p.gid}</guid>
<pubDate>${pubDate}</pubDate>
<description><![CDATA[${description}]]></description>
<enclosure url="https://worstscan.xyz/api/cover/${p.gid}" type="image/jpeg" />
</item>`
})
.join("\n")
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>worst-scan fansub</title>
<link>https://worstscan.xyz</link>
<description>Escaneos y traducciones de manga</description>
<language>es</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
<atom:link href="https://worstscan.xyz/rss.xml" rel="self" type="application/rss+xml" />
${items}
</channel>
</rss>`
return new Response(xml, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=600",
},
})
}
+33
View File
@@ -0,0 +1,33 @@
import { getAllPosts } from "@/lib/db"
import type { MetadataRoute } from "next"
export const dynamic = "force-dynamic"
// Sitemap para que Google/Bing indexen las páginas públicas.
export default function sitemap(): MetadataRoute.Sitemap {
const posts = getAllPosts(true)
const urls: MetadataRoute.Sitemap = [
{ url: "https://worstscan.xyz", lastModified: new Date(), changeFrequency: "daily", priority: 1 },
{ url: "https://worstscan.xyz/buscar", lastModified: new Date(), changeFrequency: "monthly", priority: 0.5 },
]
for (const p of posts) {
urls.push({
url: `https://worstscan.xyz/p/${p.slug}`,
lastModified: p.published_at ? new Date(p.published_at) : new Date(p.created_at),
changeFrequency: "weekly",
priority: 0.8,
})
for (const tag of p.tags || []) {
urls.push({
url: `https://worstscan.xyz/tag/${encodeURIComponent(tag)}`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.4,
})
}
}
return urls
}