notion-utils 全指南:用 TypeScript 工具集高效处理 Notion 数据

原创2026-10-14 18:12:371,870 阅读
文章标签:UI组件前端

notion-utils 全指南:用 TypeScript 工具集高效处理 Notion 数据

本指南以 react-notion-x 仓库中的 notion-utils 文档为骨架,结合其源码实现,系统讲解如何在 Node.js 与浏览器端解析 Notion 页面 ID、转换与规范化 URL、提取页面标题与属性、估算阅读时长、生成目录、遍历整个工作空间等核心能力。读完本文,你将能够直接在项目中引入并使用 notion-utils 提供的全部工具函数,理解每个函数背后的实现原理与适用场景。

一、notion-utils 是什么

notion-utils 是 react-notion-x(一个高性能的 Notion React 渲染器)生态中的工具库,官方定位是 "Useful utilities for working with Notion data. Isomorphic",即一套**同构(Isomorphic)**的 Notion 数据处理工具:同一份代码既可以运行在 Node.js 服务端,也可以在浏览器中直接使用,无需任何平台分支。

它的底层类型依赖来自同仓库的 notion-types(ExtendedRecordMap、Block、PageBlock 等核心类型均定义于此),而其 API 入口统一从 src/index.ts 导出。从导出清单可以看出,该包覆盖了 Notion 数据处理的完整链路:

  • ID 解析与转换:parsePageId、idToUuid、uuidToId、getCanonicalPageId
  • URL 处理:normalizeUrl、mapImageUrl、mapPageUrl、isUrl、isPublicNotionPage
  • 页面内容提取:getPageTitle、getBlockTitle、getTextContent、getPageContentBlockIds
  • 结构化数据:getPageTableOfContents、getPageProperty、getPageBreadcrumbs
  • 统计与估算:estimatePageReadTime、estimatePageReadTimeAsHumanizedString
  • 遍历与合并:getAllPagesInSpace、mergeRecordMaps
  • 多媒体资源:getPageImageUrls、getPageTweetIds、getPageTweetUrls
  • 列表与日期:getListStyle、getListNumber、getListNestingLevel、groupBlockContent、formatDate、formatNotionDateTime、getDateValue
  • 其他:normalizeTitle、getBlockValue、getBlockIcon、getBlockParentPage、getBlockCollectionId

二、安装与基本使用

按照 packages/notion-utils/readme.md 的说明,安装只需一条命令:

npm install notion-utils

包本身是纯 ESM 模块("type": "module",见 package.json),并且声明了 "sideEffects": false,因此支持 tree-shaking,在打包时只会引入你实际用到的函数。官方明确说明该包兼容 Node.js 和客户端 Web 两种环境;从 package.json 的 engines 字段可以看到,其要求 Node.js 版本 >= 18。

引入并使用第一个工具函数:

import { parsePageId } from 'notion-utils'

parsePageId(
  'https://www.notion.so/Notion-Tests-067dd719a912471ea9a3ac10710e7fdf'
)
// '067dd719-a912-471e-a9a3-ac10710e7fdf'

parsePageId('About-d9ae0c6e7cad49a78e21d240cf2e3d04')
// 'd9ae0c6e-7cad-49a7-8e21-d240cf2e3d04'

parsePageId('About-d9ae0c6e7cad49a78e21d240cf2e3d04', { uuid: false })
// 'd9ae0c6e7cad49a78e21d240cf2e3d04'

三、页面 ID 解析:parsePageId 源码剖析

parsePageId 是整个工具库中最常被使用的函数之一,也是官方文档唯一给出完整示例的函数。它的职责是从任何形态的 Notion URL 或路径片段中稳健地提取出页面 ID。我们来看它的完整实现(src/parse-page-id.ts):

import { idToUuid } from './id-to-uuid'

const pageIdRe = /\b([\da-f]{32})\b/
const pageId2Re = /\b([\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12})\b/

export const parsePageId = (
  id: string | undefined | null = '',
  { uuid = true }: { uuid?: boolean } = {}
): string | undefined => {
  if (!id) return

  id = id.split('?')[0]!
  if (!id) return

  const match = id.match(pageIdRe)

  if (match) {
    return uuid ? idToUuid(match[1]) : match[1]
  }

  const match2 = id.match(pageId2Re)
  if (match2) {
    return uuid ? match2[1] : match2[1]!.replaceAll('-', '')
  }

  return
}

实现要点:

  1. 输入容忍:参数类型为 string | undefined | null,默认空字符串;传入空值直接返回 undefined,不会抛错。
  2. 去除查询参数:id.split('?')[0] 会先截断 ? 之后的部分,因此 https://www.notion.so/xxx?foo=bar 这类带追踪参数的 URL 也能正确解析。
  3. 两种 ID 形态:
    • 32 位无连字符十六进制字符串(pageIdRe),例如 067dd719a912471ea9a3ac10710e7fdf;
    • 标准 UUID 形态(pageId2Re),例如 067dd719-a912-471e-a9a3-ac10710e7fdf。
  4. uuid 选项:默认 true,返回带连字符的 UUID 格式;设为 false 时返回不带连字符的紧凑格式。这决定了函数返回值究竟是 067dd719-a912-471e-a9a3-ac10710e7fdf 还是 067dd719a912471ea9a3ac10710e7fdf。

由于匹配基于 \b 词边界和正则定位,函数并不要求 ID 出现在 URL 的特定位置,只要输入串中存在 32 位十六进制或 UUID 形态的片段即可命中——这解释了为什么官方示例中 'About-d9ae0c6e7cad49a78e21d240cf2e3d04'(Notion 默认的 "标题-短ID" URL 风格)也能被正确解析。

四、ID 与 UUID 互转:idToUuid 与 uuidToId

Notion 内部存在两种页面 ID 表示法,notion-utils 提供了两个极简工具用于互转。

idToUuid(src/id-to-uuid.ts)把 32 位紧凑 ID 格式化为标准 UUID:

export const idToUuid = (id = '') =>
  `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`

即按 8-4-4-4-12 的切分规则插入连字符。parsePageId 的默认返回格式正是经由它生成的。

uuidToId(src/uuid-to-id.ts)则是反向操作,直接删除所有连字符:

export const uuidToId = (uuid: string) => uuid.replaceAll('-', '')

这两个函数在仓库中被广泛复用:例如 getCanonicalPageId 在生成 slug 时用 uuidToId 取得紧凑 ID;map-image-url.ts 在构造图片代理 URL 时用 idToUuid 把 32 位 block ID 还原为 UUID 形态。

五、URL 规范化与页面 URL 生成

5.1 normalizeUrl:统一 URL 格式

normalize-url.ts 对 URL 做标准化处理,供链接去重、缓存键生成等场景使用。它使用 memoize 包装(相同输入直接返回缓存结果),内部结合了 normalize-url 库:

return normalizeUrlImpl(url, {
  stripProtocol: true,       // 去掉 http:// / https:// 协议前缀
  stripWWW: true,            // 去掉 www.
  stripHash: true,           // 去掉 # 锚点
  stripTextFragment: true,   // 去掉 #:~:text= 文本片段
  removeQueryParameters: true // 去掉查询参数
})

值得注意的一个细节:实现会特判 Notion 的图片代理 URL(notion.so / notion.com 域名下以 /image/ 开头的路径)。这类 URL 的路径中编码了真实图片地址,直接按普通 URL 规范化会破坏其语义,因此源码会先解码 decodeURIComponent(u.pathname.slice('/image/'.length)) 取出内层地址,对内层地址递归规范化后再重新编码回 /image/... 路径,同时将协议与主机重写为 notionImageProxyOrigin(即 https://app.notion.com)。

5.2 mapPageUrl:默认页面 URL 映射

map-page-url.ts 导出的 defaultMapPageUrl 是一个柯里化函数,用于把页面 ID 映射为站点内的路由路径:

export const defaultMapPageUrl = (rootPageId?: string) => (pageId: string) => {
  pageId = (pageId || '').replaceAll('-', '')

  if (rootPageId && pageId === rootPageId) {
    return '/'
  } else {
    return `/${pageId}`
  }
}

传入根页面 ID 后,返回的函数会将根页面映射为首页 /,其余页面映射为 /${紧凑ID}。这正是 react-notion-x 在 Next.js 部署中把 Notion 页面路由到 /pages/[pageId].tsx(见 examples/full/pages/[pageId].tsx)的基础设施之一。

5.3 getCanonicalPageId:生成友好 slug

get-canonical-page-id.ts 生成用于 URL 的"规范化、适合展示"的页面 ID。它优先读取页面的 slug 或 Slug 属性(通过 getPageProperty 读取),若没有自定义 slug,则用 normalizeTitle(getBlockTitle(...)) 把页面标题规范化成 slug;最终输出 ${slug}-${紧凑ID}(uuid: true 时)或仅 slug(uuid: false 时)。若页面块不存在则回退为紧凑 ID。

配套的 normalize-title.ts 会把标题中的空格替换为 -、剔除除字母数字和 CJK 中日韩字符以外的符号、合并连续的 --、去掉首尾连字符并转小写,从而得到干净、可进入 URL 的 slug。

六、页面内容提取:标题与文本

6.1 getTextContent:剥离富文本格式

Notion 的富文本(Decoration)结构是嵌套数组,直接拼接会混入 ⁍、‣ 等特殊标记字符。get-text-content.ts 专门负责提取纯文本:

export const getTextContent = (text?: types.Decoration[]): string => {
  if (!text) {
    return ''
  } else if (Array.isArray(text)) {
    return (
      text?.reduce(
        (prev, current) =>
          prev + (current[0] !== '⁍' && current[0] !== '‣' ? current[0] : ''),
        ''
      ) ?? ''
    )
  } else {
    return text
  }
}

它遍历富文本数组,跳过 ⁍(文本装饰占位)与 ‣(引用/别名占位)两类控制字符,返回纯文本内容。这是 getBlockTitle、getPageProperty 等几乎所有文本类工具的底层依赖。

6.2 getBlockTitle 与 getPageTitle:取标题

get-block-title.ts 的 getBlockTitle 优先返回块的 properties.title 纯文本;对于 collection_view_page / collection_view 类型,则通过 getBlockCollectionId 找到关联的 collection,再返回 collection 的 name 文本。

get-page-title.ts 的 getPageTitle 则面向整页:取 recordMap.block 的第一个键作为根块 ID(Notion record map 约定根页面块位于第一位),取到根块后调用 getBlockTitle 返回页面标题,找不到根块时返回 null。react-notion-x 的 page-title.tsx 组件即基于此渲染页面大标题。

6.3 getPageContentBlockIds:收集页面全部块

get-page-content-block-ids.ts 从根块出发做深度遍历,收集页面上所有内容块的 ID。它不仅能沿 content 数组递归子块,还会处理三类特殊引用关系:

  • 属性中的页面引用(形如 ["‣", [["p", "<id>"]]] 的 relation 结构);
  • format.transclusion_reference_pointer.id(同步块引用);
  • format.alias_pointer.id(别名/链接到块)。

同时它遵守一个重要的边界规则:遍历到 page 或 collection_view_page 类型的非根块时不会深入其内部,从而保证只收集当前页面自身的内容块,不会把子页面内容误并入。

七、阅读时长估算:estimatePageReadTime

estimate-page-read-time.ts 实现了 Medium 风格的阅读时间估算(源码注释明确引用了 Medium 的 read-time 方法论),返回结构化统计结果:

type PageReadTimeEstimate = ContentStats & {
  totalWordsReadTimeInMinutes: number
  totalImageReadTimeInMinutes: number
  totalReadTimeInMinutes: number
}

两个可调参数(均有默认值):

参数 默认值 含义
wordsPerMinute 275 每分钟阅读字数
imageReadTimeInSeconds 12 每张图片的阅读耗时(秒)

其核心逻辑 getBlockContentStats 遍历页面块,按块类型加权统计:

  • quote、header、sub_header、sub_sub_header、header_4、callout、toggle、to_do、bulleted_list、numbered_list、text 等文本类块:统计标题中的单词数;
  • embed、tweet、maps、pdf、figma、video、drive、audio、file、image 等嵌入类块:每个按 1 张图片计;
  • bookmark:按 0.25 张图片计(内容密度较低);
  • code、table、collection_view:按 2 张图片计(复杂度更高);
  • table_of_contents:把目录条目文本计入单词数;
  • transclusion_reference:递归引用目标块的统计结果。

图片耗时计算还有两段式公式:图片数 ≤ 10 时用等差数列求和公式 n/2[2a+(n-1)d],超过 10 张时改用 (n/2)(a+b) + (n-10)*3,模拟"图片越多、单位时间越短"的阅读体验。

配套的 estimatePageReadTimeAsHumanizedString 会把总时长格式化为 "9 minutes"、"1 minute" 或 "less than a minute" 这样的可读字符串(humanizeReadTime 的实现见同文件末尾)。源码注释也诚实标注了 TODO:目前未专门处理非英文内容的计数。

八、目录生成:getPageTableOfContents

get-page-table-of-contents.ts 解析页面的 H1/H2/H3 标题块,生成目录元数据:

export interface TableOfContentsEntry {
  id: types.ID
  type: types.BlockType
  text: string
  indentLevel: number
}

实现要点:

  1. 递归扫描页面的 content,识别 header、sub_header、sub_sub_header、header_4 四类标题块,并对应缩进层级 0/1/2/3(indentLevels 常量映射);
  2. 对 transclusion_container(同步块)、column_list、column 等容器块递归深入;
  3. 通过 flattenResults 将嵌套结果扁平化;
  4. 最后用栈式算法对缩进层级做平滑处理:当层级跳变时,保证"递增时每次最多增加一级",避免出现目录缩进断层。

react-notion-x 侧栏的 page-aside.tsx 组件正是基于该函数渲染页面内目录的。

九、集合属性读取:getPageProperty

get-page-property.ts 用于读取 collection 中某个页面的属性值。它按属性名(大小写不敏感)在 collection 的 schema 中查找属性 ID,再根据属性类型做类型化转换:

schema 类型 返回值
created_time block.created_time
last_edited_time block.last_edited_time
multi_select string[](按逗号切分)
date number(单个时间点)或 number[]([开始, 结束],取决于是否启用 End Date)
checkbox boolean('Yes' 为 true)
其他文本类 string

日期属性的解析支持 datetime、date、datetimerange、daterange 四种子类型,统一转换为毫秒时间戳。函数整体包在 try/catch 中,遇到意外的 collection 数据结构也不会抛错,而是返回 null——这是服务端渲染场景下的稳健性设计(源码注释明确说明了这一意图)。

十、工作空间遍历:getAllPagesInSpace

get-all-pages-in-space.ts 是从种子页面出发、遍历整个 Notion 工作空间的核心工具。它返回一个 PageMap,包含所有从种子页面可达的页面。

函数签名与选项:

getAllPagesInSpace(
  rootPageId: string,
  rootSpaceId: string | undefined,
  getPage: (pageId: string) => Promise<ExtendedRecordMap>,
  {
    concurrency = 4,             // 并发抓取数,基于 p-queue
    traverseCollections = true,  // 是否遍历 collection 中的子页面
    targetPageId,                // 可选:找到目标页后提前终止
    maxDepth = Number.POSITIVE_INFINITY // 最大遍历深度
  }
): Promise<PageMap>

实现机制:

  • 用 PQueue(p-queue)实现限并发(默认 4)的页面抓取队列;
  • 每个页面经 parsePageId 规范化后去重(pages 与 pendingPageIds 双重集合);
  • 空间隔离:若未显式传 rootSpaceId,则以根页面的 space_id 为基准;遍历中若发现某页面的 space_id 与基准不一致则跳过——因为 Notion 页面可以跨空间引用,这个检查对防止无限扩散遍历至关重要;
  • 子页面判定:只把 alive !== false 且类型为 page / collection_view_page 且空间一致的块视为待遍历页面;
  • 当 traverseCollections 为 true 时,还会从 collection_query 中收集 collection 行(条目页)ID 并继续遍历,因为条目页内部可能还包含子页面;
  • 单页抓取失败时不会中断整体流程,而是 console.warn 记录错误并把该页置为 null(见 pages[pageId] = null)。

该函数在 react-notion-x 的静态站点生成(SSG)流程中用于预取整站页面数据,配合 examples/full/lib/notion.ts 中的 getPage 封装使用。

十一、图片 URL 解析:mapImageUrl 与 getPageImageUrls

11.1 签名 URL 与过期检测

map-image-url.ts 是处理 Notion 私有文件 URL 的核心模块。Notion 的文件 URL 带有临时签名,直接使用会过期失效,因此该模块提供了一整套识别与解析机制:

  • isNotionHost:判断域名是否为 notion.so / notion.com 及其子域;
  • isNotionSignedFileUrl:递归(最多 5 层)判断 URL 是否携带 Notion 文件签名(支持 file.notion.com、file.notion.so、img.notionusercontent.com 的 signature/sig/tok 参数,以及 secure.notion-static.com 等旧域名的 X-Amz-Signature/Signature 参数);
  • isNotionFileUrlExpired:解析 expirationTimestamp、Expires、exp、AWS X-Amz-Expires 与 X-Amz-Date、CDN tok JWT(解码 payload 读取 exp 声明)等多种过期时间来源;
  • getStableNotionFileSource:从临时文件 URL(如 /f/f/<space>/<file>/<name> 路径)和 img.notionusercontent.com/s3/... 附件路径还原出稳定的 attachment:<fileId>:<filename> 源,供签名失效后重新解析;
  • getSignedFileUrl:优先按原始 URL、底层文件 URL、稳定源三种键在 recordMap.signed_urls 中查找签名;旧格式 record map 则回退到按 block ID 查找。

11.2 defaultMapImageUrl 与 resolveDefaultImageUrl

defaultMapImageUrl 是 react-notion-x 默认的图片 URL 映射器,逻辑分层清晰:

  1. data: / blob: 内联 URL 直接原样返回;
  2. 域名属于 Notion 且路径为 /images/、/icons/ 的静态资源,改写协议与主机到 app.notion.com;
  3. 从代理 URL / 稳定源解析出私有文件源后,构造 https://app.notion.com/image/<encodeURIComponent(source)>?table=<table>&id=<uuid>&cache=v2 形式的代理 URL(table 依据 block.parent_table 推断,collection/team 归一为 block);
  4. 其他 http/https URL 原样返回;相对路径 /images/、/icons/ 则拼上 app.notion.com 前缀。

resolveDefaultImageUrl 则在非公开(私有)页面场景下优先使用 signed_urls 中的签名 URL,并要求签名有效期至少还有 minimumSignedUrlValidity(60 秒);签名即将过期或已是过期形式时回退到默认映射;如果映射结果仍是带签名的临时 URL,则返回 undefined 以避免输出即将失效的资源。

11.3 getPageImageUrls:提取页面全部图片

get-page-image-urls.ts 遍历 record map 中所有块,收集五类图片来源:image 块的 source 属性、page_cover、bookmark_cover、bookmark_icon、以及页面图标(getBlockIcon 且为 URL)。随后对每个 URL 调用 resolveDefaultImageUrl(公开性判断来自 isPublicNotionBlock),并允许通过 mapImageUrl 参数注入自定义映射器,最后去重返回。它在 react-notion-x 的静态导出流程中用于预取图片(见 examples/full/lib/preview-images.ts)。

十二、推文、面包屑与公共页面判定

推文提取:get-page-tweet-urls.ts 收集所有 tweet 类型块的 source URL 并去重;get-page-tweet-ids.ts 在其基础上解析 URL 路径最后一段,返回推文 ID 列表。两者结合可用于在渲染前预取 Twitter 卡片数据。

面包屑导航:get-page-breadcrumbs.ts 从当前页面沿父链向上回溯,借助 getBlockTitle 与 getBlockIcon 为每一级生成 { block, active, pageId, title, icon } 条目,直到遇到无标题无图标或没有父页面的块为止,最后反转为从根到当前的顺序。

公共页面判定:is-public-notion-page.ts 通过检查块链上的 permissions 是否包含 public_permission 来判断页面是否公开。它从根块沿 parent_id 向上遍历(兼容 32 位紧凑 ID 与 UUID 两种键形态),一旦发现显式权限:存在 public_permission 即返回 true,否则返回 false;若整条链都没有显式权限,则回退为扫描整个 record map 是否含公开权限(因为某些 collection 行缺少完整的父链)。isPublicNotionBlock 则用于判断某个图片所属块是否继承公开访问权,被 getPageImageUrls 直接调用。

十三、列表编号、嵌套层级与块分组

针对 Notion 的有序列表,notion-utils 提供了三件套:

  • group-block-content.ts 的 groupBlockContent:把块的 content 子块按类型连续分组,返回 string[][](每组是一段连续同类型块的 ID 数组);
  • get-list-number.ts 的 getListNumber:在分组中找到目标块,返回其在组内的序号;若块设置了 format.list_start_index(Notion 有序列表的起始序号),则以它为准;
  • get-list-nesting-level.ts 的 getListNestingLevel:沿 parent_id 向上数出嵌套层数,仅统计父块类型为 numbered_list 的层级。

配套的 get-list-style.ts 的 getListStyle 按层级返回 CSS 列表样式名:第 0 层 decimal(数字)、第 1 层 lower-alpha(小写字母)、第 2 层 lower-roman(小写罗马数字),更深的层级按 3 取模循环。

十四、日期格式化

  • format-date.ts 的 formatDate:接受时间戳或日期字符串,输出如 "Jan 1, 2026" 的格式(月份可选 short / long,默认 short,基于 UTC);
  • format-notion-date-time.ts 的 formatNotionDateTime:接收 Notion 日期属性结构 { type: 'datetime', start_date, start_time?, time_zone? },拼出 start_dateTstart_time+00:00 字符串(无时间时用 00:00 补足,+00:00 确保按 UTC 处理)后交给 formatDate;
  • get-date-value.ts 的 getDateValue:递归地在 Notion 属性数组中寻找形如 ['d', FormattedDate] 的日期值,适用于从嵌套结构中稳健取日期。

十五、Record Map 合并与通用取值

merge-record-maps.ts 的 mergeRecordMaps 把两个 ExtendedRecordMap 按七个键(block、collection、collection_view、notion_user、collection_query、signed_urls、preview_images)做浅层合并。它在分页抓取或多数据源聚合场景中用于把多个页面的数据拼成一个完整的 record map(后合并的键覆盖先合并的同名键)。

get-block-value.ts 的 getBlockValue 是一个泛型"拆箱"工具:Notion 曾把部分块的 value 改成双层嵌套结构(见源码注释引用的 react-notion-x issue #682),该函数递归取出 .value,保证调用方拿到的是真正的块对象;对于无 .value 且无 .id 的占位,返回 undefined。几乎所有的 getXxx 工具都经由它读取块数据。

十六、测试与质量保障

notion-utils 在 package.json 中配置了 test 脚本(run-s test:*),包含 test:typecheck(tsc --noEmit)与 test:unit(vitest run)。仓库中与它配套的测试用例可直接作为行为契约阅读:

react-notion-x 的 packages/notion-utils/fixtures/nba 目录还提供了包含大量真实结构数据的 NBA 主题 fixture,可用来直观了解 ExtendedRecordMap 的真实形态。

十七、在 react-notion-x 中的典型组合用法

作为 react-notion-x 的官方工具层,这些函数通常组合出现。一个典型的静态博客服务端流程是:

  1. 用 parsePageId 把用户输入的 Notion URL 解析为页面 ID;
  2. 用 notion-client 抓取页面得到 ExtendedRecordMap;
  3. 用 getPageTitle / getPageProperty 提取页面元数据(标题、slug、日期等);
  4. 用 getCanonicalPageId 生成站点路由 slug;
  5. 用 defaultMapPageUrl 把页面 ID 映射为站点路径;
  6. 用 getPageImageUrls / getPageTweetIds 预取外部资源;
  7. 用 estimatePageReadTime 计算阅读时长并展示;
  8. 用 getPageTableOfContents 渲染侧栏目录;
  9. 最后把 record map 交给 react-notion-x 的 NotionRenderer(见 examples/full/components/NotionPage.tsx)渲染成页面。

需要整站静态化时,再用 getAllPagesInSpace 从种子页出发遍历整个工作空间,逐个页面生成静态页。

结语

notion-utils 用一组小而精的同构工具函数,覆盖了从 ID 解析、URL 规范化、内容提取到全空间遍历的完整 Notion 数据处理链路。本文基于 packages/notion-utils/readme.md 官方文档,并结合 src 目录 的逐文件源码实现展开讲解。无论你是要在自己的项目中独立处理 Notion 数据,还是想深入理解 react-notion-x 渲染管线的前置数据处理环节,都可以从 notion-utils 出发,逐函数阅读源码与配套测试,快速建立起对 Notion 数据模型的完整认知。

登录后查看全文
react-notion-x