@roottale/cms-core 0.45.0 → 0.53.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 +21 -0
- package/README.md +1 -0
- package/dist/index.cjs +31 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +34 -7
- package/dist/index.d.ts +34 -7
- package/dist/index.js +29 -10
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# @roottale/cms-core
|
|
2
2
|
|
|
3
|
+
## 0.53.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a133f27: 블로그 상세 글의 작성·출력 기준을 하나로 맞췄습니다.
|
|
8
|
+
- 이미지 대체 텍스트를 한 줄, 최대 160자로 정규화하는 공용 계약을 추가했습니다.
|
|
9
|
+
- 상세 글 위에 카테고리를 연결하고, 발행일과 실제로 다른 수정일을 구분해 표시합니다.
|
|
10
|
+
- 본문의 공식 출처 블록을 제목과 목록이 있는 시맨틱 구간으로 렌더합니다.
|
|
11
|
+
- 블로그 전용 SEO 점검에서 목차, 외부 링크가 있는 공식 출처, 내용이 채워진 FAQ를 확인합니다.
|
|
12
|
+
- Next.js 상세 라우트가 글을 먼저 조회한 뒤 `notFound()`를 호출해야 실제 404가 된다는 통합 예제를 보강했습니다.
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- f743a06: HTML sanitizer가 안전한 HTTP·HTTPS 이미지 주소를 보존하도록 URL 허용 규칙을 바로잡습니다.
|
|
17
|
+
|
|
18
|
+
## 0.46.0
|
|
19
|
+
|
|
20
|
+
### Minor Changes
|
|
21
|
+
|
|
22
|
+
- 69f7d52: 콘텐츠 유형(Collection)에 **주소 없는 유형**(`basePath: ""`)을 지원합니다(ADR-0060). 강사·리뷰처럼 페이지 안에서 불러와 쓰는 콘텐츠는 이제 라우트 없이 운영할 수 있습니다 — `resolvePostUrl`/`resolvePostPath`는 이런 유형의 글에 항상 `null`을 반환하고, `validateCollections`는 basePath 형식·중복 검사를 건너뜁니다(여러 개 공존 허용). 외부 사이트는 posts API의 `collectionKey` 필터로 내용만 가져다 페이지 안에서 렌더하면 됩니다(`RootTaleBlogList`를 임베디드로 쓸 때는 자체 `postHref` 제공 권장). 소속 판정(`resolvePostCollection`)은 영향 없음 — key만으로 소속이 성립합니다. 기존 유형(basePath 있는 유형)은 동작 변화가 없습니다.
|
|
23
|
+
|
|
3
24
|
## 0.45.0
|
|
4
25
|
|
|
5
26
|
### Patch Changes
|
package/README.md
CHANGED
package/dist/index.cjs
CHANGED
|
@@ -33,6 +33,7 @@ __export(src_exports, {
|
|
|
33
33
|
BLOCK_NAME_PATTERN: () => BLOCK_NAME_PATTERN,
|
|
34
34
|
FIELD_BINDING_SOURCE: () => FIELD_BINDING_SOURCE,
|
|
35
35
|
FIELD_BINDING_SOURCE_ALIAS: () => FIELD_BINDING_SOURCE_ALIAS,
|
|
36
|
+
IMAGE_ALT_MAX_LENGTH: () => IMAGE_ALT_MAX_LENGTH,
|
|
36
37
|
PHASE_1_CORE_BLOCKS: () => PHASE_1_CORE_BLOCKS,
|
|
37
38
|
PHASE_1_CSP: () => PHASE_1_CSP,
|
|
38
39
|
applyBlockBindings: () => applyBlockBindings,
|
|
@@ -52,6 +53,7 @@ __export(src_exports, {
|
|
|
52
53
|
isValidBlock: () => isValidBlock,
|
|
53
54
|
issuePreviewToken: () => issuePreviewToken,
|
|
54
55
|
normalizeEmbed: () => normalizeEmbed,
|
|
56
|
+
normalizeImageAlt: () => normalizeImageAlt,
|
|
55
57
|
parseBlock: () => parseBlock,
|
|
56
58
|
postsForCollection: () => postsForCollection,
|
|
57
59
|
postsInAnyCollection: () => postsInAnyCollection,
|
|
@@ -198,12 +200,14 @@ function resolvePostUrl(siteUrl, post, collections) {
|
|
|
198
200
|
const collection = resolvePostCollection(post, collections);
|
|
199
201
|
if (!collection) return null;
|
|
200
202
|
if (collection.detail === false) return null;
|
|
203
|
+
if (!collection.basePath) return null;
|
|
201
204
|
return `${trimTrailingSlash(siteUrl)}${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;
|
|
202
205
|
}
|
|
203
206
|
function resolvePostPath(post, collections) {
|
|
204
207
|
const collection = resolvePostCollection(post, collections);
|
|
205
208
|
if (!collection) return null;
|
|
206
209
|
if (collection.detail === false) return null;
|
|
210
|
+
if (!collection.basePath) return null;
|
|
207
211
|
return `${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;
|
|
208
212
|
}
|
|
209
213
|
function postsForCollection(posts, collections, key) {
|
|
@@ -223,15 +227,17 @@ function validateCollections(collections) {
|
|
|
223
227
|
for (const c of collections) {
|
|
224
228
|
if (seenKey.has(c.key)) problems.push(`\uC911\uBCF5 collection key: "${c.key}"`);
|
|
225
229
|
seenKey.add(c.key);
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
230
|
+
if (c.basePath !== "") {
|
|
231
|
+
const base = trimTrailingSlash(c.basePath);
|
|
232
|
+
if (seenBase.has(base)) problems.push(`\uC911\uBCF5 basePath: "${c.basePath}"`);
|
|
233
|
+
seenBase.add(base);
|
|
234
|
+
if (!c.basePath.startsWith("/")) {
|
|
235
|
+
problems.push(`basePath \uB294 "/" \uB85C \uC2DC\uC791\uD574\uC57C \uD568: "${c.basePath}" (${c.key})`);
|
|
236
|
+
} else if (base.length < 2 || base.startsWith("//") || /[\\?#\s]/.test(base) || base.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
237
|
+
problems.push(
|
|
238
|
+
`basePath \uB294 \uC0AC\uC774\uD2B8 \uB0B4\uBD80 \uC808\uB300 \uACBD\uB85C\uC5EC\uC57C \uD568: "${c.basePath}" (${c.key})`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
235
241
|
}
|
|
236
242
|
for (const slug of c.categories) {
|
|
237
243
|
const owner = categoryOwner.get(slug);
|
|
@@ -433,6 +439,17 @@ function faqPageJsonLd(entries) {
|
|
|
433
439
|
};
|
|
434
440
|
}
|
|
435
441
|
|
|
442
|
+
// src/image-alt.ts
|
|
443
|
+
var IMAGE_ALT_MAX_LENGTH = 160;
|
|
444
|
+
function normalizeImageAlt(value) {
|
|
445
|
+
if (typeof value !== "string") return "";
|
|
446
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
447
|
+
const characters = Array.from(normalized);
|
|
448
|
+
if (characters.length <= IMAGE_ALT_MAX_LENGTH) return normalized;
|
|
449
|
+
const visible = characters.slice(0, IMAGE_ALT_MAX_LENGTH - 1).join("").trimEnd();
|
|
450
|
+
return `${visible}\u2026`;
|
|
451
|
+
}
|
|
452
|
+
|
|
436
453
|
// src/preview-token.ts
|
|
437
454
|
var import_node_crypto = require("crypto");
|
|
438
455
|
var DELIMITER = ".";
|
|
@@ -600,7 +617,9 @@ var SANITIZE_OPTIONS = {
|
|
|
600
617
|
},
|
|
601
618
|
allowedSchemes: ["http", "https", "mailto", "tel"],
|
|
602
619
|
allowedSchemesByTag: {
|
|
603
|
-
|
|
620
|
+
// tag별 목록은 전역 allowedSchemes를 덮어쓴다. data만 쓰면 정상적인
|
|
621
|
+
// http/https 이미지 src까지 제거되므로 이미지가 사용할 전체 scheme을 명시한다.
|
|
622
|
+
img: ["http", "https", "data"]
|
|
604
623
|
},
|
|
605
624
|
allowedSchemesAppliedToAttributes: ["href", "src", "xlink:href"],
|
|
606
625
|
allowProtocolRelative: false,
|
|
@@ -645,6 +664,7 @@ function injectStableIdsTree(blocks) {
|
|
|
645
664
|
BLOCK_NAME_PATTERN,
|
|
646
665
|
FIELD_BINDING_SOURCE,
|
|
647
666
|
FIELD_BINDING_SOURCE_ALIAS,
|
|
667
|
+
IMAGE_ALT_MAX_LENGTH,
|
|
648
668
|
PHASE_1_CORE_BLOCKS,
|
|
649
669
|
PHASE_1_CSP,
|
|
650
670
|
applyBlockBindings,
|
|
@@ -664,6 +684,7 @@ function injectStableIdsTree(blocks) {
|
|
|
664
684
|
isValidBlock,
|
|
665
685
|
issuePreviewToken,
|
|
666
686
|
normalizeEmbed,
|
|
687
|
+
normalizeImageAlt,
|
|
667
688
|
parseBlock,
|
|
668
689
|
postsForCollection,
|
|
669
690
|
postsInAnyCollection,
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/block.ts","../src/bindings.ts","../src/collections.ts","../src/delimiter.ts","../src/embed.ts","../src/faq.ts","../src/preview-token.ts","../src/sanitize.ts","../src/stable-id.ts"],"sourcesContent":["// @roottale/cms-core — ADR-0034 §3 §5 (Phase 1 Block JSON foundation)\nexport * from \"./block\";\nexport * from \"./bindings\";\nexport * from \"./collections\";\nexport * from \"./block-definition\";\nexport * from \"./delimiter\";\nexport * from \"./embed\";\nexport * from \"./faq\";\nexport * from \"./preview-token\";\nexport * from \"./sanitize\";\nexport * from \"./stable-id\";\n","// 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-0041 §7 — Block Bindings: ACF 필드 → 블록 attribute.\n//\n// 블록의 `metadata.bindings[attr] = { source, args }` 를 만나면 attribute 를\n// 바인딩 값으로 치환한다. 렌더 직전 pure transform — renderBlock 시그니처 불변.\n//\n// canonical source id = `roottale/field`. WP round-trip 위해 `acf/field` alias\n// 인식. resolver 는 주입(렌더러는 cms-fields 비종속) — 값 record 만 흐른다.\n\nimport type { Block } from \"./block\";\n\nexport interface BlockBinding {\n source: string;\n args?: Record<string, unknown>;\n}\n\n/**\n * 바인딩 해석기. 주어진 binding(+attribute/block)에 대한 값을 반환.\n * `undefined` 반환 = 미해석(원래 attribute 유지).\n */\nexport type BindingResolver = (\n binding: BlockBinding,\n attribute: string,\n block: Block,\n) => unknown;\n\n/** ACF 필드 바인딩 source id. */\nexport const FIELD_BINDING_SOURCE = \"roottale/field\";\n/** WP ACF 호환 alias (import round-trip). */\nexport const FIELD_BINDING_SOURCE_ALIAS = \"acf/field\";\n\n/**\n * acf 값 record 로부터 필드 바인딩 resolver 생성.\n * `roottale/field` / `acf/field` source 의 `args.name` 을 record 에서 조회.\n */\nexport function createFieldBindingResolver(\n fields: Record<string, unknown> | undefined | null,\n): BindingResolver {\n const values = fields ?? {};\n return (binding) => {\n if (\n binding.source !== FIELD_BINDING_SOURCE &&\n binding.source !== FIELD_BINDING_SOURCE_ALIAS\n ) {\n return undefined;\n }\n const name = binding.args?.name;\n if (typeof name !== \"string\") return undefined;\n const v = values[name];\n return v === undefined ? undefined : v;\n };\n}\n\n/** 여러 resolver 를 순서대로 시도(첫 non-undefined 채택). */\nexport function chainResolvers(\n ...resolvers: BindingResolver[]\n): BindingResolver {\n return (binding, attribute, block) => {\n for (const r of resolvers) {\n const v = r(binding, attribute, block);\n if (v !== undefined) return v;\n }\n return undefined;\n };\n}\n\nfunction applyToBlock(block: Block, resolver: BindingResolver): Block {\n const bindings = block.metadata?.bindings;\n let attributes = block.attributes;\n if (bindings) {\n let mutated = false;\n const next = { ...block.attributes };\n for (const [attr, binding] of Object.entries(bindings)) {\n const value = resolver(binding, attr, block);\n if (value !== undefined) {\n next[attr] = value;\n mutated = true;\n }\n }\n if (mutated) attributes = next;\n }\n const innerBlocks = block.innerBlocks.length\n ? block.innerBlocks.map((b) => applyToBlock(b, resolver))\n : block.innerBlocks;\n if (attributes === block.attributes && innerBlocks === block.innerBlocks) {\n return block;\n }\n return { ...block, attributes, innerBlocks };\n}\n\n/**\n * 블록 트리에 바인딩을 적용해 새 트리 반환(immutable). 바인딩 없으면 원본 그대로.\n */\nexport function applyBlockBindings(\n blocks: readonly Block[],\n resolver: BindingResolver,\n): Block[] {\n return blocks.map((b) => applyToBlock(b, resolver));\n}\n\n// ── 라이브 Tiptap 렌더 경로용 헬퍼 (cms-field-binding-editor.md D3·D4) ─────────\n//\n// Tiptap doc 의 node.attrs.bindings (BlockBindings extension 이 기록, Block JSON\n// metadata.bindings 와 동일 형태) 를 렌더 시점에 필드 값으로 치환할 때 사용.\n\n/**\n * bindings 에서 `attribute` 의 필드 값을 조회. `roottale/field`/`acf/field`\n * source 만 해석. 빈 값(undefined/null/\"\") = undefined → caller 가 fallback 렌더.\n */\nexport function boundFieldValue(\n bindings: unknown,\n attribute: string,\n fields: Record<string, unknown> | null | undefined,\n): unknown {\n if (!bindings || typeof bindings !== \"object\" || !fields) return undefined;\n const binding = (bindings as Record<string, BlockBinding | undefined>)[\n attribute\n ];\n if (\n !binding ||\n (binding.source !== FIELD_BINDING_SOURCE &&\n binding.source !== FIELD_BINDING_SOURCE_ALIAS)\n ) {\n return undefined;\n }\n const name = binding.args?.name;\n if (typeof name !== \"string\") return undefined;\n const value = fields[name];\n if (value === undefined || value === null || value === \"\") return undefined;\n return value;\n}\n\n/**\n * content 바인딩 값의 텍스트화. **항상 plain text** — caller 가 escape 해\n * 삽입한다 (바인딩 경로로 raw HTML 진입 금지, D4). string/number 외 = undefined.\n */\nexport function bindingTextValue(value: unknown): string | undefined {\n if (typeof value === \"string\") return value;\n if (typeof value === \"number\" && Number.isFinite(value)) return String(value);\n return undefined;\n}\n\n/**\n * image `src` 바인딩 값 → URL. 포맷된 MediaRef(`{url}`) 또는 http(s) 문자열만\n * 인정 — raw media id(uuid) 는 URL 이 아니므로 fallback.\n */\nexport function bindingSrcValue(value: unknown): string | undefined {\n if (value && typeof value === \"object\") {\n const url = (value as { url?: unknown }).url;\n if (typeof url === \"string\" && /^https?:\\/\\//i.test(url)) return url;\n return undefined;\n }\n if (typeof value === \"string\" && /^https?:\\/\\//i.test(value)) return value;\n return undefined;\n}\n","/**\n * 다중 스트림 콘텐츠 라우팅 프리미티브 (ADR-0060).\n *\n * 같은 글 풀(post)을 둘 이상의 스트림으로 분리(공지=게시판 /notice, 블로그=그리드\n * /blog 등). `cms-renderer-next` / `cms-renderer-astro` 가 동일 로직을 공유하도록\n * 여기(cms-core)에 둔다 — cms-client·프레임워크 비의존(구조적 타입만).\n *\n * 설계(codex consult `019ef577` 반영):\n * - **활성화 = `collections.length > 0`.** 빈 배열·미전달 = 레거시 단일 /blog.\n * - `RouteCollection` 은 **라우팅 최소셋**만(key/basePath/categories/feed/archives).\n * label/layout/icon 등 표시·작성 메타는 templates `CollectionSchema` 에 둔다.\n */\n\n/** 콘텐츠 스트림 1개 — sitemap/feed/아카이브 파생의 라우팅 단위. */\nexport interface RouteCollection {\n /** 안정 식별자 (예: \"notice\" | \"blog\"). */\n readonly key: string;\n /** 라우트 base path (예: \"/notice\" | \"/blog\"). 앞 슬래시 포함, 뒤 슬래시 없이. */\n readonly basePath: string;\n /**\n * 이 섹션이 제공하는 **주제(category) allowlist** — `${basePath}/categories/{slug}`\n * 아카이브 라우트 범위. ADR-0060 Amendment 1 (M4): 섹션 소속은 글의 `collectionKey`\n * 로 정해지므로 이 배열은 더 이상 라우팅 소유권이 아니다(catch-all 개념 폐기). 빈\n * 배열 = 이 섹션은 주제 없음.\n */\n readonly categories: readonly string[];\n /** RSS 피드 포함 여부 (기본 false). */\n readonly feed?: boolean;\n /** `${basePath}/categories/{slug}` 아카이브 emit 여부 (기본 false). */\n readonly archives?: boolean;\n /**\n * 글별 상세 라우트 존재 여부 (기본 true — 하위 호환). false 면 이 스트림은\n * \"목록 전용\" 유형(예: 후기 — 목록에서 전문 노출, 글마다 별도 상세 URL 없음).\n * `resolvePostUrl`/`resolvePostPath` 가 null 을 반환해 sitemap/feed 에서 글\n * URL 을 발행하지 않는다. 소속 판정(`resolvePostCollection`)에는 영향 없음\n * — 소속과 상세 URL 존재 여부는 별개(M4 원칙 연장).\n */\n readonly detail?: boolean;\n}\n\n/** resolve 입력 — 글의 구조적 최소 형태(cms-client `CmsPostContent` 와 구조 호환). */\nexport interface RoutablePost {\n readonly slug: string;\n /** 글의 주제(category) terms — 아카이브 라우트 필터용. 섹션 판정엔 쓰지 않는다(M4). */\n readonly terms: ReadonlyArray<{ readonly taxonomy: string; readonly slug: string }>;\n /**\n * ADR-0060 Amendment 1 (Option A, M4) — 글에 명시된 섹션 key. 섹션 판정의 유일한\n * 근거. 미설정/미존재면 어느 스트림에도 안 속함(null). 카테고리 파생 fallback 은 제거됨.\n */\n readonly collectionKey?: string | null;\n}\n\nfunction trimTrailingSlash(value: string): string {\n return value.replace(/\\/+$/, \"\");\n}\n\n/** collection 모드인지 — 길이 0(또는 미전달)이면 레거시 단일 /blog 동작. */\nexport function isInCollectionMode(\n collections: readonly RouteCollection[] | undefined,\n): boolean {\n return !!collections && collections.length > 0;\n}\n\n/**\n * 글이 속한 collection 1개를 해석 — ADR-0060 Amendment 1 (Option A, M4): **글의\n * 명시 `collectionKey` 만** 본다. 미설정/미존재 key 면 null(상세 라우트 없는 글 →\n * sitemap 제외 신호). 카테고리 slug 파생·catch-all·first-wins 는 M4 에서 제거됨 —\n * 섹션은 카테고리가 아니라 글에 명시한다. (`categories` 는 이제 주제 allowlist 로만,\n * 아카이브 라우트에서 쓰임.)\n */\nexport function resolvePostCollection(\n post: RoutablePost,\n collections: readonly RouteCollection[],\n): RouteCollection | null {\n if (!post.collectionKey) return null;\n return collections.find((c) => c.key === post.collectionKey) ?? null;\n}\n\n/**\n * 글의 정규 절대 URL — 소속 스트림 basePath 기준. 어느 스트림에도 안 속하거나,\n * 소속 스트림이 `detail: false`(목록 전용 유형)면 null.\n * slug 는 percent-encoding (한글 slug 안전).\n */\nexport function resolvePostUrl(\n siteUrl: string,\n post: RoutablePost,\n collections: readonly RouteCollection[],\n): string | null {\n const collection = resolvePostCollection(post, collections);\n if (!collection) return null;\n if (collection.detail === false) return null;\n return `${trimTrailingSlash(siteUrl)}${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;\n}\n\n/**\n * 글의 *경로*(siteUrl 없는 basePath-상대) — 예: \"/blog/hello\". 소속 스트림이\n * 없거나 `detail: false`(목록 전용 유형)면 null. astro 처럼 상대 href 가\n * 필요한 소비자용(`resolvePostUrl` 은 절대 URL).\n */\nexport function resolvePostPath(\n post: RoutablePost,\n collections: readonly RouteCollection[],\n): string | null {\n const collection = resolvePostCollection(post, collections);\n if (!collection) return null;\n if (collection.detail === false) return null;\n return `${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;\n}\n\n/**\n * 특정 collection에 명시적으로 귀속된 글만 남긴다.\n */\nexport function postsForCollection<T extends RoutablePost>(\n posts: readonly T[],\n collections: readonly RouteCollection[],\n key: string,\n): T[] {\n return posts.filter((p) => resolvePostCollection(p, collections)?.key === key);\n}\n\n/** feed/sitemap 등 파생에서 제외할, 어느 스트림에도 안 속한 글 제거. */\nexport function postsInAnyCollection<T extends RoutablePost>(\n posts: readonly T[],\n collections: readonly RouteCollection[],\n): T[] {\n return posts.filter((p) => resolvePostCollection(p, collections) !== null);\n}\n\n/**\n * `postsInAnyCollection` 의 대칭 — **어느 스트림에도 안 속한(\"미소속\") 글만** 남긴다.\n * 판정 시맨틱 = `resolvePostCollection(post, collections) === null`(collectionKey 가\n * NULL 이거나 사이트 선언 컬렉션 밖). `/blog` 처럼 \"선언 스트림에 안 잡힌 글 = 블로그\"로\n * 취급하는 표면에서 스트림 글 누출을 막는 데 쓴다(`blog-list-collection-scope.md` §2①).\n */\nexport function postsNotInAnyCollection<T extends RoutablePost>(\n posts: readonly T[],\n collections: readonly RouteCollection[],\n): T[] {\n return posts.filter((p) => resolvePostCollection(p, collections) === null);\n}\n\n/**\n * config 검증 — 중복 key/basePath, 그리고 **두 스트림에 동시 등장하는 category slug**\n * (라우팅 모호성)를 탐지. 문제 메시지 배열 반환(빈 = 정상). 빌드 타임 fail-fast 용.\n */\nexport function validateCollections(collections: readonly RouteCollection[]): string[] {\n const problems: string[] = [];\n const seenKey = new Set<string>();\n const seenBase = new Set<string>();\n const categoryOwner = new Map<string, string>();\n\n for (const c of collections) {\n if (seenKey.has(c.key)) problems.push(`중복 collection key: \"${c.key}\"`);\n seenKey.add(c.key);\n\n const base = trimTrailingSlash(c.basePath);\n if (seenBase.has(base)) problems.push(`중복 basePath: \"${c.basePath}\"`);\n seenBase.add(base);\n\n if (!c.basePath.startsWith(\"/\")) {\n problems.push(`basePath 는 \"/\" 로 시작해야 함: \"${c.basePath}\" (${c.key})`);\n } else if (\n base.length < 2 ||\n base.startsWith(\"//\") ||\n /[\\\\?#\\s]/.test(base) ||\n base.split(\"/\").some((segment) => segment === \".\" || segment === \"..\")\n ) {\n problems.push(\n `basePath 는 사이트 내부 절대 경로여야 함: \"${c.basePath}\" (${c.key})`,\n );\n }\n\n for (const slug of c.categories) {\n const owner = categoryOwner.get(slug);\n if (owner && owner !== c.key) {\n problems.push(\n `category \"${slug}\" 가 collection \"${owner}\" 와 \"${c.key}\" 에 중복 — 한 카테고리는 한 스트림에만.`,\n );\n }\n categoryOwner.set(slug, c.key);\n }\n }\n return problems;\n}\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 §5 — `core/embed` 영상 임베드 헬퍼 (provider allowlist)\n//\n// 본 모듈은 editor (URL → embed 노드 정규화) 와 renderer (저장된 embed src 의\n// 출력 시 재검증, defense-in-depth) 양쪽에서 공유. 순수 모듈 — `URL` 만 사용해\n// 브라우저/서버 모두 안전.\n//\n// 보안 원칙: 임의 iframe src 를 신뢰하지 않는다. 신규 삽입은 `normalizeEmbed`\n// 가 allowlist provider 의 canonical embed URL 로만 변환하고, 출력은\n// `isAllowedEmbedSrc` 로 host 를 한 번 더 검증한다 (legacy/import/외부 테넌트가\n// 임의 src 를 DB 에 남겨도 렌더 단계에서 차단).\n\nexport type EmbedProvider = \"youtube\" | \"vimeo\";\n\nexport interface NormalizedEmbed {\n provider: EmbedProvider;\n /** allowlist host 의 canonical iframe src (가능 시 privacy-enhanced). */\n embedSrc: string;\n /** 사용자가 입력한 원본 URL — fallback 링크 / round-trip 보존용. */\n originalUrl: string;\n}\n\nconst YOUTUBE_HOSTS = new Set([\n \"youtube.com\",\n \"www.youtube.com\",\n \"m.youtube.com\",\n \"youtu.be\",\n \"www.youtu.be\",\n]);\n\nconst VIMEO_HOSTS = new Set([\"vimeo.com\", \"www.vimeo.com\", \"player.vimeo.com\"]);\n\n/** 출력(iframe) 시 허용되는 embed src host — render 단계 defense-in-depth. */\nconst ALLOWED_EMBED_HOSTS = new Set([\n \"www.youtube-nocookie.com\",\n \"youtube-nocookie.com\",\n \"www.youtube.com\",\n \"youtube.com\",\n \"player.vimeo.com\",\n]);\n\nconst YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;\nconst VIMEO_ID = /^\\d+$/;\n\n/**\n * 사용자 입력 URL → allowlist provider 의 canonical embed. 지원하지 않는\n * provider / 파싱 실패 시 `null`.\n */\nexport function normalizeEmbed(input: unknown): NormalizedEmbed | null {\n if (typeof input !== \"string\") return null;\n const raw = input.trim();\n if (raw.length === 0) return null;\n\n let url: URL;\n try {\n url = new URL(raw);\n } catch {\n return null;\n }\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") return null;\n\n const host = url.hostname.toLowerCase();\n\n if (YOUTUBE_HOSTS.has(host)) {\n const id = extractYouTubeId(url, host);\n if (id) {\n return {\n provider: \"youtube\",\n embedSrc: `https://www.youtube-nocookie.com/embed/${id}`,\n originalUrl: raw,\n };\n }\n }\n\n if (VIMEO_HOSTS.has(host)) {\n const id = extractVimeoId(url, host);\n if (id) {\n return {\n provider: \"vimeo\",\n embedSrc: `https://player.vimeo.com/video/${id}`,\n originalUrl: raw,\n };\n }\n }\n\n return null;\n}\n\n/**\n * 이미 저장된 embed src 가 allowlist host(https) 인지 검증 — 렌더러가 iframe\n * 출력 전 호출. 임의 host / 비 https 는 거부.\n */\nexport function isAllowedEmbedSrc(value: unknown): value is string {\n if (typeof value !== \"string\") return false;\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return false;\n }\n if (url.protocol !== \"https:\") return false;\n const host = url.hostname.toLowerCase();\n if (!ALLOWED_EMBED_HOSTS.has(host)) return false;\n // host 뿐 아니라 canonical embed path 형태까지 검증 — 허용 host 의 임의 경로\n // (예: 스크립트·홈) 가 iframe 으로 로드되는 것을 차단.\n if (host === \"player.vimeo.com\") {\n return /^\\/video\\/\\d+$/.test(url.pathname);\n }\n // youtube / youtube-nocookie → /embed/<11-char id>\n return new RegExp(\"^/embed/[A-Za-z0-9_-]{11}$\").test(url.pathname);\n}\n\n/** 지도 embed 허용 host — keyless embed 만 (API 키 의존 provider 금지). */\nconst GOOGLE_MAP_HOSTS = new Set([\"www.google.com\", \"google.com\", \"maps.google.com\"]);\nconst OSM_HOSTS = new Set([\"www.openstreetmap.org\", \"openstreetmap.org\"]);\n\n/**\n * 지도 embed src allowlist — 렌더러가 contact 지도 iframe 출력 전 호출\n * (W8-7, `isAllowedEmbedSrc` 의 영상 allowlist 와 별도).\n *\n * 별도 함수인 이유: 지도는 \"외부 API 키가 필요 없는 keyless embed 만\"이라는\n * 추가 제약이 있다. 허용 형태:\n * - Google Maps 공유 embed: https://www.google.com/maps/embed?pb=…\n * - Google Maps keyless 쿼리 embed: https://maps.google.com/maps?q=…&output=embed\n * - OpenStreetMap export embed: https://www.openstreetmap.org/export/embed.html?…\n * `/maps/embed/v1/*`(Embed API — 키 필요) 와 그 밖의 host/경로는 거부 —\n * 거부 시 렌더러는 기존 facade 이미지/길찾기 링크 폴백을 유지한다(fail-soft).\n */\nexport function isAllowedMapEmbedSrc(value: unknown): value is string {\n if (typeof value !== \"string\") return false;\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return false;\n }\n if (url.protocol !== \"https:\") return false;\n const host = url.hostname.toLowerCase();\n if (GOOGLE_MAP_HOSTS.has(host)) {\n if (url.pathname === \"/maps/embed\") return true;\n if (url.pathname === \"/maps\" || url.pathname === \"/maps/\") {\n return url.searchParams.get(\"output\") === \"embed\";\n }\n return false;\n }\n if (OSM_HOSTS.has(host)) {\n return url.pathname === \"/export/embed.html\";\n }\n return false;\n}\n\nfunction extractYouTubeId(url: URL, host: string): string | null {\n if (host === \"youtu.be\" || host === \"www.youtu.be\") {\n const id = url.pathname.split(\"/\").filter(Boolean)[0] ?? \"\";\n return YOUTUBE_ID.test(id) ? id : null;\n }\n const v = url.searchParams.get(\"v\");\n if (v && YOUTUBE_ID.test(v)) return v;\n const parts = url.pathname.split(\"/\").filter(Boolean);\n if (\n parts.length >= 2 &&\n (parts[0] === \"embed\" || parts[0] === \"shorts\" || parts[0] === \"v\")\n ) {\n return YOUTUBE_ID.test(parts[1]) ? parts[1] : null;\n }\n return null;\n}\n\nfunction extractVimeoId(url: URL, host: string): string | null {\n const parts = url.pathname.split(\"/\").filter(Boolean);\n let candidate: string | undefined;\n if (host === \"player.vimeo.com\") {\n const vi = parts.indexOf(\"video\");\n candidate = vi >= 0 ? parts[vi + 1] : parts[parts.length - 1];\n } else {\n candidate = parts[0];\n }\n return candidate && VIMEO_ID.test(candidate) ? candidate : null;\n}\n","// FAQ 추출 — Tiptap doc 에서 faq 블록의 Q/A 를 평문으로 수집.\n//\n// 두 렌더러(cms-renderer-astro / cms-renderer-next)가 공유하여 FAQPage\n// 구조화데이터(JSON-LD)를 생성한다. 직렬화(HTML <details>)는 각 렌더러가\n// 자체 처리(마크업 패리티), 본 모듈은 schema.org 용 평문 추출만 담당.\n\ninterface DocNode {\n type?: string;\n text?: string;\n content?: DocNode[];\n}\n\nexport interface FaqEntry {\n question: string;\n answer: string;\n}\n\n/** 노드 트리의 모든 text 를 이어붙여 평문화 (공백 정규화). */\nfunction plainText(node: DocNode | undefined): string {\n if (!node) return \"\";\n if (typeof node.text === \"string\") return node.text;\n if (!Array.isArray(node.content)) return \"\";\n // 블록 경계는 공백으로 — 문단 사이가 붙지 않도록.\n return node.content.map(plainText).join(\" \");\n}\n\nfunction collapse(s: string): string {\n return s.replace(/\\s+/g, \" \").trim();\n}\n\n/** doc 을 순회하며 faqItem(질문/답변)을 순서대로 수집. */\nexport function extractFaqEntries(doc: unknown): FaqEntry[] {\n const out: FaqEntry[] = [];\n const walk = (node: DocNode | undefined): void => {\n if (!node || typeof node !== \"object\") return;\n if (node.type === \"faqItem\" && Array.isArray(node.content)) {\n const q = node.content.find((c) => c.type === \"faqQuestion\");\n const a = node.content.find((c) => c.type === \"faqAnswer\");\n const question = collapse(plainText(q));\n const answer = collapse(plainText(a));\n if (question && answer) out.push({ question, answer });\n return; // faqItem 내부는 더 내려가지 않음\n }\n if (Array.isArray(node.content)) node.content.forEach(walk);\n };\n if (doc && typeof doc === \"object\") {\n walk(doc as DocNode);\n }\n return out;\n}\n\n/** FAQPage JSON-LD 객체 (script 삽입은 렌더러 책임). null = 항목 없음. */\nexport function faqPageJsonLd(\n entries: FaqEntry[],\n): Record<string, unknown> | null {\n if (entries.length === 0) return null;\n return {\n \"@context\": \"https://schema.org\",\n \"@type\": \"FAQPage\",\n mainEntity: entries.map((e) => ({\n \"@type\": \"Question\",\n name: e.question,\n acceptedAnswer: { \"@type\": \"Answer\", text: e.answer },\n })),\n };\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","import sanitizeHtmlLibrary from \"sanitize-html\";\nimport type sanitizeHtmlTypes from \"sanitize-html\";\n\n// ADR-0034 §7.1.1 test #7 — XSS sanitize helper (allow-list, 2차 방어).\n//\n// Phase 1 = strict CSP (`script-src 'self'`) 가 1차 방어. 본 helper 는 렌더 직전\n// 2차 방어로 위험 태그/속성/URL scheme 을 strip 한다.\n//\n// Tiptap 에디터/renderer 실측 allowlist:\n// - StarterKit: p/h2-h4/ul/ol/li/blockquote/pre/code/hr/br + strong/em/s\n// - editor 추가 mark: u/mark/span(style: color/background) + TextAlign(style)\n// - media/table/custom nodes: img/figure/figcaption/table/tbody/tr/th/td/div\n// - FAQ renderer: details/summary + rt-cms-* classes\n// Embed iframe 은 sanitize 경로가 아니라 Tiptap JSON `renderEmbed()` 에서\n// `isAllowedEmbedSrc` 로 검증 후 렌더된다. raw HTML sanitizer 에서는 iframe 전면 strip.\n\n// URL 속성(href/src/…) — deny-list 가 아니라 **scheme allow-list** 로 판정한다.\n// HTML entity / 제어문자 인코딩(`javascript:`, `java	script:`,\n// `jav
ascript:`)은 regex 리터럴 매칭을 우회하므로, 값에서 entity 를 decode\n// 하고 ASCII 공백/제어문자를 제거(브라우저 URL 파서와 동일 정규화)한 뒤 scheme 을\n// 검사한다. sanitize-html parser 가 처리한 속성에도 동일 probe 를 적용한다.\nconst URL_ATTRS = new Set([\"href\", \"src\", \"xlink:href\"]);\n\nconst ALLOWED_URL_SCHEMES = new Set([\"http\", \"https\", \"mailto\", \"tel\"]);\nconst MALFORMED_DANGEROUS_TOKEN_RE =\n /<\\/?[^>\\s/]*(?:script|style|iframe|object|embed|svg|math|template)[^>\\s/]*(?:\\s[^>]*)?>[\\s\\S]*?<\\/?[^>\\s/]*(?:script|style|iframe|object|embed|svg|math|template)[^>\\s/]*(?:\\s[^>]*)?>/gi;\nconst SLASH_EVENT_ATTR_IN_URL_RE = /\\/on[a-z]+=/i;\nconst ROOTTALE_CLASS_RE = /^rt-[a-z0-9_-]+$/;\nconst HEX_COLOR_RE = /^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i;\nconst TEXT_ALIGN_RE = /^(left|right|center|justify)$/;\nconst ASPECT_RATIO_RE = /^\\d+(?:\\.\\d+)?\\s*\\/\\s*\\d+(?:\\.\\d+)?$/;\n\n// scheme 구성에 악용되는 named entity 만 decode (`:`/`	` 등).\nconst NAMED_ENTITY: Record<string, string> = {\n colon: \":\",\n tab: \"\\t\",\n newline: \"\\n\",\n sol: \"/\",\n semi: \";\",\n num: \"#\",\n period: \".\",\n comma: \",\",\n lpar: \"(\",\n rpar: \")\",\n};\n\nfunction fromCodePointSafe(n: number): string {\n if (!Number.isFinite(n) || n < 0 || n > 0x10ffff) return \"\";\n try {\n return String.fromCodePoint(n);\n } catch {\n return \"\";\n }\n}\n\n/** URL 값 정규화 — entity decode + 공백/제어문자 제거 + 소문자 (scheme 판정용). */\nfunction decodeUrlProbe(value: string): string {\n const decoded = value\n .replace(/&#x([0-9a-f]+);?/gi, (_, h) => fromCodePointSafe(parseInt(h, 16)))\n .replace(/&#(\\d+);?/g, (_, d) => fromCodePointSafe(parseInt(d, 10)))\n .replace(/&([a-z]+);/gi, (m, name) => NAMED_ENTITY[name.toLowerCase()] ?? m);\n // 브라우저는 scheme 판정 시 ASCII 공백/제어문자(탭/개행/CR/NUL 포함)를 제거한다.\n return [...decoded].filter((c) => { const n = c.codePointAt(0) ?? 0; return n > 0x20 && n !== 0x7f; }).join(\"\").toLowerCase();\n}\n\nfunction stripMalformedDangerousTokens(html: string): string {\n let out = html;\n for (let i = 0; i < 3; i++) {\n const next = out.replace(MALFORMED_DANGEROUS_TOKEN_RE, \"\");\n if (next === out) return out;\n out = next;\n }\n return out;\n}\n\n/**\n * 안전한 URL 값인지 — 상대경로/허용 scheme, data:image/* 는 img[src] 에서만(SVG 제외).\n */\nfunction isSafeUrlValue(tag: string, attr: string, value: string): boolean {\n const probe = decodeUrlProbe(value);\n if (SLASH_EVENT_ATTR_IN_URL_RE.test(probe)) return false;\n const scheme = /^([a-z][a-z0-9+.-]*):/.exec(probe)?.[1];\n if (!scheme) return true; // 상대경로 / fragment / query\n if (ALLOWED_URL_SCHEMES.has(scheme)) return true;\n if (\n scheme === \"data\" &&\n tag === \"img\" &&\n attr === \"src\" &&\n probe.startsWith(\"data:image/\")\n ) {\n // data:image/svg+xml 은 스크립트 실행 가능 → 차단.\n return !probe.startsWith(\"data:image/svg\");\n }\n return false;\n}\n\nfunction sanitizeUrlAttributes(\n tagName: string,\n attribs: sanitizeHtmlTypes.Attributes,\n): sanitizeHtmlTypes.Tag {\n const next: sanitizeHtmlTypes.Attributes = {};\n for (const [name, value] of Object.entries(attribs)) {\n const attr = name.toLowerCase();\n next[name] =\n URL_ATTRS.has(attr) && !isSafeUrlValue(tagName, attr, value) ? \"#\" : value;\n }\n return { tagName, attribs: next };\n}\n\nconst SANITIZE_OPTIONS: sanitizeHtmlTypes.IOptions = {\n allowedTags: [\n \"a\",\n \"blockquote\",\n \"br\",\n \"cite\",\n \"code\",\n \"details\",\n \"div\",\n \"em\",\n \"figcaption\",\n \"figure\",\n \"h2\",\n \"h3\",\n \"h4\",\n \"hr\",\n \"img\",\n \"li\",\n \"mark\",\n \"ol\",\n \"p\",\n \"pre\",\n \"s\",\n \"span\",\n \"strong\",\n \"summary\",\n \"table\",\n \"tbody\",\n \"td\",\n \"th\",\n \"tr\",\n \"u\",\n \"ul\",\n ],\n allowedAttributes: {\n \"*\": [\"class\", \"data-*\"],\n a: [\n \"href\",\n \"title\",\n { name: \"rel\", multiple: true, values: [\"noopener\", \"noreferrer\"] },\n { name: \"target\", values: [\"_blank\"] },\n ],\n details: [\"open\", \"class\", \"data-*\"],\n div: [\"class\", \"data-*\", \"style\"],\n h2: [\"id\", \"class\", \"data-*\", \"style\"],\n h3: [\"id\", \"class\", \"data-*\", \"style\"],\n h4: [\"id\", \"class\", \"data-*\", \"style\"],\n img: [\"src\", \"alt\", \"title\", \"width\", \"height\", \"loading\", \"class\", \"data-*\"],\n mark: [\"class\", \"data-*\", \"style\"],\n p: [\"class\", \"data-*\", \"style\"],\n span: [\"class\", \"data-*\", \"style\"],\n td: [\"colspan\", \"rowspan\", \"class\", \"data-*\"],\n th: [\"colspan\", \"rowspan\", \"class\", \"data-*\"],\n },\n allowedClasses: {\n \"*\": [ROOTTALE_CLASS_RE],\n },\n allowedStyles: {\n \"*\": {\n \"aspect-ratio\": [ASPECT_RATIO_RE],\n background: [HEX_COLOR_RE],\n \"background-color\": [HEX_COLOR_RE],\n color: [HEX_COLOR_RE],\n \"text-align\": [TEXT_ALIGN_RE],\n },\n },\n allowedSchemes: [\"http\", \"https\", \"mailto\", \"tel\"],\n allowedSchemesByTag: {\n img: [\"data\"],\n },\n allowedSchemesAppliedToAttributes: [\"href\", \"src\", \"xlink:href\"],\n allowProtocolRelative: false,\n nonTextTags: [\n \"script\",\n \"style\",\n \"textarea\",\n \"option\",\n \"iframe\",\n \"object\",\n \"embed\",\n \"svg\",\n \"math\",\n \"template\",\n ],\n transformTags: {\n \"*\": sanitizeUrlAttributes,\n },\n};\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 if (!html) return html;\n return sanitizeHtmlLibrary(stripMalformedDangerousTokens(html), SANITIZE_OPTIONS);\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 // core/embed allowlist (embed.ts) 의 영상 iframe — youtube-nocookie / vimeo.\n \"frame-src https://www.youtube-nocookie.com https://www.youtube.com https://player.vimeo.com; \" +\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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACMA,iBAAkB;AAGX,IAAM,qBAAqB;AAElC,IAAM,sBAAsB,aACzB,OAAO;AAAA,EACN,UAAU,aACP;AAAA,IACC,aAAE,OAAO;AAAA,MACP,QAAQ,aAAE,OAAO;AAAA,MACjB,MAAM,aAAE,OAAO,aAAE,QAAQ,CAAC,EAAE,SAAS;AAAA,IACvC,CAAC;AAAA,EACH,EACC,SAAS;AAAA,EACZ,MAAM,aAAE,OAAO,EAAE,SAAS;AAAA,EAC1B,aAAa,aAAE,OAAO,EAAE,SAAS;AACnC,CAAC,EACA,OAAO,EACP,SAAS;AAML,IAAM,cAAgC,aAAE;AAAA,EAAK,MAClD,aACG,OAAO;AAAA,IACN,KAAK,aAAE,OAAO,EAAE,KAAK;AAAA,IACrB,MAAM,aAAE,OAAO,EAAE,MAAM,kBAAkB;AAAA,IACzC,YAAY,aAAE,OAAO,aAAE,QAAQ,CAAC;AAAA,IAChC,aAAa,aAAE,MAAM,WAAW;AAAA,IAChC,SAAS,aAAE,OAAO,EAAE,SAAS;AAAA,IAC7B,UAAU;AAAA,EACZ,CAAC,EACA,OAAO;AACZ;AAkBO,SAAS,aAAa,OAAgC;AAC3D,SAAO,YAAY,UAAU,KAAK,EAAE;AACtC;AAEO,SAAS,WAAW,OAAuB;AAChD,SAAO,YAAY,MAAM,KAAK;AAChC;AAMO,IAAM,sBAAsB;AAAA,EACjC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;;;ACzDO,IAAM,uBAAuB;AAE7B,IAAM,6BAA6B;AAMnC,SAAS,2BACd,QACiB;AACjB,QAAM,SAAS,UAAU,CAAC;AAC1B,SAAO,CAAC,YAAY;AAClB,QACE,QAAQ,WAAW,wBACnB,QAAQ,WAAW,4BACnB;AACA,aAAO;AAAA,IACT;AACA,UAAM,OAAO,QAAQ,MAAM;AAC3B,QAAI,OAAO,SAAS,SAAU,QAAO;AACrC,UAAM,IAAI,OAAO,IAAI;AACrB,WAAO,MAAM,SAAY,SAAY;AAAA,EACvC;AACF;AAGO,SAAS,kBACX,WACc;AACjB,SAAO,CAAC,SAAS,WAAW,UAAU;AACpC,eAAW,KAAK,WAAW;AACzB,YAAM,IAAI,EAAE,SAAS,WAAW,KAAK;AACrC,UAAI,MAAM,OAAW,QAAO;AAAA,IAC9B;AACA,WAAO;AAAA,EACT;AACF;AAEA,SAAS,aAAa,OAAc,UAAkC;AACpE,QAAM,WAAW,MAAM,UAAU;AACjC,MAAI,aAAa,MAAM;AACvB,MAAI,UAAU;AACZ,QAAI,UAAU;AACd,UAAM,OAAO,EAAE,GAAG,MAAM,WAAW;AACnC,eAAW,CAAC,MAAM,OAAO,KAAK,OAAO,QAAQ,QAAQ,GAAG;AACtD,YAAM,QAAQ,SAAS,SAAS,MAAM,KAAK;AAC3C,UAAI,UAAU,QAAW;AACvB,aAAK,IAAI,IAAI;AACb,kBAAU;AAAA,MACZ;AAAA,IACF;AACA,QAAI,QAAS,cAAa;AAAA,EAC5B;AACA,QAAM,cAAc,MAAM,YAAY,SAClC,MAAM,YAAY,IAAI,CAAC,MAAM,aAAa,GAAG,QAAQ,CAAC,IACtD,MAAM;AACV,MAAI,eAAe,MAAM,cAAc,gBAAgB,MAAM,aAAa;AACxE,WAAO;AAAA,EACT;AACA,SAAO,EAAE,GAAG,OAAO,YAAY,YAAY;AAC7C;AAKO,SAAS,mBACd,QACA,UACS;AACT,SAAO,OAAO,IAAI,CAAC,MAAM,aAAa,GAAG,QAAQ,CAAC;AACpD;AAWO,SAAS,gBACd,UACA,WACA,QACS;AACT,MAAI,CAAC,YAAY,OAAO,aAAa,YAAY,CAAC,OAAQ,QAAO;AACjE,QAAM,UAAW,SACf,SACF;AACA,MACE,CAAC,WACA,QAAQ,WAAW,wBAClB,QAAQ,WAAW,4BACrB;AACA,WAAO;AAAA,EACT;AACA,QAAM,OAAO,QAAQ,MAAM;AAC3B,MAAI,OAAO,SAAS,SAAU,QAAO;AACrC,QAAM,QAAQ,OAAO,IAAI;AACzB,MAAI,UAAU,UAAa,UAAU,QAAQ,UAAU,GAAI,QAAO;AAClE,SAAO;AACT;AAMO,SAAS,iBAAiB,OAAoC;AACnE,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,EAAG,QAAO,OAAO,KAAK;AAC5E,SAAO;AACT;AAMO,SAAS,gBAAgB,OAAoC;AAClE,MAAI,SAAS,OAAO,UAAU,UAAU;AACtC,UAAM,MAAO,MAA4B;AACzC,QAAI,OAAO,QAAQ,YAAY,gBAAgB,KAAK,GAAG,EAAG,QAAO;AACjE,WAAO;AAAA,EACT;AACA,MAAI,OAAO,UAAU,YAAY,gBAAgB,KAAK,KAAK,EAAG,QAAO;AACrE,SAAO;AACT;;;ACrGA,SAAS,kBAAkB,OAAuB;AAChD,SAAO,MAAM,QAAQ,QAAQ,EAAE;AACjC;AAGO,SAAS,mBACd,aACS;AACT,SAAO,CAAC,CAAC,eAAe,YAAY,SAAS;AAC/C;AASO,SAAS,sBACd,MACA,aACwB;AACxB,MAAI,CAAC,KAAK,cAAe,QAAO;AAChC,SAAO,YAAY,KAAK,CAAC,MAAM,EAAE,QAAQ,KAAK,aAAa,KAAK;AAClE;AAOO,SAAS,eACd,SACA,MACA,aACe;AACf,QAAM,aAAa,sBAAsB,MAAM,WAAW;AAC1D,MAAI,CAAC,WAAY,QAAO;AACxB,MAAI,WAAW,WAAW,MAAO,QAAO;AACxC,SAAO,GAAG,kBAAkB,OAAO,CAAC,GAAG,kBAAkB,WAAW,QAAQ,CAAC,IAAI,mBAAmB,KAAK,IAAI,CAAC;AAChH;AAOO,SAAS,gBACd,MACA,aACe;AACf,QAAM,aAAa,sBAAsB,MAAM,WAAW;AAC1D,MAAI,CAAC,WAAY,QAAO;AACxB,MAAI,WAAW,WAAW,MAAO,QAAO;AACxC,SAAO,GAAG,kBAAkB,WAAW,QAAQ,CAAC,IAAI,mBAAmB,KAAK,IAAI,CAAC;AACnF;AAKO,SAAS,mBACd,OACA,aACA,KACK;AACL,SAAO,MAAM,OAAO,CAAC,MAAM,sBAAsB,GAAG,WAAW,GAAG,QAAQ,GAAG;AAC/E;AAGO,SAAS,qBACd,OACA,aACK;AACL,SAAO,MAAM,OAAO,CAAC,MAAM,sBAAsB,GAAG,WAAW,MAAM,IAAI;AAC3E;AAQO,SAAS,wBACd,OACA,aACK;AACL,SAAO,MAAM,OAAO,CAAC,MAAM,sBAAsB,GAAG,WAAW,MAAM,IAAI;AAC3E;AAMO,SAAS,oBAAoB,aAAmD;AACrF,QAAM,WAAqB,CAAC;AAC5B,QAAM,UAAU,oBAAI,IAAY;AAChC,QAAM,WAAW,oBAAI,IAAY;AACjC,QAAM,gBAAgB,oBAAI,IAAoB;AAE9C,aAAW,KAAK,aAAa;AAC3B,QAAI,QAAQ,IAAI,EAAE,GAAG,EAAG,UAAS,KAAK,iCAAuB,EAAE,GAAG,GAAG;AACrE,YAAQ,IAAI,EAAE,GAAG;AAEjB,UAAM,OAAO,kBAAkB,EAAE,QAAQ;AACzC,QAAI,SAAS,IAAI,IAAI,EAAG,UAAS,KAAK,2BAAiB,EAAE,QAAQ,GAAG;AACpE,aAAS,IAAI,IAAI;AAEjB,QAAI,CAAC,EAAE,SAAS,WAAW,GAAG,GAAG;AAC/B,eAAS,KAAK,gEAA6B,EAAE,QAAQ,MAAM,EAAE,GAAG,GAAG;AAAA,IACrE,WACE,KAAK,SAAS,KACd,KAAK,WAAW,IAAI,KACpB,WAAW,KAAK,IAAI,KACpB,KAAK,MAAM,GAAG,EAAE,KAAK,CAAC,YAAY,YAAY,OAAO,YAAY,IAAI,GACrE;AACA,eAAS;AAAA,QACP,kGAAiC,EAAE,QAAQ,MAAM,EAAE,GAAG;AAAA,MACxD;AAAA,IACF;AAEA,eAAW,QAAQ,EAAE,YAAY;AAC/B,YAAM,QAAQ,cAAc,IAAI,IAAI;AACpC,UAAI,SAAS,UAAU,EAAE,KAAK;AAC5B,iBAAS;AAAA,UACP,aAAa,IAAI,wBAAmB,KAAK,aAAQ,EAAE,GAAG;AAAA,QACxD;AAAA,MACF;AACA,oBAAc,IAAI,MAAM,EAAE,GAAG;AAAA,IAC/B;AAAA,EACF;AACA,SAAO;AACT;;;ACrKO,SAAS,eAAe,OAAsB;AACnD,QAAM,aAAa,4BAA4B,KAAK;AACpD,QAAM,YAAY,oBAAoB,WAAW,UAAU;AAC3D,QAAM,YAAY,MAAM,WAAW;AACnC,QAAM,mBAAmB,MAAM,eAAe,CAAC,GAC5C,IAAI,CAAC,MAAM,eAAe,CAAC,CAAC,EAC5B,KAAK,IAAI;AAGZ,MAAI,CAAC,aAAa,gBAAgB,WAAW,GAAG;AAC9C,WAAO,YACH,WAAW,cAAc,MAAM,IAAI,CAAC,IAAI,SAAS,UACjD,WAAW,cAAc,MAAM,IAAI,CAAC;AAAA,EAC1C;AAEA,QAAM,OAAO,YACT,WAAW,cAAc,MAAM,IAAI,CAAC,IAAI,SAAS,SACjD,WAAW,cAAc,MAAM,IAAI,CAAC;AACxC,QAAM,QAAQ,YAAY,cAAc,MAAM,IAAI,CAAC;AAEnD,QAAM,OAAO,CAAC,WAAW,eAAe,EAAE,OAAO,OAAO,EAAE,KAAK,IAAI;AACnE,SAAO,GAAG,IAAI;AAAA,EAAK,IAAI;AAAA,EAAK,KAAK;AACnC;AAGA,SAAS,cAAc,MAAsB;AAC3C,SAAO,KAAK,WAAW,OAAO,IAAI,KAAK,MAAM,QAAQ,MAAM,IAAI;AACjE;AAEA,SAAS,oBAAoB,OAAwC;AACnE,QAAM,OAAO,OAAO,KAAK,KAAK;AAC9B,MAAI,KAAK,WAAW,EAAG,QAAO;AAC9B,SAAO,KAAK,UAAU,KAAK;AAC7B;AAEA,SAAS,4BAA4B,OAEnC;AAEA,SAAO,EAAE,YAAY,EAAE,GAAG,MAAM,WAAW,EAAE;AAC/C;;;ACrCA,IAAM,gBAAgB,oBAAI,IAAI;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAED,IAAM,cAAc,oBAAI,IAAI,CAAC,aAAa,iBAAiB,kBAAkB,CAAC;AAG9E,IAAM,sBAAsB,oBAAI,IAAI;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAED,IAAM,aAAa;AACnB,IAAM,WAAW;AAMV,SAAS,eAAe,OAAwC;AACrE,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,MAAM,MAAM,KAAK;AACvB,MAAI,IAAI,WAAW,EAAG,QAAO;AAE7B,MAAI;AACJ,MAAI;AACF,UAAM,IAAI,IAAI,GAAG;AAAA,EACnB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,IAAI,aAAa,YAAY,IAAI,aAAa,QAAS,QAAO;AAElE,QAAM,OAAO,IAAI,SAAS,YAAY;AAEtC,MAAI,cAAc,IAAI,IAAI,GAAG;AAC3B,UAAM,KAAK,iBAAiB,KAAK,IAAI;AACrC,QAAI,IAAI;AACN,aAAO;AAAA,QACL,UAAU;AAAA,QACV,UAAU,0CAA0C,EAAE;AAAA,QACtD,aAAa;AAAA,MACf;AAAA,IACF;AAAA,EACF;AAEA,MAAI,YAAY,IAAI,IAAI,GAAG;AACzB,UAAM,KAAK,eAAe,KAAK,IAAI;AACnC,QAAI,IAAI;AACN,aAAO;AAAA,QACL,UAAU;AAAA,QACV,UAAU,kCAAkC,EAAE;AAAA,QAC9C,aAAa;AAAA,MACf;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAMO,SAAS,kBAAkB,OAAiC;AACjE,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI;AACJ,MAAI;AACF,UAAM,IAAI,IAAI,KAAK;AAAA,EACrB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,IAAI,aAAa,SAAU,QAAO;AACtC,QAAM,OAAO,IAAI,SAAS,YAAY;AACtC,MAAI,CAAC,oBAAoB,IAAI,IAAI,EAAG,QAAO;AAG3C,MAAI,SAAS,oBAAoB;AAC/B,WAAO,iBAAiB,KAAK,IAAI,QAAQ;AAAA,EAC3C;AAEA,SAAO,IAAI,OAAO,4BAA4B,EAAE,KAAK,IAAI,QAAQ;AACnE;AAGA,IAAM,mBAAmB,oBAAI,IAAI,CAAC,kBAAkB,cAAc,iBAAiB,CAAC;AACpF,IAAM,YAAY,oBAAI,IAAI,CAAC,yBAAyB,mBAAmB,CAAC;AAcjE,SAAS,qBAAqB,OAAiC;AACpE,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI;AACJ,MAAI;AACF,UAAM,IAAI,IAAI,KAAK;AAAA,EACrB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,IAAI,aAAa,SAAU,QAAO;AACtC,QAAM,OAAO,IAAI,SAAS,YAAY;AACtC,MAAI,iBAAiB,IAAI,IAAI,GAAG;AAC9B,QAAI,IAAI,aAAa,cAAe,QAAO;AAC3C,QAAI,IAAI,aAAa,WAAW,IAAI,aAAa,UAAU;AACzD,aAAO,IAAI,aAAa,IAAI,QAAQ,MAAM;AAAA,IAC5C;AACA,WAAO;AAAA,EACT;AACA,MAAI,UAAU,IAAI,IAAI,GAAG;AACvB,WAAO,IAAI,aAAa;AAAA,EAC1B;AACA,SAAO;AACT;AAEA,SAAS,iBAAiB,KAAU,MAA6B;AAC/D,MAAI,SAAS,cAAc,SAAS,gBAAgB;AAClD,UAAM,KAAK,IAAI,SAAS,MAAM,GAAG,EAAE,OAAO,OAAO,EAAE,CAAC,KAAK;AACzD,WAAO,WAAW,KAAK,EAAE,IAAI,KAAK;AAAA,EACpC;AACA,QAAM,IAAI,IAAI,aAAa,IAAI,GAAG;AAClC,MAAI,KAAK,WAAW,KAAK,CAAC,EAAG,QAAO;AACpC,QAAM,QAAQ,IAAI,SAAS,MAAM,GAAG,EAAE,OAAO,OAAO;AACpD,MACE,MAAM,UAAU,MACf,MAAM,CAAC,MAAM,WAAW,MAAM,CAAC,MAAM,YAAY,MAAM,CAAC,MAAM,MAC/D;AACA,WAAO,WAAW,KAAK,MAAM,CAAC,CAAC,IAAI,MAAM,CAAC,IAAI;AAAA,EAChD;AACA,SAAO;AACT;AAEA,SAAS,eAAe,KAAU,MAA6B;AAC7D,QAAM,QAAQ,IAAI,SAAS,MAAM,GAAG,EAAE,OAAO,OAAO;AACpD,MAAI;AACJ,MAAI,SAAS,oBAAoB;AAC/B,UAAM,KAAK,MAAM,QAAQ,OAAO;AAChC,gBAAY,MAAM,IAAI,MAAM,KAAK,CAAC,IAAI,MAAM,MAAM,SAAS,CAAC;AAAA,EAC9D,OAAO;AACL,gBAAY,MAAM,CAAC;AAAA,EACrB;AACA,SAAO,aAAa,SAAS,KAAK,SAAS,IAAI,YAAY;AAC7D;;;AC/JA,SAAS,UAAU,MAAmC;AACpD,MAAI,CAAC,KAAM,QAAO;AAClB,MAAI,OAAO,KAAK,SAAS,SAAU,QAAO,KAAK;AAC/C,MAAI,CAAC,MAAM,QAAQ,KAAK,OAAO,EAAG,QAAO;AAEzC,SAAO,KAAK,QAAQ,IAAI,SAAS,EAAE,KAAK,GAAG;AAC7C;AAEA,SAAS,SAAS,GAAmB;AACnC,SAAO,EAAE,QAAQ,QAAQ,GAAG,EAAE,KAAK;AACrC;AAGO,SAAS,kBAAkB,KAA0B;AAC1D,QAAM,MAAkB,CAAC;AACzB,QAAM,OAAO,CAAC,SAAoC;AAChD,QAAI,CAAC,QAAQ,OAAO,SAAS,SAAU;AACvC,QAAI,KAAK,SAAS,aAAa,MAAM,QAAQ,KAAK,OAAO,GAAG;AAC1D,YAAM,IAAI,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,SAAS,aAAa;AAC3D,YAAM,IAAI,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,SAAS,WAAW;AACzD,YAAM,WAAW,SAAS,UAAU,CAAC,CAAC;AACtC,YAAM,SAAS,SAAS,UAAU,CAAC,CAAC;AACpC,UAAI,YAAY,OAAQ,KAAI,KAAK,EAAE,UAAU,OAAO,CAAC;AACrD;AAAA,IACF;AACA,QAAI,MAAM,QAAQ,KAAK,OAAO,EAAG,MAAK,QAAQ,QAAQ,IAAI;AAAA,EAC5D;AACA,MAAI,OAAO,OAAO,QAAQ,UAAU;AAClC,SAAK,GAAc;AAAA,EACrB;AACA,SAAO;AACT;AAGO,SAAS,cACd,SACgC;AAChC,MAAI,QAAQ,WAAW,EAAG,QAAO;AACjC,SAAO;AAAA,IACL,YAAY;AAAA,IACZ,SAAS;AAAA,IACT,YAAY,QAAQ,IAAI,CAAC,OAAO;AAAA,MAC9B,SAAS;AAAA,MACT,MAAM,EAAE;AAAA,MACR,gBAAgB,EAAE,SAAS,UAAU,MAAM,EAAE,OAAO;AAAA,IACtD,EAAE;AAAA,EACJ;AACF;;;AC1DA,yBAAyD;AAOzD,IAAM,YAAY;AAElB,SAAS,UAAU,KAAqB;AACtC,SAAO,IAAI,SAAS,WAAW;AACjC;AAEA,SAAS,cAAc,GAAmB;AACxC,SAAO,OAAO,KAAK,GAAG,WAAW;AACnC;AAEA,SAAS,KAAK,QAAgB,SAAyB;AACrD,SAAO,cAAU,+BAAW,UAAU,MAAM,EAAE,OAAO,OAAO,EAAE,OAAO,CAAC;AACxE;AAGO,SAAS,kBACd,QACA,SACQ;AACR,QAAM,MAAM,QAAQ,UAAU,QAAQ;AACtC,QAAM,QAAQ,cAAU,gCAAY,EAAE,CAAC;AACvC,QAAM,UAAU,GAAG,QAAQ,MAAM,GAAG,SAAS,GAAG,GAAG,GAAG,SAAS,GAAG,KAAK;AACvE,QAAM,MAAM,KAAK,QAAQ,OAAO;AAChC,SAAO,GAAG,OAAO,GAAG,SAAS,GAAG,GAAG;AACrC;AAGO,SAAS,mBACd,QACA,OAC4B;AAC5B,QAAM,QAAQ,MAAM,MAAM,SAAS;AACnC,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,QAAM,CAAC,QAAQ,QAAQ,OAAO,GAAG,IAAI;AACrC,QAAM,MAAM,OAAO,MAAM;AACzB,MAAI,CAAC,OAAO,SAAS,GAAG,EAAG,QAAO;AAClC,MAAI,KAAK,IAAI,IAAI,IAAK,QAAO;AAE7B,QAAM,cAAc,KAAK,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,GAAG,GAAG,SAAS,GAAG,KAAK,EAAE;AAClF,QAAM,IAAI,cAAc,GAAG;AAC3B,QAAM,IAAI,cAAc,WAAW;AACnC,MAAI,EAAE,WAAW,EAAE,OAAQ,QAAO;AAClC,MAAI,KAAC,oCAAgB,GAAG,CAAC,EAAG,QAAO;AAEnC,SAAO,EAAE,QAAQ,WAAW,IAAI,KAAK,GAAG,EAAE;AAC5C;;;AC3DA,2BAAgC;AAqBhC,IAAM,YAAY,oBAAI,IAAI,CAAC,QAAQ,OAAO,YAAY,CAAC;AAEvD,IAAM,sBAAsB,oBAAI,IAAI,CAAC,QAAQ,SAAS,UAAU,KAAK,CAAC;AACtE,IAAM,+BACJ;AACF,IAAM,6BAA6B;AACnC,IAAM,oBAAoB;AAC1B,IAAM,eAAe;AACrB,IAAM,gBAAgB;AACtB,IAAM,kBAAkB;AAGxB,IAAM,eAAuC;AAAA,EAC3C,OAAO;AAAA,EACP,KAAK;AAAA,EACL,SAAS;AAAA,EACT,KAAK;AAAA,EACL,MAAM;AAAA,EACN,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,MAAM;AAAA,EACN,MAAM;AACR;AAEA,SAAS,kBAAkB,GAAmB;AAC5C,MAAI,CAAC,OAAO,SAAS,CAAC,KAAK,IAAI,KAAK,IAAI,QAAU,QAAO;AACzD,MAAI;AACF,WAAO,OAAO,cAAc,CAAC;AAAA,EAC/B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGA,SAAS,eAAe,OAAuB;AAC7C,QAAM,UAAU,MACb,QAAQ,sBAAsB,CAAC,GAAG,MAAM,kBAAkB,SAAS,GAAG,EAAE,CAAC,CAAC,EAC1E,QAAQ,cAAc,CAAC,GAAG,MAAM,kBAAkB,SAAS,GAAG,EAAE,CAAC,CAAC,EAClE,QAAQ,gBAAgB,CAAC,GAAG,SAAS,aAAa,KAAK,YAAY,CAAC,KAAK,CAAC;AAE7E,SAAO,CAAC,GAAG,OAAO,EAAE,OAAO,CAAC,MAAM;AAAE,UAAM,IAAI,EAAE,YAAY,CAAC,KAAK;AAAG,WAAO,IAAI,MAAQ,MAAM;AAAA,EAAM,CAAC,EAAE,KAAK,EAAE,EAAE,YAAY;AAC9H;AAEA,SAAS,8BAA8B,MAAsB;AAC3D,MAAI,MAAM;AACV,WAAS,IAAI,GAAG,IAAI,GAAG,KAAK;AAC1B,UAAM,OAAO,IAAI,QAAQ,8BAA8B,EAAE;AACzD,QAAI,SAAS,IAAK,QAAO;AACzB,UAAM;AAAA,EACR;AACA,SAAO;AACT;AAKA,SAAS,eAAe,KAAa,MAAc,OAAwB;AACzE,QAAM,QAAQ,eAAe,KAAK;AAClC,MAAI,2BAA2B,KAAK,KAAK,EAAG,QAAO;AACnD,QAAM,SAAS,wBAAwB,KAAK,KAAK,IAAI,CAAC;AACtD,MAAI,CAAC,OAAQ,QAAO;AACpB,MAAI,oBAAoB,IAAI,MAAM,EAAG,QAAO;AAC5C,MACE,WAAW,UACX,QAAQ,SACR,SAAS,SACT,MAAM,WAAW,aAAa,GAC9B;AAEA,WAAO,CAAC,MAAM,WAAW,gBAAgB;AAAA,EAC3C;AACA,SAAO;AACT;AAEA,SAAS,sBACP,SACA,SACuB;AACvB,QAAM,OAAqC,CAAC;AAC5C,aAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,OAAO,GAAG;AACnD,UAAM,OAAO,KAAK,YAAY;AAC9B,SAAK,IAAI,IACP,UAAU,IAAI,IAAI,KAAK,CAAC,eAAe,SAAS,MAAM,KAAK,IAAI,MAAM;AAAA,EACzE;AACA,SAAO,EAAE,SAAS,SAAS,KAAK;AAClC;AAEA,IAAM,mBAA+C;AAAA,EACnD,aAAa;AAAA,IACX;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EACA,mBAAmB;AAAA,IACjB,KAAK,CAAC,SAAS,QAAQ;AAAA,IACvB,GAAG;AAAA,MACD;AAAA,MACA;AAAA,MACA,EAAE,MAAM,OAAO,UAAU,MAAM,QAAQ,CAAC,YAAY,YAAY,EAAE;AAAA,MAClE,EAAE,MAAM,UAAU,QAAQ,CAAC,QAAQ,EAAE;AAAA,IACvC;AAAA,IACA,SAAS,CAAC,QAAQ,SAAS,QAAQ;AAAA,IACnC,KAAK,CAAC,SAAS,UAAU,OAAO;AAAA,IAChC,IAAI,CAAC,MAAM,SAAS,UAAU,OAAO;AAAA,IACrC,IAAI,CAAC,MAAM,SAAS,UAAU,OAAO;AAAA,IACrC,IAAI,CAAC,MAAM,SAAS,UAAU,OAAO;AAAA,IACrC,KAAK,CAAC,OAAO,OAAO,SAAS,SAAS,UAAU,WAAW,SAAS,QAAQ;AAAA,IAC5E,MAAM,CAAC,SAAS,UAAU,OAAO;AAAA,IACjC,GAAG,CAAC,SAAS,UAAU,OAAO;AAAA,IAC9B,MAAM,CAAC,SAAS,UAAU,OAAO;AAAA,IACjC,IAAI,CAAC,WAAW,WAAW,SAAS,QAAQ;AAAA,IAC5C,IAAI,CAAC,WAAW,WAAW,SAAS,QAAQ;AAAA,EAC9C;AAAA,EACA,gBAAgB;AAAA,IACd,KAAK,CAAC,iBAAiB;AAAA,EACzB;AAAA,EACA,eAAe;AAAA,IACb,KAAK;AAAA,MACH,gBAAgB,CAAC,eAAe;AAAA,MAChC,YAAY,CAAC,YAAY;AAAA,MACzB,oBAAoB,CAAC,YAAY;AAAA,MACjC,OAAO,CAAC,YAAY;AAAA,MACpB,cAAc,CAAC,aAAa;AAAA,IAC9B;AAAA,EACF;AAAA,EACA,gBAAgB,CAAC,QAAQ,SAAS,UAAU,KAAK;AAAA,EACjD,qBAAqB;AAAA,IACnB,KAAK,CAAC,MAAM;AAAA,EACd;AAAA,EACA,mCAAmC,CAAC,QAAQ,OAAO,YAAY;AAAA,EAC/D,uBAAuB;AAAA,EACvB,aAAa;AAAA,IACX;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EACA,eAAe;AAAA,IACb,KAAK;AAAA,EACP;AACF;AAMO,SAAS,aAAa,MAAsB;AACjD,MAAI,CAAC,KAAM,QAAO;AAClB,aAAO,qBAAAA,SAAoB,8BAA8B,IAAI,GAAG,gBAAgB;AAClF;AAMO,IAAM,cACX;;;AC/MF,oBAAuB;AAQhB,SAAS,gBAAgB,OAAqD;AACnF,SAAO;AAAA,IACL,GAAG;AAAA,IACH,KAAK,MAAM,WAAO,sBAAO;AAAA,IACzB,cAAc,MAAM,eAAe,CAAC,GAAG;AAAA,MAAI,CAAC,UAC1C,gBAAgB,KAA8C;AAAA,IAChE;AAAA,EACF;AACF;AAMO,SAAS,oBAAoB,QAAqE;AACvG,SAAO,OAAO,IAAI,CAAC,MAAM,gBAAgB,CAAC,CAAC;AAC7C;","names":["sanitizeHtmlLibrary"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/block.ts","../src/bindings.ts","../src/collections.ts","../src/delimiter.ts","../src/embed.ts","../src/faq.ts","../src/image-alt.ts","../src/preview-token.ts","../src/sanitize.ts","../src/stable-id.ts"],"sourcesContent":["// @roottale/cms-core — ADR-0034 §3 §5 (Phase 1 Block JSON foundation)\nexport * from \"./block\";\nexport * from \"./bindings\";\nexport * from \"./collections\";\nexport * from \"./block-definition\";\nexport * from \"./delimiter\";\nexport * from \"./embed\";\nexport * from \"./faq\";\nexport * from \"./image-alt\";\nexport * from \"./preview-token\";\nexport * from \"./sanitize\";\nexport * from \"./stable-id\";\n","// 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-0041 §7 — Block Bindings: ACF 필드 → 블록 attribute.\n//\n// 블록의 `metadata.bindings[attr] = { source, args }` 를 만나면 attribute 를\n// 바인딩 값으로 치환한다. 렌더 직전 pure transform — renderBlock 시그니처 불변.\n//\n// canonical source id = `roottale/field`. WP round-trip 위해 `acf/field` alias\n// 인식. resolver 는 주입(렌더러는 cms-fields 비종속) — 값 record 만 흐른다.\n\nimport type { Block } from \"./block\";\n\nexport interface BlockBinding {\n source: string;\n args?: Record<string, unknown>;\n}\n\n/**\n * 바인딩 해석기. 주어진 binding(+attribute/block)에 대한 값을 반환.\n * `undefined` 반환 = 미해석(원래 attribute 유지).\n */\nexport type BindingResolver = (\n binding: BlockBinding,\n attribute: string,\n block: Block,\n) => unknown;\n\n/** ACF 필드 바인딩 source id. */\nexport const FIELD_BINDING_SOURCE = \"roottale/field\";\n/** WP ACF 호환 alias (import round-trip). */\nexport const FIELD_BINDING_SOURCE_ALIAS = \"acf/field\";\n\n/**\n * acf 값 record 로부터 필드 바인딩 resolver 생성.\n * `roottale/field` / `acf/field` source 의 `args.name` 을 record 에서 조회.\n */\nexport function createFieldBindingResolver(\n fields: Record<string, unknown> | undefined | null,\n): BindingResolver {\n const values = fields ?? {};\n return (binding) => {\n if (\n binding.source !== FIELD_BINDING_SOURCE &&\n binding.source !== FIELD_BINDING_SOURCE_ALIAS\n ) {\n return undefined;\n }\n const name = binding.args?.name;\n if (typeof name !== \"string\") return undefined;\n const v = values[name];\n return v === undefined ? undefined : v;\n };\n}\n\n/** 여러 resolver 를 순서대로 시도(첫 non-undefined 채택). */\nexport function chainResolvers(\n ...resolvers: BindingResolver[]\n): BindingResolver {\n return (binding, attribute, block) => {\n for (const r of resolvers) {\n const v = r(binding, attribute, block);\n if (v !== undefined) return v;\n }\n return undefined;\n };\n}\n\nfunction applyToBlock(block: Block, resolver: BindingResolver): Block {\n const bindings = block.metadata?.bindings;\n let attributes = block.attributes;\n if (bindings) {\n let mutated = false;\n const next = { ...block.attributes };\n for (const [attr, binding] of Object.entries(bindings)) {\n const value = resolver(binding, attr, block);\n if (value !== undefined) {\n next[attr] = value;\n mutated = true;\n }\n }\n if (mutated) attributes = next;\n }\n const innerBlocks = block.innerBlocks.length\n ? block.innerBlocks.map((b) => applyToBlock(b, resolver))\n : block.innerBlocks;\n if (attributes === block.attributes && innerBlocks === block.innerBlocks) {\n return block;\n }\n return { ...block, attributes, innerBlocks };\n}\n\n/**\n * 블록 트리에 바인딩을 적용해 새 트리 반환(immutable). 바인딩 없으면 원본 그대로.\n */\nexport function applyBlockBindings(\n blocks: readonly Block[],\n resolver: BindingResolver,\n): Block[] {\n return blocks.map((b) => applyToBlock(b, resolver));\n}\n\n// ── 라이브 Tiptap 렌더 경로용 헬퍼 (cms-field-binding-editor.md D3·D4) ─────────\n//\n// Tiptap doc 의 node.attrs.bindings (BlockBindings extension 이 기록, Block JSON\n// metadata.bindings 와 동일 형태) 를 렌더 시점에 필드 값으로 치환할 때 사용.\n\n/**\n * bindings 에서 `attribute` 의 필드 값을 조회. `roottale/field`/`acf/field`\n * source 만 해석. 빈 값(undefined/null/\"\") = undefined → caller 가 fallback 렌더.\n */\nexport function boundFieldValue(\n bindings: unknown,\n attribute: string,\n fields: Record<string, unknown> | null | undefined,\n): unknown {\n if (!bindings || typeof bindings !== \"object\" || !fields) return undefined;\n const binding = (bindings as Record<string, BlockBinding | undefined>)[\n attribute\n ];\n if (\n !binding ||\n (binding.source !== FIELD_BINDING_SOURCE &&\n binding.source !== FIELD_BINDING_SOURCE_ALIAS)\n ) {\n return undefined;\n }\n const name = binding.args?.name;\n if (typeof name !== \"string\") return undefined;\n const value = fields[name];\n if (value === undefined || value === null || value === \"\") return undefined;\n return value;\n}\n\n/**\n * content 바인딩 값의 텍스트화. **항상 plain text** — caller 가 escape 해\n * 삽입한다 (바인딩 경로로 raw HTML 진입 금지, D4). string/number 외 = undefined.\n */\nexport function bindingTextValue(value: unknown): string | undefined {\n if (typeof value === \"string\") return value;\n if (typeof value === \"number\" && Number.isFinite(value)) return String(value);\n return undefined;\n}\n\n/**\n * image `src` 바인딩 값 → URL. 포맷된 MediaRef(`{url}`) 또는 http(s) 문자열만\n * 인정 — raw media id(uuid) 는 URL 이 아니므로 fallback.\n */\nexport function bindingSrcValue(value: unknown): string | undefined {\n if (value && typeof value === \"object\") {\n const url = (value as { url?: unknown }).url;\n if (typeof url === \"string\" && /^https?:\\/\\//i.test(url)) return url;\n return undefined;\n }\n if (typeof value === \"string\" && /^https?:\\/\\//i.test(value)) return value;\n return undefined;\n}\n","/**\n * 다중 스트림 콘텐츠 라우팅 프리미티브 (ADR-0060).\n *\n * 같은 글 풀(post)을 둘 이상의 스트림으로 분리(공지=게시판 /notice, 블로그=그리드\n * /blog 등). `cms-renderer-next` / `cms-renderer-astro` 가 동일 로직을 공유하도록\n * 여기(cms-core)에 둔다 — cms-client·프레임워크 비의존(구조적 타입만).\n *\n * 설계(codex consult `019ef577` 반영):\n * - **활성화 = `collections.length > 0`.** 빈 배열·미전달 = 레거시 단일 /blog.\n * - `RouteCollection` 은 **라우팅 최소셋**만(key/basePath/categories/feed/archives).\n * label/layout/icon 등 표시·작성 메타는 templates `CollectionSchema` 에 둔다.\n */\n\n/** 콘텐츠 스트림 1개 — sitemap/feed/아카이브 파생의 라우팅 단위. */\nexport interface RouteCollection {\n /** 안정 식별자 (예: \"notice\" | \"blog\"). */\n readonly key: string;\n /**\n * 라우트 base path (예: \"/notice\" | \"/blog\"). 앞 슬래시 포함, 뒤 슬래시 없이.\n * **`\"\"`(빈 문자열) = 주소 없는 유형** — 페이지 안에서 불러와 쓰는 콘텐츠(강사·리뷰\n * 등)로, 라우트/사이트맵/피드/아카이브를 일절 파생하지 않는다. 외부 사이트는\n * posts API 의 `collectionKey` 필터로 내용만 가져다 렌더한다. `resolvePostUrl`/\n * `resolvePostPath` 는 이 값이 빈 문자열이면 항상 null. 소속 판정\n * (`resolvePostCollection`)에는 영향 없음 — key 만으로 소속이 성립한다.\n */\n readonly basePath: string;\n /**\n * 이 섹션이 제공하는 **주제(category) allowlist** — `${basePath}/categories/{slug}`\n * 아카이브 라우트 범위. ADR-0060 Amendment 1 (M4): 섹션 소속은 글의 `collectionKey`\n * 로 정해지므로 이 배열은 더 이상 라우팅 소유권이 아니다(catch-all 개념 폐기). 빈\n * 배열 = 이 섹션은 주제 없음.\n */\n readonly categories: readonly string[];\n /** RSS 피드 포함 여부 (기본 false). */\n readonly feed?: boolean;\n /** `${basePath}/categories/{slug}` 아카이브 emit 여부 (기본 false). */\n readonly archives?: boolean;\n /**\n * 글별 상세 라우트 존재 여부 (기본 true — 하위 호환). false 면 이 스트림은\n * \"목록 전용\" 유형(예: 후기 — 목록에서 전문 노출, 글마다 별도 상세 URL 없음).\n * `resolvePostUrl`/`resolvePostPath` 가 null 을 반환해 sitemap/feed 에서 글\n * URL 을 발행하지 않는다. 소속 판정(`resolvePostCollection`)에는 영향 없음\n * — 소속과 상세 URL 존재 여부는 별개(M4 원칙 연장).\n */\n readonly detail?: boolean;\n}\n\n/** resolve 입력 — 글의 구조적 최소 형태(cms-client `CmsPostContent` 와 구조 호환). */\nexport interface RoutablePost {\n readonly slug: string;\n /** 글의 주제(category) terms — 아카이브 라우트 필터용. 섹션 판정엔 쓰지 않는다(M4). */\n readonly terms: ReadonlyArray<{ readonly taxonomy: string; readonly slug: string }>;\n /**\n * ADR-0060 Amendment 1 (Option A, M4) — 글에 명시된 섹션 key. 섹션 판정의 유일한\n * 근거. 미설정/미존재면 어느 스트림에도 안 속함(null). 카테고리 파생 fallback 은 제거됨.\n */\n readonly collectionKey?: string | null;\n}\n\nfunction trimTrailingSlash(value: string): string {\n return value.replace(/\\/+$/, \"\");\n}\n\n/** collection 모드인지 — 길이 0(또는 미전달)이면 레거시 단일 /blog 동작. */\nexport function isInCollectionMode(\n collections: readonly RouteCollection[] | undefined,\n): boolean {\n return !!collections && collections.length > 0;\n}\n\n/**\n * 글이 속한 collection 1개를 해석 — ADR-0060 Amendment 1 (Option A, M4): **글의\n * 명시 `collectionKey` 만** 본다. 미설정/미존재 key 면 null(상세 라우트 없는 글 →\n * sitemap 제외 신호). 카테고리 slug 파생·catch-all·first-wins 는 M4 에서 제거됨 —\n * 섹션은 카테고리가 아니라 글에 명시한다. (`categories` 는 이제 주제 allowlist 로만,\n * 아카이브 라우트에서 쓰임.)\n */\nexport function resolvePostCollection(\n post: RoutablePost,\n collections: readonly RouteCollection[],\n): RouteCollection | null {\n if (!post.collectionKey) return null;\n return collections.find((c) => c.key === post.collectionKey) ?? null;\n}\n\n/**\n * 글의 정규 절대 URL — 소속 스트림 basePath 기준. 어느 스트림에도 안 속하거나,\n * 소속 스트림이 `detail: false`(목록 전용 유형) 이거나, `basePath` 가 빈 문자열\n * (주소 없는 유형)이면 null. slug 는 percent-encoding (한글 slug 안전).\n */\nexport function resolvePostUrl(\n siteUrl: string,\n post: RoutablePost,\n collections: readonly RouteCollection[],\n): string | null {\n const collection = resolvePostCollection(post, collections);\n if (!collection) return null;\n if (collection.detail === false) return null;\n if (!collection.basePath) return null;\n return `${trimTrailingSlash(siteUrl)}${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;\n}\n\n/**\n * 글의 *경로*(siteUrl 없는 basePath-상대) — 예: \"/blog/hello\". 소속 스트림이\n * 없거나, `detail: false`(목록 전용 유형) 이거나, `basePath` 가 빈 문자열\n * (주소 없는 유형)이면 null. astro 처럼 상대 href 가 필요한 소비자용\n * (`resolvePostUrl` 은 절대 URL).\n */\nexport function resolvePostPath(\n post: RoutablePost,\n collections: readonly RouteCollection[],\n): string | null {\n const collection = resolvePostCollection(post, collections);\n if (!collection) return null;\n if (collection.detail === false) return null;\n if (!collection.basePath) return null;\n return `${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;\n}\n\n/**\n * 특정 collection에 명시적으로 귀속된 글만 남긴다.\n */\nexport function postsForCollection<T extends RoutablePost>(\n posts: readonly T[],\n collections: readonly RouteCollection[],\n key: string,\n): T[] {\n return posts.filter((p) => resolvePostCollection(p, collections)?.key === key);\n}\n\n/** feed/sitemap 등 파생에서 제외할, 어느 스트림에도 안 속한 글 제거. */\nexport function postsInAnyCollection<T extends RoutablePost>(\n posts: readonly T[],\n collections: readonly RouteCollection[],\n): T[] {\n return posts.filter((p) => resolvePostCollection(p, collections) !== null);\n}\n\n/**\n * `postsInAnyCollection` 의 대칭 — **어느 스트림에도 안 속한(\"미소속\") 글만** 남긴다.\n * 판정 시맨틱 = `resolvePostCollection(post, collections) === null`(collectionKey 가\n * NULL 이거나 사이트 선언 컬렉션 밖). `/blog` 처럼 \"선언 스트림에 안 잡힌 글 = 블로그\"로\n * 취급하는 표면에서 스트림 글 누출을 막는 데 쓴다(`blog-list-collection-scope.md` §2①).\n */\nexport function postsNotInAnyCollection<T extends RoutablePost>(\n posts: readonly T[],\n collections: readonly RouteCollection[],\n): T[] {\n return posts.filter((p) => resolvePostCollection(p, collections) === null);\n}\n\n/**\n * config 검증 — 중복 key/basePath, 그리고 **두 스트림에 동시 등장하는 category slug**\n * (라우팅 모호성)를 탐지. `basePath === \"\"`(주소 없는 유형)는 basePath 형식·중복\n * 검사를 건너뛴다 — 라우트가 없으므로 여러 개 공존해도 모호성이 없다. key 중복·\n * category 중복 검사는 주소 유무와 무관하게 전 항목에 적용된다. 문제 메시지 배열\n * 반환(빈 = 정상). 빌드 타임 fail-fast 용.\n */\nexport function validateCollections(collections: readonly RouteCollection[]): string[] {\n const problems: string[] = [];\n const seenKey = new Set<string>();\n const seenBase = new Set<string>();\n const categoryOwner = new Map<string, string>();\n\n for (const c of collections) {\n if (seenKey.has(c.key)) problems.push(`중복 collection key: \"${c.key}\"`);\n seenKey.add(c.key);\n\n if (c.basePath !== \"\") {\n const base = trimTrailingSlash(c.basePath);\n if (seenBase.has(base)) problems.push(`중복 basePath: \"${c.basePath}\"`);\n seenBase.add(base);\n\n if (!c.basePath.startsWith(\"/\")) {\n problems.push(`basePath 는 \"/\" 로 시작해야 함: \"${c.basePath}\" (${c.key})`);\n } else if (\n base.length < 2 ||\n base.startsWith(\"//\") ||\n /[\\\\?#\\s]/.test(base) ||\n base.split(\"/\").some((segment) => segment === \".\" || segment === \"..\")\n ) {\n problems.push(\n `basePath 는 사이트 내부 절대 경로여야 함: \"${c.basePath}\" (${c.key})`,\n );\n }\n }\n\n for (const slug of c.categories) {\n const owner = categoryOwner.get(slug);\n if (owner && owner !== c.key) {\n problems.push(\n `category \"${slug}\" 가 collection \"${owner}\" 와 \"${c.key}\" 에 중복 — 한 카테고리는 한 스트림에만.`,\n );\n }\n categoryOwner.set(slug, c.key);\n }\n }\n return problems;\n}\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 §5 — `core/embed` 영상 임베드 헬퍼 (provider allowlist)\n//\n// 본 모듈은 editor (URL → embed 노드 정규화) 와 renderer (저장된 embed src 의\n// 출력 시 재검증, defense-in-depth) 양쪽에서 공유. 순수 모듈 — `URL` 만 사용해\n// 브라우저/서버 모두 안전.\n//\n// 보안 원칙: 임의 iframe src 를 신뢰하지 않는다. 신규 삽입은 `normalizeEmbed`\n// 가 allowlist provider 의 canonical embed URL 로만 변환하고, 출력은\n// `isAllowedEmbedSrc` 로 host 를 한 번 더 검증한다 (legacy/import/외부 테넌트가\n// 임의 src 를 DB 에 남겨도 렌더 단계에서 차단).\n\nexport type EmbedProvider = \"youtube\" | \"vimeo\";\n\nexport interface NormalizedEmbed {\n provider: EmbedProvider;\n /** allowlist host 의 canonical iframe src (가능 시 privacy-enhanced). */\n embedSrc: string;\n /** 사용자가 입력한 원본 URL — fallback 링크 / round-trip 보존용. */\n originalUrl: string;\n}\n\nconst YOUTUBE_HOSTS = new Set([\n \"youtube.com\",\n \"www.youtube.com\",\n \"m.youtube.com\",\n \"youtu.be\",\n \"www.youtu.be\",\n]);\n\nconst VIMEO_HOSTS = new Set([\"vimeo.com\", \"www.vimeo.com\", \"player.vimeo.com\"]);\n\n/** 출력(iframe) 시 허용되는 embed src host — render 단계 defense-in-depth. */\nconst ALLOWED_EMBED_HOSTS = new Set([\n \"www.youtube-nocookie.com\",\n \"youtube-nocookie.com\",\n \"www.youtube.com\",\n \"youtube.com\",\n \"player.vimeo.com\",\n]);\n\nconst YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;\nconst VIMEO_ID = /^\\d+$/;\n\n/**\n * 사용자 입력 URL → allowlist provider 의 canonical embed. 지원하지 않는\n * provider / 파싱 실패 시 `null`.\n */\nexport function normalizeEmbed(input: unknown): NormalizedEmbed | null {\n if (typeof input !== \"string\") return null;\n const raw = input.trim();\n if (raw.length === 0) return null;\n\n let url: URL;\n try {\n url = new URL(raw);\n } catch {\n return null;\n }\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") return null;\n\n const host = url.hostname.toLowerCase();\n\n if (YOUTUBE_HOSTS.has(host)) {\n const id = extractYouTubeId(url, host);\n if (id) {\n return {\n provider: \"youtube\",\n embedSrc: `https://www.youtube-nocookie.com/embed/${id}`,\n originalUrl: raw,\n };\n }\n }\n\n if (VIMEO_HOSTS.has(host)) {\n const id = extractVimeoId(url, host);\n if (id) {\n return {\n provider: \"vimeo\",\n embedSrc: `https://player.vimeo.com/video/${id}`,\n originalUrl: raw,\n };\n }\n }\n\n return null;\n}\n\n/**\n * 이미 저장된 embed src 가 allowlist host(https) 인지 검증 — 렌더러가 iframe\n * 출력 전 호출. 임의 host / 비 https 는 거부.\n */\nexport function isAllowedEmbedSrc(value: unknown): value is string {\n if (typeof value !== \"string\") return false;\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return false;\n }\n if (url.protocol !== \"https:\") return false;\n const host = url.hostname.toLowerCase();\n if (!ALLOWED_EMBED_HOSTS.has(host)) return false;\n // host 뿐 아니라 canonical embed path 형태까지 검증 — 허용 host 의 임의 경로\n // (예: 스크립트·홈) 가 iframe 으로 로드되는 것을 차단.\n if (host === \"player.vimeo.com\") {\n return /^\\/video\\/\\d+$/.test(url.pathname);\n }\n // youtube / youtube-nocookie → /embed/<11-char id>\n return new RegExp(\"^/embed/[A-Za-z0-9_-]{11}$\").test(url.pathname);\n}\n\n/** 지도 embed 허용 host — keyless embed 만 (API 키 의존 provider 금지). */\nconst GOOGLE_MAP_HOSTS = new Set([\"www.google.com\", \"google.com\", \"maps.google.com\"]);\nconst OSM_HOSTS = new Set([\"www.openstreetmap.org\", \"openstreetmap.org\"]);\n\n/**\n * 지도 embed src allowlist — 렌더러가 contact 지도 iframe 출력 전 호출\n * (W8-7, `isAllowedEmbedSrc` 의 영상 allowlist 와 별도).\n *\n * 별도 함수인 이유: 지도는 \"외부 API 키가 필요 없는 keyless embed 만\"이라는\n * 추가 제약이 있다. 허용 형태:\n * - Google Maps 공유 embed: https://www.google.com/maps/embed?pb=…\n * - Google Maps keyless 쿼리 embed: https://maps.google.com/maps?q=…&output=embed\n * - OpenStreetMap export embed: https://www.openstreetmap.org/export/embed.html?…\n * `/maps/embed/v1/*`(Embed API — 키 필요) 와 그 밖의 host/경로는 거부 —\n * 거부 시 렌더러는 기존 facade 이미지/길찾기 링크 폴백을 유지한다(fail-soft).\n */\nexport function isAllowedMapEmbedSrc(value: unknown): value is string {\n if (typeof value !== \"string\") return false;\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return false;\n }\n if (url.protocol !== \"https:\") return false;\n const host = url.hostname.toLowerCase();\n if (GOOGLE_MAP_HOSTS.has(host)) {\n if (url.pathname === \"/maps/embed\") return true;\n if (url.pathname === \"/maps\" || url.pathname === \"/maps/\") {\n return url.searchParams.get(\"output\") === \"embed\";\n }\n return false;\n }\n if (OSM_HOSTS.has(host)) {\n return url.pathname === \"/export/embed.html\";\n }\n return false;\n}\n\nfunction extractYouTubeId(url: URL, host: string): string | null {\n if (host === \"youtu.be\" || host === \"www.youtu.be\") {\n const id = url.pathname.split(\"/\").filter(Boolean)[0] ?? \"\";\n return YOUTUBE_ID.test(id) ? id : null;\n }\n const v = url.searchParams.get(\"v\");\n if (v && YOUTUBE_ID.test(v)) return v;\n const parts = url.pathname.split(\"/\").filter(Boolean);\n if (\n parts.length >= 2 &&\n (parts[0] === \"embed\" || parts[0] === \"shorts\" || parts[0] === \"v\")\n ) {\n return YOUTUBE_ID.test(parts[1]) ? parts[1] : null;\n }\n return null;\n}\n\nfunction extractVimeoId(url: URL, host: string): string | null {\n const parts = url.pathname.split(\"/\").filter(Boolean);\n let candidate: string | undefined;\n if (host === \"player.vimeo.com\") {\n const vi = parts.indexOf(\"video\");\n candidate = vi >= 0 ? parts[vi + 1] : parts[parts.length - 1];\n } else {\n candidate = parts[0];\n }\n return candidate && VIMEO_ID.test(candidate) ? candidate : null;\n}\n","// FAQ 추출 — Tiptap doc 에서 faq 블록의 Q/A 를 평문으로 수집.\n//\n// 두 렌더러(cms-renderer-astro / cms-renderer-next)가 공유하여 FAQPage\n// 구조화데이터(JSON-LD)를 생성한다. 직렬화(HTML <details>)는 각 렌더러가\n// 자체 처리(마크업 패리티), 본 모듈은 schema.org 용 평문 추출만 담당.\n\ninterface DocNode {\n type?: string;\n text?: string;\n content?: DocNode[];\n}\n\nexport interface FaqEntry {\n question: string;\n answer: string;\n}\n\n/** 노드 트리의 모든 text 를 이어붙여 평문화 (공백 정규화). */\nfunction plainText(node: DocNode | undefined): string {\n if (!node) return \"\";\n if (typeof node.text === \"string\") return node.text;\n if (!Array.isArray(node.content)) return \"\";\n // 블록 경계는 공백으로 — 문단 사이가 붙지 않도록.\n return node.content.map(plainText).join(\" \");\n}\n\nfunction collapse(s: string): string {\n return s.replace(/\\s+/g, \" \").trim();\n}\n\n/** doc 을 순회하며 faqItem(질문/답변)을 순서대로 수집. */\nexport function extractFaqEntries(doc: unknown): FaqEntry[] {\n const out: FaqEntry[] = [];\n const walk = (node: DocNode | undefined): void => {\n if (!node || typeof node !== \"object\") return;\n if (node.type === \"faqItem\" && Array.isArray(node.content)) {\n const q = node.content.find((c) => c.type === \"faqQuestion\");\n const a = node.content.find((c) => c.type === \"faqAnswer\");\n const question = collapse(plainText(q));\n const answer = collapse(plainText(a));\n if (question && answer) out.push({ question, answer });\n return; // faqItem 내부는 더 내려가지 않음\n }\n if (Array.isArray(node.content)) node.content.forEach(walk);\n };\n if (doc && typeof doc === \"object\") {\n walk(doc as DocNode);\n }\n return out;\n}\n\n/** FAQPage JSON-LD 객체 (script 삽입은 렌더러 책임). null = 항목 없음. */\nexport function faqPageJsonLd(\n entries: FaqEntry[],\n): Record<string, unknown> | null {\n if (entries.length === 0) return null;\n return {\n \"@context\": \"https://schema.org\",\n \"@type\": \"FAQPage\",\n mainEntity: entries.map((e) => ({\n \"@type\": \"Question\",\n name: e.question,\n acceptedAnswer: { \"@type\": \"Answer\", text: e.answer },\n })),\n };\n}\n","/**\n * 본문 이미지 대체텍스트의 제품 안전 상한.\n *\n * HTML 표준의 길이 제한이 아니라, 본문·캡션 전체가 alt 에 잘못 저장돼\n * 스크린리더와 검색 결과를 오염시키는 사고를 막기 위한 RootTale 계약이다.\n */\nexport const IMAGE_ALT_MAX_LENGTH = 160;\n\n/**\n * 이미지 alt 를 한 줄 설명으로 정규화한다.\n *\n * - 문자열이 아니면 빈 alt\n * - 개행·연속 공백을 한 칸으로 축약\n * - 160자를 넘으면 말줄임표를 포함해 상한 안에서 자름\n */\nexport function normalizeImageAlt(value: unknown): string {\n if (typeof value !== \"string\") return \"\";\n const normalized = value.replace(/\\s+/g, \" \").trim();\n const characters = Array.from(normalized);\n if (characters.length <= IMAGE_ALT_MAX_LENGTH) return normalized;\n\n const visible = characters\n .slice(0, IMAGE_ALT_MAX_LENGTH - 1)\n .join(\"\")\n .trimEnd();\n return `${visible}…`;\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","import sanitizeHtmlLibrary from \"sanitize-html\";\nimport type sanitizeHtmlTypes from \"sanitize-html\";\n\n// ADR-0034 §7.1.1 test #7 — XSS sanitize helper (allow-list, 2차 방어).\n//\n// Phase 1 = strict CSP (`script-src 'self'`) 가 1차 방어. 본 helper 는 렌더 직전\n// 2차 방어로 위험 태그/속성/URL scheme 을 strip 한다.\n//\n// Tiptap 에디터/renderer 실측 allowlist:\n// - StarterKit: p/h2-h4/ul/ol/li/blockquote/pre/code/hr/br + strong/em/s\n// - editor 추가 mark: u/mark/span(style: color/background) + TextAlign(style)\n// - media/table/custom nodes: img/figure/figcaption/table/tbody/tr/th/td/div\n// - FAQ renderer: details/summary + rt-cms-* classes\n// Embed iframe 은 sanitize 경로가 아니라 Tiptap JSON `renderEmbed()` 에서\n// `isAllowedEmbedSrc` 로 검증 후 렌더된다. raw HTML sanitizer 에서는 iframe 전면 strip.\n\n// URL 속성(href/src/…) — deny-list 가 아니라 **scheme allow-list** 로 판정한다.\n// HTML entity / 제어문자 인코딩(`javascript:`, `java	script:`,\n// `jav
ascript:`)은 regex 리터럴 매칭을 우회하므로, 값에서 entity 를 decode\n// 하고 ASCII 공백/제어문자를 제거(브라우저 URL 파서와 동일 정규화)한 뒤 scheme 을\n// 검사한다. sanitize-html parser 가 처리한 속성에도 동일 probe 를 적용한다.\nconst URL_ATTRS = new Set([\"href\", \"src\", \"xlink:href\"]);\n\nconst ALLOWED_URL_SCHEMES = new Set([\"http\", \"https\", \"mailto\", \"tel\"]);\nconst MALFORMED_DANGEROUS_TOKEN_RE =\n /<\\/?[^>\\s/]*(?:script|style|iframe|object|embed|svg|math|template)[^>\\s/]*(?:\\s[^>]*)?>[\\s\\S]*?<\\/?[^>\\s/]*(?:script|style|iframe|object|embed|svg|math|template)[^>\\s/]*(?:\\s[^>]*)?>/gi;\nconst SLASH_EVENT_ATTR_IN_URL_RE = /\\/on[a-z]+=/i;\nconst ROOTTALE_CLASS_RE = /^rt-[a-z0-9_-]+$/;\nconst HEX_COLOR_RE = /^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i;\nconst TEXT_ALIGN_RE = /^(left|right|center|justify)$/;\nconst ASPECT_RATIO_RE = /^\\d+(?:\\.\\d+)?\\s*\\/\\s*\\d+(?:\\.\\d+)?$/;\n\n// scheme 구성에 악용되는 named entity 만 decode (`:`/`	` 등).\nconst NAMED_ENTITY: Record<string, string> = {\n colon: \":\",\n tab: \"\\t\",\n newline: \"\\n\",\n sol: \"/\",\n semi: \";\",\n num: \"#\",\n period: \".\",\n comma: \",\",\n lpar: \"(\",\n rpar: \")\",\n};\n\nfunction fromCodePointSafe(n: number): string {\n if (!Number.isFinite(n) || n < 0 || n > 0x10ffff) return \"\";\n try {\n return String.fromCodePoint(n);\n } catch {\n return \"\";\n }\n}\n\n/** URL 값 정규화 — entity decode + 공백/제어문자 제거 + 소문자 (scheme 판정용). */\nfunction decodeUrlProbe(value: string): string {\n const decoded = value\n .replace(/&#x([0-9a-f]+);?/gi, (_, h) => fromCodePointSafe(parseInt(h, 16)))\n .replace(/&#(\\d+);?/g, (_, d) => fromCodePointSafe(parseInt(d, 10)))\n .replace(/&([a-z]+);/gi, (m, name) => NAMED_ENTITY[name.toLowerCase()] ?? m);\n // 브라우저는 scheme 판정 시 ASCII 공백/제어문자(탭/개행/CR/NUL 포함)를 제거한다.\n return [...decoded].filter((c) => { const n = c.codePointAt(0) ?? 0; return n > 0x20 && n !== 0x7f; }).join(\"\").toLowerCase();\n}\n\nfunction stripMalformedDangerousTokens(html: string): string {\n let out = html;\n for (let i = 0; i < 3; i++) {\n const next = out.replace(MALFORMED_DANGEROUS_TOKEN_RE, \"\");\n if (next === out) return out;\n out = next;\n }\n return out;\n}\n\n/**\n * 안전한 URL 값인지 — 상대경로/허용 scheme, data:image/* 는 img[src] 에서만(SVG 제외).\n */\nfunction isSafeUrlValue(tag: string, attr: string, value: string): boolean {\n const probe = decodeUrlProbe(value);\n if (SLASH_EVENT_ATTR_IN_URL_RE.test(probe)) return false;\n const scheme = /^([a-z][a-z0-9+.-]*):/.exec(probe)?.[1];\n if (!scheme) return true; // 상대경로 / fragment / query\n if (ALLOWED_URL_SCHEMES.has(scheme)) return true;\n if (\n scheme === \"data\" &&\n tag === \"img\" &&\n attr === \"src\" &&\n probe.startsWith(\"data:image/\")\n ) {\n // data:image/svg+xml 은 스크립트 실행 가능 → 차단.\n return !probe.startsWith(\"data:image/svg\");\n }\n return false;\n}\n\nfunction sanitizeUrlAttributes(\n tagName: string,\n attribs: sanitizeHtmlTypes.Attributes,\n): sanitizeHtmlTypes.Tag {\n const next: sanitizeHtmlTypes.Attributes = {};\n for (const [name, value] of Object.entries(attribs)) {\n const attr = name.toLowerCase();\n next[name] =\n URL_ATTRS.has(attr) && !isSafeUrlValue(tagName, attr, value) ? \"#\" : value;\n }\n return { tagName, attribs: next };\n}\n\nconst SANITIZE_OPTIONS: sanitizeHtmlTypes.IOptions = {\n allowedTags: [\n \"a\",\n \"blockquote\",\n \"br\",\n \"cite\",\n \"code\",\n \"details\",\n \"div\",\n \"em\",\n \"figcaption\",\n \"figure\",\n \"h2\",\n \"h3\",\n \"h4\",\n \"hr\",\n \"img\",\n \"li\",\n \"mark\",\n \"ol\",\n \"p\",\n \"pre\",\n \"s\",\n \"span\",\n \"strong\",\n \"summary\",\n \"table\",\n \"tbody\",\n \"td\",\n \"th\",\n \"tr\",\n \"u\",\n \"ul\",\n ],\n allowedAttributes: {\n \"*\": [\"class\", \"data-*\"],\n a: [\n \"href\",\n \"title\",\n { name: \"rel\", multiple: true, values: [\"noopener\", \"noreferrer\"] },\n { name: \"target\", values: [\"_blank\"] },\n ],\n details: [\"open\", \"class\", \"data-*\"],\n div: [\"class\", \"data-*\", \"style\"],\n h2: [\"id\", \"class\", \"data-*\", \"style\"],\n h3: [\"id\", \"class\", \"data-*\", \"style\"],\n h4: [\"id\", \"class\", \"data-*\", \"style\"],\n img: [\"src\", \"alt\", \"title\", \"width\", \"height\", \"loading\", \"class\", \"data-*\"],\n mark: [\"class\", \"data-*\", \"style\"],\n p: [\"class\", \"data-*\", \"style\"],\n span: [\"class\", \"data-*\", \"style\"],\n td: [\"colspan\", \"rowspan\", \"class\", \"data-*\"],\n th: [\"colspan\", \"rowspan\", \"class\", \"data-*\"],\n },\n allowedClasses: {\n \"*\": [ROOTTALE_CLASS_RE],\n },\n allowedStyles: {\n \"*\": {\n \"aspect-ratio\": [ASPECT_RATIO_RE],\n background: [HEX_COLOR_RE],\n \"background-color\": [HEX_COLOR_RE],\n color: [HEX_COLOR_RE],\n \"text-align\": [TEXT_ALIGN_RE],\n },\n },\n allowedSchemes: [\"http\", \"https\", \"mailto\", \"tel\"],\n allowedSchemesByTag: {\n // tag별 목록은 전역 allowedSchemes를 덮어쓴다. data만 쓰면 정상적인\n // http/https 이미지 src까지 제거되므로 이미지가 사용할 전체 scheme을 명시한다.\n img: [\"http\", \"https\", \"data\"],\n },\n allowedSchemesAppliedToAttributes: [\"href\", \"src\", \"xlink:href\"],\n allowProtocolRelative: false,\n nonTextTags: [\n \"script\",\n \"style\",\n \"textarea\",\n \"option\",\n \"iframe\",\n \"object\",\n \"embed\",\n \"svg\",\n \"math\",\n \"template\",\n ],\n transformTags: {\n \"*\": sanitizeUrlAttributes,\n },\n};\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 if (!html) return html;\n return sanitizeHtmlLibrary(stripMalformedDangerousTokens(html), SANITIZE_OPTIONS);\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 // core/embed allowlist (embed.ts) 의 영상 iframe — youtube-nocookie / vimeo.\n \"frame-src https://www.youtube-nocookie.com https://www.youtube.com https://player.vimeo.com; \" +\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":["sanitizeHtmlLibrary"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -90,7 +90,14 @@ declare function bindingSrcValue(value: unknown): string | undefined;
|
|
|
90
90
|
interface RouteCollection {
|
|
91
91
|
/** 안정 식별자 (예: "notice" | "blog"). */
|
|
92
92
|
readonly key: string;
|
|
93
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* 라우트 base path (예: "/notice" | "/blog"). 앞 슬래시 포함, 뒤 슬래시 없이.
|
|
95
|
+
* **`""`(빈 문자열) = 주소 없는 유형** — 페이지 안에서 불러와 쓰는 콘텐츠(강사·리뷰
|
|
96
|
+
* 등)로, 라우트/사이트맵/피드/아카이브를 일절 파생하지 않는다. 외부 사이트는
|
|
97
|
+
* posts API 의 `collectionKey` 필터로 내용만 가져다 렌더한다. `resolvePostUrl`/
|
|
98
|
+
* `resolvePostPath` 는 이 값이 빈 문자열이면 항상 null. 소속 판정
|
|
99
|
+
* (`resolvePostCollection`)에는 영향 없음 — key 만으로 소속이 성립한다.
|
|
100
|
+
*/
|
|
94
101
|
readonly basePath: string;
|
|
95
102
|
/**
|
|
96
103
|
* 이 섹션이 제공하는 **주제(category) allowlist** — `${basePath}/categories/{slug}`
|
|
@@ -138,14 +145,15 @@ declare function isInCollectionMode(collections: readonly RouteCollection[] | un
|
|
|
138
145
|
declare function resolvePostCollection(post: RoutablePost, collections: readonly RouteCollection[]): RouteCollection | null;
|
|
139
146
|
/**
|
|
140
147
|
* 글의 정규 절대 URL — 소속 스트림 basePath 기준. 어느 스트림에도 안 속하거나,
|
|
141
|
-
* 소속 스트림이 `detail: false`(목록 전용 유형)
|
|
142
|
-
* slug 는 percent-encoding (한글 slug 안전).
|
|
148
|
+
* 소속 스트림이 `detail: false`(목록 전용 유형) 이거나, `basePath` 가 빈 문자열
|
|
149
|
+
* (주소 없는 유형)이면 null. slug 는 percent-encoding (한글 slug 안전).
|
|
143
150
|
*/
|
|
144
151
|
declare function resolvePostUrl(siteUrl: string, post: RoutablePost, collections: readonly RouteCollection[]): string | null;
|
|
145
152
|
/**
|
|
146
153
|
* 글의 *경로*(siteUrl 없는 basePath-상대) — 예: "/blog/hello". 소속 스트림이
|
|
147
|
-
*
|
|
148
|
-
*
|
|
154
|
+
* 없거나, `detail: false`(목록 전용 유형) 이거나, `basePath` 가 빈 문자열
|
|
155
|
+
* (주소 없는 유형)이면 null. astro 처럼 상대 href 가 필요한 소비자용
|
|
156
|
+
* (`resolvePostUrl` 은 절대 URL).
|
|
149
157
|
*/
|
|
150
158
|
declare function resolvePostPath(post: RoutablePost, collections: readonly RouteCollection[]): string | null;
|
|
151
159
|
/**
|
|
@@ -163,7 +171,10 @@ declare function postsInAnyCollection<T extends RoutablePost>(posts: readonly T[
|
|
|
163
171
|
declare function postsNotInAnyCollection<T extends RoutablePost>(posts: readonly T[], collections: readonly RouteCollection[]): T[];
|
|
164
172
|
/**
|
|
165
173
|
* config 검증 — 중복 key/basePath, 그리고 **두 스트림에 동시 등장하는 category slug**
|
|
166
|
-
* (라우팅 모호성)를 탐지.
|
|
174
|
+
* (라우팅 모호성)를 탐지. `basePath === ""`(주소 없는 유형)는 basePath 형식·중복
|
|
175
|
+
* 검사를 건너뛴다 — 라우트가 없으므로 여러 개 공존해도 모호성이 없다. key 중복·
|
|
176
|
+
* category 중복 검사는 주소 유무와 무관하게 전 항목에 적용된다. 문제 메시지 배열
|
|
177
|
+
* 반환(빈 = 정상). 빌드 타임 fail-fast 용.
|
|
167
178
|
*/
|
|
168
179
|
declare function validateCollections(collections: readonly RouteCollection[]): string[];
|
|
169
180
|
|
|
@@ -243,6 +254,22 @@ declare function extractFaqEntries(doc: unknown): FaqEntry[];
|
|
|
243
254
|
/** FAQPage JSON-LD 객체 (script 삽입은 렌더러 책임). null = 항목 없음. */
|
|
244
255
|
declare function faqPageJsonLd(entries: FaqEntry[]): Record<string, unknown> | null;
|
|
245
256
|
|
|
257
|
+
/**
|
|
258
|
+
* 본문 이미지 대체텍스트의 제품 안전 상한.
|
|
259
|
+
*
|
|
260
|
+
* HTML 표준의 길이 제한이 아니라, 본문·캡션 전체가 alt 에 잘못 저장돼
|
|
261
|
+
* 스크린리더와 검색 결과를 오염시키는 사고를 막기 위한 RootTale 계약이다.
|
|
262
|
+
*/
|
|
263
|
+
declare const IMAGE_ALT_MAX_LENGTH = 160;
|
|
264
|
+
/**
|
|
265
|
+
* 이미지 alt 를 한 줄 설명으로 정규화한다.
|
|
266
|
+
*
|
|
267
|
+
* - 문자열이 아니면 빈 alt
|
|
268
|
+
* - 개행·연속 공백을 한 칸으로 축약
|
|
269
|
+
* - 160자를 넘으면 말줄임표를 포함해 상한 안에서 자름
|
|
270
|
+
*/
|
|
271
|
+
declare function normalizeImageAlt(value: unknown): string;
|
|
272
|
+
|
|
246
273
|
interface PreviewTokenPayload {
|
|
247
274
|
postId: string;
|
|
248
275
|
expiresAt: Date;
|
|
@@ -278,4 +305,4 @@ declare function injectStableIdsTree(blocks: readonly (Omit<Block, "_id"> & {
|
|
|
278
305
|
_id?: string;
|
|
279
306
|
})[]): Block[];
|
|
280
307
|
|
|
281
|
-
export { BLOCK_NAME_PATTERN, type BindingResolver, type Block, type BlockBinding, type BlockDefinition, type BlockRegistry, type ClientHydrationSpec, type EmbedProvider, FIELD_BINDING_SOURCE, FIELD_BINDING_SOURCE_ALIAS, type FaqEntry, type NormalizedEmbed, PHASE_1_CORE_BLOCKS, PHASE_1_CSP, type Phase1CoreBlockName, type PreviewTokenPayload, type RoutablePost, type RouteCollection, applyBlockBindings, bindingSrcValue, bindingTextValue, blockSchema, boundFieldValue, chainResolvers, createFieldBindingResolver, ensureStableIds, extractFaqEntries, faqPageJsonLd, injectStableIdsTree, isAllowedEmbedSrc, isAllowedMapEmbedSrc, isInCollectionMode, isValidBlock, issuePreviewToken, normalizeEmbed, parseBlock, postsForCollection, postsInAnyCollection, postsNotInAnyCollection, resolvePostCollection, resolvePostPath, resolvePostUrl, sanitizeHtml, serializeBlock, validateCollections, verifyPreviewToken };
|
|
308
|
+
export { BLOCK_NAME_PATTERN, type BindingResolver, type Block, type BlockBinding, type BlockDefinition, type BlockRegistry, type ClientHydrationSpec, type EmbedProvider, FIELD_BINDING_SOURCE, FIELD_BINDING_SOURCE_ALIAS, type FaqEntry, IMAGE_ALT_MAX_LENGTH, type NormalizedEmbed, PHASE_1_CORE_BLOCKS, PHASE_1_CSP, type Phase1CoreBlockName, type PreviewTokenPayload, type RoutablePost, type RouteCollection, applyBlockBindings, bindingSrcValue, bindingTextValue, blockSchema, boundFieldValue, chainResolvers, createFieldBindingResolver, ensureStableIds, extractFaqEntries, faqPageJsonLd, injectStableIdsTree, isAllowedEmbedSrc, isAllowedMapEmbedSrc, isInCollectionMode, isValidBlock, issuePreviewToken, normalizeEmbed, normalizeImageAlt, parseBlock, postsForCollection, postsInAnyCollection, postsNotInAnyCollection, resolvePostCollection, resolvePostPath, resolvePostUrl, sanitizeHtml, serializeBlock, validateCollections, verifyPreviewToken };
|
package/dist/index.d.ts
CHANGED
|
@@ -90,7 +90,14 @@ declare function bindingSrcValue(value: unknown): string | undefined;
|
|
|
90
90
|
interface RouteCollection {
|
|
91
91
|
/** 안정 식별자 (예: "notice" | "blog"). */
|
|
92
92
|
readonly key: string;
|
|
93
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* 라우트 base path (예: "/notice" | "/blog"). 앞 슬래시 포함, 뒤 슬래시 없이.
|
|
95
|
+
* **`""`(빈 문자열) = 주소 없는 유형** — 페이지 안에서 불러와 쓰는 콘텐츠(강사·리뷰
|
|
96
|
+
* 등)로, 라우트/사이트맵/피드/아카이브를 일절 파생하지 않는다. 외부 사이트는
|
|
97
|
+
* posts API 의 `collectionKey` 필터로 내용만 가져다 렌더한다. `resolvePostUrl`/
|
|
98
|
+
* `resolvePostPath` 는 이 값이 빈 문자열이면 항상 null. 소속 판정
|
|
99
|
+
* (`resolvePostCollection`)에는 영향 없음 — key 만으로 소속이 성립한다.
|
|
100
|
+
*/
|
|
94
101
|
readonly basePath: string;
|
|
95
102
|
/**
|
|
96
103
|
* 이 섹션이 제공하는 **주제(category) allowlist** — `${basePath}/categories/{slug}`
|
|
@@ -138,14 +145,15 @@ declare function isInCollectionMode(collections: readonly RouteCollection[] | un
|
|
|
138
145
|
declare function resolvePostCollection(post: RoutablePost, collections: readonly RouteCollection[]): RouteCollection | null;
|
|
139
146
|
/**
|
|
140
147
|
* 글의 정규 절대 URL — 소속 스트림 basePath 기준. 어느 스트림에도 안 속하거나,
|
|
141
|
-
* 소속 스트림이 `detail: false`(목록 전용 유형)
|
|
142
|
-
* slug 는 percent-encoding (한글 slug 안전).
|
|
148
|
+
* 소속 스트림이 `detail: false`(목록 전용 유형) 이거나, `basePath` 가 빈 문자열
|
|
149
|
+
* (주소 없는 유형)이면 null. slug 는 percent-encoding (한글 slug 안전).
|
|
143
150
|
*/
|
|
144
151
|
declare function resolvePostUrl(siteUrl: string, post: RoutablePost, collections: readonly RouteCollection[]): string | null;
|
|
145
152
|
/**
|
|
146
153
|
* 글의 *경로*(siteUrl 없는 basePath-상대) — 예: "/blog/hello". 소속 스트림이
|
|
147
|
-
*
|
|
148
|
-
*
|
|
154
|
+
* 없거나, `detail: false`(목록 전용 유형) 이거나, `basePath` 가 빈 문자열
|
|
155
|
+
* (주소 없는 유형)이면 null. astro 처럼 상대 href 가 필요한 소비자용
|
|
156
|
+
* (`resolvePostUrl` 은 절대 URL).
|
|
149
157
|
*/
|
|
150
158
|
declare function resolvePostPath(post: RoutablePost, collections: readonly RouteCollection[]): string | null;
|
|
151
159
|
/**
|
|
@@ -163,7 +171,10 @@ declare function postsInAnyCollection<T extends RoutablePost>(posts: readonly T[
|
|
|
163
171
|
declare function postsNotInAnyCollection<T extends RoutablePost>(posts: readonly T[], collections: readonly RouteCollection[]): T[];
|
|
164
172
|
/**
|
|
165
173
|
* config 검증 — 중복 key/basePath, 그리고 **두 스트림에 동시 등장하는 category slug**
|
|
166
|
-
* (라우팅 모호성)를 탐지.
|
|
174
|
+
* (라우팅 모호성)를 탐지. `basePath === ""`(주소 없는 유형)는 basePath 형식·중복
|
|
175
|
+
* 검사를 건너뛴다 — 라우트가 없으므로 여러 개 공존해도 모호성이 없다. key 중복·
|
|
176
|
+
* category 중복 검사는 주소 유무와 무관하게 전 항목에 적용된다. 문제 메시지 배열
|
|
177
|
+
* 반환(빈 = 정상). 빌드 타임 fail-fast 용.
|
|
167
178
|
*/
|
|
168
179
|
declare function validateCollections(collections: readonly RouteCollection[]): string[];
|
|
169
180
|
|
|
@@ -243,6 +254,22 @@ declare function extractFaqEntries(doc: unknown): FaqEntry[];
|
|
|
243
254
|
/** FAQPage JSON-LD 객체 (script 삽입은 렌더러 책임). null = 항목 없음. */
|
|
244
255
|
declare function faqPageJsonLd(entries: FaqEntry[]): Record<string, unknown> | null;
|
|
245
256
|
|
|
257
|
+
/**
|
|
258
|
+
* 본문 이미지 대체텍스트의 제품 안전 상한.
|
|
259
|
+
*
|
|
260
|
+
* HTML 표준의 길이 제한이 아니라, 본문·캡션 전체가 alt 에 잘못 저장돼
|
|
261
|
+
* 스크린리더와 검색 결과를 오염시키는 사고를 막기 위한 RootTale 계약이다.
|
|
262
|
+
*/
|
|
263
|
+
declare const IMAGE_ALT_MAX_LENGTH = 160;
|
|
264
|
+
/**
|
|
265
|
+
* 이미지 alt 를 한 줄 설명으로 정규화한다.
|
|
266
|
+
*
|
|
267
|
+
* - 문자열이 아니면 빈 alt
|
|
268
|
+
* - 개행·연속 공백을 한 칸으로 축약
|
|
269
|
+
* - 160자를 넘으면 말줄임표를 포함해 상한 안에서 자름
|
|
270
|
+
*/
|
|
271
|
+
declare function normalizeImageAlt(value: unknown): string;
|
|
272
|
+
|
|
246
273
|
interface PreviewTokenPayload {
|
|
247
274
|
postId: string;
|
|
248
275
|
expiresAt: Date;
|
|
@@ -278,4 +305,4 @@ declare function injectStableIdsTree(blocks: readonly (Omit<Block, "_id"> & {
|
|
|
278
305
|
_id?: string;
|
|
279
306
|
})[]): Block[];
|
|
280
307
|
|
|
281
|
-
export { BLOCK_NAME_PATTERN, type BindingResolver, type Block, type BlockBinding, type BlockDefinition, type BlockRegistry, type ClientHydrationSpec, type EmbedProvider, FIELD_BINDING_SOURCE, FIELD_BINDING_SOURCE_ALIAS, type FaqEntry, type NormalizedEmbed, PHASE_1_CORE_BLOCKS, PHASE_1_CSP, type Phase1CoreBlockName, type PreviewTokenPayload, type RoutablePost, type RouteCollection, applyBlockBindings, bindingSrcValue, bindingTextValue, blockSchema, boundFieldValue, chainResolvers, createFieldBindingResolver, ensureStableIds, extractFaqEntries, faqPageJsonLd, injectStableIdsTree, isAllowedEmbedSrc, isAllowedMapEmbedSrc, isInCollectionMode, isValidBlock, issuePreviewToken, normalizeEmbed, parseBlock, postsForCollection, postsInAnyCollection, postsNotInAnyCollection, resolvePostCollection, resolvePostPath, resolvePostUrl, sanitizeHtml, serializeBlock, validateCollections, verifyPreviewToken };
|
|
308
|
+
export { BLOCK_NAME_PATTERN, type BindingResolver, type Block, type BlockBinding, type BlockDefinition, type BlockRegistry, type ClientHydrationSpec, type EmbedProvider, FIELD_BINDING_SOURCE, FIELD_BINDING_SOURCE_ALIAS, type FaqEntry, IMAGE_ALT_MAX_LENGTH, type NormalizedEmbed, PHASE_1_CORE_BLOCKS, PHASE_1_CSP, type Phase1CoreBlockName, type PreviewTokenPayload, type RoutablePost, type RouteCollection, applyBlockBindings, bindingSrcValue, bindingTextValue, blockSchema, boundFieldValue, chainResolvers, createFieldBindingResolver, ensureStableIds, extractFaqEntries, faqPageJsonLd, injectStableIdsTree, isAllowedEmbedSrc, isAllowedMapEmbedSrc, isInCollectionMode, isValidBlock, issuePreviewToken, normalizeEmbed, normalizeImageAlt, parseBlock, postsForCollection, postsInAnyCollection, postsNotInAnyCollection, resolvePostCollection, resolvePostPath, resolvePostUrl, sanitizeHtml, serializeBlock, validateCollections, verifyPreviewToken };
|
package/dist/index.js
CHANGED
|
@@ -130,12 +130,14 @@ function resolvePostUrl(siteUrl, post, collections) {
|
|
|
130
130
|
const collection = resolvePostCollection(post, collections);
|
|
131
131
|
if (!collection) return null;
|
|
132
132
|
if (collection.detail === false) return null;
|
|
133
|
+
if (!collection.basePath) return null;
|
|
133
134
|
return `${trimTrailingSlash(siteUrl)}${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;
|
|
134
135
|
}
|
|
135
136
|
function resolvePostPath(post, collections) {
|
|
136
137
|
const collection = resolvePostCollection(post, collections);
|
|
137
138
|
if (!collection) return null;
|
|
138
139
|
if (collection.detail === false) return null;
|
|
140
|
+
if (!collection.basePath) return null;
|
|
139
141
|
return `${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;
|
|
140
142
|
}
|
|
141
143
|
function postsForCollection(posts, collections, key) {
|
|
@@ -155,15 +157,17 @@ function validateCollections(collections) {
|
|
|
155
157
|
for (const c of collections) {
|
|
156
158
|
if (seenKey.has(c.key)) problems.push(`\uC911\uBCF5 collection key: "${c.key}"`);
|
|
157
159
|
seenKey.add(c.key);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
160
|
+
if (c.basePath !== "") {
|
|
161
|
+
const base = trimTrailingSlash(c.basePath);
|
|
162
|
+
if (seenBase.has(base)) problems.push(`\uC911\uBCF5 basePath: "${c.basePath}"`);
|
|
163
|
+
seenBase.add(base);
|
|
164
|
+
if (!c.basePath.startsWith("/")) {
|
|
165
|
+
problems.push(`basePath \uB294 "/" \uB85C \uC2DC\uC791\uD574\uC57C \uD568: "${c.basePath}" (${c.key})`);
|
|
166
|
+
} else if (base.length < 2 || base.startsWith("//") || /[\\?#\s]/.test(base) || base.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
167
|
+
problems.push(
|
|
168
|
+
`basePath \uB294 \uC0AC\uC774\uD2B8 \uB0B4\uBD80 \uC808\uB300 \uACBD\uB85C\uC5EC\uC57C \uD568: "${c.basePath}" (${c.key})`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
167
171
|
}
|
|
168
172
|
for (const slug of c.categories) {
|
|
169
173
|
const owner = categoryOwner.get(slug);
|
|
@@ -365,6 +369,17 @@ function faqPageJsonLd(entries) {
|
|
|
365
369
|
};
|
|
366
370
|
}
|
|
367
371
|
|
|
372
|
+
// src/image-alt.ts
|
|
373
|
+
var IMAGE_ALT_MAX_LENGTH = 160;
|
|
374
|
+
function normalizeImageAlt(value) {
|
|
375
|
+
if (typeof value !== "string") return "";
|
|
376
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
377
|
+
const characters = Array.from(normalized);
|
|
378
|
+
if (characters.length <= IMAGE_ALT_MAX_LENGTH) return normalized;
|
|
379
|
+
const visible = characters.slice(0, IMAGE_ALT_MAX_LENGTH - 1).join("").trimEnd();
|
|
380
|
+
return `${visible}\u2026`;
|
|
381
|
+
}
|
|
382
|
+
|
|
368
383
|
// src/preview-token.ts
|
|
369
384
|
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
370
385
|
var DELIMITER = ".";
|
|
@@ -532,7 +547,9 @@ var SANITIZE_OPTIONS = {
|
|
|
532
547
|
},
|
|
533
548
|
allowedSchemes: ["http", "https", "mailto", "tel"],
|
|
534
549
|
allowedSchemesByTag: {
|
|
535
|
-
|
|
550
|
+
// tag별 목록은 전역 allowedSchemes를 덮어쓴다. data만 쓰면 정상적인
|
|
551
|
+
// http/https 이미지 src까지 제거되므로 이미지가 사용할 전체 scheme을 명시한다.
|
|
552
|
+
img: ["http", "https", "data"]
|
|
536
553
|
},
|
|
537
554
|
allowedSchemesAppliedToAttributes: ["href", "src", "xlink:href"],
|
|
538
555
|
allowProtocolRelative: false,
|
|
@@ -576,6 +593,7 @@ export {
|
|
|
576
593
|
BLOCK_NAME_PATTERN,
|
|
577
594
|
FIELD_BINDING_SOURCE,
|
|
578
595
|
FIELD_BINDING_SOURCE_ALIAS,
|
|
596
|
+
IMAGE_ALT_MAX_LENGTH,
|
|
579
597
|
PHASE_1_CORE_BLOCKS,
|
|
580
598
|
PHASE_1_CSP,
|
|
581
599
|
applyBlockBindings,
|
|
@@ -595,6 +613,7 @@ export {
|
|
|
595
613
|
isValidBlock,
|
|
596
614
|
issuePreviewToken,
|
|
597
615
|
normalizeEmbed,
|
|
616
|
+
normalizeImageAlt,
|
|
598
617
|
parseBlock,
|
|
599
618
|
postsForCollection,
|
|
600
619
|
postsInAnyCollection,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/block.ts","../src/bindings.ts","../src/collections.ts","../src/delimiter.ts","../src/embed.ts","../src/faq.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-0041 §7 — Block Bindings: ACF 필드 → 블록 attribute.\n//\n// 블록의 `metadata.bindings[attr] = { source, args }` 를 만나면 attribute 를\n// 바인딩 값으로 치환한다. 렌더 직전 pure transform — renderBlock 시그니처 불변.\n//\n// canonical source id = `roottale/field`. WP round-trip 위해 `acf/field` alias\n// 인식. resolver 는 주입(렌더러는 cms-fields 비종속) — 값 record 만 흐른다.\n\nimport type { Block } from \"./block\";\n\nexport interface BlockBinding {\n source: string;\n args?: Record<string, unknown>;\n}\n\n/**\n * 바인딩 해석기. 주어진 binding(+attribute/block)에 대한 값을 반환.\n * `undefined` 반환 = 미해석(원래 attribute 유지).\n */\nexport type BindingResolver = (\n binding: BlockBinding,\n attribute: string,\n block: Block,\n) => unknown;\n\n/** ACF 필드 바인딩 source id. */\nexport const FIELD_BINDING_SOURCE = \"roottale/field\";\n/** WP ACF 호환 alias (import round-trip). */\nexport const FIELD_BINDING_SOURCE_ALIAS = \"acf/field\";\n\n/**\n * acf 값 record 로부터 필드 바인딩 resolver 생성.\n * `roottale/field` / `acf/field` source 의 `args.name` 을 record 에서 조회.\n */\nexport function createFieldBindingResolver(\n fields: Record<string, unknown> | undefined | null,\n): BindingResolver {\n const values = fields ?? {};\n return (binding) => {\n if (\n binding.source !== FIELD_BINDING_SOURCE &&\n binding.source !== FIELD_BINDING_SOURCE_ALIAS\n ) {\n return undefined;\n }\n const name = binding.args?.name;\n if (typeof name !== \"string\") return undefined;\n const v = values[name];\n return v === undefined ? undefined : v;\n };\n}\n\n/** 여러 resolver 를 순서대로 시도(첫 non-undefined 채택). */\nexport function chainResolvers(\n ...resolvers: BindingResolver[]\n): BindingResolver {\n return (binding, attribute, block) => {\n for (const r of resolvers) {\n const v = r(binding, attribute, block);\n if (v !== undefined) return v;\n }\n return undefined;\n };\n}\n\nfunction applyToBlock(block: Block, resolver: BindingResolver): Block {\n const bindings = block.metadata?.bindings;\n let attributes = block.attributes;\n if (bindings) {\n let mutated = false;\n const next = { ...block.attributes };\n for (const [attr, binding] of Object.entries(bindings)) {\n const value = resolver(binding, attr, block);\n if (value !== undefined) {\n next[attr] = value;\n mutated = true;\n }\n }\n if (mutated) attributes = next;\n }\n const innerBlocks = block.innerBlocks.length\n ? block.innerBlocks.map((b) => applyToBlock(b, resolver))\n : block.innerBlocks;\n if (attributes === block.attributes && innerBlocks === block.innerBlocks) {\n return block;\n }\n return { ...block, attributes, innerBlocks };\n}\n\n/**\n * 블록 트리에 바인딩을 적용해 새 트리 반환(immutable). 바인딩 없으면 원본 그대로.\n */\nexport function applyBlockBindings(\n blocks: readonly Block[],\n resolver: BindingResolver,\n): Block[] {\n return blocks.map((b) => applyToBlock(b, resolver));\n}\n\n// ── 라이브 Tiptap 렌더 경로용 헬퍼 (cms-field-binding-editor.md D3·D4) ─────────\n//\n// Tiptap doc 의 node.attrs.bindings (BlockBindings extension 이 기록, Block JSON\n// metadata.bindings 와 동일 형태) 를 렌더 시점에 필드 값으로 치환할 때 사용.\n\n/**\n * bindings 에서 `attribute` 의 필드 값을 조회. `roottale/field`/`acf/field`\n * source 만 해석. 빈 값(undefined/null/\"\") = undefined → caller 가 fallback 렌더.\n */\nexport function boundFieldValue(\n bindings: unknown,\n attribute: string,\n fields: Record<string, unknown> | null | undefined,\n): unknown {\n if (!bindings || typeof bindings !== \"object\" || !fields) return undefined;\n const binding = (bindings as Record<string, BlockBinding | undefined>)[\n attribute\n ];\n if (\n !binding ||\n (binding.source !== FIELD_BINDING_SOURCE &&\n binding.source !== FIELD_BINDING_SOURCE_ALIAS)\n ) {\n return undefined;\n }\n const name = binding.args?.name;\n if (typeof name !== \"string\") return undefined;\n const value = fields[name];\n if (value === undefined || value === null || value === \"\") return undefined;\n return value;\n}\n\n/**\n * content 바인딩 값의 텍스트화. **항상 plain text** — caller 가 escape 해\n * 삽입한다 (바인딩 경로로 raw HTML 진입 금지, D4). string/number 외 = undefined.\n */\nexport function bindingTextValue(value: unknown): string | undefined {\n if (typeof value === \"string\") return value;\n if (typeof value === \"number\" && Number.isFinite(value)) return String(value);\n return undefined;\n}\n\n/**\n * image `src` 바인딩 값 → URL. 포맷된 MediaRef(`{url}`) 또는 http(s) 문자열만\n * 인정 — raw media id(uuid) 는 URL 이 아니므로 fallback.\n */\nexport function bindingSrcValue(value: unknown): string | undefined {\n if (value && typeof value === \"object\") {\n const url = (value as { url?: unknown }).url;\n if (typeof url === \"string\" && /^https?:\\/\\//i.test(url)) return url;\n return undefined;\n }\n if (typeof value === \"string\" && /^https?:\\/\\//i.test(value)) return value;\n return undefined;\n}\n","/**\n * 다중 스트림 콘텐츠 라우팅 프리미티브 (ADR-0060).\n *\n * 같은 글 풀(post)을 둘 이상의 스트림으로 분리(공지=게시판 /notice, 블로그=그리드\n * /blog 등). `cms-renderer-next` / `cms-renderer-astro` 가 동일 로직을 공유하도록\n * 여기(cms-core)에 둔다 — cms-client·프레임워크 비의존(구조적 타입만).\n *\n * 설계(codex consult `019ef577` 반영):\n * - **활성화 = `collections.length > 0`.** 빈 배열·미전달 = 레거시 단일 /blog.\n * - `RouteCollection` 은 **라우팅 최소셋**만(key/basePath/categories/feed/archives).\n * label/layout/icon 등 표시·작성 메타는 templates `CollectionSchema` 에 둔다.\n */\n\n/** 콘텐츠 스트림 1개 — sitemap/feed/아카이브 파생의 라우팅 단위. */\nexport interface RouteCollection {\n /** 안정 식별자 (예: \"notice\" | \"blog\"). */\n readonly key: string;\n /** 라우트 base path (예: \"/notice\" | \"/blog\"). 앞 슬래시 포함, 뒤 슬래시 없이. */\n readonly basePath: string;\n /**\n * 이 섹션이 제공하는 **주제(category) allowlist** — `${basePath}/categories/{slug}`\n * 아카이브 라우트 범위. ADR-0060 Amendment 1 (M4): 섹션 소속은 글의 `collectionKey`\n * 로 정해지므로 이 배열은 더 이상 라우팅 소유권이 아니다(catch-all 개념 폐기). 빈\n * 배열 = 이 섹션은 주제 없음.\n */\n readonly categories: readonly string[];\n /** RSS 피드 포함 여부 (기본 false). */\n readonly feed?: boolean;\n /** `${basePath}/categories/{slug}` 아카이브 emit 여부 (기본 false). */\n readonly archives?: boolean;\n /**\n * 글별 상세 라우트 존재 여부 (기본 true — 하위 호환). false 면 이 스트림은\n * \"목록 전용\" 유형(예: 후기 — 목록에서 전문 노출, 글마다 별도 상세 URL 없음).\n * `resolvePostUrl`/`resolvePostPath` 가 null 을 반환해 sitemap/feed 에서 글\n * URL 을 발행하지 않는다. 소속 판정(`resolvePostCollection`)에는 영향 없음\n * — 소속과 상세 URL 존재 여부는 별개(M4 원칙 연장).\n */\n readonly detail?: boolean;\n}\n\n/** resolve 입력 — 글의 구조적 최소 형태(cms-client `CmsPostContent` 와 구조 호환). */\nexport interface RoutablePost {\n readonly slug: string;\n /** 글의 주제(category) terms — 아카이브 라우트 필터용. 섹션 판정엔 쓰지 않는다(M4). */\n readonly terms: ReadonlyArray<{ readonly taxonomy: string; readonly slug: string }>;\n /**\n * ADR-0060 Amendment 1 (Option A, M4) — 글에 명시된 섹션 key. 섹션 판정의 유일한\n * 근거. 미설정/미존재면 어느 스트림에도 안 속함(null). 카테고리 파생 fallback 은 제거됨.\n */\n readonly collectionKey?: string | null;\n}\n\nfunction trimTrailingSlash(value: string): string {\n return value.replace(/\\/+$/, \"\");\n}\n\n/** collection 모드인지 — 길이 0(또는 미전달)이면 레거시 단일 /blog 동작. */\nexport function isInCollectionMode(\n collections: readonly RouteCollection[] | undefined,\n): boolean {\n return !!collections && collections.length > 0;\n}\n\n/**\n * 글이 속한 collection 1개를 해석 — ADR-0060 Amendment 1 (Option A, M4): **글의\n * 명시 `collectionKey` 만** 본다. 미설정/미존재 key 면 null(상세 라우트 없는 글 →\n * sitemap 제외 신호). 카테고리 slug 파생·catch-all·first-wins 는 M4 에서 제거됨 —\n * 섹션은 카테고리가 아니라 글에 명시한다. (`categories` 는 이제 주제 allowlist 로만,\n * 아카이브 라우트에서 쓰임.)\n */\nexport function resolvePostCollection(\n post: RoutablePost,\n collections: readonly RouteCollection[],\n): RouteCollection | null {\n if (!post.collectionKey) return null;\n return collections.find((c) => c.key === post.collectionKey) ?? null;\n}\n\n/**\n * 글의 정규 절대 URL — 소속 스트림 basePath 기준. 어느 스트림에도 안 속하거나,\n * 소속 스트림이 `detail: false`(목록 전용 유형)면 null.\n * slug 는 percent-encoding (한글 slug 안전).\n */\nexport function resolvePostUrl(\n siteUrl: string,\n post: RoutablePost,\n collections: readonly RouteCollection[],\n): string | null {\n const collection = resolvePostCollection(post, collections);\n if (!collection) return null;\n if (collection.detail === false) return null;\n return `${trimTrailingSlash(siteUrl)}${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;\n}\n\n/**\n * 글의 *경로*(siteUrl 없는 basePath-상대) — 예: \"/blog/hello\". 소속 스트림이\n * 없거나 `detail: false`(목록 전용 유형)면 null. astro 처럼 상대 href 가\n * 필요한 소비자용(`resolvePostUrl` 은 절대 URL).\n */\nexport function resolvePostPath(\n post: RoutablePost,\n collections: readonly RouteCollection[],\n): string | null {\n const collection = resolvePostCollection(post, collections);\n if (!collection) return null;\n if (collection.detail === false) return null;\n return `${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;\n}\n\n/**\n * 특정 collection에 명시적으로 귀속된 글만 남긴다.\n */\nexport function postsForCollection<T extends RoutablePost>(\n posts: readonly T[],\n collections: readonly RouteCollection[],\n key: string,\n): T[] {\n return posts.filter((p) => resolvePostCollection(p, collections)?.key === key);\n}\n\n/** feed/sitemap 등 파생에서 제외할, 어느 스트림에도 안 속한 글 제거. */\nexport function postsInAnyCollection<T extends RoutablePost>(\n posts: readonly T[],\n collections: readonly RouteCollection[],\n): T[] {\n return posts.filter((p) => resolvePostCollection(p, collections) !== null);\n}\n\n/**\n * `postsInAnyCollection` 의 대칭 — **어느 스트림에도 안 속한(\"미소속\") 글만** 남긴다.\n * 판정 시맨틱 = `resolvePostCollection(post, collections) === null`(collectionKey 가\n * NULL 이거나 사이트 선언 컬렉션 밖). `/blog` 처럼 \"선언 스트림에 안 잡힌 글 = 블로그\"로\n * 취급하는 표면에서 스트림 글 누출을 막는 데 쓴다(`blog-list-collection-scope.md` §2①).\n */\nexport function postsNotInAnyCollection<T extends RoutablePost>(\n posts: readonly T[],\n collections: readonly RouteCollection[],\n): T[] {\n return posts.filter((p) => resolvePostCollection(p, collections) === null);\n}\n\n/**\n * config 검증 — 중복 key/basePath, 그리고 **두 스트림에 동시 등장하는 category slug**\n * (라우팅 모호성)를 탐지. 문제 메시지 배열 반환(빈 = 정상). 빌드 타임 fail-fast 용.\n */\nexport function validateCollections(collections: readonly RouteCollection[]): string[] {\n const problems: string[] = [];\n const seenKey = new Set<string>();\n const seenBase = new Set<string>();\n const categoryOwner = new Map<string, string>();\n\n for (const c of collections) {\n if (seenKey.has(c.key)) problems.push(`중복 collection key: \"${c.key}\"`);\n seenKey.add(c.key);\n\n const base = trimTrailingSlash(c.basePath);\n if (seenBase.has(base)) problems.push(`중복 basePath: \"${c.basePath}\"`);\n seenBase.add(base);\n\n if (!c.basePath.startsWith(\"/\")) {\n problems.push(`basePath 는 \"/\" 로 시작해야 함: \"${c.basePath}\" (${c.key})`);\n } else if (\n base.length < 2 ||\n base.startsWith(\"//\") ||\n /[\\\\?#\\s]/.test(base) ||\n base.split(\"/\").some((segment) => segment === \".\" || segment === \"..\")\n ) {\n problems.push(\n `basePath 는 사이트 내부 절대 경로여야 함: \"${c.basePath}\" (${c.key})`,\n );\n }\n\n for (const slug of c.categories) {\n const owner = categoryOwner.get(slug);\n if (owner && owner !== c.key) {\n problems.push(\n `category \"${slug}\" 가 collection \"${owner}\" 와 \"${c.key}\" 에 중복 — 한 카테고리는 한 스트림에만.`,\n );\n }\n categoryOwner.set(slug, c.key);\n }\n }\n return problems;\n}\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 §5 — `core/embed` 영상 임베드 헬퍼 (provider allowlist)\n//\n// 본 모듈은 editor (URL → embed 노드 정규화) 와 renderer (저장된 embed src 의\n// 출력 시 재검증, defense-in-depth) 양쪽에서 공유. 순수 모듈 — `URL` 만 사용해\n// 브라우저/서버 모두 안전.\n//\n// 보안 원칙: 임의 iframe src 를 신뢰하지 않는다. 신규 삽입은 `normalizeEmbed`\n// 가 allowlist provider 의 canonical embed URL 로만 변환하고, 출력은\n// `isAllowedEmbedSrc` 로 host 를 한 번 더 검증한다 (legacy/import/외부 테넌트가\n// 임의 src 를 DB 에 남겨도 렌더 단계에서 차단).\n\nexport type EmbedProvider = \"youtube\" | \"vimeo\";\n\nexport interface NormalizedEmbed {\n provider: EmbedProvider;\n /** allowlist host 의 canonical iframe src (가능 시 privacy-enhanced). */\n embedSrc: string;\n /** 사용자가 입력한 원본 URL — fallback 링크 / round-trip 보존용. */\n originalUrl: string;\n}\n\nconst YOUTUBE_HOSTS = new Set([\n \"youtube.com\",\n \"www.youtube.com\",\n \"m.youtube.com\",\n \"youtu.be\",\n \"www.youtu.be\",\n]);\n\nconst VIMEO_HOSTS = new Set([\"vimeo.com\", \"www.vimeo.com\", \"player.vimeo.com\"]);\n\n/** 출력(iframe) 시 허용되는 embed src host — render 단계 defense-in-depth. */\nconst ALLOWED_EMBED_HOSTS = new Set([\n \"www.youtube-nocookie.com\",\n \"youtube-nocookie.com\",\n \"www.youtube.com\",\n \"youtube.com\",\n \"player.vimeo.com\",\n]);\n\nconst YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;\nconst VIMEO_ID = /^\\d+$/;\n\n/**\n * 사용자 입력 URL → allowlist provider 의 canonical embed. 지원하지 않는\n * provider / 파싱 실패 시 `null`.\n */\nexport function normalizeEmbed(input: unknown): NormalizedEmbed | null {\n if (typeof input !== \"string\") return null;\n const raw = input.trim();\n if (raw.length === 0) return null;\n\n let url: URL;\n try {\n url = new URL(raw);\n } catch {\n return null;\n }\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") return null;\n\n const host = url.hostname.toLowerCase();\n\n if (YOUTUBE_HOSTS.has(host)) {\n const id = extractYouTubeId(url, host);\n if (id) {\n return {\n provider: \"youtube\",\n embedSrc: `https://www.youtube-nocookie.com/embed/${id}`,\n originalUrl: raw,\n };\n }\n }\n\n if (VIMEO_HOSTS.has(host)) {\n const id = extractVimeoId(url, host);\n if (id) {\n return {\n provider: \"vimeo\",\n embedSrc: `https://player.vimeo.com/video/${id}`,\n originalUrl: raw,\n };\n }\n }\n\n return null;\n}\n\n/**\n * 이미 저장된 embed src 가 allowlist host(https) 인지 검증 — 렌더러가 iframe\n * 출력 전 호출. 임의 host / 비 https 는 거부.\n */\nexport function isAllowedEmbedSrc(value: unknown): value is string {\n if (typeof value !== \"string\") return false;\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return false;\n }\n if (url.protocol !== \"https:\") return false;\n const host = url.hostname.toLowerCase();\n if (!ALLOWED_EMBED_HOSTS.has(host)) return false;\n // host 뿐 아니라 canonical embed path 형태까지 검증 — 허용 host 의 임의 경로\n // (예: 스크립트·홈) 가 iframe 으로 로드되는 것을 차단.\n if (host === \"player.vimeo.com\") {\n return /^\\/video\\/\\d+$/.test(url.pathname);\n }\n // youtube / youtube-nocookie → /embed/<11-char id>\n return new RegExp(\"^/embed/[A-Za-z0-9_-]{11}$\").test(url.pathname);\n}\n\n/** 지도 embed 허용 host — keyless embed 만 (API 키 의존 provider 금지). */\nconst GOOGLE_MAP_HOSTS = new Set([\"www.google.com\", \"google.com\", \"maps.google.com\"]);\nconst OSM_HOSTS = new Set([\"www.openstreetmap.org\", \"openstreetmap.org\"]);\n\n/**\n * 지도 embed src allowlist — 렌더러가 contact 지도 iframe 출력 전 호출\n * (W8-7, `isAllowedEmbedSrc` 의 영상 allowlist 와 별도).\n *\n * 별도 함수인 이유: 지도는 \"외부 API 키가 필요 없는 keyless embed 만\"이라는\n * 추가 제약이 있다. 허용 형태:\n * - Google Maps 공유 embed: https://www.google.com/maps/embed?pb=…\n * - Google Maps keyless 쿼리 embed: https://maps.google.com/maps?q=…&output=embed\n * - OpenStreetMap export embed: https://www.openstreetmap.org/export/embed.html?…\n * `/maps/embed/v1/*`(Embed API — 키 필요) 와 그 밖의 host/경로는 거부 —\n * 거부 시 렌더러는 기존 facade 이미지/길찾기 링크 폴백을 유지한다(fail-soft).\n */\nexport function isAllowedMapEmbedSrc(value: unknown): value is string {\n if (typeof value !== \"string\") return false;\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return false;\n }\n if (url.protocol !== \"https:\") return false;\n const host = url.hostname.toLowerCase();\n if (GOOGLE_MAP_HOSTS.has(host)) {\n if (url.pathname === \"/maps/embed\") return true;\n if (url.pathname === \"/maps\" || url.pathname === \"/maps/\") {\n return url.searchParams.get(\"output\") === \"embed\";\n }\n return false;\n }\n if (OSM_HOSTS.has(host)) {\n return url.pathname === \"/export/embed.html\";\n }\n return false;\n}\n\nfunction extractYouTubeId(url: URL, host: string): string | null {\n if (host === \"youtu.be\" || host === \"www.youtu.be\") {\n const id = url.pathname.split(\"/\").filter(Boolean)[0] ?? \"\";\n return YOUTUBE_ID.test(id) ? id : null;\n }\n const v = url.searchParams.get(\"v\");\n if (v && YOUTUBE_ID.test(v)) return v;\n const parts = url.pathname.split(\"/\").filter(Boolean);\n if (\n parts.length >= 2 &&\n (parts[0] === \"embed\" || parts[0] === \"shorts\" || parts[0] === \"v\")\n ) {\n return YOUTUBE_ID.test(parts[1]) ? parts[1] : null;\n }\n return null;\n}\n\nfunction extractVimeoId(url: URL, host: string): string | null {\n const parts = url.pathname.split(\"/\").filter(Boolean);\n let candidate: string | undefined;\n if (host === \"player.vimeo.com\") {\n const vi = parts.indexOf(\"video\");\n candidate = vi >= 0 ? parts[vi + 1] : parts[parts.length - 1];\n } else {\n candidate = parts[0];\n }\n return candidate && VIMEO_ID.test(candidate) ? candidate : null;\n}\n","// FAQ 추출 — Tiptap doc 에서 faq 블록의 Q/A 를 평문으로 수집.\n//\n// 두 렌더러(cms-renderer-astro / cms-renderer-next)가 공유하여 FAQPage\n// 구조화데이터(JSON-LD)를 생성한다. 직렬화(HTML <details>)는 각 렌더러가\n// 자체 처리(마크업 패리티), 본 모듈은 schema.org 용 평문 추출만 담당.\n\ninterface DocNode {\n type?: string;\n text?: string;\n content?: DocNode[];\n}\n\nexport interface FaqEntry {\n question: string;\n answer: string;\n}\n\n/** 노드 트리의 모든 text 를 이어붙여 평문화 (공백 정규화). */\nfunction plainText(node: DocNode | undefined): string {\n if (!node) return \"\";\n if (typeof node.text === \"string\") return node.text;\n if (!Array.isArray(node.content)) return \"\";\n // 블록 경계는 공백으로 — 문단 사이가 붙지 않도록.\n return node.content.map(plainText).join(\" \");\n}\n\nfunction collapse(s: string): string {\n return s.replace(/\\s+/g, \" \").trim();\n}\n\n/** doc 을 순회하며 faqItem(질문/답변)을 순서대로 수집. */\nexport function extractFaqEntries(doc: unknown): FaqEntry[] {\n const out: FaqEntry[] = [];\n const walk = (node: DocNode | undefined): void => {\n if (!node || typeof node !== \"object\") return;\n if (node.type === \"faqItem\" && Array.isArray(node.content)) {\n const q = node.content.find((c) => c.type === \"faqQuestion\");\n const a = node.content.find((c) => c.type === \"faqAnswer\");\n const question = collapse(plainText(q));\n const answer = collapse(plainText(a));\n if (question && answer) out.push({ question, answer });\n return; // faqItem 내부는 더 내려가지 않음\n }\n if (Array.isArray(node.content)) node.content.forEach(walk);\n };\n if (doc && typeof doc === \"object\") {\n walk(doc as DocNode);\n }\n return out;\n}\n\n/** FAQPage JSON-LD 객체 (script 삽입은 렌더러 책임). null = 항목 없음. */\nexport function faqPageJsonLd(\n entries: FaqEntry[],\n): Record<string, unknown> | null {\n if (entries.length === 0) return null;\n return {\n \"@context\": \"https://schema.org\",\n \"@type\": \"FAQPage\",\n mainEntity: entries.map((e) => ({\n \"@type\": \"Question\",\n name: e.question,\n acceptedAnswer: { \"@type\": \"Answer\", text: e.answer },\n })),\n };\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","import sanitizeHtmlLibrary from \"sanitize-html\";\nimport type sanitizeHtmlTypes from \"sanitize-html\";\n\n// ADR-0034 §7.1.1 test #7 — XSS sanitize helper (allow-list, 2차 방어).\n//\n// Phase 1 = strict CSP (`script-src 'self'`) 가 1차 방어. 본 helper 는 렌더 직전\n// 2차 방어로 위험 태그/속성/URL scheme 을 strip 한다.\n//\n// Tiptap 에디터/renderer 실측 allowlist:\n// - StarterKit: p/h2-h4/ul/ol/li/blockquote/pre/code/hr/br + strong/em/s\n// - editor 추가 mark: u/mark/span(style: color/background) + TextAlign(style)\n// - media/table/custom nodes: img/figure/figcaption/table/tbody/tr/th/td/div\n// - FAQ renderer: details/summary + rt-cms-* classes\n// Embed iframe 은 sanitize 경로가 아니라 Tiptap JSON `renderEmbed()` 에서\n// `isAllowedEmbedSrc` 로 검증 후 렌더된다. raw HTML sanitizer 에서는 iframe 전면 strip.\n\n// URL 속성(href/src/…) — deny-list 가 아니라 **scheme allow-list** 로 판정한다.\n// HTML entity / 제어문자 인코딩(`javascript:`, `java	script:`,\n// `jav
ascript:`)은 regex 리터럴 매칭을 우회하므로, 값에서 entity 를 decode\n// 하고 ASCII 공백/제어문자를 제거(브라우저 URL 파서와 동일 정규화)한 뒤 scheme 을\n// 검사한다. sanitize-html parser 가 처리한 속성에도 동일 probe 를 적용한다.\nconst URL_ATTRS = new Set([\"href\", \"src\", \"xlink:href\"]);\n\nconst ALLOWED_URL_SCHEMES = new Set([\"http\", \"https\", \"mailto\", \"tel\"]);\nconst MALFORMED_DANGEROUS_TOKEN_RE =\n /<\\/?[^>\\s/]*(?:script|style|iframe|object|embed|svg|math|template)[^>\\s/]*(?:\\s[^>]*)?>[\\s\\S]*?<\\/?[^>\\s/]*(?:script|style|iframe|object|embed|svg|math|template)[^>\\s/]*(?:\\s[^>]*)?>/gi;\nconst SLASH_EVENT_ATTR_IN_URL_RE = /\\/on[a-z]+=/i;\nconst ROOTTALE_CLASS_RE = /^rt-[a-z0-9_-]+$/;\nconst HEX_COLOR_RE = /^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i;\nconst TEXT_ALIGN_RE = /^(left|right|center|justify)$/;\nconst ASPECT_RATIO_RE = /^\\d+(?:\\.\\d+)?\\s*\\/\\s*\\d+(?:\\.\\d+)?$/;\n\n// scheme 구성에 악용되는 named entity 만 decode (`:`/`	` 등).\nconst NAMED_ENTITY: Record<string, string> = {\n colon: \":\",\n tab: \"\\t\",\n newline: \"\\n\",\n sol: \"/\",\n semi: \";\",\n num: \"#\",\n period: \".\",\n comma: \",\",\n lpar: \"(\",\n rpar: \")\",\n};\n\nfunction fromCodePointSafe(n: number): string {\n if (!Number.isFinite(n) || n < 0 || n > 0x10ffff) return \"\";\n try {\n return String.fromCodePoint(n);\n } catch {\n return \"\";\n }\n}\n\n/** URL 값 정규화 — entity decode + 공백/제어문자 제거 + 소문자 (scheme 판정용). */\nfunction decodeUrlProbe(value: string): string {\n const decoded = value\n .replace(/&#x([0-9a-f]+);?/gi, (_, h) => fromCodePointSafe(parseInt(h, 16)))\n .replace(/&#(\\d+);?/g, (_, d) => fromCodePointSafe(parseInt(d, 10)))\n .replace(/&([a-z]+);/gi, (m, name) => NAMED_ENTITY[name.toLowerCase()] ?? m);\n // 브라우저는 scheme 판정 시 ASCII 공백/제어문자(탭/개행/CR/NUL 포함)를 제거한다.\n return [...decoded].filter((c) => { const n = c.codePointAt(0) ?? 0; return n > 0x20 && n !== 0x7f; }).join(\"\").toLowerCase();\n}\n\nfunction stripMalformedDangerousTokens(html: string): string {\n let out = html;\n for (let i = 0; i < 3; i++) {\n const next = out.replace(MALFORMED_DANGEROUS_TOKEN_RE, \"\");\n if (next === out) return out;\n out = next;\n }\n return out;\n}\n\n/**\n * 안전한 URL 값인지 — 상대경로/허용 scheme, data:image/* 는 img[src] 에서만(SVG 제외).\n */\nfunction isSafeUrlValue(tag: string, attr: string, value: string): boolean {\n const probe = decodeUrlProbe(value);\n if (SLASH_EVENT_ATTR_IN_URL_RE.test(probe)) return false;\n const scheme = /^([a-z][a-z0-9+.-]*):/.exec(probe)?.[1];\n if (!scheme) return true; // 상대경로 / fragment / query\n if (ALLOWED_URL_SCHEMES.has(scheme)) return true;\n if (\n scheme === \"data\" &&\n tag === \"img\" &&\n attr === \"src\" &&\n probe.startsWith(\"data:image/\")\n ) {\n // data:image/svg+xml 은 스크립트 실행 가능 → 차단.\n return !probe.startsWith(\"data:image/svg\");\n }\n return false;\n}\n\nfunction sanitizeUrlAttributes(\n tagName: string,\n attribs: sanitizeHtmlTypes.Attributes,\n): sanitizeHtmlTypes.Tag {\n const next: sanitizeHtmlTypes.Attributes = {};\n for (const [name, value] of Object.entries(attribs)) {\n const attr = name.toLowerCase();\n next[name] =\n URL_ATTRS.has(attr) && !isSafeUrlValue(tagName, attr, value) ? \"#\" : value;\n }\n return { tagName, attribs: next };\n}\n\nconst SANITIZE_OPTIONS: sanitizeHtmlTypes.IOptions = {\n allowedTags: [\n \"a\",\n \"blockquote\",\n \"br\",\n \"cite\",\n \"code\",\n \"details\",\n \"div\",\n \"em\",\n \"figcaption\",\n \"figure\",\n \"h2\",\n \"h3\",\n \"h4\",\n \"hr\",\n \"img\",\n \"li\",\n \"mark\",\n \"ol\",\n \"p\",\n \"pre\",\n \"s\",\n \"span\",\n \"strong\",\n \"summary\",\n \"table\",\n \"tbody\",\n \"td\",\n \"th\",\n \"tr\",\n \"u\",\n \"ul\",\n ],\n allowedAttributes: {\n \"*\": [\"class\", \"data-*\"],\n a: [\n \"href\",\n \"title\",\n { name: \"rel\", multiple: true, values: [\"noopener\", \"noreferrer\"] },\n { name: \"target\", values: [\"_blank\"] },\n ],\n details: [\"open\", \"class\", \"data-*\"],\n div: [\"class\", \"data-*\", \"style\"],\n h2: [\"id\", \"class\", \"data-*\", \"style\"],\n h3: [\"id\", \"class\", \"data-*\", \"style\"],\n h4: [\"id\", \"class\", \"data-*\", \"style\"],\n img: [\"src\", \"alt\", \"title\", \"width\", \"height\", \"loading\", \"class\", \"data-*\"],\n mark: [\"class\", \"data-*\", \"style\"],\n p: [\"class\", \"data-*\", \"style\"],\n span: [\"class\", \"data-*\", \"style\"],\n td: [\"colspan\", \"rowspan\", \"class\", \"data-*\"],\n th: [\"colspan\", \"rowspan\", \"class\", \"data-*\"],\n },\n allowedClasses: {\n \"*\": [ROOTTALE_CLASS_RE],\n },\n allowedStyles: {\n \"*\": {\n \"aspect-ratio\": [ASPECT_RATIO_RE],\n background: [HEX_COLOR_RE],\n \"background-color\": [HEX_COLOR_RE],\n color: [HEX_COLOR_RE],\n \"text-align\": [TEXT_ALIGN_RE],\n },\n },\n allowedSchemes: [\"http\", \"https\", \"mailto\", \"tel\"],\n allowedSchemesByTag: {\n img: [\"data\"],\n },\n allowedSchemesAppliedToAttributes: [\"href\", \"src\", \"xlink:href\"],\n allowProtocolRelative: false,\n nonTextTags: [\n \"script\",\n \"style\",\n \"textarea\",\n \"option\",\n \"iframe\",\n \"object\",\n \"embed\",\n \"svg\",\n \"math\",\n \"template\",\n ],\n transformTags: {\n \"*\": sanitizeUrlAttributes,\n },\n};\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 if (!html) return html;\n return sanitizeHtmlLibrary(stripMalformedDangerousTokens(html), SANITIZE_OPTIONS);\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 // core/embed allowlist (embed.ts) 의 영상 iframe — youtube-nocookie / vimeo.\n \"frame-src https://www.youtube-nocookie.com https://www.youtube.com https://player.vimeo.com; \" +\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":[]}
|
|
1
|
+
{"version":3,"sources":["../src/block.ts","../src/bindings.ts","../src/collections.ts","../src/delimiter.ts","../src/embed.ts","../src/faq.ts","../src/image-alt.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-0041 §7 — Block Bindings: ACF 필드 → 블록 attribute.\n//\n// 블록의 `metadata.bindings[attr] = { source, args }` 를 만나면 attribute 를\n// 바인딩 값으로 치환한다. 렌더 직전 pure transform — renderBlock 시그니처 불변.\n//\n// canonical source id = `roottale/field`. WP round-trip 위해 `acf/field` alias\n// 인식. resolver 는 주입(렌더러는 cms-fields 비종속) — 값 record 만 흐른다.\n\nimport type { Block } from \"./block\";\n\nexport interface BlockBinding {\n source: string;\n args?: Record<string, unknown>;\n}\n\n/**\n * 바인딩 해석기. 주어진 binding(+attribute/block)에 대한 값을 반환.\n * `undefined` 반환 = 미해석(원래 attribute 유지).\n */\nexport type BindingResolver = (\n binding: BlockBinding,\n attribute: string,\n block: Block,\n) => unknown;\n\n/** ACF 필드 바인딩 source id. */\nexport const FIELD_BINDING_SOURCE = \"roottale/field\";\n/** WP ACF 호환 alias (import round-trip). */\nexport const FIELD_BINDING_SOURCE_ALIAS = \"acf/field\";\n\n/**\n * acf 값 record 로부터 필드 바인딩 resolver 생성.\n * `roottale/field` / `acf/field` source 의 `args.name` 을 record 에서 조회.\n */\nexport function createFieldBindingResolver(\n fields: Record<string, unknown> | undefined | null,\n): BindingResolver {\n const values = fields ?? {};\n return (binding) => {\n if (\n binding.source !== FIELD_BINDING_SOURCE &&\n binding.source !== FIELD_BINDING_SOURCE_ALIAS\n ) {\n return undefined;\n }\n const name = binding.args?.name;\n if (typeof name !== \"string\") return undefined;\n const v = values[name];\n return v === undefined ? undefined : v;\n };\n}\n\n/** 여러 resolver 를 순서대로 시도(첫 non-undefined 채택). */\nexport function chainResolvers(\n ...resolvers: BindingResolver[]\n): BindingResolver {\n return (binding, attribute, block) => {\n for (const r of resolvers) {\n const v = r(binding, attribute, block);\n if (v !== undefined) return v;\n }\n return undefined;\n };\n}\n\nfunction applyToBlock(block: Block, resolver: BindingResolver): Block {\n const bindings = block.metadata?.bindings;\n let attributes = block.attributes;\n if (bindings) {\n let mutated = false;\n const next = { ...block.attributes };\n for (const [attr, binding] of Object.entries(bindings)) {\n const value = resolver(binding, attr, block);\n if (value !== undefined) {\n next[attr] = value;\n mutated = true;\n }\n }\n if (mutated) attributes = next;\n }\n const innerBlocks = block.innerBlocks.length\n ? block.innerBlocks.map((b) => applyToBlock(b, resolver))\n : block.innerBlocks;\n if (attributes === block.attributes && innerBlocks === block.innerBlocks) {\n return block;\n }\n return { ...block, attributes, innerBlocks };\n}\n\n/**\n * 블록 트리에 바인딩을 적용해 새 트리 반환(immutable). 바인딩 없으면 원본 그대로.\n */\nexport function applyBlockBindings(\n blocks: readonly Block[],\n resolver: BindingResolver,\n): Block[] {\n return blocks.map((b) => applyToBlock(b, resolver));\n}\n\n// ── 라이브 Tiptap 렌더 경로용 헬퍼 (cms-field-binding-editor.md D3·D4) ─────────\n//\n// Tiptap doc 의 node.attrs.bindings (BlockBindings extension 이 기록, Block JSON\n// metadata.bindings 와 동일 형태) 를 렌더 시점에 필드 값으로 치환할 때 사용.\n\n/**\n * bindings 에서 `attribute` 의 필드 값을 조회. `roottale/field`/`acf/field`\n * source 만 해석. 빈 값(undefined/null/\"\") = undefined → caller 가 fallback 렌더.\n */\nexport function boundFieldValue(\n bindings: unknown,\n attribute: string,\n fields: Record<string, unknown> | null | undefined,\n): unknown {\n if (!bindings || typeof bindings !== \"object\" || !fields) return undefined;\n const binding = (bindings as Record<string, BlockBinding | undefined>)[\n attribute\n ];\n if (\n !binding ||\n (binding.source !== FIELD_BINDING_SOURCE &&\n binding.source !== FIELD_BINDING_SOURCE_ALIAS)\n ) {\n return undefined;\n }\n const name = binding.args?.name;\n if (typeof name !== \"string\") return undefined;\n const value = fields[name];\n if (value === undefined || value === null || value === \"\") return undefined;\n return value;\n}\n\n/**\n * content 바인딩 값의 텍스트화. **항상 plain text** — caller 가 escape 해\n * 삽입한다 (바인딩 경로로 raw HTML 진입 금지, D4). string/number 외 = undefined.\n */\nexport function bindingTextValue(value: unknown): string | undefined {\n if (typeof value === \"string\") return value;\n if (typeof value === \"number\" && Number.isFinite(value)) return String(value);\n return undefined;\n}\n\n/**\n * image `src` 바인딩 값 → URL. 포맷된 MediaRef(`{url}`) 또는 http(s) 문자열만\n * 인정 — raw media id(uuid) 는 URL 이 아니므로 fallback.\n */\nexport function bindingSrcValue(value: unknown): string | undefined {\n if (value && typeof value === \"object\") {\n const url = (value as { url?: unknown }).url;\n if (typeof url === \"string\" && /^https?:\\/\\//i.test(url)) return url;\n return undefined;\n }\n if (typeof value === \"string\" && /^https?:\\/\\//i.test(value)) return value;\n return undefined;\n}\n","/**\n * 다중 스트림 콘텐츠 라우팅 프리미티브 (ADR-0060).\n *\n * 같은 글 풀(post)을 둘 이상의 스트림으로 분리(공지=게시판 /notice, 블로그=그리드\n * /blog 등). `cms-renderer-next` / `cms-renderer-astro` 가 동일 로직을 공유하도록\n * 여기(cms-core)에 둔다 — cms-client·프레임워크 비의존(구조적 타입만).\n *\n * 설계(codex consult `019ef577` 반영):\n * - **활성화 = `collections.length > 0`.** 빈 배열·미전달 = 레거시 단일 /blog.\n * - `RouteCollection` 은 **라우팅 최소셋**만(key/basePath/categories/feed/archives).\n * label/layout/icon 등 표시·작성 메타는 templates `CollectionSchema` 에 둔다.\n */\n\n/** 콘텐츠 스트림 1개 — sitemap/feed/아카이브 파생의 라우팅 단위. */\nexport interface RouteCollection {\n /** 안정 식별자 (예: \"notice\" | \"blog\"). */\n readonly key: string;\n /**\n * 라우트 base path (예: \"/notice\" | \"/blog\"). 앞 슬래시 포함, 뒤 슬래시 없이.\n * **`\"\"`(빈 문자열) = 주소 없는 유형** — 페이지 안에서 불러와 쓰는 콘텐츠(강사·리뷰\n * 등)로, 라우트/사이트맵/피드/아카이브를 일절 파생하지 않는다. 외부 사이트는\n * posts API 의 `collectionKey` 필터로 내용만 가져다 렌더한다. `resolvePostUrl`/\n * `resolvePostPath` 는 이 값이 빈 문자열이면 항상 null. 소속 판정\n * (`resolvePostCollection`)에는 영향 없음 — key 만으로 소속이 성립한다.\n */\n readonly basePath: string;\n /**\n * 이 섹션이 제공하는 **주제(category) allowlist** — `${basePath}/categories/{slug}`\n * 아카이브 라우트 범위. ADR-0060 Amendment 1 (M4): 섹션 소속은 글의 `collectionKey`\n * 로 정해지므로 이 배열은 더 이상 라우팅 소유권이 아니다(catch-all 개념 폐기). 빈\n * 배열 = 이 섹션은 주제 없음.\n */\n readonly categories: readonly string[];\n /** RSS 피드 포함 여부 (기본 false). */\n readonly feed?: boolean;\n /** `${basePath}/categories/{slug}` 아카이브 emit 여부 (기본 false). */\n readonly archives?: boolean;\n /**\n * 글별 상세 라우트 존재 여부 (기본 true — 하위 호환). false 면 이 스트림은\n * \"목록 전용\" 유형(예: 후기 — 목록에서 전문 노출, 글마다 별도 상세 URL 없음).\n * `resolvePostUrl`/`resolvePostPath` 가 null 을 반환해 sitemap/feed 에서 글\n * URL 을 발행하지 않는다. 소속 판정(`resolvePostCollection`)에는 영향 없음\n * — 소속과 상세 URL 존재 여부는 별개(M4 원칙 연장).\n */\n readonly detail?: boolean;\n}\n\n/** resolve 입력 — 글의 구조적 최소 형태(cms-client `CmsPostContent` 와 구조 호환). */\nexport interface RoutablePost {\n readonly slug: string;\n /** 글의 주제(category) terms — 아카이브 라우트 필터용. 섹션 판정엔 쓰지 않는다(M4). */\n readonly terms: ReadonlyArray<{ readonly taxonomy: string; readonly slug: string }>;\n /**\n * ADR-0060 Amendment 1 (Option A, M4) — 글에 명시된 섹션 key. 섹션 판정의 유일한\n * 근거. 미설정/미존재면 어느 스트림에도 안 속함(null). 카테고리 파생 fallback 은 제거됨.\n */\n readonly collectionKey?: string | null;\n}\n\nfunction trimTrailingSlash(value: string): string {\n return value.replace(/\\/+$/, \"\");\n}\n\n/** collection 모드인지 — 길이 0(또는 미전달)이면 레거시 단일 /blog 동작. */\nexport function isInCollectionMode(\n collections: readonly RouteCollection[] | undefined,\n): boolean {\n return !!collections && collections.length > 0;\n}\n\n/**\n * 글이 속한 collection 1개를 해석 — ADR-0060 Amendment 1 (Option A, M4): **글의\n * 명시 `collectionKey` 만** 본다. 미설정/미존재 key 면 null(상세 라우트 없는 글 →\n * sitemap 제외 신호). 카테고리 slug 파생·catch-all·first-wins 는 M4 에서 제거됨 —\n * 섹션은 카테고리가 아니라 글에 명시한다. (`categories` 는 이제 주제 allowlist 로만,\n * 아카이브 라우트에서 쓰임.)\n */\nexport function resolvePostCollection(\n post: RoutablePost,\n collections: readonly RouteCollection[],\n): RouteCollection | null {\n if (!post.collectionKey) return null;\n return collections.find((c) => c.key === post.collectionKey) ?? null;\n}\n\n/**\n * 글의 정규 절대 URL — 소속 스트림 basePath 기준. 어느 스트림에도 안 속하거나,\n * 소속 스트림이 `detail: false`(목록 전용 유형) 이거나, `basePath` 가 빈 문자열\n * (주소 없는 유형)이면 null. slug 는 percent-encoding (한글 slug 안전).\n */\nexport function resolvePostUrl(\n siteUrl: string,\n post: RoutablePost,\n collections: readonly RouteCollection[],\n): string | null {\n const collection = resolvePostCollection(post, collections);\n if (!collection) return null;\n if (collection.detail === false) return null;\n if (!collection.basePath) return null;\n return `${trimTrailingSlash(siteUrl)}${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;\n}\n\n/**\n * 글의 *경로*(siteUrl 없는 basePath-상대) — 예: \"/blog/hello\". 소속 스트림이\n * 없거나, `detail: false`(목록 전용 유형) 이거나, `basePath` 가 빈 문자열\n * (주소 없는 유형)이면 null. astro 처럼 상대 href 가 필요한 소비자용\n * (`resolvePostUrl` 은 절대 URL).\n */\nexport function resolvePostPath(\n post: RoutablePost,\n collections: readonly RouteCollection[],\n): string | null {\n const collection = resolvePostCollection(post, collections);\n if (!collection) return null;\n if (collection.detail === false) return null;\n if (!collection.basePath) return null;\n return `${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;\n}\n\n/**\n * 특정 collection에 명시적으로 귀속된 글만 남긴다.\n */\nexport function postsForCollection<T extends RoutablePost>(\n posts: readonly T[],\n collections: readonly RouteCollection[],\n key: string,\n): T[] {\n return posts.filter((p) => resolvePostCollection(p, collections)?.key === key);\n}\n\n/** feed/sitemap 등 파생에서 제외할, 어느 스트림에도 안 속한 글 제거. */\nexport function postsInAnyCollection<T extends RoutablePost>(\n posts: readonly T[],\n collections: readonly RouteCollection[],\n): T[] {\n return posts.filter((p) => resolvePostCollection(p, collections) !== null);\n}\n\n/**\n * `postsInAnyCollection` 의 대칭 — **어느 스트림에도 안 속한(\"미소속\") 글만** 남긴다.\n * 판정 시맨틱 = `resolvePostCollection(post, collections) === null`(collectionKey 가\n * NULL 이거나 사이트 선언 컬렉션 밖). `/blog` 처럼 \"선언 스트림에 안 잡힌 글 = 블로그\"로\n * 취급하는 표면에서 스트림 글 누출을 막는 데 쓴다(`blog-list-collection-scope.md` §2①).\n */\nexport function postsNotInAnyCollection<T extends RoutablePost>(\n posts: readonly T[],\n collections: readonly RouteCollection[],\n): T[] {\n return posts.filter((p) => resolvePostCollection(p, collections) === null);\n}\n\n/**\n * config 검증 — 중복 key/basePath, 그리고 **두 스트림에 동시 등장하는 category slug**\n * (라우팅 모호성)를 탐지. `basePath === \"\"`(주소 없는 유형)는 basePath 형식·중복\n * 검사를 건너뛴다 — 라우트가 없으므로 여러 개 공존해도 모호성이 없다. key 중복·\n * category 중복 검사는 주소 유무와 무관하게 전 항목에 적용된다. 문제 메시지 배열\n * 반환(빈 = 정상). 빌드 타임 fail-fast 용.\n */\nexport function validateCollections(collections: readonly RouteCollection[]): string[] {\n const problems: string[] = [];\n const seenKey = new Set<string>();\n const seenBase = new Set<string>();\n const categoryOwner = new Map<string, string>();\n\n for (const c of collections) {\n if (seenKey.has(c.key)) problems.push(`중복 collection key: \"${c.key}\"`);\n seenKey.add(c.key);\n\n if (c.basePath !== \"\") {\n const base = trimTrailingSlash(c.basePath);\n if (seenBase.has(base)) problems.push(`중복 basePath: \"${c.basePath}\"`);\n seenBase.add(base);\n\n if (!c.basePath.startsWith(\"/\")) {\n problems.push(`basePath 는 \"/\" 로 시작해야 함: \"${c.basePath}\" (${c.key})`);\n } else if (\n base.length < 2 ||\n base.startsWith(\"//\") ||\n /[\\\\?#\\s]/.test(base) ||\n base.split(\"/\").some((segment) => segment === \".\" || segment === \"..\")\n ) {\n problems.push(\n `basePath 는 사이트 내부 절대 경로여야 함: \"${c.basePath}\" (${c.key})`,\n );\n }\n }\n\n for (const slug of c.categories) {\n const owner = categoryOwner.get(slug);\n if (owner && owner !== c.key) {\n problems.push(\n `category \"${slug}\" 가 collection \"${owner}\" 와 \"${c.key}\" 에 중복 — 한 카테고리는 한 스트림에만.`,\n );\n }\n categoryOwner.set(slug, c.key);\n }\n }\n return problems;\n}\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 §5 — `core/embed` 영상 임베드 헬퍼 (provider allowlist)\n//\n// 본 모듈은 editor (URL → embed 노드 정규화) 와 renderer (저장된 embed src 의\n// 출력 시 재검증, defense-in-depth) 양쪽에서 공유. 순수 모듈 — `URL` 만 사용해\n// 브라우저/서버 모두 안전.\n//\n// 보안 원칙: 임의 iframe src 를 신뢰하지 않는다. 신규 삽입은 `normalizeEmbed`\n// 가 allowlist provider 의 canonical embed URL 로만 변환하고, 출력은\n// `isAllowedEmbedSrc` 로 host 를 한 번 더 검증한다 (legacy/import/외부 테넌트가\n// 임의 src 를 DB 에 남겨도 렌더 단계에서 차단).\n\nexport type EmbedProvider = \"youtube\" | \"vimeo\";\n\nexport interface NormalizedEmbed {\n provider: EmbedProvider;\n /** allowlist host 의 canonical iframe src (가능 시 privacy-enhanced). */\n embedSrc: string;\n /** 사용자가 입력한 원본 URL — fallback 링크 / round-trip 보존용. */\n originalUrl: string;\n}\n\nconst YOUTUBE_HOSTS = new Set([\n \"youtube.com\",\n \"www.youtube.com\",\n \"m.youtube.com\",\n \"youtu.be\",\n \"www.youtu.be\",\n]);\n\nconst VIMEO_HOSTS = new Set([\"vimeo.com\", \"www.vimeo.com\", \"player.vimeo.com\"]);\n\n/** 출력(iframe) 시 허용되는 embed src host — render 단계 defense-in-depth. */\nconst ALLOWED_EMBED_HOSTS = new Set([\n \"www.youtube-nocookie.com\",\n \"youtube-nocookie.com\",\n \"www.youtube.com\",\n \"youtube.com\",\n \"player.vimeo.com\",\n]);\n\nconst YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;\nconst VIMEO_ID = /^\\d+$/;\n\n/**\n * 사용자 입력 URL → allowlist provider 의 canonical embed. 지원하지 않는\n * provider / 파싱 실패 시 `null`.\n */\nexport function normalizeEmbed(input: unknown): NormalizedEmbed | null {\n if (typeof input !== \"string\") return null;\n const raw = input.trim();\n if (raw.length === 0) return null;\n\n let url: URL;\n try {\n url = new URL(raw);\n } catch {\n return null;\n }\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") return null;\n\n const host = url.hostname.toLowerCase();\n\n if (YOUTUBE_HOSTS.has(host)) {\n const id = extractYouTubeId(url, host);\n if (id) {\n return {\n provider: \"youtube\",\n embedSrc: `https://www.youtube-nocookie.com/embed/${id}`,\n originalUrl: raw,\n };\n }\n }\n\n if (VIMEO_HOSTS.has(host)) {\n const id = extractVimeoId(url, host);\n if (id) {\n return {\n provider: \"vimeo\",\n embedSrc: `https://player.vimeo.com/video/${id}`,\n originalUrl: raw,\n };\n }\n }\n\n return null;\n}\n\n/**\n * 이미 저장된 embed src 가 allowlist host(https) 인지 검증 — 렌더러가 iframe\n * 출력 전 호출. 임의 host / 비 https 는 거부.\n */\nexport function isAllowedEmbedSrc(value: unknown): value is string {\n if (typeof value !== \"string\") return false;\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return false;\n }\n if (url.protocol !== \"https:\") return false;\n const host = url.hostname.toLowerCase();\n if (!ALLOWED_EMBED_HOSTS.has(host)) return false;\n // host 뿐 아니라 canonical embed path 형태까지 검증 — 허용 host 의 임의 경로\n // (예: 스크립트·홈) 가 iframe 으로 로드되는 것을 차단.\n if (host === \"player.vimeo.com\") {\n return /^\\/video\\/\\d+$/.test(url.pathname);\n }\n // youtube / youtube-nocookie → /embed/<11-char id>\n return new RegExp(\"^/embed/[A-Za-z0-9_-]{11}$\").test(url.pathname);\n}\n\n/** 지도 embed 허용 host — keyless embed 만 (API 키 의존 provider 금지). */\nconst GOOGLE_MAP_HOSTS = new Set([\"www.google.com\", \"google.com\", \"maps.google.com\"]);\nconst OSM_HOSTS = new Set([\"www.openstreetmap.org\", \"openstreetmap.org\"]);\n\n/**\n * 지도 embed src allowlist — 렌더러가 contact 지도 iframe 출력 전 호출\n * (W8-7, `isAllowedEmbedSrc` 의 영상 allowlist 와 별도).\n *\n * 별도 함수인 이유: 지도는 \"외부 API 키가 필요 없는 keyless embed 만\"이라는\n * 추가 제약이 있다. 허용 형태:\n * - Google Maps 공유 embed: https://www.google.com/maps/embed?pb=…\n * - Google Maps keyless 쿼리 embed: https://maps.google.com/maps?q=…&output=embed\n * - OpenStreetMap export embed: https://www.openstreetmap.org/export/embed.html?…\n * `/maps/embed/v1/*`(Embed API — 키 필요) 와 그 밖의 host/경로는 거부 —\n * 거부 시 렌더러는 기존 facade 이미지/길찾기 링크 폴백을 유지한다(fail-soft).\n */\nexport function isAllowedMapEmbedSrc(value: unknown): value is string {\n if (typeof value !== \"string\") return false;\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return false;\n }\n if (url.protocol !== \"https:\") return false;\n const host = url.hostname.toLowerCase();\n if (GOOGLE_MAP_HOSTS.has(host)) {\n if (url.pathname === \"/maps/embed\") return true;\n if (url.pathname === \"/maps\" || url.pathname === \"/maps/\") {\n return url.searchParams.get(\"output\") === \"embed\";\n }\n return false;\n }\n if (OSM_HOSTS.has(host)) {\n return url.pathname === \"/export/embed.html\";\n }\n return false;\n}\n\nfunction extractYouTubeId(url: URL, host: string): string | null {\n if (host === \"youtu.be\" || host === \"www.youtu.be\") {\n const id = url.pathname.split(\"/\").filter(Boolean)[0] ?? \"\";\n return YOUTUBE_ID.test(id) ? id : null;\n }\n const v = url.searchParams.get(\"v\");\n if (v && YOUTUBE_ID.test(v)) return v;\n const parts = url.pathname.split(\"/\").filter(Boolean);\n if (\n parts.length >= 2 &&\n (parts[0] === \"embed\" || parts[0] === \"shorts\" || parts[0] === \"v\")\n ) {\n return YOUTUBE_ID.test(parts[1]) ? parts[1] : null;\n }\n return null;\n}\n\nfunction extractVimeoId(url: URL, host: string): string | null {\n const parts = url.pathname.split(\"/\").filter(Boolean);\n let candidate: string | undefined;\n if (host === \"player.vimeo.com\") {\n const vi = parts.indexOf(\"video\");\n candidate = vi >= 0 ? parts[vi + 1] : parts[parts.length - 1];\n } else {\n candidate = parts[0];\n }\n return candidate && VIMEO_ID.test(candidate) ? candidate : null;\n}\n","// FAQ 추출 — Tiptap doc 에서 faq 블록의 Q/A 를 평문으로 수집.\n//\n// 두 렌더러(cms-renderer-astro / cms-renderer-next)가 공유하여 FAQPage\n// 구조화데이터(JSON-LD)를 생성한다. 직렬화(HTML <details>)는 각 렌더러가\n// 자체 처리(마크업 패리티), 본 모듈은 schema.org 용 평문 추출만 담당.\n\ninterface DocNode {\n type?: string;\n text?: string;\n content?: DocNode[];\n}\n\nexport interface FaqEntry {\n question: string;\n answer: string;\n}\n\n/** 노드 트리의 모든 text 를 이어붙여 평문화 (공백 정규화). */\nfunction plainText(node: DocNode | undefined): string {\n if (!node) return \"\";\n if (typeof node.text === \"string\") return node.text;\n if (!Array.isArray(node.content)) return \"\";\n // 블록 경계는 공백으로 — 문단 사이가 붙지 않도록.\n return node.content.map(plainText).join(\" \");\n}\n\nfunction collapse(s: string): string {\n return s.replace(/\\s+/g, \" \").trim();\n}\n\n/** doc 을 순회하며 faqItem(질문/답변)을 순서대로 수집. */\nexport function extractFaqEntries(doc: unknown): FaqEntry[] {\n const out: FaqEntry[] = [];\n const walk = (node: DocNode | undefined): void => {\n if (!node || typeof node !== \"object\") return;\n if (node.type === \"faqItem\" && Array.isArray(node.content)) {\n const q = node.content.find((c) => c.type === \"faqQuestion\");\n const a = node.content.find((c) => c.type === \"faqAnswer\");\n const question = collapse(plainText(q));\n const answer = collapse(plainText(a));\n if (question && answer) out.push({ question, answer });\n return; // faqItem 내부는 더 내려가지 않음\n }\n if (Array.isArray(node.content)) node.content.forEach(walk);\n };\n if (doc && typeof doc === \"object\") {\n walk(doc as DocNode);\n }\n return out;\n}\n\n/** FAQPage JSON-LD 객체 (script 삽입은 렌더러 책임). null = 항목 없음. */\nexport function faqPageJsonLd(\n entries: FaqEntry[],\n): Record<string, unknown> | null {\n if (entries.length === 0) return null;\n return {\n \"@context\": \"https://schema.org\",\n \"@type\": \"FAQPage\",\n mainEntity: entries.map((e) => ({\n \"@type\": \"Question\",\n name: e.question,\n acceptedAnswer: { \"@type\": \"Answer\", text: e.answer },\n })),\n };\n}\n","/**\n * 본문 이미지 대체텍스트의 제품 안전 상한.\n *\n * HTML 표준의 길이 제한이 아니라, 본문·캡션 전체가 alt 에 잘못 저장돼\n * 스크린리더와 검색 결과를 오염시키는 사고를 막기 위한 RootTale 계약이다.\n */\nexport const IMAGE_ALT_MAX_LENGTH = 160;\n\n/**\n * 이미지 alt 를 한 줄 설명으로 정규화한다.\n *\n * - 문자열이 아니면 빈 alt\n * - 개행·연속 공백을 한 칸으로 축약\n * - 160자를 넘으면 말줄임표를 포함해 상한 안에서 자름\n */\nexport function normalizeImageAlt(value: unknown): string {\n if (typeof value !== \"string\") return \"\";\n const normalized = value.replace(/\\s+/g, \" \").trim();\n const characters = Array.from(normalized);\n if (characters.length <= IMAGE_ALT_MAX_LENGTH) return normalized;\n\n const visible = characters\n .slice(0, IMAGE_ALT_MAX_LENGTH - 1)\n .join(\"\")\n .trimEnd();\n return `${visible}…`;\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","import sanitizeHtmlLibrary from \"sanitize-html\";\nimport type sanitizeHtmlTypes from \"sanitize-html\";\n\n// ADR-0034 §7.1.1 test #7 — XSS sanitize helper (allow-list, 2차 방어).\n//\n// Phase 1 = strict CSP (`script-src 'self'`) 가 1차 방어. 본 helper 는 렌더 직전\n// 2차 방어로 위험 태그/속성/URL scheme 을 strip 한다.\n//\n// Tiptap 에디터/renderer 실측 allowlist:\n// - StarterKit: p/h2-h4/ul/ol/li/blockquote/pre/code/hr/br + strong/em/s\n// - editor 추가 mark: u/mark/span(style: color/background) + TextAlign(style)\n// - media/table/custom nodes: img/figure/figcaption/table/tbody/tr/th/td/div\n// - FAQ renderer: details/summary + rt-cms-* classes\n// Embed iframe 은 sanitize 경로가 아니라 Tiptap JSON `renderEmbed()` 에서\n// `isAllowedEmbedSrc` 로 검증 후 렌더된다. raw HTML sanitizer 에서는 iframe 전면 strip.\n\n// URL 속성(href/src/…) — deny-list 가 아니라 **scheme allow-list** 로 판정한다.\n// HTML entity / 제어문자 인코딩(`javascript:`, `java	script:`,\n// `jav
ascript:`)은 regex 리터럴 매칭을 우회하므로, 값에서 entity 를 decode\n// 하고 ASCII 공백/제어문자를 제거(브라우저 URL 파서와 동일 정규화)한 뒤 scheme 을\n// 검사한다. sanitize-html parser 가 처리한 속성에도 동일 probe 를 적용한다.\nconst URL_ATTRS = new Set([\"href\", \"src\", \"xlink:href\"]);\n\nconst ALLOWED_URL_SCHEMES = new Set([\"http\", \"https\", \"mailto\", \"tel\"]);\nconst MALFORMED_DANGEROUS_TOKEN_RE =\n /<\\/?[^>\\s/]*(?:script|style|iframe|object|embed|svg|math|template)[^>\\s/]*(?:\\s[^>]*)?>[\\s\\S]*?<\\/?[^>\\s/]*(?:script|style|iframe|object|embed|svg|math|template)[^>\\s/]*(?:\\s[^>]*)?>/gi;\nconst SLASH_EVENT_ATTR_IN_URL_RE = /\\/on[a-z]+=/i;\nconst ROOTTALE_CLASS_RE = /^rt-[a-z0-9_-]+$/;\nconst HEX_COLOR_RE = /^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i;\nconst TEXT_ALIGN_RE = /^(left|right|center|justify)$/;\nconst ASPECT_RATIO_RE = /^\\d+(?:\\.\\d+)?\\s*\\/\\s*\\d+(?:\\.\\d+)?$/;\n\n// scheme 구성에 악용되는 named entity 만 decode (`:`/`	` 등).\nconst NAMED_ENTITY: Record<string, string> = {\n colon: \":\",\n tab: \"\\t\",\n newline: \"\\n\",\n sol: \"/\",\n semi: \";\",\n num: \"#\",\n period: \".\",\n comma: \",\",\n lpar: \"(\",\n rpar: \")\",\n};\n\nfunction fromCodePointSafe(n: number): string {\n if (!Number.isFinite(n) || n < 0 || n > 0x10ffff) return \"\";\n try {\n return String.fromCodePoint(n);\n } catch {\n return \"\";\n }\n}\n\n/** URL 값 정규화 — entity decode + 공백/제어문자 제거 + 소문자 (scheme 판정용). */\nfunction decodeUrlProbe(value: string): string {\n const decoded = value\n .replace(/&#x([0-9a-f]+);?/gi, (_, h) => fromCodePointSafe(parseInt(h, 16)))\n .replace(/&#(\\d+);?/g, (_, d) => fromCodePointSafe(parseInt(d, 10)))\n .replace(/&([a-z]+);/gi, (m, name) => NAMED_ENTITY[name.toLowerCase()] ?? m);\n // 브라우저는 scheme 판정 시 ASCII 공백/제어문자(탭/개행/CR/NUL 포함)를 제거한다.\n return [...decoded].filter((c) => { const n = c.codePointAt(0) ?? 0; return n > 0x20 && n !== 0x7f; }).join(\"\").toLowerCase();\n}\n\nfunction stripMalformedDangerousTokens(html: string): string {\n let out = html;\n for (let i = 0; i < 3; i++) {\n const next = out.replace(MALFORMED_DANGEROUS_TOKEN_RE, \"\");\n if (next === out) return out;\n out = next;\n }\n return out;\n}\n\n/**\n * 안전한 URL 값인지 — 상대경로/허용 scheme, data:image/* 는 img[src] 에서만(SVG 제외).\n */\nfunction isSafeUrlValue(tag: string, attr: string, value: string): boolean {\n const probe = decodeUrlProbe(value);\n if (SLASH_EVENT_ATTR_IN_URL_RE.test(probe)) return false;\n const scheme = /^([a-z][a-z0-9+.-]*):/.exec(probe)?.[1];\n if (!scheme) return true; // 상대경로 / fragment / query\n if (ALLOWED_URL_SCHEMES.has(scheme)) return true;\n if (\n scheme === \"data\" &&\n tag === \"img\" &&\n attr === \"src\" &&\n probe.startsWith(\"data:image/\")\n ) {\n // data:image/svg+xml 은 스크립트 실행 가능 → 차단.\n return !probe.startsWith(\"data:image/svg\");\n }\n return false;\n}\n\nfunction sanitizeUrlAttributes(\n tagName: string,\n attribs: sanitizeHtmlTypes.Attributes,\n): sanitizeHtmlTypes.Tag {\n const next: sanitizeHtmlTypes.Attributes = {};\n for (const [name, value] of Object.entries(attribs)) {\n const attr = name.toLowerCase();\n next[name] =\n URL_ATTRS.has(attr) && !isSafeUrlValue(tagName, attr, value) ? \"#\" : value;\n }\n return { tagName, attribs: next };\n}\n\nconst SANITIZE_OPTIONS: sanitizeHtmlTypes.IOptions = {\n allowedTags: [\n \"a\",\n \"blockquote\",\n \"br\",\n \"cite\",\n \"code\",\n \"details\",\n \"div\",\n \"em\",\n \"figcaption\",\n \"figure\",\n \"h2\",\n \"h3\",\n \"h4\",\n \"hr\",\n \"img\",\n \"li\",\n \"mark\",\n \"ol\",\n \"p\",\n \"pre\",\n \"s\",\n \"span\",\n \"strong\",\n \"summary\",\n \"table\",\n \"tbody\",\n \"td\",\n \"th\",\n \"tr\",\n \"u\",\n \"ul\",\n ],\n allowedAttributes: {\n \"*\": [\"class\", \"data-*\"],\n a: [\n \"href\",\n \"title\",\n { name: \"rel\", multiple: true, values: [\"noopener\", \"noreferrer\"] },\n { name: \"target\", values: [\"_blank\"] },\n ],\n details: [\"open\", \"class\", \"data-*\"],\n div: [\"class\", \"data-*\", \"style\"],\n h2: [\"id\", \"class\", \"data-*\", \"style\"],\n h3: [\"id\", \"class\", \"data-*\", \"style\"],\n h4: [\"id\", \"class\", \"data-*\", \"style\"],\n img: [\"src\", \"alt\", \"title\", \"width\", \"height\", \"loading\", \"class\", \"data-*\"],\n mark: [\"class\", \"data-*\", \"style\"],\n p: [\"class\", \"data-*\", \"style\"],\n span: [\"class\", \"data-*\", \"style\"],\n td: [\"colspan\", \"rowspan\", \"class\", \"data-*\"],\n th: [\"colspan\", \"rowspan\", \"class\", \"data-*\"],\n },\n allowedClasses: {\n \"*\": [ROOTTALE_CLASS_RE],\n },\n allowedStyles: {\n \"*\": {\n \"aspect-ratio\": [ASPECT_RATIO_RE],\n background: [HEX_COLOR_RE],\n \"background-color\": [HEX_COLOR_RE],\n color: [HEX_COLOR_RE],\n \"text-align\": [TEXT_ALIGN_RE],\n },\n },\n allowedSchemes: [\"http\", \"https\", \"mailto\", \"tel\"],\n allowedSchemesByTag: {\n // tag별 목록은 전역 allowedSchemes를 덮어쓴다. data만 쓰면 정상적인\n // http/https 이미지 src까지 제거되므로 이미지가 사용할 전체 scheme을 명시한다.\n img: [\"http\", \"https\", \"data\"],\n },\n allowedSchemesAppliedToAttributes: [\"href\", \"src\", \"xlink:href\"],\n allowProtocolRelative: false,\n nonTextTags: [\n \"script\",\n \"style\",\n \"textarea\",\n \"option\",\n \"iframe\",\n \"object\",\n \"embed\",\n \"svg\",\n \"math\",\n \"template\",\n ],\n transformTags: {\n \"*\": sanitizeUrlAttributes,\n },\n};\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 if (!html) return html;\n return sanitizeHtmlLibrary(stripMalformedDangerousTokens(html), SANITIZE_OPTIONS);\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 // core/embed allowlist (embed.ts) 의 영상 iframe — youtube-nocookie / vimeo.\n \"frame-src https://www.youtube-nocookie.com https://www.youtube.com https://player.vimeo.com; \" +\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":";AAMA,SAAS,SAAS;AAGX,IAAM,qBAAqB;AAElC,IAAM,sBAAsB,EACzB,OAAO;AAAA,EACN,UAAU,EACP;AAAA,IACC,EAAE,OAAO;AAAA,MACP,QAAQ,EAAE,OAAO;AAAA,MACjB,MAAM,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,SAAS;AAAA,IACvC,CAAC;AAAA,EACH,EACC,SAAS;AAAA,EACZ,MAAM,EAAE,OAAO,EAAE,SAAS;AAAA,EAC1B,aAAa,EAAE,OAAO,EAAE,SAAS;AACnC,CAAC,EACA,OAAO,EACP,SAAS;AAML,IAAM,cAAgC,EAAE;AAAA,EAAK,MAClD,EACG,OAAO;AAAA,IACN,KAAK,EAAE,OAAO,EAAE,KAAK;AAAA,IACrB,MAAM,EAAE,OAAO,EAAE,MAAM,kBAAkB;AAAA,IACzC,YAAY,EAAE,OAAO,EAAE,QAAQ,CAAC;AAAA,IAChC,aAAa,EAAE,MAAM,WAAW;AAAA,IAChC,SAAS,EAAE,OAAO,EAAE,SAAS;AAAA,IAC7B,UAAU;AAAA,EACZ,CAAC,EACA,OAAO;AACZ;AAkBO,SAAS,aAAa,OAAgC;AAC3D,SAAO,YAAY,UAAU,KAAK,EAAE;AACtC;AAEO,SAAS,WAAW,OAAuB;AAChD,SAAO,YAAY,MAAM,KAAK;AAChC;AAMO,IAAM,sBAAsB;AAAA,EACjC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;;;ACzDO,IAAM,uBAAuB;AAE7B,IAAM,6BAA6B;AAMnC,SAAS,2BACd,QACiB;AACjB,QAAM,SAAS,UAAU,CAAC;AAC1B,SAAO,CAAC,YAAY;AAClB,QACE,QAAQ,WAAW,wBACnB,QAAQ,WAAW,4BACnB;AACA,aAAO;AAAA,IACT;AACA,UAAM,OAAO,QAAQ,MAAM;AAC3B,QAAI,OAAO,SAAS,SAAU,QAAO;AACrC,UAAM,IAAI,OAAO,IAAI;AACrB,WAAO,MAAM,SAAY,SAAY;AAAA,EACvC;AACF;AAGO,SAAS,kBACX,WACc;AACjB,SAAO,CAAC,SAAS,WAAW,UAAU;AACpC,eAAW,KAAK,WAAW;AACzB,YAAM,IAAI,EAAE,SAAS,WAAW,KAAK;AACrC,UAAI,MAAM,OAAW,QAAO;AAAA,IAC9B;AACA,WAAO;AAAA,EACT;AACF;AAEA,SAAS,aAAa,OAAc,UAAkC;AACpE,QAAM,WAAW,MAAM,UAAU;AACjC,MAAI,aAAa,MAAM;AACvB,MAAI,UAAU;AACZ,QAAI,UAAU;AACd,UAAM,OAAO,EAAE,GAAG,MAAM,WAAW;AACnC,eAAW,CAAC,MAAM,OAAO,KAAK,OAAO,QAAQ,QAAQ,GAAG;AACtD,YAAM,QAAQ,SAAS,SAAS,MAAM,KAAK;AAC3C,UAAI,UAAU,QAAW;AACvB,aAAK,IAAI,IAAI;AACb,kBAAU;AAAA,MACZ;AAAA,IACF;AACA,QAAI,QAAS,cAAa;AAAA,EAC5B;AACA,QAAM,cAAc,MAAM,YAAY,SAClC,MAAM,YAAY,IAAI,CAAC,MAAM,aAAa,GAAG,QAAQ,CAAC,IACtD,MAAM;AACV,MAAI,eAAe,MAAM,cAAc,gBAAgB,MAAM,aAAa;AACxE,WAAO;AAAA,EACT;AACA,SAAO,EAAE,GAAG,OAAO,YAAY,YAAY;AAC7C;AAKO,SAAS,mBACd,QACA,UACS;AACT,SAAO,OAAO,IAAI,CAAC,MAAM,aAAa,GAAG,QAAQ,CAAC;AACpD;AAWO,SAAS,gBACd,UACA,WACA,QACS;AACT,MAAI,CAAC,YAAY,OAAO,aAAa,YAAY,CAAC,OAAQ,QAAO;AACjE,QAAM,UAAW,SACf,SACF;AACA,MACE,CAAC,WACA,QAAQ,WAAW,wBAClB,QAAQ,WAAW,4BACrB;AACA,WAAO;AAAA,EACT;AACA,QAAM,OAAO,QAAQ,MAAM;AAC3B,MAAI,OAAO,SAAS,SAAU,QAAO;AACrC,QAAM,QAAQ,OAAO,IAAI;AACzB,MAAI,UAAU,UAAa,UAAU,QAAQ,UAAU,GAAI,QAAO;AAClE,SAAO;AACT;AAMO,SAAS,iBAAiB,OAAoC;AACnE,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,EAAG,QAAO,OAAO,KAAK;AAC5E,SAAO;AACT;AAMO,SAAS,gBAAgB,OAAoC;AAClE,MAAI,SAAS,OAAO,UAAU,UAAU;AACtC,UAAM,MAAO,MAA4B;AACzC,QAAI,OAAO,QAAQ,YAAY,gBAAgB,KAAK,GAAG,EAAG,QAAO;AACjE,WAAO;AAAA,EACT;AACA,MAAI,OAAO,UAAU,YAAY,gBAAgB,KAAK,KAAK,EAAG,QAAO;AACrE,SAAO;AACT;;;AC9FA,SAAS,kBAAkB,OAAuB;AAChD,SAAO,MAAM,QAAQ,QAAQ,EAAE;AACjC;AAGO,SAAS,mBACd,aACS;AACT,SAAO,CAAC,CAAC,eAAe,YAAY,SAAS;AAC/C;AASO,SAAS,sBACd,MACA,aACwB;AACxB,MAAI,CAAC,KAAK,cAAe,QAAO;AAChC,SAAO,YAAY,KAAK,CAAC,MAAM,EAAE,QAAQ,KAAK,aAAa,KAAK;AAClE;AAOO,SAAS,eACd,SACA,MACA,aACe;AACf,QAAM,aAAa,sBAAsB,MAAM,WAAW;AAC1D,MAAI,CAAC,WAAY,QAAO;AACxB,MAAI,WAAW,WAAW,MAAO,QAAO;AACxC,MAAI,CAAC,WAAW,SAAU,QAAO;AACjC,SAAO,GAAG,kBAAkB,OAAO,CAAC,GAAG,kBAAkB,WAAW,QAAQ,CAAC,IAAI,mBAAmB,KAAK,IAAI,CAAC;AAChH;AAQO,SAAS,gBACd,MACA,aACe;AACf,QAAM,aAAa,sBAAsB,MAAM,WAAW;AAC1D,MAAI,CAAC,WAAY,QAAO;AACxB,MAAI,WAAW,WAAW,MAAO,QAAO;AACxC,MAAI,CAAC,WAAW,SAAU,QAAO;AACjC,SAAO,GAAG,kBAAkB,WAAW,QAAQ,CAAC,IAAI,mBAAmB,KAAK,IAAI,CAAC;AACnF;AAKO,SAAS,mBACd,OACA,aACA,KACK;AACL,SAAO,MAAM,OAAO,CAAC,MAAM,sBAAsB,GAAG,WAAW,GAAG,QAAQ,GAAG;AAC/E;AAGO,SAAS,qBACd,OACA,aACK;AACL,SAAO,MAAM,OAAO,CAAC,MAAM,sBAAsB,GAAG,WAAW,MAAM,IAAI;AAC3E;AAQO,SAAS,wBACd,OACA,aACK;AACL,SAAO,MAAM,OAAO,CAAC,MAAM,sBAAsB,GAAG,WAAW,MAAM,IAAI;AAC3E;AASO,SAAS,oBAAoB,aAAmD;AACrF,QAAM,WAAqB,CAAC;AAC5B,QAAM,UAAU,oBAAI,IAAY;AAChC,QAAM,WAAW,oBAAI,IAAY;AACjC,QAAM,gBAAgB,oBAAI,IAAoB;AAE9C,aAAW,KAAK,aAAa;AAC3B,QAAI,QAAQ,IAAI,EAAE,GAAG,EAAG,UAAS,KAAK,iCAAuB,EAAE,GAAG,GAAG;AACrE,YAAQ,IAAI,EAAE,GAAG;AAEjB,QAAI,EAAE,aAAa,IAAI;AACrB,YAAM,OAAO,kBAAkB,EAAE,QAAQ;AACzC,UAAI,SAAS,IAAI,IAAI,EAAG,UAAS,KAAK,2BAAiB,EAAE,QAAQ,GAAG;AACpE,eAAS,IAAI,IAAI;AAEjB,UAAI,CAAC,EAAE,SAAS,WAAW,GAAG,GAAG;AAC/B,iBAAS,KAAK,gEAA6B,EAAE,QAAQ,MAAM,EAAE,GAAG,GAAG;AAAA,MACrE,WACE,KAAK,SAAS,KACd,KAAK,WAAW,IAAI,KACpB,WAAW,KAAK,IAAI,KACpB,KAAK,MAAM,GAAG,EAAE,KAAK,CAAC,YAAY,YAAY,OAAO,YAAY,IAAI,GACrE;AACA,iBAAS;AAAA,UACP,kGAAiC,EAAE,QAAQ,MAAM,EAAE,GAAG;AAAA,QACxD;AAAA,MACF;AAAA,IACF;AAEA,eAAW,QAAQ,EAAE,YAAY;AAC/B,YAAM,QAAQ,cAAc,IAAI,IAAI;AACpC,UAAI,SAAS,UAAU,EAAE,KAAK;AAC5B,iBAAS;AAAA,UACP,aAAa,IAAI,wBAAmB,KAAK,aAAQ,EAAE,GAAG;AAAA,QACxD;AAAA,MACF;AACA,oBAAc,IAAI,MAAM,EAAE,GAAG;AAAA,IAC/B;AAAA,EACF;AACA,SAAO;AACT;;;ACpLO,SAAS,eAAe,OAAsB;AACnD,QAAM,aAAa,4BAA4B,KAAK;AACpD,QAAM,YAAY,oBAAoB,WAAW,UAAU;AAC3D,QAAM,YAAY,MAAM,WAAW;AACnC,QAAM,mBAAmB,MAAM,eAAe,CAAC,GAC5C,IAAI,CAAC,MAAM,eAAe,CAAC,CAAC,EAC5B,KAAK,IAAI;AAGZ,MAAI,CAAC,aAAa,gBAAgB,WAAW,GAAG;AAC9C,WAAO,YACH,WAAW,cAAc,MAAM,IAAI,CAAC,IAAI,SAAS,UACjD,WAAW,cAAc,MAAM,IAAI,CAAC;AAAA,EAC1C;AAEA,QAAM,OAAO,YACT,WAAW,cAAc,MAAM,IAAI,CAAC,IAAI,SAAS,SACjD,WAAW,cAAc,MAAM,IAAI,CAAC;AACxC,QAAM,QAAQ,YAAY,cAAc,MAAM,IAAI,CAAC;AAEnD,QAAM,OAAO,CAAC,WAAW,eAAe,EAAE,OAAO,OAAO,EAAE,KAAK,IAAI;AACnE,SAAO,GAAG,IAAI;AAAA,EAAK,IAAI;AAAA,EAAK,KAAK;AACnC;AAGA,SAAS,cAAc,MAAsB;AAC3C,SAAO,KAAK,WAAW,OAAO,IAAI,KAAK,MAAM,QAAQ,MAAM,IAAI;AACjE;AAEA,SAAS,oBAAoB,OAAwC;AACnE,QAAM,OAAO,OAAO,KAAK,KAAK;AAC9B,MAAI,KAAK,WAAW,EAAG,QAAO;AAC9B,SAAO,KAAK,UAAU,KAAK;AAC7B;AAEA,SAAS,4BAA4B,OAEnC;AAEA,SAAO,EAAE,YAAY,EAAE,GAAG,MAAM,WAAW,EAAE;AAC/C;;;ACrCA,IAAM,gBAAgB,oBAAI,IAAI;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAED,IAAM,cAAc,oBAAI,IAAI,CAAC,aAAa,iBAAiB,kBAAkB,CAAC;AAG9E,IAAM,sBAAsB,oBAAI,IAAI;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAED,IAAM,aAAa;AACnB,IAAM,WAAW;AAMV,SAAS,eAAe,OAAwC;AACrE,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,MAAM,MAAM,KAAK;AACvB,MAAI,IAAI,WAAW,EAAG,QAAO;AAE7B,MAAI;AACJ,MAAI;AACF,UAAM,IAAI,IAAI,GAAG;AAAA,EACnB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,IAAI,aAAa,YAAY,IAAI,aAAa,QAAS,QAAO;AAElE,QAAM,OAAO,IAAI,SAAS,YAAY;AAEtC,MAAI,cAAc,IAAI,IAAI,GAAG;AAC3B,UAAM,KAAK,iBAAiB,KAAK,IAAI;AACrC,QAAI,IAAI;AACN,aAAO;AAAA,QACL,UAAU;AAAA,QACV,UAAU,0CAA0C,EAAE;AAAA,QACtD,aAAa;AAAA,MACf;AAAA,IACF;AAAA,EACF;AAEA,MAAI,YAAY,IAAI,IAAI,GAAG;AACzB,UAAM,KAAK,eAAe,KAAK,IAAI;AACnC,QAAI,IAAI;AACN,aAAO;AAAA,QACL,UAAU;AAAA,QACV,UAAU,kCAAkC,EAAE;AAAA,QAC9C,aAAa;AAAA,MACf;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAMO,SAAS,kBAAkB,OAAiC;AACjE,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI;AACJ,MAAI;AACF,UAAM,IAAI,IAAI,KAAK;AAAA,EACrB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,IAAI,aAAa,SAAU,QAAO;AACtC,QAAM,OAAO,IAAI,SAAS,YAAY;AACtC,MAAI,CAAC,oBAAoB,IAAI,IAAI,EAAG,QAAO;AAG3C,MAAI,SAAS,oBAAoB;AAC/B,WAAO,iBAAiB,KAAK,IAAI,QAAQ;AAAA,EAC3C;AAEA,SAAO,IAAI,OAAO,4BAA4B,EAAE,KAAK,IAAI,QAAQ;AACnE;AAGA,IAAM,mBAAmB,oBAAI,IAAI,CAAC,kBAAkB,cAAc,iBAAiB,CAAC;AACpF,IAAM,YAAY,oBAAI,IAAI,CAAC,yBAAyB,mBAAmB,CAAC;AAcjE,SAAS,qBAAqB,OAAiC;AACpE,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI;AACJ,MAAI;AACF,UAAM,IAAI,IAAI,KAAK;AAAA,EACrB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,IAAI,aAAa,SAAU,QAAO;AACtC,QAAM,OAAO,IAAI,SAAS,YAAY;AACtC,MAAI,iBAAiB,IAAI,IAAI,GAAG;AAC9B,QAAI,IAAI,aAAa,cAAe,QAAO;AAC3C,QAAI,IAAI,aAAa,WAAW,IAAI,aAAa,UAAU;AACzD,aAAO,IAAI,aAAa,IAAI,QAAQ,MAAM;AAAA,IAC5C;AACA,WAAO;AAAA,EACT;AACA,MAAI,UAAU,IAAI,IAAI,GAAG;AACvB,WAAO,IAAI,aAAa;AAAA,EAC1B;AACA,SAAO;AACT;AAEA,SAAS,iBAAiB,KAAU,MAA6B;AAC/D,MAAI,SAAS,cAAc,SAAS,gBAAgB;AAClD,UAAM,KAAK,IAAI,SAAS,MAAM,GAAG,EAAE,OAAO,OAAO,EAAE,CAAC,KAAK;AACzD,WAAO,WAAW,KAAK,EAAE,IAAI,KAAK;AAAA,EACpC;AACA,QAAM,IAAI,IAAI,aAAa,IAAI,GAAG;AAClC,MAAI,KAAK,WAAW,KAAK,CAAC,EAAG,QAAO;AACpC,QAAM,QAAQ,IAAI,SAAS,MAAM,GAAG,EAAE,OAAO,OAAO;AACpD,MACE,MAAM,UAAU,MACf,MAAM,CAAC,MAAM,WAAW,MAAM,CAAC,MAAM,YAAY,MAAM,CAAC,MAAM,MAC/D;AACA,WAAO,WAAW,KAAK,MAAM,CAAC,CAAC,IAAI,MAAM,CAAC,IAAI;AAAA,EAChD;AACA,SAAO;AACT;AAEA,SAAS,eAAe,KAAU,MAA6B;AAC7D,QAAM,QAAQ,IAAI,SAAS,MAAM,GAAG,EAAE,OAAO,OAAO;AACpD,MAAI;AACJ,MAAI,SAAS,oBAAoB;AAC/B,UAAM,KAAK,MAAM,QAAQ,OAAO;AAChC,gBAAY,MAAM,IAAI,MAAM,KAAK,CAAC,IAAI,MAAM,MAAM,SAAS,CAAC;AAAA,EAC9D,OAAO;AACL,gBAAY,MAAM,CAAC;AAAA,EACrB;AACA,SAAO,aAAa,SAAS,KAAK,SAAS,IAAI,YAAY;AAC7D;;;AC/JA,SAAS,UAAU,MAAmC;AACpD,MAAI,CAAC,KAAM,QAAO;AAClB,MAAI,OAAO,KAAK,SAAS,SAAU,QAAO,KAAK;AAC/C,MAAI,CAAC,MAAM,QAAQ,KAAK,OAAO,EAAG,QAAO;AAEzC,SAAO,KAAK,QAAQ,IAAI,SAAS,EAAE,KAAK,GAAG;AAC7C;AAEA,SAAS,SAAS,GAAmB;AACnC,SAAO,EAAE,QAAQ,QAAQ,GAAG,EAAE,KAAK;AACrC;AAGO,SAAS,kBAAkB,KAA0B;AAC1D,QAAM,MAAkB,CAAC;AACzB,QAAM,OAAO,CAAC,SAAoC;AAChD,QAAI,CAAC,QAAQ,OAAO,SAAS,SAAU;AACvC,QAAI,KAAK,SAAS,aAAa,MAAM,QAAQ,KAAK,OAAO,GAAG;AAC1D,YAAM,IAAI,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,SAAS,aAAa;AAC3D,YAAM,IAAI,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,SAAS,WAAW;AACzD,YAAM,WAAW,SAAS,UAAU,CAAC,CAAC;AACtC,YAAM,SAAS,SAAS,UAAU,CAAC,CAAC;AACpC,UAAI,YAAY,OAAQ,KAAI,KAAK,EAAE,UAAU,OAAO,CAAC;AACrD;AAAA,IACF;AACA,QAAI,MAAM,QAAQ,KAAK,OAAO,EAAG,MAAK,QAAQ,QAAQ,IAAI;AAAA,EAC5D;AACA,MAAI,OAAO,OAAO,QAAQ,UAAU;AAClC,SAAK,GAAc;AAAA,EACrB;AACA,SAAO;AACT;AAGO,SAAS,cACd,SACgC;AAChC,MAAI,QAAQ,WAAW,EAAG,QAAO;AACjC,SAAO;AAAA,IACL,YAAY;AAAA,IACZ,SAAS;AAAA,IACT,YAAY,QAAQ,IAAI,CAAC,OAAO;AAAA,MAC9B,SAAS;AAAA,MACT,MAAM,EAAE;AAAA,MACR,gBAAgB,EAAE,SAAS,UAAU,MAAM,EAAE,OAAO;AAAA,IACtD,EAAE;AAAA,EACJ;AACF;;;AC3DO,IAAM,uBAAuB;AAS7B,SAAS,kBAAkB,OAAwB;AACxD,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,aAAa,MAAM,QAAQ,QAAQ,GAAG,EAAE,KAAK;AACnD,QAAM,aAAa,MAAM,KAAK,UAAU;AACxC,MAAI,WAAW,UAAU,qBAAsB,QAAO;AAEtD,QAAM,UAAU,WACb,MAAM,GAAG,uBAAuB,CAAC,EACjC,KAAK,EAAE,EACP,QAAQ;AACX,SAAO,GAAG,OAAO;AACnB;;;ACnBA,SAAS,YAAY,aAAa,uBAAuB;AAOzD,IAAM,YAAY;AAElB,SAAS,UAAU,KAAqB;AACtC,SAAO,IAAI,SAAS,WAAW;AACjC;AAEA,SAAS,cAAc,GAAmB;AACxC,SAAO,OAAO,KAAK,GAAG,WAAW;AACnC;AAEA,SAAS,KAAK,QAAgB,SAAyB;AACrD,SAAO,UAAU,WAAW,UAAU,MAAM,EAAE,OAAO,OAAO,EAAE,OAAO,CAAC;AACxE;AAGO,SAAS,kBACd,QACA,SACQ;AACR,QAAM,MAAM,QAAQ,UAAU,QAAQ;AACtC,QAAM,QAAQ,UAAU,YAAY,EAAE,CAAC;AACvC,QAAM,UAAU,GAAG,QAAQ,MAAM,GAAG,SAAS,GAAG,GAAG,GAAG,SAAS,GAAG,KAAK;AACvE,QAAM,MAAM,KAAK,QAAQ,OAAO;AAChC,SAAO,GAAG,OAAO,GAAG,SAAS,GAAG,GAAG;AACrC;AAGO,SAAS,mBACd,QACA,OAC4B;AAC5B,QAAM,QAAQ,MAAM,MAAM,SAAS;AACnC,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,QAAM,CAAC,QAAQ,QAAQ,OAAO,GAAG,IAAI;AACrC,QAAM,MAAM,OAAO,MAAM;AACzB,MAAI,CAAC,OAAO,SAAS,GAAG,EAAG,QAAO;AAClC,MAAI,KAAK,IAAI,IAAI,IAAK,QAAO;AAE7B,QAAM,cAAc,KAAK,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,GAAG,GAAG,SAAS,GAAG,KAAK,EAAE;AAClF,QAAM,IAAI,cAAc,GAAG;AAC3B,QAAM,IAAI,cAAc,WAAW;AACnC,MAAI,EAAE,WAAW,EAAE,OAAQ,QAAO;AAClC,MAAI,CAAC,gBAAgB,GAAG,CAAC,EAAG,QAAO;AAEnC,SAAO,EAAE,QAAQ,WAAW,IAAI,KAAK,GAAG,EAAE;AAC5C;;;AC3DA,OAAO,yBAAyB;AAqBhC,IAAM,YAAY,oBAAI,IAAI,CAAC,QAAQ,OAAO,YAAY,CAAC;AAEvD,IAAM,sBAAsB,oBAAI,IAAI,CAAC,QAAQ,SAAS,UAAU,KAAK,CAAC;AACtE,IAAM,+BACJ;AACF,IAAM,6BAA6B;AACnC,IAAM,oBAAoB;AAC1B,IAAM,eAAe;AACrB,IAAM,gBAAgB;AACtB,IAAM,kBAAkB;AAGxB,IAAM,eAAuC;AAAA,EAC3C,OAAO;AAAA,EACP,KAAK;AAAA,EACL,SAAS;AAAA,EACT,KAAK;AAAA,EACL,MAAM;AAAA,EACN,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,MAAM;AAAA,EACN,MAAM;AACR;AAEA,SAAS,kBAAkB,GAAmB;AAC5C,MAAI,CAAC,OAAO,SAAS,CAAC,KAAK,IAAI,KAAK,IAAI,QAAU,QAAO;AACzD,MAAI;AACF,WAAO,OAAO,cAAc,CAAC;AAAA,EAC/B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGA,SAAS,eAAe,OAAuB;AAC7C,QAAM,UAAU,MACb,QAAQ,sBAAsB,CAAC,GAAG,MAAM,kBAAkB,SAAS,GAAG,EAAE,CAAC,CAAC,EAC1E,QAAQ,cAAc,CAAC,GAAG,MAAM,kBAAkB,SAAS,GAAG,EAAE,CAAC,CAAC,EAClE,QAAQ,gBAAgB,CAAC,GAAG,SAAS,aAAa,KAAK,YAAY,CAAC,KAAK,CAAC;AAE7E,SAAO,CAAC,GAAG,OAAO,EAAE,OAAO,CAAC,MAAM;AAAE,UAAM,IAAI,EAAE,YAAY,CAAC,KAAK;AAAG,WAAO,IAAI,MAAQ,MAAM;AAAA,EAAM,CAAC,EAAE,KAAK,EAAE,EAAE,YAAY;AAC9H;AAEA,SAAS,8BAA8B,MAAsB;AAC3D,MAAI,MAAM;AACV,WAAS,IAAI,GAAG,IAAI,GAAG,KAAK;AAC1B,UAAM,OAAO,IAAI,QAAQ,8BAA8B,EAAE;AACzD,QAAI,SAAS,IAAK,QAAO;AACzB,UAAM;AAAA,EACR;AACA,SAAO;AACT;AAKA,SAAS,eAAe,KAAa,MAAc,OAAwB;AACzE,QAAM,QAAQ,eAAe,KAAK;AAClC,MAAI,2BAA2B,KAAK,KAAK,EAAG,QAAO;AACnD,QAAM,SAAS,wBAAwB,KAAK,KAAK,IAAI,CAAC;AACtD,MAAI,CAAC,OAAQ,QAAO;AACpB,MAAI,oBAAoB,IAAI,MAAM,EAAG,QAAO;AAC5C,MACE,WAAW,UACX,QAAQ,SACR,SAAS,SACT,MAAM,WAAW,aAAa,GAC9B;AAEA,WAAO,CAAC,MAAM,WAAW,gBAAgB;AAAA,EAC3C;AACA,SAAO;AACT;AAEA,SAAS,sBACP,SACA,SACuB;AACvB,QAAM,OAAqC,CAAC;AAC5C,aAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,OAAO,GAAG;AACnD,UAAM,OAAO,KAAK,YAAY;AAC9B,SAAK,IAAI,IACP,UAAU,IAAI,IAAI,KAAK,CAAC,eAAe,SAAS,MAAM,KAAK,IAAI,MAAM;AAAA,EACzE;AACA,SAAO,EAAE,SAAS,SAAS,KAAK;AAClC;AAEA,IAAM,mBAA+C;AAAA,EACnD,aAAa;AAAA,IACX;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EACA,mBAAmB;AAAA,IACjB,KAAK,CAAC,SAAS,QAAQ;AAAA,IACvB,GAAG;AAAA,MACD;AAAA,MACA;AAAA,MACA,EAAE,MAAM,OAAO,UAAU,MAAM,QAAQ,CAAC,YAAY,YAAY,EAAE;AAAA,MAClE,EAAE,MAAM,UAAU,QAAQ,CAAC,QAAQ,EAAE;AAAA,IACvC;AAAA,IACA,SAAS,CAAC,QAAQ,SAAS,QAAQ;AAAA,IACnC,KAAK,CAAC,SAAS,UAAU,OAAO;AAAA,IAChC,IAAI,CAAC,MAAM,SAAS,UAAU,OAAO;AAAA,IACrC,IAAI,CAAC,MAAM,SAAS,UAAU,OAAO;AAAA,IACrC,IAAI,CAAC,MAAM,SAAS,UAAU,OAAO;AAAA,IACrC,KAAK,CAAC,OAAO,OAAO,SAAS,SAAS,UAAU,WAAW,SAAS,QAAQ;AAAA,IAC5E,MAAM,CAAC,SAAS,UAAU,OAAO;AAAA,IACjC,GAAG,CAAC,SAAS,UAAU,OAAO;AAAA,IAC9B,MAAM,CAAC,SAAS,UAAU,OAAO;AAAA,IACjC,IAAI,CAAC,WAAW,WAAW,SAAS,QAAQ;AAAA,IAC5C,IAAI,CAAC,WAAW,WAAW,SAAS,QAAQ;AAAA,EAC9C;AAAA,EACA,gBAAgB;AAAA,IACd,KAAK,CAAC,iBAAiB;AAAA,EACzB;AAAA,EACA,eAAe;AAAA,IACb,KAAK;AAAA,MACH,gBAAgB,CAAC,eAAe;AAAA,MAChC,YAAY,CAAC,YAAY;AAAA,MACzB,oBAAoB,CAAC,YAAY;AAAA,MACjC,OAAO,CAAC,YAAY;AAAA,MACpB,cAAc,CAAC,aAAa;AAAA,IAC9B;AAAA,EACF;AAAA,EACA,gBAAgB,CAAC,QAAQ,SAAS,UAAU,KAAK;AAAA,EACjD,qBAAqB;AAAA;AAAA;AAAA,IAGnB,KAAK,CAAC,QAAQ,SAAS,MAAM;AAAA,EAC/B;AAAA,EACA,mCAAmC,CAAC,QAAQ,OAAO,YAAY;AAAA,EAC/D,uBAAuB;AAAA,EACvB,aAAa;AAAA,IACX;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EACA,eAAe;AAAA,IACb,KAAK;AAAA,EACP;AACF;AAMO,SAAS,aAAa,MAAsB;AACjD,MAAI,CAAC,KAAM,QAAO;AAClB,SAAO,oBAAoB,8BAA8B,IAAI,GAAG,gBAAgB;AAClF;AAMO,IAAM,cACX;;;ACjNF,SAAS,cAAc;AAQhB,SAAS,gBAAgB,OAAqD;AACnF,SAAO;AAAA,IACL,GAAG;AAAA,IACH,KAAK,MAAM,OAAO,OAAO;AAAA,IACzB,cAAc,MAAM,eAAe,CAAC,GAAG;AAAA,MAAI,CAAC,UAC1C,gBAAgB,KAA8C;AAAA,IAChE;AAAA,EACF;AACF;AAMO,SAAS,oBAAoB,QAAqE;AACvG,SAAO,OAAO,IAAI,CAAC,MAAM,gBAAgB,CAAC,CAAC;AAC7C;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roottale/cms-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
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.",
|
|
6
6
|
"main": "./dist/index.cjs",
|