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 ( + + {children} + + ); + } + return {children}; + }, pre({ node, children }) { const codeNode = node?.children[0] as | { diff --git a/components/posts/PostLinkPreview.tsx b/components/posts/PostLinkPreview.tsx new file mode 100644 index 00000000..4b029ca0 --- /dev/null +++ b/components/posts/PostLinkPreview.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useRef, useState } from "react"; +import type { ReactNode } from "react"; +import Link from "next/link"; +import { + useFloating, + autoUpdate, + offset, + flip, + shift, + useHover, + useFocus, + useDismiss, + useRole, + useInteractions, + FloatingPortal, + safePolygon, +} from "@floating-ui/react"; +import type { PostPreviewData } from "@/utils/content/preview"; + +// Hover card for internal post links. Markdown links pass `preview` resolved +// on the server; list contexts with too many posts to inline (e.g. the +// archive) pass only `slug` and the data is fetched once on first hover. +const PostLinkPreview = ({ + slug, + preview: initialPreview, + className, + children, +}: { + slug: string; + preview?: PostPreviewData; + className?: string; + children: ReactNode; +}) => { + const [isOpen, setIsOpen] = useState(false); + const [preview, setPreview] = useState(initialPreview ?? null); + const fetched = useRef(false); + + const onOpenChange = (open: boolean) => { + setIsOpen(open); + if (open && !preview && !fetched.current) { + fetched.current = true; + fetch(`/api/post-preview/${slug}`) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => data && setPreview(data)) + .catch(() => {}); + } + }; + + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange, + placement: "top", + whileElementsMounted: autoUpdate, + middleware: [ + offset(8), + flip({ fallbackAxisSideDirection: "start" }), + shift({ padding: 8 }), + ], + }); + + const hover = useHover(context, { + move: false, + delay: { open: 150, close: 0 }, + handleClose: safePolygon(), + }); + const focus = useFocus(context); + const dismiss = useDismiss(context); + const role = useRole(context, { role: "tooltip" }); + + const { getReferenceProps, getFloatingProps } = useInteractions([ + hover, + focus, + dismiss, + role, + ]); + + return ( + <> + + {children} + + {isOpen && preview && ( + +
+

+ {preview.title} +

+

+ {new Date(preview.date).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + })} +

+

+ {preview.excerpt} +

+
+
+ )} + + ); +}; + +export default PostLinkPreview; diff --git a/components/posts/PostPreview.tsx b/components/posts/PostPreview.tsx index dc49c2e4..85e08e3f 100644 --- a/components/posts/PostPreview.tsx +++ b/components/posts/PostPreview.tsx @@ -1,10 +1,10 @@ -import Link from "next/link"; import { PostMetadata } from "@/types/post"; +import PostLinkPreview from "./PostLinkPreview"; const PostPreview = (props: PostMetadata) => { return ( -

@@ -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/ link, or null. */ +export function getPostPreviewData(slug: string): PostPreviewData | null { + if (!fs.existsSync(getPostPath(slug))) return null; + + const post = getPostMetadata().find((p) => p.slug === slug); + if (!post) return null; + + const words = stripMarkdown(getPostContent(slug).content).split(" "); + const excerpt = + words.slice(0, EXCERPT_WORDS).join(" ") + + (words.length > EXCERPT_WORDS ? " …" : ""); + + return { slug, title: post.title, date: post.date, excerpt }; +}