feat: refactor Notion API integration and update Docker configuration

This commit is contained in:
2026-03-16 14:39:18 +08:00
parent 8bb135a489
commit 7faa73c91f
10 changed files with 180 additions and 353 deletions

View File

@@ -1,3 +1,5 @@
node_modules/
dist/
.astro/
.env
.git
.gitignore
dist
node_modules

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
NOTION_API_BASE_URL=http://127.0.0.1:3000
PUBLIC_NOTION_API_BASE_URL=http://127.0.0.1:3000

View File

@@ -1,14 +1,27 @@
FROM oven/bun:latest AS build
FROM oven/bun:1 AS build
ADD * /app/
WORKDIR /app
RUN bun install && \
bun run astro build
COPY package.json bun.lock ./
COPY tsconfig.json components.json astro.config.ts ./
COPY patches ./patches
COPY public ./public
COPY src ./src
ENV ASTRO_ADAPTER=node
RUN bun install --frozen-lockfile
RUN bun run build
FROM node:22-bookworm-slim
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
EXPOSE 3000
CMD ["node", "dist/server/entry.mjs"]

View File

@@ -17,8 +17,11 @@ import { pluginCollapsibleSections } from '@expressive-code/plugin-collapsible-s
import { pluginLineNumbers } from '@expressive-code/plugin-line-numbers'
import tailwindcss from '@tailwindcss/vite'
import vercel from "@astrojs/vercel"
import node from '@astrojs/node';
import vercel from '@astrojs/vercel'
import node from '@astrojs/node'
const adapterName = process.env.ASTRO_ADAPTER ?? 'vercel'
const adapter = adapterName === 'node' ? node({ mode: 'standalone' }) : vercel()
export default defineConfig({
site: 'https://nvme0n1p.dev',
@@ -44,8 +47,7 @@ export default defineConfig({
codeFontSize: '0.75rem',
borderColor: 'var(--border)',
codeFontFamily: 'var(--font-mono)',
codeBackground:
'color-mix(in oklab, var(--muted) 25%, transparent)',
codeBackground: 'color-mix(in oklab, var(--muted) 25%, transparent)',
frames: {
editorActiveTabForeground: 'var(--muted-foreground)',
editorActiveTabBackground:
@@ -112,5 +114,5 @@ export default defineConfig({
remarkPlugins: [remarkMath, remarkEmoji],
},
adapter: vercel(),
adapter,
})

View File

@@ -1,6 +1,7 @@
---
import { SITE } from '@/consts'
import { isSubpost } from '@/lib/data-utils'
import { buildNotionApiUrl } from '@/lib/notion/api'
import type { CollectionEntry } from 'astro:content'
interface Props {
@@ -17,7 +18,7 @@ const author =
? post.data.authors.join(', ')
: SITE.author
const ogImage = {
url: new URL(`/api/og/${encodeURIComponent(post.id)}`, siteUrl).toString(),
url: buildNotionApiUrl(`/og/${encodeURIComponent(post.id)}.png`),
width: 1200,
height: 630,
type: 'image/png',

View File

@@ -1,6 +1,7 @@
import { getCollection, render, type CollectionEntry } from 'astro:content'
import { readingTime, calculateWordCountFromHtml } from '@/lib/utils'
import type { Block, RichText } from './interfaces'
import { NotionApiError, fetchNotionApiJson } from './notion/api'
import {
buildHeadingId,
buildText,
@@ -56,6 +57,11 @@ export type RemotePostPayload = {
rootId: string
}
type PostsPayload = {
posts?: NotionPost[]
length?: number
}
export interface LinkEntry {
id: string
picLink?: string
@@ -95,21 +101,35 @@ export function normalizePost(post: NotionPost): CollectionEntry<'blog'> {
}
}
export async function getAllPosts(page?: Number, size?: Number): Promise<CollectionEntry<'blog'>[]> {
try {
const res = await fetch(`https://notion-api.nvme0n1p.dev/v2/posts?page=${page ?? 1}&length=${size ?? 1000}`)
if (!res.ok) throw new Error(`Failed to fetch posts: ${res.status}`)
const payload = (await res.json()) as { posts?: NotionPost[], length?: number }
const posts = (payload.posts ?? []).filter(
(post) => post.Published ?? true,
export async function fetchPostsPage(
page = 1,
size = 1000,
): Promise<{
posts: CollectionEntry<'blog'>[]
totalPages: number
}> {
const payload = await fetchNotionApiJson<PostsPayload>(
`/v2/posts?page=${page}&length=${size}`,
)
const length = payload.length;
const normalized = posts.map(normalizePost)
return normalized
const posts = (payload.posts ?? []).filter((post) => post.Published ?? true)
const normalized = posts
.map(normalizePost)
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
.filter((post) => !isSubpost(post.id)) as unknown as CollectionEntry<'blog'>[]
.filter((post) => !isSubpost(post.id)) as CollectionEntry<'blog'>[]
return {
posts: normalized,
totalPages: payload.length ?? 0,
}
}
export async function getAllPosts(
page?: number,
size?: number,
): Promise<CollectionEntry<'blog'>[]> {
try {
const { posts } = await fetchPostsPage(page ?? 1, size ?? 1000)
return posts
} catch (error) {
console.error('getAllPosts remote fetch failed, using fallback:', error)
return []
@@ -122,7 +142,6 @@ export async function getAllPostsAndSubposts(): Promise<
return getAllPosts()
}
export async function getAllTags(): Promise<Map<string, number>> {
const posts = await getAllPosts()
return posts.reduce((acc, post) => {
@@ -238,28 +257,32 @@ export function isSubpost(postId: string): boolean {
export async function fetchRemotePost(
slug: string,
): Promise<NotionPost | null> {
): Promise<{ post: NotionPost } | null> {
try {
const res = await fetch(`https://notion-api.nvme0n1p.dev/v2/posts/${slug}`)
if (!res.ok) throw new Error(`Failed to fetch post: ${res.status}`)
const data = (await res.json()) as NotionPost
const data = await fetchNotionApiJson<Partial<{ post: NotionPost }>>(
`/v2/posts/${encodeURI(slug)}?info=true`,
)
if (!data.post) return null
return data
} catch (error) {
console.error(`fetchRemotePost error for slug "${slug}":`, error)
if (error instanceof NotionApiError && error.status === 404) {
return null
}
console.error(`fetchRemotePost error for slug "${slug}":`, error)
throw error
}
}
export async function fetchRemotePostContent(
slug: string,
): Promise<RemotePostPayload | null> {
try {
const res = await fetch(`https://notion-api.nvme0n1p.dev/v2/posts/${encodeURI(slug)}`)
if (!res.ok) throw new Error(`Failed to fetch post content: ${res.status}`)
const data = (await res.json()) as Partial<{
const data = await fetchNotionApiJson<
Partial<{
post: NotionPost
blockMap: NotionBlockMap
}>
>(`/v2/posts/${encodeURI(slug)}`)
if (!data.post || !data.blockMap) return null
const rootId = data.post.id
@@ -271,18 +294,17 @@ export async function fetchRemotePostContent(
rootId,
}
} catch (error) {
console.error(`fetchRemotePostContent error for slug "${slug}":`, error)
if (error instanceof NotionApiError && error.status === 404) {
return null
}
console.error(`fetchRemotePostContent error for slug "${slug}":`, error)
throw error
}
}
export async function getFriendLinks(): Promise<LinkEntry[]> {
try {
const res = await fetch("https://notion-api.nvme0n1p.dev/v2/links")
if (!res.ok) throw new Error(`Failed to fetch links: ${res.status}`)
const data = (await res.json()) as LinkEntry[]
const data = await fetchNotionApiJson<LinkEntry[]>('/v2/links')
if (!Array.isArray(data)) throw new Error('Invalid links data format')
return data
@@ -295,7 +317,6 @@ export async function getFriendLinks(): Promise<LinkEntry[]> {
export async function getParentPost(
subpostId: string,
): Promise<CollectionEntry<'blog'> | null> {
const parentId = getParentId(subpostId)
const allPosts = await getAllPosts()
return allPosts.find((post) => post.id === parentId) || null
@@ -635,7 +656,8 @@ function buildBlocks(
blocks.push({
Id: `${targetType}-${listItems[0]?.Id ?? i}`,
Type: targetType === 'bulleted_list' ? 'bulleted_list' : 'numbered_list',
Type:
targetType === 'bulleted_list' ? 'bulleted_list' : 'numbered_list',
HasChildren: listItems.some((item) => item.HasChildren),
ListItems: listItems,
})
@@ -975,11 +997,7 @@ export function renderRemoteBlockMap(
slug: buildHeadingId(heading),
text: richTextToPlainText(heading?.RichTexts ?? []),
depth:
block.Type === 'heading_1'
? 2
: block.Type === 'heading_2'
? 3
: 4,
block.Type === 'heading_1' ? 2 : block.Type === 'heading_2' ? 3 : 4,
}
})

58
src/lib/notion/api.ts Normal file
View File

@@ -0,0 +1,58 @@
const DEFAULT_NOTION_API_BASE_URL = 'https://notion-api.nvme0n1p.dev'
function readRuntimeEnv(key: string): string | undefined {
if (typeof process === 'undefined') return undefined
const value = process.env[key]
return value && value.trim() ? value.trim() : undefined
}
export class NotionApiError extends Error {
status: number
url: string
constructor(message: string, status: number, url: string) {
super(message)
this.name = 'NotionApiError'
this.status = status
this.url = url
}
}
function normalizeBaseUrl(value: string): string {
return value.replace(/\/+$/, '')
}
export function getNotionApiServerBaseUrl(): string {
return normalizeBaseUrl(
readRuntimeEnv('NOTION_API_BASE_URL') ||
readRuntimeEnv('PUBLIC_NOTION_API_BASE_URL') ||
import.meta.env.NOTION_API_BASE_URL ||
import.meta.env.PUBLIC_NOTION_API_BASE_URL ||
DEFAULT_NOTION_API_BASE_URL,
)
}
export function getNotionApiPublicBaseUrl(): string {
return normalizeBaseUrl(
readRuntimeEnv('PUBLIC_NOTION_API_BASE_URL') ||
readRuntimeEnv('NOTION_API_BASE_URL') ||
import.meta.env.PUBLIC_NOTION_API_BASE_URL ||
import.meta.env.NOTION_API_BASE_URL ||
DEFAULT_NOTION_API_BASE_URL,
)
}
export function buildNotionApiUrl(path: string): string {
return new URL(path, `${getNotionApiPublicBaseUrl()}/`).toString()
}
export async function fetchNotionApiJson<T>(path: string): Promise<T> {
const url = new URL(path, `${getNotionApiServerBaseUrl()}/`).toString()
const res = await fetch(url)
if (!res.ok) {
throw new NotionApiError(`Failed to fetch ${url}: ${res.status}`, res.status, url)
}
return (await res.json()) as T
}

View File

@@ -1,14 +1,11 @@
import type { Post } from '../interfaces'
import { fetchNotionApiJson } from './api'
export async function getPostByPageId(pageId: string): Promise<Post | null> {
if (!pageId) return null
try {
const res = await fetch("https://notion-api.nvme0n1p.dev/v2/posts")
if (!res.ok) throw new Error(`Failed to fetch posts: ${res.status}`)
const payload = (await res.json()) as { posts?: any[] }
const payload = await fetchNotionApiJson<{ posts?: any[] }>('/v2/posts')
const match = (payload.posts ?? []).find((post) => post.id === pageId)
if (!match) return null

View File

@@ -1,256 +0,0 @@
import type { APIRoute } from 'astro'
import type { CSSProperties } from 'react'
import { createElement } from 'react'
import { ImageResponse } from '@vercel/og'
import { SITE } from '@/consts'
import { fetchRemotePost, normalizePost } from '@/lib/data-utils'
import { formatDate } from '@/lib/utils'
const WIDTH = 1200
const HEIGHT = 630
const FONT_STACK =
'Geist, "Inter", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif'
export const config = { runtime: 'edge' }
function truncateChars(input: string, maxChars: number) {
const chars = Array.from(input)
if (chars.length <= maxChars) return input
if (maxChars <= 3) return chars.slice(0, maxChars).join('')
return chars.slice(0, maxChars - 3).join('') + '...'
}
function wrapText(input: string, maxChars: number, maxLines: number) {
const text = input.replace(/\s+/g, ' ').trim()
if (!text) return []
const chars = Array.from(text)
const lines: string[] = []
let current: string[] = []
for (const ch of chars) {
if (current.length >= maxChars) {
lines.push(current.join(''))
current = []
if (lines.length >= maxLines) break
}
current.push(ch)
}
if (lines.length < maxLines && current.length > 0) {
lines.push(current.join(''))
}
const maxTotal = maxChars * maxLines
if (chars.length > maxTotal && lines.length > 0) {
lines[lines.length - 1] = truncateChars(
lines[lines.length - 1],
maxChars,
)
}
return lines
}
const containerStyle: CSSProperties = {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
padding: '48px',
color: '#0f172a',
fontFamily: FONT_STACK,
backgroundImage: 'linear-gradient(130deg, #fdf2f8 0%, #e0f2fe 60%, #f0fdf4 100%)',
overflow: 'hidden',
}
const cardStyle: CSSProperties = {
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: '32px',
width: '100%',
maxWidth: '1040px',
padding: '64px',
borderRadius: '32px',
border: '1px solid rgba(226, 232, 240, 0.7)',
background:
'linear-gradient(120deg, rgba(255, 255, 255, 0.92), rgba(240, 253, 244, 0.95))',
boxShadow: '0 40px 120px rgba(148, 163, 184, 0.35)',
}
const gridStyle: CSSProperties = {
position: 'absolute',
inset: 0,
backgroundImage:
'linear-gradient(rgba(14, 116, 144, 0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(14, 116, 144, 0.08) 1px, transparent 1px)',
backgroundSize: '40px 40px',
borderRadius: '32px',
maskImage: 'radial-gradient(circle at center, rgba(255, 255, 255, 0.9), transparent 70%)',
pointerEvents: 'none',
}
const badgeStyle: CSSProperties = {
fontSize: 20,
fontWeight: 600,
letterSpacing: '0.25em',
textTransform: 'uppercase',
color: 'rgba(15, 23, 42, 0.75)',
display: 'flex',
alignItems: 'center',
gap: '10px',
}
const badgeDotStyle: CSSProperties = {
width: 10,
height: 10,
borderRadius: '999px',
background:
'radial-gradient(circle, rgba(236, 72, 153, 0.9), rgba(236, 72, 153, 0.3))',
boxShadow: '0 0 20px rgba(236, 72, 153, 0.4)',
}
const titleStyle: CSSProperties = {
fontSize: 64,
fontWeight: 700,
letterSpacing: '-0.02em',
lineHeight: 1.15,
color: '#0f172a',
whiteSpace: 'pre-line',
}
const metaRowStyle: CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderTop: '1px solid rgba(148, 163, 184, 0.3)',
paddingTop: '28px',
marginTop: '8px',
}
const metaLeftStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: '6px',
}
const metaLabelStyle: CSSProperties = {
fontSize: 18,
textTransform: 'uppercase',
letterSpacing: '0.2em',
color: 'rgba(15, 118, 110, 0.7)',
}
const metaValueStyle: CSSProperties = {
fontSize: 24,
fontWeight: 600,
color: '#0f172a',
}
const signatureStyle: CSSProperties = {
fontSize: 22,
fontWeight: 500,
color: 'rgba(14, 116, 144, 0.85)',
}
const wrapAsBlock = (value: string, maxChars: number, maxLines: number) =>
wrapText(value, maxChars, maxLines).join('\n')
export const GET: APIRoute = async ({ params }) => {
let slug = params.slug ?? ''
if (slug) {
try {
slug = decodeURIComponent(slug)
} catch {
// Keep the raw slug if decoding fails.
}
}
const remotePost = slug ? await fetchRemotePost(slug) : null
const post = remotePost ? normalizePost(remotePost.post) : null
const title = post?.data?.title || SITE.title
const dateValue = post?.data?.date ?? null
const meta = dateValue
? `${SITE.title} | ${formatDate(dateValue)}`
: SITE.title
const siteHost = new URL(SITE.href).host
const formattedDate = dateValue ? formatDate(dateValue) : null
const titleBlock = wrapAsBlock(title, 24, 2)
const badge = createElement('div', { style: badgeStyle }, [
createElement('span', { style: badgeDotStyle }),
siteHost,
])
const cardChildren = [
createElement('div', { style: gridStyle }),
badge,
createElement('div', { style: titleStyle }, titleBlock),
]
const metaRow = createElement('div', { style: metaRowStyle }, [
createElement('div', { style: metaLeftStyle }, [
createElement('div', { style: metaLabelStyle }, 'PUBLISHED'),
createElement(
'div',
{ style: metaValueStyle },
formattedDate ?? 'Fresh dispatches',
),
]),
createElement(
'div',
{ style: signatureStyle },
formattedDate ? `${SITE.title} · ${siteHost}` : meta,
),
])
cardChildren.push(metaRow)
const accentRing = createElement('div', {
style: {
position: 'absolute',
width: 520,
height: 520,
borderRadius: '50%',
border: '1px solid rgba(236, 72, 153, 0.45)',
right: -120,
top: -140,
transform: 'rotate(25deg)',
opacity: 0.8,
},
})
const accentGlow = createElement('div', {
style: {
position: 'absolute',
width: 420,
height: 420,
borderRadius: '50%',
left: -120,
bottom: -160,
background: 'radial-gradient(circle, rgba(59, 130, 246, 0.35), transparent 65%)',
filter: 'blur(2px)',
},
})
const card = createElement('div', { style: cardStyle }, cardChildren)
const root = createElement('div', { style: containerStyle }, [
accentGlow,
accentRing,
card,
])
return new ImageResponse(
root,
{
width: WIDTH,
height: HEIGHT,
headers: {
'Content-Type': 'image/png',
'Cache-Control':
'public, max-age=0, s-maxage=86400, stale-while-revalidate=604800',
},
},
)
}

View File

@@ -5,7 +5,7 @@ import PageHead from '@/components/PageHead.astro'
import PaginationComponent from '@/components/ui/pagination'
import { SITE } from '@/consts'
import Layout from '@/layouts/Layout.astro'
import { groupPostsByYear, normalizePost } from '@/lib/data-utils'
import { fetchPostsPage, groupPostsByYear } from '@/lib/data-utils'
// export async function getStaticPaths({
// paginate,
@@ -22,22 +22,12 @@ const nowPage = parseInt(
// use url param
// const { page } = Astro.para
const page = await fetch(
`https://notion-api.nvme0n1p.dev/v2/posts/?page=${nowPage}&length=${SITE.postsPerPage}`,
)
.then((res) => {
if (!res.ok) throw new Error(`Failed to fetch posts: ${res.status}`)
return res.json()
})
.then((allPosts) => {
const totalPages = allPosts.length
const currentPage = nowPage
return {
data: allPosts.posts.map((post) => normalizePost(post)),
currentPage,
lastPage: totalPages,
}
})
const postsPage = await fetchPostsPage(nowPage, SITE.postsPerPage)
const page = {
data: postsPage.posts,
currentPage: nowPage,
lastPage: postsPage.totalPages,
}
// fetch page
if (page.lastPage < page.currentPage) {
console.log('redirect to 404')