dsh-plugin-yanyan-toolkit 1.2.0 → 1.4.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.en.md +82 -0
  3. package/README.i18n.yaml +7 -6
  4. package/README.md +53 -37
  5. package/README.zh.md +24 -8
  6. package/cordis.patch.yml +10 -2
  7. package/icon.svg +5 -8
  8. package/lib/client.js +179 -4
  9. package/lib/index.js +120 -15
  10. package/locale/en.json +1 -1
  11. package/locale/zh.json +1 -1
  12. package/package.json +5 -2
  13. package/packages/dsh-plugin-yanyan-icon/README.md +33 -0
  14. package/packages/dsh-plugin-yanyan-icon/README.zh.md +33 -0
  15. package/packages/dsh-plugin-yanyan-icon/cordis.patch.yml +7 -0
  16. package/packages/dsh-plugin-yanyan-icon/icon.svg +7 -0
  17. package/packages/dsh-plugin-yanyan-icon/lib/client.js +388 -0
  18. package/packages/dsh-plugin-yanyan-icon/lib/index.js +237 -0
  19. package/packages/dsh-plugin-yanyan-icon/locale/en.json +6 -0
  20. package/packages/dsh-plugin-yanyan-icon/locale/zh.json +6 -0
  21. package/packages/dsh-plugin-yanyan-icon/package.json +32 -0
  22. package/packages/dsh-plugin-yanyan-subagent/README.md +3 -2
  23. package/packages/dsh-plugin-yanyan-subagent/README.zh.md +3 -2
  24. package/packages/dsh-plugin-yanyan-subagent/lib/client.js +157 -33
  25. package/packages/dsh-plugin-yanyan-subagent/lib/index.js +45 -4
  26. package/packages/dsh-plugin-yanyan-subagent/lib/legacy-hidden-ids.json +29 -9
  27. package/packages/dsh-plugin-yanyan-subagent/lib/log-rewrite.js +29 -0
  28. package/packages/dsh-plugin-yanyan-subagent/locale/en.json +1 -1
  29. package/packages/dsh-plugin-yanyan-subagent/locale/zh.json +1 -1
  30. package/packages/dsh-plugin-yanyan-subagent/package.json +2 -2
  31. package/packages/dsh-plugin-yanyan-update/README.md +1 -0
  32. package/packages/dsh-plugin-yanyan-update/README.zh.md +1 -0
  33. package/packages/dsh-plugin-yanyan-update/lib/index.js +86 -2
  34. package/packages/dsh-plugin-yanyan-update/package.json +1 -1
@@ -0,0 +1,388 @@
1
+ // dsh-plugin-yanyan-icon — 浏览器半区(__ModuleLoader__ 装载形态)。
2
+ //
3
+ // 严格照抄 dsh-plugin-yanyan-update/lib/client.js 的装载 / 座位 / MutationObserver /
4
+ // 单 ctx.effect 清理骨架,零第三方依赖、不依赖 React。所有 UI 文案中文,禁用 emoji。
5
+ //
6
+ // 流程:选图 -> 原生 Canvas 居中裁切正方形并缩到 48x48 PNG -> POST 到 host
7
+ // /generate 拿到自包含 SVG -> 按插件列表 30px 行 / 36px 卡片尺寸预览 -> 下载 icon.svg。
8
+
9
+ (function (root) {
10
+ if (!root) return;
11
+ if (!root.__ModuleLoader__ || typeof root.__ModuleLoader__.load !== 'function') return;
12
+
13
+ root.__ModuleLoader__.load({
14
+ id: 'dsh-plugin-yanyan-icon',
15
+ factory: () => {
16
+ var module = { exports: {} };
17
+ var exports = module.exports;
18
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
19
+
20
+ var BASE = '/__dsh-plugin-yanyan-icon';
21
+ var CANVAS_SIZE = 48;
22
+ var MARK = 'data-yanyan-icon-toolbar';
23
+
24
+ // 文案全部中文,不接入 locale 服务,零依赖零异常风险。
25
+ var T = {
26
+ open: '图标生成',
27
+ close: '关闭',
28
+ chooseHint: '选择 PNG / JPEG / WebP 图片,自动居中裁切为正方形。',
29
+ empty: '尚未生成图标',
30
+ working: '正在生成…',
31
+ ok: '已生成,可预览并下载 icon.svg',
32
+ badFile: '请选择 PNG / JPEG / WebP 图片',
33
+ badImage: '图片读取失败,请换一张',
34
+ fail: '生成失败,请重试',
35
+ download: '下载 icon.svg',
36
+ reset: '重新选择',
37
+ rowLabel: '列表行 30px',
38
+ cardLabel: '详情卡片 36px',
39
+ usage: '把下载的 icon.svg 放到插件包根目录,并在 package.json 里声明 "icon": "icon.svg" 与 exports["./icon"],重启 DSH 后即可在插件列表显示。',
40
+ };
41
+
42
+ var CSS = [
43
+ '.yyic-root{position:relative;display:inline-flex;align-items:center;margin-left:8px;font-family:inherit;vertical-align:middle;}',
44
+ '.yyic-main{background:var(--dsw-alias-button-primary-fill,#4f46e5);color:var(--dsw-alias-label-primary-foreground,#fff);border:0;border-radius:6px;padding:4px 12px;font-size:12px;line-height:1.6;cursor:pointer;}',
45
+ '.yyic-main:hover{background:var(--dsw-alias-button-primary-hover,#4338ca);}',
46
+ '.yyic-panel{position:absolute;top:calc(100% + 6px);right:0;z-index:50;width:320px;max-width:82vw;box-sizing:border-box;background:var(--yyic-panel-bg,#ffffff);color:var(--yyic-panel-fg,#374151);border:1px solid var(--yyic-panel-border,#e5e7eb);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.18);padding:12px;font-size:12px;}',
47
+ '.yyic-head{font-weight:600;margin-bottom:8px;color:var(--yyic-head-fg,#111827);}',
48
+ '.yyic-hint{color:#6b7280;line-height:1.6;margin-bottom:8px;}',
49
+ '.yyic-file{display:block;width:100%;box-sizing:border-box;font-size:12px;margin-bottom:10px;}',
50
+ '.yyic-previews{display:flex;align-items:flex-end;gap:20px;justify-content:center;padding:12px 4px;border:1px dashed var(--yyic-panel-border,#d1d5db);border-radius:8px;margin-bottom:10px;background:var(--yyic-preview-bg,#f9fafb);}',
51
+ '.yyic-pv{text-align:center;}',
52
+ '.yyic-pv img{display:block;margin:0 auto 6px;border-radius:6px;background:#eef2ff;border:1px solid #c7d2fe;}',
53
+ '.yyic-pv span{color:#6b7280;}',
54
+ '.yyic-foot{display:flex;justify-content:flex-end;gap:8px;margin-top:6px;}',
55
+ '.yyic-btn{background:var(--dsw-alias-button-primary-fill,#4f46e5);color:var(--dsw-alias-label-primary-foreground,#fff);border:0;border-radius:6px;padding:4px 12px;font-size:12px;cursor:pointer;}',
56
+ '.yyic-btn:disabled{opacity:.5;cursor:default;}',
57
+ '.yyic-ghost{background:transparent;border:1px solid var(--yyic-panel-border,#d1d5db);border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer;color:inherit;}',
58
+ '.yyic-status{margin-top:8px;color:#b45309;min-height:16px;}',
59
+ '.yyic-usage{margin-top:10px;color:#6b7280;line-height:1.6;border-top:1px solid var(--yyic-panel-border,#f3f4f6);padding-top:8px;}',
60
+ ].join('\n');
61
+
62
+ var inject = [];
63
+
64
+ function apply(ctx) {
65
+ var container = null;
66
+ var mainBtn = null;
67
+ var panel = null;
68
+ var fileInput = null;
69
+ var previewRow = null;
70
+ var previewCard = null;
71
+ var statusEl = null;
72
+ var downloadBtn = null;
73
+ var generatedSvg = null;
74
+ var objectUrl = null;
75
+ var busy = false;
76
+ var ensureTimer = null;
77
+ var observer = null;
78
+
79
+ function seat() {
80
+ try {
81
+ var group = document.querySelector('[data-plugin-panel] [data-plugin-group="bundles"]');
82
+ if (!group) return null;
83
+ return group.firstElementChild || null;
84
+ } catch (e) {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ function makePreview(label, px) {
90
+ var wrap = document.createElement('div');
91
+ wrap.className = 'yyic-pv';
92
+ var img = document.createElement('img');
93
+ img.width = px;
94
+ img.height = px;
95
+ img.alt = '';
96
+ img.style.width = px + 'px';
97
+ img.style.height = px + 'px';
98
+ wrap.appendChild(img);
99
+ var cap = document.createElement('span');
100
+ cap.textContent = label;
101
+ wrap.appendChild(cap);
102
+ return { wrap: wrap, img: img };
103
+ }
104
+
105
+ function buildContainer() {
106
+ var c = document.createElement('div');
107
+ c.setAttribute('data-dsh-plugin', 'yanyan-icon');
108
+ c.setAttribute('data-dsh-part', 'icon-toolbar');
109
+ c.setAttribute(MARK, '');
110
+ c.className = 'yyic-root';
111
+ c.setAttribute('role', 'group');
112
+
113
+ var style = document.createElement('style');
114
+ style.setAttribute('data-yyic-style', '');
115
+ style.textContent = CSS;
116
+ c.appendChild(style);
117
+
118
+ mainBtn = document.createElement('button');
119
+ mainBtn.type = 'button';
120
+ mainBtn.className = 'yyic-main';
121
+ mainBtn.setAttribute('data-yyic-toggle', '');
122
+ mainBtn.setAttribute('aria-haspopup', 'true');
123
+ mainBtn.setAttribute('aria-expanded', 'false');
124
+ mainBtn.textContent = T.open;
125
+ c.appendChild(mainBtn);
126
+
127
+ panel = document.createElement('div');
128
+ panel.className = 'yyic-panel';
129
+ panel.setAttribute('role', 'region');
130
+ panel.setAttribute('aria-label', T.open);
131
+ panel.style.display = 'none';
132
+
133
+ var head = document.createElement('div');
134
+ head.className = 'yyic-head';
135
+ head.textContent = T.open;
136
+ panel.appendChild(head);
137
+
138
+ var hint = document.createElement('div');
139
+ hint.className = 'yyic-hint';
140
+ hint.textContent = T.chooseHint;
141
+ panel.appendChild(hint);
142
+
143
+ fileInput = document.createElement('input');
144
+ fileInput.type = 'file';
145
+ fileInput.accept = 'image/png,image/jpeg,image/webp';
146
+ fileInput.className = 'yyic-file';
147
+ fileInput.setAttribute('aria-label', T.open);
148
+ try { fileInput.addEventListener('change', onFileChange); } catch (e) {}
149
+ panel.appendChild(fileInput);
150
+
151
+ var previews = document.createElement('div');
152
+ previews.className = 'yyic-previews';
153
+ previewRow = makePreview(T.rowLabel, 30);
154
+ previewCard = makePreview(T.cardLabel, 36);
155
+ previews.appendChild(previewRow.wrap);
156
+ previews.appendChild(previewCard.wrap);
157
+ panel.appendChild(previews);
158
+
159
+ statusEl = document.createElement('div');
160
+ statusEl.className = 'yyic-status';
161
+ statusEl.textContent = T.empty;
162
+ panel.appendChild(statusEl);
163
+
164
+ var foot = document.createElement('div');
165
+ foot.className = 'yyic-foot';
166
+ downloadBtn = document.createElement('button');
167
+ downloadBtn.type = 'button';
168
+ downloadBtn.className = 'yyic-btn';
169
+ downloadBtn.setAttribute('data-yyic-download', '');
170
+ downloadBtn.textContent = T.download;
171
+ downloadBtn.disabled = true;
172
+ foot.appendChild(downloadBtn);
173
+ var resetBtn = document.createElement('button');
174
+ resetBtn.type = 'button';
175
+ resetBtn.className = 'yyic-ghost';
176
+ resetBtn.setAttribute('data-yyic-reset', '');
177
+ resetBtn.textContent = T.reset;
178
+ foot.appendChild(resetBtn);
179
+ var closeBtn = document.createElement('button');
180
+ closeBtn.type = 'button';
181
+ closeBtn.className = 'yyic-ghost';
182
+ closeBtn.setAttribute('data-yyic-close', '');
183
+ closeBtn.textContent = T.close;
184
+ foot.appendChild(closeBtn);
185
+ panel.appendChild(foot);
186
+
187
+ var usage = document.createElement('div');
188
+ usage.className = 'yyic-usage';
189
+ usage.textContent = T.usage;
190
+ panel.appendChild(usage);
191
+
192
+ try { c.addEventListener('click', onClick); } catch (e) {}
193
+ c.appendChild(panel);
194
+ return c;
195
+ }
196
+
197
+ function openPanel() {
198
+ if (!panel || !mainBtn) return;
199
+ panel.style.display = 'block';
200
+ mainBtn.setAttribute('aria-expanded', 'true');
201
+ }
202
+ function closePanel() {
203
+ if (!panel || !mainBtn) return;
204
+ panel.style.display = 'none';
205
+ mainBtn.setAttribute('aria-expanded', 'false');
206
+ }
207
+ function setStatus(text) {
208
+ if (statusEl) statusEl.textContent = text;
209
+ }
210
+ function setBusy(v) {
211
+ busy = v;
212
+ if (downloadBtn) downloadBtn.disabled = v || !generatedSvg;
213
+ }
214
+
215
+ // 居中裁切:把原图按 cover 规则裁成正方形,再缩到 CANVAS_SIZE。
216
+ function drawCover(img) {
217
+ var canvas = document.createElement('canvas');
218
+ canvas.width = CANVAS_SIZE;
219
+ canvas.height = CANVAS_SIZE;
220
+ var g = canvas.getContext('2d');
221
+ var sw = img.naturalWidth || img.width;
222
+ var sh = img.naturalHeight || img.height;
223
+ var side = Math.min(sw, sh);
224
+ var sx = (sw - side) / 2;
225
+ var sy = (sh - side) / 2;
226
+ g.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
227
+ g.drawImage(img, sx, sy, side, side, 0, 0, CANVAS_SIZE, CANVAS_SIZE);
228
+ return canvas.toDataURL('image/png');
229
+ }
230
+
231
+ function post(pathname, body) {
232
+ return fetch(pathname, {
233
+ method: 'POST',
234
+ headers: { 'content-type': 'application/json' },
235
+ body: JSON.stringify(body || {}),
236
+ }).then(function (resp) { return resp.json(); });
237
+ }
238
+
239
+ function onFileChange(e) {
240
+ var file = e.target && e.target.files && e.target.files[0];
241
+ if (!file) return;
242
+ if (!/^image\/(png|jpeg|webp)$/.test(file.type)) {
243
+ setStatus(T.badFile);
244
+ return;
245
+ }
246
+ setBusy(true);
247
+ setStatus(T.working);
248
+ var reader = new FileReader();
249
+ reader.onload = function () {
250
+ var image = new Image();
251
+ image.onload = function () {
252
+ var dataUrl;
253
+ try { dataUrl = drawCover(image); }
254
+ catch (err) { setBusy(false); setStatus(T.badImage); return; }
255
+ post(BASE + '/generate', { dataUrl: dataUrl }).then(function (data) {
256
+ if (!data || data.ok !== true || typeof data.svg !== 'string') {
257
+ throw new Error((data && data.message) || 'invalid-response');
258
+ }
259
+ generatedSvg = data.svg;
260
+ var src = 'data:image/svg+xml;base64,' + btoa(generatedSvg);
261
+ if (previewRow) previewRow.img.src = src;
262
+ if (previewCard) previewCard.img.src = src;
263
+ setStatus(T.ok);
264
+ }).catch(function (err) {
265
+ setStatus((err && err.message) || T.fail);
266
+ }).then(function () { setBusy(false); });
267
+ };
268
+ image.onerror = function () { setBusy(false); setStatus(T.badImage); };
269
+ image.src = String(reader.result);
270
+ };
271
+ reader.onerror = function () { setBusy(false); setStatus(T.badImage); };
272
+ reader.readAsDataURL(file);
273
+ }
274
+
275
+ function doDownload() {
276
+ if (!generatedSvg) return;
277
+ try {
278
+ var blob = new Blob([generatedSvg], { type: 'image/svg+xml;charset=utf-8' });
279
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
280
+ objectUrl = URL.createObjectURL(blob);
281
+ var a = document.createElement('a');
282
+ a.href = objectUrl;
283
+ a.download = 'icon.svg';
284
+ document.body.appendChild(a);
285
+ a.click();
286
+ document.body.removeChild(a);
287
+ } catch (e) {
288
+ setStatus(T.fail);
289
+ }
290
+ }
291
+
292
+ function onClick(e) {
293
+ var t = e.target;
294
+ var btn = t && t.closest ? t.closest('button') : null;
295
+ if (!btn) return;
296
+ if (btn.hasAttribute('data-yyic-toggle')) {
297
+ if (panel && panel.style.display === 'none') openPanel(); else closePanel();
298
+ return;
299
+ }
300
+ if (btn.hasAttribute('data-yyic-close')) { closePanel(); return; }
301
+ if (btn.hasAttribute('data-yyic-download')) { doDownload(); return; }
302
+ if (btn.hasAttribute('data-yyic-reset')) {
303
+ generatedSvg = null;
304
+ if (fileInput) fileInput.value = '';
305
+ if (previewRow) previewRow.img.removeAttribute('src');
306
+ if (previewCard) previewCard.img.removeAttribute('src');
307
+ if (downloadBtn) downloadBtn.disabled = true;
308
+ setStatus(T.empty);
309
+ }
310
+ }
311
+
312
+ function onKey(e) {
313
+ if (e.key === 'Escape' || e.keyCode === 27) closePanel();
314
+ }
315
+ function onOutside(e) {
316
+ if (!container || !container.contains(e.target)) closePanel();
317
+ }
318
+
319
+ // 座位消失则摘除容器,重新出现则重新 append;重复 apply 通过标记去重。
320
+ function ensureMounted() {
321
+ var s = seat();
322
+ if (!s) {
323
+ if (container && container.parentNode) {
324
+ try { container.parentNode.removeChild(container); } catch (e) {}
325
+ }
326
+ closePanel();
327
+ return;
328
+ }
329
+ if (!container) {
330
+ var stale = document.querySelector('[' + MARK + ']');
331
+ if (stale && stale.parentNode) {
332
+ try { stale.parentNode.removeChild(stale); } catch (e) {}
333
+ }
334
+ container = buildContainer();
335
+ }
336
+ if (container.parentNode !== s) {
337
+ try { s.appendChild(container); } catch (e) {}
338
+ }
339
+ }
340
+
341
+ function scheduleEnsure() {
342
+ if (ensureTimer !== null) return;
343
+ ensureTimer = setTimeout(function () {
344
+ ensureTimer = null;
345
+ ensureMounted();
346
+ }, 16);
347
+ }
348
+
349
+ // 所有 observer / 事件监听统一在这个 effect 内注册,并在清理函数里统一撤销。
350
+ ctx.effect(function () {
351
+ ensureMounted();
352
+ try {
353
+ observer = new MutationObserver(function () { scheduleEnsure(); });
354
+ observer.observe(document.body, { childList: true, subtree: true });
355
+ } catch (e) {
356
+ observer = null;
357
+ }
358
+ try { document.addEventListener('keydown', onKey); } catch (e) {}
359
+ try { document.addEventListener('click', onOutside, true); } catch (e) {}
360
+
361
+ return function () {
362
+ try { if (observer) observer.disconnect(); } catch (e) {}
363
+ observer = null;
364
+ if (ensureTimer !== null) {
365
+ try { clearTimeout(ensureTimer); } catch (e) {}
366
+ ensureTimer = null;
367
+ }
368
+ try { document.removeEventListener('keydown', onKey); } catch (e) {}
369
+ try { document.removeEventListener('click', onOutside, true); } catch (e) {}
370
+ if (objectUrl) { try { URL.revokeObjectURL(objectUrl); } catch (e) {} objectUrl = null; }
371
+ if (container) {
372
+ try { container.removeEventListener('click', onClick); } catch (e) {}
373
+ if (fileInput) { try { fileInput.removeEventListener('change', onFileChange); } catch (e) {} }
374
+ if (container.parentNode) {
375
+ try { container.parentNode.removeChild(container); } catch (e) {}
376
+ }
377
+ }
378
+ container = null;
379
+ };
380
+ }, 'yanyan-icon: toolbar');
381
+ }
382
+
383
+ exports.inject = inject;
384
+ exports.apply = apply;
385
+ return module.exports;
386
+ },
387
+ });
388
+ })(typeof window !== 'undefined' ? window : typeof self !== 'undefined' ? self : null);
@@ -0,0 +1,237 @@
1
+ // dsh-plugin-yanyan-icon — Host 半区。
2
+ //
3
+ // 职责:把浏览器半区预处理好的正方形 PNG / JPEG / WebP base64 图片,包装成
4
+ // 适配 DSH 插件列表图标位(<img> 加载、行 30px / 卡片 36px 显示)的自包含静态 SVG。
5
+ //
6
+ // 形态照抄 dsh-plugin-yanyan-update/lib/index.js 的 respond / readBody / apply /
7
+ // ctx.effect + ctx.webServer.register 骨架;准入一律走 ctx.connection.requestRejection。
8
+ //
9
+ // 约束:纯 JavaScript ESM,模块顶层不得访问 document / window / ctx;apply 只在被调用时
10
+ // 才注册路由。buildIconSvg / normalizeOptions / validateDataUrl / parseGenerateRequest
11
+ // 都是纯函数,无 DSH 依赖,单测可直接导入。
12
+
13
+ import { readFileSync } from 'node:fs';
14
+ import { dirname, join } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ export const name = 'dsh-plugin-yanyan-icon';
18
+ export const inject = ['webServer', 'connection'];
19
+ export const ROUTE_BASE = '/__dsh-plugin-yanyan-icon';
20
+ export const HEALTH_ROUTE = ROUTE_BASE + '/health';
21
+ export const GENERATE_ROUTE = ROUTE_BASE + '/generate';
22
+
23
+ const PKG_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
24
+
25
+ function readJsonSafe(file) {
26
+ try {
27
+ return JSON.parse(readFileSync(file, 'utf8'));
28
+ } catch (error) {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ // 版本一律从自身 package.json 动态读取,避免硬编码常量与包声明失同步。
34
+ export const VERSION = String((readJsonSafe(join(PKG_DIR, 'package.json')) || {}).version || '0.0.0');
35
+
36
+ const BODY_LIMIT = 2 * 1024 * 1024;
37
+ // DSH 市场索引对内联 data: 图标的硬上限(dsh-plugin-market-index 的
38
+ // MAX_INLINE_ICON_BYTES = 32 * 1024)。本地 icon.svg 不强制,但对齐该上限可保证
39
+ // 生成物在任何装载路径下都不会被拒,且 30/36px 显示也不需要更大的文件。
40
+ const MAX_OUTPUT_BYTES = 32 * 1024;
41
+
42
+ const FORBIDDEN_BODY = {
43
+ ok: false,
44
+ error: 'forbidden',
45
+ message: '请求未通过 DSH 准入裁决。',
46
+ };
47
+ const METHOD_BODY = {
48
+ ok: false,
49
+ error: 'method-not-allowed',
50
+ message: '只接受 GET 或 POST 请求。',
51
+ };
52
+
53
+ // 只允许位图 base64 data URL;SVG / javascript: / 外链一律拒绝,避免把脚本或外部引用
54
+ // 带进会被 <img> 渲染的图标里。正则与 dsh-plugin-market-index 的 DATA_ICON 同源。
55
+ const DATA_ICON = /^data:(image\/png|image\/jpeg|image\/webp);base64,([A-Za-z0-9+/]*={0,2})$/u;
56
+
57
+ export function validateDataUrl(value) {
58
+ return typeof value === 'string' && DATA_ICON.test(value);
59
+ }
60
+
61
+ // 颜色只接受 #rgb / #rrggbb,杜绝把引号或标签注入到 SVG 属性里。
62
+ function safeColor(value, fallback) {
63
+ return typeof value === 'string' && /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(value)
64
+ ? value
65
+ : fallback;
66
+ }
67
+
68
+ function clampInt(value, min, max, fallback) {
69
+ const n = Number(value);
70
+ if (!Number.isFinite(n)) return fallback;
71
+ return Math.min(max, Math.max(min, Math.round(n)));
72
+ }
73
+
74
+ // 整段覆盖、不深合并:只认列出的键,逐个做类型与范围规范化。纯函数,可单测。
75
+ export function normalizeOptions(raw) {
76
+ const c = raw && typeof raw === 'object' ? raw : {};
77
+ return {
78
+ background: safeColor(c.background, '#eef2ff'),
79
+ border: safeColor(c.border, '#c7d2fe'),
80
+ radius: clampInt(c.radius, 0, 28, 14),
81
+ padding: clampInt(c.padding, 0, 16, 4),
82
+ borderWidth: clampInt(c.borderWidth, 0, 4, 1),
83
+ };
84
+ }
85
+
86
+ // 核心纯函数:生成 64×64 viewBox、圆角矩形背景、居中内嵌位图的自包含静态 SVG。
87
+ // 无 script、无 foreignObject、无外链、无 currentColor(<img> 上下文不继承页面色,
88
+ // 所有颜色都给显式值),内嵌图片经 clipPath 裁成圆角,四周留出 padding 呼吸空间。
89
+ // 只写 xlink:href 一个 href,避免同一份 base64 在文件里出现两次把体积翻倍。
90
+ export function buildIconSvg(dataUrl, rawOptions) {
91
+ if (!validateDataUrl(dataUrl)) {
92
+ throw new Error('仅支持 PNG / JPEG / WebP 的 base64 图片数据。');
93
+ }
94
+ const o = normalizeOptions(rawOptions);
95
+ const outer = 6;
96
+ const frameSize = 52;
97
+ const inner = frameSize - o.padding * 2;
98
+ const ix = outer + o.padding;
99
+ const innerRadius = Math.max(0, o.radius - o.padding);
100
+ const svg = [
101
+ '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="plugin icon">',
102
+ '<defs><clipPath id="yyicon-clip"><rect x="' + ix + '" y="' + ix + '" width="' + inner + '" height="' + inner + '" rx="' + innerRadius + '"/></clipPath></defs>',
103
+ '<rect x="' + outer + '" y="' + outer + '" width="' + frameSize + '" height="' + frameSize + '" rx="' + o.radius + '" fill="' + o.background + '" stroke="' + o.border + '" stroke-width="' + o.borderWidth + '"/>',
104
+ '<image x="' + ix + '" y="' + ix + '" width="' + inner + '" height="' + inner + '" preserveAspectRatio="xMidYMid slice" clip-path="url(#yyicon-clip)" xlink:href="' + dataUrl + '"/>',
105
+ '</svg>',
106
+ ].join('\n');
107
+ const bytes = Buffer.byteLength(svg, 'utf8');
108
+ if (bytes > MAX_OUTPUT_BYTES) {
109
+ throw new Error('生成图标超过 32 KiB 上限,请换一张更小的图片。');
110
+ }
111
+ return svg;
112
+ }
113
+
114
+ // 从请求体字符串解析出生成参数;非法 JSON / 缺字段 / 非法图片统一抛可读错误。
115
+ // 纯函数,单测可直接调用,不需要 DSH 运行时。
116
+ export function parseGenerateRequest(rawBody) {
117
+ let body;
118
+ try {
119
+ body = JSON.parse(rawBody);
120
+ } catch (error) {
121
+ throw new Error('请求体不是合法 JSON。');
122
+ }
123
+ if (!body || typeof body !== 'object' || !validateDataUrl(body.dataUrl)) {
124
+ throw new Error('缺少合法的 dataUrl 图片数据。');
125
+ }
126
+ return { dataUrl: body.dataUrl, options: normalizeOptions(body.options) };
127
+ }
128
+
129
+ function respond(res, status, body) {
130
+ const payload = JSON.stringify(body);
131
+ res.writeHead(status, {
132
+ 'content-type': 'application/json; charset=utf-8',
133
+ 'cache-control': 'no-store',
134
+ 'content-length': Buffer.byteLength(payload),
135
+ });
136
+ res.end(payload);
137
+ }
138
+
139
+ function makeFence(ctx) {
140
+ return (req) => {
141
+ try {
142
+ return ctx.connection.requestRejection(req) === undefined;
143
+ } catch (error) {
144
+ return false;
145
+ }
146
+ };
147
+ }
148
+
149
+ function readBody(req) {
150
+ return new Promise((resolve, reject) => {
151
+ let size = 0;
152
+ const chunks = [];
153
+ req.on('data', (chunk) => {
154
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
155
+ size += buf.length;
156
+ if (size > BODY_LIMIT) {
157
+ reject(new Error('请求体超过 2 MiB 上限。'));
158
+ try { req.destroy(); } catch (error) {}
159
+ return;
160
+ }
161
+ chunks.push(buf);
162
+ });
163
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
164
+ req.on('error', reject);
165
+ });
166
+ }
167
+
168
+ function makeHealthHandler(fence) {
169
+ return (req, res) => {
170
+ if (!fence(req)) {
171
+ respond(res, 403, FORBIDDEN_BODY);
172
+ return;
173
+ }
174
+ const method = String(req.method || 'GET').toUpperCase();
175
+ if (method !== 'GET' && method !== 'POST') {
176
+ respond(res, 405, METHOD_BODY);
177
+ return;
178
+ }
179
+ respond(res, 200, { ok: true, name, version: VERSION });
180
+ };
181
+ }
182
+
183
+ function makeGenerateHandler(fence) {
184
+ return (req, res) => {
185
+ if (!fence(req)) {
186
+ respond(res, 403, FORBIDDEN_BODY);
187
+ return;
188
+ }
189
+ const method = String(req.method || 'GET').toUpperCase();
190
+ if (method !== 'POST') {
191
+ respond(res, 405, METHOD_BODY);
192
+ return;
193
+ }
194
+ readBody(req).then((raw) => {
195
+ let parsed;
196
+ try {
197
+ parsed = parseGenerateRequest(raw);
198
+ } catch (error) {
199
+ respond(res, 400, { ok: false, error: 'invalid-image', message: String((error && error.message) || error) });
200
+ return;
201
+ }
202
+ try {
203
+ const svg = buildIconSvg(parsed.dataUrl, parsed.options);
204
+ respond(res, 200, {
205
+ ok: true,
206
+ svg,
207
+ size: Buffer.byteLength(svg, 'utf8'),
208
+ hint: '把下载的 icon.svg 放到插件包根目录,并在 package.json 声明 icon 与 exports["./icon"]。',
209
+ });
210
+ } catch (error) {
211
+ respond(res, 400, { ok: false, error: 'invalid-image', message: String((error && error.message) || error) });
212
+ }
213
+ }).catch((error) => {
214
+ respond(res, 400, { ok: false, error: 'bad-request', message: String((error && error.message) || error) });
215
+ });
216
+ };
217
+ }
218
+
219
+ // register 的返回值直接作为 effect 的清理函数,禁用 / HMR 时路由自动撤销。
220
+ export function apply(ctx) {
221
+ const fence = makeFence(ctx);
222
+ const routes = [
223
+ { path: HEALTH_ROUTE, handler: makeHealthHandler(fence) },
224
+ { path: GENERATE_ROUTE, handler: makeGenerateHandler(fence) },
225
+ ];
226
+ for (const route of routes) {
227
+ ctx.effect(
228
+ () => ctx.webServer.register({ kind: 'exact', path: route.path, handler: route.handler }),
229
+ 'dsh-plugin-yanyan-icon: route ' + route.path,
230
+ );
231
+ }
232
+ try {
233
+ ctx.logger?.info?.('[dsh-plugin-yanyan-icon] host routes ready');
234
+ } catch (error) {
235
+ // 日志服务不可用时不影响路由注册
236
+ }
237
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Icon Generator",
4
+ "description": "Center-crop an uploaded image and generate an icon.svg fitted for the plugin list artwork slot"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "图标生成",
4
+ "description": "根据上传的图片居中裁切并生成适配插件列表图标位的 icon.svg"
5
+ }
6
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "dsh-plugin-yanyan-icon",
3
+ "version": "1.0.0",
4
+ "description": "图标生成:根据上传的图片生成适配插件列表图标位的 icon.svg",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "icon": "icon.svg",
8
+ "exports": {
9
+ ".": "./lib/index.js",
10
+ "./client": "./lib/client.js",
11
+ "./package.json": "./package.json",
12
+ "./locale/*.json": "./locale/*.json",
13
+ "./icon": "./icon.svg"
14
+ },
15
+ "files": ["lib/index.js","lib/client.js","cordis.patch.yml","icon.svg","locale/en.json","locale/zh.json","README.md","README.zh.md"],
16
+ "keywords": ["deepseek-harness","dsh-plugin","plugin-manager","icon","svg","cordis"],
17
+ "author": "sweet560",
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/sweet560/dsh-plugin-yanyan-toolkit.git",
22
+ "directory": "packages/dsh-plugin-yanyan-icon"
23
+ },
24
+ "engines": { "node": "^22.19.0 || >=24.0.0" },
25
+ "scripts": { "test": "node --test test/*.test.mjs" },
26
+ "dsh": {
27
+ "engines": { "dsh": ">=0.2.2-rc.2" },
28
+ "bundle": { "patch": "./cordis.patch.yml" },
29
+ "client": { "inject": [], "platform": "web" },
30
+ "manifestVersion": 1
31
+ }
32
+ }