This commit is contained in:
1 parent
c1541b80df
commit
b4c7908029
97 files changed
+12403
-154
No files matched your search
@@ -39,3 +39,6 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# 本地校验脚本产出的截图
|
||||
/.screenshots/
|
||||
@@ -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 -->
|
||||
@@ -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
|
||||
@@ -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
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
pnpm install
|
||||
|
||||
# 1. 配置数据库连接(见下方「环境变量」)
|
||||
# 2. 建表
|
||||
pnpm db:migrate
|
||||
|
||||
# 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.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
在 `.env.local` 中配置:
|
||||
|
||||
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` 已包含类型检查,构建失败会直接阻断部署。
|
||||
构建过程**不需要**数据库可用(页面为动态渲染,不在构建期取数)。
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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: "标签聚合(含各标签文章数)",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -1,26 +1,454 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
/* ==========================================================================
|
||||
设计令牌(Design Tokens)
|
||||
来源:根目录 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 {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
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
@@ -1,29 +1,49 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { fontVariables } from "@/lib/fonts";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
/**
|
||||
* 根布局。
|
||||
*
|
||||
* App Router 要求根布局渲染 <html> 与 <body>。由于所有页面都位于
|
||||
* `app/[locale]/` 下,这里只负责最外层的文档骨架与全局字体变量,
|
||||
* 具体语言、Provider 与导航壳由 `app/[locale]/layout.tsx` 承担。
|
||||
*/
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
metadataBase: new URL(siteConfig.url),
|
||||
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<"/">) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
themeColor: "#ffffff",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
>_
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
>_
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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` 好。外观会变,意图通常不会。
|
||||
@@ -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。
|
||||
@@ -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!,
|
||||
},
|
||||
});
|
||||
@@ -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]+)*$');
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1791131376170,
|
||||
"tag": "0000_silky_shocker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+17
-10
@@ -3,16 +3,23 @@ import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
{
|
||||
rules: {
|
||||
"import/no-anonymous-default-export": "off",
|
||||
"react/display-name": "off",
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
},
|
||||
},
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createNavigation } from "next-intl/navigation";
|
||||
import { routing } from "./routing";
|
||||
|
||||
export const { Link, redirect, usePathname, useRouter, getPathname } =
|
||||
createNavigation(routing);
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 已被占用" },
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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$}`;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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": "日本語"
|
||||
}
|
||||
}
|
||||
@@ -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": "日本語"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,7 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const createNextIntlPlugin = require("next-intl/plugin");
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
module.exports = withNextIntl(nextConfig);
|
||||
export default nextConfig;
|
||||
+36
-2
@@ -6,18 +6,52 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"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": {
|
||||
"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-intl": "^4.14.9",
|
||||
"next-mdx-remote": "^6.0.0",
|
||||
"pg": "^8.23.1",
|
||||
"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": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"dotenv": "^18.0.5",
|
||||
"drizzle-kit": "^0.31.11",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.8",
|
||||
"tailwindcss": "^4",
|
||||
|
||||
Generated
+3285
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,6 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
'@swc/core': true
|
||||
esbuild: true
|
||||
sharp: false
|
||||
unrs-resolver: false
|
||||
@@ -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|.*\\..*).*)"],
|
||||
};
|
||||
@@ -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">>_</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 |
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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 驱动。");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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 正常
|
||||
- [ ] 文章可正常阅读,代码高亮生效
|
||||
- [ ] 移动端可正常阅读
|
||||
- [ ] 线上部署可访问
|
||||
- [ ] 构建无错误
|
||||
@@ -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/<包名>`
|
||||
Reference in new issue
Block a user