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
+10 -5
View File
@@ -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 관리자에서 하세요.",
+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 값 지문. 적용 직전 값과 비교해 충돌을 잡는다.
@@ -38,8 +38,14 @@
모바일은 PC와 동일 설정이라 적용 시 <strong>함께 반영</strong>됩니다.
{% endif %}
{% if desc.mobile_differs %}<span class="cf24-warn">현재 PC/모바일 내용이 다릅니다.</span>{% endif %}
<br />
소스는 <strong>태그마다 줄을 나눠 정리</strong>해서 보여주며, <strong>적용하면 정리된 소스가
그대로 카페24에 저장</strong>됩니다. 줄바꿈·들여쓰기만 바뀌고 태그 구조는 그대로이며,
이미지가 벌어지지 않도록 <code>img</code>·<code>span</code> 같은 인라인 요소와
<code>&lt;style&gt;</code> 안쪽은 건드리지 않습니다.
<br />
이미지 경로의 한글 파일명은 카페24에 <code>%EC%9A%A9…</code> 로 저장되어 있습니다.
여기서는 읽기 쉽게 한글로 보여주고, 적용할 때 원래 형식으로 되돌립니다.
여기서는 한글로 보여주고, 적용할 때 원래 형식으로 되돌립니다.
</p>
<form class="cf24-editor-form" method="post"
@@ -58,8 +64,14 @@
</span>
</div>
<textarea id="cf24-html-pc" class="cf24-html cf24-html-main" name="html"
spellcheck="false">{{ html_pc }}</textarea>
{# 색칠된 <pre> 위에 투명한 <textarea> 를 겹쳐 문법 강조를 만든다.
두 요소의 글자 위치가 어긋나면 안 되므로 폰트·여백은 CSS 에서 함께 관리한다. #}
<div class="cf24-code" id="cf24-code-pc">
<pre class="cf24-code-hl" aria-hidden="true"><code id="cf24-hl-pc"></code></pre>
<textarea id="cf24-html-pc" class="cf24-code-input" name="html"
spellcheck="false" autocapitalize="off" autocorrect="off"
wrap="soft">{{ html_pc }}</textarea>
</div>
</form>
{% if desc.separated_mobile or desc.mobile_differs %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814e" />
<link rel="stylesheet" href="/static/cafe24.css?v=20260814f" />
{% endblock %}
{% block content %}
@@ -69,8 +69,8 @@
{% if r.selling %}<span class="cf24-dot cf24-dot-on" title="판매중"></span>
{% else %}<span class="cf24-dot" title="판매중지"></span>{% endif %}
</td>
{# 좁은 칸이라 월-일만. 전체 값은 title 로 확인 #}
<td class="cf24-col-date" title="{{ r.updated_date }}">{{ r.updated_date[5:10] }}</td>
{# 연도는 생략(월-일 시:분). 전체 값은 title 로 확인 #}
<td class="cf24-col-date" title="{{ r.updated_date }}">{{ r.updated_date[5:16] }}</td>
</tr>
{% endfor %}
</tbody>
@@ -105,9 +105,92 @@
var listQuery = {{ list_query | tojson }};
var dirty = false;
/* ── 문법 강조 ──────────────────────────────────────────────
색칠된 <pre> 를 투명한 <textarea> 뒤에 겹쳐 놓는 방식. 외부 라이브러리를
쓰지 않는다(자체 호스팅 원칙). 태그·속성이름·속성값·주석·기호를 구분한다. */
var TOKEN_RE = /(<!--[\s\S]*?-->)|(<![^>]*>)|(<\/?)([a-zA-Z][\w:.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)(>)/g;
var ATTR_RE = /([\w:.-]+)(?:(\s*=\s*)("[^"]*"|'[^']*'|[^\s"'>]+))?/g;
// 이 길이를 넘으면 강조를 끈다 — 타이핑마다 다시 칠하면 느려진다.
var HL_LIMIT = 200000;
function esc(text) {
return text.replace(/[&<>]/g, function (c) {
return c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;";
});
}
function paintAttrs(text) {
return text.replace(ATTR_RE, function (whole, name, eq, val) {
if (!name) return esc(whole);
var out = '<span class="cf24-t-attr">' + esc(name) + "</span>";
if (eq) out += '<span class="cf24-t-pun">' + esc(eq) + "</span>";
if (val) out += '<span class="cf24-t-val">' + esc(val) + "</span>";
return out;
});
}
function paintHtml(src) {
var out = "", last = 0, m;
TOKEN_RE.lastIndex = 0;
while ((m = TOKEN_RE.exec(src)) !== null) {
out += esc(src.slice(last, m.index));
last = TOKEN_RE.lastIndex;
if (m[1]) { out += '<span class="cf24-t-com">' + esc(m[1]) + "</span>"; continue; }
if (m[2]) { out += '<span class="cf24-t-doc">' + esc(m[2]) + "</span>"; continue; }
out += '<span class="cf24-t-pun">' + esc(m[3]) + "</span>" +
'<span class="cf24-t-tag">' + esc(m[4]) + "</span>" +
paintAttrs(m[5]) +
'<span class="cf24-t-pun">' + esc(m[6]) + "</span>";
}
return out + esc(src.slice(last));
}
function setupCodeEditor() {
var ta = document.getElementById("cf24-html-pc");
var hl = document.getElementById("cf24-hl-pc");
if (!ta || !hl) return;
var timer = null;
function repaint() {
// 마지막 줄이 잘리지 않게 개행을 하나 덧붙인다(<pre> 특성).
if (ta.value.length > HL_LIMIT) hl.textContent = ta.value + "\n";
else hl.innerHTML = paintHtml(ta.value) + "\n";
}
function resize() {
// 색칠된 <pre> 는 같은 내용·같은 스타일이라 이것이 진짜 콘텐츠 높이다.
// textarea 의 scrollHeight 를 쓰면 브라우저마다 한두 줄 더 잡혀 두 층의
// 글자 위치가 어긋난다(실측 830 vs 792).
var target = hl.parentElement.scrollHeight;
if (target > 0) ta.style.height = target + "px";
// 높이가 잠깐 부족했던 사이 내부 스크롤이 생겼으면 되돌린다(두 층 정렬 유지).
ta.scrollTop = 0;
}
function refresh() {
// 높이는 색칠 결과에서 나오므로 다시 칠한 뒤에 맞춘다.
clearTimeout(timer);
timer = setTimeout(function () { repaint(); resize(); }, 60);
}
ta.addEventListener("input", function () { dirty = true; refresh(); });
// Tab 은 포커스 이동이 아니라 들여쓰기로 쓴다.
ta.addEventListener("keydown", function (e) {
if (e.key !== "Tab") return;
e.preventDefault();
var start = ta.selectionStart, end = ta.selectionEnd;
ta.value = ta.value.slice(0, start) + " " + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = start + 2;
dirty = true;
refresh();
});
repaint();
resize();
}
// ── 편집기 조각을 새로 끼워 넣은 뒤 다시 연결 ──
window.cf24BindEditor = function () {
dirty = false;
setupCodeEditor();
pane.querySelectorAll("[data-copy]").forEach(function (btn) {
btn.addEventListener("click", function () {
@@ -134,10 +217,6 @@
dirty = false;
});
}
var box = document.getElementById("cf24-html-pc");
if (box && !box.readOnly) {
box.addEventListener("input", function () { dirty = true; });
}
};
function confirmLeave() {
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814d" />
<link rel="stylesheet" href="/static/cafe24.css?v=20260814f" />
{% endblock %}
{% block content %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814d" />
<link rel="stylesheet" href="/static/cafe24.css?v=20260814f" />
{% endblock %}
{% block content %}
+68
View File
@@ -428,6 +428,74 @@ def test_invalid_utf8_sequence_left_alone():
assert store.decode_html_urls(html) == html
# ════════════════════════════════════════════════════════════
# 소스 정리(포맷) — 렌더링을 바꾸지 않는 것이 최우선
# ════════════════════════════════════════════════════════════
_MESSY = (
'<style>\n\t/* 주석 */\n\t.v{max-width:100%}\n</style>'
'<div class="wrap"><p>안녕<span>하세요</span> 여름 특가</p>'
'<img src="/web/a.gif"><img src="/web/b.gif">'
"<table><tr><td>1</td><td>2</td></tr></table></div>"
)
def test_format_breaks_block_tags():
out = store.format_html(_MESSY)
lines = out.split("\n")
assert '<div class="wrap">' in lines
assert "</div>" in lines
# 블록 안쪽은 들여쓴다.
assert any(line.startswith(" <table>") for line in lines)
assert any(line.startswith(" <td>") for line in lines)
def test_format_keeps_inline_elements_together():
"""이미지 사이에 줄바꿈이 들어가면 화면에 공백이 생긴다 — 붙여둬야 한다."""
out = store.format_html(_MESSY)
assert '<img src="/web/a.gif"><img src="/web/b.gif">' in out
assert "<p>안녕<span>하세요</span> 여름 특가</p>" 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("<td>1</td>") == "<td>1</td>"
# 길면 나눈다.
long_text = "" * 200
assert "\n" in store.format_html("<td>%s</td>" % long_text)
def test_format_survives_broken_html():
"""닫는 태그 누락·꺾쇠 조각이 있어도 예외 없이 뭔가를 돌려준다."""
for bad in ("<div><p>열고 안 닫음", "a < b 그리고 c > d", "<<>>", "<div", ""):
assert isinstance(store.format_html(bad), str)
def test_format_does_not_touch_urls():
"""포맷은 속성값을 건드리지 않는다(인코딩과 서로 간섭하지 않게)."""
html = '<div><img src="/web/%EC%9A%A9%EA%B8%B0(a)_1.gif"></div>'
assert "/web/%EC%9A%A9%EA%B8%B0(a)_1.gif" in store.format_html(html)
def test_format_then_encode_roundtrip():
"""화면 표시(디코딩+정리) → 저장(인코딩+정리) 순서에서 URL 이 원형을 지킨다."""
raw = store.format_html(_REAL_HTML)
shown = store.format_html(store.decode_html_urls(raw))
saved = store.format_html(store.encode_html_urls(shown))
assert saved == raw
def test_fingerprint_detects_change():
a = store.fingerprint("<p>A</p>")
assert a == store.fingerprint("<p>A</p>")
+88 -6
View File
@@ -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;
}
/* ════════════════════════════════════════════════════════════
문법 강조 편집기
투명한 <textarea> 를 색칠된 <pre> 위에 정확히 겹쳐 놓는 방식이다.
두 요소의 폰트·줄높이·여백·줄바꿈 규칙이 **완전히 같아야** 글자가 어긋나지
않는다. 아래 두 선택자에 붙은 속성을 바꿀 때는 반드시 함께 바꿀 것.
외부 라이브러리를 쓰지 않는다(자체 호스팅 원칙 + CDN 의존 제거).
════════════════════════════════════════════════════════════ */
.cf24-code {
position: relative;
flex: 1 1 auto;
min-height: 340px;
overflow: auto;
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-lg, 10px);
background: var(--color-canvas-white, #fff);
}
.cf24-code-hl,
.cf24-code-input {
margin: 0;
padding: var(--sp-12, 12px);
border: 0;
width: 100%;
box-sizing: border-box;
font-family: var(--font-geist-mono, ui-monospace, "Consolas", monospace);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: break-word;
tab-size: 2;
}
.cf24-code-hl {
position: absolute;
top: 0;
left: 0;
min-height: 100%;
pointer-events: none;
color: var(--color-rich-black, #0a0a0a);
}
.cf24-code-input {
position: relative;
display: block;
background: transparent;
color: transparent;
caret-color: var(--color-rich-black, #0a0a0a);
resize: none;
overflow: hidden; /* 높이를 내용에 맞추고 스크롤은 .cf24-code 가 담당 */
}
.cf24-code-input:focus {
outline: none;
}
.cf24-code:focus-within {
border-color: var(--color-rich-black, #0a0a0a);
}
/* 선택 영역이 보이게 (글자는 투명이므로 배경만 남는다) */
.cf24-code-input::selection {
background: #b3d4fc;
}
/* 토큰 색 — GitHub 라이트 계열 */
.cf24-t-tag { color: #116329; } /* 태그 이름 */
.cf24-t-attr { color: #953800; } /* 속성 이름 */
.cf24-t-val { color: #0a3069; } /* 속성 값 */
.cf24-t-pun { color: #57606a; } /* < > / = */
.cf24-t-com { color: #6e7781; font-style: italic; } /* 주석 */
.cf24-t-doc { color: #6639ba; } /* DOCTYPE 등 선언 */
.cf24-code-hint {
font-size: var(--text-caption, 12px);
color: var(--color-midtone-gray, #737373);
}
.cf24-details {
flex: 0 0 auto;
border-top: 1px solid var(--color-subtle-ash, #e5e5e5);
@@ -370,9 +447,14 @@
margin-bottom: var(--sp-12, 12px);
}
/* 한 줄 높이로 고정한다. 세로 flex 안에서 flex-basis 를 주면 그 값이 '높이'로
적용돼 입력란이 거대해진다(실제로 그랬다 — flex 방향을 항상 확인할 것). */
.cf24-search {
flex: 0 1 320px;
padding: var(--sp-8, 8px) var(--sp-12, 12px);
flex: 0 0 auto;
width: 100%;
box-sizing: border-box;
height: 32px;
padding: 0 var(--sp-10, 10px);
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-lg, 10px);
font-size: var(--text-body, 14px);
+43 -8
View File
@@ -91,15 +91,20 @@ cafe24_oauth_tokens 저장
### 2-1. 상품관리 화면 구성 (2분할)
```
┌─ 360px ─────────────────────── 남은 폭 전부 ──────────────┐
검색 / 진열·판매 │ 선택한 상품 이름·상태
필터(중복 선택) │ PC 상세설명 HTML 편집(칸이 남은 높이 차지)
│ ── 목록 ── │ [메모] [복사] [카페24에 적용]
│ 번호 상품명 진열 │ ▸ 모바일 HTML(읽기 전용, 분리 상품만)
판매 수정 │ ▸ 버전 이력
└─────────────────┴───────────────────────────────────────────┘
┌─ 550px ──────────────────────────── 남은 폭 전부 ─────────┐
상품명 검색(한 줄) │ 상품 이름 · 번호 · 진열/판매
☐진열중 ☐판매중 │ PC 상세설명 HTML 편집
│ ── 목록(전체, 스크롤) ── │ (문법 강조 · 남은 높이 전부)
│ 번호 상품명 진열 판매 수정 │ [메모] [복사] [카페24에 적용]
(제목행 클릭 = 정렬) │ ▸ 모바일 HTML(분리 상품만)
│ │ ▸ 버전 이력 │
└───────────────────────────┴─────────────────────────────────┘
```
- 검색 입력란은 **한 줄 높이로 고정**한다(`height: 32px`). `.cf24-filters` 가 세로
flex 이므로 `flex-basis` 를 주면 그 값이 **높이**로 적용돼 입력란이 거대해진다.
실제로 그 사고가 있었다 — flex 방향을 항상 확인할 것.
- **왼쪽은 전체 목록**(페이지 없음). `list_all_products` 로 페이지를 넘겨가며 전부
받는다(1회 100개, 상한 1000개). 필터를 한 페이지에만 적용하면 다음 페이지의
해당 상품이 빠지기 때문이다.
@@ -156,10 +161,40 @@ cafe24_oauth_tokens 저장
---
### 2-2. 편집기 (문법 강조 · 소스 정리)
**문법 강조** — 색칠된 `<pre>` 위에 **투명한 `<textarea>`** 를 정확히 겹쳐 놓는
방식이다. 외부 라이브러리를 쓰지 않는다(자체 호스팅 원칙).
- 두 층의 **폰트·글자크기·줄높이·padding·`white-space`·`overflow-wrap`·`tab-size`
가 완전히 같아야** 글자가 어긋나지 않는다. `cafe24.css` 의
`.cf24-code-hl, .cf24-code-input` 규칙을 항상 함께 수정할 것.
- 높이는 **`<pre>` 의 `scrollHeight` 를 기준**으로 textarea 에 지정한다.
textarea 의 `scrollHeight` 를 쓰면 브라우저가 한두 줄 더 잡아 두 층이 어긋난다
(실측 830 vs 792). 스크롤은 바깥 `.cf24-code` 가 담당한다.
- 20만 자를 넘으면 강조를 끄고 평문으로 보여준다(타이핑마다 재색칠하면 느려짐).
- 색: 태그 초록 / 속성이름 갈색 / 속성값 남색 / 주석 회색 기울임 / 기호 회색.
**소스 정리** — `store.format_html()`. 화면에 보여줄 때와 저장할 때 **같은 함수**를
쓰므로, 화면에서 본 소스가 그대로 카페24에 저장된다.
- 줄을 나누는 것은 **블록 요소 경계에서만** 한다. HTML 에서 공백은 의미가 있어서
인라인 요소 사이에 줄바꿈을 넣으면 화면에 공백이 생긴다(이미지 사이가 벌어지는
고전적인 사고). `img`·`br`·`span`·`a` 는 블록 목록에서 **의도적으로 제외**했다.
- `<style>`·`<script>`·`<pre>`·`<textarea>` 안쪽은 한 글자도 건드리지 않는다.
- 내용이 한 줄뿐인 짧은 블록은 다시 한 줄로 합친다(`<td>1</td>`).
- **멱등**이다 — 편집하지 않고 다시 적용해도 저장값이 계속 바뀌지 않는다(테스트로 고정).
- 닫는 태그가 빠진 HTML 이 흔하므로 들여쓰기 깊이에 상한(12)을 둔다. 어떤 이유로든
실패하면 **원본을 그대로** 돌려준다(정리보다 안 깨지는 게 중요).
---
## 3-2. 편집·적용 규칙 (`POST /products/{no}/apply`)
이 순서를 절대 바꾸지 않는다.
0. 제출된 HTML 을 `encode_html_urls` → `format_html` 순으로 다듬는다(화면에서 본
정리된 소스가 그대로 저장된다).
1. **카페24에서 현재 HTML 을 다시 읽는다.** 로컬 DB 의 마지막 버전을 "지금
올라간 값"으로 가정하지 않는다(카페24 관리자에서 직접 고쳤을 수 있다).
2. 그 값으로 **BACKUP revision** 을 남긴다. 유일한 복구 수단이다.
@@ -249,7 +284,7 @@ DB·네트워크 없이 암호화 왕복, 토큰 만료/자동갱신, 상태 노
| --- | --- | --- |
| 1 | 공통 Integration · cafe24_db · OAuth 연결 화면 | ✅ 완료 |
| 2 | 상품 목록·검색·현재 HTML 조회 | ✅ 완료 |
| 3 | 편집기 · 미리보기 · Diff · 초안 | ◐ 편집기(textarea)만 완료. 미리보기·Diff·초안 예정 |
| 3 | 편집기 · 미리보기 · Diff · 초안 | ◐ 문법 강조 편집기 + 소스 정리 완료. 미리보기·Diff·초안 예정 |
| 4 | 즉시 적용 · BACKUP · Revision · 감사로그 | ✅ 완료 |
| 5 | 예약 DB · Worker(compose 서비스) · 예약관리 화면 | 예정 |
| 6 | 자동 종료/복원 · 롤백 | 예정 |