dsh-plugin-toolkit 0.10.0 → 0.11.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/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## 0.11.0
2
+
3
+ - **A language switch, in the panel.** 中文 / EN sits in the dock's header row and
4
+ in the sidebar tab, opens in Chinese (the audience this plugin is written for),
5
+ and remembers the choice in `localStorage` under `dsh-plugin-toolkit:lang`. The
6
+ panel no longer follows the host's language silently: the app can be in English
7
+ while the person reading it is not.
8
+ - `useLang()` is a tiny subscription store, so the dock, the tab and the tab
9
+ type's `title()` all switch together.
10
+ - **The testkit hands a bundle the real `window`.** `loadBundle` used to pass a
11
+ stand-in that carried only `__ModuleLoader__`, so `window.localStorage` was
12
+ `undefined` and any preference silently failed to save. That bug was found by
13
+ this feature failing its own persistence assertion.
14
+ - Fixed invalid markup: the switch was nested inside the dock's toggle button.
15
+ React warns and drops the inner button, so it now sits beside it.
16
+
1
17
  ## 0.10.0
2
18
 
3
19
  - **The checker is docked under the conversation.** It used to live only in the
package/README.md CHANGED
@@ -98,6 +98,9 @@ for, how to use it, what each check means, and what it cannot tell you — plus
98
98
  the folder field and the results. The same panel is also a **Plugin doctor** tab
99
99
  in the right sidebar, for when the conversation is busy.
100
100
 
101
+ The panel speaks both languages and opens in Chinese; the 中文 / EN switch sits
102
+ in the dock's header row (and in the sidebar tab) and the choice is remembered.
103
+
101
104
  ## Test a panel without a browser
102
105
 
103
106
  A plugin's client code is one file that calls `window.__ModuleLoader__.load({factory})` — there is no module to import, so the usual React test setup does not apply. `testkit` supplies the missing half: a DOM, the real React, and a host context that records what the panel registers.
package/README.zh.md CHANGED
@@ -57,6 +57,8 @@ cd my-panel && npm install && npm test
57
57
 
58
58
  检查器固定在**对话栏下方、输入框上方**:默认收起成一行,点开就是完整指南——这是给谁用的、怎么用、每一项检查拦什么、以及它证明不了什么——下面才是文件夹输入框和结果。同一个面板也在右侧栏有一个 **Plugin doctor** 标签页,对话区忙的时候用那个。
59
59
 
60
+ 面板中英双语,默认中文;**中文 / EN** 开关就在对话栏那一行的右侧(右侧栏标签里也有),选过之后会记住。
61
+
60
62
  ## 不用浏览器测面板
61
63
 
62
64
  ```js
package/client.js CHANGED
@@ -3,7 +3,7 @@ window.__ModuleLoader__.load({
3
3
  factory: (require) => {
4
4
  const module = { exports: {} };
5
5
  const React = require('react');
6
- const { useState, useCallback } = React;
6
+ const { useState, useCallback, useEffect } = React;
7
7
 
8
8
  const NS = 'dsh-plugin-toolkit';
9
9
  const KIND = 'toolkit';
@@ -132,11 +132,74 @@ window.__ModuleLoader__.load({
132
132
  * type's `title`/`description` are called by the sidebar's guide list, not
133
133
  * by this component, and they need the same strings.
134
134
  */
135
- const translate = (key) => {
136
- const lang = typeof document === 'undefined' ? 'en' : (document.documentElement.lang === 'zh' ? 'zh' : 'en');
137
- return COPY[lang][key] ?? key;
135
+ const translate = (key, lang = readLang()) => {
136
+ return (COPY[lang] ?? COPY.en)[key] ?? key;
138
137
  };
139
138
 
139
+ /**
140
+ * The panel's language is the user's choice, not the host's: the app may be
141
+ * in English while the person reading it is not. Chinese is the default,
142
+ * because that is the audience this plugin is written for, and the choice is
143
+ * remembered per browser.
144
+ */
145
+ const LANG_KEY = 'dsh-plugin-toolkit:lang';
146
+ const LANGUAGES = ['zh', 'en'];
147
+ const langListeners = new Set();
148
+ let langCache = null;
149
+
150
+ function readLang() {
151
+ if (langCache !== null) return langCache;
152
+ try {
153
+ const stored = window.localStorage.getItem(LANG_KEY);
154
+ if (LANGUAGES.includes(stored)) {
155
+ langCache = stored;
156
+ return langCache;
157
+ }
158
+ } catch {
159
+ // No storage (private mode, a sandboxed frame): fall through to the default.
160
+ }
161
+ langCache = 'zh';
162
+ return langCache;
163
+ }
164
+
165
+ function setLang(next) {
166
+ if (!LANGUAGES.includes(next)) return;
167
+ langCache = next;
168
+ try {
169
+ window.localStorage.setItem(LANG_KEY, next);
170
+ } catch {
171
+ // Remembering the choice is a convenience, never a requirement.
172
+ }
173
+ for (const listener of langListeners) listener();
174
+ }
175
+
176
+ function useLang() {
177
+ const [lang, set] = useState(readLang);
178
+ useEffect(() => {
179
+ const listener = () => set(readLang());
180
+ langListeners.add(listener);
181
+ return () => langListeners.delete(listener);
182
+ }, []);
183
+ return lang;
184
+ }
185
+
186
+ function LangSwitch({ lang }) {
187
+ return h('span', { className: 'tk-lang', role: 'group', 'aria-label': 'Language' },
188
+ LANGUAGES.map((code) => h('button', {
189
+ key: code,
190
+ type: 'button',
191
+ className: 'tk-lang-button',
192
+ 'data-on': code === lang ? 'true' : 'false',
193
+ 'aria-pressed': code === lang ? 'true' : 'false',
194
+ onClick: (event) => {
195
+ // The dock sits inside the composer: a click here must not reach
196
+ // the conversation's own handlers.
197
+ event.stopPropagation();
198
+ setLang(code);
199
+ },
200
+ }, code === 'zh' ? '中文' : 'EN')));
201
+ }
202
+
140
203
  const h = React.createElement;
141
204
 
142
205
  const CSS = [
@@ -165,6 +228,13 @@ window.__ModuleLoader__.load({
165
228
  '.tk-check{font-size:11px;text-transform:uppercase;letter-spacing:.04em;',
166
229
  'color:var(--dsw-alias-label-tertiary,#888)}',
167
230
  '.tk-message{font-size:12px;overflow-wrap:anywhere}',
231
+ '.tk-panel-head{display:flex;align-items:center;justify-content:space-between;gap:8px}',
232
+ '.tk-lang{display:inline-flex;flex:none;border:0.5px solid var(--dsw-alias-border-l2,#ccc);',
233
+ 'border-radius:6px;overflow:hidden}',
234
+ '.tk-lang-button{height:22px;padding:0 8px;border:0;background:none;font:inherit;font-size:11px;',
235
+ 'color:var(--dsw-alias-label-secondary,#555);cursor:pointer}',
236
+ '.tk-lang-button[data-on="true"]{background:var(--dsw-alias-state-business-primary,#4176e6);color:#fff}',
237
+ '.tk-lang-button:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);outline-offset:-1px}',
168
238
  '.tk-dock{border-top:0.5px solid var(--dsw-alias-border-l2,#e5e5e5);margin:0 12px}',
169
239
  '.tk-dock-head{display:flex}',
170
240
  '.tk-dock-toggle{display:flex;align-items:center;gap:8px;width:100%;padding:7px 2px;',
@@ -236,13 +306,15 @@ window.__ModuleLoader__.load({
236
306
  }
237
307
 
238
308
  function Form({ checker }) {
309
+ const lang = useLang();
310
+ const tr = (key) => translate(key, lang);
239
311
  return h('div', { className: 'tk-form' },
240
312
  h('input', {
241
313
  className: 'tk-input',
242
314
  type: 'text',
243
315
  value: checker.dir,
244
- placeholder: translate('placeholder'),
245
- 'aria-label': translate('placeholder'),
316
+ placeholder: tr('placeholder'),
317
+ 'aria-label': tr('placeholder'),
246
318
  onChange: (event) => checker.setDir(event.target.value),
247
319
  onKeyDown: (event) => { if (event.key === 'Enter') checker.check(); },
248
320
  }),
@@ -251,25 +323,31 @@ window.__ModuleLoader__.load({
251
323
  type: 'button',
252
324
  disabled: checker.busy === true,
253
325
  onClick: checker.check,
254
- }, checker.busy === true ? translate('checking') : translate('check')));
326
+ }, checker.busy === true ? tr('checking') : tr('check')));
255
327
  }
256
328
 
257
329
  function Result({ checker }) {
330
+ const lang = useLang();
331
+ const tr = (key) => translate(key, lang);
258
332
  return h('div', { className: 'tk-result' },
259
333
  checker.failed !== null ? h('p', { className: 'tk-failed', role: 'status' }, checker.failed) : null,
260
334
  checker.result !== null
261
335
  ? h('div', { className: 'tk-result-body' },
262
336
  h('p', { className: 'tk-verdict', 'data-ok': checker.result.ok === true ? 'true' : 'false', role: 'status' },
263
- checker.result.ok === true ? translate('pass') : translate('fail')),
337
+ checker.result.ok === true ? tr('pass') : tr('fail')),
264
338
  h('ul', { className: 'tk-rows' }, checker.result.checks.map((entry) => h(Row, { key: entry.check, entry }))))
265
339
  : null);
266
340
  }
267
341
 
268
342
  function Panel() {
269
343
  const checker = useChecker();
344
+ const lang = useLang();
345
+ const tr = (key) => translate(key, lang);
270
346
  return h('div', { className: 'tk-panel' },
271
- h('h2', { className: 'tk-title' }, translate('title')),
272
- h('p', { className: 'tk-hint' }, translate('hint')),
347
+ h('div', { className: 'tk-panel-head' },
348
+ h('h2', { className: 'tk-title' }, tr('title')),
349
+ h(LangSwitch, { lang })),
350
+ h('p', { className: 'tk-hint' }, tr('hint')),
273
351
  h(Form, { checker }),
274
352
  h(Result, { checker }));
275
353
  }
@@ -282,19 +360,23 @@ window.__ModuleLoader__.load({
282
360
  function Dock() {
283
361
  const [open, setOpen] = useState(false);
284
362
  const checker = useChecker();
285
- const guide = GUIDE[typeof document === 'undefined' || document.documentElement.lang !== 'zh' ? 'en' : 'zh'];
363
+ const lang = useLang();
364
+ const guide = GUIDE[lang] ?? GUIDE.zh;
286
365
 
287
366
  return h('section', { className: 'tk-dock', 'data-open': open === true ? 'true' : 'false' },
288
367
  h('div', { className: 'tk-dock-head' },
368
+ // The language switch is a sibling of the toggle, never inside it: a
369
+ // button inside a button is invalid and React drops the inner one.
289
370
  h('button', {
290
371
  className: 'tk-dock-toggle',
291
372
  type: 'button',
292
373
  'aria-expanded': open === true ? 'true' : 'false',
293
374
  onClick: () => setOpen(open !== true),
294
375
  },
295
- h('span', { className: 'tk-dock-title' }, translate('dockTitle')),
296
- h('span', { className: 'tk-dock-sub' }, translate('dockSub')),
297
- h('span', { className: 'tk-dock-chevron', 'aria-hidden': 'true' }, open === true ? '▾' : '▸'))),
376
+ h('span', { className: 'tk-dock-title' }, translate('dockTitle', lang)),
377
+ h('span', { className: 'tk-dock-sub' }, translate('dockSub', lang)),
378
+ h('span', { className: 'tk-dock-chevron', 'aria-hidden': 'true' }, open === true ? '▾' : '▸')),
379
+ h(LangSwitch, { lang })),
298
380
  open === true
299
381
  ? h('div', { className: 'tk-dock-body' },
300
382
  h('div', { className: 'tk-guide' },
@@ -46,7 +46,13 @@ export function createModuleLoader({ require: provide, onLoad } = {}) {
46
46
  export function loadBundle(file, options = {}) {
47
47
  const source = readBundleSource(file);
48
48
  const loader = createModuleLoader(options);
49
- const context = { window: { __ModuleLoader__: loader }, __ModuleLoader__: loader, ...(options.globals ?? {}) };
49
+ // The bundle's `window` must be the real one: a plugin reads
50
+ // `window.localStorage`, `window.matchMedia`, `window.navigator`, and a
51
+ // stand-in that only carries the loader turns every one of those into a
52
+ // silent `undefined`.
53
+ const hostWindow = options.window ?? {};
54
+ hostWindow.__ModuleLoader__ = loader;
55
+ const context = { window: hostWindow, __ModuleLoader__: loader, ...(options.globals ?? {}) };
50
56
  // The bundle is an IIFE or a top-level statement list; the browser would run it
51
57
  // as a classic script, so a function body is the closest equivalent.
52
58
  const run = new Function('window', '__ModuleLoader__', 'require', 'module', 'exports', source);
@@ -74,7 +74,7 @@ export async function mountClient(options) {
74
74
  const previousFetch = globalThis.fetch;
75
75
  globalThis.fetch = host.fetch;
76
76
  try {
77
- const exported = loadBundle(file, { require: moduleShim(modules) });
77
+ const exported = loadBundle(file, { require: moduleShim(modules), window });
78
78
  const apply = exported.apply ?? exported.default ?? exported.install ?? exported;
79
79
  if (typeof apply !== 'function') {
80
80
  throw new Error(`${file} exports no apply(ctx) function, so there is nothing to mount`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-toolkit",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Preflight, release and test DeepSeek Harness plugins: checks what the host needs, waits for npm to actually serve the version, and keeps the market index honest.",
5
5
  "type": "module",
6
6
  "bin": {