'use strict';
/**
* lib/visual-editor.js — v1.8.0 직접 편집(시각 편집기)의 서버 쪽 순수 로직. 브라우저·파일 시스템·네트워크에 의존하지 않아 Node 테스트로 바로 검증한다.
*
* 하는 일
* 1) prepareCanvas(html, opts) — 저장된 페이지를 "편집 캔버스 문서" 로 바꾼다. 캔버스는 빌더 미리보기 iframe(sandbox, allow-same-origin 없음 → 출처 null) 에
* srcdoc 으로 들어간다. 이때 페이지의 스크립트·인라인 이벤트 핸들러·javascript: 링크·iframe/object/embed·meta http-equiv 는 모두
* "실행되지 않는 보존 형태" 로 바꾸고(지우지 않는다), CSP(nonce 가 있는 편집 런타임만 실행)·(상대 경로 자원이 사이트 주소 기준으로
* 풀리도록)·편집 런타임(설정 + public/visual-editor-core.js + public/wm-ve-canvas.js)을 넣는다.
* 2) restoreFromCanvas(bodyHtml) — 캔버스가 돌려준 본문(body innerHTML)에서 편집기 표식(data-wm-ve-*)·덧씌운 UI(data-wm-ve-ui)·contenteditable 을 지우고
* 1) 에서 바꿔 둔 보존 속성을 원래 이름으로 되돌린다. 원래 페이지에 있던 코드는 그대로 남는다.
* 3) validateVisualSave(...) — 저장 요청을 검증하고 원본의 doctype·
· 여는 태그·꼬리()를 그대로 둔 채 본문만 바꿔 끼운 완성 HTML 을 돌려준다.
* 직접 편집 경로가 "임의 HTML 실행 API" 가 되지 않도록, 원본에 없던 스크립트·이벤트 속성·javascript:/data:text/html 주소·
* iframe/object/embed/meta/link/base 요소·스타일 표현식을 거절한다(원본에 있던 것은 그 수까지만 허용 — 삭제는 가능, 추가·변경은 불가).
*
* 설계 메모
* - HTML 파서 의존성이 없어 작은 태그 스캐너(scanTags)로 처리한다. 주석()·doctype· 의 script 가 실행된다). 그래서 외래 콘텐츠 안에서는 원문 규칙을 쓰지 않는다 — HTML 통합 지점(foreignObject·desc·mtext·annotation-xml)
// 안에서는 파서가 다시 HTML 로 읽어 title 이 원문이 되지만, 그 경우도 요소로 보아 세는 쪽(fail-closed)이 안전하다. 자기 닫힘 은 내용이 없다.
let foreign = 0;
while (i < n) {
const lt = html.indexOf('<', i);
if (lt < 0) { out += html.slice(i); break; }
out += html.slice(i, lt);
if (html.startsWith('', lt + 4); const end = e < 0 ? n : e + 3; out += html.slice(lt, end); i = end; continue; } // 주석
if (html[lt + 1] === '!' || html[lt + 1] === '?') { const e = html.indexOf('>', lt); const end = e < 0 ? n : e + 1; out += html.slice(lt, end); i = end; continue; } // doctype·처리 명령
const m = /^<(\/?)([a-zA-Z][a-zA-Z0-9:_-]*)/.exec(html.slice(lt, lt + 80));
if (!m) { out += '<'; i = lt + 1; continue; }
// 태그 끝('>')을 따옴표를 존중하며 찾는다 — 속성값 안의 '>' 에 끊기지 않는다
let j = lt + m[0].length; let q = null;
while (j < n) { const c = html[j]; if (q) { if (c === q) q = null; } else if (c === '"' || c === "'") q = c; else if (c === '>') break; j++; }
const end = Math.min(j + 1, n);
let attrText = html.slice(lt + m[0].length, j); const selfClosing = /\/\s*$/.test(attrText); if (selfClosing) attrText = attrText.replace(/\/\s*$/, '');
const tag = { closing: m[1] === '/', name: m[2].toLowerCase(), attrs: m[1] ? [] : parseAttrs(attrText), selfClosing, raw: html.slice(lt, end), inner: null, closeRaw: '', start: lt, end };
let after = end;
if (tag.name === 'svg' || tag.name === 'math') { if (tag.closing) { if (foreign > 0) foreign--; } else if (!tag.selfClosing) foreign++; }
if (!tag.closing && foreign === 0 && RAW_TEXT.has(tag.name)) { // 원문 요소(script·style 과 RCDATA 인 title·textarea): 닫는 태그까지의 내용은 HTML 이 아니다 — 안의 "" 같은 글자는 태그가 아니다
const re = new RegExp(`${tag.name}\\s*>`, 'ig'); re.lastIndex = end; const mm = re.exec(html);
tag.inner = html.slice(end, mm ? mm.index : n); tag.closeRaw = mm ? mm[0] : ''; after = mm ? mm.index + mm[0].length : n; tag.end = after;
}
const rep = visit(tag);
out += rep == null ? html.slice(lt, after) : rep;
i = after;
}
return out;
}
// ---------- 문서 나누기 ----------
/**
* 문서를 머리(doctype + + … + 여는 태그까지)·본문·꼬리( 부터 끝)로 나눈다.
* 가 없는 조각은 전체가 본문이다(hasBody=false). 는 있는데 가 없는 문서는 호출자가 거절한다.
* @param {string} html
* @returns {{prefix: string, body: string, suffix: string, hasBody: boolean, hasHead: boolean}}
*/
function splitDocument(html) {
html = String(html || '');
let open = null; let close = null;
scanTags(html, (t) => { if (t.name === 'body' && !t.closing && !open) open = t; else if (t.name === 'body' && t.closing && open && !close) close = t; return null; });
const hasHead = /]/i.test(html);
if (!open) return { prefix: '', body: html, suffix: '', hasBody: false, hasHead };
const bodyStart = open.end; const bodyEnd = close ? close.start : html.length;
return { prefix: html.slice(0, bodyStart), body: html.slice(bodyStart, bodyEnd), suffix: html.slice(bodyEnd), hasBody: true, hasHead };
}
// ---------- 주소 검사 ----------
/**
* javascript:/vbscript:/data:text/html 처럼 실행·문서 주입이 가능한 주소인지. 판별은 public/visual-editor-core.js 의 urlScheme 과 같다(한 구현):
* 이름 엔티티(: 	 
 / …)·숫자 엔티티(10/16진, 세미콜론 생략·앞자리 0 포함)를 두 겹까지 풀고, 제어·공백·폭 없는 문자를 걷어낸 뒤 스킴을 본다.
* 스킴 자리에 풀리지 않은(모르는) 엔티티가 남아 있으면 "흐린 스킴" 으로 보아 거절한다(fail-closed). 1.8.0 검토 수정: 예전에는 숫자 엔티티·&tab;·&newline; 만 풀어 : 를 놓쳤다.
*/
function isDangerousUrl(v) { return core.isDangerousUrl(v); }
/** style 속성·