From e60ef04503b7fd623958f60afff2f193799d175d Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Sat, 12 Sep 2026 14:27:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(supporters):=20=EC=98=81=EC=96=B4=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9D=98=20=EB=B3=91=EC=9B=90=20=EA=B3=A0?= =?UTF-8?q?=EC=9C=A0=20=EC=82=AC=EC=8B=A4=EC=9D=84=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EC=97=90=EC=84=9C=20=EC=9D=BD=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B0=94=EA=BE=B8=EA=B3=A0=20=ED=85=9C=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=EC=9E=AC=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit templates/supporters-astro 는 supporters/ 에서 만들어지는 파생물인데, 정본이 된 supporters/ 코드에 뷰 고유 사실이 문장으로 박혀 있었다. 그대로 내보내면 다른 병원 사이트에 틀린 사실이 나간다. 빈 데이터로 빌드해 보니 실제로 "Founded 확인 대기 · Sinnonhyeon Station Exit 3" 가 나왔다. 데이터로 바꾼 곳 (전부 factSheetEn.json 에 이미 있던 값이다) - 히어로 이미지 alt: site.json 의 heroImageAltEn 을 읽고, 없으면 한국어 alt 를 쓴다. - 히어로 칩: 설립연도와 위치를 데이터에 있는 것만 잇는다. 둘 다 없으면 칩을 내지 않는다. - 방문 카드 요약: factSheetEn 의 airportShort 가 있을 때만 경로를 적는다. - 병원 카드 요약: 설립연도와 factSheetEn 의 building 첫 문장을 데이터에서 잇는다. - 공항 카드: factSheetEn 의 airport 가 있을 때만 카드를 낸다. - 영문 플래너 병원명: factSheetEn 의 shortName 을 읽고, 영문 표기가 없는 병원은 한국어 상호를 쓴다. - 404 안내: 상호를 factSheet 에서 읽는다. 템플릿 빈 데이터셋에 factSheetEn.json 이 없어서 뷰의 영문 사실이 통째로 남고 있었다. 빈 factSheetEn.json 을 만들고 site.json 에 heroImageAltEn 자리를 넣었다. export_template.mjs 의 누락 검사가 상호만 찾아 지명으로 적힌 사실을 놓쳤다. 지명·건물 표현을 패턴에 넣고, 문장을 그리는 코드(src/pages·layouts·components·lib)만 내보내기를 멈추게 하고 데이터·빌드 도구는 알림만 하도록 나눴다. 빌드 도구의 기본 인자 때문에 검사가 늘 실패해 신호가 되지 못하던 문제도 함께 풀린다. 검증 - 빈 데이터 템플릿 빌드 14페이지, 영어 홈에 병원 고유 사실 0건, 사실을 말하던 조각은 생략되고 요약문만 일반 문장으로 남는다. - 뷰 사이트 빌드 32페이지, 영어 홈의 칩·공항 카드·요약문이 모두 그대로 나온다. - 게이트 41/41 통과, check.mjs 오류 0건(경고 23건은 기존 건). - 루트 tsc --noEmit 0 에러, 내보내기 종료코드 0. Co-Authored-By: Claude Opus 5 (1M context) --- supporters/scripts/export_template.mjs | 12 +- .../scripts/template/data/factSheetEn.json | 25 ++ supporters/scripts/template/data/site.json | 3 +- supporters/src/data/factSheetEn.json | 1 + supporters/src/data/site.json | 157 ++++++++++-- supporters/src/pages/404.astro | 5 +- supporters/src/pages/en/index.astro | 28 +- supporters/src/pages/en/plan.astro | 5 +- templates/supporters-astro/CLAUDE.md | 2 +- .../scripts/export_template.mjs | 12 +- .../src/data/factSheetEn.json | 25 ++ templates/supporters-astro/src/data/site.json | 3 +- .../supporters-astro/src/layouts/Base.astro | 102 ++++---- templates/supporters-astro/src/lib/i18n.ts | 48 ++++ .../supporters-astro/src/pages/404.astro | 5 +- .../supporters-astro/src/pages/en/index.astro | 112 ++++++++ .../supporters-astro/src/pages/en/plan.astro | 5 +- .../supporters-astro/src/pages/index.astro | 2 +- .../supporters-astro/src/pages/sitemap.xml.ts | 1 + .../supporters-astro/src/pages/visit.astro | 10 +- .../supporters-astro/src/styles/global.css | 241 +----------------- 21 files changed, 472 insertions(+), 332 deletions(-) create mode 100644 supporters/scripts/template/data/factSheetEn.json create mode 100644 templates/supporters-astro/src/data/factSheetEn.json create mode 100644 templates/supporters-astro/src/lib/i18n.ts create mode 100644 templates/supporters-astro/src/pages/en/index.astro diff --git a/supporters/scripts/export_template.mjs b/supporters/scripts/export_template.mjs index e928188..410d2e0 100644 --- a/supporters/scripts/export_template.mjs +++ b/supporters/scripts/export_template.mjs @@ -45,9 +45,17 @@ pkg.name = 'supporters-template'; writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); // 남은 병원 고유 문자열 검사 -const CLINIC_RE = /뷰성형외과|viewclinic|ViewclinicKR|안성민|o2oteam/; +// 상호뿐 아니라 지명·건물 같은 병원 고유 사실도 잡는다. 상호만 찾으면 +// "Sinnonhyeon Station Exit 3" 처럼 지명으로 적힌 사실이 그대로 템플릿에 남는다. +const CLINIC_RE = /뷰성형외과|viewclinic|ViewclinicKR|안성민|o2oteam|View Plastic|Sinnonhyeon|Bongeunsa|Gimpo|신논현|봉은사|19-storey/; 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); } +// 문장을 그리는 코드에 병원 고유 사실이 박히면 다른 병원 화면에 틀린 말이 나가므로 내보내기를 멈춘다. +// 데이터(src/data)는 워커가 병원마다 갈아끼우고 빌드 도구(scripts)는 고객이 보지 않으므로 알림만 한다. +const RENDER_RE = /^src\/(pages|layouts|components|lib)\//; +const blocking = leaks.filter((f) => RENDER_RE.test(f)); +const notes = leaks.filter((f) => !RENDER_RE.test(f)); +if (notes.length) console.log('참고 · 병원 이름이 남은 파일(고객 화면 아님):\n ' + notes.join('\n ')); +if (blocking.length) { console.error('고객 화면 코드에 병원 고유 사실이 남았습니다. 데이터에서 읽도록 고치세요:\n ' + blocking.join('\n ')); process.exit(1); } console.log(`템플릿 내보내기 완료: ${DEST}${existsSync(join(DEST, 'node_modules')) ? '' : ' (npm ci 필요)'}`); diff --git a/supporters/scripts/template/data/factSheetEn.json b/supporters/scripts/template/data/factSheetEn.json new file mode 100644 index 0000000..1e6a329 --- /dev/null +++ b/supporters/scripts/template/data/factSheetEn.json @@ -0,0 +1,25 @@ +{ + "_comment": "영어 페이지용 병원 사실. 값의 원본은 factSheet.json 이고 여기에는 영어 표기만 둔다. 비어 있는 값은 영어 화면에서 해당 조각을 렌더하지 않는다. 지어내지 않는다.", + "name": "", + "shortName": "", + "kind": "", + "areaLabel": "", + "address": { + "full": "", + "mapQuery": "" + }, + "transit": "", + "airport": "", + "airportShort": "", + "parking": "", + "hoursDays": { + "월·화·수·목": "", + "금": "", + "토": "", + "일·공휴일": "" + }, + "hoursClosed": "", + "building": "", + "arrival": "", + "specialties": [] +} diff --git a/supporters/scripts/template/data/site.json b/supporters/scripts/template/data/site.json index c6e9fa2..2790d81 100644 --- a/supporters/scripts/template/data/site.json +++ b/supporters/scripts/template/data/site.json @@ -23,5 +23,6 @@ "newsOutletsExample": "", "sponsorNotice": "", "ga4MeasurementId": "", - "_comment_ga4": "GA4 측정 ID(G-XXXX). 비우면 태그를 넣지 않는다. 키 이벤트 이름은 data/ai_channels.json conversion_events 와 같다." + "_comment_ga4": "GA4 측정 ID(G-XXXX). 비우면 태그를 넣지 않는다. 키 이벤트 이름은 data/ai_channels.json conversion_events 와 같다.", + "heroImageAltEn": "" } diff --git a/supporters/src/data/factSheetEn.json b/supporters/src/data/factSheetEn.json index 6e128a4..47d3e0d 100644 --- a/supporters/src/data/factSheetEn.json +++ b/supporters/src/data/factSheetEn.json @@ -10,6 +10,7 @@ }, "transit": "Line 9, Sinnonhyeon Station, Exit 3. Within 50 m of the exit. The whole building is the clinic, so you can see it from the exit.", "airport": "From Incheon Airport: take the AREX airport railroad to Gimpo Airport Station, then Line 9 express to Sinnonhyeon Station with no transfer. About 60 to 70 minutes. A taxi takes 60 to 90 minutes depending on traffic.", + "airportShort": "Line 9 from Gimpo Airport with no transfer", "parking": "The street in front of the main entrance is one-way. Enter from the pharmacy alley and turn right at the crossroads. The car park is on the left.", "hoursDays": { "월·화·수·목": "Mon to Thu", diff --git a/supporters/src/data/site.json b/supporters/src/data/site.json index 0444c6d..df66620 100644 --- a/supporters/src/data/site.json +++ b/supporters/src/data/site.json @@ -6,44 +6,149 @@ "tagline": "뷰성형외과를 알아보는 사람들의 질문에 먼저 답하는 서포터즈 매체 (샘플)", "heroTitle": "궁금한 것부터 답합니다.", "editorId": "ansm", - "logo": { "src": "/img/view-logo.png", "alt": "뷰성형외과 View Plastic Surgery", "width": 184, "height": 66 }, - "logoFoot": { "src": "/img/view-logo-foot.png", "alt": "뷰성형외과", "width": 138, "height": 49 }, - "heroImage": { "src": "/img/clinic/hero-collage.jpg", "alt": "뷰성형외과 빌딩 외관과 내부 공간 콜라주. 상담센터, 수술실, 수술센터 복도", "width": 1000, "height": 850 }, - "buildingImage": { "src": "/img/clinic/building-clean.jpg", "alt": "뷰성형외과 빌딩 외관. 유리 외벽에 VIEW 로고가 있는 고층 건물", "caption": "뷰성형외과 빌딩 외관. 출처: 공식 홈페이지 병원둘러보기" }, + "logo": { + "src": "/img/view-logo.png", + "alt": "뷰성형외과 View Plastic Surgery", + "width": 184, + "height": 66 + }, + "logoFoot": { + "src": "/img/view-logo-foot.png", + "alt": "뷰성형외과", + "width": 138, + "height": 49 + }, + "heroImage": { + "src": "/img/clinic/hero-collage.jpg", + "alt": "뷰성형외과 빌딩 외관과 내부 공간 콜라주. 상담센터, 수술실, 수술센터 복도", + "width": 1000, + "height": 850 + }, + "buildingImage": { + "src": "/img/clinic/building-clean.jpg", + "alt": "뷰성형외과 빌딩 외관. 유리 외벽에 VIEW 로고가 있는 고층 건물", + "caption": "뷰성형외과 빌딩 외관. 출처: 공식 홈페이지 병원둘러보기" + }, "imageCredit": "사진 출처: 뷰성형외과 공식 홈페이지 병원둘러보기", "homeVideos": [ - { "id": "Nbemu6mL_uY", "title": "가장 VIEW다운 가치 : 안전과 신뢰의 약속", "published": "2026-05-19" }, - { "id": "_OZRSJIDWgs", "title": "스포르자가 또 방문한 이유, 모티바 프리저베 교육 현장", "published": "2026-08-20" }, - { "id": "xohC5C6aY70", "title": "모티바 프리저베는 수술 직후 만세가 가능하다?", "published": "2026-08-31" } + { + "id": "Nbemu6mL_uY", + "title": "가장 VIEW다운 가치 : 안전과 신뢰의 약속", + "published": "2026-05-19" + }, + { + "id": "_OZRSJIDWgs", + "title": "스포르자가 또 방문한 이유, 모티바 프리저베 교육 현장", + "published": "2026-08-20" + }, + { + "id": "xohC5C6aY70", + "title": "모티바 프리저베는 수술 직후 만세가 가능하다?", + "published": "2026-08-31" + } ], "insideSummary": "19층 자체 사옥. 상담 4~5층, 검진 지하 1층, 수술 11~14층, 입원 8·10층.", "insideGallery": [ - { "src": "/img/clinic/consult-1.jpg", "alt": "뷰성형외과 4~5층 상담센터 대기 공간. 검은 소파와 밝은 조명", "caption": "4~5F 상담센터" }, - { "src": "/img/clinic/exam-1.jpg", "alt": "뷰성형외과 지하 1층 검진센터 입구. Examination Center 간판", "caption": "B1F 검진센터" }, - { "src": "/img/clinic/surgery-2.jpg", "alt": "뷰성형외과 수술실 내부. 수술대와 무영등", "caption": "11~14F 수술센터" }, - { "src": "/img/clinic/ward-1.jpg", "alt": "뷰성형외과 입원실. 창가의 1인 침대", "caption": "8·10F 입원센터" }, - { "src": "/img/clinic/vip-lounge.jpg", "alt": "뷰성형외과 VIP 라운지. 어두운 톤의 라운지 공간", "caption": "VIP 라운지" }, - { "src": "/img/clinic/dental-15f.jpg", "alt": "뷰성형외과 15층 치과 진료실. 파란 치과 의자가 줄지어 있음", "caption": "15F 치과 (양악·윤곽 협진)" } + { + "src": "/img/clinic/consult-1.jpg", + "alt": "뷰성형외과 4~5층 상담센터 대기 공간. 검은 소파와 밝은 조명", + "caption": "4~5F 상담센터" + }, + { + "src": "/img/clinic/exam-1.jpg", + "alt": "뷰성형외과 지하 1층 검진센터 입구. Examination Center 간판", + "caption": "B1F 검진센터" + }, + { + "src": "/img/clinic/surgery-2.jpg", + "alt": "뷰성형외과 수술실 내부. 수술대와 무영등", + "caption": "11~14F 수술센터" + }, + { + "src": "/img/clinic/ward-1.jpg", + "alt": "뷰성형외과 입원실. 창가의 1인 침대", + "caption": "8·10F 입원센터" + }, + { + "src": "/img/clinic/vip-lounge.jpg", + "alt": "뷰성형외과 VIP 라운지. 어두운 톤의 라운지 공간", + "caption": "VIP 라운지" + }, + { + "src": "/img/clinic/dental-15f.jpg", + "alt": "뷰성형외과 15층 치과 진료실. 파란 치과 의자가 줄지어 있음", + "caption": "15F 치과 (양악·윤곽 협진)" + } ], "clinicGallery": [ - { "src": "/img/clinic/consult-2.jpg", "alt": "상담센터 상담실 내부", "caption": "4~5F 상담센터" }, - { "src": "/img/clinic/exam-2.jpg", "alt": "검진센터 접수 데스크", "caption": "B1F 검진센터" }, - { "src": "/img/clinic/surgery-3.jpg", "alt": "수술실 앞 복도의 수술 실명제 안내 화면", "caption": "11~14F 수술센터 복도" }, - { "src": "/img/clinic/ward-2.jpg", "alt": "입원실 침대", "caption": "8·10F 입원센터" }, - { "src": "/img/clinic/vip-1.jpg", "alt": "VIP 병동 복도", "caption": "7F VIP 입원실" }, - { "src": "/img/clinic/consult-3.jpg", "alt": "상담 대기 라운지", "caption": "상담 대기 라운지" } + { + "src": "/img/clinic/consult-2.jpg", + "alt": "상담센터 상담실 내부", + "caption": "4~5F 상담센터" + }, + { + "src": "/img/clinic/exam-2.jpg", + "alt": "검진센터 접수 데스크", + "caption": "B1F 검진센터" + }, + { + "src": "/img/clinic/surgery-3.jpg", + "alt": "수술실 앞 복도의 수술 실명제 안내 화면", + "caption": "11~14F 수술센터 복도" + }, + { + "src": "/img/clinic/ward-2.jpg", + "alt": "입원실 침대", + "caption": "8·10F 입원센터" + }, + { + "src": "/img/clinic/vip-1.jpg", + "alt": "VIP 병동 복도", + "caption": "7F VIP 입원실" + }, + { + "src": "/img/clinic/consult-3.jpg", + "alt": "상담 대기 라운지", + "caption": "상담 대기 라운지" + } + ], + "clinicDoctors": [ + "dr-choi", + "dr-chung", + "dr-son", + "dr-oh" ], - "clinicDoctors": ["dr-choi", "dr-chung", "dr-son", "dr-oh"], "visitMaps": [ - { "src": "/img/clinic/map-1.jpg", "alt": "양재·강남역 방향에서 뷰성형외과로 오는 약도. 9호선 신논현역과 병원 위치 표시", "caption": "양재·강남역 방향 약도. 출처: 홈페이지 오시는길" }, - { "src": "/img/clinic/parking-map.jpg", "alt": "뷰성형외과 주차장 진입 경로 약도. 약국 골목으로 진입해 사거리에서 우회전", "caption": "주차장 진입 경로. 출처: 홈페이지 오시는길" } + { + "src": "/img/clinic/map-1.jpg", + "alt": "양재·강남역 방향에서 뷰성형외과로 오는 약도. 9호선 신논현역과 병원 위치 표시", + "caption": "양재·강남역 방향 약도. 출처: 홈페이지 오시는길" + }, + { + "src": "/img/clinic/parking-map.jpg", + "alt": "뷰성형외과 주차장 진입 경로 약도. 약국 골목으로 진입해 사거리에서 우회전", + "caption": "주차장 진입 경로. 출처: 홈페이지 오시는길" + } ], "arrivalGuide": "먼저 1층 안내데스크로 가면 안내해 줍니다. 상담센터는 4~5층, VIP 상담실은 3층, 수술 전 검진은 지하 1층 검진센터입니다.", "visitReads": [ - { "href": "/posts/visit-guide", "title": "방문 가이드: 위치·진료시간·주차·예약", "note": "대중교통·공항에서 오는 법까지 자세히" }, - { "href": "/posts/safety-system", "title": "뷰성형외과 안전 시스템은 무엇으로 이루어져 있나요?", "note": "상담에서 확인할 네 가지" }, - { "href": "/posts/revision-checklist", "title": "가슴 재수술을 고민 중이라면 무엇을 먼저 확인해야 하나요?", "note": "재수술 상담 준비 체크리스트" } + { + "href": "/posts/visit-guide", + "title": "방문 가이드: 위치·진료시간·주차·예약", + "note": "대중교통·공항에서 오는 법까지 자세히" + }, + { + "href": "/posts/safety-system", + "title": "뷰성형외과 안전 시스템은 무엇으로 이루어져 있나요?", + "note": "상담에서 확인할 네 가지" + }, + { + "href": "/posts/revision-checklist", + "title": "가슴 재수술을 고민 중이라면 무엇을 먼저 확인해야 하나요?", + "note": "재수술 상담 준비 체크리스트" + } ], "newsNote": "뷰성형외과 홈페이지 언론보도 게시판과 네이버 뉴스에서 모은 기사입니다(2026-09-04 기준). 병원이 알리거나 원장이 설명한 기사만 모았고, 그 밖의 보도는 포함하지 않았습니다. 기사 본문은 원문에서 읽을 수 있으며, 원문이 사라진 기사는 병원 게시판 사본으로 연결됩니다.", - "newsOutletsExample": "메디컬투데이, 전민일보, 아주경제, 머니S, 뉴시스 등" + "newsOutletsExample": "메디컬투데이, 전민일보, 아주경제, 머니S, 뉴시스 등", + "heroImageAltEn": "View Plastic Surgery building exterior and interior collage: consultation center, operating room, surgery center corridor" } diff --git a/supporters/src/pages/404.astro b/supporters/src/pages/404.astro index 6629ae8..55e8785 100644 --- a/supporters/src/pages/404.astro +++ b/supporters/src/pages/404.astro @@ -1,7 +1,8 @@ --- // 없는 주소. Vercel 이 dist/404.html 을 자동으로 쓴다. 옛 주소(/recovery, /stay)는 vercel.json 리다이렉트가 /plan 으로 먼저 보낸다. import Base from '../layouts/Base.astro'; -import { SITE_NAME } from '../lib'; +import { SITE_NAME, fact, v } from '../lib'; +const f = fact as Record; const site = Astro.site!.toString().replace(/\/$/, ''); const ld = { '@type': 'WebPage', '@id': `${site}/404#page`, name: '페이지를 찾을 수 없음', url: `${site}/404`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } }; --- @@ -9,7 +10,7 @@ const ld = { '@type': 'WebPage', '@id': `${site}/404#page`, name: '페이지를
404

Page Not Found

주소가 바뀌었거나 없는 페이지입니다. 아래에서 이어서 보실 수 있습니다.

diff --git a/supporters/src/pages/en/index.astro b/supporters/src/pages/en/index.astro index 482e76e..5634460 100644 --- a/supporters/src/pages/en/index.astro +++ b/supporters/src/pages/en/index.astro @@ -8,6 +8,24 @@ import VisitMap from '../../components/VisitMap.astro'; const f = fact as Record; const site = Astro.site!.toString().replace(/\/$/, ''); const yt = surface('youtube'); const gu = surface('gangnamunni'); const gg = surface('google'); +// 카드 요약문은 factSheetEn.json 에서만 만든다. 병원마다 다른 사실(공항 경로·건물)을 문장에 적어 두면 +// 다른 병원 사이트에 그대로 나가 틀린 말이 된다. 값이 없으면 그 조각을 빼고 일반 문장만 남긴다. +const airportShort = String((EN as Record).airportShort || ''); +const buildingShort = String((EN as Record).building || '').split('. ')[0]; +const visitCard = airportShort + ? `${airportShort}, opening hours by day, and where to go when you arrive.` + : 'How to reach the clinic, opening hours by day, and where to go when you arrive.'; +const clinicCard = [ + has(f.founded) && `Founded ${f.founded}`, + buildingShort || null, + 'specialties, and where reviews can be read', +].filter(Boolean).join(', ') + '.'; +const heroAlt = String((S as Record).heroImageAltEn || S.heroImage?.alt || ''); +// 히어로 칩. 설립연도와 위치는 병원마다 다르므로 데이터에 있는 것만 잇는다. 둘 다 없으면 칩을 내지 않는다. +const chipFacts = [ + has(f.founded) && `Founded ${f.founded}`, + has((EN as Record).areaLabel) && String((EN as Record).areaLabel), +].filter(Boolean).join(' · '); const hoursDays = EN.hoursDays as Record; const ld = [ { '@type': 'WebPage', '@id': `${site}/en#page`, name: `${EN.shortName} Supporters (English)`, url: `${site}/en`, inLanguage: 'en', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${fact.url}/#clinic` } }, @@ -23,7 +41,7 @@ const ld = [

{EN.shortName},
plan your stay before you fly.

{EN.shortName} is a {EN.kind} at {EN.areaLabel}{has(f.founded) && <>, founded in {f.founded}}. This site is run by supporters who answer the questions people ask before visiting. In English you can plan your recovery stay, find the way here and check clinic facts. Articles are in Korean.

- Founded {v(f.founded)} · Sinnonhyeon Station Exit 3 + {chipFacts && {chipFacts}} Based on the clinic's published guidance
@@ -31,7 +49,7 @@ const ld = [ Getting here
- {has(S.heroImage?.src) &&
View Plastic Surgery building exterior and interior collage: consultation center, operating room, surgery center corridor
} + {has(S.heroImage?.src) &&
{heroAlt}
}
@@ -39,8 +57,8 @@ const ld = [
Start Here

Where Are You Now?

Pick the step that matches where you are.

@@ -58,7 +76,7 @@ const ld = [
Visit

Getting Here

Address, transit, airport route, opening hours and what to do when you arrive.

Address

{EN.address.full}

{EN.transit}

Open in Google Maps

-
From the airport

Incheon to Sinnonhyeon

{EN.airport}

+ {has(EN.airport) &&
From the airport

Airport to the clinic

{EN.airport}

}
On arrival

Where to go in the building

{EN.arrival}

{EN.parking}

diff --git a/supporters/src/pages/en/plan.astro b/supporters/src/pages/en/plan.astro index fd0f2e9..11c5c9f 100644 --- a/supporters/src/pages/en/plan.astro +++ b/supporters/src/pages/en/plan.astro @@ -3,8 +3,11 @@ import Base from '../../layouts/Base.astro'; import Planner from '../../components/Planner.astro'; import { fact, SITE_NAME, clinicSchema, v } from '../../lib'; +import EN from '../../data/factSheetEn.json'; const f = fact as Record; -const clinicEn = 'View Plastic Surgery'; +// 영문 표기는 factSheetEn 에서 읽는다. 영문 표기를 확인하지 못한 병원은 한국어 상호를 그대로 쓴다. +const E = EN as Record; +const clinicEn = String(E.shortName || E.name || f.shortNameEn || f.nameEn || f.shortName || SITE_NAME); const site = Astro.site!.toString().replace(/\/$/, ''); const ld = [ { '@type': 'WebPage', '@id': `${site}/en/plan#page`, name: `${clinicEn} Recovery Planner`, url: `${site}/en/plan`, inLanguage: 'en', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } }, diff --git a/templates/supporters-astro/CLAUDE.md b/templates/supporters-astro/CLAUDE.md index 2473b45..d1c9798 120000 --- a/templates/supporters-astro/CLAUDE.md +++ b/templates/supporters-astro/CLAUDE.md @@ -1 +1 @@ -/Users/haewonkam/orca/workspaces/INFINITH/Productize/supporters/AGENTS.md \ No newline at end of file +/Users/haewonkam/orca/workspaces/INFINITH/Productize-2/supporters/AGENTS.md \ No newline at end of file diff --git a/templates/supporters-astro/scripts/export_template.mjs b/templates/supporters-astro/scripts/export_template.mjs index e928188..410d2e0 100644 --- a/templates/supporters-astro/scripts/export_template.mjs +++ b/templates/supporters-astro/scripts/export_template.mjs @@ -45,9 +45,17 @@ pkg.name = 'supporters-template'; writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); // 남은 병원 고유 문자열 검사 -const CLINIC_RE = /뷰성형외과|viewclinic|ViewclinicKR|안성민|o2oteam/; +// 상호뿐 아니라 지명·건물 같은 병원 고유 사실도 잡는다. 상호만 찾으면 +// "Sinnonhyeon Station Exit 3" 처럼 지명으로 적힌 사실이 그대로 템플릿에 남는다. +const CLINIC_RE = /뷰성형외과|viewclinic|ViewclinicKR|안성민|o2oteam|View Plastic|Sinnonhyeon|Bongeunsa|Gimpo|신논현|봉은사|19-storey/; 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); } +// 문장을 그리는 코드에 병원 고유 사실이 박히면 다른 병원 화면에 틀린 말이 나가므로 내보내기를 멈춘다. +// 데이터(src/data)는 워커가 병원마다 갈아끼우고 빌드 도구(scripts)는 고객이 보지 않으므로 알림만 한다. +const RENDER_RE = /^src\/(pages|layouts|components|lib)\//; +const blocking = leaks.filter((f) => RENDER_RE.test(f)); +const notes = leaks.filter((f) => !RENDER_RE.test(f)); +if (notes.length) console.log('참고 · 병원 이름이 남은 파일(고객 화면 아님):\n ' + notes.join('\n ')); +if (blocking.length) { console.error('고객 화면 코드에 병원 고유 사실이 남았습니다. 데이터에서 읽도록 고치세요:\n ' + blocking.join('\n ')); process.exit(1); } console.log(`템플릿 내보내기 완료: ${DEST}${existsSync(join(DEST, 'node_modules')) ? '' : ' (npm ci 필요)'}`); diff --git a/templates/supporters-astro/src/data/factSheetEn.json b/templates/supporters-astro/src/data/factSheetEn.json new file mode 100644 index 0000000..1e6a329 --- /dev/null +++ b/templates/supporters-astro/src/data/factSheetEn.json @@ -0,0 +1,25 @@ +{ + "_comment": "영어 페이지용 병원 사실. 값의 원본은 factSheet.json 이고 여기에는 영어 표기만 둔다. 비어 있는 값은 영어 화면에서 해당 조각을 렌더하지 않는다. 지어내지 않는다.", + "name": "", + "shortName": "", + "kind": "", + "areaLabel": "", + "address": { + "full": "", + "mapQuery": "" + }, + "transit": "", + "airport": "", + "airportShort": "", + "parking": "", + "hoursDays": { + "월·화·수·목": "", + "금": "", + "토": "", + "일·공휴일": "" + }, + "hoursClosed": "", + "building": "", + "arrival": "", + "specialties": [] +} diff --git a/templates/supporters-astro/src/data/site.json b/templates/supporters-astro/src/data/site.json index c6e9fa2..2790d81 100644 --- a/templates/supporters-astro/src/data/site.json +++ b/templates/supporters-astro/src/data/site.json @@ -23,5 +23,6 @@ "newsOutletsExample": "", "sponsorNotice": "", "ga4MeasurementId": "", - "_comment_ga4": "GA4 측정 ID(G-XXXX). 비우면 태그를 넣지 않는다. 키 이벤트 이름은 data/ai_channels.json conversion_events 와 같다." + "_comment_ga4": "GA4 측정 ID(G-XXXX). 비우면 태그를 넣지 않는다. 키 이벤트 이름은 data/ai_channels.json conversion_events 와 같다.", + "heroImageAltEn": "" } diff --git a/templates/supporters-astro/src/layouts/Base.astro b/templates/supporters-astro/src/layouts/Base.astro index d9cba52..299a8a0 100644 --- a/templates/supporters-astro/src/layouts/Base.astro +++ b/templates/supporters-astro/src/layouts/Base.astro @@ -1,6 +1,8 @@ --- import '../styles/global.css'; import { SITE_NAME, SITE_TAGLINE, INDEXABLE, fact, site as S, surface, reservationUrl, editor, has, v } from '../lib'; +import { UI, switchHref } from '../lib/i18n'; +import EN from '../data/factSheetEn.json'; const ed = editor(); const f = fact as Record; @@ -11,41 +13,28 @@ interface Props { ogImage?: string; type?: 'website' | 'article'; noindex?: boolean; - /** 페이지 언어. 외국인 환자용 영문 페이지(/en/*)에서 'en' 을 넘긴다. 기본은 한국어다. */ lang?: 'ko' | 'en'; + alternates?: { lang: string; href: string }[]; } -const { title, description, jsonLd, ogImage = S.buildingImage?.src || '/favicon.svg', type = 'website', noindex = false, lang = 'ko' } = Astro.props; -const isEn = lang === 'en'; -// 머리말·꼬리말 문구. 법적 고지(지원 관계·부작용)는 번역하지 않는다. 아래 주석 참조. -const T = isEn - ? { nav: [['/posts', 'Questions'], ['/videos', 'Doctors'], ['/newsroom', 'Newsroom'], ['/en/plan', 'Recovery Planner'], ['/about', 'About']], cta: 'Book a Consultation', supporters: 'SUPPORTERS', back: 'Back', top: 'Back to top', sample: 'This is a sample site, not yet public.' } - : { nav: [['/posts', '상담 전 질문'], ['/videos', '의료진 영상'], ['/newsroom', '뉴스룸'], ['/visit', '방문 안내'], ['/plan', '회복 일정'], ['/about', '매체 소개']], cta: '상담 예약', supporters: '서포터즈', back: '이전 페이지', top: '맨 위로', sample: '샘플 사이트입니다. 정식 공개 전입니다.' }; - -// 언어 전환. 같은 내용의 짝이 있는 페이지만 서로 잇고, 없으면 그 언어의 입구로 보낸다. -// 영문 페이지가 늘어나면 이 표에 줄만 추가한다. -const PAIRS: Record = { '/plan': '/en/plan', '/visit': '/en/plan' }; -const KO_ENTRY = '/'; -const EN_ENTRY = '/en/plan'; -const path = (Astro.url.pathname.replace(/\.html$/, '').replace(/\/index$/, '/').replace(/\/$/, '') || '/'); -const koPair = Object.entries(PAIRS).find(([, en]) => en === path)?.[0]; -const otherHref = isEn ? (koPair ?? KO_ENTRY) : (PAIRS[path] ?? EN_ENTRY); -// 짝이 없으면 같은 글의 번역본으로 가는 것이 아니므로 그렇게 말한다. -const otherExact = isEn ? Boolean(koPair) : Boolean(PAIRS[path]); -// 영문 페이지의 예약 버튼은 병원의 외국인용 영문 사이트로 보낸다(factSheet.urlEn). -// 한국어 예약 페이지로 보내면 영어로 온 사람이 한국어 화면을 만난다. -const ctaHref = isEn - ? ((f as Record).reservationUrlEn || (f as Record).urlEn || reservationUrl || '/en/plan') - : (reservationUrl || '/visit'); -// 의료광고 고지는 규정 대상이라 임의로 번역하지 않는다(의료법 56조·추천보증 심사지침). -// 병원이 영문 문구를 확정해 site.json 의 sponsorNoticeEn / factSheet 의 sideEffectNoticeEn 에 넣기 전까지는 -// 한국어 원문을 그대로 싣고 확인 대기임을 밝힌다. -const sponsorEn: string = (S as Record).sponsorNoticeEn || ''; -const sideEffectEn: string = (f as Record).sideEffectNoticeEn || ''; +const { title, description, jsonLd, ogImage = S.buildingImage?.src || '/favicon.svg', type = 'website', noindex = false, lang = 'ko', alternates = [] } = 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 u = UI[lang]; +const other = lang === 'ko' ? 'en' : 'ko'; +const switchTo = switchHref(Astro.url.pathname, other); +const homeHref = lang === 'en' ? '/en' : '/'; +const isHome = ['/', '', '/index.html', '/index', '/en', '/en/index.html', '/en/index'].includes(Astro.url.pathname.replace(/\/$/, '') || '/'); // GA4: site.json 의 ga4MeasurementId(G-XXXX) 가 있을 때만 태그를 넣는다. 키 이벤트는 data/ai_channels.json conversion_events 와 같은 이름(click_tel · click_reservation · click_clinic_site · click_map). const GA4: string = /^G-[A-Z0-9]{6,}$/.test(String((S as Record).ga4MeasurementId ?? '')) ? String((S as Record).ga4MeasurementId) : ''; +// 의료광고 고지는 규정 대상이라 임의로 번역하지 않는다(의료법 56조·추천보증 심사지침). +// 병원이 영문 문구를 확정해 site.json 의 sponsorNoticeEn / factSheet 의 sideEffectNoticeEn 에 넣기 전까지는 +// 한국어 원문을 그대로 싣고 확인 대기임을 밝힌다. i18n.ts 의 영문 foot_sponsor·foot_side 는 이 자리에 쓰지 않는다. +const sponsorEn: string = (S as Record).sponsorNoticeEn || ''; +const sideEffectEn: string = (f as Record).sideEffectNoticeEn || ''; +const noticePending = lang === 'en' && !sponsorEn; +const sponsorText = (lang === 'en' && sponsorEn) ? sponsorEn : UI.ko.foot_sponsor.replace(/\{clinic\}/g, v(f.shortName, '병원')).replace('{editor}', ed.name); +const sideEffectText = (lang === 'en' && sideEffectEn) ? sideEffectEn : fact.sideEffectNotice; const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array.isArray(jsonLd) ? { '@graph': jsonLd } : jsonLd) }) : null; --- @@ -63,7 +52,8 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array - + + {alternates.map((a) => )} @@ -74,32 +64,44 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array document.addEventListener('click',function(e){var a=e.target.closest&&e.target.closest('a');if(!a)return;var h=a.getAttribute('href')||'';var ev=h.indexOf('tel:')===0?'click_tel':a.dataset.ga==='reservation'||a.classList.contains('cta')?'click_reservation':a.dataset.ga==='clinic-site'?'click_clinic_site':a.dataset.ga==='map'?'click_map':null;if(ev)gtag('event',ev,{link_url:h,page_path:location.pathname});},true);`} />} - {!INDEXABLE &&
{T.sample}
} + {!INDEXABLE &&
{u.sample}
} - {!['/', '', '/index.html', '/index'].includes(Astro.url.pathname.replace(/\/$/, '') || '/') && ( + {!isHome && ( )}
- +