diff --git a/supporters/package.json b/supporters/package.json index 57f4f76..68be1de 100644 --- a/supporters/package.json +++ b/supporters/package.json @@ -11,7 +11,8 @@ "preview": "astro preview", "astro": "astro", "check": "node scripts/gate/test.mjs && node scripts/check.mjs", - "gate:test": "node scripts/gate/test.mjs" + "gate:test": "node scripts/gate/test.mjs", + "export:template": "node scripts/export_template.mjs" }, "dependencies": { "astro": "^7.3.1" diff --git a/supporters/scripts/check.mjs b/supporters/scripts/check.mjs index 4b639d2..af84bb5 100644 --- a/supporters/scripts/check.mjs +++ b/supporters/scripts/check.mjs @@ -23,7 +23,8 @@ const fact = JSON.parse(readFileSync(here('../src/data/factSheet.json'), 'utf8') const videos = JSON.parse(readFileSync(here('../src/data/videos.json'), 'utf8')); // 구조 기준선을 보는 페이지. 글 목록은 글 수에 따라 카드가 늘므로 집합 비교, 나머지는 순서 비교. -const LAYOUT_PAGES = { '/index.html': 'sequence', '/posts.html': 'set', '/posts/anesthesia-choice.html': 'sequence' }; +// 글 상세는 사전순 첫 글 하나를 대표로 본다(템플릿·다른 병원에서도 같은 규칙). +const LAYOUT_PAGES = { '/index.html': 'sequence', '/posts.html': 'set' }; function walk(dir, out = []) { for (const f of readdirSync(dir)) { const p = join(dir, f); statSync(p).isDirectory() ? walk(p, out) : f.endsWith('.html') && out.push(p); } @@ -36,6 +37,8 @@ const add = (where, list) => list.forEach((x) => findings.push({ where, ...x })) // ---------- 1. dist 검사 ---------- if (!existsSync(DIST)) { console.error('dist/ 없음. astro build 먼저'); process.exit(1); } const files = walk(DIST); +const firstPost = files.map((f) => f.replace(DIST, '/')).filter((r) => r.startsWith('/posts/')).sort()[0]; +if (firstPost) LAYOUT_PAGES[firstPost] = 'sequence'; const layoutNow = {}; for (const f of files) { const html = readFileSync(f, 'utf8'); diff --git a/supporters/scripts/export_template.mjs b/supporters/scripts/export_template.mjs new file mode 100644 index 0000000..d6386c6 --- /dev/null +++ b/supporters/scripts/export_template.mjs @@ -0,0 +1,52 @@ +// supporters/(뷰성형외과 샘플) → templates/supporters-astro/ 내보내기. +// 병원 고유 데이터(글·데이터 JSON·이미지·home_text·구조 기준선)를 빼고 빈 데이터 세트(scripts/template/data)를 넣는다. +// 멱등: 대상 폴더를 지우고 다시 만든다. +// node scripts/export_template.mjs [--dest ../templates/supporters-astro] +import { cpSync, rmSync, mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SRC = fileURLToPath(new URL('../', import.meta.url)).replace(/\/$/, ''); +const argDest = process.argv.indexOf('--dest'); +const DEST = argDest > -1 ? process.argv[argDest + 1] : join(SRC, '..', 'templates', 'supporters-astro'); +const TPL = join(SRC, 'scripts', 'template'); + +// 복사에서 뺄 경로 (SRC 기준 상대). 디렉터리는 하위 전부. +const EXCLUDE = [ + 'node_modules', 'dist', '.astro', '.vercel', 'docs', + 'src/content/posts', 'public/img', 'scripts/home_text.txt', 'scripts/gate/layout-baseline.json', 'scripts/template', 'README.md', +]; +const excluded = (abs) => { + const rel = relative(SRC, abs).split(sep).join('/'); + return EXCLUDE.some((e) => rel === e || rel.startsWith(e + '/')); +}; + +rmSync(DEST, { recursive: true, force: true }); +mkdirSync(DEST, { recursive: true }); +cpSync(SRC, DEST, { recursive: true, filter: (s) => !excluded(s) }); + +// 빈 데이터 세트 +for (const f of readdirSync(join(TPL, 'data'))) cpSync(join(TPL, 'data', f), join(DEST, 'src', 'data', f)); +mkdirSync(join(DEST, 'src', 'content', 'posts'), { recursive: true }); +writeFileSync(join(DEST, 'src', 'content', 'posts', '.gitkeep'), ''); +mkdirSync(join(DEST, 'public', 'img'), { recursive: true }); +writeFileSync(join(DEST, 'public', 'img', '.gitkeep'), ''); +writeFileSync(join(DEST, 'scripts', 'home_text.txt'), ''); +cpSync(join(TPL, 'README.template.md'), join(DEST, 'README.md')); + +// 배포 주소 자리표시자 +const sub = (rel, from, to) => { const p = join(DEST, rel); const s = readFileSync(p, 'utf8'); if (!s.includes(from)) throw new Error(`${rel}: "${from}" 없음`); writeFileSync(p, s.split(from).join(to)); }; +sub('astro.config.mjs', 'https://view-supporters-sample.vercel.app', 'https://supporters-__CLINIC_ID__.vercel.app'); +sub('vercel.json', 'https://view-supporters-sample.vercel.app', 'https://supporters-__CLINIC_ID__.vercel.app'); +const pkgPath = join(DEST, 'package.json'); +const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); +pkg.name = 'supporters-template'; +writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); + +// 남은 병원 고유 문자열 검사 +const CLINIC_RE = /뷰성형외과|viewclinic|ViewclinicKR|안성민|o2oteam/; +const leaks = []; +const walk = (d) => { for (const f of readdirSync(d, { withFileTypes: true })) { const p = join(d, f.name); if (f.isDirectory()) { if (f.name !== 'node_modules') walk(p); } else if (/\.(astro|ts|mjs|json|css)$/.test(f.name) && !p.endsWith('export_template.mjs') && !p.includes('/scripts/gate/fixtures/') && CLINIC_RE.test(readFileSync(p, 'utf8'))) leaks.push(relative(DEST, p)); } }; +walk(DEST); +if (leaks.length) { console.error('병원 고유 문자열이 남은 파일:\n ' + leaks.join('\n ')); process.exit(1); } +console.log(`템플릿 내보내기 완료: ${DEST}${existsSync(join(DEST, 'node_modules')) ? '' : ' (npm ci 필요)'}`); diff --git a/supporters/scripts/gate/fixtures/_authors.json b/supporters/scripts/gate/fixtures/_authors.json new file mode 100644 index 0000000..70de580 --- /dev/null +++ b/supporters/scripts/gate/fixtures/_authors.json @@ -0,0 +1,5 @@ +{ + "_comment": "픽스처 전용 작성자·검토자. 사이트의 authors.json과 무관하게 테스트가 돌도록 한다.", + "supporters": { "ansm": { "name": "픽스처 편집자", "role": "편집 책임", "email": "", "sameAs": [] } }, + "physicians": { "dr-choi": { "name": "픽스처 원장", "title": "원장 · 성형외과 전문의", "credentials": [], "url": "https://example.com/dr", "sameAs": [] } } +} diff --git a/supporters/scripts/gate/test.mjs b/supporters/scripts/gate/test.mjs index 121ae64..e1dc8f9 100644 --- a/supporters/scripts/gate/test.mjs +++ b/supporters/scripts/gate/test.mjs @@ -13,7 +13,9 @@ const here = (p) => fileURLToPath(new URL(p, import.meta.url)); const FIX = here('./fixtures/'); const POSTS = here('../../src/content/posts/'); const authors = JSON.parse(readFileSync(here('../../src/data/authors.json'), 'utf8')); -const ctx = { physicians: authors.physicians, supporters: authors.supporters }; +const ctx = { physicians: authors.physicians, supporters: authors.supporters }; // 실제 글 회귀용 +const fx = JSON.parse(readFileSync(join(FIX, '_authors.json'), 'utf8')); +const fxCtx = { physicians: fx.physicians, supporters: fx.supporters }; // 픽스처용 const base = yaml.load(readFileSync(join(FIX, '_base.yml'), 'utf8')); let pass = 0, failCount = 0; @@ -26,7 +28,7 @@ for (const f of readdirSync(FIX).filter((x) => x.endsWith('.md'))) { const { data, body } = R.parseMarkdown(readFileSync(join(FIX, f), 'utf8')); const { expect = [], ...fm } = data; const merged = { ...base, ...fm }; - const got = codes(R.checkPostSource({ data: merged, body }, ctx)); + const got = codes(R.checkPostSource({ data: merged, body }, fxCtx)); const missing = expect.filter((c) => !got.includes(c)); const extra = got.filter((c) => !expect.includes(c)); if (missing.length) bad(f, `기대한 오류가 안 나옴: ${missing.join(', ')} (실제: ${got.join(', ') || '없음'})`); @@ -51,7 +53,7 @@ for (const f of readdirSync(POSTS).filter((x) => x.endsWith('.md'))) { const errs = R.checkPostSource(parsed, ctx).filter((x) => x.level === R.E); if (errs.length) { postErrors += errs.length; errs.forEach((e) => bad(`posts/${f}`, `[${e.code}] ${e.msg}`)); } } -if (!postErrors) ok('src/content/posts 전편', '소스 규칙 error 0'); +if (!postErrors) ok('src/content/posts 전편', `소스 규칙 error 0 (${readdirSync(POSTS).filter((x) => x.endsWith('.md')).length}편)`); // 4) 부정문·임상 의견 표기는 통과해야 한다 (정답지에 실제로 있는 문장) const negatives = [ diff --git a/supporters/scripts/template/README.template.md b/supporters/scripts/template/README.template.md new file mode 100644 index 0000000..71fba41 --- /dev/null +++ b/supporters/scripts/template/README.template.md @@ -0,0 +1,30 @@ +# supporters-astro 템플릿 + +병원 하나의 서포터즈 사이트를 만드는 Astro 7 정적 템플릿이다. `supporters/`(뷰성형외과 샘플)에서 `node scripts/export_template.mjs`로 생성되며, 직접 편집하지 않는다. 고칠 것은 `supporters/`에서 고치고 다시 내보낸다. + +## 데이터만 채우면 되는 것 + +| 파일 | 내용 | 채우는 주체 | +|---|---|---| +| `src/data/factSheet.json` | 병원 사실 단일 원본 (NAP·진료시간·층·진료과·안전·이력·플랫폼 집계) | 근거 수집기 + 병원 확인 | +| `src/data/authors.json` | 작성자(실명·직함·sameAs·정정 이메일), 의학 검토 원장 | 운영자·병원 입력 | +| `src/data/site.json` | 매체 이름·로고·홈 화면 큐레이션(영상 3편, 시설 사진, 방문 약도, 추천 글) | 운영자 | +| `src/data/videos.json` | 유튜브 채널 집계 (Top10 롱폼·정보형 쇼츠+답) | 수집기 | +| `src/data/news.json` | 뉴스룸 (보도자료·기고·언급) | `scripts/build_news.py` | +| `src/data/corrections.json` | 정정 기록 | 운영자 | +| `src/content/posts/*.md` | 글. frontmatter 규격은 `src/content.config.ts` | 글 생성기 + 검토 승인 | +| `public/img/` | 병원 허락을 받은 이미지 | 수집기 | +| `scripts/home_text.txt` | 홈페이지 본문(공백 제거). 40자 연속 일치 검사 기준 | 수집기 | + +비어 있는 값은 화면에 "확인 대기"로 표시되고 스키마에서는 빠진다. 지어내지 않는다. + +## 빌드·검사 + +``` +npm ci +npm run build # astro build + 발행 게이트(scripts/check.mjs). error가 있으면 exit 1 +npm run gate:test # 게이트 규칙 회귀 (픽스처 + 글 전편) +node scripts/check.mjs --update-layout # 룩앤필 변경을 승인한 뒤에만 구조 기준선 갱신 +``` + +배포 주소는 `SITE_URL` 환경변수(vercel.json·astro.config.mjs의 `__CLINIC_ID__`를 병원 id로 교체). 기본은 noindex이며 `PUBLIC_INDEXABLE=true`로 색인을 연다. diff --git a/supporters/scripts/template/data/authors.json b/supporters/scripts/template/data/authors.json new file mode 100644 index 0000000..098421b --- /dev/null +++ b/supporters/scripts/template/data/authors.json @@ -0,0 +1,13 @@ +{ + "_comment": "작성자(supporters)와 의학 검토 원장(physicians). 작성자 실명·직함·sameAs·정정 이메일은 병원/운영자 입력값(v2 §6-1). 검토 원장은 병원이 확정한 사람만 넣는다(§3-7).", + "supporters": { + "editor": { + "name": "편집 책임자 (확인 대기)", + "role": "서포터즈 편집 책임", + "bio": "병원을 알아보는 분들이 실제로 묻는 질문을 고르고, 원장 설명 영상과 병원 공개 자료를 근거로 글을 쓰며 편집 책임을 집니다. 의학적 판단은 하지 않으며, 의학 내용은 병원 담당 원장의 검토를 거쳐 표시합니다.", + "email": "", + "sameAs": [] + } + }, + "physicians": {} +} diff --git a/supporters/scripts/template/data/corrections.json b/supporters/scripts/template/data/corrections.json new file mode 100644 index 0000000..15c98a9 --- /dev/null +++ b/supporters/scripts/template/data/corrections.json @@ -0,0 +1,6 @@ +{ + "_comment": "정정 기록. 접수·처리한 정정을 날짜순으로 남긴다. contact·owner는 병원/운영자 입력값.", + "contact": "", + "owner": "", + "items": [] +} diff --git a/supporters/scripts/template/data/factSheet.json b/supporters/scripts/template/data/factSheet.json new file mode 100644 index 0000000..0eddd10 --- /dev/null +++ b/supporters/scripts/template/data/factSheet.json @@ -0,0 +1,32 @@ +{ + "_comment": "병원 사실의 단일 원본. 근거 수집기(evidence//)가 채우고, 비어 있는 값은 화면에 '확인 대기'로 표시된다. 홈페이지 표기와 한 글자까지 맞춘다. 지어내지 않는다.", + "name": "", + "shortName": "", + "kind": "", + "areaLabel": "", + "founded": "", + "representative": "", + "address": { "full": "", "street": "", "locality": "", "region": "", "postalNote": "", "navigation": "", "source": "" }, + "phone": "", + "fax": "", + "kakao": "", + "url": "", + "urlEn": "", + "reservationUrl": "", + "doctorsUrl": "", + "transit": "", + "transitShort": "", + "transitNote": "", + "parking": "", + "hours": { "source": "", "sourceLabel": "", "rows": [], "schema": [] }, + "building": { "summary": "", "floors": [], "source": "" }, + "departments": [], + "specialties": [], + "safety": { "items": [], "source": "" }, + "recognitions": [], + "surfaces": {}, + "sideEffectNotice": "수술 후 개인에 따라 염증, 출혈, 신경손상 등의 부작용이 있을 수 있습니다.", + "businessNo": "", + "checkedAt": "", + "nextCheck": "" +} diff --git a/supporters/scripts/template/data/news.json b/supporters/scripts/template/data/news.json new file mode 100644 index 0000000..c7d2cb4 --- /dev/null +++ b/supporters/scripts/template/data/news.json @@ -0,0 +1,6 @@ +{ + "_comment": "뉴스룸 데이터. scripts/build_news.py 결과. 병원 배포 기사·원장 기고만, 제3자 규제·사건 보도 제외.", + "generatedAt": null, + "sources": [], + "items": [] +} diff --git a/supporters/scripts/template/data/site.json b/supporters/scripts/template/data/site.json new file mode 100644 index 0000000..2189e8f --- /dev/null +++ b/supporters/scripts/template/data/site.json @@ -0,0 +1,24 @@ +{ + "_comment": "매체 고유값. 비어 있으면(null·[]) 해당 블록은 '확인 대기' 문장으로 렌더된다. 이미지는 병원 허락을 받은 것만 public/img/ 에 넣고 여기서 참조한다.", + "siteName": "", + "siteNameEn": "SUPPORTERS", + "eyebrow": "", + "tagline": "", + "heroTitle": "궁금한 것부터 답합니다.", + "editorId": "editor", + "logo": null, + "logoFoot": null, + "heroImage": null, + "buildingImage": null, + "imageCredit": "", + "homeVideos": [], + "insideSummary": "", + "insideGallery": [], + "clinicGallery": [], + "clinicDoctors": [], + "visitMaps": [], + "arrivalGuide": "", + "visitReads": [], + "newsNote": "", + "newsOutletsExample": "" +} diff --git a/supporters/scripts/template/data/videos.json b/supporters/scripts/template/data/videos.json new file mode 100644 index 0000000..3a15217 --- /dev/null +++ b/supporters/scripts/template/data/videos.json @@ -0,0 +1,10 @@ +{ + "_comment": "유튜브 채널 집계. uploads 전량 → videos.list → 조회수 순위. 쇼츠 판별은 /shorts/{id} 200 응답. topLong·shortsInfo는 정보형만(제목 금칙 필터 통과).", + "fetchedAt": null, + "channel": {}, + "top": [], + "topLong": [], + "topShorts": [], + "shortsInfo": [], + "shortsDetection": "" +} diff --git a/supporters/src/components/Disclosure.astro b/supporters/src/components/Disclosure.astro index b4fd4c3..efc33d2 100644 --- a/supporters/src/components/Disclosure.astro +++ b/supporters/src/components/Disclosure.astro @@ -1,6 +1,6 @@ --- -import { fact } from '../lib'; +import { fact, SITE_NAME, v } from '../lib'; ---

- 뷰 서포터즈 활동은 {fact.shortName}의 지원을 받습니다. 이 글에는 다른 환자의 치료 경험담과 전후 사진을 싣지 않습니다. {fact.sideEffectNotice} + {SITE_NAME} 활동은 {v(fact.shortName, '병원')}의 지원을 받습니다. 이 글에는 다른 환자의 치료 경험담과 전후 사진을 싣지 않습니다. {fact.sideEffectNotice}

diff --git a/supporters/src/components/FactBlock.astro b/supporters/src/components/FactBlock.astro index 01681d2..363ce3c 100644 --- a/supporters/src/components/FactBlock.astro +++ b/supporters/src/components/FactBlock.astro @@ -1,17 +1,19 @@ --- -import { fact } from '../lib'; +import { fact, v, has } from '../lib'; +const f = fact as Record; +const rows = f.hours?.rows ?? []; interface Props { compact?: boolean } const { compact = false } = Astro.props; --- diff --git a/supporters/src/components/PostCard.astro b/supporters/src/components/PostCard.astro index ed2cb32..812cb7d 100644 --- a/supporters/src/components/PostCard.astro +++ b/supporters/src/components/PostCard.astro @@ -1,11 +1,11 @@ --- -import { CATEGORY_LABELS, supporter } from '../lib'; +import { CATEGORY_LABELS, supporter, site as S, fact } from '../lib'; interface Props { post: any } const { post } = Astro.props; const d = post.data; const v0 = d.videos?.[0] ?? d.video; -const thumb = d.thumbnail ?? d.hero?.src ?? (v0 ? `https://i.ytimg.com/vi/${v0.id}/hqdefault.jpg` : '/img/clinic/building-clean.jpg'); -const thumbAlt = d.hero?.alt ?? (v0 ? `유튜브 영상 썸네일: ${v0.title}` : '뷰성형외과 빌딩 외관'); +const thumb = d.thumbnail ?? d.hero?.src ?? (v0 ? `https://i.ytimg.com/vi/${v0.id}/hqdefault.jpg` : S.buildingImage?.src ?? '/favicon.svg'); +const thumbAlt = d.hero?.alt ?? (v0 ? `유튜브 영상 썸네일: ${v0.title}` : S.buildingImage?.alt ?? `${fact.shortName} 건물 외관`); ---
{thumbAlt} diff --git a/supporters/src/components/ReviewBox.astro b/supporters/src/components/ReviewBox.astro index 92e1c62..a50e214 100644 --- a/supporters/src/components/ReviewBox.astro +++ b/supporters/src/components/ReviewBox.astro @@ -8,7 +8,7 @@ const p = reviewer ? physician(reviewer) : null;
{p?.image ? {`${p.name} :
{a.name.slice(0, 1)}
}
-
작성 {a.name} · {a.role} · 정정 요청 o2oteam@o2o.kr
+
작성 {a.name} · {a.role} {a.email ? <> · 정정 요청 {a.email} : <> · 정정 요청은 정정 기록 페이지 안내를 따릅니다}
{p && status === 'reviewed' ? (
의학 검토 {p.name} {p.title} 검토 완료 {dateModified}
) : ( diff --git a/supporters/src/components/VideoLite.astro b/supporters/src/components/VideoLite.astro index 69843cc..37da91a 100644 --- a/supporters/src/components/VideoLite.astro +++ b/supporters/src/components/VideoLite.astro @@ -1,6 +1,8 @@ --- // 유튜브 라이트 임베드: 썸네일(i.ytimg.com) + 재생 버튼. 클릭 시 iframe으로 교체. 헤드리스·저속 환경에서도 시각적으로 보인다. +import { surface } from '../lib'; interface Props { id: string; title: string; speaker?: string; published?: string; note?: string; compact?: boolean } +const handle = surface('youtube').handle ? `유튜브 ${surface('youtube').handle}` : '유튜브'; const { id, title, speaker, published, note, compact = false } = Astro.props; ---
@@ -10,7 +12,7 @@ const { id, title, speaker, published, note, compact = false } = Astro.props;
{title} - {speaker ? `${speaker} · ` : ''}{published ? `${published} 게시 · ` : ''}유튜브 @ViewclinicKR{note ? ` · ${note}` : ''} + {speaker ? `${speaker} · ` : ''}{published ? `${published} 게시 · ` : ''}{handle}{note ? ` · ${note}` : ''}
diff --git a/templates/supporters-astro/src/content.config.ts b/templates/supporters-astro/src/content.config.ts new file mode 100644 index 0000000..50b59b4 --- /dev/null +++ b/templates/supporters-astro/src/content.config.ts @@ -0,0 +1,32 @@ +import { defineCollection } from 'astro:content'; +import { glob } from 'astro/loaders'; +import { z } from 'astro:schema'; + +const posts = defineCollection({ + loader: glob({ pattern: '**/*.md', base: './src/content/posts' }), + schema: z.object({ + title: z.string(), // H1. 질문형 한 문장 + category: z.enum(['A', 'B', 'C', 'D', 'E', 'F', 'G']), + categoryLabel: z.string(), + qbIds: z.array(z.string()), // QB 120 문항 ID + summary: z.array(z.string()).min(2).max(4), // 세 줄 요약 (직접 답변 블록) + description: z.string(), // meta description + author: z.string(), // authors.json id + reviewer: z.string().optional(), // 감수자 id (physicians) + reviewStatus: z.enum(['reviewed', 'pending']).default('pending'), + reviewedAt: z.string().optional(), // 의학 검토 완료일. reviewed일 때 필수로 쓴다 + datePublished: z.string(), + dateModified: z.string(), + video: z.object({ id: z.string(), title: z.string(), channel: z.string(), speaker: z.string().optional(), published: z.string().optional() }).optional(), + videos: z.array(z.object({ id: z.string(), title: z.string(), speaker: z.string().optional(), published: z.string().optional(), note: z.string().optional() })).default([]), + gallery: z.array(z.object({ src: z.string(), alt: z.string(), caption: z.string() })).default([]), + thumbnail: z.string().optional(), + hero: z.object({ src: z.string(), alt: z.string(), caption: z.string() }).optional(), + faq: z.array(z.object({ q: z.string(), a: z.string() })).default([]), + sources: z.array(z.object({ label: z.string(), url: z.string(), accessed: z.string(), type: z.enum(['clinic', 'doctor', 'product', 'regulation', 'platform', 'secondary']).optional() })).default([]), + history: z.array(z.object({ date: z.string(), note: z.string() })).default([]), // 갱신 기록. 화면에 그대로 노출 + tags: z.array(z.string()).default([]), + }), +}); + +export const collections = { posts }; diff --git a/templates/supporters-astro/src/content/posts/.gitkeep b/templates/supporters-astro/src/content/posts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/supporters-astro/src/data/authors.json b/templates/supporters-astro/src/data/authors.json new file mode 100644 index 0000000..098421b --- /dev/null +++ b/templates/supporters-astro/src/data/authors.json @@ -0,0 +1,13 @@ +{ + "_comment": "작성자(supporters)와 의학 검토 원장(physicians). 작성자 실명·직함·sameAs·정정 이메일은 병원/운영자 입력값(v2 §6-1). 검토 원장은 병원이 확정한 사람만 넣는다(§3-7).", + "supporters": { + "editor": { + "name": "편집 책임자 (확인 대기)", + "role": "서포터즈 편집 책임", + "bio": "병원을 알아보는 분들이 실제로 묻는 질문을 고르고, 원장 설명 영상과 병원 공개 자료를 근거로 글을 쓰며 편집 책임을 집니다. 의학적 판단은 하지 않으며, 의학 내용은 병원 담당 원장의 검토를 거쳐 표시합니다.", + "email": "", + "sameAs": [] + } + }, + "physicians": {} +} diff --git a/templates/supporters-astro/src/data/corrections.json b/templates/supporters-astro/src/data/corrections.json new file mode 100644 index 0000000..15c98a9 --- /dev/null +++ b/templates/supporters-astro/src/data/corrections.json @@ -0,0 +1,6 @@ +{ + "_comment": "정정 기록. 접수·처리한 정정을 날짜순으로 남긴다. contact·owner는 병원/운영자 입력값.", + "contact": "", + "owner": "", + "items": [] +} diff --git a/templates/supporters-astro/src/data/factSheet.json b/templates/supporters-astro/src/data/factSheet.json new file mode 100644 index 0000000..0eddd10 --- /dev/null +++ b/templates/supporters-astro/src/data/factSheet.json @@ -0,0 +1,32 @@ +{ + "_comment": "병원 사실의 단일 원본. 근거 수집기(evidence//)가 채우고, 비어 있는 값은 화면에 '확인 대기'로 표시된다. 홈페이지 표기와 한 글자까지 맞춘다. 지어내지 않는다.", + "name": "", + "shortName": "", + "kind": "", + "areaLabel": "", + "founded": "", + "representative": "", + "address": { "full": "", "street": "", "locality": "", "region": "", "postalNote": "", "navigation": "", "source": "" }, + "phone": "", + "fax": "", + "kakao": "", + "url": "", + "urlEn": "", + "reservationUrl": "", + "doctorsUrl": "", + "transit": "", + "transitShort": "", + "transitNote": "", + "parking": "", + "hours": { "source": "", "sourceLabel": "", "rows": [], "schema": [] }, + "building": { "summary": "", "floors": [], "source": "" }, + "departments": [], + "specialties": [], + "safety": { "items": [], "source": "" }, + "recognitions": [], + "surfaces": {}, + "sideEffectNotice": "수술 후 개인에 따라 염증, 출혈, 신경손상 등의 부작용이 있을 수 있습니다.", + "businessNo": "", + "checkedAt": "", + "nextCheck": "" +} diff --git a/templates/supporters-astro/src/data/news.json b/templates/supporters-astro/src/data/news.json new file mode 100644 index 0000000..c7d2cb4 --- /dev/null +++ b/templates/supporters-astro/src/data/news.json @@ -0,0 +1,6 @@ +{ + "_comment": "뉴스룸 데이터. scripts/build_news.py 결과. 병원 배포 기사·원장 기고만, 제3자 규제·사건 보도 제외.", + "generatedAt": null, + "sources": [], + "items": [] +} diff --git a/templates/supporters-astro/src/data/site.json b/templates/supporters-astro/src/data/site.json new file mode 100644 index 0000000..2189e8f --- /dev/null +++ b/templates/supporters-astro/src/data/site.json @@ -0,0 +1,24 @@ +{ + "_comment": "매체 고유값. 비어 있으면(null·[]) 해당 블록은 '확인 대기' 문장으로 렌더된다. 이미지는 병원 허락을 받은 것만 public/img/ 에 넣고 여기서 참조한다.", + "siteName": "", + "siteNameEn": "SUPPORTERS", + "eyebrow": "", + "tagline": "", + "heroTitle": "궁금한 것부터 답합니다.", + "editorId": "editor", + "logo": null, + "logoFoot": null, + "heroImage": null, + "buildingImage": null, + "imageCredit": "", + "homeVideos": [], + "insideSummary": "", + "insideGallery": [], + "clinicGallery": [], + "clinicDoctors": [], + "visitMaps": [], + "arrivalGuide": "", + "visitReads": [], + "newsNote": "", + "newsOutletsExample": "" +} diff --git a/templates/supporters-astro/src/data/videos.json b/templates/supporters-astro/src/data/videos.json new file mode 100644 index 0000000..3a15217 --- /dev/null +++ b/templates/supporters-astro/src/data/videos.json @@ -0,0 +1,10 @@ +{ + "_comment": "유튜브 채널 집계. uploads 전량 → videos.list → 조회수 순위. 쇼츠 판별은 /shorts/{id} 200 응답. topLong·shortsInfo는 정보형만(제목 금칙 필터 통과).", + "fetchedAt": null, + "channel": {}, + "top": [], + "topLong": [], + "topShorts": [], + "shortsInfo": [], + "shortsDetection": "" +} diff --git a/templates/supporters-astro/src/layouts/Base.astro b/templates/supporters-astro/src/layouts/Base.astro new file mode 100644 index 0000000..1ace83c --- /dev/null +++ b/templates/supporters-astro/src/layouts/Base.astro @@ -0,0 +1,95 @@ +--- +import '../styles/global.css'; +import { SITE_NAME, SITE_TAGLINE, INDEXABLE, fact, site as S, surface, reservationUrl, editor, has, v } from '../lib'; +const ed = editor(); +const f = fact as Record; + +interface Props { + title: string; + description: string; + jsonLd?: Record | Record[]; + ogImage?: string; + type?: 'website' | 'article'; + noindex?: boolean; +} +const { title, description, jsonLd, ogImage = S.buildingImage?.src || '/favicon.svg', type = 'website', noindex = false } = Astro.props; +const site = Astro.site?.toString().replace(/\/$/, '') ?? ''; +const cleanPath = Astro.url.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, ''); +const canonical = (new URL(cleanPath, Astro.site).toString().replace(/\/$/, '') || site); +const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array.isArray(jsonLd) ? { '@graph': jsonLd } : jsonLd) }) : null; +--- + + + + + + {title} | {SITE_NAME} + + + {(!INDEXABLE || noindex) && } + + + + + + + + + + + + {ld && +
+
+
+

{SITE_NAME} · {SITE_TAGLINE}

+

이 매체는 {v(f.shortName, '병원')}의 지원을 받아 서포터즈가 운영합니다. 글의 의학적 내용은 {v(f.shortName, '병원')} 담당 원장의 검토를 거쳐 표시하며, 검토 전 글은 "의학 검토 대기"로 표시합니다. 편집 책임 {ed.name}.

+

{fact.sideEffectNotice}

+

병원 정보 · 방문 안내 · 이 사이트에 대해 · 정정 기록 · 정정 요청 · 편집 기준 (운영자용)

+
+
+ {has(S.logoFoot?.src) &&

{S.logoFoot.alt}

} +

{v(f.name)}

+

{v(f.address?.full)}
전화 {v(f.phone)} · 팩스 {v(f.fax)}
대표 {v(f.representative)} · 사업자등록번호 {v(f.businessNo)}

+

{has(f.url) && 공식 홈페이지}{has(surface('youtube').url) && <> · 유튜브}{has(surface('gangnamunni').url) && <> · 강남언니}

+
+
+
AI Discovery · Built with INFINITH
+
+ + diff --git a/templates/supporters-astro/src/lib.ts b/templates/supporters-astro/src/lib.ts new file mode 100644 index 0000000..6d04b58 --- /dev/null +++ b/templates/supporters-astro/src/lib.ts @@ -0,0 +1,82 @@ +// 데이터 진입점. 병원 사실(factSheet) · 사람(authors) · 매체(site) 세 파일만 읽고, 페이지는 이 모듈을 통해서만 값을 쓴다. +// 값이 비어 있으면(null·''·[]) 화면에는 PENDING("확인 대기")을 쓰고 스키마에서는 그 필드를 뺀다. 지어내지 않는다. +import fact from './data/factSheet.json'; +import authors from './data/authors.json'; +import site from './data/site.json'; + +export const PENDING = '확인 대기'; +export const SITE_NAME = site.siteName || `${fact.shortName || '병원'} 서포터즈`; +export const SITE_TAGLINE = site.tagline || `${fact.shortName || '병원'}를 알아보는 사람들의 질문에 먼저 답하는 서포터즈 매체`; +export const INDEXABLE = import.meta.env.PUBLIC_INDEXABLE === 'true'; + +export const CATEGORY_LABELS: Record = { + A: '병원 소개', B: '방문·예약', C: '선택 기준', D: '시술 정보', E: '안전·신뢰', F: '가격·이벤트', G: '후기·평판', +}; + +export { fact, authors, site }; + +/** 비어 있으면 PENDING. 화면 표시용. */ +export const v = (x: unknown, fallback: string = PENDING): string => (x === null || x === undefined || x === '' ? fallback : String(x)); +/** 비어 있지 않은 값만. 스키마·링크용. */ +export const has = (x: unknown): boolean => !(x === null || x === undefined || x === '' || (Array.isArray(x) && x.length === 0)); + +type AnyRec = Record; +export const surfaces: AnyRec = (fact as AnyRec).surfaces ?? {}; +export const surface = (key: string): AnyRec => surfaces[key] ?? {}; +export const reservationUrl: string = (fact as AnyRec).reservationUrl || fact.url || ''; +export const editor = (): AnyRec => supporter(site.editorId) ?? { name: PENDING, role: '편집 책임', email: '', bio: '' }; + +export function supporter(id: string) { + return (authors.supporters as Record)[id]; +} +export function physician(id: string) { + return (authors.physicians as Record)[id]; +} + +const compact = (o: AnyRec): AnyRec => Object.fromEntries(Object.entries(o).filter(([, val]) => has(val))); + +export function clinicSchema(siteUrl: string) { + const f = fact as AnyRec; + const sameAs = ['youtube', 'instagram', 'facebook', 'gangnamunni'].map((k) => surface(k).url).concat(f.urlEn).filter(has); + return compact({ + '@type': ['MedicalClinic', 'Organization'], + '@id': `${f.url}/#clinic`, + name: f.name, + alternateName: f.shortName, + url: f.url, + telephone: f.phone, + faxNumber: f.fax, + foundingDate: f.founded, + address: has(f.address?.street) ? { + '@type': 'PostalAddress', + streetAddress: f.address.street, + addressLocality: f.address.locality, + addressRegion: f.address.region, + addressCountry: 'KR', + } : null, + openingHoursSpecification: (f.hours?.schema ?? []).map((h: AnyRec) => ({ '@type': 'OpeningHoursSpecification', ...h })), + medicalSpecialty: ['PlasticSurgery'], + sameAs, + image: has(site.buildingImage?.src) ? `${siteUrl}${site.buildingImage.src}` : null, + }); +} + +export function personSchema(id: string, siteUrl: string) { + const a = supporter(id); + return compact({ '@type': 'Person', '@id': `${siteUrl}/authors/${id}#person`, name: a.name, jobTitle: a.role, url: `${siteUrl}/authors/${id}`, sameAs: a.sameAs }); +} + +export function physicianSchema(id: string) { + const p = physician(id); + return compact({ + '@type': 'Physician', + '@id': `${p.url}#physician`, + name: p.name, + jobTitle: p.title, + url: p.url, + sameAs: p.sameAs, + medicalSpecialty: 'PlasticSurgery', + worksFor: { '@id': `${fact.url}/#clinic` }, + image: p.image ?? null, + }); +} diff --git a/templates/supporters-astro/src/pages/about.astro b/templates/supporters-astro/src/pages/about.astro new file mode 100644 index 0000000..b54f25e --- /dev/null +++ b/templates/supporters-astro/src/pages/about.astro @@ -0,0 +1,45 @@ +--- +import Base from '../layouts/Base.astro'; +import { fact, SITE_NAME, editor, reservationUrl, v, has } from '../lib'; +const ed = editor(); +const clinic = v(fact.shortName, '병원'); +const site = Astro.site!.toString().replace(/\/$/, ''); +const ld = { '@type': 'AboutPage', '@id': `${site}/about#page`, name: '이 사이트에 대해', url: `${site}/about`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } }; +--- + +
+
About

About This Site

누가 쓰는지, 무엇을 근거로 하는지, 무엇을 싣지 않는지.

+ +

어떤 곳인가요

+

{SITE_NAME}는 {clinic}를 알아보는 분들이 실제로 묻는 질문에 답하는 곳입니다. 병원 홈페이지가 병원을 소개한다면, 이곳은 "가기 전에 뭘 알아야 하지?", "이 수술은 어떻게 진행되지?", "후기는 어디서 보지?" 같은 환자 쪽 질문에서 출발합니다.

+ +

답은 무엇을 근거로 하나요

+
    +
  • {clinic} 원장들이 직접 설명한 유튜브 영상. 글에서 원장이 한 말은 영상을 함께 붙여 직접 확인할 수 있게 합니다.
  • +
  • 병원이 공개한 자료. 홈페이지, 강남언니 병원 페이지, 언론 보도.
  • +
  • 주소, 전화, 진료시간 같은 기본 정보는 병원 정보 페이지와 같은 내용을 씁니다.
  • +
+

글마다 맨 아래 "참고한 자료"에 링크와 확인한 날짜를 적습니다.

+ +

누가 쓰고 누가 확인하나요

+

글은 {SITE_NAME} 편집자 {ed.name}이 쓰고 편집 책임을 집니다. 의학적인 내용은 {clinic} 담당 원장이 검토하며, 검토가 끝난 글에는 "검토 완료"와 날짜를, 아직 검토 전인 글에는 "검토 대기"를 표시합니다. 편집자는 의학적 판단을 하지 않습니다. 수술 여부와 방법은 반드시 전문의 상담으로 정하세요.

+ +

싣지 않는 것

+
    +
  • 전후 사진
  • +
  • 다른 환자의 후기와 경험담. 후기는 강남언니, 구글, 네이버 플레이스의 집계 수치와 링크로만 안내합니다.
  • +
  • 다른 병원과의 비교, 효과를 보장하는 표현, "최고" 같은 최상급 표현
  • +
  • 가격. 이벤트 가격은 병원과 강남언니 페이지로 안내만 합니다.
  • +
+

이유는 간단합니다. 병원의 지원을 받는 사이트가 경험담이나 전후 사진으로 설득하면 광고가 되고, 읽는 분이 판단할 근거가 아니라 인상만 남기 때문입니다.

+ +

병원과의 관계

+

{SITE_NAME} 활동은 {clinic}의 지원을 받습니다. 이 사실은 모든 글 아래에 같은 문장으로 표시합니다. 감춘 광고가 아니라는 뜻이며, 그래서 위의 "싣지 않는 것"을 지킵니다.

+ +

정정 요청과 문의

+

글의 오류나 정정 요청은 편집 책임자 {ed.name}에게 {ed.email ? <>이메일 {ed.email}로 : <>이메일로(주소 {v(null)})} 보내 주세요. 접수한 요청과 처리 결과는 정정 기록에 날짜와 함께 남깁니다. 진료·예약 문의는 {clinic} 공식 채널로 하세요. {has(fact.url) && 홈페이지}{has(fact.kakao) && <> · 카카오톡 상담 채널} · 전화 {v(fact.phone)}

+ +

이 사이트를 만드는 방식(글 구조, 중복 콘텐츠 규칙, 발행 전 검사)이 궁금한 운영자는 편집 기준을 보세요.

+

{fact.sideEffectNotice}

+
+ diff --git a/templates/supporters-astro/src/pages/authors/[id].astro b/templates/supporters-astro/src/pages/authors/[id].astro new file mode 100644 index 0000000..14922cb --- /dev/null +++ b/templates/supporters-astro/src/pages/authors/[id].astro @@ -0,0 +1,19 @@ +--- +import { getCollection } from 'astro:content'; +import Base from '../../layouts/Base.astro'; +import PostCard from '../../components/PostCard.astro'; +import { authors, personSchema } from '../../lib'; +export async function getStaticPaths() { + return Object.keys(authors.supporters).map((id) => ({ params: { id } })); +} +const { id } = Astro.params; +const a = (authors.supporters as Record)[id!]; +const posts = (await getCollection('posts')).filter((p) => p.data.author === id); +const site = Astro.site!.toString().replace(/\/$/, ''); +--- + +
+
{a.role}

{a.name}

{a.bio}

+
{posts.map((p) => )}
+
+ diff --git a/templates/supporters-astro/src/pages/clinic.astro b/templates/supporters-astro/src/pages/clinic.astro new file mode 100644 index 0000000..0c82f44 --- /dev/null +++ b/templates/supporters-astro/src/pages/clinic.astro @@ -0,0 +1,45 @@ +--- +import Base from '../layouts/Base.astro'; +import FactBlock from '../components/FactBlock.astro'; +import { fact, site as S, clinicSchema, physicianSchema, physician, surface, v, has } from '../lib'; +import Gallery from '../components/Gallery.astro'; +const f = fact as Record; +const clinic = v(f.shortName, '병원'); +const docIds = (S.clinicDoctors ?? []).filter((id) => physician(id)); +const docs = docIds.map(physician); +const site = Astro.site!.toString().replace(/\/$/, ''); +const ld = [clinicSchema(site), ...docIds.slice(0, 1).map(physicianSchema)]; +const yt = surface('youtube'); const gu = surface('gangnamunni'); const blog = surface('blog'); +--- + +
+
Fact Sheet

Fact Sheet

{clinic} 기본 정보. 주소, 전화, 진료시간, 층별 안내, 의료진을 한 곳에 모았습니다. 항목마다 어디서 확인한 정보인지 출처를 적어 두었습니다.

+ +

진료시간

+
{(f.hours?.rows ?? []).map((r: any) => )}{!(f.hours?.rows ?? []).length && }
요일진료
{r.days}{r.open ? `${r.open} ~ ${r.close}` : r.note}
{v(null)}
+

출처: {has(f.hours?.source) ? {f.hours.sourceLabel} : v(null)}. 진료시간은 바뀔 수 있으니 방문 전 전화로 한 번 더 확인하세요.

+

건물과 층별 구성

+ {has(S.buildingImage?.src) &&
{S.buildingImage.alt}
{S.buildingImage.caption}
} +

{has(f.building?.summary) ? `${f.building.summary}입니다.` : `건물 규모 ${v(null)}.`}

+
{(f.building?.floors ?? []).map((fl: any) => )}{!(f.building?.floors ?? []).length && }
용도
{fl.floor}{fl.use}
{v(null)}
+ {S.clinicGallery?.length > 0 && } +

진료과와 진료 분야

+

진료과: {(f.departments ?? []).join(', ') || v(null)}

+

진료 분야: {(f.specialties ?? []).join(', ') || v(null)}

+

글과 영상에서 설명하는 의료진

+ {!docs.length &&

의료진 정보는 병원이 확정한 담당 원장부터 싣습니다. {v(null)}.

} + {docs.map((p) => (
{p.image && {`${p.name}}
{p.name} {p.title}

{p.credentials.slice(0,3).join(' · ')} 프로필

))} +

전체 의료진 {v(gu.doctors)}명(강남언니 등록 기준)은 {has(f.doctorsUrl) ? 홈페이지 의료진 소개 : '홈페이지 의료진 소개'}에서 확인할 수 있습니다.

+

병원이 공개한 이력

+
    {(f.recognitions ?? []).map((r: any) =>
  • {r.text} (출처)
  • )}{!(f.recognitions ?? []).length &&
  • {v(null)}
  • }
+

공식 채널

+
    +
  • 홈페이지: {has(f.url) ? {f.url} : v(null)}{has(f.urlEn) && <> (영문 {f.urlEn})}
  • +
  • 유튜브: {has(yt.url) ? {yt.handle} : v(null)} · 구독자 {v(yt.subscribers)} · 영상 {v(yt.videos)}개 ({v(yt.checked)} 기준)
  • +
  • 강남언니: {has(gu.url) ? 병원 페이지 : v(null)} · 평점 {v(gu.rating)} · 후기 {v(gu.reviews)}건 · 의료진 {v(gu.doctors)}명
  • +
  • 카카오톡 채널: {has(f.kakao) ? 상담 채널 : v(null)}
  • +
  • 블로그: {has(blog.url) ? {blog.url.replace(/^https?:\/\//, '').replace(/\/$/, '')} : v(null)}{has(blog.note) && <> ({blog.note})}
  • +
+

{fact.sideEffectNotice}

+
+ diff --git a/templates/supporters-astro/src/pages/corrections.astro b/templates/supporters-astro/src/pages/corrections.astro new file mode 100644 index 0000000..eaba8a9 --- /dev/null +++ b/templates/supporters-astro/src/pages/corrections.astro @@ -0,0 +1,24 @@ +--- +// 정정 기록. 무엇을 언제 왜 고쳤는지 공개한다. 정정 요청 경로와 처리 담당자를 명시한다. +import Base from '../layouts/Base.astro'; +import data from '../data/corrections.json'; +import { SITE_NAME, v } from '../lib'; +const site = Astro.site!.toString().replace(/\/$/, ''); +const items = [...data.items].sort((a, b) => b.date.localeCompare(a.date)); +const ld = { '@type': 'WebPage', '@id': `${site}/corrections#page`, name: '정정 기록', url: `${site}/corrections`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, dateModified: items[0]?.date }; +--- + +
+
Corrections

Corrections

정정 기록. 글에서 고친 내용을 날짜와 이유와 함께 남깁니다.

+ +

정정 요청 방법

+

글의 오류, 오래된 수치, 근거가 다른 내용을 발견하면 편집 책임자 {v(data.owner)}에게 {data.contact ? <>이메일 {data.contact}로 : <>이메일로(주소 {v(null)})} 보내 주세요. 어느 글의 어느 문장인지와 근거 자료를 함께 주시면 빠릅니다. 접수한 요청은 확인 뒤 이 페이지에 처리 결과를 남깁니다. 진료·예약 문의는 병원 공식 채널로 하세요.

+ +

정정 기록

+
+ {!items.length && } + {items.map((c) => )} +
날짜페이지무엇이 문제였나어떻게 고쳤나계기
아직 정정한 내용이 없습니다.
{c.date}{c.page}{c.what}{c.fix}{c.source}
+

글마다 하단의 "갱신 기록"에도 같은 변경이 날짜와 함께 표시됩니다.

+
+ diff --git a/templates/supporters-astro/src/pages/editorial.astro b/templates/supporters-astro/src/pages/editorial.astro new file mode 100644 index 0000000..88c288c --- /dev/null +++ b/templates/supporters-astro/src/pages/editorial.astro @@ -0,0 +1,84 @@ +--- +// 운영자용 페이지. 병원 담당자·서포터즈·INFINITH가 보는 편집 기준이다. 고객 내비게이션에는 넣지 않고 푸터에서만 연결하며, 색인하지 않는다. +import Base from '../layouts/Base.astro'; +import { fact, SITE_NAME } from '../lib'; +const site = Astro.site!.toString().replace(/\/$/, ''); +const ld = { '@type': 'WebPage', '@id': `${site}/editorial#page`, name: '편집 기준', url: `${site}/editorial`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } }; +--- + +
+
Editorial Policy

Editorial Policy

이 페이지는 병원 담당자, 서포터즈, INFINITH 운영진이 보는 문서입니다. 방문자에게 보이는 소개는 이 사이트에 대해에 따로 있습니다.

+
고객 화면에는 "AI가 인용하는 방식"이나 제작 원칙을 쓰지 않습니다. 방문자는 답을 보러 오지 제작 과정을 보러 오지 않습니다. 이 페이지의 내용은 이 페이지 밖으로 내보내지 않습니다.
+ +

1. 왜 이 사이트인가

+

답변엔진(ChatGPT, Perplexity, 네이버 AI 브리핑)은 광고 문장이 아니라 질문에 바로 답하는 단락을 잘라 인용합니다. 병원 홈페이지는 소개 중심이라 그 단락이 없습니다. 이 사이트는 환자 질문에 답하는 단락을 먼저 만들고, 그 답을 원장 영상과 공개 자료로 뒷받침하는 것이 목적입니다.

+ +

2. 질문 뱅크

+

질문은 INFINITH가 실측한 환자 질문 뱅크 120문항(카테고리 A~G)에서 고릅니다. 문항 하나가 글 하나이고, 각 글의 문항 번호는 페이지 소스의 data-qb 속성에만 남기고 화면에는 표시하지 않습니다.

+
+ + + + + + + +
카테고리내용글 포맷
A 병원 소개어떤 병원인지, 의료진정의문 + 사실 표
B 방문·예약가는 길, 예약, 준비체크리스트
C 선택 기준병원·원장 고르는 기준기준 표
D 시술 정보수술 방법, 보형물, 회복원장 영상 재구성 + FAQ
E 안전·신뢰마취, 실명제, 응급사실 나열 + 출처
F 가격·이벤트가격 확인처안내만, 금액 미기재
G 후기·평판후기 읽는 곳집계 수치 + 링크
+ +

3. 글 구조

+
    +
  1. H1은 질문형 한 문장.
  2. +
  3. 첫 블록 "세 줄 요약"에 답을 먼저 쓴다. 답변엔진이 잘라 쓰는 단락이다.
  4. +
  5. 본문 H2 3~5개. 표는 HTML table. 수치는 단위 포함.
  6. +
  7. 원장 영상은 글로 재구성하고 원본을 임베드한다. 영상 설명란 텍스트는 복사하지 않는다.
  8. +
  9. FAQ 5~8개. 질문 뱅크 문항을 그대로 질문으로 쓴다.
  10. +
  11. 작성·감수 정보, 참고 자료, 병원 정보 블록, 지원 관계 고지 순으로 닫는다.
  12. +
+ +

4. 원천 변환 규칙

+
    +
  • 홈페이지 문장은 옮겨 적지 않는다. 사실(개원 연도, 층 구성, 진료시간)만 가져오고 문장은 새로 쓴다.
  • +
  • 주소·전화·진료시간·의료진 수는 src/data/factSheet.json 한 곳에서만 가져온다. 화면의 병원 정보도 같은 파일에서 나온다.
  • +
  • 이미지는 병원 공개 자료를 캡처해 쓰되, alt와 캡션은 글 문맥에 맞게 새로 쓰고 출처를 적는다.
  • +
  • 후기 본문은 인용하지 않는다. 의료법 제56조가 광고에서 치료 경험담을 금지하고, 병원 지원을 받는 이 사이트는 광고로 볼 여지가 있다. 집계 수치와 링크만 쓴다.
  • +
+ +

5. 중복 콘텐츠를 피하는 규칙

+
+ + + + +
규칙이유
홈페이지 문장 40자 연속 일치 검사 후 발행구글이 복제 사이트로 분류하거나 홈페이지와 순위를 나눠 갖는 일을 막는다.
허브(이 사이트)에 먼저 발행, 네이버 블로그는 24시간 뒤 요약 재작성네이버 유사문서 판독은 뒤에 올라온 비슷한 글을 뺀다. 발행 순서가 원본 판정이다.
모든 페이지에 자기 참조 canonical같은 글이 다른 주소로 복제돼도 원본이 한 곳으로 모인다.
홈페이지에 없는 정보 유형을 중심으로준비 체크리스트, 회복 일정, 비교표, FAQ는 홈페이지가 다루지 않는 영역이다.
+ +

6. 구조화 데이터

+
+ + + + + + +
타입용도
Article + MedicalWebPage글 본체. dateModified 필수
FAQPageFAQ 블록
Person (author)서포터즈. 확정 후 sameAs로 본인 SNS 연결
Physician (reviewedBy)감수 원장. 감수 완료 상태에서만 넣는다. 감수 예정에 넣으면 허위 표기다
MedicalClinic (about){fact.shortName}. 홈페이지·유튜브·강남언니 sameAs
VideoObject임베드 영상
+ +

7. 발행 전 검사

+

정적 HTML로 빌드해 크롤러가 자바스크립트 없이 전문을 읽습니다. 빌드 뒤 scripts/check.mjs가 다음을 검사하고 하나라도 실패하면 배포하지 않습니다.

+
    +
  1. JSON-LD 문법과 글 페이지의 Article/FAQPage 존재
  2. +
  3. 모든 이미지의 alt
  4. +
  5. 금칙 표현. 치료 결과를 단정하는 말, 부작용을 부정하는 문장, 최상급 병원 표현, 1인칭 경험담 어투. 목록은 scripts/check.mjs의 BANNED 배열에 있다
  6. +
  7. 페이지당 H1 1개
  8. +
  9. 홈페이지 문장 40자 연속 일치
  10. +
+

샘플 단계에서는 사이트 전체를 noindex로 두고, 이 페이지는 정식 오픈 뒤에도 noindex를 유지합니다.

+ +

8. 고객 화면과 운영자 설명의 경계

+

다음 표현은 고객 화면(홈, 글, 영상, 뉴스룸, 병원 정보, 소개)에 쓰지 않습니다.

+
    +
  • 답변엔진, AI 인용, AEO/GEO, 질문 뱅크, QB 번호, INFINITH 실측
  • +
  • "홈페이지 문장을 복사하지 않습니다", "단일 원본", "수집 기준", "API 집계" 같은 제작 과정 설명
  • +
  • 법 조문 번호. 고객에게는 "다른 환자의 경험담을 싣지 않습니다"까지만 말하고 이유는 한 문장으로 줄인다
  • +
+

고객 화면에 남기는 것은 환자 보호 문구뿐입니다. 감수 상태, 참고 자료, 병원 지원 관계, 부작용 고지.

+
+ diff --git a/templates/supporters-astro/src/pages/index.astro b/templates/supporters-astro/src/pages/index.astro new file mode 100644 index 0000000..cfac4ff --- /dev/null +++ b/templates/supporters-astro/src/pages/index.astro @@ -0,0 +1,108 @@ +--- +import { getCollection } from 'astro:content'; +import Base from '../layouts/Base.astro'; +import PostCard from '../components/PostCard.astro'; +import FactBlock from '../components/FactBlock.astro'; +import VideoLite from '../components/VideoLite.astro'; +import Gallery from '../components/Gallery.astro'; +import { SITE_NAME, fact, site as S, clinicSchema, surface, v, has, PENDING } from '../lib'; +const posts = (await getCollection('posts')).sort((a, b) => b.data.dateModified.localeCompare(a.data.dateModified)); +const site = Astro.site!.toString().replace(/\/$/, ''); +const f = fact as Record; +const clinic = v(f.shortName, '병원'); +const area = has(f.areaLabel) ? `${f.areaLabel} 인근 ` : ''; +const yt = surface('youtube'); const gu = surface('gangnamunni'); +const ld = [ + { '@type': 'WebSite', '@id': `${site}/#website`, name: SITE_NAME, url: site, inLanguage: 'ko', about: { '@id': `${f.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } }, + clinicSchema(site), +]; +--- + +
+
+
+
+
{S.eyebrow || SITE_NAME}
+

{clinic},
{S.heroTitle || '궁금한 것부터 답합니다.'}

+

{clinic}는 {has(f.founded) ? `${f.founded}년 개원한 ` : ''}{area}{v(f.kind, '병원')}입니다. 이곳은 병원을 알아보는 분들이 실제로 묻는 질문을 골라, 원장 설명 영상과 병원이 공개한 자료를 근거로 답하는 곳입니다. 글마다 누가 썼고 원장 감수를 거쳤는지 밝힙니다.

+
+ {has(f.founded) ? `${f.founded}년 개원` : `개원 연도 ${PENDING}`} · {v(f.transitShort)} + 원장이 직접 설명한 영상을 근거로 +
+ +
+ {has(S.heroImage?.src) &&
{S.heroImage.alt}
} +
+
+ +
+ +
+ +
+
+
Why This Site

Questions First

병원에 가기 전에 알고 싶은 것부터 답합니다. 답은 원장이 직접 설명한 영상과 병원 공개 자료를 근거로 합니다.

+
+
{posts.length}답한 질문질문 하나에 글 하나. 첫 단락 "세 줄 요약"에 답을 먼저 쓰고, 그다음에 근거를 풉니다.
+
{v(yt.videos)}원장 설명 영상{v(yt.checked)} 확인. 글에서 원장이 한 말은 영상을 함께 붙여 두어 직접 확인할 수 있습니다.
+
{v(gu.reviews)}강남언니 후기{v(gu.checked)} 확인. 후기는 어디서 몇 건을 볼 수 있는지만 안내하고, 다른 환자의 경험담은 옮기지 않습니다.
+
+
+
+ +
+
+
Latest

Recent Answers

최근에 답한 질문. 첫 단락 "세 줄 요약"만 읽어도 답을 알 수 있습니다.

전체 보기
+ {posts.length ?
{posts.slice(0, 6).map((p) => )}
:

아직 발행한 글이 없습니다. 검토를 마친 글부터 차례로 올립니다.

} +
+
+ +
+
+
From The Doctors

Doctors Explain

원장이 직접 설명하는 영상. 이 사이트의 글은 이런 영상을 근거로 씁니다.

+ {S.homeVideos?.length ? ( +
+ {S.homeVideos.map((vd) => )} +
+ ) :

원장 설명 영상은 병원 공식 채널에서 골라 실을 예정입니다. {PENDING}.

} +
+
+
+
+
Inside

Inside The Building

{v(S.insideSummary, `시설 안내 ${PENDING}`)}

+ {S.insideGallery?.length ? ( + <> + +

{v(S.imageCredit, '사진 출처: 병원 공식 홈페이지')}

+ + ) :

시설 사진은 병원 공식 홈페이지에서 허락받은 사진만 싣습니다. {PENDING}.

} +
+
+
+
+
Principles

What We Do, What We Don't

읽는 분이 판단할 수 있도록 세 가지를 지킵니다.

+
+

답하는 방식

질문 하나에 글 하나. 첫 단락에 답을 먼저 씁니다. 의학적인 내용은 담당 원장이 감수하고, 감수가 끝난 글에는 날짜를, 아직인 글에는 감수 예정을 표시합니다.

+

싣지 않는 것

전후 사진, 다른 환자의 경험담, 다른 병원과의 비교, 효과를 보장하는 표현. 후기는 강남언니·구글에서 몇 건을 볼 수 있는지와 링크만 안내합니다.

+

밝히는 것

누가 썼는지, 원장 감수를 거쳤는지, 무엇을 참고했는지, 그리고 이 사이트가 {clinic}의 지원을 받는다는 사실. 이 사이트에 대해

+
+
+
+ +
Fact Sheet

Clinic Facts

주소·전화·진료시간. 방문 전에 전화로 한 번 더 확인을 권합니다.

+ + diff --git a/templates/supporters-astro/src/pages/newsroom.astro b/templates/supporters-astro/src/pages/newsroom.astro new file mode 100644 index 0000000..4784547 --- /dev/null +++ b/templates/supporters-astro/src/pages/newsroom.astro @@ -0,0 +1,124 @@ +--- +import Base from '../layouts/Base.astro'; +import news from '../data/news.json'; +import { SITE_NAME, fact, site as S, v, PENDING } from '../lib'; +const clinic = v(fact.shortName, '병원'); +const items = news.items as { date: string; title: string; outlet: string; url: string; kind: 'release' | 'column' | 'mention'; origin: string; topic: string }[]; +const site = Astro.site!.toString().replace(/\/$/, ''); +const years = [...new Set(items.map((i) => i.date.slice(0, 4)))].sort().reverse(); +const topics = [...new Set(items.map((i) => i.topic))]; +const topicCount = Object.fromEntries(topics.map((t) => [t, items.filter((i) => i.topic === t).length])); +topics.sort((a, b) => topicCount[b] - topicCount[a]); +const KIND: Record = { release: '보도자료', column: '원장 기고·인터뷰', mention: '언론 언급' }; +const counts = { release: items.filter((i) => i.kind === 'release').length, column: items.filter((i) => i.kind === 'column').length, mention: items.filter((i) => i.kind === 'mention').length }; +const outlets = new Set(items.map((i) => i.outlet)).size; +const ld = [ + { '@type': 'CollectionPage', '@id': `${site}/newsroom#page`, name: `${clinic} 뉴스룸`, url: `${site}/newsroom`, inLanguage: 'ko', about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, ...(news.generatedAt ? { dateModified: news.generatedAt } : {}) }, + { '@type': 'ItemList', '@id': `${site}/newsroom#list`, name: `${clinic} 언론 보도 목록`, numberOfItems: items.length, itemListOrder: 'https://schema.org/ItemListOrderDescending', itemListElement: items.slice(0, 200).map((i, idx) => ({ '@type': 'ListItem', position: idx + 1, url: i.url, name: i.title })) }, +]; +--- + +
+
+
Newsroom

In The News

{clinic} 보도자료와 원장 기고·인터뷰, 언론 보도를 연도와 주제별로 모았습니다. 제목을 누르면 기사 원문으로 이동합니다.

+
+
{items.length.toLocaleString()}기사{years.length ? `${years[years.length - 1]}년부터 ${years[0]}년까지` : PENDING}
+
{counts.release.toLocaleString()}보도자료병원이 직접 알린 소식. 수상, 장비 도입, 행사, 협약
+
{counts.column.toLocaleString()}원장 기고·인터뷰원장이 언론에 직접 설명한 수술 이야기
+
{outlets}매체{v(S.newsOutletsExample, PENDING)}
+
+
+
+ +
+
+
+
종류 + + + + +
+
주제 + + {topics.map((t) => )} +
+
연도 +
{years.map((y) => {y})}
+
+
+ + {!items.length &&

병원 게시판과 뉴스 검색에서 기사를 모은 뒤 싣습니다. {PENDING}.

} + {years.map((y) => { + const list = items.filter((i) => i.date.startsWith(y)); + return ( +
+

{y}

{list.length}건
+
    + {list.map((i) => ( +
  1. + {i.date} + {KIND[i.kind]} + {i.title} + {i.outlet} + {i.topic} +
  2. + ))} +
+
+ ); + })} +

{v(S.newsNote, `${clinic} 홈페이지 게시판과 뉴스 검색에서 모은 기사입니다. 병원이 알리거나 원장이 설명한 기사만 모았고, 그 밖의 보도는 포함하지 않습니다.`)}

+
+
+ + + diff --git a/templates/supporters-astro/src/pages/posts/[id].astro b/templates/supporters-astro/src/pages/posts/[id].astro new file mode 100644 index 0000000..805023d --- /dev/null +++ b/templates/supporters-astro/src/pages/posts/[id].astro @@ -0,0 +1,94 @@ +--- +import { getCollection, render } from 'astro:content'; +import Base from '../../layouts/Base.astro'; +import Summary from '../../components/Summary.astro'; +import VideoLite from '../../components/VideoLite.astro'; +import RelatedVideos from '../../components/RelatedVideos.astro'; +import Gallery from '../../components/Gallery.astro'; +import Faq from '../../components/Faq.astro'; +import ReviewBox from '../../components/ReviewBox.astro'; +import Sources from '../../components/Sources.astro'; +import History from '../../components/History.astro'; +import Disclosure from '../../components/Disclosure.astro'; +import FactBlock from '../../components/FactBlock.astro'; +import { CATEGORY_LABELS, SITE_NAME, clinicSchema, personSchema, physicianSchema, physician, supporter, fact, surface } from '../../lib'; + +export async function getStaticPaths() { + const posts = await getCollection('posts'); + return posts.map((post) => ({ params: { id: post.id }, props: { post } })); +} +const { post } = Astro.props; +const d = post.data; +const { Content } = await render(post); +const site = Astro.site!.toString().replace(/\/$/, ''); +const url = `${site}/posts/${post.id}`; +const videos = d.videos.length ? d.videos : d.video ? [d.video] : []; +const [mainVideo, ...moreVideos] = videos; +const reviewer = d.reviewer ? physician(d.reviewer) : null; + +const article: Record = { + '@type': ['Article', 'MedicalWebPage'], + '@id': `${url}#article`, + headline: d.title, + description: d.description, + inLanguage: 'ko', + url, + mainEntityOfPage: url, + datePublished: d.datePublished, + dateModified: d.dateModified, + author: { '@id': `${site}/authors/${d.author}#person` }, + publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, + about: { '@id': `${fact.url}/#clinic` }, + image: [d.hero?.src, ...d.gallery.map((g) => g.src)].filter(Boolean).map((s) => `${site}${s}`), +}; +if (!article.image.length && clinicSchema(site).image) article.image = [clinicSchema(site).image]; +if (!article.image.length) delete article.image; +// 감수가 실제로 끝난 글에만 reviewedBy를 넣는다. 감수 예정 상태에서 넣으면 허위 표기다. +if (reviewer && d.reviewStatus === 'reviewed') { article.reviewedBy = { '@id': `${reviewer.url}#physician` }; if (d.reviewedAt) article.lastReviewed = d.reviewedAt; } +if (videos.length) article.video = videos.map((v) => ({ '@type': 'VideoObject', name: v.title, embedUrl: `https://www.youtube-nocookie.com/embed/${v.id}`, url: `https://www.youtube.com/watch?v=${v.id}`, thumbnailUrl: `https://i.ytimg.com/vi/${v.id}/hqdefault.jpg`, uploadDate: v.published ?? d.datePublished, description: v.title, publisher: { '@type': 'Organization', name: `${surface('youtube').title ?? fact.shortName} (YouTube)` } })); + +const graph: any[] = [article, personSchema(d.author, site), clinicSchema(site)]; +if (d.reviewer) graph.push(physicianSchema(d.reviewer)); +if (d.faq.length) graph.push({ '@type': 'FAQPage', '@id': `${url}#faq`, mainEntity: d.faq.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) }); +--- + +
+
+
{d.category} · {CATEGORY_LABELS[d.category]}
+

{d.title}

+
+ 작성 {supporter(d.author).name} + {reviewer && {d.reviewStatus === 'reviewed' ? '의학 검토' : '설명'} {reviewer.name} {reviewer.title.split(' · ')[0]}{d.reviewStatus === 'reviewed' && d.reviewedAt ? ` (${d.reviewedAt})` : ''}} + {d.reviewStatus !== 'reviewed' && 의학 검토 대기} + 발행 {d.datePublished} + 갱신 {d.dateModified} +
+
+ + {d.hero && ( +
+ {d.hero.alt} +
{d.hero.caption}
+
+ )} + {mainVideo && } + {reviewer && ( +
+ {reviewer.image && {`${reviewer.name}} +
+
{reviewer.name} {reviewer.title}
+

{reviewer.credentials.slice(0, 3).join(' · ')} 프로필

+
+
+ )} + + + + + + + + + +
+ diff --git a/templates/supporters-astro/src/pages/posts/index.astro b/templates/supporters-astro/src/pages/posts/index.astro new file mode 100644 index 0000000..c690540 --- /dev/null +++ b/templates/supporters-astro/src/pages/posts/index.astro @@ -0,0 +1,41 @@ +--- +import { getCollection } from 'astro:content'; +import Base from '../../layouts/Base.astro'; +import PostCard from '../../components/PostCard.astro'; +import { CATEGORY_LABELS, SITE_NAME, fact, v } from '../../lib'; +const posts = (await getCollection('posts')).sort((a, b) => b.data.dateModified.localeCompare(a.data.dateModified)); +const cats = Object.keys(CATEGORY_LABELS); +const site = Astro.site!.toString().replace(/\/$/, ''); +const ld = { '@type': 'CollectionPage', '@id': `${site}/posts#page`, name: '전체 글', url: `${site}/posts`, inLanguage: 'ko', publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, hasPart: posts.map((p) => ({ '@type': 'Article', headline: p.data.title, url: `${site}/posts/${p.id}`, dateModified: p.data.dateModified })) }; +--- + +
+
Archive

All Answers

궁금한 주제를 골라 보세요. 글 하나가 질문 하나에 답합니다.

+ {!posts.length &&

아직 발행한 글이 없습니다. 검토를 마친 글부터 차례로 올립니다.

} +
+ {cats.map((c) => { + const list = posts.filter((p) => p.data.category === c); + if (!list.length) return null; + return (

{c}. {CATEGORY_LABELS[c]}

{list.length}편
{list.map((p) => )}
); + })} +
+
+ + diff --git a/templates/supporters-astro/src/pages/robots.txt.ts b/templates/supporters-astro/src/pages/robots.txt.ts new file mode 100644 index 0000000..360359b --- /dev/null +++ b/templates/supporters-astro/src/pages/robots.txt.ts @@ -0,0 +1,10 @@ +import type { APIRoute } from 'astro'; +// 샘플: 전면 차단. 프로덕션(PUBLIC_INDEXABLE=true): 전면 허용 + AI 크롤러 명시 허용. +const AI_BOTS = ['GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'PerplexityBot', 'ClaudeBot', 'Claude-SearchBot', 'Google-Extended', 'Applebot-Extended', 'Bingbot', 'Yeti', 'Googlebot']; +export const GET: APIRoute = ({ site }) => { + const indexable = import.meta.env.PUBLIC_INDEXABLE === 'true'; + const body = indexable + ? [...AI_BOTS.map((b) => `User-agent: ${b}\nAllow: /`), 'User-agent: *\nAllow: /', `Sitemap: ${site}sitemap.xml`].join('\n\n') + '\n' + : `# 샘플 사이트. 색인 금지.\nUser-agent: *\nDisallow: /\n`; + return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); +}; diff --git a/templates/supporters-astro/src/pages/sitemap.xml.ts b/templates/supporters-astro/src/pages/sitemap.xml.ts new file mode 100644 index 0000000..dd92fdf --- /dev/null +++ b/templates/supporters-astro/src/pages/sitemap.xml.ts @@ -0,0 +1,22 @@ +import type { APIRoute } from 'astro'; +import { getCollection } from 'astro:content'; +import authors from '../data/authors.json'; +export const GET: APIRoute = async ({ site }) => { + const base = site!.toString().replace(/\/$/, ''); + const posts = await getCollection('posts'); + const today = new Date().toISOString().slice(0, 10); + const urls = [ + { loc: `${base}/`, lastmod: today }, + { loc: `${base}/posts`, lastmod: today }, + { loc: `${base}/clinic`, lastmod: today }, + { loc: `${base}/visit`, lastmod: today }, + { loc: `${base}/newsroom`, lastmod: today }, + { loc: `${base}/videos`, lastmod: today }, + { loc: `${base}/about`, lastmod: today }, + { loc: `${base}/corrections`, lastmod: today }, + ...Object.keys(authors.supporters).map((id) => ({ loc: `${base}/authors/${id}`, lastmod: today })), + ...posts.map((p) => ({ loc: `${base}/posts/${p.id}`, lastmod: p.data.dateModified })), + ]; + const xml = `\n\n${urls.map((u) => ` ${u.loc}${u.lastmod}`).join('\n')}\n\n`; + return new Response(xml, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } }); +}; diff --git a/templates/supporters-astro/src/pages/videos.astro b/templates/supporters-astro/src/pages/videos.astro new file mode 100644 index 0000000..fa5abc6 --- /dev/null +++ b/templates/supporters-astro/src/pages/videos.astro @@ -0,0 +1,109 @@ +--- +import Base from '../layouts/Base.astro'; +import VideoLite from '../components/VideoLite.astro'; +import data from '../data/videos.json'; +import { SITE_NAME, fact, surface, v, has, PENDING } from '../lib'; +const clinic = v(fact.shortName, '병원'); +const gu = surface('gangnamunni'); +type V = { id: string; title: string; published: string; views: number; likes: number | null; comments: number | null; duration: number }; +const site = Astro.site!.toString().replace(/\/$/, ''); +const ch = (data.channel ?? {}) as Record; +const topLong = (data.topLong ?? []) as V[]; const topAll = (data.top ?? []) as V[]; +const fetchedAt = data.fetchedAt ?? PENDING; +const fmt = (n: number) => n >= 10000 ? `${(n / 10000).toFixed(n >= 100000 ? 0 : 1)}만` : n.toLocaleString(); +const dur = (s: number) => s >= 3600 ? `${Math.floor(s / 3600)}:${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; +const shorts = (data.shortsInfo ?? []) as (V & { answer: string; qb?: string })[]; +const tabs: { key: string; label: string; desc: string; list: V[] }[] = [ + { key: 'long', label: '설명 영상 Top 10', desc: '수술 방법과 안전을 차분히 설명하는 영상입니다. 후기나 토크 영상은 넣지 않았습니다', list: topLong }, + { key: 'shorts', label: '1분 답변 영상', desc: '궁금한 것 하나에 1분 안에 답하는 짧은 영상입니다. 영상마다 서포터즈가 답을 한 문단으로 정리해 두었습니다', list: shorts }, + { key: 'all', label: '채널 인기 전체', desc: '주제와 길이를 가리지 않고 채널에서 가장 많이 본 영상입니다', list: topAll }, +]; +const ld = [ + { '@type': 'CollectionPage', '@id': `${site}/videos#page`, name: `${clinic} 유튜브 조회수 Top 10`, url: `${site}/videos`, inLanguage: 'ko', about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, ...(data.fetchedAt ? { dateModified: data.fetchedAt } : {}) }, + { '@type': 'ItemList', '@id': `${site}/videos#list`, name: `${clinic} 유튜브 조회수 상위 영상`, itemListOrder: 'https://schema.org/ItemListOrderDescending', numberOfItems: topAll.length, itemListElement: topAll.map((v, i) => ({ '@type': 'ListItem', position: i + 1, item: { '@type': 'VideoObject', name: v.title, url: `https://www.youtube.com/watch?v=${v.id}`, embedUrl: `https://www.youtube-nocookie.com/embed/${v.id}`, thumbnailUrl: `https://i.ytimg.com/vi/${v.id}/hqdefault.jpg`, uploadDate: v.published, duration: `PT${v.duration}S`, interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: v.views }, publisher: { '@type': 'Organization', name: ch.title, url: ch.url } } })) }, +]; +--- + +
+
+
Videos

Most Watched

원장이 수술 방법과 안전을 직접 설명하는 영상을 골라 모았습니다. 궁금한 영상을 여기서 바로 재생할 수 있습니다.

+
+
+
{has(ch.totalViews) ? fmt(ch.totalViews) : PENDING}누적 조회수공식 유튜브 채널 영상 {has(ch.videos) ? ch.videos.toLocaleString() : PENDING}편 합계 ({fetchedAt} 기준)
+
{v(ch.subscribers)}구독자{has(ch.url) ? {ch.handle} : PENDING}
+
{topLong[0] ? fmt(topLong[0].views) : PENDING}설명 영상 1위 조회수{topLong[0]?.title ?? '채널 영상 집계 뒤 표시'}
+
{shorts.length}정보형 쇼츠궁금한 것 하나에 1분 안에 답하는 영상. 서포터즈가 답을 글로도 정리했습니다.
+
+
+
+
+
+
+ {tabs.map((t, i) => )} +
+ {tabs.map((t, i) => ( + + ))} +

영상은 {clinic} 공식 유튜브 채널에서 재생됩니다. 조회수·좋아요·댓글 수는 {fetchedAt} 기준입니다.

+
+
+ + + diff --git a/templates/supporters-astro/src/pages/visit.astro b/templates/supporters-astro/src/pages/visit.astro new file mode 100644 index 0000000..f301bfc --- /dev/null +++ b/templates/supporters-astro/src/pages/visit.astro @@ -0,0 +1,47 @@ +--- +// 방문 안내. 병원 정보(/clinic)가 사실 표라면 이 페이지는 "예약하고, 찾아가고, 도착해서 뭘 하는지"의 행동 순서다. 값은 모두 factSheet.json에서 가져온다. +import Base from '../layouts/Base.astro'; +import Gallery from '../components/Gallery.astro'; +import { fact, site as S, SITE_NAME, clinicSchema, reservationUrl, v, has } from '../lib'; +const f = fact as Record; +const clinic = v(f.shortName, '병원'); +const site = Astro.site!.toString().replace(/\/$/, ''); +const ld = [ + { '@type': 'WebPage', '@id': `${site}/visit#page`, name: `${clinic} 방문 안내`, url: `${site}/visit`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } }, + clinicSchema(site), +]; +--- + +
+
Visit

Plan Your Visit

{clinic} 방문 안내. 예약부터 도착 후 동선까지 순서대로 정리했습니다. 주소·전화·진료시간의 근거는 병원 정보에 있습니다.

+ +

1. 예약하기

+

상담 예약 경로는 세 가지입니다. 어느 쪽이든 같은 병원 상담센터로 연결됩니다.

+
+ + + +
경로방법
전화{has(f.phone) ? {f.phone} : v(null)}
카카오톡 채널{has(f.kakao) ? {clinic} 상담 채널 : v(null)}
홈페이지{has(reservationUrl) ? 온라인 예약 페이지 : v(null)}
+ +

2. 진료시간 확인하기

+
{(f.hours?.rows ?? []).map((r: any) => )}{!(f.hours?.rows ?? []).length && }
요일진료
{r.days}{r.open ? `${r.open} ~ ${r.close}` : r.note}
{v(null)}
+

출처: {has(f.hours?.source) ? {f.hours.sourceLabel} : v(null)}. 진료시간은 바뀔 수 있으니 방문 전 전화로 한 번 더 확인하세요.

+ +

3. 찾아가기

+

주소 {v(f.address?.full)}{has(f.address?.postalNote) && <> (지번 {f.address.postalNote})}

+

지하철 {v(f.transit)}.{has(f.transitNote) && <> {f.transitNote}}

+

차로 올 때 {v(f.parking, `주차 안내 ${v(null)}`)}.

+ {S.visitMaps?.length > 0 && } + +

4. 도착하면

+

{v(S.arrivalGuide, `도착 후 동선은 병원 확인 뒤 싣습니다. ${v(null)}.`)} 층별 구성 전체는 병원 정보에 있습니다.

+ +

5. 상담 전에 읽어볼 글

+
    + {(S.visitReads ?? []).map((r) =>
  • {r.title} {r.note}
  • )} + {!(S.visitReads ?? []).length &&
  • 검토를 마친 글부터 차례로 연결합니다.
  • } +
+

{has(reservationUrl) && {clinic} 상담 예약}

+

{fact.sideEffectNotice}

+
+ diff --git a/templates/supporters-astro/src/styles/global.css b/templates/supporters-astro/src/styles/global.css new file mode 100644 index 0000000..f04ca68 --- /dev/null +++ b/templates/supporters-astro/src/styles/global.css @@ -0,0 +1,264 @@ +/* INFINITH Design Tokens (docs/DESIGN_SYSTEM.md) 적용. + 원색 금지 · 라인 아이콘 금지 · 이모지 금지 · 다크/라이트 섹션 리듬 · 다크 위 흰 카드 · 대각선 그림자 · 파스텔 상태색 */ +:root { + --primary-900: #0A1128; + --primary-800: #1A2B5E; + --primary-50: #F4F6FB; + --accent: #6C5CE7; + --grad-start: #4F1DA1; + --grad-end: #021341; + --slate-700: #334155; --slate-600: #475569; --slate-500: #64748B; --slate-200: #E2E8F0; --slate-100: #F1F5F9; + --purple-200: #E9D5FF; --purple-300: #D8B4FE; --blue-300: #93C5FD; + --st-critical-bg: #FFF0F0; --st-critical-text: #7C3A4B; --st-critical-border: #F5D5DC; + --st-warning-bg: #FFF6ED; --st-warning-text: #7C5C3A; --st-warning-border: #F5E0C5; + --st-good-bg: #F3F0FF; --st-good-text: #4A3A7C; --st-good-border: #D5CDF5; + --st-info-bg: #EFF0FF; --st-info-text: #3A3F7C; --st-info-border: #C5CBF5; + --shadow: 3px 4px 12px rgba(0,0,0,0.06); + --shadow-hover: 4px 6px 16px rgba(0,0,0,0.09); + --max: 760px; --wide: 1280px; + font-size: 17px; +} +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; background: #fff; color: var(--primary-900); + font-family: Pretendard, "Pretendard Variable", Inter, -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Noto Sans KR", system-ui, sans-serif; + line-height: 1.7; word-break: keep-all; -webkit-font-smoothing: antialiased; +} +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +img { max-width: 100%; height: auto; display: block; } +h1, h2, h3 { line-height: 1.3; margin: 0; color: var(--primary-900); } +h1 { font-size: 2.1rem; font-weight: 800; letter-spacing: -0.02em; } +h2 { font-size: 1.45rem; font-weight: 700; margin-top: 2.8rem; margin-bottom: 0.8rem; letter-spacing: -0.01em; } +h3 { font-size: 1.12rem; font-weight: 700; margin-top: 1.8rem; margin-bottom: 0.5rem; } +.serif { font-family: "Playfair Display", Georgia, serif; } +p { margin: 0 0 1rem; color: var(--slate-700); } +ul, ol { padding-left: 1.3rem; margin: 0 0 1rem; color: var(--slate-700); } +li { margin-bottom: 0.35rem; } +table { width: 100%; border-collapse: separate; border-spacing: 0; margin: 1rem 0 1.5rem; font-size: 0.95rem; background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; overflow: hidden; box-shadow: var(--shadow); } +th, td { padding: 0.7rem 0.9rem; text-align: left; vertical-align: top; border-bottom: 1px solid var(--slate-100); } +tr:last-child td { border-bottom: 0; } +th { background: var(--primary-900); color: #fff; font-weight: 600; font-size: 0.88rem; letter-spacing: 0.02em; } +td { color: var(--slate-700); } +.table-wrap { overflow-x: auto; } + +.wrap { max-width: var(--max); margin: 0 auto; padding: 0 1.5rem; } +.wrap-wide { max-width: var(--wide); margin: 0 auto; padding: 0 1.5rem; } + +/* sections: dark / light rhythm */ +.section { padding: 4rem 0; } +.section.dark { background: var(--primary-900); background-image: radial-gradient(ellipse at 20% 0%, rgba(108,92,231,0.18), transparent 55%); color: #fff; } +.section.dark h1, .section.dark h2, .section.dark h3 { color: #fff; } +.section.dark p { color: rgba(255,255,255,0.8); } +.section.light { background: #fff; } +.section.tint { background: var(--primary-50); } +.grad-text { background: linear-gradient(to right, var(--purple-300), var(--blue-300)); -webkit-background-clip: text; background-clip: text; color: transparent; } + +/* header */ +.site-header { position: sticky; top: 0; z-index: 10; background: rgba(255,255,255,0.9); backdrop-filter: blur(12px); border-bottom: 1px solid var(--slate-100); } +.site-header .bar { display: flex; align-items: center; justify-content: space-between; height: 68px; } +.brand { display: flex; align-items: center; gap: 0.6rem; color: var(--primary-900); font-weight: 800; font-size: 1.1rem; } +.brand .mark { display: none; } +.brand-logo { height: 40px; width: auto; display: block; } + .brand-name { height: auto; font-weight: 800; font-size: 1.05rem; color: var(--primary-900); letter-spacing: -0.01em; white-space: nowrap; } +.brand-sep { width: 1px; height: 26px; background: var(--slate-200); margin: 0 0.35rem; } +.brand-text { display: flex; align-items: baseline; gap: 0.45rem; } +.brand small { font-family: "Playfair Display", Georgia, serif; font-weight: 700; color: var(--slate-500); font-size: 0.8rem; letter-spacing: 0.05em; } +.nav a { color: var(--slate-600); margin-left: 1.3rem; font-size: 0.93rem; font-weight: 500; } +.nav a:hover { color: var(--primary-900); text-decoration: none; } +.sample-banner { background: var(--st-warning-bg); color: var(--st-warning-text); border-bottom: 1px solid var(--st-warning-border); font-size: 0.83rem; text-align: center; padding: 0.5rem 1rem; font-weight: 500; } + +/* pills & badges */ +.pill { display: inline-flex; align-items: center; gap: 0.4rem; border-radius: 999px; padding: 0.35rem 0.9rem; font-size: 0.78rem; font-weight: 600; letter-spacing: 0.04em; } +.pill.grad { background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; } +.pill.ghost { background: rgba(255,255,255,0.1); color: var(--purple-200); border: 1px solid rgba(255,255,255,0.15); } +.pill.soft { background: var(--st-good-bg); color: var(--st-good-text); border: 1px solid var(--st-good-border); } +.badge { display: inline-block; font-size: 0.74rem; font-weight: 600; padding: 0.15rem 0.6rem; border-radius: 999px; margin-left: 0.4rem; vertical-align: middle; border: 1px solid transparent; } +.badge.reviewed { background: var(--st-good-bg); color: var(--st-good-text); border-color: var(--st-good-border); } +.badge.pending { background: var(--st-warning-bg); color: var(--st-warning-text); border-color: var(--st-warning-border); } +.btn { display: inline-flex; align-items: center; gap: 0.5rem; border-radius: 999px; padding: 0.75rem 1.4rem; font-weight: 600; font-size: 0.95rem; text-decoration: none; } +.btn.primary { background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; box-shadow: var(--shadow); } +.btn.secondary { background: #fff; color: var(--grad-end); border: 1px solid var(--slate-200); } +.btn:hover { text-decoration: none; box-shadow: var(--shadow-hover); } + +/* article */ +.eyebrow { color: var(--accent); font-weight: 700; font-size: 0.8rem; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 0.7rem; font-family: Inter, Pretendard, sans-serif; } +.article-head { padding: 3rem 0 1.2rem; } +.meta { color: var(--slate-500); font-size: 0.88rem; display: flex; flex-wrap: wrap; gap: 0.4rem 1.1rem; margin-top: 1rem; } +.summary { background: #F2F6FF; border: 1px solid #E3EAFB; border-radius: 16px; padding: 1.3rem 1.5rem; margin: 1.6rem 0 2.2rem; box-shadow: var(--shadow); } +.summary h2 { margin: 0 0 0.6rem; font-size: 0.8rem; color: var(--grad-start); letter-spacing: 0.08em; text-transform: uppercase; font-family: Inter, Pretendard, sans-serif; } +.summary ol { margin: 0; padding-left: 1.2rem; } +.summary li { margin-bottom: 0.35rem; font-weight: 500; color: var(--primary-900); } +.figure { margin: 1.8rem 0; } +.figure img { border-radius: 16px; box-shadow: var(--shadow); } +.figure figcaption { font-size: 0.83rem; color: var(--slate-500); margin-top: 0.6rem; } +.video { position: relative; aspect-ratio: 16 / 9; background: var(--primary-900); border-radius: 16px; overflow: hidden; margin: 0.6rem 0 0.5rem; box-shadow: var(--shadow); } +.video iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; } +.video-note { font-size: 0.85rem; color: var(--slate-500); margin-bottom: 1.4rem; } +.faq details { background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; padding: 0.95rem 1.2rem; margin-bottom: 0.6rem; box-shadow: var(--shadow); } +.faq summary { cursor: pointer; font-weight: 700; list-style: none; color: var(--primary-900); } +.faq summary::-webkit-details-marker { display: none; } +.faq summary::before { content: "Q"; display: inline-grid; place-items: center; width: 1.5rem; height: 1.5rem; border-radius: 999px; background: var(--st-good-bg); color: var(--accent); font-size: 0.75rem; margin-right: 0.6rem; font-family: Inter, sans-serif; } +.faq details p { margin: 0.7rem 0 0 2.1rem; color: var(--slate-600); } +.review-box { display: grid; grid-template-columns: 56px 1fr; gap: 1rem; align-items: start; background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; padding: 1.1rem 1.25rem; margin: 2.4rem 0 1rem; font-size: 0.92rem; box-shadow: var(--shadow); color: var(--slate-700); } +.review-box img { width: 56px; height: 56px; object-fit: cover; border-radius: 50%; } +.review-box .avatar { width: 56px; height: 56px; border-radius: 50%; background: linear-gradient(135deg, var(--grad-start), var(--grad-end)); display: grid; place-items: center; color: #fff; font-weight: 800; } +.sources { font-size: 0.88rem; color: var(--slate-600); } +.sources li { margin-bottom: 0.3rem; } +.disclosure { font-size: 0.84rem; color: var(--slate-500); border-top: 1px solid var(--slate-100); padding-top: 1.1rem; margin-top: 2.2rem; } +.fact-block { background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; padding: 1.3rem 1.5rem; margin: 2.4rem 0; font-size: 0.93rem; box-shadow: var(--shadow); } +.fact-block h2 { margin: 0 0 0.8rem; font-size: 1rem; } +.fact-block dl { display: grid; grid-template-columns: 7.5em 1fr; gap: 0.4rem 0.9rem; margin: 0; } +.fact-block dt { color: var(--slate-500); } +.fact-block dd { margin: 0; color: var(--slate-700); } +.section.dark .fact-block { color: var(--slate-700); } + +/* home */ +.hero { padding: 5rem 0 4rem; } +.hero h1 { font-size: 2.6rem; font-weight: 800; letter-spacing: -0.02em; line-height: 1.2; margin-top: 1rem; } +.hero .lede { font-size: 1.1rem; color: rgba(255,255,255,0.8); margin-top: 1.2rem; max-width: 680px; } +.hero .actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 1.6rem; } +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.25rem; } +.card { background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; padding: 1.4rem 1.5rem; display: flex; flex-direction: column; gap: 0.55rem; box-shadow: var(--shadow); transition: box-shadow .2s, transform .2s; } +.card:hover { box-shadow: var(--shadow-hover); transform: translateY(-2px); } +.card .cat { font-size: 0.75rem; color: var(--accent); font-weight: 700; letter-spacing: 0.06em; font-family: Inter, Pretendard, sans-serif; } +.card h3 { margin: 0; font-size: 1.08rem; color: var(--primary-900); } +.card h3 a { color: var(--primary-900); } +.card p { color: var(--slate-600); font-size: 0.93rem; margin: 0; flex: 1; } +.card .foot { font-size: 0.78rem; color: var(--slate-500); } +.section.dark .card p, .section.dark .card h3, .section.dark .card h3 a { color: var(--primary-900); } +.section.dark .card p { color: var(--slate-600); } +.section-title { display: flex; align-items: baseline; justify-content: space-between; margin: 0 0 1.4rem; } +.section-title h2 { margin: 0; font-size: 1.7rem; } +.section-title .sub { color: var(--slate-600); font-size: 1rem; margin: 0.3rem 0 0; } +.section.dark .section-title .sub { color: var(--purple-200); } +.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1rem; margin: 2rem 0 0; } +.kpi { background: linear-gradient(to right, #fff3eb, #e4cfff, #f5f9ff); border-radius: 16px; padding: 1.1rem 1.2rem; box-shadow: var(--shadow); } +.kpi b { display: block; font-size: 1.6rem; letter-spacing: -0.02em; color: var(--primary-900); font-family: "Playfair Display", Georgia, serif; } +.kpi span { font-size: 0.8rem; color: var(--slate-600); font-weight: 500; } +.notice { background: var(--st-warning-bg); color: var(--st-warning-text); border: 1px solid var(--st-warning-border); border-radius: 16px; padding: 0.95rem 1.2rem; font-size: 0.9rem; margin: 1.4rem 0; } + +/* footer */ +.site-footer { margin-top: 0; background: var(--primary-900); color: rgba(255,255,255,0.75); padding: 3rem 0 3rem; font-size: 0.85rem; } +.site-footer .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; } +.site-footer p { margin-bottom: 0.5rem; color: rgba(255,255,255,0.75); } +.site-footer strong { color: #fff; } +.site-footer a { color: var(--purple-300); } +.site-footer .powered { margin-top: 2rem; padding-top: 1.2rem; border-top: 1px solid rgba(255,255,255,0.1); font-family: "Playfair Display", Georgia, serif; letter-spacing: 0.05em; color: var(--purple-300); font-size: 0.8rem; } +@media (max-width: 720px) { + .brand-logo { height: 32px; } + .brand-text small { display: none; } + :root { font-size: 16px; } + h1 { font-size: 1.7rem; } + .hero h1 { font-size: 1.9rem; } + .hero { padding: 3.5rem 0 3rem; } + .section { padding: 3rem 0; } + .site-footer .cols { grid-template-columns: 1fr; } + .nav a { margin-left: 0.9rem; font-size: 0.86rem; } + .fact-block dl { grid-template-columns: 6em 1fr; } +} + +/* ── INFINITH 실측 규격 보정 (plan/view-clinic · discovery 랜딩 기준) ── */ +/* 히어로: 라이트 파스텔 라디얼 (PlanHeader.tsx 공식) */ +.hero-light { position: relative; overflow: hidden; padding: 5rem 0 4.5rem; + background: radial-gradient(ellipse at top left, #e0e7ff, transparent 50%), radial-gradient(ellipse at bottom right, #fce7f3, transparent 50%), radial-gradient(ellipse at center, #f5f3ff, transparent 60%); } +.hero-light .blob { position: absolute; border-radius: 999px; filter: blur(60px); pointer-events: none; } +.hero-light .blob.a { top: 2rem; left: 2rem; width: 18rem; height: 18rem; background: rgba(108,92,231,0.10); } +.hero-light .blob.b { bottom: 2rem; right: 2rem; width: 24rem; height: 24rem; background: rgba(213,205,245,0.30); } +.hero-light .inner { position: relative; display: grid; grid-template-columns: 1fr auto; gap: 2rem; align-items: center; } +.hero-light h1 { font-size: 2.86rem; font-weight: 800; letter-spacing: -0.02em; line-height: 1.18; color: var(--primary-900); } +.hero-light h1 .accent { color: var(--accent); } +.hero-light .lede { font-size: 1.08rem; color: var(--slate-600); margin-top: 1.1rem; max-width: 640px; } +.hero-light .chips { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1.3rem; } +.chip { display: inline-flex; align-items: center; gap: 0.4rem; border-radius: 999px; background: rgba(255,255,255,0.6); backdrop-filter: blur(6px); border: 1px solid rgba(255,255,255,0.4); padding: 0.3rem 0.8rem; font-size: 0.85rem; font-weight: 500; color: var(--slate-700); } +.chip .dot { width: 6px; height: 6px; border-radius: 999px; background: var(--accent); } +.ring { width: 8.5rem; height: 8.5rem; border-radius: 999px; background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; display: flex; flex-direction: column; align-items: center; justify-content: center; box-shadow: 0 10px 25px rgba(2,19,65,0.25); } +.ring b { font-family: "Playfair Display", Georgia, serif; font-size: 2.6rem; line-height: 1; font-weight: 700; } +.ring span { font-size: 0.72rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--purple-200); margin-top: 0.3rem; font-family: Inter, sans-serif; } + +/* 섹션 제목: 영문 Playfair + 한글 부제 (SectionWrapper 규격) */ +.sec-head { margin-bottom: 1.6rem; } +.sec-head h2 { font-family: "Playfair Display", Georgia, serif; font-size: 2rem; font-weight: 700; margin: 0; letter-spacing: 0; } +.sec-head .sub { margin: 0.35rem 0 0; color: var(--slate-600); font-size: 1rem; } +.section.dark .sec-head h2 { background: linear-gradient(to right, var(--purple-300), var(--blue-300)); -webkit-background-clip: text; background-clip: text; color: transparent; display: inline-block; } +.section.dark .sec-head .sub { color: var(--purple-200); } +.sec-head .row { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; } +.sec-head .eyebrow { margin-bottom: 0.5rem; } + +/* 다크 섹션 KPI: 다크 글래스 카드 + Playfair 숫자 #AF90FF (WhyNow.tsx 규격) */ +.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1.25rem; } +.stat { background: rgba(255,255,255,0.10); border: 1px solid rgba(255,255,255,0.10); backdrop-filter: blur(4px); border-radius: 16px; padding: 1.5rem 1.6rem; } +.stat b { display: block; font-family: "Playfair Display", Georgia, serif; font-size: 2.6rem; font-weight: 700; line-height: 1.15; color: #AF90FF; } +.stat .label { display: block; color: #fff; font-weight: 700; font-size: 0.98rem; margin-top: 0.4rem; } +.stat .desc { display: block; color: rgba(255,255,255,0.7); font-size: 0.86rem; margin-top: 0.35rem; line-height: 1.6; } + +/* 라이트 섹션 카드: 표준 카드(bg-white · border-slate-100 · shadow-sm) */ +.section.light .card { box-shadow: 0 1px 2px rgba(0,0,0,0.05); } +.section.light .card:hover { box-shadow: var(--shadow-hover); } + +/* 헤더: INFINITH 헤더 규격 (흰 배경, 우측 그라디언트 pill CTA) */ +.site-header .right { display: flex; align-items: center; gap: 1rem; } +.site-header .cta { background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; border-radius: 999px; padding: 0.5rem 1.05rem; font-size: 0.86rem; font-weight: 600; } +.site-header .cta:hover { text-decoration: none; box-shadow: var(--shadow); } +.nav a, .site-header .cta, .brand-text { white-space: nowrap; } +/* 720~1040px: 메뉴 5개가 한 줄에 들어가도록 간격·크기 축소, 브랜드 영문 서브 숨김 */ +@media (max-width: 1040px) { + .nav a { margin-left: 0.85rem; font-size: 0.86rem; } + .site-header .right { gap: 0.7rem; } + .site-header .cta { padding: 0.45rem 0.85rem; font-size: 0.82rem; } + .brand-text small { display: none; } +} +@media (max-width: 720px) { + .hero-light .inner { grid-template-columns: 1fr; } + .hero-light h1 { font-size: 1.9rem; } + .ring { display: none; } + .site-header .cta { display: none; } + .sec-head h2 { font-size: 1.6rem; } +} + +/* ── 미디어: 라이트 유튜브 임베드 · 갤러리 · 썸네일 카드 ── */ +.yt { margin: 1.4rem 0 1.8rem; } +.yt-btn { position: relative; display: block; width: 100%; aspect-ratio: 16 / 9; padding: 0; border: 0; border-radius: 16px; overflow: hidden; cursor: pointer; background: var(--primary-900); box-shadow: var(--shadow); } +.yt-btn img { width: 100%; height: 100%; object-fit: cover; transition: transform .3s; } +.yt-btn:hover img { transform: scale(1.03); } +.yt-btn .play { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 64px; height: 64px; border-radius: 999px; background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; display: grid; place-items: center; box-shadow: 0 8px 24px rgba(2,19,65,0.35); } +.yt-frame { aspect-ratio: 16 / 9; border-radius: 16px; overflow: hidden; box-shadow: var(--shadow); background: #000; } +.yt-frame iframe { width: 100%; height: 100%; border: 0; display: block; } +.yt figcaption { margin-top: 0.6rem; font-size: 0.86rem; color: var(--slate-500); display: flex; flex-direction: column; gap: 0.1rem; } +.yt figcaption strong { color: var(--primary-900); font-size: 0.95rem; } +.yt.compact { margin: 0; } +.yt.compact .play { width: 48px; height: 48px; } +.yt.compact figcaption strong { font-size: 0.9rem; } +.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1.1rem; margin: 0.8rem 0 1.6rem; } +.gallery { display: grid; gap: 1rem; margin: 1.4rem 0 1.8rem; } +.gallery.cols-2 { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); } +.gallery.cols-3 { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } +.gallery .figure { margin: 0; } +.gallery .figure img { aspect-ratio: 16 / 9; object-fit: cover; width: 100%; } +.figure.portrait img { aspect-ratio: auto; } +.card.has-thumb { padding: 0; overflow: hidden; } +.card.has-thumb .thumb img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; } +.card.has-thumb .body { padding: 1.2rem 1.4rem 1.4rem; display: flex; flex-direction: column; gap: 0.5rem; flex: 1; } +.card.has-thumb .body p { flex: 1; } +.doctor-strip { display: flex; gap: 1rem; align-items: center; background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; padding: 1rem 1.2rem; margin: 1.4rem 0; box-shadow: var(--shadow); } +.doctor-strip img { width: 72px; height: 92px; object-fit: cover; object-position: top; border-radius: 12px; } +.doctor-strip .name { font-weight: 800; color: var(--primary-900); } +.doctor-strip .creds { font-size: 0.85rem; color: var(--slate-600); margin: 0.2rem 0 0; } +.hero-light .hero-img { border-radius: 16px; overflow: hidden; box-shadow: 0 10px 30px rgba(2,19,65,0.18); max-width: 440px; } +.hero-light .hero-img img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; } +.hero-light .hero-img.square { max-width: 420px; } +.hero-light .hero-img.collage { max-width: 500px; } +.hero-light .hero-img.collage img { aspect-ratio: 500 / 425; } +.hero-light .hero-img.square img { aspect-ratio: 1 / 1; } +@media (max-width: 720px) { .hero-light .hero-img { max-width: 100%; } } + +/* ── 이전 페이지 버튼 · 플로팅 맨 위로 ── */ +.backbar-wrap { background: #fff; border-bottom: 1px solid var(--slate-100); } +.backbar { padding: 0.8rem 1.5rem; } +.backbtn { display: inline-flex; align-items: center; gap: 0.35rem; background: #fff; border: 1px solid var(--slate-200); color: var(--grad-end); border-radius: 999px; padding: 0.4rem 0.95rem 0.4rem 0.7rem; font-size: 0.85rem; font-weight: 600; box-shadow: var(--shadow); } +.backbtn:hover { text-decoration: none; box-shadow: var(--shadow-hover); } +.totop { position: fixed; right: 1.4rem; bottom: 1.6rem; width: 48px; height: 48px; border: 0; border-radius: 999px; background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; display: grid; place-items: center; cursor: pointer; box-shadow: 0 8px 24px rgba(2,19,65,0.30); opacity: 0; transform: translateY(12px); pointer-events: none; transition: opacity .2s, transform .2s, box-shadow .2s; z-index: 20; } +.totop.show { opacity: 1; transform: none; pointer-events: auto; } +.totop:hover { box-shadow: 0 10px 28px rgba(2,19,65,0.40); } +@media (max-width: 720px) { .totop { right: 1rem; bottom: 1.1rem; width: 44px; height: 44px; } } diff --git a/templates/supporters-astro/tsconfig.json b/templates/supporters-astro/tsconfig.json new file mode 100644 index 0000000..8bf91d3 --- /dev/null +++ b/templates/supporters-astro/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/templates/supporters-astro/vercel.json b/templates/supporters-astro/vercel.json new file mode 100644 index 0000000..bcedc3b --- /dev/null +++ b/templates/supporters-astro/vercel.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "astro", + "buildCommand": "npm run build", + "outputDirectory": "dist", + "build": { + "env": { + "SITE_URL": "https://supporters-__CLINIC_ID__.vercel.app", + "PUBLIC_INDEXABLE": "false" + } + }, + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "X-Robots-Tag", + "value": "noindex, nofollow" + } + ] + } + ], + "cleanUrls": true, + "trailingSlash": false +} \ No newline at end of file