노션을 블로그 CMS로 쓰는 양방향 파이프라인
블로그 글을 노션에서 쓰고 정적 사이트로 굽는다. 편집기가 좋고 어디서든 고칠 수 있어서다.
처음엔 노션에서 사이트로 가는 한 방향만 만들었다. 나중에 반대 방향도 필요해졌다. 초안을 파일로 여러 편 만들어두고 한꺼번에 올릴 일이 생겨서다.
만들 것
노션 DB ──sync──▶ src/content/blog/*.md ──build──▶ 사이트
▲
└──push── docs/초안/*.md
sync 는 노션을 읽어 마크다운으로 굽고 push 는 마크다운을 노션 페이지로 올린다.
전제
- 노션 통합(integration) 하나와 그 토큰
- DB 하나. 속성 이름을 정확히 맞춰야 한다
- 마크다운 프론트매터를 읽는 정적 사이트 생성기
1단계: DB 속성
이름이 코드와 정확히 일치해야 한다. 대소문자도 맞춘다.
| 속성 | 타입 | 필수 | 용도 |
|—|—|—|—|
| Title | 제목 | O | 글 제목 |
| Slug | 텍스트 | O | 파일명이자 주소 |
| PubDate | 날짜 | O | 발행 시각 |
| Published | 체크박스 | O | 체크해야 나간다 |
| Description | 텍스트 | | 검색 결과 요약 |
| Tags | 다중 선택 | | |
Slug 나 PubDate 가 비면 그 글은 조용히 빠진다. 오류가 안 난다. 발행했는데 목록에 없으면 여기부터 본다.
2단계: 통합 권한
DB 를 통합에 연결하는 것과 통합의 권한은 다른 설정이다. 연결만 하면 읽기는 되는데 쓰기가 403 으로 막힌다.
통합 설정 → Capabilities → Content Capabilities 에서 셋을 켠다.
Read content sync 에 필요
Insert content push 에 필요
Update content 재발행·수정에 필요
확인
쓰기 권한만 따로 보는 게 편하다. 테스트 페이지를 만들고 바로 보관하면 데이터가 안 남는다.
node scripts/push-notion.mjs --check
여기서 403 이 나면 Insert content 가 꺼져 있다.
3단계: sync (노션에서 파일로)
Published 가 체크된 글만 가져온다.
const res = await api(`/databases/${DB}/query`, {
method: "POST",
body: JSON.stringify({
filter: { property: "Published", checkbox: { equals: true } },
}),
});
블록을 마크다운으로 바꿔 파일로 굽는다. 구운 파일에는 표시를 남긴다.
---
title: 제목
slug: my-post
notion: true # 이 표시가 붙은 파일은 손으로 고치지 않는다
---
노션에서 온 파일은 직접 고치면 안 된다. 다음 동기화에서 덮어쓴다. 표시가 없으면 나중에 자기가 만든 파일인지 받아온 파일인지 구분이 안 된다.
노션에서 지웠거나 Published 를 푼 글은 파일도 지운다. 안 지우면 노션에서 내렸는데 사이트에 남는다.
4단계: push (파일에서 노션으로)
프론트매터를 노션 속성으로 옮긴다.
function properties({ title, slug, description, pubDate, tags }) {
return {
Title: { title: [{ type: "text", text: { content: title } }] },
Slug: { rich_text: [{ type: "text", text: { content: slug } }] },
PubDate: { date: { start: pubDate } },
Published: { checkbox: true },
...(description && { Description: { rich_text: [{ type: "text", text: { content: description } }] } }),
...(tags?.length && { Tags: { multi_select: tags.map((name) => ({ name })) } }),
};
}
조용히 사라지는 자리 셋
여기서 실제로 다 밟았다.
하나. 읽기 필터와 쓰기 필드가 어긋난다.
sync 는 Published = true 만 읽는데 push 가 그 필드를 안 채웠다. 노션에는 11편이 잘 올라갔고 사이트에는 한 편도 안 떴다. 오류는 없었다. 위 코드의 Published: { checkbox: true } 가 그래서 있다.
둘. 중복 방지가 없으면 재실행이 곱하기가 된다.
같은 명령을 두 번 돌리면 11편이 22편이 된다. 올리기 전에 기존 slug 를 모아 대조한다.
async function existingSlugs() {
const slugs = new Set();
let cursor;
do {
const page = await api(`/databases/${DB}/query`, {
method: "POST",
body: JSON.stringify({ page_size: 100, start_cursor: cursor }),
});
for (const row of page.results) {
const s = row.properties?.Slug?.rich_text?.[0]?.plain_text;
if (s) slugs.add(s);
}
cursor = page.has_more ? page.next_cursor : undefined;
} while (cursor);
return slugs;
}
이러면 같은 폴더를 몇 번 밀어도 안전하다. 새 초안만 올라간다.
올림 4 · 건너뜀 11 · 전체 15
셋. 값 화이트리스트.
노션 code 블록의 language 는 정해진 값만 받는다. ```js 는 거부된다. javascript 여야 한다. 한 값이 틀리면 그 글 전체가 400 으로 죽는다. 모르는 값은 plain text 로 떨어뜨린다.
5단계: 변환되는 블록만 쓴다
노션 블록 전부가 마크다운으로 가지 않는다.
변환됨 문단, 제목1~3, 목록, 체크박스, 인용, 코드, 구분선, 이미지, 콜아웃
사라짐 표, 토글, 임베드, 다단
사라질 때 경고가 없다. 표를 노션에서 그리면 사이트에서 그냥 없어진다. 표가 필요하면 마크다운으로 직접 쓰는 게 낫다.
확인
양방향을 한 번씩 돌려본다.
node scripts/push-notion.mjs docs/초안 # 올림 N · 건너뜀 M
npm run sync # 동기화 완료: N편
npm run build
push 한 편수가 sync 로 다시 내려오면 왕복이 성립한다.
여기서 편수가 안 맞으면 Published 나 Slug 나 PubDate 중 하나가 빠진 글이 있다. sync 출력에 건너뛴 글이 표시된다.
안 되면
노션에는 있는데 사이트에 없다. Published 체크, Slug 형식, PubDate 존재 순으로 본다. 셋 다 있으면 PubDate 가 미래일 수 있다.
403 restricted_resource. 통합 권한이다. 2단계.
400 validation_error. 값 화이트리스트다. 메시지 끝의 instead was 를 본다.
같은 글이 여러 개 생겼다. 중복 방지가 없다. 노션에서 지우고 4단계를 넣는다.
이 구조의 한계
노션이 원본이고 파일은 산출물이다. 두 방향을 다 쓰면 어느 쪽이 최신인지 헷갈릴 수 있다. 나는 규칙 하나로 피했다. push 는 새 글 올릴 때만 쓰고 수정은 노션에서만 한다.
노션 API 는 느리다. 블록을 100개씩 나눠 붙여야 해서 긴 글은 요청이 여러 번 나간다. 15편에 몇십 초 걸린다. 빌드마다 부르면 답답하다.
표를 못 쓴다. 이게 제일 아쉽다.