// 빌드 후 실행: 라우트별 완성 HTML( + 본문)을 dist 에 생성하는 사전 렌더링(SSG) 스크립트 import fs from 'fs'; import path from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; import { DEFAULT_PATH } from '../src/config.js'; import { getPrerenderPaths } from './routes.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(__dirname, '..'); const distDir = path.join(root, 'dist'); const ssrEntry = path.join(root, 'dist-ssr', 'entry-server.js'); const HEAD_PLACEHOLDER = ''; const HTML_PLACEHOLDER = ''; const template = fs.readFileSync(path.join(distDir, 'index.html'), 'utf-8'); if (!template.includes(HEAD_PLACEHOLDER) || !template.includes(HTML_PLACEHOLDER)) { throw new Error('dist/index.html 에서 / 자리표시자를 찾지 못했습니다.'); } // Cloudflare Workers (_worker.js) 생성용으로 원본 템플릿 보존 fs.writeFileSync(path.join(distDir, '_template.html'), template, 'utf-8'); const { render } = await import(pathToFileURL(ssrEntry).href); const writeFile = (file, content) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, content, 'utf-8'); }; // url -> 출력 파일 경로 const popularPaths = [ DEFAULT_PATH, '/incheon/yeonsu-gu/songdo-dong/middle/math', '/daegu/suseong-gu/hwanggeum-dong/middle/math', '/daegu/suseong-gu/hwanggeum-dong/high/korean', '/seoul/gangnam-gu/daechi-dong/high/math', '/gyeonggi/seongnam-si-bundang-gu/sunae-dong/middle/english', '/busan/haeundae-gu/u-dong/middle/english' ]; const allLandingPaths = Array.from(new Set([...popularPaths, ...getPrerenderPaths()])); const targets = [ { url: '/', file: path.join(distDir, 'index.html'), kind: 'home' }, // 메인 홈 화면 (지역·학년·과목 허브) { url: '/regions', file: path.join(distDir, 'regions', 'index.html'), kind: 'page' }, { url: '/admin', file: path.join(distDir, 'admin', 'index.html'), kind: 'noindex' }, { url: '/__not-found__', file: path.join(distDir, '404.html'), kind: 'noindex' }, ...allLandingPaths.map((p) => ({ url: p, file: path.join(distDir, ...p.split('/').filter(Boolean), 'index.html'), kind: 'landing' })) ]; const problems = []; let count = 0; for (const t of targets) { const { html, head, routeName } = await render(t.url); let out = template; if (head) { out = out.replace(/\s*/i, ''); out = out.replace(/<[^>]+data-default[^>]*>\s*/gi, ''); out = out.replace(HEAD_PLACEHOLDER, head); } else { out = out.replace(HEAD_PLACEHOLDER, ''); } out = out.replace(HTML_PLACEHOLDER, html); // 품질 검증: 색인 대상 페이지는 h1 1개, title, canonical, JSON-LD 가 반드시 있어야 함 if (t.kind === 'landing' || t.kind === 'home' || t.kind === 'page') { const h1 = (html.match(/]/g) || []).length; if (h1 !== 1) problems.push(`${t.url}: h1 ${h1}개`); if (!/[^<]+<\/title>/.test(head)) problems.push(`${t.url}: title 없음`); if (!/rel="canonical"/.test(head)) problems.push(`${t.url}: canonical 없음`); if (!/application\/ld\+json/.test(head) && t.kind !== 'page') problems.push(`${t.url}: JSON-LD 없음`); } if (t.kind === 'landing' && routeName !== 'LocationSubjectFullLanding') { problems.push(`${t.url}: 랜딩 라우트에 매칭되지 않음 (${String(routeName)})`); } writeFile(t.file, out); count++; } if (problems.length > 0) { console.error('[FAIL] 사전 렌더링 검증 실패:\n - ' + problems.join('\n - ')); process.exit(1); } // SSR 번들은 배포 대상이 아니므로 정리하지 않고 dist-ssr 에 남겨둠 (.gitignore 대상) console.log(`[OK] ${count}개 페이지 사전 렌더링 완료 -> dist/`);