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( name: T, ): Promise[]> { 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 content?: string[] format?: Record synced_from?: Record link_to_page_id?: string parent_table?: string } export type NotionBlock = { value: NotionBlockValue } export type NotionBlockMap = Record 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[]> { 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[]> { 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> { 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()) } 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[]> { const posts = await getAllPosts() return posts.filter((post) => post.data.authors?.includes(authorId)) } export async function getPostsByTag( tag: string, ): Promise[]> { const posts = await getAllPosts() return posts.filter((post) => post.data.tags?.includes(tag)) } export async function getRecentPosts( count: number, ): Promise[]> { 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[]> { 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[]> { return posts.reduce( (acc: Record[]>, post) => { const year = post.data.date.getFullYear().toString() ;(acc[year] ??= []).push(post) return acc }, {}, ) } export async function hasSubposts(postId: string): Promise { return false } export function isSubpost(postId: string): boolean { return false } export async function fetchRemotePost( slug: string, ): Promise { 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 { 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 { 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 | 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 | null> { const allPosts = await getAllPostsAndSubposts() return allPosts.find((post) => post.id === postId) || null } export async function getSubpostCount(parentId: string): Promise { const subposts = await getSubpostsForParent(parentId) return subposts.length } export async function getCombinedReadingTime(postId: string): Promise { 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 { 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 { 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 }