@roottale/cms-renderer-next 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 +96 -0
- package/README.md +54 -4
- package/dist/{chunk-U2IM6OCQ.js → chunk-HM5VP7SR.js} +1 -1
- package/dist/{chunk-U2IM6OCQ.js.map → chunk-HM5VP7SR.js.map} +1 -1
- package/dist/chunk-KSPPYX22.js +14 -0
- package/dist/chunk-KSPPYX22.js.map +1 -0
- package/dist/cms-public.css +46 -0
- package/dist/routes.d.ts +25 -1
- package/dist/routes.js +72 -14
- package/dist/routes.js.map +1 -1
- package/dist/section-runtime.d.ts +46 -0
- package/dist/section-runtime.js +9 -0
- package/dist/server.d.ts +5 -3
- package/dist/server.js +1679 -34
- package/dist/server.js.map +1 -1
- package/package.json +7 -23
- package/dist/before-after-client.d.ts +0 -15
- package/dist/before-after-client.js +0 -8
- package/dist/chunk-27RT6B3O.js +0 -93
- package/dist/chunk-27RT6B3O.js.map +0 -1
- package/dist/chunk-2QU4JU46.js +0 -3927
- package/dist/chunk-2QU4JU46.js.map +0 -1
- package/dist/chunk-4RQ3SEDL.js +0 -106
- package/dist/chunk-4RQ3SEDL.js.map +0 -1
- package/dist/chunk-6GQCFFHU.js +0 -540
- package/dist/chunk-6GQCFFHU.js.map +0 -1
- package/dist/chunk-H46JUIHZ.js +0 -79
- package/dist/chunk-H46JUIHZ.js.map +0 -1
- package/dist/chunk-K62RR4UQ.js +0 -53
- package/dist/chunk-K62RR4UQ.js.map +0 -1
- package/dist/chunk-LEHHSI6G.js +0 -64
- package/dist/chunk-LEHHSI6G.js.map +0 -1
- package/dist/chunk-QYNEOFL7.js +0 -418
- package/dist/chunk-QYNEOFL7.js.map +0 -1
- package/dist/chunk-U5AKACPN.js +0 -113
- package/dist/chunk-U5AKACPN.js.map +0 -1
- package/dist/chunk-UM37DOIK.js +0 -961
- package/dist/chunk-UM37DOIK.js.map +0 -1
- package/dist/chunk-YLSQAT6B.js +0 -59
- package/dist/chunk-YLSQAT6B.js.map +0 -1
- package/dist/chunk-ZL2NA3QT.js +0 -313
- package/dist/chunk-ZL2NA3QT.js.map +0 -1
- package/dist/concerns-client.d.ts +0 -12
- package/dist/concerns-client.js +0 -8
- package/dist/concerns-client.js.map +0 -1
- package/dist/contact-map-client.d.ts +0 -27
- package/dist/contact-map-client.js +0 -8
- package/dist/contact-map-client.js.map +0 -1
- package/dist/expertise-client.d.ts +0 -16
- package/dist/expertise-client.js +0 -9
- package/dist/expertise-client.js.map +0 -1
- package/dist/hero-carousel-client.d.ts +0 -52
- package/dist/hero-carousel-client.js +0 -9
- package/dist/hero-carousel-client.js.map +0 -1
- package/dist/seo-checks.d.ts +0 -87
- package/dist/seo-checks.js +0 -27
- package/dist/seo-checks.js.map +0 -1
- package/dist/site-config-BvUyDx_f.d.ts +0 -3961
- package/dist/spaces-client.d.ts +0 -10
- package/dist/spaces-client.js +0 -8
- package/dist/spaces-client.js.map +0 -1
- package/dist/team-expanding-stage-client.d.ts +0 -35
- package/dist/team-expanding-stage-client.js +0 -8
- package/dist/team-expanding-stage-client.js.map +0 -1
- package/dist/templates.css +0 -4145
- package/dist/templates.d.ts +0 -171
- package/dist/templates.js +0 -291
- package/dist/templates.js.map +0 -1
- package/dist/theme-css-vars-CD3jcEkp.d.ts +0 -7
- package/dist/ui-react-motion-client.d.ts +0 -76
- package/dist/ui-react-motion-client.js +0 -16
- package/dist/ui-react-motion-client.js.map +0 -1
- package/dist/validate.d.ts +0 -104
- package/dist/validate.js +0 -1204
- package/dist/validate.js.map +0 -1
- /package/dist/{before-after-client.js.map → section-runtime.js.map} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,101 @@
|
|
|
1
1
|
# @roottale/cms-renderer-next
|
|
2
2
|
|
|
3
|
+
## 0.53.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a133f27: 블로그 상세 글의 작성·출력 기준을 하나로 맞췄습니다.
|
|
8
|
+
- 이미지 대체 텍스트를 한 줄, 최대 160자로 정규화하는 공용 계약을 추가했습니다.
|
|
9
|
+
- 상세 글 위에 카테고리를 연결하고, 발행일과 실제로 다른 수정일을 구분해 표시합니다.
|
|
10
|
+
- 본문의 공식 출처 블록을 제목과 목록이 있는 시맨틱 구간으로 렌더합니다.
|
|
11
|
+
- 블로그 전용 SEO 점검에서 목차, 외부 링크가 있는 공식 출처, 내용이 채워진 FAQ를 확인합니다.
|
|
12
|
+
- Next.js 상세 라우트가 글을 먼저 조회한 뒤 `notFound()`를 호출해야 실제 404가 된다는 통합 예제를 보강했습니다.
|
|
13
|
+
|
|
14
|
+
- e3e7ba8: 블로그 상세 상단에 CMS에서 기록한 내용 검토일과 검토자를 표시합니다.
|
|
15
|
+
- 7d1a551: 글 메타데이터가 OG 이미지 대체 텍스트와 Google의 큰 이미지 미리보기 지시자를 함께 출력합니다.
|
|
16
|
+
- 3dc6e1d: 공개 CMS runtime에서 RootTale의 private 마케팅 섹션 공장을 분리했습니다.
|
|
17
|
+
- `templates`, `templates/styles`, `validate`, `templates/seo-checks` 공개 subpath를 제거했습니다.
|
|
18
|
+
- 글·페이지 본문, 테마 bridge, 문의, feed·sitemap·webhook runtime은 그대로 유지합니다.
|
|
19
|
+
- `section` 노드는 공개 renderer가 자동으로 해석하지 않으며, 내부 제작 경로가 명시적으로 renderer를 등록해야 합니다.
|
|
20
|
+
- 같은 릴리스 묶음의 `@roottale/cms-core` 변경과 함께 배포해야 합니다.
|
|
21
|
+
|
|
22
|
+
### Patch Changes
|
|
23
|
+
|
|
24
|
+
- 2eb82d3: 블로그 작성자 프로필의 기준을 글에 지정된 사이트 작성자 한 곳으로 통합했습니다.
|
|
25
|
+
- 작성자가 없는 글은 전역 설정으로 작성자 카드가 만들어지지 않습니다.
|
|
26
|
+
- 작성자 이름·사진·소개는 글 응답의 작성자 프로필만 사용합니다.
|
|
27
|
+
- 레거시 전역 작성자 응답 필드는 호환을 위해 남지만 항상 `null`입니다.
|
|
28
|
+
|
|
29
|
+
- 3ab5570: RSS 생성기에 선택적인 WebSub `atom:hub` 링크를 추가했다. `createFeedRoute`는
|
|
30
|
+
`webSubHubUrl`을 받아 이를 피드에 전달하며, 값을 주지 않은 기존 사이트의 출력은
|
|
31
|
+
바뀌지 않는다. Site Kit은 Google WebSub hub를 기본 연결하고 IndexNow 소유권 확인용
|
|
32
|
+
`/indexnow-key.txt` 라우트를 제공한다.
|
|
33
|
+
- Updated dependencies [a133f27]
|
|
34
|
+
- Updated dependencies [f743a06]
|
|
35
|
+
- Updated dependencies [3ab5570]
|
|
36
|
+
- @roottale/cms-core@0.53.0
|
|
37
|
+
- @roottale/cms-client@0.53.0
|
|
38
|
+
|
|
39
|
+
## 0.52.0
|
|
40
|
+
|
|
41
|
+
### Minor Changes
|
|
42
|
+
|
|
43
|
+
- 01f5534: 어드민에서 **설정**을 고치면 사이트에 즉시 반영된다 — 지금까지 글·디자인만 즉시였고 사업장 정보(전화·주소)·상단 메뉴·블로그 표시 설정·콘텐츠 유형은 최대 30분 걸렸다.
|
|
44
|
+
|
|
45
|
+
원인은 두 개였다. ① 그 설정들을 읽는 조회 함수에 **캐시 이름표를 붙일 방법이 없었다**. ② 그래서 `revalidatePath` 로는 그 조회의 응답 캐시(Next.js Data Cache)를 지울 수 없었다 — 경로 캐시만 지워지고 옛 전화번호가 그대로 남았다.
|
|
46
|
+
|
|
47
|
+
**`@roottale/cms-client`**
|
|
48
|
+
- `fetchBusinessProfile` · `fetchMenu` · `fetchMenus` · `fetchBlogSettings` · `fetchCollections` 가 `tags?: string[]` 를 받는다(이미 있던 `fetchTheme` 와 같은 방식).
|
|
49
|
+
- 이름표 상수를 내보낸다: `BUSINESS_CACHE_TAG`("rt-business") · `MENUS_CACHE_TAG`("rt-menus") · `BLOG_SETTINGS_CACHE_TAG`("rt-blog-settings") · `COLLECTIONS_CACHE_TAG`("rt-collections"). 다섯 개(테마 포함)를 한 벌로 묶은 `SETTINGS_CACHE_TAGS` 배열도 함께.
|
|
50
|
+
- **`tags` 를 안 넘기면 동작이 예전과 한 글자도 다르지 않다** — `next` 키 자체를 만들지 않는다. 기존 코드는 그대로 두어도 된다.
|
|
51
|
+
|
|
52
|
+
**`@roottale/cms-renderer-next`** — `createRevalidateRoute` 의 설정 저장 신호(`theme.updated`) 처리
|
|
53
|
+
- 테마 이름표만이 아니라 위 다섯 개를 **함께** 지운다. 무엇이 바뀌었는지 웹훅 본문에 없기 때문이고, 쓰지 않는 이름표를 지우는 것은 아무 일도 일어나지 않으므로 해가 없다.
|
|
54
|
+
- `themeTag` 를 커스텀 이름으로 덮어썼다면 그 이름을 쓴다(기본 `rt-theme` 는 건드리지 않는다).
|
|
55
|
+
- **본문 `paths` 도 함께 갱신한다**(전에는 무시했다). 콘텐츠 유형 저장이 `/sitemap.xml`·`/feed.xml`·`/llms.txt` 를 보내는데, 이건 route handler 라서 레이아웃 무효화에 딸려 온다고 가정할 수 없다. 디자인 저장처럼 `paths` 가 비어 있으면 아무 일도 일어나지 않는다.
|
|
56
|
+
- **하나가 실패해도 나머지를 전부 시도한다.** 이름표 하나에서 멈추면 남은 이름표와 레이아웃 무효화까지 건너뛰기 때문이다. 하나라도 실패하면 성공으로 위장하지 않고 **500** 과 `failed[]`(무엇이·왜)를 돌려줘 플랫폼이 재시도하게 한다.
|
|
57
|
+
- `revalidateTag` 를 주입하지 않은 사이트의 동작은 **그대로**다(이름표 생략 + 레이아웃 재검증, 응답 200).
|
|
58
|
+
- 응답 본문이 늘었다: `revalidated` 에 `requestedTags: string[]`(콜백에 **넘긴** 이름표 — 실제로 지워졌는지는 Next 가 알려주지 않아 "요청한" 목록이다)와 `paths: string[]` 가 추가됐다. 기존 `tag` 필드는 테마 이름표를 담은 채 그대로 있다 — 로그를 파싱하던 쪽이 깨지지 않는다.
|
|
59
|
+
|
|
60
|
+
**사이트에서 할 일** — 두 군데를 함께 손봐야 즉시 반영이 된다. 한쪽만 하면 아무 오류 없이 "가끔 늦게 반영"으로만 보인다.
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// 1) app/api/revalidate/route.ts — 이름표를 지울 수 있게 주입
|
|
64
|
+
import { revalidatePath, revalidateTag } from "next/cache";
|
|
65
|
+
import { THEME_CACHE_TAG } from "@roottale/cms-client/server";
|
|
66
|
+
import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";
|
|
67
|
+
|
|
68
|
+
export const POST = createRevalidateRoute({
|
|
69
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
70
|
+
// 2번째 인자(type)를 반드시 그대로 넘긴다 — `(path) => …` 로 감싸면
|
|
71
|
+
// 레이아웃 전역 무효화가 페이지 1개 무효화로 줄어든다.
|
|
72
|
+
revalidate: (path, type) => revalidatePath(path, type),
|
|
73
|
+
// { expire: 0 } = 즉시 만료. 다른 프로파일("max" 등)은 expire 가 0 이 아니라
|
|
74
|
+
// Next 가 stale-while-revalidate 로 취급해 다음 요청이 여전히 옛 값을 받는다
|
|
75
|
+
// ("max" 의 expire 는 1년). updateTag 는 서버 액션 전용이라 여기서 throw 한다.
|
|
76
|
+
revalidateTag: (tag: string) => revalidateTag(tag, { expire: 0 }),
|
|
77
|
+
themeTag: THEME_CACHE_TAG,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// 2) 설정 조회마다 이름표를 붙인다
|
|
81
|
+
const business = await fetchBusinessProfile({
|
|
82
|
+
apiKey,
|
|
83
|
+
tags: [BUSINESS_CACHE_TAG],
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
확인 방법: 웹훅 응답의 `revalidated.requestedTags` 가 비어 있으면 1번 주입이 빠진 것이다.
|
|
88
|
+
|
|
89
|
+
> **이벤트 변경 안내** — 어드민의 사업장 정보·메뉴·블로그 표시 설정·콘텐츠 유형 저장이 이제 `post.updated` 대신 `theme.updated` 를 보낸다(디자인·상단 메뉴·공지 배너·상담바는 원래 그랬다). `createRevalidateRoute` 를 쓰면 신경 쓸 것이 없다. 이벤트 이름으로 직접 분기하는 수신기를 손으로 짰다면 `theme.updated` 분기를 추가하라.
|
|
90
|
+
|
|
91
|
+
### Patch Changes
|
|
92
|
+
|
|
93
|
+
- 9cbd8e5: 블로그 목록 카드가 한 행에서 서로 다른 높이로 끝나던 문제를 고쳤다. 카드 링크의
|
|
94
|
+
`min-block-size: 100%` 는 부모(`li`)의 높이가 확정돼야 풀리는데 `li` 가 일반
|
|
95
|
+
블록이라 auto 로 접혔다. `li` 를 grid 로 만들어 링크가 행 높이를 채우게 한다.
|
|
96
|
+
- Updated dependencies [01f5534]
|
|
97
|
+
- @roottale/cms-client@0.52.0
|
|
98
|
+
|
|
3
99
|
## 0.51.1
|
|
4
100
|
|
|
5
101
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -6,7 +6,9 @@ Drop-in `<RootTaleBlogList>` / `<RootTaleBlogPost>` / `<RootTaleLeadForm>` for N
|
|
|
6
6
|
|
|
7
7
|
Pair with [`@roottale/cms-client`](https://www.npmjs.com/package/@roottale/cms-client) (the fetch client) and the RootTale CMS admin (`mysite.roottale.com`) where you author the content.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
이 public package는 글·페이지 본문과 CMS 연동 runtime만 제공한다. RootTale의
|
|
10
|
+
전문직·B2B 마케팅 섹션, variants와 `SectionSchema`는 private 제작 공장에 있으며
|
|
11
|
+
고객 사이트에는 선택된 결과만 local source로 전달한다.
|
|
10
12
|
|
|
11
13
|
## Install
|
|
12
14
|
|
|
@@ -47,17 +49,46 @@ are fallback only.
|
|
|
47
49
|
|
|
48
50
|
```ts
|
|
49
51
|
// app/api/revalidate/route.ts
|
|
50
|
-
import { revalidatePath } from "next/cache";
|
|
52
|
+
import { revalidatePath, revalidateTag } from "next/cache";
|
|
51
53
|
|
|
54
|
+
import { THEME_CACHE_TAG } from "@roottale/cms-client/server";
|
|
52
55
|
import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";
|
|
53
56
|
|
|
54
57
|
export const POST = createRevalidateRoute({
|
|
55
58
|
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
56
59
|
apiBase: process.env.ROOTTALE_API_BASE,
|
|
57
|
-
|
|
60
|
+
// Pass the second argument through. The factory calls
|
|
61
|
+
// `revalidate("/", "layout")` on settings saves; a `(path) => …` callback
|
|
62
|
+
// silently drops `"layout"` and downgrades a full layout purge to one page.
|
|
63
|
+
revalidate: (path, type) => revalidatePath(path, type),
|
|
64
|
+
// Required for settings (design tokens, site nav, business profile, blog
|
|
65
|
+
// display, content collections) to land immediately: `revalidatePath` cannot
|
|
66
|
+
// evict the Data Cache entry behind `fetchTheme`/`fetchBusinessProfile`/
|
|
67
|
+
// `fetchMenus`, so the route evicts them by cache tag instead.
|
|
68
|
+
//
|
|
69
|
+
// The profile must be `{ expire: 0 }` — Next treats any profile with a
|
|
70
|
+
// non-zero `expire` as a stale-while-revalidate update, so the next request
|
|
71
|
+
// still serves the old value (the built-in `"max"` profile expires in a
|
|
72
|
+
// year). `updateTag` is immediate but Server-Action only and throws inside a
|
|
73
|
+
// Route Handler, so it cannot be used here.
|
|
74
|
+
revalidateTag: (tag: string) => revalidateTag(tag, { expire: 0 }),
|
|
75
|
+
themeTag: THEME_CACHE_TAG,
|
|
58
76
|
});
|
|
59
77
|
```
|
|
60
78
|
|
|
79
|
+
On `theme.updated` the route evicts every tag in `SETTINGS_CACHE_TAGS`
|
|
80
|
+
(`@roottale/cms-client/server`), revalidates `/` as a layout, and then
|
|
81
|
+
revalidates the body `paths` (content-collection saves send `/sitemap.xml`,
|
|
82
|
+
`/feed.xml`, `/llms.txt` — route handlers that a layout purge cannot be assumed
|
|
83
|
+
to cover). Every step is attempted even if an earlier one throws; if anything
|
|
84
|
+
failed the route answers `500` with a `failed[]` list so the platform retries.
|
|
85
|
+
|
|
86
|
+
Attach the matching tag to each settings fetch — `tags: [THEME_CACHE_TAG]`,
|
|
87
|
+
`[BUSINESS_CACHE_TAG]`, `[MENUS_CACHE_TAG]`, `[BLOG_SETTINGS_CACHE_TAG]`,
|
|
88
|
+
`[COLLECTIONS_CACHE_TAG]`. Without `revalidateTag` the route still works and
|
|
89
|
+
returns 200, but settings changes only land after each fetch's `revalidate`
|
|
90
|
+
window (`revalidated.requestedTags` comes back empty — that is the tell).
|
|
91
|
+
|
|
61
92
|
ADMIN setup: open `/s/{tenant-slug}/sites/{site-id}`, set **Webhook URL** to
|
|
62
93
|
`https://<customer-domain>/api/revalidate`, keep it enabled, and save. See
|
|
63
94
|
[`docs/cms-revalidation-webhooks.md`](../../docs/cms-revalidation-webhooks.md)
|
|
@@ -117,7 +148,9 @@ export default async function CategoryPage({
|
|
|
117
148
|
## Blog post — `app/blog/[slug]/page.tsx`
|
|
118
149
|
|
|
119
150
|
```tsx
|
|
151
|
+
import { fetchPost } from "@roottale/cms-client/server";
|
|
120
152
|
import { RootTaleBlogPost } from "@roottale/cms-renderer-next/server";
|
|
153
|
+
import { notFound } from "next/navigation";
|
|
121
154
|
|
|
122
155
|
export default async function PostPage({
|
|
123
156
|
params,
|
|
@@ -125,13 +158,21 @@ export default async function PostPage({
|
|
|
125
158
|
params: Promise<{ slug: string }>;
|
|
126
159
|
}) {
|
|
127
160
|
const { slug } = await params;
|
|
161
|
+
const post = await fetchPost({
|
|
162
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
163
|
+
slugOrId: slug,
|
|
164
|
+
});
|
|
165
|
+
if (!post) notFound();
|
|
166
|
+
|
|
128
167
|
return (
|
|
129
168
|
<main>
|
|
130
169
|
<RootTaleBlogPost
|
|
131
170
|
apiKey={process.env.ROOTTALE_API_KEY!}
|
|
132
|
-
slugOrId={
|
|
171
|
+
slugOrId={post.id}
|
|
133
172
|
showTableOfContents
|
|
134
173
|
tableOfContentsTitle="목차"
|
|
174
|
+
breadcrumb={{ siteUrl: process.env.NEXT_PUBLIC_SITE_URL }}
|
|
175
|
+
relatedPostsCount={3}
|
|
135
176
|
/>
|
|
136
177
|
</main>
|
|
137
178
|
);
|
|
@@ -142,6 +183,15 @@ export default async function PostPage({
|
|
|
142
183
|
already renders that title, pass `showTitle={false}` to avoid a duplicate
|
|
143
184
|
heading. The excerpt, post metadata, and body remain visible.
|
|
144
185
|
|
|
186
|
+
Fetch the post in the route and call Next.js `notFound()` before rendering, as
|
|
187
|
+
shown above. `notFoundElement` only changes the component fallback; it cannot
|
|
188
|
+
turn a successful route response into an HTTP 404.
|
|
189
|
+
|
|
190
|
+
When present, category links render above the `<h1>`. The metadata row labels
|
|
191
|
+
the published date and displays a separate modified date only when its calendar
|
|
192
|
+
date differs. Body images always receive a one-line alt value capped at 160
|
|
193
|
+
characters.
|
|
194
|
+
|
|
145
195
|
## Lead form — `app/contact/page.tsx`
|
|
146
196
|
|
|
147
197
|
```tsx
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/blog-filter.ts","../src/routes-shared.ts"],"sourcesContent":["/**\n * `RootTaleBlogList` 카테고리 필터 — 순수 헬퍼(서버 필터, client JS 불요).\n *\n * SSR-only 정책 유지: 필터는 `?category=<slug>` URL 파라미터로 동작하고(칩 =\n * 서버 렌더 `<a>` 링크), consumer 가 searchParams 의 category 를 `activeCategory`\n * 로 주입한다. hydration 없이 SEO 친화 + 공유 가능한 URL.\n */\n\nimport type { CmsPostContent, CmsTermRef } from \"@roottale/cms-client/server\";\n\nexport interface BlogCategory {\n name: string;\n slug: string;\n}\n\nexport interface BlogCategoryWithCount extends BlogCategory {\n count: number;\n}\n\nfunction categoryTerms(post: CmsPostContent): CmsTermRef[] {\n return (post.terms ?? []).filter((t) => t.taxonomy === \"category\");\n}\n\n/**\n * 글 목록에 등장하는 카테고리를 slug 기준 unique 로 수집. 서버가 보내는 정렬\n * (category → name asc)을 보존하기 위해 처음 등장 순서를 유지한다.\n */\nexport function collectCategories(posts: CmsPostContent[]): BlogCategory[] {\n return collectCategoriesWithCounts(posts).map(({ name, slug }) => ({\n name,\n slug,\n }));\n}\n\nexport function collectCategoriesWithCounts(\n posts: CmsPostContent[],\n): BlogCategoryWithCount[] {\n const bySlug = new Map<string, BlogCategoryWithCount>();\n const out: BlogCategoryWithCount[] = [];\n for (const post of posts) {\n for (const t of categoryTerms(post)) {\n const current = bySlug.get(t.slug);\n if (current) {\n current.count += 1;\n } else {\n const category = { name: t.name, slug: t.slug, count: 1 };\n bySlug.set(t.slug, category);\n out.push(category);\n }\n }\n }\n return out;\n}\n\n/** activeSlug 가 비면 전체 반환. 아니면 그 카테고리를 가진 글만. */\nexport function filterPostsByCategory(\n posts: CmsPostContent[],\n activeSlug: string | null | undefined,\n): CmsPostContent[] {\n if (!activeSlug) return posts;\n return posts.filter((p) => categoryTerms(p).some((t) => t.slug === activeSlug));\n}\n\n/** 기본 카테고리 링크: 전체 → `/blog`, 특정 → `/blog/categories/<slug>`. */\nexport function defaultCategoryHref(slug: string | null): string {\n return slug ? `/blog/categories/${encodeURIComponent(slug)}` : \"/blog\";\n}\n\n// ── Collections (ADR-0060) — cms-core 라우팅 프리미티브 재export + 렌더 헬퍼 ──────\n//\n// 라우팅 코어(RouteCollection/resolvePostCollection/resolvePostUrl/postsForCollection/\n// validateCollections)는 `@roottale/cms-core` 에 있어 next/astro 가 공유한다\n// (ADR-0029 parity — codex consult `019ef577` 반영). 여기서는 next 소비자 편의를\n// 위해 재export + CmsPostContent 기반 스트림 스코프 카테고리 집계만 추가한다.\nimport {\n type RouteCollection,\n postsForCollection,\n} from \"@roottale/cms-core\";\n\nexport {\n type RouteCollection,\n type RoutablePost,\n isInCollectionMode,\n resolvePostCollection,\n resolvePostUrl,\n postsForCollection,\n postsInAnyCollection,\n validateCollections,\n} from \"@roottale/cms-core\";\n\n/**\n * 특정 collection 안에서 등장하는 카테고리 집계 — 그 스트림의 카테고리 칩/아카이브용.\n * **전체 정렬 리스트로 first-wins 해석**해 그 스트림에 최종 귀속된 글만 대상(한 글이\n * 여러 스트림 리스트에 중복되는 것을 방지 — codex 지적).\n */\nexport function collectCollectionCategories(\n posts: CmsPostContent[],\n collections: readonly RouteCollection[],\n key: string,\n): BlogCategoryWithCount[] {\n const owned = postsForCollection(posts, collections, key);\n const collection = collections.find((c) => c.key === key);\n const allowed = new Set(collection?.categories ?? []);\n return collectCategoriesWithCounts(owned).filter((c) => allowed.has(c.slug));\n}\n","/**\n * `routes` 공통 — config 타입, collections resolve, fetch 헬퍼. feed/sitemap/llms/\n * revalidate/og 모듈이 공유한다(순환 import 회피). 공개 표면은 `routes.ts` 배럴.\n */\n\nimport { fetchPosts, type CmsPostContent } from \"@roottale/cms-client/server\";\nimport type { RouteCollection } from \"@roottale/cms-core\";\n\nexport type { RouteCollection };\n// 소비자 가드용 재export (`/blog/[slug]` 에서 스트림 누출 차단 등) — ADR-0060.\nexport { resolvePostCollection, resolvePostUrl } from \"@roottale/cms-core\";\n\nexport interface RootTaleSiteConfig {\n /** `rtlk_cust_*` — 블로그 조회와 같은 키. 서버에서만(절대 브라우저 금지). */\n apiKey: string;\n /** API base 오버라이드. 기본 `https://api.roottale.com`. */\n apiBase?: string;\n /** 사이트 절대 URL (뒤 슬래시 없이). 예: `https://kjmtax.roottale.app`. */\n siteUrl: string;\n /** 피드/사이트 제목. */\n title: string;\n description?: string;\n /** 기본 \"ko-KR\". */\n language?: string;\n /** 블로그 base path. 기본 \"/blog\". 레거시 단일 스트림 — `collections` 미사용 시. */\n blogBasePath?: string;\n /**\n * 다중 스트림 라우팅 (ADR-0060). **길이 > 0 이면 collection 모드** — feed/sitemap/\n * llms.txt 가 글을 소속 스트림 basePath 로 라우팅하고, 어느 스트림에도 안 속한 글은\n * 제외한다. 비우거나 미전달 = 레거시 단일 `blogBasePath`(/blog) 동작(하위호환).\n * 선언 순서 = 우선순위(first-wins).\n *\n * 배열 또는 **async resolver**(`() => Promise<RouteCollection[]>`)를 받는다 — 후자는\n * 사이트가 `@roottale/cms-client` `fetchCollections()` 로 admin 설정을 가져올 때 쓴다.\n * resolver 가 throw 하면 fail-soft(레거시 단일 /blog).\n */\n collections?: CollectionsInput;\n}\n\n/** collections 입력 — 정적 배열 또는 async resolver(admin 설정 fetch 용). */\nexport type CollectionsInput =\n | RouteCollection[]\n | (() => RouteCollection[] | Promise<RouteCollection[]>);\n\n/** resolve 결과 — `failed` 로 \"resolver throw\" 를 \"collections 미설정\" 과 구분한다. */\nexport async function resolveCollectionsResult(\n input: CollectionsInput | undefined,\n): Promise<{ collections: RouteCollection[] | undefined; failed: boolean }> {\n if (typeof input === \"function\") {\n try {\n return { collections: await input(), failed: false };\n } catch (err) {\n console.error(\n \"[@roottale/cms-renderer-next/routes] collections resolver 실패:\",\n err,\n );\n return { collections: undefined, failed: true };\n }\n }\n return { collections: input, failed: false };\n}\n\n/**\n * resolver 면 호출(실패 시 undefined → 레거시), 배열이면 그대로. GET 팩토리(feed/\n * sitemap/llms)는 always-200 철학상 실패를 레거시 /blog 로 흡수 — revalidate 만\n * `resolveCollectionsResult` 로 실패를 구분해 under-invalidation 을 막는다.\n */\nexport async function resolveCollections(\n input: CollectionsInput | undefined,\n): Promise<RouteCollection[] | undefined> {\n return (await resolveCollectionsResult(input)).collections;\n}\n\nexport function trimSlash(u: string): string {\n return u.replace(/\\/+$/, \"\");\n}\n\nexport async function safeFetchPosts(\n config: RootTaleSiteConfig,\n limit: number,\n): Promise<CmsPostContent[]> {\n try {\n const page = await fetchPosts({\n apiKey: config.apiKey,\n baseUrl: config.apiBase,\n type: \"post\",\n limit,\n });\n return page.items;\n } catch (err) {\n // 빌드/런타임 fetch 실패 시 빈 목록 — 라우트는 항상 정상 응답(404 방지).\n console.error(\"[@roottale/cms-renderer-next/routes] fetchPosts 실패:\", err);\n return [];\n }\n}\n"],"mappings":";AA0EA;AAAA,EAEE;AAAA,OACK;AAEP;AAAA,EAGE;AAAA,EACA;AAAA,EACA;AAAA,EACA,sBAAAA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AArEP,SAAS,cAAc,MAAoC;AACzD,UAAQ,KAAK,SAAS,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,aAAa,UAAU;AACnE;AAMO,SAAS,kBAAkB,OAAyC;AACzE,SAAO,4BAA4B,KAAK,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,OAAO;AAAA,IACjE;AAAA,IACA;AAAA,EACF,EAAE;AACJ;AAEO,SAAS,4BACd,OACyB;AACzB,QAAM,SAAS,oBAAI,IAAmC;AACtD,QAAM,MAA+B,CAAC;AACtC,aAAW,QAAQ,OAAO;AACxB,eAAW,KAAK,cAAc,IAAI,GAAG;AACnC,YAAM,UAAU,OAAO,IAAI,EAAE,IAAI;AACjC,UAAI,SAAS;AACX,gBAAQ,SAAS;AAAA,MACnB,OAAO;AACL,cAAM,WAAW,EAAE,MAAM,EAAE,MAAM,MAAM,EAAE,MAAM,OAAO,EAAE;AACxD,eAAO,IAAI,EAAE,MAAM,QAAQ;AAC3B,YAAI,KAAK,QAAQ;AAAA,MACnB;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAGO,SAAS,sBACd,OACA,YACkB;AAClB,MAAI,CAAC,WAAY,QAAO;AACxB,SAAO,MAAM,OAAO,CAAC,MAAM,cAAc,CAAC,EAAE,KAAK,CAAC,MAAM,EAAE,SAAS,UAAU,CAAC;AAChF;AAGO,SAAS,oBAAoB,MAA6B;AAC/D,SAAO,OAAO,oBAAoB,mBAAmB,IAAI,CAAC,KAAK;AACjE;AA6BO,SAAS,4BACd,OACA,aACA,KACyB;AACzB,QAAM,QAAQ,mBAAmB,OAAO,aAAa,GAAG;AACxD,QAAM,aAAa,YAAY,KAAK,CAAC,MAAM,EAAE,QAAQ,GAAG;AACxD,QAAM,UAAU,IAAI,IAAI,YAAY,cAAc,CAAC,CAAC;AACpD,SAAO,4BAA4B,KAAK,EAAE,OAAO,CAAC,MAAM,QAAQ,IAAI,EAAE,IAAI,CAAC;AAC7E;;;ACnGA,SAAS,kBAAuC;AAKhD,SAAS,yBAAAC,wBAAuB,kBAAAC,uBAAsB;
|
|
1
|
+
{"version":3,"sources":["../src/blog-filter.ts","../src/routes-shared.ts"],"sourcesContent":["/**\n * `RootTaleBlogList` 카테고리 필터 — 순수 헬퍼(서버 필터, client JS 불요).\n *\n * SSR-only 정책 유지: 필터는 `?category=<slug>` URL 파라미터로 동작하고(칩 =\n * 서버 렌더 `<a>` 링크), consumer 가 searchParams 의 category 를 `activeCategory`\n * 로 주입한다. hydration 없이 SEO 친화 + 공유 가능한 URL.\n */\n\nimport type { CmsPostContent, CmsTermRef } from \"@roottale/cms-client/server\";\n\nexport interface BlogCategory {\n name: string;\n slug: string;\n}\n\nexport interface BlogCategoryWithCount extends BlogCategory {\n count: number;\n}\n\nfunction categoryTerms(post: CmsPostContent): CmsTermRef[] {\n return (post.terms ?? []).filter((t) => t.taxonomy === \"category\");\n}\n\n/**\n * 글 목록에 등장하는 카테고리를 slug 기준 unique 로 수집. 서버가 보내는 정렬\n * (category → name asc)을 보존하기 위해 처음 등장 순서를 유지한다.\n */\nexport function collectCategories(posts: CmsPostContent[]): BlogCategory[] {\n return collectCategoriesWithCounts(posts).map(({ name, slug }) => ({\n name,\n slug,\n }));\n}\n\nexport function collectCategoriesWithCounts(\n posts: CmsPostContent[],\n): BlogCategoryWithCount[] {\n const bySlug = new Map<string, BlogCategoryWithCount>();\n const out: BlogCategoryWithCount[] = [];\n for (const post of posts) {\n for (const t of categoryTerms(post)) {\n const current = bySlug.get(t.slug);\n if (current) {\n current.count += 1;\n } else {\n const category = { name: t.name, slug: t.slug, count: 1 };\n bySlug.set(t.slug, category);\n out.push(category);\n }\n }\n }\n return out;\n}\n\n/** activeSlug 가 비면 전체 반환. 아니면 그 카테고리를 가진 글만. */\nexport function filterPostsByCategory(\n posts: CmsPostContent[],\n activeSlug: string | null | undefined,\n): CmsPostContent[] {\n if (!activeSlug) return posts;\n return posts.filter((p) => categoryTerms(p).some((t) => t.slug === activeSlug));\n}\n\n/** 기본 카테고리 링크: 전체 → `/blog`, 특정 → `/blog/categories/<slug>`. */\nexport function defaultCategoryHref(slug: string | null): string {\n return slug ? `/blog/categories/${encodeURIComponent(slug)}` : \"/blog\";\n}\n\n// ── Collections (ADR-0060) — cms-core 라우팅 프리미티브 재export + 렌더 헬퍼 ──────\n//\n// 라우팅 코어(RouteCollection/resolvePostCollection/resolvePostUrl/postsForCollection/\n// validateCollections)는 `@roottale/cms-core` 에 있어 next/astro 가 공유한다\n// (ADR-0029 parity — codex consult `019ef577` 반영). 여기서는 next 소비자 편의를\n// 위해 재export + CmsPostContent 기반 스트림 스코프 카테고리 집계만 추가한다.\nimport {\n type RouteCollection,\n postsForCollection,\n} from \"@roottale/cms-core\";\n\nexport {\n type RouteCollection,\n type RoutablePost,\n isInCollectionMode,\n resolvePostCollection,\n resolvePostUrl,\n postsForCollection,\n postsInAnyCollection,\n validateCollections,\n} from \"@roottale/cms-core\";\n\n/**\n * 특정 collection 안에서 등장하는 카테고리 집계 — 그 스트림의 카테고리 칩/아카이브용.\n * **전체 정렬 리스트로 first-wins 해석**해 그 스트림에 최종 귀속된 글만 대상(한 글이\n * 여러 스트림 리스트에 중복되는 것을 방지 — codex 지적).\n */\nexport function collectCollectionCategories(\n posts: CmsPostContent[],\n collections: readonly RouteCollection[],\n key: string,\n): BlogCategoryWithCount[] {\n const owned = postsForCollection(posts, collections, key);\n const collection = collections.find((c) => c.key === key);\n const allowed = new Set(collection?.categories ?? []);\n return collectCategoriesWithCounts(owned).filter((c) => allowed.has(c.slug));\n}\n","/**\n * `routes` 공통 — config 타입, collections resolve, fetch 헬퍼. feed/sitemap/llms/\n * revalidate/og 모듈이 공유한다(순환 import 회피). 공개 표면은 `routes.ts` 배럴.\n */\n\nimport { fetchPosts, type CmsPostContent } from \"@roottale/cms-client/server\";\nimport type { RouteCollection } from \"@roottale/cms-core\";\n\nexport type { RouteCollection };\n// 소비자 가드용 재export (`/blog/[slug]` 에서 스트림 누출 차단 등) — ADR-0060.\nexport { resolvePostCollection, resolvePostUrl } from \"@roottale/cms-core\";\n\nexport interface RootTaleSiteConfig {\n /** `rtlk_cust_*` — 블로그 조회와 같은 키. 서버에서만(절대 브라우저 금지). */\n apiKey: string;\n /** API base 오버라이드. 기본 `https://api.roottale.com`. */\n apiBase?: string;\n /** 사이트 절대 URL (뒤 슬래시 없이). 예: `https://kjmtax.roottale.app`. */\n siteUrl: string;\n /** 피드/사이트 제목. */\n title: string;\n description?: string;\n /** 기본 \"ko-KR\". */\n language?: string;\n /** RSS의 WebSub discovery link. 미지정이면 기존 RSS 출력 유지. */\n webSubHubUrl?: string;\n /** 블로그 base path. 기본 \"/blog\". 레거시 단일 스트림 — `collections` 미사용 시. */\n blogBasePath?: string;\n /**\n * 다중 스트림 라우팅 (ADR-0060). **길이 > 0 이면 collection 모드** — feed/sitemap/\n * llms.txt 가 글을 소속 스트림 basePath 로 라우팅하고, 어느 스트림에도 안 속한 글은\n * 제외한다. 비우거나 미전달 = 레거시 단일 `blogBasePath`(/blog) 동작(하위호환).\n * 선언 순서 = 우선순위(first-wins).\n *\n * 배열 또는 **async resolver**(`() => Promise<RouteCollection[]>`)를 받는다 — 후자는\n * 사이트가 `@roottale/cms-client` `fetchCollections()` 로 admin 설정을 가져올 때 쓴다.\n * resolver 가 throw 하면 fail-soft(레거시 단일 /blog).\n */\n collections?: CollectionsInput;\n}\n\n/** collections 입력 — 정적 배열 또는 async resolver(admin 설정 fetch 용). */\nexport type CollectionsInput =\n | RouteCollection[]\n | (() => RouteCollection[] | Promise<RouteCollection[]>);\n\n/** resolve 결과 — `failed` 로 \"resolver throw\" 를 \"collections 미설정\" 과 구분한다. */\nexport async function resolveCollectionsResult(\n input: CollectionsInput | undefined,\n): Promise<{ collections: RouteCollection[] | undefined; failed: boolean }> {\n if (typeof input === \"function\") {\n try {\n return { collections: await input(), failed: false };\n } catch (err) {\n console.error(\n \"[@roottale/cms-renderer-next/routes] collections resolver 실패:\",\n err,\n );\n return { collections: undefined, failed: true };\n }\n }\n return { collections: input, failed: false };\n}\n\n/**\n * resolver 면 호출(실패 시 undefined → 레거시), 배열이면 그대로. GET 팩토리(feed/\n * sitemap/llms)는 always-200 철학상 실패를 레거시 /blog 로 흡수 — revalidate 만\n * `resolveCollectionsResult` 로 실패를 구분해 under-invalidation 을 막는다.\n */\nexport async function resolveCollections(\n input: CollectionsInput | undefined,\n): Promise<RouteCollection[] | undefined> {\n return (await resolveCollectionsResult(input)).collections;\n}\n\nexport function trimSlash(u: string): string {\n return u.replace(/\\/+$/, \"\");\n}\n\nexport async function safeFetchPosts(\n config: RootTaleSiteConfig,\n limit: number,\n): Promise<CmsPostContent[]> {\n try {\n const page = await fetchPosts({\n apiKey: config.apiKey,\n baseUrl: config.apiBase,\n type: \"post\",\n limit,\n });\n return page.items;\n } catch (err) {\n // 빌드/런타임 fetch 실패 시 빈 목록 — 라우트는 항상 정상 응답(404 방지).\n console.error(\"[@roottale/cms-renderer-next/routes] fetchPosts 실패:\", err);\n return [];\n }\n}\n"],"mappings":";AA0EA;AAAA,EAEE;AAAA,OACK;AAEP;AAAA,EAGE;AAAA,EACA;AAAA,EACA;AAAA,EACA,sBAAAA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AArEP,SAAS,cAAc,MAAoC;AACzD,UAAQ,KAAK,SAAS,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,aAAa,UAAU;AACnE;AAMO,SAAS,kBAAkB,OAAyC;AACzE,SAAO,4BAA4B,KAAK,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,OAAO;AAAA,IACjE;AAAA,IACA;AAAA,EACF,EAAE;AACJ;AAEO,SAAS,4BACd,OACyB;AACzB,QAAM,SAAS,oBAAI,IAAmC;AACtD,QAAM,MAA+B,CAAC;AACtC,aAAW,QAAQ,OAAO;AACxB,eAAW,KAAK,cAAc,IAAI,GAAG;AACnC,YAAM,UAAU,OAAO,IAAI,EAAE,IAAI;AACjC,UAAI,SAAS;AACX,gBAAQ,SAAS;AAAA,MACnB,OAAO;AACL,cAAM,WAAW,EAAE,MAAM,EAAE,MAAM,MAAM,EAAE,MAAM,OAAO,EAAE;AACxD,eAAO,IAAI,EAAE,MAAM,QAAQ;AAC3B,YAAI,KAAK,QAAQ;AAAA,MACnB;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAGO,SAAS,sBACd,OACA,YACkB;AAClB,MAAI,CAAC,WAAY,QAAO;AACxB,SAAO,MAAM,OAAO,CAAC,MAAM,cAAc,CAAC,EAAE,KAAK,CAAC,MAAM,EAAE,SAAS,UAAU,CAAC;AAChF;AAGO,SAAS,oBAAoB,MAA6B;AAC/D,SAAO,OAAO,oBAAoB,mBAAmB,IAAI,CAAC,KAAK;AACjE;AA6BO,SAAS,4BACd,OACA,aACA,KACyB;AACzB,QAAM,QAAQ,mBAAmB,OAAO,aAAa,GAAG;AACxD,QAAM,aAAa,YAAY,KAAK,CAAC,MAAM,EAAE,QAAQ,GAAG;AACxD,QAAM,UAAU,IAAI,IAAI,YAAY,cAAc,CAAC,CAAC;AACpD,SAAO,4BAA4B,KAAK,EAAE,OAAO,CAAC,MAAM,QAAQ,IAAI,EAAE,IAAI,CAAC;AAC7E;;;ACnGA,SAAS,kBAAuC;AAKhD,SAAS,yBAAAC,wBAAuB,kBAAAC,uBAAsB;AAqCtD,eAAsB,yBACpB,OAC0E;AAC1E,MAAI,OAAO,UAAU,YAAY;AAC/B,QAAI;AACF,aAAO,EAAE,aAAa,MAAM,MAAM,GAAG,QAAQ,MAAM;AAAA,IACrD,SAAS,KAAK;AACZ,cAAQ;AAAA,QACN;AAAA,QACA;AAAA,MACF;AACA,aAAO,EAAE,aAAa,QAAW,QAAQ,KAAK;AAAA,IAChD;AAAA,EACF;AACA,SAAO,EAAE,aAAa,OAAO,QAAQ,MAAM;AAC7C;AAOA,eAAsB,mBACpB,OACwC;AACxC,UAAQ,MAAM,yBAAyB,KAAK,GAAG;AACjD;AAEO,SAAS,UAAU,GAAmB;AAC3C,SAAO,EAAE,QAAQ,QAAQ,EAAE;AAC7B;AAEA,eAAsB,eACpB,QACA,OAC2B;AAC3B,MAAI;AACF,UAAM,OAAO,MAAM,WAAW;AAAA,MAC5B,QAAQ,OAAO;AAAA,MACf,SAAS,OAAO;AAAA,MAChB,MAAM;AAAA,MACN;AAAA,IACF,CAAC;AACD,WAAO,KAAK;AAAA,EACd,SAAS,KAAK;AAEZ,YAAQ,MAAM,iEAAuD,GAAG;AACxE,WAAO,CAAC;AAAA,EACV;AACF;","names":["postsForCollection","resolvePostCollection","resolvePostUrl"]}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// src/section-render-context.ts
|
|
2
|
+
var sectionNodeRenderer = null;
|
|
3
|
+
function setSectionNodeRenderer(renderer) {
|
|
4
|
+
sectionNodeRenderer = renderer;
|
|
5
|
+
}
|
|
6
|
+
function getSectionNodeRenderer() {
|
|
7
|
+
return sectionNodeRenderer;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
setSectionNodeRenderer,
|
|
12
|
+
getSectionNodeRenderer
|
|
13
|
+
};
|
|
14
|
+
//# sourceMappingURL=chunk-KSPPYX22.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/section-render-context.ts"],"sourcesContent":["/**\n * 섹션 렌더러 주입 지점 — ADR-0040 P2 단방향 계약(코어 ◄ site-sections).\n *\n * 본문(tiptap) 안의 `section` 노드는 비공개 `site-sections` 컴포넌트로 그리지만,\n * 공개 코어가 비공개 패키지를 직접 import 하면 순환·번들\n * 오염이 된다(dependency-cruiser `no-renderer-core-to-templates`). 그래서 코어는\n * \"섹션을 그리는 함수\" 의 자리만 비워두고, private\n * `@roottale/site-sections`를 import 하는 내부 제작 경로에서만 채운다.\n *\n * 등록 없이 `section` 노드를 만나면 dev 경고 후 skip — 미지 섹션 타입을 만났을\n * 때와 같은 fail-soft 동작이다.\n */\nimport type { ReactElement, ReactNode } from \"react\";\nimport type { CmsPostContent } from \"@roottale/cms-client/server\";\n\n/** 발행 API 호출에 필요한 최소 컨텍스트(공개 키 + 베이스 URL). */\nexport type CmsBoundContext = { apiKey: string; baseUrl?: string };\n\nexport type BlogListProps = {\n apiKey: string;\n baseUrl?: string;\n limit?: number;\n emptyMessage?: string;\n postHref?: (post: CmsPostContent) => string;\n};\n\nexport type SectionRenderContext = {\n cms: CmsBoundContext | null;\n renderBlogList: (props: BlogListProps) => ReactNode;\n isDev: () => boolean;\n};\n\n/** `section` 노드 하나를 그리는 함수 — templates 쪽 구현이 주입된다. */\nexport type SectionNodeRenderer = (\n node: { type?: string; attrs?: Record<string, unknown> },\n key: number,\n context: SectionRenderContext,\n) => ReactElement | null;\n\nlet sectionNodeRenderer: SectionNodeRenderer | null = null;\n\n/**\n * private 섹션 공장이 로드되며 호출한다. 공개 CMS runtime만 쓰는 고객 FRONT는\n * 이 함수를 호출하지 않으며 `section` 노드를 마케팅 페이지 구조로 사용하지 않는다.\n */\nexport function setSectionNodeRenderer(renderer: SectionNodeRenderer): void {\n sectionNodeRenderer = renderer;\n}\n\nexport function getSectionNodeRenderer(): SectionNodeRenderer | null {\n return sectionNodeRenderer;\n}\n"],"mappings":";AAuCA,IAAI,sBAAkD;AAM/C,SAAS,uBAAuB,UAAqC;AAC1E,wBAAsB;AACxB;AAEO,SAAS,yBAAqD;AACnE,SAAO;AACT;","names":[]}
|
package/dist/cms-public.css
CHANGED
|
@@ -120,6 +120,14 @@
|
|
|
120
120
|
|
|
121
121
|
[data-roottale-cms] :where(.rt-cms-list-item) {
|
|
122
122
|
min-inline-size: 0;
|
|
123
|
+
/*
|
|
124
|
+
* 카드 링크가 행 높이를 채우게 한다. 링크에 `min-block-size: 100%` 가 있지만
|
|
125
|
+
* 퍼센트는 부모 높이가 확정돼야 풀리는데, `li` 가 일반 블록이면 높이가 auto 라
|
|
126
|
+
* 그 100% 가 내용 높이로 접힌다 — 한 행의 카드들이 제목 줄 수에 따라 제각각
|
|
127
|
+
* 끝나는 원인이었다(2026-07-28 실측: 243·243·273px). `li` 를 grid 로 만들면
|
|
128
|
+
* 링크가 grid item 이 되어 stretch 로 행 높이를 채운다.
|
|
129
|
+
*/
|
|
130
|
+
display: grid;
|
|
123
131
|
}
|
|
124
132
|
|
|
125
133
|
[data-roottale-cms] :where(.rt-cms-card-link) {
|
|
@@ -418,6 +426,27 @@
|
|
|
418
426
|
border-block-end: 1px solid color-mix(in srgb, var(--rt-color-border, #dceef3) 72%, transparent);
|
|
419
427
|
}
|
|
420
428
|
|
|
429
|
+
[data-roottale-cms] :where(.rt-cms-post-categories) {
|
|
430
|
+
display: flex;
|
|
431
|
+
flex-wrap: wrap;
|
|
432
|
+
gap: var(--rt-spacing-2, 0.5rem);
|
|
433
|
+
margin-block-end: var(--rt-spacing-3, 0.75rem);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
[data-roottale-cms] :where(.rt-cms-post-category) {
|
|
437
|
+
display: inline-flex;
|
|
438
|
+
align-items: center;
|
|
439
|
+
min-block-size: 1.75rem;
|
|
440
|
+
padding-inline: 0.625rem;
|
|
441
|
+
border-radius: 999px;
|
|
442
|
+
background: color-mix(in srgb, var(--rt-color-primary, #06162f) 9%, transparent);
|
|
443
|
+
color: var(--rt-color-primary, #06162f);
|
|
444
|
+
font-size: var(--rt-font-size-sm, 0.8125rem);
|
|
445
|
+
font-weight: var(--rt-font-weight-bold, 700);
|
|
446
|
+
line-height: 1.2;
|
|
447
|
+
text-decoration: none;
|
|
448
|
+
}
|
|
449
|
+
|
|
421
450
|
[data-roottale-cms] :where(.rt-cms-post-header .rt-cms-title) {
|
|
422
451
|
font-size: clamp(1.75rem, 1.35rem + 1.9vw, 2.375rem);
|
|
423
452
|
font-weight: var(--rt-font-weight-bold, 800);
|
|
@@ -829,6 +858,23 @@
|
|
|
829
858
|
font-weight: 600;
|
|
830
859
|
}
|
|
831
860
|
|
|
861
|
+
/* 공식 출처 — 일반 목록과 구분되는 근거 묶음. */
|
|
862
|
+
[data-roottale-cms] :where(.rt-cms-references) {
|
|
863
|
+
padding: var(--rt-spacing-5, 1.25rem);
|
|
864
|
+
border: 1px solid color-mix(in srgb, var(--rt-color-border, #dceef3) 88%, transparent);
|
|
865
|
+
border-radius: var(--rt-radius-md, 0.5rem);
|
|
866
|
+
background: color-mix(in srgb, var(--rt-color-muted, #f7f8f7) 34%, transparent);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
[data-roottale-cms] :where(.rt-cms-references .rt-cms-references__title) {
|
|
870
|
+
margin: 0 0 var(--rt-spacing-3, 0.75rem);
|
|
871
|
+
font-size: var(--rt-font-size-xl, 1.375rem);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
[data-roottale-cms] :where(.rt-cms-references > ul) {
|
|
875
|
+
margin: 0;
|
|
876
|
+
}
|
|
877
|
+
|
|
832
878
|
/* 글 하단 고정 CTA — 본문 끝 리드 전환 블록. */
|
|
833
879
|
[data-roottale-cms] :where(.rt-cms-post-cta) {
|
|
834
880
|
max-inline-size: var(--rt-cms-measure, 42.5rem);
|
package/dist/routes.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ interface RootTaleSiteConfig {
|
|
|
20
20
|
description?: string;
|
|
21
21
|
/** 기본 "ko-KR". */
|
|
22
22
|
language?: string;
|
|
23
|
+
/** RSS의 WebSub discovery link. 미지정이면 기존 RSS 출력 유지. */
|
|
24
|
+
webSubHubUrl?: string;
|
|
23
25
|
/** 블로그 base path. 기본 "/blog". 레거시 단일 스트림 — `collections` 미사용 시. */
|
|
24
26
|
blogBasePath?: string;
|
|
25
27
|
/**
|
|
@@ -198,11 +200,26 @@ interface RevalidateRouteConfig {
|
|
|
198
200
|
* Next 의 `revalidatePath` (또는 동등 콜백) 주입. 2번째 인자(`"layout"`)로
|
|
199
201
|
* 레이아웃 전역 무효화를 지원 — `theme.updated` 시 정적 페이지까지 반영하려면
|
|
200
202
|
* 필요(next `revalidatePath(path, "layout")` 시그니처와 호환).
|
|
203
|
+
*
|
|
204
|
+
* **콜백으로 감쌀 때 2번째 인자를 반드시 그대로 넘겨라.** `(path) =>
|
|
205
|
+
* revalidatePath(path)` 처럼 1개만 받으면 이 팩토리가 호출하는
|
|
206
|
+
* `revalidate("/", "layout")` 의 `"layout"` 이 사라져 레이아웃 전역 무효화가
|
|
207
|
+
* 페이지 1개 무효화로 줄어든다(정적 고객 페이지가 안 바뀐다).
|
|
208
|
+
* 올바른 모양: `(path, type) => revalidatePath(path, type)`.
|
|
201
209
|
*/
|
|
202
210
|
revalidate: (path: string, type?: "layout" | "page") => void | Promise<void>;
|
|
203
211
|
/**
|
|
204
212
|
* Next 의 `revalidateTag` (또는 동등 콜백) 주입 — `theme.updated`(W0-4) 시
|
|
205
|
-
* 테마 fetch 캐시 tag 를 무효화한다. 미지정 시 tag
|
|
213
|
+
* 테마 + 설정류 fetch 캐시 tag(ADR-0096 Phase C)를 무효화한다. 미지정 시 tag
|
|
214
|
+
* 무효화 생략(layout 만) — 그러면 `fetchTheme`·`fetchBusinessProfile` 등의
|
|
215
|
+
* Data Cache 엔트리는 각자의 `revalidate` 초만큼 낡은 값을 계속 낸다.
|
|
216
|
+
* **주입을 권장한다.**
|
|
217
|
+
*
|
|
218
|
+
* **즉시 만료 프로파일을 넘겨라** — `(tag) => revalidateTag(tag, { expire: 0 })`.
|
|
219
|
+
* Next 는 `expire: 0` 이 아닌 프로파일을 stale-while-revalidate 업데이트로
|
|
220
|
+
* 취급해 다음 요청이 여전히 낡은 값을 받는다(`"max"` 는 expire 가 1년이라 즉시
|
|
221
|
+
* 만료가 아니다). `updateTag` 는 서버 액션 전용이어서 이 라우트에서는 throw
|
|
222
|
+
* 한다 — 쓰지 마라.
|
|
206
223
|
*/
|
|
207
224
|
revalidateTag?: (tag: string) => void | Promise<void>;
|
|
208
225
|
/** 테마 fetch 캐시 tag 이름. 기본 "rt-theme"(cms-client `THEME_CACHE_TAG`). */
|
|
@@ -407,6 +424,8 @@ interface PostSeoOverrides {
|
|
|
407
424
|
canonical?: string;
|
|
408
425
|
/** OG 이미지 — 비면 글 대표 이미지 사용. */
|
|
409
426
|
ogImage?: string;
|
|
427
|
+
/** OG 이미지 대체 텍스트 — 비면 글 제목 사용. */
|
|
428
|
+
ogImageAlt?: string;
|
|
410
429
|
/** 색인 제외. */
|
|
411
430
|
noindex?: boolean;
|
|
412
431
|
/** 링크 추적 제외. */
|
|
@@ -508,6 +527,11 @@ interface PostLocaleOptions {
|
|
|
508
527
|
interface RobotsMeta {
|
|
509
528
|
index: boolean;
|
|
510
529
|
follow: boolean;
|
|
530
|
+
googleBot?: {
|
|
531
|
+
index: boolean;
|
|
532
|
+
follow: boolean;
|
|
533
|
+
"max-image-preview": "large";
|
|
534
|
+
};
|
|
511
535
|
}
|
|
512
536
|
/**
|
|
513
537
|
* noindex/nofollow 플래그 → `Metadata.robots`. **글과 아카이브가 공유하는 단
|
package/dist/routes.js
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
resolvePostUrl,
|
|
8
8
|
safeFetchPosts,
|
|
9
9
|
trimSlash
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-HM5VP7SR.js";
|
|
11
11
|
|
|
12
12
|
// src/routes.ts
|
|
13
13
|
import { CMS_CLIENT_VERSION } from "@roottale/cms-client/server";
|
|
@@ -56,7 +56,9 @@ function buildPostMetadata(post, options = {}) {
|
|
|
56
56
|
if (post.section) openGraph.section = post.section;
|
|
57
57
|
if (post.tags && post.tags.length > 0) openGraph.tags = [...post.tags];
|
|
58
58
|
if (post.authors && post.authors.length > 0) openGraph.authors = [...post.authors];
|
|
59
|
-
if (ogImage)
|
|
59
|
+
if (ogImage) {
|
|
60
|
+
openGraph.images = [{ url: ogImage, alt: seo?.ogImageAlt || title }];
|
|
61
|
+
}
|
|
60
62
|
const meta = { title, openGraph };
|
|
61
63
|
if (description) meta.description = description;
|
|
62
64
|
if (canonical) {
|
|
@@ -80,11 +82,21 @@ function buildPostMetadata(post, options = {}) {
|
|
|
80
82
|
}
|
|
81
83
|
};
|
|
82
84
|
}
|
|
83
|
-
const
|
|
85
|
+
const baseRobots = resolveRobotsMeta({
|
|
84
86
|
...seo?.noindex !== void 0 && { noindex: seo.noindex },
|
|
85
87
|
...seo?.nofollow !== void 0 && { nofollow: seo.nofollow }
|
|
86
88
|
});
|
|
87
|
-
|
|
89
|
+
const index = baseRobots?.index ?? true;
|
|
90
|
+
const follow = baseRobots?.follow ?? true;
|
|
91
|
+
meta.robots = {
|
|
92
|
+
index,
|
|
93
|
+
follow,
|
|
94
|
+
googleBot: {
|
|
95
|
+
index,
|
|
96
|
+
follow,
|
|
97
|
+
"max-image-preview": "large"
|
|
98
|
+
}
|
|
99
|
+
};
|
|
88
100
|
if (post.id) meta.other = { "rt:content-id": post.id };
|
|
89
101
|
const twitter = { card: "summary_large_image", title };
|
|
90
102
|
if (description) twitter.description = description;
|
|
@@ -405,6 +417,7 @@ function createFeedRoute(config) {
|
|
|
405
417
|
feedUrl: `${siteUrl}/feed.xml`,
|
|
406
418
|
description: config.description,
|
|
407
419
|
language: config.language,
|
|
420
|
+
hubUrl: config.webSubHubUrl,
|
|
408
421
|
items: rssItemsFromPosts(posts, {
|
|
409
422
|
postUrl: (p) => inCollectionMode ? resolvePostUrl3(siteUrl, p, collections) ?? `${siteUrl}${blogBase}/${p.slug}` : `${siteUrl}${blogBase}/${p.slug}`
|
|
410
423
|
})
|
|
@@ -520,6 +533,10 @@ function createLlmsTxtRoute(config) {
|
|
|
520
533
|
}
|
|
521
534
|
|
|
522
535
|
// src/routes-revalidate.ts
|
|
536
|
+
import {
|
|
537
|
+
SETTINGS_CACHE_TAGS,
|
|
538
|
+
THEME_CACHE_TAG
|
|
539
|
+
} from "@roottale/cms-client/server";
|
|
523
540
|
import {
|
|
524
541
|
verifyRootTaleWebhook
|
|
525
542
|
} from "@roottale/cms-client/webhook";
|
|
@@ -549,15 +566,56 @@ function createRevalidateRoute(config) {
|
|
|
549
566
|
return Response.json({ ok: false, reason: result.reason }, { status: 401 });
|
|
550
567
|
}
|
|
551
568
|
if (result.event === "theme.updated") {
|
|
552
|
-
const themeTag = config.themeTag ??
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
569
|
+
const themeTag = config.themeTag ?? THEME_CACHE_TAG;
|
|
570
|
+
const requestedTags = [
|
|
571
|
+
.../* @__PURE__ */ new Set([
|
|
572
|
+
themeTag,
|
|
573
|
+
...SETTINGS_CACHE_TAGS.filter((t) => t !== THEME_CACHE_TAG)
|
|
574
|
+
])
|
|
575
|
+
];
|
|
576
|
+
const paths2 = pathsFromBody(rawBody);
|
|
577
|
+
const failed = [];
|
|
578
|
+
const attempt = async (target, run) => {
|
|
579
|
+
try {
|
|
580
|
+
await run();
|
|
581
|
+
} catch (e) {
|
|
582
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
583
|
+
failed.push({ target, reason });
|
|
584
|
+
console.error(
|
|
585
|
+
`[@roottale/cms-renderer-next/routes] theme.updated \uBB34\uD6A8\uD654 \uC2E4\uD328(${target}):`,
|
|
586
|
+
reason
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
if (config.revalidateTag) {
|
|
591
|
+
const revalidateTag = config.revalidateTag;
|
|
592
|
+
for (const tag of requestedTags) {
|
|
593
|
+
await attempt(`tag:${tag}`, () => revalidateTag(tag));
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
await attempt("layout:/", () => config.revalidate("/", "layout"));
|
|
597
|
+
for (const p of paths2) {
|
|
598
|
+
await attempt(`path:${p}`, () => config.revalidate(p));
|
|
599
|
+
}
|
|
600
|
+
return Response.json(
|
|
601
|
+
{
|
|
602
|
+
ok: failed.length === 0,
|
|
603
|
+
event: result.event,
|
|
604
|
+
deliveryId: result.deliveryId,
|
|
605
|
+
revalidated: {
|
|
606
|
+
// `tag` 는 테마 tag 만 담는 기존 필드(로그 호환).
|
|
607
|
+
tag: config.revalidateTag ? themeTag : null,
|
|
608
|
+
// 콜백에 **넘긴** tag 목록이다. 콜백이 실제로 무엇을 지웠는지는 이
|
|
609
|
+
// 라우트가 알 수 없다(Next 가 결과를 돌려주지 않는다) — 실패한 것만
|
|
610
|
+
// `failed` 로 구분된다. `revalidateTag` 미주입이면 빈 배열.
|
|
611
|
+
requestedTags: config.revalidateTag ? requestedTags : [],
|
|
612
|
+
layout: "/",
|
|
613
|
+
paths: paths2
|
|
614
|
+
},
|
|
615
|
+
...failed.length > 0 ? { failed } : {}
|
|
616
|
+
},
|
|
617
|
+
{ status: failed.length === 0 ? 200 : 500 }
|
|
618
|
+
);
|
|
561
619
|
}
|
|
562
620
|
const INVARIANTS = [
|
|
563
621
|
"/feed.xml",
|
|
@@ -830,7 +888,7 @@ function createFleetInfoRoute(config = {}) {
|
|
|
830
888
|
site: config.site ?? env.VERCEL_PROJECT_PRODUCTION_URL ?? null,
|
|
831
889
|
framework: "next",
|
|
832
890
|
cms_client: CMS_CLIENT_VERSION,
|
|
833
|
-
cms_renderer: true ? "0.
|
|
891
|
+
cms_renderer: true ? "0.53.0" : "dev",
|
|
834
892
|
build_sha: env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) ?? null
|
|
835
893
|
},
|
|
836
894
|
{ headers: { "cache-control": "public, max-age=300" } }
|