git 저장소가 없어 히스토리·협업 기반이 아예 없던 상태를 연다.
함께 문서를 재편했다. 그동안 문서가 있어도 "이 제품이 뭘 푸는가"와
"어떻게 도는가"를 담은 문서가 없어서, 목표 문장이 backend/frontend
README 두 곳에 복붙돼 있었다 — 상위 문서가 없어 아래로 샌 것이다.
신설
README.md 레포 진입점 + 문서 지도 + 문서 규칙 4가지
AGENTS.md 에이전트·신규 합류자용 함정 목록과 규약
(CLAUDE.md 는 여기로 걸린 심볼릭 링크)
docs/PRODUCT.md 제품 정의 — 문제·사용자·원칙·**non-goals**·성공 기준
docs/ARCHITECTURE.md payload 경계·발행 파이프라인·서빙 결정·앱 분리 설계
이동
backend/docs/DECISIONS.md → docs/DECISIONS.md
백엔드만의 결정이 아니다. 게다가 코드 주석 ~25곳이 이미
`docs/DECISIONS.md` 로 적고 있어 레포 루트 기준으로는 그게 맞다.
갱신
docs/DEPLOY.md 서빙 결정 반영 — nginx 정적 서빙이 지금 경로(3절),
Azure 는 나중에 켤 때(4절)로 분리
docs/ARCHITECTURE.md 사이트 = 한 장(2026-08-31) 구조 반영
docs/COLLECTION_SEO_AEO_FLOW.md
robots.txt·sitemap.xml 은 오리진 루트에만 굽는다는 점 명시
frontend/site/scripts/prerender.ts
헤더 주석의 렌더 보고서 경로가 실제(422줄)와 달라 수정
.gitignore
★ CLAUDE.md 를 더 이상 무시하지 않는다. 에이전트 지침은 팀과 모든
에이전트가 공유하는 규약이라 커밋해야 한다 — 무시하면 클론한 사람이
"배포 후 republish_all.py 필수" 같은 함정을 전달받지 못한다.
개인용 오버라이드는 ~/.claude/CLAUDE.md 에 둔다.
90 lines
4.4 KiB
Python
90 lines
4.4 KiB
Python
"""발행된 사이트 **전부**를 Azure Blob(`$web`)에 다시 올린다.
|
|
|
|
python scripts/republish_all.py (backend/ 에서 실행)
|
|
python scripts/republish_all.py --dry-run (올리지 않고 목록만 본다)
|
|
|
|
★ 왜 필요한가 — 렌더러(frontend/site)를 고쳐 배포하면 번들 파일명이 바뀐다
|
|
(`assets/index-DvNTmLhy.css` → `assets/index-<새해시>.css`). 그런데 평소 업로드 경로
|
|
(services/azure_static.publish)는 **방금 발행한 사이트 하나**만 올린다. 그래서
|
|
나머지 사이트의 HTML 은 Blob 에 옛 해시를 가리킨 채로 남는다. 옛 자산 블롭은 지워지지
|
|
않으니 화면이 깨지진 않지만, **디자인 수정이 그 사이트들에 영영 도달하지 않는다.**
|
|
프리렌더는 기동할 때 out/ 을 전부 다시 굽는다(watch-payloads.mjs) — 그 결과를
|
|
Blob 으로 밀어 넣는 짝이 없었다. 이 스크립트가 그 짝이다.
|
|
|
|
★ 순서: 프리렌더가 out/ 을 다 구운 **뒤에** 돌린다. 굽는 중에 돌리면 반쯤 구워진
|
|
HTML 이 올라간다.
|
|
|
|
★ 공용 자산(assets/·fonts/·robots.txt·sitemap.xml)은 한 번만 올린다.
|
|
azure_static.publish 를 사이트마다 부르면 수백 KB 번들을 사이트 수만큼 다시 올린다.
|
|
"""
|
|
import argparse, os, sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
os.environ.setdefault("APP_ENV", "local")
|
|
|
|
from azure.storage.blob import BlobServiceClient # noqa: E402
|
|
|
|
from services import azure_static # noqa: E402
|
|
|
|
|
|
def site_slugs(root) -> list[str]:
|
|
base = root / azure_static.SITE_ROOT_DIR
|
|
if not base.is_dir():
|
|
raise SystemExit(f"산출물 디렉터리가 없습니다: {base}")
|
|
# index.html 이 있는 디렉토리만 발행본으로 본다(작업 중 잔재를 올리지 않는다).
|
|
return sorted(d.name for d in base.iterdir() if d.is_dir() and (d / "index.html").is_file())
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--dry-run", action="store_true", help="업로드 없이 대상만 출력")
|
|
args = parser.parse_args()
|
|
|
|
root = azure_static.output_dir()
|
|
slugs = site_slugs(root)
|
|
print(f"[republish] 산출물: {root} · 사이트 {len(slugs)}개")
|
|
|
|
if args.dry_run:
|
|
for slug in slugs:
|
|
print(f" - {slug}")
|
|
return
|
|
|
|
if not azure_static.is_configured():
|
|
raise SystemExit("AZURE_STORAGE_CONNECTION_STRING 이 비어 있습니다 — 올릴 곳이 없습니다.")
|
|
|
|
container_name = os.environ.get("AZURE_STORAGE_CONTAINER", azure_static.DEFAULT_CONTAINER).strip()
|
|
container_name = container_name or azure_static.DEFAULT_CONTAINER
|
|
prefix = os.environ.get("AZURE_STORAGE_PREFIX", azure_static.DEFAULT_PREFIX).strip().strip("/")
|
|
|
|
service = BlobServiceClient.from_connection_string(os.environ["AZURE_STORAGE_CONNECTION_STRING"].strip())
|
|
container = service.get_container_client(container_name)
|
|
|
|
# ── 공용 자산 한 번 ──
|
|
shared = azure_static._upload_shared(container, root, prefix)
|
|
print(f"[republish] 공용 {len(shared)}개 (컨테이너 {container_name} · 접두사 {prefix or '(없음)'})")
|
|
|
|
# ── 사이트별 ──
|
|
# 한 사이트가 실패해도 나머지는 계속 올린다. 여기서 멈추면 절반만 새 번들을 가리키는
|
|
# 어중간한 상태로 남는다 — 어디까지 됐는지 로그로 남기고 끝까지 간다.
|
|
failed: list[tuple[str, str]] = []
|
|
for i, slug in enumerate(slugs, 1):
|
|
try:
|
|
files = azure_static._upload_tree(container, root, f"{azure_static.SITE_ROOT_DIR}/{slug}", prefix)
|
|
site_prefix = "/".join(p for p in (prefix, azure_static.SITE_ROOT_DIR, slug) if p)
|
|
removed = azure_static._remove_stale_site_files(container, site_prefix, files)
|
|
print(f" [{i}/{len(slugs)}] {slug} — 파일 {len(files)}개, 정리 {removed}개")
|
|
except Exception as ex: # noqa: BLE001 — 사이트별 실패 격리가 목적이다
|
|
failed.append((slug, f"{type(ex).__name__}: {ex}"))
|
|
print(f" [{i}/{len(slugs)}] {slug} — 실패: {type(ex).__name__}: {ex}")
|
|
|
|
if failed:
|
|
print(f"\n[republish] 실패 {len(failed)}개:")
|
|
for slug, reason in failed:
|
|
print(f" - {slug}: {reason}")
|
|
raise SystemExit(1)
|
|
print(f"\n[republish] 완료 — {len(slugs)}개 사이트")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|