Compare commits
7 Commits
1b495d2d2d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d4cfe773c | |||
| 1f7dbd80d6 | |||
| b005f02b7b | |||
| 4cdccb0276 | |||
| ddd0cc5795 | |||
| 33042cde79 | |||
| 5325a08bc3 |
@@ -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).
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
2
content
2
content
Submodule content updated: 99cff93cca...7b52c564dc
49
lib/posts.ts
49
lib/posts.ts
@@ -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(
|
||||||
|
(post) => !scored.some((existing) => existing._id === post._id)
|
||||||
|
);
|
||||||
|
result = [...scored, ...fallback.slice(0, limit - scored.length)].slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallback = candidates.filter(
|
_relatedCache.set(cacheKey, result);
|
||||||
(post) => !scored.some((existing) => existing._id === post._id)
|
return result;
|
||||||
);
|
|
||||||
|
|
||||||
return [...scored, ...fallback.slice(0, limit - scored.length)].slice(0, limit);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user