Files
blog-astro/src/lib/data-utils.ts
2025-12-28 09:12:20 +08:00

1023 lines
27 KiB
TypeScript

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 ContentCollection = 'blog' | 'authors' | 'projects'
async function getCollectionSafe<T extends ContentCollection>(
name: T,
): Promise<CollectionEntry<T>[]> {
try {
return await getCollection(name)
} catch {
return []
}
}
type NotionPost = {
id: string
slug?: string
Content?: string
excerpt?: string
Tags?: string[]
Published?: boolean
'Published Date'?: string
created_time?: string
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: CollectionEntry<'blog'>
blockMap: NotionBlockMap
rootId: string
}
export interface LinkEntry {
id: string
picLink?: string
published?: boolean
Description?: string
links: string
name: string
created_time?: string
last_edited_time?: string
}
export async function getAllAuthors(): Promise<CollectionEntry<'authors'>[]> {
return getCollectionSafe('authors')
}
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: '',
}
}
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,
)
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'>[]
} catch (error) {
console.error('getAllPosts remote fetch failed, using fallback:', error)
return []
}
}
export async function getAllPostsAndSubposts(): Promise<
CollectionEntry<'blog'>[]
> {
return getAllPosts()
}
export async function getAllTags(): Promise<Map<string, number>> {
const posts = await getAllPosts()
return posts.reduce((acc, post) => {
post.data.tags?.forEach((tag) => {
acc.set(tag, (acc.get(tag) || 0) + 1)
})
return acc
}, new Map<string, number>())
}
export async function getAdjacentPosts(currentId: string): Promise<{
newer: CollectionEntry<'blog'> | null
older: CollectionEntry<'blog'> | null
parent: CollectionEntry<'blog'> | null
}> {
const allPosts = await getAllPosts()
const parentPosts = allPosts.filter((post) => !isSubpost(post.id))
const currentIndex = parentPosts.findIndex((post) => post.id === currentId)
if (currentIndex === -1) {
return { newer: null, older: null, parent: null }
}
return {
newer: currentIndex > 0 ? parentPosts[currentIndex - 1] : null,
older:
currentIndex < parentPosts.length - 1
? parentPosts[currentIndex + 1]
: null,
parent: null,
}
}
export async function getPostsByAuthor(
authorId: string,
): Promise<CollectionEntry<'blog'>[]> {
const posts = await getAllPosts()
return posts.filter((post) => post.data.authors?.includes(authorId))
}
export async function getPostsByTag(
tag: string,
): Promise<CollectionEntry<'blog'>[]> {
const posts = await getAllPosts()
return posts.filter((post) => post.data.tags?.includes(tag))
}
export async function getRecentPosts(
count: number,
): Promise<CollectionEntry<'blog'>[]> {
const posts = await getAllPosts()
return posts.slice(0, count)
}
export async function getSortedTags(): Promise<
{ tag: string; count: number }[]
> {
const tagCounts = await getAllTags()
return [...tagCounts.entries()]
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => {
const countDiff = b.count - a.count
return countDiff !== 0 ? countDiff : a.tag.localeCompare(b.tag)
})
}
export function getParentId(subpostId: string): string {
return subpostId.split('/')[0]
}
export async function getSubpostsForParent(
parentId: string,
): Promise<CollectionEntry<'blog'>[]> {
const posts = await getCollectionSafe('blog')
return posts
.filter(
(post) =>
!post.data.draft &&
isSubpost(post.id) &&
getParentId(post.id) === parentId,
)
.sort((a, b) => {
const dateDiff = a.data.date.valueOf() - b.data.date.valueOf()
if (dateDiff !== 0) return dateDiff
const orderA = a.data.order ?? 0
const orderB = b.data.order ?? 0
return orderA - orderB
})
}
export function groupPostsByYear(
posts: CollectionEntry<'blog'>[],
): Record<string, CollectionEntry<'blog'>[]> {
return posts.reduce(
(acc: Record<string, CollectionEntry<'blog'>[]>, post) => {
const year = post.data.date.getFullYear().toString()
;(acc[year] ??= []).push(post)
return acc
},
{},
)
}
export async function hasSubposts(postId: string): Promise<boolean> {
return false
}
export function isSubpost(postId: string): boolean {
return false
}
export async function fetchRemotePost(
slug: string,
): Promise<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
return data
} catch (error) {
console.error(`fetchRemotePost error for slug "${slug}":`, error)
return null
}
}
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<{
post: NotionPost
blockMap: NotionBlockMap
}>
if (!data.post || !data.blockMap) return null
const rootId = data.post.id
const post = normalizePost(data.post)
return {
post,
blockMap: data.blockMap,
rootId,
}
} catch (error) {
console.error(`fetchRemotePostContent error for slug "${slug}":`, error)
return null
}
}
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[]
if (!Array.isArray(data)) throw new Error('Invalid links data format')
return data
} catch (error) {
console.error('getFriendLinks error:', error)
return []
}
}
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
}
export async function parseAuthors(authorIds: string[] = []) {
if (!authorIds.length) return []
const allAuthors = await getAllAuthors()
const authorMap = new Map(allAuthors.map((author) => [author.id, author]))
return authorIds.map((id) => {
const author = authorMap.get(id)
return {
id,
name: author?.data?.name || id,
avatar: author?.data?.avatar || '/static/logo.png',
isRegistered: !!author,
}
})
}
export async function getPostById(
postId: string,
): Promise<CollectionEntry<'blog'> | null> {
const allPosts = await getAllPostsAndSubposts()
return allPosts.find((post) => post.id === postId) || null
}
export async function getSubpostCount(parentId: string): Promise<number> {
const subposts = await getSubpostsForParent(parentId)
return subposts.length
}
export async function getCombinedReadingTime(postId: string): Promise<string> {
const post = await getPostById(postId)
if (!post) return readingTime(0)
let totalWords = calculateWordCountFromHtml(post.body)
if (!isSubpost(postId)) {
const subposts = await getSubpostsForParent(postId)
for (const subpost of subposts) {
totalWords += calculateWordCountFromHtml(subpost.body)
}
}
return readingTime(totalWords)
}
export async function getPostReadingTime(postId: string): Promise<string> {
const post = await getPostById(postId)
if (!post) return readingTime(0)
const wordCount = calculateWordCountFromHtml(post.body)
return readingTime(wordCount)
}
export type TOCHeading = {
slug: string
text: string
depth: number
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
let equationExpression: 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 'e': {
if (typeof value === 'string') {
equationExpression = value
} else if (Array.isArray(value) && typeof value[0] === 'string') {
equationExpression = value[0]
} else if (typeof text === 'string') {
equationExpression = text
}
break
}
case 'h':
if (typeof value === 'string') {
annotation.Color = value
}
break
default:
break
}
}
}
if (equationExpression) {
const richText: RichText = {
Equation: { Expression: equationExpression },
Annotation: { ...annotation },
PlainText: equationExpression,
}
if (href) {
richText.Href = href
}
if (mentionPageId) {
richText.Mention = {
Type: 'page',
Page: { Id: mentionPageId },
}
}
return richText
}
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 'equation': {
const expression =
block.properties?.title?.[0]?.[0]?.toString() ||
block.properties?.equation?.[0]?.[0]?.toString() ||
''
if (!expression) return null
return {
Id: block.id,
Type: 'equation',
HasChildren: false,
Equation: { Expression: expression },
}
}
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?.[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?.title),
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 fallbackRoot =
root ||
Object.values(blockMap)
.map((b) => b.value)
.find(
(value) =>
Array.isArray(value?.content) &&
(value.type === 'page' || value.type === 'collection_view_page'),
)
const contentOrder = Array.isArray(fallbackRoot?.content)
? fallbackRoot.content
: []
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
headings: TOCHeading[]
subpostId?: string
}
export async function getTOCSections(postId: string): Promise<TOCSection[]> {
const post = await getPostById(postId)
if (!post) return []
const parentPost = post
if (!parentPost) return []
const sections: TOCSection[] = []
const { headings } = await render(parentPost)
if (headings.length > 0) {
sections.push({
type: 'parent',
title: 'Overview',
headings: headings.map((heading) => ({
slug: heading.slug,
text: heading.text,
depth: heading.depth,
})),
})
}
return sections
}