Files
teacherpick/scripts/prerender.js
T
2026-11-10 10:12:09 +09:00

93 lines
3.8 KiB
JavaScript

// 빌드 후 실행: 라우트별 완성 HTML(<head> + 본문)을 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 = '<!--app-head-->';
const HTML_PLACEHOLDER = '<!--app-html-->';
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 에서 <!--app-head--> / <!--app-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(/<title\s+data-default[\s\S]*?<\/title>\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(/<h1[\s>]/g) || []).length;
if (h1 !== 1) problems.push(`${t.url}: h1 ${h1}개`);
if (!/<title data-head>[^<]+<\/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/`);