Merge branch 'feature/variety-item' into main

This commit is contained in:
Mina Choi 2026-09-02 15:17:24 +09:00
commit 1bcee68432
18 changed files with 416 additions and 20 deletions

View File

@ -61,6 +61,19 @@ Step2 의 토큰 없을 때 우회로. 되돌리려면 `app/router.tsx` 의 `Req
---
## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍
**왜**
에디터의 소개 섹션 본문은 `sites.theme` 에 저장됐지만 발행 payload 경계에서 버려졌고,
프리렌더도 고유 콘텐츠로 세지 않았다. 사장님이 소개를 써도 발행 화면은 0건이라며 거부했다.
**한 일**
- `SectionSetting.body` 계약을 추가하고 저장값을 payload 까지 전달
- 소개 본문을 발행 HTML에 표시하고, 켜진 소개 섹션의 8자 이상 본문만 고유 콘텐츠로 계수
- 고유 콘텐츠 0건과 JSON-LD 불일치, 계수 실패를 서로 다른 발행 사유로 분리
**검증** — 직접 입력 소개문만 있는 발행 경로 회귀 테스트 추가.
## 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
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 [])
for unit in payload.get("units") or []:
facts.extend(unit.get("facts") or [])

View File

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

View File

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

View File

@ -200,7 +200,10 @@ async def run_build(job: dict) -> dict:
)
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 []
result["unique_content_count"] = unique_count
result["mismatches"] = mismatches[:20]
@ -211,14 +214,25 @@ async def run_build(job: dict) -> dict:
# 대개 게이트 사유(고유 콘텐츠 0건·구조화 데이터 불일치)이기 때문이다.
# 여기서 사유를 정확히 골라야 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()}
if not gate.passed:
return await _fail(f"{gate.reason.name}: {gate.as_log()}", gate, stamp)
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)
# DB 발행 상태를 바꾸기 전에 정적 파일을 외부 저장소에 올린다.

View File

@ -63,11 +63,20 @@ def check_required_fields(category: PlaceCategory, facts: list) -> GateResult:
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이면
같은 템플릿 대량 생성으로 보인다. 그건 스팸 판정 대상이고, 판정되면 사이트가 통째로 무의미해진다."""
같은 템플릿 대량 생성으로 보인다. 그건 스팸 판정 대상이고, 판정되면 사이트가 통째로 무의미해진다.
★ None 은 '0건' 이 아니라 '재지 못했다' 다. 렌더러가 디스크·번들 문제로 죽으면 계수가
없는 채로 보고서가 온다 — 그걸 0 으로 읽으면 디스크 오류에 NO_UNIQUE_CONTENT 라는
엉뚱한 사유가 붙는다. 판정하지 않고 통과시키고, 진짜 사유는 report.error 가 말한다."""
if unique_content_count is None:
return GateResult(True)
if unique_content_count <= 0:
return GateResult(False, PublishRejectReason.NO_UNIQUE_CONTENT, {"unique_content_count": unique_content_count})
return GateResult(True)
@ -83,11 +92,17 @@ def check_jsonld_matches(mismatches: list) -> GateResult:
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 불일치(거짓).
★ unique_content_count 는 받되 여기서 막지 않는다 — 얇은 콘텐츠는 '틀린 것' 이 아니다
(test_evaluate_does_not_block_thin_content). 실제로 페이지 쓰기를 거부하는 쪽은
렌더러이고, 그 사유는 build_service 가 check_unique_content 로 되짚어 라벨을 붙인다.
★ 업종 필수 항목 누락은 **막지 않는다**(2026-08-27 결정).
막아야 할 것은 "틀린 정보가 나가는 것"이지 "정보가 덜 찬 것"이 아니다.
영업시간이 비어 있어도 주소·전화가 확인된 페이지는 그 자체로 쓸모가 있고,

View File

@ -363,6 +363,15 @@ def _sections(saved_sections, default_spec) -> list:
variant_id = _text(item.get("variantId"))
if 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)
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
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):
"""검증: 취소 규정 없이 빌드한다.

View File

@ -79,6 +79,12 @@ def test_zero_unique_content_blocks_publish():
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():
"""검증: 구조화 데이터 값이 화면 값과 다르다.
기대결과: ★ 규칙 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
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():
"""검증: 저장된 색이 일부 키만 담고 있을 때.
기대결과: 빠진 자리는 업종 기본이 메운다 — 렌더러 타입이 6개를 모두 요구하므로

View File

@ -103,7 +103,7 @@ function templatesFor(
fontStyle: '옛 간판체',
look: LOOK.retro,
// 이 템플릿이 팔려는 게 바로 이 아이템들이다.
defaultSectionTypes: ['songs', 'daily', 'course'],
defaultSectionTypes: ['songs', 'daily', 'course', 'schedule'],
},
];
}

View File

@ -55,6 +55,26 @@ export interface CourseItem {
source?: DataSource;
}
export interface ScheduleSlot {
/** 24시간 표기 "09:30". 정렬·플립 시각 표시가 이 값을 그대로 쓴다. */
time: string;
title: string;
place?: string;
minutes?: number;
note?: string;
searchQuery?: string;
}
export interface ScheduleItem {
name: string;
/** 누구를 위한 하루인가("혼자 온 손님" · "아이와 함께"). 고르는 기준이 된다. */
audience?: string;
season?: string;
slots?: ScheduleSlot[];
verified?: DataVerified;
source?: DataSource;
}
export interface SectionDataSpec {
/** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */
kind: string;
@ -302,6 +322,56 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
· minutes 는 도보 기준이다. 차로만 갈 수 있으면 note 에 "차로 이동" 이라고 적는다.
· 영업시간·요금은 넣지 않는다. 바뀌면 손님이 헛걸음한다.`,
},
schedule: {
kind: 'schedule',
label: '여행 스케줄',
requiredKey: 'name',
sample: JSON.stringify(
{
kind: 'schedule',
version: 1,
title: '여행 스케줄',
items: [
{
name: '비 오는 날의 하루',
audience: '혼자 온 손님',
season: '장마',
slots: [
{time: '09:00', title: '늦은 아침', place: '스테이 머뭄', minutes: 60, note: '창가 자리에서 비 소리를 먼저 듣습니다.'},
{time: '10:30', title: '실내로 피신', place: '군산근대역사박물관', minutes: 90, note: '항구 도시가 어떻게 만들어졌는지 한 바퀴.', searchQuery: '군산근대역사박물관'},
{time: '12:30', title: '점심', place: '한일옥', minutes: 60, note: '무국 한 그릇으로 몸을 데웁니다.', searchQuery: '군산 한일옥'},
{time: '14:00', title: '책과 커피', place: '마리서사', minutes: 120, note: '비 그칠 때까지 앉아 있기 좋은 곳입니다.', searchQuery: '군산 마리서사'},
{time: '17:00', title: '해 질 무렵 산책', place: '경암동 철길마을', minutes: 60, note: '비 온 뒤 철길에 물이 고여 하늘이 두 번 보입니다.', searchQuery: '군산 경암동 철길마을'},
],
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[업소]에 묵거나 들른 손님이 [지역]에서 하루를 어떻게 보내면 좋을지 여행 스케줄을 2~3개 만든다.
스케줄마다 시간대 5~7개. 아침부터 저녁까지 시각 순서로 배열한다.
스케줄은 서로 성격이 달라야 한다 — 날씨(비 오는 날)·동행(아이와 함께)·계절 중 하나로 가른다.
[스키마]
{ "kind":"schedule", "version":1, "title":"여행 스케줄", "items":[
{ "name":"스케줄 이름", "audience":"누구를 위한 하루", "season":"계절·날씨",
"slots":[ {"time":"09:00","title":"무엇을 하나","place":"장소",
"minutes":60,"note":"한 문장","searchQuery":"지도 검색어"} ],
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· time 은 24시간 "HH:MM" 로만 적는다. "오전 9시" 처럼 쓰지 않는다.
· 그 장소의 영업시간·휴무일을 안다고 가정하지 않는다. 바뀌면 손님이 헛걸음한다.
· 첫 칸은 [업소]에서 시작하고, 이동은 걸어서 또는 대중교통으로 갈 수 있는 범위로 짠다.
· 장소에 url 을 넣지 않는다. searchQuery 만 넣는다.
· 예약이 필요한 곳은 note 에 "예약 필요" 라고만 적고 연락처는 쓰지 않는다.`,
},
};
export function dataSpecFor(sectionType: string): SectionDataSpec | undefined {

View File

@ -67,6 +67,7 @@ import {ExhibitionNotice} from './variants/exhibition/ExhibitionNotice';
import {SongsTurntable} from './variants/songs/SongsTurntable';
import {DailyCalendar} from './variants/daily/DailyCalendar';
import {CourseTickets} from './variants/course/CourseTickets';
import {ScheduleTimetable} from './variants/schedule/ScheduleTimetable';
export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
hero: [
@ -435,6 +436,17 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
},
],
schedule: [
{
id: 'schedule.timetable',
name: '대합실 시간표',
description: '칸 하나가 시간대 하나. 검은 플립보드에 시각이 먼저 뜬다.',
thumb: 'carousel',
Component: ScheduleTimetable,
isDefault: true,
},
],
exhibition: [
{
id: 'exhibition.gallery',

View File

@ -0,0 +1,174 @@
/**
* 여행 스케줄 — 대합실 시간표 캐러셀.
*
* 칸 하나가 시간대 하나다. 위쪽 검은 판은 역 대합실의 플립보드(가운데 접힘선)를 그대로 옮긴 것 —
* 시각이 먼저 읽히고 무엇을 하는지가 뒤따라야 시간표로 읽힌다.
* ★ 승차권(course)과 일부러 다르게 짰다. 저쪽은 '어디를 도는가'(순번), 여기는 '언제 무엇을'(시각)이다.
* ★ 링크는 만들지 않는다. searchQuery 만 보여준다 — 지어낸 주소를 링크하지 않는 이 레포의 규약.
*/
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type ScheduleItem, type ScheduleSlot} from '../../dataSpec';
import {
CarouselNav,
ParseError,
PasteHint,
RETRO_BODY,
RETRO_INK,
RETRO_LINE,
RETRO_PAPER_LIGHT,
RETRO_RED,
RETRO_SIGN,
SourceLine,
useCarousel,
} from '../retro/common';
import '../retro/retro.css';
/** "09:30" → 9시 30분. 형식이 어긋나면 원문을 그대로 보여준다(지어내지 않는다). */
function splitTime(time: string): {head: string; tail?: string} {
const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
if (!match) return {head: time.trim()};
return {head: match[1].padStart(2, '0'), tail: match[2]};
}
function Slot({slot, isLast}: {slot: ScheduleSlot; isLast: boolean}) {
const {head, tail} = splitTime(slot.time ?? '');
return (
<div className="flex shrink-0 snap-center items-stretch">
<div
className="w-[228px] border shadow-[4px_4px_0_rgba(27,26,21,.13)]"
style={{backgroundColor: RETRO_PAPER_LIGHT, borderColor: RETRO_INK}}
>
{/* 플립보드 — 가운데 접힘선이 이 판을 시계로 만든다 */}
<div className="relative px-4 py-3 text-center" style={{backgroundColor: RETRO_INK}}>
<span
className="inline-flex items-baseline gap-1 leading-none text-[#f2ebd9]"
style={{fontFamily: RETRO_SIGN, fontSize: 30}}
>
{head}
{tail && <span className="text-[#c0b493]">:{tail}</span>}
</span>
<i
aria-hidden
className="pointer-events-none absolute inset-x-0 top-1/2 border-t"
style={{borderColor: 'rgba(242,235,217,.22)'}}
/>
</div>
<div className="space-y-2 px-4 pb-3 pt-3.5">
<h4 className="text-base font-bold text-stone-900" style={{fontFamily: RETRO_BODY}}>
{slot.title}
</h4>
{slot.place && (
<p className="text-[12px] font-semibold" style={{fontFamily: RETRO_BODY, color: RETRO_RED}}>
{slot.place}
</p>
)}
{slot.note && (
<p className="text-[13px] leading-relaxed text-stone-600" style={{fontFamily: RETRO_BODY}}>
{slot.note}
</p>
)}
</div>
<div
className="flex justify-between gap-2 border-t border-dashed px-4 py-2 text-[10px] text-stone-500"
style={{borderColor: RETRO_LINE}}
>
<span>{slot.minutes ? `${slot.minutes}분 머묾` : '머무는 시간 미정'}</span>
{slot.searchQuery && <span className="truncate">지도 검색 · {slot.searchQuery}</span>}
</div>
</div>
{/* 칸과 칸 사이의 시간 — 점선이 이어져야 '흐른다'로 읽힌다 */}
{!isLast && (
<div aria-hidden className="flex w-8 items-center justify-center">
<i className="block h-px w-full border-t border-dashed" style={{borderColor: RETRO_LINE}} />
</div>
)}
</div>
);
}
function ScheduleRow({schedule}: {schedule: ScheduleItem}) {
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
const slots = schedule.slots ?? [];
const span =
slots.length > 1 ? `${slots[0]?.time ?? ''}–${slots[slots.length - 1]?.time ?? ''}` : undefined;
return (
<div className="space-y-2.5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="flex flex-wrap items-baseline gap-2.5">
<h3 className="text-lg text-stone-900" style={{fontFamily: RETRO_SIGN}}>
{schedule.name}
</h3>
<span className="text-[11px] text-stone-500">
{[schedule.audience, schedule.season, span, `${slots.length}칸`]
.filter(Boolean)
.join(' · ')}
</span>
</div>
{slots.length > 2 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label={schedule.name} />
)}
</div>
{slots.length === 0 ? (
<p
className="border border-dashed px-4 py-5 text-center text-[11px] text-stone-500"
style={{borderColor: RETRO_LINE}}
>
시간대가 아직 없습니다. JSON 의 slots 배열을 채워 주세요.
</p>
) : (
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory overflow-x-auto pb-3">
{slots.map((slot, index) => (
<Slot
key={`${slot.time}-${slot.title}-${index}`}
slot={slot}
isLast={index === slots.length - 1}
/>
))}
</div>
)}
<SourceLine source={schedule.source} verified={schedule.verified} />
</div>
);
}
export function ScheduleTimetable(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<ScheduleItem>(section.type, section.data);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl text-stone-900 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm text-stone-600" style={{fontFamily: RETRO_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="여행 스케줄" />
) : (
<div className="space-y-8">
{parsed.items.map((schedule, index) => (
<ScheduleRow key={`${schedule.name}-${index}`} schedule={schedule} />
))}
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -249,4 +249,13 @@ export interface SectionSetting {
* 렌더러가 모르는 키를 만나도 마찬가지다 — 서버는 값을 해석하지 않고 그대로 싣는다.
*/
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 로
* 라우트를 갈라 사이트 하나에 30개 안팎의 HTML 을 구웠다.
@ -321,10 +339,7 @@ function prerenderSite(input: SitePayload, outRoot: string, assets: ReturnType<t
* 페이지가 디스크에 나가 있으면 크롤러가 그걸 읽는다.
*/
if (uniqueContentCount <= 0) {
throw new VerifyError(
['고유 콘텐츠가 0건이다 — 이 가게에만 있는 내용이 없으면 발행하지 않는다'],
uniqueContentCount,
);
throw new NoUniqueContentError(uniqueContentCount);
}
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;
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 은 전부 템플릿 값이라 가게를 구분하지 못한다.
for (const fact of [...payload.facts, ...payload.units.flatMap((unit) => unit.facts)]) {
if (fact.type === 'text' && long(fact.value)) count += 1;
@ -585,11 +605,14 @@ function main() {
} catch (ex) {
// ★ 한 사이트가 깨졌다고 나머지를 못 굽게 두지 않는다. 실패는 보고서로 남긴다 —
// 조용히 넘어가면 "발행했는데 페이지가 없다"가 다시 반복된다.
// ★ 계수를 못 잰 실패(디스크·번들·payload 파손)는 null 로 남긴다. 0 으로 적으면
// 백엔드가 "고유 콘텐츠 0건" 으로 읽어 또 엉뚱한 사유를 붙인다.
const counted = ex instanceof VerifyError || ex instanceof NoUniqueContentError ? ex : null;
fail(
entry,
ex instanceof Error ? ex.message : String(ex),
ex instanceof VerifyError ? ex.mismatches : [],
ex instanceof VerifyError ? ex.uniqueContentCount : null,
counted?.mismatches ?? [],
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;
}
/** 사장님이 에디터에서 직접 쓴 섹션 본문. 빈 줄을 문단 경계로 쓴다. */
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) {
return UNIT_SPEC[payload.place.category];
}

View File

@ -1,10 +1,14 @@
import {useSite} from '@/lib/site-context';
import {galleryImages} from '@/lib/derive';
import {galleryImages, sectionBody} from '@/lib/derive';
export function AboutSection() {
const payload = useSite();
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);
@ -46,7 +50,7 @@ export function AboutSection() {
</h2>
<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>
))}
</div>