@roottale/cms-renderer-next 0.10.0 → 0.12.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/dist/routes.js ADDED
@@ -0,0 +1,110 @@
1
+ // src/routes.ts
2
+ import {
3
+ fetchPosts,
4
+ generateRssXml,
5
+ rssItemsFromPosts
6
+ } from "@roottale/cms-client/server";
7
+ import {
8
+ verifyRootTaleWebhook
9
+ } from "@roottale/cms-client/webhook";
10
+ function trimSlash(u) {
11
+ return u.replace(/\/+$/, "");
12
+ }
13
+ async function safeFetchPosts(config, limit) {
14
+ try {
15
+ const page = await fetchPosts({
16
+ apiKey: config.apiKey,
17
+ baseUrl: config.apiBase,
18
+ type: "post",
19
+ limit
20
+ });
21
+ return page.items;
22
+ } catch (err) {
23
+ console.error("[@roottale/cms-renderer-next/routes] fetchPosts \uC2E4\uD328:", err);
24
+ return [];
25
+ }
26
+ }
27
+ function createFeedRoute(config) {
28
+ return async function GET() {
29
+ const siteUrl = trimSlash(config.siteUrl);
30
+ const blogBase = config.blogBasePath ?? "/blog";
31
+ const posts = await safeFetchPosts(config, 50);
32
+ const xml = generateRssXml({
33
+ title: config.title,
34
+ link: `${siteUrl}${blogBase}`,
35
+ feedUrl: `${siteUrl}/feed.xml`,
36
+ description: config.description,
37
+ language: config.language,
38
+ items: rssItemsFromPosts(posts, {
39
+ postUrl: (p) => `${siteUrl}${blogBase}/${p.slug}`
40
+ })
41
+ });
42
+ return new Response(xml, {
43
+ headers: {
44
+ "content-type": "application/rss+xml; charset=utf-8",
45
+ "cache-control": "public, max-age=1800, s-maxage=1800"
46
+ }
47
+ });
48
+ };
49
+ }
50
+ function createSitemap(config, staticRoutes = []) {
51
+ return async function sitemap() {
52
+ const siteUrl = trimSlash(config.siteUrl);
53
+ const blogBase = config.blogBasePath ?? "/blog";
54
+ const posts = await safeFetchPosts(config, 200);
55
+ const postEntries = posts.map((p) => ({
56
+ url: `${siteUrl}${blogBase}/${p.slug}`,
57
+ lastModified: p.updatedAt ?? p.publishedAt ?? void 0,
58
+ changeFrequency: "weekly",
59
+ priority: 0.6
60
+ }));
61
+ return [...staticRoutes, ...postEntries];
62
+ };
63
+ }
64
+ function pathsFromBody(rawBody) {
65
+ try {
66
+ const body = JSON.parse(rawBody);
67
+ if (Array.isArray(body.paths)) {
68
+ return body.paths.filter((p) => typeof p === "string");
69
+ }
70
+ } catch {
71
+ }
72
+ return [];
73
+ }
74
+ function createRevalidateRoute(config) {
75
+ const blogBase = config.blogBasePath ?? "/blog";
76
+ const alsoRevalidate = config.alsoRevalidate ?? [
77
+ "/feed.xml",
78
+ "/sitemap.xml",
79
+ blogBase
80
+ ];
81
+ return async function POST(req) {
82
+ const rawBody = await req.text();
83
+ const result = await verifyRootTaleWebhook({
84
+ rawBody,
85
+ headers: req.headers,
86
+ apiKey: config.apiKey,
87
+ apiBase: config.apiBase,
88
+ consumeJti: config.consumeJti
89
+ });
90
+ if (!result.ok) {
91
+ return Response.json({ ok: false, reason: result.reason }, { status: 401 });
92
+ }
93
+ const paths = [.../* @__PURE__ */ new Set([...pathsFromBody(rawBody), ...alsoRevalidate])];
94
+ for (const p of paths) {
95
+ await config.revalidate(p);
96
+ }
97
+ return Response.json({
98
+ ok: true,
99
+ event: result.event,
100
+ deliveryId: result.deliveryId,
101
+ revalidated: paths
102
+ });
103
+ };
104
+ }
105
+ export {
106
+ createFeedRoute,
107
+ createRevalidateRoute,
108
+ createSitemap
109
+ };
110
+ //# sourceMappingURL=routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/routes.ts"],"sourcesContent":["/**\n * `@roottale/cms-renderer-next/routes` — Next.js App Router 통합 라우트 팩토리.\n *\n * 외부 고객사 사이트가 매번 직접 작성하던 공통 라우트(RSS feed / sitemap /\n * revalidate webhook)를 1줄 팩토리로 표준화. 블로그 조회와 *동일한* 단일\n * customer API key(`ROOTTALE_API_KEY`)를 사용한다.\n *\n * 설계 원칙:\n * - feed/sitemap 은 `fetchPosts` 실패를 swallow → 라우트가 *항상 200* (빌드 시\n * 정적 생성이 throw 해서 404 나는 사고 방지).\n * - revalidate 는 admin(mysite.roottale.com)에서 글/카테고리/미디어 변경 시 보내는\n * 서명 webhook 을 검증하고, 본문 paths + 파생 경로(feed·sitemap·블로그 인덱스)를\n * 모두 revalidate → 각 사이트에 자동 반영.\n * - `next` 에 직접 의존하지 않음: revalidate 콜백(`revalidatePath`)을 주입받는다.\n *\n * 사용 예는 roottale-docs `services/external-site-integration.md` 참조.\n */\n\nimport {\n fetchPosts,\n generateRssXml,\n rssItemsFromPosts,\n type CmsPostContent,\n} from \"@roottale/cms-client/server\";\nimport {\n verifyRootTaleWebhook,\n type VerifyRootTaleWebhookResult,\n} from \"@roottale/cms-client/webhook\";\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\". */\n blogBasePath?: string;\n}\n\nfunction trimSlash(u: string): string {\n return u.replace(/\\/+$/, \"\");\n}\n\nasync 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\n/**\n * `app/feed.xml/route.ts` 용 GET 핸들러. RSS 2.0.\n *\n * ```ts\n * import { createFeedRoute } from \"@roottale/cms-renderer-next/routes\";\n * export const dynamic = \"force-dynamic\";\n * export const GET = createFeedRoute({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * siteUrl: \"https://example.com\",\n * title: \"Example 블로그\",\n * description: \"...\",\n * });\n * ```\n */\nexport function createFeedRoute(\n config: RootTaleSiteConfig,\n): () => Promise<Response> {\n return async function GET(): Promise<Response> {\n const siteUrl = trimSlash(config.siteUrl);\n const blogBase = config.blogBasePath ?? \"/blog\";\n const posts = await safeFetchPosts(config, 50);\n const xml = generateRssXml({\n title: config.title,\n link: `${siteUrl}${blogBase}`,\n feedUrl: `${siteUrl}/feed.xml`,\n description: config.description,\n language: config.language,\n items: rssItemsFromPosts(posts, {\n postUrl: (p) => `${siteUrl}${blogBase}/${p.slug}`,\n }),\n });\n return new Response(xml, {\n headers: {\n \"content-type\": \"application/rss+xml; charset=utf-8\",\n \"cache-control\": \"public, max-age=1800, s-maxage=1800\",\n },\n });\n };\n}\n\n/** Next `MetadataRoute.Sitemap[number]` 와 구조 호환(타입 결합 회피). */\nexport interface SitemapEntry {\n url: string;\n lastModified?: string | Date;\n changeFrequency?:\n | \"always\"\n | \"hourly\"\n | \"daily\"\n | \"weekly\"\n | \"monthly\"\n | \"yearly\"\n | \"never\";\n priority?: number;\n}\n\n/**\n * `app/sitemap.ts` default export. 정적 경로 + 글 경로를 합친다.\n *\n * ```ts\n * import { createSitemap } from \"@roottale/cms-renderer-next/routes\";\n * export default createSitemap(\n * { apiKey: process.env.ROOTTALE_API_KEY!, siteUrl: \"https://example.com\", title: \"...\" },\n * [{ url: \"https://example.com\", changeFrequency: \"weekly\", priority: 1 }],\n * );\n * ```\n */\nexport function createSitemap(\n config: RootTaleSiteConfig,\n staticRoutes: SitemapEntry[] = [],\n): () => Promise<SitemapEntry[]> {\n return async function sitemap(): Promise<SitemapEntry[]> {\n const siteUrl = trimSlash(config.siteUrl);\n const blogBase = config.blogBasePath ?? \"/blog\";\n const posts = await safeFetchPosts(config, 200);\n const postEntries: SitemapEntry[] = posts.map((p) => ({\n url: `${siteUrl}${blogBase}/${p.slug}`,\n lastModified: p.updatedAt ?? p.publishedAt ?? undefined,\n changeFrequency: \"weekly\",\n priority: 0.6,\n }));\n return [...staticRoutes, ...postEntries];\n };\n}\n\nexport interface RevalidateRouteConfig {\n /** `rtlk_cust_*` — 블로그 조회/webhook 검증 공통 키. */\n apiKey: string;\n apiBase?: string;\n /** Next 의 `revalidatePath` (또는 동등 콜백) 주입. */\n revalidate: (path: string) => void | Promise<void>;\n /** 블로그 base path. 기본 \"/blog\". */\n blogBasePath?: string;\n /**\n * 모든 글/분류/미디어 변경에 대해 *항상 함께* revalidate 할 파생 경로.\n * 피드·사이트맵·블로그 인덱스처럼 \"글 목록\"에 의존하는 경로 — admin 변경이\n * 자동 반영되려면 필수. 기본: [\"/feed.xml\", \"/sitemap.xml\", blogBase].\n */\n alsoRevalidate?: string[];\n /** (옵션) jti replay 방지 hook (cms-client verify 로 전달). */\n consumeJti?: (jti: string, exp: number) => Promise<\"new\" | \"seen\">;\n}\n\n/** webhook 본문에서 paths[] 추출(서명 검증 후 호출 — body_hash 가 binding). */\nfunction pathsFromBody(rawBody: string): string[] {\n try {\n const body = JSON.parse(rawBody) as { paths?: unknown };\n if (Array.isArray(body.paths)) {\n return body.paths.filter((p): p is string => typeof p === \"string\");\n }\n } catch {\n /* 비-JSON 본문 — 파생 경로만 revalidate */\n }\n return [];\n}\n\n/**\n * `app/api/revalidate/route.ts` 용 POST 핸들러.\n *\n * admin(mysite.roottale.com)이 글/카테고리/미디어 변경 시 보내는 서명 webhook 을\n * 검증하고, 본문 paths + 파생 경로(feed·sitemap·블로그 인덱스)를 revalidate.\n *\n * ```ts\n * import { revalidatePath } from \"next/cache\";\n * import { createRevalidateRoute } from \"@roottale/cms-renderer-next/routes\";\n * export const POST = createRevalidateRoute({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * revalidate: revalidatePath,\n * });\n * ```\n */\nexport function createRevalidateRoute(\n config: RevalidateRouteConfig,\n): (req: Request) => Promise<Response> {\n const blogBase = config.blogBasePath ?? \"/blog\";\n const alsoRevalidate = config.alsoRevalidate ?? [\n \"/feed.xml\",\n \"/sitemap.xml\",\n blogBase,\n ];\n\n return async function POST(req: Request): Promise<Response> {\n const rawBody = await req.text();\n const result: VerifyRootTaleWebhookResult = await verifyRootTaleWebhook({\n rawBody,\n headers: req.headers,\n apiKey: config.apiKey,\n apiBase: config.apiBase,\n consumeJti: config.consumeJti,\n });\n if (!result.ok) {\n return Response.json({ ok: false, reason: result.reason }, { status: 401 });\n }\n\n // 본문 paths + 파생 경로(항상) — 중복 제거.\n const paths = [...new Set([...pathsFromBody(rawBody), ...alsoRevalidate])];\n for (const p of paths) {\n await config.revalidate(p);\n }\n\n return Response.json({\n ok: true,\n event: result.event,\n deliveryId: result.deliveryId,\n revalidated: paths,\n });\n };\n}\n"],"mappings":"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","names":[]}
package/dist/server.d.ts CHANGED
@@ -31,24 +31,24 @@ declare function RootTalePostCard(props: RootTalePostCardProps): ReactElement;
31
31
  /**
32
32
  * `@roottale/cms-renderer/toc` — Tiptap doc → TOC entries + heading id injection.
33
33
  *
34
- * Server-only utility. Walks a Tiptap JSON doc, extracts H2/H3 headings, and
34
+ * Server-only utility. Walks a Tiptap JSON doc, extracts H1-H4 headings, and
35
35
  * derives stable kebab-case ids (Korean-safe). The companion `attachHeadingIds`
36
- * mutates a shallow clone of the doc so renderers can emit `<h2 id="...">`
37
- * anchors that match what `<RootTaleTableOfContents>` links to.
36
+ * mutates a shallow clone of the doc so renderers can emit heading anchors
37
+ * that match what `<RootTaleTableOfContents>` links to.
38
38
  *
39
39
  * Internal ancestor: `roottale-internal/packages/cms-renderer/src/toc.ts`
40
40
  * (HTML-string based, vendored snapshot). Ported to Tiptap-JSON because the
41
41
  * platform renderer no longer round-trips through HTML before render.
42
42
  */
43
43
  interface TocEntry {
44
- level: 2 | 3;
44
+ level: 2 | 3 | 4;
45
45
  text: string;
46
46
  id: string;
47
47
  }
48
48
  declare function headingToId(text: string): string;
49
49
  declare function extractToc(doc: Record<string, unknown>): TocEntry[];
50
50
  /**
51
- * Returns a shallow-cloned Tiptap doc where each H2/H3 carries the matching
51
+ * Returns a shallow-cloned Tiptap doc where each H1-H4 carries the matching
52
52
  * `attrs.id` from `entries` (same order as `extractToc` returns). Existing
53
53
  * `id` attrs are overwritten so renderer + TOC always agree on anchors.
54
54
  */
@@ -232,7 +232,7 @@ type RootTaleBlogPostProps = {
232
232
  notFoundElement?: ReactElement;
233
233
  /**
234
234
  * When true, prepends an auto-derived `<RootTaleTableOfContents>` before
235
- * the article body (H2/H3 headings only). Default: false — keep markup
235
+ * the article body (H2-H4 headings only). Default: false — keep markup
236
236
  * stable for customers who already control their own layout.
237
237
  */
238
238
  showTableOfContents?: boolean;
package/dist/server.js CHANGED
@@ -16,14 +16,18 @@ function RootTalePostCard(props) {
16
16
  const showDate = display ? display.showDate : true;
17
17
  const showAuthor = display ? display.showAuthor : false;
18
18
  const authorName = post.authorName;
19
- const meta = [
20
- showDate ? formatPublishedDate(post.publishedAt) : null,
21
- showAuthor && authorName ? `\uC791\uC131\uC790 ${authorName}` : null
22
- ].filter(Boolean).join(" \xB7 ");
19
+ const publishedDate = showDate ? formatPublishedDate(post.publishedAt) : "";
23
20
  return /* @__PURE__ */ jsx("article", { "data-roottale-cms": "card", className: "rt-cms-card", children: /* @__PURE__ */ jsxs("a", { className: "rt-cms-card-link", href: href(post), children: [
24
- meta ? /* @__PURE__ */ jsx("p", { className: "rt-cms-meta", children: meta }) : null,
21
+ publishedDate || showAuthor && authorName ? /* @__PURE__ */ jsxs("p", { className: "rt-cms-meta", children: [
22
+ publishedDate ? /* @__PURE__ */ jsx("span", { className: "rt-cms-meta-item", "data-rt-cms-meta": "date", children: publishedDate }) : null,
23
+ showAuthor && authorName ? /* @__PURE__ */ jsxs("span", { className: "rt-cms-meta-item", "data-rt-cms-meta": "author", children: [
24
+ "\uC791\uC131\uC790 ",
25
+ authorName
26
+ ] }) : null
27
+ ] }) : null,
25
28
  /* @__PURE__ */ jsx("h2", { className: "rt-cms-title", children: post.title }),
26
- post.excerpt ? /* @__PURE__ */ jsx("p", { className: "rt-cms-excerpt", children: post.excerpt }) : null
29
+ post.excerpt ? /* @__PURE__ */ jsx("p", { className: "rt-cms-excerpt", children: post.excerpt }) : null,
30
+ /* @__PURE__ */ jsx("span", { className: "rt-cms-card-action", "aria-hidden": "true", children: "\uC77D\uAE30" })
27
31
  ] }) });
28
32
  }
29
33
  function defaultHref(post) {
@@ -44,8 +48,11 @@ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
44
48
  function RootTaleTableOfContents(props) {
45
49
  const { entries, title = "\uBAA9\uCC28" } = props;
46
50
  if (entries.length === 0) return null;
47
- return /* @__PURE__ */ jsxs2("nav", { "data-roottale-cms": "toc", className: "rt-cms-toc", "aria-label": title, children: [
48
- title ? /* @__PURE__ */ jsx2("p", { className: "rt-cms-toc-title", children: title }) : null,
51
+ return /* @__PURE__ */ jsx2("nav", { "data-roottale-cms": "toc", className: "rt-cms-toc", "aria-label": title, children: /* @__PURE__ */ jsxs2("details", { className: "rt-cms-toc-details", open: true, children: [
52
+ /* @__PURE__ */ jsxs2("summary", { className: "rt-cms-toc-summary", children: [
53
+ title ? /* @__PURE__ */ jsx2("span", { className: "rt-cms-toc-title", children: title }) : null,
54
+ /* @__PURE__ */ jsx2("span", { className: "rt-cms-toc-toggle", "aria-hidden": "true" })
55
+ ] }),
49
56
  /* @__PURE__ */ jsx2("ol", { className: "rt-cms-toc-list", children: entries.map((entry) => /* @__PURE__ */ jsx2(
50
57
  "li",
51
58
  {
@@ -54,10 +61,15 @@ function RootTaleTableOfContents(props) {
54
61
  },
55
62
  entry.id
56
63
  )) })
57
- ] });
64
+ ] }) });
58
65
  }
59
66
 
60
67
  // src/toc.ts
68
+ function tocLevel(rawLevel) {
69
+ if (rawLevel < 1 || rawLevel > 4) return null;
70
+ if (rawLevel < 3) return 2;
71
+ return rawLevel === 3 ? 3 : 4;
72
+ }
61
73
  function headingToId(text) {
62
74
  return text.trim().toLowerCase().replace(/[^\p{L}\p{N}\s-]/gu, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
63
75
  }
@@ -74,14 +86,15 @@ function extractToc(doc) {
74
86
  for (const node of content) {
75
87
  if (node?.type !== "heading") continue;
76
88
  const rawLevel = Number(node.attrs?.level ?? 2);
77
- if (rawLevel !== 2 && rawLevel !== 3) continue;
89
+ const level = tocLevel(rawLevel);
90
+ if (level === null) continue;
78
91
  const text = textOf(node).trim();
79
92
  if (!text) continue;
80
93
  const base = headingToId(text) || "heading";
81
94
  const count = idCounts.get(base) ?? 0;
82
95
  const id = count === 0 ? base : `${base}-${count}`;
83
96
  idCounts.set(base, count + 1);
84
- entries.push({ level: rawLevel, text, id });
97
+ entries.push({ level, text, id });
85
98
  }
86
99
  return entries;
87
100
  }
@@ -92,7 +105,7 @@ function attachHeadingIds(doc, entries) {
92
105
  const nextContent = content.map((node) => {
93
106
  if (node?.type !== "heading") return node;
94
107
  const rawLevel = Number(node.attrs?.level ?? 2);
95
- if (rawLevel !== 2 && rawLevel !== 3) return node;
108
+ if (rawLevel < 1 || rawLevel > 4) return node;
96
109
  if (!textOf(node).trim()) return node;
97
110
  const entry = entries[cursor++];
98
111
  if (!entry) return node;
@@ -314,12 +327,13 @@ function RootTaleLeadForm(props) {
314
327
  }
315
328
 
316
329
  // src/block-to-react.tsx
330
+ import { sanitizeHtml } from "@roottale/cms-core";
317
331
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
318
332
  function isString(value) {
319
333
  return typeof value === "string";
320
334
  }
321
335
  function htmlContent(html) {
322
- return { __html: html };
336
+ return { __html: sanitizeHtml(html) };
323
337
  }
324
338
  function renderInner(blocks) {
325
339
  return blocks.map((b, i) => renderBlock(b, i));
@@ -340,18 +354,15 @@ function renderBlock(block, key = 0) {
340
354
  }
341
355
  case "core/heading": {
342
356
  const levelRaw = block.attributes.level;
343
- const level = typeof levelRaw === "number" && levelRaw >= 1 && levelRaw <= 6 ? levelRaw : 2;
357
+ const level = typeof levelRaw === "number" && levelRaw >= 2 && levelRaw <= 4 ? levelRaw : 2;
344
358
  const text = isString(block.attributes.content) ? block.attributes.content : block.rawHtml ?? "";
345
359
  const props = {
346
360
  "data-block-id": dataBlockId,
347
361
  dangerouslySetInnerHTML: htmlContent(text)
348
362
  };
349
- if (level === 1) return /* @__PURE__ */ jsx5("h1", { ...props }, key);
350
363
  if (level === 2) return /* @__PURE__ */ jsx5("h2", { ...props }, key);
351
364
  if (level === 3) return /* @__PURE__ */ jsx5("h3", { ...props }, key);
352
- if (level === 4) return /* @__PURE__ */ jsx5("h4", { ...props }, key);
353
- if (level === 5) return /* @__PURE__ */ jsx5("h5", { ...props }, key);
354
- return /* @__PURE__ */ jsx5("h6", { ...props }, key);
365
+ return /* @__PURE__ */ jsx5("h4", { ...props }, key);
355
366
  }
356
367
  case "core/image": {
357
368
  const src = isString(block.attributes.url) ? block.attributes.url : "";
@@ -513,10 +524,11 @@ async function RootTaleBlogPost(props) {
513
524
  const toc = display.showTableOfContents ? extractToc(post.bodyJson) : [];
514
525
  const renderDoc = toc.length > 0 ? attachHeadingIds(post.bodyJson, toc) : post.bodyJson;
515
526
  const authorName = post.authorName;
516
- const meta = [
517
- display.showDate ? formatPublishedDate2(post.publishedAt) : null,
518
- display.showAuthor && authorName ? `\uC791\uC131\uC790 ${authorName}` : null
519
- ].filter(Boolean).join(" \xB7 ");
527
+ const publishedDate = display.showDate ? formatPublishedDate2(post.publishedAt) : "";
528
+ const showTopAuthor = display.showAuthor && Boolean(authorName);
529
+ const displayAuthorName = display.authorProfileName ?? authorName;
530
+ const authorBio = display.authorProfileBio ?? display.authorCardDescription;
531
+ const showAuthorCard = display.showAuthorCard && Boolean(displayAuthorName);
520
532
  return /* @__PURE__ */ jsxs6(
521
533
  "article",
522
534
  {
@@ -524,17 +536,48 @@ async function RootTaleBlogPost(props) {
524
536
  className: "rt-cms-article",
525
537
  style: themeStyle ?? void 0,
526
538
  children: [
527
- /* @__PURE__ */ jsxs6("header", { children: [
528
- meta ? /* @__PURE__ */ jsx6("p", { className: "rt-cms-meta", children: meta }) : null,
539
+ /* @__PURE__ */ jsxs6("header", { className: "rt-cms-post-header", children: [
540
+ publishedDate || showTopAuthor ? /* @__PURE__ */ jsxs6("p", { className: "rt-cms-meta", children: [
541
+ publishedDate ? /* @__PURE__ */ jsx6("span", { className: "rt-cms-meta-item", "data-rt-cms-meta": "date", children: publishedDate }) : null,
542
+ showTopAuthor ? /* @__PURE__ */ jsxs6("span", { className: "rt-cms-meta-item", "data-rt-cms-meta": "author", children: [
543
+ "\uC791\uC131\uC790 ",
544
+ authorName
545
+ ] }) : null
546
+ ] }) : null,
529
547
  /* @__PURE__ */ jsx6("h1", { className: "rt-cms-title", children: post.title }),
530
548
  post.excerpt ? /* @__PURE__ */ jsx6("p", { className: "rt-cms-excerpt", children: post.excerpt }) : null
531
549
  ] }),
532
550
  toc.length > 0 ? /* @__PURE__ */ jsx6(RootTaleTableOfContents, { entries: toc, title: display.tocTitle }) : null,
533
- /* @__PURE__ */ jsx6(RenderTiptap, { doc: renderDoc })
551
+ /* @__PURE__ */ jsx6(RenderTiptap, { doc: renderDoc }),
552
+ showAuthorCard && displayAuthorName ? /* @__PURE__ */ jsxs6("section", { className: "rt-cms-author-card", "aria-label": "\uC791\uC131\uC790", children: [
553
+ display.authorProfileImageUrl ? /* @__PURE__ */ jsx6(
554
+ "img",
555
+ {
556
+ className: "rt-cms-author-card__image",
557
+ src: display.authorProfileImageUrl,
558
+ alt: "",
559
+ loading: "lazy"
560
+ }
561
+ ) : /* @__PURE__ */ jsx6("div", { className: "rt-cms-author-card__avatar", "aria-hidden": "true", children: authorInitials(displayAuthorName) }),
562
+ /* @__PURE__ */ jsxs6("div", { className: "rt-cms-author-card__body", children: [
563
+ /* @__PURE__ */ jsx6("p", { className: "rt-cms-author-card__eyebrow", children: display.authorCardEyebrow }),
564
+ /* @__PURE__ */ jsx6("p", { className: "rt-cms-author-card__name", children: displayAuthorName }),
565
+ authorBio ? /* @__PURE__ */ jsx6("p", { className: "rt-cms-author-card__description", children: authorBio }) : null
566
+ ] })
567
+ ] }) : null
534
568
  ]
535
569
  }
536
570
  );
537
571
  }
572
+ function authorInitials(name) {
573
+ const trimmed = name.trim();
574
+ if (!trimmed) return "";
575
+ const parts = trimmed.split(/\s+/).filter(Boolean);
576
+ if (parts.length >= 2) {
577
+ return `${parts[0]?.[0] ?? ""}${parts[1]?.[0] ?? ""}`.toUpperCase();
578
+ }
579
+ return [...trimmed].slice(0, 2).join("").toUpperCase();
580
+ }
538
581
  async function resolveThemeStyle(input) {
539
582
  const { themeProp, apiKey, baseUrl } = input;
540
583
  if (themeProp === null) return null;
@@ -604,13 +647,14 @@ function renderNode(node, key) {
604
647
  case "paragraph":
605
648
  return /* @__PURE__ */ jsx6("p", { style: alignStyle(node), children }, key);
606
649
  case "heading": {
607
- const level = Math.min(Math.max(Number(node.attrs?.level ?? 2), 1), 3);
650
+ const rawLevel = Number(node.attrs?.level ?? 2);
651
+ const level = Math.min(Math.max(Number.isFinite(rawLevel) ? rawLevel : 2, 2), 4);
608
652
  const idAttr = node.attrs?.id;
609
653
  const id = typeof idAttr === "string" && idAttr.length > 0 ? idAttr : void 0;
610
654
  const style = alignStyle(node);
611
- if (level === 1) return /* @__PURE__ */ jsx6("h1", { id, style, children }, key);
612
655
  if (level === 2) return /* @__PURE__ */ jsx6("h2", { id, style, children }, key);
613
- return /* @__PURE__ */ jsx6("h3", { id, style, children }, key);
656
+ if (level === 3) return /* @__PURE__ */ jsx6("h3", { id, style, children }, key);
657
+ return /* @__PURE__ */ jsx6("h4", { id, style, children }, key);
614
658
  }
615
659
  case "bulletList":
616
660
  return /* @__PURE__ */ jsx6("ul", { children }, key);