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

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