@roottale/cms-renderer-astro 0.2.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 +27 -0
- package/README.md +63 -0
- package/package.json +48 -0
- package/src/block-to-html.ts +114 -0
- package/src/blog.ts +156 -0
- package/src/index.ts +8 -0
- package/src/robots.ts +39 -0
- package/src/seo.ts +99 -0
- package/src/sitemap.ts +47 -0
- package/src/tiptap-to-html.test.ts +248 -0
- package/src/tiptap-to-html.ts +164 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# @roottale/cms-renderer-astro
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- d1b5d35: cms-\* public renderer 패키지 npm public publish — Phase 1 dogfood.
|
|
8
|
+
|
|
9
|
+
ADR-0029 §0 amend (publish-only dormant) 는 design system 5 패키지 (tokens, ui-css, ui-react, ui-astro, ui-admin) 에 한정. cms-\* 는 별도 정책 — 실행 로직 + 5-20 외부 customer site 직접 의존 + schema 호환 + 보안 경계. Codex consult verdict (session `019e6703…`) 정합.
|
|
10
|
+
|
|
11
|
+
변경:
|
|
12
|
+
- 4 패키지 `private: true` 해제 + `publishConfig.access: "public"`
|
|
13
|
+
- `@roottale/cms-renderer-next` 에서 `@roottale/tokens/tokens.css` import 제거 — 모든 `--rt-*` 변수에 static fallback 으로 self-contained. tokens dormant 와 무관하게 동작.
|
|
14
|
+
- `RootTaleLeadForm` RSC 추가 — 외부 사이트 진단 폼 (`vertical`/`redirectUrl` props, medical 국외이전 동의 자동).
|
|
15
|
+
- README + repository / homepage / keywords 메타 정비
|
|
16
|
+
- `peerDependencies.react: ^19` 명시 (cms-renderer-next)
|
|
17
|
+
- `cms-renderer-astro` 도 동등 surface 유지를 위해 동시 publish
|
|
18
|
+
|
|
19
|
+
후속:
|
|
20
|
+
- ADR 신규 — cms-\* publish 정책 (별 PR)
|
|
21
|
+
- Astro 측 LeadForm 컴포넌트 (현재 `@roottale/ui-astro` 위치, `cms-renderer-astro` 로 이동 검토)
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- Updated dependencies [d1b5d35]
|
|
26
|
+
- @roottale/cms-core@0.2.0
|
|
27
|
+
- @roottale/cms-client@0.1.0
|
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# @roottale/cms-renderer-astro
|
|
2
|
+
|
|
3
|
+
RootTale CMS public renderer for **Astro 6** sites — companion of [`@roottale/cms-renderer-next`](https://www.npmjs.com/package/@roottale/cms-renderer-next). Equivalent surface (`renderBlogList` / `renderBlogPost`), zero JS by default (ADR-0034 §1.5 amended).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @roottale/cms-renderer-astro @roottale/cms-client
|
|
9
|
+
# or
|
|
10
|
+
pnpm add @roottale/cms-renderer-astro @roottale/cms-client
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# .env — server / build-time only
|
|
17
|
+
ROOTTALE_API_KEY=rtlk_cust_xxxxxxxxxxxxxxxxxx
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Blog list — `src/pages/blog/index.astro`
|
|
21
|
+
|
|
22
|
+
```astro
|
|
23
|
+
---
|
|
24
|
+
import { renderBlogList } from "@roottale/cms-renderer-astro";
|
|
25
|
+
|
|
26
|
+
const html = await renderBlogList({
|
|
27
|
+
apiKey: import.meta.env.ROOTTALE_API_KEY,
|
|
28
|
+
limit: 20,
|
|
29
|
+
});
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
<Fragment set:html={html} />
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Blog post — `src/pages/blog/[slug].astro`
|
|
36
|
+
|
|
37
|
+
```astro
|
|
38
|
+
---
|
|
39
|
+
import { renderBlogPost } from "@roottale/cms-renderer-astro";
|
|
40
|
+
|
|
41
|
+
const { slug } = Astro.params;
|
|
42
|
+
const html = await renderBlogPost({
|
|
43
|
+
apiKey: import.meta.env.ROOTTALE_API_KEY,
|
|
44
|
+
slugOrId: slug!,
|
|
45
|
+
});
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
<Fragment set:html={html} />
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Other exports
|
|
52
|
+
|
|
53
|
+
- `renderBlocks` — Block JSON → HTML string
|
|
54
|
+
- `renderTiptapDoc` — Tiptap doc → HTML string
|
|
55
|
+
- SEO helpers — `generateArticleSchema`, `generateSitemapXml`, `generateRobotsTxt`
|
|
56
|
+
|
|
57
|
+
## Security
|
|
58
|
+
|
|
59
|
+
Same model as the Next renderer: server/build-time only (`@roottale/cms-client` throws if used in the browser). Block JSON is rendered through hardcoded mark/node mappings — link `href` passes an allowlist; no raw HTML escape hatch for authored content.
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
Proprietary (UNLICENSED). Issued under the RootTale customer contract.
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@roottale/cms-renderer-astro",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "RootTale CMS Astro 6 SSG/SSR public renderer. renderBlogList / renderBlogPost helpers + Tiptap → HTML. Companion of @roottale/cms-renderer-next (ADR-0034 §1.5 amended).",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./package.json": "./package.json"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src/",
|
|
13
|
+
"README.md",
|
|
14
|
+
"CHANGELOG.md"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@roottale/cms-core": "^0.2.0",
|
|
18
|
+
"@roottale/cms-client": "^0.1.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^22.0.0",
|
|
22
|
+
"typescript": "^5.7.0",
|
|
23
|
+
"vitest": "^2.1.0"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"roottale",
|
|
30
|
+
"cms",
|
|
31
|
+
"astro",
|
|
32
|
+
"blog",
|
|
33
|
+
"renderer",
|
|
34
|
+
"ssg"
|
|
35
|
+
],
|
|
36
|
+
"license": "UNLICENSED",
|
|
37
|
+
"homepage": "https://github.com/RootTale/roottale-platform/tree/main/packages/cms-renderer-astro",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "https://github.com/RootTale/roottale-platform.git",
|
|
41
|
+
"directory": "packages/cms-renderer-astro"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc --noEmit",
|
|
45
|
+
"type-check": "tsc --noEmit",
|
|
46
|
+
"test": "vitest run --passWithNoTests"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// ADR-0034 §1.5 + §5 — block JSON → HTML
|
|
2
|
+
//
|
|
3
|
+
// Astro 가 public page 렌더링 시 본 함수로 block tree 를 server-side HTML 로 변환.
|
|
4
|
+
// 7 block (paragraph/heading/image/list/quote/code/columns) Phase 1 핸들러.
|
|
5
|
+
// 우리가 schema 정의 안 한 block 은 rawHtml 출력 (codex v3 verdict #4, opaque
|
|
6
|
+
// atom round-trip 보존).
|
|
7
|
+
|
|
8
|
+
import type { Block } from "@roottale/cms-core";
|
|
9
|
+
|
|
10
|
+
/** XSS 차단: text content escape */
|
|
11
|
+
function escapeHtml(text: string): string {
|
|
12
|
+
return text
|
|
13
|
+
.replace(/&/g, "&")
|
|
14
|
+
.replace(/</g, "<")
|
|
15
|
+
.replace(/>/g, ">")
|
|
16
|
+
.replace(/"/g, """)
|
|
17
|
+
.replace(/'/g, "'");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** attribute value escape (XSS) */
|
|
21
|
+
function escapeAttr(value: string): string {
|
|
22
|
+
return value.replace(/"/g, """).replace(/</g, "<");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function renderInner(blocks: readonly Block[]): string {
|
|
26
|
+
return blocks.map((b) => renderBlock(b)).join("");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 단일 block 을 HTML 로 변환.
|
|
31
|
+
* 알려진 `core/<x>` block name 은 dedicated handler. 그 외 = rawHtml 출력
|
|
32
|
+
* (Tiptap opaque atom node 보존).
|
|
33
|
+
*/
|
|
34
|
+
export function renderBlock(block: Block): string {
|
|
35
|
+
switch (block.name) {
|
|
36
|
+
case "core/paragraph": {
|
|
37
|
+
const text = typeof block.attributes.content === "string"
|
|
38
|
+
? block.attributes.content
|
|
39
|
+
: (block.rawHtml ?? "");
|
|
40
|
+
return `<p data-block-id="${escapeAttr(block._id)}">${text}</p>`;
|
|
41
|
+
}
|
|
42
|
+
case "core/heading": {
|
|
43
|
+
const levelRaw = block.attributes.level;
|
|
44
|
+
const level = typeof levelRaw === "number" && levelRaw >= 1 && levelRaw <= 6 ? levelRaw : 2;
|
|
45
|
+
const text = typeof block.attributes.content === "string"
|
|
46
|
+
? block.attributes.content
|
|
47
|
+
: (block.rawHtml ?? "");
|
|
48
|
+
return `<h${level} data-block-id="${escapeAttr(block._id)}">${text}</h${level}>`;
|
|
49
|
+
}
|
|
50
|
+
case "core/image": {
|
|
51
|
+
const src = typeof block.attributes.url === "string"
|
|
52
|
+
? escapeAttr(block.attributes.url)
|
|
53
|
+
: "";
|
|
54
|
+
const alt = typeof block.attributes.alt === "string"
|
|
55
|
+
? escapeAttr(block.attributes.alt)
|
|
56
|
+
: "";
|
|
57
|
+
const caption = typeof block.attributes.caption === "string"
|
|
58
|
+
? block.attributes.caption
|
|
59
|
+
: null;
|
|
60
|
+
const img = `<img src="${src}" alt="${alt}" loading="lazy">`;
|
|
61
|
+
const inner = caption ? `${img}<figcaption>${escapeHtml(caption)}</figcaption>` : img;
|
|
62
|
+
return `<figure data-block-id="${escapeAttr(block._id)}">${inner}</figure>`;
|
|
63
|
+
}
|
|
64
|
+
case "core/list": {
|
|
65
|
+
const ordered = block.attributes.ordered === true;
|
|
66
|
+
const tag = ordered ? "ol" : "ul";
|
|
67
|
+
const items = (block.innerBlocks ?? [])
|
|
68
|
+
.map((item) => `<li>${item.rawHtml ?? renderInner(item.innerBlocks ?? [])}</li>`)
|
|
69
|
+
.join("");
|
|
70
|
+
return `<${tag} data-block-id="${escapeAttr(block._id)}">${items}</${tag}>`;
|
|
71
|
+
}
|
|
72
|
+
case "core/quote": {
|
|
73
|
+
const inner = renderInner(block.innerBlocks ?? []);
|
|
74
|
+
const cite = typeof block.attributes.citation === "string"
|
|
75
|
+
? `<cite>${escapeHtml(block.attributes.citation)}</cite>`
|
|
76
|
+
: "";
|
|
77
|
+
return `<blockquote data-block-id="${escapeAttr(block._id)}">${inner}${cite}</blockquote>`;
|
|
78
|
+
}
|
|
79
|
+
case "core/code": {
|
|
80
|
+
const code = typeof block.attributes.content === "string"
|
|
81
|
+
? escapeHtml(block.attributes.content)
|
|
82
|
+
: escapeHtml(block.rawHtml ?? "");
|
|
83
|
+
const lang = typeof block.attributes.language === "string"
|
|
84
|
+
? ` data-language="${escapeAttr(block.attributes.language)}"`
|
|
85
|
+
: "";
|
|
86
|
+
return `<pre data-block-id="${escapeAttr(block._id)}"${lang}><code>${code}</code></pre>`;
|
|
87
|
+
}
|
|
88
|
+
case "core/columns": {
|
|
89
|
+
const inner = renderInner(block.innerBlocks ?? []);
|
|
90
|
+
return `<div class="rt-columns" data-block-id="${escapeAttr(block._id)}">${inner}</div>`;
|
|
91
|
+
}
|
|
92
|
+
case "core/separator":
|
|
93
|
+
return `<hr data-block-id="${escapeAttr(block._id)}">`;
|
|
94
|
+
case "core/spacer": {
|
|
95
|
+
const heightRaw = block.attributes.height;
|
|
96
|
+
const height = typeof heightRaw === "string" ? heightRaw : "32px";
|
|
97
|
+
return `<div class="rt-spacer" data-block-id="${escapeAttr(block._id)}" style="height:${escapeAttr(height)}"></div>`;
|
|
98
|
+
}
|
|
99
|
+
case "core/group": {
|
|
100
|
+
const inner = renderInner(block.innerBlocks ?? []);
|
|
101
|
+
return `<div class="rt-group" data-block-id="${escapeAttr(block._id)}">${inner}</div>`;
|
|
102
|
+
}
|
|
103
|
+
default: {
|
|
104
|
+
// 알려지지 않은 block = opaque atom (codex v3 #4). rawHtml 출력만, 편집 X.
|
|
105
|
+
const inner = block.rawHtml ?? renderInner(block.innerBlocks ?? []);
|
|
106
|
+
return `<div class="rt-unknown-block" data-block-id="${escapeAttr(block._id)}" data-block-name="${escapeAttr(block.name)}">${inner}</div>`;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Block tree 전체를 HTML 문서 body 로 직렬화. */
|
|
112
|
+
export function renderBlocks(blocks: readonly Block[]): string {
|
|
113
|
+
return blocks.map((b) => renderBlock(b)).join("\n");
|
|
114
|
+
}
|
package/src/blog.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// ADR-0034 §1.5 amended — Astro public renderer = first-class with Next.
|
|
2
|
+
//
|
|
3
|
+
// External Astro 사이트가 cms-client `fetchPosts`/`fetchPost` 결과를 HTML 로
|
|
4
|
+
// 변환하는 helper. cms-renderer-next 의 `<RootTaleBlogList>` / `<RootTaleBlogPost>`
|
|
5
|
+
// 와 동등 surface.
|
|
6
|
+
//
|
|
7
|
+
// 사용 예 (.astro):
|
|
8
|
+
// ```astro
|
|
9
|
+
// ---
|
|
10
|
+
// import { renderBlogList } from "@roottale/cms-renderer-astro";
|
|
11
|
+
// const html = await renderBlogList({ apiKey: import.meta.env.ROOTTALE_API_KEY! });
|
|
12
|
+
// ---
|
|
13
|
+
// <Fragment set:html={html} />
|
|
14
|
+
// ```
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
fetchPost,
|
|
18
|
+
fetchPosts,
|
|
19
|
+
type CmsPostContent,
|
|
20
|
+
type CmsPostType,
|
|
21
|
+
} from "@roottale/cms-client/server";
|
|
22
|
+
|
|
23
|
+
import { renderTiptapDoc } from "./tiptap-to-html";
|
|
24
|
+
|
|
25
|
+
/** XSS 차단: text content escape (block-to-html.ts 와 동일 정책) */
|
|
26
|
+
function escapeHtml(text: string): string {
|
|
27
|
+
return text
|
|
28
|
+
.replace(/&/g, "&")
|
|
29
|
+
.replace(/</g, "<")
|
|
30
|
+
.replace(/>/g, ">")
|
|
31
|
+
.replace(/"/g, """)
|
|
32
|
+
.replace(/'/g, "'");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function escapeAttr(value: string): string {
|
|
36
|
+
return value.replace(/"/g, """).replace(/</g, "<");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function formatPublishedDate(iso: string): string {
|
|
40
|
+
const d = new Date(iso);
|
|
41
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
42
|
+
return new Intl.DateTimeFormat("ko-KR", {
|
|
43
|
+
year: "numeric",
|
|
44
|
+
month: "2-digit",
|
|
45
|
+
day: "2-digit",
|
|
46
|
+
}).format(d);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RenderBlogListOptions {
|
|
50
|
+
/** `rtlk_cust_*` API key (server-side only). */
|
|
51
|
+
apiKey: string;
|
|
52
|
+
/** Override the API base URL. Defaults to `https://api.roottale.com`. */
|
|
53
|
+
baseUrl?: string;
|
|
54
|
+
/** Page size, 1-100, default 20. */
|
|
55
|
+
limit?: number;
|
|
56
|
+
/** Filter by post type. Default `post` (blog list 의도). */
|
|
57
|
+
type?: CmsPostType;
|
|
58
|
+
/** Override the auto-resolved site id. */
|
|
59
|
+
siteId?: string;
|
|
60
|
+
/** Customer-side URL builder. Defaults to `/blog/${slug}`. */
|
|
61
|
+
postHref?: (post: CmsPostContent) => string;
|
|
62
|
+
/** Rendered when the tenant has no published posts yet. */
|
|
63
|
+
emptyMessage?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function defaultPostHref(post: CmsPostContent): string {
|
|
67
|
+
return `/blog/${post.slug}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function renderPostCard(
|
|
71
|
+
post: CmsPostContent,
|
|
72
|
+
href: (p: CmsPostContent) => string,
|
|
73
|
+
): string {
|
|
74
|
+
const date = escapeHtml(formatPublishedDate(post.publishedAt));
|
|
75
|
+
const title = escapeHtml(post.title);
|
|
76
|
+
const excerptHtml = post.excerpt
|
|
77
|
+
? `<p class="rt-cms-excerpt">${escapeHtml(post.excerpt)}</p>`
|
|
78
|
+
: "";
|
|
79
|
+
const url = escapeAttr(href(post));
|
|
80
|
+
return (
|
|
81
|
+
`<article data-roottale-cms="card" class="rt-cms-card">` +
|
|
82
|
+
`<a class="rt-cms-card-link" href="${url}">` +
|
|
83
|
+
`<p class="rt-cms-meta">${date}</p>` +
|
|
84
|
+
`<h2 class="rt-cms-title">${title}</h2>` +
|
|
85
|
+
`${excerptHtml}` +
|
|
86
|
+
`</a></article>`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function renderBlogList(
|
|
91
|
+
options: RenderBlogListOptions,
|
|
92
|
+
): Promise<string> {
|
|
93
|
+
const {
|
|
94
|
+
apiKey,
|
|
95
|
+
baseUrl,
|
|
96
|
+
limit,
|
|
97
|
+
siteId,
|
|
98
|
+
type = "post",
|
|
99
|
+
postHref = defaultPostHref,
|
|
100
|
+
emptyMessage = "아직 발행된 글이 없습니다.",
|
|
101
|
+
} = options;
|
|
102
|
+
|
|
103
|
+
const page = await fetchPosts({ apiKey, baseUrl, limit, type, siteId });
|
|
104
|
+
if (page.items.length === 0) {
|
|
105
|
+
return `<div data-roottale-cms="list"><p class="rt-cms-empty">${escapeHtml(emptyMessage)}</p></div>`;
|
|
106
|
+
}
|
|
107
|
+
const items = page.items
|
|
108
|
+
.map(
|
|
109
|
+
(post) =>
|
|
110
|
+
`<li class="rt-cms-list-item">${renderPostCard(post, postHref)}</li>`,
|
|
111
|
+
)
|
|
112
|
+
.join("");
|
|
113
|
+
return `<div data-roottale-cms="list"><ul class="rt-cms-list">${items}</ul></div>`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface RenderBlogPostOptions {
|
|
117
|
+
apiKey: string;
|
|
118
|
+
baseUrl?: string;
|
|
119
|
+
/** Slug (kebab-case) or UUIDv7 id. */
|
|
120
|
+
slugOrId: string;
|
|
121
|
+
/** Override the auto-resolved site id. */
|
|
122
|
+
siteId?: string;
|
|
123
|
+
/** Rendered when the slug is not found or not published. */
|
|
124
|
+
notFoundHtml?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function renderBlogPost(
|
|
128
|
+
options: RenderBlogPostOptions,
|
|
129
|
+
): Promise<string> {
|
|
130
|
+
const { apiKey, baseUrl, slugOrId, siteId, notFoundHtml } = options;
|
|
131
|
+
const post = await fetchPost({ apiKey, baseUrl, slugOrId, siteId });
|
|
132
|
+
if (!post) {
|
|
133
|
+
return (
|
|
134
|
+
notFoundHtml ??
|
|
135
|
+
`<div data-roottale-cms="post-missing"><p class="rt-cms-empty">글을 찾을 수 없습니다.</p></div>`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const bodyHtml = renderTiptapDoc(post.bodyJson);
|
|
140
|
+
const date = escapeHtml(formatPublishedDate(post.publishedAt));
|
|
141
|
+
const title = escapeHtml(post.title);
|
|
142
|
+
const excerpt = post.excerpt
|
|
143
|
+
? `<p class="rt-cms-excerpt">${escapeHtml(post.excerpt)}</p>`
|
|
144
|
+
: "";
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
`<article data-roottale-cms="post" class="rt-cms-article">` +
|
|
148
|
+
`<header>` +
|
|
149
|
+
`<p class="rt-cms-meta">${date}</p>` +
|
|
150
|
+
`<h1 class="rt-cms-title">${title}</h1>` +
|
|
151
|
+
`${excerpt}` +
|
|
152
|
+
`</header>` +
|
|
153
|
+
`<div data-roottale-cms="body">${bodyHtml}</div>` +
|
|
154
|
+
`</article>`
|
|
155
|
+
);
|
|
156
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @roottale/cms-renderer-astro — ADR-0034 §1.5 amended (Astro + Next 둘 다 1급
|
|
2
|
+
// public renderer). 동등 surface = @roottale/cms-renderer-next (RSC).
|
|
3
|
+
export * from "./block-to-html";
|
|
4
|
+
export * from "./tiptap-to-html";
|
|
5
|
+
export * from "./blog";
|
|
6
|
+
export * from "./seo";
|
|
7
|
+
export * from "./sitemap";
|
|
8
|
+
export * from "./robots";
|
package/src/robots.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// ADR-0034 §7.1.1 test #1 — robots.txt 자동.
|
|
2
|
+
|
|
3
|
+
export interface RobotsConfig {
|
|
4
|
+
/** User-agent 별 rule. 기본 "*" */
|
|
5
|
+
rules?: Array<{
|
|
6
|
+
userAgent: string;
|
|
7
|
+
allow?: readonly string[];
|
|
8
|
+
disallow?: readonly string[];
|
|
9
|
+
}>;
|
|
10
|
+
/** sitemap.xml 의 절대 URL */
|
|
11
|
+
sitemapUrl?: string;
|
|
12
|
+
/** 기본 disallow (draft preview 등 차단) */
|
|
13
|
+
defaultDisallow?: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function renderRobotsTxt(config: RobotsConfig = {}): string {
|
|
17
|
+
const rules = config.rules ?? [
|
|
18
|
+
{
|
|
19
|
+
userAgent: "*",
|
|
20
|
+
disallow: config.defaultDisallow ?? ["/admin", "/api/", "/preview"],
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const parts: string[] = [];
|
|
25
|
+
for (const rule of rules) {
|
|
26
|
+
parts.push(`User-agent: ${rule.userAgent}`);
|
|
27
|
+
for (const path of rule.allow ?? []) {
|
|
28
|
+
parts.push(`Allow: ${path}`);
|
|
29
|
+
}
|
|
30
|
+
for (const path of rule.disallow ?? []) {
|
|
31
|
+
parts.push(`Disallow: ${path}`);
|
|
32
|
+
}
|
|
33
|
+
parts.push("");
|
|
34
|
+
}
|
|
35
|
+
if (config.sitemapUrl) {
|
|
36
|
+
parts.push(`Sitemap: ${config.sitemapUrl}`);
|
|
37
|
+
}
|
|
38
|
+
return parts.join("\n");
|
|
39
|
+
}
|
package/src/seo.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// ADR-0034 §1 + §7.1.1 test #5 — Technical SEO 95+ 의 비타협 약속.
|
|
2
|
+
//
|
|
3
|
+
// Phase 1 Exit Contract test #1 + #5 + #8 (sitemap/robots/canonical 자동 주입).
|
|
4
|
+
|
|
5
|
+
export interface PostSeoInput {
|
|
6
|
+
title: string;
|
|
7
|
+
description?: string | null;
|
|
8
|
+
canonicalUrl: string;
|
|
9
|
+
siteName: string;
|
|
10
|
+
ogImage?: string | null;
|
|
11
|
+
publishedAt?: Date | null;
|
|
12
|
+
modifiedAt?: Date | null;
|
|
13
|
+
authorName?: string | null;
|
|
14
|
+
locale?: string; // default "ko_KR"
|
|
15
|
+
noIndex?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* <head> 메타 태그를 HTML 문자열로 생성. Astro layout 안에서 `<head>` 내부에 inject.
|
|
20
|
+
* - title (헌장 §1 = Technical SEO 95+ 의 비타협 약속)
|
|
21
|
+
* - meta description (있는 경우)
|
|
22
|
+
* - canonical link (slug change 시 301 redirect 와 정합)
|
|
23
|
+
* - Open Graph (basic)
|
|
24
|
+
* - robots noindex (옵션)
|
|
25
|
+
* - Schema.org Article JSON-LD
|
|
26
|
+
*/
|
|
27
|
+
export function renderSeoHead(input: PostSeoInput): string {
|
|
28
|
+
const locale = input.locale ?? "ko_KR";
|
|
29
|
+
const parts: string[] = [];
|
|
30
|
+
|
|
31
|
+
parts.push(`<title>${escapeText(input.title)}</title>`);
|
|
32
|
+
|
|
33
|
+
if (input.description) {
|
|
34
|
+
parts.push(`<meta name="description" content="${escapeAttr(input.description)}">`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
parts.push(`<link rel="canonical" href="${escapeAttr(input.canonicalUrl)}">`);
|
|
38
|
+
|
|
39
|
+
if (input.noIndex) {
|
|
40
|
+
parts.push(`<meta name="robots" content="noindex,follow">`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Open Graph
|
|
44
|
+
parts.push(`<meta property="og:type" content="article">`);
|
|
45
|
+
parts.push(`<meta property="og:title" content="${escapeAttr(input.title)}">`);
|
|
46
|
+
if (input.description) {
|
|
47
|
+
parts.push(`<meta property="og:description" content="${escapeAttr(input.description)}">`);
|
|
48
|
+
}
|
|
49
|
+
parts.push(`<meta property="og:url" content="${escapeAttr(input.canonicalUrl)}">`);
|
|
50
|
+
parts.push(`<meta property="og:site_name" content="${escapeAttr(input.siteName)}">`);
|
|
51
|
+
parts.push(`<meta property="og:locale" content="${escapeAttr(locale)}">`);
|
|
52
|
+
if (input.ogImage) {
|
|
53
|
+
parts.push(`<meta property="og:image" content="${escapeAttr(input.ogImage)}">`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Twitter card
|
|
57
|
+
parts.push(`<meta name="twitter:card" content="${input.ogImage ? "summary_large_image" : "summary"}">`);
|
|
58
|
+
parts.push(`<meta name="twitter:title" content="${escapeAttr(input.title)}">`);
|
|
59
|
+
if (input.description) {
|
|
60
|
+
parts.push(`<meta name="twitter:description" content="${escapeAttr(input.description)}">`);
|
|
61
|
+
}
|
|
62
|
+
if (input.ogImage) {
|
|
63
|
+
parts.push(`<meta name="twitter:image" content="${escapeAttr(input.ogImage)}">`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Schema.org Article JSON-LD
|
|
67
|
+
const schema: Record<string, unknown> = {
|
|
68
|
+
"@context": "https://schema.org",
|
|
69
|
+
"@type": "Article",
|
|
70
|
+
headline: input.title,
|
|
71
|
+
mainEntityOfPage: input.canonicalUrl,
|
|
72
|
+
};
|
|
73
|
+
if (input.description) schema.description = input.description;
|
|
74
|
+
if (input.publishedAt) schema.datePublished = input.publishedAt.toISOString();
|
|
75
|
+
if (input.modifiedAt) schema.dateModified = input.modifiedAt.toISOString();
|
|
76
|
+
if (input.authorName) {
|
|
77
|
+
schema.author = { "@type": "Person", name: input.authorName };
|
|
78
|
+
}
|
|
79
|
+
if (input.ogImage) schema.image = input.ogImage;
|
|
80
|
+
parts.push(
|
|
81
|
+
`<script type="application/ld+json">${JSON.stringify(schema)}</script>`,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return parts.join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function escapeText(value: string): string {
|
|
88
|
+
return value
|
|
89
|
+
.replace(/&/g, "&")
|
|
90
|
+
.replace(/</g, "<")
|
|
91
|
+
.replace(/>/g, ">");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function escapeAttr(value: string): string {
|
|
95
|
+
return value
|
|
96
|
+
.replace(/&/g, "&")
|
|
97
|
+
.replace(/"/g, """)
|
|
98
|
+
.replace(/</g, "<");
|
|
99
|
+
}
|
package/src/sitemap.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// ADR-0034 §7.1.1 test #1 — sitemap include 의무
|
|
2
|
+
//
|
|
3
|
+
// Per-site sitemap.xml 생성. published post/page 만 포함.
|
|
4
|
+
|
|
5
|
+
export interface SitemapEntry {
|
|
6
|
+
url: string;
|
|
7
|
+
lastModified?: Date | null;
|
|
8
|
+
changeFreq?: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
|
|
9
|
+
priority?: number; // 0.0~1.0
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* sitemap.xml 문자열 생성 (XML 1.0).
|
|
14
|
+
* Phase 1 = flat list. Phase 2+ = index sitemap (chunk by 50k entries).
|
|
15
|
+
*/
|
|
16
|
+
export function renderSitemapXml(entries: readonly SitemapEntry[]): string {
|
|
17
|
+
const xmlEntries = entries
|
|
18
|
+
.map((e) => {
|
|
19
|
+
const parts = [` <url>`, ` <loc>${escapeXml(e.url)}</loc>`];
|
|
20
|
+
if (e.lastModified) {
|
|
21
|
+
parts.push(` <lastmod>${e.lastModified.toISOString()}</lastmod>`);
|
|
22
|
+
}
|
|
23
|
+
if (e.changeFreq) {
|
|
24
|
+
parts.push(` <changefreq>${e.changeFreq}</changefreq>`);
|
|
25
|
+
}
|
|
26
|
+
if (typeof e.priority === "number") {
|
|
27
|
+
parts.push(` <priority>${e.priority.toFixed(1)}</priority>`);
|
|
28
|
+
}
|
|
29
|
+
parts.push(` </url>`);
|
|
30
|
+
return parts.join("\n");
|
|
31
|
+
})
|
|
32
|
+
.join("\n");
|
|
33
|
+
|
|
34
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
35
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
36
|
+
${xmlEntries}
|
|
37
|
+
</urlset>`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function escapeXml(value: string): string {
|
|
41
|
+
return value
|
|
42
|
+
.replace(/&/g, "&")
|
|
43
|
+
.replace(/</g, "<")
|
|
44
|
+
.replace(/>/g, ">")
|
|
45
|
+
.replace(/"/g, """)
|
|
46
|
+
.replace(/'/g, "'");
|
|
47
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { renderTiptapDoc } from "./tiptap-to-html";
|
|
4
|
+
|
|
5
|
+
function doc(...content: unknown[]) {
|
|
6
|
+
return { type: "doc", content };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function p(...content: unknown[]) {
|
|
10
|
+
return { type: "paragraph", content };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function text(value: string, marks: Array<{ type: string; attrs?: Record<string, unknown> }> = []) {
|
|
14
|
+
return { type: "text", text: value, marks };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("renderTiptapDoc — node coverage", () => {
|
|
18
|
+
it("renders paragraph + heading + horizontalRule + hardBreak", () => {
|
|
19
|
+
const html = renderTiptapDoc(
|
|
20
|
+
doc(
|
|
21
|
+
{ type: "heading", attrs: { level: 2 }, content: [text("타이틀")] },
|
|
22
|
+
p(text("문단")),
|
|
23
|
+
{ type: "horizontalRule" },
|
|
24
|
+
p(text("위"), { type: "hardBreak" }, text("아래")),
|
|
25
|
+
),
|
|
26
|
+
);
|
|
27
|
+
expect(html).toContain("<h2>타이틀</h2>");
|
|
28
|
+
expect(html).toContain("<p>문단</p>");
|
|
29
|
+
expect(html).toContain("<hr>");
|
|
30
|
+
expect(html).toContain("<p>위<br>아래</p>");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("clamps heading level to 1..3 and adds id when present", () => {
|
|
34
|
+
const html = renderTiptapDoc(
|
|
35
|
+
doc(
|
|
36
|
+
{ type: "heading", attrs: { level: 9 }, content: [text("clamp")] },
|
|
37
|
+
{
|
|
38
|
+
type: "heading",
|
|
39
|
+
attrs: { level: 3, id: "section-a" },
|
|
40
|
+
content: [text("with id")],
|
|
41
|
+
},
|
|
42
|
+
),
|
|
43
|
+
);
|
|
44
|
+
expect(html).toContain("<h3>clamp</h3>");
|
|
45
|
+
expect(html).toContain('<h3 id="section-a">with id</h3>');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("applies textAlign style on paragraph and heading", () => {
|
|
49
|
+
const html = renderTiptapDoc(
|
|
50
|
+
doc(
|
|
51
|
+
p({ ...text("center"), attrs: undefined }),
|
|
52
|
+
{
|
|
53
|
+
type: "paragraph",
|
|
54
|
+
attrs: { textAlign: "center" },
|
|
55
|
+
content: [text("center")],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
type: "heading",
|
|
59
|
+
attrs: { level: 2, textAlign: "right" },
|
|
60
|
+
content: [text("right")],
|
|
61
|
+
},
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
expect(html).toContain('<p style="text-align:center">center</p>');
|
|
65
|
+
expect(html).toContain('<h2 style="text-align:right">right</h2>');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("renders lists + blockquote + codeBlock", () => {
|
|
69
|
+
const html = renderTiptapDoc(
|
|
70
|
+
doc(
|
|
71
|
+
{
|
|
72
|
+
type: "bulletList",
|
|
73
|
+
content: [
|
|
74
|
+
{ type: "listItem", content: [p(text("a"))] },
|
|
75
|
+
{ type: "listItem", content: [p(text("b"))] },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
{ type: "blockquote", content: [p(text("q"))] },
|
|
79
|
+
{ type: "codeBlock", content: [text("hello < world")] },
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
expect(html).toContain("<ul><li><p>a</p></li><li><p>b</p></li></ul>");
|
|
83
|
+
expect(html).toContain("<blockquote><p>q</p></blockquote>");
|
|
84
|
+
expect(html).toContain("<pre><code>hello < world</code></pre>");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("renders image (src/alt/title/lazy) and skips missing src", () => {
|
|
88
|
+
const html = renderTiptapDoc(
|
|
89
|
+
doc(
|
|
90
|
+
{
|
|
91
|
+
type: "image",
|
|
92
|
+
attrs: {
|
|
93
|
+
src: "https://x.test/a.png",
|
|
94
|
+
alt: "alt",
|
|
95
|
+
title: "title",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{ type: "image", attrs: {} },
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
expect(html).toContain(
|
|
102
|
+
'<img src="https://x.test/a.png" alt="alt" title="title" loading="lazy">',
|
|
103
|
+
);
|
|
104
|
+
expect(html).not.toContain("<img>");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("renders columns / column wrappers", () => {
|
|
108
|
+
const html = renderTiptapDoc(
|
|
109
|
+
doc({
|
|
110
|
+
type: "columns",
|
|
111
|
+
content: [
|
|
112
|
+
{ type: "column", content: [p(text("L"))] },
|
|
113
|
+
{ type: "column", content: [p(text("R"))] },
|
|
114
|
+
],
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
expect(html).toContain(
|
|
118
|
+
'<div class="rt-columns"><div class="rt-column"><p>L</p></div><div class="rt-column"><p>R</p></div></div>',
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("renderTiptapDoc — mark coverage", () => {
|
|
124
|
+
it("renders bold / italic / underline / strike / code", () => {
|
|
125
|
+
const html = renderTiptapDoc(
|
|
126
|
+
doc(
|
|
127
|
+
p(
|
|
128
|
+
text("B", [{ type: "bold" }]),
|
|
129
|
+
text("I", [{ type: "italic" }]),
|
|
130
|
+
text("U", [{ type: "underline" }]),
|
|
131
|
+
text("S", [{ type: "strike" }]),
|
|
132
|
+
text("C", [{ type: "code" }]),
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
expect(html).toContain("<strong>B</strong>");
|
|
137
|
+
expect(html).toContain("<em>I</em>");
|
|
138
|
+
expect(html).toContain("<u>U</u>");
|
|
139
|
+
expect(html).toContain("<s>S</s>");
|
|
140
|
+
expect(html).toContain("<code>C</code>");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("renders highlight (with and without color attr)", () => {
|
|
144
|
+
const html = renderTiptapDoc(
|
|
145
|
+
doc(
|
|
146
|
+
p(
|
|
147
|
+
text("a", [{ type: "highlight", attrs: { color: "#FEF3C7" } }]),
|
|
148
|
+
text("b", [{ type: "highlight" }]),
|
|
149
|
+
),
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
expect(html).toContain('<mark style="background:#FEF3C7">a</mark>');
|
|
153
|
+
expect(html).toContain("<mark>b</mark>");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("renders textStyle only when color attr is set", () => {
|
|
157
|
+
const html = renderTiptapDoc(
|
|
158
|
+
doc(
|
|
159
|
+
p(
|
|
160
|
+
text("red", [{ type: "textStyle", attrs: { color: "#EF4444" } }]),
|
|
161
|
+
text("plain", [{ type: "textStyle" }]),
|
|
162
|
+
),
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
expect(html).toContain('<span style="color:#EF4444">red</span>');
|
|
166
|
+
// textStyle 가 color 없으면 wrapping 안 함 — escape 된 텍스트만 남음
|
|
167
|
+
expect(html).toContain("plain");
|
|
168
|
+
expect(html).not.toContain("<span>plain</span>");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("blocks unsafe href schemes (javascript:/data:/vbscript:) → '#'", () => {
|
|
172
|
+
const unsafe = ["javascript:alert(1)", "JaVaScRiPt:1", "data:text/html,x", "vbscript:msgbox", " javascript:foo "];
|
|
173
|
+
for (const u of unsafe) {
|
|
174
|
+
const html = renderTiptapDoc(
|
|
175
|
+
doc(p(text("x", [{ type: "link", attrs: { href: u } }]))),
|
|
176
|
+
);
|
|
177
|
+
expect(html).toContain('<a href="#" rel="noopener noreferrer" target="_blank">x</a>');
|
|
178
|
+
expect(html).not.toContain("javascript:");
|
|
179
|
+
expect(html).not.toContain("data:text/html");
|
|
180
|
+
expect(html).not.toContain("vbscript:");
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("allows http(s) / mailto / tel / root-relative / fragment / query href", () => {
|
|
185
|
+
const ok = [
|
|
186
|
+
"https://x.test/",
|
|
187
|
+
"http://x.test/",
|
|
188
|
+
"mailto:a@b.com",
|
|
189
|
+
"tel:+1",
|
|
190
|
+
"/path?q=1",
|
|
191
|
+
"#section",
|
|
192
|
+
"?q=2",
|
|
193
|
+
];
|
|
194
|
+
for (const u of ok) {
|
|
195
|
+
const html = renderTiptapDoc(
|
|
196
|
+
doc(p(text("x", [{ type: "link", attrs: { href: u } }]))),
|
|
197
|
+
);
|
|
198
|
+
expect(html).toContain(`href="${u.replace(/"/g, """)}"`);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("renders link with rel=noopener and target=_blank", () => {
|
|
203
|
+
const html = renderTiptapDoc(
|
|
204
|
+
doc(
|
|
205
|
+
p(
|
|
206
|
+
text("click", [
|
|
207
|
+
{ type: "link", attrs: { href: "https://x.test/path" } },
|
|
208
|
+
]),
|
|
209
|
+
),
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
expect(html).toContain(
|
|
213
|
+
'<a href="https://x.test/path" rel="noopener noreferrer" target="_blank">click</a>',
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("renderTiptapDoc — safety / edge", () => {
|
|
219
|
+
it("escapes html in text nodes", () => {
|
|
220
|
+
const html = renderTiptapDoc(doc(p(text("<script>x</script>"))));
|
|
221
|
+
expect(html).not.toContain("<script>");
|
|
222
|
+
expect(html).toContain("<script>x</script>");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("escapes quotes in link href attribute", () => {
|
|
226
|
+
const html = renderTiptapDoc(
|
|
227
|
+
doc(
|
|
228
|
+
p(
|
|
229
|
+
text("x", [
|
|
230
|
+
{
|
|
231
|
+
type: "link",
|
|
232
|
+
attrs: { href: 'https://x.test/"><img src=x' },
|
|
233
|
+
},
|
|
234
|
+
]),
|
|
235
|
+
),
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
expect(html).not.toContain('href="https://x.test/"><img');
|
|
239
|
+
expect(html).toContain(""");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("returns empty string for malformed input", () => {
|
|
243
|
+
expect(renderTiptapDoc(undefined)).toBe("");
|
|
244
|
+
expect(renderTiptapDoc(null)).toBe("");
|
|
245
|
+
expect(renderTiptapDoc({})).toBe("");
|
|
246
|
+
expect(renderTiptapDoc({ content: "not array" })).toBe("");
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Tiptap JSON doc → HTML 문자열.
|
|
2
|
+
//
|
|
3
|
+
// `block-to-html.ts` (Block JSON 처리) 와 별개 — 본 파일은 admin-tenant 가
|
|
4
|
+
// 저장하는 Tiptap doc (type:"doc", content:[...nodes]) 을 그대로 변환.
|
|
5
|
+
// cms-renderer-next 의 `RenderTiptap` (server.tsx) 과 1:1 동등 surface.
|
|
6
|
+
|
|
7
|
+
type TiptapMark = { type?: string; attrs?: Record<string, unknown> };
|
|
8
|
+
type TiptapNode = {
|
|
9
|
+
type?: string;
|
|
10
|
+
text?: string;
|
|
11
|
+
attrs?: Record<string, unknown>;
|
|
12
|
+
content?: TiptapNode[];
|
|
13
|
+
marks?: TiptapMark[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function escapeHtml(text: string): string {
|
|
17
|
+
return text
|
|
18
|
+
.replace(/&/g, "&")
|
|
19
|
+
.replace(/</g, "<")
|
|
20
|
+
.replace(/>/g, ">")
|
|
21
|
+
.replace(/"/g, """)
|
|
22
|
+
.replace(/'/g, "'");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function escapeAttr(value: string): string {
|
|
26
|
+
return value.replace(/"/g, """).replace(/</g, "<");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Defense-in-depth: even though admin LinkButton.normalizeUrl filters input,
|
|
31
|
+
* legacy imports / direct DB / external tenants can still emit unsafe href.
|
|
32
|
+
* Allow only http(s)/mailto/tel/root-relative/fragment/query — falls back to "#".
|
|
33
|
+
*/
|
|
34
|
+
function isSafeHref(value: unknown): value is string {
|
|
35
|
+
if (typeof value !== "string") return false;
|
|
36
|
+
const v = value.trim();
|
|
37
|
+
if (v.length === 0) return false;
|
|
38
|
+
if (v.startsWith("/") || v.startsWith("#") || v.startsWith("?")) return true;
|
|
39
|
+
return /^(https?:|mailto:|tel:)/i.test(v);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function alignStyleAttr(node: TiptapNode): string {
|
|
43
|
+
const a = node.attrs?.textAlign;
|
|
44
|
+
if (typeof a !== "string" || a === "" || a === "left") return "";
|
|
45
|
+
if (a === "center" || a === "right" || a === "justify") {
|
|
46
|
+
return ` style="text-align:${a}"`;
|
|
47
|
+
}
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function applyMarks(marks: TiptapMark[], inner: string): string {
|
|
52
|
+
let out = inner;
|
|
53
|
+
for (const m of marks) {
|
|
54
|
+
switch (m.type) {
|
|
55
|
+
case "bold":
|
|
56
|
+
out = `<strong>${out}</strong>`;
|
|
57
|
+
break;
|
|
58
|
+
case "italic":
|
|
59
|
+
out = `<em>${out}</em>`;
|
|
60
|
+
break;
|
|
61
|
+
case "underline":
|
|
62
|
+
out = `<u>${out}</u>`;
|
|
63
|
+
break;
|
|
64
|
+
case "strike":
|
|
65
|
+
out = `<s>${out}</s>`;
|
|
66
|
+
break;
|
|
67
|
+
case "code":
|
|
68
|
+
out = `<code>${out}</code>`;
|
|
69
|
+
break;
|
|
70
|
+
case "highlight": {
|
|
71
|
+
const color = m.attrs?.color;
|
|
72
|
+
const style =
|
|
73
|
+
typeof color === "string" && color.length > 0
|
|
74
|
+
? ` style="background:${escapeAttr(color)}"`
|
|
75
|
+
: "";
|
|
76
|
+
out = `<mark${style}>${out}</mark>`;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case "textStyle": {
|
|
80
|
+
const color = m.attrs?.color;
|
|
81
|
+
if (typeof color === "string" && color.length > 0) {
|
|
82
|
+
out = `<span style="color:${escapeAttr(color)}">${out}</span>`;
|
|
83
|
+
}
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
case "link": {
|
|
87
|
+
const raw = m.attrs?.href;
|
|
88
|
+
const safe = isSafeHref(raw) ? escapeAttr(raw) : "#";
|
|
89
|
+
out = `<a href="${safe}" rel="noopener noreferrer" target="_blank">${out}</a>`;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
default:
|
|
93
|
+
out = `<span>${out}</span>`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function renderNodes(nodes: TiptapNode[]): string {
|
|
100
|
+
return nodes.map((n) => renderNode(n)).join("");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function renderNode(node: TiptapNode): string {
|
|
104
|
+
if (!node || typeof node !== "object") return "";
|
|
105
|
+
const children = Array.isArray(node.content) ? renderNodes(node.content) : "";
|
|
106
|
+
switch (node.type) {
|
|
107
|
+
case "paragraph":
|
|
108
|
+
return `<p${alignStyleAttr(node)}>${children}</p>`;
|
|
109
|
+
case "heading": {
|
|
110
|
+
const levelRaw = Number(node.attrs?.level ?? 2);
|
|
111
|
+
const level = Math.min(Math.max(Number.isFinite(levelRaw) ? levelRaw : 2, 1), 3);
|
|
112
|
+
const idRaw = node.attrs?.id;
|
|
113
|
+
const idAttr =
|
|
114
|
+
typeof idRaw === "string" && idRaw.length > 0
|
|
115
|
+
? ` id="${escapeAttr(idRaw)}"`
|
|
116
|
+
: "";
|
|
117
|
+
return `<h${level}${idAttr}${alignStyleAttr(node)}>${children}</h${level}>`;
|
|
118
|
+
}
|
|
119
|
+
case "bulletList":
|
|
120
|
+
return `<ul>${children}</ul>`;
|
|
121
|
+
case "orderedList":
|
|
122
|
+
return `<ol>${children}</ol>`;
|
|
123
|
+
case "listItem":
|
|
124
|
+
return `<li>${children}</li>`;
|
|
125
|
+
case "blockquote":
|
|
126
|
+
return `<blockquote>${children}</blockquote>`;
|
|
127
|
+
case "codeBlock":
|
|
128
|
+
return `<pre><code>${children}</code></pre>`;
|
|
129
|
+
case "horizontalRule":
|
|
130
|
+
return "<hr>";
|
|
131
|
+
case "hardBreak":
|
|
132
|
+
return "<br>";
|
|
133
|
+
case "image": {
|
|
134
|
+
const src = node.attrs?.src;
|
|
135
|
+
if (typeof src !== "string" || src.length === 0) return "";
|
|
136
|
+
const alt = typeof node.attrs?.alt === "string" ? node.attrs.alt : "";
|
|
137
|
+
const title =
|
|
138
|
+
typeof node.attrs?.title === "string"
|
|
139
|
+
? ` title="${escapeAttr(node.attrs.title)}"`
|
|
140
|
+
: "";
|
|
141
|
+
return `<img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}"${title} loading="lazy">`;
|
|
142
|
+
}
|
|
143
|
+
case "columns":
|
|
144
|
+
return `<div class="rt-columns">${children}</div>`;
|
|
145
|
+
case "column":
|
|
146
|
+
return `<div class="rt-column">${children}</div>`;
|
|
147
|
+
case "text": {
|
|
148
|
+
const text = typeof node.text === "string" ? escapeHtml(node.text) : "";
|
|
149
|
+
const marks = Array.isArray(node.marks) ? node.marks : [];
|
|
150
|
+
if (marks.length === 0) return text;
|
|
151
|
+
return applyMarks(marks, text);
|
|
152
|
+
}
|
|
153
|
+
default:
|
|
154
|
+
return children ? `<span>${children}</span>` : "";
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Tiptap doc (admin-tenant 저장 형식) 을 HTML 문자열로 변환. */
|
|
159
|
+
export function renderTiptapDoc(doc: unknown): string {
|
|
160
|
+
if (!doc || typeof doc !== "object") return "";
|
|
161
|
+
const content = (doc as { content?: unknown }).content;
|
|
162
|
+
if (!Array.isArray(content)) return "";
|
|
163
|
+
return renderNodes(content as TiptapNode[]);
|
|
164
|
+
}
|