Compare commits

...

7 Commits

Author SHA1 Message Date
1d4cfe773c fix: load CJK fonts in OG image route and prefer feature_image for Twitter cards
OG images rendered without fonts caused blank/tofu text for Chinese titles,
breaking Twitter card previews. Now loads Noto Sans TC with in-memory cache.
Blog posts also prefer feature_image when available for social card images.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:14:12 +08:00
1f7dbd80d6 Update content submodule: add AI 味從哪來?LLM 為何逃不出資料的影子
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 21:20:00 +08:00
b005f02b7b fix: use getTagSlug() for tag links to prevent empty tag pages
Tags containing both spaces and dashes (e.g. "Writings - 創作") produced
mismatched slugs: inline generation created "writings---創作" while
getTagSlug() collapsed dashes to "writings-創作", causing tag pages to
show no articles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:42:45 +08:00
4cdccb0276 trigger rebuild: content submodule now available on GitHub
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:27:20 +08:00
ddd0cc5795 Update content submodule: add Codex Windows 版上線,但問題不只是工具
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:19:47 +08:00
33042cde79 fix: unify color system around configurable accent, warm-tint neutrals
Replace hardcoded purple gradients with accent-derived colors so
changing NEXT_PUBLIC_COLOR_ACCENT actually controls the entire site.
Warm-tint ink colors and body background from slate to stone.
Remove decorative floating orbs from hero. Simplify tag page to
accent-derived tints instead of 5 random colors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 21:30:42 +08:00
5325a08bc3 perf: memoize post queries and reduce JSON-LD bloat 2026-03-15 17:31:23 +08:00
16 changed files with 110 additions and 65 deletions

View File

@@ -50,14 +50,19 @@ Ask the user if they want to preview with `npm run dev` before publishing.
## Step 5: Publish ## Step 5: Publish
Execute the two-step deployment: **IMPORTANT**: The `.gitmodules` URL for `content/` points to GitHub. The CI/CD server clones the submodule from that URL, so the content submodule **must be pushed to GitHub first** before pushing the main repo. Otherwise the server will check out stale content and posts will disappear from the site.
Execute the deployment in order:
```bash ```bash
# 1. Commit and push content submodule # 1. Commit content submodule
git -C content add . && git -C content commit -m "Add new post: <title>" && git -C content push git -C content add . && git -C content commit -m "Add new post: <title>"
# 2. Update main repo submodule pointer and push (triggers CI/CD) # 2. Push content submodule to ALL remotes (GitHub first — CI/CD depends on it)
git add content && git commit -m "Update content submodule" && git push git -C content push github main && git -C content push origin main
# 3. Update main repo submodule pointer, commit, and push to both remotes
git add content && git commit -m "Update content submodule" && git push origin main && git push github main
``` ```
Confirm both pushes succeeded. The CI/CD pipeline on git.gbanyan.net will handle deployment automatically (and crontab mirrors to gitea.gbanyan.net). Confirm all pushes succeeded. The CI/CD pipeline on git.gbanyan.net will handle deployment automatically (and crontab mirrors to gitea.gbanyan.net).

View File

@@ -1,6 +1,17 @@
import { ImageResponse } from '@vercel/og'; import { ImageResponse } from '@vercel/og';
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
const fontCache = new Map<string, ArrayBuffer>();
async function loadFont(url: string): Promise<ArrayBuffer> {
const cached = fontCache.get(url);
if (cached) return cached;
const res = await fetch(url);
const data = await res.arrayBuffer();
fontCache.set(url, data);
return data;
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@@ -10,6 +21,14 @@ export async function GET(request: NextRequest) {
const description = searchParams.get('description') || ''; const description = searchParams.get('description') || '';
const tags = searchParams.get('tags')?.split(',').slice(0, 3) || []; const tags = searchParams.get('tags')?.split(',').slice(0, 3) || [];
// Load CJK font for Chinese text rendering
const fontData = await loadFont(
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-tc@latest/chinese-traditional-400-normal.woff'
);
const fontBoldData = await loadFont(
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-tc@latest/chinese-traditional-700-normal.woff'
);
const imageResponse = new ImageResponse( const imageResponse = new ImageResponse(
( (
<div <div
@@ -20,6 +39,7 @@ export async function GET(request: NextRequest) {
flexDirection: 'column', flexDirection: 'column',
alignItems: 'flex-start', alignItems: 'flex-start',
justifyContent: 'space-between', justifyContent: 'space-between',
fontFamily: '"Noto Sans TC", sans-serif',
backgroundColor: '#0f172a', backgroundColor: '#0f172a',
backgroundImage: 'radial-gradient(circle at 25px 25px, #1e293b 2%, transparent 0%), radial-gradient(circle at 75px 75px, #1e293b 2%, transparent 0%)', backgroundImage: 'radial-gradient(circle at 25px 25px, #1e293b 2%, transparent 0%), radial-gradient(circle at 75px 75px, #1e293b 2%, transparent 0%)',
backgroundSize: '100px 100px', backgroundSize: '100px 100px',
@@ -155,6 +175,10 @@ export async function GET(request: NextRequest) {
{ {
width: 1200, width: 1200,
height: 630, height: 630,
fonts: [
{ name: 'Noto Sans TC', data: fontData, weight: 400 as const, style: 'normal' as const },
{ name: 'Noto Sans TC', data: fontBoldData, weight: 700 as const, style: 'normal' as const },
],
} }
); );

View File

@@ -3,7 +3,7 @@ import Image from 'next/image';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { allPosts } from 'contentlayer2/generated'; import { allPosts } from 'contentlayer2/generated';
import { getPostBySlug, getRelatedPosts, getPostNeighbors } from '@/lib/posts'; import { getPostBySlug, getRelatedPosts, getPostNeighbors, getTagSlug } from '@/lib/posts';
import { siteConfig } from '@/lib/config'; import { siteConfig } from '@/lib/config';
import { ReadingProgress } from '@/components/reading-progress'; import { ReadingProgress } from '@/components/reading-progress';
import { ScrollReveal } from '@/components/scroll-reveal'; import { ScrollReveal } from '@/components/scroll-reveal';
@@ -40,6 +40,11 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
ogImageUrl.searchParams.set('tags', post.tags.slice(0, 3).join(',')); ogImageUrl.searchParams.set('tags', post.tags.slice(0, 3).join(','));
} }
// Prefer post's feature_image for social cards; fall back to dynamic OG
const imageUrl = post.feature_image
? `${siteConfig.url}${post.feature_image.replace('../assets', '/assets')}`
: ogImageUrl.toString();
return { return {
title: post.title, title: post.title,
description: post.description || post.title, description: post.description || post.title,
@@ -61,7 +66,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
tags: post.tags, tags: post.tags,
images: [ images: [
{ {
url: ogImageUrl.toString(), url: imageUrl,
width: 1200, width: 1200,
height: 630, height: 630,
alt: post.title, alt: post.title,
@@ -72,7 +77,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
card: 'summary_large_image', card: 'summary_large_image',
title: post.title, title: post.title,
description: post.description || post.title, description: post.description || post.title,
images: [ogImageUrl.toString()], images: [imageUrl],
}, },
}; };
} }
@@ -145,8 +150,6 @@ export default async function BlogPostPage({ params }: Props) {
wordCount: wordCount, wordCount: wordCount,
readingTime: `${readingTime} min read`, readingTime: `${readingTime} min read`,
}), }),
articleBody: textContent.slice(0, 5000),
inLanguage: siteConfig.defaultLocale,
url: postUrl, url: postUrl,
}; };
@@ -219,9 +222,7 @@ export default async function BlogPostPage({ params }: Props) {
{post.tags.map((t) => ( {post.tags.map((t) => (
<Link <Link
key={t} key={t}
href={`/tags/${encodeURIComponent( href={`/tags/${encodeURIComponent(getTagSlug(t))}`}
t.toLowerCase().replace(/\s+/g, '-')
)}`}
className="tag-chip rounded-full bg-accent-soft px-3 py-1 text-sm text-accent-textLight dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700 dark:hover:text-white" className="tag-chip rounded-full bg-accent-soft px-3 py-1 text-sm text-accent-textLight dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700 dark:hover:text-white"
> >
#{t} #{t}

View File

@@ -130,6 +130,8 @@ export default async function RootLayout({
<head> <head>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link rel="font" href="https://fonts.googleapis.com" />
<link rel="font" href="https://fonts.gstatic.com" />
</head> </head>
<body> <body>
<NextTopLoader <NextTopLoader

View File

@@ -3,7 +3,7 @@ import Image from 'next/image';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { allPages } from 'contentlayer2/generated'; import { allPages } from 'contentlayer2/generated';
import { getPageBySlug } from '@/lib/posts'; import { getPageBySlug, getTagSlug } from '@/lib/posts';
import { siteConfig } from '@/lib/config'; import { siteConfig } from '@/lib/config';
import { ReadingProgress } from '@/components/reading-progress'; import { ReadingProgress } from '@/components/reading-progress';
import { PostLayout } from '@/components/post-layout'; import { PostLayout } from '@/components/post-layout';
@@ -130,9 +130,7 @@ export default async function StaticPage({ params }: Props) {
{page.tags.map((t) => ( {page.tags.map((t) => (
<Link <Link
key={t} key={t}
href={`/tags/${encodeURIComponent( href={`/tags/${encodeURIComponent(getTagSlug(t))}`}
t.toLowerCase().replace(/\s+/g, '-')
)}`}
className="tag-chip rounded-full bg-accent-soft px-3 py-1 text-sm text-accent-textLight dark:bg-slate-800 dark:text-slate-100" className="tag-chip rounded-full bg-accent-soft px-3 py-1 text-sm text-accent-textLight dark:bg-slate-800 dark:text-slate-100"
> >
#{t} #{t}

View File

@@ -1,5 +1,6 @@
import { MetadataRoute } from 'next'; import { MetadataRoute } from 'next';
import { allPosts, allPages } from 'contentlayer2/generated'; import { allPosts, allPages } from 'contentlayer2/generated';
import { getTagSlug } from '@/lib/posts';
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
@@ -58,7 +59,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
); );
const tagPages = allTags.map((tag) => ({ const tagPages = allTags.map((tag) => ({
url: `${siteUrl}/tags/${encodeURIComponent(tag.toLowerCase().replace(/\s+/g, '-'))}`, url: `${siteUrl}/tags/${encodeURIComponent(getTagSlug(tag))}`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'weekly' as const, changeFrequency: 'weekly' as const,
priority: 0.5, priority: 0.5,

View File

@@ -21,11 +21,9 @@ export default function TagIndexPage() {
const topTags = tags.slice(0, 3); const topTags = tags.slice(0, 3);
const colorClasses = [ const colorClasses = [
'from-rose-400/70 to-rose-200/40', 'from-accent/60 to-accent/20',
'from-emerald-400/70 to-emerald-200/40', 'from-accent/50 to-accent/15',
'from-sky-400/70 to-sky-200/40', 'from-accent/40 to-accent/10',
'from-amber-400/70 to-amber-200/40',
'from-violet-400/70 to-violet-200/40'
]; ];
// CollectionPage schema with ItemList // CollectionPage schema with ItemList

View File

@@ -52,9 +52,7 @@ export function Hero() {
}[]; }[];
return ( return (
<section className="motion-card group relative mb-8 overflow-hidden rounded-xl border bg-gradient-to-r from-sky-50 via-indigo-50 to-slate-50 px-6 py-6 shadow-sm dark:border-slate-800 dark:from-slate-900 dark:via-slate-950 dark:to-slate-900"> <section className="motion-card group relative mb-8 overflow-hidden rounded-xl border bg-accent-soft px-6 py-6 shadow-sm dark:border-slate-800">
<div className="pointer-events-none absolute -left-16 -top-16 h-40 w-40 rounded-full bg-sky-300/40 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-sky-500/25" />
<div className="pointer-events-none absolute -bottom-20 right-[-3rem] h-44 w-44 rounded-full bg-indigo-300/35 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-indigo-500/25" />
<div className="relative flex items-center gap-4 motion-safe:animate-fade-in-up"> <div className="relative flex items-center gap-4 motion-safe:animate-fade-in-up">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-900 text-xl font-semibold text-slate-50 shadow-md transition-transform duration-300 ease-out group-hover:scale-105 dark:bg-slate-100 dark:text-slate-900"> <div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-900 text-xl font-semibold text-slate-50 shadow-md transition-transform duration-300 ease-out group-hover:scale-105 dark:bg-slate-100 dark:text-slate-900">

View File

@@ -18,7 +18,7 @@ export function PostCard({ post, showTags = true }: PostCardProps) {
return ( return (
<article className="motion-card group relative overflow-hidden rounded-xl border bg-white shadow-sm transition-all duration-300 ease-snappy hover:-translate-y-1 hover:shadow-lg dark:border-slate-800 dark:bg-slate-900"> <article className="motion-card group relative overflow-hidden rounded-xl border bg-white shadow-sm transition-all duration-300 ease-snappy hover:-translate-y-1 hover:shadow-lg dark:border-slate-800 dark:bg-slate-900">
<div className="pointer-events-none absolute inset-x-0 top-0 h-1 origin-left scale-x-0 bg-gradient-to-r from-[rgba(124,58,237,0.9)] via-[rgba(167,139,250,0.9)] to-[rgba(14,165,233,0.8)] opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100" /> <div className="pointer-events-none absolute inset-x-0 top-0 h-1 origin-left scale-x-0 bg-accent opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100" />
{cover && ( {cover && (
<div className="relative w-full bg-slate-100 dark:bg-slate-800 overflow-hidden"> <div className="relative w-full bg-slate-100 dark:bg-slate-800 overflow-hidden">
<Image <Image

View File

@@ -21,7 +21,7 @@ export function PostListItem({ post, priority = false }: Props) {
return ( return (
<article className="motion-card group relative flex gap-4 rounded-2xl border border-white/40 bg-white/60 p-5 shadow-lg backdrop-blur-md transition-all hover:scale-[1.01] hover:shadow-xl dark:border-white/10 dark:bg-slate-900/60"> <article className="motion-card group relative flex gap-4 rounded-2xl border border-white/40 bg-white/60 p-5 shadow-lg backdrop-blur-md transition-all hover:scale-[1.01] hover:shadow-xl dark:border-white/10 dark:bg-slate-900/60">
<div className="pointer-events-none absolute inset-x-0 top-0 h-0.5 origin-left scale-x-0 bg-gradient-to-r from-[rgba(124,58,237,0.9)] via-[rgba(167,139,250,0.9)] to-[rgba(14,165,233,0.8)] opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100" /> <div className="pointer-events-none absolute inset-x-0 top-0 h-0.5 origin-left scale-x-0 bg-accent opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100" />
{cover && ( {cover && (
<div className="relative flex h-24 w-24 flex-none overflow-hidden rounded-md bg-slate-100 dark:bg-slate-800 sm:h-auto sm:w-40"> <div className="relative flex h-24 w-24 flex-none overflow-hidden rounded-md bg-slate-100 dark:bg-slate-800 sm:h-auto sm:w-40">
<Image <Image

View File

@@ -56,7 +56,7 @@ export function ReadingProgress() {
<div className="pointer-events-none fixed inset-x-0 top-0 z-[1200] h-1.5 bg-transparent"> <div className="pointer-events-none fixed inset-x-0 top-0 z-[1200] h-1.5 bg-transparent">
<div className="relative h-1.5 w-full overflow-visible"> <div className="relative h-1.5 w-full overflow-visible">
{useScrollDriven ? ( {useScrollDriven ? (
<div aria-hidden="true" className="reading-progress-bar-scroll-driven absolute inset-y-0 left-0 w-full origin-left rounded-full bg-gradient-to-r from-[rgba(124,58,237,0.9)] via-[rgba(167,139,250,0.9)] to-[rgba(14,165,233,0.8)] shadow-[0_0_12px_rgba(124,58,237,0.5)]"> <div aria-hidden="true" className="reading-progress-bar-scroll-driven absolute inset-y-0 left-0 w-full origin-left rounded-full bg-accent">
<span <span
className="absolute -right-2 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-white/80 blur-[1px] dark:bg-slate-900/80" className="absolute -right-2 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-white/80 blur-[1px] dark:bg-slate-900/80"
aria-hidden="true" aria-hidden="true"
@@ -64,7 +64,7 @@ export function ReadingProgress() {
</div> </div>
) : ( ) : (
<div <div
className="absolute inset-y-0 left-0 w-full origin-left rounded-full bg-gradient-to-r from-[rgba(124,58,237,0.9)] via-[rgba(167,139,250,0.9)] to-[rgba(14,165,233,0.8)] shadow-[0_0_12px_rgba(124,58,237,0.5)] will-change-transform transition-[transform,opacity] duration-300 ease-out" className="absolute inset-y-0 left-0 w-full origin-left rounded-full bg-accent will-change-transform transition-[transform,opacity] duration-300 ease-out"
style={{ style={{
transform: `scaleX(${progress / 100})`, transform: `scaleX(${progress / 100})`,
opacity: progress > 0 ? 1 : 0 opacity: progress > 0 ? 1 : 0
@@ -78,7 +78,7 @@ export function ReadingProgress() {
</div> </div>
)} )}
<span <span
className="absolute inset-x-0 top-2 h-px bg-gradient-to-r from-transparent via-blue-200/40 to-transparent blur-sm dark:via-blue-900/30" className="absolute inset-x-0 top-2 h-px bg-gradient-to-r from-transparent via-accent-soft to-transparent blur-sm"
aria-hidden="true" aria-hidden="true"
/> />
</div> </div>

View File

@@ -18,7 +18,7 @@ export function RepoCard({ repo, animationDelay = 0 }: RepoCardProps) {
animationDelay > 0 ? { animationDelay: `${animationDelay}ms` } : undefined animationDelay > 0 ? { animationDelay: `${animationDelay}ms` } : undefined
} }
> >
<div className="pointer-events-none absolute inset-x-0 top-0 h-0.5 origin-left scale-x-0 bg-gradient-to-r from-[rgba(124,58,237,0.9)] via-[rgba(167,139,250,0.9)] to-[rgba(14,165,233,0.8)] opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100" /> <div className="pointer-events-none absolute inset-x-0 top-0 h-0.5 origin-left scale-x-0 bg-accent opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100" />
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<Link <Link
href={repo.htmlUrl} href={repo.htmlUrl}

View File

@@ -29,18 +29,14 @@ export function TimelineWrapper({ children, className }: TimelineWrapperProps) {
return ( return (
<div className={clsx('relative pl-6 md:pl-8', className)}> <div className={clsx('relative pl-6 md:pl-8', className)}>
<span <span
className="pointer-events-none absolute left-2 top-0 h-full w-[2px] rounded-full bg-blue-400 shadow-[0_0_10px_rgba(59,130,246,0.35)] dark:bg-cyan-300 md:left-3" className="pointer-events-none absolute left-2 top-0 h-full w-[2px] rounded-full bg-accent/40 md:left-3"
aria-hidden="true"
/>
<span
className="pointer-events-none absolute left-2 top-0 h-full w-[8px] rounded-full bg-blue-500/15 blur-[14px] md:left-3"
aria-hidden="true" aria-hidden="true"
/> />
<div className="space-y-4"> <div className="space-y-4">
{items.map((child, index) => ( {items.map((child, index) => (
<div key={index} className="relative pl-5 sm:pl-8"> <div key={index} className="relative pl-5 sm:pl-8">
<span className="pointer-events-none absolute left-0 top-1/2 h-px w-5 -translate-x-full -translate-y-1/2 bg-gradient-to-r from-transparent via-blue-300/80 to-transparent dark:via-cyan-200/80 sm:w-8" aria-hidden="true" /> <span className="pointer-events-none absolute left-0 top-1/2 h-px w-5 -translate-x-full -translate-y-1/2 bg-gradient-to-r from-transparent via-accent/30 to-transparent sm:w-8" aria-hidden="true" />
{child} {child}
</div> </div>
))} ))}

Submodule content updated: 99cff93cca...7b52c564dc

View File

@@ -1,11 +1,18 @@
import { allPosts, allPages, Post, Page } from 'contentlayer2/generated'; import { allPosts, allPages, Post, Page } from 'contentlayer2/generated';
let _sortedCache: Post[] | null = null;
let _relatedCache: Map<string, Post[]> = new Map();
let _neighborsCache: Map<string, { newer?: Post; older?: Post }> = new Map();
let _tagsCache: { tag: string; slug: string; count: number }[] | null = null;
export function getAllPostsSorted(): Post[] { export function getAllPostsSorted(): Post[] {
return [...allPosts].sort((a, b) => { if (_sortedCache) return _sortedCache;
_sortedCache = [...allPosts].sort((a, b) => {
const aDate = a.published_at ? new Date(a.published_at).getTime() : 0; const aDate = a.published_at ? new Date(a.published_at).getTime() : 0;
const bDate = b.published_at ? new Date(b.published_at).getTime() : 0; const bDate = b.published_at ? new Date(b.published_at).getTime() : 0;
return bDate - aDate; return bDate - aDate;
}); });
return _sortedCache;
} }
export function getPostBySlug(slug: string): Post | undefined { export function getPostBySlug(slug: string): Post | undefined {
@@ -38,24 +45,31 @@ export function getTagSlug(tag: string): string {
} }
export function getAllTagsWithCount(): { tag: string; slug: string; count: number }[] { export function getAllTagsWithCount(): { tag: string; slug: string; count: number }[] {
const map = new Map<string, number>(); if (_tagsCache) return _tagsCache;
const map = new Map<string, number>();
for (const post of allPosts) { for (const post of allPosts) {
if (!post.tags) continue; if (!post.tags) continue;
for (const tag of post.tags) { for (const postTag of post.tags) {
map.set(tag, (map.get(tag) ?? 0) + 1); map.set(postTag, (map.get(postTag) ?? 0) + 1);
} }
} }
return Array.from(map.entries()) _tagsCache = Array.from(map.entries())
.map(([tag, count]) => ({ tag, slug: getTagSlug(tag), count })) .map(([tag, count]) => ({ tag, slug: getTagSlug(tag), count }))
.sort((a, b) => { .sort((a, b) => {
if (b.count === a.count) return a.tag.localeCompare(b.tag); if (b.count === a.count) return a.tag.localeCompare(b.tag);
return b.count - a.count; return b.count - a.count;
}); });
return _tagsCache;
} }
export function getRelatedPosts(target: Post, limit = 3): Post[] { export function getRelatedPosts(target: Post, limit = 3): Post[] {
const cacheKey = `${target._id}-${limit}`;
if (_relatedCache.has(cacheKey)) {
return _relatedCache.get(cacheKey)!;
}
const targetTags = new Set(target.tags?.map((tag) => tag.toLowerCase()) ?? []); const targetTags = new Set(target.tags?.map((tag) => tag.toLowerCase()) ?? []);
const candidates = getAllPostsSorted().filter((post) => post._id !== target._id); const candidates = getAllPostsSorted().filter((post) => post._id !== target._id);
@@ -84,28 +98,39 @@ export function getRelatedPosts(target: Post, limit = 3): Post[] {
.slice(0, limit) .slice(0, limit)
.map((entry) => entry.post); .map((entry) => entry.post);
let result: Post[];
if (scored.length >= limit) { if (scored.length >= limit) {
return scored; result = scored;
} } else {
const fallback = candidates.filter( const fallback = candidates.filter(
(post) => !scored.some((existing) => existing._id === post._id) (post) => !scored.some((existing) => existing._id === post._id)
); );
result = [...scored, ...fallback.slice(0, limit - scored.length)].slice(0, limit);
}
return [...scored, ...fallback.slice(0, limit - scored.length)].slice(0, limit); _relatedCache.set(cacheKey, result);
return result;
} }
export function getPostNeighbors(target: Post): { export function getPostNeighbors(target: Post): {
newer?: Post; newer?: Post;
older?: Post; older?: Post;
} { } {
const cacheKey = target._id;
if (_neighborsCache.has(cacheKey)) {
return _neighborsCache.get(cacheKey)!;
}
const sorted = getAllPostsSorted(); const sorted = getAllPostsSorted();
const index = sorted.findIndex((post) => post._id === target._id); const index = sorted.findIndex((post) => post._id === target._id);
if (index === -1) return {}; if (index === -1) return {};
return { const result = {
newer: index > 0 ? sorted[index - 1] : undefined, newer: index > 0 ? sorted[index - 1] : undefined,
older: index < sorted.length - 1 ? sorted[index + 1] : undefined older: index < sorted.length - 1 ? sorted[index + 1] : undefined
}; };
_neighborsCache.set(cacheKey, result);
return result;
} }

View File

@@ -28,7 +28,7 @@
/* Custom box shadows */ /* Custom box shadows */
--shadow-lifted: 0 12px 30px -14px rgba(0, 0, 0, 0.35); --shadow-lifted: 0 12px 30px -14px rgba(0, 0, 0, 0.35);
--shadow-outline: 0 0 0 1px rgba(139, 92, 246, 0.25); --shadow-outline: 0 0 0 1px color-mix(in oklch, var(--color-accent) 25%, transparent);
/* Custom keyframes */ /* Custom keyframes */
--animate-fade-in-up: fade-in-up 0.6s ease-out both; --animate-fade-in-up: fade-in-up 0.6s ease-out both;
@@ -1332,22 +1332,19 @@
--font-weight-semibold: 600; --font-weight-semibold: 600;
--font-system-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', 'PingFang TC', 'PingFang SC', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Segoe UI Variable Text', 'Segoe UI', 'Microsoft JhengHei', 'Microsoft YaHei', 'Noto Sans TC', 'Noto Sans SC', 'Noto Sans CJK TC', 'Noto Sans CJK SC', 'Source Han Sans TC', 'Source Han Sans SC', 'Roboto', 'Ubuntu', 'Cantarell', 'Inter', 'Helvetica Neue', Arial, sans-serif; --font-system-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', 'PingFang TC', 'PingFang SC', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Segoe UI Variable Text', 'Segoe UI', 'Microsoft JhengHei', 'Microsoft YaHei', 'Noto Sans TC', 'Noto Sans SC', 'Noto Sans CJK TC', 'Noto Sans CJK SC', 'Source Han Sans TC', 'Source Han Sans SC', 'Roboto', 'Ubuntu', 'Cantarell', 'Inter', 'Helvetica Neue', Arial, sans-serif;
/* Ink + accent palette */ /* Ink palette — warm-tinted neutrals */
--color-ink-strong: #0f172a; --color-ink-strong: #1c1917;
--color-ink-body: #1f2937; --color-ink-body: #292524;
--color-ink-muted: #475569; --color-ink-muted: #57534e;
--color-accent: #7c3aed;
--color-accent-soft: #f4f0ff;
font-size: clamp(15px, 0.65vw + 11px, 19px); font-size: clamp(15px, 0.65vw + 11px, 19px);
} }
.dark { .dark {
--color-ink-strong: #e2e8f0; /* Ink palette — warm-tinted neutrals (dark) */
--color-ink-body: #cbd5e1; --color-ink-strong: #e7e5e4;
--color-ink-muted: #94a3b8; --color-ink-body: #d6d3d1;
--color-accent: #a78bfa; --color-ink-muted: #a8a29e;
--color-accent-soft: #1f1a3d;
} }
@media (min-width: 2560px) { @media (min-width: 2560px) {
@@ -1357,7 +1354,7 @@
} }
body { body {
@apply bg-white text-gray-900 transition-colors duration-200 ease-snappy dark:bg-gray-950 dark:text-gray-100; @apply bg-stone-50 text-stone-900 transition-colors duration-200 ease-snappy dark:bg-stone-950 dark:text-stone-100;
font-size: 1rem; font-size: 1rem;
line-height: var(--line-height-body); line-height: var(--line-height-body);
font-family: var(--font-system-sans); font-family: var(--font-system-sans);