@roottale/cms-renderer-next 0.2.0 → 0.2.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.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/server.tsx","../src/PostCard.tsx","../src/TableOfContents.tsx","../src/toc.ts","../src/FloatingCta.tsx","../src/LeadForm.tsx","../src/block-to-react.tsx"],"sourcesContent":["/**\n * `@roottale/cms-renderer-next/server` — RootTale CMS public-render React Server Components.\n *\n * Drop into any RSC-capable framework (Next.js App Router, Astro server islands,\n * React Router server). Single-line import, SSR-only, no `'use client'` boundary.\n *\n * ```tsx\n * import { RootTaleBlogList } from \"@roottale/cms-renderer-next/server\";\n *\n * export default function BlogPage() {\n * return <RootTaleBlogList apiKey={process.env.ROOTTALE_API_KEY!} />;\n * }\n * ```\n *\n * Customer site MUST keep `apiKey` server-side. Browser bundle ships zero\n * RootTale credentials (ADR-0023 §5.1 #15).\n *\n * Design tokens auto-apply via the CSS import below. Customers can override\n * any class with their own CSS or Tailwind utilities — scoped selectors use\n * `:where()` to keep specificity at zero (ADR-0023 §5.1 #10).\n */\n\nimport type { CSSProperties, ReactElement } from \"react\";\nimport {\n type CmsPostContent,\n type CmsPostType,\n fetchPost,\n fetchPosts,\n} from \"@roottale/cms-client/server\";\n// CSS는 customer 가 root layout 에서 명시적으로 import:\n// `import \"@roottale/cms-renderer-next/styles\";`\n// 본 파일에서 side-effect import 하지 않음 — tsup dts build 가 .css\n// side-effect import 처리 못 함 + customer 측 framework 마다 CSS 처리\n// 방식 다르므로 명시적 import 가 명확. README 참조.\n\nimport { RootTalePostCard } from \"./PostCard.js\";\nimport { RootTaleTableOfContents } from \"./TableOfContents.js\";\nimport { attachHeadingIds, extractToc } from \"./toc.js\";\n\nexport { RootTalePostCard, type RootTalePostCardProps } from \"./PostCard.js\";\nexport {\n RootTaleTableOfContents,\n type RootTaleTableOfContentsProps,\n} from \"./TableOfContents.js\";\nexport {\n RootTaleFloatingCta,\n type RootTaleFloatingCtaProps,\n type CtaButton,\n type CtaButtonType,\n type FloatingCtaPosition,\n} from \"./FloatingCta.js\";\nexport {\n RootTaleLeadForm,\n type RootTaleLeadFormProps,\n type LeadFormVertical,\n} from \"./LeadForm.js\";\nexport { renderBlock, renderBlocks } from \"./block-to-react.js\";\nexport {\n attachHeadingIds,\n extractToc,\n headingToId,\n type TocEntry,\n} from \"./toc.js\";\n\nexport type RootTaleBlogListProps = {\n apiKey: string;\n baseUrl?: string;\n limit?: number;\n /** Filter by post type (`post` or `page`). Default = `post` only (blog list 의도). */\n type?: CmsPostType;\n /** Build a customer-side URL for a single post. Defaults to `/blog/${slug}`. */\n postHref?: (post: CmsPostContent) => string;\n /** Rendered when the tenant has no published posts yet. */\n emptyMessage?: string;\n};\n\nexport async function RootTaleBlogList(\n props: RootTaleBlogListProps,\n): Promise<ReactElement> {\n const {\n apiKey,\n baseUrl,\n limit,\n type = \"post\",\n postHref = defaultPostHref,\n emptyMessage = \"아직 발행된 글이 없습니다.\",\n } = props;\n const page = await fetchPosts({ apiKey, baseUrl, limit, type });\n\n if (page.items.length === 0) {\n return (\n <div data-roottale-cms=\"list\">\n <p className=\"rt-cms-empty\">{emptyMessage}</p>\n </div>\n );\n }\n\n return (\n <div data-roottale-cms=\"list\">\n <ul className=\"rt-cms-list\">\n {page.items.map((post) => (\n <li key={post.id} className=\"rt-cms-list-item\">\n <RootTalePostCard post={post} href={postHref} />\n </li>\n ))}\n </ul>\n </div>\n );\n}\n\nexport type RootTaleBlogPostProps = {\n apiKey: string;\n slugOrId: string;\n baseUrl?: string;\n /** Rendered when the slug is not found or not published. */\n notFoundElement?: ReactElement;\n /**\n * When true, prepends an auto-derived `<RootTaleTableOfContents>` before\n * the article body (H2/H3 headings only). Default: false — keep markup\n * stable for customers who already control their own layout.\n */\n showTableOfContents?: boolean;\n /** Title shown above the TOC (only when `showTableOfContents`). */\n tableOfContentsTitle?: string;\n};\n\nexport async function RootTaleBlogPost(\n props: RootTaleBlogPostProps,\n): Promise<ReactElement> {\n const {\n apiKey,\n slugOrId,\n baseUrl,\n notFoundElement,\n showTableOfContents = false,\n tableOfContentsTitle,\n } = props;\n const post = await fetchPost({ apiKey, slugOrId, baseUrl });\n\n if (!post) {\n return (\n notFoundElement ?? (\n <div data-roottale-cms=\"post-missing\">\n <p className=\"rt-cms-empty\">글을 찾을 수 없습니다.</p>\n </div>\n )\n );\n }\n\n const toc = showTableOfContents ? extractToc(post.bodyJson) : [];\n const renderDoc =\n toc.length > 0 ? attachHeadingIds(post.bodyJson, toc) : post.bodyJson;\n\n return (\n <article data-roottale-cms=\"post\" className=\"rt-cms-article\">\n <header>\n <p className=\"rt-cms-meta\">{formatPublishedDate(post.publishedAt)}</p>\n <h1 className=\"rt-cms-title\">{post.title}</h1>\n {post.excerpt ? <p className=\"rt-cms-excerpt\">{post.excerpt}</p> : null}\n </header>\n {toc.length > 0 ? (\n <RootTaleTableOfContents entries={toc} title={tableOfContentsTitle} />\n ) : null}\n <RenderTiptap doc={renderDoc} />\n </article>\n );\n}\n\nfunction defaultPostHref(post: CmsPostContent): string {\n return `/blog/${post.slug}`;\n}\n\nfunction formatPublishedDate(iso: string): string {\n const d = new Date(iso);\n if (Number.isNaN(d.getTime())) return \"\";\n return new Intl.DateTimeFormat(\"ko-KR\", {\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n }).format(d);\n}\n\n/**\n * Minimal Tiptap doc renderer. Walks the Tiptap doc tree and emits semantic\n * HTML. This is intentionally tiny — richer extensions (image asset URL\n * resolution, TableOfContents, FloatingCta) belong in followup §5.7 #5.\n *\n * Browser-side sanitization is unnecessary here because output is rendered\n * server-side from data the customer-trusted API returned. If the customer\n * site federates with untrusted authors later, layer a sanitizer in front\n * of this component.\n */\nfunction RenderTiptap({ doc }: { doc: Record<string, unknown> }): ReactElement {\n const rawContent = (doc as TiptapDoc).content;\n const content: TiptapNode[] = Array.isArray(rawContent) ? rawContent : [];\n return <>{content.map((node, i) => renderNode(node, i))}</>;\n}\n\ntype TiptapDoc = { type?: string; content?: TiptapNode[] };\n\ntype TiptapMark = { type?: string; attrs?: Record<string, unknown> };\n\ntype TiptapNode = {\n type?: string;\n text?: string;\n attrs?: Record<string, unknown>;\n content?: TiptapNode[];\n marks?: TiptapMark[];\n};\n\n/**\n * Defense-in-depth: even though admin LinkButton.normalizeUrl filters input,\n * legacy imports / direct DB / external tenants can still emit unsafe href.\n * Allow only http(s)/mailto/tel/root-relative/fragment/query — falls back to \"#\".\n */\nfunction isSafeHref(value: unknown): value is string {\n if (typeof value !== \"string\") return false;\n const v = value.trim();\n if (v.length === 0) return false;\n if (v.startsWith(\"/\") || v.startsWith(\"#\") || v.startsWith(\"?\")) return true;\n return /^(https?:|mailto:|tel:)/i.test(v);\n}\n\nfunction alignStyle(node: TiptapNode): CSSProperties | undefined {\n const align = node.attrs?.textAlign;\n if (typeof align !== \"string\" || align === \"\" || align === \"left\") return undefined;\n if (align === \"center\" || align === \"right\" || align === \"justify\") {\n return { textAlign: align };\n }\n return undefined;\n}\n\nfunction renderNode(node: TiptapNode, key: number): ReactElement | string | null {\n if (!node || typeof node !== \"object\") return null;\n const children = Array.isArray(node.content)\n ? node.content.map((child, i) => renderNode(child, i))\n : undefined;\n\n switch (node.type) {\n case \"paragraph\":\n return <p key={key} style={alignStyle(node)}>{children}</p>;\n case \"heading\": {\n const level = Math.min(Math.max(Number(node.attrs?.level ?? 2), 1), 3);\n const idAttr = node.attrs?.id;\n const id = typeof idAttr === \"string\" && idAttr.length > 0 ? idAttr : undefined;\n const style = alignStyle(node);\n if (level === 1) return <h1 key={key} id={id} style={style}>{children}</h1>;\n if (level === 2) return <h2 key={key} id={id} style={style}>{children}</h2>;\n return <h3 key={key} id={id} style={style}>{children}</h3>;\n }\n case \"bulletList\":\n return <ul key={key}>{children}</ul>;\n case \"orderedList\":\n return <ol key={key}>{children}</ol>;\n case \"listItem\":\n return <li key={key}>{children}</li>;\n case \"blockquote\":\n return <blockquote key={key}>{children}</blockquote>;\n case \"codeBlock\":\n return (\n <pre key={key}>\n <code>{children}</code>\n </pre>\n );\n case \"horizontalRule\":\n return <hr key={key} />;\n case \"hardBreak\":\n return <br key={key} />;\n case \"image\": {\n const src = node.attrs?.src;\n if (typeof src !== \"string\" || src.length === 0) return null;\n const alt = typeof node.attrs?.alt === \"string\" ? node.attrs.alt : \"\";\n const title = typeof node.attrs?.title === \"string\" ? node.attrs.title : undefined;\n // eslint-disable-next-line @next/next/no-img-element\n return <img key={key} src={src} alt={alt} title={title} loading=\"lazy\" />;\n }\n case \"columns\":\n return <div key={key} className=\"rt-columns\">{children}</div>;\n case \"column\":\n return <div key={key} className=\"rt-column\">{children}</div>;\n case \"text\":\n return renderText(node, key);\n default:\n return children ? <span key={key}>{children}</span> : null;\n }\n}\n\nfunction renderText(node: TiptapNode, key: number): ReactElement | string {\n const text = node.text ?? \"\";\n const marks = node.marks ?? [];\n if (marks.length === 0) return text;\n let element: ReactElement | string = text;\n for (const mark of marks) {\n element = applyMark(mark, element);\n }\n return <span key={key}>{element}</span>;\n}\n\nfunction applyMark(mark: TiptapMark, child: ReactElement | string): ReactElement {\n switch (mark.type) {\n case \"bold\":\n return <strong>{child}</strong>;\n case \"italic\":\n return <em>{child}</em>;\n case \"underline\":\n return <u>{child}</u>;\n case \"strike\":\n return <s>{child}</s>;\n case \"code\":\n return <code>{child}</code>;\n case \"highlight\": {\n const color = mark.attrs?.color;\n const style =\n typeof color === \"string\" && color.length > 0\n ? { background: color }\n : undefined;\n return <mark style={style}>{child}</mark>;\n }\n case \"textStyle\": {\n const color = mark.attrs?.color;\n const style =\n typeof color === \"string\" && color.length > 0 ? { color } : undefined;\n if (!style) return <>{child}</>;\n return <span style={style}>{child}</span>;\n }\n case \"link\": {\n const raw = mark.attrs?.href;\n const href = isSafeHref(raw) ? raw : \"#\";\n return (\n <a href={href} rel=\"noopener noreferrer\" target=\"_blank\">\n {child}\n </a>\n );\n }\n default:\n return <span>{child}</span>;\n }\n}\n","/**\n * `<RootTalePostCard>` — server-only summary card for a single CmsPostContent.\n *\n * Building block for `<RootTaleBlogList>` (and customer-side custom grids).\n * Pure SSR — anchor + title + meta + excerpt. Featured image is left out for\n * now: `CmsPostContent` only carries `featuredMediaId`, not a public URL.\n * Media URL resolution belongs in a separate slice once `cms-client` exposes\n * it (ADR-0034 §4 media followup — CF Images variants).\n */\n\nimport type { ReactElement } from \"react\";\nimport type { CmsPostContent } from \"@roottale/cms-client/server\";\n\nexport interface RootTalePostCardProps {\n post: CmsPostContent;\n /** Customer-side URL builder. Defaults to `/blog/${slug}`. */\n href?: (post: CmsPostContent) => string;\n}\n\nexport function RootTalePostCard(props: RootTalePostCardProps): ReactElement {\n const { post, href = defaultHref } = props;\n return (\n <article data-roottale-cms=\"card\" className=\"rt-cms-card\">\n <a className=\"rt-cms-card-link\" href={href(post)}>\n <p className=\"rt-cms-meta\">{formatPublishedDate(post.publishedAt)}</p>\n <h2 className=\"rt-cms-title\">{post.title}</h2>\n {post.excerpt ? <p className=\"rt-cms-excerpt\">{post.excerpt}</p> : null}\n </a>\n </article>\n );\n}\n\nfunction defaultHref(post: CmsPostContent): string {\n return `/blog/${post.slug}`;\n}\n\nfunction formatPublishedDate(iso: string): string {\n const d = new Date(iso);\n if (Number.isNaN(d.getTime())) return \"\";\n return new Intl.DateTimeFormat(\"ko-KR\", {\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n }).format(d);\n}\n","/**\n * `<RootTaleTableOfContents>` — server-only anchor list for blog posts.\n *\n * Pure SSR. Renders `<nav>` + ordered anchors that link to `#<id>` slots\n * emitted by `attachHeadingIds`. No scroll-spy here — that requires an\n * `IntersectionObserver` which only exists in the browser; a client-island\n * variant can layer on top later without changing the data contract.\n *\n * Internal ancestor: `roottale-internal/packages/cms-renderer/src/TableOfContents.tsx`\n * (had `'use client'` + IntersectionObserver). Stripped to server-safe form per\n * the platform renderer contract (no `'use client'` boundary — ADR-0023 §5.3).\n */\n\nimport type { ReactElement } from \"react\";\nimport type { TocEntry } from \"./toc.js\";\n\nexport interface RootTaleTableOfContentsProps {\n entries: TocEntry[];\n title?: string;\n}\n\nexport function RootTaleTableOfContents(\n props: RootTaleTableOfContentsProps,\n): ReactElement | null {\n const { entries, title = \"목차\" } = props;\n if (entries.length === 0) return null;\n return (\n <nav data-roottale-cms=\"toc\" className=\"rt-cms-toc\" aria-label={title}>\n {title ? <p className=\"rt-cms-toc-title\">{title}</p> : null}\n <ol className=\"rt-cms-toc-list\">\n {entries.map((entry) => (\n <li\n key={entry.id}\n className={`rt-cms-toc-item rt-cms-toc-level-${entry.level}`}\n >\n <a href={`#${entry.id}`}>{entry.text}</a>\n </li>\n ))}\n </ol>\n </nav>\n );\n}\n","/**\n * `@roottale/cms-renderer/toc` — Tiptap doc → TOC entries + heading id injection.\n *\n * Server-only utility. Walks a Tiptap JSON doc, extracts H2/H3 headings, and\n * derives stable kebab-case ids (Korean-safe). The companion `attachHeadingIds`\n * mutates a shallow clone of the doc so renderers can emit `<h2 id=\"...\">`\n * anchors that match what `<RootTaleTableOfContents>` links to.\n *\n * Internal ancestor: `roottale-internal/packages/cms-renderer/src/toc.ts`\n * (HTML-string based, vendored snapshot). Ported to Tiptap-JSON because the\n * platform renderer no longer round-trips through HTML before render.\n */\n\nexport interface TocEntry {\n level: 2 | 3;\n text: string;\n id: string;\n}\n\ntype TiptapNode = {\n type?: string;\n text?: string;\n attrs?: Record<string, unknown>;\n content?: TiptapNode[];\n marks?: { type?: string }[];\n};\n\ntype TiptapDoc = { type?: string; content?: TiptapNode[] };\n\nexport function headingToId(text: string): string {\n return text\n .trim()\n .toLowerCase()\n .replace(/[^\\p{L}\\p{N}\\s-]/gu, \"\")\n .replace(/\\s+/g, \"-\")\n .replace(/-+/g, \"-\")\n .replace(/^-|-$/g, \"\");\n}\n\nfunction textOf(node: TiptapNode): string {\n if (typeof node.text === \"string\") return node.text;\n if (!Array.isArray(node.content)) return \"\";\n return node.content.map(textOf).join(\"\");\n}\n\nexport function extractToc(doc: Record<string, unknown>): TocEntry[] {\n const root = doc as TiptapDoc;\n const content = Array.isArray(root.content) ? root.content : [];\n const entries: TocEntry[] = [];\n const idCounts = new Map<string, number>();\n\n for (const node of content) {\n if (node?.type !== \"heading\") continue;\n const rawLevel = Number(node.attrs?.level ?? 2);\n if (rawLevel !== 2 && rawLevel !== 3) continue;\n const text = textOf(node).trim();\n if (!text) continue;\n\n const base = headingToId(text) || \"heading\";\n const count = idCounts.get(base) ?? 0;\n const id = count === 0 ? base : `${base}-${count}`;\n idCounts.set(base, count + 1);\n\n entries.push({ level: rawLevel, text, id });\n }\n\n return entries;\n}\n\n/**\n * Returns a shallow-cloned Tiptap doc where each H2/H3 carries the matching\n * `attrs.id` from `entries` (same order as `extractToc` returns). Existing\n * `id` attrs are overwritten so renderer + TOC always agree on anchors.\n */\nexport function attachHeadingIds(\n doc: Record<string, unknown>,\n entries: TocEntry[],\n): Record<string, unknown> {\n const root = doc as TiptapDoc;\n const content = Array.isArray(root.content) ? root.content : [];\n let cursor = 0;\n const nextContent = content.map((node) => {\n if (node?.type !== \"heading\") return node;\n const rawLevel = Number(node.attrs?.level ?? 2);\n if (rawLevel !== 2 && rawLevel !== 3) return node;\n if (!textOf(node).trim()) return node;\n const entry = entries[cursor++];\n if (!entry) return node;\n return {\n ...node,\n attrs: { ...(node.attrs ?? {}), id: entry.id },\n };\n });\n return { ...(root as Record<string, unknown>), content: nextContent };\n}\n","/**\n * `<RootTaleFloatingCta>` — fixed-position call-to-action stack.\n *\n * Server-only (anchors only, no state). External targets (`kakao` / `custom`)\n * open in a new tab with `rel=\"noopener noreferrer\"`; tel/contact stay in the\n * same tab so iOS/Android can trigger the dialer / mailto handler.\n *\n * Internal ancestor: `roottale-internal/packages/cms-renderer/src/FloatingCta.tsx`.\n */\n\nimport type { ReactElement } from \"react\";\n\nexport type CtaButtonType = \"phone\" | \"contact\" | \"kakao\" | \"custom\";\n\nexport interface CtaButton {\n type: CtaButtonType;\n label: string;\n href: string;\n /** Optional override icon (emoji or short text). */\n icon?: string;\n}\n\nexport type FloatingCtaPosition =\n | \"bottom-right\"\n | \"bottom-left\"\n | \"bottom-center\";\n\nexport interface RootTaleFloatingCtaProps {\n buttons: CtaButton[];\n position?: FloatingCtaPosition;\n}\n\nexport function RootTaleFloatingCta(\n props: RootTaleFloatingCtaProps,\n): ReactElement | null {\n const { buttons, position = \"bottom-right\" } = props;\n if (!buttons || buttons.length === 0) return null;\n return (\n <div\n data-roottale-cms=\"floating-cta\"\n className={`rt-cms-floating-cta rt-cms-floating-cta--${position}`}\n >\n {buttons.map((btn, idx) => {\n const isExternal = btn.type === \"kakao\" || btn.type === \"custom\";\n return (\n <a\n key={`${btn.type}-${idx}`}\n href={btn.href}\n className={`rt-cms-floating-cta__btn rt-cms-floating-cta__btn--${btn.type}`}\n target={isExternal ? \"_blank\" : undefined}\n rel={isExternal ? \"noopener noreferrer\" : undefined}\n >\n <span aria-hidden=\"true\" className=\"rt-cms-floating-cta__icon\">\n {btn.icon ?? defaultIcon(btn.type)}\n </span>\n <span className=\"rt-cms-floating-cta__label\">{btn.label}</span>\n </a>\n );\n })}\n </div>\n );\n}\n\nfunction defaultIcon(type: CtaButtonType): string {\n switch (type) {\n case \"phone\":\n return \"☎\";\n case \"contact\":\n return \"✉\";\n case \"kakao\":\n return \"💬\";\n case \"custom\":\n return \"→\";\n }\n}\n","/**\n * `@roottale/cms-renderer-next` — RootTaleLeadForm RSC.\n *\n * 외부 고객 사이트(roottale.com, 고객사 외주)의 진단 신청 폼. HTML form POST →\n * admin-tenant `/api/lead-intake`. cross-origin POST 는 CORS 불필요 (HTML form\n * submission). PII 는 server 측에서 암호화 후 `inquiries` insert, 302 redirect.\n *\n * vertical prop:\n * - 미지정 = \"분야 선택\" select 노출 (consulting/medical/tax/legal)\n * - 지정 = hidden input + 표시 라벨 + vertical 별 추가 필드\n * (medical = 국외이전 동의 — ADR-0018)\n *\n * redirectPath prop:\n * - 외부 사이트가 폼 제출 후 돌아갈 path (admin 이 allowlist 검증, ?ok=1 / ?err=*)\n *\n * 0 JS RSC. customer-facing className 전체 `.rt-cms-*` prefix, scoped via\n * `[data-roottale-cms]` (cms-public.css). customer override 자유.\n */\n\nimport type { ReactElement } from \"react\";\n\nexport type LeadFormVertical = \"consulting\" | \"medical\" | \"tax\" | \"legal\";\n\nexport interface RootTaleLeadFormProps {\n /** admin-tenant lead-intake endpoint. 예: `https://mysite.roottale.com/api/lead-intake` */\n action: string;\n /**\n * vertical 고정. 미지정 시 select 노출.\n * `medical` 일 때 국외이전 동의 체크박스 추가 (ADR-0018).\n */\n vertical?: LeadFormVertical;\n /**\n * 폼 제출 후 admin이 돌려보낼 절대 URL.\n * admin 측 `LEAD_INTAKE_ALLOWED_ORIGINS` allowlist 검증. fail = fallback.\n * 미지정 시 admin env의 LEAD_INTAKE_REDIRECT_BASE 사용.\n */\n redirectUrl?: string;\n heading?: string;\n description?: string;\n submitLabel?: string;\n /** 추가 클래스 (customer 디자인 hook). */\n className?: string;\n}\n\nconst VERTICAL_LABEL: Record<LeadFormVertical, string> = {\n consulting: \"컨설팅\",\n medical: \"의료\",\n tax: \"세무\",\n legal: \"법률\",\n};\n\nconst VERTICAL_OPTIONS: { value: LeadFormVertical; label: string }[] = [\n { value: \"consulting\", label: \"컨설팅 / 일반\" },\n { value: \"medical\", label: \"의료 (병의원·치과·한의원)\" },\n { value: \"tax\", label: \"세무 (세무사·회계사)\" },\n { value: \"legal\", label: \"법률 (법무법인·변호사)\" },\n];\n\nexport function RootTaleLeadForm(\n props: RootTaleLeadFormProps,\n): ReactElement {\n const {\n action,\n vertical,\n redirectUrl,\n heading,\n description,\n submitLabel = \"진단 신청\",\n className,\n } = props;\n\n const formClass = [\"rt-cms-lead-form\", className].filter(Boolean).join(\" \");\n\n return (\n <form\n method=\"post\"\n action={action}\n data-roottale-cms=\"lead-form\"\n className={formClass}\n >\n {heading ? (\n <h2 className=\"rt-cms-lead-heading\">{heading}</h2>\n ) : null}\n {description ? (\n <p className=\"rt-cms-lead-description\">{description}</p>\n ) : null}\n\n {redirectUrl ? (\n <input type=\"hidden\" name=\"_redirect_url\" value={redirectUrl} />\n ) : null}\n\n {vertical ? (\n <>\n <input type=\"hidden\" name=\"vertical\" value={vertical} />\n <p className=\"rt-cms-lead-vertical-label\">\n 분야: <strong>{VERTICAL_LABEL[vertical]}</strong>\n </p>\n </>\n ) : (\n <label className=\"rt-cms-field\">\n <span className=\"rt-cms-field-label\">\n 분야 <span aria-hidden=\"true\">*</span>\n </span>\n <select\n name=\"vertical\"\n required\n className=\"rt-cms-field-input rt-cms-field-select\"\n defaultValue=\"\"\n >\n <option value=\"\" disabled>\n 선택하세요\n </option>\n {VERTICAL_OPTIONS.map((opt) => (\n <option key={opt.value} value={opt.value}>\n {opt.label}\n </option>\n ))}\n </select>\n </label>\n )}\n\n <label className=\"rt-cms-field\">\n <span className=\"rt-cms-field-label\">\n 이름 <span aria-hidden=\"true\">*</span>\n </span>\n <input\n type=\"text\"\n name=\"contact_name\"\n required\n autoComplete=\"name\"\n className=\"rt-cms-field-input\"\n />\n </label>\n\n <label className=\"rt-cms-field\">\n <span className=\"rt-cms-field-label\">\n 사업체명 <span aria-hidden=\"true\">*</span>\n </span>\n <input\n type=\"text\"\n name=\"business_name\"\n required\n autoComplete=\"organization\"\n className=\"rt-cms-field-input\"\n />\n </label>\n\n <label className=\"rt-cms-field\">\n <span className=\"rt-cms-field-label\">\n 이메일 <span aria-hidden=\"true\">*</span>\n </span>\n <input\n type=\"email\"\n name=\"email\"\n required\n autoComplete=\"email\"\n className=\"rt-cms-field-input\"\n />\n </label>\n\n <label className=\"rt-cms-field\">\n <span className=\"rt-cms-field-label\">\n 전화번호 <span aria-hidden=\"true\">*</span>\n </span>\n <input\n type=\"tel\"\n name=\"phone\"\n required\n autoComplete=\"tel\"\n placeholder=\"010-0000-0000\"\n className=\"rt-cms-field-input\"\n />\n </label>\n\n <label className=\"rt-cms-field\">\n <span className=\"rt-cms-field-label\">현재 사이트 URL (선택)</span>\n <input\n type=\"url\"\n name=\"current_site_url\"\n placeholder=\"https://\"\n className=\"rt-cms-field-input\"\n />\n <span className=\"rt-cms-field-hint\">없으면 비워두세요.</span>\n </label>\n\n <label className=\"rt-cms-lead-consent\">\n <input type=\"checkbox\" name=\"privacy_consent\" required />\n <span>\n <strong>(필수)</strong> 개인정보 수집·이용에 동의합니다. (수집 항목:\n 이름·연락처·이메일·사업체명. 보관 기간: 문의 종료 후 3년)\n </span>\n </label>\n\n {vertical === \"medical\" ? (\n <label className=\"rt-cms-lead-consent\">\n <input\n type=\"checkbox\"\n name=\"overseas_transfer_consent\"\n required\n />\n <span>\n <strong>(필수, 의료)</strong> 의료 PII 의 국외이전(보관·처리)에\n 동의합니다. (의료법 §21·개인정보보호법 §28 — ADR-0018)\n </span>\n </label>\n ) : null}\n\n <div className=\"rt-cms-lead-actions\">\n <button type=\"submit\" className=\"rt-cms-lead-submit\">\n {submitLabel}\n </button>\n </div>\n </form>\n );\n}\n","// ADR-0034 §1.5 amended — Block JSON → React elements\n//\n// Next/RSC public renderer 가 본 함수로 block tree 를 server-side React element\n// 로 변환. cms-renderer-astro 의 `block-to-html.ts` 와 1:1 대응 (같은 7 + α\n// block 핸들러). `BlockDefinition.nextRender` 정합.\n//\n// 알려지지 않은 block 은 rawHtml 출력 (codex v3 verdict #4, opaque atom\n// round-trip 보존).\n\nimport type { ReactElement, ReactNode } from \"react\";\n\nimport type { Block } from \"@roottale/cms-core\";\n\nfunction isString(value: unknown): value is string {\n return typeof value === \"string\";\n}\n\nfunction htmlContent(html: string): { __html: string } {\n // React 의 dangerouslySetInnerHTML 표면. Block 의 rawHtml/content 는 이미\n // 서버측에서 받은 후 별도 sanitize 가 필요할 수 있음 (cms-core sanitize).\n return { __html: html };\n}\n\nfunction renderInner(blocks: readonly Block[]): ReactElement[] {\n return blocks.map((b, i) => renderBlock(b, i));\n}\n\n/**\n * 단일 block 을 ReactElement 로 변환. `block-to-html.ts` 와 동일 분기.\n */\nexport function renderBlock(block: Block, key: number = 0): ReactElement {\n const dataBlockId = block._id;\n\n switch (block.name) {\n case \"core/paragraph\": {\n const text = isString(block.attributes.content)\n ? block.attributes.content\n : block.rawHtml ?? \"\";\n return (\n <p\n key={key}\n data-block-id={dataBlockId}\n dangerouslySetInnerHTML={htmlContent(text)}\n />\n );\n }\n case \"core/heading\": {\n const levelRaw = block.attributes.level;\n const level =\n typeof levelRaw === \"number\" && levelRaw >= 1 && levelRaw <= 6\n ? levelRaw\n : 2;\n const text = isString(block.attributes.content)\n ? block.attributes.content\n : block.rawHtml ?? \"\";\n const props = {\n \"data-block-id\": dataBlockId,\n dangerouslySetInnerHTML: htmlContent(text),\n } as const;\n if (level === 1) return <h1 key={key} {...props} />;\n if (level === 2) return <h2 key={key} {...props} />;\n if (level === 3) return <h3 key={key} {...props} />;\n if (level === 4) return <h4 key={key} {...props} />;\n if (level === 5) return <h5 key={key} {...props} />;\n return <h6 key={key} {...props} />;\n }\n case \"core/image\": {\n const src = isString(block.attributes.url) ? block.attributes.url : \"\";\n const alt = isString(block.attributes.alt) ? block.attributes.alt : \"\";\n const caption = isString(block.attributes.caption)\n ? block.attributes.caption\n : null;\n return (\n <figure key={key} data-block-id={dataBlockId}>\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img src={src} alt={alt} loading=\"lazy\" />\n {caption && <figcaption>{caption}</figcaption>}\n </figure>\n );\n }\n case \"core/list\": {\n const ordered = block.attributes.ordered === true;\n const items = (block.innerBlocks ?? []).map(\n (item, i): ReactNode =>\n item.rawHtml ? (\n <li\n key={i}\n dangerouslySetInnerHTML={htmlContent(item.rawHtml)}\n />\n ) : (\n <li key={i}>{renderInner(item.innerBlocks ?? [])}</li>\n ),\n );\n return ordered ? (\n <ol key={key} data-block-id={dataBlockId}>\n {items}\n </ol>\n ) : (\n <ul key={key} data-block-id={dataBlockId}>\n {items}\n </ul>\n );\n }\n case \"core/quote\": {\n const inner = renderInner(block.innerBlocks ?? []);\n const citation = isString(block.attributes.citation)\n ? block.attributes.citation\n : null;\n return (\n <blockquote key={key} data-block-id={dataBlockId}>\n {inner}\n {citation && <cite>{citation}</cite>}\n </blockquote>\n );\n }\n case \"core/code\": {\n const code = isString(block.attributes.content)\n ? block.attributes.content\n : block.rawHtml ?? \"\";\n const lang = isString(block.attributes.language)\n ? block.attributes.language\n : undefined;\n return (\n <pre\n key={key}\n data-block-id={dataBlockId}\n data-language={lang}\n >\n <code>{code}</code>\n </pre>\n );\n }\n case \"core/columns\": {\n return (\n <div\n key={key}\n className=\"rt-columns\"\n data-block-id={dataBlockId}\n >\n {renderInner(block.innerBlocks ?? [])}\n </div>\n );\n }\n case \"core/separator\":\n return <hr key={key} data-block-id={dataBlockId} />;\n case \"core/spacer\": {\n const heightRaw = block.attributes.height;\n const height = isString(heightRaw) ? heightRaw : \"32px\";\n return (\n <div\n key={key}\n className=\"rt-spacer\"\n data-block-id={dataBlockId}\n style={{ height }}\n />\n );\n }\n case \"core/group\": {\n return (\n <div\n key={key}\n className=\"rt-group\"\n data-block-id={dataBlockId}\n >\n {renderInner(block.innerBlocks ?? [])}\n </div>\n );\n }\n default: {\n const rawHtml = block.rawHtml;\n if (rawHtml) {\n return (\n <div\n key={key}\n className=\"rt-unknown-block\"\n data-block-id={dataBlockId}\n data-block-name={block.name}\n dangerouslySetInnerHTML={htmlContent(rawHtml)}\n />\n );\n }\n return (\n <div\n key={key}\n className=\"rt-unknown-block\"\n data-block-id={dataBlockId}\n data-block-name={block.name}\n >\n {renderInner(block.innerBlocks ?? [])}\n </div>\n );\n }\n }\n}\n\n/** Block tree 전체를 React element 배열로 직렬화. */\nexport function renderBlocks(blocks: readonly Block[]): ReactElement[] {\n return blocks.map((b, i) => renderBlock(b, i));\n}\n"],"mappings":"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","names":["jsx","jsxs","jsx","jsxs","jsx","jsxs","jsx","jsxs","Fragment","jsx","jsxs","formatPublishedDate"]}
package/package.json CHANGED
@@ -1,23 +1,32 @@
1
1
  {
2
2
  "name": "@roottale/cms-renderer-next",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "RootTale CMS public-render React/Next.js Server Components. SSR-only RSC components (RootTaleBlogList / RootTaleBlogPost / RootTaleLeadForm) for external customer sites. Companion of @roottale/cms-renderer-astro (ADR-0034 §1.5 amended).",
6
- "main": "./src/index.ts",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
7
8
  "exports": {
8
- ".": "./src/index.ts",
9
- "./server": "./src/server.tsx",
10
- "./styles": "./src/styles/cms-public.css",
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./server": {
15
+ "types": "./dist/server.d.ts",
16
+ "import": "./dist/server.js",
17
+ "default": "./dist/server.js"
18
+ },
19
+ "./styles": "./dist/cms-public.css",
11
20
  "./package.json": "./package.json"
12
21
  },
13
22
  "files": [
14
- "src/",
23
+ "dist/",
15
24
  "README.md",
16
25
  "CHANGELOG.md"
17
26
  ],
18
27
  "dependencies": {
19
- "@roottale/cms-client": "^0.1.0",
20
- "@roottale/cms-core": "^0.2.0"
28
+ "@roottale/cms-client": "^0.1.1",
29
+ "@roottale/cms-core": "^0.2.1"
21
30
  },
22
31
  "peerDependencies": {
23
32
  "react": "^19.0.0"
@@ -49,7 +58,7 @@
49
58
  "directory": "packages/cms-renderer-next"
50
59
  },
51
60
  "scripts": {
52
- "build": "tsc --noEmit",
61
+ "build": "tsup",
53
62
  "type-check": "tsc --noEmit",
54
63
  "test": "vitest run --passWithNoTests"
55
64
  }
@@ -1,75 +0,0 @@
1
- /**
2
- * `<RootTaleFloatingCta>` — fixed-position call-to-action stack.
3
- *
4
- * Server-only (anchors only, no state). External targets (`kakao` / `custom`)
5
- * open in a new tab with `rel="noopener noreferrer"`; tel/contact stay in the
6
- * same tab so iOS/Android can trigger the dialer / mailto handler.
7
- *
8
- * Internal ancestor: `roottale-internal/packages/cms-renderer/src/FloatingCta.tsx`.
9
- */
10
-
11
- import type { ReactElement } from "react";
12
-
13
- export type CtaButtonType = "phone" | "contact" | "kakao" | "custom";
14
-
15
- export interface CtaButton {
16
- type: CtaButtonType;
17
- label: string;
18
- href: string;
19
- /** Optional override icon (emoji or short text). */
20
- icon?: string;
21
- }
22
-
23
- export type FloatingCtaPosition =
24
- | "bottom-right"
25
- | "bottom-left"
26
- | "bottom-center";
27
-
28
- export interface RootTaleFloatingCtaProps {
29
- buttons: CtaButton[];
30
- position?: FloatingCtaPosition;
31
- }
32
-
33
- export function RootTaleFloatingCta(
34
- props: RootTaleFloatingCtaProps,
35
- ): ReactElement | null {
36
- const { buttons, position = "bottom-right" } = props;
37
- if (!buttons || buttons.length === 0) return null;
38
- return (
39
- <div
40
- data-roottale-cms="floating-cta"
41
- className={`rt-cms-floating-cta rt-cms-floating-cta--${position}`}
42
- >
43
- {buttons.map((btn, idx) => {
44
- const isExternal = btn.type === "kakao" || btn.type === "custom";
45
- return (
46
- <a
47
- key={`${btn.type}-${idx}`}
48
- href={btn.href}
49
- className={`rt-cms-floating-cta__btn rt-cms-floating-cta__btn--${btn.type}`}
50
- target={isExternal ? "_blank" : undefined}
51
- rel={isExternal ? "noopener noreferrer" : undefined}
52
- >
53
- <span aria-hidden="true" className="rt-cms-floating-cta__icon">
54
- {btn.icon ?? defaultIcon(btn.type)}
55
- </span>
56
- <span className="rt-cms-floating-cta__label">{btn.label}</span>
57
- </a>
58
- );
59
- })}
60
- </div>
61
- );
62
- }
63
-
64
- function defaultIcon(type: CtaButtonType): string {
65
- switch (type) {
66
- case "phone":
67
- return "☎";
68
- case "contact":
69
- return "✉";
70
- case "kakao":
71
- return "💬";
72
- case "custom":
73
- return "→";
74
- }
75
- }
package/src/LeadForm.tsx DELETED
@@ -1,215 +0,0 @@
1
- /**
2
- * `@roottale/cms-renderer-next` — RootTaleLeadForm RSC.
3
- *
4
- * 외부 고객 사이트(roottale.com, 고객사 외주)의 진단 신청 폼. HTML form POST →
5
- * admin-tenant `/api/lead-intake`. cross-origin POST 는 CORS 불필요 (HTML form
6
- * submission). PII 는 server 측에서 암호화 후 `inquiries` insert, 302 redirect.
7
- *
8
- * vertical prop:
9
- * - 미지정 = "분야 선택" select 노출 (consulting/medical/tax/legal)
10
- * - 지정 = hidden input + 표시 라벨 + vertical 별 추가 필드
11
- * (medical = 국외이전 동의 — ADR-0018)
12
- *
13
- * redirectPath prop:
14
- * - 외부 사이트가 폼 제출 후 돌아갈 path (admin 이 allowlist 검증, ?ok=1 / ?err=*)
15
- *
16
- * 0 JS RSC. customer-facing className 전체 `.rt-cms-*` prefix, scoped via
17
- * `[data-roottale-cms]` (cms-public.css). customer override 자유.
18
- */
19
-
20
- import type { ReactElement } from "react";
21
-
22
- export type LeadFormVertical = "consulting" | "medical" | "tax" | "legal";
23
-
24
- export interface RootTaleLeadFormProps {
25
- /** admin-tenant lead-intake endpoint. 예: `https://mysite.roottale.com/api/lead-intake` */
26
- action: string;
27
- /**
28
- * vertical 고정. 미지정 시 select 노출.
29
- * `medical` 일 때 국외이전 동의 체크박스 추가 (ADR-0018).
30
- */
31
- vertical?: LeadFormVertical;
32
- /**
33
- * 폼 제출 후 admin이 돌려보낼 절대 URL.
34
- * admin 측 `LEAD_INTAKE_ALLOWED_ORIGINS` allowlist 검증. fail = fallback.
35
- * 미지정 시 admin env의 LEAD_INTAKE_REDIRECT_BASE 사용.
36
- */
37
- redirectUrl?: string;
38
- heading?: string;
39
- description?: string;
40
- submitLabel?: string;
41
- /** 추가 클래스 (customer 디자인 hook). */
42
- className?: string;
43
- }
44
-
45
- const VERTICAL_LABEL: Record<LeadFormVertical, string> = {
46
- consulting: "컨설팅",
47
- medical: "의료",
48
- tax: "세무",
49
- legal: "법률",
50
- };
51
-
52
- const VERTICAL_OPTIONS: { value: LeadFormVertical; label: string }[] = [
53
- { value: "consulting", label: "컨설팅 / 일반" },
54
- { value: "medical", label: "의료 (병의원·치과·한의원)" },
55
- { value: "tax", label: "세무 (세무사·회계사)" },
56
- { value: "legal", label: "법률 (법무법인·변호사)" },
57
- ];
58
-
59
- export function RootTaleLeadForm(
60
- props: RootTaleLeadFormProps,
61
- ): ReactElement {
62
- const {
63
- action,
64
- vertical,
65
- redirectUrl,
66
- heading,
67
- description,
68
- submitLabel = "진단 신청",
69
- className,
70
- } = props;
71
-
72
- const formClass = ["rt-cms-lead-form", className].filter(Boolean).join(" ");
73
-
74
- return (
75
- <form
76
- method="post"
77
- action={action}
78
- data-roottale-cms="lead-form"
79
- className={formClass}
80
- >
81
- {heading ? (
82
- <h2 className="rt-cms-lead-heading">{heading}</h2>
83
- ) : null}
84
- {description ? (
85
- <p className="rt-cms-lead-description">{description}</p>
86
- ) : null}
87
-
88
- {redirectUrl ? (
89
- <input type="hidden" name="_redirect_url" value={redirectUrl} />
90
- ) : null}
91
-
92
- {vertical ? (
93
- <>
94
- <input type="hidden" name="vertical" value={vertical} />
95
- <p className="rt-cms-lead-vertical-label">
96
- 분야: <strong>{VERTICAL_LABEL[vertical]}</strong>
97
- </p>
98
- </>
99
- ) : (
100
- <label className="rt-cms-field">
101
- <span className="rt-cms-field-label">
102
- 분야 <span aria-hidden="true">*</span>
103
- </span>
104
- <select
105
- name="vertical"
106
- required
107
- className="rt-cms-field-input rt-cms-field-select"
108
- defaultValue=""
109
- >
110
- <option value="" disabled>
111
- 선택하세요
112
- </option>
113
- {VERTICAL_OPTIONS.map((opt) => (
114
- <option key={opt.value} value={opt.value}>
115
- {opt.label}
116
- </option>
117
- ))}
118
- </select>
119
- </label>
120
- )}
121
-
122
- <label className="rt-cms-field">
123
- <span className="rt-cms-field-label">
124
- 이름 <span aria-hidden="true">*</span>
125
- </span>
126
- <input
127
- type="text"
128
- name="contact_name"
129
- required
130
- autoComplete="name"
131
- className="rt-cms-field-input"
132
- />
133
- </label>
134
-
135
- <label className="rt-cms-field">
136
- <span className="rt-cms-field-label">
137
- 사업체명 <span aria-hidden="true">*</span>
138
- </span>
139
- <input
140
- type="text"
141
- name="business_name"
142
- required
143
- autoComplete="organization"
144
- className="rt-cms-field-input"
145
- />
146
- </label>
147
-
148
- <label className="rt-cms-field">
149
- <span className="rt-cms-field-label">
150
- 이메일 <span aria-hidden="true">*</span>
151
- </span>
152
- <input
153
- type="email"
154
- name="email"
155
- required
156
- autoComplete="email"
157
- className="rt-cms-field-input"
158
- />
159
- </label>
160
-
161
- <label className="rt-cms-field">
162
- <span className="rt-cms-field-label">
163
- 전화번호 <span aria-hidden="true">*</span>
164
- </span>
165
- <input
166
- type="tel"
167
- name="phone"
168
- required
169
- autoComplete="tel"
170
- placeholder="010-0000-0000"
171
- className="rt-cms-field-input"
172
- />
173
- </label>
174
-
175
- <label className="rt-cms-field">
176
- <span className="rt-cms-field-label">현재 사이트 URL (선택)</span>
177
- <input
178
- type="url"
179
- name="current_site_url"
180
- placeholder="https://"
181
- className="rt-cms-field-input"
182
- />
183
- <span className="rt-cms-field-hint">없으면 비워두세요.</span>
184
- </label>
185
-
186
- <label className="rt-cms-lead-consent">
187
- <input type="checkbox" name="privacy_consent" required />
188
- <span>
189
- <strong>(필수)</strong> 개인정보 수집·이용에 동의합니다. (수집 항목:
190
- 이름·연락처·이메일·사업체명. 보관 기간: 문의 종료 후 3년)
191
- </span>
192
- </label>
193
-
194
- {vertical === "medical" ? (
195
- <label className="rt-cms-lead-consent">
196
- <input
197
- type="checkbox"
198
- name="overseas_transfer_consent"
199
- required
200
- />
201
- <span>
202
- <strong>(필수, 의료)</strong> 의료 PII 의 국외이전(보관·처리)에
203
- 동의합니다. (의료법 §21·개인정보보호법 §28 — ADR-0018)
204
- </span>
205
- </label>
206
- ) : null}
207
-
208
- <div className="rt-cms-lead-actions">
209
- <button type="submit" className="rt-cms-lead-submit">
210
- {submitLabel}
211
- </button>
212
- </div>
213
- </form>
214
- );
215
- }
package/src/PostCard.tsx DELETED
@@ -1,45 +0,0 @@
1
- /**
2
- * `<RootTalePostCard>` — server-only summary card for a single CmsPostContent.
3
- *
4
- * Building block for `<RootTaleBlogList>` (and customer-side custom grids).
5
- * Pure SSR — anchor + title + meta + excerpt. Featured image is left out for
6
- * now: `CmsPostContent` only carries `featuredMediaId`, not a public URL.
7
- * Media URL resolution belongs in a separate slice once `cms-client` exposes
8
- * it (ADR-0034 §4 media followup — CF Images variants).
9
- */
10
-
11
- import type { ReactElement } from "react";
12
- import type { CmsPostContent } from "@roottale/cms-client/server";
13
-
14
- export interface RootTalePostCardProps {
15
- post: CmsPostContent;
16
- /** Customer-side URL builder. Defaults to `/blog/${slug}`. */
17
- href?: (post: CmsPostContent) => string;
18
- }
19
-
20
- export function RootTalePostCard(props: RootTalePostCardProps): ReactElement {
21
- const { post, href = defaultHref } = props;
22
- return (
23
- <article data-roottale-cms="card" className="rt-cms-card">
24
- <a className="rt-cms-card-link" href={href(post)}>
25
- <p className="rt-cms-meta">{formatPublishedDate(post.publishedAt)}</p>
26
- <h2 className="rt-cms-title">{post.title}</h2>
27
- {post.excerpt ? <p className="rt-cms-excerpt">{post.excerpt}</p> : null}
28
- </a>
29
- </article>
30
- );
31
- }
32
-
33
- function defaultHref(post: CmsPostContent): string {
34
- return `/blog/${post.slug}`;
35
- }
36
-
37
- function formatPublishedDate(iso: string): string {
38
- const d = new Date(iso);
39
- if (Number.isNaN(d.getTime())) return "";
40
- return new Intl.DateTimeFormat("ko-KR", {
41
- year: "numeric",
42
- month: "2-digit",
43
- day: "2-digit",
44
- }).format(d);
45
- }
@@ -1,42 +0,0 @@
1
- /**
2
- * `<RootTaleTableOfContents>` — server-only anchor list for blog posts.
3
- *
4
- * Pure SSR. Renders `<nav>` + ordered anchors that link to `#<id>` slots
5
- * emitted by `attachHeadingIds`. No scroll-spy here — that requires an
6
- * `IntersectionObserver` which only exists in the browser; a client-island
7
- * variant can layer on top later without changing the data contract.
8
- *
9
- * Internal ancestor: `roottale-internal/packages/cms-renderer/src/TableOfContents.tsx`
10
- * (had `'use client'` + IntersectionObserver). Stripped to server-safe form per
11
- * the platform renderer contract (no `'use client'` boundary — ADR-0023 §5.3).
12
- */
13
-
14
- import type { ReactElement } from "react";
15
- import type { TocEntry } from "./toc.js";
16
-
17
- export interface RootTaleTableOfContentsProps {
18
- entries: TocEntry[];
19
- title?: string;
20
- }
21
-
22
- export function RootTaleTableOfContents(
23
- props: RootTaleTableOfContentsProps,
24
- ): ReactElement | null {
25
- const { entries, title = "목차" } = props;
26
- if (entries.length === 0) return null;
27
- return (
28
- <nav data-roottale-cms="toc" className="rt-cms-toc" aria-label={title}>
29
- {title ? <p className="rt-cms-toc-title">{title}</p> : null}
30
- <ol className="rt-cms-toc-list">
31
- {entries.map((entry) => (
32
- <li
33
- key={entry.id}
34
- className={`rt-cms-toc-item rt-cms-toc-level-${entry.level}`}
35
- >
36
- <a href={`#${entry.id}`}>{entry.text}</a>
37
- </li>
38
- ))}
39
- </ol>
40
- </nav>
41
- );
42
- }
@@ -1,118 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import { attachHeadingIds, extractToc, headingToId } from "../toc.js";
4
-
5
- describe("headingToId", () => {
6
- it("lowercases ASCII and joins with hyphens", () => {
7
- expect(headingToId("Getting Started")).toBe("getting-started");
8
- });
9
-
10
- it("preserves Korean characters", () => {
11
- expect(headingToId("진료 안내")).toBe("진료-안내");
12
- });
13
-
14
- it("strips punctuation and collapses whitespace", () => {
15
- expect(headingToId(" Hello, World!! ")).toBe("hello-world");
16
- });
17
-
18
- it("falls back to empty string when no word characters remain", () => {
19
- expect(headingToId("---")).toBe("");
20
- });
21
- });
22
-
23
- describe("extractToc", () => {
24
- it("ignores non-heading nodes and H1/H4+ headings", () => {
25
- const doc = {
26
- type: "doc",
27
- content: [
28
- { type: "paragraph", content: [{ type: "text", text: "intro" }] },
29
- {
30
- type: "heading",
31
- attrs: { level: 1 },
32
- content: [{ type: "text", text: "Top-level" }],
33
- },
34
- {
35
- type: "heading",
36
- attrs: { level: 2 },
37
- content: [{ type: "text", text: "Section A" }],
38
- },
39
- {
40
- type: "heading",
41
- attrs: { level: 3 },
42
- content: [{ type: "text", text: "Detail A.1" }],
43
- },
44
- {
45
- type: "heading",
46
- attrs: { level: 4 },
47
- content: [{ type: "text", text: "Too deep" }],
48
- },
49
- ],
50
- };
51
- expect(extractToc(doc)).toEqual([
52
- { level: 2, text: "Section A", id: "section-a" },
53
- { level: 3, text: "Detail A.1", id: "detail-a1" },
54
- ]);
55
- });
56
-
57
- it("deduplicates ids when headings collide", () => {
58
- const doc = {
59
- type: "doc",
60
- content: [
61
- {
62
- type: "heading",
63
- attrs: { level: 2 },
64
- content: [{ type: "text", text: "FAQ" }],
65
- },
66
- {
67
- type: "heading",
68
- attrs: { level: 2 },
69
- content: [{ type: "text", text: "FAQ" }],
70
- },
71
- {
72
- type: "heading",
73
- attrs: { level: 2 },
74
- content: [{ type: "text", text: "FAQ" }],
75
- },
76
- ],
77
- };
78
- expect(extractToc(doc).map((e) => e.id)).toEqual([
79
- "faq",
80
- "faq-1",
81
- "faq-2",
82
- ]);
83
- });
84
-
85
- it("returns empty for an empty doc", () => {
86
- expect(extractToc({})).toEqual([]);
87
- expect(extractToc({ type: "doc" })).toEqual([]);
88
- });
89
- });
90
-
91
- describe("attachHeadingIds", () => {
92
- it("writes attrs.id onto matching H2/H3 nodes in order", () => {
93
- const doc = {
94
- type: "doc",
95
- content: [
96
- {
97
- type: "heading",
98
- attrs: { level: 2 },
99
- content: [{ type: "text", text: "A" }],
100
- },
101
- { type: "paragraph", content: [{ type: "text", text: "body" }] },
102
- {
103
- type: "heading",
104
- attrs: { level: 3 },
105
- content: [{ type: "text", text: "B" }],
106
- },
107
- ],
108
- };
109
- const entries = extractToc(doc);
110
- const out = attachHeadingIds(doc, entries) as {
111
- content: { type: string; attrs?: { id?: string } }[];
112
- };
113
- expect(out.content[0]?.attrs?.id).toBe("a");
114
- expect(out.content[2]?.attrs?.id).toBe("b");
115
- // original doc untouched
116
- expect((doc.content[0] as { attrs?: { id?: string } }).attrs?.id).toBeUndefined();
117
- });
118
- });