diff --git a/.dockerignore b/.dockerignore index 262e83b..76757bc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,5 @@ -node_modules/ -dist/ -.astro/ \ No newline at end of file +.env +.git +.gitignore +dist +node_modules diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a89392c --- /dev/null +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile index 6d154b9..d51466e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] \ No newline at end of file + +CMD ["node", "dist/server/entry.mjs"] diff --git a/astro.config.ts b/astro.config.ts index 8d6d8ba..05f2c04 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -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(), -}) \ No newline at end of file + adapter, +}) diff --git a/src/components/PostHead.astro b/src/components/PostHead.astro index bb7e834..0ec3bce 100644 --- a/src/components/PostHead.astro +++ b/src/components/PostHead.astro @@ -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', diff --git a/src/lib/data-utils.ts b/src/lib/data-utils.ts index bbff789..32ec9f5 100644 --- a/src/lib/data-utils.ts +++ b/src/lib/data-utils.ts @@ -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 @@ -73,43 +79,57 @@ export async function getAllAuthors(): Promise[]> { export function normalizePost(post: NotionPost): CollectionEntry<'blog'> { const dateString = - post['Published Date'] ?? post.created_time ?? new Date().toISOString() - const date = new Date(dateString) - const id = post.slug || post.id - const banner = (post as any).banner || null - const image = banner - return { - id, - collection: 'blog', - data: { - title: post.Content || id || 'Untitled', - description: (post as any).excerpt || '', - date, - tags: Array.isArray(post.Tags) ? post.Tags : [], - draft: !(post.Published ?? true), - authors: [DEFAULT_AUTHOR_ID], - banner, - image, - }, - body: '', - } + post['Published Date'] ?? post.created_time ?? new Date().toISOString() + const date = new Date(dateString) + const id = post.slug || post.id + const banner = (post as any).banner || null + const image = banner + return { + id, + collection: 'blog', + data: { + title: post.Content || id || 'Untitled', + description: (post as any).excerpt || '', + date, + tags: Array.isArray(post.Tags) ? post.Tags : [], + draft: !(post.Published ?? true), + authors: [DEFAULT_AUTHOR_ID], + banner, + image, + }, + body: '', + } } -export async function getAllPosts(page?: Number, size?: Number): Promise[]> { +export async function fetchPostsPage( + page = 1, + size = 1000, +): Promise<{ + posts: CollectionEntry<'blog'>[] + totalPages: number +}> { + const payload = await fetchNotionApiJson( + `/v2/posts?page=${page}&length=${size}`, + ) + 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 CollectionEntry<'blog'>[] + return { + posts: normalized, + totalPages: payload.length ?? 0, + } +} + +export async function getAllPosts( + page?: number, + size?: number, +): Promise[]> { 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, - ) - const length = payload.length; - const normalized = posts.map(normalizePost) - - return normalized - .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()) - .filter((post) => !isSubpost(post.id)) as unknown as CollectionEntry<'blog'>[] + 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> { const posts = await getAllPosts() return posts.reduce((acc, post) => { @@ -238,15 +257,19 @@ export function isSubpost(postId: string): boolean { export async function fetchRemotePost( slug: string, -): Promise { +): 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>( + `/v2/posts/${encodeURI(slug)}?info=true`, + ) + if (!data.post) return null return data } catch (error) { + if (error instanceof NotionApiError && error.status === 404) { + return null + } console.error(`fetchRemotePost error for slug "${slug}":`, error) - return null + throw error } } @@ -254,12 +277,12 @@ export async function fetchRemotePostContent( slug: string, ): Promise { 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<{ - post: NotionPost - blockMap: NotionBlockMap - }> + 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) { + if (error instanceof NotionApiError && error.status === 404) { + return null + } console.error(`fetchRemotePostContent error for slug "${slug}":`, error) - return null + throw error } } export async function getFriendLinks(): Promise { - 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('/v2/links') if (!Array.isArray(data)) throw new Error('Invalid links data format') return data @@ -295,7 +317,6 @@ export async function getFriendLinks(): Promise { export async function getParentPost( subpostId: string, ): Promise | 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, } }) diff --git a/src/lib/notion/api.ts b/src/lib/notion/api.ts new file mode 100644 index 0000000..c300823 --- /dev/null +++ b/src/lib/notion/api.ts @@ -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(path: string): Promise { + 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 +} diff --git a/src/lib/notion/client.ts b/src/lib/notion/client.ts index e35174a..4178cdb 100644 --- a/src/lib/notion/client.ts +++ b/src/lib/notion/client.ts @@ -1,14 +1,11 @@ - import type { Post } from '../interfaces' +import { fetchNotionApiJson } from './api' export async function getPostByPageId(pageId: string): Promise { 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 diff --git a/src/pages/api/og/[slug].ts b/src/pages/api/og/[slug].ts deleted file mode 100644 index 9372522..0000000 --- a/src/pages/api/og/[slug].ts +++ /dev/null @@ -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', - }, - }, - ) -} diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro index 762b9b3..b71ac4a 100644 --- a/src/pages/blog/index.astro +++ b/src/pages/blog/index.astro @@ -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')