feat(cafe24): 문법 강조 편집기 + 소스 자동 정리, 목록 550px

1) 검색 입력란이 거대했던 버그

.cf24-filters 가 세로 flex 인데 .cf24-search 에 flex:0 1 320px 을 줬다. 세로
방향에서는 flex-basis 가 '높이'로 적용돼 입력란이 320px 짜리 상자가 됐다.
height:32px 로 한 줄에 고정했고, 그만큼 목록이 더 보인다.

2) 목록 550px

요청대로 왼쪽을 550px 로 넓혔다. 남은 폭(약 290px)이 상품명 몫이라 대부분 한 줄에
들어가고, 수정일도 월-일 시:분까지 보여준다. 컬럼은 번호·상품명·진열·판매·수정 5개
그대로다.

3) 문법 강조 편집기

색칠된 <pre> 위에 투명한 <textarea> 를 겹치는 방식으로 직접 구현했다. 외부
라이브러리를 쓰지 않는 이유는 자체 호스팅 원칙이다(CDN 의존 금지). 태그·속성이름·
속성값·주석·기호를 색으로 구분하고 Tab 은 들여쓰기로 쓴다.

두 층의 글자가 어긋나지 않으려면 폰트·줄높이·padding·줄바꿈 규칙이 완전히 같아야
한다. 특히 높이는 <pre> 의 scrollHeight 를 기준으로 textarea 에 지정한다 —
textarea 의 scrollHeight 를 쓰면 두 줄쯤 더 잡혀 어긋난다(실측 830 vs 792,
브라우저에서 확인 후 수정). 20만 자를 넘으면 강조를 끈다.

4) 소스 정리(포맷)와 저장 반영

store.format_html 을 추가했다. 화면 표시와 저장에 같은 함수를 쓰므로 화면에서 본
정리된 소스가 그대로 카페24에 저장된다.

렌더링을 바꾸지 않는 것을 최우선으로 했다. HTML 에서 공백은 의미가 있어서 인라인
요소 사이에 줄바꿈을 넣으면 화면에 공백이 생긴다 — 이미지 사이가 벌어지는 고전적인
사고다. 그래서 블록 요소 경계에서만 줄을 나누고 img·br·span·a 는 블록 목록에서
일부러 뺐다. <style>·<script>·<pre>·<textarea> 안쪽은 한 글자도 건드리지 않는다.
내용이 한 줄뿐인 짧은 블록은 다시 한 줄로 합친다.

멱등성을 테스트로 고정했다. 처음 구현은 <style> 안 빈 줄이 실행마다 한 줄씩 늘어나
멱등이 깨졌고(테스트가 잡음), 앞뒤 빈 줄을 버리도록 고쳤다. 편집하지 않고 다시
적용해도 저장값이 계속 달라지면 버전 이력이 의미를 잃는다.

닫는 태그가 빠진 HTML 이 흔하므로 들여쓰기 상한(12)을 뒀고, 어떤 이유로든 실패하면
원본을 그대로 돌려준다.

검증: 유닛테스트 41개 통과(신규 8개 — 블록 분리·인라인 보존(이미지 붙음)·style
원문 보존·멱등·짧은 블록 합치기·깨진 HTML 내성·속성값 미변경·정리+인코딩 왕복).
브라우저 실측: 검색란 32px, 목록 550px/편집기 750px, 오버레이 두 층 높이 일치
(편집 전 792=792, 20줄 추가 후 1175=1175), 토큰 색상 적용, 가로 스크롤 없음.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 12:48:23 +09:00
parent 119a128ee0
commit 1d3dac3bec
9 changed files with 480 additions and 31 deletions
+168
View File
@@ -181,6 +181,174 @@ def encode_html_urls(html: str) -> str:
return _map_urls(html, encode_url_value)
# ════════════════════════════════════════════════════════════
# 소스 정리(포맷) — 태그마다 줄을 나누고 들여쓴다.
#
# ⚠️ 렌더링을 바꾸지 않는 것이 최우선이다. HTML 에서 공백은 의미가 있어서,
# 인라인 요소 사이에 줄바꿈을 넣으면 화면에 공백이 생긴다(이미지 사이가
# 벌어지는 고전적인 사고). 그래서 **블록 요소 경계에서만** 줄을 나눈다.
# img·br·span·a 같은 인라인 요소와 텍스트는 원래 줄에 그대로 둔다.
# <style>·<script>·<pre>·<textarea> 안은 한 글자도 건드리지 않는다.
# ════════════════════════════════════════════════════════════
# 앞뒤 공백이 렌더링에 영향을 주지 않는 구조 태그만 넣는다.
_BLOCK_TAGS = frozenset(
"""html head body div p table thead tbody tfoot tr td th caption colgroup col
ul ol li dl dt dd section article header footer nav aside main
figure figcaption form fieldset legend h1 h2 h3 h4 h5 h6 hr center blockquote
style script iframe noscript""".split()
)
# 안쪽을 원문 그대로 보존할 태그
_RAW_TAGS = frozenset({"style", "script", "pre", "textarea"})
# 닫는 태그가 없는 태그
_VOID_TAGS = frozenset(
"area base br col embed hr img input link meta param source track wbr".split()
)
# 들여쓰기가 무한히 깊어지지 않게 (닫는 태그를 생략한 HTML 이 흔하다)
_MAX_INDENT = 12
_TOKEN_RE = re.compile(
r"(?P<comment><!--.*?-->)"
r"|(?P<cdata><!\[CDATA\[.*?\]\]>)"
r"|(?P<decl><![^>]*>)"
r"|(?P<tag><(?P<slash>/?)\s*(?P<name>[a-zA-Z][\w:.-]*)"
r"(?P<attrs>(?:\"[^\"]*\"|'[^']*'|[^>\"'])*)>)",
re.DOTALL,
)
def format_html(html: str, *, indent: str = " ") -> str:
"""상세페이지 HTML 을 사람이 읽기 좋게 정리한다.
실패하면 원본을 그대로 돌려준다 — 정리보다 안 깨지는 게 중요하다.
같은 값을 두 번 넣어도 결과가 같다(멱등).
"""
source = html or ""
if not source.strip():
return source
try:
return _format_html(source, indent)
except Exception: # noqa: BLE001 — 어떤 이유로든 원본을 지키는 쪽을 택한다.
return source
def _format_html(source: str, indent: str) -> str:
lines: list[str] = []
buffer = ""
depth = 0
def pad(level: int) -> str:
return indent * min(max(level, 0), _MAX_INDENT)
def flush() -> None:
"""모아둔 인라인/텍스트를 한 줄로 내보낸다(빈 줄은 버린다)."""
nonlocal buffer
text = buffer.strip()
if text:
lines.append(pad(depth) + text)
buffer = ""
position = 0
while True:
match = _TOKEN_RE.search(source, position)
if match is None:
buffer += source[position:]
break
buffer += source[position : match.start()]
position = match.end()
raw = match.group(0)
# 주석·DOCTYPE 등은 한 줄 차지
if match.group("comment") or match.group("cdata") or match.group("decl"):
flush()
lines.append(pad(depth) + raw.strip())
continue
name = (match.group("name") or "").lower()
closing = bool(match.group("slash"))
self_closed = (match.group("attrs") or "").rstrip().endswith("/")
# <style>/<script>/<pre>/<textarea> 안은 원문 유지
if name in _RAW_TAGS and not closing:
end = re.compile(r"</\s*%s\s*>" % re.escape(name), re.IGNORECASE).search(
source, position
)
inner = source[position : end.start()] if end else source[position:]
flush()
lines.append(pad(depth) + raw)
# 앞뒤 빈 줄은 버린다 — 남기면 매번 실행할 때마다 한 줄씩 늘어난다(멱등 깨짐).
body = inner.strip("\n")
if body:
for line in body.split("\n"):
lines.append(line.rstrip())
if end:
lines.append(pad(depth) + end.group(0))
position = end.end()
else:
position = len(source)
continue
# 인라인 태그와 텍스트는 줄을 나누지 않는다 (공백이 생기면 렌더링이 바뀐다)
if name not in _BLOCK_TAGS:
buffer += raw
continue
if closing:
flush()
depth -= 1
lines.append(pad(depth) + raw)
else:
flush()
lines.append(pad(depth) + raw)
if name not in _VOID_TAGS and not self_closed:
depth += 1
flush()
return "\n".join(_collapse_short_blocks(lines))
# 짧은 블록을 한 줄로 되돌릴 때 쓰는 패턴
_OPEN_TAG_LINE = re.compile(
r"^(?P<pad>\s*)<(?P<name>[a-zA-Z][\w:.-]*)(?:\"[^\"]*\"|'[^']*'|[^>\"'])*>$"
)
_BLOCK_TAG_IN_TEXT = re.compile(
r"</?(?:%s)\b" % "|".join(sorted(_BLOCK_TAGS)), re.IGNORECASE
)
# 한 줄로 합칠 최대 길이
_COLLAPSE_WIDTH = 120
def _collapse_short_blocks(lines: list[str]) -> list[str]:
"""`<td>\n 1\n</td>` 처럼 내용이 한 줄뿐인 짧은 블록은 한 줄로 되돌린다.
보기 좋게 하려는 것이며, 합치는 규칙이 결정적이라 멱등성은 유지된다.
"""
out: list[str] = []
index = 0
while index < len(lines):
opening = _OPEN_TAG_LINE.match(lines[index])
if opening and index + 2 < len(lines):
name = opening.group("name").lower()
middle = lines[index + 1].strip()
closing = lines[index + 2].strip()
merged = lines[index] + middle + closing
if (
name not in _VOID_TAGS
and name not in _RAW_TAGS
and closing.lower() == f"</{name}>"
and middle
and not _BLOCK_TAG_IN_TEXT.search(middle)
and len(merged) <= _COLLAPSE_WIDTH
):
out.append(merged)
index += 3
continue
out.append(lines[index])
index += 1
return out
def fingerprint(html: str) -> str:
"""편집 시작 시점의 카페24 값 지문. 적용 직전 값과 비교해 충돌을 잡는다.