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
24 changes: 24 additions & 0 deletions app/api/post-preview/[slug]/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
24 changes: 24 additions & 0 deletions components/posts/MarkdownContent.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 (
<PostLinkPreview slug={preview.slug} preview={preview}>
{children}
</PostLinkPreview>
);
}
return <a href={href}>{children}</a>;
},
pre({ node, children }) {
const codeNode = node?.children[0] as
| {
Expand Down
119 changes: 119 additions & 0 deletions components/posts/PostLinkPreview.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Link
href={`/posts/${slug}`}
ref={refs.setReference}
className={className}
{...getReferenceProps()}
>
{children}
</Link>
{isOpen && preview && (
<FloatingPortal>
<div
// floating-ui's refs object exposes callback refs by design
// eslint-disable-next-line react-hooks/refs
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
className="z-50 max-w-xs p-3 rounded-md shadow-md border border-japanese-shiraumenezu dark:border-white/10 bg-japanese-hakuji dark:bg-dark-tag"
>
<p className="text-xs font-bold text-japanese-sumiiro dark:text-japanese-nyuhakushoku mb-0.5">
{preview.title}
</p>
<p className="text-[10px] text-japanese-ginnezu mb-1.5">
{new Date(preview.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</p>
<p className="text-[11px] leading-relaxed text-japanese-nezumiiro dark:text-japanese-ginnezu">
{preview.excerpt}
</p>
</div>
</FloatingPortal>
)}
</>
);
};

export default PostLinkPreview;
8 changes: 4 additions & 4 deletions components/posts/PostPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import Link from "next/link";
import { PostMetadata } from "@/types/post";
import PostLinkPreview from "./PostLinkPreview";

const PostPreview = (props: PostMetadata) => {
return (
<Link
href={`/posts/${props.slug}`}
<PostLinkPreview
slug={props.slug}
className="group relative flex justify-between items-center cursor-crosshair text-sm md:text-base"
>
<p className="text-japanese-sumiiro dark:text-japanese-shironezu group-hover:underline">
Expand All @@ -13,7 +13,7 @@ const PostPreview = (props: PostMetadata) => {
<p className="text-japanese-nezumiiro dark:text-japanese-ginnezu shrink-0 ml-4">
{props.date}
</p>
</Link>
</PostLinkPreview>
);
};

Expand Down
41 changes: 41 additions & 0 deletions utils/content/preview.ts
Original file line number Diff line number Diff line change
@@ -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/<slug> 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 };
}
Loading