@roottale/cms-core 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # @roottale/cms-core
2
2
 
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - dist build 도입 — `.ts` 소스 직접 publish 폐기.
8
+
9
+ 기존 (0.1.0/0.2.0): `exports` 가 `./src/server.ts` 가리킴 → 외부 Next.js
10
+ Turbopack/Webpack 이 npm 모듈에서 TS 자동 컴파일 안 해 build 실패 (customer
11
+ site 가 `transpilePackages` 명시해야 했음).
12
+
13
+ 수정: `tsup` 으로 `dist/*.js` + `dist/*.d.ts` (ESM) 출력. `exports` 가 dist
14
+ 가리킴. customer site 측 `transpilePackages` 불필요.
15
+ - `cms-client@0.1.1` — ESM `dist/server.js` + types
16
+ - `cms-core@0.2.1` — ESM `dist/index.js` + types
17
+ - `cms-renderer-next@0.2.1` — ESM `dist/{server,index}.js` + types + `dist/cms-public.css`
18
+ - `server.tsx` 의 `import "./styles/cms-public.css"` 제거 — customer 가
19
+ `@roottale/cms-renderer-next/styles` 로 명시 import (README 정합).
20
+ - `cms-renderer-astro@0.2.1` — ESM `dist/index.js` + types
21
+
22
+ 후속 (customer site PR):
23
+ - roottale-web / kjmtax / theoneulsan 의 `next.config` 의 `transpilePackages`
24
+ 에서 `@roottale/cms-*` 제거. `pnpm update @roottale/cms-client @roottale/cms-renderer-next` 로 patch 적용.
25
+
3
26
  ## 0.2.0
4
27
 
5
28
  ### Minor Changes
@@ -0,0 +1,130 @@
1
+ import { z, ZodSchema } from 'zod';
2
+
3
+ /** WP-compatible name pattern: `core/<x>` | `roottale/<x>` */
4
+ declare const BLOCK_NAME_PATTERN: RegExp;
5
+ /**
6
+ * Block schema (recursive — innerBlocks).
7
+ * `_id` 는 internal-only (export 시 strip, ADR §5).
8
+ */
9
+ declare const blockSchema: z.ZodType<Block>;
10
+ interface Block {
11
+ /** uuidv7. Internal-only — never exported as WP delimiter attribute. */
12
+ _id: string;
13
+ /** `core/<x>` or `roottale/<x>` */
14
+ name: string;
15
+ attributes: Record<string, unknown>;
16
+ innerBlocks: Block[];
17
+ /** WP delimiter innerHTML (preserved for round-trip — codex v2 verdict #7). */
18
+ rawHtml?: string;
19
+ metadata?: {
20
+ bindings?: Record<string, {
21
+ source: string;
22
+ args?: Record<string, unknown>;
23
+ }>;
24
+ name?: string;
25
+ reusableRef?: string;
26
+ };
27
+ }
28
+ declare function isValidBlock(value: unknown): value is Block;
29
+ declare function parseBlock(value: unknown): Block;
30
+ /**
31
+ * Phase 1 core block names (10) — ADR-0034 §5.
32
+ * Phase 2+ 에 `core/embed`, `core/gallery`, `core/cover` 등 추가.
33
+ */
34
+ declare const PHASE_1_CORE_BLOCKS: readonly ["core/paragraph", "core/heading", "core/image", "core/list", "core/quote", "core/code", "core/columns", "core/group", "core/separator", "core/spacer"];
35
+ type Phase1CoreBlockName = (typeof PHASE_1_CORE_BLOCKS)[number];
36
+
37
+ /**
38
+ * Astro island spec — `client:visible` / `client:idle` / `client:load` 등.
39
+ * frontend 의 native interactivity. WP Interactivity API 카피 안 함.
40
+ */
41
+ interface AstroIslandSpec {
42
+ /** Astro `client:*` directive */
43
+ hydration?: "visible" | "idle" | "load" | "media" | "only";
44
+ /** client-side module path (relative to package) */
45
+ module?: string;
46
+ }
47
+ /**
48
+ * BlockDefinition<A> — block 의 schema + Astro render 어댑터.
49
+ * Phase 5 에 Next public renderer 활성 시 별도 ADR-amend 의 add 의무.
50
+ */
51
+ interface BlockDefinition<A = Record<string, unknown>> {
52
+ /** `core/<x>` or `roottale/<x>` */
53
+ name: string;
54
+ /** display metadata for admin editor */
55
+ meta: {
56
+ title: string;
57
+ description?: string;
58
+ category?: "text" | "media" | "design" | "widgets" | "theme" | "embed";
59
+ icon?: string;
60
+ };
61
+ /** attributes validation */
62
+ schema: ZodSchema<A>;
63
+ /** WP `supports` 등 flags */
64
+ supports?: {
65
+ align?: boolean;
66
+ color?: boolean;
67
+ spacing?: boolean;
68
+ typography?: boolean;
69
+ customClassName?: boolean;
70
+ };
71
+ /** parent block name 강제 (e.g. `core/column` 은 `core/columns` 아래만) */
72
+ parent?: readonly string[];
73
+ /** ancestor block name 강제 */
74
+ ancestor?: readonly string[];
75
+ /** Astro server render — 본 함수는 packages/cms-renderer-astro 에서 호출 */
76
+ astroRender: (block: Block & {
77
+ attributes: A;
78
+ }) => unknown;
79
+ /** Astro client island (optional) */
80
+ clientHydration?: {
81
+ astro?: AstroIslandSpec;
82
+ };
83
+ }
84
+ /**
85
+ * Block registry — Phase 1 에서 register/lookup helper 추가 (현재는 type-only).
86
+ */
87
+ type BlockRegistry = ReadonlyMap<string, BlockDefinition>;
88
+
89
+ /**
90
+ * Block → WP delimiter 형식 직렬화 (export).
91
+ * `_id` 와 `metadata.reusableRef` 는 strip — WP 호환.
92
+ */
93
+ declare function serializeBlock(block: Block): string;
94
+
95
+ interface PreviewTokenPayload {
96
+ postId: string;
97
+ expiresAt: Date;
98
+ }
99
+ /** 새 preview token 생성. DB insert 책임은 caller. */
100
+ declare function issuePreviewToken(secret: string, payload: PreviewTokenPayload): string;
101
+ /** Token 검증 (서명 + 만료). DB row consumed_at update 는 caller (atomic). */
102
+ declare function verifyPreviewToken(secret: string, token: string): PreviewTokenPayload | null;
103
+
104
+ /**
105
+ * 미니멀 XSS sanitize — Tiptap HTML output 또는 rawHtml 의 안전 정리.
106
+ * Phase 1 acceptance: `<script>alert(1)</script>` 인라인이 inert 가 되어야 함.
107
+ */
108
+ declare function sanitizeHtml(html: string): string;
109
+ /**
110
+ * 표준 CSP 헤더 값 (Phase 1 acceptance test #7 정합).
111
+ * Astro middleware / Next.js header config 에서 사용.
112
+ */
113
+ declare const PHASE_1_CSP: string;
114
+
115
+ /**
116
+ * Block 에 stable id 주입.
117
+ * 이미 `_id` 있는 block 은 그대로, 없으면 uuidv7 생성. recursive (innerBlocks).
118
+ */
119
+ declare function ensureStableIds(block: Omit<Block, "_id"> & {
120
+ _id?: string;
121
+ }): Block;
122
+ /**
123
+ * 한 번에 block tree 전체에 stable id 주입.
124
+ * import 또는 새 post 생성 시 entrypoint.
125
+ */
126
+ declare function injectStableIdsTree(blocks: readonly (Omit<Block, "_id"> & {
127
+ _id?: string;
128
+ })[]): Block[];
129
+
130
+ export { type AstroIslandSpec, BLOCK_NAME_PATTERN, type Block, type BlockDefinition, type BlockRegistry, PHASE_1_CORE_BLOCKS, PHASE_1_CSP, type Phase1CoreBlockName, type PreviewTokenPayload, blockSchema, ensureStableIds, injectStableIdsTree, isValidBlock, issuePreviewToken, parseBlock, sanitizeHtml, serializeBlock, verifyPreviewToken };
package/dist/index.js ADDED
@@ -0,0 +1,144 @@
1
+ // src/block.ts
2
+ import { z } from "zod";
3
+ var BLOCK_NAME_PATTERN = /^(core|roottale)\/[a-z0-9-]+$/;
4
+ var blockMetadataSchema = z.object({
5
+ bindings: z.record(
6
+ z.object({
7
+ source: z.string(),
8
+ args: z.record(z.unknown()).optional()
9
+ })
10
+ ).optional(),
11
+ name: z.string().optional(),
12
+ reusableRef: z.string().optional()
13
+ }).strict().optional();
14
+ var blockSchema = z.lazy(
15
+ () => z.object({
16
+ _id: z.string().uuid(),
17
+ name: z.string().regex(BLOCK_NAME_PATTERN),
18
+ attributes: z.record(z.unknown()),
19
+ innerBlocks: z.array(blockSchema),
20
+ rawHtml: z.string().optional(),
21
+ metadata: blockMetadataSchema
22
+ }).strict()
23
+ );
24
+ function isValidBlock(value) {
25
+ return blockSchema.safeParse(value).success;
26
+ }
27
+ function parseBlock(value) {
28
+ return blockSchema.parse(value);
29
+ }
30
+ var PHASE_1_CORE_BLOCKS = [
31
+ "core/paragraph",
32
+ "core/heading",
33
+ "core/image",
34
+ "core/list",
35
+ "core/quote",
36
+ "core/code",
37
+ "core/columns",
38
+ "core/group",
39
+ "core/separator",
40
+ "core/spacer"
41
+ ];
42
+
43
+ // src/delimiter.ts
44
+ function serializeBlock(block) {
45
+ const exportable = sanitizeAttributesForExport(block);
46
+ const attrsJson = serializeAttributes(exportable.attributes);
47
+ const innerHtml = block.rawHtml ?? "";
48
+ const innerSerialized = (block.innerBlocks ?? []).map((b) => serializeBlock(b)).join("\n");
49
+ if (!innerHtml && innerSerialized.length === 0) {
50
+ return attrsJson ? `<!-- wp:${normalizeName(block.name)} ${attrsJson} /-->` : `<!-- wp:${normalizeName(block.name)} /-->`;
51
+ }
52
+ const open = attrsJson ? `<!-- wp:${normalizeName(block.name)} ${attrsJson} -->` : `<!-- wp:${normalizeName(block.name)} -->`;
53
+ const close = `<!-- /wp:${normalizeName(block.name)} -->`;
54
+ const body = [innerHtml, innerSerialized].filter(Boolean).join("\n");
55
+ return `${open}
56
+ ${body}
57
+ ${close}`;
58
+ }
59
+ function normalizeName(name) {
60
+ return name.startsWith("core/") ? name.slice("core/".length) : name;
61
+ }
62
+ function serializeAttributes(attrs) {
63
+ const keys = Object.keys(attrs);
64
+ if (keys.length === 0) return "";
65
+ return JSON.stringify(attrs);
66
+ }
67
+ function sanitizeAttributesForExport(block) {
68
+ return { attributes: { ...block.attributes } };
69
+ }
70
+
71
+ // src/preview-token.ts
72
+ import { createHmac, randomBytes, timingSafeEqual } from "crypto";
73
+ var DELIMITER = ".";
74
+ function base64url(buf) {
75
+ return buf.toString("base64url");
76
+ }
77
+ function fromBase64url(s) {
78
+ return Buffer.from(s, "base64url");
79
+ }
80
+ function sign(secret, message) {
81
+ return base64url(createHmac("sha256", secret).update(message).digest());
82
+ }
83
+ function issuePreviewToken(secret, payload) {
84
+ const exp = payload.expiresAt.getTime();
85
+ const nonce = base64url(randomBytes(12));
86
+ const message = `${payload.postId}${DELIMITER}${exp}${DELIMITER}${nonce}`;
87
+ const sig = sign(secret, message);
88
+ return `${message}${DELIMITER}${sig}`;
89
+ }
90
+ function verifyPreviewToken(secret, token) {
91
+ const parts = token.split(DELIMITER);
92
+ if (parts.length !== 4) return null;
93
+ const [postId, expRaw, nonce, sig] = parts;
94
+ const exp = Number(expRaw);
95
+ if (!Number.isFinite(exp)) return null;
96
+ if (Date.now() > exp) return null;
97
+ const expectedSig = sign(secret, `${postId}${DELIMITER}${exp}${DELIMITER}${nonce}`);
98
+ const a = fromBase64url(sig);
99
+ const b = fromBase64url(expectedSig);
100
+ if (a.length !== b.length) return null;
101
+ if (!timingSafeEqual(a, b)) return null;
102
+ return { postId, expiresAt: new Date(exp) };
103
+ }
104
+
105
+ // src/sanitize.ts
106
+ var SCRIPT_TAG_RE = /<script\b[^>]*>[\s\S]*?<\/script\s*>/gi;
107
+ var ON_EVENT_ATTR_RE = /\s+on[a-z]+\s*=\s*"(?:[^"\\]|\\.)*"/gi;
108
+ var ON_EVENT_ATTR_RE_SINGLE = /\s+on[a-z]+\s*=\s*'(?:[^'\\]|\\.)*'/gi;
109
+ var JS_URL_RE = /\s+(href|src)\s*=\s*(['"])\s*javascript:[^'"]*\2/gi;
110
+ var DATA_URL_RE = /\s+(href|src)\s*=\s*(['"])\s*data:(?!image\/)[^'"]*\2/gi;
111
+ function sanitizeHtml(html) {
112
+ return html.replace(SCRIPT_TAG_RE, "").replace(ON_EVENT_ATTR_RE, "").replace(ON_EVENT_ATTR_RE_SINGLE, "").replace(JS_URL_RE, ' $1="#"').replace(DATA_URL_RE, ' $1="#"');
113
+ }
114
+ var PHASE_1_CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://imagedelivery.net https://*.r2.cloudflarestorage.com; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
115
+
116
+ // src/stable-id.ts
117
+ import { uuidv7 } from "uuidv7";
118
+ function ensureStableIds(block) {
119
+ return {
120
+ ...block,
121
+ _id: block._id ?? uuidv7(),
122
+ innerBlocks: (block.innerBlocks ?? []).map(
123
+ (inner) => ensureStableIds(inner)
124
+ )
125
+ };
126
+ }
127
+ function injectStableIdsTree(blocks) {
128
+ return blocks.map((b) => ensureStableIds(b));
129
+ }
130
+ export {
131
+ BLOCK_NAME_PATTERN,
132
+ PHASE_1_CORE_BLOCKS,
133
+ PHASE_1_CSP,
134
+ blockSchema,
135
+ ensureStableIds,
136
+ injectStableIdsTree,
137
+ isValidBlock,
138
+ issuePreviewToken,
139
+ parseBlock,
140
+ sanitizeHtml,
141
+ serializeBlock,
142
+ verifyPreviewToken
143
+ };
144
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/block.ts","../src/delimiter.ts","../src/preview-token.ts","../src/sanitize.ts","../src/stable-id.ts"],"sourcesContent":["// ADR-0034 §5 — Block JSON 스펙 (WP 호환)\n// Gutenberg block shape + 우리 확장:\n// - `_id` (uuidv7 stable across revisions, ADR §2 P5)\n// - `rawHtml` (WP delimiter innerHTML 보존, codex v2 verdict #7)\n// - `metadata.reusableRef` (Phase 2 = flat expand)\n\nimport { z } from \"zod\";\n\n/** WP-compatible name pattern: `core/<x>` | `roottale/<x>` */\nexport const BLOCK_NAME_PATTERN = /^(core|roottale)\\/[a-z0-9-]+$/;\n\nconst blockMetadataSchema = z\n .object({\n bindings: z\n .record(\n z.object({\n source: z.string(),\n args: z.record(z.unknown()).optional(),\n }),\n )\n .optional(),\n name: z.string().optional(),\n reusableRef: z.string().optional(),\n })\n .strict()\n .optional();\n\n/**\n * Block schema (recursive — innerBlocks).\n * `_id` 는 internal-only (export 시 strip, ADR §5).\n */\nexport const blockSchema: z.ZodType<Block> = z.lazy(() =>\n z\n .object({\n _id: z.string().uuid(),\n name: z.string().regex(BLOCK_NAME_PATTERN),\n attributes: z.record(z.unknown()),\n innerBlocks: z.array(blockSchema),\n rawHtml: z.string().optional(),\n metadata: blockMetadataSchema,\n })\n .strict(),\n);\n\nexport interface Block {\n /** uuidv7. Internal-only — never exported as WP delimiter attribute. */\n _id: string;\n /** `core/<x>` or `roottale/<x>` */\n name: string;\n attributes: Record<string, unknown>;\n innerBlocks: Block[];\n /** WP delimiter innerHTML (preserved for round-trip — codex v2 verdict #7). */\n rawHtml?: string;\n metadata?: {\n bindings?: Record<string, { source: string; args?: Record<string, unknown> }>;\n name?: string;\n reusableRef?: string;\n };\n}\n\nexport function isValidBlock(value: unknown): value is Block {\n return blockSchema.safeParse(value).success;\n}\n\nexport function parseBlock(value: unknown): Block {\n return blockSchema.parse(value);\n}\n\n/**\n * Phase 1 core block names (10) — ADR-0034 §5.\n * Phase 2+ 에 `core/embed`, `core/gallery`, `core/cover` 등 추가.\n */\nexport const PHASE_1_CORE_BLOCKS = [\n \"core/paragraph\",\n \"core/heading\",\n \"core/image\",\n \"core/list\",\n \"core/quote\",\n \"core/code\",\n \"core/columns\",\n \"core/group\",\n \"core/separator\",\n \"core/spacer\",\n] as const;\nexport type Phase1CoreBlockName = (typeof PHASE_1_CORE_BLOCKS)[number];\n","// ADR-0034 §5 + §6 — WP delimiter parse/serialize\n//\n// WP 의 block serialization 은 HTML 주석 형태:\n// <!-- wp:paragraph {\"attr\":1} -->\n// <p>본문</p>\n// <!-- /wp:paragraph -->\n//\n// Round-trip 룰 (codex v2 verdict #7 + v3 verdict #4):\n// - `_id` 는 export 시 strip (WP-side import 가 unknown attribute 로 인식해 깨짐 차단)\n// - `rawHtml` ↔ innerHTML\n// - 우리가 schema 정의 안 한 block = Tiptap opaque atom node (편집 불가, 보존)\n\nimport type { Block } from \"./block\";\n\n/**\n * Block → WP delimiter 형식 직렬화 (export).\n * `_id` 와 `metadata.reusableRef` 는 strip — WP 호환.\n */\nexport function serializeBlock(block: Block): string {\n const exportable = sanitizeAttributesForExport(block);\n const attrsJson = serializeAttributes(exportable.attributes);\n const innerHtml = block.rawHtml ?? \"\";\n const innerSerialized = (block.innerBlocks ?? [])\n .map((b) => serializeBlock(b))\n .join(\"\\n\");\n\n // self-closing 형태 (innerHTML + innerBlocks 모두 없으면)\n if (!innerHtml && innerSerialized.length === 0) {\n return attrsJson\n ? `<!-- wp:${normalizeName(block.name)} ${attrsJson} /-->`\n : `<!-- wp:${normalizeName(block.name)} /-->`;\n }\n\n const open = attrsJson\n ? `<!-- wp:${normalizeName(block.name)} ${attrsJson} -->`\n : `<!-- wp:${normalizeName(block.name)} -->`;\n const close = `<!-- /wp:${normalizeName(block.name)} -->`;\n\n const body = [innerHtml, innerSerialized].filter(Boolean).join(\"\\n\");\n return `${open}\\n${body}\\n${close}`;\n}\n\n/** WP-side 에서 `core/` 는 보통 namespace 생략 (e.g. `<!-- wp:paragraph -->`) */\nfunction normalizeName(name: string): string {\n return name.startsWith(\"core/\") ? name.slice(\"core/\".length) : name;\n}\n\nfunction serializeAttributes(attrs: Record<string, unknown>): string {\n const keys = Object.keys(attrs);\n if (keys.length === 0) return \"\";\n return JSON.stringify(attrs);\n}\n\nfunction sanitizeAttributesForExport(block: Block): {\n attributes: Record<string, unknown>;\n} {\n // `_id` 와 `metadata.reusableRef` 제거. attributes 안의 임의 키는 그대로 export.\n return { attributes: { ...block.attributes } };\n}\n","// ADR-0034 §7.1.1 test #10 — Draft preview token sign/verify.\n//\n// HMAC-SHA256 signed token + DB row (post_preview_tokens_v2) lookup.\n// Atomic consume = consumedAt update at verify time.\n//\n// Token format: `<postId>.<expiresAt-ms>.<hmac-base64url>`\n\nimport { createHmac, randomBytes, timingSafeEqual } from \"node:crypto\";\n\nexport interface PreviewTokenPayload {\n postId: string;\n expiresAt: Date;\n}\n\nconst DELIMITER = \".\";\n\nfunction base64url(buf: Buffer): string {\n return buf.toString(\"base64url\");\n}\n\nfunction fromBase64url(s: string): Buffer {\n return Buffer.from(s, \"base64url\");\n}\n\nfunction sign(secret: string, message: string): string {\n return base64url(createHmac(\"sha256\", secret).update(message).digest());\n}\n\n/** 새 preview token 생성. DB insert 책임은 caller. */\nexport function issuePreviewToken(\n secret: string,\n payload: PreviewTokenPayload,\n): string {\n const exp = payload.expiresAt.getTime();\n const nonce = base64url(randomBytes(12));\n const message = `${payload.postId}${DELIMITER}${exp}${DELIMITER}${nonce}`;\n const sig = sign(secret, message);\n return `${message}${DELIMITER}${sig}`;\n}\n\n/** Token 검증 (서명 + 만료). DB row consumed_at update 는 caller (atomic). */\nexport function verifyPreviewToken(\n secret: string,\n token: string,\n): PreviewTokenPayload | null {\n const parts = token.split(DELIMITER);\n if (parts.length !== 4) return null;\n const [postId, expRaw, nonce, sig] = parts as [string, string, string, string];\n const exp = Number(expRaw);\n if (!Number.isFinite(exp)) return null;\n if (Date.now() > exp) return null;\n\n const expectedSig = sign(secret, `${postId}${DELIMITER}${exp}${DELIMITER}${nonce}`);\n const a = fromBase64url(sig);\n const b = fromBase64url(expectedSig);\n if (a.length !== b.length) return null;\n if (!timingSafeEqual(a, b)) return null;\n\n return { postId, expiresAt: new Date(exp) };\n}\n","// ADR-0034 §7.1.1 test #7 — XSS sanitize helper (minimal allow-list).\n//\n// Phase 1 = strict CSP (`script-src 'self'`) 가 1차 방어. 본 helper 는 2차\n// 방어로 Tiptap output 의 inline `<script>` / `on*` attribute / `javascript:`\n// URL 을 strip. Phase 2 에 본격 sanitize (sanitize-html 또는 DOMPurify).\n\nconst SCRIPT_TAG_RE = /<script\\b[^>]*>[\\s\\S]*?<\\/script\\s*>/gi;\nconst ON_EVENT_ATTR_RE = /\\s+on[a-z]+\\s*=\\s*\"(?:[^\"\\\\]|\\\\.)*\"/gi;\nconst ON_EVENT_ATTR_RE_SINGLE = /\\s+on[a-z]+\\s*=\\s*'(?:[^'\\\\]|\\\\.)*'/gi;\nconst JS_URL_RE = /\\s+(href|src)\\s*=\\s*(['\"])\\s*javascript:[^'\"]*\\2/gi;\nconst DATA_URL_RE = /\\s+(href|src)\\s*=\\s*(['\"])\\s*data:(?!image\\/)[^'\"]*\\2/gi;\n\n/**\n * 미니멀 XSS sanitize — Tiptap HTML output 또는 rawHtml 의 안전 정리.\n * Phase 1 acceptance: `<script>alert(1)</script>` 인라인이 inert 가 되어야 함.\n */\nexport function sanitizeHtml(html: string): string {\n return html\n .replace(SCRIPT_TAG_RE, \"\")\n .replace(ON_EVENT_ATTR_RE, \"\")\n .replace(ON_EVENT_ATTR_RE_SINGLE, \"\")\n .replace(JS_URL_RE, \" $1=\\\"#\\\"\")\n .replace(DATA_URL_RE, \" $1=\\\"#\\\"\");\n}\n\n/**\n * 표준 CSP 헤더 값 (Phase 1 acceptance test #7 정합).\n * Astro middleware / Next.js header config 에서 사용.\n */\nexport const PHASE_1_CSP =\n \"default-src 'self'; \" +\n \"script-src 'self'; \" +\n \"style-src 'self' 'unsafe-inline'; \" +\n \"img-src 'self' data: https://imagedelivery.net https://*.r2.cloudflarestorage.com; \" +\n \"font-src 'self' data:; \" +\n \"connect-src 'self'; \" +\n \"frame-ancestors 'none'; \" +\n \"base-uri 'self'; \" +\n \"form-action 'self'\";\n","// ADR-0034 §2 P5 — Stable block id (uuidv7 inject)\n//\n// Tiptap 의 BlockId extension 이 본 helper 를 사용해 새 block 에 uuidv7 inject.\n// 기존 block 의 `_id` 는 보존 (revision 간 정합).\n\nimport { uuidv7 } from \"uuidv7\";\n\nimport type { Block } from \"./block\";\n\n/**\n * Block 에 stable id 주입.\n * 이미 `_id` 있는 block 은 그대로, 없으면 uuidv7 생성. recursive (innerBlocks).\n */\nexport function ensureStableIds(block: Omit<Block, \"_id\"> & { _id?: string }): Block {\n return {\n ...block,\n _id: block._id ?? uuidv7(),\n innerBlocks: (block.innerBlocks ?? []).map((inner) =>\n ensureStableIds(inner as Omit<Block, \"_id\"> & { _id?: string }),\n ),\n };\n}\n\n/**\n * 한 번에 block tree 전체에 stable id 주입.\n * import 또는 새 post 생성 시 entrypoint.\n */\nexport function injectStableIdsTree(blocks: readonly (Omit<Block, \"_id\"> & { _id?: string })[]): Block[] {\n return blocks.map((b) => ensureStableIds(b));\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,15 +1,20 @@
1
1
  {
2
2
  "name": "@roottale/cms-core",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "RootTale CMS core — Block JSON schema (Gutenberg-compatible), serialize, validate. ADR-0034 §3 §5. Transitive dependency of @roottale/cms-renderer-next / @roottale/cms-renderer-astro.",
6
- "main": "./src/index.ts",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
7
8
  "exports": {
8
- ".": "./src/index.ts",
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ },
9
14
  "./package.json": "./package.json"
10
15
  },
11
16
  "files": [
12
- "src/",
17
+ "dist/",
13
18
  "README.md",
14
19
  "CHANGELOG.md"
15
20
  ],
@@ -41,7 +46,7 @@
41
46
  "directory": "packages/cms-core"
42
47
  },
43
48
  "scripts": {
44
- "build": "tsc --noEmit",
49
+ "build": "tsup",
45
50
  "type-check": "tsc --noEmit",
46
51
  "test": "vitest run --passWithNoTests"
47
52
  }
@@ -1,58 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import { issuePreviewToken, verifyPreviewToken } from "../preview-token";
4
-
5
- describe("preview-token (HMAC + expiry)", () => {
6
- const secret = "test-secret-do-not-use-in-prod";
7
-
8
- it("issue → verify round-trips postId + expiry", () => {
9
- const expiresAt = new Date(Date.now() + 60_000);
10
- const token = issuePreviewToken(secret, {
11
- postId: "post-abc",
12
- expiresAt,
13
- });
14
- const payload = verifyPreviewToken(secret, token);
15
- expect(payload?.postId).toBe("post-abc");
16
- expect(payload?.expiresAt.getTime()).toBe(expiresAt.getTime());
17
- });
18
-
19
- it("rejects wrong secret", () => {
20
- const token = issuePreviewToken(secret, {
21
- postId: "post-abc",
22
- expiresAt: new Date(Date.now() + 60_000),
23
- });
24
- expect(verifyPreviewToken("other-secret", token)).toBeNull();
25
- });
26
-
27
- it("rejects expired token", () => {
28
- const token = issuePreviewToken(secret, {
29
- postId: "post-abc",
30
- expiresAt: new Date(Date.now() - 1),
31
- });
32
- expect(verifyPreviewToken(secret, token)).toBeNull();
33
- });
34
-
35
- it("rejects tampered postId", () => {
36
- const token = issuePreviewToken(secret, {
37
- postId: "post-abc",
38
- expiresAt: new Date(Date.now() + 60_000),
39
- });
40
- const parts = token.split(".");
41
- // swap postId
42
- parts[0] = "post-evil";
43
- const tampered = parts.join(".");
44
- expect(verifyPreviewToken(secret, tampered)).toBeNull();
45
- });
46
-
47
- it("rejects malformed token (wrong segment count)", () => {
48
- expect(verifyPreviewToken(secret, "only.two")).toBeNull();
49
- expect(verifyPreviewToken(secret, "a.b.c.d.e")).toBeNull();
50
- });
51
-
52
- it("two issues with same payload differ (nonce randomness)", () => {
53
- const expiresAt = new Date(Date.now() + 60_000);
54
- const t1 = issuePreviewToken(secret, { postId: "p", expiresAt });
55
- const t2 = issuePreviewToken(secret, { postId: "p", expiresAt });
56
- expect(t1).not.toBe(t2);
57
- });
58
- });
@@ -1,57 +0,0 @@
1
- // ADR-0034 §5 — BlockDefinition (렌더러 어댑터)
2
- // codex v2 verdict #3 + #11: Astro = 유일 public renderer (Phase 0~4).
3
- // `nextRender` 시그니처 의도적 제거 — Phase 5 에 Next public 활성 시 별도 ADR.
4
-
5
- import type { ZodSchema } from "zod";
6
-
7
- import type { Block } from "./block";
8
-
9
- /**
10
- * Astro island spec — `client:visible` / `client:idle` / `client:load` 등.
11
- * frontend 의 native interactivity. WP Interactivity API 카피 안 함.
12
- */
13
- export interface AstroIslandSpec {
14
- /** Astro `client:*` directive */
15
- hydration?: "visible" | "idle" | "load" | "media" | "only";
16
- /** client-side module path (relative to package) */
17
- module?: string;
18
- }
19
-
20
- /**
21
- * BlockDefinition<A> — block 의 schema + Astro render 어댑터.
22
- * Phase 5 에 Next public renderer 활성 시 별도 ADR-amend 의 add 의무.
23
- */
24
- export interface BlockDefinition<A = Record<string, unknown>> {
25
- /** `core/<x>` or `roottale/<x>` */
26
- name: string;
27
- /** display metadata for admin editor */
28
- meta: {
29
- title: string;
30
- description?: string;
31
- category?: "text" | "media" | "design" | "widgets" | "theme" | "embed";
32
- icon?: string;
33
- };
34
- /** attributes validation */
35
- schema: ZodSchema<A>;
36
- /** WP `supports` 등 flags */
37
- supports?: {
38
- align?: boolean;
39
- color?: boolean;
40
- spacing?: boolean;
41
- typography?: boolean;
42
- customClassName?: boolean;
43
- };
44
- /** parent block name 강제 (e.g. `core/column` 은 `core/columns` 아래만) */
45
- parent?: readonly string[];
46
- /** ancestor block name 강제 */
47
- ancestor?: readonly string[];
48
- /** Astro server render — 본 함수는 packages/cms-renderer-astro 에서 호출 */
49
- astroRender: (block: Block & { attributes: A }) => unknown;
50
- /** Astro client island (optional) */
51
- clientHydration?: { astro?: AstroIslandSpec };
52
- }
53
-
54
- /**
55
- * Block registry — Phase 1 에서 register/lookup helper 추가 (현재는 type-only).
56
- */
57
- export type BlockRegistry = ReadonlyMap<string, BlockDefinition>;
package/src/block.ts DELETED
@@ -1,85 +0,0 @@
1
- // ADR-0034 §5 — Block JSON 스펙 (WP 호환)
2
- // Gutenberg block shape + 우리 확장:
3
- // - `_id` (uuidv7 stable across revisions, ADR §2 P5)
4
- // - `rawHtml` (WP delimiter innerHTML 보존, codex v2 verdict #7)
5
- // - `metadata.reusableRef` (Phase 2 = flat expand)
6
-
7
- import { z } from "zod";
8
-
9
- /** WP-compatible name pattern: `core/<x>` | `roottale/<x>` */
10
- export const BLOCK_NAME_PATTERN = /^(core|roottale)\/[a-z0-9-]+$/;
11
-
12
- const blockMetadataSchema = z
13
- .object({
14
- bindings: z
15
- .record(
16
- z.object({
17
- source: z.string(),
18
- args: z.record(z.unknown()).optional(),
19
- }),
20
- )
21
- .optional(),
22
- name: z.string().optional(),
23
- reusableRef: z.string().optional(),
24
- })
25
- .strict()
26
- .optional();
27
-
28
- /**
29
- * Block schema (recursive — innerBlocks).
30
- * `_id` 는 internal-only (export 시 strip, ADR §5).
31
- */
32
- export const blockSchema: z.ZodType<Block> = z.lazy(() =>
33
- z
34
- .object({
35
- _id: z.string().uuid(),
36
- name: z.string().regex(BLOCK_NAME_PATTERN),
37
- attributes: z.record(z.unknown()),
38
- innerBlocks: z.array(blockSchema),
39
- rawHtml: z.string().optional(),
40
- metadata: blockMetadataSchema,
41
- })
42
- .strict(),
43
- );
44
-
45
- export interface Block {
46
- /** uuidv7. Internal-only — never exported as WP delimiter attribute. */
47
- _id: string;
48
- /** `core/<x>` or `roottale/<x>` */
49
- name: string;
50
- attributes: Record<string, unknown>;
51
- innerBlocks: Block[];
52
- /** WP delimiter innerHTML (preserved for round-trip — codex v2 verdict #7). */
53
- rawHtml?: string;
54
- metadata?: {
55
- bindings?: Record<string, { source: string; args?: Record<string, unknown> }>;
56
- name?: string;
57
- reusableRef?: string;
58
- };
59
- }
60
-
61
- export function isValidBlock(value: unknown): value is Block {
62
- return blockSchema.safeParse(value).success;
63
- }
64
-
65
- export function parseBlock(value: unknown): Block {
66
- return blockSchema.parse(value);
67
- }
68
-
69
- /**
70
- * Phase 1 core block names (10) — ADR-0034 §5.
71
- * Phase 2+ 에 `core/embed`, `core/gallery`, `core/cover` 등 추가.
72
- */
73
- export const PHASE_1_CORE_BLOCKS = [
74
- "core/paragraph",
75
- "core/heading",
76
- "core/image",
77
- "core/list",
78
- "core/quote",
79
- "core/code",
80
- "core/columns",
81
- "core/group",
82
- "core/separator",
83
- "core/spacer",
84
- ] as const;
85
- export type Phase1CoreBlockName = (typeof PHASE_1_CORE_BLOCKS)[number];
package/src/delimiter.ts DELETED
@@ -1,59 +0,0 @@
1
- // ADR-0034 §5 + §6 — WP delimiter parse/serialize
2
- //
3
- // WP 의 block serialization 은 HTML 주석 형태:
4
- // <!-- wp:paragraph {"attr":1} -->
5
- // <p>본문</p>
6
- // <!-- /wp:paragraph -->
7
- //
8
- // Round-trip 룰 (codex v2 verdict #7 + v3 verdict #4):
9
- // - `_id` 는 export 시 strip (WP-side import 가 unknown attribute 로 인식해 깨짐 차단)
10
- // - `rawHtml` ↔ innerHTML
11
- // - 우리가 schema 정의 안 한 block = Tiptap opaque atom node (편집 불가, 보존)
12
-
13
- import type { Block } from "./block";
14
-
15
- /**
16
- * Block → WP delimiter 형식 직렬화 (export).
17
- * `_id` 와 `metadata.reusableRef` 는 strip — WP 호환.
18
- */
19
- export function serializeBlock(block: Block): string {
20
- const exportable = sanitizeAttributesForExport(block);
21
- const attrsJson = serializeAttributes(exportable.attributes);
22
- const innerHtml = block.rawHtml ?? "";
23
- const innerSerialized = (block.innerBlocks ?? [])
24
- .map((b) => serializeBlock(b))
25
- .join("\n");
26
-
27
- // self-closing 형태 (innerHTML + innerBlocks 모두 없으면)
28
- if (!innerHtml && innerSerialized.length === 0) {
29
- return attrsJson
30
- ? `<!-- wp:${normalizeName(block.name)} ${attrsJson} /-->`
31
- : `<!-- wp:${normalizeName(block.name)} /-->`;
32
- }
33
-
34
- const open = attrsJson
35
- ? `<!-- wp:${normalizeName(block.name)} ${attrsJson} -->`
36
- : `<!-- wp:${normalizeName(block.name)} -->`;
37
- const close = `<!-- /wp:${normalizeName(block.name)} -->`;
38
-
39
- const body = [innerHtml, innerSerialized].filter(Boolean).join("\n");
40
- return `${open}\n${body}\n${close}`;
41
- }
42
-
43
- /** WP-side 에서 `core/` 는 보통 namespace 생략 (e.g. `<!-- wp:paragraph -->`) */
44
- function normalizeName(name: string): string {
45
- return name.startsWith("core/") ? name.slice("core/".length) : name;
46
- }
47
-
48
- function serializeAttributes(attrs: Record<string, unknown>): string {
49
- const keys = Object.keys(attrs);
50
- if (keys.length === 0) return "";
51
- return JSON.stringify(attrs);
52
- }
53
-
54
- function sanitizeAttributesForExport(block: Block): {
55
- attributes: Record<string, unknown>;
56
- } {
57
- // `_id` 와 `metadata.reusableRef` 제거. attributes 안의 임의 키는 그대로 export.
58
- return { attributes: { ...block.attributes } };
59
- }
package/src/index.ts DELETED
@@ -1,7 +0,0 @@
1
- // @roottale/cms-core — ADR-0034 §3 §5 (Phase 1 Block JSON foundation)
2
- export * from "./block";
3
- export * from "./block-definition";
4
- export * from "./delimiter";
5
- export * from "./preview-token";
6
- export * from "./sanitize";
7
- export * from "./stable-id";
@@ -1,60 +0,0 @@
1
- // ADR-0034 §7.1.1 test #10 — Draft preview token sign/verify.
2
- //
3
- // HMAC-SHA256 signed token + DB row (post_preview_tokens_v2) lookup.
4
- // Atomic consume = consumedAt update at verify time.
5
- //
6
- // Token format: `<postId>.<expiresAt-ms>.<hmac-base64url>`
7
-
8
- import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
9
-
10
- export interface PreviewTokenPayload {
11
- postId: string;
12
- expiresAt: Date;
13
- }
14
-
15
- const DELIMITER = ".";
16
-
17
- function base64url(buf: Buffer): string {
18
- return buf.toString("base64url");
19
- }
20
-
21
- function fromBase64url(s: string): Buffer {
22
- return Buffer.from(s, "base64url");
23
- }
24
-
25
- function sign(secret: string, message: string): string {
26
- return base64url(createHmac("sha256", secret).update(message).digest());
27
- }
28
-
29
- /** 새 preview token 생성. DB insert 책임은 caller. */
30
- export function issuePreviewToken(
31
- secret: string,
32
- payload: PreviewTokenPayload,
33
- ): string {
34
- const exp = payload.expiresAt.getTime();
35
- const nonce = base64url(randomBytes(12));
36
- const message = `${payload.postId}${DELIMITER}${exp}${DELIMITER}${nonce}`;
37
- const sig = sign(secret, message);
38
- return `${message}${DELIMITER}${sig}`;
39
- }
40
-
41
- /** Token 검증 (서명 + 만료). DB row consumed_at update 는 caller (atomic). */
42
- export function verifyPreviewToken(
43
- secret: string,
44
- token: string,
45
- ): PreviewTokenPayload | null {
46
- const parts = token.split(DELIMITER);
47
- if (parts.length !== 4) return null;
48
- const [postId, expRaw, nonce, sig] = parts as [string, string, string, string];
49
- const exp = Number(expRaw);
50
- if (!Number.isFinite(exp)) return null;
51
- if (Date.now() > exp) return null;
52
-
53
- const expectedSig = sign(secret, `${postId}${DELIMITER}${exp}${DELIMITER}${nonce}`);
54
- const a = fromBase64url(sig);
55
- const b = fromBase64url(expectedSig);
56
- if (a.length !== b.length) return null;
57
- if (!timingSafeEqual(a, b)) return null;
58
-
59
- return { postId, expiresAt: new Date(exp) };
60
- }
package/src/sanitize.ts DELETED
@@ -1,39 +0,0 @@
1
- // ADR-0034 §7.1.1 test #7 — XSS sanitize helper (minimal allow-list).
2
- //
3
- // Phase 1 = strict CSP (`script-src 'self'`) 가 1차 방어. 본 helper 는 2차
4
- // 방어로 Tiptap output 의 inline `<script>` / `on*` attribute / `javascript:`
5
- // URL 을 strip. Phase 2 에 본격 sanitize (sanitize-html 또는 DOMPurify).
6
-
7
- const SCRIPT_TAG_RE = /<script\b[^>]*>[\s\S]*?<\/script\s*>/gi;
8
- const ON_EVENT_ATTR_RE = /\s+on[a-z]+\s*=\s*"(?:[^"\\]|\\.)*"/gi;
9
- const ON_EVENT_ATTR_RE_SINGLE = /\s+on[a-z]+\s*=\s*'(?:[^'\\]|\\.)*'/gi;
10
- const JS_URL_RE = /\s+(href|src)\s*=\s*(['"])\s*javascript:[^'"]*\2/gi;
11
- const DATA_URL_RE = /\s+(href|src)\s*=\s*(['"])\s*data:(?!image\/)[^'"]*\2/gi;
12
-
13
- /**
14
- * 미니멀 XSS sanitize — Tiptap HTML output 또는 rawHtml 의 안전 정리.
15
- * Phase 1 acceptance: `<script>alert(1)</script>` 인라인이 inert 가 되어야 함.
16
- */
17
- export function sanitizeHtml(html: string): string {
18
- return html
19
- .replace(SCRIPT_TAG_RE, "")
20
- .replace(ON_EVENT_ATTR_RE, "")
21
- .replace(ON_EVENT_ATTR_RE_SINGLE, "")
22
- .replace(JS_URL_RE, " $1=\"#\"")
23
- .replace(DATA_URL_RE, " $1=\"#\"");
24
- }
25
-
26
- /**
27
- * 표준 CSP 헤더 값 (Phase 1 acceptance test #7 정합).
28
- * Astro middleware / Next.js header config 에서 사용.
29
- */
30
- export const PHASE_1_CSP =
31
- "default-src 'self'; " +
32
- "script-src 'self'; " +
33
- "style-src 'self' 'unsafe-inline'; " +
34
- "img-src 'self' data: https://imagedelivery.net https://*.r2.cloudflarestorage.com; " +
35
- "font-src 'self' data:; " +
36
- "connect-src 'self'; " +
37
- "frame-ancestors 'none'; " +
38
- "base-uri 'self'; " +
39
- "form-action 'self'";
package/src/stable-id.ts DELETED
@@ -1,30 +0,0 @@
1
- // ADR-0034 §2 P5 — Stable block id (uuidv7 inject)
2
- //
3
- // Tiptap 의 BlockId extension 이 본 helper 를 사용해 새 block 에 uuidv7 inject.
4
- // 기존 block 의 `_id` 는 보존 (revision 간 정합).
5
-
6
- import { uuidv7 } from "uuidv7";
7
-
8
- import type { Block } from "./block";
9
-
10
- /**
11
- * Block 에 stable id 주입.
12
- * 이미 `_id` 있는 block 은 그대로, 없으면 uuidv7 생성. recursive (innerBlocks).
13
- */
14
- export function ensureStableIds(block: Omit<Block, "_id"> & { _id?: string }): Block {
15
- return {
16
- ...block,
17
- _id: block._id ?? uuidv7(),
18
- innerBlocks: (block.innerBlocks ?? []).map((inner) =>
19
- ensureStableIds(inner as Omit<Block, "_id"> & { _id?: string }),
20
- ),
21
- };
22
- }
23
-
24
- /**
25
- * 한 번에 block tree 전체에 stable id 주입.
26
- * import 또는 새 post 생성 시 entrypoint.
27
- */
28
- export function injectStableIdsTree(blocks: readonly (Omit<Block, "_id"> & { _id?: string })[]): Block[] {
29
- return blocks.map((b) => ensureStableIds(b));
30
- }