feat: 파이썬 삼중따옴표 안의 HTML을 HTML로 하이라이팅

sprinkler_backend.py의 `DASHBOARD_HTML = """<!DOCTYPE html>…"""` 174줄이
통째로 단색으로 보였다. Monaco의 파이썬 문법(Monarch)이 삼중따옴표 내부를
`string` 토큰 하나로 처리하기 때문이고, VS Code의 TextMate 문법과 달리
Monarch에는 언어 주입이 기본으로 없다.

다만 Monarch에도 nextEmbedded가 있어서 구간을 다른 언어 토크나이저에
넘길 수 있다(Monaco 자체 HTML 문법이 <script>/<style>을 이렇게 처리한다).
그걸 파이썬에 얹었다. HTML로 넘기면 그 안의 <style>/<script>까지 연쇄로
살아난다.

판별: 삼중따옴표 뒤 첫 비어있지 않은 내용이 `<`로 시작하면 HTML로 본다.
Monarch는 줄 단위라 여는 줄에서 바로 판단할 수 없어 대기 상태를 하나 뒀다.
SQL 독스트링(conn.execute("""CREATE TABLE…))과 산문 독스트링은 `<`로
시작하지 않으므로 종전대로 문자열 색을 유지한다. 빗나가도 색만 이상해질 뿐
편집·저장에는 영향이 없다.

구현 중 밟은 것 둘:
 - 빈 줄에서 `/^\s*$/`는 폭 0으로 매칭돼 같은 상태에 머물며 무한 루프가
   된다(Monarch가 "진행 없음"으로 예외를 던진다). `/\s+$/`로 최소 한 글자를
   요구하면 빈 줄은 아예 매칭되지 않고 다음 줄로 넘어가며 상태만 유지된다.
 - HTML 언어를 미리 로드해야 한다. Monaco는 언어를 지연 로딩해서, 파이썬
   파일만 열면 HTML 토크나이저가 없어 임베딩이 조용히 실패한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-08-18 15:19:14 +09:00
co-authored by Claude Opus 5
parent ae4439efba
commit 4ea87f1f7a
+63
View File
@@ -152,6 +152,68 @@ function setCodeAiTech(tech) {
localStorage.setItem('codeAiTech', tech);
}
// 파이썬 삼중따옴표 안의 HTML을 HTML로 칠한다.
//
// Monaco의 파이썬 문법(Monarch)은 삼중따옴표 내부를 통째로 `string` 토큰 하나로 처리해서,
// `DASHBOARD_HTML = """<!DOCTYPE html>…"""` 같은 블록이 174줄 내내 단색으로 보였다
// (2026-08-18 리포트). VS Code의 TextMate 문법과 달리 Monarch에는 언어 주입이 기본으로 없다.
//
// 다만 Monarch에도 `nextEmbedded`가 있어서 다른 언어 토크나이저에 구간을 넘길 수 있다
// (Monaco 자체 HTML 문법이 <script>/<style>을 이렇게 처리한다). 그걸 파이썬에 얹는다.
// HTML을 넘기면 그 안의 <style>/<script>까지 연쇄로 살아난다.
//
// 판별: 삼중따옴표 다음의 **첫 비어있지 않은 내용이 `<`로 시작**하면 HTML로 본다.
// Monarch는 줄 단위라 여는 줄에서 바로 판단할 수 없어서, 대기 상태를 하나 둔다.
// 빗나가도(예: `<`로 시작하는 산문 독스트링) 색만 이상해질 뿐 편집·저장에는 영향이 없다.
function enablePythonEmbeddedHtml() {
try {
const py = monaco.languages.getLanguages().find(l => l.id === 'python');
if (!py || typeof py.loader !== 'function') return;
// HTML 토크나이저가 먼저 등록돼 있어야 nextEmbedded가 붙는다. Monaco는 언어를
// 지연 로딩하므로, 파이썬 파일만 열면 HTML은 아직 없어 임베딩이 조용히 실패한다.
const html = monaco.languages.getLanguages().find(l => l.id === 'html');
Promise.all([py.loader(), html && typeof html.loader === 'function' ? html.loader() : null])
.then(([pyMod]) => {
const lang = pyMod && pyMod.language;
if (!lang || !lang.tokenizer || !lang.tokenizer.whitespace) return;
const t = lang.tokenizer;
const openHtml = (quote, waitState) => [
[new RegExp(quote), { token: 'string', next: '@' + waitState }],
];
// 여는 따옴표 뒤 첫 실질 내용을 보고 갈라지는 대기 상태
const waitRules = (embedState, docState) => [
// 공백만 있는 줄은 소비하고 계속 대기한다. `/^\s*$/`로 쓰면 완전히 빈 줄에서
// **폭 0으로 매칭돼 같은 상태에 머물며 무한 루프**가 된다(Monarch가 진행 없음으로
// 예외를 던진다). `\s+`로 최소 한 글자를 요구하면 그 경우 아예 매칭되지 않고,
// 빈 줄은 규칙 없이 다음 줄로 넘어가면서 상태만 유지된다 — 원하는 동작이다.
[/\s+$/, 'string'],
[/(?=\s*<)/, { token: '@rematch', next: '@' + embedState, nextEmbedded: 'text/html' }],
[/(?=[\s\S])/, { token: '@rematch', next: '@' + docState }], // HTML 아님 → 평범한 독스트링
];
const embedRules = (quote) => [
[new RegExp(quote), { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }],
[/[\s\S]/, ''],
];
// 기존 삼중따옴표 규칙보다 **앞에** 끼워야 먼저 걸린다.
t.whitespace = [
...openHtml("'''", 'pyHtmlWaitSq'),
...openHtml('"""', 'pyHtmlWaitDq'),
...t.whitespace.filter(r => !(Array.isArray(r) && String(r[0]) === String(/'''/)) &&
!(Array.isArray(r) && String(r[0]) === String(/"""/))),
];
t.pyHtmlWaitSq = waitRules('pyHtmlSq', 'endDocString');
t.pyHtmlWaitDq = waitRules('pyHtmlDq', 'endDblDocString');
t.pyHtmlSq = embedRules("'''");
t.pyHtmlDq = embedRules('"""');
monaco.languages.setMonarchTokensProvider('python', lang);
})
.catch(e => console.warn('[enablePythonEmbeddedHtml]', e));
} catch (e) { console.warn('[enablePythonEmbeddedHtml]', e); }
}
function initCodeView() {
updateCodeAiLabel();
setCodeAiTech(codeAiTech);
@@ -163,6 +225,7 @@ function initCodeView() {
// create() 전에 정의해야 한다. 뒤에 두면 첫 화면은 내장 기본값으로 그려지고,
// 사용자가 테마를 한 번 바꾸기 전까지 주석 색 재정의가 반영되지 않는다.
defineCodeThemes();
enablePythonEmbeddedHtml();
const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
const savedTheme = (() => { try { return localStorage.getItem('codeEditorTheme'); } catch { return null; } })();
monacoEditor = monaco.editor.create(document.getElementById('code-editor-container'), {