세 가지가 한 줄기다 — 목업에만 있던 것을 제품으로 옮기면서, 그게 이미 나가 있는
사이트를 건드리지 않게 하는 데까지가 한 변경이다.
① 지역 읽기(mockup/README T7) — 목업은 주입 스크립트로 그렸고 렌더러엔 없었다.
새로 발행한 업장에서는 영영 빈자리였다(`daily` 가 프롬프트를 빌더에만 둬서 서버가
그 종류를 몰랐던 것과 같은 사고).
· shared: `ReadingItem` · `SECTION_ITEM_REQUIRED_KEY.reading` · `reading` 프롬프트
(프롬프트 단일 출처는 `section-prompts.ts` 하나다 — 코드에 문장을 박지 않는다)
· backend: STORY_KINDS 등록. `_SEARCH_LINK_KINDS` — 이 종류는 모델의 URL 을 안 받고
제목으로 만든 네이버 검색 링크를 코드가 붙인다(주소를 짐작해 적으면 없는 문서로 간다)
· site: '지역 이야기' 여섯 번째 탭. 구운 HTML 은 앞에서 여섯 꼭지, 붙은 뒤 한 번 섞는다
· 탭 이름은 `{지명} 읽기` — '군산' 을 코드에 박지 않는다
② 엽서 공유가 모든 발행 사이트에서 막혀 있던 것. 사진이 `*.pstatic.net` ·
`tong.visitkorea.or.kr` 에 있고 그쪽이 `Access-Control-Allow-Origin` 을 안 준다
(실측 세 곳 모두 없음) — 캔버스가 오염돼 `toBlob` 이 죽는다. 클라이언트에서는 못 넘는다.
· `prerender.ts mirrorMedia`: 굽기 전에 `s/<slug>/img/<주소해시>.<확장자>` 로 받고
payload 주소를 우리 오리진 절대주소로 바꾼다(og:image·JSON-LD 도 같은 값을 쓴다)
· 못 받으면 원래 주소를 쓴다. 파일명이 주소 해시라 다시 구워도 안 받는다
· `originUrl`·`sourceType` 은 그대로 — DECISIONS 1-2 가 "불가" 면 CRAWL 제외가 먹어야 한다
· 엽서 미리보기를 240px 로 묶었다(대표: "엽서 ui 너무 큼")
③ **기동이 전부 다시 굽지 않는다** (대표: "전체 재굽기 할 필요가 없어, 사장님이
재발행하면 끝인데 / css js만 안 깨지게 하란 말이야").
렌더러를 한 줄 고칠 때마다 이미 나가 있는 사이트의 HTML 이 통째로 바뀌던 자리다.
· `watch-payloads.mjs`: 기동 = `--refresh-assets` 하나. 한 번도 안 구워진 payload 만 굽는다
· `prerender.ts refreshBakedAssets`: 구워진 HTML 의 `assets/index-<해시>.css|js` 파일명만
새 번들로 바꾼다. 내용·payload·접두사는 그대로. 보호 슬러그는 건너뛴다
· 그래서 ①②는 **다음 발행 때** 그 사이트에 들어간다
문서: AGENTS.md 함정 둘(사진 내려받기 · 기동은 안 굽는다) 추가, 전체 재굽기를 전제하던
옛 항목 둘을 고쳤다. mockup/README T7 은 "제품에 들어갔다" 로, DATA_MODEL 의 STORY kind 목록 갱신.
검증: tsc·eslint 통과(site·frontend), site 79 passed(읽기 4건 추가).
실측 — buru 굽기: 사진 10장 내려받고 og:image 가 우리 주소, 재굽기 때 0건;
`--refresh-assets`: 옛 해시로 바꿔 둔 index.html 1곳이 새 번들 주소로 바뀌고 내용은 그대로.
백엔드 테스트는 이 기계의 5432 가 다른 터널에 물려 있어 못 돌렸다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1167 lines
54 KiB
TypeScript
1167 lines
54 KiB
TypeScript
import {
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
renameSync,
|
|
rmSync,
|
|
statSync,
|
|
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,
|
|
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';
|
|
|
|
/**
|
|
* 발행 사이트 프리렌더.
|
|
*
|
|
* npm run prerender 데모 payload 로 굽는다
|
|
* npm run prerender -- --payload=a.json 파일 하나
|
|
* npm run prerender -- --payload=a.json --payload=b.json 여러 개(바뀐 것만)
|
|
* npm run prerender -- --payload=./payloads 디렉토리 안의 *.json 전부
|
|
* npm run prerender -- --out=../../dist 출력 위치 지정
|
|
*
|
|
* ★ --payload 은 여러 번 줄 수 있다. 사이트 1,000개에서 한 명이 발행했다고 전부 다시
|
|
* 구우면 못 쓴다 — 감시 프로세스가 바뀐 payload 만 골라 넘긴다.
|
|
*
|
|
* 산출물(사이트 1개당):
|
|
* out/s/<slug>/index.html 사이트 전체(한 장)
|
|
* out/s/<slug>/llms.txt 확인된 사실 목록(AEO)
|
|
* out/payloads/.status/<slug>.json 렌더 결과 보고서(백엔드가 읽는다)
|
|
*
|
|
* 오리진 루트(사이트 전체가 공유):
|
|
* out/robots.txt · out/sitemap.xml 크롤러가 읽는 유일한 자리
|
|
* out/<indexnow-key>.txt 색인 통보용 키 파일
|
|
*
|
|
* 공용 산출물(사이트 전체가 공유):
|
|
* out/assets/… 클라이언트 번들(하이드레이션용)
|
|
* out/fonts/… 폰트
|
|
*
|
|
* ★ 자산을 사이트마다 복사하지 않는다. 같은 해시의 번들을 1,000벌 복사하면 디스크도
|
|
* 낭비지만, 더 나쁜 건 브라우저 캐시가 사이트마다 따로 잡혀 매번 새로 받는다는 점이다.
|
|
* (커스텀 도메인 사이트는 예외 — 호스트가 달라 공용 경로를 공유할 수 없다.)
|
|
*
|
|
* ★ 이 스크립트는 백엔드 BUILD 잡이 부르는 자리다. 잡이 payload JSON 을 써 주고
|
|
* 이걸 실행하면 정적 파일이 나온다. 지금은 payload 를 파일에서 읽지만,
|
|
* API 가 열리면 --payload=https://... 를 지원하는 것으로 충분하다.
|
|
*/
|
|
|
|
/** 사이트별 산출물이 놓이는 디렉터리. 발행 주소 `<host>/s/<slug>` 와 같은 모양이다. */
|
|
const SITE_DIR = 's';
|
|
|
|
/**
|
|
* 손으로 만든 시연본(목업)이 차지한 슬러그. **이 슬러그는 굽지 않는다.**
|
|
*
|
|
* ★ 왜 (2026-09-15, 실측)
|
|
* `/s/stay` 는 payload 가 없는 목업이고 그 index.html 이 **유일본**이다(AGENTS.md 함정 1).
|
|
* 그런데 누군가 빌더에서 슬러그 `stay` 로 발행하자 `payloads/stay.json` 이 생겼고, 프리렌더가
|
|
* 기동하며 그 payload 로 같은 자리를 구워 **목업을 통째로 덮었다** — 캐치프레이즈 100개 ·
|
|
* 미니 플레이어 · 날씨 문구 · 주입분이 전부 사라졌다. 파일을 백업에서 되돌려야 했다.
|
|
* "payload 가 없으면 안 굽는다" 는 목업을 지켜 주지 못한다. **payload 가 생기는 순간** 덮인다.
|
|
*
|
|
* ★ 슬러그를 막는 것이지 발행을 막는 것이 아니다. 백엔드는 발행에 성공했다고 보고, 여기서
|
|
* 실패 보고서를 써 준다 — 사장님 화면에 사유가 뜨고, 목업은 그대로 남는다.
|
|
*
|
|
* ★ `stay` 하나만 막는다. `stay2` · `stay3` 도 원래 목업이었지만 그 슬러그는 이미 실제 사이트가
|
|
* 가져가 발행 중이라(버터브루 = stay2) 막으면 그 사이트가 재굽기에서 빠진다.
|
|
* 시연본은 `/s/stay` 다(mockup/README 2.1).
|
|
*
|
|
* ★ 목업을 늘리거나 걷어낼 때는 `PRERENDER_PROTECTED_SLUGS`(쉼표) 로 덮어쓴다. 값을 비우면
|
|
* 보호가 전부 풀린다 — 목업을 정식 사이트로 넘길 때만 그렇게 한다.
|
|
*/
|
|
const PROTECTED_SLUGS = new Set(
|
|
(process.env.PRERENDER_PROTECTED_SLUGS ?? 'stay')
|
|
.split(',')
|
|
.map((slug) => slug.trim())
|
|
.filter(Boolean),
|
|
);
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const SITE_ROOT = resolve(HERE, '..', '..'); // dist/prerender → site/
|
|
// 백엔드가 노래 파일을 떨구는 자리. payload 디렉토리와 나란히 둔다(한 디렉토리 약속).
|
|
const SONGS_DIR = join(SITE_ROOT, 'songs');
|
|
const CLIENT_DIR = join(SITE_ROOT, 'dist', 'client');
|
|
|
|
interface Args {
|
|
/** --payload 은 여러 번 줄 수 있다. 비면 데모 payload 로 굽는다. */
|
|
payloads: string[];
|
|
out: string;
|
|
/** 굽지 않고 **이미 구워진 HTML 의 자산 주소만** 새 번들로 갈아 끼운다. `main()` 주석 참조. */
|
|
refreshAssets: boolean;
|
|
/** payload 가 사는 디렉토리. 자산 주소를 갈아 끼울 대상을 **payload 가 있는 슬러그로** 좁힌다. */
|
|
payloadDir: string;
|
|
}
|
|
|
|
function parseArgs(): Args {
|
|
const args: Args = {
|
|
payloads: [],
|
|
out: join(SITE_ROOT, 'out'),
|
|
refreshAssets: false,
|
|
payloadDir: join(SITE_ROOT, 'payloads'),
|
|
};
|
|
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 === 'refresh-assets') args.refreshAssets = true;
|
|
if (key === 'payload-dir' && value) args.payloadDir = resolve(process.cwd(), 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 한 건. 읽기·검증이 실패했으면 payload 대신 error 가 담긴다.
|
|
*
|
|
* ★ 실패를 여기서 throw 하지 않는다. 예전에는 payload 하나가 깨지면 배치 전체가 죽어서,
|
|
* 멀쩡한 사이트까지 못 구웠다(게다가 아무 보고서도 남지 않았다). 실패는 값으로 옮기고
|
|
* main 이 사이트 단위로 격리한다.
|
|
*/
|
|
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 이 없습니다'};
|
|
}
|
|
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 에 엔트리 청크가 없습니다.');
|
|
|
|
// ★ 파일명만 돌려준다. 앞에 붙일 경로는 사이트마다 다르다(`/s/<slug>`) —
|
|
// 여기서 `/assets/…` 로 굳히면 사이트가 `/s/mmg/` 아래 놓이는 순간 전부 404 가 되고,
|
|
// JS 가 안 붙어 하이드레이션이 죽는다(정적 HTML 만 남는다). 실측으로 그렇게 됐다.
|
|
return {
|
|
script: entry.file.replace(/^assets\//, ''),
|
|
css: (entry.css ?? []).map((file) => file.replace(/^assets\//, '')),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 구조화 데이터 대조 실패. ★ 재시도해도 소용없다 — 데이터가 고쳐져야 통과한다.
|
|
*
|
|
* 일반 렌더 오류(디스크·번들)와 구분해야 한다. 그쪽은 재시도가 의미 있지만
|
|
* 이건 사장님이 값을 고치기 전까지 몇 번을 구워도 같은 결과다.
|
|
*/
|
|
class VerifyError extends Error {
|
|
constructor(
|
|
readonly mismatches: string[],
|
|
/** ★ 실패해도 계수는 보고한다. 백엔드가 NO_UNIQUE_CONTENT 와 JSONLD_MISMATCH 를 갈라야 한다. */
|
|
readonly uniqueContentCount: number | null = null,
|
|
) {
|
|
super(`구조화 데이터가 화면 값과 다릅니다(${mismatches.length}건): ${mismatches.slice(0, 3).join(' · ')}`);
|
|
this.name = 'VerifyError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 고유 콘텐츠 0건. **VerifyError 와 갈라 둔다.**
|
|
*
|
|
* ★ 예전에는 이 사유를 VerifyError 의 mismatches 에 실어 던졌다. 백엔드 게이트는
|
|
* mismatches 가 비지 않았다는 것만 보고 JSONLD_MISMATCH 로 판정했고, 사장님 화면에는
|
|
* "구조화 데이터와 화면 값이 다릅니다" 라는 **틀린 문구**가 떴다 — 구조화 데이터는
|
|
* 멀쩡했다. 사유가 다르면 예외도 갈라야 라벨이 안 섞인다.
|
|
*/
|
|
class NoUniqueContentError extends Error {
|
|
/** 백엔드가 JSONLD_MISMATCH 로 오인하지 않도록 늘 비어 있다. */
|
|
readonly mismatches: string[] = [];
|
|
|
|
constructor(readonly uniqueContentCount: number) {
|
|
super('고유 콘텐츠가 0건이다 — 이 가게에만 있는 내용이 없으면 발행하지 않는다');
|
|
this.name = 'NoUniqueContentError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 사이트는 **한 장**이다(2026-08-31). 예전에는 홈·객실·객실상세·주변·오시는길·FAQ 로
|
|
* 라우트를 갈라 사이트 하나에 30개 안팎의 HTML 을 구웠다.
|
|
*
|
|
* ★ 왜 합쳤나 — 소상공인은 원래 내용이 적다. 쪼갤수록 페이지마다 얇아지고, 검색엔진은
|
|
* 그런 페이지를 색인에서 버린다. 한 장에 모으면 알찬 페이지 하나가 된다.
|
|
* 경로형(`/s/<slug>`)이라 얇은 페이지의 평가가 도메인 전체로 번지는 것도 막는다.
|
|
*/
|
|
|
|
/**
|
|
* payload 를 HTML 안에 심을 수 있게 직렬화한다.
|
|
*
|
|
* ★ `<`, `>`, `&` 를 유니코드 이스케이프한다. 안 하면 사장님이 소개문에 적은
|
|
* `</script>` 한 줄이 스크립트 태그를 닫아 버린다 — 그 뒤 내용이 마크업으로 새고,
|
|
* 최악의 경우 임의 스크립트가 된다. U+2028/2029 는 JS 문법상 줄바꿈이라 함께 막는다.
|
|
*/
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 내려받은 사진이 놓이는 폴더. 사이트 디렉토리 안이라 Azure 발행 때 함께 올라간다. */
|
|
const MEDIA_DIR = 'img';
|
|
|
|
/** 한 장당 상한(바이트). 원본이 통짜 PNG 인 경우가 있어 막아 둔다 — 넘으면 원래 주소로 둔다. */
|
|
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',
|
|
};
|
|
|
|
/**
|
|
* 사진을 **우리 오리진으로 옮긴다.**
|
|
*
|
|
* ★ 왜 (2026-09-15, 실측)
|
|
* 발행본 사진은 수집한 자리(`*.pstatic.net` · `tong.visitkorea.or.kr`)를 그대로 가리켰다.
|
|
* 그 호스트들은 `Access-Control-Allow-Origin` 을 주지 않는다 — 그래서 그 사진을 캔버스에
|
|
* 그리면 **캔버스가 오염돼 파일로 못 뽑는다**(브라우저 정책). 엽서 쓰기의 저장·공유가
|
|
* 모든 발행 사이트에서 막혀 있었고("이 사진은 다른 사이트에 올라와 있어…"),
|
|
* 화면에는 미리보기만 남았다. 클라이언트에서는 넘을 방법이 없다 — CORS 없는 `fetch` 도
|
|
* 같은 벽에 막힌다. **같은 오리진에 파일이 있어야** 풀린다.
|
|
* ★ 덤이 아니라 같이 딸려 오는 것: 남의 CDN 이 핫링크를 끊거나 주소를 바꾸면 사진이
|
|
* 통째로 사라지는데, 옮겨 놓으면 그 일이 우리 사이트를 건드리지 못한다.
|
|
* ★ **재게시 권리(DECISIONS 1-2)의 결론을 앞당기지 않는다.** 화면에 이미 싣고 있는 것만
|
|
* 같은 자리로 옮기는 것이고, `originUrl` · `sourceType` 은 그대로 남는다 —
|
|
* "불가" 로 결론 나면 `sourceType = CRAWL` 을 발행에서 빼는 그 대응이 그대로 먹는다.
|
|
* ★ 실패는 조용히 넘긴다. 못 받은 사진은 **원래 주소를 그대로 쓴다** — 사진이 사라지는 것보다
|
|
* 공유가 막힌 채로 보이는 쪽이 낫다.
|
|
*/
|
|
async function mirrorMedia(payload: SitePayload, outRoot: string) {
|
|
const items = (payload.media ?? []).filter((item) => /^https?:\/\//i.test(item.url ?? ''));
|
|
if (items.length === 0) return;
|
|
|
|
const siteDir = join(outRoot, SITE_DIR, payload.site.slug);
|
|
const dir = join(siteDir, MEDIA_DIR);
|
|
// ★ 절대 주소로 바꾼다. 이 주소는 `<img src>` 뿐 아니라 og:image · JSON-LD 의 image 로도
|
|
// 나가는데, 그 둘은 절대 주소여야 한다(상대 주소를 주면 크롤러마다 다르게 읽는다).
|
|
// 수집 주소도 절대였으니 바뀌는 것은 호스트뿐이다.
|
|
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)와 같은 이유다. 안 치우면 사장님이 사진을
|
|
// 바꿀 때마다 쌓이고, Azure 발행 때 그대로 같이 올라간다.
|
|
for (const name of readdirSync(dir)) {
|
|
if (!wanted.has(name)) rmSync(join(dir, name), {force: true});
|
|
}
|
|
if (fetched > 0) console.log(` 사진 ${fetched}장을 내려받았습니다 (총 ${wanted.size}장)`);
|
|
}
|
|
|
|
/**
|
|
* 이미 구워진 사이트의 **자산 주소만** 새 번들로 갈아 끼운다. 내용은 건드리지 않는다.
|
|
*
|
|
* ★ 왜 이게 기본인가 (2026-09-15 대표 지시: "새로 만드는거만 기능적용, 나머지는 css js
|
|
* 에셋 주소만 바꿔치기")
|
|
* 예전에는 컨테이너가 뜰 때마다 payload 전부를 다시 구웠다. 그러면 렌더러를 한 줄 고칠
|
|
* 때마다 **이미 나가 있는 사이트의 HTML 이 통째로 바뀐다** — 사장님이 발행한 적 없는데
|
|
* 내용이 달라지고, 구글이 다시 읽어 가는 값도 달라진다. 새 기능은 **다음 발행 때** 들어간다.
|
|
* ★ 그런데 번들 파일명은 콘텐츠 해시라, 아무것도 안 하면 옛 HTML 이 옛 번들을 가리킨 채
|
|
* 남는다. 그 자산은 `referencedAssets` 덕에 지워지지는 않지만(AGENTS.md), 디자인 수정이
|
|
* 영영 반영되지 않는다. 그래서 **주소만** 바꾼다 — 시연본(`/s/stay`)에 하던 그 일이다
|
|
* (mockup/README "번들만 갈아 끼운다").
|
|
* ★ 서버가 그린 HTML 은 옛 렌더러의 것이고 붙는 스크립트는 새 렌더러다. 하이드레이션이
|
|
* 어긋나면 리액트가 그 자리에서 다시 그린다 — 손님 화면은 새것, 크롤러가 읽는 정적
|
|
* HTML 은 옛것이다. 그 둘을 맞추는 방법은 재발행뿐이고, 그건 사장님이 누르는 것이다.
|
|
* ★ 이번에 구운 사이트는 건너뛴다(이미 새 주소다).
|
|
* ★ ★ **payload 가 없는 디렉토리는 건드리지 않는다** — 목업(`stay3` · `*.old`)이다.
|
|
* 손으로 만든 유일본이라 다시 만들 수 없고(AGENTS.md 함정 1), 옛 렌더러가 그린 HTML 에
|
|
* 최신 번들을 물리면 어떻게 깨지든 되돌릴 방법이 없다. 그것들의 번들 교체는 사람이
|
|
* 한다(mockup/README "번들만 갈아 끼운다"). 보호 슬러그도 같은 이유로 건너뛴다.
|
|
*/
|
|
function refreshBakedAssets(
|
|
outRoot: string,
|
|
assets: {script: string; css: string[]},
|
|
baked: Set<string>,
|
|
payloadDir: string,
|
|
) {
|
|
const sitesDir = join(outRoot, SITE_DIR);
|
|
if (!existsSync(sitesDir)) return;
|
|
|
|
const owned = new Set(
|
|
(existsSync(payloadDir) ? readdirSync(payloadDir) : [])
|
|
.filter((name) => name.endsWith('.json') && !name.startsWith('.'))
|
|
.map((name) => name.slice(0, -'.json'.length)),
|
|
);
|
|
|
|
const css = assets.css[0];
|
|
let changed = 0;
|
|
|
|
for (const entry of readdirSync(sitesDir, {withFileTypes: true})) {
|
|
if (!entry.isDirectory()) continue;
|
|
if (baked.has(entry.name) || PROTECTED_SLUGS.has(entry.name)) continue;
|
|
if (!owned.has(entry.name)) continue;
|
|
|
|
const file = join(sitesDir, entry.name, 'index.html');
|
|
if (!existsSync(file)) continue;
|
|
const before = readFileSync(file, 'utf-8');
|
|
// 접두사(`/assets` · `/sites/assets`)는 그대로 두고 파일명만 바꾼다 — 사이트가
|
|
// 서브패스에 마운트돼 있으면 접두사가 다르다(assetPlan).
|
|
let after = before.replace(/(assets\/)index-[A-Za-z0-9_-]+\.js/g, `$1${assets.script}`);
|
|
if (css) after = after.replace(/(assets\/)index-[A-Za-z0-9_-]+\.css/g, `$1${css}`);
|
|
if (after === before) continue;
|
|
writeFileSync(file, after, 'utf-8');
|
|
changed += 1;
|
|
}
|
|
|
|
console.log(` ✓ 자산 주소 갱신 ${changed}곳 (내용은 그대로 — 새 기능은 다음 발행 때)`);
|
|
}
|
|
|
|
function assetPlan(payload: SitePayload, outRoot: string, siteDir: string) {
|
|
const basePath = payload.site.basePath.replace(/\/+$/, '');
|
|
const suffix = `/${SITE_DIR}/${payload.site.slug}`;
|
|
// out/ 이 도메인 루트가 아닌 곳에 마운트돼도 맞도록 basePath 에서 역산한다.
|
|
// ('/s/joy' → '' · '/sites/s/joy' → '/sites')
|
|
const rootPrefix = basePath.endsWith(suffix) ? basePath.slice(0, -suffix.length) : '';
|
|
const shared = basePath !== '';
|
|
|
|
return {
|
|
shared,
|
|
/** 자산을 실제로 복사해 넣을 디렉토리. */
|
|
dir: shared ? outRoot : siteDir,
|
|
/** HTML 이 참조할 접두사. 두 경우 모두 루트 절대경로가 된다. */
|
|
base: `${rootPrefix}/assets`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 이 숙소의 노래 파일을 사이트 디렉토리로 옮겨 놓는다.
|
|
*
|
|
* ★ 왜 백엔드가 직접 out/ 에 쓰지 않나
|
|
* 백엔드는 발행물 디렉토리를 모른다 — payload JSON 을 약속된 자리에 떨구는 것이 경계다
|
|
* (ARCHITECTURE 1절). 노래도 같은 약속을 쓴다: 백엔드는 `site/songs/<파일>` 에 두고,
|
|
* 굽는 쪽인 여기가 사이트 안으로 복사한다. 그래야 Azure 발행(`azure_static.publish`)이
|
|
* 사이트 디렉토리를 통째로 올릴 때 노래도 함께 올라간다.
|
|
*
|
|
* ★ 없으면 조용히 넘어간다. 곡은 발행보다 2~3분 늦게 완성되므로 "아직 없음" 이 정상이고,
|
|
* 그때 payload 에 songs 가 비어 있어 화면도 플레이어를 안 그린다.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/*
|
|
* 지난 발행의 곡은 치운다.
|
|
*
|
|
* ★ 발행할 때마다 새 곡을 만들고 파일명은 song_id 라, 치우지 않으면 발행 횟수만큼 1MB 짜리
|
|
* 파일이 사이트 디렉토리에 쌓인다. 그리고 그것들은 Azure 발행 때 **함께 올라간다** —
|
|
* 아무도 듣지 않는 옛 곡이 계속 쌓이는 종류의 낭비다.
|
|
* ★ 자산(out/assets)과 달리 보관 기간을 두지 않는다. 번들은 크롤러가 나중에 렌더할 때
|
|
* 필요하지만(AGENTS.md), 노래는 그 페이지에서 버튼을 눌러야 나는 것이라 옛 HTML 이
|
|
* 가리킬 일이 없다 — payload 에 실린 곡 하나만 남기면 된다.
|
|
*/
|
|
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 에 심는 블롭이 전부 같은 객체를 본다.
|
|
// (블롭만 원본으로 두면 미검증 fact 가 HTML 소스로 새고, AI 크롤러는 그걸 읽는다.)
|
|
const payload = sanitizePayloadForPublish(input);
|
|
// ★ 발행 주소가 `<host>/s/<slug>` 이므로 파일도 같은 모양으로 놓는다.
|
|
// 그래야 out/ 을 그대로 정적 서빙하면 발행된 모든 사이트가 실제 주소로 열린다 —
|
|
// 사이트마다 서버를 따로 띄우거나 경로를 손으로 맞출 필요가 없다.
|
|
const siteDir = join(outRoot, SITE_DIR, payload.site.slug);
|
|
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)이 이 클래스
|
|
안에서만 산다. 빼면 유동 타이포와 .shell·.h2 가 통째로 죽어 글자 크기가 본문으로 떨어진다.
|
|
빌더 캔버스는 같은 규칙을 `.site-canvas` 로 받는다. */
|
|
' <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` 하나만 남는다.
|
|
*
|
|
* ★ 사이트별 `sitemap.xml` 을 없앴다 — 한 장짜리 사이트의 사이트맵은 URL 이 하나뿐이라,
|
|
* 사이트가 1,000개면 URL 한 줄짜리 파일이 1,000개 생긴다. 루트 사이트맵 하나에
|
|
* 전부 담는다(사이트맵 하나에 URL 50,000개까지 들어간다).
|
|
*
|
|
* ★ 사이트별 `robots.txt` 도 없앴다 — `<host>/s/<slug>/robots.txt` 는 **아무도 읽지 않는다**.
|
|
* 크롤러는 오리진 루트에서만 읽는다(RFC 9309).
|
|
*/
|
|
writeFile(siteDir, 'llms.txt', renderLlmsTxt(payload));
|
|
|
|
copySongs(payload, siteDir);
|
|
|
|
// 하이드레이션용 번들. 공용 호스트면 out/ 루트 한 벌을 공유하므로 여기서는 아무것도 안 한다
|
|
// (main 이 사이트를 굽기 전에 한 번 깔아 둔다). 커스텀 도메인일 때만 사이트 안에 복사한다.
|
|
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 를 그대로 담는다. 백엔드가 이걸
|
|
// site_versions.jsonld(파이썬 빌더 산출물)와 대조해 두 렌더러의 드리프트를 잡는다.
|
|
return {
|
|
siteDir,
|
|
payload,
|
|
jsonld,
|
|
uniqueContentCount,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 이 가게에만 있는 콘텐츠 건수 — 백엔드 `count_unique_content` 와 **같은 규칙**이다.
|
|
*
|
|
* ★ 두 숫자가 다르면 게이트가 통과시킨 근거와 실제 페이지가 어긋났다는 뜻이다.
|
|
* 백엔드가 보고서를 받아 대조한다. 규칙을 바꿀 땐 반드시 양쪽을 같이 고친다.
|
|
* (backend/services/builder/render.py 의 MIN_UNIQUE_TEXT · count_unique_content)
|
|
*/
|
|
const MIN_UNIQUE_TEXT = 8;
|
|
|
|
function countUniqueContent(payload: SitePayload): number {
|
|
const long = (value: unknown) => String(value ?? '').trim().length >= MIN_UNIQUE_TEXT;
|
|
let count = 0;
|
|
|
|
// 소개 섹션의 직접 입력 본문은 실제 AboutSection 에 표시되는 가게 고유 문장이다.
|
|
// ★ enabled 를 함께 본다. 꺼서 페이지에 없는 문장까지 세면 빈 페이지가 게이트를 통과한다.
|
|
const intro = payload.theme.sections.find((section) => section.id === 'intro');
|
|
if (intro?.enabled && long(intro.body)) count += 1;
|
|
|
|
/**
|
|
* 붙여넣기 아이템(가요·일력·승차권·인물…)의 항목도 이 가게에만 있는 문장이다.
|
|
*
|
|
* ★ 안 세면 "곡을 여덟 개 채웠는데 고유 콘텐츠 0건으로 발행이 막힌다"가 된다 —
|
|
* 소개 본문(intro.body)이 계약에 없던 시절과 같은 구멍이다.
|
|
* ★ 긴 문장이 한 줄이라도 있는 항목만 센다. 제목·연도만 있는 줄은 가게를 구분하지 못한다.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 렌더 결과 보고서. payload 파일 옆(`.status/<slug>.json`)에 쓴다.
|
|
*
|
|
* ★ 왜 필요한가
|
|
* 지금까지 프리렌더는 성공하든 실패하든 아무것도 남기지 않았다. 빌드가 깨지면
|
|
* DB 에는 "발행됨"으로 남고 페이지는 없는 상태가 되는데, 아무도 그걸 모른다.
|
|
* 백엔드에 마운트된 유일한 디렉토리가 payload 디렉토리라 보고서도 그 안에 쓴다.
|
|
*
|
|
* ★ 임시파일 → rename. 백엔드가 반쯤 쓰인 JSON 을 읽지 않게 한다.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 옛 자산 보관 기간.
|
|
*
|
|
* ★ 왜 지우지 않고 남기나 — HTML 은 자산 경로를 **파일명 해시까지 박아** 굽는다
|
|
* (`/assets/index-DvNTmLhy.css`). 렌더러를 배포하면 이름이 바뀌는데, 그 순간 옛 파일을
|
|
* 지우면 아직 다시 굽지 않은 사이트는 CSS·JS 가 **404** 다. 예전 구현이 그랬다.
|
|
*
|
|
* ★ 더 나쁜 건 크롤러다. 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌린다. 그 사이에
|
|
* 자산이 사라지면 스타일도 스크립트도 없는 페이지를 렌더한 것으로 기록한다 — 하필
|
|
* 신규 도메인이 평가받는 시기에 그렇게 된다. 유예 창이 필요하다는 게 업계 통념이고
|
|
* (Vercel 은 검색봇에 한해 스큐 보호 창을 60일로 늘린다), 우리 창은 0초였다.
|
|
*
|
|
* 한 벌이 400KB 안팎이라 한 달치를 남겨도 10MB 남짓이다 — 싸게 사는 안전이다.
|
|
*/
|
|
const ASSET_RETENTION_DAYS = 30;
|
|
/** 하루에 여러 번 배포해도 직전 빌드는 반드시 남는다(보관 기간과 무관). */
|
|
const ASSET_MIN_BUILDS = 2;
|
|
/**
|
|
* 어떤 빌드가 어떤 파일을 깔았는지. **보관 기간의 근거는 이 파일이다.**
|
|
*
|
|
* ★ 파일 mtime 으로 나이를 재지 않는다 — 복사·동기화가 시각을 갈아 버리면 옛 파일이
|
|
* 영원히 젊어지거나 산 파일이 지워진다. 점(.)으로 시작해 업로드에서 빠진다
|
|
* (azure_static 이 dotfile 을 거른다).
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 발행본이 **지금 실제로 참조하고 있는** 자산. 여기 들어오면 절대 지우지 않는다.
|
|
*
|
|
* ★ 왜 보관 기간만으로는 부족한가 — `out/s/` 에는 **payload 가 없는 사이트**가 있다(목업).
|
|
* 그건 재굽기 대상이 아니다. 프리렌더는 payload 를 받아 그 사이트만 굽고, payload 가 없는
|
|
* 디렉토리는 쳐다보지도 않는다 — 그래서 자산이 한 번 지워지면 **영영 복구되지 않는다.**
|
|
* 재굽기를 몇 번을 돌려도 살아나지 않고, 사람이 파일을 손으로 되돌려 넣어야 한다.
|
|
*
|
|
* 실측(2026-09-07): `/s/stay` · `/s/stay2` · `/s/stay3` 가 번들 해시가 바뀐 순간
|
|
* CSS·JS·이미지 전부 404 가 됐다. 보관 기간(30일)으로는 못 막는다 — 기간이 지나면
|
|
* 똑같은 일이 난다. **참조가 살아 있는 한 남긴다** 가 유일하게 맞는 규칙이다.
|
|
*
|
|
* ★ 사이트를 굽기 전에 부른다. 그래야 이번에 다시 굽지 않는 사이트의 옛 참조가 잡힌다.
|
|
*/
|
|
function referencedAssets(outRoot: string): Set<string> {
|
|
const sitesDir = join(outRoot, SITE_DIR);
|
|
const found = new Set<string>();
|
|
if (!existsSync(sitesDir)) return found;
|
|
|
|
for (const entry of readdirSync(sitesDir, {withFileTypes: true})) {
|
|
if (!entry.isDirectory()) continue;
|
|
const file = join(sitesDir, entry.name, 'index.html');
|
|
if (!existsSync(file)) continue;
|
|
// basePath 가 붙어도(`/sites/assets/…`) `/assets/` 뒤만 집으면 파일 경로가 나온다.
|
|
for (const match of readFileSync(file, 'utf-8').matchAll(/\/assets\/([^"'\s)\\]+)/g)) {
|
|
found.add(decodeURIComponent(match[1]));
|
|
}
|
|
}
|
|
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 {
|
|
// 없거나 깨졌으면 빈 대장으로 시작한다. 디스크에 있던 파일은 pruneAssets 가
|
|
// "처음 본 것" 으로 입양하므로 지워지지 않는다 — 그 ★ 주석이 이 실패의 근거다.
|
|
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];
|
|
|
|
/**
|
|
* ★ 대장에 없는 파일은 **지우지 않는다.** 언제 깔렸는지 모를 뿐이므로 지금 처음 본 것으로
|
|
* 치고 보관 기간을 새로 준다.
|
|
*
|
|
* 이 줄이 없어서 실제로 운영 사이트가 끊겼다(2026-09-07). 대장은 이 기능과 함께 생겼으니
|
|
* **배포 직후 첫 실행에는 대장이 없다** — 그때 디스크에 있던 기존 자산이 전부 "대장에 없음"
|
|
* 으로 분류돼 한꺼번에 삭제됐고, 아직 다시 굽지 않은 사이트의 CSS 가 통째로 404 가 됐다.
|
|
* 옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다.
|
|
*/
|
|
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/ 을 대상 디렉토리에 깐다.
|
|
*
|
|
* ★ assets/ 를 통째로 지우고 다시 깔지 않는다(예전 구현). 옛 해시 파일은 보관 기간까지
|
|
* 남겨야 한다 — 근거는 ASSET_RETENTION_DAYS 주석. 권한 때문에 통째로 지웠던 것인데,
|
|
* copyDirectoryFiles 가 **파일마다** 먼저 rmSync 하므로 그 문제는 그대로 해결된다.
|
|
*/
|
|
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` 와 사이트맵 인덱스.
|
|
*
|
|
* ★ 왜 필요한가
|
|
* 크롤러는 robots.txt 를 **오리진 루트에서만** 읽는다(RFC 9309). 발행 사이트는
|
|
* `<host>/s/<slug>/` 아래라, 지금까지 구워 온 사이트별 robots.txt 는 한 번도 읽힌 적이 없다 —
|
|
* AI 크롤러 명시 허용도, `Sitemap:` 지시도 전달되지 않았다. 사이트맵은 만들어 두고
|
|
* 그 존재를 알릴 방법이 없었으니 크롤러가 사이트를 찾아올 경로 자체가 없었다.
|
|
*
|
|
* ★ 왜 이번 실행에 온 payload 가 아니라 출력 디렉토리를 훑는가
|
|
* 발행은 **바뀐 사이트 하나만** 굽는다(scripts/watch-payloads.mjs). 이번 실행분만 인덱스에
|
|
* 담으면 나머지 사이트가 인덱스에서 사라진다. 디스크에 실제로 존재하는 발행본이 곧 정답이다.
|
|
*/
|
|
/**
|
|
* 빌더 미리보기가 iframe 으로 띄우는 CSR 셸.
|
|
*
|
|
* ★ 왜 iframe 인가 (2026-09-09)
|
|
* 빌더 안에 발행본 컴포넌트를 **직접** 그려 봤는데, 색·서체·섹션 구성이 같아져도
|
|
* **레이아웃 폭이 어긋났다.** 미디어 쿼리는 창 폭을 보는데 미리보기의 실제 사이트 폭은
|
|
* 그 안의 프레임(max-w-5xl)이기 때문이다. 실측(1400px 창 · 1024px 프레임):
|
|
* festival 2560px → 6027px, guide 1168 → 2168, location 586 → 1135.
|
|
* 내용(글자 수)은 완전히 같은데 그리드 컬럼 수만 달라 두 배씩 길어졌다.
|
|
* iframe 은 자체 뷰포트를 가져서 미디어 쿼리가 발행본과 **정확히 같은 폭**을 본다.
|
|
* PC/태블릿/모바일 전환도 iframe 폭만 바꾸면 그대로 맞는다.
|
|
*
|
|
* ★ payload 는 셸이 아니라 브라우저가 가져온다(`entry-client`). 굽는 시점에는
|
|
* 어느 사업장을 미리 볼지 모르고, 미리보기는 **발행 전 최신 값**을 봐야 한다.
|
|
*/
|
|
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 writeRootMachineFiles(outRoot: string, origin: string) {
|
|
const sitesDir = join(outRoot, SITE_DIR);
|
|
if (!existsSync(sitesDir)) return;
|
|
|
|
const sites: DirectoryEntry[] = readdirSync(sitesDir, {withFileTypes: true})
|
|
.filter((entry) => entry.isDirectory())
|
|
.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 {
|
|
// ★ 끝 슬래시를 붙이지 않는다. 페이지의 canonical 은 `/s/<slug>` 다(shared/lib/slug.ts
|
|
// publishUrl). 사이트맵이 `/s/<slug>/` 로 어긋나 있던 동안 서치콘솔은 제출한 URL 을
|
|
// 전부 "대체 페이지(적절한 표준 태그가 있음)" 로 분류했다 — 색인은 되는데 제출분은
|
|
// 0건으로 보이는, 눈으로 원인을 못 찾는 종류다.
|
|
loc: joinUrl(origin, SITE_DIR, entry.slug),
|
|
title: readBakedTitle(html) || entry.slug,
|
|
// ★ mtime 으로 떨어지는 건 dateModified 메타가 없던 시절의 산출물뿐이다.
|
|
// 그 사이트를 한 번 다시 구우면 제 값이 들어온다(readBakedLastmod 주석 참조).
|
|
lastmod: readBakedLastmod(html) ?? statSync(entry.file).mtime.toISOString(),
|
|
};
|
|
})
|
|
.sort((a, b) => a.loc.localeCompare(b.loc));
|
|
|
|
// ★ `/s` 목록 페이지. 크롤러가 발행본에 닿는 두 번째 경로다 —
|
|
// 사이트맵만 있을 때 서치콘솔은 "참조 페이지: 감지된 페이지 없음" 이라고 답했다.
|
|
// ★ 끝 슬래시를 붙이지 않는다 — 슬러그 페이지(`/s/<slug>`)와 같은 형태여야 한다.
|
|
// nginx 가 `location = /s` 로 이 파일을 직접 주고 `/s/` 는 여기로 301 한다
|
|
// (nginx/site.conf). 그 블록이 없으면 `/s` 는 빌더 SPA 셸을 200 으로 내준다.
|
|
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` 에 키 문자열만 들어 있다.
|
|
*
|
|
* ★ 이게 없으면 백엔드의 통보가 403 으로 거절된다. 검색엔진은 통보를 받으면
|
|
* 이 URL 을 열어 같은 키가 있는지 보고, 그걸로 "이 호스트를 제어하는 쪽이 보냈다"를 확인한다.
|
|
* 비밀이 아니다 — 공개되어야 작동하는 값이다.
|
|
*
|
|
* ★ 백엔드와 **같은 env 를 본다**. 키를 두 군데 적으면 어긋나는 날 통보가 조용히 다 막힌다.
|
|
*/
|
|
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();
|
|
|
|
/*
|
|
* ★ 기동 모드 — 굽지 않고 자산 주소만 갈아 끼운다(`refreshBakedAssets` 주석).
|
|
* 컨테이너가 뜰 때 전부 다시 굽던 자리다. 2026-09-15 대표 지시로 바뀌었다:
|
|
* **새로 발행하는 사이트만 새 기능을 받는다.**
|
|
*/
|
|
if (args.refreshAssets && args.payloads.length === 0) {
|
|
console.log(`[prerender] 자산 주소만 갱신 → ${args.out}`);
|
|
writeSharedAssets(args.out, referencedAssets(args.out));
|
|
refreshBakedAssets(args.out, assets, new Set(), args.payloadDir);
|
|
writePreviewShell(args.out, assets);
|
|
console.log('[prerender] 완료');
|
|
return;
|
|
}
|
|
|
|
const loaded = loadPayloads(args.payloads);
|
|
|
|
console.log(`[prerender] 사이트 ${loaded.length}개 → ${args.out}`);
|
|
|
|
// ★ 굽기 **전에** 참조를 훑는다. 이번에 다시 굽지 않는 사이트(payload 가 없는 목업 포함)가
|
|
// 무엇을 가리키고 있는지는 지금 디스크에 있는 HTML 만 안다.
|
|
const referenced = referencedAssets(args.out);
|
|
|
|
// ★ 공용 자산은 사이트를 굽기 전에 딱 한 번 깐다. 사이트마다 복사하던 걸 여기로 뺐다.
|
|
writeSharedAssets(args.out, referenced);
|
|
|
|
let failed = 0;
|
|
/** 이번에 실제로 구운 슬러그. 자산 주소 갱신에서 빼려고 모은다(이미 새 주소다). */
|
|
const baked = new Set<string>();
|
|
/** 루트 기계용 파일을 쓸 오리진. 이 호스트의 사이트는 전부 같은 오리진을 쓴다. */
|
|
let origin = '';
|
|
|
|
/** 보고서용 슬러그. payload 를 못 읽었으면 파일명에서 얻는다(백엔드가 `<slug>.json` 으로 쓴다). */
|
|
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 를 보므로, 여기서 주소를 바꿔 놓아야 한 벌로 맞는다.
|
|
await mirrorMedia(entry.payload, args.out);
|
|
const result = prerenderSite(entry.payload, args.out, assets, referenced);
|
|
const payload = result.payload;
|
|
baked.add(payload.site.slug);
|
|
origin = origin || payload.site.origin;
|
|
const home = joinUrl(payload.site.origin, payload.site.basePath);
|
|
console.log(
|
|
` ✓ ${payload.place.name} (${payload.site.slug}) — index.html + llms.txt`,
|
|
);
|
|
console.log(` ${result.siteDir}`);
|
|
console.log(` ${home}`);
|
|
|
|
if (entry.file) {
|
|
writeReport(entry.file, {
|
|
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,
|
|
});
|
|
}
|
|
} catch (ex) {
|
|
// ★ 한 사이트가 깨졌다고 나머지를 못 굽게 두지 않는다. 실패는 보고서로 남긴다 —
|
|
// 조용히 넘어가면 "발행했는데 페이지가 없다"가 다시 반복된다.
|
|
// ★ 계수를 못 잰 실패(디스크·번들·payload 파손)는 null 로 남긴다. 0 으로 적으면
|
|
// 백엔드가 "고유 콘텐츠 0건" 으로 읽어 또 엉뚱한 사유를 붙인다.
|
|
const counted = ex instanceof VerifyError || ex instanceof NoUniqueContentError ? ex : null;
|
|
fail(
|
|
entry,
|
|
ex instanceof Error ? ex.message : String(ex),
|
|
counted?.mismatches ?? [],
|
|
counted?.uniqueContentCount ?? null,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ★ 이번에 안 구운 사이트도 **번들 주소는** 새것으로 맞춰 둔다. 내용은 그대로다.
|
|
if (args.refreshAssets) refreshBakedAssets(args.out, assets, baked, args.payloadDir);
|
|
|
|
// ★ 실패한 사이트가 있어도 루트 파일은 갱신한다 — 성공한 사이트까지 색인에서 빠질 이유가 없다.
|
|
// (인덱스는 디스크를 훑으므로 실패한 사이트는 애초에 들어가지 않는다.)
|
|
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();
|