@nsnanocat/preference-panes 1.1.0 → 1.1.1

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.
@@ -147,26 +147,8 @@ class ActionMenu {
147
147
  }
148
148
 
149
149
  /**
150
- * 统一解析模块页的资源地址:Header 优先于查询参数,再使用模块约定。
151
- * Resolve module resource locations: headers override query parameters and module conventions.
152
- * @param {URL} url 已解析的页面请求地址 / Parsed page request URL.
153
- * @param {Record<string, string | undefined>} [headers] 请求头,名称不区分大小写 / Case-insensitive request headers.
154
- * @returns {{url: string, module: string, json: string, css: string}} 页面上下文与两个资源输入 / Page context and two resource inputs.
155
- */
156
- function pageInputs(url, headers = {}) {
157
- const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(url.pathname);
158
- if (!match) throw new TypeError("Open a concrete module URL");
159
- const module = match[1];
160
- const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
161
- const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
162
- const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? "";
163
- if (!json.trim()) throw new TypeError("JSON resource URL is required");
164
- return { url: url.href, module, json, css };
165
- }
166
-
167
- /**
168
- * 模块文档容器:原始 HTML 不改写,请求上下文随 iframe 元素传递。
169
- * Module document container: preserve HTML verbatim and carry request context on the iframe element.
150
+ * 模块文档容器:原始 HTML 不改写,只向 iframe 标记模块身份。
151
+ * Module document container: preserve HTML verbatim and mark only the module identity on the iframe.
170
152
  */
171
153
  class ModuleFrame extends EventTarget {
172
154
  #url;
@@ -188,23 +170,25 @@ class ModuleFrame extends EventTarget {
188
170
  };
189
171
 
190
172
  /**
191
- * 建立 iframe 与请求输入;调用方挂载 element 后调用 load。
192
- * Create the iframe and request inputs; callers mount element and then call load.
173
+ * 建立 iframe;调用方挂载 element 后调用 load。
174
+ * Create the iframe; callers mount element and then call load.
193
175
  * @param {string | URL} url 模块请求地址 / Module request URL.
194
- * @param {RequestInit} [options] 原生请求头和取消信号 / Native headers and cancellation signal.
176
+ * @param {{signal?: AbortSignal}} [options] 外部取消信号 / External cancellation signal.
195
177
  */
196
178
  constructor(url, options = {}) {
197
179
  super();
198
180
  this.#url = new URL(url, document.baseURI);
199
- this.#options = { ...options, headers: new Headers(options.headers) };
200
- const inputs = pageInputs(this.#url, Object.fromEntries(this.#options.headers));
181
+ const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(this.#url.pathname);
182
+ if (!match) throw new TypeError("Open a concrete module URL");
183
+ this.#options = options;
201
184
  this.element = document.createElement("iframe");
202
- this.element.title = `${inputs.module} 设置`;
203
- this.element.dataset.preferencePanes = JSON.stringify(inputs);
185
+ this.element.title = `${match[1]} 设置`;
186
+ this.element.dataset.preferencePanes = "true";
187
+ this.element.dataset.preferencePanesModule = match[1];
204
188
  this.element.addEventListener("preferencepanes:change", this.#change);
205
189
  this.element.addEventListener("preferencepanes:confirm", this.#confirmation);
206
190
  this.element.addEventListener("preferencepanes:notice", this.#notice);
207
- this.#state = { title: inputs.module, module: inputs.module, busy: false, canGoBack: true, actions: [] };
191
+ this.#state = { title: match[1], module: match[1], busy: false, canGoBack: true, actions: [] };
208
192
  options.signal?.addEventListener("abort", this.#abort, { once: true });
209
193
  }
210
194
 
@@ -225,7 +209,7 @@ class ModuleFrame extends EventTarget {
225
209
  if (this.#options.signal?.aborted) this.destroy();
226
210
  const timer = setTimeout(() => this.#controller.abort(), 10000);
227
211
  try {
228
- const response = await fetch(this.#url, { cache: "no-store", credentials: "omit", ...this.#options, signal: this.#controller.signal });
212
+ const response = await fetch(this.#url, { cache: "no-store", credentials: "omit", signal: this.#controller.signal });
229
213
  if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
230
214
  const html = await response.text();
231
215
  this.#controller.signal.throwIfAborted();
@@ -275,7 +259,6 @@ class ModuleFrame extends EventTarget {
275
259
  * @typedef {object} ModuleProbeOptions
276
260
  * @property {typeof globalThis.fetch} [fetch] 可注入的 fetch / Injectable fetch.
277
261
  * @property {AbortSignal} [signal] 外部取消信号 / External cancellation signal.
278
- * @property {string} [json] BoxJS JSON 来源,将随探测请求头传递 / BoxJS JSON source sent in the probe header.
279
262
  * @property {number} [timeout] 超时毫秒数,默认 3500 / Timeout in milliseconds, defaults to 3500.
280
263
  */
281
264
 
@@ -286,14 +269,14 @@ class ModuleFrame extends EventTarget {
286
269
  * @param {ModuleProbeOptions} [options] 请求选项 / Request options.
287
270
  * @returns {Promise<Response>} 原始 HTTP 响应,可直接读取 status 和响应头 / Native HTTP response; read status and headers directly.
288
271
  */
289
- async function probeModule(url, { fetch: request = globalThis.fetch, json, signal, timeout = 3500 } = {}) {
272
+ async function probeModule(url, { fetch: request = globalThis.fetch, signal, timeout = 3500 } = {}) {
290
273
  const controller = new AbortController();
291
274
  const abort = () => controller.abort();
292
275
  if (signal?.aborted) abort();
293
276
  signal?.addEventListener("abort", abort, { once: true });
294
277
  const timer = setTimeout(() => controller.abort(), timeout);
295
278
  try {
296
- return await request(url, { method: "HEAD", cache: "no-store", credentials: "omit", signal: controller.signal, headers: json ? { "X-PreferencePanes-JSON": json } : undefined });
279
+ return await request(url, { method: "HEAD", cache: "no-store", credentials: "omit", signal: controller.signal });
297
280
  } finally {
298
281
  clearTimeout(timer);
299
282
  signal?.removeEventListener("abort", abort);
@@ -45,8 +45,8 @@ function normalizeBoxJs(config, module) {
45
45
  target.owners.add(app);
46
46
  }
47
47
  }
48
- if (module === undefined && modules.size !== 1) throw new TypeError("Import BoxJS JSON for exactly one module");
49
- const target = module === undefined ? modules.values().next().value : modules.get(module);
48
+ if (modules.size !== 1) throw new TypeError("Import BoxJS JSON for exactly one module");
49
+ const target = modules.values().next().value ;
50
50
  if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
51
51
  const metadata = normalizeMetadata(target.owners.size === 1 ? presentation([...target.owners][0]) : {});
52
52
  const fields = [];
@@ -428,28 +428,50 @@ class ActionMenu {
428
428
  */
429
429
  class PreferencesClient {
430
430
  #module;
431
- #configURL;
432
431
  #definition;
433
432
  #request;
434
433
  #notify;
435
434
  #timeout;
436
435
  #session = new AbortController();
437
- #values;
436
+ #values = {};
438
437
  #saving = false;
439
438
 
440
439
  /**
441
- * 创建只调用模块 API、不读取或解析 BoxJS 的页面客户端。
442
- * Create a page client that only calls the module API and never reads or parses BoxJS.
443
- * @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.
444
443
  */
445
- constructor({ model, definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
446
- this.#module = model.module;
447
- this.#configURL = model.configURL;
444
+ constructor({ definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
445
+ this.#module = definition.module;
448
446
  this.#definition = definition;
449
447
  this.#request = request;
450
448
  this.#notify = notify;
451
449
  this.#timeout = timeout;
452
- 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();
453
475
  }
454
476
 
455
477
  /**
@@ -548,7 +570,7 @@ class PreferencesClient {
548
570
  credentials: "omit",
549
571
  cache: "no-store",
550
572
  signal: controller.signal,
551
- headers: { "Content-Type": "application/json", "X-PreferencePanes-JSON": this.#configURL },
573
+ headers: { "Content-Type": "application/json" },
552
574
  body: JSON.stringify(payload),
553
575
  });
554
576
  if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
@@ -572,6 +594,10 @@ class PreferencesClient {
572
594
  if (this.#saving) throw new Error("A settings write is already in progress");
573
595
  this.#saving = true;
574
596
  try {
597
+ if (operation === "write") {
598
+ const field = this.#definition.fields.find(candidate => candidate.key === key);
599
+ if (!field || !validValue(field, payload.value)) throw new TypeError("Invalid setting value");
600
+ }
575
601
  await this.#send(action, payload);
576
602
  switch (operation) {
577
603
  case "write":
@@ -769,24 +795,23 @@ class PreferencesPanel {
769
795
  #release;
770
796
 
771
797
  /**
772
- * 挂载 API 返回的模块模型表单。
773
- * Mount the module form returned by the API.
798
+ * 挂载 BoxJS 定义对应的模块表单。
799
+ * Mount the module form described by a BoxJS definition.
774
800
  * @param {HTMLElement} root 包内挂载元素 / Internal mount element.
775
- * @param {import("../index.js").ModuleModel & {definition: import("../index.js").ModuleDefinition}} model 已规范化模块模型 / Normalized module model.
801
+ * @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
776
802
  */
777
- constructor(root, model) {
778
- this.#release = this.#mount(root, model);
803
+ constructor(root, definition) {
804
+ this.#release = this.#mount(root, definition);
779
805
  }
780
806
 
781
807
  /**
782
808
  * 建立面板 DOM、交互和会话,并返回其释放操作。
783
809
  * Build panel DOM, interactions, and session, then return its release operation.
784
810
  * @param {HTMLElement} root 包内挂载元素 / Internal mount element.
785
- * @param {import("../index.js").ModuleModel & {definition: import("../index.js").ModuleDefinition}} model 已规范化模块模型 / Normalized module model.
811
+ * @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
786
812
  * @returns {() => void} 释放操作 / Release operation.
787
813
  */
788
- #mount(root, model) {
789
- const { definition } = model;
814
+ #mount(root, definition) {
790
815
  const title = definition.metadata?.name ?? definition.module;
791
816
  const document = root.ownerDocument;
792
817
  const window = document.defaultView;
@@ -878,7 +903,7 @@ class PreferencesPanel {
878
903
  toast.hidden = true;
879
904
  }, 2400);
880
905
  };
881
- const client = new PreferencesClient({ model, definition, notify });
906
+ const client = new PreferencesClient({ definition, notify });
882
907
  /**
883
908
  * 两种菜单入口共用异步错误处理,包含宿主确认框错误。
884
909
  * Share async error handling between both menus, including host-dialog errors.
@@ -906,6 +931,7 @@ class PreferencesPanel {
906
931
  publishNavigation();
907
932
  viewport.replaceChildren(statusView("读取设置…"));
908
933
  try {
934
+ await client.open();
909
935
  if (version === generation) controls();
910
936
  } catch (error) {
911
937
  if (version !== generation) return;
@@ -1294,15 +1320,14 @@ function installDefaultStyles(document) {
1294
1320
  }
1295
1321
 
1296
1322
  /**
1297
- * 管理模块设置视图的模型规范化、样式、主题同步和面板生命周期。
1298
- * Manage model normalization, styles, theme synchronization, and panel lifecycle for a module settings view.
1323
+ * 管理 BoxJS 规范化、主题同步和面板生命周期。
1324
+ * Manage BoxJS normalization, theme synchronization, and panel lifecycle.
1299
1325
  */
1300
1326
  class PreferencesView {
1301
1327
  #existing;
1302
1328
  #root;
1303
1329
  #base;
1304
1330
  #ownsBase;
1305
- #custom;
1306
1331
  #previousTitle;
1307
1332
  #previousTheme;
1308
1333
  #systemTheme;
@@ -1312,22 +1337,12 @@ class PreferencesView {
1312
1337
  #panel;
1313
1338
 
1314
1339
  /**
1315
- * 使用模块 API 返回的模型挂载设置页。
1316
- * Mount a settings page from the model returned by the module API.
1317
- * @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
1318
- * @param {string} [css] 可选 CSS 正文 / Optional module-scoped CSS text.
1340
+ * 使用原始 BoxJS JSON 挂载设置页。
1341
+ * Mount a settings page from raw BoxJS JSON.
1342
+ * @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
1319
1343
  */
1320
- constructor(model, css = "") {
1321
- if (typeof css !== "string") throw new TypeError("CSS must be a string");
1322
- const definition = normalizeBoxJs(model.boxjs, model.module);
1323
- const values = { ...model.values };
1324
- for (const field of definition.fields) {
1325
- if (values[field.key] === undefined) continue;
1326
- values[field.key] = normalizeStoredValue(field, values[field.key]);
1327
- if (!validValue(field, values[field.key])) throw new TypeError(`Invalid stored value: ${field.key}`);
1328
- }
1329
- for (const field of definition.fields) if (values[field.key] === undefined && Object.hasOwn(field, "defaultValue")) values[field.key] = structuredClone(field.defaultValue);
1330
- const rendered = { ...model, definition, values };
1344
+ constructor(boxjs) {
1345
+ const definition = normalizeBoxJs(boxjs);
1331
1346
  const metadata = definition.metadata ?? {};
1332
1347
  const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
1333
1348
  if (image) resourceURL(image);
@@ -1342,9 +1357,6 @@ class PreferencesView {
1342
1357
  const styles = installDefaultStyles(document);
1343
1358
  this.#base = styles.element;
1344
1359
  this.#ownsBase = styles.owned;
1345
- this.#custom = element("style", "");
1346
- this.#custom.textContent = css;
1347
- document.head.append(this.#custom);
1348
1360
  this.#previousTitle = document.title;
1349
1361
  this.#previousTheme = document.documentElement.dataset.theme;
1350
1362
  this.#systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
@@ -1359,7 +1371,7 @@ class PreferencesView {
1359
1371
  document.title = metadata.name ?? definition.module;
1360
1372
  try {
1361
1373
  this.#root.replaceChildren();
1362
- this.#panel = new PreferencesPanel(this.#root, rendered);
1374
+ this.#panel = new PreferencesPanel(this.#root, definition);
1363
1375
  } catch (error) {
1364
1376
  this.destroy();
1365
1377
  throw error;
@@ -1387,7 +1399,6 @@ class PreferencesView {
1387
1399
  this.#systemTheme.removeEventListener("change", this.#syncAppearance);
1388
1400
  this.#panel?.destroy();
1389
1401
  if (this.#ownsBase) this.#base.remove();
1390
- this.#custom.remove();
1391
1402
  if (this.#existing) this.#root.replaceChildren();
1392
1403
  else this.#root.remove();
1393
1404
  document.title = this.#previousTitle;
@@ -1398,14 +1409,13 @@ class PreferencesView {
1398
1409
  }
1399
1410
 
1400
1411
  /**
1401
- * 使用模块 API 返回的模型挂载设置页;CSS 仅覆盖当前模块。
1402
- * Mount a settings page from a module API model; CSS only overrides this module.
1403
- * @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
1404
- * @param {string} [css] 可选 CSS 正文 / Optional module-scoped CSS text.
1405
- * @returns {PreferencesView} 模块视图 / Module view.
1412
+ * 使用原始 BoxJS JSON 挂载设置页。
1413
+ * Mount a settings page from raw BoxJS JSON.
1414
+ * @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
1415
+ * @returns {import("./index.js").MountedPreferences} 模块视图 / Module view.
1406
1416
  */
1407
- function mount(model, css = "") {
1408
- return new PreferencesView(model, css);
1417
+ function mount(boxjs) {
1418
+ return new PreferencesView(boxjs);
1409
1419
  }
1410
1420
 
1411
- export { PreferencesView, mount };
1421
+ export { mount };