Compare commits
2 Commits
de5ff5186f
...
6df125d840
| Author | SHA1 | Date | |
|---|---|---|---|
| 6df125d840 | |||
| f008b24574 |
31
AGENTS.md
31
AGENTS.md
@ -25,16 +25,39 @@
|
||||
|
||||
밟으면 **조용히 틀린다** — 빌드는 성공하고 화면도 뜨는데 결과가 잘못된 종류다.
|
||||
|
||||
- **★★ `out/s/` 에는 payload 가 없는 사이트가 있다 — 목업(`stay` · `stay2` · `stay3` · `*.old`).**
|
||||
프리렌더는 **payload 를 받은 사이트만** 굽는다. 목업은 손으로 넣은 것이라 재굽기 대상이
|
||||
아니고, **자산이 한 번 지워지면 영영 복구되지 않는다** — 재굽기를 몇 번 돌려도 안 살아나고
|
||||
사람이 파일을 되돌려 넣어야 한다. 실측(2026-09-07): 번들 해시가 바뀌자 목업 3개의 CSS·JS·
|
||||
이미지가 전부 404 가 됐고, 그 파일들은 `stay-mockup` 워크트리에서 손으로 꺼내 복구했다.
|
||||
→ **`out/assets` 에서 파일을 지우는 코드는 `out/s/**` 의 HTML 이 참조하는 것을 먼저 뺀다**
|
||||
(`prerender.ts` `referencedAssets`). 보관 기간으로는 못 막는다 — 기간이 지나면 같은 일이 난다.
|
||||
→ 목업을 다루는 작업은 `out/s/` 를 먼저 열어 **payload 가 없는 디렉토리가 무엇인지** 본다.
|
||||
|
||||
- **번들 파일명은 콘텐츠 해시다.** HTML 은 `/assets/index-DvNTmLhy.css` 를 **루트 절대경로**로
|
||||
가리킨다. 경로는 프리렌더가 `dist/client/.vite/manifest.json` 에서 읽어 박는다
|
||||
(`prerender.ts:160`). 렌더러 CSS 를 고치면 이름이 바뀐다.
|
||||
- **로컬 `out/assets` 는 빌드마다 통째로 갈린다** (`prerender.ts:573` `rmSync`). 옛 해시 파일이
|
||||
사라지므로 새 번들로 일부 사이트만 구우면 나머지는 CSS 가 404 다.
|
||||
→ 프리렌더 기동 시 전체 재굽기가 이 구멍을 메운다.
|
||||
- **옛 해시 자산은 30일 남는다** (`prerender.ts` `ASSET_RETENTION_DAYS`). 예전에는 빌드마다
|
||||
`out/assets` 를 통째로 갈아서, 새 번들로 일부만 구우면 나머지 사이트가 CSS 404 였다.
|
||||
지금은 남긴다 — 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌리므로, 그 사이 자산이 사라지면
|
||||
스타일 없는 페이지를 렌더한 것으로 기록된다. 보관 근거는 `out/assets/.builds.json` 대장이고
|
||||
파일 mtime 이 아니다.
|
||||
→ 재굽기는 여전히 필요하지만 **급하지 않다**. 디자인이 반영 안 될 뿐, 깨지지는 않는다.
|
||||
- **★ 대장(`out/assets/.builds.json`)에 없는 자산은 지우지 않는다** — "지금 처음 본 것" 으로
|
||||
치고 보관 기간을 새로 준다(`pruneAssets`). **이 규칙을 깨면 운영 사이트가 즉시 끊긴다.**
|
||||
실제로 그랬다(2026-09-07): 대장은 이 기능과 함께 생겼으므로 **배포 직후 첫 실행에는 대장이
|
||||
없고**, 그때 디스크에 있던 기존 자산이 전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다.
|
||||
옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다.
|
||||
→ 자산을 지우는 코드를 손볼 때는 **"기록이 없다"와 "만료됐다"를 절대 같이 묶지 않는다.**
|
||||
→ 이미 끊겼다면 복구는 `docker compose restart solution-prerender` (기동하며 전체 재굽기).
|
||||
- **★ 사이트를 굽는 컨테이너는 `solution-prerender` 다.** `solution-frontend` 는 **개발용**이라
|
||||
운영에서는 아예 뜨지 않는다(`docker-compose.yml` `profiles: ["dev"]`). 이름이 비슷해서
|
||||
`restart solution-frontend` 를 치면 **아무 일도 안 일어나는데 명령은 성공한다** —
|
||||
재굽기를 했다고 믿고 넘어가게 된다. 실제로 그렇게 복구가 한 번 헛돌았다(2026-09-07).
|
||||
- **★ 프론트(`solution/site`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
|
||||
`azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 —
|
||||
**렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.**
|
||||
→ `docker compose restart solution-frontend` 후 `python scripts/republish_all.py`
|
||||
→ `docker compose restart solution-prerender` 후 `python scripts/republish_all.py`
|
||||
- **발행 호스트는 두 곳에 있고 같아야 한다.** 백엔드 `SITE_PUBLIC_HOST`(기본 `web4ai.o2osolution.ai`,
|
||||
`site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부
|
||||
이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더
|
||||
|
||||
@ -66,18 +66,28 @@ site-out/
|
||||
|
||||
```
|
||||
프론트 수정 → 새 번들(새 해시)
|
||||
로컬 out/assets : 통째로 교체 (옛 해시 삭제) ← 재굽기 안 한 사이트는 CSS 404
|
||||
Azure : 새 해시 추가, 옛 해시 유지 ← 안 깨지지만 옛 디자인 그대로 박제
|
||||
로컬 out/assets : 새 해시 추가, 옛 해시 30일 보관 ← 안 깨진다. 옛 디자인으로 뜰 뿐
|
||||
Azure : 새 해시 추가, 옛 해시 유지 ← 같다
|
||||
```
|
||||
|
||||
프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 그래서 로컬 out/ 은 재시작만
|
||||
하면 정합이 맞는다. 하지만 Azure 는 발행 잡이 도는 사이트 하나씩만 올린다 — 그 짝을 맞추는 게
|
||||
`solution/backend/scripts/republish_all.py` 다.
|
||||
**2026-09-07 이전에는 로컬 `out/assets` 를 통째로 갈았다.** 그래서 재굽기 전까지 나머지 사이트가
|
||||
CSS 404 였다 — 하필 크롤러가 그 순간 렌더하면 스타일 없는 페이지를 본 것으로 기록된다.
|
||||
지금은 `ASSET_RETENTION_DAYS`(30일) 동안 옛 해시를 남긴다. 보관 근거는 `out/assets/.builds.json`
|
||||
대장이다(파일 mtime 이 아니다 — 복사·동기화가 시각을 갈아 버린다).
|
||||
|
||||
**그래서 재굽기는 여전히 필요하지만 급하지는 않다.** 안 하면 그 사이트만 옛 디자인으로 뜬다.
|
||||
프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 하지만 Azure 는 발행 잡이 도는
|
||||
사이트 하나씩만 올린다 — 그 짝을 맞추는 게 `solution/backend/scripts/republish_all.py` 다.
|
||||
|
||||
⚠️ 남은 것: `azure_static._upload_shared` 는 매 발행마다 `assets/` **전체**를 다시 올린다.
|
||||
옛 해시를 남기기 시작했으므로 보관 기간만큼 업로드량이 는다. Azure 를 켤 때는 이미 있는
|
||||
블롭(해시 파일이라 이름이 같으면 내용도 같다)을 건너뛰도록 먼저 고친다.
|
||||
|
||||
|
||||
**규칙: `solution/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
|
||||
|
||||
```bash
|
||||
docker compose restart solution-frontend # 기동하며 전체 재굽기
|
||||
docker compose restart solution-prerender # 기동하며 전체 재굽기
|
||||
docker compose logs -f solution-frontend # "[watch] 기동" 배치가 끝날 때까지 대기
|
||||
docker compose exec solution-worker python scripts/republish_all.py
|
||||
```
|
||||
@ -215,5 +225,5 @@ docker compose exec solution-worker python scripts/republish_all.py
|
||||
|
||||
## 5. 되돌리기
|
||||
`out/` 은 재생성물이라 백업이 필요 없다. 문제가 생기면
|
||||
`docker compose restart solution-frontend` → 전체 재굽기 → `republish_all.py`.
|
||||
`docker compose restart solution-prerender` → 전체 재굽기 → `republish_all.py`.
|
||||
지켜야 할 건 **DB 와 `out/payloads/`** 뿐이다.
|
||||
|
||||
114
docs/DEVLOG.md
114
docs/DEVLOG.md
@ -5,6 +5,120 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-07 — (사고 2) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다
|
||||
|
||||
**무슨 일**
|
||||
`/s/stay` · `/s/stay2` · `/s/stay3` 의 CSS·JS·이미지가 전부 404 가 됐다. 재굽기를 돌려도
|
||||
살아나지 않았다.
|
||||
|
||||
**왜**
|
||||
`out/s/` 에 디렉토리가 8개인데 payload 는 4개뿐이다. 나머지는 **손으로 넣은 목업**이고,
|
||||
프리렌더는 payload 를 받은 사이트만 굽는다 — 목업은 **재굽기 대상이 아니다.** 그래서 번들
|
||||
해시가 바뀌어 옛 자산이 지워지는 순간 영영 복구 불가가 된다. 문서 어디에도 목업 얘기가
|
||||
한 줄도 없어서(2026-09-07 grep 0건) 이 존재를 모르고 자산 삭제 코드를 건드렸다.
|
||||
|
||||
**고친 것** (`scripts/prerender.ts`)
|
||||
- `referencedAssets()` — 굽기 **전에** `out/s/**/index.html` 을 훑어 `/assets/…` 참조를 모은다
|
||||
- `pruneAssets` 가 그 목록을 절대 지우지 않는다. **보관 기간보다 우선한다** —
|
||||
기간으로 막으면 30일 뒤에 똑같은 사고가 난다
|
||||
- AGENTS.md 함정 목록 맨 위에 ★★ 로 박았다. 목업의 존재 자체가 문서에 없던 게 근본 원인이다
|
||||
|
||||
**복구** — 지워진 파일은 `stay-mockup` 워크트리(`solution/site/out/assets`)에 남아 있어서
|
||||
서버 볼륨에 손으로 되돌려 넣었다. `docker cp` → `out/assets`.
|
||||
|
||||
**검증** — 목업 상황 재현: payload 없는 `out/s/mock/index.html` 이 옛 해시를 가리키게 두고
|
||||
재굽기 → 참조 3개가 남는다. 대장을 60일 전으로 돌려 만료를 강제해도 그대로 남는다.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-07 — (사고) 자산 보관 첫 배포에 운영 사이트 CSS 가 끊겼다
|
||||
|
||||
**무슨 일**
|
||||
바로 아래 항목(옛 해시 자산 30일 보관)을 배포하자 **기존 사이트의 CSS·JS 가 전부 404** 가 됐다.
|
||||
옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다.
|
||||
|
||||
**왜**
|
||||
`pruneAssets` 가 "대장(`.builds.json`)에 없는 파일" 을 만료로 보고 지웠다. 그런데 **대장은 이
|
||||
기능과 함께 처음 생긴다** — 배포 직후 첫 실행에는 대장이 없으므로, 디스크에 있던 기존 자산이
|
||||
전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다. 아직 다시 굽지 않은 사이트는 그 순간 죽는다.
|
||||
|
||||
**놓친 것** — 검증을 `out/` 을 비운 상태에서만 돌렸다. 재현해야 했던 건 빈 디렉토리가 아니라
|
||||
**"옛 자산은 있는데 대장은 없는"** 상태, 즉 실제 배포 직전의 서버 모습이었다.
|
||||
|
||||
**고친 것** (`scripts/prerender.ts` `pruneAssets`)
|
||||
- 대장에 없는 파일은 지우지 않고 **"지금 처음 본 것" 으로 입양해** 보관 기간을 새로 준다
|
||||
- 규칙으로 굳혀 둔다: **"기록이 없다" 와 "만료됐다" 를 같이 묶지 않는다**(AGENTS.md 함정 목록)
|
||||
|
||||
**복구** — `docker compose restart solution-prerender` (기동하며 전체 재굽기 → HTML 이 새 해시를
|
||||
가리킨다). 자산을 되살리는 게 아니라 HTML 을 새로 굽는 쪽이 빠르다.
|
||||
|
||||
**검증** — 배포 직전 상태를 재현: `out/assets` 에 옛 해시 파일만 두고 대장 없이 첫 실행 →
|
||||
옛 파일 2개가 그대로 남고 대장에 입양 항목으로 들어간다. 재실행해도 대장이 늘지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-07 — 옛 해시 자산을 30일 남긴다 — 배포와 재굽기를 뗀다
|
||||
|
||||
**왜**
|
||||
`writeSharedAssets` 가 빌드마다 `out/assets` 를 통째로 지우고 다시 깔았다. HTML 은 자산 경로를
|
||||
파일명 해시까지 박아 굽기 때문에, 렌더러를 배포하는 순간 **아직 다시 굽지 않은 사이트는 전부
|
||||
CSS·JS 404** 였다. 구멍을 "기동 시 전체 재굽기" 와 "배포하면 반드시 전체 재업로드" 라는 **규칙**
|
||||
으로 막고 있었다 — 규칙으로 막는다는 건 구조가 못 막는다는 뜻이다.
|
||||
|
||||
진짜 위험은 방문자가 아니라 크롤러다. 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌린다.
|
||||
그 사이에 자산이 사라지면 스타일도 스크립트도 없는 페이지를 렌더한 것으로 기록한다 —
|
||||
하필 지금이 신규 도메인이 평가받는 시기다. 유예 창이 필요하다는 건 업계 통념이고
|
||||
(Vercel 은 검색봇에 한해 스큐 보호 창을 60일로 늘린다), 우리 창은 0초였다.
|
||||
|
||||
**바꾼 것** (`scripts/prerender.ts`)
|
||||
- `assets/` 를 통째로 지우지 않는다. 권한 때문에 지웠던 것인데 `copyDirectoryFiles` 가
|
||||
**파일마다** 먼저 `rmSync` 하므로 그 문제는 그대로 해결된다
|
||||
- `ASSET_RETENTION_DAYS`(30일) · `ASSET_MIN_BUILDS`(2) — 기간이 지나도 직전 빌드는 남는다
|
||||
- `out/assets/.builds.json` 대장: 어떤 빌드가 어떤 파일을 깔았는지. **mtime 으로 나이를 재지
|
||||
않는다** — 복사·동기화가 시각을 갈아 버리면 옛 파일이 영원히 젊어지거나 산 파일이 지워진다.
|
||||
발행마다 이 함수가 도므로, 번들이 그대로면 줄을 늘리지 않고 맨 앞 줄의 시각만 갱신한다
|
||||
- 점(.)으로 시작해 `azure_static` 의 dotfile 필터에 걸러진다 — 대장은 업로드되지 않는다
|
||||
|
||||
**얻은 것** — 프론트 배포와 전체 재굽기가 **분리된다.** 재굽기를 안 하면 그 사이트만 옛
|
||||
디자인으로 뜬다(예전엔 깨졌다). AGENTS.md 의 ★규칙은 남지만 이유가 "안 하면 죽는다" 에서
|
||||
"안 하면 반영이 안 된다" 로 내려온다.
|
||||
|
||||
**남은 것** — `azure_static._upload_shared` 가 매 발행마다 `assets/` 전체를 올린다. 보관 기간만큼
|
||||
업로드량이 는다. Azure 는 지금 꺼져 있으므로(DEPLOY.md) 켤 때 이미 있는 블롭을 건너뛰도록 고친다.
|
||||
|
||||
**검증** — 실제로 세 번 구워 확인: 번들 해시가 바뀌어도 옛 파일 3개가 그대로 남고, 같은 번들로
|
||||
다시 구우면 대장이 늘지 않으며(2줄 유지), 대장의 마지막 줄을 60일 전으로 돌리자 그 빌드의
|
||||
파일 3개만 정리됐다. `tsc·eslint` 통과, `vitest` 22 passed.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-07 — 사이트맵 lastmod 를 파일 mtime 에서 뗐다
|
||||
|
||||
**왜**
|
||||
`lastmod` 를 구운 `index.html` 의 **파일 mtime** 에서 읽고 있었다. 그런데 렌더러를 배포하면
|
||||
번들 해시가 바뀌어 **내용이 한 글자도 안 바뀐 사이트까지 전부 다시 구워진다** — mtime 은
|
||||
그때마다 오늘이 되고, 사이트맵은 "전 사이트가 오늘 갱신됨" 을 통보한다.
|
||||
|
||||
구글은 lastmod 를 페이지의 실제 수정과 대조해 맞을 때만 쓰고, 어긋나면 **그 필드를 아예
|
||||
무시한다**(Search Central: "the date and time of the last significant update" ·
|
||||
"consistently and verifiably accurate"). 즉 이 오염은 지금 당장 뭘 깨뜨리는 게 아니라,
|
||||
**사장님이 진짜로 내용을 고쳐 재발행한 날의 신호를 미리 죽여 두는** 종류다. 배포할 때마다
|
||||
신뢰를 태우고 있었고, 사이트가 100개를 넘기면 되돌리는 데 시간이 걸린다.
|
||||
|
||||
**바꾼 것**
|
||||
- `seo/directory.ts`: `readBakedTitle` · `readBakedLastmod` — 구운 HTML 에서 목록·사이트맵
|
||||
값을 꺼낸다. lastmod 는 페이지가 head 에 선언한 `dateModified`(= `payload.site.updatedAt`)
|
||||
**그 값 그대로**다. 구글이 대조하는 값과 글자 그대로 같아 어긋날 수가 없다
|
||||
- `scripts/prerender.ts`: `readTitle` 을 위로 옮기고 사이트맵 항목에서 mtime 제거. 파일을
|
||||
한 번만 읽어 제목과 lastmod 를 같이 꺼낸다. mtime 은 `dateModified` 메타가 없던 시절의
|
||||
산출물에만 남는 폴백이다 — 그 사이트를 한 번 다시 구우면 제 값이 들어온다
|
||||
- `seo/directory.test.ts`: head.ts 의 메타와 파서의 **커플링을 고정**한다. 태그 모양이 바뀌면
|
||||
파서가 조용히 undefined 를 내고 mtime 으로 되돌아간다 — 빌드도 화면도 멀쩡한 회귀라서 붙였다
|
||||
|
||||
**검증** — `tsc·eslint` 통과, `vitest` 22 passed (신규 5건).
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-03 — 레포·발행 호스트 교체 — `o2o-site-AEO` / `web4ai.o2osolution.ai`
|
||||
|
||||
**왜**
|
||||
|
||||
@ -22,6 +22,8 @@ import {render} from '@/entry-server';
|
||||
import {
|
||||
collectJsonLd,
|
||||
homeMeta,
|
||||
readBakedLastmod,
|
||||
readBakedTitle,
|
||||
renderHead,
|
||||
renderLlmsTxt,
|
||||
renderRootLlmsTxt,
|
||||
@ -283,7 +285,12 @@ function assetPlan(payload: SitePayload, outRoot: string, siteDir: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function prerenderSite(input: SitePayload, outRoot: string, assets: ReturnType<typeof readAssets>) {
|
||||
function prerenderSite(
|
||||
input: SitePayload,
|
||||
outRoot: string,
|
||||
assets: ReturnType<typeof readAssets>,
|
||||
referenced: Set<string>,
|
||||
) {
|
||||
// ★ 여기서 한 번 깎고, 그 뒤로는 깎인 payload 만 쓴다 —
|
||||
// 서버 렌더 · JSON-LD · llms.txt · HTML 에 심는 블롭이 전부 같은 객체를 본다.
|
||||
// (블롭만 원본으로 두면 미검증 fact 가 HTML 소스로 새고, AI 크롤러는 그걸 읽는다.)
|
||||
@ -368,7 +375,7 @@ function prerenderSite(input: SitePayload, outRoot: string, assets: ReturnType<t
|
||||
// 하이드레이션용 번들. 공용 호스트면 out/ 루트 한 벌을 공유하므로 여기서는 아무것도 안 한다
|
||||
// (main 이 사이트를 굽기 전에 한 번 깔아 둔다). 커스텀 도메인일 때만 사이트 안에 복사한다.
|
||||
if (!plan.shared) {
|
||||
writeSharedAssets(plan.dir);
|
||||
writeSharedAssets(plan.dir, referenced);
|
||||
}
|
||||
|
||||
// 이전 구현이 사이트마다 복사해 둔 자산이 남아 있으면 지운다 — 공용으로 바뀐 뒤에는
|
||||
@ -477,18 +484,166 @@ function writeReport(payloadFile: string, report: RenderReport) {
|
||||
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/ 을 대상 디렉토리에 깐다.
|
||||
*
|
||||
* Docker bind mount 에서 이전 빌드가 남긴 파일 권한이 호스트와 어긋날 수 있어
|
||||
* assets/ 는 통째로 교체한다(낡은 해시 파일과 권한을 함께 제거).
|
||||
* ★ assets/ 를 통째로 지우고 다시 깔지 않는다(예전 구현). 옛 해시 파일은 보관 기간까지
|
||||
* 남겨야 한다 — 근거는 ASSET_RETENTION_DAYS 주석. 권한 때문에 통째로 지웠던 것인데,
|
||||
* copyDirectoryFiles 가 **파일마다** 먼저 rmSync 하므로 그 문제는 그대로 해결된다.
|
||||
*/
|
||||
function writeSharedAssets(destRoot: string) {
|
||||
function writeSharedAssets(destRoot: string, referenced: Set<string>) {
|
||||
const assetsSrc = join(CLIENT_DIR, 'assets');
|
||||
if (existsSync(assetsSrc)) {
|
||||
const assetsDest = join(destRoot, 'assets');
|
||||
rmSync(assetsDest, {recursive: true, force: true});
|
||||
copyDirectoryFiles(assetsSrc, assetsDest);
|
||||
pruneAssets(assetsDest, listRelativeFiles(assetsSrc), referenced);
|
||||
}
|
||||
const publicDir = join(SITE_ROOT, 'public');
|
||||
if (existsSync(publicDir)) {
|
||||
@ -518,18 +673,21 @@ function writeRootMachineFiles(outRoot: string, origin: string) {
|
||||
.map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')}))
|
||||
// index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. 사이트맵에 넣지 않는다.
|
||||
.filter((entry) => existsSync(entry.file))
|
||||
.map((entry) => ({
|
||||
// ★ 끝 슬래시를 붙이지 않는다. 페이지의 canonical 은 `/s/<slug>` 다(shared/lib/slug.ts
|
||||
// publishUrl). 사이트맵이 `/s/<slug>/` 로 어긋나 있던 동안 서치콘솔은 제출한 URL 을
|
||||
// 전부 "대체 페이지(적절한 표준 태그가 있음)" 로 분류했다 — 색인은 되는데 제출분은
|
||||
// 0건으로 보이는, 눈으로 원인을 못 찾는 종류다.
|
||||
loc: joinUrl(origin, SITE_DIR, entry.slug),
|
||||
// 제목은 구운 HTML 에서 읽는다. payload 에서 가져오면 이번 실행분만 이름이 있고
|
||||
// 나머지는 슬러그로 떨어진다 — 발행은 바뀐 사이트 하나만 굽기 때문이다.
|
||||
title: readTitle(entry.file) || entry.slug,
|
||||
// 페이지는 그 사이트를 구울 때마다 다시 쓰인다 — 파일 mtime 이 곧 마지막 발행 시각이다.
|
||||
lastmod: statSync(entry.file).mtime.toISOString(),
|
||||
}))
|
||||
.map((entry) => {
|
||||
// 제목과 lastmod 가 같은 HTML 에서 나온다 — 파일은 한 번만 읽는다.
|
||||
const html = readFileSync(entry.file, 'utf-8');
|
||||
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/` 목록 페이지. 크롤러가 발행본에 닿는 두 번째 경로다 —
|
||||
@ -549,12 +707,6 @@ function writeRootMachineFiles(outRoot: string, origin: string) {
|
||||
writeIndexNowKey(outRoot);
|
||||
}
|
||||
|
||||
/** 구운 index.html 에서 <title> 만 꺼낸다. 파서를 붙일 값어치가 없는 한 줄짜리 일이다. */
|
||||
function readTitle(file: string): string {
|
||||
const match = /<title>([^<]*)<\/title>/.exec(readFileSync(file, 'utf-8'));
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* IndexNow 키 파일 — `https://<host>/<key>.txt` 에 키 문자열만 들어 있다.
|
||||
*
|
||||
@ -583,8 +735,12 @@ function main() {
|
||||
|
||||
console.log(`[prerender] 사이트 ${loaded.length}개 → ${args.out}`);
|
||||
|
||||
// ★ 굽기 **전에** 참조를 훑는다. 이번에 다시 굽지 않는 사이트(payload 가 없는 목업 포함)가
|
||||
// 무엇을 가리키고 있는지는 지금 디스크에 있는 HTML 만 안다.
|
||||
const referenced = referencedAssets(args.out);
|
||||
|
||||
// ★ 공용 자산은 사이트를 굽기 전에 딱 한 번 깐다. 사이트마다 복사하던 걸 여기로 뺐다.
|
||||
writeSharedAssets(args.out);
|
||||
writeSharedAssets(args.out, referenced);
|
||||
|
||||
let failed = 0;
|
||||
/** 루트 기계용 파일을 쓸 오리진. 이 호스트의 사이트는 전부 같은 오리진을 쓴다. */
|
||||
@ -628,7 +784,7 @@ function main() {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = prerenderSite(entry.payload, args.out, assets);
|
||||
const result = prerenderSite(entry.payload, args.out, assets, referenced);
|
||||
const payload = result.payload;
|
||||
origin = origin || payload.site.origin;
|
||||
const home = joinUrl(payload.site.origin, payload.site.basePath);
|
||||
|
||||
46
solution/site/src/seo/directory.test.ts
Normal file
46
solution/site/src/seo/directory.test.ts
Normal file
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 구운 HTML → 사이트맵·목록 값.
|
||||
*
|
||||
* ★ 이 테스트가 지키는 것은 **커플링 하나**다. 사이트맵의 lastmod 는 페이지가 head 에
|
||||
* 선언한 dateModified 와 같은 값이어야 한다 — 구글이 lastmod 를 신뢰하는 조건이
|
||||
* "페이지의 실제 수정과 대조해 맞을 것" 이기 때문이다. head.ts 가 태그 모양을 바꾸면
|
||||
* readBakedLastmod 는 조용히 undefined 를 내고 사이트맵은 mtime 으로 되돌아간다 —
|
||||
* 빌드는 통과하고 화면도 멀쩡한, 눈으로 못 찾는 종류의 회귀다. 그래서 붙인다.
|
||||
*/
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
import {MOONLIGHT_STAY_PAYLOAD} from '../fixtures/moonlight-stay';
|
||||
import {readBakedLastmod, readBakedTitle} from './directory';
|
||||
import {renderHead} from './head';
|
||||
import {homeMeta} from './meta';
|
||||
|
||||
/** 실제 프리렌더와 같은 경로로 head 를 굽는다(prerender.ts prerenderSite). */
|
||||
function bakedHead(): string {
|
||||
const payload = MOONLIGHT_STAY_PAYLOAD;
|
||||
return renderHead({payload, meta: homeMeta(payload)});
|
||||
}
|
||||
|
||||
describe('readBakedLastmod', () => {
|
||||
it('head 가 선언한 dateModified 를 그대로 돌려준다', () => {
|
||||
expect(readBakedLastmod(bakedHead())).toBe(MOONLIGHT_STAY_PAYLOAD.site.updatedAt);
|
||||
});
|
||||
|
||||
it('메타가 없으면 undefined — 호출부가 mtime 으로 떨어진다', () => {
|
||||
expect(readBakedLastmod('<html><head><title>x</title></head></html>')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('날짜로 못 읽는 값은 버린다 — 틀린 lastmod 는 없는 것보다 나쁘다', () => {
|
||||
expect(readBakedLastmod('<meta name="dateModified" content="어제" />')).toBeUndefined();
|
||||
expect(readBakedLastmod('<meta name="dateModified" content="" />')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readBakedTitle', () => {
|
||||
it('구운 HTML 의 title 을 꺼낸다', () => {
|
||||
expect(readBakedTitle(bakedHead())).toBe(homeMeta(MOONLIGHT_STAY_PAYLOAD).title);
|
||||
});
|
||||
|
||||
it('title 이 없으면 빈 문자열 — 호출부가 슬러그로 떨어진다', () => {
|
||||
expect(readBakedTitle('<html><head></head></html>')).toBe('');
|
||||
});
|
||||
});
|
||||
@ -9,6 +9,38 @@ export interface DirectoryEntry {
|
||||
lastmod?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 구운 `index.html` 에서 `<title>` 만 꺼낸다. 파서를 붙일 값어치가 없는 한 줄짜리 일이다.
|
||||
*
|
||||
* ★ 왜 payload 가 아니라 구운 HTML 을 읽나 — 발행은 **바뀐 사이트 하나만** 굽는다.
|
||||
* 이번 실행분으로만 목록을 만들면 나머지 사이트가 목록에서 사라진다.
|
||||
*/
|
||||
export function readBakedTitle(html: string): string {
|
||||
const match = /<title>([^<]*)<\/title>/.exec(html);
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트맵 `lastmod` — **페이지가 스스로 선언한 `dateModified` 를 그대로** 쓴다.
|
||||
*
|
||||
* ★ 파일 mtime 을 쓰면 안 된다(예전 구현). 렌더러를 배포하면 번들 해시가 바뀌어 내용이
|
||||
* 같은 사이트까지 전부 다시 구워진다 — mtime 은 그때마다 오늘이 되고, 사이트맵은
|
||||
* **"전 사이트가 오늘 갱신됨"** 을 통보한다. 구글은 lastmod 를 페이지의 실제 수정과
|
||||
* 대조해 맞을 때만 쓰고 어긋나면 그 필드를 **아예 무시한다**(Search Central: "the date and
|
||||
* time of the last significant update" · "consistently and verifiably accurate").
|
||||
* 즉 이 오염은 사장님이 **진짜로** 내용을 고쳐 재발행한 날의 신호까지 같이 죽인다.
|
||||
*
|
||||
* ★ head.ts 의 `dateModified` 메타와 **같은 값**을 읽는다 — 구글이 대조하는 그 값이라
|
||||
* 사이트맵과 페이지가 어긋날 수 없다. head.ts 가 이 태그를 바꾸면 여기도 같이 고친다
|
||||
* (directory.test.ts 가 그 커플링을 고정해 둔다).
|
||||
*/
|
||||
export function readBakedLastmod(html: string): string | undefined {
|
||||
const match = /<meta name="dateModified" content="([^"]*)"/.exec(html);
|
||||
const value = match?.[1].trim();
|
||||
// 값이 깨졌으면 넣지 않는다 — 틀린 lastmod 는 없는 것보다 나쁘다(위 ★ 참조).
|
||||
return value && !Number.isNaN(Date.parse(value)) ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* `/s/` 발행 사이트 목록 페이지.
|
||||
*
|
||||
|
||||
Loading…
Reference in New Issue
Block a user