diff --git a/app/modules/cafe24/routes_products.py b/app/modules/cafe24/routes_products.py index 454ebf9..99892be 100644 --- a/app/modules/cafe24/routes_products.py +++ b/app/modules/cafe24/routes_products.py @@ -107,10 +107,13 @@ def _editor_ctx(st: Any, product_no: int) -> dict[str, Any]: "summary_description": product.get("summary_description") or "", }, "desc": desc, - # 편집기에는 이미지 경로의 %EC%9A%A9… 을 한글로 풀어서 보여준다. - # 저장할 때 다시 인코딩하므로 카페24에 저장되는 값은 그대로다. - "html_pc": store.decode_html_urls(desc.description) if desc else "", - "html_mobile": store.decode_html_urls(desc.mobile_description) if desc else "", + # 편집기에는 (1) 이미지 경로의 %EC%9A%A9… 을 한글로 풀고 + # (2) 태그마다 줄을 나눠 정리해서 보여준다. + # 저장할 때 같은 정리를 거친 값을 카페24에 쓴다(화면과 저장값이 같다). + "html_pc": store.format_html(store.decode_html_urls(desc.description)) if desc else "", + "html_mobile": ( + store.format_html(store.decode_html_urls(desc.mobile_description)) if desc else "" + ), # 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페24 값과 비교하므로). "fingerprint": store.fingerprint(desc.description) if desc else "", "revisions": st.list_revisions(product_no, limit=20), @@ -233,7 +236,9 @@ def product_apply( base = f"/cafe24/?{list_query}" if list_query else f"/cafe24/?selected={product_no}" back = base if f"selected={product_no}" in base else f"{base}&selected={product_no}" - submitted = store.encode_html_urls(html or "") + # 화면에서 보던 그대로(정리된 소스)를 카페24에 반영한다. 한글 이미지 경로는 + # 원래의 퍼센트 인코딩으로 되돌린다. + submitted = store.format_html(store.encode_html_urls(html or "")) if not submitted.strip(): return RedirectResponse( url=f"{back}&err=내용이 비어 있습니다. 상세페이지를 비우려면 카페24 관리자에서 하세요.", diff --git a/app/modules/cafe24/store.py b/app/modules/cafe24/store.py index 93e7099..a12eeec 100644 --- a/app/modules/cafe24/store.py +++ b/app/modules/cafe24/store.py @@ -181,6 +181,174 @@ def encode_html_urls(html: str) -> str: return _map_urls(html, encode_url_value) +# ════════════════════════════════════════════════════════════ +# 소스 정리(포맷) — 태그마다 줄을 나누고 들여쓴다. +# +# ⚠️ 렌더링을 바꾸지 않는 것이 최우선이다. HTML 에서 공백은 의미가 있어서, +# 인라인 요소 사이에 줄바꿈을 넣으면 화면에 공백이 생긴다(이미지 사이가 +# 벌어지는 고전적인 사고). 그래서 **블록 요소 경계에서만** 줄을 나눈다. +# img·br·span·a 같은 인라인 요소와 텍스트는 원래 줄에 그대로 둔다. +# ' + '

안녕하세요 여름 특가

' + '' + "
12
" +) + + +def test_format_breaks_block_tags(): + out = store.format_html(_MESSY) + lines = out.split("\n") + assert '
' in lines + assert "
" in lines + # 블록 안쪽은 들여쓴다. + assert any(line.startswith(" ") for line in lines) + assert any(line.startswith(" ") == "" + # 길면 나눈다. + long_text = "가" * 200 + assert "\n" in store.format_html("" % long_text) + + +def test_format_survives_broken_html(): + """닫는 태그 누락·꺾쇠 조각이 있어도 예외 없이 뭔가를 돌려준다.""" + for bad in ("

열고 안 닫음", "a < b 그리고 c > d", "<<>>", "A

") assert a == store.fingerprint("

A

") diff --git a/app/static/cafe24.css b/app/static/cafe24.css index 784c519..36b6857 100644 --- a/app/static/cafe24.css +++ b/app/static/cafe24.css @@ -29,7 +29,7 @@ ════════════════════════════════════════════════════════════ */ .cf24-split { display: grid; - grid-template-columns: 360px minmax(0, 1fr); + grid-template-columns: 550px minmax(0, 1fr); gap: var(--sp-12, 12px); align-items: stretch; } @@ -126,11 +126,11 @@ vertical-align: top; } -/* 고정폭 합계를 최소로 잡아 상품명에 남은 폭을 준다. +/* 목록 폭 550px 기준. 고정폭을 뺀 나머지(약 290px)가 상품명 몫이다. 진열/판매 칸은 제목(2글자) + 정렬 화살표가 들어갈 만큼만. */ -.cf24-col-no { width: 36px; text-align: right; color: var(--color-midtone-gray, #737373); } +.cf24-col-no { width: 40px; text-align: right; color: var(--color-midtone-gray, #737373); } .cf24-col-flag { width: 40px; text-align: center; } -.cf24-col-date { width: 46px; white-space: nowrap; color: var(--color-midtone-gray, #737373); } +.cf24-col-date { width: 78px; white-space: nowrap; color: var(--color-midtone-gray, #737373); } .cf24-col-name { word-break: break-word; } /* 긴 상품명은 2줄까지만 — 행 높이를 고르게 유지해 목록을 훑기 쉽게 한다. @@ -253,6 +253,83 @@ min-height: 340px; } +/* ════════════════════════════════════════════════════════════ + 문법 강조 편집기 + 투명한
") for line in lines) + + +def test_format_keeps_inline_elements_together(): + """이미지 사이에 줄바꿈이 들어가면 화면에 공백이 생긴다 — 붙여둬야 한다.""" + out = store.format_html(_MESSY) + assert '' in out + assert "

안녕하세요 여름 특가

" in out + + +def test_format_preserves_style_content_verbatim(): + out = store.format_html(_MESSY) + assert "\t/* 주석 */" in out + assert "\t.v{max-width:100%}" in out + + +def test_format_is_idempotent(): + """편집하지 않고 다시 적용해도 저장값이 계속 바뀌면 안 된다.""" + once = store.format_html(_MESSY) + assert store.format_html(once) == once + assert store.format_html(store.format_html(once)) == once + + +def test_format_collapses_short_blocks(): + assert store.format_html("
11%s