3줄 요약
- 본문 HTML 안의 스타일 블록은 본문에만 적용되지 않는다. 프론트엔드 렌더러가 그 블록을 뽑아 페이지 머리말에 다시 심고, 브라우저는 그것을 문서 전체에 적용한다(BSP-Multi-Workers/src/workers/frontend/index.js 기준).
- 그래서 조판은 스타일 블록이 아니라 태그별 인라인 스타일로 넣는다. scripts/publish/md2bsp.py가 태그마다 스타일 문자열을 붙여 주입하는 이유가 이것이다.
- 발행 전에 두 곳에서 막는다. 변환기 자체 점검이 잔존을 잡고, 품질 게이트가 검출되면 발행을 중단시킨다.
| 질문 | 답 |
|---|---|
| 본문에 넣은 스타일이 왜 본문 밖까지 바꾸는가 | 렌더러가 전체 문서 판본에서 스타일을 추출해 페이지 <head>에 다시 심기 때문이다 |
| 오염 범위는 어디까지인가 | 그 페이지 문서 전체다. 내비게이션, 구독 폼, 관련글 목록, 댓글 영역이 같은 문서 안에 있다 |
| 어떤 판본이 추출 대상이 되는가 | 본문 문자열에 <!DOCTYPE html> 또는 <html이 들어 있는 전체 문서 판본 |
| 그러면 조판은 무엇으로 하는가 | 태그별 인라인 스타일. 변환기가 사전을 갖고 태그마다 붙인다 |
| 발행 전 검사는 무엇을 보는가 | 변환 산출물에 스타일 태그와 <h1>이 남았는지. 남으면 종료 코드 1로 막는다 |
이 글대로 하면 무엇이 달라지는가 — 글 한 편의 CSS가 페이지의 다른 영역을 바꾸는 사고를 발행 전 명령 한 줄로 걸러내고, 스타일 블록 없이도 표와 코드블록이 제대로 조판된 본문을 만들 수 있다.
스타일 블록 하나가 어떻게 문서 전체를 오염시키는가?
렌더러가 본문에서 스타일을 뽑아 페이지 머리말로 옮기기 때문이다. 이것은 브라우저의 동작이 아니라 발행 플랫폼 코드에 적힌 동작이다. BSP-Multi-Workers/src/workers/frontend/index.js의 글 페이지 핸들러는 본문을 읽은 직후 다음을 한다.
let postContent = postResult.content_html || postResult.content || '';
let inlineStyles = '';
if (postContent.includes('<!DOCTYPE html>') || postContent.includes('<html')) {
const styleMatches = postContent.match(/<style[^>]*>([\s\S]*?)<\/style>/gi);
if (styleMatches) {
inlineStyles = styleMatches.map(s => s.replace(/<\/?style[^>]*>/gi, '')).join('\n');
}
const bodyMatch = postContent.match(/<body[^>]*>([\s\S]*)<\/body>/i);
if (bodyMatch) { postContent = bodyMatch[1]; }
}
뽑아낸 inlineStyles는 페이지 템플릿의 머리말 안에서 그대로 펼쳐진다. 템플릿에는 /* Inline Styles from HTML Document */ 주석이 붙어 있고 그 자리에 삽입된다. 즉 글쓴이가 본문 안에 적었다고 생각한 규칙이 실제로는 문서의 최상위 규칙으로 승격된다.
전체 문서 판본이 아니어도 결과는 크게 다르지 않다. 조각 판본이면 추출이 일어나지 않아 스타일 태그가 본문 자리에 그대로 남는데, CSS에는 위치 기반 범위 제한이 없어서 브라우저는 그 규칙도 문서 전체에 적용한다. 머리말로 올라가느냐 본문에 남느냐는 눈에 보이는 위치의 차이일 뿐이고, 적용 범위는 양쪽 다 문서 전체다.
정의를 한 문장으로 적으면 이렇다. 스코프 없는 CSS란 선택자가 특정 영역으로 한정되지 않아 같은 문서의 모든 요소에 적용되는 규칙이다. table, h2, a 같은 태그 선택자가 전형적인 예다.
오염 범위는 정확히 어디까지인가?
그 글 페이지의 문서 전체다. 여기서 처음의 이해가 한 번 어긋났고, 코드를 읽고 나서 범위를 좁혀 다시 적었다.
처음 진단은 "한 글에 넣은 CSS가 다른 글의 본문까지 바꾼다"였다. 이 진단이 틀린 이유는 추출이 요청된 글을 기준으로 일어나기 때문이다. 위 코드의 postResult는 지금 요청된 글이고, 다른 글의 본문은 이 경로를 지나지 않는다. 진짜 범위는 글 사이가 아니라 한 페이지 안이다. 그런데 그 한 페이지 안에 사이트 공용 요소가 전부 들어 있다는 것이 문제의 크기를 결정한다.
| 오염되는 것 | 왜 같은 문서에 있는가 |
|---|---|
| 상단 내비게이션과 블로그 제목 | 글 페이지 템플릿이 함께 렌더한다 |
| 뉴스레터 구독 폼 | 브레드크럼 영역에 통합돼 있다 |
| 관련글 목록, 시리즈 목록 | 본문 아래 같은 문서에 서버 렌더된다 |
| 댓글 영역과 광고 슬롯 | 같은 템플릿 안이다 |
| 다른 글의 제목 링크 | 관련글 목록의 항목으로 들어와 있다 |
그래서 규칙은 그대로 유지되고 이유만 정확해진다. 본문에 table { border: none } 한 줄을 넣으면 그 글의 표만 바뀌는 것이 아니라 그 페이지에 렌더된 사이트 공용 표가 함께 바뀐다. 같은 조판 틀을 여러 편에 복사해 넣으면 그 차이가 편마다 재현되므로, 독자가 보는 결과는 사이트 전반의 조판이 흔들리는 것과 구분되지 않는다.
한 가지 더, 스타일 블록은 본문 중간 광고 삽입 로직과도 얽힌다. 광고를 넣는 함수는 문단을 </p> 기준으로 자르는데, 스타일과 스크립트 블록 안의 태그가 잘리지 않도록 자르기 전에 자리표시자로 빼 둔다.
protectedContent = protectedContent.replace(/<style[\s\S]*?<\/style>/gi, (match) => {
const index = protectedBlocks.length;
protectedBlocks.push(match);
return `<!--ADPROTECT${index}-->`;
});
이 보호 장치가 있다는 사실 자체가 본문에 스타일 블록이 들어오면 하류 처리가 복잡해진다는 신호다. 넣지 않으면 이 경로가 아예 필요 없다.
내 글이 오염을 일으키는 판본인지 어떻게 판별하는가?
세 곳을 순서대로 본다. 앞 단계에서 걸리면 뒤는 볼 필요가 없다.
- 원고에서 본다. 마크다운 소스나 변환 전 HTML에 스타일 태그가 있는지 센다. 0이 아니면 여기서 끝이다.
grep -c "<style" Blogs/claude-code/my-post.md
- 변환 산출물에서 본다. 변환기를 돌리면 마지막 줄에 자체 점검 결과가 찍힌다. 산출물에 스타일 태그나
<h1>이 남아 있으면 FAIL이 뜨고 종료 코드가 1이 된다.
python -X utf8 scripts/publish/md2bsp.py Blogs/claude-code/my-post.md _build/my-post.html
- 라이브 페이지에서 본다. 이미 발행된 글이면 렌더된 머리말에 추출된 규칙이 들어갔는지를 확인한다. 마커 주석 다음에 오는 중괄호 수를 세는 방식이 가장 간단하다.
curl -s "https://my-blog.org/claude-code/post/my-post" > /tmp/page.html
sed -n '/Inline Styles from HTML Document/,/광고 슬롯 기본 스타일/p' /tmp/page.html | grep -c "{"
세 번째 방법은 템플릿의 주석 문구에 의존한다. 템플릿이 바뀌면 범위 지정이 빗나가므로, 이 명령이 0을 돌려줄 때는 오염이 없다는 뜻인지 마커가 바뀐 것인지 먼저 확인한다.
그러면 본문 조판은 무엇으로 하는가?
태그별 인라인 스타일이다. 변환기가 태그마다 스타일 문자열을 사전으로 갖고 있다가 정규 치환으로 붙인다. scripts/publish/md2bsp.py의 사전 일부는 다음과 같다.
S = {
"h2": "font-size:1.5rem;font-weight:700;margin:2.4rem 0 .9rem;padding-bottom:.5rem;"
"border-bottom:2px solid #e2e8f0;line-height:1.35;",
"p": "line-height:1.85;margin:0 0 1.05rem;",
"table": "width:100%;border-collapse:collapse;margin:1.3rem 0;font-size:.95rem;",
"th": "background:#f1f5f9;border:1px solid #cbd5e1;padding:.62rem .7rem;text-align:left;"
"font-weight:700;",
"td": "border:1px solid #e2e8f0;padding:.62rem .7rem;vertical-align:top;",
}
붙이는 쪽은 두 줄이다. 여는 태그 뒤에 속성이 있는 경우와 없는 경우를 나눠 처리한다.
for tag, css in S.items():
html = re.sub(rf"<{tag}(?=[ >])", f'<{tag} style="{css}"', html)
html = html.replace(f"<{tag}>", f'<{tag} style="{css}">')
이 방식은 손이 더 가는 대신 범위가 자기 요소로 닫힌다. 같은 문서의 내비게이션이나 관련글 목록에는 이 속성이 붙지 않으므로 영향이 없다.
처리 순서에 함정이 하나 있다. 코드블록을 먼저 처리해야 한다. 위의 일반 태그 루프가 <pre>와 <code>를 건드리면 코드블록 안쪽 요소에 본문용 스타일이 덧붙어 조판이 무너진다. 그래서 변환기는 코드블록 전용 치환을 앞에 두고, 뒤에 남은 <code>만 인라인 코드로 취급한다. 남은 것을 고르는 조건은 스타일 속성이 아직 없다는 것이다.
html = re.sub(r"<code(?![^>]*style=)", f'<code style="{S_CODE}"', html)
이 변환을 LLM에게 맡기지 않고 결정적 스크립트로 만든 이유는 마크다운을 HTML로 바꿀 때 LLM을 쓰지 않는 이유에 따로 정리했다.
표가 모바일에서 본문을 밀어내는 것은 어떻게 막는가?
표를 가로 스크롤 컨테이너로 감싼다. 표에 width:100%만 주면 열이 많을 때 표가 화면보다 넓어지고, 문서 전체가 가로로 밀린다. 감싸는 쪽이 스크롤을 흡수하면 본문은 제자리에 남는다.
WRAP = "max-width:100%;overflow-x:auto;"
html = re.sub(r"(<table[^>]*>)", rf'<div style="{WRAP}">\1', html)
html = html.replace("</table>", "</table></div>")
여기서도 같은 원칙이 적용된다. 이 규칙을 스타일 블록에 table { overflow-x: auto }로 적으면 두 가지가 동시에 틀어진다. 첫째로 그 페이지의 사이트 공용 표까지 바뀌고, 둘째로 표 요소 자체에는 가로 스크롤이 잘 걸리지 않아 의도한 효과도 나지 않는다. 감싸는 요소를 만들어 그 요소에 인라인으로 주는 편이 범위와 효과 양쪽에서 맞는다.
발행 전에 무엇을 검사하는가?
두 겹으로 검사한다. 변환기 자체 점검이 첫 겹이고, 품질 게이트가 둘째 겹이다. 변환기 쪽은 산출물에 남으면 안 되는 것을 본다.
bad = []
if "<style" in html or "<h1" in html:
bad.append("style/h1 잔존")
if re.search("[\U0001F300-\U0001FAFF]", html):
bad.append("이모지 잔존")
품질 게이트 쪽은 원고 단계에서 같은 것을 오류로 처리해 발행을 막는다. 여기서 오탐이 한 번 났고, 그 기록이 코드 주석으로 남아 있다. 구조 검사를 원문 그대로 돌리면 코드블록 안의 주석 줄을 마크다운 제목으로 세는 문제가 생겨 4편이 그렇게 실패했다. 대응은 검사 대상 텍스트를 바꾸는 것이었다. 구조 검사는 코드펜스를 지운 사본으로 하고, 금지 문자열 검사는 코드펜스와 백틱과 따옴표 안을 공백으로 치환한 사본으로 한다. 이 글이 스타일 태그 이름을 여러 번 쓰면서도 검사를 통과하는 이유가 그것이다. 같은 계열의 오탐과 대응은 AI가 만든 통계를 잡아내는 법에도 나온다.
발행 전에 손으로 확인할 항목을 체크리스트로 옮기면 다음과 같다.
[ ] 원고와 변환 산출물에 스타일 태그가 0개인가
[ ] 변환기 자체 점검이 PASS인가 (종료 코드 0)
[ ] 표가 가로 스크롤 컨테이너로 감싸였는가
[ ] 코드블록 안쪽 요소에 본문용 스타일이 덧붙지 않았는가
[ ] 제목 요소를 본문에 직접 넣지 않았는가 (플랫폼이 따로 렌더한다)
[ ] 라이브 페이지 머리말에 추출된 규칙이 0건인가
이 규칙이 통하지 않는 경우는 언제인가?
인라인 스타일로 표현할 수 없는 것들이 남는다. 세 가지가 대표적이다.
첫째, 미디어 쿼리와 가상 클래스는 인라인 속성으로 쓸 수 없다. 화면 폭에 따라 달라지는 조판이나 마우스를 올렸을 때의 변화는 이 방식으로 만들지 못한다. 이때 선택지는 플랫폼 공용 스타일시트에 규칙을 추가하는 것이지 본문에 블록을 넣는 것이 아니다.
둘째, 인터랙티브 위젯처럼 자체 스타일이 필요한 산출물은 본문에 직접 넣는 대신 별도 문서로 분리해 삽입 프레임으로 부른다. 프레임 안쪽은 별개 문서라 스타일이 바깥으로 새지 않는다.
셋째, 이 글의 코드 인용은 특정 플랫폼의 렌더러를 읽고 적은 것이라 다른 플랫폼에는 그대로 적용되지 않는다. 다만 판별 질문은 옮겨진다. 자기 플랫폼에서 확인할 것은 하나다. 본문 필드의 스타일을 렌더러가 문서 머리말로 옮기는가, 옮기지 않더라도 그 문서 안에 사이트 공용 요소가 함께 렌더되는가. 둘 중 하나라도 참이면 본문에 스타일 블록을 넣는 순간 범위는 본문을 넘어선다. 이런 판단 근거를 대화가 아니라 파일에 남겨 두는 방법은 문서화되지 않은 결정이 진짜 위험이다에 정리했다.
자주 묻는 질문
Q. 본문에 넣은 스타일이 왜 본문 밖까지 바꾸는가?
A. 프론트엔드 렌더러가 전체 문서 판본의 본문에서 스타일을 추출해 페이지 머리말에 다시 심기 때문이다. 조각 판본이라 추출이 일어나지 않아도 CSS에는 위치 기반 범위 제한이 없어 문서 전체에 적용된다.
Q. 오염 범위는 다른 글의 페이지까지 가는가?
A. 추출은 요청된 글을 기준으로 일어나므로 다른 글의 페이지 본문은 지나지 않는다. 다만 그 한 페이지 안에 내비게이션, 구독 폼, 관련글 목록, 댓글이 함께 있어서 사이트 공용 요소가 대상이 된다.
Q. 스타일 블록 없이 표와 코드블록 조판을 어떻게 하는가?
A. 태그별 인라인 스타일 사전을 두고 변환 단계에서 붙인다. 코드블록은 일반 태그 루프보다 먼저 처리하고, 스타일 속성이 아직 없는 <code>만 인라인 코드로 취급한다.
Q. 표가 모바일에서 화면을 밀어내면 어떻게 막는가?
A. 표를 max-width:100%;overflow-x:auto; 속성을 가진 요소로 감싼다. 표 요소 자체에 가로 스크롤을 주는 방식은 잘 걸리지 않고, 태그 선택자로 주면 같은 페이지의 공용 표까지 바뀐다.
Q. 발행 전 검사는 어디서 하는가?
A. 두 겹이다. 변환기는 산출물에 스타일 태그와 제목 요소가 남았는지 보고 남으면 종료 코드 1을 돌려주며, 품질 게이트는 원고 단계에서 같은 항목을 오류로 처리해 발행을 막는다.
Comments
Comments (0)
Leave a Comment