// 발행 게이트 러너. 빌드 결과(dist)와 소스(글 frontmatter·데이터)를 검사해 error가 하나라도 있으면 배포를 막는다. // 규칙 본체는 scripts/gate/rules.mjs. 근거는 docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2.md §3. // // node scripts/check.mjs 검사 // node scripts/check.mjs --update-layout 구조 기준선(§3-2) 갱신. 룩앤필 변경을 haewon이 승인한 뒤에만 // node scripts/check.mjs --today=YYYY-MM-DD // node scripts/check.mjs --home-text=../evidence//home_text.txt 40자 중복 검사 기준을 수집기 산출물로 import { readdirSync, readFileSync, statSync, existsSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import * as R from './gate/rules.mjs'; import { parse as parseHtml } from 'node-html-parser'; const here = (p) => fileURLToPath(new URL(p, import.meta.url)); const DIST = here('../dist/'); const POSTS = here('../src/content/posts/'); const LAYOUT_BASELINE = here('./gate/layout-baseline.json'); const args = process.argv.slice(2); const updateLayout = args.includes('--update-layout'); const today = args.find((a) => a.startsWith('--today='))?.slice(8) ?? new Date().toISOString().slice(0, 10); const homeTextPath = args.find((a) => a.startsWith('--home-text='))?.slice(12) ?? here('./home_text.txt'); const HOME_TEXT = existsSync(homeTextPath) ? readFileSync(homeTextPath, 'utf8') : ''; const authors = JSON.parse(readFileSync(here('../src/data/authors.json'), 'utf8')); const fact = JSON.parse(readFileSync(here('../src/data/factSheet.json'), 'utf8')); const videos = JSON.parse(readFileSync(here('../src/data/videos.json'), 'utf8')); const site = JSON.parse(readFileSync(here('../src/data/site.json'), 'utf8')); const INDEXABLE = process.env.PUBLIC_INDEXABLE === 'true'; // 구조 기준선을 보는 페이지. 글 목록은 글 수에 따라 카드가 늘므로 집합 비교, 나머지는 순서 비교. // 글 상세는 사전순 첫 글 하나를 대표로 본다(템플릿·다른 병원에서도 같은 규칙). 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); } return out; } // 홈페이지 40자 일치 검사에서 뺄 블록. 둘 다 출처를 밝히고 병원 원문을 그대로 옮기는 자리라 설계상 일치한다. // #recovery-quote : /stay 의 "시술 후 관리 안내" 인용(출처 링크·확인일 표시) // .doctor-strip : 글의 검토자 표시줄. 원장 이력을 병원 의료진 페이지에서 옮기고 프로필 링크를 단다. // 다른 말로 바꿔 쓰면 이력이 사실과 달라지므로 원문 그대로 두는 것이 맞다. const QUOTED_BLOCKS = ['#recovery-quote', '.doctor-strip']; function withoutQuotedBlocks(html) { const root = parseHtml(html); for (const sel of QUOTED_BLOCKS) root.querySelectorAll(sel).forEach((el) => el.remove()); return root.toString(); } const findings = []; // { where, level, code, msg } 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'); const rel = f.replace(DIST, '/'); // astro.config 의 redirects 가 만든 meta refresh 페이지(/stay → /recovery). 본문이 없으니 검사하지 않는다. if (/([\s\S]*?)<\/script>/g)].map((m) => m[1]); for (const b of blocks) { try { const types = JSON.stringify(JSON.parse(b)); if (rel.startsWith('/posts/') && !/FAQPage|Article/.test(types)) add(rel, [{ level: R.E, code: 'SCHEMA_MISSING', msg: 'Article/FAQPage 스키마 없음' }]); } catch (e) { add(rel, [{ level: R.E, code: 'JSONLD_PARSE', msg: `JSON-LD 파싱 실패: ${e.message}` }]); } } if (!blocks.length && !/404/.test(rel)) add(rel, [{ level: R.E, code: 'JSONLD_MISSING', msg: 'JSON-LD 없음' }]); // alt for (const img of html.matchAll(/]*>/g)) if (!/\balt="[^"]+"/.test(img[0])) add(rel, [{ level: R.E, code: 'IMG_ALT', msg: `alt 없는 이미지: ${img[0].slice(0, 80)}` }]); // H1 const h1 = (html.match(/ f.endsWith('.md')); for (const f of posts) { const parsed = R.parseMarkdown(readFileSync(join(POSTS, f), 'utf8')); add(`posts/${f}`, R.checkPostSource(parsed, { physicians: authors.physicians, supporters: authors.supporters })); } // ---------- 3. 데이터 검사 ---------- add('data/factSheet.json', R.checkNextCheck(fact, today)); add('data/videos.json', R.checkVideoTitles(videos.topLong, { label: '설명 영상 탭' })); add('data/videos.json', R.checkVideoTitles(videos.shortsInfo, { label: '1분 답변 탭' })); // "채널 인기 전체" 탭은 haewon 결정(2026-09-07)으로 제거됨. videos.top 은 화면에 나오지 않으므로 검사하지 않는다. const news = existsSync(here('../src/data/news.json')) ? JSON.parse(readFileSync(here('../src/data/news.json'), 'utf8')) : { items: [] }; add('data/news.json', R.checkNewsItems(news.items)); // 뉴스룸: 후기·전후 비교·변신 제목, 규제·사건 보도, 날짜 형식 // ---------- 출력 ---------- const errors = findings.filter((x) => x.level === R.E); const warns = findings.filter((x) => x.level === R.W); const byCode = (list) => Object.entries(list.reduce((m, x) => ((m[x.code] = (m[x.code] ?? 0) + 1), m), {})).map(([k, v]) => `${k}×${v}`).join(', '); for (const x of errors) console.error(` ✗ ${x.where}: [${x.code}] ${x.msg}`); const shownWarn = warns.slice(0, 40); for (const x of shownWarn) console.warn(` △ ${x.where}: [${x.code}] ${x.msg}`); if (warns.length > shownWarn.length) console.warn(` △ … 경고 ${warns.length - shownWarn.length}건 더 있음`); console.log(`검사 파일 ${files.length}개 · 글 ${posts.length}편 · 오류 ${errors.length}건${errors.length ? ` (${byCode(errors)})` : ''} · 경고 ${warns.length}건${warns.length ? ` (${byCode(warns)})` : ''}`); if (errors.length) process.exit(1);