[fix] solution/backend,shared,site: 에디터에 쓴 소개문이 발행에서 사라지던 구멍 — 계약에 body 추가

사장님이 소개 섹션에 본문을 써도 발행이 "고유 콘텐츠 0건"으로 거부됐다.
본문은 sites.theme 에 저장은 되는데 payload 경계에서 버려졌다 — _sections() 가
저장값에서 id·name·enabled·locked·variantId 다섯 개만 꺼내 새로 만들었다.
그래서 발행본에 안 나오고, 계수에도 안 잡혔다.

거부 문구도 틀렸다. 렌더러가 '고유 콘텐츠 0건'을 JSON-LD 불일치와 같은 VerifyError 의
mismatches 에 실어 던져서, 백엔드가 JSONLD_MISMATCH 로 판정하고 화면에는
"구조화 데이터와 화면 값이 다릅니다" 가 떴다. 구조화 데이터는 멀쩡했다.

- shared/site-payload: SectionSetting.body 추가 — variantId 와 같은 사연
- backend/site_payload: 저장된 body 를 payload 까지 실어 보낸다
- site/derive,AboutSection: 직접 쓴 본문을 그린다. 없으면 intro fact 로 떨어진다
- site/prerender: 켜진 소개 섹션의 8자 이상 본문을 고유 콘텐츠로 계수
- site/prerender: NoUniqueContentError 분리 — mismatches 를 비워 라벨이 안 섞이게.
  계수를 못 잰 실패는 null 로 보고한다(0 으로 적으면 디스크 오류가 같은 사유를 받는다)
- backend/build_service,publish_gate: 렌더 실패가 0건이면 NO_UNIQUE_CONTENT 라벨을 붙인다.
  evaluate() 는 안 건드렸다 — 얇은 콘텐츠로 발행을 막지 않기로 한 결정 그대로다
- backend/router: theme API 설명에 body 반영

테스트 8 failed / 511 passed. 실패 8건은 변경 전(508 passed)과 동일한 기존 실패다
(test_default_sections_match_the_editor 의 solution/front 경로 오타 등).
tsc·site·shared 통과. 실물 검증: 본문만 있는 payload → ok=true, uniqueContentCount=1,
발행 HTML 에 문장 포함. 같은 payload 에서 본문을 빼면 0건으로 거부.
This commit is contained in:
Mina Choi 2026-09-02 12:00:16 +09:00
parent 22b7623aeb
commit 8410380769
14 changed files with 159 additions and 19 deletions

View File

@ -5,6 +5,19 @@
--- ---
## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍
**왜**
에디터의 소개 섹션 본문은 `sites.theme` 에 저장됐지만 발행 payload 경계에서 버려졌고,
프리렌더도 고유 콘텐츠로 세지 않았다. 사장님이 소개를 써도 발행 화면은 0건이라며 거부했다.
**한 일**
- `SectionSetting.body` 계약을 추가하고 저장값을 payload 까지 전달
- 소개 본문을 발행 HTML에 표시하고, 켜진 소개 섹션의 8자 이상 본문만 고유 콘텐츠로 계수
- 고유 콘텐츠 0건과 JSON-LD 불일치, 계수 실패를 서로 다른 발행 사유로 분리
**검증** — 직접 입력 소개문만 있는 발행 경로 회귀 테스트 추가.
## 2026-09-02 — 템플릿이 색만 바꾸던 걸 끝냈다 (5개 → 3개) ## 2026-09-02 — 템플릿이 색만 바꾸던 걸 끝냈다 (5개 → 3개)
**왜** **왜**

View File

@ -201,6 +201,12 @@ def fake_renderer(monkeypatch, tmp_path_factory):
return len(str(value or "").strip()) >= MIN_UNIQUE_TEXT return len(str(value or "").strip()) >= MIN_UNIQUE_TEXT
count = 0 count = 0
intro = next(
(s for s in (payload.get("theme") or {}).get("sections") or [] if s.get("id") == "intro"),
None,
)
if intro and intro.get("enabled") and long(intro.get("body")):
count += 1
facts = list(payload.get("facts") or []) facts = list(payload.get("facts") or [])
for unit in payload.get("units") or []: for unit in payload.get("units") or []:
facts.extend(unit.get("facts") or []) facts.extend(unit.get("facts") or [])

View File

@ -88,8 +88,8 @@ class Req_SiteTheme(SiteProtocol):
★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다: ★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
{"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}} {"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}}
sections 는 {id, name, enabled, locked, variantId?} 의 목록이고 **배열 순서가 곧 섹션 순서**다 sections 는 {id, name, enabled, locked, variantId?, body?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
(별도 order 필드가 없다). variantId 는 고른 게 있을 때만 키가 붙는다. (별도 order 필드가 없다). variantId 와 직접 입력 본문 body 는 값이 있을 때만 키가 붙는다.
pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 — pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 —
서버는 배달부지 심판이 아니다. 서버는 배달부지 심판이 아니다.

View File

@ -106,7 +106,7 @@ async def set_template(
description="에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다" description="에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다"
"(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: " "(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: "
"{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"colorPaletteId\":\"...\"," "{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"colorPaletteId\":\"...\","
"\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\"}]}}. " "\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\",\"body\"}]}}. "
"★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). " "★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). "
"★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. " "★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. "
"직렬화 크기(64KB)만 막는다. " "직렬화 크기(64KB)만 막는다. "

View File

@ -200,7 +200,10 @@ async def run_build(job: dict) -> dict:
) )
mismatches = list(report.get("mismatches") or []) mismatches = list(report.get("mismatches") or [])
unique_count = report.get("uniqueContentCount") or 0 # ★ None(재지 못했다)과 0(재 봤더니 0건)을 뭉개지 않는다. 게이트는 raw 를 보고,
# 기록·화면에는 0 으로 떨어뜨린다. 뭉개면 디스크 오류가 NO_UNIQUE_CONTENT 로 둔갑한다.
unique_count_raw = report.get("uniqueContentCount")
unique_count = unique_count_raw or 0
jsonld = report.get("jsonld") or [] jsonld = report.get("jsonld") or []
result["unique_content_count"] = unique_count result["unique_content_count"] = unique_count
result["mismatches"] = mismatches[:20] result["mismatches"] = mismatches[:20]
@ -211,14 +214,25 @@ async def run_build(job: dict) -> dict:
# 대개 게이트 사유(고유 콘텐츠 0건·구조화 데이터 불일치)이기 때문이다. # 대개 게이트 사유(고유 콘텐츠 0건·구조화 데이터 불일치)이기 때문이다.
# 여기서 사유를 정확히 골라야 publish_logs 에 '무엇을 고쳐야 하는지' 가 남는다 — # 여기서 사유를 정확히 골라야 publish_logs 에 '무엇을 고쳐야 하는지' 가 남는다 —
# 전부 "렌더 실패"로 뭉뚱그리면 운영자가 손댈 곳을 알 수 없다. # 전부 "렌더 실패"로 뭉뚱그리면 운영자가 손댈 곳을 알 수 없다.
gate = publish_gate.evaluate(PlaceCategory(place.category), snapshot["facts"], unique_count, mismatches) gate = publish_gate.evaluate(
PlaceCategory(place.category), snapshot["facts"], unique_count_raw, mismatches
)
result["gate"] = {"passed": gate.passed, **gate.as_log()} result["gate"] = {"passed": gate.passed, **gate.as_log()}
if not gate.passed: if not gate.passed:
return await _fail(f"{gate.reason.name}: {gate.as_log()}", gate, stamp) return await _fail(f"{gate.reason.name}: {gate.as_log()}", gate, stamp)
if not report.get("ok"): if not report.get("ok"):
# 게이트로 설명되지 않는 실패(디스크·번들·payload 파손). 재시도가 의미 있는 쪽이다. # ★ 렌더러가 거부한 이유에 사유 코드를 붙인다. evaluate() 는 얇은 콘텐츠로 막지
# 않지만 렌더러는 스팸 판정을 피하려 페이지 쓰기를 거부한다 — 그 사유를 "렌더 실패"
# 로 뭉개면 화면이 NO_UNIQUE_CONTENT 문구를 못 고르고, 사장님은 손댈 곳을 모른다.
thin = publish_gate.check_unique_content(unique_count_raw)
if not thin.passed:
# 위에서 evaluate 결과로 채워 둔 gate 를 덮는다 — 화면(GateRejectCard)은 이 값으로
# 문구를 고르는데, passed=True 인 채로 두면 "서버 검수를 통과하지 못했습니다" 만 뜬다.
result["gate"] = {"passed": False, **thin.as_log()}
return await _fail(f"{thin.reason.name}: {thin.as_log()}", thin, stamp)
# 나머지는 게이트로 설명되지 않는 실패(디스크·번들·payload 파손). 재시도가 의미 있다.
return await _fail(str(report.get("error") or "렌더 실패"), None, stamp) return await _fail(str(report.get("error") or "렌더 실패"), None, stamp)
# DB 발행 상태를 바꾸기 전에 정적 파일을 외부 저장소에 올린다. # DB 발행 상태를 바꾸기 전에 정적 파일을 외부 저장소에 올린다.

View File

@ -63,11 +63,20 @@ def check_required_fields(category: PlaceCategory, facts: list) -> GateResult:
return GateResult(True) return GateResult(True)
def check_unique_content(unique_content_count: int) -> GateResult: def check_unique_content(unique_content_count: int | None) -> GateResult:
"""고유 콘텐츠 품질 진단용. 발행 게이트에서는 더 이상 호출하지 않는다. """렌더러가 '고유 콘텐츠 0건' 으로 거부했을 때 사유 코드를 붙이는 자리.
evaluate() 는 이걸 부르지 않는다(얇은 콘텐츠로 발행을 막지 않기로 했다).
부르는 곳은 build_service — 렌더 보고서가 실패로 왔을 때 그 이유를 되짚는다.
이 가게에만 있는 것(소개문·FAQ·객실 설명·사진 alt·템플릿 아닌 fact 값)이 0이면 이 가게에만 있는 것(소개문·FAQ·객실 설명·사진 alt·템플릿 아닌 fact 값)이 0이면
같은 템플릿 대량 생성으로 보인다. 그건 스팸 판정 대상이고, 판정되면 사이트가 통째로 무의미해진다.""" 같은 템플릿 대량 생성으로 보인다. 그건 스팸 판정 대상이고, 판정되면 사이트가 통째로 무의미해진다.
★ None 은 '0건' 이 아니라 '재지 못했다' 다. 렌더러가 디스크·번들 문제로 죽으면 계수가
없는 채로 보고서가 온다 — 그걸 0 으로 읽으면 디스크 오류에 NO_UNIQUE_CONTENT 라는
엉뚱한 사유가 붙는다. 판정하지 않고 통과시키고, 진짜 사유는 report.error 가 말한다."""
if unique_content_count is None:
return GateResult(True)
if unique_content_count <= 0: if unique_content_count <= 0:
return GateResult(False, PublishRejectReason.NO_UNIQUE_CONTENT, {"unique_content_count": unique_content_count}) return GateResult(False, PublishRejectReason.NO_UNIQUE_CONTENT, {"unique_content_count": unique_content_count})
return GateResult(True) return GateResult(True)
@ -83,11 +92,17 @@ def check_jsonld_matches(mismatches: list) -> GateResult:
return GateResult(True) return GateResult(True)
def evaluate(category: PlaceCategory, facts: list, unique_content_count: int, mismatches: list) -> GateResult: def evaluate(
category: PlaceCategory, facts: list, unique_content_count: int | None, mismatches: list
) -> GateResult:
"""게이트 전체. **처음 걸린 것에서 멈춘다** — 운영자가 하나씩 고치게 사유를 하나만 준다. """게이트 전체. **처음 걸린 것에서 멈춘다** — 운영자가 하나씩 고치게 사유를 하나만 준다.
순서는 심각도 순: 미검증(클레임) → JSON-LD 불일치(거짓). 순서는 심각도 순: 미검증(클레임) → JSON-LD 불일치(거짓).
★ unique_content_count 는 받되 여기서 막지 않는다 — 얇은 콘텐츠는 '틀린 것' 이 아니다
(test_evaluate_does_not_block_thin_content). 실제로 페이지 쓰기를 거부하는 쪽은
렌더러이고, 그 사유는 build_service 가 check_unique_content 로 되짚어 라벨을 붙인다.
★ 업종 필수 항목 누락은 **막지 않는다**(2026-08-27 결정). ★ 업종 필수 항목 누락은 **막지 않는다**(2026-08-27 결정).
막아야 할 것은 "틀린 정보가 나가는 것"이지 "정보가 덜 찬 것"이 아니다. 막아야 할 것은 "틀린 정보가 나가는 것"이지 "정보가 덜 찬 것"이 아니다.
영업시간이 비어 있어도 주소·전화가 확인된 페이지는 그 자체로 쓸모가 있고, 영업시간이 비어 있어도 주소·전화가 확인된 페이지는 그 자체로 쓸모가 있고,

View File

@ -363,6 +363,15 @@ def _sections(saved_sections, default_spec) -> list:
variant_id = _text(item.get("variantId")) variant_id = _text(item.get("variantId"))
if variant_id: if variant_id:
entry["variantId"] = variant_id entry["variantId"] = variant_id
# ★ 사장님이 에디터에 직접 쓴 섹션 본문. variantId 와 같은 이유로 그대로 싣는다 —
# 이 필드가 없던 동안 캔버스에 쓴 소개문은 payload 경계에서 통째로 버려졌다.
# 저장(sites.theme)은 되는데 발행본에는 안 나오고, 고유 콘텐츠로도 세지 않아
# "소개를 썼는데 발행이 고유 콘텐츠 0건으로 막힌다" 가 됐다.
# ★ fact 가 아니라 검증 대상이 아니다. 사장님이 자기 가게에 대해 쓴 자기 문장이고,
# 섹션 제목(name)이 이미 같은 경로로 나간다.
body = _text(item.get("body"))
if body:
entry["body"] = body
out.append(entry) out.append(entry)
for sid, label, locked in default_spec: for sid, label, locked in default_spec:

View File

@ -136,6 +136,29 @@ async def test_no_unique_content_blocks_publish(auth_headers, client):
assert r["gate"]["reason"] == PublishRejectReason.NO_UNIQUE_CONTENT.name assert r["gate"]["reason"] == PublishRejectReason.NO_UNIQUE_CONTENT.name
async def test_owner_written_intro_counts_as_unique_content(auth_headers, client):
"""검증: fact·FAQ·사진 설명은 없지만 사장님이 소개 섹션 본문을 직접 썼다.
기대결과: 실제 발행본에 표시되는 소개문 1건으로 계수되어 발행된다."""
h = await auth_headers("owner-intro")
pid = await _place(client, h, "직접소개펜션")
for k, v in {**REQUIRED, "cancel_policy": "X"}.items():
await client.post(f"/v1/place/{pid}/fact", headers=h, json={"key": k, "value": v})
await client.post(f"/v1/place/{pid}/site/theme", headers=h, json={"theme": {"sections": [{
"id": "intro", "name": "소개", "enabled": True, "locked": False,
"body": "하조대의 아침 바다를 조용히 바라볼 수 있는 작은 숙소입니다.",
}]}})
job_id = (await client.post(
f"/v1/place/{pid}/site/build", headers=h, json={"publish": True}
)).json()["job_id"]
await _run()
r = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"]["result"]
assert r["gate"]["passed"] is True
assert r["unique_content_count"] == 1
assert r["published"] is True
async def test_missing_required_field_warns_but_publishes(auth_headers, client, db_engine): async def test_missing_required_field_warns_but_publishes(auth_headers, client, db_engine):
"""검증: 취소 규정 없이 빌드한다. """검증: 취소 규정 없이 빌드한다.

View File

@ -79,6 +79,12 @@ def test_zero_unique_content_blocks_publish():
assert check_unique_content(1).passed is True assert check_unique_content(1).passed is True
def test_unmeasured_unique_content_is_not_zero():
"""검증: 렌더러가 계수를 못 재고(None) 실패했다.
기대결과: 통과 — 디스크·번들 오류에 NO_UNIQUE_CONTENT 라는 엉뚱한 사유가 붙으면 안 된다."""
assert check_unique_content(None).passed is True
def test_jsonld_mismatch_blocks_publish(): def test_jsonld_mismatch_blocks_publish():
"""검증: 구조화 데이터 값이 화면 값과 다르다. """검증: 구조화 데이터 값이 화면 값과 다르다.
기대결과: ★ 규칙 3 — JSONLD_MISMATCH 로 거부. 검색엔진에만 다른 말을 하면 안 된다.""" 기대결과: ★ 규칙 3 — JSONLD_MISMATCH 로 거부. 검색엔진에만 다른 말을 하면 안 된다."""

View File

@ -222,6 +222,18 @@ def test_owner_disabled_section_stays_disabled():
assert next(s for s in theme["sections"] if s["id"] == "photos")["enabled"] is False assert next(s for s in theme["sections"] if s["id"] == "photos")["enabled"] is False
def test_owner_written_section_body_reaches_publish_payload():
"""검증: 에디터에서 직접 쓴 소개 본문.
기대결과: 발행 payload 에 그대로 남는다 — 저장만 되고 경계에서 버려지면 발행본과 계수에 못 쓴다."""
theme = _payload_theme({"sections": [{
"id": "intro", "name": "소개", "enabled": True, "locked": False,
"body": "바다를 보며 조용히 쉬어가는 작은 숙소입니다.",
}]})
assert next(s for s in theme["sections"] if s["id"] == "intro")["body"] == (
"바다를 보며 조용히 쉬어가는 작은 숙소입니다."
)
def test_partial_colors_are_filled_from_category_default(): def test_partial_colors_are_filled_from_category_default():
"""검증: 저장된 색이 일부 키만 담고 있을 때. """검증: 저장된 색이 일부 키만 담고 있을 때.
기대결과: 빠진 자리는 업종 기본이 메운다 — 렌더러 타입이 6개를 모두 요구하므로 기대결과: 빠진 자리는 업종 기본이 메운다 — 렌더러 타입이 6개를 모두 요구하므로

View File

@ -249,4 +249,13 @@ export interface SectionSetting {
* 렌더러가 모르는 키를 만나도 마찬가지다 — 서버는 값을 해석하지 않고 그대로 싣는다. * 렌더러가 모르는 키를 만나도 마찬가지다 — 서버는 값을 해석하지 않고 그대로 싣는다.
*/ */
variantId?: string; variantId?: string;
/**
* 사장님이 에디터에서 직접 쓴 섹션 본문.
*
* ★ variantId 와 같은 사연이다 — 이 필드가 없던 동안 캔버스에 쓴 소개문은 payload 경계에서
* 버려졌다. 저장(sites.theme)은 되는데 발행본에 안 나왔고, 고유 콘텐츠로도 세지 않아
* "소개를 썼는데 고유 콘텐츠 0건으로 발행이 막힌다" 가 됐다.
* ★ 줄바꿈이 문단 구분이다. 렌더러가 빈 줄을 기준으로 <p> 를 나눈다.
*/
body?: string;
} }

View File

@ -192,6 +192,24 @@ class VerifyError extends Error {
} }
} }
/**
* 고유 콘텐츠 0건. **VerifyError 와 갈라 둔다.**
*
* ★ 예전에는 이 사유를 VerifyError 의 mismatches 에 실어 던졌다. 백엔드 게이트는
* mismatches 가 비지 않았다는 것만 보고 JSONLD_MISMATCH 로 판정했고, 사장님 화면에는
* "구조화 데이터와 화면 값이 다릅니다" 라는 **틀린 문구**가 떴다 — 구조화 데이터는
* 멀쩡했다. 사유가 다르면 예외도 갈라야 라벨이 안 섞인다.
*/
class NoUniqueContentError extends Error {
/** 백엔드가 JSONLD_MISMATCH 로 오인하지 않도록 늘 비어 있다. */
readonly mismatches: string[] = [];
constructor(readonly uniqueContentCount: number) {
super('고유 콘텐츠가 0건이다 — 이 가게에만 있는 내용이 없으면 발행하지 않는다');
this.name = 'NoUniqueContentError';
}
}
/** /**
* 사이트는 **한 장**이다(2026-08-31). 예전에는 홈·객실·객실상세·주변·오시는길·FAQ 로 * 사이트는 **한 장**이다(2026-08-31). 예전에는 홈·객실·객실상세·주변·오시는길·FAQ 로
* 라우트를 갈라 사이트 하나에 30개 안팎의 HTML 을 구웠다. * 라우트를 갈라 사이트 하나에 30개 안팎의 HTML 을 구웠다.
@ -321,10 +339,7 @@ function prerenderSite(input: SitePayload, outRoot: string, assets: ReturnType<t
* 페이지가 디스크에 나가 있으면 크롤러가 그걸 읽는다. * 페이지가 디스크에 나가 있으면 크롤러가 그걸 읽는다.
*/ */
if (uniqueContentCount <= 0) { if (uniqueContentCount <= 0) {
throw new VerifyError( throw new NoUniqueContentError(uniqueContentCount);
['고유 콘텐츠가 0건이다 — 이 가게에만 있는 내용이 없으면 발행하지 않는다'],
uniqueContentCount,
);
} }
writeFile(siteDir, 'index.html', html); writeFile(siteDir, 'index.html', html);
@ -377,6 +392,11 @@ function countUniqueContent(payload: SitePayload): number {
const long = (value: unknown) => String(value ?? '').trim().length >= MIN_UNIQUE_TEXT; const long = (value: unknown) => String(value ?? '').trim().length >= MIN_UNIQUE_TEXT;
let count = 0; let count = 0;
// 소개 섹션의 직접 입력 본문은 실제 AboutSection 에 표시되는 가게 고유 문장이다.
// ★ enabled 를 함께 본다. 꺼서 페이지에 없는 문장까지 세면 빈 페이지가 게이트를 통과한다.
const intro = payload.theme.sections.find((section) => section.id === 'intro');
if (intro?.enabled && long(intro.body)) count += 1;
// 문장형 fact 만 센다 — bool·number·time 은 전부 템플릿 값이라 가게를 구분하지 못한다. // 문장형 fact 만 센다 — bool·number·time 은 전부 템플릿 값이라 가게를 구분하지 못한다.
for (const fact of [...payload.facts, ...payload.units.flatMap((unit) => unit.facts)]) { for (const fact of [...payload.facts, ...payload.units.flatMap((unit) => unit.facts)]) {
if (fact.type === 'text' && long(fact.value)) count += 1; if (fact.type === 'text' && long(fact.value)) count += 1;
@ -585,11 +605,14 @@ function main() {
} catch (ex) { } catch (ex) {
// ★ 한 사이트가 깨졌다고 나머지를 못 굽게 두지 않는다. 실패는 보고서로 남긴다 — // ★ 한 사이트가 깨졌다고 나머지를 못 굽게 두지 않는다. 실패는 보고서로 남긴다 —
// 조용히 넘어가면 "발행했는데 페이지가 없다"가 다시 반복된다. // 조용히 넘어가면 "발행했는데 페이지가 없다"가 다시 반복된다.
// ★ 계수를 못 잰 실패(디스크·번들·payload 파손)는 null 로 남긴다. 0 으로 적으면
// 백엔드가 "고유 콘텐츠 0건" 으로 읽어 또 엉뚱한 사유를 붙인다.
const counted = ex instanceof VerifyError || ex instanceof NoUniqueContentError ? ex : null;
fail( fail(
entry, entry,
ex instanceof Error ? ex.message : String(ex), ex instanceof Error ? ex.message : String(ex),
ex instanceof VerifyError ? ex.mismatches : [], counted?.mismatches ?? [],
ex instanceof VerifyError ? ex.uniqueContentCount : null, counted?.uniqueContentCount ?? null,
); );
} }
} }

View File

@ -121,6 +121,12 @@ export function isSectionEnabled(payload: SitePayload, id: string): boolean {
return payload.theme.sections.find((section) => section.id === id)?.enabled ?? false; return payload.theme.sections.find((section) => section.id === id)?.enabled ?? false;
} }
/** 사장님이 에디터에서 직접 쓴 섹션 본문. 빈 줄을 문단 경계로 쓴다. */
export function sectionBody(payload: SitePayload, id: string): string[] {
const body = payload.theme.sections.find((section) => section.id === id)?.body;
return body?.split(/\n\s*\n/).map((paragraph) => paragraph.trim()).filter(Boolean) ?? [];
}
export function unitSpec(payload: SitePayload) { export function unitSpec(payload: SitePayload) {
return UNIT_SPEC[payload.place.category]; return UNIT_SPEC[payload.place.category];
} }

View File

@ -1,10 +1,14 @@
import {useSite} from '@/lib/site-context'; import {useSite} from '@/lib/site-context';
import {galleryImages} from '@/lib/derive'; import {galleryImages, sectionBody} from '@/lib/derive';
export function AboutSection() { export function AboutSection() {
const payload = useSite(); const payload = useSite();
const {narrative, place} = payload; const {narrative, place} = payload;
if (narrative.about.length === 0) return null; // 직접 입력한 소개가 있으면 그 문장이 이긴다. 없을 때만 수집·승인된 intro fact 로 떨어진다.
// ★ 에디터 본문을 계수만 하고 화면에 안 내면 빈 페이지가 발행 게이트를 통과한다.
const paragraphs = sectionBody(payload, 'intro');
const about = paragraphs.length > 0 ? paragraphs : narrative.about;
if (about.length === 0) return null;
// 대표가 아닌 사진 중 첫 장. 히어로와 같은 사진이 두 번 나오지 않게. // 대표가 아닌 사진 중 첫 장. 히어로와 같은 사진이 두 번 나오지 않게.
const image = galleryImages(payload).find((m) => !m.isPrimary); const image = galleryImages(payload).find((m) => !m.isPrimary);
@ -46,7 +50,7 @@ export function AboutSection() {
</h2> </h2>
<div className="space-y-3 text-sm leading-relaxed opacity-80"> <div className="space-y-3 text-sm leading-relaxed opacity-80">
{narrative.about.map((paragraph, index) => ( {about.map((paragraph, index) => (
<p key={index}>{paragraph}</p> <p key={index}>{paragraph}</p>
))} ))}
</div> </div>