More customzation

This commit is contained in:
2025-11-28 15:29:39 +08:00
parent 985164f4c5
commit 9133d23a15
85 changed files with 4176 additions and 1439 deletions

180
src/lib/blog-helpers.ts Normal file
View File

@@ -0,0 +1,180 @@
import type {
Heading1,
Heading2,
Heading3,
RichText,
Text,
} from './interfaces'
function toSlug(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
function extractHeadingText(
heading?: Heading1 | Heading2 | Heading3,
): { text: string; slug: string } {
const parts = heading?.RichTexts ?? []
const plain = parts.map((r) => r.PlainText || r.Text?.Content || '').join('')
const slug = toSlug(plain) || heading?.RichTexts?.[0]?.PlainText || ''
return { text: plain, slug: slug || 'heading' }
}
export function buildHeadingId(
heading?: Heading1 | Heading2 | Heading3,
): string {
const { slug } = extractHeadingText(heading)
return slug || 'heading'
}
export function isAmazonURL(url?: URL | string | null): boolean {
if (!url) return false
const href = typeof url === 'string' ? url : url.toString()
return /amazon\.[a-z.]+|amzn\.to/.test(href)
}
export function isGitHubURL(url?: URL | string | null): boolean {
if (!url) return false
const href = typeof url === 'string' ? url : url.toString()
return /github\.com/.test(href)
}
export function isTweetURL(url?: URL | string | null): boolean {
if (!url) return false
const href = typeof url === 'string' ? url : url.toString()
return /twitter\.com|x\.com/.test(href)
}
export function isTikTokURL(url?: URL | string | null): boolean {
if (!url) return false
const href = typeof url === 'string' ? url : url.toString()
return /tiktok\.com/.test(href)
}
export function isInstagramURL(url?: URL | string | null): boolean {
if (!url) return false
const href = typeof url === 'string' ? url : url.toString()
return /instagram\.com/.test(href)
}
export function isPinterestURL(url?: URL | string | null): boolean {
if (!url) return false
const href = typeof url === 'string' ? url : url.toString()
return /pinterest\.com|pin\.it/.test(href)
}
export function isCodePenURL(url?: URL | string | null): boolean {
if (!url) return false
const href = typeof url === 'string' ? url : url.toString()
return /codepen\.io/.test(href)
}
export function isCircuitSimulatorAppletURL(
url?: URL | string | null,
): boolean {
if (!url) return false
const href = typeof url === 'string' ? url : url.toString()
return /falstad\.com\/circuit/.test(href)
}
export function isYouTubeURL(url?: URL | string | null): boolean {
if (!url) return false
const href = typeof url === 'string' ? url : url.toString()
return /youtube\.com|youtu\.be/.test(href)
}
export function parseYouTubeVideoId(url?: URL | string | null): string {
if (!url) return ''
const target = typeof url === 'string' ? new URL(url) : url
if (target.hostname.includes('youtu.be')) {
return target.pathname.replace('/', '')
}
if (target.searchParams.has('v')) {
return target.searchParams.get('v') || ''
}
const parts = target.pathname.split('/')
return parts.pop() || ''
}
export function filePath(url: URL | string): string {
return typeof url === 'string' ? url : url.toString()
}
export function getPostLink(slug: string): string {
return `/blog/${slug}`
}
export function richTextToPlainText(richTexts: RichText[] = []): string {
return richTexts
.map((r) => r.PlainText || r.Text?.Content || '')
.filter(Boolean)
.join('')
}
export function getStaticFilePath(path: string): string {
return `${path}`
}
export function buildText(
content: string,
link?: string,
annotation?: Partial<RichText['Annotation']>,
): RichText {
const text: Text = { Content: content }
if (link) {
text.Link = { Url: link }
}
return {
Text: text,
Annotation: {
Bold: false,
Italic: false,
Strikethrough: false,
Underline: false,
Code: false,
Color: 'default',
...(annotation || {}),
},
PlainText: content,
Href: link,
}
}
export function toNotionImageUrl(
url: string,
block?: { parent_table?: string; id?: string },
): string {
if (!url) return url
let notionUrl = url
if (!url.startsWith('https://www.notion.so')) {
notionUrl = 'https://www.notion.so'.concat(
url.startsWith('/image') ? url : `/image/${encodeURIComponent(url)}`,
)
}
try {
const imageUrl = new URL(notionUrl)
if (block) {
const table = ['space', 'team'].includes(block.parent_table || '')
? 'block'
: block.parent_table
if (table) {
imageUrl.searchParams.set('table', table)
}
if (block.id) {
imageUrl.searchParams.set('id', block.id)
}
imageUrl.searchParams.set('cache', 'v2')
}
return imageUrl.toString()
} catch (err) {
console.error('toNotionImageUrl error:', err)
return url
}
}

View File

@@ -1,5 +1,14 @@
import { getCollection, render, type CollectionEntry } from 'astro:content'
import { readingTime, calculateWordCountFromHtml } from '@/lib/utils'
import type { Block, RichText } from './interfaces'
import {
buildHeadingId,
buildText,
richTextToPlainText,
toNotionImageUrl,
} from './blog-helpers'
const DEFAULT_AUTHOR_ID = 'libr'
type NotionPost = {
id: string
@@ -13,6 +22,28 @@ type NotionPost = {
last_edited_time?: string
}
export type NotionBlockValue = {
id: string
type: string
properties?: Record<string, any>
content?: string[]
format?: Record<string, any>
synced_from?: Record<string, any>
link_to_page_id?: string
parent_table?: string
}
export type NotionBlock = {
value: NotionBlockValue
}
export type NotionBlockMap = Record<string, NotionBlock>
export type RemotePostPayload = {
post: NotionPost
blockMap: NotionBlockMap
}
export interface LinkEntry {
id: string
picLink?: string
@@ -36,6 +67,14 @@ export async function getAllPosts(): Promise<CollectionEntry<'blog'>[]> {
return posts
.filter((post) => !post.data.draft && !isSubpost(post.id))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
.map((post) => ({
...post,
data: {
...post.data,
banner: post.data.banner ?? post.data.image,
authors: [DEFAULT_AUTHOR_ID],
},
}))
}
try {
@@ -51,6 +90,7 @@ export async function getAllPosts(): Promise<CollectionEntry<'blog'>[]> {
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
return {
id,
@@ -61,7 +101,8 @@ export async function getAllPosts(): Promise<CollectionEntry<'blog'>[]> {
date,
tags: Array.isArray(post.Tags) ? post.Tags : [],
draft: !(post.Published ?? true),
authors: [],
authors: [DEFAULT_AUTHOR_ID],
banner,
},
body: '',
}
@@ -256,6 +297,21 @@ export async function fetchRemotePost(
}
}
export async function fetchRemotePostContent(
slug: string,
): Promise<RemotePostPayload | null> {
try {
const res = await fetch(`${POSTS_API_URL}/${encodeURI(slug)}`)
if (!res.ok) throw new Error(`Failed to fetch post content: ${res.status}`)
const data = (await res.json()) as Partial<RemotePostPayload>
if (!data.post || !data.blockMap) return null
return data as RemotePostPayload
} catch (error) {
console.error(`fetchRemotePostContent error for slug "${slug}":`, error)
return null
}
}
export async function getFriendLinks(): Promise<LinkEntry[]> {
const fallback: LinkEntry[] = []
@@ -345,6 +401,579 @@ export type TOCHeading = {
isSubpostTitle?: boolean
}
export type RenderedRemoteContent = {
blocks: Block[]
headingBlocks: Block[]
headings: TOCHeading[]
wordCount: number
}
function parseRichTexts(raw: any): RichText[] {
if (!Array.isArray(raw)) return []
return raw.map((segment: any) => {
const [text, decorations] = segment
const annotation: RichText['Annotation'] = {
Bold: false,
Italic: false,
Strikethrough: false,
Underline: false,
Code: false,
Color: 'default',
}
let href: string | undefined
let mentionPageId: string | undefined
if (Array.isArray(decorations)) {
for (const deco of decorations) {
const [type, value] = deco
switch (type) {
case 'b':
annotation.Bold = true
break
case 'i':
annotation.Italic = true
break
case 's':
annotation.Strikethrough = true
break
case '_':
annotation.Underline = true
break
case 'c':
annotation.Code = true
break
case 'a':
href =
typeof value === 'string'
? value
: typeof value?.[0] === 'string'
? value[0]
: undefined
break
case 'p':
mentionPageId =
typeof value === 'object' && value?.id ? value.id : undefined
break
case 'h':
if (typeof value === 'string') {
annotation.Color = value
}
break
default:
break
}
}
}
const richText = buildText(typeof text === 'string' ? text : '', href, {
Color: annotation.Color,
Bold: annotation.Bold,
Italic: annotation.Italic,
Strikethrough: annotation.Strikethrough,
Underline: annotation.Underline,
Code: annotation.Code,
})
if (mentionPageId) {
richText.Mention = {
Type: 'page',
Page: { Id: mentionPageId },
}
}
return richText
})
}
function countWordsFromRichTexts(richTexts: RichText[] = []): number {
const text = richTextToPlainText(richTexts)
if (!text) return 0
return text.split(/\s+/).filter(Boolean).length
}
function countWords(blocks: Block[]): number {
let total = 0
const addChildren = (children?: Block[]) => {
if (children && children.length > 0) {
total += countWords(children)
}
}
for (const block of blocks) {
switch (block.Type) {
case 'paragraph':
total += countWordsFromRichTexts(block.Paragraph?.RichTexts)
addChildren(block.Paragraph?.Children)
break
case 'heading_1':
total += countWordsFromRichTexts(block.Heading1?.RichTexts)
addChildren(block.Heading1?.Children)
break
case 'heading_2':
total += countWordsFromRichTexts(block.Heading2?.RichTexts)
addChildren(block.Heading2?.Children)
break
case 'heading_3':
total += countWordsFromRichTexts(block.Heading3?.RichTexts)
addChildren(block.Heading3?.Children)
break
case 'bulleted_list':
case 'numbered_list':
block.ListItems?.forEach((item) => {
if (item.Type === 'bulleted_list_item') {
total += countWordsFromRichTexts(item.BulletedListItem?.RichTexts)
addChildren(item.BulletedListItem?.Children)
} else if (item.Type === 'numbered_list_item') {
total += countWordsFromRichTexts(item.NumberedListItem?.RichTexts)
addChildren(item.NumberedListItem?.Children)
}
})
break
case 'to_do':
total += countWordsFromRichTexts(block.ToDo?.RichTexts)
addChildren(block.ToDo?.Children)
break
case 'quote':
total += countWordsFromRichTexts(block.Quote?.RichTexts)
addChildren(block.Quote?.Children)
break
case 'callout':
total += countWordsFromRichTexts(block.Callout?.RichTexts)
addChildren(block.Callout?.Children)
break
case 'code':
total += countWordsFromRichTexts(block.Code?.RichTexts)
break
case 'image':
total += countWordsFromRichTexts(block.Image?.Caption)
break
case 'bookmark':
total += countWordsFromRichTexts(block.Bookmark?.Caption)
break
case 'link_preview':
break
case 'embed':
break
case 'video':
total += countWordsFromRichTexts(block.Video?.Caption)
break
case 'column_list':
block.ColumnList?.Columns?.forEach((column) => {
addChildren(column.Children)
})
break
case 'synced_block':
addChildren(block.SyncedBlock?.Children)
break
case 'toggle':
total += countWordsFromRichTexts(block.Toggle?.RichTexts)
addChildren(block.Toggle?.Children)
break
default:
break
}
}
return total
}
function buildBlocks(
contentIds: string[],
blockMap: NotionBlockMap,
headingBlocks: Block[],
): Block[] {
const blocks: Block[] = []
let i = 0
while (i < contentIds.length) {
const currentId = contentIds[i]
const current = blockMap[currentId]?.value
if (!current) {
i++
continue
}
if (current.type === 'bulleted_list' || current.type === 'numbered_list') {
const listItems: Block[] = []
const targetType = current.type
while (
i < contentIds.length &&
blockMap[contentIds[i]]?.value?.type === targetType
) {
const item = blockMap[contentIds[i]]?.value
const childIds = Array.isArray(item?.content) ? item.content : []
const children = buildBlocks(childIds, blockMap, headingBlocks)
if (targetType === 'bulleted_list') {
listItems.push({
Id: item?.id ?? contentIds[i],
Type: 'bulleted_list_item',
HasChildren: children.length > 0,
BulletedListItem: {
RichTexts: parseRichTexts(item?.properties?.title),
Color:
typeof item?.format?.block_color === 'string'
? item?.format?.block_color
: 'default',
Children: children.length > 0 ? children : undefined,
},
})
} else {
listItems.push({
Id: item?.id ?? contentIds[i],
Type: 'numbered_list_item',
HasChildren: children.length > 0,
NumberedListItem: {
RichTexts: parseRichTexts(item?.properties?.title),
Color:
typeof item?.format?.block_color === 'string'
? item?.format?.block_color
: 'default',
Children: children.length > 0 ? children : undefined,
},
})
}
i++
}
blocks.push({
Id: `${targetType}-${listItems[0]?.Id ?? i}`,
Type: targetType === 'bulleted_list' ? 'bulleted_list' : 'numbered_list',
HasChildren: listItems.some((item) => item.HasChildren),
ListItems: listItems,
})
continue
}
const block = convertBlock(currentId, blockMap, headingBlocks)
if (block) {
blocks.push(block)
}
i++
}
return blocks
}
function convertBlock(
blockId: string,
blockMap: NotionBlockMap,
headingBlocks: Block[],
): Block | null {
const block = blockMap[blockId]?.value
if (!block) return null
const childIds = Array.isArray(block.content) ? block.content : []
const children =
childIds.length > 0 ? buildBlocks(childIds, blockMap, headingBlocks) : []
const color =
typeof block.format?.block_color === 'string'
? block.format?.block_color
: 'default'
switch (block.type) {
case 'text': {
return {
Id: block.id,
Type: 'paragraph',
HasChildren: children.length > 0,
Paragraph: {
RichTexts: parseRichTexts(block.properties?.title),
Color: color,
Children: children.length > 0 ? children : undefined,
},
}
}
case 'header':
case 'sub_header':
case 'sub_sub_header': {
const heading = {
RichTexts: parseRichTexts(block.properties?.title),
Color: color,
IsToggleable: false,
Children: children.length > 0 ? children : undefined,
}
const headingBlock: Block =
block.type === 'header'
? {
Id: block.id,
Type: 'heading_1',
HasChildren: children.length > 0,
Heading1: heading,
}
: block.type === 'sub_header'
? {
Id: block.id,
Type: 'heading_2',
HasChildren: children.length > 0,
Heading2: heading,
}
: {
Id: block.id,
Type: 'heading_3',
HasChildren: children.length > 0,
Heading3: heading,
}
headingBlocks.push(headingBlock)
return headingBlock
}
case 'code': {
return {
Id: block.id,
Type: 'code',
HasChildren: false,
Code: {
Caption: parseRichTexts(block.properties?.caption),
RichTexts: parseRichTexts(block.properties?.title),
Language:
block.properties?.language?.[0]?.[0]?.toString()?.toLowerCase() ||
'plain text',
},
}
}
case 'image': {
const src =
block.properties?.source?.[0]?.[0] || block.format?.display_source
if (!src) return null
const processed = toNotionImageUrl(src, block)
return {
Id: block.id,
Type: 'image',
HasChildren: false,
Image: {
Caption: parseRichTexts(block.properties?.caption),
Type: 'external',
External: { Url: processed },
Width: block.format?.block_width,
Height: block.format?.block_height,
},
}
}
case 'divider': {
return { Id: block.id, Type: 'divider', HasChildren: false }
}
case 'quote': {
return {
Id: block.id,
Type: 'quote',
HasChildren: children.length > 0,
Quote: {
RichTexts: parseRichTexts(block.properties?.title),
Color: color,
Children: children.length > 0 ? children : undefined,
},
}
}
case 'callout': {
return {
Id: block.id,
Type: 'callout',
HasChildren: children.length > 0,
Callout: {
RichTexts: parseRichTexts(block.properties?.title),
Icon: block.format?.page_icon
? { Type: 'emoji', Emoji: block.format?.page_icon }
: null,
Color: color,
Children: children.length > 0 ? children : undefined,
},
}
}
case 'tweet': {
const url = block.properties?.source?.[0]?.[0] || ''
if (!url) return null
return {
Id: block.id,
Type: 'embed',
HasChildren: false,
Embed: { Url: url },
}
}
case 'bookmark': {
const url =
block.properties?.link_url?.[0]?.[0] ||
block.properties?.source?.[0]?.[0] ||
block.properties?.title?.[0]?.[0] ||
''
if (!url) return null
return {
Id: block.id,
Type: 'bookmark',
HasChildren: false,
Bookmark: {
Caption: parseRichTexts(block.properties?.caption),
Url: url,
},
}
}
case 'link_preview': {
const url =
block.properties?.link_url?.[0]?.[0] ||
block.properties?.source?.[0]?.[0]
if (!url) return null
return {
Id: block.id,
Type: 'link_preview',
HasChildren: false,
LinkPreview: { Url: url },
}
}
case 'embed': {
const url =
block.properties?.source?.[0]?.[0] || block.format?.display_source
if (!url) return null
return {
Id: block.id,
Type: 'embed',
HasChildren: false,
Embed: { Url: url },
}
}
case 'video': {
const url =
block.properties?.source?.[0]?.[0] || block.format?.display_source
if (!url) return null
return {
Id: block.id,
Type: 'video',
HasChildren: false,
Video: {
Caption: parseRichTexts(block.properties?.caption),
Type: 'external',
External: { Url: url },
},
}
}
case 'to_do': {
const checked =
block.properties?.checked?.[0]?.[0]?.toString().toLowerCase() === 'yes'
return {
Id: block.id,
Type: 'to_do',
HasChildren: children.length > 0,
ToDo: {
RichTexts: parseRichTexts(block.properties?.title),
Checked: checked,
Color: color,
Children: children.length > 0 ? children : undefined,
},
}
}
case 'toggle': {
return {
Id: block.id,
Type: 'toggle',
HasChildren: children.length > 0,
Toggle: {
RichTexts: parseRichTexts(block.properties?.title),
Color: color,
Children: children.length > 0 ? children : undefined,
},
}
}
case 'column_list': {
const columns =
childIds.map((id) => {
const col = blockMap[id]?.value
const colChildren = Array.isArray(col?.content)
? buildBlocks(col?.content, blockMap, headingBlocks)
: []
return {
Id: id,
Type: 'column',
HasChildren: colChildren.length > 0,
Children: colChildren,
}
}) || []
return {
Id: block.id,
Type: 'column_list',
HasChildren: columns.some((c) => c.HasChildren),
ColumnList: { Columns: columns },
}
}
case 'synced_block': {
const syncedFrom =
block.synced_from && typeof block.synced_from.block_id === 'string'
? { BlockId: block.synced_from.block_id }
: null
return {
Id: block.id,
Type: 'synced_block',
HasChildren: children.length > 0,
SyncedBlock: {
SyncedFrom: syncedFrom,
Children: children.length > 0 ? children : undefined,
},
}
}
case 'table_of_contents': {
return {
Id: block.id,
Type: 'table_of_contents',
HasChildren: false,
TableOfContents: { Color: color },
}
}
case 'link_to_page': {
const pageId =
block.link_to_page_id || block.properties?.link_to_page_id?.[0]?.[0]
return {
Id: block.id,
Type: 'link_to_page',
HasChildren: false,
LinkToPage: {
Type: 'page',
PageId: pageId,
},
}
}
default:
return null
}
}
export function renderRemoteBlockMap(
blockMap: NotionBlockMap,
rootId: string,
): RenderedRemoteContent {
const headingBlocks: Block[] = []
const root = blockMap[rootId]?.value
const contentOrder = Array.isArray(root?.content)
? root?.content
: Object.keys(blockMap)
const blocks = buildBlocks(contentOrder, blockMap, headingBlocks)
const headings: TOCHeading[] = headingBlocks.map((block) => {
const heading = block.Heading1 || block.Heading2 || block.Heading3
return {
slug: buildHeadingId(heading),
text: richTextToPlainText(heading?.RichTexts ?? []),
depth:
block.Type === 'heading_1'
? 2
: block.Type === 'heading_2'
? 3
: 4,
}
})
const wordCount = countWords(blocks)
return { blocks, headingBlocks, headings, wordCount }
}
export type TOCSection = {
type: 'parent' | 'subpost'
title: string

269
src/lib/interfaces.ts Normal file
View File

@@ -0,0 +1,269 @@
export interface Database {
Title: string
Description: string
Icon: FileObject | Emoji | null
Cover: FileObject | null
}
export interface Post {
PageId: string
Title: string
Icon: FileObject | Emoji | null
Cover: FileObject | null
Slug: string
Date: string
Tags: SelectProperty[]
Excerpt: string
FeaturedImage: FileObject | null
Rank: number
}
export interface Block {
Id: string
Type: string
HasChildren: boolean
ListItems?: Block[]
Paragraph?: Paragraph
Heading1?: Heading1
Heading2?: Heading2
Heading3?: Heading3
BulletedListItem?: BulletedListItem
NumberedListItem?: NumberedListItem
ToDo?: ToDo
Image?: Image
File?: File
Code?: Code
Quote?: Quote
Equation?: Equation
Callout?: Callout
SyncedBlock?: SyncedBlock
Toggle?: Toggle
Embed?: Embed
Video?: Video
Bookmark?: Bookmark
LinkPreview?: LinkPreview
Table?: Table
ColumnList?: ColumnList
TableOfContents?: TableOfContents
LinkToPage?: LinkToPage
}
export interface Paragraph {
RichTexts: RichText[]
Color: string
Children?: Block[]
}
export interface Heading1 {
RichTexts: RichText[]
Color: string
IsToggleable: boolean
Children?: Block[]
}
export interface Heading2 {
RichTexts: RichText[]
Color: string
IsToggleable: boolean
Children?: Block[]
}
export interface Heading3 {
RichTexts: RichText[]
Color: string
IsToggleable: boolean
Children?: Block[]
}
export interface BulletedListItem {
RichTexts: RichText[]
Color: string
Children?: Block[]
}
export interface NumberedListItem {
RichTexts: RichText[]
Color: string
Children?: Block[]
}
export interface ToDo {
RichTexts: RichText[]
Checked: boolean
Color: string
Children?: Block[]
}
export interface Image {
Caption: RichText[]
Type: string
File?: FileObject
External?: External
Width?: number
Height?: number
}
export interface Video {
Caption: RichText[]
Type: string
External?: External
}
export interface File {
Caption: RichText[]
Type: string
File?: FileObject
External?: External
}
export interface FileObject {
Type: string
Url: string
ExpiryTime?: string
}
export interface External {
Url: string
}
export interface Code {
Caption: RichText[]
RichTexts: RichText[]
Language: string
}
export interface Quote {
RichTexts: RichText[]
Color: string
Children?: Block[]
}
export interface Equation {
Expression: string
}
export interface Callout {
RichTexts: RichText[]
Icon: FileObject | Emoji | null
Color: string
Children?: Block[]
}
export interface SyncedBlock {
SyncedFrom: SyncedFrom | null
Children?: Block[]
}
export interface SyncedFrom {
BlockId: string
}
export interface Toggle {
RichTexts: RichText[]
Color: string
Children?: Block[]
}
export interface Embed {
Url: string
}
export interface Bookmark {
Caption: RichText[]
Url: string
}
export interface LinkPreview {
Url: string
}
export interface Table {
TableWidth: number
HasColumnHeader: boolean
HasRowHeader: boolean
Rows: TableRow[]
}
export interface TableRow {
Id: string
Type: string
HasChildren: boolean
Cells: TableCell[]
}
export interface TableCell {
RichTexts: RichText[]
}
export interface ColumnList {
Columns: Column[]
}
export interface Column {
Id: string
Type: string
HasChildren: boolean
Children: Block[]
}
export interface List {
Type: string
ListItems: Block[]
}
export interface TableOfContents {
Color: string
}
export interface RichText {
Text?: Text
Annotation: Annotation
PlainText: string
Href?: string
Equation?: Equation
Mention?: Mention
}
export interface Text {
Content: string
Link?: Link
}
export interface Emoji {
Type: string
Emoji: string
}
export interface Annotation {
Bold: boolean
Italic: boolean
Strikethrough: boolean
Underline: boolean
Code: boolean
Color: string
}
export interface Link {
Url: string
}
export interface SelectProperty {
id: string
name: string
color: string
}
export interface LinkToPage {
Type: string
PageId: string
}
export interface Mention {
Type: string
Page?: Reference
}
export interface Reference {
Id: string
}

40
src/lib/notion/client.ts Normal file
View File

@@ -0,0 +1,40 @@
import { POSTS_API_URL } from '../data-utils'
import type { Post } from '../interfaces'
export async function getPostByPageId(pageId: string): Promise<Post | null> {
if (!pageId) return null
try {
const res = await fetch(POSTS_API_URL)
if (!res.ok) throw new Error(`Failed to fetch posts: ${res.status}`)
const payload = (await res.json()) as { posts?: any[] }
const match = (payload.posts ?? []).find((post) => post.id === pageId)
if (!match) return null
const dateString =
match['Published Date'] || match.created_time || new Date().toISOString()
return {
PageId: match.id,
Title: match.Content || match.slug || 'Untitled',
Icon: null,
Cover: null,
Slug: match.slug || match.id,
Date: dateString,
Tags: Array.isArray(match.Tags)
? match.Tags.map((name: string) => ({
id: name,
name,
color: 'default',
}))
: [],
Excerpt: match.excerpt || '',
FeaturedImage: null,
Rank: Number(match.rank || 0),
}
} catch (error) {
console.error('getPostByPageId error:', error)
return null
}
}

4
src/lib/style-helpers.ts Normal file
View File

@@ -0,0 +1,4 @@
export function snakeToKebab(text: string | undefined): string {
if (!text) return ''
return text.replace(/_/g, '-')
}

View File

@@ -22,7 +22,7 @@ export function calculateWordCountFromHtml(
}
export function readingTime(wordCount: number): string {
const readingTimeMinutes = Math.max(1, Math.round(wordCount / 200))
const readingTimeMinutes = Math.max(1, Math.round(wordCount / 100))
return `${readingTimeMinutes} min read`
}