diff --git a/client-next/public/robots.txt b/client-next/public/robots.txt deleted file mode 100644 index 9f7d44f..0000000 --- a/client-next/public/robots.txt +++ /dev/null @@ -1,3 +0,0 @@ -User-agent: * -Allow: / -Sitemap: https://postscholar.org/sitemap.xml diff --git a/client-next/scripts/generate-sitemap.js b/client-next/scripts/generate-sitemap.js index ba1c188..6a2713f 100644 --- a/client-next/scripts/generate-sitemap.js +++ b/client-next/scripts/generate-sitemap.js @@ -1,5 +1,5 @@ -// Sitemap generator for PostScholar -// Run manually after deployment to update sitemap.xml +// Legacy sitemap generator — prefer client-next/src/app/sitemap.js (dynamic). +// This script remains for one-off exports if the API is unavailable. // Usage: DATABASE_URL=your_railway_public_url node client-next/scripts/generate-sitemap.js const { Pool } = require('pg') @@ -10,12 +10,24 @@ const pool = new Pool({ connectionString: process.env.DATABASE_URL }) async function generateSitemap() { const result = await pool.query( - 'SELECT id, created_at FROM discussions ORDER BY created_at DESC' + `SELECT d.id, d.created_at, p.title + FROM discussions d + JOIN papers p ON p.id = d.paper_id + ORDER BY d.created_at DESC` ) + function slugify(title, id) { + const slug = (title || 'discussion') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) + return `${slug || 'discussion'}-${id}` + } + const urls = result.rows.map(row => ` - https://postscholar.org/d/${row.id} + https://postscholar.org/d/${slugify(row.title, row.id)} ${new Date(row.created_at).toISOString().split('T')[0]} weekly 0.8 diff --git a/client-next/src/app/LandingHome.jsx b/client-next/src/app/LandingHome.jsx index 07379ee..d1b4349 100644 --- a/client-next/src/app/LandingHome.jsx +++ b/client-next/src/app/LandingHome.jsx @@ -1,4 +1,5 @@ import Link from 'next/link' +import { discussionPath } from '@/lib/discussionSlug' import Layout from '@/components/Layout' import styles from './landing.module.css' @@ -101,7 +102,11 @@ export default function LandingHome({ discussions = [] }) { ) : (
{preview.map(d => ( - +

{d.title}

{[d.username || d.started_by, formatAuthors(d.authors_json), d.journal, d.year] diff --git a/client-next/src/app/d/[id]/page.js b/client-next/src/app/d/[id]/page.js deleted file mode 100644 index 02ee9be..0000000 --- a/client-next/src/app/d/[id]/page.js +++ /dev/null @@ -1,88 +0,0 @@ -import Link from 'next/link' -import Layout from '@/components/Layout' -import PaperHeader from '@/components/PaperHeader' -import PaperSidebar from '@/components/PaperSidebar' -import DiscussionComments from './DiscussionComments' -import { getServerApiUrl } from '@/lib/config' -import styles from './Discussion.module.css' - -async function getDiscussionData(id) { - try { - const res = await fetch(`${getServerApiUrl()}/discussions/${id}/paper`, { - cache: 'no-store' - }) - if (!res.ok) return null - return res.json() - } catch (error) { - console.error('Failed to fetch discussion data:', error) - return null - } -} - -export async function generateMetadata({ params }) { - const { id } = await params - const data = await getDiscussionData(id) - if (!data || data.error) { - return { - title: 'Discussion — PostScholar', - description: 'Academic discussion on PostScholar' - } - } - - const { paper } = data - const authors = paper.authors_json?.map(a => `${a.given} ${a.family}`).join(', ') || '' - const title = paper.title ? `${paper.title} — PostScholar` : 'Discussion — PostScholar' - const description = paper.abstract - ? paper.abstract.slice(0, 160) - : `Discussion of "${paper.title}" on PostScholar` - - return { - title, - description, - openGraph: { - title: paper.title, - description: paper.abstract?.slice(0, 200) || description, - type: 'article', - authors: authors ? [authors] : undefined, - }, - twitter: { - card: 'summary', - title: paper.title, - description: paper.abstract?.slice(0, 200) || description, - }, - } -} - -export default async function DiscussionPage({ params }) { - const { id } = await params - const data = await getDiscussionData(id) - - if (!data || data.error) { - return ( - -

Discussion not found.

- - ) - } - - const { paper, started_by, discussion_created_at, custom_tags } = data - - const sidebar = paper ? : null - - return ( - - ← Discussions - {paper && ( - - )} -
- - - ) -} \ No newline at end of file diff --git a/client-next/src/app/d/[slug]/page.js b/client-next/src/app/d/[slug]/page.js new file mode 100644 index 0000000..7a18361 --- /dev/null +++ b/client-next/src/app/d/[slug]/page.js @@ -0,0 +1,194 @@ +import Link from 'next/link' +import { redirect } from 'next/navigation' +import Layout from '@/components/Layout' +import PaperHeader from '@/components/PaperHeader' +import PaperSidebar from '@/components/PaperSidebar' +import DiscussionComments from '../[id]/DiscussionComments' +import { getServerApiUrl } from '@/lib/config' +import { + buildDiscussionSlug, + parseDiscussionId, +} from '@/lib/discussionSlug' +import { SITE_NAME, SITE_URL } from '@/lib/site' +import styles from '../[id]/Discussion.module.css' + +async function getDiscussionData(id) { + try { + const res = await fetch(`${getServerApiUrl()}/discussions/${id}/paper`, { + cache: 'no-store', + }) + if (!res.ok) return null + return res.json() + } catch (error) { + console.error('Failed to fetch discussion data:', error) + return null + } +} + +function buildJsonLd({ paper, discussionId, startedBy, discussionCreatedAt }) { + const authors = paper.authors_json?.map(a => ({ + '@type': 'Person', + name: [a.given, a.family].filter(Boolean).join(' '), + })) || [] + + const pageUrl = `${SITE_URL}/d/${buildDiscussionSlug(paper.title, discussionId)}` + + return [ + { + '@context': 'https://schema.org', + '@type': 'ScholarlyArticle', + headline: paper.title, + name: paper.title, + identifier: paper.doi ? `https://doi.org/${paper.doi}` : undefined, + author: authors.length > 0 ? authors : undefined, + datePublished: paper.year ? `${paper.year}` : undefined, + publisher: paper.journal + ? { '@type': 'Organization', name: paper.journal } + : undefined, + description: paper.abstract || undefined, + url: pageUrl, + isPartOf: { + '@type': 'WebSite', + name: SITE_NAME, + url: SITE_URL, + }, + }, + { + '@context': 'https://schema.org', + '@type': 'DiscussionForumPosting', + headline: paper.title, + name: paper.title, + url: pageUrl, + datePublished: discussionCreatedAt, + author: startedBy + ? { '@type': 'Person', name: startedBy } + : { '@type': 'Organization', name: SITE_NAME }, + about: { + '@type': 'ScholarlyArticle', + headline: paper.title, + identifier: paper.doi ? `https://doi.org/${paper.doi}` : undefined, + }, + isPartOf: { + '@type': 'WebSite', + name: SITE_NAME, + url: SITE_URL, + }, + }, + ] +} + +export async function generateMetadata({ params }) { + const { slug } = await params + const id = parseDiscussionId(slug) + if (!id) { + return { + title: 'Discussion — PostScholar', + description: 'Academic discussion on PostScholar', + } + } + + const data = await getDiscussionData(id) + if (!data || data.error) { + return { + title: 'Discussion — PostScholar', + description: 'Academic discussion on PostScholar', + } + } + + const { paper } = data + const authors = + paper.authors_json?.map(a => `${a.given} ${a.family}`).join(', ') || '' + const title = paper.title ? `${paper.title} — PostScholar` : 'Discussion — PostScholar' + const description = paper.abstract + ? paper.abstract.slice(0, 160) + : `Discussion of "${paper.title}" on PostScholar` + const canonicalSlug = buildDiscussionSlug(paper.title, id) + const canonicalPath = `/d/${canonicalSlug}` + + return { + title, + description, + alternates: { + canonical: canonicalPath, + }, + openGraph: { + title: paper.title, + description: paper.abstract?.slice(0, 200) || description, + type: 'article', + url: canonicalPath, + siteName: SITE_NAME, + locale: 'en_US', + authors: authors ? [authors] : undefined, + publishedTime: data.discussion_created_at, + }, + twitter: { + card: 'summary_large_image', + title: paper.title, + description: paper.abstract?.slice(0, 200) || description, + }, + } +} + +export default async function DiscussionPage({ params }) { + const { slug } = await params + const id = parseDiscussionId(slug) + + if (!id) { + return ( + +

Discussion not found.

+
+ ) + } + + const data = await getDiscussionData(id) + + if (!data || data.error) { + return ( + +

Discussion not found.

+
+ ) + } + + const { paper, started_by, discussion_created_at, custom_tags } = data + const canonicalSlug = buildDiscussionSlug(paper.title, id) + + if (slug !== canonicalSlug) { + redirect(`/d/${canonicalSlug}`) + } + + const sidebar = paper ? ( + + ) : null + + const jsonLd = buildJsonLd({ + paper, + discussionId: id, + startedBy: started_by, + discussionCreatedAt: discussion_created_at, + }) + + return ( + +