@vibe-x/dsh-html-workbench 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/assets/workbench.html +901 -0
- package/cordis.patch.yml +6 -0
- package/dsh.plugin.json +16 -0
- package/lib/client.js +267 -0
- package/package.json +51 -0
- package/scripts/workbench.py +774 -0
- package/src/client.js +229 -0
- package/src/host.js +201 -0
- package/src/index.js +27 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Dependency-free local service for the HTML visual workbench."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import base64
|
|
8
|
+
import hashlib
|
|
9
|
+
import html
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
import mimetypes
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tarfile
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.parse
|
|
22
|
+
import urllib.request
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from html.parser import HTMLParser
|
|
25
|
+
from http import HTTPStatus
|
|
26
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
SERVICE_NAME = "html-workbench"
|
|
32
|
+
SERVICE_VERSION = "2.0.0"
|
|
33
|
+
DEFAULT_PORT = 4317
|
|
34
|
+
MAX_REQUEST_BYTES = 8 * 1024 * 1024
|
|
35
|
+
SCRIPT_PATTERN = re.compile(r"^<script\b[\s\S]*</script\s*>$", re.IGNORECASE)
|
|
36
|
+
GRAPESJS_VERSION = "0.23.4"
|
|
37
|
+
VENDOR_ASSETS = {
|
|
38
|
+
"grapes.min.js": {
|
|
39
|
+
"route": "/vendor/grapesjs/grapes.min.js",
|
|
40
|
+
"archive": "package/dist/grapes.min.js",
|
|
41
|
+
"sha256": "66155421db3a640add8eaf77391b6a744d36af80833cd91d44f8d3220fb76231",
|
|
42
|
+
"content_type": "text/javascript; charset=utf-8",
|
|
43
|
+
},
|
|
44
|
+
"grapes.min.css": {
|
|
45
|
+
"route": "/vendor/grapesjs/css/grapes.min.css",
|
|
46
|
+
"archive": "package/dist/css/grapes.min.css",
|
|
47
|
+
"sha256": "fb55e939b3349c280d68c0617dc87e56baa3eab55ea56a1855db9f5efcc7268d",
|
|
48
|
+
"content_type": "text/css; charset=utf-8",
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
VENDOR_ARCHIVES = [
|
|
52
|
+
("npmmirror", f"https://registry.npmmirror.com/grapesjs/-/grapesjs-{GRAPESJS_VERSION}.tgz"),
|
|
53
|
+
]
|
|
54
|
+
VENDOR_CDNS = [
|
|
55
|
+
("cdnjs", f"https://cdnjs.cloudflare.com/ajax/libs/grapesjs/{GRAPESJS_VERSION}"),
|
|
56
|
+
("jsdelivr", f"https://cdn.jsdelivr.net/npm/grapesjs@{GRAPESJS_VERSION}/dist"),
|
|
57
|
+
("unpkg", f"https://unpkg.com/grapesjs@{GRAPESJS_VERSION}/dist"),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class WorkbenchError(Exception):
|
|
62
|
+
def __init__(self, code: str, message: str, status: int = 500, **extra: Any) -> None:
|
|
63
|
+
super().__init__(message)
|
|
64
|
+
self.code = code
|
|
65
|
+
self.status = status
|
|
66
|
+
self.extra = extra
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def default_vendor_cache() -> Path:
|
|
70
|
+
if os.name == "nt":
|
|
71
|
+
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
|
72
|
+
else:
|
|
73
|
+
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
|
74
|
+
return base / "html-workbench" / "vendor" / "grapesjs" / GRAPESJS_VERSION
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def sha256_bytes(content: bytes) -> str:
|
|
78
|
+
return hashlib.sha256(content).hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def verify_vendor_content(name: str, content: bytes) -> bytes:
|
|
82
|
+
expected = str(VENDOR_ASSETS[name]["sha256"])
|
|
83
|
+
actual = sha256_bytes(content)
|
|
84
|
+
if actual != expected:
|
|
85
|
+
raise ValueError(f"{name} 校验失败(预期 {expected},实际 {actual})")
|
|
86
|
+
return content
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def download_bytes(url: str, limit: int = 5_000_000, timeout: float = 20.0) -> bytes:
|
|
90
|
+
request = urllib.request.Request(url, headers={"User-Agent": f"html-workbench/{SERVICE_VERSION}"})
|
|
91
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
92
|
+
content = response.read(limit + 1)
|
|
93
|
+
if len(content) > limit:
|
|
94
|
+
raise ValueError(f"下载内容超过 {limit} 字节")
|
|
95
|
+
return content
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def vendor_from_archive(url: str) -> dict[str, bytes]:
|
|
99
|
+
archive = download_bytes(url)
|
|
100
|
+
files: dict[str, bytes] = {}
|
|
101
|
+
with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as package:
|
|
102
|
+
members = {member.name: member for member in package.getmembers() if member.isfile()}
|
|
103
|
+
for name, metadata in VENDOR_ASSETS.items():
|
|
104
|
+
archive_name = str(metadata["archive"])
|
|
105
|
+
member = members.get(archive_name)
|
|
106
|
+
if member is None:
|
|
107
|
+
raise ValueError(f"压缩包缺少 {archive_name}")
|
|
108
|
+
source = package.extractfile(member)
|
|
109
|
+
if source is None:
|
|
110
|
+
raise ValueError(f"无法读取 {archive_name}")
|
|
111
|
+
files[name] = verify_vendor_content(name, source.read())
|
|
112
|
+
return files
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def vendor_from_cdn(base_url: str) -> dict[str, bytes]:
|
|
116
|
+
return {
|
|
117
|
+
name: verify_vendor_content(
|
|
118
|
+
name,
|
|
119
|
+
download_bytes(f"{base_url}/{'css/' if name.endswith('.css') else ''}{name}"),
|
|
120
|
+
)
|
|
121
|
+
for name in VENDOR_ASSETS
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def write_vendor_cache(cache_dir: Path, files: dict[str, bytes]) -> None:
|
|
126
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
for name, content in files.items():
|
|
128
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{name}.", dir=cache_dir)
|
|
129
|
+
try:
|
|
130
|
+
with os.fdopen(descriptor, "wb") as output:
|
|
131
|
+
output.write(content)
|
|
132
|
+
output.flush()
|
|
133
|
+
os.fsync(output.fileno())
|
|
134
|
+
os.replace(temporary_name, cache_dir / name)
|
|
135
|
+
finally:
|
|
136
|
+
if os.path.exists(temporary_name):
|
|
137
|
+
os.unlink(temporary_name)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def ensure_vendor_assets(cache_dir: Path) -> dict[str, Path]:
|
|
141
|
+
paths = {name: cache_dir / name for name in VENDOR_ASSETS}
|
|
142
|
+
try:
|
|
143
|
+
for name, path in paths.items():
|
|
144
|
+
verify_vendor_content(name, path.read_bytes())
|
|
145
|
+
return paths
|
|
146
|
+
except (OSError, ValueError):
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
failures: list[str] = []
|
|
150
|
+
sources = [
|
|
151
|
+
*((name, url, vendor_from_archive) for name, url in VENDOR_ARCHIVES),
|
|
152
|
+
*((name, url, vendor_from_cdn) for name, url in VENDOR_CDNS),
|
|
153
|
+
]
|
|
154
|
+
for source_name, url, loader in sources:
|
|
155
|
+
try:
|
|
156
|
+
files = loader(url)
|
|
157
|
+
write_vendor_cache(cache_dir, files)
|
|
158
|
+
return paths
|
|
159
|
+
except (OSError, ValueError, tarfile.TarError, urllib.error.URLError) as caught:
|
|
160
|
+
failures.append(f"{source_name}: {caught}")
|
|
161
|
+
raise WorkbenchError(
|
|
162
|
+
"VENDOR_DOWNLOAD_FAILED",
|
|
163
|
+
"首次启动需要下载约 1.2 MB 的 GrapesJS,但所有下载源均不可用。请检查网络后重试。",
|
|
164
|
+
503,
|
|
165
|
+
attempts=failures,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class HtmlNode:
|
|
171
|
+
tag: str
|
|
172
|
+
attrs: dict[str, str]
|
|
173
|
+
start: int
|
|
174
|
+
start_end: int
|
|
175
|
+
parent: "HtmlNode | None" = None
|
|
176
|
+
end_start: int | None = None
|
|
177
|
+
end_end: int | None = None
|
|
178
|
+
children: list["HtmlNode"] = field(default_factory=list)
|
|
179
|
+
|
|
180
|
+
def is_inside(self, ancestor: "HtmlNode") -> bool:
|
|
181
|
+
current = self.parent
|
|
182
|
+
while current is not None:
|
|
183
|
+
if current is ancestor:
|
|
184
|
+
return True
|
|
185
|
+
current = current.parent
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class SourceHtmlParser(HTMLParser):
|
|
190
|
+
def __init__(self, source: str) -> None:
|
|
191
|
+
super().__init__(convert_charrefs=False)
|
|
192
|
+
self.source = source
|
|
193
|
+
self.line_offsets: list[int] = [0]
|
|
194
|
+
for match in re.finditer(r"\n", source):
|
|
195
|
+
self.line_offsets.append(match.end())
|
|
196
|
+
self.nodes: list[HtmlNode] = []
|
|
197
|
+
self.stack: list[HtmlNode] = []
|
|
198
|
+
|
|
199
|
+
def source_offset(self) -> int:
|
|
200
|
+
line, column = self.getpos()
|
|
201
|
+
return self.line_offsets[line - 1] + column
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def normalize_attrs(attrs: list[tuple[str, str | None]]) -> dict[str, str]:
|
|
205
|
+
return {name: value if value is not None else "" for name, value in attrs}
|
|
206
|
+
|
|
207
|
+
def add_node(self, tag: str, attrs: list[tuple[str, str | None]], closed: bool) -> None:
|
|
208
|
+
start = self.source_offset()
|
|
209
|
+
raw = self.get_starttag_text() or ""
|
|
210
|
+
parent = self.stack[-1] if self.stack else None
|
|
211
|
+
node = HtmlNode(tag.lower(), self.normalize_attrs(attrs), start, start + len(raw), parent)
|
|
212
|
+
if parent is not None:
|
|
213
|
+
parent.children.append(node)
|
|
214
|
+
self.nodes.append(node)
|
|
215
|
+
if closed:
|
|
216
|
+
node.end_start = node.start_end
|
|
217
|
+
node.end_end = node.start_end
|
|
218
|
+
else:
|
|
219
|
+
self.stack.append(node)
|
|
220
|
+
|
|
221
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
222
|
+
self.add_node(tag, attrs, tag.lower() in {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"})
|
|
223
|
+
|
|
224
|
+
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
225
|
+
self.add_node(tag, attrs, True)
|
|
226
|
+
|
|
227
|
+
def handle_endtag(self, tag: str) -> None:
|
|
228
|
+
target = tag.lower()
|
|
229
|
+
index = next((i for i in range(len(self.stack) - 1, -1, -1) if self.stack[i].tag == target), None)
|
|
230
|
+
if index is None:
|
|
231
|
+
return
|
|
232
|
+
start = self.source_offset()
|
|
233
|
+
match = re.match(r"</\s*[^>]+>", self.source[start:], re.IGNORECASE)
|
|
234
|
+
end = start + (len(match.group(0)) if match else len(tag) + 3)
|
|
235
|
+
node = self.stack[index]
|
|
236
|
+
node.end_start = start
|
|
237
|
+
node.end_end = end
|
|
238
|
+
del self.stack[index:]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def parse_source(source: str) -> dict[str, Any]:
|
|
242
|
+
parser = SourceHtmlParser(source)
|
|
243
|
+
try:
|
|
244
|
+
parser.feed(source)
|
|
245
|
+
parser.close()
|
|
246
|
+
except Exception as caught:
|
|
247
|
+
raise WorkbenchError("INVALID_DOCUMENT", f"HTML 解析失败:{caught}", 400) from caught
|
|
248
|
+
|
|
249
|
+
def first(tag: str) -> HtmlNode | None:
|
|
250
|
+
return next((node for node in parser.nodes if node.tag == tag), None)
|
|
251
|
+
|
|
252
|
+
html_node = first("html")
|
|
253
|
+
head_node = first("head")
|
|
254
|
+
body_node = first("body")
|
|
255
|
+
if not html_node or not head_node or not body_node or body_node.end_start is None or head_node.end_start is None:
|
|
256
|
+
raise WorkbenchError("INVALID_DOCUMENT", "目标必须是包含 html、head、body 的完整 HTML 文档。", 400)
|
|
257
|
+
|
|
258
|
+
head_children = [node for node in parser.nodes if node.parent is head_node]
|
|
259
|
+
override = next(
|
|
260
|
+
(node for node in head_children if node.tag == "style" and "data-grapesjs-overrides" in node.attrs),
|
|
261
|
+
None,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def inner(node: HtmlNode) -> str:
|
|
265
|
+
if node.end_start is None:
|
|
266
|
+
return ""
|
|
267
|
+
return source[node.start_end : node.end_start]
|
|
268
|
+
|
|
269
|
+
source_css = "\n".join(inner(node) for node in head_children if node.tag == "style" and node is not override)
|
|
270
|
+
links = [
|
|
271
|
+
node.attrs.get("href", "")
|
|
272
|
+
for node in head_children
|
|
273
|
+
if node.tag == "link" and node.attrs.get("rel", "").lower() == "stylesheet" and node.attrs.get("href")
|
|
274
|
+
]
|
|
275
|
+
head_scripts = [
|
|
276
|
+
{"attributes": node.attrs, "content": inner(node)}
|
|
277
|
+
for node in head_children
|
|
278
|
+
if node.tag == "script" and node.end_start is not None
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
raw_body = source[body_node.start_end : body_node.end_start]
|
|
282
|
+
body_script_nodes = [
|
|
283
|
+
node
|
|
284
|
+
for node in parser.nodes
|
|
285
|
+
if node.tag == "script" and node.end_end is not None and node.is_inside(body_node)
|
|
286
|
+
]
|
|
287
|
+
body_scripts = [source[node.start : node.end_end] for node in body_script_nodes]
|
|
288
|
+
clean_body = raw_body
|
|
289
|
+
for node in sorted(body_script_nodes, key=lambda item: item.start, reverse=True):
|
|
290
|
+
relative_start = node.start - body_node.start_end
|
|
291
|
+
relative_end = node.end_end - body_node.start_end
|
|
292
|
+
clean_body = clean_body[:relative_start] + clean_body[relative_end:]
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
"bodyHtml": clean_body,
|
|
296
|
+
"bodyScripts": body_scripts,
|
|
297
|
+
"sourceCss": source_css,
|
|
298
|
+
"overrideCss": inner(override) if override else "",
|
|
299
|
+
"links": links,
|
|
300
|
+
"headScripts": head_scripts,
|
|
301
|
+
"htmlAttributes": html_node.attrs,
|
|
302
|
+
"bodyAttributes": body_node.attrs,
|
|
303
|
+
"locations": {
|
|
304
|
+
"bodyStart": body_node.start_end,
|
|
305
|
+
"bodyEnd": body_node.end_start,
|
|
306
|
+
"headEnd": head_node.end_start,
|
|
307
|
+
"overrideStart": override.start if override else None,
|
|
308
|
+
"overrideEnd": override.end_end if override else None,
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def inline_event_attributes(source: str) -> list[tuple[str, tuple[str, str] | None, tuple[int, ...], dict[str, str]]]:
|
|
314
|
+
"""Extract body `on*` attributes with stable identity and structural fallback.
|
|
315
|
+
|
|
316
|
+
GrapesJS intentionally strips inline event attributes from component models.
|
|
317
|
+
The workbench keeps the original source as the behavioral authority, so these
|
|
318
|
+
attributes must survive a visual-only save. Prefer id/data-wb-id/data-mode;
|
|
319
|
+
the sibling-index path only applies when the element tree was not rearranged.
|
|
320
|
+
"""
|
|
321
|
+
parser = SourceHtmlParser(f"<body>{source}</body>")
|
|
322
|
+
parser.feed(parser.source)
|
|
323
|
+
parser.close()
|
|
324
|
+
body = next((node for node in parser.nodes if node.tag == "body"), None)
|
|
325
|
+
if body is None:
|
|
326
|
+
return []
|
|
327
|
+
|
|
328
|
+
def path_for(node: HtmlNode) -> tuple[int, ...]:
|
|
329
|
+
path: list[int] = []
|
|
330
|
+
current = node
|
|
331
|
+
while current.parent is not None and current.parent is not body:
|
|
332
|
+
path.append(current.parent.children.index(current))
|
|
333
|
+
current = current.parent
|
|
334
|
+
path.append(body.children.index(current))
|
|
335
|
+
return tuple(reversed(path))
|
|
336
|
+
|
|
337
|
+
events: list[tuple[str, tuple[str, str] | None, tuple[int, ...], dict[str, str]]] = []
|
|
338
|
+
for node in parser.nodes:
|
|
339
|
+
if not node.is_inside(body):
|
|
340
|
+
continue
|
|
341
|
+
attrs = {name: value for name, value in node.attrs.items() if name.lower().startswith("on")}
|
|
342
|
+
if not attrs:
|
|
343
|
+
continue
|
|
344
|
+
identity = next(
|
|
345
|
+
((name, node.attrs[name]) for name in ("id", "data-wb-id", "data-mode") if node.attrs.get(name)),
|
|
346
|
+
None,
|
|
347
|
+
)
|
|
348
|
+
events.append((node.tag, identity, path_for(node), attrs))
|
|
349
|
+
return events
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def restore_inline_event_attributes(body_html: str, source_body_html: str) -> str:
|
|
353
|
+
"""Restore source inline handlers omitted by GrapesJS during serialization."""
|
|
354
|
+
source_events = inline_event_attributes(source_body_html)
|
|
355
|
+
if not source_events:
|
|
356
|
+
return body_html
|
|
357
|
+
|
|
358
|
+
parser = SourceHtmlParser(f"<body>{body_html}</body>")
|
|
359
|
+
parser.feed(parser.source)
|
|
360
|
+
parser.close()
|
|
361
|
+
body = next((node for node in parser.nodes if node.tag == "body"), None)
|
|
362
|
+
if body is None:
|
|
363
|
+
return body_html
|
|
364
|
+
|
|
365
|
+
def path_for(node: HtmlNode) -> tuple[int, ...]:
|
|
366
|
+
path: list[int] = []
|
|
367
|
+
current = node
|
|
368
|
+
while current.parent is not None and current.parent is not body:
|
|
369
|
+
path.append(current.parent.children.index(current))
|
|
370
|
+
current = current.parent
|
|
371
|
+
path.append(body.children.index(current))
|
|
372
|
+
return tuple(reversed(path))
|
|
373
|
+
|
|
374
|
+
by_identity: dict[tuple[str, str], list[HtmlNode]] = {}
|
|
375
|
+
by_path: dict[tuple[int, ...], HtmlNode] = {}
|
|
376
|
+
for node in parser.nodes:
|
|
377
|
+
if not node.is_inside(body):
|
|
378
|
+
continue
|
|
379
|
+
for name in ("id", "data-wb-id", "data-mode"):
|
|
380
|
+
if node.attrs.get(name):
|
|
381
|
+
by_identity.setdefault((name, node.attrs[name]), []).append(node)
|
|
382
|
+
by_path[path_for(node)] = node
|
|
383
|
+
|
|
384
|
+
additions: list[tuple[int, str]] = []
|
|
385
|
+
for tag, identity, path, attributes in source_events:
|
|
386
|
+
candidates = by_identity.get(identity, []) if identity else []
|
|
387
|
+
target = candidates[0] if len(candidates) == 1 else by_path.get(path)
|
|
388
|
+
if target is None or target.tag != tag:
|
|
389
|
+
continue
|
|
390
|
+
missing = {
|
|
391
|
+
name: value
|
|
392
|
+
for name, value in attributes.items()
|
|
393
|
+
if name.lower() not in {existing.lower() for existing in target.attrs}
|
|
394
|
+
}
|
|
395
|
+
if missing:
|
|
396
|
+
serialized = "".join(f' {name}="{html.escape(value, quote=True)}"' for name, value in missing.items())
|
|
397
|
+
additions.append((target.start_end - 1, serialized))
|
|
398
|
+
|
|
399
|
+
restored = body_html
|
|
400
|
+
# Offsets include the synthetic `<body>` prefix; apply from right to left.
|
|
401
|
+
synthetic_prefix = len("<body>")
|
|
402
|
+
for offset, serialized in sorted(additions, reverse=True):
|
|
403
|
+
index = offset - synthetic_prefix
|
|
404
|
+
restored = restored[:index] + serialized + restored[index:]
|
|
405
|
+
return restored
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def revision_of(content: str) -> str:
|
|
409
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def inside_root(target: Path, root: Path) -> bool:
|
|
413
|
+
try:
|
|
414
|
+
target.relative_to(root)
|
|
415
|
+
return True
|
|
416
|
+
except ValueError:
|
|
417
|
+
return False
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def resolve_html_file(raw_file: str, editor_root: Path) -> Path:
|
|
421
|
+
if not raw_file.strip():
|
|
422
|
+
raise WorkbenchError("FILE_REQUIRED", "请通过 file 参数指定 HTML 文件。", 400)
|
|
423
|
+
requested = Path(raw_file).expanduser()
|
|
424
|
+
try:
|
|
425
|
+
target = requested.resolve(strict=True)
|
|
426
|
+
except FileNotFoundError as caught:
|
|
427
|
+
raise WorkbenchError("FILE_NOT_FOUND", f"找不到文件:{requested.resolve()}", 404) from caught
|
|
428
|
+
if target.suffix.lower() != ".html":
|
|
429
|
+
raise WorkbenchError("INVALID_DOCUMENT", "只支持本地 .html 文件。", 400)
|
|
430
|
+
if not inside_root(target, editor_root):
|
|
431
|
+
raise WorkbenchError("OUTSIDE_EDITOR_ROOT", f"文件不在允许目录中:{target}", 403)
|
|
432
|
+
return target
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def encode_asset_token(target: Path) -> str:
|
|
436
|
+
return base64.urlsafe_b64encode(str(target).encode("utf-8")).decode("ascii").rstrip("=")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def decode_asset_token(token: str) -> str:
|
|
440
|
+
padding = "=" * (-len(token) % 4)
|
|
441
|
+
try:
|
|
442
|
+
return base64.urlsafe_b64decode(token + padding).decode("utf-8")
|
|
443
|
+
except (ValueError, UnicodeDecodeError) as caught:
|
|
444
|
+
raise WorkbenchError("INVALID_ASSET", "资源地址无效。", 400) from caught
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def read_document(target: Path) -> dict[str, Any]:
|
|
448
|
+
try:
|
|
449
|
+
source = target.read_text(encoding="utf-8")
|
|
450
|
+
except UnicodeDecodeError as caught:
|
|
451
|
+
raise WorkbenchError("INVALID_DOCUMENT", "HTML 文件必须使用 UTF-8 编码。", 400) from caught
|
|
452
|
+
parsed = parse_source(source)
|
|
453
|
+
stat = target.stat()
|
|
454
|
+
return {
|
|
455
|
+
**parsed,
|
|
456
|
+
"html": source,
|
|
457
|
+
"revision": revision_of(source),
|
|
458
|
+
"mtimeMs": stat.st_mtime * 1000,
|
|
459
|
+
"filePath": str(target),
|
|
460
|
+
"fileName": target.name,
|
|
461
|
+
"assetBase": f"/assets/{encode_asset_token(target)}/",
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def save_document(target: Path, body: dict[str, Any]) -> dict[str, Any]:
|
|
466
|
+
base_revision = body.get("baseRevision")
|
|
467
|
+
body_html = body.get("bodyHtml")
|
|
468
|
+
css = body.get("css")
|
|
469
|
+
if not isinstance(base_revision, str) or not isinstance(body_html, str) or not isinstance(css, str):
|
|
470
|
+
raise WorkbenchError("INVALID_SAVE", "保存请求必须包含 baseRevision、bodyHtml 和 css。", 400)
|
|
471
|
+
if len(body_html) > 6_000_000 or len(css) > 1_000_000:
|
|
472
|
+
raise WorkbenchError("INVALID_SAVE", "保存内容超过大小限制。", 400)
|
|
473
|
+
if re.search(r"<(?:html|head|body)\b", body_html, re.IGNORECASE) or re.search(r"<script\b", body_html, re.IGNORECASE):
|
|
474
|
+
raise WorkbenchError("INVALID_SAVE", "bodyHtml 不允许包含 html、head、body 或 script。", 400)
|
|
475
|
+
|
|
476
|
+
current = read_document(target)
|
|
477
|
+
if current["revision"] != base_revision:
|
|
478
|
+
raise WorkbenchError("REVISION_CONFLICT", "磁盘文件已被外部修改,未覆盖最新版。", 409, document=current)
|
|
479
|
+
|
|
480
|
+
source = current["html"]
|
|
481
|
+
body_html = restore_inline_event_attributes(body_html, current["bodyHtml"])
|
|
482
|
+
locations = current["locations"]
|
|
483
|
+
override = f"<style data-grapesjs-overrides>\n{css}\n</style>\n"
|
|
484
|
+
if locations["overrideStart"] is not None:
|
|
485
|
+
source = source[: locations["overrideStart"]] + override + source[locations["overrideEnd"] :]
|
|
486
|
+
else:
|
|
487
|
+
source = source[: locations["headEnd"]] + override + source[locations["headEnd"] :]
|
|
488
|
+
|
|
489
|
+
refreshed = parse_source(source)
|
|
490
|
+
scripts = body.get("bodyScripts")
|
|
491
|
+
if isinstance(scripts, list) and all(isinstance(item, str) and SCRIPT_PATTERN.match(item.strip()) for item in scripts):
|
|
492
|
+
preserved_scripts = "\n".join(scripts)
|
|
493
|
+
else:
|
|
494
|
+
preserved_scripts = "\n".join(current["bodyScripts"])
|
|
495
|
+
next_body = f"{body_html}\n{preserved_scripts}" if preserved_scripts else body_html
|
|
496
|
+
body_start = refreshed["locations"]["bodyStart"]
|
|
497
|
+
body_end = refreshed["locations"]["bodyEnd"]
|
|
498
|
+
source = source[:body_start] + "\n" + next_body + "\n" + source[body_end:]
|
|
499
|
+
|
|
500
|
+
latest = read_document(target)
|
|
501
|
+
if latest["revision"] != base_revision:
|
|
502
|
+
raise WorkbenchError("REVISION_CONFLICT", "保存期间文件发生外部修改,未覆盖最新版。", 409, document=latest)
|
|
503
|
+
|
|
504
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.workbench-", suffix=".tmp", dir=target.parent)
|
|
505
|
+
try:
|
|
506
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as output:
|
|
507
|
+
output.write(source)
|
|
508
|
+
output.flush()
|
|
509
|
+
os.fsync(output.fileno())
|
|
510
|
+
os.chmod(temporary_name, target.stat().st_mode)
|
|
511
|
+
os.replace(temporary_name, target)
|
|
512
|
+
finally:
|
|
513
|
+
if os.path.exists(temporary_name):
|
|
514
|
+
os.unlink(temporary_name)
|
|
515
|
+
return read_document(target)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
class WorkbenchServer(ThreadingHTTPServer):
|
|
519
|
+
daemon_threads = True
|
|
520
|
+
allow_reuse_address = True
|
|
521
|
+
|
|
522
|
+
def __init__(self, address: tuple[str, int], asset_file: Path, editor_root: Path, vendor_files: dict[str, Path]) -> None:
|
|
523
|
+
self.asset_file = asset_file.resolve(strict=True)
|
|
524
|
+
self.editor_root = editor_root.resolve(strict=True)
|
|
525
|
+
self.vendor_files = {name: path.resolve(strict=True) for name, path in vendor_files.items()}
|
|
526
|
+
self.vendor_routes = {str(metadata["route"]): name for name, metadata in VENDOR_ASSETS.items()}
|
|
527
|
+
super().__init__(address, WorkbenchHandler)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
class WorkbenchHandler(BaseHTTPRequestHandler):
|
|
531
|
+
server: WorkbenchServer
|
|
532
|
+
protocol_version = "HTTP/1.1"
|
|
533
|
+
|
|
534
|
+
def log_message(self, format_string: str, *args: Any) -> None:
|
|
535
|
+
sys.stderr.write(f"{self.log_date_time_string()} {format_string % args}\n")
|
|
536
|
+
|
|
537
|
+
def send_bytes(self, status: int, content: bytes, content_type: str, cache: str = "no-store") -> None:
|
|
538
|
+
self.send_response(status)
|
|
539
|
+
self.send_header("Content-Type", content_type)
|
|
540
|
+
self.send_header("Content-Length", str(len(content)))
|
|
541
|
+
if cache == "no-store":
|
|
542
|
+
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
|
543
|
+
self.send_header("Pragma", "no-cache")
|
|
544
|
+
self.send_header("Expires", "0")
|
|
545
|
+
else:
|
|
546
|
+
self.send_header("Cache-Control", cache)
|
|
547
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
548
|
+
self.end_headers()
|
|
549
|
+
self.wfile.write(content)
|
|
550
|
+
|
|
551
|
+
def send_json(self, status: int, payload: dict[str, Any]) -> None:
|
|
552
|
+
content = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
553
|
+
self.send_bytes(status, content, "application/json; charset=utf-8")
|
|
554
|
+
|
|
555
|
+
def send_error_payload(self, caught: Exception) -> None:
|
|
556
|
+
if isinstance(caught, WorkbenchError):
|
|
557
|
+
self.send_json(caught.status, {"error": caught.code, "message": str(caught), **caught.extra})
|
|
558
|
+
else:
|
|
559
|
+
self.send_json(500, {"error": "SERVER_ERROR", "message": str(caught)})
|
|
560
|
+
|
|
561
|
+
def query_file(self, query: dict[str, list[str]]) -> Path:
|
|
562
|
+
return resolve_html_file(query.get("file", [""])[0], self.server.editor_root)
|
|
563
|
+
|
|
564
|
+
def do_GET(self) -> None:
|
|
565
|
+
parsed = urllib.parse.urlsplit(self.path)
|
|
566
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
567
|
+
try:
|
|
568
|
+
if parsed.path == "/api/health":
|
|
569
|
+
self.send_json(200, {
|
|
570
|
+
"ok": True,
|
|
571
|
+
"service": SERVICE_NAME,
|
|
572
|
+
"version": SERVICE_VERSION,
|
|
573
|
+
"port": self.server.server_port,
|
|
574
|
+
"editorRoot": str(self.server.editor_root),
|
|
575
|
+
"capabilities": ["grapesjs-canvas", "autosave", "file-query", "file-watch", "stdlib-python"],
|
|
576
|
+
})
|
|
577
|
+
return
|
|
578
|
+
if parsed.path == "/api/document":
|
|
579
|
+
self.send_json(200, read_document(self.query_file(query)))
|
|
580
|
+
return
|
|
581
|
+
if parsed.path == "/api/events":
|
|
582
|
+
self.stream_events(self.query_file(query))
|
|
583
|
+
return
|
|
584
|
+
if parsed.path.startswith("/assets/"):
|
|
585
|
+
self.send_asset(parsed.path)
|
|
586
|
+
return
|
|
587
|
+
if parsed.path in self.server.vendor_routes:
|
|
588
|
+
name = self.server.vendor_routes[parsed.path]
|
|
589
|
+
metadata = VENDOR_ASSETS[name]
|
|
590
|
+
self.send_bytes(200, self.server.vendor_files[name].read_bytes(), str(metadata["content_type"]), "public, max-age=31536000, immutable")
|
|
591
|
+
return
|
|
592
|
+
self.send_bytes(200, self.server.asset_file.read_bytes(), "text/html; charset=utf-8")
|
|
593
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
594
|
+
return
|
|
595
|
+
except Exception as caught:
|
|
596
|
+
self.send_error_payload(caught)
|
|
597
|
+
|
|
598
|
+
def do_PUT(self) -> None:
|
|
599
|
+
parsed = urllib.parse.urlsplit(self.path)
|
|
600
|
+
if parsed.path != "/api/document":
|
|
601
|
+
self.send_json(404, {"error": "NOT_FOUND", "message": "接口不存在。"})
|
|
602
|
+
return
|
|
603
|
+
try:
|
|
604
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
605
|
+
if length <= 0 or length > MAX_REQUEST_BYTES:
|
|
606
|
+
raise WorkbenchError("INVALID_SAVE", "保存请求大小无效。", 400)
|
|
607
|
+
body = json.loads(self.rfile.read(length).decode("utf-8"))
|
|
608
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
609
|
+
self.send_json(200, save_document(self.query_file(query), body))
|
|
610
|
+
except json.JSONDecodeError as caught:
|
|
611
|
+
self.send_error_payload(WorkbenchError("INVALID_SAVE", f"保存请求不是有效 JSON:{caught}", 400))
|
|
612
|
+
except Exception as caught:
|
|
613
|
+
self.send_error_payload(caught)
|
|
614
|
+
|
|
615
|
+
def send_asset(self, route: str) -> None:
|
|
616
|
+
parts = route.split("/", 3)
|
|
617
|
+
if len(parts) < 4:
|
|
618
|
+
raise WorkbenchError("INVALID_ASSET", "资源地址无效。", 400)
|
|
619
|
+
token = parts[2]
|
|
620
|
+
source_file = resolve_html_file(decode_asset_token(token), self.server.editor_root)
|
|
621
|
+
relative = urllib.parse.unquote(parts[3])
|
|
622
|
+
resource = (source_file.parent / relative).resolve(strict=True)
|
|
623
|
+
if not inside_root(resource, self.server.editor_root):
|
|
624
|
+
raise WorkbenchError("OUTSIDE_EDITOR_ROOT", "资源超出允许目录。", 403)
|
|
625
|
+
content_type = mimetypes.guess_type(resource.name)[0] or "application/octet-stream"
|
|
626
|
+
self.send_bytes(200, resource.read_bytes(), content_type, "private, max-age=60")
|
|
627
|
+
|
|
628
|
+
def stream_events(self, target: Path) -> None:
|
|
629
|
+
self.send_response(200)
|
|
630
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
631
|
+
self.send_header("Cache-Control", "no-cache")
|
|
632
|
+
self.send_header("Connection", "keep-alive")
|
|
633
|
+
self.end_headers()
|
|
634
|
+
last_revision = ""
|
|
635
|
+
while True:
|
|
636
|
+
try:
|
|
637
|
+
document = read_document(target)
|
|
638
|
+
if document["revision"] != last_revision:
|
|
639
|
+
last_revision = document["revision"]
|
|
640
|
+
payload = json.dumps({"revision": last_revision, "mtimeMs": document["mtimeMs"]}, separators=(",", ":"))
|
|
641
|
+
self.wfile.write(f"data: {payload}\n\n".encode("utf-8"))
|
|
642
|
+
self.wfile.flush()
|
|
643
|
+
except FileNotFoundError:
|
|
644
|
+
self.wfile.write(b'event: error\ndata: {"error":"FILE_NOT_FOUND"}\n\n')
|
|
645
|
+
self.wfile.flush()
|
|
646
|
+
time.sleep(0.5)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def default_asset_file(script_file: Path) -> Path:
|
|
650
|
+
packaged = script_file.parent.parent / "assets" / "workbench.html"
|
|
651
|
+
if packaged.is_file():
|
|
652
|
+
return packaged
|
|
653
|
+
raise WorkbenchError("ASSET_NOT_FOUND", f"找不到前端资源:{packaged}", 500)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def health(port: int, timeout: float = 0.5) -> dict[str, Any] | None:
|
|
657
|
+
try:
|
|
658
|
+
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=timeout) as response:
|
|
659
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
660
|
+
return payload if payload.get("service") == SERVICE_NAME else None
|
|
661
|
+
except (OSError, ValueError, urllib.error.URLError):
|
|
662
|
+
return None
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def start_detached(script_file: Path, port: int, editor_root: Path, asset_file: Path | None, vendor_cache: Path) -> tuple[int, Path]:
|
|
666
|
+
log_file = Path(tempfile.gettempdir()) / f"html-workbench-{port}.log"
|
|
667
|
+
command = [sys.executable, str(script_file), "serve", "--port", str(port), "--editor-root", str(editor_root), "--vendor-cache", str(vendor_cache)]
|
|
668
|
+
if asset_file is not None:
|
|
669
|
+
command.extend(["--asset", str(asset_file)])
|
|
670
|
+
flags = 0
|
|
671
|
+
kwargs: dict[str, Any] = {"stdin": subprocess.DEVNULL, "start_new_session": os.name != "nt"}
|
|
672
|
+
if os.name == "nt":
|
|
673
|
+
flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS # type: ignore[attr-defined]
|
|
674
|
+
kwargs["creationflags"] = flags
|
|
675
|
+
kwargs.pop("start_new_session", None)
|
|
676
|
+
with log_file.open("ab") as log:
|
|
677
|
+
process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, close_fds=True, **kwargs)
|
|
678
|
+
return process.pid, log_file
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def command_serve(args: argparse.Namespace, script_file: Path) -> int:
|
|
682
|
+
asset_file = Path(args.asset).expanduser().resolve(strict=True) if args.asset else default_asset_file(script_file)
|
|
683
|
+
editor_root = Path(args.editor_root).expanduser().resolve(strict=True)
|
|
684
|
+
vendor_files = ensure_vendor_assets(Path(args.vendor_cache).expanduser())
|
|
685
|
+
server = WorkbenchServer(("127.0.0.1", args.port), asset_file, editor_root, vendor_files)
|
|
686
|
+
print(json.dumps({"ok": True, "url": f"http://127.0.0.1:{args.port}", "editorRoot": str(editor_root)}, ensure_ascii=False), flush=True)
|
|
687
|
+
try:
|
|
688
|
+
server.serve_forever(poll_interval=0.25)
|
|
689
|
+
except KeyboardInterrupt:
|
|
690
|
+
pass
|
|
691
|
+
finally:
|
|
692
|
+
server.server_close()
|
|
693
|
+
return 0
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def command_open(args: argparse.Namespace, script_file: Path) -> int:
|
|
697
|
+
target = Path(args.file).expanduser().resolve(strict=True)
|
|
698
|
+
editor_root = Path(args.editor_root).expanduser().resolve(strict=True)
|
|
699
|
+
resolve_html_file(str(target), editor_root)
|
|
700
|
+
existing = health(args.port)
|
|
701
|
+
reused = existing is not None
|
|
702
|
+
pid: int | None = None
|
|
703
|
+
log_file: Path | None = None
|
|
704
|
+
if not reused:
|
|
705
|
+
asset_file = Path(args.asset).expanduser().resolve(strict=True) if args.asset else None
|
|
706
|
+
pid, log_file = start_detached(script_file, args.port, editor_root, asset_file, Path(args.vendor_cache).expanduser())
|
|
707
|
+
deadline = time.monotonic() + args.wait
|
|
708
|
+
while time.monotonic() < deadline:
|
|
709
|
+
if health(args.port):
|
|
710
|
+
break
|
|
711
|
+
time.sleep(0.1)
|
|
712
|
+
else:
|
|
713
|
+
raise WorkbenchError("SERVER_UNAVAILABLE", f"服务启动失败,请查看日志:{log_file}", 500)
|
|
714
|
+
query = urllib.parse.urlencode({"file": str(target)})
|
|
715
|
+
print(json.dumps({
|
|
716
|
+
"ok": True,
|
|
717
|
+
"url": f"http://127.0.0.1:{args.port}/?{query}",
|
|
718
|
+
"reused": reused,
|
|
719
|
+
**({"pid": pid} if pid else {}),
|
|
720
|
+
}, ensure_ascii=False))
|
|
721
|
+
return 0
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
725
|
+
parser = argparse.ArgumentParser(description="Start and use the local HTML visual workbench.")
|
|
726
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
727
|
+
|
|
728
|
+
serve = subparsers.add_parser("serve", help="run the service in the foreground")
|
|
729
|
+
serve.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
730
|
+
serve.add_argument("--editor-root", default=str(Path.home()))
|
|
731
|
+
serve.add_argument("--asset", help="override the bundled workbench.html path")
|
|
732
|
+
serve.add_argument("--vendor-cache", default=str(default_vendor_cache()), help="directory for verified GrapesJS files")
|
|
733
|
+
|
|
734
|
+
open_command = subparsers.add_parser("open", help="start or reuse the service and print an editor URL")
|
|
735
|
+
open_command.add_argument("file")
|
|
736
|
+
open_command.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
737
|
+
open_command.add_argument("--editor-root", default=str(Path.home()))
|
|
738
|
+
open_command.add_argument("--asset", help="override the bundled workbench.html path")
|
|
739
|
+
open_command.add_argument("--vendor-cache", default=str(default_vendor_cache()), help="directory for verified GrapesJS files")
|
|
740
|
+
open_command.add_argument("--wait", type=float, default=8.0)
|
|
741
|
+
|
|
742
|
+
health_command = subparsers.add_parser("health", help="check the local service")
|
|
743
|
+
health_command.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
744
|
+
return parser
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def main() -> int:
|
|
748
|
+
if sys.version_info < (3, 9):
|
|
749
|
+
print(json.dumps({"ok": False, "error": "PYTHON_TOO_OLD", "message": "需要 Python 3.9 或更高版本。"}, ensure_ascii=False), file=sys.stderr)
|
|
750
|
+
return 2
|
|
751
|
+
args = build_parser().parse_args()
|
|
752
|
+
script_file = Path(__file__).resolve()
|
|
753
|
+
try:
|
|
754
|
+
if args.command == "serve":
|
|
755
|
+
return command_serve(args, script_file)
|
|
756
|
+
if args.command == "open":
|
|
757
|
+
return command_open(args, script_file)
|
|
758
|
+
payload = health(args.port)
|
|
759
|
+
print(json.dumps(payload or {"ok": False, "error": "SERVER_UNAVAILABLE"}, ensure_ascii=False))
|
|
760
|
+
return 0 if payload else 1
|
|
761
|
+
except WorkbenchError as caught:
|
|
762
|
+
print(json.dumps({"ok": False, "error": caught.code, "message": str(caught)}, ensure_ascii=False), file=sys.stderr)
|
|
763
|
+
return 1
|
|
764
|
+
except FileNotFoundError as caught:
|
|
765
|
+
print(json.dumps({"ok": False, "error": "FILE_NOT_FOUND", "message": str(caught)}, ensure_ascii=False), file=sys.stderr)
|
|
766
|
+
return 1
|
|
767
|
+
except OSError as caught:
|
|
768
|
+
code = "PORT_IN_USE" if getattr(caught, "errno", None) in {48, 98, 10048} else "SERVER_UNAVAILABLE"
|
|
769
|
+
print(json.dumps({"ok": False, "error": code, "message": str(caught)}, ensure_ascii=False), file=sys.stderr)
|
|
770
|
+
return 1
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
if __name__ == "__main__":
|
|
774
|
+
raise SystemExit(main())
|