@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.
@@ -1,199 +0,0 @@
1
- // ADR-0034 §1.5 amended — Block JSON → React elements
2
- //
3
- // Next/RSC public renderer 가 본 함수로 block tree 를 server-side React element
4
- // 로 변환. cms-renderer-astro 의 `block-to-html.ts` 와 1:1 대응 (같은 7 + α
5
- // block 핸들러). `BlockDefinition.nextRender` 정합.
6
- //
7
- // 알려지지 않은 block 은 rawHtml 출력 (codex v3 verdict #4, opaque atom
8
- // round-trip 보존).
9
-
10
- import type { ReactElement, ReactNode } from "react";
11
-
12
- import type { Block } from "@roottale/cms-core";
13
-
14
- function isString(value: unknown): value is string {
15
- return typeof value === "string";
16
- }
17
-
18
- function htmlContent(html: string): { __html: string } {
19
- // React 의 dangerouslySetInnerHTML 표면. Block 의 rawHtml/content 는 이미
20
- // 서버측에서 받은 후 별도 sanitize 가 필요할 수 있음 (cms-core sanitize).
21
- return { __html: html };
22
- }
23
-
24
- function renderInner(blocks: readonly Block[]): ReactElement[] {
25
- return blocks.map((b, i) => renderBlock(b, i));
26
- }
27
-
28
- /**
29
- * 단일 block 을 ReactElement 로 변환. `block-to-html.ts` 와 동일 분기.
30
- */
31
- export function renderBlock(block: Block, key: number = 0): ReactElement {
32
- const dataBlockId = block._id;
33
-
34
- switch (block.name) {
35
- case "core/paragraph": {
36
- const text = isString(block.attributes.content)
37
- ? block.attributes.content
38
- : block.rawHtml ?? "";
39
- return (
40
- <p
41
- key={key}
42
- data-block-id={dataBlockId}
43
- dangerouslySetInnerHTML={htmlContent(text)}
44
- />
45
- );
46
- }
47
- case "core/heading": {
48
- const levelRaw = block.attributes.level;
49
- const level =
50
- typeof levelRaw === "number" && levelRaw >= 1 && levelRaw <= 6
51
- ? levelRaw
52
- : 2;
53
- const text = isString(block.attributes.content)
54
- ? block.attributes.content
55
- : block.rawHtml ?? "";
56
- const props = {
57
- "data-block-id": dataBlockId,
58
- dangerouslySetInnerHTML: htmlContent(text),
59
- } as const;
60
- if (level === 1) return <h1 key={key} {...props} />;
61
- if (level === 2) return <h2 key={key} {...props} />;
62
- if (level === 3) return <h3 key={key} {...props} />;
63
- if (level === 4) return <h4 key={key} {...props} />;
64
- if (level === 5) return <h5 key={key} {...props} />;
65
- return <h6 key={key} {...props} />;
66
- }
67
- case "core/image": {
68
- const src = isString(block.attributes.url) ? block.attributes.url : "";
69
- const alt = isString(block.attributes.alt) ? block.attributes.alt : "";
70
- const caption = isString(block.attributes.caption)
71
- ? block.attributes.caption
72
- : null;
73
- return (
74
- <figure key={key} data-block-id={dataBlockId}>
75
- {/* eslint-disable-next-line @next/next/no-img-element */}
76
- <img src={src} alt={alt} loading="lazy" />
77
- {caption && <figcaption>{caption}</figcaption>}
78
- </figure>
79
- );
80
- }
81
- case "core/list": {
82
- const ordered = block.attributes.ordered === true;
83
- const items = (block.innerBlocks ?? []).map(
84
- (item, i): ReactNode =>
85
- item.rawHtml ? (
86
- <li
87
- key={i}
88
- dangerouslySetInnerHTML={htmlContent(item.rawHtml)}
89
- />
90
- ) : (
91
- <li key={i}>{renderInner(item.innerBlocks ?? [])}</li>
92
- ),
93
- );
94
- return ordered ? (
95
- <ol key={key} data-block-id={dataBlockId}>
96
- {items}
97
- </ol>
98
- ) : (
99
- <ul key={key} data-block-id={dataBlockId}>
100
- {items}
101
- </ul>
102
- );
103
- }
104
- case "core/quote": {
105
- const inner = renderInner(block.innerBlocks ?? []);
106
- const citation = isString(block.attributes.citation)
107
- ? block.attributes.citation
108
- : null;
109
- return (
110
- <blockquote key={key} data-block-id={dataBlockId}>
111
- {inner}
112
- {citation && <cite>{citation}</cite>}
113
- </blockquote>
114
- );
115
- }
116
- case "core/code": {
117
- const code = isString(block.attributes.content)
118
- ? block.attributes.content
119
- : block.rawHtml ?? "";
120
- const lang = isString(block.attributes.language)
121
- ? block.attributes.language
122
- : undefined;
123
- return (
124
- <pre
125
- key={key}
126
- data-block-id={dataBlockId}
127
- data-language={lang}
128
- >
129
- <code>{code}</code>
130
- </pre>
131
- );
132
- }
133
- case "core/columns": {
134
- return (
135
- <div
136
- key={key}
137
- className="rt-columns"
138
- data-block-id={dataBlockId}
139
- >
140
- {renderInner(block.innerBlocks ?? [])}
141
- </div>
142
- );
143
- }
144
- case "core/separator":
145
- return <hr key={key} data-block-id={dataBlockId} />;
146
- case "core/spacer": {
147
- const heightRaw = block.attributes.height;
148
- const height = isString(heightRaw) ? heightRaw : "32px";
149
- return (
150
- <div
151
- key={key}
152
- className="rt-spacer"
153
- data-block-id={dataBlockId}
154
- style={{ height }}
155
- />
156
- );
157
- }
158
- case "core/group": {
159
- return (
160
- <div
161
- key={key}
162
- className="rt-group"
163
- data-block-id={dataBlockId}
164
- >
165
- {renderInner(block.innerBlocks ?? [])}
166
- </div>
167
- );
168
- }
169
- default: {
170
- const rawHtml = block.rawHtml;
171
- if (rawHtml) {
172
- return (
173
- <div
174
- key={key}
175
- className="rt-unknown-block"
176
- data-block-id={dataBlockId}
177
- data-block-name={block.name}
178
- dangerouslySetInnerHTML={htmlContent(rawHtml)}
179
- />
180
- );
181
- }
182
- return (
183
- <div
184
- key={key}
185
- className="rt-unknown-block"
186
- data-block-id={dataBlockId}
187
- data-block-name={block.name}
188
- >
189
- {renderInner(block.innerBlocks ?? [])}
190
- </div>
191
- );
192
- }
193
- }
194
- }
195
-
196
- /** Block tree 전체를 React element 배열로 직렬화. */
197
- export function renderBlocks(blocks: readonly Block[]): ReactElement[] {
198
- return blocks.map((b, i) => renderBlock(b, i));
199
- }
package/src/index.ts DELETED
@@ -1,7 +0,0 @@
1
- // Default entry is intentionally empty. External customers use
2
- // `@roottale/cms-renderer-next/server` for SSR-only components and
3
- // `@roottale/cms-renderer-next/styles` for the scoped CSS bundle.
4
- //
5
- // Astro 사이트는 `@roottale/cms-renderer-astro` (동등 surface) 사용.
6
- // 두 패키지 모두 1급 public renderer (ADR-0034 §1.5 amended).
7
- export {};
package/src/server.tsx DELETED
@@ -1,338 +0,0 @@
1
- /**
2
- * `@roottale/cms-renderer-next/server` — RootTale CMS public-render React Server Components.
3
- *
4
- * Drop into any RSC-capable framework (Next.js App Router, Astro server islands,
5
- * React Router server). Single-line import, SSR-only, no `'use client'` boundary.
6
- *
7
- * ```tsx
8
- * import { RootTaleBlogList } from "@roottale/cms-renderer-next/server";
9
- *
10
- * export default function BlogPage() {
11
- * return <RootTaleBlogList apiKey={process.env.ROOTTALE_API_KEY!} />;
12
- * }
13
- * ```
14
- *
15
- * Customer site MUST keep `apiKey` server-side. Browser bundle ships zero
16
- * RootTale credentials (ADR-0023 §5.1 #15).
17
- *
18
- * Design tokens auto-apply via the CSS import below. Customers can override
19
- * any class with their own CSS or Tailwind utilities — scoped selectors use
20
- * `:where()` to keep specificity at zero (ADR-0023 §5.1 #10).
21
- */
22
-
23
- import type { CSSProperties, ReactElement } from "react";
24
- import {
25
- type CmsPostContent,
26
- type CmsPostType,
27
- fetchPost,
28
- fetchPosts,
29
- } from "@roottale/cms-client/server";
30
- // Note: tokens.css is intentionally NOT imported here. cms-renderer-next is
31
- // shipped as an independent npm package — every `--rt-*` variable referenced
32
- // in cms-public.css has a static fallback. Customers who use @roottale/tokens
33
- // can layer it on top, but the package works without it.
34
- import "./styles/cms-public.css";
35
-
36
- import { RootTalePostCard } from "./PostCard.js";
37
- import { RootTaleTableOfContents } from "./TableOfContents.js";
38
- import { attachHeadingIds, extractToc } from "./toc.js";
39
-
40
- export { RootTalePostCard, type RootTalePostCardProps } from "./PostCard.js";
41
- export {
42
- RootTaleTableOfContents,
43
- type RootTaleTableOfContentsProps,
44
- } from "./TableOfContents.js";
45
- export {
46
- RootTaleFloatingCta,
47
- type RootTaleFloatingCtaProps,
48
- type CtaButton,
49
- type CtaButtonType,
50
- type FloatingCtaPosition,
51
- } from "./FloatingCta.js";
52
- export {
53
- RootTaleLeadForm,
54
- type RootTaleLeadFormProps,
55
- type LeadFormVertical,
56
- } from "./LeadForm.js";
57
- export { renderBlock, renderBlocks } from "./block-to-react.js";
58
- export {
59
- attachHeadingIds,
60
- extractToc,
61
- headingToId,
62
- type TocEntry,
63
- } from "./toc.js";
64
-
65
- export type RootTaleBlogListProps = {
66
- apiKey: string;
67
- baseUrl?: string;
68
- limit?: number;
69
- /** Filter by post type (`post` or `page`). Default = `post` only (blog list 의도). */
70
- type?: CmsPostType;
71
- /** Build a customer-side URL for a single post. Defaults to `/blog/${slug}`. */
72
- postHref?: (post: CmsPostContent) => string;
73
- /** Rendered when the tenant has no published posts yet. */
74
- emptyMessage?: string;
75
- };
76
-
77
- export async function RootTaleBlogList(
78
- props: RootTaleBlogListProps,
79
- ): Promise<ReactElement> {
80
- const {
81
- apiKey,
82
- baseUrl,
83
- limit,
84
- type = "post",
85
- postHref = defaultPostHref,
86
- emptyMessage = "아직 발행된 글이 없습니다.",
87
- } = props;
88
- const page = await fetchPosts({ apiKey, baseUrl, limit, type });
89
-
90
- if (page.items.length === 0) {
91
- return (
92
- <div data-roottale-cms="list">
93
- <p className="rt-cms-empty">{emptyMessage}</p>
94
- </div>
95
- );
96
- }
97
-
98
- return (
99
- <div data-roottale-cms="list">
100
- <ul className="rt-cms-list">
101
- {page.items.map((post) => (
102
- <li key={post.id} className="rt-cms-list-item">
103
- <RootTalePostCard post={post} href={postHref} />
104
- </li>
105
- ))}
106
- </ul>
107
- </div>
108
- );
109
- }
110
-
111
- export type RootTaleBlogPostProps = {
112
- apiKey: string;
113
- slugOrId: string;
114
- baseUrl?: string;
115
- /** Rendered when the slug is not found or not published. */
116
- notFoundElement?: ReactElement;
117
- /**
118
- * When true, prepends an auto-derived `<RootTaleTableOfContents>` before
119
- * the article body (H2/H3 headings only). Default: false — keep markup
120
- * stable for customers who already control their own layout.
121
- */
122
- showTableOfContents?: boolean;
123
- /** Title shown above the TOC (only when `showTableOfContents`). */
124
- tableOfContentsTitle?: string;
125
- };
126
-
127
- export async function RootTaleBlogPost(
128
- props: RootTaleBlogPostProps,
129
- ): Promise<ReactElement> {
130
- const {
131
- apiKey,
132
- slugOrId,
133
- baseUrl,
134
- notFoundElement,
135
- showTableOfContents = false,
136
- tableOfContentsTitle,
137
- } = props;
138
- const post = await fetchPost({ apiKey, slugOrId, baseUrl });
139
-
140
- if (!post) {
141
- return (
142
- notFoundElement ?? (
143
- <div data-roottale-cms="post-missing">
144
- <p className="rt-cms-empty">글을 찾을 수 없습니다.</p>
145
- </div>
146
- )
147
- );
148
- }
149
-
150
- const toc = showTableOfContents ? extractToc(post.bodyJson) : [];
151
- const renderDoc =
152
- toc.length > 0 ? attachHeadingIds(post.bodyJson, toc) : post.bodyJson;
153
-
154
- return (
155
- <article data-roottale-cms="post" className="rt-cms-article">
156
- <header>
157
- <p className="rt-cms-meta">{formatPublishedDate(post.publishedAt)}</p>
158
- <h1 className="rt-cms-title">{post.title}</h1>
159
- {post.excerpt ? <p className="rt-cms-excerpt">{post.excerpt}</p> : null}
160
- </header>
161
- {toc.length > 0 ? (
162
- <RootTaleTableOfContents entries={toc} title={tableOfContentsTitle} />
163
- ) : null}
164
- <RenderTiptap doc={renderDoc} />
165
- </article>
166
- );
167
- }
168
-
169
- function defaultPostHref(post: CmsPostContent): string {
170
- return `/blog/${post.slug}`;
171
- }
172
-
173
- function formatPublishedDate(iso: string): string {
174
- const d = new Date(iso);
175
- if (Number.isNaN(d.getTime())) return "";
176
- return new Intl.DateTimeFormat("ko-KR", {
177
- year: "numeric",
178
- month: "2-digit",
179
- day: "2-digit",
180
- }).format(d);
181
- }
182
-
183
- /**
184
- * Minimal Tiptap doc renderer. Walks the Tiptap doc tree and emits semantic
185
- * HTML. This is intentionally tiny — richer extensions (image asset URL
186
- * resolution, TableOfContents, FloatingCta) belong in followup §5.7 #5.
187
- *
188
- * Browser-side sanitization is unnecessary here because output is rendered
189
- * server-side from data the customer-trusted API returned. If the customer
190
- * site federates with untrusted authors later, layer a sanitizer in front
191
- * of this component.
192
- */
193
- function RenderTiptap({ doc }: { doc: Record<string, unknown> }): ReactElement {
194
- const rawContent = (doc as TiptapDoc).content;
195
- const content: TiptapNode[] = Array.isArray(rawContent) ? rawContent : [];
196
- return <>{content.map((node, i) => renderNode(node, i))}</>;
197
- }
198
-
199
- type TiptapDoc = { type?: string; content?: TiptapNode[] };
200
-
201
- type TiptapMark = { type?: string; attrs?: Record<string, unknown> };
202
-
203
- type TiptapNode = {
204
- type?: string;
205
- text?: string;
206
- attrs?: Record<string, unknown>;
207
- content?: TiptapNode[];
208
- marks?: TiptapMark[];
209
- };
210
-
211
- /**
212
- * Defense-in-depth: even though admin LinkButton.normalizeUrl filters input,
213
- * legacy imports / direct DB / external tenants can still emit unsafe href.
214
- * Allow only http(s)/mailto/tel/root-relative/fragment/query — falls back to "#".
215
- */
216
- function isSafeHref(value: unknown): value is string {
217
- if (typeof value !== "string") return false;
218
- const v = value.trim();
219
- if (v.length === 0) return false;
220
- if (v.startsWith("/") || v.startsWith("#") || v.startsWith("?")) return true;
221
- return /^(https?:|mailto:|tel:)/i.test(v);
222
- }
223
-
224
- function alignStyle(node: TiptapNode): CSSProperties | undefined {
225
- const align = node.attrs?.textAlign;
226
- if (typeof align !== "string" || align === "" || align === "left") return undefined;
227
- if (align === "center" || align === "right" || align === "justify") {
228
- return { textAlign: align };
229
- }
230
- return undefined;
231
- }
232
-
233
- function renderNode(node: TiptapNode, key: number): ReactElement | string | null {
234
- if (!node || typeof node !== "object") return null;
235
- const children = Array.isArray(node.content)
236
- ? node.content.map((child, i) => renderNode(child, i))
237
- : undefined;
238
-
239
- switch (node.type) {
240
- case "paragraph":
241
- return <p key={key} style={alignStyle(node)}>{children}</p>;
242
- case "heading": {
243
- const level = Math.min(Math.max(Number(node.attrs?.level ?? 2), 1), 3);
244
- const idAttr = node.attrs?.id;
245
- const id = typeof idAttr === "string" && idAttr.length > 0 ? idAttr : undefined;
246
- const style = alignStyle(node);
247
- if (level === 1) return <h1 key={key} id={id} style={style}>{children}</h1>;
248
- if (level === 2) return <h2 key={key} id={id} style={style}>{children}</h2>;
249
- return <h3 key={key} id={id} style={style}>{children}</h3>;
250
- }
251
- case "bulletList":
252
- return <ul key={key}>{children}</ul>;
253
- case "orderedList":
254
- return <ol key={key}>{children}</ol>;
255
- case "listItem":
256
- return <li key={key}>{children}</li>;
257
- case "blockquote":
258
- return <blockquote key={key}>{children}</blockquote>;
259
- case "codeBlock":
260
- return (
261
- <pre key={key}>
262
- <code>{children}</code>
263
- </pre>
264
- );
265
- case "horizontalRule":
266
- return <hr key={key} />;
267
- case "hardBreak":
268
- return <br key={key} />;
269
- case "image": {
270
- const src = node.attrs?.src;
271
- if (typeof src !== "string" || src.length === 0) return null;
272
- const alt = typeof node.attrs?.alt === "string" ? node.attrs.alt : "";
273
- const title = typeof node.attrs?.title === "string" ? node.attrs.title : undefined;
274
- // eslint-disable-next-line @next/next/no-img-element
275
- return <img key={key} src={src} alt={alt} title={title} loading="lazy" />;
276
- }
277
- case "columns":
278
- return <div key={key} className="rt-columns">{children}</div>;
279
- case "column":
280
- return <div key={key} className="rt-column">{children}</div>;
281
- case "text":
282
- return renderText(node, key);
283
- default:
284
- return children ? <span key={key}>{children}</span> : null;
285
- }
286
- }
287
-
288
- function renderText(node: TiptapNode, key: number): ReactElement | string {
289
- const text = node.text ?? "";
290
- const marks = node.marks ?? [];
291
- if (marks.length === 0) return text;
292
- let element: ReactElement | string = text;
293
- for (const mark of marks) {
294
- element = applyMark(mark, element);
295
- }
296
- return <span key={key}>{element}</span>;
297
- }
298
-
299
- function applyMark(mark: TiptapMark, child: ReactElement | string): ReactElement {
300
- switch (mark.type) {
301
- case "bold":
302
- return <strong>{child}</strong>;
303
- case "italic":
304
- return <em>{child}</em>;
305
- case "underline":
306
- return <u>{child}</u>;
307
- case "strike":
308
- return <s>{child}</s>;
309
- case "code":
310
- return <code>{child}</code>;
311
- case "highlight": {
312
- const color = mark.attrs?.color;
313
- const style =
314
- typeof color === "string" && color.length > 0
315
- ? { background: color }
316
- : undefined;
317
- return <mark style={style}>{child}</mark>;
318
- }
319
- case "textStyle": {
320
- const color = mark.attrs?.color;
321
- const style =
322
- typeof color === "string" && color.length > 0 ? { color } : undefined;
323
- if (!style) return <>{child}</>;
324
- return <span style={style}>{child}</span>;
325
- }
326
- case "link": {
327
- const raw = mark.attrs?.href;
328
- const href = isSafeHref(raw) ? raw : "#";
329
- return (
330
- <a href={href} rel="noopener noreferrer" target="_blank">
331
- {child}
332
- </a>
333
- );
334
- }
335
- default:
336
- return <span>{child}</span>;
337
- }
338
- }
package/src/toc.ts DELETED
@@ -1,95 +0,0 @@
1
- /**
2
- * `@roottale/cms-renderer/toc` — Tiptap doc → TOC entries + heading id injection.
3
- *
4
- * Server-only utility. Walks a Tiptap JSON doc, extracts H2/H3 headings, and
5
- * derives stable kebab-case ids (Korean-safe). The companion `attachHeadingIds`
6
- * mutates a shallow clone of the doc so renderers can emit `<h2 id="...">`
7
- * anchors that match what `<RootTaleTableOfContents>` links to.
8
- *
9
- * Internal ancestor: `roottale-internal/packages/cms-renderer/src/toc.ts`
10
- * (HTML-string based, vendored snapshot). Ported to Tiptap-JSON because the
11
- * platform renderer no longer round-trips through HTML before render.
12
- */
13
-
14
- export interface TocEntry {
15
- level: 2 | 3;
16
- text: string;
17
- id: string;
18
- }
19
-
20
- type TiptapNode = {
21
- type?: string;
22
- text?: string;
23
- attrs?: Record<string, unknown>;
24
- content?: TiptapNode[];
25
- marks?: { type?: string }[];
26
- };
27
-
28
- type TiptapDoc = { type?: string; content?: TiptapNode[] };
29
-
30
- export function headingToId(text: string): string {
31
- return text
32
- .trim()
33
- .toLowerCase()
34
- .replace(/[^\p{L}\p{N}\s-]/gu, "")
35
- .replace(/\s+/g, "-")
36
- .replace(/-+/g, "-")
37
- .replace(/^-|-$/g, "");
38
- }
39
-
40
- function textOf(node: TiptapNode): string {
41
- if (typeof node.text === "string") return node.text;
42
- if (!Array.isArray(node.content)) return "";
43
- return node.content.map(textOf).join("");
44
- }
45
-
46
- export function extractToc(doc: Record<string, unknown>): TocEntry[] {
47
- const root = doc as TiptapDoc;
48
- const content = Array.isArray(root.content) ? root.content : [];
49
- const entries: TocEntry[] = [];
50
- const idCounts = new Map<string, number>();
51
-
52
- for (const node of content) {
53
- if (node?.type !== "heading") continue;
54
- const rawLevel = Number(node.attrs?.level ?? 2);
55
- if (rawLevel !== 2 && rawLevel !== 3) continue;
56
- const text = textOf(node).trim();
57
- if (!text) continue;
58
-
59
- const base = headingToId(text) || "heading";
60
- const count = idCounts.get(base) ?? 0;
61
- const id = count === 0 ? base : `${base}-${count}`;
62
- idCounts.set(base, count + 1);
63
-
64
- entries.push({ level: rawLevel, text, id });
65
- }
66
-
67
- return entries;
68
- }
69
-
70
- /**
71
- * Returns a shallow-cloned Tiptap doc where each H2/H3 carries the matching
72
- * `attrs.id` from `entries` (same order as `extractToc` returns). Existing
73
- * `id` attrs are overwritten so renderer + TOC always agree on anchors.
74
- */
75
- export function attachHeadingIds(
76
- doc: Record<string, unknown>,
77
- entries: TocEntry[],
78
- ): Record<string, unknown> {
79
- const root = doc as TiptapDoc;
80
- const content = Array.isArray(root.content) ? root.content : [];
81
- let cursor = 0;
82
- const nextContent = content.map((node) => {
83
- if (node?.type !== "heading") return node;
84
- const rawLevel = Number(node.attrs?.level ?? 2);
85
- if (rawLevel !== 2 && rawLevel !== 3) return node;
86
- if (!textOf(node).trim()) return node;
87
- const entry = entries[cursor++];
88
- if (!entry) return node;
89
- return {
90
- ...node,
91
- attrs: { ...(node.attrs ?? {}), id: entry.id },
92
- };
93
- });
94
- return { ...(root as Record<string, unknown>), content: nextContent };
95
- }
File without changes