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:
@@ -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 값 지문. 적용 직전 값과 비교해 충돌을 잡는다.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user