mirror of
https://github.com/lbr77/blog-astro.git
synced 2026-04-09 00:19:12 +00:00
More customzation
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user