mirror of
https://github.com/lbr77/blog-astro.git
synced 2026-04-08 16:11:56 +00:00
1023 lines
27 KiB
TypeScript
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
|
|
}
|