@nsnanocat/preference-panes 1.1.0 → 1.1.2

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.
@@ -1,112 +1,3 @@
1
- /**
2
- * 统一解析模块页的资源地址:Header 优先于查询参数,再使用模块约定。
3
- * Resolve module resource locations: headers override query parameters and module conventions.
4
- * @param {URL} url 已解析的页面请求地址 / Parsed page request URL.
5
- * @param {Record<string, string | undefined>} [headers] 请求头,名称不区分大小写 / Case-insensitive request headers.
6
- * @returns {{url: string, module: string, json: string, css: string}} 页面上下文与两个资源输入 / Page context and two resource inputs.
7
- */
8
- function pageInputs(url, headers = {}) {
9
- const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(url.pathname);
10
- if (!match) throw new TypeError("Open a concrete module URL");
11
- const module = match[1];
12
- const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
13
- const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
14
- const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? "";
15
- if (!json.trim()) throw new TypeError("JSON resource URL is required");
16
- return { url: url.href, module, json, css };
17
- }
18
-
19
- /**
20
- * 创建元素,所有展示文本通过 textContent 写入。
21
- * Create elements and assign display text through textContent only.
22
- * @template {keyof HTMLElementTagNameMap} T
23
- * @param {T} tag 元素标签 / Element tag.
24
- * @param {string} className 样式类名 / CSS class.
25
- * @param {string} [text] 纯文本 / Plain text.
26
- * @returns {HTMLElementTagNameMap[T]} 创建的元素 / Created element.
27
- */
28
- function element(tag, className, text) {
29
- const node = document.createElement(tag);
30
- node.className = className;
31
- if (text !== undefined) node.textContent = text;
32
- return node;
33
- }
34
-
35
- /**
36
- * 创建通用设置行;外部 CSS 可通过 pp 类名覆盖视觉样式。
37
- * Create a generic settings row whose appearance can be overridden through pp classes.
38
- * @template {"div" | "label"} T
39
- * @param {T} tag 行元素 / Row element.
40
- * @returns {HTMLElementTagNameMap[T]} 设置行 / Settings row.
41
- */
42
- function settingRow(tag) {
43
- return element(tag, "pp-row");
44
- }
45
-
46
- /**
47
- * 为标准 HTML 输入控件添加通用面板类名。
48
- * Add the generic panel class to a standard HTML input control.
49
- * @param {HTMLElement} control 已创建的原生控件 / Existing native control.
50
- * @returns {HTMLElement} 输入控件 / Input control.
51
- */
52
- function fieldControl(control) {
53
- control.classList.add("pp-editor");
54
- return control;
55
- }
56
-
57
- /**
58
- * 元数据地址只允许 HTTP(S) 和相对地址。
59
- * Allow only HTTP(S) and relative metadata addresses.
60
- * @param {string} value 元数据地址 / Metadata address.
61
- * @returns {string} 完整地址 / Absolute address.
62
- */
63
- function resourceURL(value) {
64
- const url = new URL(value, document.baseURI);
65
- if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Metadata URLs must use HTTP(S)");
66
- return url.href;
67
- }
68
-
69
- /**
70
- * 创建覆盖可用内容区的通用读取状态,失败时可附加重试动作。
71
- * Create a shared status view that fills the available content area and may include retry.
72
- * @param {string} message 状态文本 / Status message.
73
- * @param {(() => unknown) | undefined} [retry] 重试动作 / Retry action.
74
- * @returns {HTMLElement} 居中状态视图 / Centered status view.
75
- */
76
- function statusView(message, retry) {
77
- const view = element("section", "pp-status");
78
- view.setAttribute("role", "status");
79
- view.setAttribute("aria-live", "polite");
80
- const spinner = element("span", "pp-status-spinner");
81
- spinner.setAttribute("aria-hidden", "true");
82
- view.append(spinner, element("p", "pp-status-message", message));
83
- if (retry) {
84
- const button = element("button", "pp-status-action", "重新读取");
85
- button.type = "button";
86
- button.onclick = retry;
87
- view.append(button);
88
- }
89
- return view;
90
- }
91
-
92
- /**
93
- * 请求宿主确认;独立网页使用浏览器对话框。
94
- * Request confirmation from the host, using the browser dialog for standalone pages.
95
- * @param {Window} host 模块窗口 / Module window.
96
- * @param {string} message 确认内容 / Confirmation message.
97
- * @returns {Promise<boolean>} 用户是否确认 / Whether the user confirmed.
98
- */
99
- function requestConfirmation(host, message) {
100
- return new Promise((resolve, reject) => {
101
- const frame = host.frameElement;
102
- if (frame) {
103
- const event = new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:confirm", { cancelable: true, detail: { message, resolve, reject } });
104
- if (!frame.dispatchEvent(event)) return;
105
- }
106
- resolve(host.confirm(message));
107
- });
108
- }
109
-
110
1
  /**
111
2
  * 校验原始路径片段,不进行 URL 编码转换。
112
3
  * Validate raw path segments without URL encoding conversion.
@@ -292,6 +183,97 @@ function validValue(field, value) {
292
183
  return !field.options || (field.type === "array" ? value : [value]).every(item => field.options.some(option => option.key === item));
293
184
  }
294
185
 
186
+ /**
187
+ * 创建元素,所有展示文本通过 textContent 写入。
188
+ * Create elements and assign display text through textContent only.
189
+ * @template {keyof HTMLElementTagNameMap} T
190
+ * @param {T} tag 元素标签 / Element tag.
191
+ * @param {string} className 样式类名 / CSS class.
192
+ * @param {string} [text] 纯文本 / Plain text.
193
+ * @returns {HTMLElementTagNameMap[T]} 创建的元素 / Created element.
194
+ */
195
+ function element(tag, className, text) {
196
+ const node = document.createElement(tag);
197
+ node.className = className;
198
+ if (text !== undefined) node.textContent = text;
199
+ return node;
200
+ }
201
+
202
+ /**
203
+ * 创建通用设置行;外部 CSS 可通过 pp 类名覆盖视觉样式。
204
+ * Create a generic settings row whose appearance can be overridden through pp classes.
205
+ * @template {"div" | "label"} T
206
+ * @param {T} tag 行元素 / Row element.
207
+ * @returns {HTMLElementTagNameMap[T]} 设置行 / Settings row.
208
+ */
209
+ function settingRow(tag) {
210
+ return element(tag, "pp-row");
211
+ }
212
+
213
+ /**
214
+ * 为标准 HTML 输入控件添加通用面板类名。
215
+ * Add the generic panel class to a standard HTML input control.
216
+ * @param {HTMLElement} control 已创建的原生控件 / Existing native control.
217
+ * @returns {HTMLElement} 输入控件 / Input control.
218
+ */
219
+ function fieldControl(control) {
220
+ control.classList.add("pp-editor");
221
+ return control;
222
+ }
223
+
224
+ /**
225
+ * 元数据地址只允许 HTTP(S) 和相对地址。
226
+ * Allow only HTTP(S) and relative metadata addresses.
227
+ * @param {string} value 元数据地址 / Metadata address.
228
+ * @returns {string} 完整地址 / Absolute address.
229
+ */
230
+ function resourceURL(value) {
231
+ const url = new URL(value, document.baseURI);
232
+ if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Metadata URLs must use HTTP(S)");
233
+ return url.href;
234
+ }
235
+
236
+ /**
237
+ * 创建覆盖可用内容区的通用读取状态,失败时可附加重试动作。
238
+ * Create a shared status view that fills the available content area and may include retry.
239
+ * @param {string} message 状态文本 / Status message.
240
+ * @param {(() => unknown) | undefined} [retry] 重试动作 / Retry action.
241
+ * @returns {HTMLElement} 居中状态视图 / Centered status view.
242
+ */
243
+ function statusView(message, retry) {
244
+ const view = element("section", "pp-status");
245
+ view.setAttribute("role", "status");
246
+ view.setAttribute("aria-live", "polite");
247
+ const spinner = element("span", "pp-status-spinner");
248
+ spinner.setAttribute("aria-hidden", "true");
249
+ view.append(spinner, element("p", "pp-status-message", message));
250
+ if (retry) {
251
+ const button = element("button", "pp-status-action", "重新读取");
252
+ button.type = "button";
253
+ button.onclick = retry;
254
+ view.append(button);
255
+ }
256
+ return view;
257
+ }
258
+
259
+ /**
260
+ * 请求宿主确认;独立网页使用浏览器对话框。
261
+ * Request confirmation from the host, using the browser dialog for standalone pages.
262
+ * @param {Window} host 模块窗口 / Module window.
263
+ * @param {string} message 确认内容 / Confirmation message.
264
+ * @returns {Promise<boolean>} 用户是否确认 / Whether the user confirmed.
265
+ */
266
+ function requestConfirmation(host, message) {
267
+ return new Promise((resolve, reject) => {
268
+ const frame = host.frameElement;
269
+ if (frame) {
270
+ const event = new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:confirm", { cancelable: true, detail: { message, resolve, reject } });
271
+ if (!frame.dispatchEvent(event)) return;
272
+ }
273
+ resolve(host.confirm(message));
274
+ });
275
+ }
276
+
295
277
  /**
296
278
  * 共用三点按钮和底部操作菜单;弹层挂载到文档根部,不受标题栏显示状态影响。
297
279
  * Shared overflow trigger and bottom action sheet; the layer is mounted at document level and remains independent of header visibility.
@@ -446,28 +428,50 @@ class ActionMenu {
446
428
  */
447
429
  class PreferencesClient {
448
430
  #module;
449
- #configURL;
450
431
  #definition;
451
432
  #request;
452
433
  #notify;
453
434
  #timeout;
454
435
  #session = new AbortController();
455
- #values;
436
+ #values = {};
456
437
  #saving = false;
457
438
 
458
439
  /**
459
- * 创建只调用模块 API、不读取或解析 BoxJS 的页面客户端。
460
- * Create a page client that only calls the module API and never reads or parses BoxJS.
461
- * @param {import("./client.mjs").PreferencesClientOptions} options API 模型、请求与通知 / API model, requests, and notifications.
440
+ * 创建从 BoxJS 定义读取和持久化设置的页面客户端。
441
+ * Create a page client that reads and persists settings from a BoxJS definition.
442
+ * @param {import("./client.mjs").PreferencesClientOptions} options 字段定义、请求与通知 / Field definition, requests, and notifications.
462
443
  */
463
- constructor({ model, definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
464
- this.#module = model.module;
465
- this.#configURL = model.configURL;
444
+ constructor({ definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
445
+ this.#module = definition.module;
466
446
  this.#definition = definition;
467
447
  this.#request = request;
468
448
  this.#notify = notify;
469
449
  this.#timeout = timeout;
470
- this.#values = structuredClone(model.values);
450
+ }
451
+
452
+ /**
453
+ * 读取一次 Settings 子树并建立页面值快照。
454
+ * Read the Settings subtree once and establish the page value snapshot.
455
+ * @returns {Promise<import("./client.mjs").ModuleSnapshot>} 页面快照 / Page snapshot.
456
+ */
457
+ async open() {
458
+ let subtree = await this.readSettings();
459
+ if (subtree === undefined) subtree = {};
460
+ if (typeof subtree === "string") subtree = JSON.parse(subtree);
461
+ if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
462
+ const values = {};
463
+ for (const field of this.#definition.fields) {
464
+ const stored = field.key
465
+ .split(".")
466
+ .slice(this.#definition.settingsPath.length)
467
+ .reduce((parent, part) => Object(parent)[part], subtree);
468
+ const value = normalizeStoredValue(field, stored === undefined ? field.defaultValue : stored);
469
+ if (value === undefined) continue;
470
+ if (!validValue(field, value)) throw new TypeError(`Invalid stored value: ${field.key}`);
471
+ values[field.key] = value;
472
+ }
473
+ this.#values = values;
474
+ return this.snapshot();
471
475
  }
472
476
 
473
477
  /**
@@ -485,7 +489,7 @@ class PreferencesClient {
485
489
  * @returns {Promise<unknown>} Settings 内容或 undefined / Settings content or undefined.
486
490
  */
487
491
  async readSettings() {
488
- const response = await this.#send("get", { scope: "settings" });
492
+ const response = await this.#send("get", `@${this.#definition.storageKey}.${this.#definition.settingsPath.join(".")}`);
489
493
  return response.status === 404 ? undefined : response.json();
490
494
  }
491
495
 
@@ -495,7 +499,7 @@ class PreferencesClient {
495
499
  * @returns {Promise<unknown>} Caches 内容或 undefined / Caches content or undefined.
496
500
  */
497
501
  async readCaches() {
498
- const response = await this.#send("get", { scope: "caches" });
502
+ const response = await this.#send("get", `@${this.#definition.storageKey}.${this.#module}.Caches`);
499
503
  return response.status === 404 ? undefined : response.json();
500
504
  }
501
505
 
@@ -505,7 +509,7 @@ class PreferencesClient {
505
509
  * @returns {Promise<void>} 操作完成 / Operation completion.
506
510
  */
507
511
  clearCaches() {
508
- return this.#change("delete", { scope: "caches" }, "clearCaches");
512
+ return this.#change("delete", `${this.#module}.Caches`, undefined, "clearCaches");
509
513
  }
510
514
 
511
515
  /**
@@ -514,7 +518,7 @@ class PreferencesClient {
514
518
  * @returns {Promise<void>} 操作完成 / Operation completion.
515
519
  */
516
520
  reset() {
517
- return this.#change("delete", { scope: "module" }, "reset");
521
+ return this.#change("delete", this.#module, undefined, "reset");
518
522
  }
519
523
 
520
524
  /**
@@ -534,7 +538,7 @@ class PreferencesClient {
534
538
  * @returns {Promise<void>} 操作完成 / Operation completion.
535
539
  */
536
540
  set(key, value) {
537
- return this.#change("set", { key, value }, "write", key);
541
+ return this.#change("set", key, value, "write");
538
542
  }
539
543
 
540
544
  /**
@@ -544,30 +548,31 @@ class PreferencesClient {
544
548
  * @returns {Promise<void>} 操作完成 / Operation completion.
545
549
  */
546
550
  remove(key) {
547
- return this.#change("delete", { key }, "delete", key);
551
+ return this.#change("delete", key, undefined, "delete");
548
552
  }
549
553
 
550
554
  /**
551
- * 向模块 API 发送 JSON 动作。
552
- * Send a JSON action to the module API.
553
- * @param {"get" | "set" | "delete"} action 模块动作 / Module action.
554
- * @param {unknown} payload JSON 请求体 / JSON request body.
555
+ * 向固定存储 API 发送完整路径的 form 动作。
556
+ * Send a complete-path form action to the fixed storage API.
557
+ * @param {"get" | "set" | "delete"} action 存储动作 / Storage action.
558
+ * @param {string} path 完整 @root.path / Complete @root.path.
559
+ * @param {unknown} [value] set 写入值 / Value written by set.
555
560
  * @returns {Promise<Response>} 原始响应 / Raw response.
556
561
  */
557
- async #send(action, payload) {
562
+ async #send(action, path, value) {
558
563
  const controller = new AbortController();
559
564
  const abort = () => controller.abort();
560
565
  if (this.#session.signal.aborted) abort();
561
566
  this.#session.signal.addEventListener("abort", abort, { once: true });
562
567
  const timer = setTimeout(abort, this.#timeout);
563
568
  try {
564
- const response = await this.#request(`/api/${encodeURIComponent(this.#module)}/${action}`, {
569
+ const response = await this.#request(`/api/${action}`, {
565
570
  method: "POST",
566
571
  credentials: "omit",
567
572
  cache: "no-store",
568
573
  signal: controller.signal,
569
- headers: { "Content-Type": "application/json", "X-PreferencePanes-JSON": this.#configURL },
570
- body: JSON.stringify(payload),
574
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
575
+ body: new URLSearchParams([[path, action === "set" ? JSON.stringify(value) : ""]]).toString(),
571
576
  });
572
577
  if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
573
578
  return response;
@@ -581,24 +586,28 @@ class PreferencesClient {
581
586
  * 执行写入动作;成功后只更新当前页面值。
582
587
  * Execute a mutation and update only the current page values after success.
583
588
  * @param {"set" | "delete"} action API 动作 / API action.
584
- * @param {Record<string, unknown>} payload JSON 请求体 / JSON request body.
589
+ * @param {string} key 不含存储根的路径 / Path without the storage root.
590
+ * @param {unknown} value set 写入值 / Value written by set.
585
591
  * @param {"write" | "delete" | "clearCaches" | "reset"} operation 通知操作 / Notification operation.
586
- * @param {string} [key] 字段路径 / Field path.
587
592
  * @returns {Promise<void>} 操作完成 / Operation completion.
588
593
  */
589
- async #change(action, payload, operation, key) {
594
+ async #change(action, key, value, operation) {
590
595
  if (this.#saving) throw new Error("A settings write is already in progress");
591
596
  this.#saving = true;
592
597
  try {
593
- await this.#send(action, payload);
598
+ let field;
599
+ if (operation === "write" || operation === "delete") {
600
+ field = this.#definition.fields.find(candidate => candidate.key === key);
601
+ if (!field || (operation === "write" && !validValue(field, value))) throw new TypeError("Invalid setting value");
602
+ }
603
+ await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
594
604
  switch (operation) {
595
605
  case "write":
596
- this.#values[key] = structuredClone(payload.value);
606
+ this.#values[key] = structuredClone(value);
597
607
  break;
598
608
  case "delete": {
599
- const field = this.#definition.fields.find(candidate => candidate.key === key);
600
609
  delete this.#values[key];
601
- if (field && Object.hasOwn(field, "defaultValue")) this.#values[key] = structuredClone(field.defaultValue);
610
+ if (Object.hasOwn(field, "defaultValue")) this.#values[key] = structuredClone(field.defaultValue);
602
611
  break;
603
612
  }
604
613
  case "clearCaches":
@@ -787,24 +796,23 @@ class PreferencesPanel {
787
796
  #release;
788
797
 
789
798
  /**
790
- * 挂载 API 返回的模块模型表单。
791
- * Mount the module form returned by the API.
799
+ * 挂载 BoxJS 定义对应的模块表单。
800
+ * Mount the module form described by a BoxJS definition.
792
801
  * @param {HTMLElement} root 包内挂载元素 / Internal mount element.
793
- * @param {import("../index.js").ModuleModel & {definition: import("../index.js").ModuleDefinition}} model 已规范化模块模型 / Normalized module model.
802
+ * @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
794
803
  */
795
- constructor(root, model) {
796
- this.#release = this.#mount(root, model);
804
+ constructor(root, definition) {
805
+ this.#release = this.#mount(root, definition);
797
806
  }
798
807
 
799
808
  /**
800
809
  * 建立面板 DOM、交互和会话,并返回其释放操作。
801
810
  * Build panel DOM, interactions, and session, then return its release operation.
802
811
  * @param {HTMLElement} root 包内挂载元素 / Internal mount element.
803
- * @param {import("../index.js").ModuleModel & {definition: import("../index.js").ModuleDefinition}} model 已规范化模块模型 / Normalized module model.
812
+ * @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
804
813
  * @returns {() => void} 释放操作 / Release operation.
805
814
  */
806
- #mount(root, model) {
807
- const { definition } = model;
815
+ #mount(root, definition) {
808
816
  const title = definition.metadata?.name ?? definition.module;
809
817
  const document = root.ownerDocument;
810
818
  const window = document.defaultView;
@@ -896,7 +904,7 @@ class PreferencesPanel {
896
904
  toast.hidden = true;
897
905
  }, 2400);
898
906
  };
899
- const client = new PreferencesClient({ model, definition, notify });
907
+ const client = new PreferencesClient({ definition, notify });
900
908
  /**
901
909
  * 两种菜单入口共用异步错误处理,包含宿主确认框错误。
902
910
  * Share async error handling between both menus, including host-dialog errors.
@@ -924,6 +932,7 @@ class PreferencesPanel {
924
932
  publishNavigation();
925
933
  viewport.replaceChildren(statusView("读取设置…"));
926
934
  try {
935
+ await client.open();
927
936
  if (version === generation) controls();
928
937
  } catch (error) {
929
938
  if (version !== generation) return;
@@ -1312,15 +1321,14 @@ function installDefaultStyles(document) {
1312
1321
  }
1313
1322
 
1314
1323
  /**
1315
- * 管理模块设置视图的模型规范化、样式、主题同步和面板生命周期。
1316
- * Manage model normalization, styles, theme synchronization, and panel lifecycle for a module settings view.
1324
+ * 管理 BoxJS 规范化、主题同步和面板生命周期。
1325
+ * Manage BoxJS normalization, theme synchronization, and panel lifecycle.
1317
1326
  */
1318
1327
  class PreferencesView {
1319
1328
  #existing;
1320
1329
  #root;
1321
1330
  #base;
1322
1331
  #ownsBase;
1323
- #custom;
1324
1332
  #previousTitle;
1325
1333
  #previousTheme;
1326
1334
  #systemTheme;
@@ -1330,22 +1338,12 @@ class PreferencesView {
1330
1338
  #panel;
1331
1339
 
1332
1340
  /**
1333
- * 使用模块 API 返回的模型挂载设置页。
1334
- * Mount a settings page from the model returned by the module API.
1335
- * @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
1336
- * @param {string} [css] 可选 CSS 正文 / Optional module-scoped CSS text.
1341
+ * 使用原始 BoxJS JSON 挂载设置页。
1342
+ * Mount a settings page from raw BoxJS JSON.
1343
+ * @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
1337
1344
  */
1338
- constructor(model, css = "") {
1339
- if (typeof css !== "string") throw new TypeError("CSS must be a string");
1340
- const definition = normalizeBoxJs(model.boxjs, model.module);
1341
- const values = { ...model.values };
1342
- for (const field of definition.fields) {
1343
- if (values[field.key] === undefined) continue;
1344
- values[field.key] = normalizeStoredValue(field, values[field.key]);
1345
- if (!validValue(field, values[field.key])) throw new TypeError(`Invalid stored value: ${field.key}`);
1346
- }
1347
- for (const field of definition.fields) if (values[field.key] === undefined && Object.hasOwn(field, "defaultValue")) values[field.key] = structuredClone(field.defaultValue);
1348
- const rendered = { ...model, definition, values };
1345
+ constructor(boxjs) {
1346
+ const definition = normalizeBoxJs(boxjs);
1349
1347
  const metadata = definition.metadata ?? {};
1350
1348
  const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
1351
1349
  if (image) resourceURL(image);
@@ -1360,9 +1358,6 @@ class PreferencesView {
1360
1358
  const styles = installDefaultStyles(document);
1361
1359
  this.#base = styles.element;
1362
1360
  this.#ownsBase = styles.owned;
1363
- this.#custom = element("style", "");
1364
- this.#custom.textContent = css;
1365
- document.head.append(this.#custom);
1366
1361
  this.#previousTitle = document.title;
1367
1362
  this.#previousTheme = document.documentElement.dataset.theme;
1368
1363
  this.#systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
@@ -1377,7 +1372,7 @@ class PreferencesView {
1377
1372
  document.title = metadata.name ?? definition.module;
1378
1373
  try {
1379
1374
  this.#root.replaceChildren();
1380
- this.#panel = new PreferencesPanel(this.#root, rendered);
1375
+ this.#panel = new PreferencesPanel(this.#root, definition);
1381
1376
  } catch (error) {
1382
1377
  this.destroy();
1383
1378
  throw error;
@@ -1405,7 +1400,6 @@ class PreferencesView {
1405
1400
  this.#systemTheme.removeEventListener("change", this.#syncAppearance);
1406
1401
  this.#panel?.destroy();
1407
1402
  if (this.#ownsBase) this.#base.remove();
1408
- this.#custom.remove();
1409
1403
  if (this.#existing) this.#root.replaceChildren();
1410
1404
  else this.#root.remove();
1411
1405
  document.title = this.#previousTitle;
@@ -1416,11 +1410,20 @@ class PreferencesView {
1416
1410
  }
1417
1411
 
1418
1412
  /**
1419
- * 管理模块文档的页面输入、初始请求、重载和错误状态。
1420
- * Manage page inputs, initial requests, reloads, and error states for a module document.
1413
+ * 使用原始 BoxJS JSON 挂载设置页。
1414
+ * Mount a settings page from raw BoxJS JSON.
1415
+ * @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
1416
+ * @returns {import("./index.js").MountedPreferences} 模块视图 / Module view.
1417
+ */
1418
+ function mount(boxjs) {
1419
+ return new PreferencesView(boxjs);
1420
+ }
1421
+
1422
+ /**
1423
+ * 管理模块文档的配置请求、重载和错误状态。
1424
+ * Manage configuration requests, reloads, and error states for a module document.
1421
1425
  */
1422
1426
  class ModulePage {
1423
- #document;
1424
1427
  #window;
1425
1428
  #root;
1426
1429
  #view;
@@ -1431,7 +1434,6 @@ class ModulePage {
1431
1434
  * @param {Document} document 模块文档 / Module document.
1432
1435
  */
1433
1436
  constructor(document) {
1434
- this.#document = document;
1435
1437
  this.#window = document.defaultView;
1436
1438
  this.#root = document.querySelector("#preferences");
1437
1439
  installDefaultStyles(document);
@@ -1439,8 +1441,8 @@ class ModulePage {
1439
1441
  }
1440
1442
 
1441
1443
  /**
1442
- * URL 或代理传递的 Header 导入 JSON/CSS,支持独立文档与 srcdoc。
1443
- * Import JSON/CSS from the URL or proxy-carried headers in standalone and srcdoc documents.
1444
+ * 通过模块 API 读取 BoxJS JSON 并挂载通用前端。
1445
+ * Read BoxJS JSON through the module API and mount the generic frontend.
1444
1446
  * @returns {Promise<void>} 启动完成 / Startup completion.
1445
1447
  */
1446
1448
  async start() {
@@ -1448,12 +1450,15 @@ class ModulePage {
1448
1450
  this.#view?.destroy();
1449
1451
  this.#view = undefined;
1450
1452
  this.#root.replaceChildren(statusView("读取设置…"));
1451
- const inputs = this.#readInputs();
1452
- const apiURL = new URL(`/api/${encodeURIComponent(inputs.module)}`, inputs.url).href;
1453
- const styleURL = this.#resourceURL(inputs.css, inputs.url);
1454
- const [style, modelResponse] = await Promise.all([styleURL ? fetch(styleURL, { cache: "no-store", credentials: "omit" }) : null, fetch(apiURL, { cache: "no-store", credentials: "omit", headers: { Accept: "application/json", "X-PreferencePanes-JSON": inputs.json } })]);
1455
- if ((style && style.status !== 200) || modelResponse.status !== 200) throw new Error(`HTTP ${modelResponse.status !== 200 ? modelResponse.status : style.status}`);
1456
- this.#view = new PreferencesView(await modelResponse.json(), style ? await style.text() : "");
1453
+ const embedded = this.#window.frameElement?.dataset.preferencePanesModule;
1454
+ const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(this.#window.location.pathname);
1455
+ const module = embedded ?? match?.[1];
1456
+ if (!module) throw new TypeError("Open a concrete module URL");
1457
+ const response = await fetch(`/api/${encodeURIComponent(module)}`, { cache: "no-store", credentials: "omit", headers: { Accept: "application/json" } });
1458
+ if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
1459
+ const boxjs = await response.json();
1460
+ normalizeBoxJs(boxjs, module);
1461
+ this.#view = mount(boxjs);
1457
1462
  } catch (error) {
1458
1463
  this.#root.replaceChildren(statusView(`加载失败:${error.message}`, () => this.start()));
1459
1464
  }
@@ -1470,39 +1475,6 @@ class ModulePage {
1470
1475
  this.#view = undefined;
1471
1476
  }
1472
1477
 
1473
- /**
1474
- * 读取嵌入参数、文档元数据或当前 URL 输入。
1475
- * Read embedded parameters, document metadata, or current URL inputs.
1476
- * @returns {ReturnType<typeof pageInputs>} 页面输入 / Page inputs.
1477
- */
1478
- #readInputs() {
1479
- const context = this.#document.querySelector('meta[name="preference-panes-inputs"]');
1480
- const embedded = this.#window.frameElement?.dataset.preferencePanes;
1481
- switch (true) {
1482
- case embedded !== undefined:
1483
- this.#document.documentElement.dataset.preferencePanesEmbedded = "";
1484
- return JSON.parse(embedded);
1485
- case context !== null:
1486
- return JSON.parse(decodeURIComponent(context.content));
1487
- default:
1488
- return pageInputs(new URL(this.#window.location.href));
1489
- }
1490
- }
1491
-
1492
- /**
1493
- * 将可选页面资源限制为 HTTP(S) 地址。
1494
- * Restrict an optional page resource to an HTTP(S) URL.
1495
- * @param {string | undefined} source 资源地址 / Resource location.
1496
- * @param {string} baseURL 页面基准地址 / Page base URL.
1497
- * @returns {string | null} 绝对资源地址 / Absolute resource URL.
1498
- */
1499
- #resourceURL(source, baseURL) {
1500
- if (!source) return null;
1501
- const url = new URL(source, baseURL);
1502
- if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Resources must use HTTP(S) URLs");
1503
- return url.href;
1504
- }
1505
-
1506
1478
  /**
1507
1479
  * 从前进后退缓存恢复时重新加载模块。
1508
1480
  * Reload the module when restored from the back-forward cache.
@@ -1515,5 +1487,3 @@ class ModulePage {
1515
1487
  }
1516
1488
 
1517
1489
  new ModulePage(document).start();
1518
-
1519
- export { ModulePage };