npm package: @hansolbangul/notion-render
Description: Reusable Notion collection parsing, rendering, SEO, and Next.js helpers.
Latest version: 0.0.3

# @hansolbangul/notion-render

`@hansolbangul/notion-render`는 공개 Notion 페이지를 CMS처럼 읽어서 Next.js 블로그나 문서 사이트에 연결할 때 쓰는 라이브러리입니다.

이 패키지는 아래 흐름을 재사용 가능하게 분리하는 데 초점을 둡니다.

- Notion 컬렉션 페이지 읽기
- 페이지 속성 정규화
- 게시글 필터링
- 태그/카테고리 집계
- `generateStaticParams` 생성
- `react-notion-x` 기반 렌더링
- Next.js SEO 메타데이터 생성
- SSG / ISR 연결

중요:

- 이 패키지는 공식 Notion REST API가 아니라 `notion-client` 기반의 public page `recordMap` 방식을 사용합니다.
- 즉, 공개 가능한 Notion 페이지를 읽는 구조입니다.

## 이 패키지가 하는 일

- Notion 컬렉션 페이지에서 row 목록을 가져옵니다.
- 각 row의 속성을 읽어 일반적인 JS 객체로 바꿉니다.
- `slug`, `status`, `type`, `date` 같은 값을 기준으로 게시 가능한 글만 골라냅니다.
- 태그, 카테고리 같은 다중 선택 속성을 집계합니다.
- 상세 페이지 렌더링용 `recordMap`을 가져옵니다.
- `react-notion-x` 렌더러를 감싼 공용 컴포넌트를 제공합니다.
- Next.js `Metadata`와 JSON-LD 생성 헬퍼를 제공합니다.

## 이 패키지가 하지 않는 일

- 카드 UI, 리스트 UI, 헤더, 푸터를 만들지는 않습니다.
- 댓글, 공유 버튼, 테마 토글까지 포함하지는 않습니다.
- 프로젝트별 고유한 `BlogPost` 타입을 강제로 고정하지는 않습니다.

## 설치

이미 Next.js + React 앱이 있다면 보통 아래 한 줄이면 됩니다.

```bash
yarn add @hansolbangul/notion-render
```

직접 의존성을 함께 명시해서 설치하고 싶다면:

```bash
yarn add @hansolbangul/notion-render notion-client notion-types notion-utils react-notion-x prismjs katex
yarn add next react react-dom
```

## 모듈 구성

### `@hansolbangul/notion-render/server/notion-api`

서버에서 Notion 데이터를 읽을 때 사용합니다.

주요 export:

- `createNotionClient`
- `getCollectionItems`
- `getPageRecordMap`
- `resolvePageProperties`

### `@hansolbangul/notion-render/server/filters`

정규화된 row 데이터를 실제 게시글 목록으로 다듬을 때 사용합니다.

주요 export:

- `filterPublishedItems`
- `countMultiValueOptions`
- `toStaticParams`
- `findAdjacentSlugs`

### `@hansolbangul/notion-render/next/metadata`

Next.js App Router 기준 SEO 메타데이터를 만들 때 사용합니다.

주요 export:

- `buildSiteMetadata`
- `buildArticleMetadata`
- `buildWebsiteJsonLd`

### `@hansolbangul/notion-render/react/NotionContent`

클라이언트 컴포넌트에서 `recordMap`을 렌더링할 때 사용합니다.

주의:

- Notion 렌더링 스타일 CSS는 라이브러리 내부에서 자동 주입하지 않습니다.
- 앱 루트 레이아웃에서 아래 CSS를 직접 import 해야 합니다.

```ts
import "react-notion-x/src/styles.css";
import "prismjs/themes/prism-tomorrow.css";
import "katex/dist/katex.min.css";
```

주요 export:

- `NotionContent`
- `defaultMapPageUrl`

## 빠른 시작

### 1. 컬렉션 페이지 읽기

```ts
import { getCollectionItems } from "@hansolbangul/notion-render/server/notion-api";

type BlogPost = {
  id: string;
  title: string;
  slug: string;
  status: string[];
  type: string[];
  tags?: string[];
  summary?: string;
  thumbnail?: string;
  date?: {
    start_date?: string;
  };
  createdTime: string;
  fullWidth: boolean;
};

export async function getPosts(pageId: string) {
  return getCollectionItems<BlogPost>({ pageId });
}
```

### 2. 게시 가능한 글만 필터링

```ts
import { filterPublishedItems } from "@hansolbangul/notion-render/server/filters";

const publishedPosts = filterPublishedItems(posts, {
  acceptStatus: ["Public"],
  acceptType: ["Post"],
});
```

### 3. 정적 params 생성

```ts
import { toStaticParams } from "@hansolbangul/notion-render/server/filters";

export async function generateStaticParams() {
  const posts = await getPosts(process.env.NOTION_PAGE_ID!);
  return toStaticParams(posts);
}
```

### 4. 상세 페이지용 recordMap 읽기

```ts
import { getPageRecordMap } from "@hansolbangul/notion-render/server/notion-api";

export async function getPostDetail(postId: string) {
  return getPageRecordMap(postId);
}
```

### 5. Notion 페이지 렌더링

```tsx
"use client";

import { NotionContent } from "@hansolbangul/notion-render/react/NotionContent";
import type { ExtendedRecordMap } from "notion-types";

type Props = {
  recordMap: ExtendedRecordMap;
  title: string;
};

export function ArticleBody({ recordMap, title }: Props) {
  return (
    <NotionContent
      recordMap={recordMap}
      className="bg-white"
      pageTitle={<h1>{title}</h1>}
    />
  );
}
```

## SEO 설정 예시

### 사이트 메타데이터

```ts
import {
  buildSiteMetadata,
  buildWebsiteJsonLd,
} from "@hansolbangul/notion-render/next/metadata";

export const metadata = buildSiteMetadata({
  title: "내 노션 블로그",
  description: "노션을 CMS로 사용하는 블로그",
  siteUrl: "https://example.com",
  keywords: ["blog", "notion"],
  locale: "ko_KR",
  siteName: "내 노션 블로그",
  image: {
    url: "/og.png",
    alt: "내 노션 블로그",
    width: 1200,
    height: 630,
  },
});

const websiteJsonLd = buildWebsiteJsonLd({
  title: "내 노션 블로그",
  description: "노션을 CMS로 사용하는 블로그",
  siteUrl: "https://example.com",
  siteName: "내 노션 블로그",
  image: {
    url: "/og.png",
    width: 1200,
    height: 630,
  },
});
```

### 게시글 메타데이터

```ts
import { buildArticleMetadata } from "@hansolbangul/notion-render/next/metadata";

export async function generateMetadata() {
  return buildArticleMetadata({
    title: post.title,
    description: post.summary || post.title,
    siteUrl: "https://example.com",
    path: `/post/${post.slug}`,
    keywords: post.tags,
    image: post.thumbnail
      ? {
          url: post.thumbnail,
          alt: post.title,
          width: 1200,
          height: 630,
        }
      : undefined,
    publishedTime: post.date?.start_date,
    authors: post.author?.map((author) => author.name).filter(Boolean),
  });
}
```

## SSG / ISR 추천 패턴

권장 흐름은 아래와 같습니다.

1. 서버에서 컬렉션 row 전체를 읽습니다.
2. `getCollectionItems`로 일반 객체 배열을 만듭니다.
3. `filterPublishedItems`로 게시 가능한 글만 남깁니다.
4. `toStaticParams`를 `generateStaticParams`에 연결합니다.
5. `unstable_cache`로 목록과 상세 `recordMap`을 캐시합니다.
6. `revalidateTag`, `revalidatePath`로 온디맨드 ISR을 연결합니다.

예시:

```ts
import { unstable_cache } from "next/cache";
import { filterPublishedItems } from "@hansolbangul/notion-render/server/filters";
import {
  getCollectionItems,
  getPageRecordMap,
} from "@hansolbangul/notion-render/server/notion-api";

const REVALIDATE_SECONDS = 3600;
const REVALIDATE_TAG = "notion-posts";

export const getCachedPosts = unstable_cache(
  async () =>
    filterPublishedItems(
      await getCollectionItems({
        pageId: process.env.NOTION_PAGE_ID!,
      }),
      {
        acceptStatus: ["Public"],
        acceptType: ["Post"],
      },
    ),
  [REVALIDATE_TAG],
  {
    revalidate: REVALIDATE_SECONDS,
    tags: [REVALIDATE_TAG],
  },
);

export const getCachedRecordMap = unstable_cache(
  async (pageId: string) => getPageRecordMap(pageId),
  ["notion-record-map"],
  {
    revalidate: REVALIDATE_SECONDS,
    tags: [REVALIDATE_TAG],
  },
);
```

## 타입은 고정인가?

아니요. 아래 예시의 `BlogPost` 타입은 고정 스키마가 아니라 "사용 예시"입니다.

```ts
type BlogPost = {
  id: string;
  title: string;
  slug: string;
  status: string[];
  type: string[];
  tags?: string[];
  summary?: string;
  thumbnail?: string;
  date?: {
    start_date?: string;
  };
  createdTime: string;
  fullWidth: boolean;
};
```

정확히는 이렇게 이해하시면 됩니다.

- `getCollectionItems<T>()`는 제네릭 함수라서 사용자가 원하는 타입 `T`로 받을 수 있습니다.
- 런타임에서 실제로 만들어지는 key는 Notion 컬렉션의 속성 이름을 기준으로 동적으로 결정됩니다.
- 다만 공용 헬퍼인 `filterPublishedItems`는 기본적으로 `title`, `slug`, `status`, `type`, `date` 같은 key 이름을 기대합니다.
- 만약 Notion 속성명이 다르면 옵션으로 key 이름을 바꿔서 사용할 수 있습니다.

예를 들어 속성명이 완전히 다를 수도 있습니다.

```ts
type ArticleRow = {
  id: string;
  제목: string;
  urlKey: string;
  공개상태: string[];
  문서종류: string[];
  발행일?: {
    start_date?: string;
  };
  createdTime: string;
  fullWidth: boolean;
};

const rows = await getCollectionItems<ArticleRow>({
  pageId: process.env.NOTION_PAGE_ID!,
});

const publishedRows = filterPublishedItems(rows, {
  titleKey: "제목",
  slugKey: "urlKey",
  statusKey: "공개상태",
  typeKey: "문서종류",
  dateKey: "발행일",
  acceptStatus: ["공개"],
  acceptType: ["아티클"],
});
```

즉:

- 정적 타입은 사용자가 정의합니다.
- 실제 데이터 key는 Notion 속성 이름에 따라 결정됩니다.
- 블로그용 헬퍼는 기본 key 규약을 가지지만, 일부는 옵션으로 바꿀 수 있습니다.

## 추천 타입 형태

아래 형태는 블로그 프로젝트에서 가장 무난한 권장 예시입니다.

```ts
type Post = {
  id: string;
  title: string;
  slug: string;
  summary?: string;
  thumbnail?: string;
  status: string[];
  type: string[];
  tags?: string[];
  category?: string[];
  author?: {
    id: string;
    name?: string;
    profilePhoto?: string | null;
  }[];
  date?: {
    start_date?: string;
    end_date?: string;
  };
  createdTime: string;
  fullWidth: boolean;
};
```

## 개발

라이브러리 폴더에서:

```bash
yarn install
yarn build
yarn test
```

상위 추출 작업 레포에서:

```bash
yarn build:notion-render
yarn test:notion-render
```

## 배포

```bash
yarn install
yarn test
yarn npm publish --access public
```

배포 패키지 이름:

```txt
@hansolbangul/notion-render
```