여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제 - 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다 - eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다 파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이). site·frontend·admin tsc, site vitest 105 passed Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
987 lines
37 KiB
TypeScript
987 lines
37 KiB
TypeScript
import {
|
|
copyFileSync,
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
renameSync,
|
|
rmSync,
|
|
statSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import {createHash} from 'node:crypto';
|
|
import {writeFile as writeFileAsync} from 'node:fs/promises';
|
|
import {basename, dirname, join, resolve} from 'node:path';
|
|
import {fileURLToPath} from 'node:url';
|
|
import {SourceType, type SitePayload} from '@o2o/shared';
|
|
import {
|
|
joinUrl,
|
|
parseSectionData,
|
|
sanitizePayloadForPublish,
|
|
SECTION_ITEM_REQUIRED_KEY,
|
|
} from '@o2o/shared';
|
|
import {render} from '@site/entry-server';
|
|
import {
|
|
collectJsonLd,
|
|
homeMeta,
|
|
readBakedLastmod,
|
|
readBakedNoindex,
|
|
readBakedOrigin,
|
|
readBakedTitle,
|
|
renderHead,
|
|
renderLlmsTxt,
|
|
renderRootLlmsTxt,
|
|
renderRootRobotsTxt,
|
|
renderSiteIndex,
|
|
renderSiteUrlset,
|
|
type DirectoryEntry,
|
|
type SiteEntry,
|
|
verifyGeo,
|
|
verifyJsonLd,
|
|
} from '@site/seo';
|
|
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
|
|
|
|
/** 발행 사이트 프리렌더. */
|
|
|
|
/** 공개 주소가 읽는 디렉터리. */
|
|
const SITE_DIR = 's';
|
|
|
|
/** 실제로 굽는 자리. */
|
|
const VERSIONS_DIR = 'versions';
|
|
|
|
/** 이 코드가 처음 도는 순간 `out/s/<slug>` 가 이미 **일반 디렉토리**(옛 방식의 산출물)일 수 있다. */
|
|
const LEGACY_VERSION = 'legacy';
|
|
|
|
/** 손으로 만든 시연본(목업)이 차지한 슬러그. */
|
|
const PROTECTED_SLUGS = new Set(
|
|
(process.env.PRERENDER_PROTECTED_SLUGS ?? 'stay,stay3,stay4,stay5,stay6')
|
|
.split(',')
|
|
.map((slug) => slug.trim())
|
|
.filter(Boolean),
|
|
);
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const SITE_ROOT = resolve(HERE, '..', '..'); // dist/prerender → site/
|
|
// 백엔드가 노래 파일을 떨구는 자리.
|
|
const SONGS_DIR = join(SITE_ROOT, 'songs');
|
|
const CLIENT_DIR = join(SITE_ROOT, 'dist', 'client');
|
|
|
|
interface Args {
|
|
/** -payload 은 여러 번 줄 수 있다. */
|
|
payloads: string[];
|
|
out: string;
|
|
/** 아무것도 굽지 않고 공용 자산(out/assets · out/fonts · public/)만 채우고 정리한다. */
|
|
seedAssets: boolean;
|
|
stageOnly: boolean;
|
|
activate?: string;
|
|
}
|
|
|
|
function parseArgs(): Args {
|
|
const args: Args = {
|
|
payloads: [],
|
|
out: join(SITE_ROOT, 'out'),
|
|
seedAssets: false,
|
|
stageOnly: false,
|
|
};
|
|
for (const arg of process.argv.slice(2)) {
|
|
const [key, value] = arg.replace(/^--/, '').split('=');
|
|
if (key === 'payload' && value) args.payloads.push(value);
|
|
if (key === 'out' && value) args.out = resolve(process.cwd(), value);
|
|
if (key === 'seed-assets') args.seedAssets = true;
|
|
if (key === 'stage-only') args.stageOnly = true;
|
|
if (key === 'activate') args.activate = value;
|
|
}
|
|
return args;
|
|
}
|
|
|
|
/** 경로(파일 또는 디렉토리)를 payload 파일 목록으로 편다. */
|
|
function expandPayloadPaths(paths: string[]): string[] {
|
|
const files: string[] = [];
|
|
for (const path of paths) {
|
|
const target = resolve(process.cwd(), path);
|
|
if (!existsSync(target)) {
|
|
throw new Error(`payload 를 찾을 수 없습니다: ${target}`);
|
|
}
|
|
if (target.endsWith('.json')) {
|
|
files.push(target);
|
|
continue;
|
|
}
|
|
for (const name of readdirSync(target)) {
|
|
// 백엔드가 원자적 rename 전에 쓰는 임시파일(.<slug>.json.tmp)을 집지 않는다.
|
|
if (name.endsWith('.json') && !name.startsWith('.')) files.push(join(target, name));
|
|
}
|
|
}
|
|
// 같은 파일을 두 번 굽지 않는다(--payload=dir --payload=dir/a.json 같은 조합).
|
|
return [...new Set(files)];
|
|
}
|
|
|
|
/** 읽어들인 payload 한 건. */
|
|
interface LoadedPayload {
|
|
payload?: SitePayload;
|
|
/** 렌더 결과 보고서를 이 파일 옆에 쓴다 — 백엔드가 보는 유일한 경로다. */
|
|
file?: string;
|
|
error?: string;
|
|
}
|
|
|
|
/** payload 파일 하나를 읽고 검증한다. */
|
|
function loadOne(file: string): LoadedPayload {
|
|
try {
|
|
const payload = JSON.parse(readFileSync(file, 'utf-8')) as SitePayload;
|
|
// 모르는 스키마 버전을 반쪽만 렌더해서 내보내는 게 최악이다 — 이 파일만 건너뛴다.
|
|
if (payload.schemaVersion !== 1) {
|
|
return {
|
|
file,
|
|
payload,
|
|
error: `지원하지 않는 schemaVersion=${payload.schemaVersion} (이 렌더러는 1만 처리합니다)`,
|
|
};
|
|
}
|
|
if (!payload.site?.slug) {
|
|
return {file, payload, error: 'payload 에 site.slug 이 없습니다'};
|
|
}
|
|
if (!/^[\p{L}\p{N}_-]+$/u.test(payload.site.slug) || !Number.isSafeInteger(payload.site.version) || payload.site.version < 1) {
|
|
return {file, payload, error: '잘못된 슬러그 또는 발행 버전입니다'};
|
|
}
|
|
return {file, payload};
|
|
} catch (ex) {
|
|
return {file, error: ex instanceof Error ? ex.message : String(ex)};
|
|
}
|
|
}
|
|
|
|
/** payload 를 읽는다. */
|
|
function loadPayloads(paths: string[]): LoadedPayload[] {
|
|
if (paths.length === 0) {
|
|
console.log('[prerender] --payload 이 없어 데모 payload 로 굽습니다.');
|
|
return [{payload: MOONLIGHT_STAY_PAYLOAD}];
|
|
}
|
|
|
|
const files = expandPayloadPaths(paths);
|
|
if (files.length === 0) throw new Error(`payload JSON 이 없습니다: ${paths.join(', ')}`);
|
|
return files.map(loadOne);
|
|
}
|
|
|
|
/** 클라이언트 빌드 manifest 에서 엔트리 JS/CSS 경로를 뽑는다. */
|
|
function readAssets(): {script: string; css: string[]} {
|
|
const manifestPath = join(CLIENT_DIR, '.vite', 'manifest.json');
|
|
if (!existsSync(manifestPath)) {
|
|
throw new Error(
|
|
`클라이언트 빌드 결과가 없습니다: ${manifestPath}\n 먼저 \`npm run build:client\` 를 실행하세요.`,
|
|
);
|
|
}
|
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as Record<
|
|
string,
|
|
{file: string; css?: string[]; isEntry?: boolean}
|
|
>;
|
|
const entry = Object.values(manifest).find((chunk) => chunk.isEntry);
|
|
if (!entry) throw new Error('manifest 에 엔트리 청크가 없습니다.');
|
|
|
|
// 파일명만 돌려준다.
|
|
return {
|
|
script: entry.file.replace(/^assets\//, ''),
|
|
css: (entry.css ?? []).map((file) => file.replace(/^assets\//, '')),
|
|
};
|
|
}
|
|
|
|
/** 구조화 데이터 대조 실패. */
|
|
class VerifyError extends Error {
|
|
constructor(
|
|
readonly mismatches: string[],
|
|
/** 실패해도 계수는 보고한다. */
|
|
readonly uniqueContentCount: number | null = null,
|
|
) {
|
|
super(`구조화 데이터가 화면 값과 다릅니다(${mismatches.length}건): ${mismatches.slice(0, 3).join(' · ')}`);
|
|
this.name = 'VerifyError';
|
|
}
|
|
}
|
|
|
|
/** 고유 콘텐츠 0건. */
|
|
class NoUniqueContentError extends Error {
|
|
/** 백엔드가 JSONLD_MISMATCH 로 오인하지 않도록 늘 비어 있다. */
|
|
readonly mismatches: string[] = [];
|
|
|
|
constructor(readonly uniqueContentCount: number) {
|
|
super('고유 콘텐츠가 0건이다 — 이 가게에만 있는 내용이 없으면 발행하지 않는다');
|
|
this.name = 'NoUniqueContentError';
|
|
}
|
|
}
|
|
|
|
/** 사이트는 **한 장**이다. */
|
|
|
|
/** payload 를 HTML 안에 심을 수 있게 직렬화한다. */
|
|
function serializePayload(payload: SitePayload): string {
|
|
return JSON.stringify(payload)
|
|
.replace(/</g, '\\u003c')
|
|
.replace(/>/g, '\\u003e')
|
|
.replace(/&/g, '\\u0026')
|
|
.replace(/\u2028/g, '\\u2028')
|
|
.replace(/\u2029/g, '\\u2029');
|
|
}
|
|
|
|
function writeFile(outDir: string, relPath: string, content: string) {
|
|
const full = join(outDir, relPath);
|
|
mkdirSync(dirname(full), {recursive: true});
|
|
writeFileSync(full, content, 'utf-8');
|
|
}
|
|
|
|
/** Node 24의 recursive cpSync가 macOS Docker bind mount에서 0바이트·write-only 파일을 남기고 EACCES로 실패하는 경우가 있어, 빌드 자산은 파일 단위로 복사한다. */
|
|
function copyDirectoryFiles(source: string, destination: string) {
|
|
mkdirSync(destination, {recursive: true});
|
|
for (const entry of readdirSync(source, {withFileTypes: true})) {
|
|
const from = join(source, entry.name);
|
|
const to = join(destination, entry.name);
|
|
if (entry.isDirectory()) copyDirectoryFiles(from, to);
|
|
else if (entry.isFile()) {
|
|
// 이전 실패가 write-only 0바이트 파일을 남겼어도 덮어쓰기를 시도하지 말고 먼저 지운다.
|
|
rmSync(to, {force: true});
|
|
copyFileSync(from, to);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 내려받은 사진이 놓이는 폴더. */
|
|
const MEDIA_DIR = 'img';
|
|
|
|
/** 한 장당 상한(바이트). */
|
|
const MEDIA_MAX_BYTES = 8 * 1024 * 1024;
|
|
|
|
const MEDIA_EXT: Record<string, string> = {
|
|
'image/jpeg': '.jpg',
|
|
'image/jpg': '.jpg',
|
|
'image/png': '.png',
|
|
'image/webp': '.webp',
|
|
'image/avif': '.avif',
|
|
'image/gif': '.gif',
|
|
};
|
|
|
|
/** 사진을 **우리 오리진으로 옮긴다.** */
|
|
async function mirrorMedia(payload: SitePayload, siteDir: string) {
|
|
const items = (payload.media ?? []).filter((item) => /^https?:\/\//i.test(item.url ?? ''));
|
|
if (items.length === 0) return;
|
|
|
|
const dir = join(siteDir, MEDIA_DIR);
|
|
// 절대 주소로 바꾼다.
|
|
const publicBase = joinUrl(payload.site.origin, payload.site.basePath);
|
|
mkdirSync(dir, {recursive: true});
|
|
|
|
const wanted = new Set<string>();
|
|
let fetched = 0;
|
|
|
|
for (const item of items) {
|
|
const origin = item.url;
|
|
// 파일명은 **주소의 해시**다.
|
|
const stem = createHash('sha1').update(origin).digest('hex').slice(0, 16);
|
|
const hit = readdirSync(dir).find((name) => name.startsWith(`${stem}.`));
|
|
if (hit) {
|
|
wanted.add(hit);
|
|
item.url = joinUrl(publicBase, `${MEDIA_DIR}/${hit}`);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(origin, {
|
|
// 봇 차단을 우회하는 게 아니라, 기본 UA 를 거절하는 CDN 에 평범한 브라우저로 보이게 한다.
|
|
headers: {'user-agent': 'Mozilla/5.0 (compatible; o2o-web4ai/1.0)'},
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
const type = (res.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
|
|
const ext = MEDIA_EXT[type];
|
|
if (!res.ok || !ext) throw new Error(`${res.status} ${type || '타입 없음'}`);
|
|
const body = new Uint8Array(await res.arrayBuffer());
|
|
if (body.byteLength === 0 || body.byteLength > MEDIA_MAX_BYTES) {
|
|
throw new Error(`${body.byteLength} 바이트`);
|
|
}
|
|
const name = `${stem}${ext}`;
|
|
await writeFileAsync(join(dir, name), body);
|
|
wanted.add(name);
|
|
item.url = joinUrl(publicBase, `${MEDIA_DIR}/${name}`);
|
|
fetched += 1;
|
|
} catch (ex) {
|
|
console.warn(` ! 사진을 못 받았습니다(원래 주소를 씁니다): ${origin} — ${ex}`);
|
|
}
|
|
}
|
|
|
|
// 지난 발행의 사진은 치운다 — 노래(copySongs)와 같은 이유다.
|
|
for (const name of readdirSync(dir)) {
|
|
if (!wanted.has(name)) rmSync(join(dir, name), {force: true});
|
|
}
|
|
if (fetched > 0) console.log(` 사진 ${fetched}장을 내려받았습니다 (총 ${wanted.size}장)`);
|
|
}
|
|
|
|
/** 이 슬러그·버전이 실제로 구워지는 자리 — `out/versions/<slug>/<version>/`. */
|
|
function versionDir(outRoot: string, slug: string, version: number | string): string {
|
|
return join(outRoot, VERSIONS_DIR, slug, String(version));
|
|
}
|
|
|
|
/** `out/s/<slug>` 가 (이 코드 이전에 구워진) 일반 디렉토리인가 — 심볼릭 링크가 아니면 옛 산출물이다. */
|
|
function isLegacyFlatSite(publicDir: string): boolean {
|
|
try {
|
|
return lstatSync(publicDir).isDirectory();
|
|
} catch {
|
|
return false; // 없음(첫 발행) — 마이그레이션 대상 아님.
|
|
}
|
|
}
|
|
|
|
/** 공개 주소(`out/s/<slug>`)를 이 버전으로 **원자적으로** 돌린다. */
|
|
function publishVersion(outRoot: string, slug: string, version: number): void {
|
|
const publicDir = join(outRoot, SITE_DIR, slug);
|
|
const target = versionDir(outRoot, slug, version);
|
|
if (!existsSync(target)) {
|
|
throw new Error(`publishVersion: 대상 버전 디렉토리가 없다 — ${target}`);
|
|
}
|
|
|
|
if (isLegacyFlatSite(publicDir)) {
|
|
const legacy = versionDir(outRoot, slug, LEGACY_VERSION);
|
|
if (!existsSync(legacy)) {
|
|
mkdirSync(dirname(legacy), {recursive: true});
|
|
renameSync(publicDir, legacy);
|
|
console.log(` (마이그레이션) 옛 산출물을 versions/${slug}/${LEGACY_VERSION}/ 로 보존`);
|
|
} else {
|
|
// legacy 자리가 이미 있다(재시도 등) — 지금 것은 legacy 보다 못 믿을 이유가 없지만 둘을 합치지 않는다.
|
|
throw new Error('기존 사이트와 legacy가 함께 있어 자동 전환을 중단합니다');
|
|
}
|
|
}
|
|
|
|
const tmp = join(outRoot, SITE_DIR, `.${slug}.tmplink`);
|
|
mkdirSync(dirname(tmp), {recursive: true}); // 첫 발행이면 `out/s/` 자체가 아직 없다.
|
|
rmSync(tmp, {force: true});
|
|
// 상대 경로로 링크한다 — named volume 을 다른 마운트 지점(로컬·컨테이너)에서 봐도 깨지지 않는다.
|
|
symlinkSync(join('..', VERSIONS_DIR, slug, String(version)), tmp, 'dir');
|
|
renameSync(tmp, publicDir);
|
|
}
|
|
|
|
/** 오래된 버전 디렉토리를 지운다. */
|
|
const ROLLBACK_MIN_VERSIONS = 5;
|
|
const ROLLBACK_RETENTION_DAYS = 30;
|
|
|
|
function pruneOldVersions(outRoot: string, slug: string, currentVersion: number): void {
|
|
const base = join(outRoot, VERSIONS_DIR, slug);
|
|
if (!existsSync(base)) return;
|
|
|
|
const numbered = readdirSync(base, {withFileTypes: true})
|
|
.filter((entry) => entry.isDirectory() && entry.name !== LEGACY_VERSION)
|
|
.map((entry) => Number(entry.name))
|
|
.filter((n) => Number.isFinite(n))
|
|
.sort((a, b) => b - a);
|
|
|
|
const cutoff = Date.now() - ROLLBACK_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
let removed = 0;
|
|
numbered.forEach((version, index) => {
|
|
if (version === currentVersion) return; // 지금 공개된 버전은 몇 번째든 남긴다.
|
|
if (index < ROLLBACK_MIN_VERSIONS) return;
|
|
const dir = join(base, String(version));
|
|
let mtime = 0;
|
|
try {
|
|
mtime = statSync(dir).mtimeMs;
|
|
} catch {
|
|
return;
|
|
}
|
|
if (mtime >= cutoff) return;
|
|
rmSync(dir, {recursive: true, force: true});
|
|
removed += 1;
|
|
});
|
|
if (removed > 0) {
|
|
console.log(` 옛 버전 디렉토리 ${removed}개 정리 (최근 ${ROLLBACK_MIN_VERSIONS}개·${ROLLBACK_RETENTION_DAYS}일은 항상 남긴다)`);
|
|
}
|
|
}
|
|
|
|
function assetPlan(payload: SitePayload, outRoot: string, siteDir: string) {
|
|
const basePath = payload.site.basePath.replace(/\/+$/, '');
|
|
const suffix = `/${SITE_DIR}/${payload.site.slug}`;
|
|
// out/ 이 도메인 루트가 아닌 곳에 마운트돼도 맞도록 basePath 에서 역산한다.
|
|
const rootPrefix = basePath.endsWith(suffix) ? basePath.slice(0, -suffix.length) : '';
|
|
const shared = basePath !== '';
|
|
|
|
return {
|
|
shared,
|
|
/** 자산을 실제로 복사해 넣을 디렉토리. */
|
|
dir: shared ? outRoot : siteDir,
|
|
/** HTML 이 참조할 접두사. */
|
|
base: `${rootPrefix}/assets`,
|
|
};
|
|
}
|
|
|
|
/** 이 숙소의 노래 파일을 사이트 디렉토리로 옮겨 놓는다. */
|
|
function copySongs(payload: SitePayload, siteDir: string) {
|
|
const wanted = new Set<string>();
|
|
|
|
for (const song of payload.songs ?? []) {
|
|
const name = song.fileName || song.audioUrl.split('/').pop();
|
|
if (!name) continue;
|
|
wanted.add(name);
|
|
const from = join(SONGS_DIR, name);
|
|
if (!existsSync(from)) {
|
|
console.warn(` ! 노래 파일이 없습니다(건너뜀): ${from}`);
|
|
continue;
|
|
}
|
|
const to = join(siteDir, name);
|
|
rmSync(to, {force: true});
|
|
copyFileSync(from, to);
|
|
}
|
|
|
|
/* 지난 발행의 곡은 치운다. */
|
|
if (!existsSync(siteDir)) return;
|
|
for (const entry of readdirSync(siteDir, {withFileTypes: true})) {
|
|
if (!entry.isFile() || !entry.name.endsWith('.mp3')) continue;
|
|
if (wanted.has(entry.name)) continue;
|
|
rmSync(join(siteDir, entry.name), {force: true});
|
|
}
|
|
}
|
|
|
|
function prerenderSite(
|
|
input: SitePayload,
|
|
outRoot: string,
|
|
assets: ReturnType<typeof readAssets>,
|
|
referenced: Set<string>,
|
|
) {
|
|
// 여기서 한 번 깎고, 그 뒤로는 깎인 payload 만 쓴다 — 서버 렌더 · JSON-LD · llms.txt · HTML 에 심는 블롭이 전부 같은 객체를 본다.
|
|
const payload = sanitizePayloadForPublish(input);
|
|
// 이번 버전 전용 디렉토리에 굽는다(`out/s/<slug>` 가 아니다) — publishVersion 주석 참조.
|
|
const siteDir = versionDir(outRoot, payload.site.slug, payload.site.version);
|
|
const plan = assetPlan(payload, outRoot, siteDir);
|
|
/** 브라우저가 자산을 찾아갈 접두사. */
|
|
const assetBase = plan.base;
|
|
|
|
/** 먼저 메모리에 굽고, 검증을 통과한 뒤에야 파일로 쓴다. */
|
|
const mismatches: string[] = [];
|
|
// 실패하더라도 보고서에 실어야 하므로 먼저 센다.
|
|
const uniqueContentCount = countUniqueContent(payload);
|
|
|
|
const meta = homeMeta(payload);
|
|
const appHtml = render(payload);
|
|
const jsonld = collectJsonLd(payload, {title: meta.title, description: meta.description});
|
|
const head = renderHead({
|
|
payload,
|
|
meta,
|
|
scriptSrc: `${assetBase}/${assets.script}`,
|
|
cssHrefs: assets.css.map((file) => `${assetBase}/${file}`),
|
|
});
|
|
|
|
const html = [
|
|
'<!doctype html>',
|
|
'<html lang="ko">',
|
|
' <head>',
|
|
head,
|
|
' </head>',
|
|
/* class="site" 는 장식이 아니다 — 시안 토큰·유틸(shared/styles/site.css)이 이 클래스 안에서만 산다. */
|
|
' <body class="site">',
|
|
` <div id="root">${appHtml}</div>`,
|
|
` <script>window.__SITE_PAYLOAD__=${serializePayload(payload)}</script>`,
|
|
' </body>',
|
|
'</html>',
|
|
'',
|
|
].join('\n');
|
|
|
|
/** 절대규칙 3 — 나갈 바로 그 HTML 에 대고 대조한다. */
|
|
for (const problem of verifyJsonLd(html, jsonld)) mismatches.push(problem);
|
|
for (const problem of verifyGeo(jsonld, payload.place.latitude, payload.place.longitude)) {
|
|
mismatches.push(problem);
|
|
}
|
|
|
|
if (mismatches.length > 0) {
|
|
throw new VerifyError(mismatches, uniqueContentCount);
|
|
}
|
|
|
|
/** 절대규칙 2 — 이 가게에만 있는 콘텐츠가 0건이면 굽지 않는다. */
|
|
if (uniqueContentCount <= 0) {
|
|
throw new NoUniqueContentError(uniqueContentCount);
|
|
}
|
|
|
|
writeFile(siteDir, 'index.html', html);
|
|
|
|
/** 기계용 파일은 `llms.txt` 하나만 남는다. */
|
|
writeFile(siteDir, 'llms.txt', renderLlmsTxt(payload));
|
|
|
|
copySongs(payload, siteDir);
|
|
|
|
// 하이드레이션용 번들.
|
|
if (!plan.shared) {
|
|
writeSharedAssets(plan.dir, referenced);
|
|
}
|
|
|
|
// 이전 구현이 사이트마다 복사해 둔 자산이 남아 있으면 지운다 — 공용으로 바뀐 뒤에는 아무도 참조하지 않는 죽은 파일이고, 사이트 수만큼 디스크를 계속 먹는다.
|
|
if (plan.shared) {
|
|
rmSync(join(siteDir, 'assets'), {recursive: true, force: true});
|
|
rmSync(join(siteDir, 'fonts'), {recursive: true, force: true});
|
|
}
|
|
|
|
// 보고서용 — 실제로 나간 JSON-LD 를 그대로 담는다.
|
|
return {
|
|
siteDir,
|
|
payload,
|
|
jsonld,
|
|
uniqueContentCount,
|
|
};
|
|
}
|
|
|
|
/** 이 가게에만 있는 콘텐츠 건수 — 백엔드 `count_unique_content` 와 **같은 규칙**이다. */
|
|
const MIN_UNIQUE_TEXT = 8;
|
|
|
|
function countUniqueContent(payload: SitePayload): number {
|
|
// socialPosts는 우리 출력이다.
|
|
const long = (value: unknown) => String(value ?? '').trim().length >= MIN_UNIQUE_TEXT;
|
|
let count = 0;
|
|
|
|
// 소개 섹션의 직접 입력 본문은 실제 AboutSection 에 표시되는 가게 고유 문장이다.
|
|
const intro = payload.theme.sections.find((section) => section.id === 'intro');
|
|
if (intro?.enabled && long(intro.body)) count += 1;
|
|
|
|
/** 붙여넣기 아이템(가요·일력·승차권·인물…)의 항목도 이 가게에만 있는 문장이다. */
|
|
const hasLongText = (value: unknown): boolean => {
|
|
if (typeof value === 'string') return long(value);
|
|
if (Array.isArray(value)) return value.some(hasLongText);
|
|
if (value && typeof value === 'object') return Object.values(value).some(hasLongText);
|
|
return false;
|
|
};
|
|
for (const section of payload.theme.sections) {
|
|
if (!section.enabled || !SECTION_ITEM_REQUIRED_KEY[section.id]) continue;
|
|
const parsed = parseSectionData<Record<string, unknown>>(section.id, section.data);
|
|
for (const item of parsed.items) {
|
|
if (hasLongText(item)) count += 1;
|
|
}
|
|
}
|
|
|
|
// 문장형 fact 만 센다 — bool·number·time 은 전부 템플릿 값이라 가게를 구분하지 못한다.
|
|
for (const fact of [...payload.facts, ...payload.units.flatMap((unit) => unit.facts)]) {
|
|
if (fact.type === 'text' && long(fact.value)) count += 1;
|
|
}
|
|
for (const faq of payload.faqs) {
|
|
// 문의 안내(TEMPLATE)는 모든 가게에 같은 문구다 — 세면 내용 없는 사이트가 게이트를 통과한다.
|
|
if (faq.sourceType === SourceType.TEMPLATE) continue;
|
|
if (String(faq.question ?? '').trim() && long(faq.answer)) count += 1;
|
|
}
|
|
for (const item of payload.media) {
|
|
if (long(item.alt)) count += 1;
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
/** 렌더 결과 보고서. */
|
|
interface RenderReport {
|
|
schemaVersion: 1;
|
|
slug: string;
|
|
siteId: string;
|
|
placeId: string;
|
|
siteVersion: number;
|
|
ok: boolean;
|
|
renderedAt: string;
|
|
routes: number;
|
|
bundle: string;
|
|
uniqueContentCount: number | null;
|
|
jsonld: unknown[] | null;
|
|
/** 절대규칙 3 위반 목록. */
|
|
mismatches: string[];
|
|
error: string | null;
|
|
}
|
|
|
|
function writeReport(payloadFile: string, report: RenderReport) {
|
|
const dir = join(dirname(payloadFile), '.status');
|
|
mkdirSync(dir, {recursive: true});
|
|
const target = join(dir, `${report.slug}.json`);
|
|
const tmp = join(dir, `.${report.slug}.json.tmp`);
|
|
writeFileSync(tmp, JSON.stringify(report, null, 2), 'utf-8');
|
|
renameSync(tmp, target);
|
|
}
|
|
|
|
/** 옛 자산 보관 기간. */
|
|
const ASSET_RETENTION_DAYS = 30;
|
|
/** 하루에 여러 번 배포해도 직전 빌드는 반드시 남는다(보관 기간과 무관). */
|
|
const ASSET_MIN_BUILDS = 2;
|
|
/** 어떤 빌드가 어떤 파일을 깔았는지. */
|
|
const ASSET_LEDGER = '.builds.json';
|
|
|
|
/** 자산 대장 한 줄 — 한 번의 번들 빌드가 깐 파일 목록. */
|
|
interface AssetBuild {
|
|
/** 이 번들을 마지막으로 깐 시각(ISO). */
|
|
at: string;
|
|
/** assets/ 기준 상대 경로. */
|
|
files: string[];
|
|
}
|
|
|
|
/** 디렉토리 안의 파일을 상대 경로로 편다(하위 디렉토리 포함). */
|
|
function listRelativeFiles(dir: string, prefix = ''): string[] {
|
|
const found: string[] = [];
|
|
for (const entry of readdirSync(dir, {withFileTypes: true})) {
|
|
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
if (entry.isDirectory()) found.push(...listRelativeFiles(join(dir, entry.name), rel));
|
|
else if (entry.isFile()) found.push(rel);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/** 발행본이 **지금 실제로 참조하고 있는** 자산. */
|
|
function isDirLike(parent: string, entry: {name: string; isDirectory(): boolean; isSymbolicLink(): boolean}): boolean {
|
|
// `out/s/<slug>` 는 심볼릭 링크다(publishVersion).
|
|
if (entry.isDirectory()) return true;
|
|
if (!entry.isSymbolicLink()) return false;
|
|
try {
|
|
return statSync(join(parent, entry.name)).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function referencedAssets(outRoot: string): Set<string> {
|
|
const found = new Set<string>();
|
|
|
|
const scanIndexHtml = (file: string) => {
|
|
if (!existsSync(file)) return;
|
|
// basePath 가 붙어도(`/sites/assets/…`) `/assets/` 뒤만 집으면 파일 경로가 나온다.
|
|
for (const match of readFileSync(file, 'utf-8').matchAll(/\/assets\/([^"'\s)\\]+)/g)) {
|
|
found.add(decodeURIComponent(match[1]));
|
|
}
|
|
};
|
|
|
|
const sitesDir = join(outRoot, SITE_DIR);
|
|
if (existsSync(sitesDir)) {
|
|
for (const entry of readdirSync(sitesDir, {withFileTypes: true})) {
|
|
if (!isDirLike(sitesDir, entry)) continue;
|
|
scanIndexHtml(join(sitesDir, entry.name, 'index.html'));
|
|
}
|
|
}
|
|
|
|
const versionsDir = join(outRoot, VERSIONS_DIR);
|
|
if (existsSync(versionsDir)) {
|
|
for (const slugEntry of readdirSync(versionsDir, {withFileTypes: true})) {
|
|
if (!slugEntry.isDirectory()) continue;
|
|
const slugDir = join(versionsDir, slugEntry.name);
|
|
for (const versionEntry of readdirSync(slugDir, {withFileTypes: true})) {
|
|
if (!versionEntry.isDirectory()) continue;
|
|
scanIndexHtml(join(slugDir, versionEntry.name, 'index.html'));
|
|
}
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
function readAssetLedger(assetsDir: string): AssetBuild[] {
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(join(assetsDir, ASSET_LEDGER), 'utf-8')) as {
|
|
builds?: AssetBuild[];
|
|
};
|
|
return Array.isArray(parsed.builds) ? parsed.builds : [];
|
|
} catch {
|
|
// 없거나 깨졌으면 빈 대장으로 시작한다.
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** 보관 기간이 지난 옛 해시 파일만 지운다. */
|
|
function pruneAssets(assetsDir: string, current: string[], referenced: Set<string>) {
|
|
const signature = (files: string[]) => [...files].sort().join('\n');
|
|
const now = new Date().toISOString();
|
|
const previous = readAssetLedger(assetsDir);
|
|
const head: AssetBuild = {at: now, files: current};
|
|
const builds =
|
|
previous[0] && signature(previous[0].files) === signature(current)
|
|
? [head, ...previous.slice(1)]
|
|
: [head, ...previous];
|
|
|
|
/** 대장에 없는 파일은 **지우지 않는다.** 언제 깔렸는지 모를 뿐이므로 지금 처음 본 것으로 치고 보관 기간을 새로 준다. */
|
|
const recorded = new Set(builds.flatMap((build) => build.files));
|
|
const adopted = listRelativeFiles(assetsDir).filter(
|
|
(file) => file !== ASSET_LEDGER && !recorded.has(file),
|
|
);
|
|
if (adopted.length > 0) {
|
|
builds.push({at: now, files: adopted});
|
|
console.log(` ✓ 대장에 없던 자산 ${adopted.length}개를 보관 대상으로 넣는다`);
|
|
}
|
|
|
|
const cutoff = Date.now() - ASSET_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
const kept = builds.filter(
|
|
(build, index) => index < ASSET_MIN_BUILDS || Date.parse(build.at) >= cutoff,
|
|
);
|
|
const alive = new Set(kept.flatMap((build) => build.files));
|
|
|
|
let removed = 0;
|
|
for (const file of listRelativeFiles(assetsDir)) {
|
|
// 참조가 살아 있으면 기간과 무관하게 남긴다 — referencedAssets 주석 참조.
|
|
if (file === ASSET_LEDGER || alive.has(file) || referenced.has(file)) continue;
|
|
rmSync(join(assetsDir, file), {force: true});
|
|
removed += 1;
|
|
}
|
|
|
|
writeFileSync(
|
|
join(assetsDir, ASSET_LEDGER),
|
|
JSON.stringify({schemaVersion: 1, builds: kept}, null, 2),
|
|
'utf-8',
|
|
);
|
|
if (removed > 0) {
|
|
console.log(` ✓ 보관 기간(${ASSET_RETENTION_DAYS}일)이 지난 자산 ${removed}개 정리`);
|
|
}
|
|
}
|
|
|
|
/** 클라이언트 번들과 public/ 을 대상 디렉토리에 깐다. */
|
|
function writeSharedAssets(destRoot: string, referenced: Set<string>) {
|
|
const assetsSrc = join(CLIENT_DIR, 'assets');
|
|
if (existsSync(assetsSrc)) {
|
|
const assetsDest = join(destRoot, 'assets');
|
|
copyDirectoryFiles(assetsSrc, assetsDest);
|
|
pruneAssets(assetsDest, listRelativeFiles(assetsSrc), referenced);
|
|
}
|
|
const publicDir = join(SITE_ROOT, 'public');
|
|
if (existsSync(publicDir)) {
|
|
copyDirectoryFiles(publicDir, destRoot);
|
|
}
|
|
}
|
|
|
|
/** 오리진 루트의 `robots.txt` 와 사이트맵 인덱스. */
|
|
/** 빌더 미리보기가 iframe 으로 띄우는 CSR 셸. */
|
|
function writePreviewShell(outRoot: string, assets: {script: string; css: string[]}) {
|
|
const lines = [
|
|
'<!doctype html>',
|
|
'<html lang="ko">',
|
|
' <head>',
|
|
' <meta charset="utf-8" />',
|
|
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
// 미리보기는 색인 대상이 아니다.
|
|
' <meta name="robots" content="noindex, nofollow" />',
|
|
' <title>미리보기</title>',
|
|
...assets.css.map((file) => ` <link rel="stylesheet" href="/assets/${file}" />`),
|
|
' </head>',
|
|
' <body class="site">',
|
|
' <div id="root"></div>',
|
|
` <script type="module" src="/assets/${assets.script}"></script>`,
|
|
' </body>',
|
|
'</html>',
|
|
];
|
|
const dir = join(outRoot, 'preview');
|
|
mkdirSync(dir, {recursive: true});
|
|
writeFileSync(join(dir, 'index.html'), lines.join('\n'), 'utf-8');
|
|
console.log(' ✓ 빌더 미리보기 셸 (/preview)');
|
|
}
|
|
|
|
/** 이미 구워져 있는 사이트에서 오리진을 알아낸다. */
|
|
function findBakedOrigin(outRoot: string): string {
|
|
const sitesDir = join(outRoot, SITE_DIR);
|
|
if (!existsSync(sitesDir)) return '';
|
|
let newest: {origin: string; mtimeMs: number} | null = null;
|
|
for (const entry of readdirSync(sitesDir, {withFileTypes: true})) {
|
|
if (!entry.isDirectory()) continue;
|
|
const file = join(sitesDir, entry.name, 'index.html');
|
|
if (!existsSync(file)) continue;
|
|
const origin = readBakedOrigin(readFileSync(file, 'utf-8'));
|
|
if (!origin) continue;
|
|
const mtimeMs = statSync(file).mtimeMs;
|
|
if (!newest || mtimeMs > newest.mtimeMs) newest = {origin, mtimeMs};
|
|
}
|
|
return newest?.origin ?? '';
|
|
}
|
|
|
|
function writeRootMachineFiles(outRoot: string, origin: string) {
|
|
const sitesDir = join(outRoot, SITE_DIR);
|
|
if (!existsSync(sitesDir)) return;
|
|
|
|
const sites: DirectoryEntry[] = readdirSync(sitesDir, {withFileTypes: true})
|
|
// `out/s/<slug>` 는 이제 버전 디렉토리를 가리키는 심볼릭 링크다(publishVersion) — 링크 자체는 isDirectory() 가 false 이므로 isDirLike 로 대상까지 확인해야 목록에서 안 빠진다.
|
|
.filter((entry) => isDirLike(sitesDir, entry))
|
|
.map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')}))
|
|
// index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다.
|
|
.filter((entry) => existsSync(entry.file))
|
|
// 제목·lastmod·noindex 가 같은 HTML 에서 나온다 — 파일은 한 번만 읽는다.
|
|
.map((entry) => ({...entry, html: readFileSync(entry.file, 'utf-8')}))
|
|
// noindex 를 선언한 페이지(목업·백업)는 싣지 않는다 — readBakedNoindex 주석 참조.
|
|
.filter((entry) => !readBakedNoindex(entry.html))
|
|
.map(({html, ...entry}) => {
|
|
return {
|
|
// 끝 슬래시를 붙이지 않는다.
|
|
loc: joinUrl(origin, SITE_DIR, entry.slug),
|
|
title: readBakedTitle(html) || entry.slug,
|
|
// mtime 으로 떨어지는 건 dateModified 메타가 없던 시절의 산출물뿐이다.
|
|
lastmod: readBakedLastmod(html) ?? statSync(entry.file).mtime.toISOString(),
|
|
};
|
|
})
|
|
.sort((a, b) => a.loc.localeCompare(b.loc));
|
|
|
|
// `/s` 목록 페이지.
|
|
const indexUrl = joinUrl(origin, SITE_DIR);
|
|
writeFileSync(join(sitesDir, 'index.html'), renderSiteIndex(origin, indexUrl, sites), 'utf-8');
|
|
|
|
// 사이트맵에는 랜딩·목록 페이지도 담는다.
|
|
const entries: SiteEntry[] = [{loc: origin + '/'}, {loc: indexUrl}, ...sites];
|
|
|
|
writeFileSync(join(outRoot, 'robots.txt'), renderRootRobotsTxt(origin), 'utf-8');
|
|
writeFileSync(join(outRoot, 'sitemap.xml'), renderSiteUrlset(entries), 'utf-8');
|
|
writeFileSync(join(outRoot, 'llms.txt'), renderRootLlmsTxt(origin, indexUrl, sites), 'utf-8');
|
|
console.log(` ✓ 루트 robots.txt · sitemap.xml · llms.txt · /s/ 목록 (사이트 ${sites.length}개)`);
|
|
writeIndexNowKey(outRoot);
|
|
}
|
|
|
|
/** IndexNow 키 파일 — `https://<host>/<key>.txt` 에 키 문자열만 들어 있다. */
|
|
function writeIndexNowKey(outRoot: string) {
|
|
const key = (process.env.INDEXNOW_KEY ?? '').trim();
|
|
if (!key) return;
|
|
// 규격: 8~128자, 영문·숫자·하이픈.
|
|
if (!/^[A-Za-z0-9-]{8,128}$/.test(key)) {
|
|
console.warn(' ! INDEXNOW_KEY 형식이 규격(8~128자 영문·숫자·하이픈)에 맞지 않아 건너뛴다');
|
|
return;
|
|
}
|
|
writeFileSync(join(outRoot, `${key}.txt`), key, 'utf-8');
|
|
console.log(' ✓ IndexNow 키 파일');
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs();
|
|
const assets = readAssets();
|
|
if (args.activate) {
|
|
const match = /^([\p{L}\p{N}_-]+):([1-9][0-9]*)$/u.exec(args.activate);
|
|
if (!match || PROTECTED_SLUGS.has(match[1])) throw new Error('잘못되거나 보호된 발행 대상');
|
|
const [, slug, version] = match;
|
|
const saved = JSON.parse(readFileSync(join(versionDir(args.out, slug, version), '.render-report.json'), 'utf8'));
|
|
if (!saved.ok || saved.slug !== slug || saved.siteVersion !== Number(version)) throw new Error('검증된 버전이 아닙니다');
|
|
publishVersion(args.out, slug, Number(version));
|
|
writeRootMachineFiles(args.out, saved.origin);
|
|
return;
|
|
}
|
|
|
|
/* 자산만 시딩(dev 편의) — **굽지 않는다.** 개발 컨테이너(solution-frontend --profile dev)가 기동할 때 `out/assets` · `out/fonts` · `public/` 을 채워 두는 용도다. */
|
|
if (args.seedAssets) {
|
|
console.log(`[prerender] 공용 자산만 시딩 → ${args.out}`);
|
|
writeSharedAssets(args.out, referencedAssets(args.out));
|
|
writePreviewShell(args.out, assets);
|
|
/* 루트 색인(sitemap.xml · llms.txt · `/s` 목록)은 여기서도 다시 쓴다. */
|
|
const bakedOrigin = findBakedOrigin(args.out);
|
|
if (bakedOrigin) writeRootMachineFiles(args.out, bakedOrigin);
|
|
console.log('[prerender] 완료');
|
|
return;
|
|
}
|
|
|
|
const loaded = loadPayloads(args.payloads);
|
|
|
|
console.log(`[prerender] 사이트 ${loaded.length}개 → ${args.out}`);
|
|
|
|
// 굽기 **전에** 참조를 훑는다.
|
|
const referenced = referencedAssets(args.out);
|
|
|
|
// 공용 자산은 사이트를 굽기 전에 딱 한 번 깐다.
|
|
writeSharedAssets(args.out, referenced);
|
|
|
|
let failed = 0;
|
|
/** 루트 기계용 파일을 쓸 오리진. */
|
|
let origin = '';
|
|
|
|
/** 보고서용 슬러그. */
|
|
const slugOf = (entry: LoadedPayload) =>
|
|
entry.payload?.site?.slug ?? (entry.file ? basename(entry.file, '.json') : '');
|
|
|
|
const fail = (
|
|
entry: LoadedPayload,
|
|
message: string,
|
|
mismatches: string[] = [],
|
|
uniqueContentCount: number | null = null,
|
|
) => {
|
|
failed += 1;
|
|
console.error(` ✗ ${slugOf(entry) || '(slug 없음)'} — ${message}`);
|
|
if (!entry.file) return;
|
|
writeReport(entry.file, {
|
|
schemaVersion: 1,
|
|
slug: slugOf(entry),
|
|
siteId: entry.payload?.site?.siteId ?? '',
|
|
placeId: entry.payload?.site?.placeId ?? '',
|
|
siteVersion: entry.payload?.site?.version ?? 0,
|
|
ok: false,
|
|
renderedAt: new Date().toISOString(),
|
|
routes: 0,
|
|
bundle: assets.script,
|
|
uniqueContentCount,
|
|
jsonld: null,
|
|
mismatches,
|
|
error: message,
|
|
});
|
|
};
|
|
|
|
for (const entry of loaded) {
|
|
// 읽기·검증 단계에서 이미 실패한 건 굽지 않는다.
|
|
if (entry.error || !entry.payload) {
|
|
fail(entry, entry.error ?? 'payload 를 읽지 못했습니다');
|
|
continue;
|
|
}
|
|
|
|
// 목업이 쓰는 슬러그는 굽지 않는다 — 구우면 손으로 만든 유일본을 덮는다(PROTECTED_SLUGS).
|
|
if (PROTECTED_SLUGS.has(entry.payload.site.slug)) {
|
|
fail(
|
|
entry,
|
|
`슬러그 '${entry.payload.site.slug}' 는 시연본이 쓰고 있어 발행할 수 없습니다. 다른 슬러그로 발행해 주세요.`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
// 굽기 **전에** 사진을 우리 자리로 옮긴다 — 렌더·JSON-LD·og:image 가 전부 같은 payload 를 보므로, 여기서 주소를 바꿔 놓아야 한 벌로 맞는다.
|
|
const stagingDir = versionDir(args.out, entry.payload.site.slug, entry.payload.site.version);
|
|
const savedReportPath = join(stagingDir, '.render-report.json');
|
|
// 성공한 버전은 불변이다.
|
|
if (existsSync(savedReportPath) && existsSync(join(stagingDir, 'index.html'))) {
|
|
const saved = JSON.parse(readFileSync(savedReportPath, 'utf8'));
|
|
if (!saved.ok || saved.siteId !== entry.payload.site.siteId) throw new Error('저장 버전 불일치');
|
|
if (entry.file) writeReport(entry.file, saved);
|
|
if (entry.payload.site.publish && !args.stageOnly) {
|
|
publishVersion(args.out, entry.payload.site.slug, entry.payload.site.version);
|
|
origin = saved.origin;
|
|
}
|
|
continue;
|
|
}
|
|
await mirrorMedia(entry.payload, stagingDir);
|
|
const result = prerenderSite(entry.payload, args.out, assets, referenced);
|
|
const payload = result.payload;
|
|
origin = origin || payload.site.origin;
|
|
|
|
// publish=false(미리보기·재빌드)면 여기서 끝난다 — `out/versions/…` 에는 남지만 `out/s/<slug>` 는 손대지 않는다.
|
|
if (payload.site.publish && !args.stageOnly) {
|
|
publishVersion(args.out, payload.site.slug, payload.site.version);
|
|
pruneOldVersions(args.out, payload.site.slug, payload.site.version);
|
|
const home = joinUrl(payload.site.origin, payload.site.basePath);
|
|
console.log(` ✓ ${payload.place.name} (${payload.site.slug}) — 발행 v${payload.site.version}`);
|
|
console.log(` ${home}`);
|
|
} else {
|
|
console.log(` ✓ ${payload.place.name} (${payload.site.slug}) — 빌드만(v${payload.site.version}, 공개 주소는 그대로)`);
|
|
}
|
|
console.log(` ${result.siteDir}`);
|
|
|
|
if (entry.file) {
|
|
const report: RenderReport = {
|
|
schemaVersion: 1,
|
|
slug: payload.site.slug,
|
|
siteId: payload.site.siteId,
|
|
placeId: payload.site.placeId,
|
|
siteVersion: payload.site.version,
|
|
ok: true,
|
|
renderedAt: new Date().toISOString(),
|
|
// 사이트당 한 장.
|
|
routes: 1,
|
|
bundle: assets.script,
|
|
uniqueContentCount: result.uniqueContentCount,
|
|
jsonld: result.jsonld,
|
|
mismatches: [],
|
|
error: null,
|
|
};
|
|
writeFileSync(savedReportPath, JSON.stringify({...report, origin: payload.site.origin}));
|
|
writeReport(entry.file, report);
|
|
}
|
|
} catch (ex) {
|
|
// 계수를 못 잰 실패(디스크·번들·payload 파손)는 null 로 남긴다.
|
|
const counted = ex instanceof VerifyError || ex instanceof NoUniqueContentError ? ex : null;
|
|
fail(
|
|
entry,
|
|
ex instanceof Error ? ex.message : String(ex),
|
|
counted?.mismatches ?? [],
|
|
counted?.uniqueContentCount ?? null,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 실패한 사이트가 있어도 루트 파일은 갱신한다 — 성공한 사이트까지 색인에서 빠질 이유가 없다.
|
|
writePreviewShell(args.out, assets);
|
|
if (origin) writeRootMachineFiles(args.out, origin);
|
|
|
|
if (failed > 0) {
|
|
console.error(`[prerender] ${loaded.length}개 중 ${failed}개 실패`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
console.log('[prerender] 완료');
|
|
}
|
|
|
|
void main();
|