diff --git a/src/components/mdx-component.tsx b/src/components/mdx-component.tsx index 4a02909..4499d4a 100644 --- a/src/components/mdx-component.tsx +++ b/src/components/mdx-component.tsx @@ -1,6 +1,6 @@ import { cn } from "@/lib/utils"; import Image, { type ImageProps } from "next/image"; -import type React from "react"; +import * as React from "react"; import { Table, TableBody, @@ -13,6 +13,41 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism"; import { Badge } from "./ui/badge"; +const CALLOUT_COLOR_CLASSES: Record = { + default: "bg-muted/40 border-border", + default_background: "bg-muted/40 border-border", + gray: "bg-muted/40 border-border", + gray_background: "bg-muted border-border", + brown: "bg-amber-50 dark:bg-amber-950/25 border-amber-200 dark:border-amber-900/40", + brown_background: + "bg-amber-50 dark:bg-amber-950/25 border-amber-200 dark:border-amber-900/40", + orange: + "bg-orange-50 dark:bg-orange-950/25 border-orange-200 dark:border-orange-900/40", + orange_background: + "bg-orange-50 dark:bg-orange-950/25 border-orange-200 dark:border-orange-900/40", + yellow: + "bg-yellow-50 dark:bg-yellow-950/25 border-yellow-200 dark:border-yellow-900/40", + yellow_background: + "bg-yellow-50 dark:bg-yellow-950/25 border-yellow-200 dark:border-yellow-900/40", + green: + "bg-emerald-50 dark:bg-emerald-950/25 border-emerald-200 dark:border-emerald-900/40", + green_background: + "bg-emerald-50 dark:bg-emerald-950/25 border-emerald-200 dark:border-emerald-900/40", + blue: "bg-blue-50 dark:bg-blue-950/25 border-blue-200 dark:border-blue-900/40", + blue_background: + "bg-blue-50 dark:bg-blue-950/25 border-blue-200 dark:border-blue-900/40", + purple: + "bg-purple-50 dark:bg-purple-950/25 border-purple-200 dark:border-purple-900/40", + purple_background: + "bg-purple-50 dark:bg-purple-950/25 border-purple-200 dark:border-purple-900/40", + pink: "bg-pink-50 dark:bg-pink-950/25 border-pink-200 dark:border-pink-900/40", + pink_background: + "bg-pink-50 dark:bg-pink-950/25 border-pink-200 dark:border-pink-900/40", + red: "bg-red-50 dark:bg-red-950/25 border-red-200 dark:border-red-900/40", + red_background: + "bg-red-50 dark:bg-red-950/25 border-red-200 dark:border-red-900/40", +}; + const components = { h1: ({ children }: { children?: React.ReactNode }) => (

{children}

@@ -34,11 +69,68 @@ const components = { li: ({ children }: { children?: React.ReactNode }) => (
  • {children}
  • ), - blockquote: ({ children }: { children?: React.ReactNode }) => ( -
    - {children} -
    - ), + blockquote: ({ children }: { children?: React.ReactNode }) => { + const childrenArray = React.Children.toArray(children); + const firstChild = childrenArray[0]; + + if (React.isValidElement(firstChild)) { + const pChildren = React.Children.toArray( + (firstChild.props as { children?: React.ReactNode }).children + ); + const marker = pChildren[0]; + + if ( + React.isValidElement(marker) && + marker.type === "span" && + (marker.props as any)?.["data-callout"] + ) { + const icon = (marker.props as any)?.["data-icon"] ?? "💡"; + const color = String((marker.props as any)?.["data-color"] ?? "default"); + const colorClass = CALLOUT_COLOR_CLASSES[color] ?? CALLOUT_COLOR_CLASSES.default; + + const newPChildren = pChildren.slice(1); + if (typeof newPChildren[0] === "string") { + newPChildren[0] = newPChildren[0].replace(/^ /, ""); + } + + const firstParagraphIsEmpty = + newPChildren.length === 0 || + newPChildren.every( + (node) => typeof node === "string" && node.trim().length === 0 + ); + + const contentChildren = firstParagraphIsEmpty + ? childrenArray.slice(1) + : [ + React.cloneElement( + firstChild as React.ReactElement, + {}, + newPChildren + ), + ...childrenArray.slice(1), + ]; + + return ( +
    +
    {icon}
    +
    {contentChildren}
    +
    + ); + } + } + + return ( +
    + {children} +
    + ); + }, code: ({ className, children, diff --git a/src/lib/notion.ts b/src/lib/notion.ts index 7a227cf..8545e81 100644 --- a/src/lib/notion.ts +++ b/src/lib/notion.ts @@ -23,6 +23,118 @@ const IMAGES_PATH = 'public/images/notion'; export type { Post }; export { getWordCount } from "./utils"; +type NotionColor = + | "default" + | "gray" + | "brown" + | "orange" + | "yellow" + | "green" + | "blue" + | "purple" + | "pink" + | "red" + | "default_background" + | "gray_background" + | "brown_background" + | "orange_background" + | "yellow_background" + | "green_background" + | "blue_background" + | "purple_background" + | "pink_background" + | "red_background" + | (string & {}); + +type NotionRichText = { + type: "text" | "equation" | (string & {}); + plain_text: string; + href: string | null; + annotations?: { + bold: boolean; + italic: boolean; + strikethrough: boolean; + underline: boolean; + code: boolean; + color: NotionColor; + }; + equation?: { expression: string }; +}; + +type NotionCalloutIcon = + | { type: "emoji"; emoji?: string } + | { type: "external"; external?: { url: string } } + | { type: "file"; file?: { url: string; expiry_time: string } } + | { type: "custom_emoji"; custom_emoji?: { id: string; name: string; url: string } } + | null; + +async function getBlockChildren(blockId: string, totalPage: number | null = null) { + const result: any[] = []; + let pageCount = 0; + let start_cursor: string | undefined = undefined; + + do { + const response = (await notion.blocks.children.list({ + start_cursor, + block_id: blockId, + })) as any; + + result.push(...(response.results ?? [])); + start_cursor = response?.next_cursor ?? undefined; + pageCount += 1; + } while (start_cursor != null && (totalPage == null || pageCount < totalPage)); + + // notion-to-md expects numbered_list_item to be sequentially numbered. + let numberedListIndex = 0; + for (const block of result) { + if (block && typeof block === "object" && block.type === "numbered_list_item") { + block.numbered_list_item = block.numbered_list_item ?? {}; + block.numbered_list_item.number = ++numberedListIndex; + } else { + numberedListIndex = 0; + } + } + + return result; +} + +function escapeHtmlAttribute(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("\"", """) + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function richTextToMarkdown(richText: NotionRichText[] | undefined) { + const parts: string[] = []; + for (const content of richText ?? []) { + if (!content) continue; + + if (content.type === "equation" && content.equation?.expression) { + parts.push(`$${content.equation.expression}$`); + continue; + } + + const annotations = content.annotations as any; + let plainText = content.plain_text ?? ""; + if (annotations) { + plainText = n2m.annotatePlainText(plainText, annotations); + } + if (content.href) { + plainText = `[${plainText}](${content.href})`; + } + parts.push(plainText); + } + return parts.join(""); +} + +function toBlockquoteMarkdown(blockquoteContent: string) { + return (blockquoteContent || "") + .split("\n") + .map((line) => (line.trim().length === 0 ? ">" : `> ${line}`)) + .join("\n"); +} async function processImageBlock(block: BlockObjectResponse): Promise { if (block.type !== 'image' || block.image.type !== 'file') { @@ -78,6 +190,36 @@ async function processImageBlock(block: BlockObjectResponse): Promise { return `![${altText}](${githubRawUrl})`; } +async function processCalloutBlock(block: any): Promise { + if (!block || typeof block !== "object" || block.type !== "callout") return ""; + + const icon: NotionCalloutIcon = block.callout?.icon ?? null; + const iconText = + icon?.type === "emoji" && icon.emoji?.trim().length ? icon.emoji : "💡"; + + const color: NotionColor = block.callout?.color ?? "default"; + + const calloutText = richTextToMarkdown(block.callout?.rich_text); + + let childrenMarkdown = ""; + if (block.has_children) { + const childrenBlocks = await getBlockChildren(block.id, 100); + const childrenMdBlocks = await n2m.blocksToMarkdown(childrenBlocks as any); + childrenMarkdown = (n2m.toMarkdownString(childrenMdBlocks).parent ?? "").trim(); + } + + const combined = [calloutText.trim(), childrenMarkdown.trim()] + .filter(Boolean) + .join("\n\n") + .trim(); + + const marker = ``; + const blockquoteContent = combined.length ? `${marker}\n\n${combined}` : marker; + return toBlockquoteMarkdown(blockquoteContent); +} + export async function getDatabaseStructure() { const database = await notion.databases.retrieve({ database_id: process.env.NOTION_DATABASE_ID!, @@ -138,6 +280,7 @@ export async function getPostFromNotion(pageId: string): Promise { // Set custom transformer for image blocks n2m.setCustomTransformer("image", processImageBlock as any); + n2m.setCustomTransformer("callout", processCalloutBlock as any); const mdBlocks = await n2m.pageToMarkdown(pageId); const mdResult = n2m.toMarkdownString(mdBlocks) as any;