diff --git a/app/api/post-preview/[slug]/route.ts b/app/api/post-preview/[slug]/route.ts
new file mode 100644
index 00000000..9c4f463f
--- /dev/null
+++ b/app/api/post-preview/[slug]/route.ts
@@ -0,0 +1,24 @@
+import { NextResponse } from "next/server";
+import { isSafeSlug } from "@/config/paths";
+import { getPostMetadata } from "@/utils/content/posts";
+import { getPostPreviewData } from "@/utils/content/preview";
+
+// Prerendered per post so archive-page hover cards don't need every excerpt
+// shipped in the page payload.
+export const dynamic = "force-static";
+
+export const generateStaticParams = async () => {
+ return getPostMetadata().map((post) => ({ slug: post.slug }));
+};
+
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ slug: string }> }
+) {
+ const { slug } = await params;
+ const preview = isSafeSlug(slug) ? getPostPreviewData(slug) : null;
+ if (!preview) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+ return NextResponse.json(preview);
+}
diff --git a/components/posts/MarkdownContent.tsx b/components/posts/MarkdownContent.tsx
index f4d42316..d90a7246 100644
--- a/components/posts/MarkdownContent.tsx
+++ b/components/posts/MarkdownContent.tsx
@@ -1,9 +1,21 @@
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import { codeToHtml } from "shiki";
+import { isSafeSlug } from "@/config/paths";
+import { getPostPreviewData } from "@/utils/content/preview";
import CopyButton from "./CopyButton";
+import PostLinkPreview from "./PostLinkPreview";
import { baseComponents, remarkPlugins, rehypePlugins } from "./markdownConfig";
+// Internal post links get a hover preview card. Matches relative and absolute
+// forms; anything else falls through to a plain anchor.
+function postSlugFromHref(href: string): string | null {
+ const match = href.match(/^(?:https?:\/\/bneo\.xyz)?\/posts\/([^/#?]+)$/);
+ if (!match) return null;
+ const slug = decodeURIComponent(match[1]);
+ return isSafeSlug(slug) ? slug : null;
+}
+
// Server component: fenced code is highlighted with shiki at render time
// (build time for static pages), so no highlighting JS ships to the client.
// Both themes are emitted as CSS variables; globals.css flips them on .dark.
@@ -38,6 +50,18 @@ async function CodeBlock({
const components: Components = {
...baseComponents,
+ a({ href, children }) {
+ const slug = href ? postSlugFromHref(href) : null;
+ const preview = slug ? getPostPreviewData(slug) : null;
+ if (preview) {
+ return (
+
+ {preview.title}
+
+ {new Date(preview.date).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })}
+
+ {preview.excerpt}
+
@@ -13,7 +13,7 @@ const PostPreview = (props: PostMetadata) => {
{props.date}
- + ); }; diff --git a/utils/content/preview.ts b/utils/content/preview.ts new file mode 100644 index 00000000..fc30f66c --- /dev/null +++ b/utils/content/preview.ts @@ -0,0 +1,41 @@ +import fs from "fs"; +import { getPostPath } from "@/config/paths"; +import { getPostContent, getPostMetadata } from "./posts"; + +export interface PostPreviewData { + slug: string; + title: string; + date: string; + excerpt: string; +} + +const EXCERPT_WORDS = 40; + +/** Rough markdown → plain text, good enough for a short excerpt. */ +function stripMarkdown(markdown: string): string { + return markdown + .replace(/```[\s\S]*?```/g, " ") + .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/<[^>]+>/g, " ") + .replace(/^#{1,6}\s+/gm, "") + .replace(/^>\s?/gm, "") + .replace(/[*_`~]/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +/** Preview card data for an internal /posts/