This commit is contained in:
z.ai committed 2026-10-08 11:09:37 +08:00
1 parent c1541b80df
commit b4c7908029
97 files changed
+12403 -154

No files matched your search

+3
View File
@@ -39,3 +39,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# 本地校验脚本产出的截图
/.screenshots/
-9
View File
@@ -1,9 +0,0 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
+852
View File
@@ -0,0 +1,852 @@
---
version: alpha
name: Mintlify-design-analysis
description: Mintlify presents documentation infrastructure with a dual-mode aesthetic — atmospheric sky-gradient marketing heroes (cloud illustration backdrops, soft cream-to-blue washes) paired with dense developer-grade documentation surfaces. The system uses Inter for UI prose, Geist Mono for code, and a signature Mintlify green ({colors.brand-green}) reserved for accent CTAs and active states. Black-pill primary buttons dominate marketing, white-on-dark inversions appear on dark hero bands, and a 3-column documentation layout (sidebar / prose / TOC) anchors the developer experience. Coverage spans homepage, startups program, pricing comparison, and the live tabs documentation page.
colors:
primary: "#0a0a0a"
on-primary: "#ffffff"
brand-green: "#00d4a4"
brand-green-deep: "#00b48a"
brand-green-soft: "#7cebcb"
brand-tag: "#3772cf"
brand-warn: "#c37d0d"
brand-annotate: "#1ba673"
brand-error: "#d45656"
brand-cursor: "#888888"
hero-sky-from: "#87a8c8"
hero-sky-to: "#f5e9d8"
hero-dark-from: "#1a3d4a"
hero-dark-to: "#2d5a4f"
testimonial-orange: "#f55a3c"
testimonial-orange-deep: "#cc3a1f"
canvas: "#ffffff"
canvas-dark: "#0a0a0a"
surface: "#f7f7f7"
surface-soft: "#fafafa"
surface-code: "#1c1c1e"
hairline: "#e5e5e5"
hairline-soft: "#ededed"
hairline-dark: "#1f1f1f"
ink: "#0a0a0a"
charcoal: "#1c1c1e"
slate: "#3a3a3c"
steel: "#5a5a5c"
stone: "#888888"
muted: "#a8a8aa"
on-dark: "#ffffff"
on-dark-muted: "#b3b3b3"
typography:
hero-display:
fontFamily: Inter
fontSize: 72px
fontWeight: 600
lineHeight: 1.05
letterSpacing: -2px
display-lg:
fontFamily: Inter
fontSize: 56px
fontWeight: 600
lineHeight: 1.10
letterSpacing: -1.5px
heading-1:
fontFamily: Inter
fontSize: 48px
fontWeight: 600
lineHeight: 1.10
letterSpacing: -1px
heading-2:
fontFamily: Inter
fontSize: 36px
fontWeight: 600
lineHeight: 1.20
letterSpacing: -0.5px
heading-3:
fontFamily: Inter
fontSize: 28px
fontWeight: 600
lineHeight: 1.25
heading-4:
fontFamily: Inter
fontSize: 22px
fontWeight: 600
lineHeight: 1.30
heading-5:
fontFamily: Inter
fontSize: 18px
fontWeight: 600
lineHeight: 1.40
subtitle:
fontFamily: Inter
fontSize: 18px
fontWeight: 400
lineHeight: 1.50
body-md:
fontFamily: Inter
fontSize: 16px
fontWeight: 400
lineHeight: 1.50
body-md-medium:
fontFamily: Inter
fontSize: 16px
fontWeight: 500
lineHeight: 1.50
body-sm:
fontFamily: Inter
fontSize: 14px
fontWeight: 400
lineHeight: 1.50
body-sm-medium:
fontFamily: Inter
fontSize: 14px
fontWeight: 500
lineHeight: 1.50
caption:
fontFamily: Inter
fontSize: 13px
fontWeight: 400
lineHeight: 1.40
caption-bold:
fontFamily: Inter
fontSize: 13px
fontWeight: 600
lineHeight: 1.40
micro:
fontFamily: Inter
fontSize: 12px
fontWeight: 500
lineHeight: 1.40
micro-uppercase:
fontFamily: Inter
fontSize: 11px
fontWeight: 600
lineHeight: 1.40
letterSpacing: 0.5px
button-md:
fontFamily: Inter
fontSize: 14px
fontWeight: 500
lineHeight: 1.30
code-md:
fontFamily: Geist Mono
fontSize: 14px
fontWeight: 400
lineHeight: 1.50
code-sm:
fontFamily: Geist Mono
fontSize: 13px
fontWeight: 400
lineHeight: 1.40
code-inline:
fontFamily: Geist Mono
fontSize: 13px
fontWeight: 500
lineHeight: 1.30
rounded:
xs: 4px
sm: 6px
md: 8px
lg: 12px
xl: 16px
xxl: 24px
full: 9999px
spacing:
xxs: 4px
xs: 8px
sm: 12px
md: 16px
lg: 20px
xl: 24px
xxl: 32px
xxxl: 40px
section-sm: 48px
section: 64px
section-lg: 96px
hero: 120px
components:
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
typography: "{typography.button-md}"
rounded: "{rounded.full}"
padding: "10px 20px"
button-primary-pressed:
backgroundColor: "{colors.charcoal}"
textColor: "{colors.on-primary}"
button-primary-disabled:
backgroundColor: "{colors.hairline}"
textColor: "{colors.muted}"
button-accent-green:
backgroundColor: "{colors.brand-green}"
textColor: "{colors.primary}"
typography: "{typography.button-md}"
rounded: "{rounded.full}"
padding: "10px 20px"
button-on-dark:
backgroundColor: "{colors.on-dark}"
textColor: "{colors.primary}"
typography: "{typography.button-md}"
rounded: "{rounded.full}"
padding: "10px 20px"
button-secondary:
backgroundColor: "transparent"
textColor: "{colors.ink}"
typography: "{typography.button-md}"
rounded: "{rounded.full}"
padding: "10px 20px"
border: "1px solid {colors.hairline}"
button-ghost:
backgroundColor: "transparent"
textColor: "{colors.ink}"
typography: "{typography.button-md}"
rounded: "{rounded.md}"
padding: "8px 12px"
button-link:
backgroundColor: "transparent"
textColor: "{colors.ink}"
typography: "{typography.body-sm-medium}"
padding: "0"
button-icon-circular:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
rounded: "{rounded.full}"
size: 32px
border: "1px solid {colors.hairline}"
card-base:
backgroundColor: "{colors.canvas}"
rounded: "{rounded.lg}"
padding: "{spacing.xl}"
border: "1px solid {colors.hairline}"
card-feature:
backgroundColor: "{colors.surface}"
rounded: "{rounded.lg}"
padding: "{spacing.xxl}"
card-help:
backgroundColor: "{colors.canvas}"
rounded: "{rounded.lg}"
padding: "{spacing.xl}"
border: "1px solid {colors.hairline}"
card-startup-perk:
backgroundColor: "{colors.canvas}"
rounded: "{rounded.lg}"
padding: "{spacing.xl}"
border: "1px solid {colors.hairline}"
pricing-card:
backgroundColor: "{colors.canvas}"
rounded: "{rounded.lg}"
padding: "{spacing.xxl}"
border: "1px solid {colors.hairline}"
pricing-card-featured:
backgroundColor: "{colors.canvas}"
rounded: "{rounded.lg}"
padding: "{spacing.xxl}"
border: "2px solid {colors.brand-green}"
shadow: "rgba(0, 212, 164, 0.08) 0px 8px 24px"
testimonial-card-feature:
backgroundColor: "{colors.testimonial-orange}"
textColor: "{colors.on-dark}"
rounded: "{rounded.lg}"
padding: "{spacing.section}"
testimonial-card-quote:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
rounded: "{rounded.lg}"
padding: "{spacing.xxl}"
border: "1px solid {colors.hairline}"
text-input:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.body-md}"
rounded: "{rounded.md}"
padding: "{spacing.sm} {spacing.md}"
border: "1px solid {colors.hairline}"
height: 40px
text-input-focused:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
border: "2px solid {colors.brand-green}"
search-pill:
backgroundColor: "{colors.surface}"
textColor: "{colors.steel}"
typography: "{typography.body-sm}"
rounded: "{rounded.md}"
padding: "{spacing.xs} {spacing.md}"
height: 36px
border: "1px solid {colors.hairline}"
segmented-tab:
backgroundColor: "transparent"
textColor: "{colors.steel}"
typography: "{typography.body-sm-medium}"
padding: "{spacing.sm} {spacing.md}"
border: "0 0 2px transparent solid"
segmented-tab-active:
backgroundColor: "transparent"
textColor: "{colors.ink}"
typography: "{typography.body-sm-medium}"
border: "0 0 2px {colors.ink} solid"
pill-tab:
backgroundColor: "{colors.canvas}"
textColor: "{colors.steel}"
typography: "{typography.body-sm-medium}"
rounded: "{rounded.full}"
padding: "8px 16px"
border: "1px solid {colors.hairline}"
pill-tab-active:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
rounded: "{rounded.full}"
border: "1px solid {colors.primary}"
toggle-monthly-yearly:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink}"
rounded: "{rounded.full}"
padding: "4px"
badge-discount:
backgroundColor: "{colors.brand-green}"
textColor: "{colors.primary}"
typography: "{typography.caption-bold}"
rounded: "{rounded.full}"
padding: "2px 8px"
badge-required:
backgroundColor: "{colors.brand-error}"
textColor: "{colors.on-dark}"
typography: "{typography.micro-uppercase}"
rounded: "{rounded.sm}"
padding: "2px 6px"
badge-type:
backgroundColor: "{colors.surface}"
textColor: "{colors.steel}"
typography: "{typography.code-sm}"
rounded: "{rounded.sm}"
padding: "2px 6px"
badge-tag:
backgroundColor: "rgba(55, 114, 207, 0.15)"
textColor: "{colors.brand-tag}"
typography: "{typography.caption-bold}"
rounded: "{rounded.sm}"
padding: "2px 8px"
promo-banner:
backgroundColor: "{colors.canvas-dark}"
textColor: "{colors.on-dark}"
typography: "{typography.body-sm-medium}"
padding: "{spacing.sm} {spacing.md}"
code-block:
backgroundColor: "{colors.surface-code}"
textColor: "{colors.on-dark}"
typography: "{typography.code-md}"
rounded: "{rounded.md}"
padding: "{spacing.md}"
code-block-header:
backgroundColor: "{colors.surface-code}"
textColor: "{colors.on-dark-muted}"
typography: "{typography.caption}"
padding: "{spacing.xs} {spacing.md}"
border: "0 0 1px {colors.hairline-dark} solid"
code-inline:
backgroundColor: "{colors.surface}"
textColor: "{colors.charcoal}"
typography: "{typography.code-inline}"
rounded: "{rounded.xs}"
padding: "2px 6px"
border: "1px solid {colors.hairline}"
property-row:
backgroundColor: "transparent"
textColor: "{colors.ink}"
typography: "{typography.body-sm}"
padding: "{spacing.md} 0"
border: "0 0 1px {colors.hairline-soft} solid"
feature-comparison-table:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.body-sm}"
rounded: "{rounded.md}"
border: "1px solid {colors.hairline}"
feature-comparison-row:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
padding: "{spacing.md} {spacing.lg}"
border: "0 0 1px {colors.hairline-soft} solid"
sidebar-nav-item:
backgroundColor: "transparent"
textColor: "{colors.steel}"
typography: "{typography.body-sm}"
rounded: "{rounded.sm}"
padding: "{spacing.xs} {spacing.md}"
sidebar-nav-item-active:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink}"
typography: "{typography.body-sm-medium}"
sidebar-section-header:
backgroundColor: "transparent"
textColor: "{colors.steel}"
typography: "{typography.micro-uppercase}"
padding: "{spacing.md} {spacing.md} {spacing.xs}"
doc-toc-item:
backgroundColor: "transparent"
textColor: "{colors.steel}"
typography: "{typography.body-sm}"
padding: "{spacing.xxs} 0"
doc-toc-item-active:
backgroundColor: "transparent"
textColor: "{colors.ink}"
typography: "{typography.body-sm-medium}"
copy-code-button:
backgroundColor: "transparent"
textColor: "{colors.on-dark-muted}"
typography: "{typography.caption}"
rounded: "{rounded.sm}"
padding: "{spacing.xxs} {spacing.xs}"
border: "1px solid {colors.hairline-dark}"
hero-band-sky:
backgroundColor: "{colors.hero-sky-from}"
textColor: "{colors.on-dark}"
rounded: "0"
padding: "{spacing.hero}"
hero-band-dark:
backgroundColor: "{colors.hero-dark-from}"
textColor: "{colors.on-dark}"
rounded: "0"
padding: "{spacing.hero}"
hero-product-mockup:
backgroundColor: "{colors.canvas}"
rounded: "{rounded.lg}"
padding: "0"
border: "1px solid {colors.hairline-soft}"
shadow: "rgba(0, 0, 0, 0.12) 0px 24px 48px -8px"
logo-wall-item:
backgroundColor: "transparent"
textColor: "{colors.steel}"
typography: "{typography.body-md-medium}"
padding: "{spacing.lg}"
faq-accordion-item:
backgroundColor: "{colors.canvas}"
rounded: "{rounded.md}"
padding: "{spacing.xl}"
border: "1px solid {colors.hairline-soft}"
footer-region:
backgroundColor: "{colors.canvas}"
textColor: "{colors.steel}"
typography: "{typography.body-sm}"
padding: "{spacing.section} {spacing.xxl}"
border: "1px solid {colors.hairline}"
footer-link:
backgroundColor: "transparent"
textColor: "{colors.steel}"
typography: "{typography.body-sm}"
padding: "{spacing.xxs} 0"
startup-program-card:
backgroundColor: "{colors.canvas}"
rounded: "{rounded.lg}"
padding: "{spacing.xxl}"
border: "1px solid {colors.hairline}"
founder-quote-card:
backgroundColor: "{colors.testimonial-orange}"
textColor: "{colors.on-dark}"
rounded: "{rounded.lg}"
padding: "{spacing.xxl}"
---
## Overview
Mintlify positions itself at the intersection of polished marketing presentation and developer-grade documentation density. The home and startups pages open with cinematic atmospheric heroes — soft sky-gradient backdrops with cloud illustrations on the homepage, dark teal-to-mint gradients with a rocket launch on the startups page — that feel more like a SaaS landing aesthetic than a developer tool. Then the deeper surfaces (pricing comparison, live documentation pages) collapse into dense, high-information layouts where Inter body type carries 14–16px copy across long-form prose, syntax-highlighted code blocks, and 3-column documentation grids.
The brand's signature mint green ({colors.brand-green}) appears sparingly but decisively — on the hero "Get started" pill button, the green checkmark icons inside feature lists, the "Featured" pricing tier border, and active state indicators inside docs UI. Black-pill primary buttons dominate the marketing flow; white-on-dark inversions appear on dark hero bands. The signature pairing of Inter (body, headings) with Geist Mono (code blocks, inline references, type signatures) reinforces the developer-tool DNA without requiring a third typeface.
**Key Characteristics:**
- Atmospheric gradient hero bands (sky-blue to cream on homepage; teal-to-mint on startups) provide cinematic marketing presentation
- Signature Mintlify mint green ({colors.brand-green}) reserved for accent CTAs, active states, and feature confirmations
- Black-pill primary buttons ({colors.primary} + `{rounded.full}`) for marketing CTAs
- Inter for all UI prose; Geist Mono for code blocks, inline code, and type/property signatures
- 3-column documentation layout (sidebar / prose / TOC) with dense 14px body type for long-form developer reading
- Tightly-controlled radius scale: marketing uses `{rounded.lg}` (12px), pill buttons use `{rounded.full}` — no in-between corner softening
- Vibrant testimonial card (`{colors.testimonial-orange}`) breaks color rhythm intentionally for emotional impact
## Colors
> Source pages: mintlify.com/ (homepage), /startups (program page), /pricing (comparison), /docs/components/tabs (live documentation). Token coverage was identical across all four pages.
### Brand & Accent
- **Mintlify Mint** ({colors.brand-green}): Signature accent — used on hero "Get started" pill button, green checkmarks in feature lists, featured pricing tier border accent, sidebar active indicator dots.
- **Deep Mint** ({colors.brand-green-deep}): Pressed/active variant of the mint accent.
- **Soft Mint** ({colors.brand-green-soft}): Subtle background tint for success states and confirmation surfaces.
- **Brand Tag** ({colors.brand-tag}): Documentation tag and reference color (used in `<Tabs>` JSX-style annotations and code-tag chips).
- **Brand Annotate** ({colors.brand-annotate}): Inline code annotation green (used in twoslash code annotation system).
- **Brand Warn** ({colors.brand-warn}): Code warning highlight (deprecated, caution).
- **Brand Error** ({colors.brand-error}): Red used for required-field labels and error highlight.
- **Testimonial Orange** ({colors.testimonial-orange}): Warm coral-orange used on the "Cursor" testimonial card and warm callout surfaces.
### Surface
- **Canvas White** ({colors.canvas}): Primary page and card background.
- **Canvas Dark** ({colors.canvas-dark}): Promo banner, dark inversion surfaces, code editor wrapper.
- **Surface** ({colors.surface}): Subtle section backgrounds, search-pill rest, code-inline background, sidebar active state.
- **Surface Soft** ({colors.surface-soft}): Quieter section backgrounds and FAQ accordion.
- **Surface Code** ({colors.surface-code}): Dark code-block wrapper background.
- **Hairline** ({colors.hairline}): 1px borders and primary dividers.
- **Hairline Soft** ({colors.hairline-soft}): Quieter table-row dividers and secondary section breaks.
### Hero Atmospheric
- **Hero Sky From / To** ({colors.hero-sky-from}, {colors.hero-sky-to}): Atmospheric sky-blue to soft cream gradient on the homepage hero.
- **Hero Dark From / To** ({colors.hero-dark-from}, {colors.hero-dark-to}): Dark teal to mint gradient on the startups hero.
### Text
- **Ink** ({colors.ink}): Primary headlines and CTA text.
- **Charcoal** ({colors.charcoal}): Body text, code-inline foreground.
- **Slate** ({colors.slate}): Secondary text and metadata.
- **Steel** ({colors.steel}): Tertiary text, table headers, sidebar inactive items, footer links.
- **Stone** ({colors.stone}): Captions, twoslash cursor color, muted labels.
- **Muted** ({colors.muted}): De-emphasized labels and disabled text.
- **On Dark** ({colors.on-dark}): White text on dark surfaces (hero bands, code blocks, promo banner).
- **On Dark Muted** ({colors.on-dark-muted}): Reduced-opacity white for code-block headers and metadata on dark.
### Semantic
- Error tones derive from `{colors.brand-error}` for input borders, required-field labels, and validation messaging.
## Typography
### Font Family
**Inter** (primary): Variable typeface optimized for UI legibility. Used across every UI surface — body, headings, navigation, button labels, captions. Fallbacks: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif.
**Geist Mono** (code): Monospace typeface used inside code blocks, inline code references, type signatures (e.g. `string`, `number`, `boolean`), and property names in API documentation. Fallbacks: 'SF Mono', Menlo, Consolas, 'Geist Mono Fallback', monospace.
The brand uses no italic variants of either face — emphasis comes from weight (500/600), color shift, or background highlighting (in code references).
### Hierarchy
| Token | Size | Weight | Line Height | Letter Spacing | Use |
|---|---|---|---|---|---|
| `{typography.hero-display}` | 72px | 600 | 1.05 | -2px | Marketing hero display ("The intelligent Knowledge Platform") |
| `{typography.display-lg}` | 56px | 600 | 1.10 | -1.5px | Major section opener ("Built for the intelligence age") |
| `{typography.heading-1}` | 48px | 600 | 1.10 | -1px | Page-level headlines ("Pricing on your terms") |
| `{typography.heading-2}` | 36px | 600 | 1.20 | -0.5px | Section headlines ("Apply to the Mintlify startup program") |
| `{typography.heading-3}` | 28px | 600 | 1.25 | 0 | Subsection headers, "Tabs" docs page title |
| `{typography.heading-4}` | 22px | 600 | 1.30 | 0 | Card titles, larger feature headers |
| `{typography.heading-5}` | 18px | 600 | 1.40 | 0 | Smaller feature headers, FAQ question titles |
| `{typography.subtitle}` | 18px | 400 | 1.50 | 0 | Hero subtitle, lead body |
| `{typography.body-md}` | 16px | 400 | 1.50 | 0 | Primary body text |
| `{typography.body-md-medium}` | 16px | 500 | 1.50 | 0 | Body emphasis |
| `{typography.body-sm}` | 14px | 400 | 1.50 | 0 | Secondary body, table cells, navigation |
| `{typography.body-sm-medium}` | 14px | 500 | 1.50 | 0 | Active sidebar nav, button labels, tab labels |
| `{typography.caption}` | 13px | 400 | 1.40 | 0 | Helper text, fine print, code-block headers |
| `{typography.caption-bold}` | 13px | 600 | 1.40 | 0 | Badge labels |
| `{typography.micro}` | 12px | 500 | 1.40 | 0 | Footer microcopy, label chips |
| `{typography.micro-uppercase}` | 11px | 600 | 1.40 | 0.5px | Sidebar section headers, "REQUIRED" labels |
| `{typography.button-md}` | 14px | 500 | 1.30 | 0 | Pill button labels |
| `{typography.code-md}` | 14px | 400 | 1.50 | 0 | Code block content |
| `{typography.code-sm}` | 13px | 400 | 1.40 | 0 | Smaller code, type signatures |
| `{typography.code-inline}` | 13px | 500 | 1.30 | 0 | Inline `<Tabs>` references in body |
### Principles
- **Tight hero leading** (1.05) creates magazine-grade display headlines on the 72px hero
- **Negative letter-spacing** progresses inversely with size — display sizes use -2px to -1.5px; smaller headings relax to 0
- **Documentation-grade body** (1.50 line-height on 14–16px) ensures comfortable long-form reading in dense docs surfaces
- **Inter / Geist Mono pairing** — Inter for everything else, Geist Mono surgically for code references; the contrast between the two is the brand's developer-respect signal
- **Uppercase micro labels** with +0.5px letter-spacing carry sidebar section headers and "REQUIRED" annotation tags
## Layout
### Spacing System
- **Base unit**: 4px (8px primary increment)
- **Tokens**: `{spacing.xxs}` (4px) · `{spacing.xs}` (8px) · `{spacing.sm}` (12px) · `{spacing.md}` (16px) · `{spacing.lg}` (20px) · `{spacing.xl}` (24px) · `{spacing.xxl}` (32px) · `{spacing.xxxl}` (40px) · `{spacing.section-sm}` (48px) · `{spacing.section}` (64px) · `{spacing.section-lg}` (96px) · `{spacing.hero}` (120px)
- **Section rhythm**: Marketing pages use `{spacing.section-lg}` (96px) between major bands; pricing comparison tightens to `{spacing.section}` (64px); documentation surfaces use `{spacing.xxl}` (32px) between subsections
- **Card internal padding**: Standard `{spacing.xl}` (24px) for compact cards; `{spacing.xxl}` (32px) for pricing cards and feature panels; testimonial card pushes to `{spacing.section}` (64px) for hero-card presence
### Grid & Container
- Marketing pages use a 1280px max-width with 32px gutters
- Hero and feature bands often use 2-column splits (text left, illustration/mockup right)
- Pricing page renders 3 tier cards in a row at desktop (FREE / Lift Off / Custom), then a comprehensive feature comparison table below
- Documentation pages use a strict 3-column grid: left sidebar nav (~240px), center prose (~720px max-width), right TOC (~200px)
- Logo walls use 6-up rows of customer logos at 80–100px height each
### Whitespace Philosophy
Marketing surfaces give content generous breathing room — `{spacing.hero}` (120px) above-the-fold creates space for atmospheric gradient backdrops to read clearly. Documentation tightens dramatically: section gaps drop to `{spacing.xxl}` (32px), table rows pack to `{spacing.md}` (16px), sidebar nav compresses to `{spacing.xs}` (8px) vertical rhythm.
## Elevation & Depth
The system runs predominantly flat with strategic atmospheric depth.
| Level | Treatment | Use |
|---|---|---|
| 0 (flat) | No shadow; `{colors.hairline}` border | Default cards, table rows, form inputs |
| 1 (subtle) | `rgba(0, 0, 0, 0.04) 0px 1px 2px 0px` | Hover-elevated tiles, subtle highlights |
| 2 (card) | `rgba(0, 0, 0, 0.08) 0px 4px 12px 0px` | Standard feature cards |
| 3 (mockup) | `rgba(0, 0, 0, 0.12) 0px 24px 48px -8px` | Hero product mockup framing — the deep diffuse drop on the homepage hero docs preview |
| 4 (brand-tinted) | `rgba(0, 212, 164, 0.08) 0px 8px 24px` | Featured pricing tier glow |
### Decorative Depth
- The homepage hero uses an atmospheric photographic backdrop (cloud illustration on sky-gradient) for depth — no shadow needed; the imagery does the work
- The startups hero uses a similar treatment with a rocket-launch illustration cutting across the dark teal gradient
- Code blocks carry their own internal depth via syntax-highlighting color hierarchy on the dark surface; no shadow used
## Shapes
### Border Radius Scale
| Token | Value | Use |
|---|---|---|
| `{rounded.xs}` | 4px | Inline code chips, micro tags |
| `{rounded.sm}` | 6px | Sidebar nav items, type badges |
| `{rounded.md}` | 8px | Inputs, search pill, code blocks, secondary cards |
| `{rounded.lg}` | 12px | Standard cards, pricing tiers, hero mockup, FAQ items |
| `{rounded.xl}` | 16px | Larger feature panels |
| `{rounded.xxl}` | 24px | Featured product showcase tiles |
| `{rounded.full}` | 9999px | All buttons, pill tabs, badges |
The radius scale is tightly disciplined — the brand never uses a corner softening between `{rounded.md}` (8px) and `{rounded.lg}` (12px) for the same component family. Pill buttons (`{rounded.full}`) are used universally; rectangular cards use `{rounded.lg}` (12px) consistently.
### Photography Geometry
- Hero illustrations (cloud, rocket) sit on full-bleed gradient backdrops with no internal framing
- Customer logo walls use 1:1 ratio cells without rounding (logos are presented inline as wordmarks)
- Testimonial photos use 1:1 aspect with `{rounded.md}` (8px) softening
- Code editor mockup hero image uses `{rounded.lg}` (12px) corners on a hairline-bordered card with a deep diffuse drop shadow
## Components
> Per the no-hover policy, hover states are NOT documented. Default and pressed/active states only.
### Buttons
**`button-primary`** — Black pill primary CTA, the dominant action across all surfaces.
- Background `{colors.primary}`, text `{colors.on-primary}`, typography `{typography.button-md}`, padding `10px 20px`, rounded `{rounded.full}`.
- Pressed state `button-primary-pressed` lifts to `{colors.charcoal}`.
- Disabled state `button-primary-disabled` uses `{colors.hairline}` background and `{colors.muted}` text.
**`button-accent-green`** — Mint green pill for brand-emphasis CTAs (hero "Get started", featured pricing CTA).
- Background `{colors.brand-green}`, text `{colors.primary}`, typography `{typography.button-md}`, padding `10px 20px`, rounded `{rounded.full}`.
**`button-on-dark`** — White pill for use on dark hero bands (startups page "Get started").
- Background `{colors.on-dark}`, text `{colors.primary}`, typography `{typography.button-md}`, padding `10px 20px`, rounded `{rounded.full}`.
**`button-secondary`** — Outlined pill for secondary actions.
- Background transparent, text `{colors.ink}`, border `1px solid {colors.hairline}`, typography `{typography.button-md}`, padding `10px 20px`, rounded `{rounded.full}`.
**`button-ghost`** — Quieter rectangular ghost button (sidebar action, tertiary nav).
- Background transparent, text `{colors.ink}`, typography `{typography.button-md}`, padding `8px 12px`, rounded `{rounded.md}`.
**`button-link`** — Inline text link styled as a subtle button.
- Background transparent, text `{colors.ink}`, typography `{typography.body-sm-medium}`, padding `0`. Underline appears on activation.
**`button-icon-circular`** — 32×32px circular utility button (close, copy, arrow).
- Background `{colors.canvas}`, text `{colors.ink}`, border `1px solid {colors.hairline}`, rounded `{rounded.full}`.
### Cards & Containers
**`card-base`** — Standard documentation/feature card.
- Background `{colors.canvas}`, rounded `{rounded.lg}`, padding `{spacing.xl}`, border `1px solid {colors.hairline}`.
**`card-feature`** — Feature panel on light gray surface.
- Background `{colors.surface}`, rounded `{rounded.lg}`, padding `{spacing.xxl}`.
**`card-help`** — "Need help?" CTA cards below the pricing comparison ("Quickstart guide", "Guide to technical writing", "Founder", "Sales").
- Background `{colors.canvas}`, rounded `{rounded.lg}`, padding `{spacing.xl}`, border `1px solid {colors.hairline}`.
**`card-startup-perk`** — Startup-program perk grid item ("Discounts and credits", "Priority support", "Startup pack", "Founder community").
- Background `{colors.canvas}`, rounded `{rounded.lg}`, padding `{spacing.xl}`, border `1px solid {colors.hairline}`. Carries an icon at top, heading `{typography.heading-5}`, description `{typography.body-sm}` `{colors.steel}`.
**`pricing-card`** — Standard pricing tier card.
- Background `{colors.canvas}`, rounded `{rounded.lg}`, padding `{spacing.xxl}`, border `1px solid {colors.hairline}`.
- Title `{typography.heading-3}`, price `{typography.display-lg}`, feature list `{typography.body-sm}` with green checkmark icons.
**`pricing-card-featured`** — Highlighted pricing tier (Lift Off / featured plan).
- Background `{colors.canvas}`, rounded `{rounded.lg}`, padding `{spacing.xxl}`, border `2px solid {colors.brand-green}`, soft brand-tinted shadow `rgba(0, 212, 164, 0.08) 0px 8px 24px`.
**`testimonial-card-feature`** — Bright orange large testimonial card with photo + quote ("Cursor — Every YC batch we consistently see the top performing startups use Mintlify to build their docs.").
- Background `{colors.testimonial-orange}`, text `{colors.on-dark}`, rounded `{rounded.lg}`, padding `{spacing.section}`. Photo on right, large quote in `{typography.heading-3}` left, attribution below in `{typography.body-sm-medium}`.
**`testimonial-card-quote`** — Smaller white testimonial card on the startups page.
- Background `{colors.canvas}`, rounded `{rounded.lg}`, padding `{spacing.xxl}`, border `1px solid {colors.hairline}`.
**`founder-quote-card`** — Cursor founder testimonial card variant on the orange surface.
- Background `{colors.testimonial-orange}`, text `{colors.on-dark}`, rounded `{rounded.lg}`, padding `{spacing.xxl}`. Carries the specific founder portrait + quote treatment.
**`startup-program-card`** — Larger application/program card containing perks grid + apply CTA.
- Background `{colors.canvas}`, rounded `{rounded.lg}`, padding `{spacing.xxl}`, border `1px solid {colors.hairline}`.
### Inputs & Forms
**`text-input`** — Standard text field.
- Background `{colors.canvas}`, text `{colors.ink}`, border `1px solid {colors.hairline}`, rounded `{rounded.md}`, padding `{spacing.sm} {spacing.md}`, height 40px.
**`text-input-focused`** — Activated state.
- Border switches to `2px solid {colors.brand-green}` — focus uses the brand mint as the activation signal.
**`search-pill`** — Documentation top-bar search.
- Background `{colors.surface}`, text `{colors.steel}`, typography `{typography.body-sm}`, rounded `{rounded.md}`, height 36px, border `1px solid {colors.hairline}`.
### Tabs
**`segmented-tab`** + **`segmented-tab-active`** — Underline-style tab navigation (used inside docs Tabs component for "First tab / Second tab / Third tab").
- Inactive: text `{colors.steel}`, transparent background, padding `{spacing.sm} {spacing.md}`. Active: text `{colors.ink}`, 2px bottom border in `{colors.ink}`.
**`pill-tab`** + **`pill-tab-active`** — Pill-style tab nav (top of pricing page: "Pricing / Roadmap").
- Inactive: background `{colors.canvas}`, text `{colors.steel}`, border `1px solid {colors.hairline}`, padding `8px 16px`, rounded `{rounded.full}`.
- Active: background `{colors.primary}`, text `{colors.on-primary}`, no border.
**`toggle-monthly-yearly`** — Two-state pill toggle (Monthly / Annual on pricing page).
- Background `{colors.surface}`, rounded `{rounded.full}`, padding `4px`. Active state moves a white pill thumb to the selected position.
### Badges & Status
**`badge-discount`** — Small green "Save 20%" badge attached to annual toggle.
- Background `{colors.brand-green}`, text `{colors.primary}`, typography `{typography.caption-bold}`, rounded `{rounded.full}`, padding `2px 8px`.
**`badge-required`** — Red "REQUIRED" label on documentation property rows.
- Background `{colors.brand-error}`, text `{colors.on-dark}`, typography `{typography.micro-uppercase}`, rounded `{rounded.sm}`, padding `2px 6px`.
**`badge-type`** — Type signature chip in documentation (e.g. `string`, `number`, `boolean`).
- Background `{colors.surface}`, text `{colors.steel}`, typography `{typography.code-sm}`, rounded `{rounded.sm}`, padding `2px 6px`.
**`badge-tag`** — Documentation tag chip (e.g. `<Tabs>` reference highlighted in body text).
- Background `rgba(55, 114, 207, 0.15)`, text `{colors.brand-tag}`, typography `{typography.caption-bold}`, rounded `{rounded.sm}`, padding `2px 8px`.
**`promo-banner`** — Sticky black promo strip ABOVE the top nav (when present).
- Background `{colors.canvas-dark}`, text `{colors.on-dark}`, typography `{typography.body-sm-medium}`, padding `{spacing.sm} {spacing.md}`.
### Code
**`code-block`** — Syntax-highlighted code container.
- Background `{colors.surface-code}`, text `{colors.on-dark}`, typography `{typography.code-md}`, rounded `{rounded.md}`, padding `{spacing.md}`.
**`code-block-header`** — Header bar above the code with language label + copy button.
- Background `{colors.surface-code}`, text `{colors.on-dark-muted}`, typography `{typography.caption}`, padding `{spacing.xs} {spacing.md}`, bottom border `1px solid {colors.hairline-dark}`.
**`code-inline`** — Inline `<Tabs>` reference in body prose.
- Background `{colors.surface}`, text `{colors.charcoal}`, typography `{typography.code-inline}`, rounded `{rounded.xs}`, padding `2px 6px`, border `1px solid {colors.hairline}`.
**`copy-code-button`** — "Copy code" button in code-block header.
- Background transparent, text `{colors.on-dark-muted}`, typography `{typography.caption}`, rounded `{rounded.sm}`, padding `{spacing.xxs} {spacing.xs}`, border `1px solid {colors.hairline-dark}`.
### Documentation Components
**`property-row`** — API property documentation row (e.g. `defaultIndex` on the Tabs page).
- Background transparent, text `{colors.ink}`, typography `{typography.body-sm}`, padding `{spacing.md} 0`, bottom border `1px solid {colors.hairline-soft}`.
- Layout: property name in `{typography.code-inline}` + type badge + optional REQUIRED badge + description below in `{typography.body-sm}` `{colors.steel}`.
**`feature-comparison-table`** — Detailed pricing-page feature comparison table.
- Background `{colors.canvas}`, text `{colors.ink}`, typography `{typography.body-sm}`, rounded `{rounded.md}`, border `1px solid {colors.hairline}`.
**`feature-comparison-row`** — Individual row inside the comparison table.
- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.md} {spacing.lg}`, bottom border `1px solid {colors.hairline-soft}`. Section dividers in `{typography.micro-uppercase}` `{colors.steel}`.
**`sidebar-nav-item`** + **`sidebar-nav-item-active`** — Documentation left rail link entries.
- Inactive: background transparent, text `{colors.steel}`, typography `{typography.body-sm}`, rounded `{rounded.sm}`, padding `{spacing.xs} {spacing.md}`.
- Active: background `{colors.surface}`, text `{colors.ink}`, typography `{typography.body-sm-medium}`.
**`sidebar-section-header`** — Uppercase section header inside sidebar (e.g. "COMPONENTS", "PRIMITIVES").
- Background transparent, text `{colors.steel}`, typography `{typography.micro-uppercase}`, padding `{spacing.md} {spacing.md} {spacing.xs}`.
**`doc-toc-item`** + **`doc-toc-item-active`** — Right-rail table-of-contents links.
- Inactive: background transparent, text `{colors.steel}`, typography `{typography.body-sm}`, padding `{spacing.xxs} 0`.
- Active: text `{colors.ink}`, typography `{typography.body-sm-medium}`, optional left-border accent in `{colors.brand-green}`.
### Navigation
**Top Navigation (Marketing)** — Sticky white bar with logo, link list, and right-side CTAs.
- Background `{colors.canvas}`, height ~64px, bottom border `1px solid {colors.hairline-soft}`.
- Left: Mintlify wordmark + horizontal link list (Solutions, Pricing, Customers, Documentation, Changelog).
- Right: secondary "Talk to sales" + black-pill "Get Started".
**Top Navigation (Documentation)** — Compressed nav with center search-pill and right-side account/upgrade CTAs.
- Background `{colors.canvas}`, height ~56px. Search-pill at center, "Documentation / Guides / API Reference / Changelog" links + "Talk to us" + green "Get started" right.
### Signature Components
**`hero-band-sky`** — Homepage hero with atmospheric sky-blue to cream gradient and cloud illustrations.
- Background gradient `linear-gradient(180deg, {colors.hero-sky-from} 0%, {colors.hero-sky-to} 100%)`, text `{colors.on-dark}` (early portion of gradient) shifting to `{colors.ink}` further down, padding `{spacing.hero}`.
- Layout: centered hero headline in `{typography.hero-display}`, centered subtitle in `{typography.subtitle}`, centered button row (`button-accent-green` "Get started" + `button-secondary` "Talk to us"), product mockup below the buttons.
**`hero-band-dark`** — Startups hero with dark teal-to-mint gradient and rocket launch illustration.
- Background gradient `linear-gradient(135deg, {colors.hero-dark-from} 0%, {colors.hero-dark-to} 100%)`, text `{colors.on-dark}`, padding `{spacing.hero}`.
- Layout: hero headline left in `{typography.hero-display}` `{colors.on-dark}`, illustration right (rocket cutting across the gradient), button row uses `button-on-dark` (white pill) + ghost link.
**`hero-product-mockup`** — Code-editor mockup framed inside the homepage hero.
- Background `{colors.canvas}`, rounded `{rounded.lg}`, border `1px solid {colors.hairline-soft}`, deep shadow `rgba(0, 0, 0, 0.12) 0px 24px 48px -8px`.
- Carries a documentation page preview inside (sidebar on left, prose body, mock UI controls).
**`logo-wall-item`** — Customer logo cell in 6-up trust-row grids ("Anthropic / Cognition / Mintlify / Vercel / react / Lovable", "Stripe / Block / PayPal / Compound / Auth").
- Background transparent, text `{colors.steel}`, typography `{typography.body-md-medium}`, padding `{spacing.lg}`.
- Logos rendered as wordmarks with consistent vertical centering.
**`faq-accordion-item`** — Frequently-asked-questions panel item (visible on pricing page).
- Background `{colors.canvas}`, rounded `{rounded.md}`, padding `{spacing.xl}`, border `1px solid {colors.hairline-soft}`.
- Question in `{typography.heading-5}`, expanded answer in `{typography.body-md}` `{colors.steel}`, chevron icon in `{colors.steel}` 16px.
**`footer-region`** — Multi-column site footer.
- Background `{colors.canvas}`, top border `1px solid {colors.hairline}`, padding `{spacing.section} {spacing.xxl}`.
- 5 column groups (Explore / Resources / Company / Legal + brand mark column).
- Section headers in `{typography.body-sm-medium}` `{colors.ink}`, link items in `{typography.body-sm}` `{colors.steel}`.
**`footer-link`** — Individual link entry in the footer.
- Background transparent, text `{colors.steel}`, typography `{typography.body-sm}`, padding `{spacing.xxs} 0`.
## Do's and Don'ts
### Do
- Reserve `{colors.brand-green}` (Mintlify mint) for accent CTAs and active state indicators only — even one accent button per viewport carries weight
- Use `{colors.primary}` (black) as the dominant CTA on light backgrounds; switch to `button-on-dark` (white pill) on dark hero bands
- Apply `{rounded.full}` to every button and pill; never soften pill corners
- Pair Inter (UI prose) with Geist Mono (code) — never introduce a third typeface
- Use atmospheric gradient hero bands sparingly (only the homepage and startups page); keep deeper surfaces flat and dense
- Apply `{rounded.lg}` (12px) consistently on cards; use `{rounded.md}` (8px) only on compact UI like search pills and code blocks
- Keep documentation prose at `{typography.body-md}` (16px) with 1.50 line-height — never compress
### Don't
- Don't use `{colors.brand-green}` on body text or large surfaces — it loses signal
- Don't introduce additional accent colors beyond mint, tag-blue, error-red, and the testimonial orange
- Don't apply heavy shadows on flat documentation cards; reserve elevation for the hero product mockup
- Don't reduce documentation line-height below 1.50 — long-form readability suffers
- Don't combine atmospheric gradients with multiple competing color accents in the same hero — the sky/dark gradient is the brand mood; let it breathe
- Don't use Inter for code or Geist Mono for prose — the typeface assignment IS the brand voice
## Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|---|---|---|
| Mobile (small) | < 480px | Single column. Hero scales to 36px. Pill nav collapses to hamburger. Pricing tiers stack 1-up. Footer 1-column accordion. |
| Mobile (large) | 480 – 767px | Same as small but feature tiles render 2-up. Hero scales to 44px. |
| Tablet | 768 – 1023px | 2-column feature grids. Pill-tab nav returns. Documentation sidebar collapses to drawer. Hero scales to 56px. |
| Desktop | 1024 – 1279px | Full 3-column docs grid (sidebar / body / TOC). 3-tier pricing card row. Hero at 72px. |
| Wide Desktop | ≥ 1280px | Wider hero gutters, larger product mockup, fixed 240px sidebar. |
### Touch Targets
- Pill buttons render at 36–40px effective height — bumps to 44px on mobile via padding override
- Circular icon buttons: 32×32px desktop → 44×44px mobile
- Form inputs render at 40px height; bumps to 44px mobile
- Sidebar nav items render at ~32px tall — bump to 44px mobile drawers
### Collapsing Strategy
- **Promo banner** stays full-width; truncates at < 480px
- **Top nav** below 1024px collapses to hamburger; horizontal links move into drawer
- **Hero band**: 2-column hero (text + mockup) collapses to stacked at < 1024px; mockup rendered below text on mobile
- **Documentation grid**: 3-column desktop → sidebar-drawer at < 1024px → single-column at < 768px
- **Pricing comparison**: 3-column tiers → 1-column stacked at < 768px; comparison table becomes horizontal-scroll
- **Hero typography**: `{typography.hero-display}` (72px) → 56px tablet → 44px mobile-large → 36px mobile-small
- **Customer logo wall**: 6-up → 3-up at tablet → 2-up at mobile
- **Footer**: 5-column desktop → 2-column tablet → accordion at mobile
### Image Behavior
- Hero illustrations (cloud, rocket) lazy-load with the hero band; remain crisp at all breakpoints (SVG-based)
- Product mockup retains its aspect ratio across breakpoints; scales proportionally
- Customer logos use SVG wordmarks; remain crisp on retina displays
## Iteration Guide
1. Focus on ONE component at a time. The system has high internal consistency.
2. Reference component names and tokens directly (`{colors.primary}`, `{component-name}-pressed`, `{rounded.full}`) — do not paraphrase.
3. Run `npx @google/design.md lint DESIGN.md` after edits to catch broken refs and contrast issues.
4. Add new variants as separate `components:` entries (`-pressed`, `-disabled`, `-focused`, `-active`).
5. Default to `{typography.body-md}` for body and `{typography.subtitle}` for emphasis. Headlines step down `hero-display → display-lg → heading-1 → heading-2 → heading-3 → heading-4 → heading-5`.
6. Keep `{colors.brand-green}` confined to accent moments. If it appears on a generic surface, ask whether it earned that role.
7. Pill-shaped buttons (`{rounded.full}`) always; squared buttons signal "third-party widget" in this language.
8. Documentation prose belongs in `{typography.body-md}` 16px with 1.50 line-height — anything denser breaks the reading experience.
## Known Gaps
- Specific dark-mode token values for canvas, surface, ink, and hairline are not surfaced on these pages; the brand has not yet shipped a published dark-mode palette
- Animation/transition timings are not extracted; recommend 150–200ms ease for hover/focus state transitions
- Form validation success state is not explicitly captured beyond defaults — implement following standard green-border + success badge patterns
- Code syntax highlighting palette inside docs is not formalized; documentation samples carry their own twoslash-style annotation system tokens (e.g. `{colors.brand-tag}`, `{colors.brand-annotate}`, `{colors.brand-warn}`) but the full highlight scheme is not enumerated
+466 -21
View File
@@ -1,36 +1,481 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # 个人博客 · v0.1
## Getting Started 一个使用 Next.js + PostgreSQL 构建的个人博客,支持 Markdown / MDX 写作、代码高亮、
多语言路由,并带有一套完整的文章 CRUD REST 接口。
First, run the development server: > 设计规范见 [`DESIGN.md`](./DESIGN.md),第三方库文档见 [`文档地址.md`](./文档地址.md),
> 功能范围见 [`v0.1功能文档.md`](./v0.1功能文档.md)。
---
## 快速开始
```bash ```bash
npm run dev pnpm install
# or
yarn dev # 1. 配置数据库连接(见下方「环境变量」)
# or # 2. 建表
pnpm dev pnpm db:migrate
# or
bun dev # 3. 导入示例文章(把 content/posts/ 下的 Markdown 写入数据库)
pnpm seed
pnpm dev # 开发服务器 http://localhost:3000
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 生产构建与本地预览:
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. ```bash
pnpm build # 构建(含类型检查)
pnpm start # 启动生产服务器
pnpm lint # ESLint
pnpm typecheck # 仅类型检查
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. > 首次访问 `/` 会 307 重定向到默认语言(`/zh`),这是 next-intl 的正常行为。
## Learn More ---
To learn more about Next.js, take a look at the following resources: ## 环境变量
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 在 `.env.local` 中配置:
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! | 变量 | 必填 | 说明 |
| --- | --- | --- |
| `DATABASE_URL` | 是 | PostgreSQL 连接串。本地通常为 `postgresql://user:pass@localhost:5432/blog?sslmode=disable`;托管库(Neon / Supabase 等)用其提供的连接串(一般带 `?sslmode=require`)。 |
| `NEXT_PUBLIC_SITE_URL` | 是 | 站点公开地址,用于 `sitemap.xml`、`robots.txt`、canonical 与 Open Graph 的绝对 URL。部署后改成正式域名即可,无需改代码。 |
| `DATABASE_POOL_MAX` | 否 | 连接池上限,默认 `10`。Serverless 环境建议调小。 |
| `DATABASE_STATEMENT_TIMEOUT_MS` | 否 | 单条 SQL 超时,默认 `15000`。 |
## Deploy on Vercel > **SSL 说明**:本地 Postgres 默认不启用 SSL,若连接串写了 `sslmode=require`
> 会报 `The server does not support SSL connections`。用 `pnpm db:diagnose`
> 可以自动判断该用哪种配置。
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. `.env.local` 已加入 `.gitignore`,不会被提交。
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ---
## 数据库
### 为什么用 `pg` 而不是 `@neondatabase/serverless`
脚手架原本使用 `drizzle-orm/neon-http` + `@neondatabase/serverless`。
该驱动走的是 **Neon 的 HTTP 代理端点**,不是 PostgreSQL 的 TCP 协议,
因此**无法连接本地或自建的普通 Postgres**(实测报 `fetch failed`)。
本项目改用 `pg`(node-postgres)连接池,既能连本地 Postgres,
也能连 Neon / Supabase / Railway 等任何标准 Postgres——
部署到 Neon 时只需使用其 TCP 连接串即可,代码无需改动。
### 数据模型
单表 `posts`:
| 列 | 类型 | 说明 |
| --- | --- | --- |
| `id` | serial | 主键 |
| `slug` | varchar(200) | URL 标识,**唯一**,格式 `^[a-z0-9]+(-[a-z0-9]+)*$` |
| `title` | text | 标题 |
| `summary` | text | 摘要(卡片与 SEO description) |
| `content` | text | Markdown / MDX 正文 |
| `tags` | text[] | 标签数组,默认空数组 |
| `status` | varchar(20) | `draft` \| `published` |
| `reading_minutes` | integer | 阅读时长,写入时计算并缓存 |
| `cover` | text | 封面图路径 |
| `description` | text | 覆盖默认 SEO 描述 |
| `published_at` | timestamptz | 发布时间;草稿为 `null` |
| `created_at` / `updated_at` | timestamptz | 时间戳 |
**索引**
| 索引 | 用途 |
| --- | --- |
| `posts_slug_unique_idx` | slug 唯一性 + 按 slug 查询 |
| `posts_status_published_at_idx` | 列表页主查询:按状态过滤 + 按发布时间倒序 |
| `posts_tags_gin_idx` | 标签筛选(GIN 支持 `tags @> ARRAY['x']`) |
**约束**(写在迁移 SQL 中,Drizzle 的 schema 回调不生成 CHECK 约束)
- `posts_status_check`:`status IN ('draft', 'published')`
- `posts_slug_format_check`:slug 必须匹配 `^[a-z0-9]+(-[a-z0-9]+)*$`
> 约束同时存在于数据库与应用层(Zod)是有意为之:应用层给出友好的字段级报错,
> 数据库层保证任何写入路径(脚本、手工 SQL)都无法绕过。
### 常用命令
```bash
pnpm db:check # 探测连通性并列出表与行数
pnpm db:diagnose # 诊断驱动 / SSL 配置是否正确
pnpm db:generate # 由 schema.ts 生成迁移 SQL
pnpm db:migrate # 执行迁移
pnpm db:verify # 校验约束与索引是否真的生效(含"坏写入"测试)
pnpm db:studio # Drizzle Studio 可视化查看数据
pnpm seed # 把 content/posts/*.mdx 导入数据库(幂等 upsert)
```
---
## API
所有接口遵循统一响应格式:
```jsonc
// 成功
{ "data": ..., "meta": { ... } }
// 失败
{ "error": { "code": "VALIDATION_ERROR", "message": "...", "details": [{ "field": "slug", "message": "..." }] } }
```
错误码:`VALIDATION_ERROR`(400) · `BAD_REQUEST`(400) · `NOT_FOUND`(404) ·
`CONFLICT`(409) · `METHOD_NOT_ALLOWED`(405) · `INTERNAL_ERROR`(500) ·
`SERVICE_UNAVAILABLE`(503)
### `GET /api/posts` — 文章列表
| 参数 | 默认 | 说明 |
| --- | --- | --- |
| `limit` | 20 | 1–100 |
| `offset` | 0 | 偏移量 |
| `tag` | — | 按标签过滤 |
| `status` | — | `draft` \| `published` |
| `includeDrafts` | `false` | 是否包含草稿 |
```bash
curl "http://localhost:3000/api/posts?limit=5"
curl "http://localhost:3000/api/posts?tag=Next.js"
```
返回 `meta: { total, limit, offset, hasMore }`。**默认只返回已发布文章。**
### `POST /api/posts` — 新建文章
```bash
curl -X POST http://localhost:3000/api/posts \
-H "content-type: application/json" \
-d '{
"slug": "hello-world",
"title": "你好世界",
"summary": "第一篇文章",
"content": "## 正文\n\n支持 **Markdown**。",
"tags": ["随笔"],
"status": "published"
}'
```
必填:`slug` / `title` / `summary` / `content`;其余可选。
`status` 默认 `draft`。返回 **201**。
### `GET /api/posts/[slug]` — 文章详情
含正文。草稿与不存在的 slug 返回 **404**。传 `?includeDrafts=true` 可读草稿。
### `PATCH /api/posts/[slug]` — 更新文章
部分更新,只传要改的字段。也支持 `PUT`(同义)。
- 改 `slug` 时会校验新 slug 未被占用
- 改 `content` 会自动重算 `reading_minutes`
- `draft → published` 自动补 `published_at`;`published → draft` 清空它
- 空 body `{}` 返回 **400**
### `DELETE /api/posts/[slug]` — 删除文章
成功返回 **204**,不存在返回 **404**。
### `GET /api/tags` — 标签聚合
返回全部已发布文章的标签及数量,按数量倒序。
### `GET /api/health` — 健康检查
返回数据库连通性与延迟;数据库不可用时返回 **503**,便于监控直接判定不健康。
---
## 项目结构
```
app/
├── layout.tsx # 根布局:<html> / <body> + 全局字体变量
├── globals.css # 设计令牌(@theme)+ 基础层 + .prose-blog 排版
├── not-found.tsx # 全局 404(无语言前缀时兜底)
├── robots.ts # 动态生成 robots.txt
├── sitemap.ts # 动态生成 sitemap.xml
├── api/
│ ├── health/route.ts # GET 健康检查
│ ├── posts/route.ts # GET 列表 / POST 新建
│ ├── posts/[slug]/route.ts # GET 详情 / PATCH·PUT 更新 / DELETE 删除
│ └── tags/route.ts # GET 标签聚合
└── [locale]/ # 语言段:en / zh / ja
├── layout.tsx # i18n Provider + 页头页脚外壳
├── page.tsx # 首页:Hero + 文章列表
├── error.tsx # 错误边界
├── not-found.tsx # 语言段内 404(带站点外壳)
├── about/page.tsx # 关于页
└── posts/[slug]/page.tsx # 文章详情页
components/
├── ui/ # 基础组件:Button / Card / Badge / Container / Section
├── layout/ # 站点壳:SiteHeader / SiteFooter / LocaleSwitcher
├── home/hero.tsx # 首页 Hero(氛围渐变)
├── post/ # 文章卡片、目录
├── mdx/ # MDX 元素映射:CodeBlock / Callout
└── motion/ # 动画:Reveal / MotionItem / ReadingProgress
lib/
├── api/response.ts # 统一响应格式、错误码、错误包装器
├── db/
│ ├── index.ts # pg 连接池 + 健康检查(server-only)
│ └── schema.ts # Drizzle 表定义
├── validation/post.ts # Zod 校验契约(API 与脚本共用)
├── posts/
│ ├── repository.ts # 仓储层:分页 / 筛选 / 排序 / 草稿过滤
│ ├── index.ts # 页面使用的门面(server-only)
│ └── types.ts # 领域类型
├── fonts.ts # 字体配置(next/font 自托管)
├── site-config.ts # 站点配置(单一事实来源)
├── mdx.ts # MDX 编译管线(remark + rehype + Shiki)
├── toc.ts # 从 MDX 提取目录
└── utils.ts # cn / 日期格式化 / URL 拼接
content/posts/ # 文章导入源(.mdx,通过 pnpm seed 写入数据库)
drizzle/migrations/ # 数据库迁移 SQL
messages/ # i18n 文案(en / zh / ja)
scripts/ # 开发期验证脚本(见下文)
proxy.ts # 语言路由中间件(Next.js 16 新约定)
```
---
## 写作方式
内容已迁移到 **PostgreSQL**,有两种写入方式:
### 方式一:调用 API(推荐)
见上文 [API](#api) 一节的 `POST /api/posts` / `PATCH /api/posts/[slug]`。
### 方式二:写 Markdown 文件后导入
在 `content/posts/` 下新增 `.mdx` 文件(**文件名即 slug**),然后运行
`pnpm seed` 导入数据库。该命令是**幂等 upsert**:已存在的 slug 会被更新,
不会产生重复数据。
```mdx
---
title: "文章标题"
date: "2025-03-08"
summary: "一句话摘要,用于卡片与 SEO description。"
tags: ["Next.js", "性能"]
draft: false
---
正文使用 Markdown 编写,支持 GFM 表格、任务列表与删除线。
```
### frontmatter 字段
| 字段 | 必填 | 类型 | 对应数据库列 |
| --- | --- | --- | --- |
| `title` | 是 | string | `title` |
| `date` | 是 | string(`YYYY-MM-DD`) | `published_at` |
| `summary` | 是 | string | `summary` |
| `tags` | 否 | string[] | `tags` |
| `draft` | 否 | boolean | `status`(`true` → `draft`) |
| `cover` | 否 | string | `cover` |
| `description` | 否 | string | `description` |
**校验分两层**:`pnpm seed` 导入前会校验 frontmatter 并指出具体文件与字段;
数据库层还有 CHECK 约束兜底。API 写入则由 Zod 校验。
> `content/posts/` 现在只是**导入源**,不再是运行时内容源。
> 页面的数据全部来自数据库。
### 草稿机制
`status = 'draft'` 的文章:
- 不出现在首页列表与 `GET /api/posts`(除非显式传 `includeDrafts=true`)
- 不出现在 `sitemap.xml`
- 直接访问其 URL 返回**真正的 HTTP 404**
- `GET /api/posts/[slug]` 同样返回 404;需传 `?includeDrafts=true` 才能读到
> **实现要点**:详情页必须**不使用路由级 `loading.tsx`**。
> 路由级 `loading.tsx` 会创建 Suspense 边界,Next.js 会立即以 **200** 状态
> 流式输出骨架屏,之后页面内的 `notFound()` 只能替换内容、无法再改写状态码,
> 结果就是「草稿/不存在的文章返回 200、内容却是 404 页」——
> 搜索引擎会把这些 URL 当作有效页面收录。
> 详见 `components/post/post-skeleton.tsx` 的注释。
### 在正文中使用组件
MDX 中可直接使用内置组件(正文存在数据库里,同样会被编译):
```mdx
<Callout type="tip" title="提示">
支持 info / tip / warning / danger 四种类型。
</Callout>
```
---
## 架构要点
### 字体:构建期自托管
中文 Noto Sans SC、英文 Inter、代码 IBM Plex Mono,全部通过 `next/font/google`
在**构建期下载并自托管**(输出到 `.next/static/media`),运行时不会向 Google 发请求,
因此没有第三方请求、没有 FOUT,也符合隐私要求。
每个字体都显式配置了 `fallback` 回退栈与 `adjustFontFallback`:
| 字体 | 角色 | 回退栈 |
| --- | --- | --- |
| Inter | 英文 / UI | `-apple-system, BlinkMacSystemFont, Segoe UI, Helvetica Neue, Arial, sans-serif` |
| Noto Sans SC | 中文 | `PingFang SC, Hiragino Sans GB, Microsoft YaHei, Source Han Sans SC, WenQuanYi Micro Hei, sans-serif` |
| IBM Plex Mono | 代码 | `SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, Courier New, monospace` |
> Noto Sans SC 在 Google Fonts 上按权重分片(非可变字体),因此显式声明 4 个权重,
> 由 next/font 生成 `unicode-range` 分片,浏览器只下载实际用到的子集。
> 因其体积较大,设置了 `preload: false`,避免抢占首屏关键资源。
### 设计令牌:单一事实来源
`DESIGN.md` 中的 `{colors.*}` / `{rounded.*}` / `{spacing.*}` 全部映射为
`app/globals.css` 里 `@theme` 声明的 CSS 变量,因此 Tailwind 工具类
(`bg-canvas`、`rounded-lg`、`p-xl`)与手写 CSS 读取的是**同一份变量**。
### 渲染策略
| 页面 | 渲染方式 |
| --- | --- |
| 首页 / 关于页 | SSG(`generateStaticParams` 预生成 3 种语言) |
| 文章详情页 | SSG(构建期编译 MDX + Shiki 高亮 + 提取目录) |
| sitemap / robots | 构建期静态生成 |
| `/[locale]/posts/[slug]` | `dynamicParams = false`,未预生成的 slug 返回 404 |
文章正文与代码高亮结果都是静态 HTML,运行时零额外渲染开销。
### 动画的渐进增强
`components/motion/` 下的动画**没有**使用 motion 的 `whileInView`。
它在服务端会输出 `opacity: 0`,只有 JS 执行且元素滚入视口后才显示——
一旦 JS 加载失败,文章列表会**永久不可见**,搜索引擎抓到的也是空内容。
改为渐进增强策略:服务端输出完全可见的 HTML,客户端挂载后,
仅当元素位于视口下方时才叠加一次过渡动画。**可读性是默认状态,动画是叠加效果。**
---
## 验证脚本
### 一次性运行全部测试
```bash
pnpm test # 校验单测 + 404 状态码 + API 端到端(需先 pnpm start)
```
或分别运行:
```bash
pnpm test:validation # Zod 校验规则单测(无需服务器/数据库)
pnpm test:404 # 检查草稿与不存在的文章是否返回真正的 404
pnpm test:api # API 端到端(CRUD + 分页 + 筛选 + 错误分支)
pnpm db:verify # 数据库约束与索引是否真的生效(含"坏写入"测试)
```
`test:api` / `test:404` 需要先启动服务器:
```bash
pnpm start --port 3000
node scripts/test-api.mjs http://localhost:3000
```
> 这些脚本的测试数据统一使用 `zz-` 前缀,跑完会自动清理,不影响真实内容。
### 视觉问题的排查脚本
横向溢出、代码块滚动行为这类问题,单看截图难以定位到具体元素,
因此通过 Chrome DevTools Protocol 直接向浏览器查询元素的 `boundingRect`:
```bash
# 1. 启动带调试端口的 headless Chrome
chrome --headless=new --remote-debugging-port=9222 --window-size=390,900 about:blank
# 2. 从 http://127.0.0.1:9222/json 取 webSocketDebuggerUrl,设为环境变量
export CDP_WS="ws://127.0.0.1:9222/devtools/page/<id>"
# 3. 测量指定视口下的横向溢出
node scripts/measure-overflow.mjs http://localhost:3000/zh 390 900
# 4. 检查代码块是否在内部滚动而非撑破布局
node scripts/check-code-blocks.mjs http://localhost:3000/zh/posts/<slug> 390
# 5. 在精确视口下截图(含整页)
node scripts/screenshot.mjs http://localhost:3000/zh 390 844 out.png --full
# 6. 诊断 notFound() 的状态码行为
node scripts/diagnose-404.mjs http://localhost:3000
```
> **为什么需要 `screenshot.mjs`**:`chrome --headless --screenshot --window-size=390,900`
> 的输出位图宽度与 CSS 视口宽度可能不一致,会让移动端截图看起来"内容被截断",
> 而实际测量 `document.scrollWidth` 却是正常的。用 CDP 的
> `Emulation.setDeviceMetricsOverride` 才能得到与真实设备一致的截图。
---
## 已知限制
1. **API 尚无鉴权。** `POST` / `PATCH` / `DELETE` 目前任何人都能调用,
仅适合本地开发或内网使用。上线前必须加鉴权(Session + HttpOnly Cookie 或
API Key),并给写操作加上速率限制。
2. **`<html lang>` 固定为 `zh-CN`。** 根布局 `app/layout.tsx` 渲染 `<html>`,
而语言信息要到 `app/[locale]/layout.tsx` 才能确定,因此无法直接改写该属性。
实际语言已通过 canonical、`og:locale`、`hreflang` 正确告知搜索引擎。
3. **文章内容不随语言切换。** 语言切换只影响 UI 文案,文章本身是单一版本。
如需多语言文章,可给 `posts` 表加 `locale` 列并调整唯一索引为 `(slug, locale)`。
4. **列表接口用 offset 分页。** 数据量增大后 offset 会变慢
(需扫描并丢弃前 N 行),届时应改为基于 `published_at + id` 的游标分页,
接口形状可以保持不变(`meta.hasMore` 已预留)。
5. **详情页未启用 ISR 缓存。** 为保证返回真正的 404,详情页使用
`dynamic = "force-dynamic"`。查询走 slug 唯一索引,开销很小;
若要进一步提速,可在数据层加 Redis 缓存。
6. **没有路由级 `loading.tsx`。** 为保证 `notFound()` 能正确设置 404 状态码,
全局骨架屏被移除(原因见上文「草稿机制」)。如需加载态,
请在页面内部用 `<Suspense>` 包裹具体的数据区块。
7. **标签目前仅作展示,没有标签聚合页。** `GET /api/tags` 与 `listTags()` 已实现,
加标签页时可直接复用。
8. **无内容版本历史。** 更新会直接覆盖原记录(`updated_at` 会变化),
没有 revision 表。如需回溯,应增加 `post_revisions` 表。
9. **暗色模式未实现。** `DESIGN.md` 明确说明上游品牌尚未发布暗色令牌,
因此 `globals.css` 中固定 `color-scheme: light`,未做自动反转。
---
## 部署到 Vercel
1. 将仓库推送到 GitHub / GitLab
2. 在 Vercel 导入项目(会自动识别 Next.js)
3. 配置环境变量:
- `DATABASE_URL`:生产数据库连接串(Neon / Supabase 等)
- `NEXT_PUBLIC_SITE_URL`:正式域名,如 `https://blog.example.com`
4. 首次部署后,在本地对生产库执行一次迁移:`pnpm db:migrate`
5. 在 Vercel 的 Domains 中添加自定义域名,HTTPS 证书自动签发
> **Serverless 注意**:Vercel 的每个函数实例都会建立自己的连接池。
> 建议使用 Neon 的 pooled 连接串(`-pooler` 主机名),
> 并把 `DATABASE_POOL_MAX` 调小(如 `2`)以避免耗尽数据库连接数。
`pnpm build` 已包含类型检查,构建失败会直接阻断部署。
构建过程**不需要**数据库可用(页面为动态渲染,不在构建期取数)。
+234
View File
@@ -0,0 +1,234 @@
import type { Metadata } from "next";
import { getTranslations, setRequestLocale } from "next-intl/server";
import {
Briefcase,
MapPin,
Mail,
Code2,
Gauge,
Palette,
Wrench,
} from "lucide-react";
import { Container, Section } from "@/components/ui/layout";
import { Card } from "@/components/ui/card";
import { buttonClassName } from "@/components/ui/button-variants";
import { Reveal } from "@/components/motion/reveal";
import { siteConfig } from "@/lib/site-config";
/**
* 关于页渲染策略:ISR 缓存 60 秒。
* 路由段配置必须是字面量(Next.js 会静态分析)。
*/
export const revalidate = 60;
type AboutPageProps = {
params: Promise<{ locale: string }>;
};
export async function generateMetadata({
params,
}: AboutPageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "About" });
return {
title: t("title"),
description: t("description"),
alternates: { canonical: `/${locale}/about` },
openGraph: {
type: "profile",
title: t("title"),
description: t("description"),
url: `${siteConfig.url}/${locale}/about`,
images: [{ url: siteConfig.ogImage }],
},
};
}
/** 关注方向 —— 图标仅为装饰,语义由文本承载 */
const FOCUS_AREAS = [
{
icon: Gauge,
title: "Web 性能",
description:
"关注核心网页指标、渲染路径与打包体积,把「快」当作可度量的工程目标而不是感觉。",
},
{
icon: Code2,
title: "开发者体验",
description:
"好的工具应该让正确的事情变简单。投入最多的地方是构建流程、类型系统与文档。",
},
{
icon: Palette,
title: "设计系统",
description:
"把设计规范翻译成可执行的代码约束,让一致性由类型系统保证,而不是靠代码评审。",
},
{
icon: Wrench,
title: "工程化实践",
description:
"测试策略、CI/CD、可观测性——那些看不见但决定了项目能走多远的基建。",
},
] as const;
export default async function AboutPage({ params }: AboutPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("About");
const { author } = siteConfig;
const facts = [
{ icon: Briefcase, label: t("role"), value: author.jobTitle },
{ icon: MapPin, label: t("location"), value: author.location },
{ icon: Mail, label: t("email"), value: author.email },
];
return (
<>
{/* 页头 */}
<Section
spacing="sm"
className="border-b border-hairline-soft bg-surface-soft"
>
<Container size="narrow">
<p className="text-micro-uppercase font-semibold uppercase tracking-[0.5px] text-steel">
{t("eyebrow")}
</p>
<h1 className="mt-sm text-heading-1 font-semibold text-ink">
{t("title")}
</h1>
<p className="mt-md max-w-[640px] text-subtitle text-steel text-balance-pretty">
{t("description")}
</p>
</Container>
</Section>
<Section spacing="default">
<Container size="narrow">
<div className="grid grid-cols-1 gap-xxl lg:grid-cols-[minmax(0,1fr)_280px]">
{/* 主栏:简介 + 关注方向 */}
<div className="flex flex-col gap-section">
<section aria-labelledby="intro-heading">
<h2
id="intro-heading"
className="text-heading-3 font-semibold text-ink"
>
{t("introTitle")}
</h2>
<div className="mt-md flex flex-col gap-md text-body-md leading-[1.75] text-slate">
<p>{author.bio}</p>
<p>
这个博客用来记录我在日常工作中遇到的问题和解决思路。
我倾向于写下那些「当时找不到答案、后来自己想明白了」的内容,
因为这类经验对别人往往也最有价值。
</p>
<p>
写作风格上,我更在意准确而不是完整:与其罗列所有可能性,
不如把一条路径讲透,并说清楚它为什么成立、在什么情况下会失效。
</p>
</div>
</section>
<section aria-labelledby="focus-heading">
<h2
id="focus-heading"
className="text-heading-3 font-semibold text-ink"
>
{t("techTitle")}
</h2>
<p className="mt-xs text-body-sm text-steel">
{t("techDescription")}
</p>
<div className="mt-lg grid grid-cols-1 gap-md sm:grid-cols-2">
{FOCUS_AREAS.map((area, index) => (
<Reveal key={area.title} delay={index * 0.05}>
<Card className="flex h-full flex-col gap-xs" padding="md">
<area.icon
className="size-5 text-brand-green-deep"
aria-hidden="true"
/>
<h3 className="text-heading-5 font-semibold text-ink">
{area.title}
</h3>
<p className="text-body-sm text-steel">
{area.description}
</p>
</Card>
</Reveal>
))}
</div>
</section>
</div>
{/* 侧栏:基本信息 + 联系方式 */}
<aside className="flex flex-col gap-xl">
<Card
padding="md"
variant="feature"
className="flex flex-col gap-md"
>
<h2 className="text-heading-5 font-semibold text-ink">
基本信息
</h2>
<dl className="flex flex-col gap-sm">
{facts.map((fact) => (
<div key={fact.label} className="flex items-start gap-sm">
<fact.icon
className="mt-[2px] size-4 shrink-0 text-steel"
aria-hidden="true"
/>
<div className="min-w-0">
<dt className="text-caption text-steel">
{fact.label}
</dt>
<dd className="break-words text-body-sm text-ink">
{fact.value}
</dd>
</div>
</div>
))}
</dl>
</Card>
<Card padding="md" className="flex flex-col gap-sm">
<h2 className="text-heading-5 font-semibold text-ink">
{t("contactTitle")}
</h2>
<p className="text-body-sm text-steel">
{t("contactDescription")}
</p>
<ul className="mt-xs flex flex-col gap-xs">
{siteConfig.social.map((item) => (
<li key={item.label}>
<a
href={item.href}
target={
item.href.startsWith("mailto:") ? undefined : "_blank"
}
rel="noopener noreferrer"
className={buttonClassName({
variant: "secondary",
size: "sm",
className: "w-full justify-start",
})}
>
{item.label}
</a>
</li>
))}
</ul>
</Card>
</aside>
</div>
</Container>
</Section>
</>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useEffect } from "react";
import { RotateCcw, TriangleAlert } from "lucide-react";
/**
* 错误边界(语言段内)。
*
* 必须是客户端组件。捕获渲染期间的运行时错误,
* 提供重试入口,并把错误上报到控制台便于排查。
*
* 注意:`error.message` 在生产环境会被脱敏,因此面向用户只展示通用文案,
* 详细信息(digest)留给开发者排查。
*/
export default function LocaleError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("[LocaleError]", error);
}, [error]);
return (
<section className="mx-auto flex min-h-[60vh] w-full max-w-[640px] flex-col items-center justify-center px-lg text-center">
<span
aria-hidden="true"
className="grid size-14 place-items-center rounded-full bg-brand-error/10"
>
<TriangleAlert className="size-6 text-brand-error" />
</span>
<h1 className="mt-lg text-heading-3 font-semibold text-ink">
出了点问题
</h1>
<p className="mt-sm max-w-[420px] text-body-md text-steel">
页面加载时发生了意外错误,请稍后重试。如果问题持续出现,请通过「关于」页的联系方式告诉我。
</p>
{error.digest ? (
<p className="mt-sm font-mono text-caption text-muted">
错误编号:{error.digest}
</p>
) : null}
<button
type="button"
onClick={reset}
className="mt-xxl inline-flex h-10 items-center gap-xs rounded-full bg-primary px-lg text-button-md font-medium text-on-primary transition-colors duration-150 active:bg-charcoal"
>
<RotateCcw className="size-4" aria-hidden="true" />
重试
</button>
</section>
);
}
+117
View File
@@ -0,0 +1,117 @@
import type { Metadata } from "next";
import { NextIntlClientProvider } from "next-intl";
import {
getMessages,
getTranslations,
setRequestLocale,
} from "next-intl/server";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";
import { SiteHeader } from "@/components/layout/site-header";
import { SiteFooter } from "@/components/layout/site-footer";
import { siteConfig } from "@/lib/site-config";
/**
* 语言段布局。
*
* 文档骨架(<html> / <body>)由根布局 app/layout.tsx 提供,
* 这里只负责:校验 locale、注入 i18n Provider、渲染站点壳(页头 / 页脚)。
*
* 注意:因为根布局固定了 <html lang="zh-CN">,实际请求语言通过
* `app/[locale]/layout.tsx` 无法直接改写 <html>;如需精确的 lang 属性,
* 参见 README 的「已知限制」一节。
*/
/** 预生成所有语言的路由 */
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
type Locale = (typeof routing.locales)[number];
type LocaleLayoutProps = {
children: React.ReactNode;
params: Promise<{ locale: string }>;
};
/** 校验 locale 合法性,非法则交给 404 */
function assertLocale(locale: string): asserts locale is Locale {
if (!routing.locales.includes(locale as Locale)) {
notFound();
}
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
assertLocale(locale);
const t = await getTranslations({ locale, namespace: "Site" });
const title = `${siteConfig.name} · ${t("tagline")}`;
return {
title: {
default: title,
template: `%s · ${siteConfig.name}`,
},
description: siteConfig.description,
alternates: {
canonical: `/${locale}`,
languages: Object.fromEntries(
routing.locales.map((item) => [item, `/${item}`]),
),
},
openGraph: {
type: "website",
siteName: siteConfig.name,
locale,
url: `${siteConfig.url}/${locale}`,
title,
description: siteConfig.description,
images: [{ url: siteConfig.ogImage }],
},
twitter: {
card: "summary_large_image",
title,
description: siteConfig.description,
images: [siteConfig.ogImage],
},
};
}
export default async function LocaleLayout({
children,
params,
}: LocaleLayoutProps) {
const { locale } = await params;
assertLocale(locale);
// 启用静态渲染(next-intl 要求显式声明当前请求的 locale)
setRequestLocale(locale);
const messages = await getMessages();
const t = await getTranslations({ locale, namespace: "Nav" });
return (
<NextIntlClientProvider messages={messages}>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-md focus:top-md focus:z-[60] focus:rounded-full focus:bg-primary focus:px-lg focus:py-xs focus:text-body-sm focus:text-on-primary"
>
{t("skipToContent")}
</a>
<div className="flex min-h-dvh flex-col">
<SiteHeader />
<main id="main-content" className="flex-1">
{children}
</main>
<SiteFooter />
</div>
</NextIntlClientProvider>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { Link } from "@/i18n/navigation";
import { getTranslations } from "next-intl/server";
import { ArrowLeft, Compass } from "lucide-react";
import { Container, Section } from "@/components/ui/layout";
import { buttonClassName } from "@/components/ui/button-variants";
import { getAllPosts } from "@/lib/posts";
/**
* 404 页面(语言段内)。
*
* 位于 `app/[locale]/not-found.tsx`,因此能访问 next-intl 上下文并使用
* 站点的页头 / 页脚布局。同时列出最新文章作为"继续浏览"的出口,
* 避免用户走进死胡同。
*/
export default async function NotFound() {
const t = await getTranslations("NotFound");
const posts = (await getAllPosts()).slice(0, 3);
return (
<Section spacing="lg">
<Container size="narrow" className="flex flex-col items-center text-center">
<span
aria-hidden="true"
className="grid size-14 place-items-center rounded-full bg-surface"
>
<Compass className="size-6 text-steel" />
</span>
<p className="mt-lg font-mono text-micro-uppercase font-semibold uppercase tracking-[0.5px] text-brand-error">
{t("eyebrow")}
</p>
<h1 className="mt-sm text-heading-1 font-semibold text-ink text-balance-pretty">
{t("title")}
</h1>
<p className="mt-md max-w-[480px] text-subtitle text-steel text-balance-pretty">
{t("description")}
</p>
<div className="mt-xxl flex flex-col gap-sm sm:flex-row">
<Link
href="/"
className={buttonClassName({ variant: "primary", size: "md" })}
>
<ArrowLeft className="size-4" aria-hidden="true" />
{t("backHome")}
</Link>
<Link
href="/#posts"
className={buttonClassName({ variant: "secondary", size: "md" })}
>
{t("browsePosts")}
</Link>
</div>
{posts.length > 0 ? (
<div className="mt-section w-full border-t border-hairline-soft pt-xl text-left">
<h2 className="text-micro-uppercase font-semibold uppercase tracking-[0.5px] text-steel">
也许你想看看这些
</h2>
<ul className="mt-md flex flex-col divide-y divide-hairline-soft">
{posts.map((post) => (
<li key={post.slug}>
<Link
href={`/posts/${post.slug}`}
className="group flex flex-col gap-xxs py-md transition-colors duration-150"
>
<span className="text-body-md font-medium text-ink group-hover:text-brand-green-deep">
{post.title}
</span>
<span className="text-body-sm text-steel">
{post.summary}
</span>
</Link>
</li>
))}
</ul>
</div>
) : null}
</Container>
</Section>
);
}
+135
View File
@@ -0,0 +1,135 @@
import type { Metadata } from "next";
import { Link } from "@/i18n/navigation";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { ArrowRight } from "lucide-react";
import { Container, Section } from "@/components/ui/layout";
import { SectionHeading } from "@/components/ui/section-heading";
import { buttonClassName } from "@/components/ui/button-variants";
import { PostCard } from "@/components/post/post-card";
import { MotionItem } from "@/components/motion/motion-item";
import { HomeHero } from "@/components/home/hero";
import { getAllPosts, getAllTags } from "@/lib/posts";
import { siteConfig } from "@/lib/site-config";
/**
* 首页渲染策略:ISR 缓存 60 秒。
*
* 内容来自数据库,因此不在构建期固化;访问时生成并缓存,
* 更新文章后最多 60 秒自动生效。
*
* 注意:Next.js 会静态分析路由段配置,这里必须写成**字面量**,
* 不能引用从其他模块导入的常量,否则会报
* "Invalid segment configuration export detected"。
*/
export const revalidate = 60;
type HomePageProps = {
params: Promise<{ locale: string }>;
};
export async function generateMetadata({
params,
}: HomePageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "Site" });
return {
title: `${siteConfig.name} · ${t("tagline")}`,
description: siteConfig.description,
alternates: { canonical: `/${locale}` },
};
}
export default async function HomePage({ params }: HomePageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("Home");
// Hero 需要的统计数据量很小,直接在此等待;
// 文章列表区块用 Suspense 包裹,先出骨架再填充内容。
const [posts, tags] = await Promise.all([getAllPosts(), getAllTags()]);
const earliestPost = posts.at(-1);
return (
<>
<HomeHero
eyebrow={t("eyebrow")}
title={t("heroTitle")}
subtitle={t("heroSubtitle")}
primaryLabel={t("ctaPrimary")}
secondaryLabel={t("ctaSecondary")}
stats={{
posts: posts.length,
tags: tags.length,
since: earliestPost ? earliestPost.date.slice(0, 4) : "—",
postsLabel: t("statsPosts"),
tagsLabel: t("statsTags"),
sinceLabel: t("statsSince"),
}}
/>
<Section id="posts" spacing="lg" aria-labelledby="latest-posts-heading">
<Container>
<SectionHeading
eyebrow={t("latestEyebrow")}
title={<span id="latest-posts-heading">{t("latestTitle")}</span>}
description={t("latestDescription")}
className="mb-xxl"
/>
{posts.length === 0 ? (
<EmptyState />
) : (
<div className="grid grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post, index) => (
<MotionItem
key={post.slug}
delay={Math.min(index, 6) * 0.05}
className="h-full"
>
<PostCard post={post} className="h-full" />
</MotionItem>
))}
</div>
)}
<div className="mt-xxl flex justify-center">
<Link
href="/about"
className={buttonClassName({ variant: "secondary" })}
>
{t("ctaSecondary")}
<ArrowRight className="size-4" aria-hidden="true" />
</Link>
</div>
</Container>
</Section>
</>
);
}
/** 无文章时的空状态(skill 要求前端具备加载态 / 空态 / 错误态) */
function EmptyState() {
return (
<div className="flex flex-col items-center gap-sm rounded-lg border border-dashed border-hairline bg-surface-soft px-xxl py-section text-center">
<p className="text-heading-5 font-semibold text-ink">还没有已发布的文章</p>
<p className="max-w-[520px] text-body-sm text-steel">
调用{" "}
<code className="rounded-xs border border-hairline bg-surface px-[6px] py-[2px] font-mono text-code-inline">
POST /api/posts
</code>{" "}
创建文章(或运行{" "}
<code className="rounded-xs border border-hairline bg-surface px-[6px] py-[2px] font-mono text-code-inline">
pnpm seed
</code>{" "}
导入示例内容),状态为{" "}
<code className="rounded-xs border border-hairline bg-surface px-[6px] py-[2px] font-mono text-code-inline">
published
</code>{" "}
的文章就会出现在这里。
</p>
</div>
);
}
+250
View File
@@ -0,0 +1,250 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Link } from "@/i18n/navigation";
import { getLocale, getTranslations, setRequestLocale } from "next-intl/server";
import { ArrowLeft, ArrowRight, CalendarDays, Clock } from "lucide-react";
import { routing } from "@/i18n/routing";
import { Container, Section } from "@/components/ui/layout";
import { Badge } from "@/components/ui/badge";
import { ReadingProgress } from "@/components/motion/reading-progress";
import { TableOfContents } from "@/components/post/table-of-contents";
import { getAdjacentPosts, getPostBySlug } from "@/lib/posts";
import { compilePostMdx } from "@/lib/mdx";
import { extractToc } from "@/lib/toc";
import { formatDate, toDateTimeAttribute } from "@/lib/utils";
import { siteConfig } from "@/lib/site-config";
/**
* 文章详情页。
*
* 数据流:从数据库读取正文 → 编译 MDX(Shiki 语法高亮)
* → 提取 TOC → 渲染。
*
* 整页为 Server Component:正文与高亮结果都是服务端渲染的 HTML,
* 只有阅读进度条与目录高亮这两个交互点进入客户端 bundle。
*/
type PostPageProps = {
params: Promise<{ locale: string; slug: string }>;
};
/**
* 渲染策略:按需动态渲染,不做 ISR 缓存。
*
* ## 为什么这里不能用 `revalidate`
*
* 详情页需要根据文章是否存在返回**真正的 HTTP 404**。但一旦启用 ISR,
* Next.js 会把该路由视为可静态生成:首次请求时先以 200 开始渲染,
* 之后 `notFound()` 只替换页面内容,**状态码已经发出去了**,
* 结果就是「不存在/草稿的文章 → 内容显示 404、状态码却是 200」。
*
* 这会让搜索引擎把不存在的 URL 当成有效页面收录,属于 SEO 缺陷。
* 因此详情页保持动态渲染(内容本身仍很快,SQL 走 slug 唯一索引)。
*
* 首页、关于页、sitemap 不存在这个问题,仍使用 ISR 缓存。
*/
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
}: PostPageProps): Promise<Metadata> {
const { locale, slug } = await params;
const post = await getPostBySlug(slug);
if (!post) {
return { title: "文章不存在" };
}
const description = post.description ?? post.summary;
const url = `${siteConfig.url}/${locale}/posts/${post.slug}`;
return {
title: post.title,
description,
keywords: post.tags,
authors: [{ name: siteConfig.author.name }],
alternates: {
canonical: `/${locale}/posts/${post.slug}`,
languages: Object.fromEntries(
routing.locales.map((item) => [item, `/${item}/posts/${post.slug}`]),
),
},
openGraph: {
type: "article",
url,
title: post.title,
description,
publishedTime: new Date(`${post.date}T00:00:00Z`).toISOString(),
authors: [siteConfig.author.name],
tags: post.tags,
images: [{ url: post.cover ?? siteConfig.ogImage }],
},
twitter: {
card: "summary_large_image",
title: post.title,
description,
images: [post.cover ?? siteConfig.ogImage],
},
};
}
export default async function PostPage({ params }: PostPageProps) {
const { locale, slug } = await params;
setRequestLocale(locale);
const post = await getPostBySlug(slug);
// 草稿与不存在的 slug 一律 404
if (!post) {
notFound();
}
const t = await getTranslations("Post");
const currentLocale = await getLocale();
const { content } = await compilePostMdx(post.content);
const toc = extractToc(post.content);
const { previous, next } = await getAdjacentPosts(post.slug);
// 结构化数据,帮助搜索引擎理解文章内容
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
description: post.description ?? post.summary,
datePublished: new Date(`${post.date}T00:00:00Z`).toISOString(),
author: {
"@type": "Person",
name: siteConfig.author.name,
},
keywords: post.tags?.join(", "),
inLanguage: locale,
mainEntityOfPage: {
"@type": "WebPage",
"@id": `${siteConfig.url}/${locale}/posts/${post.slug}`,
},
};
return (
<>
<ReadingProgress />
<script
type="application/ld+json"
// JSON.stringify 的输出已转义 < > &,可安全内联
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<Section spacing="default">
<Container>
{/* 返回首页入口 */}
<Link
href="/"
className="inline-flex items-center gap-xs text-body-sm text-steel transition-colors duration-150 hover:text-ink"
>
<ArrowLeft className="size-4" aria-hidden="true" />
{t("backHome")}
</Link>
<div className="mt-xl grid grid-cols-1 gap-xxl lg:grid-cols-[minmax(0,1fr)_200px]">
<article className="min-w-0">
{/* 文章头部 */}
<header className="flex flex-col gap-sm border-b border-hairline-soft pb-xl">
<div className="flex flex-wrap items-center gap-sm text-caption text-steel">
<span className="inline-flex items-center gap-[6px]">
<CalendarDays className="size-3.5" aria-hidden="true" />
<time dateTime={toDateTimeAttribute(post.date)}>
{formatDate(post.date, currentLocale)}
</time>
</span>
<span aria-hidden="true" className="text-muted">
·
</span>
<span className="inline-flex items-center gap-[6px]">
<Clock className="size-3.5" aria-hidden="true" />
{t("readingTime", { minutes: post.readingMinutes })}
</span>
</div>
<h1 className="text-heading-1 font-semibold text-ink text-balance-pretty">
{post.title}
</h1>
<p className="text-subtitle text-steel text-balance-pretty">
{post.summary}
</p>
{post.tags && post.tags.length > 0 ? (
<ul
aria-label={t("tags")}
className="mt-xs flex flex-wrap gap-xs"
>
{post.tags.map((tag) => (
<li key={tag}>
<Badge variant="tag">{tag}</Badge>
</li>
))}
</ul>
) : null}
</header>
{/* MDX 正文 */}
<div className="prose-blog mt-xl">{content}</div>
{/* 上一篇 / 下一篇 */}
{previous || next ? (
<nav
aria-label="文章导航"
className="mt-section grid grid-cols-1 gap-md border-t border-hairline-soft pt-xl sm:grid-cols-2"
>
{previous ? (
<Link
href={`/posts/${previous.slug}`}
className="group flex flex-col gap-xxs rounded-lg border border-hairline p-md transition-colors duration-150 hover:bg-surface-soft"
>
<span className="inline-flex items-center gap-xs text-caption text-steel">
<ArrowLeft className="size-3.5" aria-hidden="true" />
{t("previous")}
</span>
<span className="text-body-sm font-medium text-ink group-hover:text-brand-green-deep">
{previous.title}
</span>
</Link>
) : (
<span />
)}
{next ? (
<Link
href={`/posts/${next.slug}`}
className="group flex flex-col items-end gap-xxs rounded-lg border border-hairline p-md text-right transition-colors duration-150 hover:bg-surface-soft sm:col-start-2"
>
<span className="inline-flex items-center gap-xs text-caption text-steel">
{t("next")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</span>
<span className="text-body-sm font-medium text-ink group-hover:text-brand-green-deep">
{next.title}
</span>
</Link>
) : null}
</nav>
) : null}
</article>
{/* 右侧目录:移动端隐藏,lg 起显示为粘性侧栏 */}
{toc.length > 0 ? (
<aside className="hidden lg:block">
<div className="sticky top-[88px]">
<TableOfContents items={toc} />
</div>
</aside>
) : null}
</div>
</Container>
</Section>
</>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { checkDatabaseHealth } from "@/lib/db";
import { ErrorCode, ok, withErrorHandling } from "@/lib/api/response";
/**
* GET /api/health —— 健康检查
*
* 用于部署后的可用性探测与监控。返回:
* - status: "ok" | "degraded"
* - database: 连通性与往返延迟
* - timestamp: 服务端时间
*
* 数据库不可用时返回 503,便于负载均衡/监控直接判定为不健康。
*/
export const dynamic = "force-dynamic";
export const GET = withErrorHandling(async () => {
const database = await checkDatabaseHealth();
const payload = {
status: database.ok ? ("ok" as const) : ("degraded" as const),
database: {
ok: database.ok,
latencyMs: database.latencyMs,
...(database.error ? { error: database.error } : {}),
},
timestamp: new Date().toISOString(),
};
if (!database.ok) {
return NextResponse.json(
{
error: {
code: ErrorCode.SERVICE_UNAVAILABLE,
message: "数据库不可用",
},
...payload,
},
{ status: 503 },
);
}
return ok(payload);
});
+101
View File
@@ -0,0 +1,101 @@
import { NextResponse } from "next/server";
import {
ok,
noContent,
withErrorHandling,
parseJsonBody,
ApiError,
} from "@/lib/api/response";
import { updatePostSchema } from "@/lib/validation/post";
import {
getPostBySlug,
getPostBySlugIncludingDrafts,
updatePost,
deletePost,
assertSlugAvailable,
} from "@/lib/posts/repository";
/**
* /api/posts/[slug] —— 单篇文章的读取、更新与删除
*
* 注意:GET 默认只返回已发布文章;传入 ?includeDrafts=true 可读到草稿,
* 供后续接入后台管理时使用(v0.1 尚无鉴权,接入后台前应加上权限校验)。
*/
type RouteContext = { params: Promise<{ slug: string }> };
/** GET /api/posts/[slug] —— 读取单篇文章(含正文) */
export const GET = withErrorHandling(
async (request: Request, context: RouteContext) => {
const { slug } = await context.params;
const { searchParams } = new URL(request.url);
const includeDrafts = searchParams.get("includeDrafts") === "true";
const post = includeDrafts
? await getPostBySlugIncludingDrafts(slug)
: await getPostBySlug(slug);
if (!post) {
throw ApiError.notFound(`文章 "${slug}" 不存在或尚未发布`);
}
return ok(post);
},
);
/**
* PATCH /api/posts/[slug] —— 更新文章(部分更新)
*
* 支持更新 slug 本身;此时会校验新 slug 未被占用。
*/
export const PATCH = withErrorHandling(
async (request: Request, context: RouteContext) => {
const { slug } = await context.params;
const body = await parseJsonBody(request);
const input = updatePostSchema.parse(body);
// 目标文章必须存在(含草稿,便于把草稿改为已发布)
const existing = await getPostBySlugIncludingDrafts(slug);
if (!existing) {
throw ApiError.notFound(`文章 "${slug}" 不存在`);
}
// 改 slug 时需要判重(排除自身)
if (input.slug && input.slug !== slug) {
await assertSlugAvailable(input.slug, slug);
}
const updated = await updatePost(slug, input);
if (!updated) {
throw ApiError.notFound(`文章 "${slug}" 不存在`);
}
return ok(updated);
},
);
/** PUT 与 PATCH 同义,便于不支持 PATCH 的客户端使用 */
export const PUT = PATCH;
/** DELETE /api/posts/[slug] —— 删除文章 */
export const DELETE = withErrorHandling(
async (_request: Request, context: RouteContext) => {
const { slug } = await context.params;
const removed = await deletePost(slug);
if (!removed) {
throw ApiError.notFound(`文章 "${slug}" 不存在`);
}
// 204 No Content:删除成功且无响应体
return noContent();
},
);
/** 列出该资源支持的方法 */
export function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: { Allow: "GET, PATCH, PUT, DELETE, OPTIONS" },
});
}
+77
View File
@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { ok, created, withErrorHandling, parseJsonBody } from "@/lib/api/response";
import { createPostSchema, listPostsQuerySchema } from "@/lib/validation/post";
import { createPost, listPosts } from "@/lib/posts/repository";
/**
* GET /api/posts —— 文章列表
*
* 查询参数:
* - limit 1–100,默认 20
* - offset 默认 0
* - tag 按标签过滤
* - status draft | published(不传则只返回已发布)
* - includeDrafts true 时包含草稿(内部/管理用途)
*
* 响应:{ data: PostMeta[], meta: { total, limit, offset, hasMore } }
*/
export const GET = withErrorHandling(async (request: Request) => {
const { searchParams } = new URL(request.url);
// 校验查询参数:非法值直接返回 400 + 字段级错误
const query = listPostsQuerySchema.parse(
Object.fromEntries(searchParams.entries()),
);
const result = await listPosts({
limit: query.limit,
offset: query.offset,
tag: query.tag,
includeDrafts: query.includeDrafts,
status: query.status,
});
return ok(result.items, {
total: result.total,
limit: result.limit,
offset: result.offset,
hasMore: result.hasMore,
});
});
/**
* POST /api/posts —— 新建文章
*
* 请求体(JSON):
* - slug 必填,^[a-z0-9]+(-[a-z0-9]+)*$
* - title 必填
* - summary 必填
* - content 必填(Markdown / MDX 源码)
* - tags 可选,字符串数组
* - status draft | published,默认 draft
* - publishedAt 可选,ISO 日期;status=published 时缺省为当前时间
* - cover 可选
* - description 可选,覆盖 SEO 描述
*
* 响应:201 { data: Post }
*/
export const POST = withErrorHandling(async (request: Request) => {
const body = await parseJsonBody(request);
const input = createPostSchema.parse(body);
// slug 唯一性在应用层先查一次,给出友好的字段级报错;
// 数据库唯一索引仍作为最终防线(并发下可能拦截,返回 409)
const post = await createPost(input);
return created(post);
});
/** 其他方法统一返回 405 */
export function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
Allow: "GET, POST, OPTIONS",
},
});
}
+52
View File
@@ -0,0 +1,52 @@
import { ok, withErrorHandling } from "@/lib/api/response";
import { siteConfig } from "@/lib/site-config";
/**
* GET /api —— 接口索引
*
* 列出当前可用的接口,方便开发与调试时快速查看。
* 生产环境可按需移除或限制访问。
*/
export const GET = withErrorHandling(async () => {
return ok({
name: siteConfig.name,
version: "v0.1",
endpoints: [
{
method: "GET",
path: "/api/health",
description: "健康检查(含数据库连通性与延迟)",
},
{
method: "GET",
path: "/api/posts",
description: "文章列表,支持 limit / offset / tag / status / includeDrafts",
},
{
method: "POST",
path: "/api/posts",
description: "新建文章",
},
{
method: "GET",
path: "/api/posts/[slug]",
description: "文章详情(含正文)",
},
{
method: "PATCH",
path: "/api/posts/[slug]",
description: "更新文章(部分更新;PUT 同义)",
},
{
method: "DELETE",
path: "/api/posts/[slug]",
description: "删除文章",
},
{
method: "GET",
path: "/api/tags",
description: "标签聚合(含各标签文章数)",
},
],
});
});
+14
View File
@@ -0,0 +1,14 @@
import { ok, withErrorHandling } from "@/lib/api/response";
import { listTags } from "@/lib/posts/repository";
/**
* GET /api/tags —— 标签聚合
*
* 返回全部已发布文章使用到的标签及各自文章数,按数量倒序。
* 响应:{ data: [{ tag: string, count: number }], meta: { total } }
*/
export const GET = withErrorHandling(async () => {
const tags = await listTags();
return ok(tags, { total: tags.length });
});
+445 -17
View File
@@ -1,26 +1,454 @@
@import "tailwindcss"; @import "tailwindcss";
:root { /* ==========================================================================
--background: #ffffff; 设计令牌(Design Tokens)
--foreground: #171717; 来源:根目录 DESIGN.md(Mintlify 设计系统分析)
命名规则:DESIGN.md 的 {colors.xxx} → --color-xxx(Tailwind v4 @theme)
{rounded.xxx} → --radius-xxx
{spacing.xxx} → --spacing-xxx
========================================================================== */
@theme {
/* ---------- 颜色:品牌与强调 ---------- */
--color-primary: #0a0a0a;
--color-on-primary: #ffffff;
--color-brand-green: #00d4a4;
--color-brand-green-deep: #00b48a;
--color-brand-green-soft: #7cebcb;
--color-brand-tag: #3772cf;
--color-brand-warn: #c37d0d;
--color-brand-annotate: #1ba673;
--color-brand-error: #d45656;
--color-brand-cursor: #888888;
/* ---------- 颜色:Hero 氛围渐变 ---------- */
--color-hero-sky-from: #87a8c8;
--color-hero-sky-to: #f5e9d8;
--color-hero-dark-from: #1a3d4a;
--color-hero-dark-to: #2d5a4f;
/* ---------- 颜色:强调(证言卡片) ---------- */
--color-testimonial-orange: #f55a3c;
--color-testimonial-orange-deep: #cc3a1f;
/* ---------- 颜色:表面 ---------- */
--color-canvas: #ffffff;
--color-canvas-dark: #0a0a0a;
--color-surface: #f7f7f7;
--color-surface-soft: #fafafa;
--color-surface-code: #1c1c1e;
--color-hairline: #e5e5e5;
--color-hairline-soft: #ededed;
--color-hairline-dark: #1f1f1f;
/* ---------- 颜色:文字 ---------- */
--color-ink: #0a0a0a;
--color-charcoal: #1c1c1e;
--color-slate: #3a3a3c;
--color-steel: #5a5a5c;
--color-stone: #888888;
--color-muted: #a8a8aa;
--color-on-dark: #ffffff;
--color-on-dark-muted: #b3b3b3;
/* ---------- 圆角 ---------- */
--radius-xs: 4px;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-xxl: 24px;
/* ---------- 间距(基础单位 4px) ---------- */
--spacing-xxs: 4px;
--spacing-xs: 8px;
--spacing-sm: 12px;
--spacing-md: 16px;
--spacing-lg: 20px;
--spacing-xl: 24px;
--spacing-xxl: 32px;
--spacing-xxxl: 40px;
--spacing-section-sm: 48px;
--spacing-section: 64px;
--spacing-section-lg: 96px;
--spacing-hero: 120px;
/* ---------- 字体族 ---------- */
/* 中文 Noto Sans SC、英文 Inter、代码 IBM Plex Mono
顺序上 Inter 在前,中文字符由 Noto Sans SC 补齐(二者 unicode-range 互补)。 */
--font-sans:
var(--font-inter), var(--font-noto-sans-sc), -apple-system,
BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
--font-mono:
var(--font-ibm-plex-mono), "SFMono-Regular", "SF Mono", Menlo, Consolas,
"Liberation Mono", "Courier New", monospace;
/* ---------- 字号阶梯(含行高 / 字距) ---------- */
--text-hero-display: 72px;
--text-hero-display--line-height: 1.05;
--text-hero-display--letter-spacing: -2px;
--text-display-lg: 56px;
--text-display-lg--line-height: 1.1;
--text-display-lg--letter-spacing: -1.5px;
--text-heading-1: 48px;
--text-heading-1--line-height: 1.1;
--text-heading-1--letter-spacing: -1px;
--text-heading-2: 36px;
--text-heading-2--line-height: 1.2;
--text-heading-2--letter-spacing: -0.5px;
--text-heading-3: 28px;
--text-heading-3--line-height: 1.25;
--text-heading-4: 22px;
--text-heading-4--line-height: 1.3;
--text-heading-5: 18px;
--text-heading-5--line-height: 1.4;
--text-subtitle: 18px;
--text-subtitle--line-height: 1.5;
--text-body-md: 16px;
--text-body-md--line-height: 1.5;
--text-body-sm: 14px;
--text-body-sm--line-height: 1.5;
--text-caption: 13px;
--text-caption--line-height: 1.4;
--text-micro: 12px;
--text-micro--line-height: 1.4;
--text-micro-uppercase: 11px;
--text-micro-uppercase--line-height: 1.4;
--text-micro-uppercase--letter-spacing: 0.5px;
--text-button-md: 14px;
--text-button-md--line-height: 1.3;
--text-code-md: 14px;
--text-code-md--line-height: 1.5;
--text-code-sm: 13px;
--text-code-sm--line-height: 1.4;
--text-code-inline: 13px;
--text-code-inline--line-height: 1.3;
/* ---------- 阴影(DESIGN.md Elevation) ---------- */
--shadow-level-1: rgba(0, 0, 0, 0.04) 0px 1px 2px 0px;
--shadow-card: rgba(0, 0, 0, 0.08) 0px 4px 12px 0px;
--shadow-mockup: rgba(0, 0, 0, 0.12) 0px 24px 48px -8px;
--shadow-brand: rgba(0, 212, 164, 0.08) 0px 8px 24px;
/* ---------- 动效 ---------- */
--ease-brand: cubic-bezier(0.22, 1, 0.36, 1);
--animate-fade-up: fade-up 0.5s var(--ease-brand) both;
--animate-shimmer: shimmer 1.8s linear infinite;
} }
@theme inline { @keyframes fade-up {
--color-background: var(--background); from {
--color-foreground: var(--foreground); opacity: 0;
--font-sans: var(--font-geist-sans); transform: translateY(12px);
--font-mono: var(--font-geist-mono); }
to {
opacity: 1;
transform: none;
}
} }
@media (prefers-color-scheme: dark) { @keyframes shimmer {
:root { from {
--background: #0a0a0a; background-position: 200% 0;
--foreground: #ededed; }
} to {
background-position: -200% 0;
}
} }
body { /* ==========================================================================
background: var(--background); 基础层
color: var(--foreground); ========================================================================== */
font-family: Arial, Helvetica, sans-serif;
@layer base {
:root {
/* 站点默认承载于画布白,DESIGN.md 未发布暗色令牌,故不做自动暗色反转。 */
--background: var(--color-canvas);
--foreground: var(--color-ink);
color-scheme: light;
}
* {
border-color: var(--color-hairline);
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
/* 固定顶栏高度 + 一点余量,避免锚点跳转被遮挡 */
scroll-padding-top: 88px;
/* 防止长 URL / 代码片段把布局撑出横向滚动条 */
overflow-x: hidden;
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
font-size: var(--text-body-md);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
code,
kbd,
samp,
pre {
font-family: var(--font-mono);
font-feature-settings: "liga" 0;
}
/* 键盘可达性:统一焦点环 */
:focus-visible {
outline: 2px solid var(--color-brand-green);
outline-offset: 2px;
border-radius: var(--radius-xs);
}
::selection {
background-color: var(--color-brand-green-soft);
color: var(--color-ink);
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ==========================================================================
工具类
========================================================================== */
@utility text-balance-pretty {
text-wrap: pretty;
}
/* 文章正文(由 MDX 渲染)——按 DESIGN.md 的文档式排版 */
@layer components {
.prose-blog {
color: var(--color-charcoal);
font-size: var(--text-body-md);
line-height: 1.75;
/* 长单词 / URL 不撑破容器 */
overflow-wrap: break-word;
word-break: break-word;
}
.prose-blog > * + * {
margin-top: 1.25em;
}
.prose-blog h2 {
margin-top: 2.5em;
margin-bottom: 0.75em;
font-size: var(--text-heading-3);
font-weight: 600;
line-height: 1.25;
letter-spacing: -0.2px;
color: var(--color-ink);
scroll-margin-top: 96px;
}
.prose-blog h3 {
margin-top: 2em;
margin-bottom: 0.6em;
font-size: var(--text-heading-4);
font-weight: 600;
line-height: 1.3;
color: var(--color-ink);
scroll-margin-top: 96px;
}
.prose-blog h4 {
margin-top: 1.75em;
margin-bottom: 0.5em;
font-size: var(--text-heading-5);
font-weight: 600;
line-height: 1.4;
color: var(--color-ink);
scroll-margin-top: 96px;
}
.prose-blog p,
.prose-blog li {
color: var(--color-slate);
}
.prose-blog a {
color: var(--color-ink);
font-weight: 500;
text-decoration: underline;
text-decoration-color: var(--color-brand-green);
text-decoration-thickness: 2px;
text-underline-offset: 3px;
}
.prose-blog a:hover {
color: var(--color-brand-green-deep);
}
.prose-blog strong {
color: var(--color-ink);
font-weight: 600;
}
.prose-blog ul,
.prose-blog ol {
padding-left: 1.5em;
}
.prose-blog ul {
list-style: disc;
}
.prose-blog ol {
list-style: decimal;
}
.prose-blog li::marker {
color: var(--color-stone);
}
.prose-blog li + li {
margin-top: 0.5em;
}
.prose-blog blockquote {
border-left: 3px solid var(--color-brand-green);
padding: 0.25em 0 0.25em 1.25em;
color: var(--color-steel);
font-style: normal;
}
.prose-blog blockquote p {
color: var(--color-steel);
}
.prose-blog hr {
margin: 2.5em 0;
border: 0;
border-top: 1px solid var(--color-hairline);
}
.prose-blog img {
border-radius: var(--radius-lg);
border: 1px solid var(--color-hairline);
width: 100%;
height: auto;
}
/* 行内代码(DESIGN.md: code-inline) */
.prose-blog :not(pre) > code {
background-color: var(--color-surface);
color: var(--color-charcoal);
font-size: 0.875em;
font-weight: 500;
padding: 2px 6px;
border-radius: var(--radius-xs);
border: 1px solid var(--color-hairline);
white-space: nowrap;
}
/* 代码块容器(DESIGN.md: code-block) */
.prose-blog figure[data-rehype-pretty-code-figure] {
margin: 1.75em 0;
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--color-hairline-dark);
background-color: var(--color-surface-code);
}
.prose-blog figure[data-rehype-pretty-code-figure] figcaption {
background-color: var(--color-surface-code);
color: var(--color-on-dark-muted);
font-family: var(--font-mono);
font-size: var(--text-caption);
padding: 8px 16px;
border-bottom: 1px solid var(--color-hairline-dark);
}
.prose-blog figure[data-rehype-pretty-code-figure] pre {
margin: 0;
padding: 16px 0;
overflow-x: auto;
background-color: var(--color-surface-code);
font-size: var(--text-code-md);
line-height: 1.6;
}
.prose-blog figure[data-rehype-pretty-code-figure] code {
display: grid;
font-size: inherit;
background: none;
border: 0;
padding: 0;
}
.prose-blog figure[data-rehype-pretty-code-figure] [data-line] {
padding: 0 16px;
border-left: 2px solid transparent;
}
.prose-blog figure[data-rehype-pretty-code-figure] [data-highlighted-line] {
background-color: rgba(0, 212, 164, 0.08);
border-left-color: var(--color-brand-green);
}
.prose-blog table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-body-sm);
border: 1px solid var(--color-hairline);
border-radius: var(--radius-md);
overflow: hidden;
display: block;
overflow-x: auto;
}
.prose-blog th {
background-color: var(--color-surface);
color: var(--color-ink);
font-weight: 600;
text-align: left;
}
.prose-blog th,
.prose-blog td {
padding: 12px 20px;
border-bottom: 1px solid var(--color-hairline-soft);
}
.prose-blog tr:last-child td {
border-bottom: 0;
}
/* 标题锚点(rehype-autolink-headings) */
.prose-blog .heading-anchor {
color: var(--color-muted);
text-decoration: none;
margin-left: 0.4em;
font-weight: 400;
opacity: 0;
transition: opacity 150ms ease;
}
.prose-blog h2:hover .heading-anchor,
.prose-blog h3:hover .heading-anchor,
.prose-blog h4:hover .heading-anchor,
.prose-blog .heading-anchor:focus-visible {
opacity: 1;
}
} }
+42 -22
View File
@@ -1,29 +1,49 @@
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { fontVariables } from "@/lib/fonts";
import { siteConfig } from "@/lib/site-config";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ /**
variable: "--font-geist-sans", * 根布局。
subsets: ["latin"], *
}); * App Router 要求根布局渲染 <html> 与 <body>。由于所有页面都位于
* `app/[locale]/` 下,这里只负责最外层的文档骨架与全局字体变量,
const geistMono = Geist_Mono({ * 具体语言、Provider 与导航壳由 `app/[locale]/layout.tsx` 承担。
variable: "--font-geist-mono", */
subsets: ["latin"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", metadataBase: new URL(siteConfig.url),
description: "Generated by create next app", title: {
default: siteConfig.name,
template: `%s · ${siteConfig.name}`,
},
description: siteConfig.description,
applicationName: siteConfig.name,
authors: [{ name: siteConfig.author.name }],
creator: siteConfig.author.name,
formatDetection: {
email: false,
address: false,
telephone: false,
},
}; };
export default function RootLayout({ children }: LayoutProps<"/">) { export const viewport: Viewport = {
return ( width: "device-width",
<html initialScale: 1,
lang="en" themeColor: "#ffffff",
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} };
>
<body className="min-h-full flex flex-col">{children}</body> export default function RootLayout({
</html> children,
); }: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN" className={fontVariables} suppressHydrationWarning>
<body className="min-h-dvh bg-canvas text-ink antialiased">
{children}
</body>
</html>
);
} }
+39
View File
@@ -0,0 +1,39 @@
import Link from "next/link";
import type { Metadata } from "next";
/**
* 全局 404。
*
* 当请求路径无法匹配任何语言前缀(例如 /en/foo/bar 之外的情况)时,
* Next.js 使用这个兜底页面。它位于根布局之下,没有 next-intl 上下文,
* 因此文案是静态的,链接也直接指向默认语言。
*/
export const metadata: Metadata = {
title: "页面不存在 · 404",
description: "你访问的页面不存在,或者已经被移动到了别处。",
};
export default function NotFound() {
return (
<main className="mx-auto flex min-h-dvh w-full max-w-[720px] flex-col items-center justify-center px-lg text-center">
<p className="font-mono text-micro-uppercase font-semibold uppercase tracking-[0.5px] text-brand-error">
Error 404
</p>
<h1 className="mt-sm text-heading-2 font-semibold text-ink">
页面走丢了
</h1>
<p className="mt-md max-w-[480px] text-subtitle text-steel">
你访问的页面不存在,或者已经被移动到了别处。
</p>
<Link
href="/"
className="mt-xxl inline-flex h-10 items-center rounded-full bg-primary px-lg text-button-md font-medium text-on-primary transition-colors duration-150 active:bg-charcoal"
>
返回首页
</Link>
</main>
);
}
-69
View File
@@ -1,69 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert h-5 w-[100px]"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the{" "}
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx
</code>{" "}
file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { MetadataRoute } from "next";
import { siteConfig } from "@/lib/site-config";
/**
* 动态生成 robots.txt。
*
* 允许抓取全站(含 Next.js 静态资源),并指向 sitemap。
* 部署到自定义域名后,只需修改 NEXT_PUBLIC_SITE_URL 环境变量即可。
*/
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/api/"],
},
],
sitemap: `${siteConfig.url}/sitemap.xml`,
host: siteConfig.url,
};
}
+62
View File
@@ -0,0 +1,62 @@
import type { MetadataRoute } from "next";
import { routing } from "@/i18n/routing";
import { getAllPosts } from "@/lib/posts";
import { siteConfig } from "@/lib/site-config";
/**
* 动态生成 sitemap.xml。
*
* 覆盖:首页、关于页,以及全部已发布文章(草稿不会出现在这里)。
* 每个条目都带上 hreflang alternate,帮助搜索引擎理解多语言对应关系。
*
* 内容来自数据库,因此 sitemap 也在请求时生成并缓存,
* 而不是在构建期固化——否则构建之后新增的文章不会出现在 sitemap 中。
*
* 注意:Next.js 要求 sitemap 返回绝对 URL。
*/
export const revalidate = 60;
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
/** 为一条路径生成所有语言的 alternates */
const buildAlternates = (path: string) => ({
languages: Object.fromEntries(
routing.locales.map((locale) => [
locale,
`${siteConfig.url}/${locale}${path}`,
]),
),
});
const staticEntries: MetadataRoute.Sitemap = routing.locales.flatMap(
(locale) => [
{
url: `${siteConfig.url}/${locale}`,
lastModified: new Date(),
changeFrequency: "daily" as const,
priority: 1,
alternates: buildAlternates(""),
},
{
url: `${siteConfig.url}/${locale}/about`,
lastModified: new Date(),
changeFrequency: "monthly" as const,
priority: 0.6,
alternates: buildAlternates("/about"),
},
],
);
const postEntries: MetadataRoute.Sitemap = routing.locales.flatMap((locale) =>
posts.map((post) => ({
url: `${siteConfig.url}/${locale}/posts/${post.slug}`,
lastModified: new Date(`${post.date}T00:00:00Z`),
changeFrequency: "weekly" as const,
priority: 0.8,
alternates: buildAlternates(`/posts/${post.slug}`),
})),
);
return [...staticEntries, ...postEntries];
}
+111
View File
@@ -0,0 +1,111 @@
import { Link } from "@/i18n/navigation";
import { ArrowRight, Sparkles } from "lucide-react";
import { Container } from "@/components/ui/layout";
import { buttonClassName } from "@/components/ui/button-variants";
/**
* 首页 Hero 区。
*
* 对应 DESIGN.md 的 hero-band-sky:大气渐变(天空蓝 → 柔奶油)作为背景,
* 居中标题、副标题与按钮行。品牌薄荷绿仅用于单个强调 CTA。
*
* 这里用 CSS 渐变 + 径向光晕替代插画资源,避免引入额外图片依赖,
* 同时保持规范要求的「氛围感渐变」基调。
*/
export interface HomeHeroProps {
eyebrow: string;
title: string;
subtitle: string;
primaryLabel: string;
secondaryLabel: string;
stats: {
posts: number;
tags: number;
since: string;
postsLabel: string;
tagsLabel: string;
sinceLabel: string;
};
}
export function HomeHero({
eyebrow,
title,
subtitle,
primaryLabel,
secondaryLabel,
stats,
}: HomeHeroProps) {
return (
<section
aria-labelledby="hero-heading"
className="relative overflow-hidden border-b border-hairline-soft"
>
{/* 氛围渐变背景(装饰性,对辅助技术隐藏) */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 bg-gradient-to-b from-hero-sky-from via-[#d9dbe0] to-hero-sky-to"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 opacity-70 [background:radial-gradient(60%_50%_at_50%_0%,rgba(255,255,255,0.85),transparent_70%)]"
/>
<Container className="flex flex-col items-center py-section text-center sm:py-section-lg lg:py-hero">
{/* 大写微标签 */}
<p className="inline-flex items-center gap-xs rounded-full border border-white/60 bg-white/70 px-md py-[6px] text-micro-uppercase font-semibold uppercase tracking-[0.5px] text-steel backdrop-blur-sm">
<Sparkles className="size-3.5 text-brand-green-deep" aria-hidden="true" />
{eyebrow}
</p>
<h1
id="hero-heading"
className="mt-lg w-full max-w-[880px] text-heading-2 font-semibold text-ink text-balance-pretty sm:text-heading-1 lg:text-hero-display"
>
{title}
</h1>
<p className="mt-lg w-full max-w-[640px] text-body-md text-slate text-balance-pretty sm:text-subtitle">
{subtitle}
</p>
{/* 按钮行:品牌绿强调 CTA + 描边次级按钮 */}
<div className="mt-xxl flex flex-col items-center gap-sm sm:flex-row">
<Link
href="/#posts"
className={buttonClassName({ variant: "accent", size: "lg" })}
>
{primaryLabel}
<ArrowRight className="size-4" aria-hidden="true" />
</Link>
<Link
href="/about"
className={buttonClassName({ variant: "secondary", size: "lg" })}
>
{secondaryLabel}
</Link>
</div>
{/* 站点统计:窄屏下压缩间距,避免三个卡片把布局撑出横向滚动 */}
<dl className="mt-section grid w-full min-w-0 max-w-[560px] grid-cols-3 gap-xs sm:mt-hero sm:gap-md">
<StatItem value={String(stats.posts)} label={stats.postsLabel} />
<StatItem value={String(stats.tags)} label={stats.tagsLabel} />
<StatItem value={stats.since} label={stats.sinceLabel} />
</dl>
</Container>
</section>
);
}
function StatItem({ value, label }: { value: string; label: string }) {
return (
<div className="flex min-w-0 flex-col items-center gap-[2px] rounded-lg border border-white/60 bg-white/55 px-xs py-sm backdrop-blur-sm sm:px-md">
<dt className="sr-only">{label}</dt>
<dd className="font-mono text-heading-5 font-semibold text-ink sm:text-heading-4">
{value}
</dd>
<dd className="text-caption text-steel">{label}</dd>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { usePathname, useRouter } from "@/i18n/navigation";
import { useLocale, useTranslations } from "next-intl";
import { Globe } from "lucide-react";
import { routing } from "@/i18n/routing";
import { cn } from "@/lib/utils";
/**
* 语言切换器。
*
* 使用 next-intl 的 usePathname / useRouter,切换语言时保留当前路径,
* 而不是跳回首页。通过 useParams 需要传参的场景在这里不适用,
* 因为本项目的动态段只有 slug,路径本身已经能表达。
*/
export function LocaleSwitcher({ className }: { className?: string }) {
const t = useTranslations("LocaleSwitcher");
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const [open, setOpen] = useState(false);
const handleSelect = (nextLocale: string) => {
setOpen(false);
router.replace(pathname, { locale: nextLocale });
};
return (
<div className={cn("relative", className)}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={t("label")}
className="inline-flex h-9 items-center gap-[6px] rounded-full px-sm text-body-sm text-steel transition-colors duration-150 active:bg-surface"
>
<Globe className="size-4" aria-hidden="true" />
<span className="hidden sm:inline">{t(locale)}</span>
</button>
{open ? (
<>
{/* 点击遮罩关闭菜单 */}
<button
type="button"
tabIndex={-1}
aria-hidden="true"
onClick={() => setOpen(false)}
className="fixed inset-0 z-40 cursor-default"
/>
<ul
role="listbox"
aria-label={t("label")}
className="absolute right-0 z-50 mt-xs min-w-[140px] overflow-hidden rounded-md border border-hairline bg-canvas py-xxs shadow-card"
>
{routing.locales.map((item) => (
<li key={item}>
<button
type="button"
role="option"
aria-selected={item === locale}
onClick={() => handleSelect(item)}
className={cn(
"flex w-full items-center px-md py-xs text-left text-body-sm transition-colors duration-150",
item === locale
? "bg-surface font-medium text-ink"
: "text-steel active:bg-surface",
)}
>
{t(item)}
</button>
</li>
))}
</ul>
</>
) : null}
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { Link } from "@/i18n/navigation";
import { getTranslations } from "next-intl/server";
import { Mail, Rss, SquareArrowOutUpRight } from "lucide-react";
import { siteConfig } from "@/lib/site-config";
/**
* 社交链接图标。
*
* 注意:lucide-react v1.x 已移除品牌图标(Github / Twitter 等),
* 因此这里使用通用图标,由 `label` 文本承载具体含义——
* 图标仅作装饰(aria-hidden),可访问性不依赖它。
*/
const SOCIAL_ICONS = {
github: SquareArrowOutUpRight,
twitter: Rss,
mail: Mail,
} as const;
/**
* 站点页脚。
*
* DESIGN.md 的 footer-region:白底上一道 hairline 顶边,多列分组,
* 分栏标题用 body-sm-medium 深色,链接用 body-sm 灰色。
*/
export async function SiteFooter() {
const t = await getTranslations("Footer");
const tNav = await getTranslations("Nav");
const year = new Date().getFullYear();
return (
<footer className="border-t border-hairline bg-canvas">
<div className="mx-auto w-full max-w-[1280px] px-lg py-section sm:px-xxl">
<div className="grid grid-cols-1 gap-xxl sm:grid-cols-2 lg:grid-cols-4">
{/* 品牌列 */}
<div className="flex flex-col gap-sm lg:col-span-2">
<div className="flex items-center gap-xs text-body-md font-semibold text-ink">
<span
aria-hidden="true"
className="grid size-7 place-items-center rounded-md bg-primary font-mono text-caption font-semibold text-brand-green"
>
&gt;_
</span>
{siteConfig.name}
</div>
<p className="max-w-[360px] text-body-sm text-steel">
{siteConfig.description}
</p>
</div>
{/* 导航列 */}
<nav aria-label={t("navigation")} className="flex flex-col gap-xs">
<h2 className="mb-xs text-body-sm font-medium text-ink">
{t("navigation")}
</h2>
<Link
href="/"
className="py-xxs text-body-sm text-steel transition-colors duration-150 hover:text-ink"
>
{tNav("home")}
</Link>
<Link
href="/about"
className="py-xxs text-body-sm text-steel transition-colors duration-150 hover:text-ink"
>
{tNav("about")}
</Link>
</nav>
{/* 社交列 */}
<div className="flex flex-col gap-xs">
<h2 className="mb-xs text-body-sm font-medium text-ink">
{t("elsewhere")}
</h2>
<ul className="flex flex-col gap-xxs">
{siteConfig.social.map((item) => {
const Icon = SOCIAL_ICONS[item.icon];
return (
<li key={item.label}>
<a
href={item.href}
target={item.href.startsWith("mailto:") ? undefined : "_blank"}
rel="noopener noreferrer"
className="inline-flex items-center gap-xs py-xxs text-body-sm text-steel transition-colors duration-150 hover:text-ink"
>
<Icon className="size-4" aria-hidden="true" />
{item.label}
</a>
</li>
);
})}
</ul>
</div>
</div>
<div className="mt-xxl flex flex-col gap-xs border-t border-hairline-soft pt-lg text-caption text-steel sm:flex-row sm:items-center sm:justify-between">
<p>
© {year} {siteConfig.name}. {t("rights")}
</p>
<p className="font-mono">{t("builtWith")}</p>
</div>
</div>
</footer>
);
}
+154
View File
@@ -0,0 +1,154 @@
"use client";
import { useEffect, useState } from "react";
import { Link, usePathname } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Menu, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { LocaleSwitcher } from "./locale-switcher";
const NAV_ITEMS = [
{ href: "/", key: "home" },
{ href: "/about", key: "about" },
] as const;
/**
* 站点头部导航。
*
* DESIGN.md:粘性白色导航条,高度约 64px,底部分割线 1px hairline-soft。
* 桌面端横向展示链接与 CTA;移动端(<1024px)折叠为抽屉菜单。
*/
export function SiteHeader() {
const t = useTranslations("Nav");
const pathname = usePathname();
const [menuOpen, setMenuOpen] = useState(false);
const shouldReduceMotion = useReducedMotion();
// 菜单打开时锁定页面滚动(与外部系统 document.body 同步)
useEffect(() => {
if (!menuOpen) {
return;
}
const original = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = original;
};
}, [menuOpen]);
const isActive = (href: string) =>
href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<header className="sticky top-0 z-40 border-b border-hairline-soft bg-canvas/85 backdrop-blur-md">
<div className="mx-auto flex h-16 w-full max-w-[1280px] items-center justify-between gap-md px-lg sm:px-xxl">
{/* 品牌标识 */}
<Link
href="/"
className="flex items-center gap-xs text-body-md font-semibold text-ink"
aria-label={t("home")}
>
<span
aria-hidden="true"
className="grid size-7 place-items-center rounded-md bg-primary font-mono text-caption font-semibold text-brand-green"
>
&gt;_
</span>
<span className="hidden sm:inline">个人博客</span>
</Link>
{/* 桌面端导航 */}
<nav
aria-label={t("home")}
className="hidden items-center gap-xxs md:flex"
>
{NAV_ITEMS.map((item) => (
<Link
key={item.href}
href={item.href}
aria-current={isActive(item.href) ? "page" : undefined}
className={cn(
"rounded-full px-md py-xs text-body-sm transition-colors duration-150",
isActive(item.href)
? "font-medium text-ink"
: "text-steel active:bg-surface",
)}
>
{t(item.key)}
</Link>
))}
</nav>
<div className="flex items-center gap-xxs">
<LocaleSwitcher />
{/* GitHub 入口(桌面端) */}
<a
href="https://github.com"
target="_blank"
rel="noopener noreferrer"
className="hidden h-9 items-center rounded-full bg-primary px-lg text-button-md text-on-primary transition-colors duration-150 active:bg-charcoal md:inline-flex"
>
GitHub
</a>
{/* 移动端菜单开关 */}
<button
type="button"
onClick={() => setMenuOpen((value) => !value)}
aria-expanded={menuOpen}
aria-controls="mobile-navigation"
aria-label={menuOpen ? t("closeMenu") : t("openMenu")}
className="grid size-10 place-items-center rounded-full text-ink transition-colors duration-150 active:bg-surface md:hidden"
>
{menuOpen ? (
<X className="size-5" aria-hidden="true" />
) : (
<Menu className="size-5" aria-hidden="true" />
)}
</button>
</div>
</div>
{/* 移动端抽屉 */}
<AnimatePresence>
{menuOpen ? (
<motion.nav
id="mobile-navigation"
aria-label={t("home")}
initial={shouldReduceMotion ? false : { opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
className="overflow-hidden border-t border-hairline-soft bg-canvas md:hidden"
>
<ul className="flex flex-col px-lg py-sm sm:px-xxl">
{NAV_ITEMS.map((item) => (
<li key={item.href}>
<Link
href={item.href}
// 点击后立即收起抽屉,避免依赖路由变化的副作用
onClick={() => setMenuOpen(false)}
aria-current={isActive(item.href) ? "page" : undefined}
className={cn(
// 触控目标 ≥ 44px
"flex min-h-11 items-center rounded-md px-sm text-body-md transition-colors duration-150",
isActive(item.href)
? "font-medium text-ink"
: "text-steel active:bg-surface",
)}
>
{t(item.key)}
</Link>
</li>
))}
</ul>
</motion.nav>
) : null}
</AnimatePresence>
</header>
);
}
+67
View File
@@ -0,0 +1,67 @@
import type { ReactNode } from "react";
import { Info, Lightbulb, TriangleAlert, CircleAlert } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* 提示块:可在 MDX 正文中直接使用。
*
* 颜色取自 DESIGN.md 的语义色:
* - info → brand-tag(信息蓝)
* - tip → brand-green(品牌绿)
* - warning → brand-warn(警告橙)
* - danger → brand-error(错误红)
*/
const CALLOUT_VARIANTS = {
info: {
icon: Info,
label: "说明",
className: "border-brand-tag/30 bg-brand-tag/[0.06] text-brand-tag",
},
tip: {
icon: Lightbulb,
label: "提示",
className:
"border-brand-green/40 bg-brand-green/[0.07] text-brand-green-deep",
},
warning: {
icon: TriangleAlert,
label: "注意",
className: "border-brand-warn/30 bg-brand-warn/[0.07] text-brand-warn",
},
danger: {
icon: CircleAlert,
label: "警告",
className: "border-brand-error/30 bg-brand-error/[0.06] text-brand-error",
},
} as const;
export type CalloutType = keyof typeof CALLOUT_VARIANTS;
export interface CalloutProps {
type?: CalloutType;
title?: string;
children: ReactNode;
}
export function Callout({ type = "info", title, children }: CalloutProps) {
const variant = CALLOUT_VARIANTS[type] ?? CALLOUT_VARIANTS.info;
const Icon = variant.icon;
return (
<aside
className={cn(
"my-xl flex gap-sm rounded-md border p-md",
variant.className,
)}
>
<Icon className="mt-[2px] size-4 shrink-0" aria-hidden="true" />
<div className="flex-1 text-body-sm">
<p className="mb-xxs font-semibold">
{title ?? variant.label}
</p>
<div className="text-slate [&>p]:text-slate [&>p+p]:mt-xs">{children}</div>
</div>
</aside>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
import { useState, type ReactNode } from "react";
import { Check, Copy } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* 代码块容器:对应 DESIGN.md 的 code-block + code-block-header + copy-code-button。
*
* Shiki 已在构建期把高亮结果编译成 <pre><code>…</code></pre>,
* 这里只负责套上头部(语言标签 + 复制按钮),不重新解析代码。
*
* 复制逻辑优先使用 Clipboard API,失败时回退到 execCommand,
* 以便在非 HTTPS 环境下依然可用。
*/
export interface CodeBlockProps {
children?: ReactNode;
className?: string;
/** Shiki 输出的 data-language(由 rehype-pretty-code 注入到 figure 上) */
"data-language"?: string;
raw?: string;
}
async function copyText(text: string): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// 落回下面的兼容实现
}
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const succeeded = document.execCommand("copy");
document.body.removeChild(textarea);
return succeeded;
} catch {
return false;
}
}
/** 从 <pre> 的 React 子节点中抽取纯文本,作为复制内容 */
function extractText(node: ReactNode): string {
if (node === null || node === undefined || typeof node === "boolean") {
return "";
}
if (typeof node === "string" || typeof node === "number") {
return String(node);
}
if (Array.isArray(node)) {
return node.map(extractText).join("");
}
if (typeof node === "object" && "props" in node) {
const props = (node as { props?: { children?: ReactNode } }).props;
return extractText(props?.children);
}
return "";
}
export function CodeBlock({ children, className, ...props }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const language =
props["data-language"] ?? detectLanguage(className) ?? "text";
const handleCopy = async () => {
const text = extractText(children).replace(/\n$/, "");
const succeeded = await copyText(text);
if (succeeded) {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
}
};
return (
<div className="group relative">
<div className="absolute right-xs top-xs z-10 flex items-center gap-xs">
<span className="font-mono text-caption text-on-dark-muted">
{language}
</span>
<button
type="button"
onClick={handleCopy}
aria-label={copied ? "已复制代码" : "复制代码"}
className="inline-flex items-center gap-[4px] rounded-sm border border-hairline-dark bg-surface-code/90 px-xs py-[4px] font-mono text-caption text-on-dark-muted transition-colors duration-150 active:text-on-dark"
>
{copied ? (
<Check className="size-3 text-brand-green" aria-hidden="true" />
) : (
<Copy className="size-3" aria-hidden="true" />
)}
<span className={cn(copied && "text-brand-green")}>
{copied ? "已复制" : "复制"}
</span>
</button>
</div>
<pre className={className} {...props}>
{children}
</pre>
</div>
);
}
/** 从 className 中提取语言,如 "language-ts" → "ts" */
function detectLanguage(className?: string): string | null {
const match = /language-([\w-]+)/.exec(className ?? "");
return match ? match[1] : null;
}
+74
View File
@@ -0,0 +1,74 @@
import Image from "next/image";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { Callout } from "./callout";
import { CodeBlock } from "./code-block";
/**
* MDX 组件映射。
*
* 通过替换原生元素,把 DESIGN.md 的排版规则与可访问性要求统一注入正文:
* - img → next/image(图片优化 + 保留 alt)
* - pre → 带语言标签与复制按钮的代码块
* - a → 外链自动补 rel/target
*
* 类型说明:这里不使用 `mdx/types` 的 MDXComponents,因为它依赖可选安装的
* @types/mdx。改为按组件签名手写映射类型,避免引入额外类型包。
*/
type MdxImageProps = ComponentPropsWithoutRef<"img">;
type MdxAnchorProps = ComponentPropsWithoutRef<"a"> & { children?: ReactNode };
/**
* MDX 组件映射类型。
*
* `next-mdx-remote` 内部使用 `MDXComponents`(带字符串索引签名),
* 因此这里显式声明索引签名,保证类型兼容的同时仍然约束了我们实际用到的键。
*/
export interface MdxComponentMap {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
img: (props: MdxImageProps) => ReactNode;
a: (props: MdxAnchorProps) => ReactNode;
pre: typeof CodeBlock;
Callout: typeof Callout;
}
export const mdxComponents: MdxComponentMap = {
// 文章内图片改用 next/image,alt 缺失时降级为空字符串(装饰性图片)
img: ({ src, alt, width, height }) => {
if (typeof src !== "string") {
return null;
}
return (
<Image
src={src}
alt={alt ?? ""}
width={typeof width === "number" ? width : 1200}
height={typeof height === "number" ? height : 630}
className="rounded-lg border border-hairline"
sizes="(max-width: 768px) 100vw, 720px"
/>
);
},
// 外链统一加上安全属性;站内锚点(# 开头)与相对路径保持原样
a: ({ href, children, ...props }) => {
const isExternal = typeof href === "string" && /^https?:\/\//.test(href);
return (
<a
href={href}
{...(isExternal
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
{...props}
>
{children}
</a>
);
},
pre: CodeBlock,
Callout,
};
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* 入场动画包装器。
*
* ## 设计取舍(重要)
*
* 这里**没有**使用 motion 的 `whileInView`。原因是它在服务端会输出
* `opacity: 0`,只有 JS 执行且元素滚入视口后才显示。对博客来说这不可接受:
* JS 失败、被拦截或加载缓慢时,**整份文章列表会永久不可见**,同时
* 搜索引擎抓到的也是空内容。
*
* 改为「渐进增强」策略:
* 1. 服务端输出完全可见的 HTML —— 无 JS 也能正常阅读,SEO 无风险;
* 2. 客户端挂载后,仅当元素**尚未进入视口**时,才加上入场动画的起始类,
* 并在下一帧移除,从而播放一次淡入上移。
*
* 换句话说:可读性是默认状态,动画是额外叠加的效果。
*/
export interface MotionItemProps {
children: ReactNode;
delay?: number;
className?: string;
}
export function MotionItem({ children, delay = 0, className }: MotionItemProps) {
const ref = useRef<HTMLDivElement>(null);
const [animationClass, setAnimationClass] = useState<string>("");
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
// 尊重系统的「减弱动态效果」偏好
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
if (prefersReducedMotion) {
return;
}
// 已经出现在首屏内的元素不播放动画,避免可见内容的闪烁
const rect = element.getBoundingClientRect();
const isBelowFold = rect.top > window.innerHeight;
if (!isBelowFold) {
return;
}
// 仅在元素位于视口下方时才设置动画起始态
setAnimationClass("opacity-0 translate-y-3");
// 下一帧移除起始类,触发 CSS transition 过渡到最终状态
const frame = requestAnimationFrame(() => {
requestAnimationFrame(() => setAnimationClass(""));
});
return () => cancelAnimationFrame(frame);
}, []);
return (
<div
ref={ref}
className={cn(
"motion-safe:transition-[opacity,transform] motion-safe:duration-500 motion-safe:ease-brand",
animationClass,
className,
)}
style={delay > 0 ? { transitionDelay: `${delay}s` } : undefined}
>
{children}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { motion, useReducedMotion, useScroll, useSpring } from "motion/react";
/**
* 页面顶部阅读进度条。
*
* 使用 motion 的 `useScroll` + `useSpring`:滚动位置映射为宽度百分比,
* 经弹簧平滑后呈现,避免直接跟随时产生的抖动。
*/
export function ReadingProgress() {
const shouldReduceMotion = useReducedMotion();
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, {
stiffness: 220,
damping: 40,
restDelta: 0.001,
});
if (shouldReduceMotion) {
return null;
}
return (
<motion.div
aria-hidden="true"
style={{ scaleX }}
className="fixed inset-x-0 top-0 z-50 h-[2px] origin-left bg-brand-green"
/>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* 通用入场动画包装器(渐进增强)。
*
* 与 MotionItem 采用同一策略:服务端输出的 HTML 默认可见,
* 仅在客户端确认元素位于视口下方时,才叠加一次淡入上移动画。
*
* 这样即使 JS 完全不可用,内容依然完整可读——动画纯粹是增强项。
*/
export interface RevealProps {
children: ReactNode;
className?: string;
/** 延迟秒数,用于列表的错位入场 */
delay?: number;
/** 位移距离(Tailwind 间距类,如 "translate-y-3") */
offsetClassName?: string;
}
export function Reveal({
children,
className,
delay = 0,
offsetClassName = "translate-y-4",
}: RevealProps) {
const ref = useRef<HTMLDivElement>(null);
const [animationClass, setAnimationClass] = useState<string>("");
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
if (prefersReducedMotion) {
return;
}
// 首屏内可见的元素不做动画,避免内容闪烁
const rect = element.getBoundingClientRect();
if (rect.top <= window.innerHeight) {
return;
}
setAnimationClass(cn("opacity-0", offsetClassName));
const frame = requestAnimationFrame(() => {
requestAnimationFrame(() => setAnimationClass(""));
});
return () => cancelAnimationFrame(frame);
}, [offsetClassName]);
return (
<div
ref={ref}
className={cn(
"motion-safe:transition-[opacity,transform] motion-safe:duration-500 motion-safe:ease-brand",
animationClass,
className,
)}
style={delay > 0 ? { transitionDelay: `${delay}s` } : undefined}
>
{children}
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { Link } from "@/i18n/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { CalendarDays, Clock } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { formatDate, toDateTimeAttribute } from "@/lib/utils";
import type { PostMeta } from "@/lib/posts/types";
/**
* 文章卡片(服务端组件)。
*
* DESIGN.md 的 card-base:白底 + 1px hairline 描边 + rounded-lg,
* 内部留白 {spacing.xl}。展示标题、日期、阅读时长、摘要与标签。
*/
export interface PostCardProps {
post: PostMeta;
/** 是否展示摘要;紧凑列表可关闭 */
showSummary?: boolean;
className?: string;
}
export async function PostCard({
post,
showSummary = true,
className,
}: PostCardProps) {
const t = await getTranslations("Post");
const locale = await getLocale();
return (
<article className={className}>
<Link
href={`/posts/${post.slug}`}
className="group flex h-full flex-col gap-sm rounded-lg border border-hairline bg-canvas p-xl transition-colors duration-150 hover:border-hairline-soft hover:bg-surface-soft active:bg-surface"
>
{/* 元信息:日期 + 阅读时长 */}
<div className="flex flex-wrap items-center gap-sm text-caption text-steel">
<span className="inline-flex items-center gap-[6px]">
<CalendarDays className="size-3.5" aria-hidden="true" />
<time dateTime={toDateTimeAttribute(post.date)}>
{formatDate(post.date, locale)}
</time>
</span>
<span aria-hidden="true" className="text-muted">
·
</span>
<span className="inline-flex items-center gap-[6px]">
<Clock className="size-3.5" aria-hidden="true" />
{t("readingTime", { minutes: post.readingMinutes })}
</span>
</div>
{/* 标题 */}
<h3 className="text-heading-4 font-semibold text-ink transition-colors duration-150 group-hover:text-brand-green-deep">
{post.title}
</h3>
{/* 摘要 */}
{showSummary ? (
<p className="flex-1 text-body-sm text-steel text-balance-pretty">
{post.summary}
</p>
) : null}
{/* 标签 */}
{post.tags && post.tags.length > 0 ? (
<ul className="mt-xs flex flex-wrap gap-xs">
{post.tags.map((tag) => (
<li key={tag}>
<Badge variant="tag">{tag}</Badge>
</li>
))}
</ul>
) : null}
</Link>
</article>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { cn } from "@/lib/utils";
/**
* 文章骨架屏组件。
*
* ## 为什么本项目没有使用路由级 `loading.tsx`
*
* 路由级 `loading.tsx` 会创建 Suspense 边界,Next.js 会**立即以 200 状态**
* 流式输出骨架屏;之后页面内的 `notFound()` 只能替换内容,无法再改写已经
* 发出的状态码。
*
* 实测结果(Next.js 16.3.8):
* - 存在 `app/[locale]/loading.tsx` 时,`/zh/posts/<草稿>` 与
* `/zh/posts/<不存在>` 均返回 **200**(内容却是 404 页面);
* - 移除后,二者正确返回 **404**。
*
* 对博客而言,让不存在的 URL 返回 200 会被搜索引擎当作有效页面收录,
* 属于必须避免的 SEO 缺陷。因此这里**牺牲路由级骨架屏**,
* 保证状态码正确;骨架屏仅作为组件按需使用(例如包裹已知存在的
* 慢速数据区块)。
*/
export function PostListSkeleton({ count = 6 }: { count?: number }) {
return (
<div
className="grid grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3"
aria-hidden="true"
>
{Array.from({ length: count }).map((_, index) => (
<div
// 骨架屏为静态占位,使用索引作 key 不会引起状态错位
key={index}
className={cn(
"flex flex-col gap-sm rounded-lg border border-hairline bg-canvas p-xl",
)}
>
{/* 日期 + 阅读时长 */}
<div className="h-4 w-40 animate-pulse rounded-sm bg-hairline-soft" />
{/* 标题 */}
<div className="h-6 w-full animate-pulse rounded-md bg-hairline" />
{/* 摘要两行 */}
<div className="h-4 w-full animate-pulse rounded-sm bg-hairline-soft" />
<div className="h-4 w-3/4 animate-pulse rounded-sm bg-hairline-soft" />
{/* 标签 */}
<div className="mt-xs flex gap-xs">
<div className="h-5 w-14 animate-pulse rounded-sm bg-hairline-soft" />
<div className="h-5 w-16 animate-pulse rounded-sm bg-hairline-soft" />
</div>
</div>
))}
</div>
);
}
/** 文章详情页正文骨架 */
export function PostBodySkeleton() {
return (
<div className="mt-xl flex flex-col gap-md" aria-hidden="true">
{Array.from({ length: 10 }).map((_, index) => (
<div
key={index}
className="h-4 animate-pulse rounded-sm bg-hairline-soft"
style={{ width: `${[100, 96, 88, 100, 92, 100, 84][index % 7]}%` }}
/>
))}
<div className="mt-md h-40 w-full animate-pulse rounded-md bg-surface-code/20" />
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { TocItem } from "@/lib/toc";
/**
* 文章目录(右侧栏)。
*
* 用 IntersectionObserver 做滚动高亮——比监听 scroll 事件性能更好,
* 且不需要手动计算元素偏移。根边距设置为顶部 96px(避开粘性导航)
* 到底部 -70%,让"当前阅读位置"落在视口上部三分之一处。
*/
export function TableOfContents({ items }: { items: TocItem[] }) {
const t = useTranslations("Post");
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? "");
useEffect(() => {
if (items.length === 0) {
return;
}
const headings = items
.map((item) => document.getElementById(item.id))
.filter((element): element is HTMLElement => element !== null);
if (headings.length === 0) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
// 取当前可见且位置最靠上的标题作为激活项
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort(
(a, b) =>
a.boundingClientRect.top - b.boundingClientRect.top,
);
if (visible.length > 0) {
setActiveId(visible[0].target.id);
}
},
{
rootMargin: "-96px 0px -70% 0px",
threshold: 0,
},
);
for (const heading of headings) {
observer.observe(heading);
}
return () => observer.disconnect();
}, [items]);
if (items.length === 0) {
return null;
}
return (
<nav aria-labelledby="toc-heading" className="flex flex-col gap-xs">
<h2
id="toc-heading"
className="text-micro-uppercase font-semibold uppercase tracking-[0.5px] text-steel"
>
{t("tableOfContents")}
</h2>
<ul className="flex flex-col border-l border-hairline">
{items.map((item) => {
const isActive = item.id === activeId;
return (
<li key={item.id}>
<a
href={`#${item.id}`}
aria-current={isActive ? "location" : undefined}
className={cn(
"block border-l-2 py-xxs text-body-sm transition-colors duration-150",
item.level === 3 ? "pl-lg" : "pl-sm",
// 用 -ml-px 让边框与父级 border-l 重合
"-ml-px",
isActive
? "border-brand-green font-medium text-ink"
: "border-transparent text-steel hover:text-ink",
)}
>
{item.title}
</a>
</li>
);
})}
</ul>
</nav>
);
}
+40
View File
@@ -0,0 +1,40 @@
import type { HTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/**
* 徽章变体,对应 DESIGN.md 的 badge-* 组件:
* - tag → badge-tag(文章标签,蓝色 + 半透明底)
* - type → badge-type(代码风格的类型标记)
* - discount → badge-discount(品牌绿强调)
* - required → badge-required(错误红,大写微标签)
* - neutral → 中性灰,用于日期 / 阅读时长等元信息
*/
export const badgeVariants = cva(
"inline-flex items-center gap-[4px] whitespace-nowrap rounded-sm leading-none",
{
variants: {
variant: {
tag: "bg-brand-tag/15 text-brand-tag text-caption font-semibold px-xs py-[3px]",
type: "bg-surface text-steel font-mono text-code-sm px-xs py-[3px]",
discount: "bg-brand-green text-primary text-caption font-semibold rounded-full px-xs py-[3px]",
required:
"bg-brand-error text-on-dark text-micro-uppercase font-semibold px-[6px] py-[3px]",
neutral: "bg-surface text-steel text-caption px-xs py-[3px]",
},
},
defaultVariants: {
variant: "neutral",
},
},
);
export type BadgeProps = HTMLAttributes<HTMLSpanElement> &
VariantProps<typeof badgeVariants>;
/** 徽章 / 标签 */
export function Badge({ className, variant, ...props }: BadgeProps) {
return (
<span className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { AnchorHTMLAttributes } from "react";
import {
buttonClassName,
type ButtonStyleOptions,
} from "./button-variants";
export type ButtonLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> &
ButtonStyleOptions;
/**
* 外链按钮:用于邮件、社交链接等外部地址。
* 站内跳转请使用 next-intl 的 <Link> 并传入 buttonClassName()。
*/
export function ButtonLink({
className,
variant,
size,
rel = "noopener noreferrer",
target = "_blank",
...props
}: ButtonLinkProps) {
return (
<a
rel={rel}
target={target}
className={buttonClassName({ className, variant, size })}
{...props}
/>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/**
* 按钮样式变体。
*
* 对应 DESIGN.md 的组件定义:
* - primary → button-primary(黑底胶囊,主 CTA)
* - secondary → button-secondary(描边胶囊)
* - accent → button-accent-green(品牌薄荷绿,谨慎使用)
* - ghost → button-ghost(无背景矩形)
* - link → button-link(内联文字链接)
*
* 规范要求:所有按钮一律 `{rounded.full}` 胶囊形,不软化圆角。
*/
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-xs font-medium whitespace-nowrap rounded-full transition-colors duration-150 ease-out disabled:pointer-events-none disabled:bg-hairline disabled:text-muted",
{
variants: {
variant: {
primary: "bg-primary text-on-primary active:bg-charcoal",
secondary:
"bg-transparent text-ink border border-hairline active:bg-surface",
accent: "bg-brand-green text-primary active:bg-brand-green-deep",
onDark: "bg-on-dark text-primary active:bg-on-dark-muted",
ghost: "rounded-md bg-transparent text-ink active:bg-surface",
link: "rounded-none bg-transparent text-ink underline-offset-4 active:underline p-0 h-auto",
},
size: {
sm: "h-9 px-md text-body-sm",
// DESIGN.md: padding 10px 20px,字号 14px / 行高 1.3
md: "h-10 px-lg text-button-md",
// 移动端触控目标 ≥ 44px
lg: "h-11 px-xl text-body-md",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
},
);
export type ButtonVariantProps = VariantProps<typeof buttonVariants>;
export interface ButtonStyleOptions extends ButtonVariantProps {
className?: string;
}
/** 生成按钮 className,供 <button> 与 <Link> 复用同一套样式 */
export function buttonClassName({
className,
variant,
size,
}: ButtonStyleOptions = {}): string {
return cn(buttonVariants({ variant, size }), className);
}
+25
View File
@@ -0,0 +1,25 @@
import type { ButtonHTMLAttributes } from "react";
import {
buttonClassName,
type ButtonStyleOptions,
} from "./button-variants";
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
ButtonStyleOptions;
/** 按钮组件:渲染原生 <button>,样式由 buttonVariants 统一管理 */
export function Button({
className,
variant,
size,
type = "button",
...props
}: ButtonProps) {
return (
<button
type={type}
className={buttonClassName({ className, variant, size })}
{...props}
/>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { HTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/**
* 卡片变体,对应 DESIGN.md:
* - base → card-base(白底 + hairline 描边)
* - feature → card-feature(surface 底、无描边)
* - mockup → hero-product-mockup(含大扩散阴影,仅 Hero 使用)
*/
export const cardVariants = cva("", {
variants: {
variant: {
base: "bg-canvas border border-hairline rounded-lg",
feature: "bg-surface rounded-lg",
mockup: "bg-canvas border border-hairline-soft rounded-lg shadow-mockup",
},
padding: {
none: "p-0",
sm: "p-md",
md: "p-xl",
lg: "p-xxl",
},
},
defaultVariants: {
variant: "base",
padding: "md",
},
});
export type CardProps = HTMLAttributes<HTMLDivElement> &
VariantProps<typeof cardVariants>;
/** 通用卡片容器 */
export function Card({ className, variant, padding, ...props }: CardProps) {
return (
<div
className={cn(cardVariants({ variant, padding }), className)}
{...props}
/>
);
}
+84
View File
@@ -0,0 +1,84 @@
import type { HTMLAttributes, ElementType } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/**
* 容器:统一站点内容宽度与左右留白。
* DESIGN.md:营销页面 1280px 最大宽度 + 32px 沟槽。
*/
const containerVariants = cva("mx-auto w-full", {
variants: {
size: {
// 文章正文最舒适的阅读宽度
prose: "max-w-[720px]",
default: "max-w-[1280px]",
narrow: "max-w-[960px]",
},
gutter: {
default: "px-lg sm:px-xxl",
none: "px-0",
},
},
defaultVariants: {
size: "default",
gutter: "default",
},
});
export type ContainerProps = HTMLAttributes<HTMLElement> &
VariantProps<typeof containerVariants> & {
/** 渲染的语义化标签,默认 div */
as?: ElementType;
};
export function Container({
className,
size,
gutter,
as: Component = "div",
...props
}: ContainerProps) {
return (
<Component
className={cn(containerVariants({ size, gutter }), className)}
{...props}
/>
);
}
/**
* 区块:负责垂直节奏。
* DESIGN.md:营销页区块之间 96px,文档面 32–64px。
*/
const sectionVariants = cva("w-full", {
variants: {
spacing: {
none: "py-0",
sm: "py-section-sm",
default: "py-section",
lg: "py-section-lg",
},
},
defaultVariants: {
spacing: "default",
},
});
export type SectionProps = HTMLAttributes<HTMLElement> &
VariantProps<typeof sectionVariants> & {
as?: ElementType;
};
export function Section({
className,
spacing,
as: Component = "section",
...props
}: SectionProps) {
return (
<Component
className={cn(sectionVariants({ spacing }), className)}
{...props}
/>
);
}
+52
View File
@@ -0,0 +1,52 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* 区块标题:DESIGN.md 中反复出现的「大写微标签 + 标题 + 描述」结构。
* 微标签使用 {typography.micro-uppercase}(11px / 600 / +0.5px 字距)。
*/
export interface SectionHeadingProps {
/** 标签上方的大写微标签 */
eyebrow?: string;
title: ReactNode;
description?: ReactNode;
className?: string;
/** 对齐方式,默认居中(营销区块);文档面可用 left */
align?: "center" | "left";
as?: "h1" | "h2" | "h3";
}
export function SectionHeading({
eyebrow,
title,
description,
className,
align = "center",
as: Heading = "h2",
}: SectionHeadingProps) {
return (
<div
className={cn(
"flex flex-col gap-sm",
align === "center" ? "items-center text-center" : "items-start text-left",
className,
)}
>
{eyebrow ? (
<p className="text-micro-uppercase font-semibold uppercase tracking-[0.5px] text-steel">
{eyebrow}
</p>
) : null}
<Heading className="text-heading-2 font-semibold text-ink text-balance-pretty">
{title}
</Heading>
{description ? (
<p className="max-w-[640px] text-subtitle text-steel text-balance-pretty">
{description}
</p>
) : null}
</div>
);
}
@@ -0,0 +1,119 @@
---
title: "用 Next.js App Router 搭建个人博客"
date: "2025-03-08"
summary: "从零搭一个可写、可读、可部署的博客:内容层怎么设计、Server Component 怎么分工、MDX 与代码高亮怎么接。"
tags: ["Next.js", "MDX", "架构"]
draft: false
---
这个博客的第一版目标很克制:**能写、能读、能部署**。没有后台、没有数据库、没有登录。所有的复杂度都收敛到一件事上——把 Markdown 文件变成页面。
## 为什么不用 CMS
第一版最容易犯的错误,是一上来就选一个「以后肯定用得上」的方案。数据库 + 后台管理听起来很美,但它会带来一整套额外的负担:
- 需要维护 schema 与迁移
- 需要处理鉴权、草稿权限、并发编辑
- 需要为内容管理单独做一套 UI
- 本地开发必须先起一个数据库
而写作本身的需求其实非常朴素:打开编辑器,写 Markdown,提交。Git 已经是一个成熟的内容版本管理系统了——它自带历史记录、分支、协作和回滚。
> 用文件系统当数据库,用 Git 当版本管理。第一版不需要更多东西。
## 内容层设计
文章放在 `content/posts/` 目录,文件名即 slug:
```text
content/posts/
├── building-a-blog-with-nextjs.mdx
├── react-server-components.mdx
└── tailwind-v4-design-tokens.mdx
```
每篇文章顶部是一段 YAML frontmatter,声明标题、日期、摘要、标签和草稿状态:
```yaml
---
title: "用 Next.js App Router 搭建个人博客"
date: "2025-03-08"
summary: "从零搭一个可写、可读、可部署的博客。"
tags: ["Next.js", "MDX"]
draft: false
---
```
读取层只有一个职责:**扫描目录 → 解析 frontmatter → 校验 → 排序**。草稿在读取时就被过滤掉,因此它既不会出现在列表里,也无法通过直接访问 URL 被读到。
### 校验放在读取层
内容错误最容易的暴露时机是构建阶段,而不是线上。所以 frontmatter 的校验写在读取函数里:缺少 `title`、`date` 格式不对、`tags` 不是数组,都会直接抛出错误并终止构建。
```ts
const problems: string[] = [];
if (!title) {
problems.push("缺少必填字段 `title`(标题)");
}
if (problems.length > 0) {
throw new PostValidationError(slug, problems);
}
```
这样一来,错误信息会明确指向**哪个文件**、**哪个字段**,而不是在页面上渲染出一个空白标题。
## Server Component 的分工
App Router 的默认是 Server Component。博客恰好是它的理想场景:内容在构建时就已经确定,没有任何需要客户端状态的东西。
分工原则很简单:
1. **数据获取全部在服务端** —— 用 `server-only` 标记读取层,物理上防止它被打进客户端 bundle
2. **交互才下沉到客户端** —— 滚动进度、目录高亮、移动端菜单这类需要 `useState` 的部分单独拆成客户端组件
3. **列表页不传递正文** —— 列表只需要元信息,正文留在详情页按需读取
`server-only` 这个包很小,但价值很大:
```ts
import "server-only";
```
如果哪次重构不小心把内容读取层 `import` 进了客户端组件,构建会立刻失败,而不是把整个 `content/` 目录打进浏览器产物。
## 排序要稳定
按日期倒序是最直观的排序方式,但只按日期排序会有一个隐患:**同一天发布的两篇文章,顺序可能在不同机器上不一致**。
```ts
function byDateDescending(a: PostMeta, b: PostMeta): number {
const diff = new Date(b.date).getTime() - new Date(a.date).getTime();
return diff !== 0 ? diff : a.slug.localeCompare(b.slug);
}
```
用 slug 兜底之后,排序结果就是确定性的,构建产物也不会因为环境不同而漂移。
## 日期时区陷阱
YAML 里的 `2025-03-08` 会被解析成 Date 对象,而这个 Date 默认是本机时区的午夜。如果服务器在 UTC+8 而构建机在 UTC,同一篇文章可能显示成 3 月 7 日。
解决办法是**始终把日期当作 UTC 处理**:
```ts
new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
}).format(new Date(`${date}T00:00:00Z`));
```
读取时统一转成 `YYYY-MM-DD` 字符串,格式化时显式指定 `timeZone: "UTC"`。日期在整条链路上就不再漂移了。
## 下一步
第一版到这里就足够了。接下来真正值得做的事,是**先写几篇文章**,让真实的使用暴露出真实的需求——那时候再决定要不要加标签页、搜索或者 RSS,会比现在拍脑袋靠谱得多。
内容层已经预留了扩展点:标签聚合、相邻文章导航都基于同一份元信息,加功能不需要改动存储格式。
@@ -0,0 +1,144 @@
---
title: "设计令牌:把设计规范变成可执行的代码"
date: "2025-02-20"
summary: "设计系统最容易失效的地方不是设计稿,而是设计与代码之间的翻译过程。用 Tailwind v4 的 @theme 让令牌成为唯一事实来源。"
tags: ["设计系统", "Tailwind", "CSS"]
draft: false
---
每个团队都遇到过这种对话:
> 「这个按钮的圆角怎么是 8px?设计稿是 12px。」
> 「我随手写的,没注意。」
问题不在于谁写错了,而在于**规范停留在文档里,没有变成代码**。
## 令牌是设计与代码之间的契约
一份设计规范通常包含颜色、字体、间距、圆角、阴影。这些值如果只写在 Figma 和 Markdown 里,它们就只是**建议**;只有当它们变成代码里有名字的变量,才成为**约束**。
关键在于给值起名,而不是记住值:
| 不要这样写 | 要这样写 |
| --- | --- |
| `border-radius: 12px` | `rounded-lg` |
| `color: #0a0a0a` | `text-ink` |
| `padding: 24px` | `p-xl` |
前者的语义是「12 像素」,后者的语义是「标准卡片圆角」。当设计决定把标准圆角改成 14px 时,前者需要全局搜索替换,后者只需要改一行。
## Tailwind v4 的 @theme
Tailwind v4 把配置从 JavaScript 搬回了 CSS。用 `@theme` 声明的变量会同时具备两种身份:**CSS 自定义属性**和**生成工具类的依据**。
```css
@theme {
--color-ink: #0a0a0a;
--color-brand-green: #00d4a4;
--radius-lg: 12px;
--spacing-xl: 24px;
}
```
声明之后,`text-ink`、`bg-brand-green`、`rounded-lg`、`p-xl` 这些工具类会自动可用,同时也可以在原生 CSS 里直接引用:
```css
.card {
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
border: 1px solid var(--color-hairline);
}
```
这就是单一事实来源的价值——工具类和手写 CSS 读的是**同一份变量**,不可能对不上。
## 颜色:先定语义,再定色值
新手容易直接把调色板搬进来:`--color-gray-100`、`--color-gray-200`、`--color-blue-500`。这能工作,但它没有表达**用途**。
更好的方式是按角色命名:
```text
--color-ink 主文字
--color-steel 三级文字
--color-canvas 页面背景
--color-surface 次级表面
--color-hairline 分割线
--color-brand-green 品牌强调色
```
这样命名之后,暗色模式的实现会变得非常自然——同一个语义变量换一组值即可,组件代码一行都不用改。反过来,如果组件里写的是 `bg-gray-100`,切暗色时就只能靠 `dark:` 前缀逐处覆盖。
## 字号的陷阱:行高和字距
排版令牌如果只存字号,会丢掉一半信息。同一个 16px,正文需要 1.5 倍行高,标题可能只需要 1.2。
Tailwind v4 允许把行高和字距直接绑定到字号上:
```css
@theme {
--text-heading-2: 36px;
--text-heading-2--line-height: 1.2;
--text-heading-2--letter-spacing: -0.5px;
}
```
现在 `text-heading-2` 一次带出行高和字距,不需要每次手写 `text-4xl leading-tight tracking-tight`。
还有一个细节值得注意:**负字距应该随字号递增**。大标题需要更紧的字距才显得结实,小字号则应该放松甚至归零。
```text
72px → letter-spacing: -2px
36px → letter-spacing: -0.5px
18px → letter-spacing: 0
```
## 间距:八点网格之外的例外
八点网格(8px 递增)是常见约定,但直接用它做类名会很难读:`p-3` 到底是 12px 还是 16px?
按尺码命名的可读性更好:
```css
@theme {
--spacing-xxs: 4px;
--spacing-xs: 8px;
--spacing-sm: 12px;
--spacing-md: 16px;
--spacing-lg: 20px;
--spacing-xl: 24px;
--spacing-section: 64px;
--spacing-hero: 120px;
}
```
注意最后两个:`section` 和 `hero` 是**语义间距**。区块之间 64px、Hero 区 120px,这类值在页面上反复出现,给它们名字比记数字可靠得多。
## 用类型约束收口
令牌再多,也需要有人保证组件只从令牌里取值,而不是随手写魔法数字。`class-variance-authority` 能把变体定义收敛到一处:
```ts
const buttonVariants = cva("inline-flex items-center rounded-full", {
variants: {
variant: {
primary: "bg-primary text-on-primary",
secondary: "border border-hairline text-ink",
accent: "bg-brand-green text-primary",
},
size: {
md: "px-lg py-[10px] text-button-md",
sm: "px-md py-xs text-body-sm",
},
},
defaultVariants: { variant: "primary", size: "md" },
});
```
组件使用者只能在 `primary | secondary | accent` 里选,写不出 `bg-[#0a0a0a]` 这种绕过令牌的代码。**把约束交给类型系统,比交给代码评审更可靠。**
## 一条经验
设计令牌的价值不在于「变量很多」,而在于**边界清晰**。真正需要严格约束的是颜色和间距——它们决定了视觉的一致性;而字号阶梯可以适度宽松,因为排版本来就需要灵活性。
最后一点:令牌的命名应该描述**意图**,而不是外观。`brand-green` 比 `mint` 好,`danger` 比 `red` 好。外观会变,意图通常不会。
+143
View File
@@ -0,0 +1,143 @@
---
title: "React Server Components 的心智模型"
date: "2025-01-12"
summary: "服务端组件不是「服务端渲染的组件」,它是一次关于数据边界的重新划分。理解这条边界,比记住 API 更重要。"
tags: ["React", "Next.js", "性能"]
draft: false
---
第一次接触 Server Components,最容易带着旧框架的直觉去理解它:**「这不就是 SSR 吗?」**
不是。SSR 是把同一个组件在服务端渲染成 HTML,然后在客户端**再跑一遍**完成 hydration。Server Components 是另一回事——它**只在服务端运行**,代码永远不会发送到浏览器。
## 两种组件,一条边界
在 App Router 里,组件默认是服务端组件。只有当文件顶部写了 `"use client"`,它才会成为客户端组件。
这条边界的关键含义是:
| | Server Component | Client Component |
| --- | --- | --- |
| 运行位置 | 仅服务端 | 服务端(首屏)+ 浏览器 |
| 能否用 `useState` | 否 | 是 |
| 能否直接读数据库 | 是 | 否 |
| 代码是否进 bundle | 否 | 是 |
所以选择不是「哪个更好」,而是「这个组件需要什么」:
- 需要读取数据、访问文件系统、使用密钥 → **Server**
- 需要 `useState`、事件处理、浏览器 API → **Client**
> 默认留在服务端,只在真正需要交互时才越过边界。
## 边界是单向的
数据只能从服务端流向客户端,不能反向。服务端组件可以渲染客户端组件,但客户端组件不能 `import` 服务端组件。
```tsx
// ✅ 服务端组件渲染客户端组件,可以把数据作为 props 传下去
export default async function Page() {
const posts = await getPosts();
return (
<main>
<PostList posts={posts} />
<ScrollProgress /> {/* 客户端组件 */}
</main>
);
}
```
反过来不行,因为客户端 bundle 里没有服务端组件的代码。
需要注意的是,**props 必须可序列化**。函数、类实例、Date 之外的复杂对象都传不过去。
```tsx
// ❌ 函数无法跨过边界
<ClientChart format={(v) => v.toFixed(2)} />
// ✅ 传数据,让客户端自己格式化
<ClientChart values={values} precision={2} />
```
## 把服务端代码物理隔离
一个常见的 bug 是:某个工具函数本来只在服务端用,结果被链式 `import` 进了客户端组件,**连同数据库凭据一起**打进了浏览器产物。
`server-only` 包能在构建阶段拦住这类事故:
```ts
import "server-only";
export function getPosts() {
// 这里可以安全地读取内容目录或查询数据库
}
```
只要有任何客户端组件(直接或间接)导入这个模块,构建就会失败。这比靠代码评审去发现可靠得多。
## 交互要沉到叶子节点
既然客户端组件会进 bundle,就应该让**边界尽可能靠近叶子**。
```tsx
// ❌ 整个页面变成客户端组件,所有文章数据都要走 props 传
"use client";
export default function ArticlePage({ post }) {
const [liked, setLiked] = useState(false);
return (
<article>
<MDXContent source={post.content} />
<LikeButton liked={liked} onClick={() => setLiked(!liked)} />
</article>
);
}
```
问题在于:MDX 渲染器、语法高亮、整个文章正文——全部被拖进了客户端 bundle,只为了一个点赞按钮。
正确的拆法是让正文留在服务端:
```tsx
// ✅ 页面是服务端组件,只有按钮是客户端组件
export default async function ArticlePage({ params }) {
const post = await getPost(params.slug);
return (
<article>
<MDXContent source={post.content} />
<LikeButton />
</article>
);
}
```
`ArticlePage` 和 `MDXContent` 都不进 bundle,`LikeButton` 才是唯一需要下载的交互代码。**交互的粒度决定了 bundle 的大小。**
## 常见误区
**误区一:客户端组件只在浏览器运行。**
客户端组件同样会在服务端做一次预渲染,用来产出首屏 HTML。所以它里面不能直接访问 `window`——除非放进 `useEffect`。
**误区二:服务端组件能保留状态。**
服务端组件每次请求都会重新执行,没有状态、没有生命周期,也不能用 `useEffect`。
**误区三:加了 `"use client"` 就「更安全」。**
恰恰相反——它意味着代码会发送给用户。加上这个指令应该是**有意识的选择**,而不是遇到报错就顺手加的补丁。
## 一个判断口诀
拿不准组件该放哪边时,问三个问题:
1. 需要 `useState` / `useEffect` 吗?→ 需要就是客户端
2. 有事件处理(`onClick` 等)吗?→ 有就是客户端
3. 涉及密钥、数据库、文件系统吗?→ 涉及就必须是服务端
三个都不沾,就**留在服务端**。这通常是默认且正确的选择。
## 小结
Server Components 真正改变的不是渲染性能,而是**数据的归属**。它让「这份数据该在哪里被读取」重新成为一个需要思考的设计问题,而不是把所有逻辑都堆到客户端再想办法优化。
想清楚边界画在哪里,比记住哪个 API 可用更重要。
@@ -0,0 +1,15 @@
---
title: "(草稿)Web 性能优化的优先级"
date: "2025-04-01"
summary: "这是一篇用于验证草稿过滤的示例文章,不应出现在首页、文章列表或 sitemap 中。"
tags: ["性能"]
draft: true
---
这篇文章的 `draft: true`,用于验证草稿功能:
- 不出现在首页文章列表
- 不出现在 sitemap.xml
- 直接访问其在线的 URL 应返回 404
如果它在任何地方出现了,说明草稿过滤逻辑有 bug。
+13
View File
@@ -0,0 +1,13 @@
import { config } from "dotenv";
import { defineConfig } from "drizzle-kit";
config({ path: ".env.local" });
export default defineConfig({
schema: "./lib/db/schema.ts",
out: "./drizzle/migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
+21
View File
@@ -0,0 +1,21 @@
CREATE TABLE "posts" (
"id" serial PRIMARY KEY NOT NULL,
"slug" varchar(200) NOT NULL,
"title" text NOT NULL,
"summary" text NOT NULL,
"content" text NOT NULL,
"tags" text[] DEFAULT ARRAY[]::text[] NOT NULL,
"status" varchar(20) DEFAULT 'draft' NOT NULL,
"reading_minutes" integer DEFAULT 1 NOT NULL,
"cover" text,
"description" text,
"published_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "posts_slug_unique_idx" ON "posts" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "posts_status_published_at_idx" ON "posts" USING btree ("status","published_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "posts_tags_gin_idx" ON "posts" USING gin ("tags");--> statement-breakpoint
ALTER TABLE "posts" ADD CONSTRAINT "posts_status_check" CHECK ("status" IN ('draft', 'published'));--> statement-breakpoint
ALTER TABLE "posts" ADD CONSTRAINT "posts_slug_format_check" CHECK ("slug" ~ '^[a-z0-9]+(-[a-z0-9]+)*$');
+167
View File
@@ -0,0 +1,167 @@
{
"id": "3eafaa67-cb7c-4d39-8838-ebfb112f9e13",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.posts": {
"name": "posts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"slug": {
"name": "slug",
"type": "varchar(200)",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": true
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true
},
"tags": {
"name": "tags",
"type": "text[]",
"primaryKey": false,
"notNull": true,
"default": "ARRAY[]::text[]"
},
"status": {
"name": "status",
"type": "varchar(20)",
"primaryKey": false,
"notNull": true,
"default": "'draft'"
},
"reading_minutes": {
"name": "reading_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"cover": {
"name": "cover",
"type": "text",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"published_at": {
"name": "published_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"posts_slug_unique_idx": {
"name": "posts_slug_unique_idx",
"columns": [
{
"expression": "slug",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"posts_status_published_at_idx": {
"name": "posts_status_published_at_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "published_at",
"isExpression": false,
"asc": false,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"posts_tags_gin_idx": {
"name": "posts_tags_gin_idx",
"columns": [
{
"expression": "tags",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "gin",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1791131376170,
"tag": "0000_silky_shocker",
"breakpoints": true
}
]
}
+17 -10
View File
@@ -3,16 +3,23 @@ import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript"; import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([ const eslintConfig = defineConfig([
...nextVitals, ...nextVitals,
...nextTs, ...nextTs,
// Override default ignores of eslint-config-next. {
globalIgnores([ rules: {
// Default ignores of eslint-config-next: "import/no-anonymous-default-export": "off",
".next/**", "react/display-name": "off",
"out/**", "@typescript-eslint/no-require-imports": "off",
"build/**", },
"next-env.d.ts", },
]), // Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]); ]);
export default eslintConfig; export default eslintConfig;
+5
View File
@@ -0,0 +1,5 @@
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);
+15
View File
@@ -0,0 +1,15 @@
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as "en" | "zh" | "ja")) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
+8
View File
@@ -0,0 +1,8 @@
// src/i18n/routing.ts
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["en", "zh", "ja"],
defaultLocale: "en",
localePrefix: "always",
});
+199
View File
@@ -0,0 +1,199 @@
import { NextResponse } from "next/server";
import { ZodError } from "zod";
/**
* 统一的 API 响应格式与错误码。
*
* 所有接口遵循同一契约,便于前端与脚本一致地处理:
*
* 成功:{ "data": ..., "meta"?: ... }
* 失败:{ "error": { "code": "...", "message": "...", "details"?: [...] } }
*
* skill 要求"显式优于隐式":错误码是机器可读的稳定标识,
* message 面向人,details 用于字段级校验错误。
*/
/** 机器可读的错误码 */
export const ErrorCode = {
VALIDATION_ERROR: "VALIDATION_ERROR",
NOT_FOUND: "NOT_FOUND",
CONFLICT: "CONFLICT",
BAD_REQUEST: "BAD_REQUEST",
INTERNAL_ERROR: "INTERNAL_ERROR",
METHOD_NOT_ALLOWED: "METHOD_NOT_ALLOWED",
SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE",
} as const;
export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode];
/** 错误码 → HTTP 状态码 */
const STATUS_BY_CODE: Record<ErrorCodeValue, number> = {
VALIDATION_ERROR: 400,
BAD_REQUEST: 400,
NOT_FOUND: 404,
CONFLICT: 409,
METHOD_NOT_ALLOWED: 405,
INTERNAL_ERROR: 500,
SERVICE_UNAVAILABLE: 503,
};
export interface ApiErrorDetail {
/** 出错的字段路径,如 "slug" 或 "tags.0" */
field?: string;
message: string;
}
/** 业务异常:路由中抛出,由 withErrorHandling 统一转成响应 */
export class ApiError extends Error {
constructor(
readonly code: ErrorCodeValue,
message: string,
readonly details?: ApiErrorDetail[],
) {
super(message);
this.name = "ApiError";
}
static notFound(message = "资源不存在") {
return new ApiError(ErrorCode.NOT_FOUND, message);
}
static conflict(message: string, details?: ApiErrorDetail[]) {
return new ApiError(ErrorCode.CONFLICT, message, details);
}
static badRequest(message: string, details?: ApiErrorDetail[]) {
return new ApiError(ErrorCode.BAD_REQUEST, message, details);
}
static validation(message: string, details?: ApiErrorDetail[]) {
return new ApiError(ErrorCode.VALIDATION_ERROR, message, details);
}
}
/** 成功响应 */
export function ok<T>(data: T, meta?: Record<string, unknown>, status = 200) {
return NextResponse.json(meta ? { data, meta } : { data }, { status });
}
/** 创建成功(201) */
export function created<T>(data: T) {
return NextResponse.json({ data }, { status: 201 });
}
/** 无内容(删除成功) */
export function noContent() {
return new NextResponse(null, { status: 204 });
}
/** 失败响应 */
export function fail(
code: ErrorCodeValue,
message: string,
details?: ApiErrorDetail[],
) {
return NextResponse.json(
{ error: { code, message, ...(details ? { details } : {}) } },
{ status: STATUS_BY_CODE[code] },
);
}
/** 把 Zod 的校验错误转成字段级 details */
function zodToDetails(error: ZodError): ApiErrorDetail[] {
return error.issues.map((issue) => ({
field: issue.path.join(".") || undefined,
message: issue.message,
}));
}
/**
* 从错误对象中提取 Postgres 错误码。
*
* 关键点:Drizzle 会把驱动抛出的原始错误包在自己的 `DrizzleQueryError`
* 里,因此 `error.code` 是 undefined,真正的 PG 错误码在 `error.cause.code`。
* 这里沿 cause 链向下查找,最多 5 层,避免无限循环。
*/
function extractPostgresErrorCode(error: unknown): string | undefined {
let current = error;
for (let depth = 0; depth < 5 && current; depth++) {
const code = (current as { code?: unknown }).code;
// PG 错误码是 5 位数字字符串(如 23505);排除其他库的同名字段
if (typeof code === "string" && /^\d{5}$/.test(code)) {
return code;
}
current = (current as { cause?: unknown }).cause;
}
return undefined;
}
/**
* 路由处理器的错误包装器。
*
* 统一处理三类异常:
* 1. ApiError → 按错误码返回
* 2. ZodError → 400 + 字段级 details
* 3. 其他(含数据库错误)→ 500,并把细节写进服务端日志
*
* 数据库约束冲突单独识别为 4xx,这对 slug 重复是常见场景。
*/
export function withErrorHandling<Args extends unknown[]>(
handler: (...args: Args) => Promise<NextResponse>,
) {
return async (...args: Args): Promise<NextResponse> => {
try {
return await handler(...args);
} catch (error) {
if (error instanceof ApiError) {
return fail(error.code, error.message, error.details);
}
if (error instanceof ZodError) {
return fail(
ErrorCode.VALIDATION_ERROR,
"请求参数校验失败",
zodToDetails(error),
);
}
const pgCode = extractPostgresErrorCode(error);
// 唯一约束冲突
if (pgCode === "23505") {
return fail(ErrorCode.CONFLICT, "该 slug 已存在,请换一个", [
{ field: "slug", message: "该 slug 已被占用" },
]);
}
// CHECK 约束冲突(如非法 status/slug 格式)
if (pgCode === "23514") {
return fail(
ErrorCode.VALIDATION_ERROR,
"数据不满足数据库约束,请检查 status 与 slug 格式",
);
}
// 非空约束冲突
if (pgCode === "23502") {
return fail(ErrorCode.VALIDATION_ERROR, "缺少必填字段");
}
// 字符串长度超限
if (pgCode === "22001") {
return fail(ErrorCode.VALIDATION_ERROR, "字段长度超出限制");
}
console.error("[api] 未处理的异常:", error);
return fail(ErrorCode.INTERNAL_ERROR, "服务器内部错误");
}
};
}
/** 解析并校验 JSON 请求体 */
export async function parseJsonBody(request: Request): Promise<unknown> {
try {
return await request.json();
} catch {
throw ApiError.badRequest("请求体不是合法的 JSON");
}
}
+125
View File
@@ -0,0 +1,125 @@
import "server-only";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
/**
* 数据库客户端(node-postgres 连接池)。
*
* ## 为什么用 pg 而不是 neon-http
*
* 脚手架原本使用 `drizzle-orm/neon-http` + `@neondatabase/serverless`。
* 该驱动走的是 **Neon 的 HTTP 代理端点**,不是 Postgres 的 TCP 协议,
* 因此无法连接本地或自建的普通 Postgres。
*
* 本项目改用 `pg`(node-postgres),它既能连本地 Postgres,
* 也能连 Neon / Supabase / Railway 等任何标准 Postgres——
* 部署到 Neon 时只需保留其 TCP 连接串(`*.neon.tech` 的 5432 端口)。
*
* ## 连接池与 Next.js 开发模式
*
* Next.js 开发模式下模块会被热重载,若不缓存连接池,每次改动都会新建一批
* 连接,很快耗尽数据库的 max_connections。因此把 pool 挂到 globalThis 上做单例。
*/
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error(
"缺少环境变量 DATABASE_URL。请在 .env.local 中配置 Postgres 连接串," +
"例如:postgresql://user:password@localhost:5432/blog",
);
}
// 收窄为 string,供下方闭包使用(模块级已判空,但闭包内 TS 无法自动收窄)
const resolvedConnectionString: string = connectionString;
/** 在 globalThis 上缓存连接池,避免开发模式热重载导致的连接泄漏 */
const globalForDb = globalThis as unknown as {
__postgresPool?: Pool;
};
function createPool(): Pool {
const pool = new Pool({
connectionString: resolvedConnectionString,
// 本地开发(localhost)通常没配证书,强制 SSL 会直接失败;
// 托管数据库(Neon / Supabase 等)则要求 SSL。
ssl: shouldUseSsl(resolvedConnectionString)
? { rejectUnauthorized: false }
: undefined,
// 单实例默认 10 连接足够;Serverless 环境建议调小或接 Neon 的 pooler
max: Number(process.env.DATABASE_POOL_MAX ?? 10),
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 10_000,
// 语句级超时,避免慢查询长期占用连接
statement_timeout: Number(process.env.DATABASE_STATEMENT_TIMEOUT_MS ?? 15_000),
});
// 连接池错误不应导致进程崩溃(例如数据库重启期间的瞬时失败)
pool.on("error", (error) => {
console.error("[db] 空闲连接异常:", error.message);
});
return pool;
}
/** 判断是否需要启用 SSL:本地地址不启用,远端默认启用 */
function shouldUseSsl(url: string): boolean {
try {
const { hostname, searchParams } = new URL(url);
const sslmode = searchParams.get("sslmode");
// 显式声明优先
if (sslmode === "disable") {
return false;
}
if (sslmode === "require" || sslmode === "verify-full") {
return true;
}
// 未显式声明时,按主机推断
const isLocal =
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname.endsWith(".local");
return !isLocal;
} catch {
// URL 解析失败时交给 pg 自己报错,这里默认不启用 SSL
return false;
}
}
const pool = globalForDb.__postgresPool ?? createPool();
// 生产环境不需要挂载(模块不会热重载),但挂载也无副作用
globalForDb.__postgresPool = pool;
export const db = drizzle(pool, { schema });
/** 底层连接池,供健康检查与脚本使用 */
export { pool };
export type DB = typeof db;
/** 检查数据库连通性,用于健康检查接口 */
export async function checkDatabaseHealth(): Promise<{
ok: boolean;
latencyMs: number;
error?: string;
}> {
const startedAt = Date.now();
try {
await pool.query("select 1");
return { ok: true, latencyMs: Date.now() - startedAt };
} catch (error) {
return {
ok: false,
latencyMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : String(error),
};
}
}
+107
View File
@@ -0,0 +1,107 @@
import {
pgTable,
text,
timestamp,
integer,
index,
uniqueIndex,
serial,
varchar,
} from "drizzle-orm/pg-core";
import { relations, sql } from "drizzle-orm";
/**
* 数据库模型。
*
* 设计要点:
* - `slug` 唯一且带索引:它是文章的唯一对外标识(URL 的一部分)。
* - `status` 用枚举字符串而非布尔:草稿/已发布之外,未来可能加归档。
* 这里用 varchar + CHECK 约束保证取值合法(Drizzle 的 pgEnum 也可,
* 但 enum 类型后续增删值需要 ALTER TYPE,字符串 + CHECK 更灵活)。
* - `tags` 使用 text[] 而非独立关联表:v0.1 的标签只需按包含查询,
* 数组 + GIN 索引即可满足;引入 tags 关联表会让读写都多一次 join。
* - 保留 `created_at` / `updated_at`,便于排序与增量同步。
* - `published_at` 单独存:草稿转发布时,发布时间不等于创建时间。
*/
export const posts = pgTable(
"posts",
{
id: serial("id").primaryKey(),
/** URL 标识,全局唯一,仅允许小写字母/数字/连字符 */
slug: varchar("slug", { length: 200 }).notNull(),
/** 标题 */
title: text("title").notNull(),
/** 摘要,用于卡片与 SEO description */
summary: text("summary").notNull(),
/** 正文(Markdown / MDX 源码) */
content: text("content").notNull(),
/** 标签数组 */
tags: text("tags")
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
/** 状态:draft(草稿)| published(已发布) */
status: varchar("status", { length: 20 }).notNull().default("draft"),
/** 阅读时长(分钟),写入时计算并缓存,避免列表页重复解析正文 */
readingMinutes: integer("reading_minutes").notNull().default(1),
/** 封面图路径 */
cover: text("cover"),
/** 覆盖默认 SEO 描述 */
description: text("description"),
/** 发布时间;草稿为 null */
publishedAt: timestamp("published_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => [
// slug 是 URL 标识,必须唯一;唯一索引同时承担查询加速
uniqueIndex("posts_slug_unique_idx").on(table.slug),
// 列表页主查询:按状态过滤 + 按发布时间倒序
index("posts_status_published_at_idx").on(
table.status,
table.publishedAt.desc(),
),
// 标签筛选:GIN 索引支持 `tags @> ARRAY['x']` 与 `'x' = ANY(tags)`
index("posts_tags_gin_idx").using("gin", table.tags),
],
);
/**
* 数据完整性约束(在迁移 SQL 中手工维护)。
*
* Drizzle 的 `pgTable` 第三个回调只支持索引类定义,`sql` 模板不会被生成为
* CHECK 约束。因此以下约束直接写在迁移文件里,并在应用层用 Zod 做同样的校验:
*
* - `posts_status_check` : status IN ('draft', 'published')
* - `posts_slug_format_check` : slug 匹配 ^[a-z0-9]+(-[a-z0-9]+)*$
*
* 注意:约束同时存在于数据库与应用层是有意为之——应用层给出友好报错,
* 数据库层保证任何写入路径(脚本、手工 SQL)都无法绕过。
*/
export const postsRelations = relations(posts, () => ({}));
/** 由 schema 推导的插入类型 */
export type NewPost = typeof posts.$inferInsert;
/** 由 schema 推导的查询类型 */
export type PostRow = typeof posts.$inferSelect;
+86
View File
@@ -0,0 +1,86 @@
import { Inter, Noto_Sans_SC, IBM_Plex_Mono } from "next/font/google";
/**
* 站点字体系统(next/font 自托管)。
*
* 设计规范(DESIGN.md)要求:
* - Inter → 英文 / UI 正文
* - Noto Sans SC → 中文
* - IBM Plex Mono → 代码(规范原文为 Geist Mono,本项目按需求改用 IBM Plex Mono)
*
* 说明:next/font/google 在 `next build` 阶段下载字体文件并自托管到
* `.next/static/media`,运行时不会向 Google 发起请求,也不产生 FOUT/CLS。
* 每个字体都显式配置 `fallback`(含 `adjustFontFallback`),保证在字体
* 尚未加载或加载失败时,回退栈与目标字体的度量尽量接近。
*/
/** 英文 / UI 主字体:Inter */
export const fontInter = Inter({
variable: "--font-inter",
subsets: ["latin", "latin-ext"],
weight: ["400", "500", "600", "700"],
style: ["normal"],
display: "swap",
preload: true,
fallback: [
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"Helvetica Neue",
"Arial",
"sans-serif",
],
adjustFontFallback: true,
});
/**
* 中文字体:Noto Sans SC
*
* 该字族在 Google Fonts 上是按权重分片的(非可变字体),因此显式声明需要
* 的 4 个权重,由 next/font 生成 unicode-range 分片,浏览器只下载用到的子集。
*/
export const fontNotoSansSC = Noto_Sans_SC({
variable: "--font-noto-sans-sc",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
style: ["normal"],
display: "swap",
// 中文字体体积大、子集多,禁用预载以免抢占首屏关键资源。
preload: false,
fallback: [
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
"Source Han Sans SC",
"WenQuanYi Micro Hei",
"sans-serif",
],
adjustFontFallback: false,
});
/** 代码字体:IBM Plex Mono */
export const fontIbmPlexMono = IBM_Plex_Mono({
variable: "--font-ibm-plex-mono",
subsets: ["latin", "latin-ext"],
weight: ["400", "500", "600"],
style: ["normal"],
display: "swap",
preload: true,
fallback: [
"SFMono-Regular",
"SF Mono",
"Menlo",
"Consolas",
"Liberation Mono",
"Courier New",
"monospace",
],
adjustFontFallback: true,
});
/** 挂到 <html> 上的字体变量类名。 */
export const fontVariables = [
fontInter.variable,
fontNotoSansSC.variable,
fontIbmPlexMono.variable,
].join(" ");
+71
View File
@@ -0,0 +1,71 @@
import "server-only";
import { compileMDX } from "next-mdx-remote/rsc";
import rehypePrettyCode, {
type Options as PrettyCodeOptions,
} from "rehype-pretty-code";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import remarkGfm from "remark-gfm";
import { mdxComponents } from "@/components/mdx/mdx-components";
/**
* MDX 编译管线。
*
* 插件顺序(rehype 在 remark 之后执行):
* 1. remark-gfm → 表格、任务列表、删除线
* 2. rehype-slug → 为标题生成 id(TOC 锚点依赖它)
* 3. rehype-autolink-headings → 标题旁生成可点击的 # 锚点
* 4. rehype-pretty-code → 基于 Shiki 的语法高亮 + 行号/高亮行
*
* Shiki 在构建期完成高亮,输出静态 HTML,运行时零 JS 开销。
*/
/** 代码高亮主题:深色 surface-code (#1c1c1e) 上对比度良好 */
const CODE_THEME = "github-dark-default";
const prettyCodeOptions: PrettyCodeOptions = {
theme: CODE_THEME,
// 保留默认的 pre/code 结构,样式由 globals.css 的 .prose-blog 控制
keepBackground: true,
defaultLang: "plaintext",
};
export interface CompiledMdx {
content: React.ReactElement;
}
/** 编译 MDX 正文为可渲染的 React 元素树 */
export async function compilePostMdx(source: string): Promise<CompiledMdx> {
const { content } = await compileMDX({
source,
components: mdxComponents,
options: {
parseFrontmatter: false,
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: "append",
properties: {
className: "heading-anchor",
ariaLabel: "锚点链接",
tabIndex: -1,
},
content: {
type: "text",
value: "#",
},
},
],
[rehypePrettyCode, prettyCodeOptions],
],
},
},
});
return { content };
}
+48
View File
@@ -0,0 +1,48 @@
import "server-only";
import {
listAllPublishedPosts,
getPostBySlug as getPostBySlugFromDb,
getAdjacentPosts as getAdjacentPostsFromDb,
listTags,
getPostStats,
} from "./repository";
import type { Post, PostMeta } from "./types";
/**
* 内容读取门面(服务端)。
*
* 页面层只依赖本模块,不直接接触仓储实现。这样做的价值:
* 数据来源从「文件系统」切换到「PostgreSQL」时,**页面代码一行都不用改**。
*
* 所有函数默认只返回已发布文章(草稿在仓储层被过滤),
* 因此草稿既不出现在列表里,也无法通过直接访问 URL 读到。
*/
export type { Post, PostMeta } from "./types";
/** 全部已发布文章,按发布日期倒序 */
export async function getAllPosts(): Promise<PostMeta[]> {
return listAllPublishedPosts();
}
/** 按 slug 读取单篇文章(含正文);草稿与不存在的 slug 均返回 null */
export async function getPostBySlug(slug: string): Promise<Post | null> {
const post = await getPostBySlugFromDb(slug);
return post as Post | null;
}
/** 相邻文章(详情页上下篇导航) */
export async function getAdjacentPosts(slug: string) {
return getAdjacentPostsFromDb(slug);
}
/** 全部标签及其文章数 */
export async function getAllTags(): Promise<{ tag: string; count: number }[]> {
return listTags();
}
/** 站点统计(已发布数 / 草稿数 / 标签数) */
export async function getStats() {
return getPostStats();
}
+386
View File
@@ -0,0 +1,386 @@
import "server-only";
import { and, desc, eq, sql, count } from "drizzle-orm";
import { db } from "@/lib/db";
import { posts as postsTable, type PostRow } from "@/lib/db/schema";
import { estimateReadingMinutes } from "@/lib/validation/post";
import { ApiError } from "@/lib/api/response";
import type { PostFrontmatter, PostMeta } from "@/lib/posts/types";
/**
* 文章仓储层。
*
* 职责边界:
* - 只做数据访问与行↔领域对象映射,**不含** HTTP 概念(状态码、请求解析)
* - 草稿过滤规则集中在这里:默认查询永远排除草稿
*
* 页面(Server Component)与 API 路由共用本模块,保证两处行为一致。
*/
/** 数据库行 → 领域对象 */
function toPostMeta(row: PostRow): PostMeta {
return {
slug: row.slug,
title: row.title,
// 对外仍用 YYYY-MM-DD,保持与原有 frontmatter 契约一致
date: (row.publishedAt ?? row.createdAt).toISOString().slice(0, 10),
summary: row.summary,
tags: row.tags ?? [],
draft: row.status === "draft",
cover: row.cover ?? undefined,
description: row.description ?? undefined,
readingMinutes: row.readingMinutes,
};
}
export interface ListPostsOptions {
limit?: number;
offset?: number;
tag?: string;
/** 是否包含草稿;默认 false */
includeDrafts?: boolean;
/** 只看草稿 */
draftsOnly?: boolean;
/** 显式指定状态;优先级高于 includeDrafts / draftsOnly */
status?: "draft" | "published";
}
export interface ListPostsResult {
items: PostMeta[];
total: number;
limit: number;
offset: number;
hasMore: boolean;
}
/** 构造状态过滤条件 */
function statusCondition(options: ListPostsOptions) {
// 显式 status 优先级最高
if (options.status) {
return eq(postsTable.status, options.status);
}
if (options.draftsOnly) {
return eq(postsTable.status, "draft");
}
if (options.includeDrafts) {
return undefined;
}
return eq(postsTable.status, "published");
}
/**
* 查询文章列表(不含正文),按发布时间倒序。
*
* 分页用 limit/offset:v0.1 数据量小,offset 分页足够且实现简单。
* 数据量增大后应改游标分页(基于 published_at + id),接口形状可保持不变。
*/
export async function listPosts(
options: ListPostsOptions = {},
): Promise<ListPostsResult> {
const limit = Math.min(Math.max(options.limit ?? 20, 1), 100);
const offset = Math.max(options.offset ?? 0, 0);
const conditions = [statusCondition(options)];
if (options.tag) {
// 使用 GIN 索引可加速的数组包含判断
conditions.push(sql`${postsTable.tags} @> ARRAY[${options.tag}]::text[]`);
}
const where = and(...conditions.filter(Boolean));
const [rows, totalResult] = await Promise.all([
db
.select()
.from(postsTable)
.where(where)
// NULLS LAST 保证草稿(published_at 为空)排在已发布之后
.orderBy(
sql`${postsTable.publishedAt} DESC NULLS LAST`,
desc(postsTable.createdAt),
)
.limit(limit)
.offset(offset),
db.select({ value: count() }).from(postsTable).where(where),
]);
const total = totalResult[0]?.value ?? 0;
return {
items: rows.map(toPostMeta),
total,
limit,
offset,
hasMore: offset + rows.length < total,
};
}
/** 列出全部已发布文章(用于首页与 sitemap),不分页 */
export async function listAllPublishedPosts(): Promise<PostMeta[]> {
const result = await listPosts({ limit: 100 });
return result.items;
}
/** 数据库行 → 完整文章(含正文) */
function toPost(row: PostRow): PostFrontmatter & {
slug: string;
content: string;
readingMinutes: number;
} {
const meta = toPostMeta(row);
return {
slug: row.slug,
title: meta.title,
date: meta.date,
summary: meta.summary,
tags: meta.tags,
draft: meta.draft,
cover: meta.cover,
description: meta.description,
content: row.content,
readingMinutes: row.readingMinutes,
};
}
/**
* 按 slug 读取单篇文章(含正文)。
*
* 草稿视为不存在(返回 null),因此既不出现在列表里,
* 也无法通过直接访问 URL 读到。
*/
export async function getPostBySlug(slug: string) {
const rows = await db
.select()
.from(postsTable)
.where(and(eq(postsTable.slug, slug), eq(postsTable.status, "published")))
.limit(1);
return rows.length > 0 ? toPost(rows[0]) : null;
}
/** 管理用途:按 slug 读取,包含草稿 */
export async function getPostBySlugIncludingDrafts(slug: string) {
const rows = await db
.select()
.from(postsTable)
.where(eq(postsTable.slug, slug))
.limit(1);
return rows.length > 0 ? toPost(rows[0]) : null;
}
/** 相邻文章(用于详情页上下篇导航) */
export async function getAdjacentPosts(slug: string): Promise<{
previous: PostMeta | null;
next: PostMeta | null;
}> {
const all = await listAllPublishedPosts();
const index = all.findIndex((post) => post.slug === slug);
if (index === -1) {
return { previous: null, next: null };
}
return {
// 列表按发布时间倒序:索引更小 = 更新
previous: index > 0 ? all[index - 1] : null,
next: index < all.length - 1 ? all[index + 1] : null,
};
}
/** 标签聚合:返回标签及其文章数,按出现次数倒序 */
export async function listTags(): Promise<{ tag: string; count: number }[]> {
const rows = await db
.select({
tag: sql<string>`unnest(${postsTable.tags})`,
})
.from(postsTable)
.where(eq(postsTable.status, "published"));
const counter = new Map<string, number>();
for (const row of rows) {
counter.set(row.tag, (counter.get(row.tag) ?? 0) + 1);
}
return [...counter.entries()]
.map(([tag, value]) => ({ tag, count: value }))
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
}
export interface CreatePostData {
slug: string;
title: string;
summary: string;
content: string;
tags: string[];
status: "draft" | "published";
publishedAt?: Date;
cover?: string | null;
description?: string | null;
}
/** 新建文章 */
export async function createPost(data: CreatePostData) {
const now = new Date();
// 发布状态必须有发布时间:未显式提供则用当前时间
const publishedAt =
data.status === "published" ? (data.publishedAt ?? now) : null;
const rows = await db
.insert(postsTable)
.values({
slug: data.slug,
title: data.title,
summary: data.summary,
content: data.content,
tags: data.tags,
status: data.status,
publishedAt,
cover: data.cover ?? null,
description: data.description ?? null,
readingMinutes: estimateReadingMinutes(data.content),
createdAt: now,
updatedAt: now,
})
.returning();
return toPost(rows[0]);
}
export interface UpdatePostData {
slug?: string;
title?: string;
summary?: string;
content?: string;
tags?: string[];
status?: "draft" | "published";
publishedAt?: Date;
cover?: string | null;
description?: string | null;
}
/**
* 更新文章。
*
* 状态流转规则(有意显式处理,避免隐式副作用):
* - draft → published:若没有 publishedAt,则补上当前时间
* - published → draft:清空 publishedAt(草稿没有发布时间)
*/
export async function updatePost(
slug: string,
data: UpdatePostData,
): Promise<ReturnType<typeof toPost> | null> {
const existing = await db
.select()
.from(postsTable)
.where(eq(postsTable.slug, slug))
.limit(1);
if (existing.length === 0) {
return null;
}
const current = existing[0];
const nextStatus = data.status ?? current.status;
let publishedAt = current.publishedAt;
if (data.publishedAt !== undefined) {
publishedAt = data.publishedAt;
} else if (nextStatus === "published" && !current.publishedAt) {
publishedAt = new Date();
} else if (nextStatus === "draft") {
publishedAt = null;
}
const rows = await db
.update(postsTable)
.set({
...(data.slug !== undefined ? { slug: data.slug } : {}),
...(data.title !== undefined ? { title: data.title } : {}),
...(data.summary !== undefined ? { summary: data.summary } : {}),
...(data.content !== undefined
? {
content: data.content,
// 正文变了,阅读时长要重算
readingMinutes: estimateReadingMinutes(data.content),
}
: {}),
...(data.tags !== undefined ? { tags: data.tags } : {}),
...(data.status !== undefined ? { status: data.status } : {}),
...(data.cover !== undefined ? { cover: data.cover } : {}),
...(data.description !== undefined
? { description: data.description }
: {}),
publishedAt,
updatedAt: new Date(),
})
.where(eq(postsTable.slug, slug))
.returning();
return rows.length > 0 ? toPost(rows[0]) : null;
}
/** 删除文章;返回是否真的删除了 */
export async function deletePost(slug: string): Promise<boolean> {
const rows = await db
.delete(postsTable)
.where(eq(postsTable.slug, slug))
.returning({ id: postsTable.id });
return rows.length > 0;
}
/** slug 是否已存在(可排除某个 slug,用于更新时判重) */
export async function slugExists(
slug: string,
excludeSlug?: string,
): Promise<boolean> {
const rows = await db
.select({ slug: postsTable.slug })
.from(postsTable)
.where(eq(postsTable.slug, slug))
.limit(1);
if (rows.length === 0) {
return false;
}
return rows[0].slug !== excludeSlug;
}
/** 统计信息(首页展示用) */
export async function getPostStats(): Promise<{
published: number;
drafts: number;
tags: number;
}> {
const [publishedRows, draftRows, tags] = await Promise.all([
db
.select({ value: count() })
.from(postsTable)
.where(eq(postsTable.status, "published")),
db
.select({ value: count() })
.from(postsTable)
.where(eq(postsTable.status, "draft")),
listTags(),
]);
return {
published: publishedRows[0]?.value ?? 0,
drafts: draftRows[0]?.value ?? 0,
tags: tags.length,
};
}
/** 确保 slug 不存在,否则抛 409 */
export async function assertSlugAvailable(
slug: string,
excludeSlug?: string,
): Promise<void> {
if (await slugExists(slug, excludeSlug)) {
throw ApiError.conflict(`slug "${slug}" 已存在,请换一个`, [
{ field: "slug", message: "该 slug 已被占用" },
]);
}
}
+40
View File
@@ -0,0 +1,40 @@
/**
* 文章领域类型。
*
* 数据源已从 `content/posts/*.mdx` 文件迁移到 PostgreSQL 的 `posts` 表。
* 为了不改动页面层与 MDX 渲染管线,这里保留了原有的字段命名
* (`date`、`draft` 等),由 `lib/posts/repository.ts` 负责
* 在数据库行与这些类型之间做映射。
*/
/** 文章的公共元信息 */
export interface PostFrontmatter {
/** 标题 */
title: string;
/** 发布日期,格式 YYYY-MM-DD */
date: string;
/** 摘要,用于卡片与 SEO description */
summary: string;
/** 标签 */
tags?: string[];
/** 是否为草稿;为 true 时不参与任何列表、详情与 sitemap 输出 */
draft?: boolean;
/** 封面图路径 */
cover?: string;
/** 覆盖默认 SEO 描述 */
description?: string;
}
/** 列表页使用的文章摘要信息(不含正文) */
export interface PostMeta extends PostFrontmatter {
/** URL 标识 */
slug: string;
/** 阅读时长(分钟,取整,最少 1) */
readingMinutes: number;
}
/** 详情页使用的完整文章 */
export interface Post extends PostMeta {
/** Markdown / MDX 正文(未编译) */
content: string;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* 站点级配置(单一事实来源)。
*
* 所有页面的 metadata、sitemap、robots、页头页脚都从这里取值,
* 避免站点名称 / 域名散落在多个文件中。
*/
export const siteConfig = {
/** 站点名称 */
name: "个人博客",
/** 站点英文标识,用于 Logo 与 SEO */
nameEn: "Inkwell",
/** 一句话简介 */
tagline: "记录工程实践与思考",
/** 站点描述(用于首页 metadata 与 OG) */
description:
"一个使用 Next.js 构建的个人博客,记录前端工程、系统设计与日常思考。支持 Markdown / MDX 写作与代码高亮。",
/** 作者信息 */
author: {
name: "站长",
email: "hello@example.com",
bio: "前端工程师,关注 Web 性能、开发者体验与设计系统。相信好的工具能让复杂的事情变得简单。",
location: "中国 · 杭州",
jobTitle: "前端工程师",
},
/** 社交 / 联系方式(关于页展示) */
social: [
{ label: "GitHub", href: "https://github.com", icon: "github" as const },
{ label: "X / Twitter", href: "https://x.com", icon: "twitter" as const },
{ label: "Email", href: "mailto:hello@example.com", icon: "mail" as const },
],
/** 部署后的站点地址,用于 sitemap / robots / 绝对 URL */
url: process.env.NEXT_PUBLIC_SITE_URL ?? "https://example.com",
/** 默认 OG 图 */
ogImage: "/og.svg",
} as const;
export type SiteConfig = typeof siteConfig;
+103
View File
@@ -0,0 +1,103 @@
import "server-only";
/**
* 从 MDX 原文中提取标题,生成目录(TOC)。
*
* 说明:这里用正则扫描 Markdown 标题而不是解析 AST——因为标题锚点 id
* 由 rehype-slug 生成,其算法是 GitHub 风格的 slugify。为了与渲染结果
* 完全一致,本模块实现了同一套规则(转小写、去标点、空格转连字符)。
*
* 只提取 h2 / h3:文档式阅读下超过三级的目录反而降低可扫读性。
*/
export interface TocItem {
/** 标题文本(已去除行内 Markdown 标记) */
title: string;
/** 与 rehype-slug 生成结果一致的锚点 id */
id: string;
/** 标题层级:2 或 3 */
level: 2 | 3;
}
/**
* GitHub 风格 slugify,与 rehype-slug 的默认行为保持一致。
*
* - 转小写
* - 移除除字母、数字、空格、连字符、下划线、CJK 之外的字符
* - 空格转连字符
*/
export function slugifyHeading(text: string): string {
return text
.trim()
.toLowerCase()
.replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g, "")
.replace(/\s+/g, "-");
}
/** 去掉标题中的行内标记:**粗体**、`代码`、[链接](url) */
function stripInlineMarkdown(text: string): string {
return text
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") // [文本](链接) → 文本
.replace(/[*_`~]/g, "") // 强调与行内代码标记
.trim();
}
/**
* 提取目录项。
*
* 跳过代码块内的 `#` 注释,避免把代码里的注释误当作标题。
*/
export function extractToc(source: string): TocItem[] {
const items: TocItem[] = [];
const slugCounter = new Map<string, number>();
let insideCodeFence = false;
let fenceMarker = "";
for (const rawLine of source.split("\n")) {
const line = rawLine.trimEnd();
// 跟踪代码块边界(``` 或 ~~~)
const fenceMatch = /^\s*(`{3,}|~{3,})/.exec(line);
if (fenceMatch) {
const marker = fenceMatch[1][0];
if (!insideCodeFence) {
insideCodeFence = true;
fenceMarker = marker;
} else if (marker === fenceMarker) {
insideCodeFence = false;
fenceMarker = "";
}
continue;
}
if (insideCodeFence) {
continue;
}
// ATX 标题:## 标题 / ### 标题
const headingMatch = /^(#{2,3})\s+(.+?)\s*#*\s*$/.exec(line);
if (!headingMatch) {
continue;
}
const level = headingMatch[1].length as 2 | 3;
const title = stripInlineMarkdown(headingMatch[2]);
if (!title) {
continue;
}
// 处理重复标题:rehype-slug 会为同名标题追加 -1、-2 …
const baseId = slugifyHeading(title);
const seen = slugCounter.get(baseId) ?? 0;
slugCounter.set(baseId, seen + 1);
const id = seen === 0 ? baseId : `${baseId}-${seen}`;
items.push({ title, id, level });
}
return items;
}
+29
View File
@@ -0,0 +1,29 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** 合并 className:clsx 处理条件类,tailwind-merge 消解冲突的 Tailwind 类 */
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/** 将日期格式化为中文可读形式,如 2025 年 3 月 8 日 */
export function formatDate(date: string, locale = "zh-CN"): string {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
}).format(new Date(`${date}T00:00:00Z`));
}
/** 机器可读日期,用于 <time dateTime> */
export function toDateTimeAttribute(date: string): string {
return new Date(`${date}T00:00:00Z`).toISOString();
}
/** 拼接绝对 URL,用于 canonical / OG / sitemap */
export function absoluteUrl(base: string, pathname: string): string {
const base$ = base.replace(/\/+$/, "");
const path$ = pathname.startsWith("/") ? pathname : `/${pathname}`;
return `${base$}${path$}`;
}
+139
View File
@@ -0,0 +1,139 @@
import { z } from "zod";
/**
* 文章的输入校验契约(单一事实来源)。
*
* API 路由、seed 脚本、未来的后台表单都复用这套 schema,
* 避免"接口校验一套、脚本又一套"导致的不一致。
*/
/** slug:小写字母/数字,用连字符分隔,与数据库 CHECK 约束保持一致 */
export const slugSchema = z
.string()
.trim()
.min(1, "slug 不能为空")
.max(200, "slug 最长 200 个字符")
.regex(
/^[a-z0-9]+(-[a-z0-9]+)*$/,
"slug 只能包含小写字母、数字和连字符,且不能以连字符开头或结尾",
);
/** 状态枚举,与数据库 CHECK 约束保持一致 */
export const postStatusSchema = z.enum(["draft", "published"]);
/** 标签:去空白、去重、限制数量与长度 */
export const tagsSchema = z
.array(z.string().trim().min(1).max(40, "单个标签最长 40 个字符"))
.max(10, "最多 10 个标签")
.default([])
.transform((tags) => [...new Set(tags)]);
/** 日期:接受 YYYY-MM-DD 或完整 ISO 字符串,统一转为 Date */
export const dateInputSchema = z
.string()
.trim()
.min(1)
.refine((value) => !Number.isNaN(new Date(value).getTime()), {
message: "不是合法的日期",
})
.transform((value) => new Date(value));
/** 创建文章 */
export const createPostSchema = z.object({
slug: slugSchema,
title: z.string().trim().min(1, "标题不能为空").max(200, "标题最长 200 个字符"),
summary: z
.string()
.trim()
.min(1, "摘要不能为空")
.max(500, "摘要最长 500 个字符"),
content: z.string().min(1, "正文不能为空"),
tags: tagsSchema,
status: postStatusSchema.default("draft"),
/** 仅当 status=published 时有意义;不传则用当前时间 */
publishedAt: dateInputSchema.optional(),
cover: z.string().trim().min(1).nullish(),
description: z.string().trim().max(500).nullish(),
});
/**
* 更新文章:所有字段可选,但至少要传一个。
*
* ⚠️ `.partial()` 的陷阱:它只把字段变成可选,**不会移除 `.default()`**。
* `createPostSchema` 里 `tags` 有 `.default([])`、`status` 有 `.default("draft")`,
* 于是 `PATCH {}` 会被解析成 `{ tags: [], status: "draft" }`,
* 既绕过了"至少传一个字段"的校验,又会在用户只想改标题时
* 意外把文章状态重置为草稿。
*
* 因此这里基于 **字段定义** 重建 schema:所有字段都显式声明为 `.optional()`,
* 不带任何默认值。未传 = 不修改。
*/
export const updatePostSchema = z
.object({
slug: slugSchema.optional(),
title: z
.string()
.trim()
.min(1, "标题不能为空")
.max(200, "标题最长 200 个字符")
.optional(),
summary: z
.string()
.trim()
.min(1, "摘要不能为空")
.max(500, "摘要最长 500 个字符")
.optional(),
content: z.string().min(1, "正文不能为空").optional(),
tags: z
.array(z.string().trim().min(1).max(40, "单个标签最长 40 个字符"))
.max(10, "最多 10 个标签")
.transform((tags) => [...new Set(tags)])
.optional(),
status: postStatusSchema.optional(),
publishedAt: dateInputSchema.optional(),
cover: z.string().trim().min(1).nullish(),
description: z.string().trim().max(500).nullish(),
})
// 至少要有一个"真正传了值"的键(nullish 字段传 null 也算显式修改)
.refine((data) => Object.keys(data).length > 0, {
message: "至少需要提供一个要更新的字段",
});
/** 列表查询参数 */
export const listPostsQuerySchema = z.object({
/** 返回数量,1–100 */
limit: z.coerce.number().int().min(1).max(100).default(20),
/** 偏移量,用于分页 */
offset: z.coerce.number().int().min(0).default(0),
/** 按标签过滤 */
tag: z.string().trim().min(1).optional(),
/** 按状态过滤;不传则默认只返回已发布 */
status: postStatusSchema.optional(),
/** 是否包含草稿(仅用于内部/管理场景) */
includeDrafts: z
.enum(["true", "false"])
.default("false")
.transform((value) => value === "true"),
});
export type CreatePostInput = z.infer<typeof createPostSchema>;
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
export type ListPostsQuery = z.infer<typeof listPostsQuerySchema>;
/**
* 估算阅读时长(分钟)。
* 中文按字符计(约 350 字/分钟),英文按单词计(约 200 词/分钟)。
*
* 写入时计算并缓存到 reading_minutes 列,避免列表页反复解析正文。
* 与 `lib/posts/reading-time.ts` 共用同一实现,保证行为一致。
*/
export function estimateReadingMinutes(content: string): number {
const chineseCharacters = content.match(/[\u4e00-\u9fa5]/g)?.length ?? 0;
const latinWords =
content
.replace(/[\u4e00-\u9fa5]/g, " ")
.match(/[A-Za-z0-9]+/g)?.length ?? 0;
const minutes = chineseCharacters / 350 + latinWords / 200;
return Math.max(1, Math.round(minutes));
}
+81
View File
@@ -0,0 +1,81 @@
{
"Site": {
"name": "Personal Blog",
"tagline": "Notes on engineering practice"
},
"Nav": {
"home": "Home",
"about": "About",
"posts": "Posts",
"openMenu": "Open navigation menu",
"closeMenu": "Close navigation menu",
"skipToContent": "Skip to main content"
},
"Home": {
"eyebrow": "Personal Blog",
"heroTitle": "Notes on engineering practice",
"heroSubtitle": "Writing about front-end engineering, system design and developer experience — less hype, more of what actually solves problems.",
"ctaPrimary": "Start reading",
"ctaSecondary": "About me",
"latestEyebrow": "Latest",
"latestTitle": "Recent writing",
"latestDescription": "Every published article, newest first.",
"postCount": "{count} posts",
"statsPosts": "posts",
"statsTags": "tags",
"statsSince": "writing since"
},
"Post": {
"readingTime": "{minutes} min read",
"publishedOn": "Published on",
"backHome": "Back to home",
"tableOfContents": "On this page",
"tags": "Tags",
"previous": "Previous",
"next": "Next",
"draft": "Draft",
"notFoundTitle": "Post not found",
"notFoundDescription": "This article may have been removed, or it is still an unpublished draft."
},
"About": {
"eyebrow": "About",
"title": "About me",
"description": "A front-end engineer focused on web performance, developer experience and design systems.",
"introTitle": "Introduction",
"contactTitle": "Get in touch",
"contactDescription": "Feel free to reach out through any of the channels below — I usually reply within a day or two.",
"email": "Email",
"location": "Location",
"role": "Role",
"techTitle": "Focus areas",
"techDescription": "These are the areas I spend most of my working time on."
},
"Sitemap": {
"title": "Sitemap",
"description": "An index of every page and article on this site."
},
"NotFound": {
"eyebrow": "Error 404",
"title": "This page went missing",
"description": "The page you are looking for does not exist, or it has been moved somewhere else.",
"backHome": "Back to home",
"browsePosts": "Browse posts"
},
"Error": {
"title": "Something went wrong",
"description": "An unexpected error occurred while loading this page. Please try again.",
"retry": "Try again"
},
"Footer": {
"rights": "All rights reserved.",
"builtWith": "Built with Next.js",
"navigation": "Navigation",
"elsewhere": "Find me elsewhere"
},
"LocaleSwitcher": {
"label": "Language",
"en": "English",
"zh": "中文",
"ja": "日本語"
}
}
+81
View File
@@ -0,0 +1,81 @@
{
"Site": {
"name": "個人ブログ",
"tagline": "エンジニアリングの実践と思考"
},
"Nav": {
"home": "ホーム",
"about": "私について",
"posts": "記事",
"openMenu": "ナビゲーションメニューを開く",
"closeMenu": "ナビゲーションメニューを閉じる",
"skipToContent": "メインコンテンツへスキップ"
},
"Home": {
"eyebrow": "個人ブログ",
"heroTitle": "エンジニアリングの実践と思考",
"heroSubtitle": "フロントエンドエンジニアリング、システム設計、開発者体験について書いています。流行を追わず、実際に問題を解決する方法だけを記録します。",
"ctaPrimary": "読んでみる",
"ctaSecondary": "私について",
"latestEyebrow": "最新記事",
"latestTitle": "最近書いたこと",
"latestDescription": "公開日時の新しい順に並んだすべての記事です。",
"postCount": "{count} 件の記事",
"statsPosts": "記事",
"statsTags": "タグ",
"statsSince": "執筆開始"
},
"Post": {
"readingTime": "約 {minutes} 分",
"publishedOn": "公開日",
"backHome": "ホームに戻る",
"tableOfContents": "目次",
"tags": "タグ",
"previous": "前の記事",
"next": "次の記事",
"draft": "下書き",
"notFoundTitle": "記事が見つかりません",
"notFoundDescription": "この記事は削除されたか、まだ公開されていない下書きです。"
},
"About": {
"eyebrow": "私について",
"title": "私について",
"description": "Web パフォーマンス、開発者体験、デザインシステムに関心を持つフロントエンドエンジニアです。",
"introTitle": "紹介",
"contactTitle": "お問い合わせ",
"contactDescription": "以下のいずれかの方法でお気軽にご連絡ください。通常 1〜2 日以内に返信します。",
"email": "メール",
"location": "所在地",
"role": "職種",
"techTitle": "関心分野",
"techDescription": "日々の業務で最も多くの時間を費やしている分野です。"
},
"Sitemap": {
"title": "サイトマップ",
"description": "このサイトのすべてのページと記事の索引です。"
},
"NotFound": {
"eyebrow": "エラー 404",
"title": "ページが見つかりません",
"description": "お探しのページは存在しないか、別の場所へ移動されました。",
"backHome": "ホームに戻る",
"browsePosts": "記事を見る"
},
"Error": {
"title": "問題が発生しました",
"description": "ページの読み込み中に予期しないエラーが発生しました。もう一度お試しください。",
"retry": "再試行"
},
"Footer": {
"rights": "All rights reserved.",
"builtWith": "Next.js で構築",
"navigation": "ナビゲーション",
"elsewhere": "他の場所で"
},
"LocaleSwitcher": {
"label": "言語",
"en": "English",
"zh": "中文",
"ja": "日本語"
}
}
+81
View File
@@ -0,0 +1,81 @@
{
"Site": {
"name": "个人博客",
"tagline": "记录工程实践与思考"
},
"Nav": {
"home": "首页",
"about": "关于",
"posts": "文章",
"openMenu": "打开导航菜单",
"closeMenu": "关闭导航菜单",
"skipToContent": "跳到主要内容"
},
"Home": {
"eyebrow": "个人博客",
"heroTitle": "记录工程实践与思考",
"heroSubtitle": "这里写下我在前端工程、系统设计与开发者体验上的实践与思考。不追热点,只记录真正解决问题的方法。",
"ctaPrimary": "开始阅读",
"ctaSecondary": "了解我",
"latestEyebrow": "最新文章",
"latestTitle": "最近写了什么",
"latestDescription": "按发布时间倒序排列的全部文章。",
"postCount": "{count} 篇文章",
"statsPosts": "篇文章",
"statsTags": "个标签",
"statsSince": "开始写作"
},
"Post": {
"readingTime": "{minutes} 分钟阅读",
"publishedOn": "发布于",
"backHome": "返回首页",
"tableOfContents": "本页目录",
"tags": "标签",
"previous": "上一篇",
"next": "下一篇",
"draft": "草稿",
"notFoundTitle": "文章不存在",
"notFoundDescription": "这篇文章可能已被删除,或者仍是未发布的草稿。"
},
"About": {
"eyebrow": "关于",
"title": "关于我",
"description": "一名前端工程师,关注 Web 性能、开发者体验与设计系统。",
"introTitle": "简介",
"contactTitle": "联系我",
"contactDescription": "欢迎通过以下任意方式与我交流,我通常会在一两天内回复。",
"email": "邮箱",
"location": "所在地",
"role": "职业",
"techTitle": "关注方向",
"techDescription": "这些是我日常工作中投入最多时间的方向。"
},
"Sitemap": {
"title": "站点地图",
"description": "本站全部页面与文章的索引。"
},
"NotFound": {
"eyebrow": "错误 404",
"title": "页面走丢了",
"description": "你访问的页面不存在,或者已经被移动到了别处。",
"backHome": "返回首页",
"browsePosts": "浏览文章"
},
"Error": {
"title": "出了点问题",
"description": "页面加载时发生了意外错误,请稍后重试。",
"retry": "重试"
},
"Footer": {
"rights": "保留所有权利。",
"builtWith": "使用 Next.js 构建",
"navigation": "导航",
"elsewhere": "在其他地方找到我"
},
"LocaleSwitcher": {
"label": "语言",
"en": "English",
"zh": "中文",
"ja": "日本語"
}
}
+4 -3
View File
@@ -1,7 +1,8 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const createNextIntlPlugin = require("next-intl/plugin");
const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
}; };
module.exports = withNextIntl(nextConfig);
export default nextConfig; export default nextConfig;
+36 -2
View File
@@ -6,18 +6,52 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"typecheck": "tsc --noEmit",
"db:check": "node scripts/db-check.mjs",
"db:diagnose": "node scripts/db-diagnose.mjs",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:verify": "node scripts/db-verify-schema.mjs",
"db:studio": "drizzle-kit studio",
"seed": "node scripts/seed-posts.mjs",
"test:validation": "node scripts/test-validation.mjs",
"test:api": "node scripts/test-api.mjs",
"test:404": "node scripts/check-404.mjs",
"test": "node scripts/test-validation.mjs && node scripts/check-404.mjs && node scripts/test-api.mjs"
}, },
"dependencies": { "dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.3",
"gray-matter": "^4.0.3",
"immer": "^11.1.21",
"lucide-react": "^1.51.0",
"motion": "^14.0.0",
"next": "16.3.8", "next": "16.3.8",
"next-intl": "^4.14.9",
"next-mdx-remote": "^6.0.0",
"pg": "^8.23.1",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8",
"rehype-autolink-headings": "^7.1.0",
"rehype-pretty-code": "^0.14.5",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"server-only": "^0.0.1",
"shiki": "^4.5.0",
"tailwind-merge": "^3.7.0",
"use-immer": "^0.11.0",
"zod": "^4.6.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.23.1",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"dotenv": "^18.0.5",
"drizzle-kit": "^0.31.11",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.8", "eslint-config-next": "16.3.8",
"tailwindcss": "^4", "tailwindcss": "^4",
+3285
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -1,3 +1,6 @@
allowBuilds: allowBuilds:
'@parcel/watcher': true
'@swc/core': true
esbuild: true
sharp: false sharp: false
unrs-resolver: false unrs-resolver: false
+32
View File
@@ -0,0 +1,32 @@
import type { NextRequest } from "next/server";
import createNextIntl from "next-intl/middleware";
import { routing } from "./i18n/routing";
/**
* 语言路由中间件。
*
* Next.js 16 起 `middleware.ts` 约定更名为 `proxy.ts`
* (nextjs.org/docs/messages/middleware-to-proxy)。
*
* ## 为什么不在这里拦截不存在的文章
*
* 一开始的思路是在中间件里提前查库、对草稿和不存在的 slug 直接返回 404。
* 最终**放弃了这个方案**,原因:
* 1. 中间件在每个匹配请求上都会执行,等于给所有页面加了一次额外开销;
* 2. 中间件运行在受限运行时,引入数据库驱动会显著增大中间件包体积;
* 3. 即便返回 404,也需要与页面渲染逻辑保持一致,容易产生两套判定标准。
*
* 实际采用的做法见 `app/[locale]/posts/[slug]/page.tsx`:
* 在**渲染之前**用 `notFound()` 终止,并配合 `dynamic = "force-dynamic"`
* 避免流式响应已经提交状态码。具体说明见该文件注释。
*/
const handleI18nRouting = createNextIntl(routing);
export default function proxy(request: NextRequest) {
return handleI18nRouting(request);
}
export const config = {
// 跳过 API、Next 内部资源与静态文件(含点号的路径)
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};
+40
View File
@@ -0,0 +1,40 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-label="个人博客 — 记录工程实践与思考">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#87a8c8"/>
<stop offset="55%" stop-color="#d9dbe0"/>
<stop offset="100%" stop-color="#f5e9d8"/>
</linearGradient>
<radialGradient id="glow" cx="50%" cy="0%" r="70%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="1200" height="630" fill="url(#sky)"/>
<rect width="1200" height="630" fill="url(#glow)"/>
<!-- 品牌标识 -->
<g transform="translate(96, 96)">
<rect width="56" height="56" rx="12" fill="#0a0a0a"/>
<text x="28" y="37" font-family="monospace" font-size="22" font-weight="600" fill="#00d4a4" text-anchor="middle">&gt;_</text>
</g>
<!-- 主标题 -->
<text x="96" y="300" font-family="Inter, 'Noto Sans SC', -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="72" font-weight="600" letter-spacing="-2" fill="#0a0a0a">
记录工程实践与思考
</text>
<!-- 副标题 -->
<text x="96" y="380" font-family="Inter, 'Noto Sans SC', -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="28" font-weight="400" fill="#3a3a3c">
前端工程 · 系统设计 · 开发者体验
</text>
<!-- 底部强调线 -->
<rect x="96" y="452" width="120" height="6" rx="3" fill="#00d4a4"/>
<!-- 站点标识 -->
<text x="96" y="530" font-family="Inter, 'Noto Sans SC', sans-serif" font-size="22" font-weight="500" fill="#5a5a5c">
个人博客
</text>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+42
View File
@@ -0,0 +1,42 @@
/**
* 检查「不存在的文章 / 草稿」是否返回真正的 HTTP 404。
*
* 背景:在动态路由(无 generateStaticParams 预生成)中,
* `notFound()` 的行为依赖渲染模式。若页面被当作静态/缓存内容渲染,
* 可能只渲染 404 内容但状态码仍是 200——这对 SEO 有害,
* 搜索引擎会把不存在的 URL 当成有效页面收录。
*
* 用法:node scripts/check-404.mjs [baseUrl]
*/
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/$/, "");
const cases = [
{ path: "/zh/posts/definitely-not-exist", expect: 404, label: "不存在的文章" },
{ path: "/zh/posts/web-performance-priority", expect: 404, label: "草稿文章" },
{ path: "/zh/no-such-page", expect: 404, label: "不存在的页面" },
{ path: "/zh/posts/building-a-blog-with-nextjs", expect: 200, label: "已发布文章" },
{ path: "/api/posts/definitely-not-exist", expect: 404, label: "API:不存在的文章" },
{ path: "/api/posts/web-performance-priority", expect: 404, label: "API:草稿" },
];
let pass = 0;
let fail = 0;
for (const testCase of cases) {
const response = await fetch(`${BASE}${testCase.path}`, { redirect: "manual" });
const okStatus = response.status === testCase.expect;
if (okStatus) {
pass++;
console.log(` [PASS] ${testCase.label}: ${response.status}`);
} else {
fail++;
console.log(
` [FAIL] ${testCase.label}: 期望 ${testCase.expect},实际 ${response.status}`,
);
}
}
console.log(`\n 通过 ${pass} / ${pass + fail}`);
process.exitCode = fail > 0 ? 1 : 0;
+85
View File
@@ -0,0 +1,85 @@
/**
* 检查文章页代码块在窄屏下的滚动行为。
*
* 期望:代码块自身横向滚动(overflow-x: auto),
* 而不是把整个页面撑出横向滚动条。
*
* 用法:CDP_WS=<ws地址> node scripts/check-code-blocks.mjs <url> <width>
*/
const [, , targetUrl, widthArg = "390"] = process.argv;
const width = Number(widthArg);
const wsUrl = process.env.CDP_WS;
if (!wsUrl) {
console.error("缺少 CDP_WS 环境变量");
process.exit(1);
}
const socket = new WebSocket(wsUrl);
let messageId = 0;
const pending = new Map();
function send(method, params = {}) {
const id = ++messageId;
socket.send(JSON.stringify({ id, method, params }));
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`CDP 超时: ${method}`));
}
}, 30000);
});
}
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.id && pending.has(message.id)) {
const { resolve } = pending.get(message.id);
pending.delete(message.id);
resolve(message.result);
}
});
socket.addEventListener("open", async () => {
await send("Page.enable");
await send("Runtime.enable");
await send("Emulation.setDeviceMetricsOverride", {
width,
height: 900,
deviceScaleFactor: 1,
mobile: true,
});
await send("Page.navigate", { url: targetUrl });
await new Promise((resolve) => setTimeout(resolve, 4000));
const result = await send("Runtime.evaluate", {
expression: `(() => {
const pres = [...document.querySelectorAll('.prose-blog pre')];
const doc = document.documentElement;
return {
documentOverflow: doc.scrollWidth - doc.clientWidth,
preCount: pres.length,
blocks: pres.map((pre, index) => {
const figure = pre.closest('figure');
return {
index,
overflowX: getComputedStyle(pre).overflowX,
scrollable: pre.scrollWidth > pre.clientWidth,
scrollWidth: pre.scrollWidth,
clientWidth: pre.clientWidth,
figureOverflow: figure ? getComputedStyle(figure).overflow : null,
};
}),
};
})()`,
returnByValue: true,
});
console.log(JSON.stringify(result.result.value, null, 2));
socket.close();
process.exit(0);
});
+68
View File
@@ -0,0 +1,68 @@
/**
* 数据库连通性探测(node-postgres 版)。
*
* 用法:node scripts/db-check.mjs
*
* 与之前的版本不同,这里直接用 pg 驱动走 TCP 协议,
* 因此能连接本地/自建的普通 Postgres。
*/
import pg from "pg";
import { config } from "dotenv";
config({ path: ".env.local" });
const url = process.env.DATABASE_URL;
if (!url) {
console.error("缺少 DATABASE_URL(检查 .env.local)");
process.exit(1);
}
const masked = url.replace(/:\/\/([^:]+):[^@]+@/, "://$1:***@");
console.log(`连接目标: ${masked}\n`);
const parsed = new URL(url);
const sslmode = parsed.searchParams.get("sslmode");
const isLocal = /^(localhost|127\.0\.0\.1|::1)$/.test(parsed.hostname);
const useSsl = sslmode === "disable" ? false : sslmode ? true : !isLocal;
console.log(`主机: ${parsed.hostname}:${parsed.port || 5432}`);
console.log(`sslmode: ${sslmode ?? "(未指定)"} → 启用 SSL: ${useSsl}\n`);
const client = new pg.Client({
connectionString: url,
ssl: useSsl ? { rejectUnauthorized: false } : undefined,
});
try {
await client.connect();
const info = await client.query(
"select version() as version, current_database() as db, current_user as usr",
);
console.log("✓ 连接成功");
console.log(` 数据库: ${info.rows[0].db}`);
console.log(` 用户: ${info.rows[0].usr}`);
console.log(` 版本: ${String(info.rows[0].version).split(",")[0]}\n`);
const tables = await client.query(
`select table_name from information_schema.tables
where table_schema = 'public' order by table_name`,
);
console.log(`public schema 下的表 (${tables.rowCount}):`);
if (tables.rowCount === 0) {
console.log(" (空 —— 尚未执行迁移)");
} else {
for (const row of tables.rows) {
const count = await client.query(
`select count(*)::int as n from "${row.table_name}"`,
);
console.log(` - ${row.table_name} (${count.rows[0].n} 行)`);
}
}
} catch (error) {
console.error("✗ 连接失败:", error.message);
process.exitCode = 1;
} finally {
await client.end().catch(() => {});
}
+120
View File
@@ -0,0 +1,120 @@
/**
* 诊断 DATABASE_URL 与驱动/SSL 配置是否匹配。
*
* 覆盖两类常见问题:
* 1. **驱动不匹配**:@neondatabase/serverless 的 neon-http 走 Neon 的 HTTP 代理,
* 无法连接普通 Postgres(需改用 pg)。
* 2. **SSL 不匹配**:本地 Postgres 默认不开 SSL,若连接串带 sslmode=require
* 会报 "The server does not support SSL connections"。
*
* 用法:node scripts/db-diagnose.mjs
*/
import { config } from "dotenv";
import net from "node:net";
import pg from "pg";
config({ path: ".env.local" });
const url = process.env.DATABASE_URL;
if (!url) {
console.error("缺少 DATABASE_URL");
process.exit(1);
}
let parsed;
try {
parsed = new URL(url);
} catch {
console.error(`DATABASE_URL 不是合法 URL: ${url}`);
process.exit(1);
}
const host = parsed.hostname;
const port = Number(parsed.port || 5432);
const database = parsed.pathname.replace(/^\//, "");
const sslmode = parsed.searchParams.get("sslmode");
const isNeonHost = /\.neon\.tech$/i.test(host);
const isLocal = /^(localhost|127\.0\.0\.1|::1)$/.test(host);
console.log("── DATABASE_URL 解析 ─────────────────");
console.log(` host: ${host}`);
console.log(` port: ${port}`);
console.log(` database: ${database}`);
console.log(` sslmode: ${sslmode ?? "(未指定)"}`);
console.log(` Neon 托管域名: ${isNeonHost ? "是" : "否"}`);
console.log(` 本地地址: ${isLocal ? "是" : "否"}\n`);
/** 探测 TCP 端口是否可连接 */
function probeTcp(hostname, tcpPort, timeoutMs = 3000) {
return new Promise((resolve) => {
const socket = net.createConnection({ host: hostname, port: tcpPort });
const done = (result) => {
socket.destroy();
resolve(result);
};
socket.setTimeout(timeoutMs);
socket.once("connect", () => done(true));
socket.once("timeout", () => done(false));
socket.once("error", () => done(false));
});
}
const tcpReachable = await probeTcp(host, port);
console.log("── 网络连通性 ────────────────────────");
console.log(` TCP ${host}:${port} 可达: ${tcpReachable ? "是" : "否"}\n`);
// 依次尝试「按 sslmode」与「不启用 SSL」,判断是否为 SSL 配置问题
async function tryConnect(useSsl) {
const client = new pg.Client({
connectionString: url,
ssl: useSsl ? { rejectUnauthorized: false } : undefined,
connectionTimeoutMillis: 5000,
});
try {
await client.connect();
await client.query("select 1");
await client.end();
return { ok: true };
} catch (error) {
await client.end().catch(() => {});
return { ok: false, error: error.message };
}
}
console.log("── 实际连接测试 ──────────────────────");
const withConfiguredSsl = await tryConnect(
sslmode === "disable" ? false : sslmode ? true : !isLocal,
);
console.log(
` 按当前 sslmode 连接: ${withConfiguredSsl.ok ? "✓ 成功" : `✗ ${withConfiguredSsl.error}`}`,
);
let oppositeResult = null;
if (!withConfiguredSsl.ok) {
oppositeResult = await tryConnect(!(sslmode === "disable"));
console.log(
` 改用相反 SSL 设置: ${oppositeResult.ok ? "✓ 成功" : `✗ ${oppositeResult.error}`}`,
);
}
console.log("");
console.log("── 结论 ──────────────────────────────");
if (withConfiguredSsl.ok) {
console.log(" ✓ 配置正确,数据库可用。");
} else if (oppositeResult?.ok) {
console.log(" ✗ SSL 配置与服务器不匹配。");
console.log(
isLocal
? " 本地 Postgres 默认不启用 SSL,请把连接串改为 ?sslmode=disable"
: " 该服务器要求 SSL,请在连接串中加上 ?sslmode=require",
);
} else if (!tcpReachable) {
console.log(" ✗ 端口不可达:请确认数据库已启动、主机与端口正确。");
} else {
console.log(" ✗ 连接失败,且与 SSL 无关,请检查用户名/密码/数据库名。");
console.log(" → 若使用 neon-http 驱动连接普通 Postgres,需改用 pg 驱动。");
}
+164
View File
@@ -0,0 +1,164 @@
/**
* 验证 posts 表的约束与索引是否按预期生效。
*
* 这里直接对数据库做"坏写入"测试,确认约束真的能拦住非法数据——
* 只检查迁移文件容易漏掉约束静默失效的情况。
*
* 用法:node scripts/db-verify-schema.mjs
*/
import pg from "pg";
import { config } from "dotenv";
config({ path: ".env.local" });
const client = new pg.Client({
connectionString: process.env.DATABASE_URL,
ssl: /localhost|127\.0\.0\.1/.test(process.env.DATABASE_URL ?? "")
? undefined
: { rejectUnauthorized: false },
});
let pass = 0;
let fail = 0;
function check(name, condition, detail = "") {
if (condition) {
pass++;
console.log(` [PASS] ${name}`);
} else {
fail++;
console.log(` [FAIL] ${name} ${detail}`);
}
}
/** 期望插入失败(被约束拦截),返回错误信息 */
async function expectReject(label, sql, params) {
try {
await client.query(sql, params);
check(label, false, "← 竟然插入成功了,约束未生效");
// 清理掉误插入的数据
await client.query("delete from posts where slug like 'zz-test-%'");
} catch (error) {
check(label, true);
return error.message;
}
}
await client.connect();
console.log("── 列结构 ────────────────────────────");
const cols = await client.query(
`select column_name, data_type, is_nullable, column_default
from information_schema.columns where table_name='posts' order by ordinal_position`,
);
const colNames = cols.rows.map((r) => r.column_name);
console.log(` 列 (${colNames.length}): ${colNames.join(", ")}`);
check("包含 slug 列", colNames.includes("slug"));
check("包含 status 列", colNames.includes("status"));
check("包含 tags 列", colNames.includes("tags"));
check("包含 published_at 列", colNames.includes("published_at"));
check("包含 reading_minutes 列", colNames.includes("reading_minutes"));
const tagsCol = cols.rows.find((r) => r.column_name === "tags");
check("tags 为数组类型", tagsCol?.data_type === "ARRAY", `got ${tagsCol?.data_type}`);
console.log("\n── 索引 ──────────────────────────────");
const idx = await client.query(
`select indexname, indexdef from pg_indexes where tablename='posts' order by indexname`,
);
for (const row of idx.rows) {
console.log(` - ${row.indexname}`);
}
const idxNames = idx.rows.map((r) => r.indexname);
check("slug 唯一索引存在", idxNames.includes("posts_slug_unique_idx"));
check("状态+发布时间索引存在", idxNames.includes("posts_status_published_at_idx"));
check("标签 GIN 索引存在", idxNames.includes("posts_tags_gin_idx"));
const ginDef = idx.rows.find((r) => r.indexname === "posts_tags_gin_idx")?.indexdef ?? "";
check("标签索引使用 gin", /using gin/i.test(ginDef), ginDef);
console.log("\n── 约束 ──────────────────────────────");
const cons = await client.query(
`select conname, pg_get_constraintdef(oid) as def
from pg_constraint where conrelid = 'posts'::regclass order by conname`,
);
for (const row of cons.rows) {
console.log(` - ${row.conname}: ${row.def}`);
}
const conNames = cons.rows.map((r) => r.conname);
check("status CHECK 约束存在", conNames.includes("posts_status_check"));
check("slug 格式 CHECK 约束存在", conNames.includes("posts_slug_format_check"));
console.log("\n── 约束实际拦截能力(坏写入测试)──");
await expectReject(
"拒绝非法 status",
`insert into posts (slug,title,summary,content,status) values ($1,$2,$3,$4,$5)`,
["zz-test-badstatus", "t", "s", "c", "archived"],
);
await expectReject(
"拒绝非法 slug(大写/下划线)",
`insert into posts (slug,title,summary,content) values ($1,$2,$3,$4)`,
["ZZ_Test_Slug", "t", "s", "c"],
);
// 唯一约束需要先插入一行,再插入同样的 slug 才能验证
await client.query(
`insert into posts (slug,title,summary,content) values ($1,$2,$3,$4)`,
["zz-test-dup", "t", "s", "c"],
);
await expectReject(
"拒绝重复 slug",
`insert into posts (slug,title,summary,content) values ($1,$2,$3,$4)`,
["zz-test-dup", "t", "s", "c"],
);
console.log("\n── 正常写入与数组读写 ───────────────");
try {
const inserted = await client.query(
`insert into posts (slug,title,summary,content,tags,status,published_at,reading_minutes)
values ($1,$2,$3,$4,$5,$6,now(),$7) returning id, tags, status`,
[
"zz-test-ok",
"正常文章",
"摘要",
"正文",
["Next.js", "数据库"],
"published",
3,
],
);
check("合法数据可写入", inserted.rowCount === 1);
check(
"tags 数组正确回读",
Array.isArray(inserted.rows[0].tags) && inserted.rows[0].tags.length === 2,
JSON.stringify(inserted.rows[0].tags),
);
// 验证 GIN 索引可用的标签查询语法
const byTag = await client.query(
`select count(*)::int as n from posts where tags @> ARRAY[$1]::text[]`,
["Next.js"],
);
check("按标签包含查询可用", byTag.rows[0].n >= 1);
const anyTag = await client.query(
`select count(*)::int as n from posts where $1 = any(tags)`,
["数据库"],
);
check("按标签 ANY 查询可用", anyTag.rows[0].n >= 1);
} finally {
// 清理测试数据
await client.query("delete from posts where slug like 'zz-test-%'");
}
const remaining = await client.query("select count(*)::int as n from posts");
console.log(`\n清理后剩余行数: ${remaining.rows[0].n}`);
console.log("\n================================");
console.log(` 通过 ${pass} / ${pass + fail}`);
console.log("================================");
await client.end();
process.exitCode = fail > 0 ? 1 : 0;
+35
View File
@@ -0,0 +1,35 @@
/**
* 隔离测试:确认 notFound() 返回 200 的根因。
*
* 依次访问三种路径,对比状态码:
* 1. 未匹配任何路由的路径(由 Next.js 自身处理)
* 2. 匹配动态路由但数据不存在(由页面内 notFound() 处理)
* 3. 正常存在的数据
*
* 用法:node scripts/diagnose-404.mjs [baseUrl]
*/
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/$/, "");
const cases = [
["/zh/this-route-does-not-exist", "未匹配路由"],
["/zh/posts/zzz-absent", "动态路由 + notFound()"],
["/zh/posts/building-a-blog-with-nextjs", "正常存在的文章"],
["/api/posts/zzz-absent", "API 路由 404"],
];
for (const [path, label] of cases) {
const response = await fetch(`${BASE}${path}`, { redirect: "manual" });
// 检查响应头,判断是否为流式响应
const contentType = response.headers.get("content-type") ?? "";
console.log(
`${String(response.status).padStart(3)} ${label.padEnd(24)} ${path}`,
);
console.log(
` content-type=${contentType.split(";")[0]} ` +
`x-nextjs-cache=${response.headers.get("x-nextjs-cache") ?? "-"} ` +
`cache-control=${(response.headers.get("cache-control") ?? "-").slice(0, 40)}`,
);
}
+145
View File
@@ -0,0 +1,145 @@
/**
* 通过 Chrome DevTools Protocol 测量页面在移动端视口下的横向溢出。
*
* 用法:node scripts/measure-overflow.mjs <url> <width> <height>
*
* 之所以单独写这个脚本:横向溢出是纯视觉问题,单看截图难以定位到
* 具体是哪个元素撑破了布局。这里直接问浏览器要每个元素的 boundingRect。
*/
const [, , targetUrl, widthArg = "390", heightArg = "900"] = process.argv;
const width = Number(widthArg);
const height = Number(heightArg);
const wsUrl = process.env.CDP_WS;
if (!wsUrl) {
console.error("缺少 CDP_WS 环境变量(WebSocket 调试地址)");
process.exit(1);
}
const socket = new WebSocket(wsUrl);
let messageId = 0;
const pending = new Map();
/** 发送 CDP 命令并等待响应 */
function send(method, params = {}) {
const id = ++messageId;
socket.send(JSON.stringify({ id, method, params }));
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`CDP 超时: ${method}`));
}
}, 30000);
});
}
/** 在页面上下文中执行表达式并返回其值 */
async function evaluate(expression) {
const result = await send("Runtime.evaluate", {
expression,
returnByValue: true,
awaitPromise: true,
});
if (result.exceptionDetails) {
throw new Error(
`页面执行异常: ${result.exceptionDetails.text} ${
result.exceptionDetails.exception?.description ?? ""
}`,
);
}
return result.result.value;
}
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.id && pending.has(message.id)) {
const { resolve, reject } = pending.get(message.id);
pending.delete(message.id);
if (message.error) {
reject(new Error(message.error.message));
} else {
resolve(message.result);
}
}
});
socket.addEventListener("error", (error) => {
console.error("WebSocket 错误:", error.message ?? error);
process.exit(1);
});
socket.addEventListener("open", async () => {
try {
await send("Page.enable");
await send("Runtime.enable");
await send("Emulation.setDeviceMetricsOverride", {
width,
height,
deviceScaleFactor: 1,
mobile: true,
});
await send("Page.navigate", { url: targetUrl });
// 等待网络空闲 + 首屏渲染完成
await new Promise((resolve) => setTimeout(resolve, 4000));
const report = await evaluate(`(() => {
const doc = document.documentElement;
const clientWidth = doc.clientWidth;
const offenders = [];
for (const el of document.querySelectorAll("body *")) {
const rect = el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) continue;
const overflowRight = rect.right - clientWidth;
const overflowLeft = -rect.left;
if (overflowRight > 1 || overflowLeft > 1) {
offenders.push({
tag: el.tagName.toLowerCase(),
cls: String(el.className || "").slice(0, 90),
text: (el.textContent || "").trim().slice(0, 34),
left: Math.round(rect.left),
right: Math.round(rect.right),
width: Math.round(rect.width),
overflowRight: Math.round(overflowRight),
});
}
}
// 只保留"最外层"的溢出元素,避免父子的重复报告
const minimal = offenders.filter((candidate) => {
return !offenders.some(
(other) =>
other !== candidate &&
other.left <= candidate.left &&
other.right >= candidate.right &&
other.width > candidate.width,
);
});
return {
clientWidth,
scrollWidth: doc.scrollWidth,
documentOverflow: doc.scrollWidth - clientWidth,
culpritCount: minimal.length,
culprits: minimal.slice(0, 12),
};
})()`);
console.log(JSON.stringify(report, null, 2));
socket.close();
process.exit(0);
} catch (error) {
console.error("测量失败:", error.message);
socket.close();
process.exit(1);
}
});
+86
View File
@@ -0,0 +1,86 @@
/**
* 通过 CDP 在精确的视口尺寸下截图(含整页截图)。
*
* 为什么不直接用 `chrome --headless --screenshot --window-size`:
* 该模式下截图位图宽度与 CSS 视口宽度可能不一致,导致移动端截图
* 看起来"内容被截断",实际测量(document.scrollWidth)却是正常的。
* 这里用 Emulation.setDeviceMetricsOverride 明确指定视口,
* 保证截图与真实设备表现一致。
*
* 用法:CDP_WS=<ws地址> node scripts/screenshot.mjs <url> <width> <height> <输出路径> [--full]
*/
import { writeFileSync } from "node:fs";
const [, , targetUrl, widthArg, heightArg, outputPath, ...flags] = process.argv;
const width = Number(widthArg ?? 1440);
const height = Number(heightArg ?? 900);
const fullPage = flags.includes("--full");
const wsUrl = process.env.CDP_WS;
if (!wsUrl || !targetUrl || !outputPath) {
console.error(
"用法:CDP_WS=<ws地址> node scripts/screenshot.mjs <url> <width> <height> <输出路径> [--full]",
);
process.exit(1);
}
const socket = new WebSocket(wsUrl);
let messageId = 0;
const pending = new Map();
function send(method, params = {}) {
const id = ++messageId;
socket.send(JSON.stringify({ id, method, params }));
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`CDP 超时: ${method}`));
}
}, 40000);
});
}
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.id && pending.has(message.id)) {
const { resolve } = pending.get(message.id);
pending.delete(message.id);
resolve(message.result);
}
});
socket.addEventListener("open", async () => {
await send("Page.enable");
await send("Runtime.enable");
await send("Emulation.setDeviceMetricsOverride", {
width,
height,
deviceScaleFactor: 1,
mobile: width < 768,
});
await send("Page.navigate", { url: targetUrl });
// 等待字体加载与首屏动画稳定
await new Promise((resolve) => setTimeout(resolve, 4500));
// 让所有延迟出现的元素(滚动动画)进入最终状态
await send("Runtime.evaluate", {
expression:
"window.scrollTo(0, document.body.scrollHeight); window.scrollTo(0, 0);",
});
await new Promise((resolve) => setTimeout(resolve, 1200));
const screenshot = await send("Page.captureScreenshot", {
format: "png",
captureBeyondViewport: fullPage,
optimizeForSpeed: false,
});
writeFileSync(outputPath, Buffer.from(screenshot.data, "base64"));
console.log(`已保存 ${outputPath} (${width}x${height}${fullPage ? ", 整页" : ""})`);
socket.close();
process.exit(0);
});
+189
View File
@@ -0,0 +1,189 @@
/**
* 把 content/posts/ 下的 Markdown 文件导入数据库。
*
* 用途:一次性把现有的文件式内容迁移进 posts 表,
* 让数据库成为唯一内容源。
*
* 行为:
* - 幂等:按 slug upsert(存在则更新,不存在则插入)
* - 解析 YAML frontmatter 并做与 API 相同的校验
* - `draft: true` 映射为 status='draft'
* - 打印每条记录的处理结果
*
* 用法:
* node scripts/seed-posts.mjs # 导入 / 更新
* node scripts/seed-posts.mjs --dry-run # 只解析并打印,不写库
*/
import { readdirSync, readFileSync, existsSync } from "node:fs";
import { join, extname } from "node:path";
import pg from "pg";
import matter from "gray-matter";
import { config } from "dotenv";
config({ path: ".env.local" });
const DRY_RUN = process.argv.includes("--dry-run");
const POSTS_DIR = join(process.cwd(), "content", "posts");
if (!existsSync(POSTS_DIR)) {
console.error(`内容目录不存在: ${POSTS_DIR}`);
process.exit(1);
}
/** 估算阅读时长(与 lib/validation/post.ts 保持一致) */
function estimateReadingMinutes(content) {
const chineseCharacters = content.match(/[\u4e00-\u9fa5]/g)?.length ?? 0;
const latinWords =
content.replace(/[\u4e00-\u9fa5]/g, " ").match(/[A-Za-z0-9]+/g)?.length ??
0;
return Math.max(1, Math.round(chineseCharacters / 350 + latinWords / 200));
}
/** 从 frontmatter 解析出可写入数据库的记录 */
function parseFile(fileName) {
const slug = fileName.replace(/\.(mdx|md)$/i, "");
const raw = readFileSync(join(POSTS_DIR, fileName), "utf8");
const { data, content } = matter(raw);
const problems = [];
const title = typeof data.title === "string" ? data.title.trim() : "";
if (!title) problems.push("缺少 title");
let date = "";
if (data.date instanceof Date && !Number.isNaN(data.date.getTime())) {
date = data.date.toISOString().slice(0, 10);
} else if (typeof data.date === "string" && data.date.trim()) {
date = data.date.trim();
}
if (!date || Number.isNaN(new Date(date).getTime())) {
problems.push("缺少或非法的 date");
}
const summary = typeof data.summary === "string" ? data.summary.trim() : "";
if (!summary) problems.push("缺少 summary");
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
problems.push(`slug "${slug}" 不符合 ^[a-z0-9]+(-[a-z0-9]+)*$(数据库 CHECK 约束会拒绝)`);
}
if (problems.length > 0) {
return { slug, error: problems.join("; ") };
}
return {
slug,
title,
summary,
content,
tags: Array.isArray(data.tags) ? data.tags.map(String) : [],
status: data.draft === true ? "draft" : "published",
publishedAt: new Date(`${date}T00:00:00Z`),
cover: typeof data.cover === "string" ? data.cover : null,
description:
typeof data.description === "string" ? data.description : null,
readingMinutes: estimateReadingMinutes(content),
};
}
const files = readdirSync(POSTS_DIR).filter((f) =>
[".md", ".mdx"].includes(extname(f).toLowerCase()),
);
if (files.length === 0) {
console.log("content/posts/ 下没有 Markdown 文件");
process.exit(0);
}
console.log(`发现 ${files.length} 个内容文件${DRY_RUN ? "(dry-run,不写库)" : ""}\n`);
const records = [];
for (const file of files) {
const parsed = parseFile(file);
if (parsed.error) {
console.error(`✗ ${file}: ${parsed.error}`);
process.exitCode = 1;
continue;
}
records.push(parsed);
}
if (process.exitCode === 1) {
console.error("\n存在解析失败的文件,已中止导入。");
process.exit(1);
}
if (DRY_RUN) {
for (const r of records) {
console.log(
` ${r.status === "draft" ? "[草稿]" : "[已发布]"} ${r.slug} 日期=${r.publishedAt
.toISOString()
.slice(0, 10)} 标签=${r.tags.join(",") || "-"} ${r.readingMinutes}分钟`,
);
}
process.exit(0);
}
const client = new pg.Client({
connectionString: process.env.DATABASE_URL,
ssl: /localhost|127\.0\.0\.1/.test(process.env.DATABASE_URL ?? "")
? undefined
: { rejectUnauthorized: false },
});
await client.connect();
let inserted = 0;
let updated = 0;
try {
for (const r of records) {
// upsert:以 slug 为冲突键
const result = await client.query(
`insert into posts
(slug, title, summary, content, tags, status, published_at,
cover, description, reading_minutes, created_at, updated_at)
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10, now(), now())
on conflict (slug) do update set
title = excluded.title,
summary = excluded.summary,
content = excluded.content,
tags = excluded.tags,
status = excluded.status,
published_at = excluded.published_at,
cover = excluded.cover,
description = excluded.description,
reading_minutes = excluded.reading_minutes,
updated_at = now()
returning (xmax = 0) as is_insert`,
[
r.slug,
r.title,
r.summary,
r.content,
r.tags,
r.status,
r.status === "published" ? r.publishedAt : null,
r.cover,
r.description,
r.readingMinutes,
],
);
const isInsert = result.rows[0].is_insert;
if (isInsert) inserted++;
else updated++;
console.log(
` ${isInsert ? "+ 新增" : "~ 更新"} ${r.slug} (${r.status}, ${r.readingMinutes} 分钟)`,
);
}
const total = await client.query("select count(*)::int as n from posts");
console.log(`\n完成:新增 ${inserted},更新 ${updated},表内共 ${total.rows[0].n} 行`);
} catch (error) {
console.error("\n导入失败:", error.message);
process.exitCode = 1;
} finally {
await client.end().catch(() => {});
}
+306
View File
@@ -0,0 +1,306 @@
/**
* API 端到端自测。
*
* 覆盖完整 CRUD 链路 + 边界与错误分支:
* - 列表/分页/标签筛选/草稿隔离
* - 详情读取(含 404)
* - 创建(含校验失败、slug 冲突)
* - 更新(部分更新、slug 改名、草稿↔发布流转)
* - 删除(含重复删除 404)
*
* 所有测试数据以 `zz-` 前缀创建,结束后清理,不影响真实内容。
*
* 用法:node scripts/test-api.mjs [baseUrl]
* 默认 baseUrl = http://localhost:3000
*/
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/$/, "");
const PREFIX = "zz-test";
let pass = 0;
let fail = 0;
const failures = [];
function check(name, condition, detail = "") {
if (condition) {
pass++;
console.log(` [PASS] ${name}`);
} else {
fail++;
failures.push(`${name} ${detail}`);
console.log(` [FAIL] ${name} ${detail}`);
}
}
/** 发起请求并返回 { status, body } */
async function call(method, path, body) {
const response = await fetch(`${BASE}${path}`, {
method,
headers: body ? { "content-type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text();
let parsed = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
return { status: response.status, body: parsed };
}
console.log(`目标: ${BASE}\n`);
// ─────────────────────────────────────────────
console.log("── 0. 健康检查 ──────────────────────");
const health = await call("GET", "/api/health");
check("GET /api/health 返回 200", health.status === 200, `got ${health.status}`);
check("健康状态为 ok", health.body?.data?.status === "ok", JSON.stringify(health.body?.data));
check(
"返回数据库延迟",
typeof health.body?.data?.database?.latencyMs === "number",
);
// ─────────────────────────────────────────────
console.log("\n── 1. 列表与分页 ────────────────────");
const list = await call("GET", "/api/posts");
check("GET /api/posts 返回 200", list.status === 200, `got ${list.status}`);
check("响应含 data 数组", Array.isArray(list.body?.data));
check("响应含 meta.total", typeof list.body?.meta?.total === "number");
check("响应含 meta.hasMore", typeof list.body?.meta?.hasMore === "boolean");
check(
"默认只返回已发布文章",
list.body.data.every((p) => p.draft === false),
JSON.stringify(list.body.data?.map((p) => [p.slug, p.draft])),
);
check(
"按日期倒序",
list.body.data.every(
(post, index, array) =>
index === 0 || new Date(array[index - 1].date) >= new Date(post.date),
),
JSON.stringify(list.body.data?.map((p) => p.date)),
);
const limited = await call("GET", "/api/posts?limit=1");
check("limit=1 只返回 1 条", limited.body?.data?.length === 1);
check("limit 反映在 meta", limited.body?.meta?.limit === 1);
const badLimit = await call("GET", "/api/posts?limit=999");
check("limit 超限返回 400", badLimit.status === 400, `got ${badLimit.status}`);
check(
"校验错误含 code",
badLimit.body?.error?.code === "VALIDATION_ERROR",
JSON.stringify(badLimit.body?.error),
);
const badOffset = await call("GET", "/api/posts?offset=-1");
check("offset 负数返回 400", badOffset.status === 400, `got ${badOffset.status}`);
// ─────────────────────────────────────────────
console.log("\n── 2. 标签筛选与聚合 ────────────────");
const tags = await call("GET", "/api/tags");
check("GET /api/tags 返回 200", tags.status === 200);
check("标签为数组", Array.isArray(tags.body?.data));
check(
"标签含 count 字段",
tags.body.data.every((t) => typeof t.tag === "string" && typeof t.count === "number"),
JSON.stringify(tags.body?.data?.[0]),
);
if (tags.body.data.length > 0) {
const firstTag = tags.body.data[0].tag;
const filtered = await call("GET", `/api/posts?tag=${encodeURIComponent(firstTag)}`);
check(`按标签 "${firstTag}" 筛选返回 200`, filtered.status === 200);
check(
"筛选结果都含该标签",
filtered.body.data.every((p) => p.tags.includes(firstTag)),
JSON.stringify(filtered.body.data?.map((p) => p.tags)),
);
check("筛选结果非空", filtered.body.data.length > 0);
}
// ─────────────────────────────────────────────
console.log("\n── 3. 创建文章 ──────────────────────");
const createPayload = {
slug: `${PREFIX}-create`,
title: "测试文章:创建",
summary: "这是一篇用于接口自测的文章摘要。",
content: "## 标题\n\n正文内容,包含 `行内代码`。\n\n```ts\nconst x: number = 1;\n```\n",
tags: ["测试", "API"],
status: "published",
};
// 先清理可能残留的同名数据
await call("DELETE", `/api/posts/${PREFIX}-create`);
const createRes = await call("POST", "/api/posts", createPayload);
check("POST /api/posts 返回 201", createRes.status === 201, `got ${createRes.status}`);
check("返回含 slug", createRes.body?.data?.slug === createPayload.slug);
check("返回含正文", typeof createRes.body?.data?.content === "string");
check("阅读时长已计算", createRes.body?.data?.readingMinutes >= 1);
check("标签已保存", Array.isArray(createRes.body?.data?.tags) && createRes.body.data.tags.length === 2);
// 重复 slug → 409
const dupRes = await call("POST", "/api/posts", createPayload);
check("重复 slug 返回 409", dupRes.status === 409, `got ${dupRes.status}`);
check("冲突错误码正确", dupRes.body?.error?.code === "CONFLICT", JSON.stringify(dupRes.body?.error));
// 校验失败 → 400
const invalidRes = await call("POST", "/api/posts", {
slug: "Invalid_Slug",
title: "",
summary: "",
content: "",
});
check("非法输入返回 400", invalidRes.status === 400, `got ${invalidRes.status}`);
check("含字段级 details", Array.isArray(invalidRes.body?.error?.details));
check(
"details 指出 slug 问题",
invalidRes.body.error.details.some((d) => d.field === "slug"),
JSON.stringify(invalidRes.body?.error?.details),
);
// 非 JSON 请求体 → 400
const badJson = await fetch(`${BASE}/api/posts`, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{ not json",
});
check("非法 JSON 返回 400", badJson.status === 400, `got ${badJson.status}`);
// ─────────────────────────────────────────────
console.log("\n── 4. 详情读取 ──────────────────────");
const detail = await call("GET", `/api/posts/${PREFIX}-create`);
check("GET 详情返回 200", detail.status === 200, `got ${detail.status}`);
check("详情含正文", typeof detail.body?.data?.content === "string");
check("详情标题正确", detail.body?.data?.title === createPayload.title);
const missing = await call("GET", "/api/posts/zz-does-not-exist");
check("不存在的 slug 返回 404", missing.status === 404, `got ${missing.status}`);
check("404 错误码正确", missing.body?.error?.code === "NOT_FOUND");
// ─────────────────────────────────────────────
console.log("\n── 5. 更新文章 ──────────────────────");
const patchRes = await call("PATCH", `/api/posts/${PREFIX}-create`, {
title: "测试文章:已更新标题",
});
check("PATCH 返回 200", patchRes.status === 200, `got ${patchRes.status}`);
check("标题已更新", patchRes.body?.data?.title === "测试文章:已更新标题");
check(
"未提供的字段保持不变",
patchRes.body?.data?.summary === createPayload.summary,
);
check("slug 未被意外修改", patchRes.body?.data?.slug === PREFIX + "-create");
// 部分更新正文 → 阅读时长应重算
const longContent = "中".repeat(1400);
const contentPatch = await call("PATCH", `/api/posts/${PREFIX}-create`, {
content: longContent,
});
check(
"更新正文后重算阅读时长",
contentPatch.body?.data?.readingMinutes >= 3,
`got ${contentPatch.body?.data?.readingMinutes}`,
);
// 空 body → 400
const emptyPatch = await call("PATCH", `/api/posts/${PREFIX}-create`, {});
check("空更新返回 400", emptyPatch.status === 400, `got ${emptyPatch.status}`);
// 更新不存在的资源 → 404
const patchMissing = await call("PATCH", "/api/posts/zz-nope", { title: "x" });
check("更新不存在的文章返回 404", patchMissing.status === 404, `got ${patchMissing.status}`);
// slug 改名
const renamed = await call("PATCH", `/api/posts/${PREFIX}-create`, {
slug: `${PREFIX}-renamed`,
});
check("可以修改 slug", renamed.status === 200 && renamed.body?.data?.slug === `${PREFIX}-renamed`);
const oldGone = await call("GET", `/api/posts/${PREFIX}-create`);
check("旧 slug 已不可访问", oldGone.status === 404, `got ${oldGone.status}`);
// ─────────────────────────────────────────────
console.log("\n── 6. 草稿流转与隔离 ────────────────");
const draftSlug = `${PREFIX}-draft`;
await call("DELETE", `/api/posts/${draftSlug}`);
const draftCreate = await call("POST", "/api/posts", {
slug: draftSlug,
title: "测试草稿",
summary: "草稿摘要",
content: "草稿正文",
status: "draft",
});
check("创建草稿返回 201", draftCreate.status === 201, `got ${draftCreate.status}`);
check("草稿 draft=true", draftCreate.body?.data?.draft === true);
const draftInList = await call("GET", "/api/posts");
check(
"草稿不出现在默认列表中",
!draftInList.body.data.some((p) => p.slug === draftSlug),
);
const draftDetail = await call("GET", `/api/posts/${draftSlug}`);
check("草稿详情返回 404", draftDetail.status === 404, `got ${draftDetail.status}`);
const draftIncluded = await call("GET", "/api/posts?includeDrafts=true");
check(
"includeDrafts=true 时可见草稿",
draftIncluded.body.data.some((p) => p.slug === draftSlug),
);
const draftOnly = await call("GET", "/api/posts?status=draft");
check(
"status=draft 只返回草稿",
draftOnly.body.data.length > 0 && draftOnly.body.data.every((p) => p.draft === true),
);
// 草稿 → 发布
const published = await call("PATCH", `/api/posts/${draftSlug}`, {
status: "published",
});
check("草稿可发布", published.status === 200 && published.body?.data?.draft === false);
check("发布后有日期", Boolean(published.body?.data?.date));
const nowVisible = await call("GET", `/api/posts/${draftSlug}`);
check("发布后可正常访问", nowVisible.status === 200, `got ${nowVisible.status}`);
// 已发布 → 草稿
const reDraft = await call("PATCH", `/api/posts/${draftSlug}`, { status: "draft" });
check("可退回草稿", reDraft.body?.data?.draft === true);
const hiddenAgain = await call("GET", `/api/posts/${draftSlug}`);
check("退回草稿后不可访问", hiddenAgain.status === 404, `got ${hiddenAgain.status}`);
// ─────────────────────────────────────────────
console.log("\n── 7. 删除 ──────────────────────────");
const del = await call("DELETE", `/api/posts/${PREFIX}-renamed`);
check("DELETE 返回 204", del.status === 204, `got ${del.status}`);
const delAgain = await call("DELETE", `/api/posts/${PREFIX}-renamed`);
check("重复删除返回 404", delAgain.status === 404, `got ${delAgain.status}`);
const afterDelete = await call("GET", `/api/posts/${PREFIX}-renamed`);
check("删除后不可访问", afterDelete.status === 404, `got ${afterDelete.status}`);
// 清理草稿
await call("DELETE", `/api/posts/${draftSlug}`);
// ─────────────────────────────────────────────
console.log("\n── 8. 清理确认 ──────────────────────");
const finalList = await call("GET", "/api/posts?includeDrafts=true&limit=100");
const leftovers = finalList.body.data.filter((p) => p.slug.startsWith(PREFIX));
check("测试数据已清理干净", leftovers.length === 0, JSON.stringify(leftovers.map((p) => p.slug)));
check("真实文章仍在", finalList.body.data.length >= 3, `剩余 ${finalList.body.data.length} 篇`);
console.log("\n================================");
console.log(` 通过 ${pass} / ${pass + fail}`);
if (failures.length > 0) {
console.log(" 失败项:");
for (const f of failures) console.log(` - ${f}`);
}
console.log("================================");
process.exitCode = fail > 0 ? 1 : 0;
+127
View File
@@ -0,0 +1,127 @@
/**
* 校验规则的单元自测(不依赖服务器与数据库)。
*
* 重点验证容易出错的地方:
* - PATCH 空对象必须被拒绝(`.partial()` 不会移除 `.default()`)
* - slug 格式、标签去重、日期转换
* - 列表查询参数的类型强制与边界
*
* 用法:node scripts/test-validation.mjs
*/
import {
createPostSchema,
updatePostSchema,
listPostsQuerySchema,
estimateReadingMinutes,
} from "../lib/validation/post.ts";
let pass = 0;
let fail = 0;
function check(name, condition, detail = "") {
if (condition) {
pass++;
console.log(` [PASS] ${name}`);
} else {
fail++;
console.log(` [FAIL] ${name} ${detail}`);
}
}
console.log("── createPostSchema ─────────────────");
const validCreate = createPostSchema.safeParse({
slug: "hello-world",
title: "标题",
summary: "摘要",
content: "正文",
});
check("合法输入通过", validCreate.success);
check("status 默认为 draft", validCreate.data?.status === "draft");
check("tags 默认为空数组", Array.isArray(validCreate.data?.tags) && validCreate.data.tags.length === 0);
const badSlug = createPostSchema.safeParse({
slug: "Hello_World",
title: "t",
summary: "s",
content: "c",
});
check("非法 slug 被拒绝", !badSlug.success);
const emptyTitle = createPostSchema.safeParse({
slug: "ok",
title: " ",
summary: "s",
content: "c",
});
check("纯空白标题被拒绝", !emptyTitle.success);
const dupTags = createPostSchema.safeParse({
slug: "ok",
title: "t",
summary: "s",
content: "c",
tags: ["a", "a", "b"],
});
check("标签自动去重", dupTags.success && dupTags.data.tags.length === 2, JSON.stringify(dupTags.data?.tags));
console.log("\n── updatePostSchema(关键回归点)──");
const emptyUpdate = updatePostSchema.safeParse({});
check("空对象被拒绝(回归:不得因 default 而通过)", !emptyUpdate.success, JSON.stringify(emptyUpdate.data));
const titleOnly = updatePostSchema.safeParse({ title: "新标题" });
check("只传 title 通过", titleOnly.success);
check("未传的字段不出现在结果中", !("summary" in (titleOnly.data ?? {})), JSON.stringify(titleOnly.data));
const tagsOnly = updatePostSchema.safeParse({ tags: ["x"] });
check("只传 tags 通过", tagsOnly.success);
const invalidStatus = updatePostSchema.safeParse({ status: "archived" });
check("非法 status 被拒绝", !invalidStatus.success);
const slugUpdate = updatePostSchema.safeParse({ slug: "new-slug" });
check("可单独更新 slug", slugUpdate.success);
console.log("\n── listPostsQuerySchema ─────────────");
const defaults = listPostsQuerySchema.safeParse({});
check("默认 limit=20", defaults.data?.limit === 20);
check("默认 offset=0", defaults.data?.offset === 0);
check("默认 includeDrafts=false", defaults.data?.includeDrafts === false);
const coerced = listPostsQuerySchema.safeParse({ limit: "5", offset: "10" });
check("字符串数字被强制转换", coerced.data?.limit === 5 && coerced.data?.offset === 10);
const tooBig = listPostsQuerySchema.safeParse({ limit: "101" });
check("limit 超上限被拒绝", !tooBig.success);
const zero = listPostsQuerySchema.safeParse({ limit: "0" });
check("limit=0 被拒绝", !zero.success);
const negative = listPostsQuerySchema.safeParse({ offset: "-5" });
check("负 offset 被拒绝", !negative.success);
const drafts = listPostsQuerySchema.safeParse({ includeDrafts: "true" });
check("includeDrafts 字符串转布尔", drafts.data?.includeDrafts === true);
const badStatus = listPostsQuerySchema.safeParse({ status: "nope" });
check("非法 status 被拒绝", !badStatus.success);
console.log("\n── estimateReadingMinutes ───────────");
check("空内容至少 1 分钟", estimateReadingMinutes("") === 1);
check("短内容为 1 分钟", estimateReadingMinutes("hello world") === 1);
check(
"长中文内容时长增加",
estimateReadingMinutes("中".repeat(1400)) >= 3,
`got ${estimateReadingMinutes("中".repeat(1400))}`,
);
check(
"长英文内容时长增加",
estimateReadingMinutes("word ".repeat(1000)) >= 4,
`got ${estimateReadingMinutes("word ".repeat(1000))}`,
);
console.log("\n================================");
console.log(` 通过 ${pass} / ${pass + fail}`);
console.log("================================");
process.exitCode = fail > 0 ? 1 : 0;
+143
View File
@@ -0,0 +1,143 @@
---
name: fullstack-task
description: 辅助 AI 完成全栈开发任务,包括需求拆解、技术选型、前后端实现、数据库设计、API 设计、测试、部署与验收。当用户要求开发功能、修复全栈 bug、设计接口、写数据库模型、做代码审查、搭建项目或交付一个可运行的端到端功能时使用本 skill。
---
# 全栈任务助手
## 何时使用
在以下场景加载本 skill:
- 用户要求“实现一个功能”且涉及前端 + 后端
- 用户要求设计 API、数据库模型、组件结构
- 用户要求从零搭建项目或脚手架
- 用户要求排查跨层 bug(前端 → API → DB)
- 用户要求代码审查、重构、性能优化
- 用户要求生成可运行的端到端交付物
## 核心原则
1. **先澄清,再动手**:需求模糊时必须先确认范围、约束、验收标准。
2. **端到端思考**:任何功能都要同时考虑 UI、API、数据、错误、测试、部署。
3. **最小可用优先**:先跑通主链路,再优化扩展性和性能。
4. **显式优于隐式**:类型、契约、边界、错误码必须明确。
5. **可运行即交付**:给出的代码要能跑,不能是伪代码,除非用户明确要求。
6. **不臆造**:不确定的库版本、API 行为、字段名,要标注或询问。
## 工作流程
### 第 1 步:需求拆解
输出一份简短 spec,包含:
- 目标:这个功能解决什么问题
- 用户故事:谁在什么场景下做什么
- 范围:做什么 / 不做什么
- 验收标准:可测试的条目
- 约束:技术栈、兼容性、性能、时间
如果信息不足,最多问 3 个关键问题,其余用合理默认值并标注。
### 第 2 步:技术方案
按需确定:
- 技术栈:参考 `references/stack-defaults.md`
- 数据模型:实体、字段、关系、索引
- API 契约:路径、方法、请求、响应、错误码
- 前端结构:页面、组件、状态、数据获取方式
- 部署方式:环境变量、构建、托管
### 第 3 步:任务拆分
拆成可独立验证的任务,每个任务:
- 有明确输入输出
- 有验收方式
- 尽量 30 分钟内可完成
- 标注依赖顺序
### 第 4 步:实现
按 `数据 → API → 前端 → 联调` 的顺序推进。
实现要求:
- 类型完整,避免 `any`
- 错误处理明确
- 输入校验(前后端都做)
- 环境变量集中管理
- 不硬编码密钥
- 关键路径加日志
- 命名清晰,避免缩写
### 第 5 步:验证
至少覆盖:
- 主链路可跑通
- 边界:空值、超长、非法输入、并发
- 错误:网络失败、DB 失败、权限不足
- 构建:`build` / `lint` / `typecheck` 通过
- 测试:关键逻辑有单测,主链路有集成测试
### 第 6 步:交付
输出:
- 变更文件清单
- 运行方式
- 环境变量说明
- 已知限制
- 后续建议
## 参考文档
按需加载,不要一次性全读:
- `references/stack-defaults.md`:默认技术栈与选型
- `references/api-design.md`:REST / RPC 设计规范
- `references/database.md`:模型、迁移、索引、事务
- `references/frontend.md`:组件、状态、数据获取、可访问性
- `references/testing.md`:测试策略与用例模板
## 模板
- `templates/feature-spec.md`:功能 spec 模板
- `templates/pr-checklist.md`:PR 自检清单
## 检查清单
交付前必须自查:
- [ ] 需求有明确验收标准
- [ ] 数据模型有主键、索引、时间戳
- [ ] API 有输入校验和错误码
- [ ] 前端有加载态、空态、错误态
- [ ] 敏感信息走环境变量
- [ ] 主链路可端到端跑通
- [ ] 构建、lint、typecheck 通过
- [ ] 有最小测试覆盖
- [ ] README 或交付说明完整
## 反模式
避免以下行为:
- 直接写代码不澄清需求
- 一次改动跨太多模块,难以 review
- 前端和后端契约不一致
- 忽略错误态和边界
- 把密钥写进代码
- 只测 happy path
- 交付无法运行的片段
- 编造不存在的 API 或字段
## 输出风格
- 中文为主,术语保留英文
- 先结论后细节
- 代码块标注语言
- 长任务分阶段汇报
- 不确定处显式标注 `[待确认]`
@@ -0,0 +1,55 @@
# API 设计规范
## 风格
- 默认 REST
- 复杂查询可用 RPC 风格
- 内部服务可用 tRPC
## 路径
- 资源用复数:`/api/posts`
- 嵌套不超过两层:`/api/posts/:id/comments`
- 动作用子资源或 POST:`/api/posts/:id/publish`
## 方法
- GET 查询
- POST 创建
- PATCH 局部更新
- PUT 全量替换
- DELETE 删除
## 请求
- Body 用 JSON
- 分页:`?page=1&pageSize=20` 或 cursor
- 排序:`?sort=-createdAt`
- 过滤:`?status=published&tag=nextjs`
## 响应
成功:
```json
{ "data": { ... }, "meta": { ... } }
```
## 状态码
- 200 -> 成功
- 201 -> 创建成功
- 204 -> 删除成功
- 400 -> 参数错误
- 401 -> 未登录
- 403 -> 无权限
- 404 -> 不存在
- 409 -> 冲突
- 422 -> 业务校验失败
- 500 -> 服务器错误
## 校验
- 所有输入用 Zod 校验
- 错误信息可读,不暴露内部细节
@@ -0,0 +1,46 @@
---
## references/database.md
```markdown
# 数据库设计规范
## 通用字段
- `id`:主键,cuid / uuid / 自增
- `createdAt`:创建时间
- `updatedAt`:更新时间
- `deletedAt`:软删除(可选)
## 建模
- 先画实体关系,再写 schema
- 一对多用外键
- 多对多用关联表
- 避免过宽表,必要时拆表
## 索引
- 外键加索引
- 高频查询字段加索引
- 复合索引注意顺序
- 唯一约束用 unique
## 迁移
- 每次 schema 变更生成迁移文件
- 迁移必须可回滚
- 生产环境先备份
## 事务
- 多表写入用事务
- 保持事务短小
- 避免在事务里做网络请求
## 查询
- 避免 N+1
- 列表接口必须分页
- 大字段按需 select
```
@@ -0,0 +1,43 @@
# 前端规范
## 组件
- 一个组件一个职责
- 展示组件与容器组件分离
- props 用 TypeScript 定义
- 避免超过 200 行,超出则拆
## 状态
- 局部状态用 useState
- 跨组件用 Context 或状态库
- 服务端状态用 TanStack Query
- 避免把服务端数据放进全局 store
## 数据获取
- Next.js 优先 Server Components
- 客户端请求要有 loading / error / empty
- 请求失败要有重试或提示
## 表单
- React Hook Form + Zod
- 前端校验 + 后端校验
- 提交中禁用按钮
- 错误定位到字段
## 可访问性
- 语义化标签
- 图片有 alt
- 表单有 label
- 键盘可操作
- 颜色对比度达标
## 性能
- 图片用 next/image
- 路由级代码分割
- 长列表虚拟化
- 避免不必要的 re-render
@@ -0,0 +1,39 @@
# 默认技术栈
除非用户指定,按以下默认值执行。
## 前端
- 框架:Next.js(App Router)或 React + Vite
- 语言:TypeScript
- 样式:Tailwind CSS
- 状态:React 内置 + TanStack Query
- 表单:React Hook Form + Zod
## 后端
- 运行时:Node.js
- 框架:Next.js Route Handlers / Hono / Express
- 校验:Zod
- 鉴权:Session + HttpOnly Cookie,或 JWT
- 日志:pino
## 数据库
- 默认:PostgreSQL
- ORM:Prisma 或 Drizzle
- 轻量场景:SQLite
- 缓存:Redis(可选)
## 部署
- 前端 / 全栈:Vercel
- 容器:Docker + Fly.io / Railway
- 静态:Cloudflare Pages
## 工具
- 包管理:pnpm
- Lint:ESLint + Prettier
- 测试:Vitest + Playwright
- CI:GitHub Actions
@@ -0,0 +1,37 @@
# 测试规范
## 分层
- 单元测试:纯函数、工具、校验
- 集成测试:API、DB、服务
- 端到端:主链路
## 工具
- Vitest:单元 + 集成
- Playwright:端到端
- MSW:mock 网络
## 用例设计
每个功能至少覆盖:
- happy path
- 空值 / 缺字段
- 非法输入
- 权限不足
- 资源不存在
- 并发或重复提交
## 命名
- describe("createPost", () => {
- it("creates a post with valid input", ...)
- it("rejects empty title", ...)
- })
## CI
- push 触发 lint + typecheck + test
- 主分支保护
- 失败阻断合并
@@ -0,0 +1,23 @@
set -e
echo "== 全栈项目自检 =="
echo "[1/6] 检查 package.json"
test -f package.json && echo "OK" || echo "缺少 package.json"
echo "[2/6] 检查 .env.example"
test -f .env.example && echo "OK" || echo "建议添加 .env.example"
echo "[3/6] 检查 README"
test -f README.md && echo "OK" || echo "缺少 README.md"
echo "[4/6] 类型检查"
pnpm tsc --noEmit || echo "类型检查失败"
echo "[5/6] Lint"
pnpm lint || echo "Lint 失败"
echo "[6/6] 构建"
pnpm build || echo "构建失败"
echo "== 完成 =="
@@ -0,0 +1,50 @@
# 功能 Spec:<名称>
## 目标
<一句话说明>
## 用户故事
- 作为 <角色>,我希望 <行为>,以便 <价值>
## 范围
### 做
- ...
### 不做
- ...
## 验收标准
- [ ] ...
- [ ] ...
## 数据模型
| 字段 | 类型 | 说明 |
| ---- | ---- | ---- |
| | | |
## API
| 方法 | 路径 | 说明 |
| ---- | ---- | ---- |
| | | |
## 前端
- 页面:
- 组件:
- 状态:
## 风险
- ...
## 任务拆分
- [ ] ...
@@ -0,0 +1,13 @@
# PR 自检清单
- [ ] 需求有对应 spec 或 issue
- [ ] 变更范围聚焦,无无关改动
- [ ] 类型完整,无 any
- [ ] 输入校验完整
- [ ] 错误处理完整
- [ ] 敏感信息走环境变量
- [ ] 主链路手动验证通过
- [ ] lint / typecheck / build 通过
- [ ] 测试通过,新逻辑有测试
- [ ] README 或注释已更新
- [ ] 已知限制已说明
+89
View File
@@ -0,0 +1,89 @@
# 个人博客功能文档
> 版本:v0.1.0
> 阶段:第一版
> 技术:Next.js
> 目标:完成一个可写、可读、可部署的个人博客最小版本。
---
## 一、第一版必须完成的功能
### 1. 内容管理
- [ ] 支持使用 Markdown / MDX 编写文章
- [ ] 支持文章元信息:标题、日期、摘要、标签、草稿状态
- [ ] 支持草稿文章不展示
- [ ] 支持文章按日期倒序排列
### 2. 首页
- [ ] 展示站点名称和简介
- [ ] 展示文章列表
- [ ] 文章卡片展示:标题、日期、摘要、标签
- [ ] 点击文章卡片进入文章详情页
### 3. 文章详情页
- [ ] 展示文章标题、日期、标签
- [ ] 渲染 Markdown / MDX 正文
- [ ] 支持代码块高亮
- [ ] 支持基础排版:标题、列表、引用、链接、图片
- [ ] 提供返回首页入口
### 4. 关于页
- [ ] 展示个人简介
- [ ] 展示联系方式或社交链接
### 5. 404 页面
- [ ] 自定义 404 页面
- [ ] 提供返回首页入口
### 6. SEO
- [ ] 每个页面有 title 和 description
- [ ] 文章页有独立 SEO 信息
- [ ] 生成 sitemap.xml
- [ ] 生成 robots.txt
### 7. 基础体验
- [ ] 移动端和桌面端响应式
- [ ] 页面结构语义化
- [ ] 图片支持 alt 文本
### 8. 部署
- [ ] 支持部署到 Vercel
- [ ] 线上可访问
- [ ] 支持自定义域名和 HTTPS
- [ ] `npm run build` 成功
---
## 二、第一版不包含的功能
- 后台管理
- 数据库
- 用户登录
- 评论
- 搜索
- 统计
- 分页
- 标签页 / 归档页 / RSS
- 暗黑模式
- 多语言
- 广告 / 付费 / 会员
---
## 三、第一版完成标准
- [ ] 至少 3 篇非草稿文章
- [ ] 首页、文章页、关于页、404 正常
- [ ] 文章可正常阅读,代码高亮生效
- [ ] 移动端可正常阅读
- [ ] 线上部署可访问
- [ ] 构建无错误
+232
View File
@@ -0,0 +1,232 @@
# 第三方库文档地址汇总
> 本文件汇总项目中使用到的全部第三方库及其官方文档地址。
> 版本号与 `package.json` 保持一致,便于查阅对应版本的 API。
---
## 一、核心框架
| 库 | 版本 | 用途 | 文档地址 |
| --- | --- | --- | --- |
| Next.js | 16.3.8 | React 全栈框架(App Router、SSG、字体优化、SEO 元数据) | https://nextjs.org/docs |
| React | 19.2.8 | UI 库(Server Components / Client Components) | https://react.dev |
| React DOM | 19.2.8 | React 的 DOM 渲染器 | https://react.dev/reference/react-dom |
| TypeScript | 5.x | 类型系统 | https://www.typescriptlang.org/docs/ |
### Next.js 相关子文档
| 主题 | 文档地址 |
| --- | --- |
| App Router 路由约定 | https://nextjs.org/docs/app/building-your-application/routing |
| Server / Client Components | https://nextjs.org/docs/app/building-your-application/rendering/server-components |
| `next/font` 字体优化(自托管) | https://nextjs.org/docs/app/api-reference/components/font |
| `generateMetadata` 与 SEO | https://nextjs.org/docs/app/api-reference/functions/generate-metadata |
| `sitemap.xml` 生成 | https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap |
| `robots.txt` 生成 | https://nextjs.org/docs/app/api-reference/file-conventions/metadata/robots |
| `not-found` 404 页面 | https://nextjs.org/docs/app/api-reference/file-conventions/not-found |
| `loading` 加载态 | https://nextjs.org/docs/app/api-reference/file-conventions/loading |
| `error` 错误边界 | https://nextjs.org/docs/app/api-reference/file-conventions/error |
| `next/image` 图片优化 | https://nextjs.org/docs/app/api-reference/components/image |
| `middleware` → `proxy` 迁移说明 | https://nextjs.org/docs/messages/middleware-to-proxy |
---
## 二、内容与 MDX 渲染
| 库 | 版本 | 用途 | 文档地址 |
| --- | --- | --- | --- |
| next-mdx-remote | 6.0.0 | 在 Server Component 中编译并渲染 MDX | https://github.com/hashicorp/next-mdx-remote |
| gray-matter | 4.0.3 | 解析 Markdown 的 YAML frontmatter | https://github.com/jonschlinkert/gray-matter |
| remark-gfm | 4.0.1 | 支持 GitHub 风格 Markdown(表格、任务列表、删除线) | https://github.com/remarkjs/remark-gfm |
| rehype-slug | 6.0.0 | 为标题自动生成锚点 id(目录跳转依赖) | https://github.com/rehypejs/rehype-slug |
| rehype-autolink-headings | 7.1.0 | 为标题生成可点击的锚点链接 | https://github.com/rehypejs/rehype-autolink-headings |
---
## 三、代码高亮
| 库 | 版本 | 用途 | 文档地址 |
| --- | --- | --- | --- |
| Shiki | 4.5.0 | 基于 TextMate 语法的代码高亮引擎(构建期高亮,运行时零 JS) | https://shiki.style |
| rehype-pretty-code | 0.14.5 | 将 Shiki 接入 rehype 管线,提供行号、高亮行等能力 | https://rehype-pretty.pages.dev |
### Shiki 相关子文档
| 主题 | 文档地址 |
| --- | --- |
| 主题列表与预览 | https://shiki.style/themes |
| 支持的语言 | https://shiki.style/languages |
| 双主题(明暗)配置 | https://shiki.style/guide/dual-themes |
> 本项目使用 `github-dark-default` 主题,在 `{colors.surface-code}`(`#1c1c1e`)深色底上对比度良好。
---
## 四、UI 与样式
| 库 | 版本 | 用途 | 文档地址 |
| --- | --- | --- | --- |
| Tailwind CSS | 4.x | 原子化 CSS 框架(`@theme` 声明设计令牌) | https://tailwindcss.com/docs |
| @tailwindcss/postcss | 4.x | Tailwind v4 的 PostCSS 插件 | https://tailwindcss.com/docs/installation/framework-guides/nextjs |
| class-variance-authority | 0.7.1 | 管理组件样式变体(variant / size) | https://cva.style/docs |
| clsx | 2.1.1 | 条件拼接 className | https://github.com/lukeed/clsx |
| tailwind-merge | 3.7.0 | 消解冲突的 Tailwind 类名 | https://github.com/dcastil/tailwind-merge |
| lucide-react | 1.51.0 | 图标库 | https://lucide.dev/icons |
### Tailwind v4 相关子文档
| 主题 | 文档地址 |
| --- | --- |
| `@theme` 定义设计令牌 | https://tailwindcss.com/docs/theme |
| 自定义工具类 `@utility` | https://tailwindcss.com/docs/adding-custom-styles#customizing-your-theme |
| 响应式断点 | https://tailwindcss.com/docs/responsive-design |
| 深色模式(本项目未启用) | https://tailwindcss.com/docs/dark-mode |
> **lucide-react 注意事项**:v1.x 起已移除品牌图标(`Github`、`Twitter` 等)。
> 如需品牌图标,请使用 https://simpleicons.org 或 https://github.com/lobehub/lobe-icons 。
---
## 五、动画
| 库 | 版本 | 用途 | 文档地址 |
| --- | --- | --- | --- |
| Motion | 14.0.0 | 动画库(Framer Motion 的继任者),用于入场动画与阅读进度条 | https://motion.dev/docs |
### Motion 相关子文档
| 主题 | 文档地址 |
| --- | --- |
| React 快速上手 | https://motion.dev/docs/react-quick-start |
| `useScroll` 滚动关联动画 | https://motion.dev/docs/react-use-scroll |
| `useSpring` 弹簧平滑 | https://motion.dev/docs/react-use-spring |
| `useReducedMotion` 无障碍 | https://motion.dev/docs/react-use-reduced-motion |
> **重要实现说明**:本项目**刻意未使用** `whileInView`。
> 它会在服务端输出 `opacity: 0`,只有 JS 执行且元素滚入视口后才显示;
> 一旦 JS 加载失败,文章列表将永久不可见,且不利于 SEO。
> 因此改为「渐进增强」:服务端输出可见 HTML,客户端仅在元素位于视口下方时叠加一次过渡动画。
> 参见 `components/motion/reveal.tsx` 与 `components/motion/motion-item.tsx`。
---
## 六、数据库与接口
| 库 | 版本 | 用途 | 文档地址 |
| --- | --- | --- | --- |
| node-postgres (`pg`) | 8.23.1 | PostgreSQL 驱动(TCP 连接池) | https://node-postgres.com |
| Drizzle ORM | 0.45.3 | 类型安全的 SQL 查询构建器 | https://orm.drizzle.team/docs/overview |
| Drizzle Kit | 0.31.11 | 迁移生成与执行、Studio | https://orm.drizzle.team/docs/kit-overview |
| Zod | 4.6.5 | 请求参数与请求体的运行时校验 | https://zod.dev |
### 数据库相关子文档
| 主题 | 文档地址 |
| --- | --- |
| node-postgres 连接池 | https://node-postgres.com/apis/pool |
| node-postgres SSL 配置 | https://node-postgres.com/features/ssl |
| Drizzle + node-postgres 接入 | https://orm.drizzle.team/docs/get-started-postgresql |
| Drizzle Schema 定义(pgTable) | https://orm.drizzle.team/docs/sql-schema-declaration |
| Drizzle 索引定义 | https://orm.drizzle.team/docs/indexes-constraints |
| Drizzle 查询(select/where/orderBy) | https://orm.drizzle.team/docs/data-querying |
| Drizzle 迁移(generate / migrate) | https://orm.drizzle.team/docs/migrations |
| Drizzle Studio | https://orm.drizzle.team/docs/studio |
| PostgreSQL 错误码对照 | https://www.postgresql.org/docs/current/errcodes-appendix.html |
| Zod 对象 schema 与 refine | https://zod.dev/api |
| PostgreSQL 数组类型与 GIN 索引 | https://www.postgresql.org/docs/current/indexes-types.html |
> **驱动选择说明**:项目**不使用** `@neondatabase/serverless` 的 `neon-http` 驱动。
> 它走 Neon 的 HTTP 代理端点而非 PostgreSQL TCP 协议,无法连接本地/自建 Postgres。
> 改用 `pg` 后,本地与 Neon / Supabase / Railway 等标准 Postgres 都能直连,
> 部署时只需更换连接串。
>
> 若确实需要使用 Neon 的 HTTP 驱动,参见:
> https://orm.drizzle.team/docs/connect-neon
---
## 七、国际化
| 库 | 版本 | 用途 | 文档地址 |
| --- | --- | --- | --- |
| next-intl | 4.14.9 | App Router 国际化(路由前缀、消息、服务端翻译) | https://next-intl.dev/docs |
### next-intl 相关子文档
| 主题 | 文档地址 |
| --- | --- |
| 开始使用(App Router) | https://next-intl.dev/docs/getting-started/app-router |
| 路由与 `defineRouting` | https://next-intl.dev/docs/routing |
| 服务端 `getTranslations` | https://next-intl.dev/docs/environments/server-client-components |
| 导航封装 `createNavigation` | https://next-intl.dev/docs/routing/navigation |
| 静态渲染 `setRequestLocale` | https://next-intl.dev/docs/routing/setup#static-rendering |
---
## 八、字体
字体通过 `next/font/google` 在**构建期下载并自托管**,运行时不会请求 Google 服务器。
| 字体 | 用途 | 授权 | 来源 |
| --- | --- | --- | --- |
| Inter | 英文 / UI 正文 | SIL Open Font License 1.1 | https://fonts.google.com/specimen/Inter |
| Noto Sans SC | 中文 | SIL Open Font License 1.1 | https://fonts.google.com/noto/specimen/Noto+Sans+SC |
| IBM Plex Mono | 代码 | SIL Open Font License 1.1 | https://fonts.google.com/specimen/IBM+Plex+Mono |
### 字体相关文档
| 主题 | 文档地址 |
| --- | --- |
| Inter 官方站点 | https://rsms.me/inter/ |
| Noto 字体项目 | https://notofonts.github.io |
| IBM Plex 官方站点 | https://www.ibm.com/plex/ |
| `next/font` 自托管机制 | https://nextjs.org/docs/app/api-reference/components/font#self-hosting-fonts |
---
## 九、开发工具
| 库 | 版本 | 用途 | 文档地址 |
| --- | --- | --- | --- |
| ESLint | 9.x | 代码检查 | https://eslint.org/docs/latest/ |
| eslint-config-next | 16.3.8 | Next.js 官方 ESLint 规则 | https://nextjs.org/docs/app/api-reference/config/eslint |
| PostCSS | 4.x | CSS 处理管线 | https://postcss.org |
| pnpm | 12.3.4 | 包管理器 | https://pnpm.io/motivation |
---
## 十、设计规范参考
| 资源 | 说明 | 地址 |
| --- | --- | --- |
| DESIGN.md | 本项目设计令牌来源(Mintlify 设计系统分析) | 见仓库根目录 `DESIGN.md` |
| design.md 校验工具 | 检查设计令牌引用与对比度 | https://getdesign.md |
| Mintlify 设计系统 | 上游设计语言参考 | https://getdesign.md/mintlify/design-md |
---
## 十一、部署
| 平台 | 用途 | 文档地址 |
| --- | --- | --- |
| Vercel | 推荐的部署平台(Node.js 运行时、自动 HTTPS、自定义域名) | https://vercel.com/docs |
| Next.js 部署指南 | 自托管与各平台部署说明 | https://nextjs.org/docs/app/building-your-application/deploying |
---
## 附:版本查询与升级
```bash
# 查看当前安装版本
pnpm list --depth 0
# 检查过期的依赖
pnpm outdated
# 交互式升级
pnpm update --interactive --latest
```
> 如需查询某个包的最新版本与发布时间,可访问 npm 官方页面:
> `https://www.npmjs.com/package/<包名>`