Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
347 changes: 347 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,10 @@
"react-dom": "^18",
"react-focus-lock": "^2.13.7",
"react-hook-form": "^7.71.2",
"react-markdown": "^9.1.0",
"rehype-sanitize": "^6.0.0",
"remark": "^15.0.1",
"remark-gfm": "^4.0.1",
"resend": "^6.9.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
Expand Down
101 changes: 101 additions & 0 deletions src/app/(public)/articles/[slug]/ArticleMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
'use client';

/**
* Renders user-authored markdown safely. react-markdown never executes JSX (so a
* stray `<` in a post can't break the page), rehype-sanitize strips dangerous
* HTML, and remark-gfm adds tables/strikethrough/task-lists. Every element maps
* to design tokens — no raw hex, no prose plugin — for x.ai/Substack-grade
* long-form typography that respects light and dark themes.
*/

import Link from 'next/link';
import type { ComponentProps } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeSanitize from 'rehype-sanitize';

const components = {
h1: (props: ComponentProps<'h1'>) => (
<h1
className="mt-12 mb-5 text-3xl font-semibold leading-tight tracking-display text-fg-primary"
{...props}
/>
),
h2: (props: ComponentProps<'h2'>) => (
<h2
className="mt-11 mb-4 text-2xl font-semibold leading-snug tracking-display text-fg-primary"
{...props}
/>
),
h3: (props: ComponentProps<'h3'>) => (
<h3 className="mt-9 mb-3 text-xl font-semibold leading-snug text-fg-primary" {...props} />
),
p: (props: ComponentProps<'p'>) => (
<p className="my-5 text-lg leading-8 text-fg-primary" {...props} />
),
a: ({ href, ...props }: ComponentProps<'a'>) => (
<Link
href={href || '#'}
className="font-medium text-accent-warm underline decoration-1 underline-offset-2 transition-opacity hover:opacity-80"
{...props}
/>
),
ul: (props: ComponentProps<'ul'>) => (
<ul className="my-5 list-disc space-y-2 pl-6 text-lg leading-8 text-fg-primary" {...props} />
),
ol: (props: ComponentProps<'ol'>) => (
<ol className="my-5 list-decimal space-y-2 pl-6 text-lg leading-8 text-fg-primary" {...props} />
),
li: (props: ComponentProps<'li'>) => <li className="pl-1.5" {...props} />,
blockquote: (props: ComponentProps<'blockquote'>) => (
<blockquote
className="my-7 border-l-2 border-accent-warm pl-5 text-lg italic leading-8 text-fg-secondary"
{...props}
/>
),
hr: () => <hr className="my-10 border-t border-subtle" />,
img: ({ alt, ...props }: ComponentProps<'img'>) => (
// eslint-disable-next-line @next/next/no-img-element
<img
alt={alt || ''}
className="my-8 w-full rounded-lg border border-subtle"
loading="lazy"
{...props}
/>
),
code: (props: ComponentProps<'code'>) => (
<code
className="rounded bg-surface-raised px-1.5 py-0.5 font-mono text-[0.9em] text-fg-primary [pre_&]:bg-transparent [pre_&]:p-0"
{...props}
/>
),
pre: (props: ComponentProps<'pre'>) => (
<pre
className="my-6 overflow-x-auto rounded-lg border border-subtle bg-surface-raised/50 p-4 font-mono text-sm leading-6 text-fg-primary"
{...props}
/>
),
table: (props: ComponentProps<'table'>) => (
<div className="my-6 overflow-x-auto">
<table className="w-full border-collapse text-left text-base text-fg-primary" {...props} />
</div>
),
th: (props: ComponentProps<'th'>) => (
<th className="border-b border-default px-3 py-2 font-semibold" {...props} />
),
td: (props: ComponentProps<'td'>) => (
<td className="border-b border-subtle px-3 py-2" {...props} />
),
};

export default function ArticleMarkdown({ body }: { body: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize]}
components={components}
>
{body}
</ReactMarkdown>
);
}
158 changes: 158 additions & 0 deletions src/app/(public)/articles/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { ArrowLeft, Clock, Lock, Users } from 'lucide-react';
import { getArticleBySlug } from '@/services/articles/get-article';
import { ARTICLE_COPY } from '@/config/articles';
import { ROUTES } from '@/config/routes';
import { JsonLdScript } from '@/lib/seo/structured-data';
import { APP_NAME, SITE_URL } from '@/config/brand';
import ArticleMarkdown from './ArticleMarkdown';

interface PageProps {
params: Promise<{ slug: string }>;
}

function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}

function profileHref(username: string | undefined, id: string): string {
return `/profiles/${username ?? id}`;
}

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const article = await getArticleBySlug(slug);
if (!article) {
return { title: 'Article not found' };
}

const isIndexable = article.visibility === 'public';
return {
title: article.title,
description: article.excerpt,
robots: isIndexable ? undefined : { index: false, follow: false },
openGraph: {
title: article.title,
description: article.excerpt,
type: 'article',
publishedTime: article.publishedAt,
authors: [article.author.name],
images: article.coverImage ? [article.coverImage] : undefined,
url: `${SITE_URL}/articles/${article.slug}`,
},
twitter: {
card: article.coverImage ? 'summary_large_image' : 'summary',
title: article.title,
description: article.excerpt,
images: article.coverImage ? [article.coverImage] : undefined,
},
};
}

export default async function ArticlePage({ params }: PageProps) {
const { slug } = await params;
const article = await getArticleBySlug(slug);
if (!article) {
notFound();
}

const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.title,
description: article.excerpt,
datePublished: article.publishedAt,
author: { '@type': 'Person', name: article.author.name },
publisher: { '@type': 'Organization', name: APP_NAME, url: SITE_URL },
url: `${SITE_URL}/articles/${article.slug}`,
...(article.coverImage ? { image: article.coverImage } : {}),
};

const authorHref = profileHref(article.author.username, article.author.id);

return (
<>
{article.visibility === 'public' && <JsonLdScript data={jsonLd} />}
<div className="min-h-screen bg-surface-page pt-20 pb-24 text-fg-primary">
<article className="mx-auto w-full max-w-[680px] px-5">
<Link
href={ROUTES.ARTICLES}
className="mb-8 inline-flex items-center gap-1.5 text-sm text-fg-secondary transition-colors hover:text-fg-primary"
>
<ArrowLeft className="h-4 w-4" />
{ARTICLE_COPY.reader.back}
</Link>

{article.coverImage && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={article.coverImage}
alt=""
className="mb-10 aspect-[2/1] w-full rounded-xl border border-subtle object-cover"
/>
)}

<header className="mb-10">
<h1 className="text-4xl font-semibold leading-[1.15] tracking-display text-fg-primary sm:text-5xl">
{article.title}
</h1>
{article.excerpt && (
<p className="mt-5 text-xl leading-relaxed text-fg-secondary">{article.excerpt}</p>
)}

<div className="mt-7 flex flex-wrap items-center gap-x-4 gap-y-3 border-y border-subtle py-4 text-sm text-fg-secondary">
<Link
href={authorHref}
className="flex items-center gap-2.5 text-fg-primary transition-opacity hover:opacity-80"
>
{article.author.avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={article.author.avatarUrl}
alt=""
className="h-9 w-9 rounded-full border border-subtle object-cover"
/>
) : (
<span className="flex h-9 w-9 items-center justify-center rounded-full border border-subtle bg-surface-raised text-xs font-semibold text-fg-secondary">
{article.author.name.slice(0, 1).toUpperCase()}
</span>
)}
<span className="font-medium">{article.author.name}</span>
</Link>
<span aria-hidden className="text-fg-tertiary">
·
</span>
<time dateTime={article.publishedAt}>{formatDate(article.publishedAt)}</time>
<span className="inline-flex items-center gap-1.5">
<Clock className="h-4 w-4" />
{article.readingTime} min read
</span>
{article.visibility === 'private' && (
<span className="inline-flex items-center gap-1.5 rounded-md bg-surface-raised px-2 py-0.5 text-xs text-fg-secondary">
<Lock className="h-3.5 w-3.5" />
{ARTICLE_COPY.reader.privateNotice}
</span>
)}
{article.visibility === 'followers' && (
<span className="inline-flex items-center gap-1.5 rounded-md bg-surface-raised px-2 py-0.5 text-xs text-fg-secondary">
<Users className="h-3.5 w-3.5" />
{ARTICLE_COPY.reader.followersNotice}
</span>
)}
</div>
</header>

<div className="[&>*:first-child]:mt-0">
<ArticleMarkdown body={article.body} />
</div>
</article>
</div>
</>
);
}
Loading
Loading