@roottale/cms-renderer-next 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +228 -0
- package/dist/server.js +672 -0
- package/dist/server.js.map +1 -0
- package/package.json +18 -9
- package/src/FloatingCta.tsx +0 -75
- package/src/LeadForm.tsx +0 -215
- package/src/PostCard.tsx +0 -45
- package/src/TableOfContents.tsx +0 -42
- package/src/__tests__/toc.test.ts +0 -118
- package/src/block-to-react.tsx +0 -199
- package/src/index.ts +0 -7
- package/src/server.tsx +0 -338
- package/src/toc.ts +0 -95
- /package/{src/styles → dist}/cms-public.css +0 -0
|
@@ -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
|
-
});
|
package/src/block-to-react.tsx
DELETED
|
@@ -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
|
-
}
|