Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 98 additions & 6 deletions src/components/mdx-component.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<string, string> = {
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 }) => (
<h1 className="mb-4 font-bold text-4xl">{children}</h1>
Expand All @@ -34,11 +69,68 @@ const components = {
li: ({ children }: { children?: React.ReactNode }) => (
<li className="mb-2">{children}</li>
),
blockquote: ({ children }: { children?: React.ReactNode }) => (
<blockquote className="mb-4 border-neutral-300 border-l-2 py-2 pl-4 italic">
{children}
</blockquote>
),
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<any>,
{},
newPChildren
),
...childrenArray.slice(1),
];

return (
<div
className={cn(
"my-6 flex gap-3 rounded-md border px-4 py-3",
"text-foreground",
colorClass
)}
>
<div className="mt-0.5 select-none text-lg leading-none">{icon}</div>
<div className="min-w-0 flex-1">{contentChildren}</div>
</div>
);
}
}

return (
<blockquote className="mb-4 border-neutral-300 border-l-2 py-2 pl-4 italic">
{children}
</blockquote>
);
},
code: ({
className,
children,
Expand Down
143 changes: 143 additions & 0 deletions src/lib/notion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("&", "&amp;")
.replaceAll("\"", "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}

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<string> {
if (block.type !== 'image' || block.image.type !== 'file') {
Expand Down Expand Up @@ -78,6 +190,36 @@ async function processImageBlock(block: BlockObjectResponse): Promise<string> {
return `![${altText}](${githubRawUrl})`;
}

async function processCalloutBlock(block: any): Promise<string> {
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 = `<span data-callout="true" data-icon="${escapeHtmlAttribute(
iconText
)}" data-color="${escapeHtmlAttribute(String(color))}"></span>`;
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!,
Expand Down Expand Up @@ -138,6 +280,7 @@ export async function getPostFromNotion(pageId: string): Promise<Post | null> {

// 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;
Expand Down