@roottale/cms-renderer-next 0.53.0 → 0.53.1

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/dist/server.d.ts CHANGED
@@ -4,6 +4,7 @@ export { RootTaleTheme } from '@roottale/cms-client/server';
4
4
  import { RootTaleThemeStyle } from '@roottale/ui-react/theme';
5
5
  import { Block, BindingResolver, RouteCollection } from '@roottale/cms-core';
6
6
  export { a as BlogCategory, B as BlogCategoryWithCount, c as collectCategories, b as collectCategoriesWithCounts, d as defaultCategoryHref, f as filterPostsByCategory } from './blog-filter-BKCGM0qd.js';
7
+ import * as react_jsx_runtime from 'react/jsx-runtime';
7
8
 
8
9
  /**
9
10
  * `<RootTalePostCard>` — server-only summary card for a single CmsPostContent.
@@ -277,9 +278,23 @@ type RootTalePageProps = {
277
278
  showTitle?: boolean;
278
279
  theme?: RootTaleThemeInput;
279
280
  locale?: string;
281
+ /** 페이지 JSON을 렌더하기 직전 콘텐츠 참조를 실제 섹션 props로 바꾸는 서버 함수. */
282
+ prepareBody?: (document: Record<string, unknown>) => Promise<Record<string, unknown>>;
280
283
  };
281
284
  declare function RootTalePage(props: RootTalePageProps): Promise<ReactElement>;
282
285
 
286
+ interface RootTaleExposureSlotProps {
287
+ readonly apiKey: string;
288
+ readonly baseUrl?: string;
289
+ readonly siteId?: string;
290
+ readonly slotKey: string;
291
+ readonly path: string;
292
+ readonly allowedVariants: readonly string[];
293
+ readonly revalidate?: number;
294
+ }
295
+ /** FRONT가 코드로 만든 슬롯 안에서만 활성 배너·팝업을 렌더한다. */
296
+ declare function RootTaleExposureSlot(props: RootTaleExposureSlotProps): Promise<react_jsx_runtime.JSX.Element | null>;
297
+
283
298
  type RenderTiptapProps = {
284
299
  readonly doc: Record<string, unknown>;
285
300
  readonly fields?: Record<string, unknown> | null;
@@ -288,4 +303,33 @@ type RenderTiptapProps = {
288
303
  };
289
304
  declare function RenderTiptap({ doc, fields, apiKey, baseUrl, }: RenderTiptapProps): ReactElement;
290
305
 
291
- export { type BlogPostBreadcrumbOptions, type CtaButton, type CtaButtonType, type FloatingCtaPosition, type LeadFormVertical, RenderTiptap, RootTaleBlogCategories, type RootTaleBlogCategoriesProps, RootTaleBlogList, type RootTaleBlogListProps, RootTaleBlogPost, type RootTaleBlogPostProps, type RootTaleCssVars, RootTaleFaqJsonLd, RootTaleFloatingCta, type RootTaleFloatingCtaProps, RootTaleLeadForm, type RootTaleLeadFormProps, RootTalePage, type RootTalePageProps, RootTalePostCard, type RootTalePostCardProps, RootTaleTableOfContents, type RootTaleTableOfContentsProps, type RootTaleThemeInput, RootTaleThemeProvider, type TocEntry, attachHeadingIds, extractToc, headingToId, renderBlock, renderBlocks, renderBoundBlocks, themeToCssVars };
306
+ /**
307
+ * 본문 렌더 소스 결정 — HTML 정본 우선, 없으면 Block JSON 폴백.
308
+ *
309
+ * 배경: 외부에서 이관한 글은 **HTML 이 정본**이고 body_json 은 그 파생/대체본이다.
310
+ * 파생본은 표·중첩 목록 같은 구조에서 원문과 미묘하게 달라질 수 있으므로, 원문이
311
+ * 남아 있으면 그것을 보여주는 편이 정확하다. 반대로 에디터에서 편집된 글은
312
+ * body_html 이 비워지므로(저장 경로가 무효화) 자동으로 JSON 경로로 돌아온다.
313
+ *
314
+ * 안전성: HTML 은 신뢰하지 않는다. 최종 sink 는 공용 `sanitizeHtml` 하나뿐이며
315
+ * (cms-core), 렌더 직전 여기서 통과시킨다 — 저장 시점에 정제됐더라도 다시 한다
316
+ * (저장 경로가 여럿이고 sanitizer 규칙은 갱신되므로, 방어는 렌더 시점 기준).
317
+ *
318
+ * sanitize 결과가 실질적으로 비면(태그만 남고 내용이 없음) HTML 을 쓰지 않고
319
+ * JSON 으로 폴백한다 — 빈 화면보다 파생본이 낫다.
320
+ */
321
+ type PostBodySource = {
322
+ readonly kind: "html";
323
+ readonly html: string;
324
+ } | {
325
+ readonly kind: "json";
326
+ };
327
+ /**
328
+ * 렌더에 쓸 본문 소스를 고른다.
329
+ *
330
+ * @param bodyHtml 원문 HTML 정본 (null/미포함 = 구 서버이거나 JSON 이 정본).
331
+ * @returns `kind: "html"` 이면 `html` 은 **이미 sanitize 된** 값이라 그대로 주입 가능.
332
+ */
333
+ declare function resolvePostBodySource(bodyHtml: string | null | undefined): PostBodySource;
334
+
335
+ export { type BlogPostBreadcrumbOptions, type CtaButton, type CtaButtonType, type FloatingCtaPosition, type LeadFormVertical, type PostBodySource, RenderTiptap, RootTaleBlogCategories, type RootTaleBlogCategoriesProps, RootTaleBlogList, type RootTaleBlogListProps, RootTaleBlogPost, type RootTaleBlogPostProps, type RootTaleCssVars, RootTaleExposureSlot, type RootTaleExposureSlotProps, RootTaleFaqJsonLd, RootTaleFloatingCta, type RootTaleFloatingCtaProps, RootTaleLeadForm, type RootTaleLeadFormProps, RootTalePage, type RootTalePageProps, RootTalePostCard, type RootTalePostCardProps, RootTaleTableOfContents, type RootTaleTableOfContentsProps, type RootTaleThemeInput, RootTaleThemeProvider, type TocEntry, attachHeadingIds, extractToc, headingToId, renderBlock, renderBlocks, renderBoundBlocks, resolvePostBodySource, themeToCssVars };
package/dist/server.js CHANGED
@@ -12,6 +12,9 @@ import {
12
12
  import {
13
13
  LeadFormMeta
14
14
  } from "./chunk-VG6UUF4D.js";
15
+ import {
16
+ RootTaleExposure
17
+ } from "./chunk-GVKENK6H.js";
15
18
 
16
19
  // src/PostCard.tsx
17
20
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -623,7 +626,6 @@ function attachHeadingIds(doc, entries) {
623
626
 
624
627
  // src/server/blog-list.tsx
625
628
  import {
626
- fetchPosts,
627
629
  resolvePostDisplay
628
630
  } from "@roottale/cms-client/server";
629
631
  import {
@@ -640,6 +642,18 @@ async function fetchBlogSettingsFailSoft(input) {
640
642
  return fetchBlogSettings(input).catch(() => DEFAULT_BLOG_SETTINGS);
641
643
  }
642
644
 
645
+ // src/server/fetch-posts-fail-soft.ts
646
+ import {
647
+ fetchPosts
648
+ } from "@roottale/cms-client/server";
649
+ async function fetchPostsFailSoft(options) {
650
+ try {
651
+ return await fetchPosts(options);
652
+ } catch {
653
+ return { items: [], hasMore: false, nextCursor: null };
654
+ }
655
+ }
656
+
643
657
  // src/server/post-links.ts
644
658
  import {
645
659
  resolvePostCollection,
@@ -726,7 +740,7 @@ async function RootTaleBlogList(props) {
726
740
  } = props;
727
741
  const postHref = props.postHref ?? (collections ? makeCollectionPostHref(collections) : defaultPostHref);
728
742
  const [page, themeStyle, blogSettings] = await Promise.all([
729
- fetchPosts({
743
+ fetchPostsFailSoft({
730
744
  apiKey,
731
745
  baseUrl,
732
746
  limit,
@@ -787,9 +801,6 @@ async function RootTaleBlogList(props) {
787
801
  }
788
802
 
789
803
  // src/server/blog-categories.tsx
790
- import {
791
- fetchPosts as fetchPosts2
792
- } from "@roottale/cms-client/server";
793
804
  import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
794
805
  async function RootTaleBlogCategories(props) {
795
806
  const {
@@ -804,7 +815,7 @@ async function RootTaleBlogCategories(props) {
804
815
  theme: themeProp
805
816
  } = props;
806
817
  const [page, themeStyle] = await Promise.all([
807
- fetchPosts2({
818
+ fetchPostsFailSoft({
808
819
  apiKey,
809
820
  baseUrl,
810
821
  limit,
@@ -839,7 +850,7 @@ async function RootTaleBlogCategories(props) {
839
850
  // src/server/blog-post.tsx
840
851
  import {
841
852
  fetchPost,
842
- fetchPosts as fetchPosts3,
853
+ fetchPosts as fetchPosts2,
843
854
  resolvePostDisplay as resolvePostDisplay2,
844
855
  selectRelatedPosts
845
856
  } from "@roottale/cms-client/server";
@@ -931,12 +942,30 @@ function renderFieldFacts(post) {
931
942
  ] }, meta.name)) }) });
932
943
  }
933
944
 
945
+ // src/server/post-body-source.ts
946
+ import { sanitizeHtml as sanitizeHtml2 } from "@roottale/cms-core";
947
+ function hasVisibleContent(html) {
948
+ if (/<(?:img|hr|br|iframe|video|audio)\b/i.test(html)) return true;
949
+ return html.replace(/<[^>]*>/g, "").replace(/&nbsp;/gi, " ").trim().length > 0;
950
+ }
951
+ function resolvePostBodySource(bodyHtml) {
952
+ if (typeof bodyHtml !== "string") return { kind: "json" };
953
+ const trimmed = bodyHtml.trim();
954
+ if (trimmed.length === 0) return { kind: "json" };
955
+ const sanitized = sanitizeHtml2(trimmed);
956
+ if (!hasVisibleContent(sanitized)) return { kind: "json" };
957
+ return { kind: "html", html: sanitized };
958
+ }
959
+
934
960
  // src/server/tiptap-node-renderer.tsx
961
+ import { Fragment as Fragment4 } from "react";
935
962
  import {
936
963
  bindingSrcValue,
937
964
  bindingTextValue,
938
965
  boundFieldValue,
939
- normalizeImageAlt
966
+ ensureImportedHtmlDocumentRoots,
967
+ normalizeImageAlt,
968
+ scopeImportedCss
940
969
  } from "@roottale/cms-core";
941
970
 
942
971
  // src/server/tiptap-embed-renderer.tsx
@@ -1151,6 +1180,33 @@ function renderTiptapNode(value, key, context) {
1151
1180
  key
1152
1181
  );
1153
1182
  }
1183
+ case "importedHtml": {
1184
+ const rawHtml = node.attrs?.html;
1185
+ if (typeof rawHtml !== "string" || rawHtml.length === 0) return null;
1186
+ const scope = typeof node.attrs?.scope === "string" ? node.attrs.scope : "";
1187
+ const assetUrls = Array.isArray(node.attrs?.assetUrls) ? node.attrs.assetUrls.filter(
1188
+ (value2) => typeof value2 === "string" && /^https:\/\//i.test(value2)
1189
+ ) : [];
1190
+ const scopedCss = scopeImportedCss(
1191
+ typeof node.attrs?.css === "string" ? node.attrs.css : "",
1192
+ scope,
1193
+ assetUrls
1194
+ );
1195
+ return /* @__PURE__ */ jsxs9(Fragment4, { children: [
1196
+ scopedCss ? /* @__PURE__ */ jsx12("style", { "data-rt-imported-style": scope, children: scopedCss }) : null,
1197
+ /* @__PURE__ */ jsx12(
1198
+ "div",
1199
+ {
1200
+ className: "rt-imported-html",
1201
+ "data-rt-import-scope": scopedCss ? scope : void 0,
1202
+ style: { position: "relative", isolation: "isolate" },
1203
+ dangerouslySetInnerHTML: {
1204
+ __html: ensureImportedHtmlDocumentRoots(rawHtml, assetUrls)
1205
+ }
1206
+ }
1207
+ )
1208
+ ] }, key);
1209
+ }
1154
1210
  case "columns":
1155
1211
  return /* @__PURE__ */ jsx12("div", { className: "rt-columns", children }, key);
1156
1212
  case "column":
@@ -1217,7 +1273,7 @@ function renderTiptapNode(value, key, context) {
1217
1273
  }
1218
1274
 
1219
1275
  // src/server/render-tiptap.tsx
1220
- import { Fragment as Fragment4, jsx as jsx13 } from "react/jsx-runtime";
1276
+ import { Fragment as Fragment5, jsx as jsx13 } from "react/jsx-runtime";
1221
1277
  function isDevelopmentRuntime() {
1222
1278
  const processValue = Reflect.get(globalThis, "process");
1223
1279
  if (!isRecord(processValue)) return true;
@@ -1252,7 +1308,7 @@ function RenderTiptap({
1252
1308
  fields: fields ?? null,
1253
1309
  section: createSectionContextWithBlogListNetworkFetch(apiKey, baseUrl)
1254
1310
  };
1255
- return /* @__PURE__ */ jsx13(Fragment4, { children: contentNodes(renderDoc).map((value, index) => {
1311
+ return /* @__PURE__ */ jsx13(Fragment5, { children: contentNodes(renderDoc).map((value, index) => {
1256
1312
  const node = parseTiptapNode(value);
1257
1313
  if (node?.type !== "tableOfContents") {
1258
1314
  return renderTiptapNode(value, index, context);
@@ -1270,7 +1326,7 @@ function RenderTiptap({
1270
1326
  }
1271
1327
 
1272
1328
  // src/server/blog-post-body.tsx
1273
- import { Fragment as Fragment5, jsx as jsx14, jsxs as jsxs10 } from "react/jsx-runtime";
1329
+ import { Fragment as Fragment6, jsx as jsx14, jsxs as jsxs10 } from "react/jsx-runtime";
1274
1330
  function BlogPostBody(props) {
1275
1331
  const {
1276
1332
  doc,
@@ -1279,10 +1335,17 @@ function BlogPostBody(props) {
1279
1335
  baseUrl,
1280
1336
  toc,
1281
1337
  tocTitle,
1282
- tocPosition
1338
+ tocPosition,
1339
+ bodyHtml
1283
1340
  } = props;
1284
1341
  const tableOfContents = toc.length > 0 ? /* @__PURE__ */ jsx14(RootTaleTableOfContents, { entries: toc, title: tocTitle }) : null;
1285
- const body = /* @__PURE__ */ jsx14("div", { "data-roottale-cms": "body", className: "rt-cms-body", children: /* @__PURE__ */ jsx14(
1342
+ const source = resolvePostBodySource(bodyHtml);
1343
+ const body = /* @__PURE__ */ jsx14("div", { "data-roottale-cms": "body", className: "rt-cms-body", children: source.kind === "html" ? (
1344
+ // source.html 은 resolvePostBodySource 가 공용 sanitizeHtml 로 정제한 값이다
1345
+ // (이 컴포넌트에서 한 번 더 정제하지 않는 이유 — sink 가 한 곳이어야 눈으로
1346
+ // 추적 가능하고, 이중 정제는 안전을 더 만들지 않으면서 비용만 든다).
1347
+ /* @__PURE__ */ jsx14("div", { dangerouslySetInnerHTML: { __html: source.html } })
1348
+ ) : /* @__PURE__ */ jsx14(
1286
1349
  RenderTiptap,
1287
1350
  {
1288
1351
  doc,
@@ -1297,7 +1360,7 @@ function BlogPostBody(props) {
1297
1360
  /* @__PURE__ */ jsx14("aside", { className: "rt-cms-toc-side", children: tableOfContents })
1298
1361
  ] });
1299
1362
  }
1300
- return /* @__PURE__ */ jsxs10(Fragment5, { children: [
1363
+ return /* @__PURE__ */ jsxs10(Fragment6, { children: [
1301
1364
  tableOfContents,
1302
1365
  body
1303
1366
  ] });
@@ -1311,7 +1374,7 @@ import {
1311
1374
  import {
1312
1375
  resolvePostCollection as resolvePostCollection2
1313
1376
  } from "@roottale/cms-core";
1314
- import { Fragment as Fragment6, jsx as jsx15, jsxs as jsxs11 } from "react/jsx-runtime";
1377
+ import { Fragment as Fragment7, jsx as jsx15, jsxs as jsxs11 } from "react/jsx-runtime";
1315
1378
  function absoluteUrl(siteUrl, path) {
1316
1379
  const normalizedPath = path.startsWith("/") ? path : `/${path}`;
1317
1380
  return `${trimSlash(siteUrl)}${normalizedPath}`;
@@ -1346,7 +1409,7 @@ function renderBlogPostBreadcrumb(input) {
1346
1409
  url: absoluteUrl(siteUrl, crumb.href)
1347
1410
  }))
1348
1411
  ) : null;
1349
- return /* @__PURE__ */ jsxs11(Fragment6, { children: [
1412
+ return /* @__PURE__ */ jsxs11(Fragment7, { children: [
1350
1413
  /* @__PURE__ */ jsx15("nav", { "aria-label": "\uBE0C\uB808\uB4DC\uD06C\uB7FC", className: "rt-cms-breadcrumb", children: /* @__PURE__ */ jsx15("ol", { children: crumbs.map(
1351
1414
  (crumb, index) => index === crumbs.length - 1 ? /* @__PURE__ */ jsx15("li", { "aria-current": "page", children: crumb.label }, index) : /* @__PURE__ */ jsx15("li", { children: /* @__PURE__ */ jsx15("a", { href: crumb.href, children: crumb.label }) }, index)
1352
1415
  ) }) }),
@@ -1426,7 +1489,7 @@ function RootTaleFaqJsonLd({
1426
1489
  import { jsx as jsx17, jsxs as jsxs13 } from "react/jsx-runtime";
1427
1490
  async function fetchRelatedPostsFailSoft(input) {
1428
1491
  if (input.count <= 0) return [];
1429
- const candidates = await fetchPosts3({
1492
+ const candidates = await fetchPosts2({
1430
1493
  apiKey: input.apiKey,
1431
1494
  baseUrl: input.baseUrl,
1432
1495
  type: "post",
@@ -1611,7 +1674,8 @@ async function RootTaleBlogPost(props) {
1611
1674
  baseUrl,
1612
1675
  toc,
1613
1676
  tocTitle: display.tocTitle,
1614
- tocPosition: display.tocPosition
1677
+ tocPosition: display.tocPosition,
1678
+ bodyHtml: post.bodyHtml
1615
1679
  }
1616
1680
  ),
1617
1681
  showAuthorCard && authorName ? /* @__PURE__ */ jsxs13("section", { className: "rt-cms-author-card", "aria-label": "\uC791\uC131\uC790", children: [
@@ -1650,7 +1714,8 @@ async function RootTalePage(props) {
1650
1714
  notFoundElement,
1651
1715
  showTitle = true,
1652
1716
  theme: themeProp,
1653
- locale
1717
+ locale,
1718
+ prepareBody
1654
1719
  } = props;
1655
1720
  const [post, themeStyle] = await Promise.all([
1656
1721
  fetchPost2({ apiKey, slugOrId, baseUrl, ...locale ? { locale } : {} }),
@@ -1659,6 +1724,8 @@ async function RootTalePage(props) {
1659
1724
  if (!post) {
1660
1725
  return notFoundElement ?? /* @__PURE__ */ jsx18("div", { "data-roottale-cms": "page-missing", style: themeStyle ?? void 0, children: /* @__PURE__ */ jsx18("p", { className: "rt-cms-empty", children: t(locale, "pageNotFound") }) });
1661
1726
  }
1727
+ const pageBodySource = resolvePostBodySource(post.bodyHtml);
1728
+ const bodyJson = pageBodySource.kind === "json" && prepareBody ? await prepareBody(post.bodyJson) : post.bodyJson;
1662
1729
  return /* @__PURE__ */ jsxs14(
1663
1730
  "article",
1664
1731
  {
@@ -1667,10 +1734,10 @@ async function RootTalePage(props) {
1667
1734
  style: themeStyle ?? void 0,
1668
1735
  children: [
1669
1736
  showTitle ? /* @__PURE__ */ jsx18("header", { className: "rt-cms-post-header", children: /* @__PURE__ */ jsx18("h1", { className: "rt-cms-title", children: post.title }) }) : null,
1670
- /* @__PURE__ */ jsx18("div", { "data-roottale-cms": "body", className: "rt-cms-body", children: /* @__PURE__ */ jsx18(
1737
+ /* @__PURE__ */ jsx18("div", { "data-roottale-cms": "body", className: "rt-cms-body", children: pageBodySource.kind === "html" ? /* @__PURE__ */ jsx18("div", { dangerouslySetInnerHTML: { __html: pageBodySource.html } }) : /* @__PURE__ */ jsx18(
1671
1738
  RenderTiptap,
1672
1739
  {
1673
- doc: post.bodyJson,
1740
+ doc: bodyJson,
1674
1741
  fields: post.fields,
1675
1742
  apiKey,
1676
1743
  baseUrl
@@ -1680,11 +1747,28 @@ async function RootTalePage(props) {
1680
1747
  }
1681
1748
  );
1682
1749
  }
1750
+
1751
+ // src/server/exposure-slot.tsx
1752
+ import { fetchExposure } from "@roottale/cms-client/server";
1753
+ import { jsx as jsx19 } from "react/jsx-runtime";
1754
+ async function RootTaleExposureSlot(props) {
1755
+ const campaign = await fetchExposure({
1756
+ apiKey: props.apiKey,
1757
+ baseUrl: props.baseUrl,
1758
+ siteId: props.siteId,
1759
+ slotKey: props.slotKey,
1760
+ path: props.path,
1761
+ revalidate: props.revalidate
1762
+ });
1763
+ if (!campaign || !props.allowedVariants.includes(campaign.content.variant)) return null;
1764
+ return /* @__PURE__ */ jsx19(RootTaleExposure, { campaign, allowedVariants: props.allowedVariants });
1765
+ }
1683
1766
  export {
1684
1767
  RenderTiptap,
1685
1768
  RootTaleBlogCategories,
1686
1769
  RootTaleBlogList,
1687
1770
  RootTaleBlogPost,
1771
+ RootTaleExposureSlot,
1688
1772
  RootTaleFaqJsonLd,
1689
1773
  RootTaleFloatingCta,
1690
1774
  RootTaleLeadForm,
@@ -1702,6 +1786,7 @@ export {
1702
1786
  renderBlock,
1703
1787
  renderBlocks,
1704
1788
  renderBoundBlocks,
1789
+ resolvePostBodySource,
1705
1790
  themeToCssVars2 as themeToCssVars
1706
1791
  };
1707
1792
  //# sourceMappingURL=server.js.map