@roottale/cms-mcp 0.51.1 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -0
- package/dist/index.js +1 -1
- package/docs/api-reference.md +143 -4
- package/docs/blog.md +40 -3
- package/docs/collections.md +27 -3
- package/docs/getting-started.md +63 -4
- package/docs/menus.md +7 -2
- package/docs/overview.md +2 -1
- package/docs/revalidation-webhooks.md +190 -8
- package/docs/seo.md +23 -1
- package/docs/theme-and-settings.md +99 -6
- package/examples/nextjs/app/api/revalidate/route.ts +18 -3
- package/examples/nextjs/app/blog/[slug]/page.tsx +11 -7
- package/examples/nextjs/app/layout.tsx +6 -0
- package/examples/nextjs/components/site-nav.tsx +4 -1
- package/examples/nextjs/lib/blog.ts +8 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,96 @@
|
|
|
1
1
|
# @roottale/cms-mcp
|
|
2
2
|
|
|
3
|
+
## 0.53.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c4dd18d: API 키 발급 권한에 **설정 쓰기(`read_write_settings`)** 가 생겼다 — 어드민 화면 대신 외부 도구가 사업장 정보·상단 메뉴를 고칠 수 있게 하는 권한이다(ADR-0096 B3).
|
|
8
|
+
- `getting-started.md` — 권한 선택지에 `read_write_settings` 추가 + 권한별 scope 표. 지금까지 문서에는 scope 이름이 흩어져 있어서, 어떤 키가 무엇을 할 수 있는지 한눈에 볼 곳이 없었다.
|
|
9
|
+
- 어드민 화면은 scope 이름 대신 사람 말로 된 권한 이름("읽기" · "읽기 + 쓰기" · "읽기 + 쓰기 + 설정 변경")을 쓰고, 발급된 키 목록에도 그 이름과 실제 scope 전체가 붙는다. 문서의 권한 목록에 화면 이름을 함께 적어 두 곳을 이어 놓았다.
|
|
10
|
+
- **권한을 실제보다 좁게 안내하던 두 곳을 바로잡았다.** ① `read` 를 "읽기 전용"이라고 했지만 `cms:read` 하나로 **상담 게시판 글이 생성된다**(`POST /v1/cms/public/inquiries`) — 최소 권한이라고 믿고 발급한 키로 데이터가 만들어지고 있었다. ② `read_write` 설명이 작성·발행만 말하고 **발행 취소·글 영구 삭제·미디어 삭제·분류 삭제**를 빼놓아, 키 유출 시 피해를 실제보다 작게 안내했다. `getting-started.md` · `api-reference.md` 양쪽을 고쳤다.
|
|
11
|
+
- 키 교체 절차의 4단계에 판정 기준을 넣었다. "최근 사용 시각으로 확인"만으로는 부족하다 — 목록이 날짜만 보여 주면 같은 날 교체했을 때 옛 키가 아직 호출되는지 구분할 수 없다(화면은 시각까지 보여 주도록 함께 고쳤다). 이제 "정상 호출 주기를 한 번 넘길 때까지 관찰"을 기준으로 적는다.
|
|
12
|
+
- **이미 발급된 `read_write` 키는 그대로다.** `settings:write` 는 새로 발급하는 키에만 붙는다 — 남의 서버에 들어가 있는 글쓰기 키가 하루아침에 사업장 정보·메뉴까지 바꿀 수 있게 되는 일은 없다.
|
|
13
|
+
- `getting-started.md` 에 **키 교체 절차** 신설. 발급된 키의 권한은 나중에 바꿀 수 없어서, 권한을 올리려면 새 키로 갈아타야 한다. 순서를 틀리면(옛 키를 먼저 삭제) 그 사이 요청이 `401 invalid_key` 로 실패하므로 "새 키 발급 → 환경변수 교체 → 재배포 → 최근 사용 확인 → 옛 키 삭제" 순서를 못박았다. 키 유출이 의심될 때만 순서를 뒤집는다.
|
|
14
|
+
|
|
15
|
+
`settings:write` 를 실제로 요구하는 엔드포인트(사업장 정보·상단 메뉴 쓰기)는 아직 없다. 권한 표면을 먼저 여는 이유는, 엔드포인트가 열리는 날 모든 사이트가 키를 재발급해야 하는 상황을 만들지 않기 위해서다.
|
|
16
|
+
|
|
17
|
+
- a133f27: 블로그 상세 글의 작성·출력 기준을 하나로 맞췄습니다.
|
|
18
|
+
- 이미지 대체 텍스트를 한 줄, 최대 160자로 정규화하는 공용 계약을 추가했습니다.
|
|
19
|
+
- 상세 글 위에 카테고리를 연결하고, 발행일과 실제로 다른 수정일을 구분해 표시합니다.
|
|
20
|
+
- 본문의 공식 출처 블록을 제목과 목록이 있는 시맨틱 구간으로 렌더합니다.
|
|
21
|
+
- 블로그 전용 SEO 점검에서 목차, 외부 링크가 있는 공식 출처, 내용이 채워진 FAQ를 확인합니다.
|
|
22
|
+
- Next.js 상세 라우트가 글을 먼저 조회한 뒤 `notFound()`를 호출해야 실제 404가 된다는 통합 예제를 보강했습니다.
|
|
23
|
+
|
|
24
|
+
- 2eb82d3: 블로그 작성자 프로필의 기준을 글에 지정된 사이트 작성자 한 곳으로 통합했습니다.
|
|
25
|
+
- 작성자가 없는 글은 전역 설정으로 작성자 카드가 만들어지지 않습니다.
|
|
26
|
+
- 작성자 이름·사진·소개는 글 응답의 작성자 프로필만 사용합니다.
|
|
27
|
+
- 레거시 전역 작성자 응답 필드는 호환을 위해 남지만 항상 `null`입니다.
|
|
28
|
+
|
|
29
|
+
- 3e5ac18: **수동 갱신 API로 설정 저장 신호(`theme.updated`)를 보낼 수 있게 됐다** (ADR-0096 Phase C1).
|
|
30
|
+
|
|
31
|
+
지금까지 `POST /v1/cms/revalidate` 의 `event` 는 글 이벤트 셋(`post.published`·`post.updated`·`post.deleted`)뿐이었다. 그래서 알림 주소를 나중에 등록해 저장 시점의 신호를 놓쳤거나, 수신 라우트를 고친 뒤 설정 캐시를 한 번 비우고 싶을 때 **손으로 할 수 있는 일이 없었다** — 사이트의 재검증 주기(길면 30분)를 기다리거나 어드민에서 설정을 의미 없이 다시 저장하는 것뿐이었다.
|
|
32
|
+
- `event: "theme.updated"` 를 받는다. 받는 값 넷을 `api-reference.md` 와 `revalidation-webhooks.md` 양쪽에 적었다.
|
|
33
|
+
- **이 값만 한 단계 위 권한을 요구한다.** `read_write_settings`("읽기 + 쓰기 + 설정 변경", scope `settings:write`)로 발급한 키가 필요하고, 글쓰기 키(`read_write`)로 보내면 `403 insufficient_scope` 다. 나머지 세 값은 지금까지 그대로 글쓰기 키로 보낸다 — **기존 호출자의 권한은 조이지 않았다.**
|
|
34
|
+
- 권한을 나눈 이유를 문서에 적었다. `theme.updated` 는 경로 몇 개가 아니라 **설정 캐시 이름표 전체 + 루트 레이아웃(그 아래 모든 페이지)** 을 다시 만들게 한다. 글을 쓰라고 내준 키가 사이트 전체 재생성을 반복해서 돌릴 수 있으면 안 된다.
|
|
35
|
+
- 설정 쓰기 API(`PATCH /v1/cms/settings/*`)로 사업장 정보·상단 메뉴를 바꾸면 이 신호는 **저장과 함께 자동으로** 나간다는 점도 함께 적었다 — 직접 보낼 필요가 없는 경우와 있는 경우를 가려 놓았다.
|
|
36
|
+
- 웹훅 발송 트리거 목록에 설정 쓰기 API 호출을 추가하고, 수동 호출 항목에 어떤 이벤트를 지정할 수 있는지 링크를 달았다.
|
|
37
|
+
|
|
38
|
+
`paths` 를 함께 보내면 이름표·레이아웃 무효화 **위에** 그 경로들이 더해진다. 비워 두면 홈(`/`)이 기본으로 들어가는데, 이는 `theme.updated` 분기가 없는 옛 수신 라우트(`@roottale/cms-renderer-next` 0.41.0 미만)를 위한 값이다.
|
|
39
|
+
|
|
40
|
+
- 91aee06: **사업장 정보와 상단 메뉴를 API로 바꿀 수 있게 됐다.** 지금까지 이 두 설정을 고치는 길은 어드민 화면뿐이어서, 사이트를 새로 붙일 때마다 데이터베이스를 직접 손대는 일이 반복됐다(ADR-0096 Phase B).
|
|
41
|
+
- `PATCH /v1/cms/settings/business-profile` · `PATCH /v1/cms/settings/site-nav` 두 엔드포인트를 `api-reference.md` 에 "설정 쓰기 API" 절로 넣었다. 요청·응답 예시, 저장 규칙(길이·개수 상한, https 전용 주소, `HH:MM` 표기), 오류 코드까지 한 곳에 있다.
|
|
42
|
+
- **메서드는 `PATCH` 지만 "그 설정 블록 전체 교체"다.** 부분 병합이 아니라는 사실을 문서 맨 앞에 못박았다 — 한 칸만 고칠 생각으로 그 칸만 보내면 나머지가 지워진다. 주소·좌표·메뉴 그룹처럼 겹겹이 중첩된 값에 병합 규칙을 만들면 "어디까지 덮어쓰는가"가 매번 헷갈리기 때문에 택한 방식이고, 대신 그 사실을 숨기지 않는다.
|
|
43
|
+
- **대상 사이트를 설정 본문과 섞지 않는다.** `site_id` 는 본문 바깥에 적고, 사이트가 둘 이상인 테넌트가 이를 빠뜨리면 `400` 으로 거부한다. 예전 규칙대로라면 "가장 오래된 사이트"가 조용히 선택돼 **엉뚱한 사이트의 간판 정보와 메뉴가 바뀐다.**
|
|
44
|
+
- 응답은 **저장된 값 그대로**다. 보낸 값과 다를 수 있어서(요일 순서 정렬, 목록 중복 제거, 업종 기본값) 다음에 무엇을 보게 될지는 이 응답이 정답이다. 공개 조회로 확인하려 하지 말라고 적었다 — 거기엔 최대 10초 캐시가 걸려 있다.
|
|
45
|
+
- 저장 뒤 나가는 갱신 알림의 결과를 `revalidate` 로 함께 돌려준다. `configured`·`delivered`·`failed`·`degraded` 네 값으로 **"알림을 등록하지 않은 정상 상태"와 "보내지도 못한 상태"를 구분**한다. 지금까지는 둘 다 빈 결과라 겉보기가 같았다.
|
|
46
|
+
- `theme-and-settings.md` 의 상단 메뉴 절과 `getting-started.md` 의 권한 목록에서 이 문서로 가는 길을 열었다.
|
|
47
|
+
|
|
48
|
+
권한은 `read_write_settings` 키에만 있다. 이미 쓰고 있는 글쓰기 키(`read_write`)에는 붙지 않으므로, 남의 서버에 들어가 있는 키가 하루아침에 사업장 정보와 메뉴까지 바꿀 수 있게 되는 일은 없다.
|
|
49
|
+
|
|
50
|
+
- 3ab5570: RSS 생성기에 선택적인 WebSub `atom:hub` 링크를 추가했다. `createFeedRoute`는
|
|
51
|
+
`webSubHubUrl`을 받아 이를 피드에 전달하며, 값을 주지 않은 기존 사이트의 출력은
|
|
52
|
+
바뀌지 않는다. Site Kit은 Google WebSub hub를 기본 연결하고 IndexNow 소유권 확인용
|
|
53
|
+
`/indexnow-key.txt` 라우트를 제공한다.
|
|
54
|
+
|
|
55
|
+
## 0.52.0
|
|
56
|
+
|
|
57
|
+
### Patch Changes
|
|
58
|
+
|
|
59
|
+
- 01f5534: 연동 문서·예시가 **설정 저장 즉시 반영** 배선을 담게 됐다. 지금까지 예시의 revalidate 라우트는 `revalidatePath` 만 넣었고, 그 예시를 그대로 따른 사이트는 어드민에서 전화·주소·메뉴를 고쳐도 최대 30분 옛 값을 보여 줬다 — 예시를 복사해 쓰는 것이 정상 경로인데 그 예시에 구멍이 있었다.
|
|
60
|
+
- `revalidation-webhooks.md` — 예시에 `revalidateTag` 주입 추가. "설정 저장을 즉시 반영하려면 — 캐시 이름표(`tags`)" 절 신설(조회 함수 ↔ 이름표 상수 표, 빠뜨렸을 때의 증상, 자기 진단법). 이벤트 목록에 `theme.updated` 추가 + 저수준(`verifyRootTaleWebhook`) 예시에도 그 분기를 넣었다. 트러블슈팅에 "글은 즉시인데 설정만 늦음" 행 추가.
|
|
61
|
+
- **두 가지 함정을 문서·예시 전체에서 바로잡았다.** ① 이름표를 지울 때는 `revalidateTag(tag, { expire: 0 })` 여야 한다 — `"max"` 같은 프로파일은 `expire` 가 0 이 아니어서 Next 가 stale-while-revalidate 로 취급하고, 다음 요청이 여전히 옛 값을 받는다(`"max"` 의 `expire` 는 1년). `updateTag` 는 즉시지만 서버 액션 전용이라 웹훅 라우트에서 오류가 난다. ② `revalidate` 를 콜백으로 감쌀 때 2번째 인자(`type`)를 그대로 넘겨야 한다 — 안 넘기면 레이아웃 전역 무효화가 페이지 1개 무효화로 줄어 정적 페이지가 안 바뀐다.
|
|
62
|
+
- `theme-and-settings.md` — 맨 앞에 이름표 표를 두고, `fetchTheme`·`fetchBusinessProfile`·`fetchBlogSettings` 예시에 `tags` 를 넣었다.
|
|
63
|
+
- `menus.md` · `collections.md` — `fetchMenu`·`fetchCollections` 예시에 이름표, collections 의 revalidate 예시에 `revalidateTag` 주입.
|
|
64
|
+
- `overview.md` · `getting-started.md` — 사업장 정보·메뉴·콘텐츠 유형 조회 함수를 표에 넣고, "다음 단계"에서 설정 반영 문서를 가리킨다.
|
|
65
|
+
- `examples/nextjs` — revalidate 라우트에 `revalidateTag`, layout 의 사업장 정보 조회에 `BUSINESS_CACHE_TAG`, 헤더 메뉴에 `MENUS_CACHE_TAG`, 블로그 설정 조회에 `BLOG_SETTINGS_CACHE_TAG`.
|
|
66
|
+
|
|
67
|
+
이름표 상수와 수신측 동작은 `@roottale/cms-client` · `@roottale/cms-renderer-next` 쪽 변경 사항을 참고.
|
|
68
|
+
|
|
69
|
+
- f3aef69: 공개 API `/v1/cms/public/theme` 의 `siteNav` 상한이 **좁아진다** — 그룹당 열이 4 → 3, 열당 링크가 8 → 6. 그룹 7개·유틸리티 링크 4개는 그대로다.
|
|
70
|
+
|
|
71
|
+
지금까지 `siteNav` 를 저장하는 유일한 경로가 어드민 **설정 > 사이트 > 상단 메뉴** 폼이었고 그 폼은 이미 3열·6링크까지만 입력칸을 제공했다. 그래서 상한을 넘는 값은 만들어질 수 없었고(프로덕션 실측: `siteNav` 를 가진 사이트 0곳), 이번 변경으로 잘려 나가는 데이터는 없다. 저장 스키마·어드민 폼·공개 계약이 각자 다른 숫자를 들고 있던 것을 하나로 맞추는 정리다.
|
|
72
|
+
|
|
73
|
+
`@roottale/cms-client` 의 `SiteNavSettings` 타입과 `fetchTheme` 시그니처는 그대로다 — 소비 코드 변경은 필요 없다.
|
|
74
|
+
|
|
75
|
+
다만 **상한을 넘는 값이 응답에서 어떻게 되는지**를 이번에 문서화했다: 개별 항목을 잘라내는 것이 아니라 `siteNav` 전체가 빠진다(`undefined`). 손으로 DB 를 고쳐 4열짜리 메뉴를 넣어 둔 사이트가 있다면 메뉴가 통째로 사라지므로, 사이트에는 항상 폴백 메뉴를 두는 편이 안전하다. 모양·상한·폴백 안내는 `docs/theme-and-settings.md` 의 "상단 메뉴 — theme.siteNav" 절에 추가했다.
|
|
76
|
+
|
|
77
|
+
- cfd78e4: 발행 웹훅의 `paths` 가 글이 속한 **콘텐츠 유형의 주소**를 담는다는 점을 문서에
|
|
78
|
+
명시했다. 지금까지는 공지 유형(`/notice`) 글을 고쳐도 어드민이 `/blog` 주소만
|
|
79
|
+
보내서, 정작 바뀐 목록이 갱신되지 않았다. 어드민 쪽 발송 로직을 고치면서 문서도
|
|
80
|
+
사실에 맞췄다 — 유형별 목록·상세 주소, 카테고리 모음은 그 유형에서 켰을 때만,
|
|
81
|
+
유형을 옮긴 글은 이전·이후 주소가 함께 온다는 것, 그리고 주소 없는 유형·목록 전용
|
|
82
|
+
유형은 상세 주소가 빠진다는 것.
|
|
83
|
+
|
|
84
|
+
**수신 측 코드 변경은 필요 없다.** `createRevalidateRoute` 는 예전과 똑같이 모든
|
|
85
|
+
서명 이벤트에서 블로그 목록·피드·사이트맵을 함께 갱신하고, `collections` 를 넘긴
|
|
86
|
+
사이트는 각 유형의 목록 주소도 이미 자동으로 갱신하고 있었다. 공개 API·타입·
|
|
87
|
+
시그니처는 그대로라 patch 로 올린다(문서 문구만 바뀐다).
|
|
88
|
+
|
|
89
|
+
- 66ad45d: 발행 웹훅 문서에 "주소가 리다이렉트되면 실패한다" 와 Cloudflare SSL 모드
|
|
90
|
+
Flexible 함정을 명시했다. 둘 다 화면에는 아무 오류가 안 뜨고 발행 반영만
|
|
91
|
+
조용히 늦어지는 유형이라, 실제로 자사 사이트와 고객사 한 곳이 각각 이 이유로
|
|
92
|
+
웹훅이 죽어 있었다(2026-07-27 실측).
|
|
93
|
+
|
|
3
94
|
## 0.51.1
|
|
4
95
|
|
|
5
96
|
## 0.50.0
|
package/dist/index.js
CHANGED
|
@@ -944,7 +944,7 @@ function registerTools(server) {
|
|
|
944
944
|
}
|
|
945
945
|
|
|
946
946
|
// src/server.ts
|
|
947
|
-
var VERSION = true ? "0.
|
|
947
|
+
var VERSION = true ? "0.53.0" : "dev";
|
|
948
948
|
var SERVER_INSTRUCTIONS = `
|
|
949
949
|
roottale-cms-mcp\uB294 RootTale CMS\uB97C \uC678\uBD80 \uC0AC\uC774\uD2B8(\uC8FC\uB85C Next.js)\uC5D0 \uC5F0\uB3D9\uD558\uACE0
|
|
950
950
|
\uAE00\xB7\uC378\uB124\uC77C\xB7\uBCF8\uBB38 \uC774\uBBF8\uC9C0\uB97C \uC790\uB3D9\uD654\uD558\uAE30 \uC704\uD55C \uBB38\uC11C\xB7\uC608\uC2DC \uCF54\uB4DC\xB7API tool\uC744 \uC81C\uACF5\uD569\uB2C8\uB2E4.
|
package/docs/api-reference.md
CHANGED
|
@@ -12,9 +12,14 @@ description: 공개 API raw 엔드포인트 — JS 외 스택이나 저수준
|
|
|
12
12
|
`@roottale/cms-client`를 사용하세요. 글쓰기·미디어 자동화는 아래 HTTP API,
|
|
13
13
|
MCP tool 또는 공개 CLI를 사용합니다.
|
|
14
14
|
|
|
15
|
-
글쓰기·미디어 자동화는 `read_write` API 키가 필요합니다. `read` 키는
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
글쓰기·미디어 자동화는 `read_write` API 키가 필요합니다. `read` 키는 공개
|
|
16
|
+
콘텐츠뿐 아니라 관리 API의 초안·예약·비공개 글과 미디어 목록도 조회할 수
|
|
17
|
+
있습니다. 다만 **완전한 읽기 전용은 아닙니다** — 상담 게시판 글 작성
|
|
18
|
+
(`POST /v1/cms/public/inquiries`)이 `cms:read`로 허용됩니다. 기존 글·미디어·
|
|
19
|
+
설정을 고치거나 지우려면 `read_write` 이상이 필요합니다.
|
|
20
|
+
|
|
21
|
+
사업장 정보·상단 메뉴를 바꾸는 일(아래 "설정 쓰기 API")은 한 단계 위인
|
|
22
|
+
`read_write_settings` 키가 따로 필요합니다 — 글쓰기 키로는 열리지 않습니다.
|
|
18
23
|
|
|
19
24
|
## 관리 API 빠른 흐름
|
|
20
25
|
|
|
@@ -135,6 +140,115 @@ tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다
|
|
|
135
140
|
미디어 삭제 전 해당 URL을 쓰는 본문과 `featured_media_id` 연결을 먼저
|
|
136
141
|
교체하세요. 삭제 후 기존 공개 URL은 더 이상 유효하지 않습니다.
|
|
137
142
|
|
|
143
|
+
## 설정 쓰기 API
|
|
144
|
+
|
|
145
|
+
사업장 정보와 상단 메뉴를 어드민 화면 대신 API로 바꿉니다. **`read_write_settings`
|
|
146
|
+
권한으로 발급한 키가 필요합니다** (`settings:write`). 글쓰기 키(`read_write`)로
|
|
147
|
+
호출하면 `403 insufficient_scope` 입니다.
|
|
148
|
+
|
|
149
|
+
세 가지를 먼저 알아 두세요.
|
|
150
|
+
|
|
151
|
+
1. **두 엔드포인트 모두 "그 설정 블록 전체 교체"입니다.** 메서드는 `PATCH`
|
|
152
|
+
지만 부분 병합이 아닙니다 — 보낸 값이 그 설정의 전부가 되고, 빠뜨린
|
|
153
|
+
필드는 지워집니다. 한 칸만 고치고 싶으면 공개 조회로 현재 값을 받아
|
|
154
|
+
그 값을 고쳐서 통째로 보내세요.
|
|
155
|
+
2. **대상 사이트는 `settings` 바깥(envelope)에 적습니다.** 사이트가 둘
|
|
156
|
+
이상인 테넌트가 `site_id` 를 생략하면 `400 site_id_required` 입니다 —
|
|
157
|
+
임의의 사이트를 고르지 않습니다. 사이트에 묶인 키(site-scoped)는 생략하고,
|
|
158
|
+
다른 사이트를 지목하면 `404` 입니다.
|
|
159
|
+
3. **저장 뒤 연결된 사이트에 `theme.updated` 알림을 보냅니다.** 알림이
|
|
160
|
+
실패해도 저장은 되돌아가지 않습니다 — 결과는 응답의 `revalidate` 로
|
|
161
|
+
확인합니다.
|
|
162
|
+
|
|
163
|
+
### PATCH /v1/cms/settings/business-profile
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{
|
|
167
|
+
"site_id": "019eb70c-…",
|
|
168
|
+
"settings": {
|
|
169
|
+
"name": "길동세무회계",
|
|
170
|
+
"business_type": "AccountingService",
|
|
171
|
+
"telephone": "02-1234-5678",
|
|
172
|
+
"address": { "street_address": "테헤란로 123", "address_locality": "강남구" },
|
|
173
|
+
"opening_hours": [ { "days": ["Mo","Tu","We","Th","Fr"], "opens": "09:00", "closes": "18:00" } ],
|
|
174
|
+
"area_served": ["서울 강남구"],
|
|
175
|
+
"services": ["기장·신고대리", "세무고문"],
|
|
176
|
+
"profiles": { "naver_place": "https://map.naver.com/p/entry/place/1" }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`settings` 안쪽 필드는 `GET /v1/cms/public/business-profile` 응답과 같은
|
|
182
|
+
이름입니다. 서버가 정하는 값(`tenant_id`·`site_id`·`configured`·`updated_at`)은
|
|
183
|
+
넣을 수 없습니다 — 넣으면 `400` 입니다.
|
|
184
|
+
|
|
185
|
+
저장 규칙:
|
|
186
|
+
|
|
187
|
+
| 항목 | 규칙 |
|
|
188
|
+
|---|---|
|
|
189
|
+
| `profiles.*` | **https 절대 주소만.** `http://` 와 로컬 주소는 거부 |
|
|
190
|
+
| `opening_hours` | 최대 7행. 시각은 `HH:MM` 24시간 표기(`09:00`) |
|
|
191
|
+
| `area_served` | 10개 / 각 40자 |
|
|
192
|
+
| `services` | 12개 / 각 80자 (중복은 자동 제거) |
|
|
193
|
+
| `alternate_name`·`fax_number` | 각 120자 / 40자 |
|
|
194
|
+
| 모르는 필드 | 거부 (`400`) |
|
|
195
|
+
|
|
196
|
+
### PATCH /v1/cms/settings/site-nav
|
|
197
|
+
|
|
198
|
+
```json
|
|
199
|
+
{
|
|
200
|
+
"settings": {
|
|
201
|
+
"navGroups": [
|
|
202
|
+
{ "label": "사무소 소개", "href": "/about" },
|
|
203
|
+
{ "label": "소식",
|
|
204
|
+
"columns": [ { "title": "알림",
|
|
205
|
+
"links": [ { "label": "공지", "href": "/notice" } ] } ] }
|
|
206
|
+
],
|
|
207
|
+
"cta": { "label": "상담 문의", "href": "/contact" }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`settings` 는 `GET /v1/cms/public/theme` 의 `siteNav` 와 같은 모양입니다.
|
|
213
|
+
|
|
214
|
+
저장 규칙: 메뉴 그룹 7개 / 그룹당 열 3개 / 열당 링크 6개 / 보조 링크
|
|
215
|
+
(`utilityLinks`) 4개. 주소는 `#앵커`·`/경로`·`http(s)://` 만 허용합니다.
|
|
216
|
+
그룹에는 `href` 나 `columns` 중 하나가 반드시 있어야 합니다.
|
|
217
|
+
|
|
218
|
+
> 상단 메뉴는 한 번 만들면 빈 값으로 되돌릴 수 없습니다(`navGroups` 는 최소
|
|
219
|
+
> 1개). 메뉴를 없애려면 어드민 화면에서 지우세요.
|
|
220
|
+
|
|
221
|
+
### 응답
|
|
222
|
+
|
|
223
|
+
두 엔드포인트가 같은 모양으로 답합니다.
|
|
224
|
+
|
|
225
|
+
```json
|
|
226
|
+
{
|
|
227
|
+
"tenant_id": "…", "site_id": "…",
|
|
228
|
+
"updated_at": "2026-07-28T04:20:00.000Z",
|
|
229
|
+
"settings": { "…": "저장된 값 그대로" },
|
|
230
|
+
"revalidate": { "configured": 1, "delivered": 1, "failed": 0, "degraded": false }
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`settings` 는 **저장된 값**입니다 — 보낸 값과 다를 수 있습니다(요일 순서 정렬,
|
|
235
|
+
목록 중복 제거, `business_type` 기본값 `LocalBusiness` 등). 다음 조회에서 무엇을
|
|
236
|
+
보게 될지는 이 값이 정답이고, 공개 조회로 확인하려 하지 마세요(공개 응답에는
|
|
237
|
+
최대 10초 캐시가 걸립니다).
|
|
238
|
+
|
|
239
|
+
`revalidate` 는 저장 뒤 보낸 갱신 알림의 결과입니다.
|
|
240
|
+
|
|
241
|
+
| 필드 | 뜻 |
|
|
242
|
+
|---|---|
|
|
243
|
+
| `configured` | 등록되어 있고 켜져 있는 알림 목적지 수 |
|
|
244
|
+
| `delivered` | 성공 |
|
|
245
|
+
| `failed` | 목적지가 오류를 돌려줌 |
|
|
246
|
+
| `degraded` | **보내지도 못한** 목적지가 있음 — 설정을 점검해야 합니다 |
|
|
247
|
+
|
|
248
|
+
`configured: 0, degraded: false` 는 정상입니다(알림을 등록하지 않은 사이트).
|
|
249
|
+
`degraded: true` 면 사이트에 반영되지 않았을 수 있으니 어드민의 발행 알림
|
|
250
|
+
설정을 확인하세요.
|
|
251
|
+
|
|
138
252
|
## GET /v1/cms/public/posts
|
|
139
253
|
|
|
140
254
|
발행된 글 목록 (커서 페이지네이션).
|
|
@@ -237,7 +351,7 @@ fallback 네비를 렌더하세요 (`menus.md` 참고).
|
|
|
237
351
|
|
|
238
352
|
## GET /v1/cms/public/blog-settings
|
|
239
353
|
|
|
240
|
-
블로그 표시 설정 (TOC·작성자·발행일·작성자
|
|
354
|
+
블로그 표시 설정 (TOC·작성자·발행일·작성자 카드 표시 정책, 글 하단 CTA).
|
|
241
355
|
`post_cta` 는 admin 에서 활성화하고 버튼 문구·링크를 채웠을 때만 객체이며,
|
|
242
356
|
그 외에는 `null`. `RootTaleBlogPost` 가 본문 끝에 자동으로 렌더하므로 별도
|
|
243
357
|
연동 코드는 필요 없다. `toc_position` 은 목차 배치(`"inline"`=본문 위 접이식,
|
|
@@ -256,6 +370,13 @@ fallback 네비를 렌더하세요 (`menus.md` 참고).
|
|
|
256
370
|
"updated_at": null }
|
|
257
371
|
```
|
|
258
372
|
|
|
373
|
+
`author_profile_name`·`author_profile_bio`·`author_profile_image_url`·
|
|
374
|
+
`author_card_description`은 이전 연동을 깨지 않기 위해 응답 모양에만 남아 있고
|
|
375
|
+
항상 `null`입니다. 작성자 이름·사진·소개는 글 응답의 `author_name`·
|
|
376
|
+
`author_image_url`·`author_bio`를 사용하세요. 이 값은 글에 지정된 사이트별
|
|
377
|
+
작성자 프로필에서 옵니다. 글에 작성자가 없으면 작성자 메타와 카드를 표시하지
|
|
378
|
+
않습니다.
|
|
379
|
+
|
|
259
380
|
`site_profile` 은 사이트 공통 SEO 값. `default_og_image_url`(1200×630 권장)은
|
|
260
381
|
글에 대표/OG 이미지가 없을 때 SNS 공유 썸네일 폴백으로 쓰세요 —
|
|
261
382
|
`generateMetadata` 에서 `post.seo?.ogImage ?? post.featured_media_url ??
|
|
@@ -410,6 +531,24 @@ IP/tenant rate limit 초과 시 `429 rate_limited`를 반환합니다.
|
|
|
410
531
|
{ "event": "post.updated", "paths": ["/blog", "/blog/my-post"], "slug": "my-post" }
|
|
411
532
|
```
|
|
412
533
|
|
|
534
|
+
`event`는 `post.published` · `post.updated` · `post.deleted` · `theme.updated`
|
|
535
|
+
중 하나이고, 생략하면 `post.updated`입니다. 앞의 세 값은 `read_write` 권한
|
|
536
|
+
(`cms:write`)으로 보냅니다.
|
|
537
|
+
|
|
538
|
+
**`theme.updated`(설정 저장 신호)만 `read_write_settings` 권한이 필요합니다**
|
|
539
|
+
("읽기 + 쓰기 + 설정 변경", scope `settings:write`). 글쓰기 키로 보내면
|
|
540
|
+
`403 insufficient_scope`입니다. 이 신호는 경로 몇 개가 아니라 수신 측의 **설정
|
|
541
|
+
캐시 이름표 전체와 루트 레이아웃(그 아래 모든 페이지)** 을 다시 만들게 하므로,
|
|
542
|
+
글을 쓰라고 내준 키에는 열지 않습니다.
|
|
543
|
+
|
|
544
|
+
```json
|
|
545
|
+
{ "event": "theme.updated" }
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
위 "설정 쓰기 API"로 사업장 정보·상단 메뉴를 바꾸면 이 신호는 저장과 함께
|
|
549
|
+
자동으로 나갑니다 — 직접 보낼 필요는 없습니다. 자세한 동작은
|
|
550
|
+
[재검증 웹훅 문서](./revalidation-webhooks.md#수동-revalidation-api)에 있습니다.
|
|
551
|
+
|
|
413
552
|
## 에러 형식
|
|
414
553
|
|
|
415
554
|
비 2xx 응답은 JSON 에러 바디(`code`, `message`)를 가집니다. 주요 코드:
|
package/docs/blog.md
CHANGED
|
@@ -76,7 +76,9 @@ export default function BlogPage() {
|
|
|
76
76
|
|
|
77
77
|
```tsx
|
|
78
78
|
// app/blog/[slug]/page.tsx
|
|
79
|
+
import { fetchPost } from "@roottale/cms-client/server";
|
|
79
80
|
import { RootTaleBlogPost } from "@roottale/cms-renderer-next/server";
|
|
81
|
+
import { notFound } from "next/navigation";
|
|
80
82
|
|
|
81
83
|
export const revalidate = 1800;
|
|
82
84
|
|
|
@@ -86,24 +88,40 @@ export default async function PostPage({
|
|
|
86
88
|
params: Promise<{ slug: string }>;
|
|
87
89
|
}) {
|
|
88
90
|
const { slug } = await params; // Next.js 15+ async params
|
|
91
|
+
const post = await fetchPost({
|
|
92
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
93
|
+
baseUrl: process.env.ROOTTALE_API_BASE,
|
|
94
|
+
slugOrId: slug,
|
|
95
|
+
});
|
|
96
|
+
if (!post) notFound();
|
|
97
|
+
|
|
89
98
|
return (
|
|
90
99
|
<RootTaleBlogPost
|
|
91
100
|
apiKey={process.env.ROOTTALE_API_KEY!}
|
|
92
101
|
baseUrl={process.env.ROOTTALE_API_BASE}
|
|
93
|
-
slugOrId={
|
|
102
|
+
slugOrId={post.id}
|
|
94
103
|
showTableOfContents
|
|
95
104
|
tableOfContentsTitle="목차"
|
|
96
105
|
relatedPostsCount={3}
|
|
106
|
+
breadcrumb={{ siteUrl: process.env.NEXT_PUBLIC_SITE_URL }}
|
|
97
107
|
/>
|
|
98
108
|
);
|
|
99
109
|
}
|
|
100
110
|
```
|
|
101
111
|
|
|
102
|
-
`RootTaleBlogPost`는 기본적으로 글 제목을 `<h1>`으로 출력합니다.
|
|
112
|
+
`RootTaleBlogPost`는 기본적으로 글 제목을 `<h1>`으로 출력합니다. CMS에서 검토일과
|
|
113
|
+
검토자를 지정한 글은 글 상단 메타 영역에 검토 이력도 자동으로 표시됩니다. 공통 배너나
|
|
103
114
|
페이지 전용 헤더에서 같은 제목을 직접 마크업한다면 `showTitle={false}`를
|
|
104
115
|
넘기세요. 이 옵션은 CMS 제목만 생략하며 요약·발행일·작성자와 본문은 그대로
|
|
105
116
|
렌더합니다.
|
|
106
117
|
|
|
118
|
+
상세 라우트는 렌더 전에 글을 조회하고, 없으면 Next.js `notFound()`를 호출해야
|
|
119
|
+
실제 HTTP 404가 됩니다. `notFoundElement`는 컴포넌트 안의 대체 화면일 뿐 응답
|
|
120
|
+
상태를 404로 바꾸지 못합니다.
|
|
121
|
+
|
|
122
|
+
카테고리가 있으면 H1 위에 링크로 표시됩니다. 메타 줄에는 발행일이 명시되고,
|
|
123
|
+
수정일의 달력 날짜가 발행일과 다를 때만 수정일을 따로 표시합니다.
|
|
124
|
+
|
|
107
125
|
`relatedPostsCount`(기본 0=off)를 주면 글 하단에 **같은 카테고리 최근 글**을 N개
|
|
108
126
|
`<nav class="rt-cms-related">` 로 노출합니다(현재 글 제외, 발행일 내림차순).
|
|
109
127
|
제목은 `relatedPostsTitle`(기본 "관련 글"), 링크는 목록과 동일하게 `postHref`
|
|
@@ -127,6 +145,21 @@ export default async function PostPage({
|
|
|
127
145
|
블록을 본문에 직접 배치할 때는 상단 자동 ToC 와 중복되지 않도록
|
|
128
146
|
`showTableOfContents` 를 생략(기본 `false`)하는 것을 권장합니다.
|
|
129
147
|
|
|
148
|
+
#### 블로그 글 구성 + 공식 출처
|
|
149
|
+
|
|
150
|
+
어드민 에디터의 슬래시 메뉴 `/블로그 글 구성` 또는 툴바의 **블로그 글 구성**을
|
|
151
|
+
누르면 인트로 → 목차 → H2 본문 → 공식 출처 → FAQ 골격이 한 번에 들어갑니다.
|
|
152
|
+
저장될 안내 문구는 넣지 않고 실제 작성 칸만 비워 둡니다.
|
|
153
|
+
|
|
154
|
+
`/공식 출처`는 `references` 블록을 삽입합니다. 학회·정부·연구기관처럼 본문
|
|
155
|
+
주장을 직접 뒷받침하는 원문을 불릿 링크로 적으세요. 공개 화면에서는
|
|
156
|
+
`<section class="rt-cms-references">`와 보이는 제목으로 렌더됩니다. SEO 점검은
|
|
157
|
+
이 블록 안에 외부 원문 링크가 있는지도 확인합니다.
|
|
158
|
+
|
|
159
|
+
이미지 삽입 시 대체 텍스트를 함께 입력합니다. 에디터와 공개 렌더러 모두 줄바꿈·
|
|
160
|
+
과도한 공백을 정리하고 최대 160자로 제한하므로, 이미지 주변 문단이나 캡션 전체를
|
|
161
|
+
복사하지 말고 이미지의 핵심 의미만 짧게 적으세요.
|
|
162
|
+
|
|
130
163
|
#### FAQ 블록 (자주 묻는 질문 + 구조화데이터)
|
|
131
164
|
|
|
132
165
|
어드민 에디터의 슬래시 메뉴 `/FAQ` 로 **FAQ 블록**(`roottale/faq`)을 삽입하면,
|
|
@@ -323,9 +356,13 @@ export async function generateMetadata({
|
|
|
323
356
|
(`getPost` 의 `BlogPostMeta` 가 그대로 호환), 원본 post 를 쓸 땐
|
|
324
357
|
`seo`로 넘길 값은 `(post.metaJson as { seo?: ... }).seo`입니다.
|
|
325
358
|
|
|
326
|
-
SEO 오버라이드 필드: `title`, `description`, `canonical`, `ogImage`,
|
|
359
|
+
SEO 오버라이드 필드: `title`, `description`, `canonical`, `ogImage`, `ogImageAlt`,
|
|
327
360
|
`noindex`, `nofollow`.
|
|
328
361
|
|
|
362
|
+
`buildPostMetadata`는 글 페이지에 Google 큰 이미지 미리보기
|
|
363
|
+
(`max-image-preview:large`)를 기본으로 허용합니다. `ogImageAlt`가 있으면 OG 이미지의
|
|
364
|
+
대체 텍스트로 사용하고, 없으면 검색 제목을 사용합니다.
|
|
365
|
+
|
|
329
366
|
### slug 변경 시 301 리다이렉트 (필수 권장)
|
|
330
367
|
|
|
331
368
|
어드민에서 글 slug를 바꿔도 API는 **옛 slug로 글을 찾아 현재 slug로
|
package/docs/collections.md
CHANGED
|
@@ -205,13 +205,19 @@ export const GET = createFeedRoute({ apiKey, siteUrl, title, collections: COLLEC
|
|
|
205
205
|
함수**로 넘깁니다. 운영자가 어드민에서 섹션을 바꾸면 사이트가 따라갑니다.
|
|
206
206
|
|
|
207
207
|
```ts
|
|
208
|
-
import {
|
|
208
|
+
import {
|
|
209
|
+
COLLECTIONS_CACHE_TAG,
|
|
210
|
+
fetchCollections,
|
|
211
|
+
} from "@roottale/cms-client/server";
|
|
209
212
|
|
|
210
213
|
const DEFAULT: RouteCollection[] = [ /* 위와 동일 — fail-soft 기본값 */ ];
|
|
211
214
|
|
|
212
215
|
async function getCollections(): Promise<RouteCollection[]> {
|
|
213
216
|
try {
|
|
214
|
-
const c = await fetchCollections({
|
|
217
|
+
const c = await fetchCollections({
|
|
218
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
219
|
+
tags: [COLLECTIONS_CACHE_TAG], // 어드민 저장 시 즉시 반영 (아래 revalidate 절)
|
|
220
|
+
});
|
|
215
221
|
return c.length ? c : DEFAULT;
|
|
216
222
|
} catch {
|
|
217
223
|
return DEFAULT; // API 미설정/실패 시 기본값
|
|
@@ -345,14 +351,32 @@ return buildPostMetadata(post, {
|
|
|
345
351
|
### revalidate
|
|
346
352
|
|
|
347
353
|
```ts
|
|
354
|
+
import { revalidatePath, revalidateTag } from "next/cache";
|
|
355
|
+
import { THEME_CACHE_TAG } from "@roottale/cms-client/server";
|
|
348
356
|
import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";
|
|
349
|
-
|
|
357
|
+
|
|
358
|
+
export const POST = createRevalidateRoute({
|
|
359
|
+
apiKey,
|
|
360
|
+
// 2번째 인자(type)를 반드시 그대로 넘긴다 — 안 넘기면 레이아웃 전역 무효화가
|
|
361
|
+
// 페이지 1개 무효화로 줄어든다.
|
|
362
|
+
revalidate: (path, type) => revalidatePath(path, type),
|
|
363
|
+
collections: COLLECTIONS,
|
|
364
|
+
// 어드민에서 콘텐츠 유형·메뉴·사업장 정보 등 **설정**을 저장했을 때 그 값을
|
|
365
|
+
// 담은 fetch 캐시를 지운다. `{ expire: 0 }` 이어야 즉시 만료다
|
|
366
|
+
// (revalidation-webhooks.md §1 "설정 저장").
|
|
367
|
+
revalidateTag: (tag: string) => revalidateTag(tag, { expire: 0 }),
|
|
368
|
+
themeTag: THEME_CACHE_TAG,
|
|
369
|
+
});
|
|
350
370
|
```
|
|
351
371
|
|
|
352
372
|
각 섹션 basePath(+ `archives`면 `/categories`)와 `/feed.xml`·`/sitemap.xml`·`/llms.txt`를
|
|
353
373
|
자동 무효화합니다. resolver가 실패하면 잘못된 경로를 추측하지 않고 불변 경로만 갱신하며
|
|
354
374
|
응답에 `warning`을 노출합니다.
|
|
355
375
|
|
|
376
|
+
`fetchCollections`로 유형을 동적 로드한다면 그 조회에도
|
|
377
|
+
`tags: [COLLECTIONS_CACHE_TAG]`를 붙이세요 — 어드민에서 유형을 추가·수정했을 때
|
|
378
|
+
사이트가 옛 목록을 붙들고 있지 않게 됩니다.
|
|
379
|
+
|
|
356
380
|
### 동적 OG 이미지
|
|
357
381
|
|
|
358
382
|
```tsx
|
package/docs/getting-started.md
CHANGED
|
@@ -9,16 +9,69 @@ description: API 키 발급, 환경변수 설정, 패키지 설치, 첫 콘텐
|
|
|
9
9
|
|
|
10
10
|
1. 어드민(`admin.roottale.com`) 로그인
|
|
11
11
|
2. **설정 > 사이트 연결 키** 메뉴로 이동
|
|
12
|
-
3. 새 키 발급 — 권한
|
|
13
|
-
- **read** (기본):
|
|
14
|
-
초안·예약·비공개 글, 미디어 목록을 조회할 수
|
|
15
|
-
|
|
12
|
+
3. 새 키 발급 — 권한 선택 (괄호 안은 화면에 보이는 이름):
|
|
13
|
+
- **read** — "읽기" (기본): 공개 콘텐츠와 관리 API의
|
|
14
|
+
초안·예약·비공개 글, 미디어 목록을 조회할 수 있음.
|
|
15
|
+
**완전한 읽기 전용은 아닙니다** — 상담 게시판
|
|
16
|
+
글 작성(`POST /v1/cms/public/inquiries`)이 이 권한으로 됩니다(사이트의
|
|
17
|
+
상담 폼이 동작하려면 필요). 기존 글·미디어·설정을 고치거나 지우지는 못함
|
|
18
|
+
- **read_write** — "읽기 + 쓰기": 위에 더해 글 작성·수정·**발행·발행 취소·
|
|
19
|
+
영구 삭제**, 미디어 업로드·**삭제**, 카테고리/태그 생성·**삭제**(딸린
|
|
20
|
+
연결까지 cascade). 유출 시 콘텐츠가 지워질 수 있는 권한입니다
|
|
21
|
+
- **read_write_settings** — "읽기 + 쓰기 + 설정 변경": 위에 더해
|
|
22
|
+
**사이트 설정 쓰기**(사업장 정보·상단 메뉴)와, 수동 갱신 API로
|
|
23
|
+
**설정 저장 신호(`theme.updated`) 보내기**. 뒤엣것은 사이트의 모든 페이지를
|
|
24
|
+
다시 만들게 하는 신호라 글쓰기 권한과 나눠 두었습니다. 어드민 화면 대신
|
|
25
|
+
외부 도구가 가게 이름·주소·전화번호·메뉴를 고쳐야 할 때만 선택하세요.
|
|
26
|
+
쓰는 방법은 [HTTP API 레퍼런스의 "설정 쓰기 API"](./api-reference.md#설정-쓰기-api)
|
|
16
27
|
4. 발급된 키(`rtlk_cust_` + 24자)는 **발급 직후 1회만 평문 표시**됩니다. 바로
|
|
17
28
|
복사해 환경변수에 저장하세요.
|
|
18
29
|
|
|
30
|
+
발급된 뒤에는 키 목록에서 각 키의 권한 이름과 **실제 scope 전체**를 확인할 수
|
|
31
|
+
있습니다("이 키의 권한 자세히"). 설정 변경이 가능하거나 화면이 모르는 scope가
|
|
32
|
+
섞인 키는 눈에 띄게 표시됩니다.
|
|
33
|
+
|
|
19
34
|
키는 사이트 단위로 스코프되어(site-scoped) 해당 사이트의 콘텐츠·웹훅 검증에만
|
|
20
35
|
사용됩니다.
|
|
21
36
|
|
|
37
|
+
권한별 scope는 다음과 같습니다. 넓은 권한은 좁은 권한을 그대로 포함합니다.
|
|
38
|
+
|
|
39
|
+
| 권한 | scope |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `read` | `cms:read` |
|
|
42
|
+
| `read_write` | `cms:read` `cms:write` `post:draft:write` `post:publish` `media:write` `taxonomies:write` |
|
|
43
|
+
| `read_write_settings` | `read_write` + `settings:write` |
|
|
44
|
+
|
|
45
|
+
> `cms:read`라는 이름과 달리 이 scope 하나로 상담 게시판 글이 **생성**됩니다.
|
|
46
|
+
> "최소 권한 = 아무것도 못 바꿈"으로 읽지 마세요.
|
|
47
|
+
|
|
48
|
+
`settings:write`는 **새로 발급하는 키에만** 붙습니다. 이미 쓰고 있는
|
|
49
|
+
`read_write` 키는 그대로 두어도 동작이 달라지지 않고, 설정 쓰기 권한이
|
|
50
|
+
저절로 생기지도 않습니다.
|
|
51
|
+
|
|
52
|
+
### 발급한 뒤에 권한을 바꾸려면 — 새 키로 교체
|
|
53
|
+
|
|
54
|
+
발급된 키의 권한은 **나중에 바꿀 수 없습니다.** 예를 들어 `read_write` 키를
|
|
55
|
+
쓰다가 설정 쓰기가 필요해졌다면, 그 키에 권한을 더하는 것이 아니라 새 키를
|
|
56
|
+
발급해 교체합니다. 순서를 지키면 서비스가 끊기지 않습니다.
|
|
57
|
+
|
|
58
|
+
1. 어드민 **설정 > 사이트 연결 키**에서 원하는 권한으로 **새 키를 발급**합니다.
|
|
59
|
+
이름은 교체 대상과 구분되게 적으세요(예: `운영 서버 2026-07`). 목록에 붙는
|
|
60
|
+
권한 이름으로 어떤 키를 교체해야 하는지 찾을 수 있습니다.
|
|
61
|
+
2. 배포 환경의 `ROOTTALE_API_KEY`를 새 키로 바꾸고 **재배포**합니다.
|
|
62
|
+
(환경변수만 바꾸고 재배포하지 않으면 옛 키가 계속 쓰입니다.)
|
|
63
|
+
3. 사이트가 정상 동작하는지 확인합니다 — 글 목록이 보이는지, 자동화가
|
|
64
|
+
`401 invalid_key` 없이 도는지.
|
|
65
|
+
4. 목록의 **최근 사용**(날짜 + 시각)이 교체 시점 이후로 갱신되지 않는지 확인한 뒤
|
|
66
|
+
삭제합니다. 판정 기준은 **그 키의 정상 호출 주기를 한 번 넘길 때까지 관찰**
|
|
67
|
+
입니다 — 하루 한 번 도는 배치라면 하루, ISR 재검증이 30분이면 30분. 배포가
|
|
68
|
+
여러 곳(프리뷰·스테이징·사내 도구)이면 전부 교체됐는지 함께 봅니다.
|
|
69
|
+
옛 키를 먼저 지우면 그 사이 요청이 `401 invalid_key`로 실패합니다.
|
|
70
|
+
|
|
71
|
+
같은 절차를 키 유출이 의심될 때도 씁니다. 다만 그때는 순서를 뒤집어, 옛 키를
|
|
72
|
+
먼저 삭제하고 새 키로 교체하세요 — 잠깐의 중단보다 유출된 키가 살아 있는 쪽이
|
|
73
|
+
위험합니다.
|
|
74
|
+
|
|
22
75
|
## 2. 환경변수
|
|
23
76
|
|
|
24
77
|
```bash
|
|
@@ -30,6 +83,9 @@ ROOTTALE_API_KEY=rtlk_cust_xxxxxxxxxxxxxxxxxxxxxxxx
|
|
|
30
83
|
|
|
31
84
|
# 사이트 정식 도메인 (RSS/sitemap/canonical 생성용)
|
|
32
85
|
NEXT_PUBLIC_SITE_URL=https://example.com
|
|
86
|
+
|
|
87
|
+
# (선택) RootTale 관리자에서 IndexNow를 켰을 때 발급된 검증 키
|
|
88
|
+
INDEXNOW_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
33
89
|
```
|
|
34
90
|
|
|
35
91
|
**중요**: `ROOTTALE_API_KEY`는 절대 `NEXT_PUBLIC_*` 접두를 붙이지 마세요.
|
|
@@ -150,4 +206,7 @@ npx -y @roottale/cms-mcp cli media upload --help
|
|
|
150
206
|
|
|
151
207
|
- 블로그 페이지 구현 → `blog.md`
|
|
152
208
|
- 발행 즉시 사이트 반영 → `revalidation-webhooks.md`
|
|
209
|
+
- 설정(디자인·메뉴·사업장 정보) 저장 즉시 반영 → `revalidation-webhooks.md` §1
|
|
210
|
+
"설정 저장" + `theme-and-settings.md` (수신 라우트의 `revalidateTag` 주입과
|
|
211
|
+
조회의 `tags` 를 **둘 다** 해야 합니다)
|
|
153
212
|
- HTTP 자동화 엔드포인트 → `api-reference.md`
|
package/docs/menus.md
CHANGED
|
@@ -21,12 +21,15 @@ description: 어드민 "디자인 > 메뉴"에서 관리하는 네비게이션
|
|
|
21
21
|
```tsx
|
|
22
22
|
// components/site-nav.tsx (Server Component)
|
|
23
23
|
import Link from "next/link";
|
|
24
|
-
import { fetchMenu } from "@roottale/cms-client/server";
|
|
24
|
+
import { MENUS_CACHE_TAG, fetchMenu } from "@roottale/cms-client/server";
|
|
25
25
|
|
|
26
26
|
export async function SiteNav() {
|
|
27
27
|
const menu = await fetchMenu({
|
|
28
28
|
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
29
29
|
slug: "primary",
|
|
30
|
+
// 캐시 이름표 — 어드민에서 메뉴를 저장하면 웹훅 수신 라우트가 이 이름표로
|
|
31
|
+
// 이 조회의 캐시를 지웁니다 (revalidation-webhooks.md §1 "설정 저장").
|
|
32
|
+
tags: [MENUS_CACHE_TAG],
|
|
30
33
|
});
|
|
31
34
|
|
|
32
35
|
// 메뉴 미설정(null)이면 자체 fallback 네비를 렌더하세요.
|
|
@@ -67,7 +70,9 @@ export async function SiteNav() {
|
|
|
67
70
|
```
|
|
68
71
|
|
|
69
72
|
`fetchMenu` 는 미존재/구 서버의 404 를 `null` 로 돌려주므로(fail-soft) 항상
|
|
70
|
-
fallback 분기를 두세요. 전체 메뉴 목록이 필요하면
|
|
73
|
+
fallback 분기를 두세요. 전체 메뉴 목록이 필요하면
|
|
74
|
+
`fetchMenus({ apiKey, tags: [MENUS_CACHE_TAG] })` — 두 함수가 같은 이름표를
|
|
75
|
+
씁니다(메뉴를 저장하면 어느 쪽 캐시든 함께 낡기 때문).
|
|
71
76
|
|
|
72
77
|
## 타입
|
|
73
78
|
|
package/docs/overview.md
CHANGED
|
@@ -34,9 +34,10 @@ RootTale CMS는 어드민(`admin.roottale.com`)에서 콘텐츠를 작성·발
|
|
|
34
34
|
| 기능 | 같은 키 하나로 |
|
|
35
35
|
|---|---|
|
|
36
36
|
| 블로그 글 목록/상세 조회 | `fetchPosts` / `fetchPost` |
|
|
37
|
-
| 발행 웹훅 서명 검증 | `createRevalidateRoute` (JWKS 공개키 — 별도 secret 보관 불필요) |
|
|
37
|
+
| 발행 웹훅 서명 검증 + 캐시 갱신 | `createRevalidateRoute` (JWKS 공개키 — 별도 secret 보관 불필요). 설정 저장을 즉시 반영하려면 `revalidateTag` 주입 필수 — `revalidation-webhooks.md` §1 |
|
|
38
38
|
| 상담문의(리드) 접수 | `submitInquiry` — 키가 테넌트를 식별 |
|
|
39
39
|
| 테마·블로그 표시·분석 태그 설정 조회 | `fetchTheme` / `fetchBlogSettings` / `fetchAnalyticsConfig` |
|
|
40
|
+
| 사업장 정보·메뉴·콘텐츠 유형 조회 | `fetchBusinessProfile` / `fetchMenu`·`fetchMenus` / `fetchCollections` |
|
|
40
41
|
|
|
41
42
|
키는 **서버 전용**입니다. 브라우저로 노출되면 안 됩니다(`NEXT_PUBLIC_*` 금지).
|
|
42
43
|
`@roottale/cms-client`는 브라우저에서 import 시 의도적으로 throw 합니다.
|
|
@@ -18,13 +18,19 @@ description: 글 발행/수정 시 사이트 캐시를 near-real-time으로 갱
|
|
|
18
18
|
|
|
19
19
|
```ts
|
|
20
20
|
// app/api/revalidate/route.ts
|
|
21
|
-
import { revalidatePath } from "next/cache";
|
|
21
|
+
import { revalidatePath, revalidateTag } from "next/cache";
|
|
22
|
+
import { THEME_CACHE_TAG } from "@roottale/cms-client/server";
|
|
22
23
|
import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";
|
|
23
24
|
|
|
24
25
|
export const POST = createRevalidateRoute({
|
|
25
26
|
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
26
27
|
apiBase: process.env.ROOTTALE_API_BASE,
|
|
27
28
|
revalidate: revalidatePath,
|
|
29
|
+
// 설정(디자인 토큰·상단 메뉴·사업장 정보·블로그 표시 설정) 저장을 즉시
|
|
30
|
+
// 반영하려면 **반드시 넣으세요**. `{ expire: 0 }` 이어야 즉시 만료입니다.
|
|
31
|
+
// 아래 §설정 저장 참고.
|
|
32
|
+
revalidateTag: (tag: string) => revalidateTag(tag, { expire: 0 }),
|
|
33
|
+
themeTag: THEME_CACHE_TAG,
|
|
28
34
|
// 글 변경 시 함께 갱신할 추가 경로 (기본: /feed.xml, /sitemap.xml, /blog)
|
|
29
35
|
alsoRevalidate: ["/feed.xml", "/sitemap.xml", "/blog", "/"],
|
|
30
36
|
});
|
|
@@ -36,16 +42,74 @@ export function GET(): Response {
|
|
|
36
42
|
|
|
37
43
|
블로그가 `/blog`가 아닌 경로면 `blogBasePath: "/insights"` 옵션을 추가하세요.
|
|
38
44
|
|
|
39
|
-
카테고리/태그 인덱스 같은 동적 경로가 있다면 `revalidate` 콜백을
|
|
45
|
+
카테고리/태그 인덱스 같은 동적 경로가 있다면 `revalidate` 콜백을 확장합니다.
|
|
46
|
+
**2번째 인자(`type`)를 반드시 그대로 넘기세요** — 설정 저장 시 팩토리가
|
|
47
|
+
`revalidate("/", "layout")` 으로 루트 레이아웃 전역 무효화를 요청하는데,
|
|
48
|
+
`(path)` 만 받는 콜백은 그 `"layout"` 을 버려서 정적 페이지가 안 바뀝니다.
|
|
40
49
|
|
|
41
50
|
```ts
|
|
42
|
-
function revalidateBlogPath(path: string): void {
|
|
43
|
-
revalidatePath(path);
|
|
51
|
+
function revalidateBlogPath(path: string, type?: "layout" | "page"): void {
|
|
52
|
+
revalidatePath(path, type); // ← type 을 빠뜨리지 마세요
|
|
44
53
|
if (path === "/blog/categories") revalidatePath("/blog/categories/[category]", "page");
|
|
45
54
|
if (path === "/blog/tags") revalidatePath("/blog/tags/[tag]", "page");
|
|
46
55
|
}
|
|
47
56
|
```
|
|
48
57
|
|
|
58
|
+
### 설정 저장을 즉시 반영하려면 — 캐시 이름표(`tags`)
|
|
59
|
+
|
|
60
|
+
글은 **경로**에 담겨 있어서 `revalidatePath`로 지워집니다. 반면 디자인 토큰·
|
|
61
|
+
상단 메뉴·사업장 정보 같은 **설정**은 경로가 아니라 `fetch` 응답 캐시(Next.js
|
|
62
|
+
Data Cache)에 들어 있어서, `revalidatePath`로는 지워지지 않습니다. 그래서 설정
|
|
63
|
+
조회에는 **캐시 이름표(`tags`)** 를 붙이고, 웹훅 수신 시 그 이름표를 지웁니다.
|
|
64
|
+
|
|
65
|
+
두 곳을 함께 해 주세요.
|
|
66
|
+
|
|
67
|
+
1. **수신 라우트**에 `revalidateTag`를 주입한다 (위 §1 예시)
|
|
68
|
+
2. **설정 조회**마다 이름표를 붙인다
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import {
|
|
72
|
+
BUSINESS_CACHE_TAG,
|
|
73
|
+
MENUS_CACHE_TAG,
|
|
74
|
+
THEME_CACHE_TAG,
|
|
75
|
+
fetchBusinessProfile,
|
|
76
|
+
fetchMenu,
|
|
77
|
+
fetchTheme,
|
|
78
|
+
} from "@roottale/cms-client/server";
|
|
79
|
+
|
|
80
|
+
const theme = await fetchTheme({ apiKey, tags: [THEME_CACHE_TAG] });
|
|
81
|
+
const business = await fetchBusinessProfile({ apiKey, tags: [BUSINESS_CACHE_TAG] });
|
|
82
|
+
const menu = await fetchMenu({ apiKey, slug: "primary", tags: [MENUS_CACHE_TAG] });
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
이름표 상수는 `@roottale/cms-client/server`가 내보냅니다 — 문자열을 직접 쓰지
|
|
86
|
+
말고 상수를 쓰세요(양쪽 이름이 어긋나면 조용히 안 지워집니다).
|
|
87
|
+
|
|
88
|
+
> **두 번째 인자는 `{ expire: 0 }` 이어야 합니다.** Next는 `expire`가 0이 아닌
|
|
89
|
+
> 프로파일을 stale-while-revalidate 업데이트로 취급해서, 이름표를 지워도 다음
|
|
90
|
+
> 요청이 여전히 옛 값을 받습니다 — `"max"` 프로파일의 `expire`는 **1년**이라
|
|
91
|
+
> "즉시 반영"이 되지 않습니다. `updateTag`는 즉시 만료이지만 **서버 액션
|
|
92
|
+
> 전용**이라 웹훅 라우트(Route Handler)에서 호출하면 오류가 납니다. 이 경로에서
|
|
93
|
+
> 쓸 수 있는 것은 `revalidateTag(tag, { expire: 0 })` 하나뿐입니다.
|
|
94
|
+
|
|
95
|
+
| 조회 함수 | 이름표 상수 | 어드민 화면 |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `fetchTheme` (`theme.siteNav` 포함) | `THEME_CACHE_TAG` | 디자인 · 설정 > 사이트 > 상단 메뉴 |
|
|
98
|
+
| `fetchBusinessProfile` | `BUSINESS_CACHE_TAG` | 운영 > 비즈니스 프로필 |
|
|
99
|
+
| `fetchMenu` / `fetchMenus` | `MENUS_CACHE_TAG` | 디자인 > 메뉴 |
|
|
100
|
+
| `fetchBlogSettings` | `BLOG_SETTINGS_CACHE_TAG` | 설정 > 블로그 |
|
|
101
|
+
| `fetchCollections` | `COLLECTIONS_CACHE_TAG` | 설정 > 콘텐츠 유형 |
|
|
102
|
+
|
|
103
|
+
다섯 개를 한 벌로 묶은 `SETTINGS_CACHE_TAGS` 배열도 있습니다. 수신 라우트는
|
|
104
|
+
설정 저장 신호(`theme.updated`) 한 번에 **이 목록 전체**를 지웁니다 — 무엇이
|
|
105
|
+
바뀌었는지 웹훅 본문에 없기 때문이며, 쓰지 않는 이름표를 지우는 것은 아무 일도
|
|
106
|
+
일어나지 않으므로 해가 없습니다.
|
|
107
|
+
|
|
108
|
+
> **`revalidateTag`를 주입하지 않으면** 설정 변경이 그 조회의 `revalidate`
|
|
109
|
+
> 초만큼(설정에 따라 30분까지) 늦게 반영됩니다. 라우트는 정상 200을 돌려주고
|
|
110
|
+
> 어드민에도 오류가 안 뜨기 때문에, 이 누락은 "가끔 늦게 반영된다"로만 보입니다.
|
|
111
|
+
> 응답 본문의 `revalidated.requestedTags`가 빈 배열이면 주입이 빠진 것입니다.
|
|
112
|
+
|
|
49
113
|
## 2. 어드민에 웹훅 URL 등록
|
|
50
114
|
|
|
51
115
|
1. 어드민 **내 사이트 > (사이트 선택)** 페이지로 이동
|
|
@@ -56,14 +120,51 @@ function revalidateBlogPath(path: string): void {
|
|
|
56
120
|
|
|
57
121
|
URL을 비우고 저장하면 웹훅이 비활성화됩니다.
|
|
58
122
|
|
|
123
|
+
### 주소는 "실제로 서비스되는 주소" 여야 합니다
|
|
124
|
+
|
|
125
|
+
웹훅은 **리다이렉트를 따라가지 않습니다**(서명이 본문에 묶여 있어 따라가면
|
|
126
|
+
안 됩니다). 그래서 다른 주소로 넘어가는 주소를 등록하면 3xx 응답을 받고
|
|
127
|
+
그대로 실패합니다 — 화면에는 아무 오류가 안 뜨고, 발행한 글만 조용히 늦게
|
|
128
|
+
반영됩니다.
|
|
129
|
+
|
|
130
|
+
- 사이트 도메인을 바꿨다면 **이 URL도 같이 바꾸세요**. 옛 주소가 새 주소로
|
|
131
|
+
넘어가도록 해 뒀더라도 웹훅에는 소용이 없습니다.
|
|
132
|
+
- `example.com` 이 `www.example.com` 으로 넘어가는 구성이라면 **넘어간 뒤의
|
|
133
|
+
주소**(`https://www.example.com/api/revalidate`)를 등록하세요.
|
|
134
|
+
|
|
135
|
+
### Cloudflare를 쓴다면 SSL 모드가 Flexible이면 안 됩니다
|
|
136
|
+
|
|
137
|
+
도메인이 Cloudflare에 있고 SSL/TLS 모드가 **Flexible**이면, Cloudflare가
|
|
138
|
+
원본 서버에 평문 HTTP로 붙습니다. Vercel·Netlify 등 대부분의 호스팅은 HTTP
|
|
139
|
+
요청을 HTTPS로 되돌리므로 **자기 자신으로 무한히 넘어가는 308**이 되고,
|
|
140
|
+
웹훅은 매번 실패합니다.
|
|
141
|
+
|
|
142
|
+
브라우저로는 멀쩡해 보일 수 있습니다(DNS가 Cloudflare를 거치지 않는 설정이면
|
|
143
|
+
사람이 접속할 때는 이 경로를 안 타기 때문입니다). SSL/TLS 모드를 **Full
|
|
144
|
+
(strict)** 로 두세요 — 보안상으로도 그쪽이 맞습니다.
|
|
145
|
+
|
|
146
|
+
확인 방법: 아래 응답이 `308`이고 이동 주소가 **요청한 주소와 같으면** 이
|
|
147
|
+
경우입니다.
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
curl -sI -X POST https://<사이트 도메인>/api/revalidate
|
|
151
|
+
```
|
|
152
|
+
|
|
59
153
|
## 웹훅 발송 트리거
|
|
60
154
|
|
|
61
155
|
- 게시물 생성/발행/수정/삭제/발행 취소
|
|
62
156
|
- 게시물의 카테고리·태그 변경 (블로그 카드의 카테고리 라벨이 바뀌므로)
|
|
63
157
|
- 분류(taxonomy) 용어 생성/수정/순서 변경/삭제
|
|
64
|
-
-
|
|
65
|
-
-
|
|
66
|
-
-
|
|
158
|
+
- 디자인 토큰 변경 → `theme.updated`
|
|
159
|
+
- 상단 메뉴·헤더/푸터 메뉴·공지 배너·상담바·문의 게시판 설정 변경 → `theme.updated`
|
|
160
|
+
- 사업장 정보(비즈니스 프로필) 변경 → `theme.updated`
|
|
161
|
+
- 블로그 표시 설정 변경 (TOC, 작성자/발행일, 작성자 카드) → `theme.updated`
|
|
162
|
+
- 콘텐츠 유형(스트림) 변경 → `theme.updated`
|
|
163
|
+
- 설정 쓰기 API(`PATCH /v1/cms/settings/*`) 호출 → `theme.updated`
|
|
164
|
+
- 수동 revalidation API 호출 (`event`로 지정 — 아래 §"수동 revalidation API")
|
|
165
|
+
|
|
166
|
+
설정 저장은 **전부 `theme.updated` 하나**로 옵니다 — 무엇이 바뀌었는지는 본문에
|
|
167
|
+
없습니다. 그래서 수신 측은 이 이벤트에서 설정 이름표를 통째로 지웁니다.
|
|
67
168
|
|
|
68
169
|
## 캐시 무효화 규칙
|
|
69
170
|
|
|
@@ -73,18 +174,51 @@ URL을 비우고 저장하면 웹훅이 비활성화됩니다.
|
|
|
73
174
|
바뀌어도 카드 메타(카테고리 라벨 등)가 바뀔 수 있음
|
|
74
175
|
- 상세 페이지는 현재 slug + payload의 `paths` 힌트 경로 모두 revalidate
|
|
75
176
|
- 홈에 최신 글 섹션이 있으면 `alsoRevalidate`에 `/` 포함
|
|
177
|
+
- `theme.updated`(설정 저장)는 **캐시 이름표 전체 + `revalidatePath("/",
|
|
178
|
+
"layout")` + 본문 `paths`** 로 처리 — 설정은 모든 페이지에 깔리므로 경로만
|
|
179
|
+
열거하면 정적 페이지가 빠지고, 반대로 `/sitemap.xml`·`/feed.xml` 같은 route
|
|
180
|
+
handler 는 레이아웃 무효화에 딸려 온다고 보장할 수 없어 `paths` 도 함께 씁니다
|
|
76
181
|
|
|
77
182
|
분류 변경은 `taxonomy.updated` 이벤트 **한 번**으로 전달됩니다. payload의
|
|
78
183
|
`paths`에는 영향을 받는 글 상세 경로와 카테고리 모음 경로가 중복 없이 들어갑니다.
|
|
79
184
|
연결된 글마다 웹훅을 따로 보내지 않으므로, 카테고리 하나를 바꿔도 수십 번 재검증되는
|
|
80
185
|
문제가 없습니다.
|
|
81
186
|
|
|
187
|
+
## 글 웹훅의 `paths` — 콘텐츠 유형 기준
|
|
188
|
+
|
|
189
|
+
글 관련 이벤트(`post.published`·`post.updated`·`post.deleted`)의 `paths`는 **그 글이
|
|
190
|
+
속한 콘텐츠 유형(스트림)의 주소**를 담습니다. 공지 유형(`/notice`) 글이면 `/notice`와
|
|
191
|
+
`/notice/{slug}`가 오고, `/blog`는 오지 않습니다.
|
|
192
|
+
|
|
193
|
+
한 글의 `paths`에 들어가는 것:
|
|
194
|
+
|
|
195
|
+
- 유형의 목록 주소 (예: `/notice`)
|
|
196
|
+
- 유형의 글 상세 주소 (예: `/notice/my-post`). 한글 주소는 인코딩한 값과 원문을
|
|
197
|
+
함께 보냅니다
|
|
198
|
+
- 유형의 **카테고리 모음 켬** 설정이 켜져 있을 때만 `{유형 주소}/categories`와
|
|
199
|
+
`{유형 주소}/categories/{카테고리}`
|
|
200
|
+
- **유형을 옮긴 글**은 옮기기 전·후 주소가 함께 옵니다 — 옮기기 전 목록에서도
|
|
201
|
+
글이 빠져야 하기 때문입니다
|
|
202
|
+
|
|
203
|
+
주소가 안 나오는 경우도 있습니다.
|
|
204
|
+
|
|
205
|
+
- **주소 없는 유형**(페이지 안에서 불러 쓰는 강사·후기 같은 콘텐츠)이나 **목록
|
|
206
|
+
전용 유형**(글마다 상세 주소가 없는 유형)은 상세 주소가 없으므로 그만큼 빠집니다.
|
|
207
|
+
유형 자체에 주소가 없으면 `paths`가 비어 올 수 있습니다
|
|
208
|
+
- 글에 유형이 없거나(미분류) 사이트가 아직 콘텐츠 유형을 안 쓰면 예전처럼
|
|
209
|
+
`/blog`·`/blog/{slug}`·`/blog/categories*`로 옵니다
|
|
210
|
+
|
|
211
|
+
`paths`가 비거나 유형 주소만 와도 걱정할 필요는 없습니다. 위 §캐시 무효화 규칙대로
|
|
212
|
+
수신 측은 모든 서명 이벤트에서 블로그 목록과 피드·사이트맵을 함께 갱신하고,
|
|
213
|
+
`collections`를 넘긴 사이트는 각 유형의 목록 주소도 자동으로 갱신합니다.
|
|
214
|
+
|
|
82
215
|
## 저수준 검증 — verifyRootTaleWebhook
|
|
83
216
|
|
|
84
217
|
`createRevalidateRoute`를 못 쓰는 환경(다른 프레임워크 등)은
|
|
85
218
|
`@roottale/cms-client/webhook`으로 직접 검증합니다:
|
|
86
219
|
|
|
87
220
|
```ts
|
|
221
|
+
import { SETTINGS_CACHE_TAGS } from "@roottale/cms-client/server";
|
|
88
222
|
import { verifyRootTaleWebhook } from "@roottale/cms-client/webhook";
|
|
89
223
|
|
|
90
224
|
export async function POST(request: Request) {
|
|
@@ -101,7 +235,22 @@ export async function POST(request: Request) {
|
|
|
101
235
|
const paths = Array.isArray(payload.paths)
|
|
102
236
|
? payload.paths.filter((path): path is string => typeof path === "string")
|
|
103
237
|
: [];
|
|
104
|
-
// result.event:
|
|
238
|
+
// result.event:
|
|
239
|
+
// "post.published" | "post.updated" | "post.deleted" | "taxonomy.updated"
|
|
240
|
+
// | "theme.updated" ← 설정 저장(디자인 토큰·상단 메뉴·사업장 정보 등)
|
|
241
|
+
//
|
|
242
|
+
// "theme.updated"는 캐시 이름표 + 레이아웃 무효화가 본체이고, 본문 paths 는
|
|
243
|
+
// 그 위에 더합니다 — 콘텐츠 유형 저장은 /sitemap.xml·/feed.xml·/llms.txt 를
|
|
244
|
+
// 보내는데 이건 route handler 라 레이아웃 무효화로 덮인다고 볼 수 없습니다.
|
|
245
|
+
// (위 §1 "설정 저장" 참고)
|
|
246
|
+
if (result.event === "theme.updated") {
|
|
247
|
+
// { expire: 0 } = 즉시 만료. "max" 등 다른 프로파일은 SWR 업데이트라 다음
|
|
248
|
+
// 요청이 옛 값을 받습니다. updateTag 는 서버 액션 전용이라 여기서 throw.
|
|
249
|
+
for (const tag of SETTINGS_CACHE_TAGS) revalidateTag(tag, { expire: 0 });
|
|
250
|
+
revalidatePath("/", "layout"); // 정적 페이지까지 반영
|
|
251
|
+
for (const path of paths) revalidatePath(path); // 콘텐츠 유형 저장의 sitemap·feed
|
|
252
|
+
return Response.json({ ok: true });
|
|
253
|
+
}
|
|
105
254
|
for (const path of paths) revalidatePath(path);
|
|
106
255
|
return Response.json({ ok: true });
|
|
107
256
|
}
|
|
@@ -128,11 +277,44 @@ Content-Type: application/json
|
|
|
128
277
|
}
|
|
129
278
|
```
|
|
130
279
|
|
|
280
|
+
`event`에 넣을 수 있는 값은 `post.published` · `post.updated` · `post.deleted` ·
|
|
281
|
+
`theme.updated` 넷입니다. 생략하면 `post.updated`입니다.
|
|
282
|
+
|
|
283
|
+
### 설정을 직접 바꿨을 때 — `theme.updated`
|
|
284
|
+
|
|
285
|
+
사업장 정보·상단 메뉴를 [설정 쓰기 API](./api-reference.md#설정-쓰기-api)로
|
|
286
|
+
바꾸면 이 신호는 **저장과 함께 자동으로** 나갑니다. 직접 보낼 일은 그 밖의
|
|
287
|
+
경우입니다 — 예를 들어 알림 주소를 나중에 등록해서 저장 시점의 신호를 놓쳤거나,
|
|
288
|
+
수신 라우트를 고친 뒤 캐시 이름표를 한 번 비우고 싶을 때입니다.
|
|
289
|
+
|
|
290
|
+
```http
|
|
291
|
+
POST https://api.roottale.com/v1/cms/revalidate
|
|
292
|
+
Authorization: Bearer rtlk_cust_...
|
|
293
|
+
Content-Type: application/json
|
|
294
|
+
|
|
295
|
+
{ "event": "theme.updated" }
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
> **이 값만 한 단계 위 권한이 필요합니다.** `read_write_settings` 권한("읽기 +
|
|
299
|
+
> 쓰기 + 설정 변경", scope `settings:write`)으로 발급한 키여야 합니다. 글쓰기
|
|
300
|
+
> 키(`read_write`)로 보내면 `403 insufficient_scope`이고, 나머지 세 값은 지금까지
|
|
301
|
+
> 그대로 글쓰기 키로 보낼 수 있습니다.
|
|
302
|
+
>
|
|
303
|
+
> 권한을 나눈 이유는 비용입니다. `theme.updated`는 경로 몇 개가 아니라 **설정
|
|
304
|
+
> 이름표 전체 + 루트 레이아웃(그 아래 모든 페이지)** 을 다시 만들게 합니다.
|
|
305
|
+
> 글을 쓰라고 내준 키가 사이트 전체 재생성을 반복해서 돌릴 수 있으면 안 됩니다.
|
|
306
|
+
|
|
307
|
+
`paths`를 함께 보내면 이름표·레이아웃 무효화 **위에** 그 경로들이 더해집니다.
|
|
308
|
+
비워 두면 홈(`/`)이 기본으로 들어갑니다 — `theme.updated` 분기가 없는 옛
|
|
309
|
+
수신 라우트(`@roottale/cms-renderer-next` 0.41.0 미만)를 위한 기본값입니다.
|
|
310
|
+
|
|
131
311
|
## 트러블슈팅
|
|
132
312
|
|
|
133
313
|
| 증상 | 확인 |
|
|
134
314
|
|---|---|
|
|
135
315
|
| 발행해도 사이트 미반영 | 어드민의 자동 갱신 URL·활성화 체크, 배포 도메인 일치 여부 |
|
|
316
|
+
| 전송 기록이 `308`·`301` | 등록한 주소가 다른 주소로 넘어가고 있습니다. 넘어간 뒤의 주소를 등록하세요. 이동 주소가 요청 주소와 같으면 Cloudflare SSL 모드가 Flexible입니다(위 §2 참고) |
|
|
136
317
|
| 401 `invalid_signature` | `ROOTTALE_API_KEY`가 해당 사이트 스코프 키인지 |
|
|
137
318
|
| 401 `timestamp_out_of_window` | 서버 시계 동기화 (NTP) |
|
|
138
319
|
| 일부 페이지만 갱신 | `alsoRevalidate`·동적 경로 콜백 누락 |
|
|
320
|
+
| 글은 즉시인데 **설정(전화·주소·메뉴·디자인)만 늦게** 반영 | `revalidateTag` 주입과 조회의 `tags` 누락 (§1 "설정 저장") — 응답의 `revalidated.requestedTags`가 비어 있으면 주입이 빠진 것 |
|
package/docs/seo.md
CHANGED
|
@@ -340,6 +340,29 @@ export default function robots(): MetadataRoute.Robots {
|
|
|
340
340
|
}
|
|
341
341
|
```
|
|
342
342
|
|
|
343
|
+
## 새 글 발견 알림: WebSub와 IndexNow
|
|
344
|
+
|
|
345
|
+
사이트맵은 전체 URL 목록의 원장이고, 발행 직후 알림은 보조 수단입니다. 일반 블로그
|
|
346
|
+
글에는 Google Indexing API를 쓰지 마세요. 이 API는 `JobPosting`과
|
|
347
|
+
`BroadcastEvent`가 포함된 라이브 스트림 페이지에만 허용됩니다.
|
|
348
|
+
|
|
349
|
+
`createFeedRoute`에 `webSubHubUrl`을 주면 RSS에 `rel="hub"`와 기존
|
|
350
|
+
`rel="self"`가 함께 들어갑니다.
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
export const GET = createFeedRoute({
|
|
354
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
355
|
+
siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
|
|
356
|
+
title: "사이트 블로그",
|
|
357
|
+
webSubHubUrl: "https://pubsubhubbub.appspot.com/",
|
|
358
|
+
});
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
IndexNow는 Bing·Naver 등 참여 검색엔진용이며 Google 색인 요청이 아닙니다. 소유권
|
|
362
|
+
확인을 위해 같은 host의 공개 키 파일이 필요합니다. Site Kit은
|
|
363
|
+
`INDEXNOW_KEY`가 설정된 경우 `/indexnow-key.txt`에서 그 키만 반환합니다. 플랫폼이
|
|
364
|
+
보낸 알림의 200·202 응답은 “접수됨”을 뜻하며 검색결과 노출을 보장하지 않습니다.
|
|
365
|
+
|
|
343
366
|
## 브레드크럼 (BreadcrumbList)
|
|
344
367
|
|
|
345
368
|
사이트 구조를 검색엔진에 전달하고 검색결과에 경로가 표시됩니다.
|
|
@@ -800,4 +823,3 @@ const violations = scanAssetHosts(
|
|
|
800
823
|
`@import`는 1-depth만 보며(중첩 `@import`는 범위 밖), CSS escape 시퀀스
|
|
801
824
|
(`\3a ` 등)까지는 디코드하지 않습니다. 런타임에 JS로 삽입되는 요청은 배포
|
|
802
825
|
후 실제 네트워크 요청을 검사하는 E2E 프로브로 보완하세요.
|
|
803
|
-
|
|
@@ -8,25 +8,113 @@ description: 어드민에서 관리하는 디자인 토큰, 블로그 표시 옵
|
|
|
8
8
|
어드민에서 설정한 값을 공개 API로 조회해 사이트에 반영합니다. 모두
|
|
9
9
|
`@roottale/cms-client/server`에서 제공하며 같은 API 키를 사용합니다.
|
|
10
10
|
|
|
11
|
+
## 저장 즉시 반영 — 캐시 이름표(`tags`)
|
|
12
|
+
|
|
13
|
+
이 문서의 모든 조회 함수는 `tags?: string[]` 옵션을 받습니다. **설정 조회에는
|
|
14
|
+
이름표를 붙이세요.** 붙이지 않으면 어드민에서 값을 고쳐도 그 조회의
|
|
15
|
+
`revalidate` 초만큼(설정에 따라 30분까지) 옛 값이 사이트에 남습니다 —
|
|
16
|
+
`revalidatePath`는 경로 캐시만 지우고 `fetch` 응답 캐시는 못 지웁니다.
|
|
17
|
+
|
|
18
|
+
| 조회 함수 | 이름표 상수 |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `fetchTheme` (`theme.siteNav` 포함) | `THEME_CACHE_TAG` |
|
|
21
|
+
| `fetchBusinessProfile` | `BUSINESS_CACHE_TAG` |
|
|
22
|
+
| `fetchMenu` / `fetchMenus` | `MENUS_CACHE_TAG` |
|
|
23
|
+
| `fetchBlogSettings` | `BLOG_SETTINGS_CACHE_TAG` |
|
|
24
|
+
| `fetchCollections` | `COLLECTIONS_CACHE_TAG` |
|
|
25
|
+
|
|
26
|
+
다섯 개를 한 벌로 묶은 `SETTINGS_CACHE_TAGS` 배열도 내보냅니다. 이름표를 지우는
|
|
27
|
+
쪽(웹훅 수신 라우트) 배선은 `revalidation-webhooks.md` §1 "설정 저장"을
|
|
28
|
+
따르세요 — **양쪽을 다 해야** 즉시 반영이 됩니다.
|
|
29
|
+
|
|
11
30
|
## 디자인 토큰 — fetchTheme
|
|
12
31
|
|
|
13
32
|
```ts
|
|
14
|
-
import { fetchTheme } from "@roottale/cms-client/server";
|
|
33
|
+
import { THEME_CACHE_TAG, fetchTheme } from "@roottale/cms-client/server";
|
|
15
34
|
|
|
16
|
-
const theme = await fetchTheme({
|
|
35
|
+
const theme = await fetchTheme({
|
|
36
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
37
|
+
tags: [THEME_CACHE_TAG],
|
|
38
|
+
});
|
|
17
39
|
// theme.colors / theme.fonts / theme.radius — 어드민에서 설정한 토큰만 포함
|
|
18
40
|
```
|
|
19
41
|
|
|
20
42
|
토큰을 CSS 변수로 매핑해 렌더러 스타일과 사이트 스타일을 일치시킬 수
|
|
21
43
|
있습니다. 토큰 변경 시에도 발행 웹훅이 발송되어 캐시가 갱신됩니다.
|
|
22
44
|
|
|
45
|
+
## 상단 메뉴 — theme.siteNav
|
|
46
|
+
|
|
47
|
+
같은 `fetchTheme` 응답에 어드민 **설정 > 사이트 > 상단 메뉴**에서 저장한 GNB
|
|
48
|
+
구조가 함께 담깁니다. 별도 호출이 없고 테마와 같은 캐시 태그로 무효화됩니다.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
const theme = await fetchTheme({
|
|
52
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
53
|
+
tags: [THEME_CACHE_TAG],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// 미설정이면 undefined — 사이트가 자체 기본 메뉴로 폴백합니다.
|
|
57
|
+
const navGroups = theme.siteNav?.navGroups ?? FALLBACK_NAV;
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```jsonc
|
|
61
|
+
{
|
|
62
|
+
"navGroups": [
|
|
63
|
+
{ "label": "오시는 길", "href": "/visit" },
|
|
64
|
+
{
|
|
65
|
+
"label": "진료과목",
|
|
66
|
+
"columns": [
|
|
67
|
+
{
|
|
68
|
+
"title": "척추",
|
|
69
|
+
"links": [
|
|
70
|
+
{ "label": "허리 통증", "href": "/spine/back-pain", "description": "증상과 진료 흐름" }
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
"cta": { "label": "예약", "href": "/reserve" },
|
|
77
|
+
"utilityLinks": [{ "label": "블로그", "href": "/blog" }]
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
각 그룹은 `href`(단일 링크) 또는 `columns`(드롭다운) 중 **최소 하나**를 가져야
|
|
82
|
+
합니다. 모든 `href` 는 `#anchor` · `/path` · `http(s)://` 형식만 허용됩니다.
|
|
83
|
+
|
|
84
|
+
개수 상한:
|
|
85
|
+
|
|
86
|
+
| 항목 | 최대 |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `navGroups` | 7 |
|
|
89
|
+
| 그룹당 `columns` | 3 |
|
|
90
|
+
| 열당 `links` | 6 |
|
|
91
|
+
| `utilityLinks` | 4 |
|
|
92
|
+
|
|
93
|
+
> **주의**: 상한을 넘거나 형식이 어긋나면 개별 항목만 잘라내는 것이 아니라
|
|
94
|
+
> **`siteNav` 전체가 응답에서 빠집니다**(`undefined`). 메뉴가 통째로 사라지지
|
|
95
|
+
> 않도록 사이트에는 항상 폴백 메뉴를 두세요. 어드민 폼은 위 상한까지만 입력칸을
|
|
96
|
+
> 제공하므로 어드민으로 저장한 값은 이 조건을 이미 만족합니다.
|
|
97
|
+
|
|
98
|
+
### 어드민 대신 API로 바꾸기
|
|
99
|
+
|
|
100
|
+
상단 메뉴와 사업장 정보는 API로도 바꿀 수 있습니다 —
|
|
101
|
+
`PATCH /v1/cms/settings/site-nav` · `PATCH /v1/cms/settings/business-profile`.
|
|
102
|
+
`read_write_settings` 권한으로 발급한 키가 필요하고, 저장은 **그 블록 전체
|
|
103
|
+
교체**입니다. 요청·응답 모양과 저장 규칙은
|
|
104
|
+
[HTTP API 레퍼런스의 "설정 쓰기 API"](./api-reference.md#설정-쓰기-api)를 보세요.
|
|
105
|
+
|
|
106
|
+
저장에 성공하면 서버가 `theme.updated` 알림을 보내므로, 위 이름표 배선이
|
|
107
|
+
되어 있으면 사이트에 곧바로 반영됩니다.
|
|
108
|
+
|
|
23
109
|
## 블로그 표시 설정 — fetchBlogSettings
|
|
24
110
|
|
|
25
|
-
어드민의 블로그 표시 옵션(TOC 노출, 작성자/발행일 표시, 작성자
|
|
26
|
-
|
|
111
|
+
어드민의 블로그 표시 옵션(TOC 노출, 작성자/발행일 표시, 작성자 카드 표시
|
|
112
|
+
정책)을 조회합니다. 작성자 이름·사진·소개는 이 설정이 아니라 각 글의
|
|
113
|
+
`authorName`·`authorImageUrl`·`authorBio`가 정본입니다.
|
|
27
114
|
|
|
28
115
|
```ts
|
|
29
116
|
import {
|
|
117
|
+
BLOG_SETTINGS_CACHE_TAG,
|
|
30
118
|
fetchBlogSettings,
|
|
31
119
|
resolvePostDisplay,
|
|
32
120
|
DEFAULT_BLOG_SETTINGS,
|
|
@@ -34,16 +122,19 @@ import {
|
|
|
34
122
|
|
|
35
123
|
const settings = await fetchBlogSettings({
|
|
36
124
|
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
125
|
+
tags: [BLOG_SETTINGS_CACHE_TAG],
|
|
37
126
|
});
|
|
38
127
|
// showTableOfContents, showAuthor, showDate, showAuthorCard,
|
|
39
|
-
// tocTitle,
|
|
128
|
+
// tocTitle, authorProfileImageRadius / Position 등
|
|
40
129
|
|
|
41
130
|
// 글 단위 오버라이드(metaJson)와 합성해 최종 표시값 계산
|
|
42
131
|
const display = resolvePostDisplay(settings, post);
|
|
43
132
|
```
|
|
44
133
|
|
|
45
134
|
`RootTaleBlogPost` 컴포넌트를 쓰면 이 설정이 자동 반영됩니다 — 커스텀 UI를
|
|
46
|
-
만들 때만 직접 조회하면 됩니다.
|
|
135
|
+
만들 때만 직접 조회하면 됩니다. 호환 필드인 `authorProfileName`·
|
|
136
|
+
`authorProfileBio`·`authorProfileImageUrl`·`authorCardDescription`은 항상
|
|
137
|
+
`null`이며 새 코드에서 사용하지 마세요.
|
|
47
138
|
|
|
48
139
|
## 비즈니스 프로필 (로컬 SEO) — fetchBusinessProfile
|
|
49
140
|
|
|
@@ -53,12 +144,14 @@ const display = resolvePostDisplay(settings, post);
|
|
|
53
144
|
|
|
54
145
|
```ts
|
|
55
146
|
import {
|
|
147
|
+
BUSINESS_CACHE_TAG,
|
|
56
148
|
fetchBusinessProfile,
|
|
57
149
|
localBusinessSchema,
|
|
58
150
|
} from "@roottale/cms-client/server";
|
|
59
151
|
|
|
60
152
|
const business = await fetchBusinessProfile({
|
|
61
153
|
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
154
|
+
tags: [BUSINESS_CACHE_TAG],
|
|
62
155
|
});
|
|
63
156
|
// 미설정이면 null. 설정돼 있으면 name, alternateName, businessType, telephone,
|
|
64
157
|
// faxNumber, address, geo, openingHours, priceRange, areaServed, services,
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
// RootTale 발행 웹훅 수신 — ES256 서명 검증 후 Next.js 캐시 revalidate.
|
|
2
2
|
// 어드민 "내 사이트 > 배포"에 https://<도메인>/api/revalidate 로 등록한다.
|
|
3
|
-
import { revalidatePath } from "next/cache";
|
|
3
|
+
import { revalidatePath, revalidateTag } from "next/cache";
|
|
4
|
+
import { THEME_CACHE_TAG } from "@roottale/cms-client/server";
|
|
4
5
|
import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
// 2번째 인자(type)를 반드시 그대로 넘긴다 — 팩토리가 설정 저장 시
|
|
8
|
+
// `revalidate("/", "layout")` 을 부르는데, 콜백이 path 만 받으면 "layout" 이
|
|
9
|
+
// 사라져 루트 레이아웃 전역 무효화가 페이지 1개 무효화로 줄어든다.
|
|
10
|
+
function revalidateBlogPath(path: string, type?: "layout" | "page"): void {
|
|
11
|
+
revalidatePath(path, type);
|
|
8
12
|
if (path === "/blog/categories") {
|
|
9
13
|
revalidatePath("/blog/categories/[category]", "page");
|
|
10
14
|
}
|
|
@@ -14,6 +18,17 @@ export const POST = createRevalidateRoute({
|
|
|
14
18
|
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
15
19
|
apiBase: process.env.ROOTTALE_API_BASE,
|
|
16
20
|
revalidate: revalidateBlogPath,
|
|
21
|
+
// 설정(디자인 토큰·상단 메뉴·사업장 정보·블로그 표시 설정) 저장 시 그 값을
|
|
22
|
+
// 담고 있는 fetch 캐시를 지운다. **이 주입이 없으면** revalidatePath 만으로는
|
|
23
|
+
// fetch 캐시(Data Cache)가 안 지워져서 설정 변경이 최대 `revalidate` 초만큼
|
|
24
|
+
// 늦게 반영된다. 어떤 fetch 에 어떤 이름표를 붙이는지는 theme-and-settings.md.
|
|
25
|
+
//
|
|
26
|
+
// `{ expire: 0 }` = 즉시 만료. 다른 프로파일("max" 등)은 expire 가 0 이 아니라
|
|
27
|
+
// Next 가 stale-while-revalidate 업데이트로 취급해 다음 요청이 여전히 옛 값을
|
|
28
|
+
// 받는다("max" 의 expire 는 1년이다). `updateTag` 는 Server Action 전용이라
|
|
29
|
+
// route handler 에서 throw 하므로 쓰지 않는다.
|
|
30
|
+
revalidateTag: (tag: string) => revalidateTag(tag, { expire: 0 }),
|
|
31
|
+
themeTag: THEME_CACHE_TAG,
|
|
17
32
|
// 사이트맵 인덱스 + 분리된 하위 사이트맵(static/blog/categories)을 모두 무효화.
|
|
18
33
|
// 홈에 최신 글 섹션이 있으면 "/" 포함 — 글 변경 시 홈도 함께 갱신.
|
|
19
34
|
alsoRevalidate: [
|
|
@@ -30,13 +30,15 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
|
30
30
|
// 어드민 SEO 패널(metaJson.seo) override + self-canonical + avcd 구조(RSS
|
|
31
31
|
// alternate·Twitter Card·og article 확장)까지 1줄로. modified/section/tags 는
|
|
32
32
|
// 있는 만큼만 넘기면 됩니다 — 없으면 해당 필드만 생략.
|
|
33
|
-
return buildPostMetadata(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
return buildPostMetadata(
|
|
34
|
+
{
|
|
35
|
+
...post,
|
|
36
|
+
modified: post.modified,
|
|
37
|
+
section: post.category || undefined,
|
|
38
|
+
tags: post.tags.map((t) => t.name),
|
|
39
|
+
},
|
|
40
|
+
{ siteUrl: SITE_URL, path: `/blog/${post.slug}` },
|
|
41
|
+
);
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export default async function PostPage({ params }: Props) {
|
|
@@ -79,6 +81,8 @@ export default async function PostPage({ params }: Props) {
|
|
|
79
81
|
baseUrl={process.env.ROOTTALE_API_BASE}
|
|
80
82
|
slugOrId={post.id}
|
|
81
83
|
showTitle={false}
|
|
84
|
+
showTableOfContents
|
|
85
|
+
relatedPostsCount={3}
|
|
82
86
|
// opt-in — 시각 브레드크럼 + BreadcrumbList JSON-LD. siteUrl 없으면
|
|
83
87
|
// 시각 브레드크럼만(JSON-LD 미emit).
|
|
84
88
|
breadcrumb={{ siteUrl: SITE_URL }}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import "@roottale/cms-renderer-next/styles";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
BUSINESS_CACHE_TAG,
|
|
5
6
|
fetchBusinessProfile,
|
|
6
7
|
localBusinessSchema,
|
|
7
8
|
} from "@roottale/cms-client/server";
|
|
@@ -20,8 +21,13 @@ export default async function RootLayout({
|
|
|
20
21
|
}) {
|
|
21
22
|
// 로컬 SEO — 어드민(운영 > 비즈니스 프로필)에서 사업장 정보를 저장하면
|
|
22
23
|
// LocalBusiness JSON-LD 를 자동 렌더. 미설정/실패 시 null → 렌더 건너뜀 (seo.md).
|
|
24
|
+
//
|
|
25
|
+
// `tags` 는 캐시 이름표다. 이걸 붙여야 어드민에서 전화·주소를 고친 순간
|
|
26
|
+
// revalidate 라우트가 이 fetch 캐시를 지울 수 있다(안 붙이면 최대 `revalidate`
|
|
27
|
+
// 초만큼 옛 값이 남는다).
|
|
23
28
|
const business = await fetchBusinessProfile({
|
|
24
29
|
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
30
|
+
tags: [BUSINESS_CACHE_TAG],
|
|
25
31
|
}).catch(() => null);
|
|
26
32
|
|
|
27
33
|
return (
|
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
// 메뉴 미설정(null)이면 fallback 네비 — 항상 fallback 분기를 두세요.
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
|
|
5
|
-
import { fetchMenu } from "@roottale/cms-client/server";
|
|
5
|
+
import { MENUS_CACHE_TAG, fetchMenu } from "@roottale/cms-client/server";
|
|
6
6
|
|
|
7
7
|
export async function SiteNav() {
|
|
8
|
+
// `tags` = 캐시 이름표. 어드민에서 메뉴를 저장하면 revalidate 라우트가 이
|
|
9
|
+
// 이름표로 이 fetch 캐시를 지운다(안 붙이면 옛 메뉴가 한동안 남는다).
|
|
8
10
|
const menu = await fetchMenu({
|
|
9
11
|
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
10
12
|
baseUrl: process.env.ROOTTALE_API_BASE,
|
|
11
13
|
slug: "primary",
|
|
14
|
+
tags: [MENUS_CACHE_TAG],
|
|
12
15
|
}).catch(() => null);
|
|
13
16
|
|
|
14
17
|
if (!menu) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// RootTale CMS 블로그 데이터 레이어 — 서버 전용.
|
|
2
2
|
// 사이트 UI에 맞는 메타 형태로 변환하는 wrapper. API 키는 env에서만 읽는다.
|
|
3
3
|
import {
|
|
4
|
+
BLOG_SETTINGS_CACHE_TAG,
|
|
4
5
|
fetchBlogSettings,
|
|
5
6
|
fetchPost,
|
|
6
7
|
type CmsPostContent,
|
|
@@ -107,7 +108,13 @@ export async function getCategoryPromotion(
|
|
|
107
108
|
categorySlug: string,
|
|
108
109
|
): Promise<ArchivePromotion> {
|
|
109
110
|
try {
|
|
110
|
-
|
|
111
|
+
// `tags` = 캐시 이름표. 어드민에서 블로그 표시 설정을 저장하면 revalidate
|
|
112
|
+
// 라우트가 이 이름표로 이 fetch 캐시를 지운다.
|
|
113
|
+
const settings = await fetchBlogSettings({
|
|
114
|
+
apiKey: getApiKey(),
|
|
115
|
+
baseUrl,
|
|
116
|
+
tags: [BLOG_SETTINGS_CACHE_TAG],
|
|
117
|
+
});
|
|
111
118
|
return resolveCategoryPromotion(
|
|
112
119
|
settings.sitemap?.promotedCategorySlugs,
|
|
113
120
|
categorySlug,
|