[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:
parent
a1a416ca4a
commit
6b9e01d876
45
AGENTS.md
45
AGENTS.md
@ -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 에 구워진다** — 호스트를 바꾸면 프리렌더
|
||||
|
||||
@ -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` 안에 있다.
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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}: 출처가 없다")
|
||||
|
||||
@ -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": "오늘의 엽서",
|
||||
|
||||
@ -140,7 +140,7 @@ _DEFAULT_THEME = {
|
||||
# 수집이 다 됐어도 그 섹션이 아예 안 나온다 — 저장값이 없는 사업장에는 이 표가 곧 발행본이다.
|
||||
# ★ "이용 규정"은 뺐다 (2026-09-09) — 발행본에 그 섹션이 없다. 체크인·취소·취사·
|
||||
# 반려동물 줄은 기본 정보 안에서 규정 덩이로 묶여 나간다(EssentialInfoSection).
|
||||
# ★ 가요·일력·인물·연표·엽서는 여기 넣지 않는다. 이 표의 항목은 전부 켜서 나가는데
|
||||
# ★ 가요·일력·인물·연표·읽기·엽서는 여기 넣지 않는다. 이 표의 항목은 전부 켜서 나가는데
|
||||
# (`_sections`), 그것들은 '지역 이야기'(story) 탭 **안에서** 그려지는 것이라
|
||||
# 켜면 탭 밖에 한 번 더 선다. story 하나만 두면 데이터가 있는 것만 탭이 된다.
|
||||
# ★ 퀴즈(quiz)도 넣지 않지만 사정이 다르다 — 탭이 아니라 **독립 섹션**이라
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 원문
|
||||
|
||||
@ -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: '오늘의 엽서',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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: '오늘의 엽서',
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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**
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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: '엽서',
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
|
||||
96
solution/site/src/sections/items/ReadingSection.tsx
Normal file
96
solution/site/src/sections/items/ReadingSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 = [
|
||||
{id: 'songs', label: '가요 다방', Component: SongsSection},
|
||||
{id: 'people', label: '인물 열전', Component: PeopleSection},
|
||||
{id: 'chronicle', label: '시간의 골목', Component: ChronicleSection},
|
||||
{id: 'postcard', label: '오늘의 엽서', Component: PostcardSection},
|
||||
] as const;
|
||||
/* ★ '읽기' 만 라벨에 지명이 든다 — 나머지 넷은 어느 도시에서도 같은 이름이지만
|
||||
이건 "그 도시를 읽는다" 가 이름의 뜻이라 지명을 빼면 무엇을 읽는지가 사라진다.
|
||||
지명을 코드에 박지 않는다(`군산 읽기` 는 시연본 한 곳의 값이다). */
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
62
solution/site/src/sections/items/reading.test.tsx
Normal file
62
solution/site/src/sections/items/reading.test.tsx
Normal 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} 읽기`);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user