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:
@@ -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 관리자에서 하세요.",
|
||||
|
||||
@@ -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><style></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 === "&" ? "&" : c === "<" ? "<" : ">";
|
||||
});
|
||||
}
|
||||
|
||||
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 %}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 | 자동 종료/복원 · 롤백 | 예정 |
|
||||
|
||||
Reference in New Issue
Block a user