Compare commits

...

5 Commits

Author SHA1 Message Date
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
15 changed files with 79 additions and 63 deletions

View File

@@ -50,14 +50,19 @@ Ask the user if they want to preview with `npm run dev` before publishing.
## 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
# 1. Commit and push content submodule
git -C content add . && git -C content commit -m "Add new post: <title>" && git -C content push
# 1. Commit content submodule
git -C content add . && git -C content commit -m "Add new post: <title>"
# 2. Update main repo submodule pointer and push (triggers CI/CD)
git add content && git commit -m "Update content submodule" && git push
# 2. Push content submodule to ALL remotes (GitHub first — CI/CD depends on it)
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

@@ -3,7 +3,7 @@ import Image from 'next/image';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
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 { ReadingProgress } from '@/components/reading-progress';
import { ScrollReveal } from '@/components/scroll-reveal';
@@ -145,8 +145,6 @@ export default async function BlogPostPage({ params }: Props) {
wordCount: wordCount,
readingTime: `${readingTime} min read`,
}),
articleBody: textContent.slice(0, 5000),
inLanguage: siteConfig.defaultLocale,
url: postUrl,
};
@@ -219,9 +217,7 @@ export default async function BlogPostPage({ params }: Props) {
{post.tags.map((t) => (
<Link
key={t}
href={`/tags/${encodeURIComponent(
t.toLowerCase().replace(/\s+/g, '-')
)}`}
href={`/tags/${encodeURIComponent(getTagSlug(t))}`}
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}

View File

@@ -130,6 +130,8 @@ export default async function RootLayout({
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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>
<body>
<NextTopLoader

View File

@@ -3,7 +3,7 @@ import Image from 'next/image';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { allPages } from 'contentlayer2/generated';
import { getPageBySlug } from '@/lib/posts';
import { getPageBySlug, getTagSlug } from '@/lib/posts';
import { siteConfig } from '@/lib/config';
import { ReadingProgress } from '@/components/reading-progress';
import { PostLayout } from '@/components/post-layout';
@@ -130,9 +130,7 @@ export default async function StaticPage({ params }: Props) {
{page.tags.map((t) => (
<Link
key={t}
href={`/tags/${encodeURIComponent(
t.toLowerCase().replace(/\s+/g, '-')
)}`}
href={`/tags/${encodeURIComponent(getTagSlug(t))}`}
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}

View File

@@ -1,5 +1,6 @@
import { MetadataRoute } from 'next';
import { allPosts, allPages } from 'contentlayer2/generated';
import { getTagSlug } from '@/lib/posts';
export default function sitemap(): MetadataRoute.Sitemap {
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) => ({
url: `${siteUrl}/tags/${encodeURIComponent(tag.toLowerCase().replace(/\s+/g, '-'))}`,
url: `${siteUrl}/tags/${encodeURIComponent(getTagSlug(tag))}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.5,

View File

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

View File

@@ -52,9 +52,7 @@ export function Hero() {
}[];
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">
<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" />
<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="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">

View File

@@ -18,7 +18,7 @@ export function PostCard({ post, showTags = true }: PostCardProps) {
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">
<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 && (
<div className="relative w-full bg-slate-100 dark:bg-slate-800 overflow-hidden">
<Image

View File

@@ -21,7 +21,7 @@ export function PostListItem({ post, priority = false }: Props) {
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">
<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 && (
<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

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="relative h-1.5 w-full overflow-visible">
{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
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"
@@ -64,7 +64,7 @@ export function ReadingProgress() {
</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={{
transform: `scaleX(${progress / 100})`,
opacity: progress > 0 ? 1 : 0
@@ -78,7 +78,7 @@ export function ReadingProgress() {
</div>
)}
<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"
/>
</div>

View File

@@ -18,7 +18,7 @@ export function RepoCard({ repo, animationDelay = 0 }: RepoCardProps) {
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">
<Link
href={repo.htmlUrl}

View File

@@ -29,18 +29,14 @@ export function TimelineWrapper({ children, className }: TimelineWrapperProps) {
return (
<div className={clsx('relative pl-6 md:pl-8', className)}>
<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"
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"
className="pointer-events-none absolute left-2 top-0 h-full w-[2px] rounded-full bg-accent/40 md:left-3"
aria-hidden="true"
/>
<div className="space-y-4">
{items.map((child, index) => (
<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}
</div>
))}

Submodule content updated: 99cff93cca...43c3a0b18f

View File

@@ -1,11 +1,18 @@
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[] {
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 bDate = b.published_at ? new Date(b.published_at).getTime() : 0;
return bDate - aDate;
});
return _sortedCache;
}
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 }[] {
const map = new Map<string, number>();
if (_tagsCache) return _tagsCache;
const map = new Map<string, number>();
for (const post of allPosts) {
if (!post.tags) continue;
for (const tag of post.tags) {
map.set(tag, (map.get(tag) ?? 0) + 1);
for (const postTag of post.tags) {
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 }))
.sort((a, b) => {
if (b.count === a.count) return a.tag.localeCompare(b.tag);
return b.count - a.count;
});
return _tagsCache;
}
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 candidates = getAllPostsSorted().filter((post) => post._id !== target._id);
@@ -84,28 +98,39 @@ export function getRelatedPosts(target: Post, limit = 3): Post[] {
.slice(0, limit)
.map((entry) => entry.post);
let result: Post[];
if (scored.length >= limit) {
return scored;
result = scored;
} else {
const fallback = candidates.filter(
(post) => !scored.some((existing) => existing._id === post._id)
);
result = [...scored, ...fallback.slice(0, limit - scored.length)].slice(0, limit);
}
const fallback = candidates.filter(
(post) => !scored.some((existing) => existing._id === post._id)
);
return [...scored, ...fallback.slice(0, limit - scored.length)].slice(0, limit);
_relatedCache.set(cacheKey, result);
return result;
}
export function getPostNeighbors(target: Post): {
newer?: Post;
older?: Post;
} {
const cacheKey = target._id;
if (_neighborsCache.has(cacheKey)) {
return _neighborsCache.get(cacheKey)!;
}
const sorted = getAllPostsSorted();
const index = sorted.findIndex((post) => post._id === target._id);
if (index === -1) return {};
return {
const result = {
newer: index > 0 ? 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 */
--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 */
--animate-fade-in-up: fade-in-up 0.6s ease-out both;
@@ -1332,22 +1332,19 @@
--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;
/* Ink + accent palette */
--color-ink-strong: #0f172a;
--color-ink-body: #1f2937;
--color-ink-muted: #475569;
--color-accent: #7c3aed;
--color-accent-soft: #f4f0ff;
/* Ink palette — warm-tinted neutrals */
--color-ink-strong: #1c1917;
--color-ink-body: #292524;
--color-ink-muted: #57534e;
font-size: clamp(15px, 0.65vw + 11px, 19px);
}
.dark {
--color-ink-strong: #e2e8f0;
--color-ink-body: #cbd5e1;
--color-ink-muted: #94a3b8;
--color-accent: #a78bfa;
--color-accent-soft: #1f1a3d;
/* Ink palette — warm-tinted neutrals (dark) */
--color-ink-strong: #e7e5e4;
--color-ink-body: #d6d3d1;
--color-ink-muted: #a8a29e;
}
@media (min-width: 2560px) {
@@ -1357,7 +1354,7 @@
}
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;
line-height: var(--line-height-body);
font-family: var(--font-system-sans);