@xcanwin/manyoyo 5.8.10 → 5.9.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/lib/web/frontend/app.css +196 -1
- package/lib/web/frontend/app.html +20 -0
- package/lib/web/frontend/app.js +118 -2
- package/lib/web/frontend/codemirror-entry.js +98 -0
- package/lib/web/frontend/codemirror.bundle.js +31648 -0
- package/lib/web/frontend/file-browser.js +406 -0
- package/lib/web/frontend/markdown-renderer.js +189 -28
- package/lib/web/frontend/markdown.css +9 -1
- package/lib/web/server.js +230 -1
- package/package.json +15 -1
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
function escapeHtml(value) {
|
|
3
|
+
return String(value == null ? '' : value)
|
|
4
|
+
.replace(/&/g, '&')
|
|
5
|
+
.replace(/</g, '<')
|
|
6
|
+
.replace(/>/g, '>')
|
|
7
|
+
.replace(/"/g, '"')
|
|
8
|
+
.replace(/'/g, ''');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function formatBytes(size) {
|
|
12
|
+
const value = Number(size || 0);
|
|
13
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
14
|
+
return '0 B';
|
|
15
|
+
}
|
|
16
|
+
if (value < 1024) {
|
|
17
|
+
return `${value} B`;
|
|
18
|
+
}
|
|
19
|
+
if (value < 1024 * 1024) {
|
|
20
|
+
return `${(value / 1024).toFixed(1)} KB`;
|
|
21
|
+
}
|
|
22
|
+
if (value < 1024 * 1024 * 1024) {
|
|
23
|
+
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
|
24
|
+
}
|
|
25
|
+
return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatDateTime(value) {
|
|
29
|
+
if (!value) {
|
|
30
|
+
return '未知时间';
|
|
31
|
+
}
|
|
32
|
+
const date = new Date(value);
|
|
33
|
+
if (Number.isNaN(date.getTime())) {
|
|
34
|
+
return '未知时间';
|
|
35
|
+
}
|
|
36
|
+
return date.toLocaleString('zh-CN', {
|
|
37
|
+
month: '2-digit',
|
|
38
|
+
day: '2-digit',
|
|
39
|
+
hour: '2-digit',
|
|
40
|
+
minute: '2-digit'
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildEntryMeta(entry) {
|
|
45
|
+
const parts = [];
|
|
46
|
+
if (entry && entry.kind === 'directory') {
|
|
47
|
+
parts.push('目录');
|
|
48
|
+
} else {
|
|
49
|
+
parts.push(formatBytes(entry && entry.size));
|
|
50
|
+
}
|
|
51
|
+
if (entry && entry.mtimeMs) {
|
|
52
|
+
parts.push(formatDateTime(entry.mtimeMs));
|
|
53
|
+
}
|
|
54
|
+
return parts.join(' · ');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function inferLanguageFromPath(filePath) {
|
|
58
|
+
const text = String(filePath || '').toLowerCase();
|
|
59
|
+
if (text.endsWith('.md') || text.endsWith('.markdown')) return 'markdown';
|
|
60
|
+
if (text.endsWith('.json')) return 'json';
|
|
61
|
+
if (text.endsWith('.py')) return 'python';
|
|
62
|
+
if (text.endsWith('.yaml') || text.endsWith('.yml')) return 'yaml';
|
|
63
|
+
if (text.endsWith('.html') || text.endsWith('.htm')) return 'html';
|
|
64
|
+
if (text.endsWith('.css')) return 'css';
|
|
65
|
+
if (text.endsWith('.js') || text.endsWith('.jsx') || text.endsWith('.mjs') || text.endsWith('.cjs') || text.endsWith('.ts') || text.endsWith('.tsx')) {
|
|
66
|
+
return 'javascript';
|
|
67
|
+
}
|
|
68
|
+
return 'text';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function create(options) {
|
|
72
|
+
const root = options && options.root;
|
|
73
|
+
const api = options && options.api;
|
|
74
|
+
const onError = options && typeof options.onError === 'function'
|
|
75
|
+
? options.onError
|
|
76
|
+
: function (message) { window.alert(message); };
|
|
77
|
+
if (!root || typeof api !== 'function') {
|
|
78
|
+
return {
|
|
79
|
+
sync: function () {}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
root.innerHTML = `
|
|
84
|
+
<section class="files-browser">
|
|
85
|
+
<header class="files-toolbar">
|
|
86
|
+
<button type="button" class="secondary" data-action="up">上一级</button>
|
|
87
|
+
<button type="button" class="secondary" data-action="refresh">刷新</button>
|
|
88
|
+
<div class="files-toolbar-path" data-role="path">/</div>
|
|
89
|
+
<div class="files-toolbar-status" data-role="status">未加载</div>
|
|
90
|
+
</header>
|
|
91
|
+
<div class="files-layout">
|
|
92
|
+
<aside class="files-sidebar">
|
|
93
|
+
<div class="files-list" data-role="list"></div>
|
|
94
|
+
</aside>
|
|
95
|
+
<section class="files-preview">
|
|
96
|
+
<header class="files-preview-head">
|
|
97
|
+
<div class="files-preview-title" data-role="preview-title">未选择文件</div>
|
|
98
|
+
<div class="files-preview-meta" data-role="preview-meta">请选择左侧文件或目录</div>
|
|
99
|
+
</header>
|
|
100
|
+
<div class="files-preview-body" data-role="preview-body"></div>
|
|
101
|
+
</section>
|
|
102
|
+
</div>
|
|
103
|
+
</section>
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
const pathNode = root.querySelector('[data-role="path"]');
|
|
107
|
+
const statusNode = root.querySelector('[data-role="status"]');
|
|
108
|
+
const listNode = root.querySelector('[data-role="list"]');
|
|
109
|
+
const previewTitleNode = root.querySelector('[data-role="preview-title"]');
|
|
110
|
+
const previewMetaNode = root.querySelector('[data-role="preview-meta"]');
|
|
111
|
+
const previewBodyNode = root.querySelector('[data-role="preview-body"]');
|
|
112
|
+
const upBtn = root.querySelector('[data-action="up"]');
|
|
113
|
+
const refreshBtn = root.querySelector('[data-action="refresh"]');
|
|
114
|
+
|
|
115
|
+
const state = {
|
|
116
|
+
visible: false,
|
|
117
|
+
sessionName: '',
|
|
118
|
+
containerName: '',
|
|
119
|
+
containerPath: '',
|
|
120
|
+
historyOnly: false,
|
|
121
|
+
currentPath: '',
|
|
122
|
+
parentPath: '',
|
|
123
|
+
entries: [],
|
|
124
|
+
selectedPath: '',
|
|
125
|
+
selectedFile: null,
|
|
126
|
+
loadingList: false,
|
|
127
|
+
loadingFile: false,
|
|
128
|
+
listRequestId: 0,
|
|
129
|
+
readRequestId: 0,
|
|
130
|
+
editor: null,
|
|
131
|
+
editorHost: null
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
function setStatus(text) {
|
|
135
|
+
if (statusNode) {
|
|
136
|
+
statusNode.textContent = String(text || '').trim() || '就绪';
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function destroyEditor() {
|
|
141
|
+
if (state.editor && typeof state.editor.destroy === 'function') {
|
|
142
|
+
state.editor.destroy();
|
|
143
|
+
}
|
|
144
|
+
state.editor = null;
|
|
145
|
+
state.editorHost = null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function renderPreviewEmpty(title, description) {
|
|
149
|
+
if (previewTitleNode) {
|
|
150
|
+
previewTitleNode.textContent = title;
|
|
151
|
+
}
|
|
152
|
+
if (previewMetaNode) {
|
|
153
|
+
previewMetaNode.textContent = description;
|
|
154
|
+
}
|
|
155
|
+
if (previewBodyNode) {
|
|
156
|
+
previewBodyNode.innerHTML = `<div class="files-empty">${escapeHtml(description)}</div>`;
|
|
157
|
+
}
|
|
158
|
+
destroyEditor();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function ensureEditorHost() {
|
|
162
|
+
if (!previewBodyNode) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
previewBodyNode.innerHTML = '';
|
|
166
|
+
const host = document.createElement('div');
|
|
167
|
+
host.className = 'files-editor-host';
|
|
168
|
+
previewBodyNode.appendChild(host);
|
|
169
|
+
state.editorHost = host;
|
|
170
|
+
return host;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function renderPreviewPayload(payload) {
|
|
174
|
+
state.selectedFile = payload || null;
|
|
175
|
+
if (!payload) {
|
|
176
|
+
renderPreviewEmpty('未选择文件', '请选择左侧文件进行预览。');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (previewTitleNode) {
|
|
180
|
+
previewTitleNode.textContent = payload.path || '未命名文件';
|
|
181
|
+
}
|
|
182
|
+
if (previewMetaNode) {
|
|
183
|
+
previewMetaNode.textContent = `${payload.kind === 'text' ? '文本文件' : '文件'} · ${formatBytes(payload.size)}${payload.truncated ? ' · 已截断预览' : ''}`;
|
|
184
|
+
}
|
|
185
|
+
if (!previewBodyNode) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (payload.kind === 'text') {
|
|
190
|
+
const language = payload.language || inferLanguageFromPath(payload.path);
|
|
191
|
+
if (window.ManyoyoCodeEditor && typeof window.ManyoyoCodeEditor.create === 'function') {
|
|
192
|
+
if (!state.editor || !state.editorHost || !previewBodyNode.contains(state.editorHost)) {
|
|
193
|
+
destroyEditor();
|
|
194
|
+
const host = ensureEditorHost();
|
|
195
|
+
if (host) {
|
|
196
|
+
state.editor = window.ManyoyoCodeEditor.create(host, {
|
|
197
|
+
doc: String(payload.content || ''),
|
|
198
|
+
language,
|
|
199
|
+
readOnly: true
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
state.editor.setValue(String(payload.content || ''));
|
|
204
|
+
state.editor.setLanguage(language);
|
|
205
|
+
state.editor.setReadOnly(true);
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
destroyEditor();
|
|
211
|
+
previewBodyNode.innerHTML = `<pre class="files-pre">${escapeHtml(String(payload.content || ''))}</pre>`;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
destroyEditor();
|
|
216
|
+
previewBodyNode.innerHTML = `<div class="files-note">当前文件暂不支持在线预览。文件类型:${escapeHtml(payload.kind || 'unknown')}</div>`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function renderList() {
|
|
220
|
+
if (pathNode) {
|
|
221
|
+
pathNode.textContent = state.currentPath || state.containerPath || '/';
|
|
222
|
+
}
|
|
223
|
+
if (upBtn) {
|
|
224
|
+
upBtn.disabled = state.loadingList || !state.parentPath;
|
|
225
|
+
}
|
|
226
|
+
if (refreshBtn) {
|
|
227
|
+
refreshBtn.disabled = state.loadingList || state.loadingFile;
|
|
228
|
+
}
|
|
229
|
+
if (!listNode) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
listNode.innerHTML = '';
|
|
233
|
+
|
|
234
|
+
if (!state.sessionName) {
|
|
235
|
+
listNode.innerHTML = '<div class="files-empty">请选择左侧会话后再浏览容器文件。</div>';
|
|
236
|
+
renderPreviewEmpty('未选择会话', '请选择左侧会话后再浏览容器文件。');
|
|
237
|
+
setStatus('未选择会话');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (state.historyOnly) {
|
|
241
|
+
listNode.innerHTML = '<div class="files-empty">当前会话只有历史记录,没有可访问的运行中容器。</div>';
|
|
242
|
+
renderPreviewEmpty('容器不可用', '当前会话只有历史记录,没有可访问的运行中容器。');
|
|
243
|
+
setStatus('容器不可用');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (state.loadingList) {
|
|
247
|
+
listNode.innerHTML = '<div class="files-empty">正在读取目录...</div>';
|
|
248
|
+
setStatus('读取目录中');
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (!state.entries.length) {
|
|
252
|
+
listNode.innerHTML = '<div class="files-empty">当前目录为空。</div>';
|
|
253
|
+
setStatus('目录为空');
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
state.entries.forEach(function (entry) {
|
|
258
|
+
const button = document.createElement('button');
|
|
259
|
+
button.type = 'button';
|
|
260
|
+
button.className = 'files-entry' + (state.selectedPath === entry.path ? ' is-active' : '');
|
|
261
|
+
button.title = String(entry.path || entry.name || '');
|
|
262
|
+
button.addEventListener('click', function () {
|
|
263
|
+
if (entry.kind === 'directory') {
|
|
264
|
+
loadDirectory(entry.path);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
loadFile(entry.path);
|
|
268
|
+
});
|
|
269
|
+
button.innerHTML = `
|
|
270
|
+
<span class="files-entry-name">
|
|
271
|
+
<span class="files-entry-title">${escapeHtml(entry.name || entry.path || '未命名')}</span>
|
|
272
|
+
</span>
|
|
273
|
+
<span class="files-entry-meta">${escapeHtml(buildEntryMeta(entry))}</span>
|
|
274
|
+
`;
|
|
275
|
+
listNode.appendChild(button);
|
|
276
|
+
});
|
|
277
|
+
setStatus(state.loadingFile ? '读取文件中' : `共 ${state.entries.length} 项`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function loadDirectory(targetPath) {
|
|
281
|
+
const pathText = String(targetPath || state.currentPath || state.containerPath || '/').trim() || '/';
|
|
282
|
+
const requestId = state.listRequestId + 1;
|
|
283
|
+
state.listRequestId = requestId;
|
|
284
|
+
state.loadingList = true;
|
|
285
|
+
state.selectedPath = '';
|
|
286
|
+
renderList();
|
|
287
|
+
try {
|
|
288
|
+
const payload = await api('/api/sessions/' + encodeURIComponent(state.sessionName) + '/fs/list?path=' + encodeURIComponent(pathText));
|
|
289
|
+
if (requestId !== state.listRequestId) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
state.currentPath = payload && payload.path ? payload.path : pathText;
|
|
293
|
+
state.parentPath = payload && payload.parentPath ? payload.parentPath : '';
|
|
294
|
+
state.entries = Array.isArray(payload && payload.entries) ? payload.entries : [];
|
|
295
|
+
renderPreviewEmpty(state.currentPath, '请选择左侧文件进行预览。');
|
|
296
|
+
} catch (e) {
|
|
297
|
+
if (requestId !== state.listRequestId) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
onError(e && e.message ? e.message : '读取目录失败');
|
|
301
|
+
} finally {
|
|
302
|
+
if (requestId !== state.listRequestId) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
state.loadingList = false;
|
|
306
|
+
renderList();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function loadFile(targetPath) {
|
|
311
|
+
const pathText = String(targetPath || '').trim();
|
|
312
|
+
if (!pathText) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const requestId = state.readRequestId + 1;
|
|
316
|
+
state.readRequestId = requestId;
|
|
317
|
+
state.loadingFile = true;
|
|
318
|
+
state.selectedPath = pathText;
|
|
319
|
+
renderList();
|
|
320
|
+
renderPreviewEmpty(pathText, '正在读取文件内容...');
|
|
321
|
+
try {
|
|
322
|
+
const payload = await api('/api/sessions/' + encodeURIComponent(state.sessionName) + '/fs/read?path=' + encodeURIComponent(pathText));
|
|
323
|
+
if (requestId !== state.readRequestId) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
renderPreviewPayload(payload);
|
|
327
|
+
} catch (e) {
|
|
328
|
+
if (requestId !== state.readRequestId) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
renderPreviewEmpty(pathText, e && e.message ? e.message : '读取文件失败');
|
|
332
|
+
onError(e && e.message ? e.message : '读取文件失败');
|
|
333
|
+
} finally {
|
|
334
|
+
if (requestId !== state.readRequestId) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
state.loadingFile = false;
|
|
338
|
+
renderList();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function sync(context) {
|
|
343
|
+
const session = context && context.session;
|
|
344
|
+
const detail = context && context.detail;
|
|
345
|
+
const nextSessionName = String(session && session.name ? session.name : '').trim();
|
|
346
|
+
const nextContainerName = String(session && session.containerName ? session.containerName : '').trim();
|
|
347
|
+
const nextContainerPath = String(
|
|
348
|
+
(detail && detail.containerPath)
|
|
349
|
+
|| (session && session.containerPath)
|
|
350
|
+
|| '/'
|
|
351
|
+
).trim() || '/';
|
|
352
|
+
const nextVisible = Boolean(context && context.visible);
|
|
353
|
+
const nextHistoryOnly = context && context.historyOnly === true;
|
|
354
|
+
const sessionChanged = nextSessionName !== state.sessionName;
|
|
355
|
+
const containerPathChanged = nextContainerPath !== state.containerPath;
|
|
356
|
+
|
|
357
|
+
state.visible = nextVisible;
|
|
358
|
+
state.historyOnly = nextHistoryOnly;
|
|
359
|
+
|
|
360
|
+
if (sessionChanged) {
|
|
361
|
+
state.sessionName = nextSessionName;
|
|
362
|
+
state.containerName = nextContainerName;
|
|
363
|
+
state.containerPath = nextContainerPath;
|
|
364
|
+
state.currentPath = '';
|
|
365
|
+
state.parentPath = '';
|
|
366
|
+
state.entries = [];
|
|
367
|
+
state.selectedPath = '';
|
|
368
|
+
renderPreviewEmpty('未选择文件', '请选择左侧文件进行预览。');
|
|
369
|
+
} else if (containerPathChanged) {
|
|
370
|
+
state.containerPath = nextContainerPath;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
renderList();
|
|
374
|
+
if (!nextVisible || !state.sessionName || state.historyOnly) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (sessionChanged || containerPathChanged || !state.currentPath) {
|
|
378
|
+
loadDirectory(state.containerPath || '/');
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (upBtn) {
|
|
383
|
+
upBtn.addEventListener('click', function () {
|
|
384
|
+
if (state.parentPath) {
|
|
385
|
+
loadDirectory(state.parentPath);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (refreshBtn) {
|
|
391
|
+
refreshBtn.addEventListener('click', function () {
|
|
392
|
+
loadDirectory(state.currentPath || state.containerPath || '/');
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
renderList();
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
sync
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
window.ManyoyoFileBrowser = {
|
|
404
|
+
create
|
|
405
|
+
};
|
|
406
|
+
}());
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
(function () {
|
|
2
|
-
const
|
|
2
|
+
const MARKDOWN_LINK_PROTOCOL_PATTERN = /^(https?:|mailto:|tel:)/i;
|
|
3
|
+
const MARKDOWN_IMAGE_PROTOCOL_PATTERN = /^(https?:)/i;
|
|
3
4
|
const runtime = {
|
|
4
5
|
configured: false,
|
|
5
|
-
available: false
|
|
6
|
+
available: false,
|
|
7
|
+
linkGuardBound: false,
|
|
8
|
+
linkOpenHandler: null
|
|
6
9
|
};
|
|
7
10
|
|
|
8
11
|
function escapeHtml(value) {
|
|
@@ -14,26 +17,175 @@
|
|
|
14
17
|
.replace(/'/g, ''');
|
|
15
18
|
}
|
|
16
19
|
|
|
17
|
-
function
|
|
20
|
+
function normalizeRendererValue(value) {
|
|
21
|
+
if (value && typeof value === 'object') {
|
|
22
|
+
if (typeof value.href === 'string') {
|
|
23
|
+
return value.href;
|
|
24
|
+
}
|
|
25
|
+
if (typeof value.text === 'string') {
|
|
26
|
+
return value.text;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return String(value == null ? '' : value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sanitizeMarkdownLinkUrl(value) {
|
|
18
33
|
const raw = String(value == null ? '' : value).trim();
|
|
19
34
|
if (!raw) {
|
|
20
35
|
return '';
|
|
21
36
|
}
|
|
22
|
-
if (raw
|
|
37
|
+
if (MARKDOWN_LINK_PROTOCOL_PATTERN.test(raw)) {
|
|
23
38
|
return raw;
|
|
24
39
|
}
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sanitizeMarkdownImageUrl(value) {
|
|
44
|
+
const raw = String(value == null ? '' : value).trim();
|
|
45
|
+
if (!raw) {
|
|
46
|
+
return '';
|
|
47
|
+
}
|
|
25
48
|
if (raw[0] === '/') {
|
|
26
49
|
return raw.startsWith('//') ? '' : raw;
|
|
27
50
|
}
|
|
28
51
|
if (raw.startsWith('./') || raw.startsWith('../')) {
|
|
29
52
|
return raw;
|
|
30
53
|
}
|
|
31
|
-
if (
|
|
54
|
+
if (MARKDOWN_IMAGE_PROTOCOL_PATTERN.test(raw)) {
|
|
32
55
|
return raw;
|
|
33
56
|
}
|
|
34
57
|
return '';
|
|
35
58
|
}
|
|
36
59
|
|
|
60
|
+
function getRendererToken(value) {
|
|
61
|
+
return value && typeof value === 'object' ? value : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderInlineTokens(rendererContext, token, fallbackText) {
|
|
65
|
+
if (
|
|
66
|
+
token
|
|
67
|
+
&& Array.isArray(token.tokens)
|
|
68
|
+
&& rendererContext
|
|
69
|
+
&& rendererContext.parser
|
|
70
|
+
&& typeof rendererContext.parser.parseInline === 'function'
|
|
71
|
+
) {
|
|
72
|
+
try {
|
|
73
|
+
return String(rendererContext.parser.parseInline(token.tokens) || '');
|
|
74
|
+
} catch (e) {
|
|
75
|
+
// ignore and fallback to plain text below
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (token && typeof token.text === 'string') {
|
|
79
|
+
return escapeHtml(token.text);
|
|
80
|
+
}
|
|
81
|
+
return escapeHtml(fallbackText || '');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildSafeAnchorHtml(href, title, content, extraAttrs) {
|
|
85
|
+
let output = '<a href="' + escapeHtml(href) + '" target="_blank" rel="noopener noreferrer"'
|
|
86
|
+
+ ' referrerpolicy="no-referrer" data-safe-external-link="true"'
|
|
87
|
+
+ ' data-safe-href="' + escapeHtml(href) + '"';
|
|
88
|
+
if (title) {
|
|
89
|
+
output += ' title="' + escapeHtml(title) + '"';
|
|
90
|
+
}
|
|
91
|
+
if (extraAttrs) {
|
|
92
|
+
output += extraAttrs;
|
|
93
|
+
}
|
|
94
|
+
output += '>' + content + '</a>';
|
|
95
|
+
return output;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function openExternalLinkWithNoReferrer(href) {
|
|
99
|
+
const url = String(href || '').trim();
|
|
100
|
+
if (!url || typeof document === 'undefined' || !document || typeof document.createElement !== 'function') {
|
|
101
|
+
if (typeof window.open === 'function') {
|
|
102
|
+
const opened = window.open(url, '_blank', 'noopener,noreferrer');
|
|
103
|
+
if (opened) {
|
|
104
|
+
opened.opener = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const anchor = document.createElement('a');
|
|
111
|
+
anchor.href = url;
|
|
112
|
+
anchor.target = '_blank';
|
|
113
|
+
anchor.rel = 'noopener noreferrer';
|
|
114
|
+
anchor.referrerPolicy = 'no-referrer';
|
|
115
|
+
anchor.style.display = 'none';
|
|
116
|
+
document.body.appendChild(anchor);
|
|
117
|
+
try {
|
|
118
|
+
if (typeof anchor.click === 'function') {
|
|
119
|
+
anchor.click();
|
|
120
|
+
} else if (typeof window.open === 'function') {
|
|
121
|
+
const opened = window.open(url, '_blank', 'noopener,noreferrer');
|
|
122
|
+
if (opened) {
|
|
123
|
+
opened.opener = null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
if (anchor.parentNode && typeof anchor.parentNode.removeChild === 'function') {
|
|
128
|
+
anchor.parentNode.removeChild(anchor);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function requestExternalLinkOpen(href) {
|
|
134
|
+
const url = String(href || '').trim();
|
|
135
|
+
if (!url) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (typeof runtime.linkOpenHandler === 'function') {
|
|
139
|
+
runtime.linkOpenHandler(url);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const shouldOpen = typeof window.confirm === 'function'
|
|
144
|
+
? window.confirm('即将打开外部链接:\n' + url + '\n\n确认继续打开?')
|
|
145
|
+
: true;
|
|
146
|
+
if (!shouldOpen) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
openExternalLinkWithNoReferrer(url);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function bindDocumentLinkGuard() {
|
|
154
|
+
if (runtime.linkGuardBound || typeof document === 'undefined' || !document || typeof document.addEventListener !== 'function') {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
document.addEventListener('click', function (event) {
|
|
159
|
+
const target = event && event.target;
|
|
160
|
+
if (!target || typeof target.closest !== 'function') {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const link = target.closest('a[data-safe-external-link="true"]');
|
|
164
|
+
if (!link) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (event.preventDefault) {
|
|
168
|
+
event.preventDefault();
|
|
169
|
+
}
|
|
170
|
+
if (event.stopPropagation) {
|
|
171
|
+
event.stopPropagation();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const href = String(
|
|
175
|
+
(typeof link.getAttribute === 'function' && (link.getAttribute('data-safe-href') || link.getAttribute('href')))
|
|
176
|
+
|| link.href
|
|
177
|
+
|| ''
|
|
178
|
+
).trim();
|
|
179
|
+
if (!href) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
requestExternalLinkOpen(href);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
runtime.linkGuardBound = true;
|
|
187
|
+
}
|
|
188
|
+
|
|
37
189
|
function getMarkedApi() {
|
|
38
190
|
const api = window.marked;
|
|
39
191
|
if (!api || typeof api.parse !== 'function') {
|
|
@@ -57,45 +209,48 @@
|
|
|
57
209
|
try {
|
|
58
210
|
const renderer = new markedApi.Renderer();
|
|
59
211
|
renderer.html = function (html) {
|
|
60
|
-
|
|
212
|
+
const token = getRendererToken(html);
|
|
213
|
+
return escapeHtml(token ? token.text : html);
|
|
61
214
|
};
|
|
62
215
|
renderer.link = function (href, title, text) {
|
|
63
|
-
const
|
|
216
|
+
const token = getRendererToken(href);
|
|
217
|
+
const rawHref = token ? token.href : normalizeRendererValue(href);
|
|
218
|
+
const rawTitle = token ? token.title : title;
|
|
219
|
+
const safeHref = sanitizeMarkdownLinkUrl(rawHref);
|
|
220
|
+
const safeText = renderInlineTokens(this, token, text)
|
|
221
|
+
// [P1-02] 移除 marked 已渲染链接文本中的 on* 事件属性,防止内联 HTML 注入 XSS
|
|
222
|
+
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '');
|
|
64
223
|
if (!safeHref) {
|
|
65
|
-
return escapeHtml(text || '');
|
|
66
|
-
}
|
|
67
|
-
// [P1-02] 移除 marked 已渲染链接文本中的 on* 事件属性,防止内联 HTML 注入 XSS
|
|
68
|
-
const safeText = String(text || '').replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '');
|
|
69
|
-
let output = '<a href="' + escapeHtml(safeHref) + '" target="_blank" rel="noopener noreferrer"';
|
|
70
|
-
if (title) {
|
|
71
|
-
output += ' title="' + escapeHtml(title) + '"';
|
|
224
|
+
return safeText || escapeHtml(token ? token.text : text || '');
|
|
72
225
|
}
|
|
73
|
-
|
|
74
|
-
return output;
|
|
226
|
+
return buildSafeAnchorHtml(safeHref, rawTitle, safeText || escapeHtml(safeHref));
|
|
75
227
|
};
|
|
76
228
|
// [P1-01] 重写 image 渲染器:
|
|
77
229
|
// - 外部 http/https 图片转为可点击链接,避免浏览器自动发起外部请求(追踪像素风险)
|
|
78
230
|
// - 相对路径图片正常渲染为 <img>
|
|
79
231
|
// - 危险协议(javascript:/data: 等)降级为纯文本
|
|
80
232
|
renderer.image = function (href, title, text) {
|
|
81
|
-
const
|
|
233
|
+
const token = getRendererToken(href);
|
|
234
|
+
const rawHref = token ? token.href : normalizeRendererValue(href);
|
|
235
|
+
const rawTitle = token ? token.title : title;
|
|
236
|
+
const safeHref = sanitizeMarkdownImageUrl(rawHref);
|
|
237
|
+
const safeText = renderInlineTokens(this, token, text)
|
|
238
|
+
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '');
|
|
82
239
|
if (!safeHref) {
|
|
83
|
-
return escapeHtml(text || '');
|
|
240
|
+
return safeText || escapeHtml(token ? token.text : text || '');
|
|
84
241
|
}
|
|
85
242
|
// 外部绝对 URL:转为链接,用户主动决定是否访问
|
|
86
243
|
if (/^https?:/i.test(safeHref)) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
return output + '>[\uD83D\uDDBC\uFE0F点击查看图片:' + safeText + ']</a>';
|
|
244
|
+
return buildSafeAnchorHtml(
|
|
245
|
+
safeHref,
|
|
246
|
+
rawTitle,
|
|
247
|
+
'[\uD83D\uDDBC\uFE0F点击查看图片:' + (safeText || escapeHtml(safeHref)) + ']'
|
|
248
|
+
);
|
|
94
249
|
}
|
|
95
250
|
// 相对路径:正常渲染为图片
|
|
96
251
|
let output = '<img src="' + escapeHtml(safeHref) + '" alt="' + escapeHtml(text || '') + '"';
|
|
97
|
-
if (
|
|
98
|
-
output += ' title="' + escapeHtml(
|
|
252
|
+
if (rawTitle) {
|
|
253
|
+
output += ' title="' + escapeHtml(rawTitle) + '"';
|
|
99
254
|
}
|
|
100
255
|
return output + '>';
|
|
101
256
|
};
|
|
@@ -138,6 +293,12 @@
|
|
|
138
293
|
|
|
139
294
|
window.ManyoyoMarkdown = {
|
|
140
295
|
shouldRenderMessage,
|
|
141
|
-
render
|
|
296
|
+
render,
|
|
297
|
+
openExternalLink: openExternalLinkWithNoReferrer,
|
|
298
|
+
setLinkOpenHandler: function (handler) {
|
|
299
|
+
runtime.linkOpenHandler = typeof handler === 'function' ? handler : null;
|
|
300
|
+
}
|
|
142
301
|
};
|
|
302
|
+
|
|
303
|
+
bindDocumentLinkGuard();
|
|
143
304
|
}());
|