SEO: OG por post, RSS feed, sitemap, FTS5 busqueda, View Transitions, Cloudflare Polish
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link"
|
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 { SITE_NAME, SITE_TAGLINE, SITE_LINKS } from "@/lib/site"
|
||||||
import { AgeGate } from "@/components/age-gate"
|
import { AgeGate } from "@/components/age-gate"
|
||||||
|
|
||||||
@@ -37,6 +37,17 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
|
|||||||
{SITE_NAME} · {SITE_TAGLINE}
|
{SITE_NAME} · {SITE_TAGLINE}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-4">
|
<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 && (
|
{SITE_LINKS.twitter && (
|
||||||
<>
|
<>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import type { Metadata } from "next"
|
||||||
import { getPostBySlug, getAllPosts } from "@/lib/db"
|
import { getPostBySlug, getAllPosts } from "@/lib/db"
|
||||||
import { PostCard } from "@/components/post-card"
|
import { PostCard } from "@/components/post-card"
|
||||||
import { TagBadge } from "@/components/tag-badge"
|
import { TagBadge } from "@/components/tag-badge"
|
||||||
@@ -7,6 +8,42 @@ import { ArrowLeft, BookOpen, ExternalLink } from "lucide-react"
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
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({
|
export default async function PostDetailPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
+30
-2
@@ -2,8 +2,25 @@ import type { Metadata } from "next"
|
|||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "worst-scan",
|
title: {
|
||||||
|
default: "worst-scan",
|
||||||
|
template: "%s — worst-scan",
|
||||||
|
},
|
||||||
description: "worst-scan fansub — escaneos y traducciones de manga",
|
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({
|
export default function RootLayout({
|
||||||
@@ -11,9 +28,20 @@ export default function RootLayout({
|
|||||||
}: Readonly<{ children: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="es">
|
<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">
|
<body className="min-h-screen bg-[var(--background)] text-[var(--foreground)] antialiased">
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+57
-11
@@ -43,8 +43,44 @@ function migrate(db: Database.Database) {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
|
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published);
|
CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published);
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_created ON posts(created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_posts_created ON posts(created_at DESC);
|
||||||
|
|
||||||
|
-- FTS5: búsqueda full-text sobre título/título_jpn/artista/parodia/tags.
|
||||||
|
-- Tabla externa (content=posts) para que se sincronice con los triggers.
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
||||||
|
title, title_jpn, artist, parody, tags,
|
||||||
|
content='posts',
|
||||||
|
content_rowid='id',
|
||||||
|
tokenize='unicode61'
|
||||||
|
);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
// Triggers de sincronización FTS (solo si no existen).
|
||||||
|
db.exec(`
|
||||||
|
CREATE TRIGGER IF NOT EXISTS posts_ai AFTER INSERT ON posts BEGIN
|
||||||
|
INSERT INTO posts_fts(rowid, title, title_jpn, artist, parody, tags)
|
||||||
|
VALUES (new.id, new.title, new.title_jpn, new.artist, new.parody, new.tags);
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS posts_ad AFTER DELETE ON posts BEGIN
|
||||||
|
INSERT INTO posts_fts(posts_fts, rowid, title, title_jpn, artist, parody, tags)
|
||||||
|
VALUES ('delete', old.id, old.title, old.title_jpn, old.artist, old.parody, old.tags);
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS posts_au AFTER UPDATE ON posts BEGIN
|
||||||
|
INSERT INTO posts_fts(posts_fts, rowid, title, title_jpn, artist, parody, tags)
|
||||||
|
VALUES ('delete', old.id, old.title, old.title_jpn, old.artist, old.parody, old.tags);
|
||||||
|
INSERT INTO posts_fts(rowid, title, title_jpn, artist, parody, tags)
|
||||||
|
VALUES (new.id, new.title, new.title_jpn, new.artist, new.parody, new.tags);
|
||||||
|
END;
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Reindexar FTS si la tabla externa quedó desincronizada (posts sin indexar).
|
||||||
|
try {
|
||||||
|
const ftsCount = db.prepare("SELECT COUNT(*) as c FROM posts_fts").get() as { c: number }
|
||||||
|
const postCount = db.prepare("SELECT COUNT(*) as c FROM posts").get() as { c: number }
|
||||||
|
if (ftsCount.c < postCount.c) {
|
||||||
|
db.exec("INSERT INTO posts_fts(posts_fts) VALUES('rebuild')")
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
db.exec("ALTER TABLE posts ADD COLUMN url TEXT")
|
db.exec("ALTER TABLE posts ADD COLUMN url TEXT")
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -214,22 +250,32 @@ export function getPostCount(): number {
|
|||||||
return row.count
|
return row.count
|
||||||
}
|
}
|
||||||
|
|
||||||
// Búsqueda pública por título/título_jpn/artista/tags (LIKE sobre JSON).
|
// Búsqueda pública por título/título_jpn/artista/tags (FTS5 full-text).
|
||||||
|
// Devuelve los posts MATCH (por relevancia), filtrando publicados.
|
||||||
export function searchPosts(query: string, publishedOnly = true): Post[] {
|
export function searchPosts(query: string, publishedOnly = true): Post[] {
|
||||||
const d = getDb()
|
const d = getDb()
|
||||||
const q = `%${query}%`
|
const q = query.trim()
|
||||||
const where = publishedOnly ? "published = 1 AND" : ""
|
if (!q) return []
|
||||||
|
|
||||||
|
// Escapar comillas para FTS5 y construir una búsqueda por prefijo en cada token.
|
||||||
|
const escaped = q.replace(/"/g, "").trim()
|
||||||
|
if (!escaped) return []
|
||||||
|
|
||||||
|
const ftsQuery = escaped
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((tok) => `"${tok}"*`)
|
||||||
|
.join(" AND ")
|
||||||
|
|
||||||
|
const where = publishedOnly ? "AND p.published = 1" : ""
|
||||||
const rows = d
|
const rows = d
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT * FROM posts WHERE ${where} (
|
`SELECT p.* FROM posts p
|
||||||
title LIKE @q OR
|
JOIN posts_fts f ON f.rowid = p.id
|
||||||
title_jpn LIKE @q OR
|
WHERE posts_fts MATCH @q ${where}
|
||||||
artist LIKE @q OR
|
ORDER BY bm25(posts_fts) LIMIT 100`,
|
||||||
parody LIKE @q OR
|
|
||||||
tags LIKE @q
|
|
||||||
) ORDER BY published_at DESC LIMIT 100`,
|
|
||||||
)
|
)
|
||||||
.all({ q }) as Record<string, unknown>[]
|
.all({ q: ftsQuery }) as Record<string, unknown>[]
|
||||||
return rows.map(rowToPost)
|
return rows.map(rowToPost)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const publicExact = new Set([
|
|||||||
"/api/health",
|
"/api/health",
|
||||||
"/api/proxy/health",
|
"/api/proxy/health",
|
||||||
"/setup",
|
"/setup",
|
||||||
|
"/rss.xml",
|
||||||
|
"/sitemap.xml",
|
||||||
])
|
])
|
||||||
|
|
||||||
// Prefijos públicos: /api/cover (imágenes de portada para el sitio público),
|
// Prefijos públicos: /api/cover (imágenes de portada para el sitio público),
|
||||||
|
|||||||
Reference in New Issue
Block a user