@nsnanocat/preference-panes 0.1.0 → 0.2.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 CHANGED
@@ -1,6 +1,23 @@
1
1
  # @nsnanocat/preference-panes
2
2
 
3
- 尚未发布的通用设置面板和代理存储 API,基于 `@nsnanocat/util`。字段直接来自运行时加载的 BoxJS JSON,页面、缓存与读写逻辑不包含具体项目的选项。
3
+ 通用设置面板和代理存储 API,基于 `@nsnanocat/util`,首版 `0.1.0` 已发布到 npm 和 GitHub Packages。字段直接来自运行时加载的 BoxJS JSON,页面、缓存与读写逻辑不包含具体项目的选项。
4
+
5
+ ## 目录结构
6
+
7
+ | 目录 | 内容 |
8
+ | --- | --- |
9
+ | `src/` | 包入口、实现及同目录的 TypeScript 声明 |
10
+ | `src/SettingsHandler.mjs` | 通用代理处理类:下载配置、校验与存储读写 |
11
+ | `src/browser/` | 浏览器面板、会话缓存和样式 |
12
+ | `src/lib/` | BoxJS 解析和路径解析 |
13
+ | `src/proxy/` | 代理宿主的独立打包入口 |
14
+ | `test/` | 持续回归测试、类型契约和测试数据 |
15
+ | `examples/` | 可复用的最小集成示例 |
16
+ | `apifox/` | 接口定义、说明和原生 JSON 生成器 |
17
+ | `.github/` | CI、发布工作流和发布说明 |
18
+ | `dist/` | 构建产物,不提交 Git |
19
+
20
+ 公开包路径仍为 `@nsnanocat/preference-panes`、`@nsnanocat/preference-panes/browser` 和 `@nsnanocat/preference-panes/browser/panel.css`,由 package.json 的 exports 映射到源码目录。
4
21
 
5
22
  ## 工作方式
6
23
 
@@ -56,23 +73,22 @@ const panel = mountPreferencePanes({
56
73
  ## 代理读写组件
57
74
 
58
75
  ```js
59
- import { createSettingsHandler } from "@nsnanocat/preference-panes";
60
- import { fetch } from "@nsnanocat/util/polyfill/fetch";
76
+ import { SettingsHandler } from "@nsnanocat/preference-panes";
61
77
 
62
- const handle = createSettingsHandler({
78
+ const handler = new SettingsHandler({
63
79
  origin: "https://example.org",
64
- loadConfig: async module => {
65
- const response = await fetch(`https://assets.example.org/${module}.boxjs.json`);
66
- if (response.status !== 200) throw new Error(`BoxJS HTTP ${response.status}`);
67
- return JSON.parse(response.body);
68
- }
80
+ configURL: "https://assets.example.org/Module.boxjs.json"
69
81
  });
70
- const response = await handle($request);
82
+ const response = await handler.handle($request);
71
83
  // 接入现有平台的 done 适配;或直接使用下述打包入口。
72
84
  ```
73
85
 
74
86
  每个支持面板的模块都携带自己的配置 Mock,并引用同一个通用读写脚本。脚本正则只匹配各自 `/api/<模块>` 的数据路径,配置 Mock 则只匹配 `/configs/`。处理器每次键值请求运行时加载配置,仅允许操作已声明的字段。
75
87
 
88
+ `SettingsHandler` 自身使用 util `fetch` 下载 configURL,要求 HTTP 200、解析 JSON 并按 URL 中的模块归一化 BoxJS。下载、HTTP 状态、JSON 或配置错误返回 502,不读取存储;HEAD 只加载配置验证声明,不读取存储。类不缓存配置,各次 handle 请求加载一次;浏览器仍按页面会话缓存设置。configURL 可指向单模块配置或包含多个模块的 BoxJS 订阅,字段所属模块由 `/api/` 后第一段选择。
89
+
90
+ 此 class API 属于 dev 中的下一版变更,替换 0.1.0 的 `createSettingsHandler({ loadConfig })` 工厂;已发布的 0.1.0 尚不导出 SettingsHandler。接入方只需构造实例并调用 handle,不再自行实现配置请求。
91
+
76
92
  持久化 GET 调用一次 util `Storage.getItem`;POST/DELETE 在写入前重新读取最新根对象,再用 util `Lodash.set/unset` 修改单键并 `Storage.setItem` 写回,保留其它模块、隐藏字段和缓存。这是代理端必要的读改写,浏览器不会因此重新 GET 整个模块。多个独立脚本上下文同时写同一根键仍受代理存储无事务能力的限制。
77
93
 
78
94
  `resolveSettings(stored, definition)` 是可选的 GET 解析器,用来按模块既有规则合并 database、argument、持久化值。默认只返回持久化覆盖值,控件缺值时使用 BoxJS `val`。本包不修改插件原有的配置优先级;删除后不重新计算 resolver,而是在下次进入/刷新时重新读取。
@@ -81,6 +97,21 @@ const response = await handle($request);
81
97
 
82
98
  支持 settings 数组、单个 app 的 `settings`、订阅的 `apps[].settings`;控件类型支持 boolean、selects、checkboxes、text、textarea、number。不执行 BoxJS 脚本或 HTML。
83
99
 
100
+ 复用 [BoxJs 原版配置格式](https://github.com/chavyleung/scripts/tree/master/box) 的字段语义,不加载原版 HTML/Vue 应用:
101
+
102
+ | 字段 | 用途 |
103
+ | --- | --- |
104
+ | `name`、`val`、`type`、`desc`、`items` | 控件标题、默认值、类型、说明和选项 |
105
+ | `placeholder` | 文本和数字输入框的占位提示 |
106
+ | `rows`、`autoGrow` | 多行文本框的基础行数和自动高度;保存值仍为字符串 |
107
+ | app 的 `name`、`author`、`desc`、`descs`、`repo` | 页面名称、作者、多段说明及项目链接 |
108
+ | app 的 `icon`、`icons` | 显式图标优先;否则取彩色版 `icons[1]`,单个图标则用 `icons[0]` |
109
+ | app 的 `id`、`script` | 保留在 `definition.metadata` 中,不参与路径映射或脚本执行 |
110
+
111
+ 原版 `icons` 表示透明/彩色变体,不是亮暗模式顺序。显示名称和说明使用纯文本。一个模块的字段来自唯一 app 时,才采用该 app 的元数据;多个 app 合并声明同一模块时不任意选取其中一个的元数据。app 的 id/name 不决定模块归属,始终由字段 ID 选择;因此订阅里无关应用的旧扁平 ID 不会参与该模块渲染。
112
+
113
+ `keys`、脚本执行、`desc_html`/`descs_html`、动态字符串形式的 `items` 及 slider/radios/modalSelects/colorpicker 不属于当前支持范围。不会给旧扁平 ID 猜测存储根;本地读写仍由现有 util Storage/Lodash 完成,不引入原版 Env 的另一套存储实现。这些兼容增强位于 dev,尚未发布。
114
+
84
115
  设置 ID 必须为 `@存储根.模块.子路径.键`。同一模块使用一个存储根,字段路径不得重复或父子重叠。为了用一次 GET 获取设置,字段必须具有模块根以下的公共父路径,例如 `Enhanced.Settings` 或 `Weather.Preferences`;公共路径自动计算,不固定为 Settings。不满足条件或遇到不支持的控件会报错。
85
116
 
86
117
  ## 打包与示例
@@ -90,11 +121,11 @@ npm ci --registry=https://registry.npmjs.org/ --@nsnanocat:registry=https://regi
90
121
  npm run build
91
122
  npm run check
92
123
  npm run apifox:generate
93
- node scripts/generate-apifox.mjs --check
124
+ npm run apifox:check
94
125
  npm pack --dry-run
95
126
  ```
96
127
 
97
- 构建生成可直接加载的 `dist/preference-panes.mjs` 和代理 IIFE `dist/preference-panes.request.js`,公共样式位于 `browser/panel.css`。代理包包括 util 的平台适配和 `@nsnanocat/url`,不依赖 Node 内置模块。
128
+ 构建生成可直接加载的 `dist/preference-panes.mjs` 和代理 IIFE `dist/preference-panes.request.js`,公共样式源码位于 `src/browser/panel.css`。代理包包括 util 的平台适配和 `@nsnanocat/url`,不依赖 Node 内置模块。
98
129
 
99
130
  [Surge 模板](examples/surge.sgmodule)使用原生 Map Local 提供静态资源,http-request 提供持久化 API。模板中的域名均为占位,尚未部署;需要把源码资源和 dist 产物发布到自己的资源地址。其 `argument` 只配置 `origin` 和 `configURL`,不会固化字段。Map Local 下载缓存的更新时机由代理管理;浏览器 no-store 不会强制 Surge 更新资源缓存。配置 Mock 与脚本 configURL 应引用同一版本的 BoxJS。
100
131
 
@@ -104,6 +135,6 @@ Quantumult X 等不能通过模板传递 `$argument` 的平台,需要在构建
104
135
 
105
136
  - [完整请求、返回与缓存时序说明](apifox/guide.md)
106
137
  - [Apifox 原生 JSON](apifox/preference-panes.apifox.json)
107
- - [Apifox 项目](https://app.apifox.com/project/8803052)、[Git 数据源绑定记录](apifox/sync.md)
138
+ - [Apifox 项目](https://app.apifox.com/project/8803052)、[文档维护与同步](apifox/README.md)
108
139
 
109
- main/dev 普通推送与手动 CI 只验证并生成候选 tgz;两套 v* tag workflow 分别发布 npm 与 GitHub Packages。首版拟定为 `0.1.0`,发布步骤、首次 npm 注册与 Trusted Publisher 配置见 [RELEASING.md](RELEASING.md)。Enhanced 可先用候选 tgz 验证,正式依赖及 lockfile 必须在 registry 首发成功后生成。
140
+ main/dev 普通推送与手动 CI 只验证并生成候选 tgz;两套 v* tag workflow 分别发布 npm 与 GitHub Packages。首版为 `0.1.0`,发布步骤与 Trusted Publisher 配置见 [发布说明](.github/RELEASING.md)。Enhanced 通过 GitHub Packages 安装正式依赖,lockfile 使用 registry 下载地址。
@@ -266,6 +266,346 @@ class Lodash {
266
266
  }
267
267
  }
268
268
 
269
+ class URLSearchParams {
270
+ constructor(params, onUpdate) {
271
+ switch (typeof params) {
272
+ case "string": {
273
+ if (params.length === 0)
274
+ break;
275
+ if (params.startsWith("?"))
276
+ params = params.slice(1);
277
+ const pairs = params.split("&").map(pair => {
278
+ const separator = pair.indexOf("=");
279
+ return separator < 0 ? [pair, ""] : [pair.slice(0, separator), pair.slice(separator + 1)];
280
+ });
281
+ pairs.forEach(([key, value]) => {
282
+ this.#params.push(key ? this.#decodeQueryComponent(key) : key);
283
+ this.#values.push(this.#decodeQueryComponent(value));
284
+ });
285
+ break;
286
+ }
287
+ case "object":
288
+ if (Array.isArray(params)) {
289
+ Object.entries(params).forEach(([key, value]) => {
290
+ this.#params.push(key);
291
+ this.#values.push(value);
292
+ });
293
+ }
294
+ else if (Symbol.iterator in Object(params)) {
295
+ for (const [key, value] of params) {
296
+ this.#params.push(key);
297
+ this.#values.push(value);
298
+ }
299
+ }
300
+ break;
301
+ }
302
+ this.#updateSearchString(this.#params, this.#values);
303
+ this.#onUpdate = onUpdate;
304
+ }
305
+ // Create 2 seperate arrays for the params and values to make management and lookup easier.
306
+ #param = "";
307
+ #params = [];
308
+ #values = [];
309
+ #onUpdate;
310
+ #decodeQueryComponent(str) {
311
+ return decodeURIComponent(str.replace(/\+/g, " "));
312
+ }
313
+ #encodeQueryComponent(str) {
314
+ return encodeURIComponent(str)
315
+ .replace(/%20/g, "+")
316
+ .replace(/[!'()~]/g, character => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
317
+ }
318
+ // Update the search property of the URL instance with the new params and values.
319
+ #updateSearchString(params, values) {
320
+ if (params.length === 0)
321
+ this.#param = "";
322
+ else
323
+ this.#param = params
324
+ .map((param, index) => {
325
+ switch (typeof values[index]) {
326
+ case "object":
327
+ return `${this.#encodeQueryComponent(param)}=${this.#encodeQueryComponent(JSON.stringify(values[index]))}`;
328
+ case "boolean":
329
+ case "number":
330
+ case "string":
331
+ return `${this.#encodeQueryComponent(param)}=${this.#encodeQueryComponent(values[index])}`;
332
+ case "undefined":
333
+ default:
334
+ return this.#encodeQueryComponent(param);
335
+ }
336
+ })
337
+ .join("&");
338
+ this.#onUpdate?.(this.#param);
339
+ }
340
+ // Add a given param with a given value to the end.
341
+ append(name, value) {
342
+ this.#params.push(name);
343
+ this.#values.push(value);
344
+ this.#updateSearchString(this.#params, this.#values);
345
+ }
346
+ // Remove all occurances of a given param
347
+ delete(name, value) {
348
+ while (this.#params.indexOf(name) > -1) {
349
+ this.#values.splice(this.#params.indexOf(name), 1);
350
+ this.#params.splice(this.#params.indexOf(name), 1);
351
+ }
352
+ this.#updateSearchString(this.#params, this.#values);
353
+ }
354
+ // Return an array to be structured in this way: [[param1, value1], [param2, value2]] to mimic the native method's ES6 iterator.
355
+ entries() {
356
+ return this.#params.map((param, index) => [param, this.#values[index]]);
357
+ }
358
+ // Return the value matched to the first occurance of a given param.
359
+ get(name) {
360
+ return this.#values[this.#params.indexOf(name)];
361
+ }
362
+ // Return all values matched to all occurances of a given param.
363
+ getAll(name) {
364
+ return this.#values.filter((value, index) => this.#params[index] === name);
365
+ }
366
+ // Return a boolean to indicate whether a given param exists.
367
+ has(name, value) {
368
+ return this.#params.indexOf(name) > -1;
369
+ }
370
+ // Return an array of the param names to mimic the native method's ES6 iterator.
371
+ keys() {
372
+ return this.#params;
373
+ }
374
+ // Set a given param to a given value.
375
+ set(name, value) {
376
+ if (this.#params.indexOf(name) === -1) {
377
+ this.append(name, value); // If the given param doesn't already exist, append it.
378
+ }
379
+ else {
380
+ let first = true;
381
+ const newValues = [];
382
+ // If the param already exists, change the value of the first occurance and remove any remaining occurances.
383
+ this.#params = this.#params.filter((currentParam, index) => {
384
+ if (currentParam !== name) {
385
+ newValues.push(this.#values[index]);
386
+ return true;
387
+ // If the currentParam matches the one being changed and it's the first one, keep the param and change its value to the given one.
388
+ }
389
+ else if (first) {
390
+ first = false;
391
+ newValues.push(value);
392
+ return true;
393
+ }
394
+ // If the currentParam matches the one being changed, but it's not the first, remove it.
395
+ return false;
396
+ });
397
+ this.#values = newValues;
398
+ this.#updateSearchString(this.#params, this.#values);
399
+ }
400
+ }
401
+ // Sort all key/value pairs, if any, by their keys then by their values.
402
+ sort() {
403
+ // Call entries to make sorting easier, then rewrite the params and values in the new order.
404
+ const sortedPairs = this.entries().sort();
405
+ this.#params = [];
406
+ this.#values = [];
407
+ sortedPairs.forEach(pair => {
408
+ this.#params.push(pair[0]);
409
+ this.#values.push(pair[1]);
410
+ });
411
+ this.#updateSearchString(this.#params, this.#values);
412
+ }
413
+ // Return the search string without the '?'.
414
+ toString = () => this.#param;
415
+ // Return and array of the param values to mimic the native method's ES6 iterator..
416
+ values = () => this.#values.values();
417
+ }
418
+
419
+ class URL {
420
+ constructor(url, base) {
421
+ switch (typeof url) {
422
+ case "string": {
423
+ const urlIsValid = /^(blob:|file:)?[a-zA-z]+:\/\/.*/.test(url);
424
+ const baseIsValid = base ? /^(blob:|file:)?[a-zA-z]+:\/\/.*/.test(base) : false;
425
+ // If a string is passed for url instead of location or link, then set the properties of the URL instance.
426
+ if (urlIsValid)
427
+ this.href = url;
428
+ // If the url isn't valid, but the base is, then prepend the base to the url.
429
+ else if (baseIsValid)
430
+ this.href = base + url;
431
+ // If no valid url or base is given, then throw a type error.
432
+ else
433
+ throw new TypeError('URL string is not valid. If using a relative url, a second argument needs to be passed representing the base URL. Example: new URL("relative/path", "http://www.example.com");');
434
+ break;
435
+ }
436
+ case "object":
437
+ break;
438
+ default:
439
+ throw new TypeError("Invalid argument type.");
440
+ }
441
+ }
442
+ #url = {
443
+ hash: "",
444
+ host: "",
445
+ hostname: "",
446
+ href: "",
447
+ password: "",
448
+ pathname: "",
449
+ port: Number.NaN,
450
+ protocol: "",
451
+ search: "",
452
+ searchParams: new URLSearchParams(""),
453
+ username: "",
454
+ };
455
+ // refer: http://www.ietf.org/rfc/rfc3986.txt
456
+ static #URLRegExp = /^(?<scheme>([^:\/?#]+):)?(?:\/\/(?<authority>[^\/?#]*))?(?<path>[^?#]*)(?<query>\?([^#]*))?(?<hash>#(.*))?$/;
457
+ static #AuthorityRegExp = /^(?<authentication>(?<username>[^:]*)(:(?<password>[^@]*))?@)?(?<hostname>[^:]+)(:(?<port>\d+))?$/;
458
+ get hash() {
459
+ return this.#url.hash;
460
+ }
461
+ set hash(value) {
462
+ if (value.length !== 0) {
463
+ if (value.startsWith("#"))
464
+ value = value.slice(1);
465
+ this.#url.hash = `#${encodeURIComponent(value)}`;
466
+ }
467
+ }
468
+ get host() {
469
+ return this.port.length > 0 ? `${this.hostname}:${this.port}` : this.hostname;
470
+ }
471
+ set host(value) {
472
+ [this.hostname, this.port] = value.split(":", 2);
473
+ }
474
+ get hostname() {
475
+ return encodeURIComponent(this.#url.hostname);
476
+ }
477
+ set hostname(value) {
478
+ this.#url.hostname = value ?? "";
479
+ }
480
+ get href() {
481
+ let authority = "";
482
+ if (this.username.length > 0) {
483
+ authority += this.username;
484
+ if (this.password.length > 0)
485
+ authority += `:${this.password}`;
486
+ authority += "@";
487
+ }
488
+ return `${this.protocol}//${authority}${this.host}${this.pathname}${this.search}${this.hash}`;
489
+ }
490
+ set href(value) {
491
+ if (value.startsWith("blob:") || value.startsWith("file:"))
492
+ value = value.slice(5);
493
+ const urlMatch = value.match(URL.#URLRegExp);
494
+ if (!urlMatch)
495
+ throw new TypeError("Invalid URL format.");
496
+ this.protocol = urlMatch.groups.scheme ?? "";
497
+ const authorityMatch = urlMatch.groups.authority.match(URL.#AuthorityRegExp);
498
+ this.username = authorityMatch.groups.username ?? "";
499
+ this.password = authorityMatch.groups.password ?? "";
500
+ this.hostname = authorityMatch.groups.hostname ?? "";
501
+ this.port = authorityMatch.groups.port ?? "";
502
+ this.pathname = urlMatch.groups.path ?? "";
503
+ this.search = urlMatch.groups.query ?? "";
504
+ this.hash = urlMatch.groups.hash ?? "";
505
+ }
506
+ get origin() {
507
+ return `${this.protocol}//${this.host}`;
508
+ }
509
+ get password() {
510
+ return encodeURIComponent(this.#url.password);
511
+ }
512
+ set password(value) {
513
+ if (this.username.length > 0)
514
+ this.#url.password = value ?? "";
515
+ }
516
+ get pathname() {
517
+ return `/${this.#url.pathname}`;
518
+ }
519
+ set pathname(value) {
520
+ value = `${value}`;
521
+ if (value.startsWith("/"))
522
+ value = value.slice(1);
523
+ this.#url.pathname = value;
524
+ }
525
+ get port() {
526
+ if (Number.isNaN(this.#url.port))
527
+ return "";
528
+ const port = this.#url.port.toString();
529
+ if (this.protocol === "ftp:" && port === "21")
530
+ return "";
531
+ if (this.protocol === "http:" && port === "80")
532
+ return "";
533
+ if (this.protocol === "https:" && port === "443")
534
+ return "";
535
+ return port;
536
+ }
537
+ set port(value) {
538
+ switch (value) {
539
+ case "":
540
+ this.#url.port = Number.NaN;
541
+ break;
542
+ default: {
543
+ const port = Number.parseInt(value, 10);
544
+ if (port >= 0 && port < 65535)
545
+ this.#url.port = port;
546
+ }
547
+ }
548
+ }
549
+ get protocol() {
550
+ return `${this.#url.protocol}:`;
551
+ }
552
+ set protocol(value) {
553
+ if (value.endsWith(":"))
554
+ value = value.slice(0, -1);
555
+ this.#url.protocol = value;
556
+ }
557
+ get search() {
558
+ if (this.#url.search.length > 0)
559
+ return `?${this.#url.search}`;
560
+ else
561
+ return "";
562
+ }
563
+ set search(value) {
564
+ value = `${value}`;
565
+ if (value.startsWith("?"))
566
+ value = value.slice(1);
567
+ this.#url.search = value;
568
+ this.#url.searchParams = new URLSearchParams(this.#url.search, search => {
569
+ this.#url.search = search;
570
+ });
571
+ }
572
+ get searchParams() {
573
+ return this.#url.searchParams;
574
+ }
575
+ get username() {
576
+ return encodeURIComponent(this.#url.username);
577
+ }
578
+ set username(value) {
579
+ this.#url.username = value ?? "";
580
+ }
581
+ static parse = (url, base) => new URL(url, base);
582
+ /**
583
+ * Returns the string representation of the URL.
584
+ *
585
+ * @returns {string} The href of the URL.
586
+ */
587
+ toString = () => this.href;
588
+ /**
589
+ * Converts the URL object properties to a JSON string.
590
+ *
591
+ * @returns {string} A JSON string representation of the URL object.
592
+ */
593
+ toJSON = () => JSON.stringify({
594
+ hash: this.hash,
595
+ host: this.host,
596
+ hostname: this.hostname,
597
+ href: this.href,
598
+ origin: this.origin,
599
+ password: this.password,
600
+ pathname: this.pathname,
601
+ port: this.port,
602
+ protocol: this.protocol,
603
+ search: this.search,
604
+ searchParams: this.searchParams,
605
+ username: this.username,
606
+ });
607
+ }
608
+
269
609
  /**
270
610
  * 将 /api/ 后的 URL 路径转换为 util 的路径片段;非 API 路径不处理。
271
611
  * Convert URL segments after /api/ to util path segments; ignore non-API paths.
@@ -292,11 +632,23 @@ function parseSettingsPath(url) {
292
632
  * Normalize a BoxJS array, app or subscription using the source JSON as the field authority.
293
633
  * @param {unknown} config BoxJS JSON / BoxJS document.
294
634
  * @param {string} module API 第一段模块名 / First API path segment.
295
- * @returns {import("../types/index.js").ModuleDefinition} 存储根和字段 / Storage root and fields.
635
+ * @returns {import("../index.js").ModuleDefinition} 存储根和字段 / Storage root and fields.
296
636
  */
297
637
  function normalizeBoxJs(config, module) {
298
638
  parseSettingsPath(`https://example.invalid/api/${module}`);
299
- const entries = Array.isArray(config) ? config : config?.apps ? config.apps.flatMap((app) => app.settings ?? []) : config?.settings;
639
+ const apps = Array.isArray(config) ? [] : (config?.apps ?? [config]);
640
+ if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
641
+ for (const candidate of apps) {
642
+ if (!candidate || typeof candidate !== "object") throw new TypeError("Expected BoxJS app object");
643
+ if (candidate.settings !== undefined && !Array.isArray(candidate.settings)) throw new TypeError("Expected BoxJS settings array");
644
+ }
645
+ const owners = apps.filter((candidate) =>
646
+ candidate.settings?.some(
647
+ (entry) => typeof entry.id === "string" && entry.id.startsWith("@") && entry.id.slice(1).split(".")[1] === module,
648
+ ),
649
+ );
650
+ const entries = Array.isArray(config) ? config : owners.flatMap((candidate) => candidate.settings);
651
+ const app = owners.length === 1 ? owners[0] : undefined;
300
652
  if (!Array.isArray(entries)) throw new TypeError("Expected BoxJS settings array, app or subscription");
301
653
  let storageKey;
302
654
  const fields = [];
@@ -317,6 +669,10 @@ function normalizeBoxJs(config, module) {
317
669
  name: entry.name,
318
670
  type: type === "select" ? typeof entry.val : type,
319
671
  description: entry.desc ?? "",
672
+ control: entry.type,
673
+ ...(entry.placeholder === undefined ? {} : { placeholder: entry.placeholder }),
674
+ ...(entry.rows === undefined ? {} : { rows: entry.rows }),
675
+ ...(entry.autoGrow === undefined ? {} : { autoGrow: entry.autoGrow }),
320
676
  };
321
677
  if (type === "select" && !["string", "number", "boolean"].includes(field.type))
322
678
  throw new TypeError(`Select requires a scalar val: ${entry.id}`);
@@ -324,6 +680,9 @@ function normalizeBoxJs(config, module) {
324
680
  if (Object.hasOwn(entry, "val")) field.defaultValue = normalizeStoredValue(field, entry.val);
325
681
  if (
326
682
  typeof field.name !== "string" ||
683
+ (field.placeholder !== undefined && typeof field.placeholder !== "string") ||
684
+ (field.rows !== undefined && (!Number.isInteger(field.rows) || field.rows < 1)) ||
685
+ (field.autoGrow !== undefined && typeof field.autoGrow !== "boolean") ||
327
686
  fields.some((other) => other.key === field.key || other.key.startsWith(`${field.key}.`) || field.key.startsWith(`${other.key}.`))
328
687
  )
329
688
  throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);
@@ -340,13 +699,32 @@ function normalizeBoxJs(config, module) {
340
699
  if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
341
700
  const common = fields[0].key.split(".").slice(0, -1);
342
701
  for (const field of fields) while (!field.key.startsWith(`${common.join(".")}.`)) common.pop();
343
- return { module, storageKey, fields, settingsPath: common };
702
+ const metadata = {};
703
+ if (app) {
704
+ for (const key of ["id", "name", "author", "repo", "script", "icon", "description", "desc"]) {
705
+ if (app[key] === undefined) continue;
706
+ if (typeof app[key] !== "string") throw new TypeError(`Invalid BoxJS app ${key}`);
707
+ metadata[key] = app[key];
708
+ }
709
+ for (const key of ["icons", "descs"]) {
710
+ if (app[key] === undefined) continue;
711
+ if (!Array.isArray(app[key]) || app[key].some((item) => typeof item !== "string")) throw new TypeError(`Invalid BoxJS app ${key}`);
712
+ metadata[key] = [...app[key]];
713
+ }
714
+ }
715
+ return {
716
+ module,
717
+ storageKey,
718
+ fields,
719
+ settingsPath: common,
720
+ ...(Object.keys(metadata).length ? { metadata } : {}),
721
+ };
344
722
  }
345
723
 
346
724
  /**
347
725
  * 归一化 BoxJS 的字符串存储值,不改变普通文本内容。
348
726
  * Normalize BoxJS string persistence without changing free-text values.
349
- * @param {import("../types/index.js").SettingsField} field 字段 / Field.
727
+ * @param {import("../index.js").SettingsField} field 字段 / Field.
350
728
  * @param {unknown} value 存储值 / Stored value.
351
729
  * @returns {unknown} 控件值 / Control value.
352
730
  */
@@ -379,8 +757,8 @@ function validValue(field, value) {
379
757
  /**
380
758
  * 创建页面会话缓存;打开时重读,选项操作仅在 HTTP 200 后更新缓存。
381
759
  * Create a page-session cache; reload on open and mutate cache only after HTTP 200.
382
- * @param {import("../types/browser.js").PreferencesClientOptions} options 请求与通知 / Requests and notifications.
383
- * @returns {import("../types/browser.js").PreferencesClient} 通用客户端 / Generic client.
760
+ * @param {import("./index.js").PreferencesClientOptions} options 请求与通知 / Requests and notifications.
761
+ * @returns {import("./index.js").PreferencesClient} 通用客户端 / Generic client.
384
762
  */
385
763
  function createPreferencesClient({ fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 } = {}) {
386
764
  const sessions = new Map();
@@ -488,7 +866,7 @@ function createPreferencesClient({ fetch: request = globalThis.fetch.bind(global
488
866
  /**
489
867
  * 挂载从 BoxJS 实时生成的设置面板和短暂通知。
490
868
  * Mount runtime-generated BoxJS controls and transient notifications.
491
- * @param {import("../types/browser.js").PreferencesPanelOptions} options 容器与请求;页面路径 /settings/{module} 对应配置 / Container and requests; /settings/{module} selects config.
869
+ * @param {import("./index.js").PreferencesPanelOptions} options 容器与请求;页面路径 /settings/{module} 对应配置 / Container and requests; /settings/{module} selects config.
492
870
  * @returns {{destroy(): void}} 清理接口 / Cleanup handle.
493
871
  */
494
872
  function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
@@ -563,7 +941,38 @@ function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
563
941
  }
564
942
  function controls() {
565
943
  const { definition, values } = client.snapshot(active);
944
+ heading.textContent = definition.metadata?.name || active;
566
945
  const view = node("section", "pp-fields");
946
+ const growingInputs = [];
947
+ const metadata = definition.metadata;
948
+ if (metadata) {
949
+ const info = node("div", "pp-module-info");
950
+ const iconURL = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
951
+ const resourceURL = (value) => {
952
+ const url = new window.URL(value, window.location.href);
953
+ if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Module metadata URLs must use HTTP or HTTPS");
954
+ return url.href;
955
+ };
956
+ if (iconURL) {
957
+ const image = node("img", "pp-module-icon");
958
+ image.src = resourceURL(iconURL);
959
+ image.alt = "";
960
+ info.append(image);
961
+ }
962
+ const details = node("div", "pp-module-details");
963
+ if (metadata.author) details.append(node("p", "pp-description", metadata.author));
964
+ for (const description of [metadata.desc ?? metadata.description, ...(metadata.descs ?? [])])
965
+ if (description) details.append(node("p", "pp-description", description));
966
+ if (metadata.repo) {
967
+ const link = node("a", "pp-module-source", "项目主页");
968
+ link.href = resourceURL(metadata.repo);
969
+ link.target = "_blank";
970
+ link.rel = "noopener noreferrer";
971
+ details.append(link);
972
+ }
973
+ info.append(details);
974
+ view.append(info);
975
+ }
567
976
  for (const field of definition.fields) {
568
977
  const row = node("fieldset", "pp-field");
569
978
  row.append(node("legend", "", field.name));
@@ -598,8 +1007,23 @@ function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
598
1007
  for (const option of inputs) option.input.checked = Array.isArray(value) && value.includes(option.key);
599
1008
  };
600
1009
  } else {
601
- const input = node(field.type === "array" ? "textarea" : "input", "pp-input");
1010
+ const multiline = field.control === "textarea" || field.type === "array";
1011
+ const input = node(multiline ? "textarea" : "input", "pp-input");
602
1012
  input.setAttribute("aria-label", field.name);
1013
+ if (field.placeholder) input.placeholder = field.placeholder;
1014
+ if (multiline && field.rows) input.rows = field.rows;
1015
+ const grow = () => {
1016
+ if (!multiline || !field.autoGrow || !input.isConnected) return;
1017
+ input.style.height = "auto";
1018
+ const baseline = input.getBoundingClientRect().height;
1019
+ const style = window.getComputedStyle(input);
1020
+ const borders = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
1021
+ input.style.height = `${Math.max(baseline, input.scrollHeight + borders)}px`;
1022
+ };
1023
+ if (multiline && field.autoGrow) {
1024
+ input.addEventListener("input", grow);
1025
+ growingInputs.push(grow);
1026
+ }
603
1027
  if (field.type === "boolean") {
604
1028
  input.type = "checkbox";
605
1029
  write = (value) => {
@@ -607,9 +1031,10 @@ function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
607
1031
  };
608
1032
  read = () => input.checked;
609
1033
  } else {
610
- input.type = field.type === "number" ? "number" : "text";
1034
+ if (!multiline) input.type = field.type === "number" ? "number" : "text";
611
1035
  write = (value) => {
612
1036
  input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
1037
+ grow();
613
1038
  };
614
1039
  read = () =>
615
1040
  field.type === "array"
@@ -673,6 +1098,7 @@ function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
673
1098
  view.append(row);
674
1099
  }
675
1100
  viewport.replaceChildren(view);
1101
+ for (const grow of growingInputs) grow();
676
1102
  }
677
1103
  function route() {
678
1104
  if (saving) {