[feat] solution: 지역 읽기 섹션 · 엽서 공유를 풀고 · 기존 사이트는 자산 주소만 갈아 끼운다

세 가지가 한 줄기다 — 목업에만 있던 것을 제품으로 옮기면서, 그게 이미 나가 있는
사이트를 건드리지 않게 하는 데까지가 한 변경이다.

① 지역 읽기(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>
This commit is contained in:
Mina Choi 2026-09-15 09:27:32 +09:00
parent a1a416ca4a
commit 6b9e01d876
24 changed files with 761 additions and 52 deletions

View File

@ -41,6 +41,37 @@
구워 목업을 통째로 날렸다(캐치프레이즈 100개·미니 플레이어·날씨 문구·주입분 전부).
그 payload 는 `solution/site/payloads-mockup-hold/` 로 옮긴다 — 지우면 재발행 때 또 온다.
- **★ 굽기는 네트워크를 탄다 — 사진을 내려받는다** (`prerender.ts` `mirrorMedia`).
`payload.media[].url` 이 남의 도메인이면 `out/s/<slug>/img/<주소해시>.<확장자>` 로 받아 놓고
payload 의 주소를 **우리 오리진 절대주소**로 바꾼 뒤에 굽는다. 이유는 캔버스다 —
수집처(`*.pstatic.net` · `tong.visitkorea.or.kr`)가 `Access-Control-Allow-Origin` 을 안 줘서
그 사진을 캔버스에 그리면 오염돼 `toBlob` 이 막히고, **엽서 쓰기의 저장·공유가 모든 발행
사이트에서 죽어 있었다**(실측 2026-09-15). 클라이언트에서는 못 넘는다.
→ 못 받은 사진은 **원래 주소를 그대로 쓴다**(사진이 사라지는 것보다 낫다). 로그에 한 줄 남는다.
→ 주소가 그대로면 파일명도 그대로라 **다시 구워도 내려받지 않는다.** 처음 한 번만 느리다.
→ ★ **이미 나가 있는 사이트는 그대로 둔다.** 새 기능은 사장님이 **다시 발행할 때** 들어간다
(아래 항목).
→ `originUrl` · `sourceType` 은 손대지 않는다 — 재게시 권리(DECISIONS 1-2)가 "불가" 로
결론 나면 `sourceType = CRAWL` 을 빼는 그 대응이 그대로 먹어야 한다.
- **★★ 배포해도 기존 사이트를 다시 굽지 않는다 — 자산 주소만 갈아 끼운다.**
(2026-09-15 대표 지시: "전체 재굽기 할 필요가 없어, 사장님이 재발행하면 끝인데 /
css js만 안 깨지게 하란 말이야")
예전에는 `solution-prerender` 가 뜰 때마다 payload 를 **전부 다시 구웠다.** 그러면 렌더러를
한 줄 고칠 때마다 이미 나가 있는 사이트의 HTML 이 통째로 바뀐다 — 사장님은 발행한 적이
없는데 내용이 달라지고, 구글이 다시 읽어 가는 값도 달라진다.
지금 기동이 하는 일은 `prerender.js --refresh-assets` 하나다
(`watch-payloads.mjs` `refreshAssets` → `prerender.ts` `refreshBakedAssets`):
→ 구워진 `index.html` 안의 `assets/index-<해시>.css|js` **파일명만** 새 번들로 바꾼다.
내용·구조·payload 는 손대지 않는다. 접두사(`/assets` · `/sites/assets`)도 그대로 둔다.
→ **한 번도 안 구워진 payload 만** 굽는다(볼륨이 비었거나 감시가 꺼진 새 발행).
→ ★ **payload 가 없는 디렉토리는 건드리지 않는다**(목업 `stay3` · `*.old`, 그리고
`PROTECTED_SLUGS`). 손으로 만든 유일본에 최신 번들을 물렸다가 깨지면 되돌릴 수 없다 —
그쪽 번들 교체는 사람이 한다(mockup/README "번들만 갈아 끼운다").
대상은 `--payload-dir` 에 `<슬러그>.json` 이 있는 사이트뿐이다.
★ 그래서 **정적 HTML 은 옛 렌더러의 것이고 스크립트는 새 렌더러**다. 어긋나면 리액트가
그 자리에서 다시 그리므로 손님 화면은 새것이지만, **크롤러가 읽는 HTML 은 옛것**이다.
둘을 맞추는 방법은 재발행뿐이고 그건 사장님이 누른다. 급하면 `republish_all.py` 지만
**먼저 묻는다** — 전 사이트의 발행일이 한꺼번에 움직이는 일이다.
- **번들 파일명은 콘텐츠 해시다.** HTML 은 `/assets/index-DvNTmLhy.css` 를 **루트 절대경로**로
가리킨다. 경로는 프리렌더가 `dist/client/.vite/manifest.json` 에서 읽어 박는다
(`prerender.ts:160`). 렌더러 CSS 를 고치면 이름이 바뀐다.
@ -56,15 +87,19 @@
없고**, 그때 디스크에 있던 기존 자산이 전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다.
옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다.
→ 자산을 지우는 코드를 손볼 때는 **"기록이 없다"와 "만료됐다"를 절대 같이 묶지 않는다.**
→ 이미 끊겼다면 복구는 `docker compose restart solution-prerender` (기동하며 전체 재굽기).
→ 이미 끊겼다면 복구는 `docker compose restart solution-prerender` — 기동이 공용 자산을
다시 깔고 구워진 HTML 의 자산 주소를 맞춘다(전체 재굽기가 아니다, 위 ★★ 항목).
- **★ 사이트를 굽는 컨테이너는 `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-prerender` 후 `python scripts/republish_all.py`
- **★ 프론트(`solution/site`)를 고쳐도 기존 사이트의 내용은 안 바뀐다.** 기동은 자산 주소만
맞춘다(위 ★★ 항목) — 새 렌더러로 다시 그려지는 건 **그 사장님이 다시 발행할 때**다.
Azure 를 쓰는 경우엔 한 겹 더 있다: `azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만
올린다 — 다른 사이트의 블롭은 그대로다.
→ 전 사이트를 한꺼번에 새 렌더러로 맞춰야 할 일이 생기면
`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 에 구워진다** — 호스트를 바꾸면 프리렌더

View File

@ -170,7 +170,7 @@ Gemini 가 쓰고, 곡은 Suno 가 붙인다.
|---|---|---|
| 1 WEATHER | Open-Meteo | — |
| 2 FESTIVAL · 3 ATTRACTION · 4 RESTAURANT · 5 COURSE | TourAPI (좌표 반경) | — |
| 6 STORY | Perplexity | `songs` `people` `chronicle` `postcard` `quiz` |
| 6 STORY | Perplexity | `songs` `daily` `people` `chronicle` `reading` `postcard` `quiz` |
`body`(JSONB)에 항목이 들어간다. **지역 이야기는 종류당 한 행**이고 항목들은 `body.items` 안에 있다.

View File

@ -318,7 +318,7 @@ class LocalContentType(CodeEnum):
# 코드값 ↔ **타입명**. `area_contents.kind` 와 `site_sections.data.items[].kind` 가 같은 어휘를 쓴다 —
# 개인화 행(거리·숨김)이 어느 공용 실체를 가리키는지 이름만 보고 알 수 있어야 한다.
# ★ STORY 는 여기 없다. 그 다섯(songs·people·chronicle·postcard·quiz)은 kind 가 곧 타입명이고,
# ★ STORY 는 여기 없다. 그것들(songs·people·chronicle·reading·postcard·quiz)은 kind 가 곧 타입명이고,
# 코드값 하나(6)를 나눠 쓴다. 아래 표는 kind 가 비어 있던 장소류를 채우기 위한 것이다.
AREA_KIND = {
LocalContentType.WEATHER.value: "weather",
@ -328,9 +328,9 @@ AREA_KIND = {
LocalContentType.COURSE.value: "course",
}
# 지역 이야기 여섯. `services/prompts/story.py` 의 산출물 키와 같아야 한다.
# 지역 이야기 일곱. `services/prompts/story.py` 의 산출물 키와 같아야 한다.
# ★ 순서는 발행본 '지역 이야기' 탭 순서다(`site/sections/items/StorySection.tsx`).
STORY_KINDS = ("songs", "daily", "people", "chronicle", "postcard", "quiz")
STORY_KINDS = ("songs", "daily", "people", "chronicle", "reading", "postcard", "quiz")
class LocalSource(CodeEnum):

View File

@ -11,9 +11,12 @@
`search_results` 의 첫 줄을 붙인다 — 모델 답변은 환각이 섞이지만 search_results 는
실제로 검색된 주소다(`grounding/channels.py` 와 같은 판단).
둘 다 없으면 항목을 버린다. 출처 없는 사실은 이 레포의 규칙 위반이다.
★ 예외가 하나 있다 — `_SEARCH_LINK_KINDS`. 거기 있는 종류는 **모델의 URL 을 아예 안 받고**
제목으로 찾아가는 검색 링크를 코드가 만든다.
"""
import json
import re
from urllib.parse import quote
from common.logger import LOG
@ -23,10 +26,27 @@ _TITLE_KEY = {
"daily": "title",
"people": "name",
"chronicle": "title",
"reading": "title",
"postcard": "line",
"quiz": "question",
}
# ★ 출처를 **모델에게 받지 않고 코드가 만드는** 종류.
# 개별 문서 주소(`terms.naver.com/entry.naver?docId=…`)는 짐작해서 적으면 없는 문서로
# 이어진다 — 레포 절대규칙(없는 사실을 짓지 않는다)에 걸린다. 검색 링크는 제목을 그대로
# 넘기니 항상 관련 결과로 뜬다. 2026-09-14 대표 지시("확인필요 없애고 링크는 네이버 링크로"),
# 시연본 구현은 `site/scripts/mockup/build_reading.py` 의 `naver()`.
_SEARCH_LINK_KINDS = {"reading"}
def _search_source(title: str, region_label: str) -> dict:
"""제목으로 찾아가는 검색 링크. 지역명이 제목에 없으면 붙여 검색 정확도를 올린다."""
region = (region_label or "").split()[-1] if region_label else ""
query = title if (not region or region[:-1] in title or region in title) else f"{title} {region}"
return {"name": "네이버에서 찾아보기",
"url": "https://search.naver.com/search.naver?query=" + quote(query)}
# 코드펜스를 두르고 오는 경우가 있다. 규칙 1 로 금지했지만 모델은 종종 어긴다.
_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE)
@ -57,7 +77,9 @@ def _clean_source(value) -> dict | None:
return {"name": (value.get("name") or url).strip(), "url": url}
def parse_items(payload: dict, kind: str, limit: int) -> tuple[list[dict], list[str]]:
def parse_items(
payload: dict, kind: str, limit: int, region_label: str = ""
) -> tuple[list[dict], list[str]]:
"""(쓸 수 있는 항목, 버린 이유) — 버린 이유는 로그와 잡 결과에 남긴다.
한 항목이 잘못돼도 나머지를 살린다. 지역 하나에 8~14건인데 한 줄 때문에 전부 버리면
@ -101,6 +123,14 @@ def parse_items(payload: dict, kind: str, limit: int) -> tuple[list[dict], list[
item = {k: v for k, v in raw.items() if v not in (None, "", [], {})}
item[title_key] = title
if kind in _SEARCH_LINK_KINDS:
# 출처는 코드가 만든다(위 _SEARCH_LINK_KINDS). 모델이 URL 을 적어 왔어도 버린다 —
# 짐작해 적은 주소가 섞이는 통로를 아예 남기지 않는다. 검수 배지도 함께 뺀다.
item["source"] = _search_source(title, region_label)
item.pop("verified", None)
out.append(item)
continue
source = _clean_source(raw.get("source")) or fallback
if source is None:
dropped.append(f"{title}: 출처가 없다")

View File

@ -30,6 +30,13 @@
"task": "[해야 할 일]\n[지역]의 역사를 연도순으로 10~14개 사건으로 정리한다.\n가장 오래된 것부터 가장 최근까지 고르게 펴고, 한 시대에 몰지 않는다.\n\n[스키마]\n{ \"kind\":\"chronicle\", \"version\":1, \"title\":\"시간의 골목\", \"items\":[\n { \"year\":1899, \"title\":\"사건 이름\", \"summary\":\"두 문장 이내\",\n \"place\":\"지금 가 볼 수 있는 자리\", \"turning\":true,\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
"rules": "\n[이 아이템만의 규칙]\n· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다.\n· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다.\n· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.\n· 다만 **찾아갈 수 있는 자리가 있으면 반드시 적는다.** 행정 개편처럼 장소가 없어 보이는\n 사건에도 그 일이 남긴 자리가 대개 있다(개항 → 항구, 부두 건설 → 그 부두, 준공 → 그 구조물).\n 이 값으로 공공데이터에서 그 해의 사진을 찾아 붙인다 — 비면 연표가 활자만으로 선다.\n"
},
"reading": {
"kind": "reading",
"label": "지역 읽기",
"maxItems": 34,
"task": "[해야 할 일]\n[지역]을 소개하는 글 30~34꼭지를 갈래로 나눠 쓴다.\n갈래는 다섯이다 — 문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활.\n한 갈래에 몰지 않되, 댈 수 있는 사실이 적은 갈래는 적게 쓴다.\n\n[스키마]\n{ \"kind\":\"reading\", \"version\":1, \"title\":\"[지역] 읽기\",\n \"subtitle\":\"문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활 — 올 때마다 다른 몇 가지\",\n \"items\":[\n { \"group\":\"문학|섬과 바다|역사|장소|음식과 생활\",\n \"title\":\"꼭지 제목\", \"body\":\"서너 문장\", \"year\":1937 } ] }",
"rules": "\n[이 아이템만의 규칙]\n· body 는 **서너 문장**이다. 두 문장이면 카드가 한 줄로 접혀 빈 카드처럼 보인다.\n 다만 문장을 늘리려고 없는 숫자·연도·거리를 지어내지 않는다 — 널리 알려진 배경만 보탠다.\n· **이미 다른 자리에 선 것은 쓰지 않는다.** 인물·대중가요·축제·연표 사건·주변 명소·맛집은\n 각자 섹션이 있다. 같은 것을 두 번 세우면 페이지만 길어지고 손님은 같은 말을 두 번 읽는다.\n· year 는 연도를 댈 수 있는 꼭지에만 쓴다. 짐작해서 넣지 않는다.\n· source 와 verified 를 적지 않는다. **주소를 짐작해 적으면 없는 문서로 이어진다** —\n 제목으로 찾아가는 검색 링크를 서버가 붙인다(2026-09-14 대표 지시).\n· 시·소설·가사의 원문을 한 줄도 옮기지 않는다. 제목과 배경만 쓴다.\n· 부제에 전체 개수를 적지 않는다. 화면은 이 중 5~6개만 매번 무작위로 보여준다 —\n 숫자를 박으면 보이는 개수와 어긋난다.\n"
},
"postcard": {
"kind": "postcard",
"label": "오늘의 엽서",

View File

@ -140,7 +140,7 @@ _DEFAULT_THEME = {
# 수집이 다 됐어도 그 섹션이 아예 안 나온다 — 저장값이 없는 사업장에는 이 표가 곧 발행본이다.
# ★ "이용 규정"은 뺐다 (2026-09-09) — 발행본에 그 섹션이 없다. 체크인·취소·취사·
# 반려동물 줄은 기본 정보 안에서 규정 덩이로 묶여 나간다(EssentialInfoSection).
# ★ 가요·일력·인물·연표·엽서는 여기 넣지 않는다. 이 표의 항목은 전부 켜서 나가는데
# ★ 가요·일력·인물·연표·읽기·엽서는 여기 넣지 않는다. 이 표의 항목은 전부 켜서 나가는데
# (`_sections`), 그것들은 '지역 이야기'(story) 탭 **안에서** 그려지는 것이라
# 켜면 탭 밖에 한 번 더 선다. story 하나만 두면 데이터가 있는 것만 탭이 된다.
# ★ 퀴즈(quiz)도 넣지 않지만 사정이 다르다 — 탭이 아니라 **독립 섹션**이라

View File

@ -74,7 +74,7 @@ async def _generate_kind(client: httpx.AsyncClient, kind: str, region_label: str
LOG.w(f"[story] {kind} 호출 실패 region={region_label}: {ex}")
return [], [f"호출 실패: {ex}"]
items, dropped = grounding.parse_items(payload, kind, prompts.max_items(kind))
items, dropped = grounding.parse_items(payload, kind, prompts.max_items(kind), region_label)
items = await _attach_images(kind, items, region_label)
LOG.i(f"[story] {region_label} {kind}: {len(items)}건 채택, {len(dropped)}건 버림")
return items, dropped

View File

@ -23,7 +23,7 @@ def _reply(content: str, search_results=None) -> dict:
# ── 프롬프트 ────────────────────────────────────────────────────────────
def test_프롬프트는_shared_산출물에서_온다():
"""여섯 종이 모두 있고, 지역명이 빈칸 없이 박힌다."""
assert set(prompts.kinds()) == {"songs", "daily", "people", "chronicle", "postcard", "quiz"}
assert set(prompts.kinds()) == {"songs", "daily", "people", "chronicle", "reading", "postcard", "quiz"}
text = prompts.build_prompt("songs", "전북 군산시")
assert "[지역] 전북 군산시" in text
assert "[지역]을 노래한 대중가요" in text # task 원문

View File

@ -343,6 +343,52 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
],
},
reading: {
kind: 'reading',
label: '지역 읽기',
sample: JSON.stringify(
{
kind: 'reading',
version: 1,
title: '군산 읽기',
subtitle: '문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활 — 올 때마다 다른 몇 가지',
items: [
{
group: '문학',
title: '채만식 『탁류』',
body:
'일제강점기 군산을 무대로 한 장편소설입니다. 쌀을 사고팔던 미두장을 중심으로, ' +
'가족을 지키려던 초봉이 시대의 소용돌이에 휩쓸려 무너지는 과정을 그렸습니다. ' +
'금강 하구의 흙탕물이라는 제목처럼, 개항장 군산의 실제 지명과 풍경 위에 ' +
'이야기가 펼쳐집니다.',
year: 1937,
},
{
group: '섬과 바다',
title: '고군산군도',
body:
'예순 남짓한 섬이 모인 군도입니다. 새만금 방조제가 육지와 신시도를 잇고, ' +
'그 뒤로 무녀도 · 선유도 · 장자도가 다리로 차례로 이어져 차로 들어갈 수 ' +
'있습니다. 섬마다 성격이 달라 하루로는 다 못 봅니다.',
},
],
},
null,
2,
),
task: SECTION_PROMPTS.reading.task,
rules: SECTION_PROMPTS.reading.rules,
/* 출처 칸이 없다 — 이 종류의 출처는 서버가 제목으로 만든 검색 링크다
(`backend/services/grounding/story.py` `_SEARCH_LINK_KINDS`). 손으로 적는 칸을 두면
거기 적힌 주소가 그 규칙을 비껴간다. */
fields: [
{key: 'group', label: '갈래', half: true, hint: '문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활'},
{key: 'year', label: '연도', type: 'number', half: true},
{key: 'title', label: '꼭지 제목'},
{key: 'body', label: '본문', type: 'area', hint: '서너 문장'},
],
},
postcard: {
kind: 'postcard',
label: '오늘의 엽서',

View File

@ -64,6 +64,7 @@ import {SongsTurntable} from './variants/songs/SongsTurntable';
import {DailyCalendar} from './variants/daily/DailyCalendar';
import {PeopleFilmstrip} from './variants/people/PeopleFilmstrip';
import {ChronicleRail} from './variants/chronicle/ChronicleRail';
import {ReadingRail} from './variants/reading/ReadingRail';
import {PostcardStack} from './variants/postcard/PostcardStack';
import {QuizFlip} from './variants/quiz/QuizFlip';
@ -413,6 +414,17 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
],
reading: [
{
id: 'reading.posts',
name: '읽기 카드',
description: '한 슬라이드에 한 꼭지. 발행본은 이 중 5~6개만 매번 무작위로 세운다.',
thumb: 'carousel',
Component: ReadingRail,
isDefault: true,
},
],
postcard: [
{
id: 'postcard.stack',

View File

@ -0,0 +1,93 @@
/**
* 지역 읽기 — 한 슬라이드에 한 꼭지.
*
* ★ 에디터는 **전부 보여준다.** 발행본은 이 중 5~6개만 매번 무작위로 세우지만
* (`site/sections/items/ReadingSection.tsx`), 고칠 사람은 안 뽑힌 꼭지도 봐야 한다 —
* 에디터에서 무작위로 가리면 방금 고친 글이 다음 렌더에 사라진다.
* ★ 출처 줄이 없다. 이 종류의 출처는 제목으로 만든 검색 링크라 서버가 붙인다
* (`backend/services/grounding/story.py` `_SEARCH_LINK_KINDS`) — 붙여넣기 단계에는 없다.
*/
import {Rail, SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type ReadingItem} from '@o2o/shared';
import {
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ParseError,
PasteHint,
} from '../items/common';
import '../items/items.css';
function Post({item}: {item: ReadingItem}) {
return (
<article
className="w-[300px] shrink-0 snap-center border p-4"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
{item.group && (
<p
className="text-[11px] font-bold tracking-[0.18em]"
style={{color: ITEM_ACCENT, fontFamily: ITEM_BODY}}
>
{item.group}
{item.year != null && <span className="font-normal opacity-70"> · {item.year}</span>}
</p>
)}
<h3
className="mt-2 border-b-2 pb-2 text-lg leading-snug"
style={{fontFamily: ITEM_HEADING, borderColor: 'currentColor'}}
>
{item.title}
</h3>
<p
className="mt-2.5 text-[13px] leading-[1.8] break-keep opacity-80"
style={{fontFamily: ITEM_BODY}}
>
{item.body}
</p>
</article>
);
}
export function ReadingRail(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<ReadingItem>(section.type, section.data);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="지역 읽기" />
) : (
<div className="space-y-3">
{/* 발행본과 에디터가 다르게 보이는 자리라, 몇 개가 나가는지 여기서 밝힌다. */}
<p className="text-[11px] opacity-60">
전체 {parsed.items.length}꼭지 · 발행본은 이 중 5~6개를 매번 무작위로 세웁니다
</p>
<Rail label="지역 읽기" gap={0}>
{parsed.items.map((item, index) => (
<Post key={`${item.title}-${index}`} item={item} />
))}
</Rail>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -125,6 +125,32 @@ export interface ChronicleItem {
source?: DataSource;
}
/**
* 지역 읽기 — 도시를 갈래로 묶어 한 꼭지씩 넘겨 보는 글.
*
* ★ 왜 일력(daily)과 따로 있나 (2026-09-14 대표 의견: "오늘의 한 장으로 군산을 소개하기는
* 무리다, 하루에 하나씩밖에 안 알려 주니까")
* 일력은 오늘·어제·내일 **세 장만** 화면에 선다. 도시를 소개하는 그릇으로는 작다.
* 이쪽은 갈래(문학·섬과 바다·역사·장소·음식과 생활)로 묶은 더미에서 매번 몇 개만 뽑아 낸다.
* ★ 이미 제 자리를 가진 것은 여기 넣지 않는다 — 인물·가요·축제·연표·명소·맛집.
* 같은 이야기를 두 번 세우면 페이지만 길어진다(프롬프트 규칙으로도 막는다).
*/
export interface ReadingItem {
/** 갈래 이름. 카드 위 이름표로만 쓴다 — 갈래별로 묶어 세우지 않는다(뽑기는 갈래를 안 가린다). */
group?: string;
title: string;
/** 서너 문장. 두 문장짜리는 카드가 한 줄로 접혀 빈 카드처럼 보인다(2026-09-14 대표 지적). */
body: string;
/** 연도를 댈 수 있는 꼭지만. 없으면 이름표에 연도를 안 붙인다. */
year?: number;
verified?: DataVerified;
/**
* 출처. **모델이 적지 않는다** — 제목으로 찾아가는 검색 링크를 서버가 붙인다
* (`services/grounding/story.py`). 개별 문서 주소를 짐작해 적으면 없는 문서로 이어진다.
*/
source?: DataSource;
}
export interface LiteratureItem {
workTitle: string;
author?: string;
@ -288,6 +314,7 @@ export const SECTION_ITEM_REQUIRED_KEY: Record<string, string> = {
schedule: 'name',
people: 'name',
chronicle: 'title',
reading: 'title',
literature: 'workTitle',
postcard: 'line',
quiz: 'question',

View File

@ -15,7 +15,14 @@
*/
/** 아이템 종류. `area_contents.kind` · JSON 봉투의 `kind` 와 같은 값이다. */
export type StoryKind = 'songs' | 'daily' | 'people' | 'chronicle' | 'postcard' | 'quiz';
export type StoryKind =
| 'songs'
| 'daily'
| 'people'
| 'chronicle'
| 'reading'
| 'postcard'
| 'quiz';
/**
* 순서는 발행본 '지역 이야기' 탭 순서다(`site/sections/items/StorySection.tsx`).
@ -30,6 +37,7 @@ export const STORY_KINDS: StoryKind[] = [
'daily',
'people',
'chronicle',
'reading',
'postcard',
'quiz',
];
@ -157,6 +165,36 @@ export const SECTION_PROMPTS: Record<StoryKind, SectionPromptSpec> = {
`,
},
reading: {
kind: 'reading',
label: '지역 읽기',
maxItems: 34,
task: `[해야 할 일]
[지역]을 소개하는 글 30~34꼭지를 갈래로 나눠 쓴다.
갈래는 다섯이다 — 문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활.
한 갈래에 몰지 않되, 댈 수 있는 사실이 적은 갈래는 적게 쓴다.
[스키마]
{ "kind":"reading", "version":1, "title":"[지역] 읽기",
"subtitle":"문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활 — 올 때마다 다른 몇 가지",
"items":[
{ "group":"문학|섬과 바다|역사|장소|음식과 생활",
"title":"꼭지 제목", "body":"서너 문장", "year":1937 } ] }`,
rules: `
[이 아이템만의 규칙]
· body 는 **서너 문장**이다. 두 문장이면 카드가 한 줄로 접혀 빈 카드처럼 보인다.
다만 문장을 늘리려고 없는 숫자·연도·거리를 지어내지 않는다 — 널리 알려진 배경만 보탠다.
· **이미 다른 자리에 선 것은 쓰지 않는다.** 인물·대중가요·축제·연표 사건·주변 명소·맛집은
각자 섹션이 있다. 같은 것을 두 번 세우면 페이지만 길어지고 손님은 같은 말을 두 번 읽는다.
· year 는 연도를 댈 수 있는 꼭지에만 쓴다. 짐작해서 넣지 않는다.
· source 와 verified 를 적지 않는다. **주소를 짐작해 적으면 없는 문서로 이어진다** —
제목으로 찾아가는 검색 링크를 서버가 붙인다(2026-09-14 대표 지시).
· 시·소설·가사의 원문을 한 줄도 옮기지 않는다. 제목과 배경만 쓴다.
· 부제에 전체 개수를 적지 않는다. 화면은 이 중 5~6개만 매번 무작위로 보여준다 —
숫자를 박으면 보이는 개수와 어긋난다.
`,
},
postcard: {
kind: 'postcard',
label: '오늘의 엽서',

View File

@ -204,6 +204,7 @@ import type {
PeopleItem,
PostcardItem,
QuizItem,
ReadingItem,
SongItem,
} from '../lib/section-data';
@ -222,7 +223,7 @@ export interface LocalContents {
*/
itineraries?: ItineraryItem[];
/**
* 지역 이야기 — 서버가 지역 단위로 생성한 가요·일력·인물·연표·엽서·퀴즈.
* 지역 이야기 — 서버가 지역 단위로 생성한 가요·일력·인물·연표·읽기·엽서·퀴즈.
*
* ★ 사장님이 붙여넣는 같은 종류의 JSON(`theme.sections[].data`)과 **모양이 같다.**
* 화면은 둘을 한 배열로 이어 그린다(`StorySection`) — 사장님 값이 앞이다.
@ -235,12 +236,13 @@ export interface LocalContents {
syncedAt?: string;
}
/** 지역 이야기 여섯 종. 데이터가 없는 종류는 키 자체가 없다 — 빈 배열을 만들지 않는다. */
/** 지역 이야기 일곱 종. 데이터가 없는 종류는 키 자체가 없다 — 빈 배열을 만들지 않는다. */
export interface LocalStories {
songs?: SongItem[];
daily?: DailyItem[];
people?: PeopleItem[];
chronicle?: ChronicleItem[];
reading?: ReadingItem[];
postcard?: PostcardItem[];
quiz?: QuizItem[];
}

View File

@ -84,6 +84,7 @@
| `local` | 지역 정보 | `local.attractions/restaurants` | TourAPI 수집 (시연본 맛집은 `restaurants.json` 31곳) | |
| `itinerary` | **추천 일정** | `data(kind=itinerary)` | `services/itinerary.py` + 빌더 프롬프트 `dataSpec.ts:556` | **T2** |
| `story` | 군산 이야기 | 아래 6탭 껍데기 | — | |
| `reading` | 지역 읽기 | `local.story.reading` | LLM `section-prompts.ts` `reading` | |
| `songs` | 가요 다방 | `data(kind=songs)` | LLM `section-prompts.ts:67` | **T3 T5** |
| `daily` | 오늘의 한 장 | `data(kind=daily)` | LLM `:91` | 시연본은 노래·문학 39장(`build_daily.py`) |
| `people` | 인물 열전 | `data(kind=people)` | LLM `:113` | **T3 T5** |
@ -95,8 +96,9 @@
| `weather` | 날씨 | `local.weather` | open-meteo | |
| `planner` | 계절별 추천 하루 | `data(kind=planner)` | **없음** | (시연본은 꺼 둠) |
섹션이 아닌 것이 셋 더 있다 — 헤더 음악 플레이어(**T4**) · **군산 읽기**(**T7**, '군산 이야기'
탭 묶음의 여섯 번째 탭) · **엽서 쓰기**(독립 섹션, `festival` 바로 앞).
섹션이 아닌 것이 둘 더 있다 — 헤더 음악 플레이어(**T4**) · **엽서 쓰기**(독립 섹션,
`festival` 바로 앞). **군산 읽기**(T7)는 2026-09-15 에 렌더러로 들어갔다 — 이제 payload 에
`local.story.reading` 만 있으면 새 업장에서도 탭이 선다.
---
@ -273,11 +275,25 @@
---
### T7 — 도시를 펼쳐 보이는 자리
### T7 — 도시를 펼쳐 보이는 자리 ✔ 제품에 들어갔다 (2026-09-15)
**증상** 도시 이야기를 담는 자리가 전부 한 번에 하나만 보인다. 하루에 한 장만 보여주는
일력이나, 넉 장짜리 엽서 레일 정도로는 군산이라는 도시를 소개하기엔 부족하다.
**제품에서 어디에 있나** — 이제 주입분이 아니라 렌더러가 그린다.
| 자리 | 파일 |
|---|---|
| 항목 계약 `ReadingItem` | `shared/src/lib/section-data.ts` |
| 프롬프트(단일 출처) | `shared/src/lib/section-prompts.ts` `reading` |
| 생성·출처 링크 | `backend/services/grounding/story.py` `_SEARCH_LINK_KINDS` |
| 발행본 | `site/src/sections/items/ReadingSection.tsx` — '지역 이야기' 여섯 번째 탭 |
| 빌더 캔버스 | `frontend/.../canvas/variants/reading/ReadingRail.tsx` |
★ 제품과 시연본이 **한 군데 다르다**: 탭 이름이 `{지명} 읽기` 다(시연본은 '군산 읽기' 고정).
★ 제품은 출처를 모델에게 받지 않는다 — 제목으로 만든 네이버 검색 링크를 서버가 붙인다
(`build_reading.py` `naver()` 를 그대로 옮긴 것이다).
**할 일** 문학·역사·섬과 바다·장소·음식처럼 갈래로 나눈 도시 이야기를, 한 화면에서
옆으로 넘겨 가며 볼 수 있는 자리를 만든다.
@ -920,7 +936,9 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/"
1. **캐치프레이즈 순환** — 대표 문구 `<p>` 를 찾아 **그 아래에 줄(`#w4d-sub`)을 붙이고** 단어 단위로 갈아 끼운다. 대표 문구는 안 건드린다 → T1
2. **헤더 미니 플레이어** — 카세트 아이콘 + 재생/멈춤 + 목록. 자작곡 5곡.
**자동재생**은 걸어 보고, 브라우저가 막으면 첫 손길에 켠다(**1.3 T4**) → T4
3. **군산 읽기** — 렌더러에 없는 섹션. '군산 이야기' 탭 묶음의 **여섯 번째 탭**으로 들어가
3. **군산 읽기** — ★ **2026-09-15 부터 렌더러에 있다**(`ReadingSection.tsx`, **T7**). 아래는
시연본 주입분이 어떻게 굴러가는지의 기록이고, 제품을 고칠 때는 렌더러를 본다.
'군산 이야기' 탭 묶음의 **여섯 번째 탭**으로 들어가
34꼭지 중 5~6개를 매번 무작위로 카로셀(embla, 한 슬라이드에 한 꼭지) 로 보여준다.
`startReading()` 이 story 탭 패널들의 마지막(postcard) 뒤에 자기 패널을 꽂고,
자기 탭 버튼은 진짜 탭들의 `[role="tablist"]` 끝에 붙인다 → **T7**

View File

@ -9,6 +9,8 @@ import {
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';
@ -112,14 +114,25 @@ 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')};
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;
}
@ -298,6 +311,156 @@ function copyDirectoryFiles(source: string, destination: string) {
}
}
/** 내려받은 사진이 놓이는 폴더. 사이트 디렉토리 안이라 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}`;
@ -855,11 +1018,26 @@ function writeIndexNowKey(outRoot: string) {
console.log(' ✓ IndexNow 키 파일');
}
function main() {
async function main() {
const args = parseArgs();
const loaded = loadPayloads(args.payloads);
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 가 없는 목업 포함)가
@ -870,6 +1048,8 @@ function main() {
writeSharedAssets(args.out, referenced);
let failed = 0;
/** 이번에 실제로 구운 슬러그. 자산 주소 갱신에서 빼려고 모은다(이미 새 주소다). */
const baked = new Set<string>();
/** 루트 기계용 파일을 쓸 오리진. 이 호스트의 사이트는 전부 같은 오리진을 쓴다. */
let origin = '';
@ -920,8 +1100,12 @@ function main() {
}
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(
@ -963,6 +1147,9 @@ function main() {
}
}
// ★ 이번에 안 구운 사이트도 **번들 주소는** 새것으로 맞춰 둔다. 내용은 그대로다.
if (args.refreshAssets) refreshBakedAssets(args.out, assets, baked, args.payloadDir);
// ★ 실패한 사이트가 있어도 루트 파일은 갱신한다 — 성공한 사이트까지 색인에서 빠질 이유가 없다.
// (인덱스는 디스크를 훑으므로 실패한 사이트는 애초에 들어가지 않는다.)
writePreviewShell(args.out, assets);
@ -976,4 +1163,4 @@ function main() {
console.log('[prerender] 완료');
}
main();
void main();

View File

@ -33,6 +33,7 @@ const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, '..');
const PAYLOAD_DIR = join(ROOT, 'payloads');
const PRERENDER_JS = join(ROOT, 'dist', 'prerender', 'prerender.js');
const SITES_DIR = join(ROOT, 'out', 's');
const ONCE = process.argv.includes('--once');
/** 폴링 간격. 발행은 분 단위 작업이라 2초 지연은 문제가 되지 않는다. */
@ -156,6 +157,27 @@ function prerender(files, reason) {
return run('node', [PRERENDER_JS, ...files.map((file) => `--payload=${file}`)], label);
}
/**
* 기동 때 하는 일 — **굽지 않고 자산 주소만 갈아 끼운다.**
*
* ★ 왜 (2026-09-15 대표 지시: "전체 재굽기 할 필요가 없어, 사장님이 재발행하면 끝인데 /
* css js만 안 깨지게 하란 말이야")
* 예전에는 여기서 payload 를 전부 다시 구웠다. 그러면 렌더러를 고칠 때마다 이미 나가 있는
* 사이트의 HTML 이 통째로 바뀐다 — 사장님은 발행한 적이 없는데 내용이 달라진다.
* 새 기능은 **다음 발행 때** 들어가면 되고, 기존 사이트는 번들만 안 깨지면 된다.
* ★ 번들 파일명이 콘텐츠 해시라 이것마저 안 하면 옛 HTML 이 옛 번들을 가리킨 채 굳는다.
* 자산 자체는 지워지지 않지만(`prerender.ts` referencedAssets) 디자인 수정이 영영 안 간다.
*/
function refreshAssets() {
// payload 디렉토리를 넘긴다 — 자산 주소를 갈아 끼울 대상을 **payload 가 있는 슬러그로**
// 좁히려는 것이다. 목업(payload 없는 디렉토리)은 손으로 바꾼다(AGENTS.md 함정 1).
return run(
'node',
[PRERENDER_JS, '--refresh-assets', `--payload-dir=${PAYLOAD_DIR}`],
'자산 주소 갱신 — 기동',
);
}
// ── 큐 ────────────────────────────────────────────────────────────────────
/** 굽기를 기다리는 payload 경로. 굽는 동안 들어온 변경은 여기에 쌓였다가 이어서 돈다. */
const pending = new Set();
@ -209,6 +231,11 @@ function basenameOf(file) {
return file.slice(file.lastIndexOf('/') + 1);
}
/** 그 payload 가 이미 구워져 있나. 파일명이 슬러그다(백엔드가 `<slug>.json` 으로 쓴다). */
function bakedIndexOf(file) {
return join(SITES_DIR, basenameOf(file).replace(/\.json$/, ''), 'index.html');
}
/** payload 디렉토리의 *.json 목록. 백엔드가 rename 전에 쓰는 임시파일(.tmp)은 건너뛴다. */
function listPayloads() {
if (!existsSync(PAYLOAD_DIR)) return [];
@ -228,11 +255,16 @@ async function main() {
return;
}
// 기동 시 한 번은 전부 굽는다. 감시가 꺼져 있는 동안 발행된 것도 사이트가 있어야 하고,
// 코드가 바뀌었다면 번들이 새로 나왔으므로 기존 사이트도 다시 구워야 맞다.
// ★ 기동 시 전부 굽지 않는다(refreshAssets 주석). 자산 주소만 맞추고, 내용은
// 사장님이 다시 발행할 때 새 렌더러로 구워진다.
const all = listPayloads();
const seen = new Map(all.map((file) => [file, statSync(file).mtimeMs]));
await prerender(all, '기동');
await refreshAssets();
// 아직 한 번도 안 구워진 payload 는 굽는다 — 감시가 꺼져 있는 동안 발행됐거나 볼륨이
// 비어 있던 경우다. 사이트가 아예 없는 것과 "옛 내용으로 서 있는 것" 은 다른 문제다.
const unbuilt = all.filter((file) => !existsSync(bakedIndexOf(file)));
await prerender(unbuilt, '아직 안 구워진 것');
if (ONCE) return;

View File

@ -60,6 +60,7 @@ const ANCHOR: Record<string, string> = {
songs: 'songs',
daily: 'daily',
chronicle: 'chronicle',
reading: 'reading',
people: 'people',
quiz: 'quiz',
postcard: 'postcard',
@ -84,6 +85,7 @@ const FALLBACK_LABEL: Record<string, string> = {
songs: '노래',
daily: '일력',
chronicle: '연표',
reading: '읽기',
people: '인물',
quiz: '퀴즈',
postcard: '엽서',

View File

@ -110,7 +110,10 @@ function stampLines(ctx: CanvasRenderingContext2D, name: string, maxWidth: numbe
* 실측(2026-09-14): 발행본 사진은 네이버 CDN(`*.pstatic.net`)에 있고 그쪽은
* `Access-Control-Allow-Origin` 을 주지 않는다 — 그래서 이 시도는 실패하고,
* **CORS 없이 다시 받아 미리보기만** 세운다(저장·공유는 부르는 쪽이 막는다).
* ★ 근본 해결은 사진을 우리 오리진으로 옮기는 것이다(시연본이 `img/mirror/` 로 그렇게 한다).
* ★ **2026-09-15 에 근본을 고쳤다** — 굽는 쪽이 사진을 내려받아 `s/<slug>/img/` 에 두고
* payload 의 주소를 우리 오리진으로 바꾼다(`scripts/prerender.ts` `mirrorMedia`).
* 같은 오리진이면 오염되지 않아 저장·공유가 열린다. 아래 폴백은 **못 받아 온 사진**
* (CDN 이 거절했거나 타입이 이상한 것)만을 위한 것이라 이제 드물게 탄다.
*/
async function loadImage(url: string): Promise<{img: HTMLImageElement; exportable: boolean}> {
const tryLoad = (crossOrigin: string | null) => new Promise<HTMLImageElement>((resolve, reject) => {

View File

@ -25,7 +25,9 @@ export function PostcardMakerSection() {
const [hint, setHint] = useState('');
const [touch, setTouch] = useState(false);
/* 사진이 남의 도메인이면 캔버스가 오염돼 파일로 못 뽑는다(postcard-canvas.loadImage 주석).
그때는 미리보기만 남기고 저장·공유를 막는다 — 눌러도 안 되는 단추를 두지 않는다. */
굽는 쪽이 사진을 우리 오리진으로 옮기고부터(prerender `mirrorMedia`) 여기 걸리는 건
**내려받기에 실패한 사진**뿐이다. 그때는 미리보기만 남기고 저장·공유를 막는다 —
눌러도 안 되는 단추를 두지 않는다. */
const [exportable, setExportable] = useState(true);
useEffect(() => {
@ -109,15 +111,19 @@ export function PostcardMakerSection() {
title="엽서 쓰기"
lead={`${placeName}에서 보내는 한 장을 직접 써 봅니다. 사진을 고르고 한 마디를 적으면 그 자리에서 엽서가 되고, 그대로 공유하거나 저장할 수 있습니다.`}
>
<div className="grid gap-6 sm:grid-cols-[minmax(0,1fr)_minmax(0,320px)]">
{/* ★ 폭을 묶어 둔다 (2026-09-15 대표: "엽서 ui 너무 큼"). 엽서는 1:1 이라 열 폭을
그대로 주면 넓은 화면에서 한 변이 700px 넘는 정사각이 서서, 예약하러 온 손님이
이 한 칸을 스크롤로 넘겨야 다음 섹션에 닿는다. 미리보기는 손바닥만 하면 된다.
★ 320px 도 크다고 했다(2026-09-15, 같은 날 두 번째) — 240px 로 내렸다. */}
<div className="mx-auto grid max-w-2xl gap-4 sm:grid-cols-[minmax(0,240px)_minmax(0,1fr)] sm:items-start">
<canvas
ref={canvasRef}
width={POSTCARD_SIZE}
height={POSTCARD_SIZE}
aria-label="내가 만든 엽서 미리보기"
className="border-line w-full border"
className="border-line mx-auto w-full max-w-[240px] border"
/>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2.5">
<div className="slider-viewport" role="radiogroup" aria-label={`엽서 사진 고르기(${photos.length}장)`} tabIndex={0}>
<div className="slider-track" style={{gap: '0.5rem'}}>
{photos.map((photo, i) => (
@ -125,9 +131,10 @@ export function PostcardMakerSection() {
key={photo.url}
type="button"
onClick={() => setPicked(i)}
aria-pressed={i === picked}
role="radio"
aria-checked={i === picked}
aria-label={`사진 ${i + 1}`}
className="border-line size-16 shrink-0 overflow-hidden rounded border transition-opacity"
className="border-line size-10 shrink-0 overflow-hidden rounded border transition-opacity"
style={{opacity: i === picked ? 1 : 0.5}}
>
<img src={photo.url} alt="" loading="lazy" className="size-full object-cover" />
@ -137,30 +144,31 @@ export function PostcardMakerSection() {
</div>
<textarea
rows={3}
rows={2}
maxLength={60}
value={text}
placeholder="엽서에 쓸 한 마디를 적어 보세요"
onChange={(e) => { if (withinLineLimit(e.target.value)) setText(e.target.value); }}
className="border-line w-full rounded border p-3 text-[length:var(--fs-sm)]"
className="border-line w-full rounded border p-2.5 text-[length:var(--fs-sm)]"
/>
<p className="text-muted text-[length:var(--fs-xs)]">{text.length}/60</p>
<p className="text-muted -mt-1 text-[length:var(--fs-xs)]">{text.length}/60</p>
{exportable ? (
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => void share()} className="tap border-line rounded-full border px-4 py-2 text-[length:var(--fs-sm)] font-bold">
<button type="button" onClick={() => void share()} className="tap border-line rounded-full border px-3.5 py-1.5 text-[length:var(--fs-sm)] font-bold">
{touch ? '카카오톡 등으로 공유' : '공유하기'}
</button>
<button
type="button"
onClick={() => void exportBlob().then((b) => b && download(b))}
className="tap border-line rounded-full border px-4 py-2 text-[length:var(--fs-sm)]"
className="tap border-line rounded-full border px-3.5 py-1.5 text-[length:var(--fs-sm)]"
>
이미지로 저장
</button>
</div>
) : (
/* 브라우저 정책이라 여기서는 못 넘는다 — 사진이 이 사이트 주소로 올라와야 풀린다. */
/* 브라우저 정책이라 여기서는 못 넘는다 — 사진이 이 사이트 주소로 와야 풀리고,
그 일은 굽는 쪽(prerender `mirrorMedia`)이 한다. 여기까지 왔다는 건 그게 실패했다는 뜻. */
<p className="text-muted text-[length:var(--fs-sm)]">
이 사진은 다른 사이트에 올라와 있어 저장·공유가 막혀 있습니다. 화면으로만 보여 드립니다.
</p>

View File

@ -0,0 +1,96 @@
/**
* 지역 읽기 — 도시 이야기 더미에서 **매번 몇 개만** 뽑아 한 꼭지씩 넘겨 본다.
*
* ★ 왜 다 안 보여주나 (2026-09-14 대표: "34개중 15~19개인데 왜 34개라고 써놔?" →
* "그냥 5-6개 정도만 보여주는거 어때?" → "랜덤으로")
* 서른 꼭지를 한 화면에 펴면 스크롤 압박이 된다. 접는 게 아니라 **돌려 보여준다** —
* 다음에 들어오면 다른 조합이 뜬다. 그래서 `<details>` 를 쓰지 않는다(다 남겨야 하는
* FAQ·일력과는 다른 경우다).
* ★ 인용은 손해 보지 않는다 — 안 뽑힌 꼭지도 `window.__SITE_PAYLOAD__` 로 문서 안에 그대로
* 남는다(`scripts/prerender.ts`). 화면에서 고르는 것과 문서에서 지우는 것은 다른 일이다.
* ★ 뽑기는 **붙은 뒤에** 한다. 구운 HTML 과 첫 렌더가 달라지면 하이드레이션이 깨진다 —
* 서버는 앞에서부터 여섯 개를 세우고(크롤러가 읽는 것이 이것이다), 브라우저가 붙는 순간
* 한 번 섞는다. 다시 섞지는 않는다(재렌더마다 바뀌면 읽던 카드가 눈앞에서 사라진다).
* ★ 갈래(문학·역사·…)로 묶어 세우지 않는다. 갈래는 카드 위 이름표로만 쓰고, 뽑기는
* 갈래를 가리지 않는다 — 갈래마다 3~4개씩 뽑던 판은 한 번에 15~19개가 떴다.
*/
import {useEffect, useState} from 'react';
import type {ReadingItem} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ItemSection, Rail, SourceLine} from './common';
/** 한 번에 세우는 꼭지 수. 5 와 6 사이에서 흔들어 "볼 때마다 다르다"를 만든다. */
const MIN_PICK = 5;
const MAX_PICK = 6;
function shuffled<T>(list: readonly T[]): T[] {
const copy = list.slice();
for (let i = copy.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
export function ReadingSection() {
const payload = useSite();
const parsed = sectionItems<ReadingItem>(payload, 'reading');
const all = parsed.items;
// 서버·첫 렌더가 같아야 한다. 섞기는 아래 useEffect 가 붙은 뒤에 한 번만.
const [picked, setPicked] = useState<ReadingItem[]>(() => all.slice(0, MAX_PICK));
useEffect(() => {
if (all.length <= MIN_PICK) return;
const count = Math.random() < 0.5 ? MIN_PICK : MAX_PICK;
setPicked(shuffled(all).slice(0, count));
// 더미 전체가 바뀔 때만 다시 뽑는다(발행본에서는 한 번뿐이다).
}, [all]);
if (all.length === 0) return null;
const region = payload.place.addressLocality ?? '지역';
return (
<ItemSection
id="reading"
name={parsed.title || sectionName(payload, 'reading', `${region} 읽기`)}
subtitle={parsed.subtitle}
>
<Rail label={`${region} 읽기`}>
{picked.map((item, index) => (
<article
key={`${item.title}-${index}`}
className="w-[86%] shrink-0 grow-0 sm:w-[min(600px,56%)]"
>
<div
className="h-full border p-5"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
{/* 갈래 이름표. '문학면' 처럼 신문 지면으로 부르지 않는다 — 그 틀이 없다. */}
{item.group && (
<p
className="text-[11px] font-bold tracking-[0.18em]"
style={{color: ITEM_ACCENT}}
>
{item.group}
{item.year != null && <span className="font-normal opacity-70"> · {item.year}</span>}
</p>
)}
<h3
className="serif mt-2 border-b-2 pb-2.5 text-xl leading-snug font-bold"
style={{borderColor: 'currentColor'}}
>
{item.title}
</h3>
{/* 서너 문장이 들어온다 — 줄바꿈이 낱말 가운데를 끊지 않게 keep-all 로 둔다. */}
<p className="mt-3 text-[14.5px] leading-[1.8] break-keep opacity-90">{item.body}</p>
<div className="mt-4 border-t border-dashed pt-2.5" style={{borderColor: ITEM_BORDER}}>
<SourceLine source={item.source} verified={item.verified} />
</div>
</div>
</article>
))}
</Rail>
</ItemSection>
);
}

View File

@ -1,8 +1,8 @@
/**
* 군산 이야기 — 레트로 아이템 다섯을 한 섹션에 묶고 탭으로 가른다.
* 지역 이야기 — 레트로 아이템 다섯을 한 섹션에 묶고 탭으로 가른다.
*
* ★ 왜 묶나 (2026-09-04, 사장님 요청)
* 가요 다방·오늘의 한 장·인물 열전·시간의 골목·오늘의 엽서가 각각 섹션으로 서 있었다.
* 가요 다방·인물 열전·시간의 골목·지역 읽기·오늘의 엽서가 각각 섹션으로 서 있었다.
* 실측 390px 에서 다섯이 합쳐 3,470px — 전체의 17% 이고, 예약하러 온 손님은 이 구간을
* 다섯 화면 넘게 밀어야 아래(주변·FAQ)에 닿았다. 성격이 같은 것들이니 한 자리에 묶고
* **한 번에 하나만** 편다.
@ -17,6 +17,7 @@ import {sectionItems, sectionName} from '@site/lib/derive';
import {SongsSection} from './SongsSection';
import {PeopleSection} from './PeopleSection';
import {ChronicleSection} from './ChronicleSection';
import {ReadingSection} from './ReadingSection';
import {PostcardSection} from './PostcardSection';
import {ITEM_BORDER, ITEM_INK, ITEM_INVERSE_INK} from './common';
@ -25,20 +26,28 @@ import {ITEM_BORDER, ITEM_INK, ITEM_INVERSE_INK} from './common';
* 탭은 데이터가 있는 것만 선다 — 사장님이 아이템 하나를 끄면 문장이 곧바로 거짓말이 된다.
* 실제로 '가요 다방'과 '오늘의 한 장'을 뺐더니 셋인데 "다섯 갈래"라고 적혀 있었다.
*/
const COUNT_WORD: Record<number, string> = {1: '한', 2: '두', 3: '세', 4: '네', 5: '다섯'};
const COUNT_WORD: Record<number, string> = {
1: '한', 2: '두', 3: '세', 4: '네', 5: '다섯', 6: '여섯', 7: '일곱',
};
const TABS = [
/* ★ '읽기' 만 라벨에 지명이 든다 — 나머지 넷은 어느 도시에서도 같은 이름이지만
이건 "그 도시를 읽는다" 가 이름의 뜻이라 지명을 빼면 무엇을 읽는지가 사라진다.
지명을 코드에 박지 않는다(`군산 읽기` 는 시연본 한 곳의 값이다). */
const tabsOf = (region: string) =>
[
{id: 'songs', label: '가요 다방', Component: SongsSection},
{id: 'people', label: '인물 열전', Component: PeopleSection},
{id: 'chronicle', label: '시간의 골목', Component: ChronicleSection},
{id: 'reading', label: `${region} 읽기`, Component: ReadingSection},
{id: 'postcard', label: '오늘의 엽서', Component: PostcardSection},
] as const;
] as const;
export function StorySection() {
const payload = useSite();
const title = sectionName(payload, 'story', `${payload.place.addressLocality ?? '지역'} 이야기`);
const region = payload.place.addressLocality ?? '지역';
const title = sectionName(payload, 'story', `${region} 이야기`);
// 데이터가 있는 것만 탭이 된다 — 눌러서 빈 화면을 보게 하지 않는다.
const tabs = TABS.filter((tab) => sectionItems(payload, tab.id).items.length > 0);
const tabs = tabsOf(region).filter((tab) => sectionItems(payload, tab.id).items.length > 0);
const [active, setActive] = useState(0);
if (tabs.length === 0) return null;

View File

@ -8,6 +8,7 @@ import {SongsSection} from './SongsSection';
import {DailySection} from './DailySection';
import {PeopleSection} from './PeopleSection';
import {ChronicleSection} from './ChronicleSection';
import {ReadingSection} from './ReadingSection';
import {PostcardSection} from './PostcardSection';
import {QuizSection} from './QuizSection';
import {ItinerarySection} from './ItinerarySection';
@ -20,12 +21,13 @@ export const ITEM_SECTIONS: Record<string, () => React.ReactElement | null> = {
daily: DailySection,
people: PeopleSection,
chronicle: ChronicleSection,
reading: ReadingSection,
postcard: PostcardSection,
quiz: QuizSection,
itinerary: ItinerarySection,
video: VideoSection,
// 인스타에 올라오는 그때그때의 행사. 사장님이 옮겨 적고 원문 주소를 단다.
event: EventSection,
// 레트로 다섯(가요다방·오늘의한장·인물열전·시간의골목·엽서)을 탭으로 묶은 자리.
// 레트로 아이템(가요다방·인물열전·시간의골목·지역읽기·엽서)을 탭으로 묶은 자리.
story: StorySection,
};

View File

@ -0,0 +1,62 @@
import {expect, it} from 'vitest';
import {renderToStaticMarkup} from 'react-dom/server';
import type {ReadingItem} from '@o2o/shared';
import {SiteProvider} from '@site/lib/site-context';
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
import {ReadingSection} from './ReadingSection';
import {StorySection} from './StorySection';
function withReading(items: ReadingItem[]) {
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
payload.local.story = {...(payload.local.story ?? {}), reading: items};
return payload;
}
const ITEMS: ReadingItem[] = Array.from({length: 9}, (_, i) => ({
group: '문학',
title: `꼭지 ${i}`,
body: `본문 ${i}`,
source: {name: '네이버에서 찾아보기', url: `https://search.naver.com/search.naver?query=${i}`},
}));
it('구운 HTML 은 앞에서부터 여섯 꼭지다 — 섞기는 붙은 뒤에 한다', () => {
// 서버와 첫 렌더가 달라지면 하이드레이션이 깨진다. 무작위는 useEffect 뒤에만 돈다.
const html = renderToStaticMarkup(
<SiteProvider payload={withReading(ITEMS)}>
<ReadingSection />
</SiteProvider>,
);
expect(html).toContain('꼭지 0');
expect(html).toContain('꼭지 5');
expect(html).not.toContain('꼭지 6');
});
it('꼭지가 없으면 섹션 자체가 없다', () => {
const html = renderToStaticMarkup(
<SiteProvider payload={withReading([])}>
<ReadingSection />
</SiteProvider>,
);
expect(html).toBe('');
});
it('출처 링크를 그대로 단다 — 검색 링크는 서버가 만든 값이다', () => {
const html = renderToStaticMarkup(
<SiteProvider payload={withReading(ITEMS.slice(0, 1))}>
<ReadingSection />
</SiteProvider>,
);
expect(html).toContain('search.naver.com');
expect(html).toContain('네이버에서 찾아보기');
});
it("'지역 이야기' 탭에 지명이 든 이름으로 선다 — 군산을 코드에 박지 않는다", () => {
const payload = withReading(ITEMS);
const region = payload.place.addressLocality;
const html = renderToStaticMarkup(
<SiteProvider payload={payload}>
<StorySection />
</SiteProvider>,
);
expect(html).toContain(`${region} 읽기`);
});