@roottale/cms-renderer-next 0.52.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +23 -2
  3. package/dist/{chunk-U2IM6OCQ.js → chunk-HM5VP7SR.js} +1 -1
  4. package/dist/{chunk-U2IM6OCQ.js.map → chunk-HM5VP7SR.js.map} +1 -1
  5. package/dist/chunk-KSPPYX22.js +14 -0
  6. package/dist/chunk-KSPPYX22.js.map +1 -0
  7. package/dist/cms-public.css +38 -0
  8. package/dist/routes.d.ts +9 -0
  9. package/dist/routes.js +18 -5
  10. package/dist/routes.js.map +1 -1
  11. package/dist/section-runtime.d.ts +46 -0
  12. package/dist/section-runtime.js +9 -0
  13. package/dist/server.d.ts +5 -3
  14. package/dist/server.js +1679 -34
  15. package/dist/server.js.map +1 -1
  16. package/package.json +7 -23
  17. package/dist/before-after-client.d.ts +0 -15
  18. package/dist/before-after-client.js +0 -8
  19. package/dist/chunk-27RT6B3O.js +0 -93
  20. package/dist/chunk-27RT6B3O.js.map +0 -1
  21. package/dist/chunk-2QU4JU46.js +0 -3927
  22. package/dist/chunk-2QU4JU46.js.map +0 -1
  23. package/dist/chunk-4RQ3SEDL.js +0 -106
  24. package/dist/chunk-4RQ3SEDL.js.map +0 -1
  25. package/dist/chunk-6GQCFFHU.js +0 -540
  26. package/dist/chunk-6GQCFFHU.js.map +0 -1
  27. package/dist/chunk-H46JUIHZ.js +0 -79
  28. package/dist/chunk-H46JUIHZ.js.map +0 -1
  29. package/dist/chunk-K62RR4UQ.js +0 -53
  30. package/dist/chunk-K62RR4UQ.js.map +0 -1
  31. package/dist/chunk-LEHHSI6G.js +0 -64
  32. package/dist/chunk-LEHHSI6G.js.map +0 -1
  33. package/dist/chunk-QYNEOFL7.js +0 -418
  34. package/dist/chunk-QYNEOFL7.js.map +0 -1
  35. package/dist/chunk-U5AKACPN.js +0 -113
  36. package/dist/chunk-U5AKACPN.js.map +0 -1
  37. package/dist/chunk-UM37DOIK.js +0 -961
  38. package/dist/chunk-UM37DOIK.js.map +0 -1
  39. package/dist/chunk-YLSQAT6B.js +0 -59
  40. package/dist/chunk-YLSQAT6B.js.map +0 -1
  41. package/dist/chunk-ZL2NA3QT.js +0 -313
  42. package/dist/chunk-ZL2NA3QT.js.map +0 -1
  43. package/dist/concerns-client.d.ts +0 -12
  44. package/dist/concerns-client.js +0 -8
  45. package/dist/concerns-client.js.map +0 -1
  46. package/dist/contact-map-client.d.ts +0 -27
  47. package/dist/contact-map-client.js +0 -8
  48. package/dist/contact-map-client.js.map +0 -1
  49. package/dist/expertise-client.d.ts +0 -16
  50. package/dist/expertise-client.js +0 -9
  51. package/dist/expertise-client.js.map +0 -1
  52. package/dist/hero-carousel-client.d.ts +0 -52
  53. package/dist/hero-carousel-client.js +0 -9
  54. package/dist/hero-carousel-client.js.map +0 -1
  55. package/dist/seo-checks.d.ts +0 -87
  56. package/dist/seo-checks.js +0 -27
  57. package/dist/seo-checks.js.map +0 -1
  58. package/dist/site-config-DNpvlqA5.d.ts +0 -3961
  59. package/dist/spaces-client.d.ts +0 -10
  60. package/dist/spaces-client.js +0 -8
  61. package/dist/spaces-client.js.map +0 -1
  62. package/dist/team-expanding-stage-client.d.ts +0 -35
  63. package/dist/team-expanding-stage-client.js +0 -8
  64. package/dist/team-expanding-stage-client.js.map +0 -1
  65. package/dist/templates.css +0 -4145
  66. package/dist/templates.d.ts +0 -171
  67. package/dist/templates.js +0 -291
  68. package/dist/templates.js.map +0 -1
  69. package/dist/theme-css-vars-CD3jcEkp.d.ts +0 -7
  70. package/dist/ui-react-motion-client.d.ts +0 -76
  71. package/dist/ui-react-motion-client.js +0 -16
  72. package/dist/ui-react-motion-client.js.map +0 -1
  73. package/dist/validate.d.ts +0 -104
  74. package/dist/validate.js +0 -1204
  75. package/dist/validate.js.map +0 -1
  76. /package/dist/{before-after-client.js.map → section-runtime.js.map} +0 -0
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/templates/seo-checks.ts"],"sourcesContent":["// 실시간 SEO 어시스턴트 — 순수 체크 모듈 (UI 의존성 0, vitest 단위 테스트 대상).\n//\n// Google Search Central 권고 기반의 휴리스틱 점검. 점수가 검색 순위를 보장하지\n// 않으므로 hint 문구는 과장 없이 \"권고\" 톤으로 유지한다.\n//\n// Tiptap 노드 타입은 packages/cms-import/src/wxr-export.ts 의 렌더 규칙과 동일\n// 어휘 (paragraph/heading/list/blockquote/codeBlock/image/embed/text + marks).\n//\n// 위치 (2026-07-11 W4-5 PR 1 — seo-suite-rankmath-parity.md §3①): 원래\n// apps/tenant-admin/src/lib/seo-checks.ts 에 있었으나, 발행 게이트\n// (publish-gate.ts, 같은 디렉터리)의 서버 재실행 소비처가 생겨 클라이언트 패널\n// (tenant-admin SeoChecklist)과 서버가 같은 함수를 실행해야 하는 요건이 됐다\n// (ADR-0061 원 결정 \"추출 기각\"의 전제 — 소비자 1곳 — 가 변함, ADR-0061 §amend\n// 대상). 순수 함수(순환 의존 없음)라 파일 이동만 — 로직 변경 없음.\n\nexport type SeoCheckLevel = \"good\" | \"warn\" | \"bad\";\n\nexport interface SeoCheckResult {\n id: string;\n level: SeoCheckLevel;\n label: string;\n hint: string;\n}\n\nexport interface SeoCheckInput {\n title: string;\n seoTitle: string;\n seoDescription: string;\n excerpt: string;\n slug: string;\n /** Tiptap doc JSON (PostsEditor 의 editor.getJSON() 형태). */\n bodyJson: TiptapNode | null | undefined;\n hasFeaturedImage: boolean;\n /** SEO 패널의 canonical URL — noindex 와 겸용 충돌 점검용 (선택). */\n canonical?: string;\n /** SEO 패널의 noindex 설정 (선택). */\n noindex?: boolean;\n}\n\n// ── Tiptap JSON 순회 유틸 (순수 함수) ───────────────────────────────────────\n\nexport interface TiptapMark {\n type?: string;\n attrs?: Record<string, unknown>;\n}\n\nexport interface TiptapNode {\n type?: string;\n text?: string;\n attrs?: Record<string, unknown>;\n content?: TiptapNode[];\n marks?: TiptapMark[];\n}\n\n/** 블록 레벨 노드 — 텍스트 추출 시 경계에 개행을 넣어 문단이 붙지 않게 한다. */\nconst BLOCK_TYPES = new Set([\n \"paragraph\",\n \"heading\",\n \"listItem\",\n \"blockquote\",\n \"codeBlock\",\n]);\n\n/** depth-first 로 모든 노드를 방문한다 (루트 포함). */\nexport function walkNodes(\n node: TiptapNode | null | undefined,\n visit: (node: TiptapNode) => void,\n): void {\n if (!node || typeof node !== \"object\") return;\n visit(node);\n if (Array.isArray(node.content)) {\n for (const child of node.content) walkNodes(child, visit);\n }\n}\n\n/** 조건에 맞는 노드를 문서 순서대로 수집한다. */\nexport function collectNodes(\n node: TiptapNode | null | undefined,\n predicate: (node: TiptapNode) => boolean,\n): TiptapNode[] {\n const out: TiptapNode[] = [];\n walkNodes(node, (n) => {\n if (predicate(n)) out.push(n);\n });\n return out;\n}\n\n/** 본문 텍스트 추출 — 블록 경계는 개행으로 구분. */\nexport function extractText(node: TiptapNode | null | undefined): string {\n if (!node || typeof node !== \"object\") return \"\";\n if (node.type === \"text\") return typeof node.text === \"string\" ? node.text : \"\";\n const inner = Array.isArray(node.content)\n ? node.content.map((c) => extractText(c)).join(\"\")\n : \"\";\n return BLOCK_TYPES.has(node.type ?? \"\") ? `${inner}\\n` : inner;\n}\n\n/** 공백 제외 글자 수 — \"본문 분량\" 측정 기준. */\nexport function countBodyChars(node: TiptapNode | null | undefined): number {\n return extractText(node).replace(/\\s+/g, \"\").length;\n}\n\n// ── 개별 체크 ────────────────────────────────────────────────────────────────\n\n// 한국어 구글 SERP 표시 한도 기준 (SEO강의-2-콘텐츠: 타이틀 30자(공백 포함 35),\n// 디스크립션 90자(공백 포함 100)). 영문 기준(60/160)보다 짧다.\nconst TITLE_MIN = 8;\nconst DESC_MIN = 50;\nconst BODY_GOOD = 800;\nconst SLUG_MAX = 60;\n\n/**\n * 사이트별 bounded 임계값 (ADR-0061 §5 content kind). seo-checks 는 KnowledgeKind 를\n * 모르고(§4②) 평범한 숫자만 받는다 — content→thresholds 해석은 호출자(페이지)가.\n * 필드명은 site-knowledge ContentPolicy 와 동일(구조적으로 그대로 전달 가능).\n */\nexport interface SeoThresholds {\n titleMaxLen: number;\n descMaxLen: number;\n bodyMinLen: number;\n}\n\n/** 기본값 = 한국어 SERP 기준 현행 상수. 미전달 시 동작 불변(하위호환). */\nexport const DEFAULT_SEO_THRESHOLDS: SeoThresholds = {\n titleMaxLen: 30,\n descMaxLen: 90,\n bodyMinLen: 300,\n};\n\n/** 1. 표시 제목 길이 — seoTitle 이 있으면 그것이 검색결과 제목. */\nfunction checkTitle(\n input: SeoCheckInput,\n thresholds: SeoThresholds,\n): SeoCheckResult {\n const display = (input.seoTitle.trim() || input.title.trim());\n const len = display.length;\n const max = thresholds.titleMaxLen;\n const label = \"제목 길이\";\n if (len === 0) {\n return {\n id: \"title-length\",\n level: \"bad\",\n label,\n hint: \"제목이 없습니다. 검색결과의 첫인상이므로 꼭 작성하세요.\",\n };\n }\n if (len >= TITLE_MIN && len <= max) {\n return {\n id: \"title-length\",\n level: \"good\",\n label,\n hint: `${len}자 — 한국어 검색결과에 잘리지 않는 길이예요 (${max}자 기준). 메인 키워드는 왼쪽에.`,\n };\n }\n return {\n id: \"title-length\",\n level: \"warn\",\n label,\n hint:\n len < TITLE_MIN\n ? `${len}자 — 너무 짧으면 내용을 전달하기 어려워요. ${TITLE_MIN}~${max}자를 권장해요.`\n : `${len}자 — 한국어 검색결과는 약 ${max}자(공백 포함 ${max + 5}자)까지 표시돼요. 메인 키워드를 왼쪽에 두고 줄여보세요.`,\n };\n}\n\n/** 2. 메타 설명 — 비우면 요약(excerpt)이 fallback (PostsEditor UI 동작과 동일). */\nfunction checkDescription(\n input: SeoCheckInput,\n thresholds: SeoThresholds,\n): SeoCheckResult {\n const desc = input.seoDescription.trim() || input.excerpt.trim();\n const len = desc.length;\n const max = thresholds.descMaxLen;\n const label = \"메타 설명\";\n if (len === 0) {\n return {\n id: \"meta-description\",\n level: \"warn\",\n label,\n hint: \"메타 설명·요약이 비어 있어요. 없으면 검색엔진이 본문에서 발췌합니다.\",\n };\n }\n if (len >= DESC_MIN && len <= max) {\n return {\n id: \"meta-description\",\n level: \"good\",\n label,\n hint: `${len}자 — 한국어 검색결과 설명으로 적당한 길이예요 (${max}자 기준). 키워드는 2회까지, 형용사로 클릭 유도.`,\n };\n }\n return {\n id: \"meta-description\",\n level: \"warn\",\n label,\n hint:\n len < DESC_MIN\n ? `${len}자 — 조금 더 채우면 클릭을 유도하기 좋아요 (${DESC_MIN}~${max}자 권장).`\n : `${len}자 — 한국어 검색결과는 약 ${max}자(공백 포함 ${max + 10}자)까지 표시돼요. ${max}자 이내 권장.`,\n };\n}\n\n/** 3. 본문 분량 — 공백 제외 글자 수 기준. */\nfunction checkBodyLength(\n chars: number,\n thresholds: SeoThresholds,\n): SeoCheckResult {\n const min = thresholds.bodyMinLen;\n const label = \"본문 분량\";\n if (chars >= BODY_GOOD) {\n return {\n id: \"body-length\",\n level: \"good\",\n label,\n hint: `약 ${chars}자 — 주제를 충분히 다룰 수 있는 분량이에요.`,\n };\n }\n if (chars < min) {\n return {\n id: \"body-length\",\n level: \"warn\",\n label,\n hint: `약 ${chars}자 — 너무 짧으면 검색엔진이 주제를 파악하기 어려워요 (${min}자 이상 권장).`,\n };\n }\n return {\n id: \"body-length\",\n level: \"warn\",\n label,\n hint: `약 ${chars}자 — ${BODY_GOOD}자 이상으로 깊이를 더하면 더 좋아요.`,\n };\n}\n\n/** 4. 소제목 구조 — 긴 글의 h2/h3 부재 + heading 레벨 건너뜀(h2→h4 등). */\nfunction checkHeadings(body: TiptapNode | null | undefined, chars: number): SeoCheckResult {\n const label = \"소제목 구조\";\n const headings = collectNodes(body, (n) => n.type === \"heading\");\n const levels = headings.map((h) => {\n const lv = Number(h.attrs?.level ?? 2);\n return Number.isFinite(lv) ? lv : 2;\n });\n const hasH2orH3 = levels.some((lv) => lv === 2 || lv === 3);\n if (chars >= BODY_GOOD && !hasH2orH3) {\n return {\n id: \"heading-structure\",\n level: \"warn\",\n label,\n hint: \"긴 글인데 소제목(h2/h3)이 없어요. 구획을 나누면 독자·검색엔진 모두 구조를 파악하기 쉬워요.\",\n };\n }\n // 연속 heading 간 레벨 건너뜀 (h2 다음 h4 등) — 첫 heading 은 기준점으로 허용.\n for (let i = 1; i < levels.length; i += 1) {\n const prevLevel = levels[i - 1];\n const currLevel = levels[i];\n if (prevLevel === undefined || currLevel === undefined) continue;\n if (currLevel - prevLevel > 1) {\n return {\n id: \"heading-structure\",\n level: \"warn\",\n label,\n hint: `소제목 레벨을 건너뛰었어요 (h${prevLevel}→h${currLevel}). 단계적으로 내려가면 문서 구조가 명확해져요.`,\n };\n }\n }\n return {\n id: \"heading-structure\",\n level: \"good\",\n label,\n hint:\n headings.length > 0\n ? `소제목 ${headings.length}개 — 구조가 단계적으로 잘 잡혀 있어요.`\n : \"아직 짧은 글이라 소제목 없이도 괜찮아요.\",\n };\n}\n\n/** 5. 이미지 alt — 본문 image 노드 중 대체텍스트 빈 것. */\nfunction checkImageAlt(body: TiptapNode | null | undefined): SeoCheckResult {\n const label = \"이미지 대체텍스트\";\n const images = collectNodes(body, (n) => n.type === \"image\");\n const missing = images.filter((img) => {\n const alt = img.attrs?.alt;\n return typeof alt !== \"string\" || alt.trim() === \"\";\n });\n if (missing.length > 0) {\n return {\n id: \"image-alt\",\n level: \"warn\",\n label,\n hint: `이미지 ${missing.length}개에 대체텍스트(alt)가 없어요. 이미지 검색·스크린리더 접근성에 필요해요.`,\n };\n }\n return {\n id: \"image-alt\",\n level: \"good\",\n label,\n hint:\n images.length > 0\n ? `이미지 ${images.length}개 모두 대체텍스트가 있어요.`\n : \"본문에 이미지가 없어요 — 추가한다면 대체텍스트를 함께 넣어 주세요.\",\n };\n}\n\n/** 6. 대표 이미지 — SNS 공유 카드·검색 썸네일에 사용. */\nfunction checkFeaturedImage(input: SeoCheckInput): SeoCheckResult {\n const label = \"대표 이미지\";\n if (!input.hasFeaturedImage) {\n return {\n id: \"featured-image\",\n level: \"warn\",\n label,\n hint: \"대표 이미지가 없어요. SNS 공유 카드와 검색 썸네일에 쓰여요.\",\n };\n }\n return {\n id: \"featured-image\",\n level: \"good\",\n label,\n hint: \"대표 이미지가 설정되어 있어요.\",\n };\n}\n\n/** 7. 링크 — 본문 내 link mark 가 달린 텍스트 수. */\nfunction checkLinks(body: TiptapNode | null | undefined): SeoCheckResult {\n const label = \"링크\";\n const linked = collectNodes(body, (n) =>\n Array.isArray(n.marks) ? n.marks.some((m) => m.type === \"link\") : false,\n );\n if (linked.length === 0) {\n return {\n id: \"links\",\n level: \"warn\",\n label,\n // W4-5 PR 3 — 내부링크 제안 패널(에디터 사이드바)로 안내 (§3③).\n hint: \"본문에 링크가 없어요. 아래 '링크 제안' 패널에서 추천 글을 확인해보세요.\",\n };\n }\n return {\n id: \"links\",\n level: \"good\",\n label,\n hint: `본문에 링크 ${linked.length}개 — 관련 글·출처 연결이 되어 있어요.`,\n };\n}\n\n/** 8. slug 길이 — 패턴(소문자·한글·hyphen)은 이미 폼에서 강제되므로 길이만. */\nfunction checkSlug(input: SeoCheckInput): SeoCheckResult {\n const label = \"Slug 길이\";\n const len = input.slug.length;\n if (len > SLUG_MAX) {\n return {\n id: \"slug-length\",\n level: \"warn\",\n label,\n hint: `${len}자 — URL 이 길면 공유·가독성이 떨어져요 (${SLUG_MAX}자 이내 권장).`,\n };\n }\n return {\n id: \"slug-length\",\n level: \"good\",\n label,\n hint: \"URL 로 쓰기 적당한 길이예요.\",\n };\n}\n\n/** 9. 도입부 — 첫 문단이 비어 있거나 이미지/임베드로 시작하면 경고. */\nfunction checkIntro(body: TiptapNode | null | undefined): SeoCheckResult {\n const label = \"도입부\";\n const first = Array.isArray(body?.content) ? body.content[0] : undefined;\n if (!first) {\n return {\n id: \"intro\",\n level: \"warn\",\n label,\n hint: \"본문이 비어 있어요. 첫 문단에 글의 핵심 요약을 담아 주세요.\",\n };\n }\n if (first.type === \"image\" || first.type === \"embed\") {\n return {\n id: \"intro\",\n level: \"warn\",\n label,\n hint: \"본문이 이미지로 시작해요. 첫 문단에 핵심 요약을 먼저 쓰면 검색 발췌에 유리해요.\",\n };\n }\n if (extractText(first).trim() === \"\") {\n return {\n id: \"intro\",\n level: \"warn\",\n label,\n hint: \"첫 문단이 비어 있어요. 글의 핵심 요약으로 시작해 주세요.\",\n };\n }\n return {\n id: \"intro\",\n level: \"good\",\n label,\n hint: \"첫 문단이 텍스트로 시작해요 — 검색 발췌·미리보기에 좋아요.\",\n };\n}\n\n// ── 진입점 ───────────────────────────────────────────────────────────────────\n// ── 10. noindex + canonical 겸용 (테크니컬 SEO — 충돌 신호) ──────────────────\n\n/**\n * noindex 와 canonical 을 같은 페이지에 함께 쓰면 검색엔진에 모순 신호가\n * 간다 (\"색인하지 마라\" + \"이 URL 이 대표다\"). 둘 중 하나만 사용해야 한다.\n * 충돌이 없으면 결과 자체를 내지 않는다 (체크리스트 노이즈 방지).\n */\nfunction checkNoindexCanonicalConflict(\n input: SeoCheckInput,\n): SeoCheckResult | null {\n if (!input.noindex || !input.canonical?.trim()) return null;\n return {\n id: \"noindex-canonical\",\n level: \"bad\",\n label: \"noindex + canonical 충돌\",\n hint:\n \"noindex 와 canonical 을 함께 쓰면 검색엔진에 모순 신호가 갑니다. 색인 제외가 목적이면 canonical 을 비우고, 대표 URL 지정이 목적이면 noindex 를 끄세요.\",\n };\n}\n\n// ── 11. 일반(generic) 앵커 텍스트 ────────────────────────────────────────────\n\n/** \"여기를 클릭\" 류의 무의미 앵커 — 링크 대상이 무엇인지 전달하지 못한다. */\nconst GENERIC_ANCHOR_RE =\n /^(여기|여기를 클릭|클릭|클릭하세요|더 보기|더보기|자세히|자세히 보기|바로가기|링크|click here|here|more|read more)$/i;\n\nfunction checkAnchorText(body: TiptapNode | null | undefined): SeoCheckResult | null {\n const linked = collectNodes(body, (n) =>\n Array.isArray(n.marks) ? n.marks.some((m) => m.type === \"link\") : false,\n );\n if (linked.length === 0) return null; // 링크 부재는 checkLinks 가 담당.\n const generic = linked.filter((n) =>\n GENERIC_ANCHOR_RE.test((n.text ?? \"\").trim()),\n );\n if (generic.length === 0) {\n return {\n id: \"anchor-text\",\n level: \"good\",\n label: \"앵커 텍스트\",\n hint: \"링크 글자가 구체적이에요 — 검색엔진이 링크 대상의 주제를 이해할 수 있어요.\",\n };\n }\n return {\n id: \"anchor-text\",\n level: \"warn\",\n label: \"앵커 텍스트\",\n hint: `\"여기를 클릭\" 같은 일반 앵커 ${generic.length}개 — 링크 대상의 키워드가 들어간 구체적인 문구로 바꿔보세요.`,\n };\n}\n\n// ── 12. 비즈니스 프로필 완성도 (W4-5 PR 3 — seo-suite-rankmath-parity.md §3④) ──\n//\n// 로컬 SEO — 주소·전화·영업시간·좌표가 비어 있으면 Contact 표면의 LocalBusiness\n// JSON-LD 가 빈약해진다(§2-2 결정: Contact 표면 조건부 방출). 비즈니스 프로필은\n// 사이트 단위 설정(글마다 다르지 않음, publish-gate 는 안 걺 — 순수 함수 원칙상\n// fetch 는 호출자(admin 페이지) 책임, 여기는 완성도 플래그만 받는다.\nexport interface BusinessProfileCompleteness {\n hasAddress: boolean;\n hasTelephone: boolean;\n hasOpeningHours: boolean;\n hasGeo: boolean;\n}\n\n/**\n * 비즈니스 프로필 완성도 체크 — `profile` 미전달(사이트 없음 등)이면 결과 자체를\n * 내지 않는다(체크리스트 노이즈 방지, `checkNoindexCanonicalConflict` 와 동일 패턴).\n */\nexport function checkBusinessProfile(\n profile: BusinessProfileCompleteness | undefined,\n): SeoCheckResult | null {\n if (!profile) return null;\n const label = \"비즈니스 프로필\";\n const missing: string[] = [];\n if (!profile.hasAddress) missing.push(\"주소\");\n if (!profile.hasTelephone) missing.push(\"전화번호\");\n if (!profile.hasOpeningHours) missing.push(\"영업시간\");\n if (!profile.hasGeo) missing.push(\"좌표\");\n if (missing.length === 0) {\n return {\n id: \"business-profile\",\n level: \"good\",\n label,\n hint: \"사업장 정보가 채워져 있어요 — 로컬 SEO 구조화 데이터가 온전해요.\",\n };\n }\n return {\n id: \"business-profile\",\n level: \"warn\",\n label,\n hint: `${missing.join(\"·\")} 정보가 비어 있어요 — 설정 > 비즈니스 프로필에서 채우면 지역 검색 노출에 도움돼요.`,\n };\n}\n\nexport function runSeoChecks(\n input: SeoCheckInput,\n thresholds: SeoThresholds = DEFAULT_SEO_THRESHOLDS,\n): SeoCheckResult[] {\n const chars = countBodyChars(input.bodyJson);\n const results = [\n checkTitle(input, thresholds),\n checkDescription(input, thresholds),\n checkBodyLength(chars, thresholds),\n checkHeadings(input.bodyJson, chars),\n checkImageAlt(input.bodyJson),\n checkFeaturedImage(input),\n checkLinks(input.bodyJson),\n checkAnchorText(input.bodyJson),\n checkSlug(input),\n checkIntro(input.bodyJson),\n checkNoindexCanonicalConflict(input),\n ];\n return results.filter((r): r is SeoCheckResult => r !== null);\n}\n\n// ── 숫자 점수 (W4-5 PR 1 — seo-suite-rankmath-parity.md §3①) ────────────────\n//\n// runSeoChecks() 의 good/warn/bad 판정을 0~100 점으로 환산하는 가중합 래퍼.\n// good=만점·warn=½·bad=0. 가중치는 제목/설명/본문분량을 상향(검색결과 노출에\n// 직결) — 합계 100. `noindex-canonical` 은 상시 등장하는 등급형 체크가 아니라\n// 드문 설정 충돌 플래그라 점수에서 제외(가중치 미정의 id 는 합산에서 빠진다 —\n// 결과 배열에 없어도 없어도 점수식이 깨지지 않음, renormalize).\nexport const SEO_SCORE_WEIGHTS: Readonly<Record<string, number>> = {\n \"title-length\": 20,\n \"meta-description\": 15,\n \"body-length\": 20,\n \"heading-structure\": 10,\n \"image-alt\": 8,\n \"featured-image\": 5,\n links: 7,\n \"anchor-text\": 5,\n \"slug-length\": 5,\n intro: 5,\n};\n\nconst LEVEL_SCORE: Record<SeoCheckLevel, number> = { good: 1, warn: 0.5, bad: 0 };\n\n/**\n * 가중합 0~100 점. 가중치가 정의된 id 만 합산(lexicon 등 확장 항목은 자동 제외).\n * 결과 배열에 가중치 있는 항목이 하나도 없으면 0(평가 불가 상태를 0으로 fail-closed).\n */\nexport function computeSeoScore(\n results: readonly SeoCheckResult[],\n weights: Readonly<Record<string, number>> = SEO_SCORE_WEIGHTS,\n): number {\n let earned = 0;\n let total = 0;\n for (const r of results) {\n const w = weights[r.id];\n if (w === undefined) continue;\n total += w;\n earned += w * LEVEL_SCORE[r.level];\n }\n if (total <= 0) return 0;\n return Math.round((earned / total) * 100);\n}\n\n// ── readability (한국어 가독성) — W4-5 PR 1 §3① ─────────────────────────────\n//\n// 영어권 Flesch-Kincaid 류 음절/단어 기반 지표는 한글 조판에 맞지 않아 이식하지\n// 않는다(설계 §3① 명시). 대신 한글 문장 부호·블록 구조로 셀 수 있는 4개 체크:\n// 문장 길이(평균·최장)·문단 길이·소제목 간 본문 밀도·목록 활용. 별도 그룹으로\n// 노출하되 계약(SeoCheckResult, good/warn/bad)은 seo-checks 와 동일.\n\nexport interface ReadabilityThresholds {\n /** 문장 평균 길이(공백 제외 글자 수) 상한. */\n sentenceAvgMax: number;\n /** 문장 최장 길이 상한. */\n sentenceLongMax: number;\n /** 문단 평균 길이 상한. */\n paragraphAvgMax: number;\n /** 소제목 사이(또는 첫 소제목 이전) 최대 글자 수 상한. */\n headingDensityMax: number;\n /** 이 글자 수 이상인데 목록이 없으면 권고. */\n listRecommendBodyMin: number;\n}\n\nexport const DEFAULT_READABILITY_THRESHOLDS: ReadabilityThresholds = {\n sentenceAvgMax: 60,\n sentenceLongMax: 100,\n paragraphAvgMax: 300,\n headingDensityMax: 1200,\n listRecommendBodyMin: 800,\n};\n\n/** 문장 분리 — 한국어 종결부호(.!?…) + 개행 기준. 물음표/느낌표 뒤 공백도 경계. */\nfunction splitSentences(text: string): string[] {\n return text\n .split(/(?<=[.!?…])\\s+|\\n+/)\n .map((s) => s.trim())\n .filter((s) => s.length > 0);\n}\n\n/** 12. 문장 길이 — 평균·최장 (공백 제외). */\nfunction checkSentenceLength(\n body: TiptapNode | null | undefined,\n thresholds: ReadabilityThresholds,\n): SeoCheckResult {\n const label = \"문장 길이\";\n const sentences = splitSentences(extractText(body));\n if (sentences.length === 0) {\n return {\n id: \"readability-sentence-length\",\n level: \"good\",\n label,\n hint: \"본문이 비어 있어 아직 평가할 문장이 없어요.\",\n };\n }\n const lens = sentences.map((s) => s.replace(/\\s+/g, \"\").length);\n const avg = lens.reduce((a, b) => a + b, 0) / lens.length;\n const max = Math.max(...lens);\n if (avg <= thresholds.sentenceAvgMax && max <= thresholds.sentenceLongMax) {\n return {\n id: \"readability-sentence-length\",\n level: \"good\",\n label,\n hint: `평균 ${Math.round(avg)}자·최장 ${max}자 — 읽기 편한 문장 길이예요.`,\n };\n }\n return {\n id: \"readability-sentence-length\",\n level: \"warn\",\n label,\n hint: `평균 ${Math.round(avg)}자·최장 ${max}자 — 문장이 길어요. 한 문장에 하나의 정보만 담아 짧게 끊어보세요 (평균 ${thresholds.sentenceAvgMax}자 이하 권장).`,\n };\n}\n\n/** 13. 문단 길이 — 평균 (공백 제외). */\nfunction checkParagraphLength(\n body: TiptapNode | null | undefined,\n thresholds: ReadabilityThresholds,\n): SeoCheckResult {\n const label = \"문단 길이\";\n const paragraphLens = collectNodes(body, (n) => n.type === \"paragraph\")\n .map((p) => extractText(p).replace(/\\s+/g, \"\").length)\n .filter((n) => n > 0);\n if (paragraphLens.length === 0) {\n return {\n id: \"readability-paragraph-length\",\n level: \"good\",\n label,\n hint: \"아직 평가할 문단이 없어요.\",\n };\n }\n const avg = paragraphLens.reduce((a, b) => a + b, 0) / paragraphLens.length;\n const max = Math.max(...paragraphLens);\n if (avg <= thresholds.paragraphAvgMax) {\n return {\n id: \"readability-paragraph-length\",\n level: \"good\",\n label,\n hint: `평균 ${Math.round(avg)}자 — 문단이 적당히 나뉘어 있어요.`,\n };\n }\n return {\n id: \"readability-paragraph-length\",\n level: \"warn\",\n label,\n hint: `평균 ${Math.round(avg)}자(최장 ${max}자) — 문단이 길어요. ${thresholds.paragraphAvgMax}자 안팎에서 줄바꿈하면 읽기 편해져요.`,\n };\n}\n\n/** 14. 소제목 간 본문 밀도 — heading 사이(첫 heading 이전 포함) 최대 글자 수. */\nfunction checkHeadingDensity(\n body: TiptapNode | null | undefined,\n thresholds: ReadabilityThresholds,\n): SeoCheckResult {\n const label = \"소제목 간 밀도\";\n const topNodes = Array.isArray(body?.content) ? body.content : [];\n const segments: number[] = [];\n let current = 0;\n let sawHeading = false;\n for (const node of topNodes) {\n if (node.type === \"heading\") {\n sawHeading = true;\n segments.push(current);\n current = 0;\n continue;\n }\n current += extractText(node).replace(/\\s+/g, \"\").length;\n }\n segments.push(current);\n\n if (!sawHeading) {\n const total = countBodyChars(body);\n if (total <= thresholds.headingDensityMax) {\n return {\n id: \"readability-heading-density\",\n level: \"good\",\n label,\n hint: \"본문 길이가 짧아 소제목 없이도 밀도가 적당해요.\",\n };\n }\n return {\n id: \"readability-heading-density\",\n level: \"warn\",\n label,\n hint: `본문 ${total}자 — 소제목 없이 이어져 있어요. 섹션을 나누면 읽기 편해져요.`,\n };\n }\n\n const max = Math.max(...segments);\n if (max <= thresholds.headingDensityMax) {\n return {\n id: \"readability-heading-density\",\n level: \"good\",\n label,\n hint: `소제목 사이 최대 ${max}자 — 밀도가 적당해요.`,\n };\n }\n return {\n id: \"readability-heading-density\",\n level: \"warn\",\n label,\n hint: `소제목 사이 최대 ${max}자 — 한 구간이 너무 길어요. 소제목을 더 나눠보세요 (${thresholds.headingDensityMax}자 이하 권장).`,\n };\n}\n\n/** 15. 목록 활용 — bulletList/orderedList 사용 여부(긴 글 기준). */\nfunction checkListUsage(\n body: TiptapNode | null | undefined,\n thresholds: ReadabilityThresholds,\n): SeoCheckResult {\n const label = \"목록 활용\";\n const lists = collectNodes(\n body,\n (n) => n.type === \"bulletList\" || n.type === \"orderedList\",\n );\n if (lists.length > 0) {\n return {\n id: \"readability-list-usage\",\n level: \"good\",\n label,\n hint: `목록 ${lists.length}개 — 정보를 구조적으로 정리했어요.`,\n };\n }\n const chars = countBodyChars(body);\n if (chars < thresholds.listRecommendBodyMin) {\n return {\n id: \"readability-list-usage\",\n level: \"good\",\n label,\n hint: \"짧은 글이라 목록 없이도 괜찮아요.\",\n };\n }\n return {\n id: \"readability-list-usage\",\n level: \"warn\",\n label,\n hint: \"긴 글인데 목록(리스트)이 없어요. 나열할 내용은 목록으로 정리하면 스캔하기 쉬워져요.\",\n };\n}\n\n/** readability 4종 진입점 — 항목 순서 고정(문장→문단→밀도→목록). */\nexport function runReadabilityChecks(\n bodyJson: TiptapNode | null | undefined,\n thresholds: ReadabilityThresholds = DEFAULT_READABILITY_THRESHOLDS,\n): SeoCheckResult[] {\n return [\n checkSentenceLength(bodyJson, thresholds),\n checkParagraphLength(bodyJson, thresholds),\n checkHeadingDensity(bodyJson, thresholds),\n checkListUsage(bodyJson, thresholds),\n ];\n}\n"],"mappings":"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","names":[]}
@@ -1,79 +0,0 @@
1
- "use client";
2
-
3
- // src/templates/concerns-client.tsx
4
- import { useState } from "react";
5
- import { jsx, jsxs } from "react/jsx-runtime";
6
- function ConcernsFigureMap({
7
- figureImage,
8
- figureAlt,
9
- regions
10
- }) {
11
- const [selectedId, setSelectedId] = useState(regions[0]?.id ?? "");
12
- const selected = regions.find((region) => region.id === selectedId) ?? regions[0];
13
- if (!selected) return null;
14
- return /* @__PURE__ */ jsxs("div", { className: "rt-tpl-concerns-map-layout", children: [
15
- /* @__PURE__ */ jsxs("div", { className: "rt-tpl-concerns-figure-wrap", children: [
16
- /* @__PURE__ */ jsx(
17
- "img",
18
- {
19
- className: "rt-tpl-concerns-figure",
20
- src: figureImage,
21
- alt: figureAlt,
22
- loading: "lazy"
23
- }
24
- ),
25
- /* @__PURE__ */ jsx("div", { className: "rt-tpl-concerns-hotspots", "aria-label": "\uBB38\uC81C \uC601\uC5ED \uD56B\uC2A4\uD31F", children: regions.map((region) => {
26
- const active = region.id === selected.id;
27
- return /* @__PURE__ */ jsx(
28
- "button",
29
- {
30
- type: "button",
31
- className: "rt-tpl-concerns-hotspot",
32
- style: {
33
- left: `${region.x ?? 50}%`,
34
- top: `${region.y ?? 50}%`
35
- },
36
- "aria-label": region.label,
37
- "aria-pressed": active,
38
- "data-active": active ? "true" : "false",
39
- onClick: () => setSelectedId(region.id),
40
- onFocus: () => setSelectedId(region.id),
41
- children: /* @__PURE__ */ jsx("span", { children: region.label })
42
- },
43
- region.id
44
- );
45
- }) })
46
- ] }),
47
- /* @__PURE__ */ jsxs("div", { className: "rt-tpl-concerns-side", children: [
48
- /* @__PURE__ */ jsx("div", { className: "rt-tpl-concerns-button-list", "aria-label": "\uBB38\uC81C \uC601\uC5ED \uBAA9\uB85D", children: regions.map((region) => /* @__PURE__ */ jsx(
49
- "button",
50
- {
51
- type: "button",
52
- className: "rt-tpl-concerns-list-button",
53
- "data-active": region.id === selected.id ? "true" : "false",
54
- "aria-pressed": region.id === selected.id,
55
- onClick: () => setSelectedId(region.id),
56
- children: region.label
57
- },
58
- region.id
59
- )) }),
60
- /* @__PURE__ */ jsx(ConcernRegionPanel, { region: selected })
61
- ] })
62
- ] });
63
- }
64
- function ConcernRegionPanel({
65
- region
66
- }) {
67
- return /* @__PURE__ */ jsxs("article", { className: "rt-tpl-concerns-panel", children: [
68
- /* @__PURE__ */ jsx("p", { className: "rt-tpl-concerns-label", children: region.label }),
69
- /* @__PURE__ */ jsx("h3", { className: "rt-tpl-concerns-title", children: region.title }),
70
- /* @__PURE__ */ jsx("p", { className: "rt-tpl-concerns-desc", children: region.description }),
71
- region.items ? /* @__PURE__ */ jsx("ul", { className: "rt-tpl-concerns-items", children: region.items.map((item) => /* @__PURE__ */ jsx("li", { children: item }, item)) }) : null,
72
- region.href ? /* @__PURE__ */ jsx("a", { className: "rt-tpl-concerns-cta", href: region.href, children: region.ctaLabel ?? "\uC790\uC138\uD788 \uBCF4\uAE30" }) : null
73
- ] });
74
- }
75
-
76
- export {
77
- ConcernsFigureMap
78
- };
79
- //# sourceMappingURL=chunk-H46JUIHZ.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/templates/concerns-client.tsx"],"sourcesContent":["\"use client\";\n\nimport { useState } from \"react\";\n\nimport type { ConcernsSection } from \"./site-config.js\";\n\ntype ConcernRegion = ConcernsSection[\"props\"][\"regions\"][number];\n\nexport function ConcernsFigureMap({\n figureImage,\n figureAlt,\n regions,\n}: {\n readonly figureImage: string;\n readonly figureAlt: string;\n readonly regions: readonly ConcernRegion[];\n}) {\n const [selectedId, setSelectedId] = useState(regions[0]?.id ?? \"\");\n const selected =\n regions.find((region) => region.id === selectedId) ?? regions[0];\n\n if (!selected) return null;\n\n return (\n <div className=\"rt-tpl-concerns-map-layout\">\n <div className=\"rt-tpl-concerns-figure-wrap\">\n <img\n className=\"rt-tpl-concerns-figure\"\n src={figureImage}\n alt={figureAlt}\n loading=\"lazy\"\n />\n <div className=\"rt-tpl-concerns-hotspots\" aria-label=\"문제 영역 핫스팟\">\n {regions.map((region) => {\n const active = region.id === selected.id;\n return (\n <button\n key={region.id}\n type=\"button\"\n className=\"rt-tpl-concerns-hotspot\"\n style={{\n left: `${region.x ?? 50}%`,\n top: `${region.y ?? 50}%`,\n }}\n aria-label={region.label}\n aria-pressed={active}\n data-active={active ? \"true\" : \"false\"}\n onClick={() => setSelectedId(region.id)}\n onFocus={() => setSelectedId(region.id)}\n >\n <span>{region.label}</span>\n </button>\n );\n })}\n </div>\n </div>\n <div className=\"rt-tpl-concerns-side\">\n <div className=\"rt-tpl-concerns-button-list\" aria-label=\"문제 영역 목록\">\n {regions.map((region) => (\n <button\n key={region.id}\n type=\"button\"\n className=\"rt-tpl-concerns-list-button\"\n data-active={region.id === selected.id ? \"true\" : \"false\"}\n aria-pressed={region.id === selected.id}\n onClick={() => setSelectedId(region.id)}\n >\n {region.label}\n </button>\n ))}\n </div>\n <ConcernRegionPanel region={selected} />\n </div>\n </div>\n );\n}\n\nfunction ConcernRegionPanel({\n region,\n}: {\n readonly region: ConcernRegion;\n}) {\n return (\n <article className=\"rt-tpl-concerns-panel\">\n <p className=\"rt-tpl-concerns-label\">{region.label}</p>\n <h3 className=\"rt-tpl-concerns-title\">{region.title}</h3>\n <p className=\"rt-tpl-concerns-desc\">{region.description}</p>\n {region.items ? (\n <ul className=\"rt-tpl-concerns-items\">\n {region.items.map((item) => (\n <li key={item}>{item}</li>\n ))}\n </ul>\n ) : null}\n {region.href ? (\n <a className=\"rt-tpl-concerns-cta\" href={region.href}>\n {region.ctaLabel ?? \"자세히 보기\"}\n </a>\n ) : null}\n </article>\n );\n}\n"],"mappings":";AAEA,SAAS,gBAAgB;AAuBnB,SACE,KADF;AAjBC,SAAS,kBAAkB;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AACF,GAIG;AACD,QAAM,CAAC,YAAY,aAAa,IAAI,SAAS,QAAQ,CAAC,GAAG,MAAM,EAAE;AACjE,QAAM,WACJ,QAAQ,KAAK,CAAC,WAAW,OAAO,OAAO,UAAU,KAAK,QAAQ,CAAC;AAEjE,MAAI,CAAC,SAAU,QAAO;AAEtB,SACE,qBAAC,SAAI,WAAU,8BACb;AAAA,yBAAC,SAAI,WAAU,+BACb;AAAA;AAAA,QAAC;AAAA;AAAA,UACC,WAAU;AAAA,UACV,KAAK;AAAA,UACL,KAAK;AAAA,UACL,SAAQ;AAAA;AAAA,MACV;AAAA,MACA,oBAAC,SAAI,WAAU,4BAA2B,cAAW,gDAClD,kBAAQ,IAAI,CAAC,WAAW;AACvB,cAAM,SAAS,OAAO,OAAO,SAAS;AACtC,eACE;AAAA,UAAC;AAAA;AAAA,YAEC,MAAK;AAAA,YACL,WAAU;AAAA,YACV,OAAO;AAAA,cACL,MAAM,GAAG,OAAO,KAAK,EAAE;AAAA,cACvB,KAAK,GAAG,OAAO,KAAK,EAAE;AAAA,YACxB;AAAA,YACA,cAAY,OAAO;AAAA,YACnB,gBAAc;AAAA,YACd,eAAa,SAAS,SAAS;AAAA,YAC/B,SAAS,MAAM,cAAc,OAAO,EAAE;AAAA,YACtC,SAAS,MAAM,cAAc,OAAO,EAAE;AAAA,YAEtC,8BAAC,UAAM,iBAAO,OAAM;AAAA;AAAA,UAbf,OAAO;AAAA,QAcd;AAAA,MAEJ,CAAC,GACH;AAAA,OACF;AAAA,IACA,qBAAC,SAAI,WAAU,wBACb;AAAA,0BAAC,SAAI,WAAU,+BAA8B,cAAW,0CACrD,kBAAQ,IAAI,CAAC,WACZ;AAAA,QAAC;AAAA;AAAA,UAEC,MAAK;AAAA,UACL,WAAU;AAAA,UACV,eAAa,OAAO,OAAO,SAAS,KAAK,SAAS;AAAA,UAClD,gBAAc,OAAO,OAAO,SAAS;AAAA,UACrC,SAAS,MAAM,cAAc,OAAO,EAAE;AAAA,UAErC,iBAAO;AAAA;AAAA,QAPH,OAAO;AAAA,MAQd,CACD,GACH;AAAA,MACA,oBAAC,sBAAmB,QAAQ,UAAU;AAAA,OACxC;AAAA,KACF;AAEJ;AAEA,SAAS,mBAAmB;AAAA,EAC1B;AACF,GAEG;AACD,SACE,qBAAC,aAAQ,WAAU,yBACjB;AAAA,wBAAC,OAAE,WAAU,yBAAyB,iBAAO,OAAM;AAAA,IACnD,oBAAC,QAAG,WAAU,yBAAyB,iBAAO,OAAM;AAAA,IACpD,oBAAC,OAAE,WAAU,wBAAwB,iBAAO,aAAY;AAAA,IACvD,OAAO,QACN,oBAAC,QAAG,WAAU,yBACX,iBAAO,MAAM,IAAI,CAAC,SACjB,oBAAC,QAAe,kBAAP,IAAY,CACtB,GACH,IACE;AAAA,IACH,OAAO,OACN,oBAAC,OAAE,WAAU,uBAAsB,MAAM,OAAO,MAC7C,iBAAO,YAAY,mCACtB,IACE;AAAA,KACN;AAEJ;","names":[]}
@@ -1,53 +0,0 @@
1
- "use client";
2
-
3
- // src/templates/spaces-client.tsx
4
- import { useState } from "react";
5
- import { jsx, jsxs } from "react/jsx-runtime";
6
- function SpacesStage({
7
- spaces
8
- }) {
9
- const [selectedId, setSelectedId] = useState(spaces[0]?.id ?? "");
10
- const selected = spaces.find((space) => space.id === selectedId) ?? spaces[0];
11
- if (!selected) return null;
12
- return /* @__PURE__ */ jsxs("div", { className: "rt-tpl-spaces-stage", children: [
13
- /* @__PURE__ */ jsxs("figure", { className: "rt-tpl-spaces-main", children: [
14
- /* @__PURE__ */ jsx(
15
- "img",
16
- {
17
- className: "rt-tpl-spaces-main-img",
18
- src: selected.imageSrc,
19
- alt: selected.imageAlt,
20
- loading: "lazy"
21
- }
22
- ),
23
- /* @__PURE__ */ jsxs("figcaption", { className: "rt-tpl-spaces-caption", children: [
24
- selected.kicker ? /* @__PURE__ */ jsx("span", { className: "rt-tpl-spaces-kicker", children: selected.kicker }) : null,
25
- /* @__PURE__ */ jsx("span", { className: "rt-tpl-spaces-title", children: selected.title ?? selected.label }),
26
- selected.description ? /* @__PURE__ */ jsx("span", { className: "rt-tpl-spaces-desc", children: selected.description }) : null
27
- ] })
28
- ] }),
29
- /* @__PURE__ */ jsx("div", { className: "rt-tpl-spaces-rail", "aria-label": "\uACF5\uAC04 \uC120\uD0DD", children: spaces.map((space) => {
30
- const active = space.id === selected.id;
31
- return /* @__PURE__ */ jsxs(
32
- "button",
33
- {
34
- type: "button",
35
- className: "rt-tpl-spaces-thumb",
36
- "data-active": active ? "true" : "false",
37
- "aria-pressed": active,
38
- onClick: () => setSelectedId(space.id),
39
- children: [
40
- /* @__PURE__ */ jsx("img", { src: space.imageSrc, alt: "", loading: "lazy" }),
41
- /* @__PURE__ */ jsx("span", { children: space.label })
42
- ]
43
- },
44
- space.id
45
- );
46
- }) })
47
- ] });
48
- }
49
-
50
- export {
51
- SpacesStage
52
- };
53
- //# sourceMappingURL=chunk-K62RR4UQ.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/templates/spaces-client.tsx"],"sourcesContent":["\"use client\";\n\nimport { useState } from \"react\";\n\nimport type { SpacesSection } from \"./site-config.js\";\n\ntype SpaceItem = SpacesSection[\"props\"][\"spaces\"][number];\n\nexport function SpacesStage({\n spaces,\n}: {\n readonly spaces: readonly SpaceItem[];\n}) {\n const [selectedId, setSelectedId] = useState(spaces[0]?.id ?? \"\");\n const selected = spaces.find((space) => space.id === selectedId) ?? spaces[0];\n\n if (!selected) return null;\n\n return (\n <div className=\"rt-tpl-spaces-stage\">\n <figure className=\"rt-tpl-spaces-main\">\n <img\n className=\"rt-tpl-spaces-main-img\"\n src={selected.imageSrc}\n alt={selected.imageAlt}\n loading=\"lazy\"\n />\n <figcaption className=\"rt-tpl-spaces-caption\">\n {selected.kicker ? (\n <span className=\"rt-tpl-spaces-kicker\">{selected.kicker}</span>\n ) : null}\n <span className=\"rt-tpl-spaces-title\">\n {selected.title ?? selected.label}\n </span>\n {selected.description ? (\n <span className=\"rt-tpl-spaces-desc\">{selected.description}</span>\n ) : null}\n </figcaption>\n </figure>\n <div className=\"rt-tpl-spaces-rail\" aria-label=\"공간 선택\">\n {spaces.map((space) => {\n const active = space.id === selected.id;\n return (\n <button\n key={space.id}\n type=\"button\"\n className=\"rt-tpl-spaces-thumb\"\n data-active={active ? \"true\" : \"false\"}\n aria-pressed={active}\n onClick={() => setSelectedId(space.id)}\n >\n <img src={space.imageSrc} alt=\"\" loading=\"lazy\" />\n <span>{space.label}</span>\n </button>\n );\n })}\n </div>\n </div>\n );\n}\n"],"mappings":";AAEA,SAAS,gBAAgB;AAmBjB,cAMA,YANA;AAbD,SAAS,YAAY;AAAA,EAC1B;AACF,GAEG;AACD,QAAM,CAAC,YAAY,aAAa,IAAI,SAAS,OAAO,CAAC,GAAG,MAAM,EAAE;AAChE,QAAM,WAAW,OAAO,KAAK,CAAC,UAAU,MAAM,OAAO,UAAU,KAAK,OAAO,CAAC;AAE5E,MAAI,CAAC,SAAU,QAAO;AAEtB,SACE,qBAAC,SAAI,WAAU,uBACb;AAAA,yBAAC,YAAO,WAAU,sBAChB;AAAA;AAAA,QAAC;AAAA;AAAA,UACC,WAAU;AAAA,UACV,KAAK,SAAS;AAAA,UACd,KAAK,SAAS;AAAA,UACd,SAAQ;AAAA;AAAA,MACV;AAAA,MACA,qBAAC,gBAAW,WAAU,yBACnB;AAAA,iBAAS,SACR,oBAAC,UAAK,WAAU,wBAAwB,mBAAS,QAAO,IACtD;AAAA,QACJ,oBAAC,UAAK,WAAU,uBACb,mBAAS,SAAS,SAAS,OAC9B;AAAA,QACC,SAAS,cACR,oBAAC,UAAK,WAAU,sBAAsB,mBAAS,aAAY,IACzD;AAAA,SACN;AAAA,OACF;AAAA,IACA,oBAAC,SAAI,WAAU,sBAAqB,cAAW,6BAC5C,iBAAO,IAAI,CAAC,UAAU;AACrB,YAAM,SAAS,MAAM,OAAO,SAAS;AACrC,aACE;AAAA,QAAC;AAAA;AAAA,UAEC,MAAK;AAAA,UACL,WAAU;AAAA,UACV,eAAa,SAAS,SAAS;AAAA,UAC/B,gBAAc;AAAA,UACd,SAAS,MAAM,cAAc,MAAM,EAAE;AAAA,UAErC;AAAA,gCAAC,SAAI,KAAK,MAAM,UAAU,KAAI,IAAG,SAAQ,QAAO;AAAA,YAChD,oBAAC,UAAM,gBAAM,OAAM;AAAA;AAAA;AAAA,QARd,MAAM;AAAA,MASb;AAAA,IAEJ,CAAC,GACH;AAAA,KACF;AAEJ;","names":[]}
@@ -1,64 +0,0 @@
1
- "use client";
2
-
3
- // src/templates/contact-map-client.tsx
4
- import { useState } from "react";
5
- import { jsx, jsxs } from "react/jsx-runtime";
6
- function ContactMapClickToLoad({
7
- embedUrl,
8
- externalUrl,
9
- facadeImageSrc,
10
- facadeImageAlt
11
- }) {
12
- const [loaded, setLoaded] = useState(false);
13
- if (loaded) {
14
- return /* @__PURE__ */ jsx("div", { className: "rt-tpl-contact-map", children: /* @__PURE__ */ jsx(
15
- "iframe",
16
- {
17
- src: embedUrl,
18
- title: "\uC624\uC2DC\uB294\uAE38 \uC9C0\uB3C4",
19
- loading: "lazy",
20
- referrerPolicy: "no-referrer-when-downgrade"
21
- }
22
- ) });
23
- }
24
- return (
25
- // `rt-tpl-contact-map` 클래스를 함께 붙여 aspect-ratio/border 등 variant별
26
- // 박스 규칙(channel-cards/visit-band 오버라이드 포함, templates.css)을
27
- // 그대로 물려받는다 — 클릭 후 iframe 전환 시 레이아웃 시프트가 없다.
28
- /* @__PURE__ */ jsxs("div", { className: "rt-tpl-contact-map rt-tpl-contact-map-click", children: [
29
- facadeImageSrc ? /* @__PURE__ */ jsx(
30
- "img",
31
- {
32
- className: "rt-tpl-contact-map-click-bg",
33
- src: facadeImageSrc,
34
- alt: facadeImageAlt ?? "",
35
- loading: "lazy"
36
- }
37
- ) : null,
38
- /* @__PURE__ */ jsx(
39
- "button",
40
- {
41
- type: "button",
42
- className: "rt-tpl-contact-map-click-btn",
43
- onClick: () => setLoaded(true),
44
- children: "\uC9C0\uB3C4 \uC5F4\uAE30"
45
- }
46
- ),
47
- externalUrl ? /* @__PURE__ */ jsx(
48
- "a",
49
- {
50
- className: "rt-tpl-contact-map-click-external",
51
- href: externalUrl,
52
- target: "_blank",
53
- rel: "noopener noreferrer",
54
- children: "\uC0C8 \uCC3D\uC5D0\uC11C \uD06C\uAC8C \uBCF4\uAE30"
55
- }
56
- ) : null
57
- ] })
58
- );
59
- }
60
-
61
- export {
62
- ContactMapClickToLoad
63
- };
64
- //# sourceMappingURL=chunk-LEHHSI6G.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/templates/contact-map-client.tsx"],"sourcesContent":["\"use client\";\n\n/**\n * `contact` 지도 블록의 \"클릭-투-로드\" 모드(ADR-0086 P2 D3) — chambarun\n * mockup `.map-facade`/`site.js` \"지도: 클릭-투-로드\" 이식\n * (`clients/chambarun/mockup/assets/site.js` \"지도: 클릭-투-로드\" 블록).\n *\n * 원본은 DOM 을 직접 갈아끼우는 vanilla 스크립트(`replaceWith`)였다 — 여기서는\n * React state 로 같은 효과(정적 프리뷰 → 클릭 시 1회 iframe 마운트)를 낸다.\n * `ContactMapBlock`(sections.tsx, RSC)이 embedUrl 이 allowlist 를 통과했을\n * 때만 이 컴포넌트를 렌더하므로, 이 컴포넌트 자신은 allowlist 재검증을 하지\n * 않는다(호출자 계약).\n *\n * 빌드: tsup 독립 entry(`contact-map-client`, tsup.config.ts) — \"use client\"\n * 경계가 `templates.js`(서버 전용)로 새지 않게 격리(hero-carousel-client 류와\n * 동일 계약).\n */\nimport { useState, type ReactElement } from \"react\";\n\nexport interface ContactMapClickToLoadProps {\n readonly embedUrl: string;\n readonly externalUrl: string | undefined;\n readonly facadeImageSrc: string | undefined;\n readonly facadeImageAlt: string | undefined;\n}\n\nexport function ContactMapClickToLoad({\n embedUrl,\n externalUrl,\n facadeImageSrc,\n facadeImageAlt,\n}: ContactMapClickToLoadProps): ReactElement {\n const [loaded, setLoaded] = useState(false);\n\n if (loaded) {\n return (\n <div className=\"rt-tpl-contact-map\">\n <iframe\n src={embedUrl}\n title=\"오시는길 지도\"\n loading=\"lazy\"\n referrerPolicy=\"no-referrer-when-downgrade\"\n />\n </div>\n );\n }\n\n return (\n // `rt-tpl-contact-map` 클래스를 함께 붙여 aspect-ratio/border 등 variant별\n // 박스 규칙(channel-cards/visit-band 오버라이드 포함, templates.css)을\n // 그대로 물려받는다 — 클릭 후 iframe 전환 시 레이아웃 시프트가 없다.\n <div className=\"rt-tpl-contact-map rt-tpl-contact-map-click\">\n {facadeImageSrc ? (\n <img\n className=\"rt-tpl-contact-map-click-bg\"\n src={facadeImageSrc}\n alt={facadeImageAlt ?? \"\"}\n loading=\"lazy\"\n />\n ) : null}\n <button\n type=\"button\"\n className=\"rt-tpl-contact-map-click-btn\"\n onClick={() => setLoaded(true)}\n >\n 지도 열기\n </button>\n {externalUrl ? (\n <a\n className=\"rt-tpl-contact-map-click-external\"\n href={externalUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n >\n 새 창에서 크게 보기\n </a>\n ) : null}\n </div>\n );\n}\n"],"mappings":";AAiBA,SAAS,gBAAmC;AAoBpC,cAcJ,YAdI;AAXD,SAAS,sBAAsB;AAAA,EACpC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAA6C;AAC3C,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,KAAK;AAE1C,MAAI,QAAQ;AACV,WACE,oBAAC,SAAI,WAAU,sBACb;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL,OAAM;AAAA,QACN,SAAQ;AAAA,QACR,gBAAe;AAAA;AAAA,IACjB,GACF;AAAA,EAEJ;AAEA;AAAA;AAAA;AAAA;AAAA,IAIE,qBAAC,SAAI,WAAU,+CACZ;AAAA,uBACC;AAAA,QAAC;AAAA;AAAA,UACC,WAAU;AAAA,UACV,KAAK;AAAA,UACL,KAAK,kBAAkB;AAAA,UACvB,SAAQ;AAAA;AAAA,MACV,IACE;AAAA,MACJ;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,WAAU;AAAA,UACV,SAAS,MAAM,UAAU,IAAI;AAAA,UAC9B;AAAA;AAAA,MAED;AAAA,MACC,cACC;AAAA,QAAC;AAAA;AAAA,UACC,WAAU;AAAA,UACV,MAAM;AAAA,UACN,QAAO;AAAA,UACP,KAAI;AAAA,UACL;AAAA;AAAA,MAED,IACE;AAAA,OACN;AAAA;AAEJ;","names":[]}