@roottale/cms-renderer-next 0.51.1 → 0.52.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 CHANGED
@@ -1,5 +1,65 @@
1
1
  # @roottale/cms-renderer-next
2
2
 
3
+ ## 0.52.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 01f5534: 어드민에서 **설정**을 고치면 사이트에 즉시 반영된다 — 지금까지 글·디자인만 즉시였고 사업장 정보(전화·주소)·상단 메뉴·블로그 표시 설정·콘텐츠 유형은 최대 30분 걸렸다.
8
+
9
+ 원인은 두 개였다. ① 그 설정들을 읽는 조회 함수에 **캐시 이름표를 붙일 방법이 없었다**. ② 그래서 `revalidatePath` 로는 그 조회의 응답 캐시(Next.js Data Cache)를 지울 수 없었다 — 경로 캐시만 지워지고 옛 전화번호가 그대로 남았다.
10
+
11
+ **`@roottale/cms-client`**
12
+ - `fetchBusinessProfile` · `fetchMenu` · `fetchMenus` · `fetchBlogSettings` · `fetchCollections` 가 `tags?: string[]` 를 받는다(이미 있던 `fetchTheme` 와 같은 방식).
13
+ - 이름표 상수를 내보낸다: `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` 배열도 함께.
14
+ - **`tags` 를 안 넘기면 동작이 예전과 한 글자도 다르지 않다** — `next` 키 자체를 만들지 않는다. 기존 코드는 그대로 두어도 된다.
15
+
16
+ **`@roottale/cms-renderer-next`** — `createRevalidateRoute` 의 설정 저장 신호(`theme.updated`) 처리
17
+ - 테마 이름표만이 아니라 위 다섯 개를 **함께** 지운다. 무엇이 바뀌었는지 웹훅 본문에 없기 때문이고, 쓰지 않는 이름표를 지우는 것은 아무 일도 일어나지 않으므로 해가 없다.
18
+ - `themeTag` 를 커스텀 이름으로 덮어썼다면 그 이름을 쓴다(기본 `rt-theme` 는 건드리지 않는다).
19
+ - **본문 `paths` 도 함께 갱신한다**(전에는 무시했다). 콘텐츠 유형 저장이 `/sitemap.xml`·`/feed.xml`·`/llms.txt` 를 보내는데, 이건 route handler 라서 레이아웃 무효화에 딸려 온다고 가정할 수 없다. 디자인 저장처럼 `paths` 가 비어 있으면 아무 일도 일어나지 않는다.
20
+ - **하나가 실패해도 나머지를 전부 시도한다.** 이름표 하나에서 멈추면 남은 이름표와 레이아웃 무효화까지 건너뛰기 때문이다. 하나라도 실패하면 성공으로 위장하지 않고 **500** 과 `failed[]`(무엇이·왜)를 돌려줘 플랫폼이 재시도하게 한다.
21
+ - `revalidateTag` 를 주입하지 않은 사이트의 동작은 **그대로**다(이름표 생략 + 레이아웃 재검증, 응답 200).
22
+ - 응답 본문이 늘었다: `revalidated` 에 `requestedTags: string[]`(콜백에 **넘긴** 이름표 — 실제로 지워졌는지는 Next 가 알려주지 않아 "요청한" 목록이다)와 `paths: string[]` 가 추가됐다. 기존 `tag` 필드는 테마 이름표를 담은 채 그대로 있다 — 로그를 파싱하던 쪽이 깨지지 않는다.
23
+
24
+ **사이트에서 할 일** — 두 군데를 함께 손봐야 즉시 반영이 된다. 한쪽만 하면 아무 오류 없이 "가끔 늦게 반영"으로만 보인다.
25
+
26
+ ```ts
27
+ // 1) app/api/revalidate/route.ts — 이름표를 지울 수 있게 주입
28
+ import { revalidatePath, revalidateTag } from "next/cache";
29
+ import { THEME_CACHE_TAG } from "@roottale/cms-client/server";
30
+ import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";
31
+
32
+ export const POST = createRevalidateRoute({
33
+ apiKey: process.env.ROOTTALE_API_KEY!,
34
+ // 2번째 인자(type)를 반드시 그대로 넘긴다 — `(path) => …` 로 감싸면
35
+ // 레이아웃 전역 무효화가 페이지 1개 무효화로 줄어든다.
36
+ revalidate: (path, type) => revalidatePath(path, type),
37
+ // { expire: 0 } = 즉시 만료. 다른 프로파일("max" 등)은 expire 가 0 이 아니라
38
+ // Next 가 stale-while-revalidate 로 취급해 다음 요청이 여전히 옛 값을 받는다
39
+ // ("max" 의 expire 는 1년). updateTag 는 서버 액션 전용이라 여기서 throw 한다.
40
+ revalidateTag: (tag: string) => revalidateTag(tag, { expire: 0 }),
41
+ themeTag: THEME_CACHE_TAG,
42
+ });
43
+
44
+ // 2) 설정 조회마다 이름표를 붙인다
45
+ const business = await fetchBusinessProfile({
46
+ apiKey,
47
+ tags: [BUSINESS_CACHE_TAG],
48
+ });
49
+ ```
50
+
51
+ 확인 방법: 웹훅 응답의 `revalidated.requestedTags` 가 비어 있으면 1번 주입이 빠진 것이다.
52
+
53
+ > **이벤트 변경 안내** — 어드민의 사업장 정보·메뉴·블로그 표시 설정·콘텐츠 유형 저장이 이제 `post.updated` 대신 `theme.updated` 를 보낸다(디자인·상단 메뉴·공지 배너·상담바는 원래 그랬다). `createRevalidateRoute` 를 쓰면 신경 쓸 것이 없다. 이벤트 이름으로 직접 분기하는 수신기를 손으로 짰다면 `theme.updated` 분기를 추가하라.
54
+
55
+ ### Patch Changes
56
+
57
+ - 9cbd8e5: 블로그 목록 카드가 한 행에서 서로 다른 높이로 끝나던 문제를 고쳤다. 카드 링크의
58
+ `min-block-size: 100%` 는 부모(`li`)의 높이가 확정돼야 풀리는데 `li` 가 일반
59
+ 블록이라 auto 로 접혔다. `li` 를 grid 로 만들어 링크가 행 높이를 채우게 한다.
60
+ - Updated dependencies [01f5534]
61
+ - @roottale/cms-client@0.52.0
62
+
3
63
  ## 0.51.1
4
64
 
5
65
  ### Patch Changes
package/README.md CHANGED
@@ -47,17 +47,46 @@ are fallback only.
47
47
 
48
48
  ```ts
49
49
  // app/api/revalidate/route.ts
50
- import { revalidatePath } from "next/cache";
50
+ import { revalidatePath, revalidateTag } from "next/cache";
51
51
 
52
+ import { THEME_CACHE_TAG } from "@roottale/cms-client/server";
52
53
  import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";
53
54
 
54
55
  export const POST = createRevalidateRoute({
55
56
  apiKey: process.env.ROOTTALE_API_KEY!,
56
57
  apiBase: process.env.ROOTTALE_API_BASE,
57
- revalidate: revalidatePath,
58
+ // Pass the second argument through. The factory calls
59
+ // `revalidate("/", "layout")` on settings saves; a `(path) => …` callback
60
+ // silently drops `"layout"` and downgrades a full layout purge to one page.
61
+ revalidate: (path, type) => revalidatePath(path, type),
62
+ // Required for settings (design tokens, site nav, business profile, blog
63
+ // display, content collections) to land immediately: `revalidatePath` cannot
64
+ // evict the Data Cache entry behind `fetchTheme`/`fetchBusinessProfile`/
65
+ // `fetchMenus`, so the route evicts them by cache tag instead.
66
+ //
67
+ // The profile must be `{ expire: 0 }` — Next treats any profile with a
68
+ // non-zero `expire` as a stale-while-revalidate update, so the next request
69
+ // still serves the old value (the built-in `"max"` profile expires in a
70
+ // year). `updateTag` is immediate but Server-Action only and throws inside a
71
+ // Route Handler, so it cannot be used here.
72
+ revalidateTag: (tag: string) => revalidateTag(tag, { expire: 0 }),
73
+ themeTag: THEME_CACHE_TAG,
58
74
  });
59
75
  ```
60
76
 
77
+ On `theme.updated` the route evicts every tag in `SETTINGS_CACHE_TAGS`
78
+ (`@roottale/cms-client/server`), revalidates `/` as a layout, and then
79
+ revalidates the body `paths` (content-collection saves send `/sitemap.xml`,
80
+ `/feed.xml`, `/llms.txt` — route handlers that a layout purge cannot be assumed
81
+ to cover). Every step is attempted even if an earlier one throws; if anything
82
+ failed the route answers `500` with a `failed[]` list so the platform retries.
83
+
84
+ Attach the matching tag to each settings fetch — `tags: [THEME_CACHE_TAG]`,
85
+ `[BUSINESS_CACHE_TAG]`, `[MENUS_CACHE_TAG]`, `[BLOG_SETTINGS_CACHE_TAG]`,
86
+ `[COLLECTIONS_CACHE_TAG]`. Without `revalidateTag` the route still works and
87
+ returns 200, but settings changes only land after each fetch's `revalidate`
88
+ window (`revalidated.requestedTags` comes back empty — that is the tell).
89
+
61
90
  ADMIN setup: open `/s/{tenant-slug}/sites/{site-id}`, set **Webhook URL** to
62
91
  `https://<customer-domain>/api/revalidate`, keep it enabled, and save. See
63
92
  [`docs/cms-revalidation-webhooks.md`](../../docs/cms-revalidation-webhooks.md)
@@ -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) {
@@ -1,5 +1,5 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
- import { C as ConcernsSection } from './site-config-BvUyDx_f.js';
2
+ import { C as ConcernsSection } from './site-config-DNpvlqA5.js';
3
3
  import 'zod';
4
4
 
5
5
  type ConcernRegion = ConcernsSection["props"]["regions"][number];
@@ -1,5 +1,5 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
- import { E as ExpertiseSection } from './site-config-BvUyDx_f.js';
2
+ import { E as ExpertiseSection } from './site-config-DNpvlqA5.js';
3
3
  import 'zod';
4
4
 
5
5
  type ExpertiseItem = ExpertiseSection["props"]["items"][number];
package/dist/routes.d.ts CHANGED
@@ -198,11 +198,26 @@ interface RevalidateRouteConfig {
198
198
  * Next 의 `revalidatePath` (또는 동등 콜백) 주입. 2번째 인자(`"layout"`)로
199
199
  * 레이아웃 전역 무효화를 지원 — `theme.updated` 시 정적 페이지까지 반영하려면
200
200
  * 필요(next `revalidatePath(path, "layout")` 시그니처와 호환).
201
+ *
202
+ * **콜백으로 감쌀 때 2번째 인자를 반드시 그대로 넘겨라.** `(path) =>
203
+ * revalidatePath(path)` 처럼 1개만 받으면 이 팩토리가 호출하는
204
+ * `revalidate("/", "layout")` 의 `"layout"` 이 사라져 레이아웃 전역 무효화가
205
+ * 페이지 1개 무효화로 줄어든다(정적 고객 페이지가 안 바뀐다).
206
+ * 올바른 모양: `(path, type) => revalidatePath(path, type)`.
201
207
  */
202
208
  revalidate: (path: string, type?: "layout" | "page") => void | Promise<void>;
203
209
  /**
204
210
  * Next 의 `revalidateTag` (또는 동등 콜백) 주입 — `theme.updated`(W0-4) 시
205
- * 테마 fetch 캐시 tag 를 무효화한다. 미지정 시 tag 무효화 생략(layout 만).
211
+ * 테마 + 설정류 fetch 캐시 tag(ADR-0096 Phase C)를 무효화한다. 미지정 시 tag
212
+ * 무효화 생략(layout 만) — 그러면 `fetchTheme`·`fetchBusinessProfile` 등의
213
+ * Data Cache 엔트리는 각자의 `revalidate` 초만큼 낡은 값을 계속 낸다.
214
+ * **주입을 권장한다.**
215
+ *
216
+ * **즉시 만료 프로파일을 넘겨라** — `(tag) => revalidateTag(tag, { expire: 0 })`.
217
+ * Next 는 `expire: 0` 이 아닌 프로파일을 stale-while-revalidate 업데이트로
218
+ * 취급해 다음 요청이 여전히 낡은 값을 받는다(`"max"` 는 expire 가 1년이라 즉시
219
+ * 만료가 아니다). `updateTag` 는 서버 액션 전용이어서 이 라우트에서는 throw
220
+ * 한다 — 쓰지 마라.
206
221
  */
207
222
  revalidateTag?: (tag: string) => void | Promise<void>;
208
223
  /** 테마 fetch 캐시 tag 이름. 기본 "rt-theme"(cms-client `THEME_CACHE_TAG`). */
package/dist/routes.js CHANGED
@@ -520,6 +520,10 @@ function createLlmsTxtRoute(config) {
520
520
  }
521
521
 
522
522
  // src/routes-revalidate.ts
523
+ import {
524
+ SETTINGS_CACHE_TAGS,
525
+ THEME_CACHE_TAG
526
+ } from "@roottale/cms-client/server";
523
527
  import {
524
528
  verifyRootTaleWebhook
525
529
  } from "@roottale/cms-client/webhook";
@@ -549,15 +553,56 @@ function createRevalidateRoute(config) {
549
553
  return Response.json({ ok: false, reason: result.reason }, { status: 401 });
550
554
  }
551
555
  if (result.event === "theme.updated") {
552
- const themeTag = config.themeTag ?? "rt-theme";
553
- if (config.revalidateTag) await config.revalidateTag(themeTag);
554
- await config.revalidate("/", "layout");
555
- return Response.json({
556
- ok: true,
557
- event: result.event,
558
- deliveryId: result.deliveryId,
559
- revalidated: { tag: config.revalidateTag ? themeTag : null, layout: "/" }
560
- });
556
+ const themeTag = config.themeTag ?? THEME_CACHE_TAG;
557
+ const requestedTags = [
558
+ .../* @__PURE__ */ new Set([
559
+ themeTag,
560
+ ...SETTINGS_CACHE_TAGS.filter((t) => t !== THEME_CACHE_TAG)
561
+ ])
562
+ ];
563
+ const paths2 = pathsFromBody(rawBody);
564
+ const failed = [];
565
+ const attempt = async (target, run) => {
566
+ try {
567
+ await run();
568
+ } catch (e) {
569
+ const reason = e instanceof Error ? e.message : String(e);
570
+ failed.push({ target, reason });
571
+ console.error(
572
+ `[@roottale/cms-renderer-next/routes] theme.updated \uBB34\uD6A8\uD654 \uC2E4\uD328(${target}):`,
573
+ reason
574
+ );
575
+ }
576
+ };
577
+ if (config.revalidateTag) {
578
+ const revalidateTag = config.revalidateTag;
579
+ for (const tag of requestedTags) {
580
+ await attempt(`tag:${tag}`, () => revalidateTag(tag));
581
+ }
582
+ }
583
+ await attempt("layout:/", () => config.revalidate("/", "layout"));
584
+ for (const p of paths2) {
585
+ await attempt(`path:${p}`, () => config.revalidate(p));
586
+ }
587
+ return Response.json(
588
+ {
589
+ ok: failed.length === 0,
590
+ event: result.event,
591
+ deliveryId: result.deliveryId,
592
+ revalidated: {
593
+ // `tag` 는 테마 tag 만 담는 기존 필드(로그 호환).
594
+ tag: config.revalidateTag ? themeTag : null,
595
+ // 콜백에 **넘긴** tag 목록이다. 콜백이 실제로 무엇을 지웠는지는 이
596
+ // 라우트가 알 수 없다(Next 가 결과를 돌려주지 않는다) — 실패한 것만
597
+ // `failed` 로 구분된다. `revalidateTag` 미주입이면 빈 배열.
598
+ requestedTags: config.revalidateTag ? requestedTags : [],
599
+ layout: "/",
600
+ paths: paths2
601
+ },
602
+ ...failed.length > 0 ? { failed } : {}
603
+ },
604
+ { status: failed.length === 0 ? 200 : 500 }
605
+ );
561
606
  }
562
607
  const INVARIANTS = [
563
608
  "/feed.xml",
@@ -830,7 +875,7 @@ function createFleetInfoRoute(config = {}) {
830
875
  site: config.site ?? env.VERCEL_PROJECT_PRODUCTION_URL ?? null,
831
876
  framework: "next",
832
877
  cms_client: CMS_CLIENT_VERSION,
833
- cms_renderer: true ? "0.51.1" : "dev",
878
+ cms_renderer: true ? "0.52.0" : "dev",
834
879
  build_sha: env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) ?? null
835
880
  },
836
881
  { headers: { "cache-control": "public, max-age=300" } }