@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.
package/README.md CHANGED
@@ -1,76 +1,94 @@
1
1
  # @nsnanocat/preference-panes
2
2
 
3
- PreferencePanes 分别提供具体模块的设置前端和本地持久化 API。`web.js` 只返回模块 HTML 与公共浏览器资源,`api.js` 只处理模块数据和持久化;业务模块自己发布版本对应的 BoxJS JSON。项目主页及其客户端 SDK 调用属于调用方,不进入本包。
3
+ PreferencePanes 提供一个由 BoxJS JSON 驱动的通用设置前端,以及一个独立的代理持久化 API。业务模块只发布自己的 `/configs/{module}` `/api/{module}`;通用 `/settings/**` 前端只需要安装一次。
4
4
 
5
- ## 模块 API
5
+ Biliverse 中,Enhanced 是唯一安装 `web.js` 的模块。Global、Redirect、ADBlock 不携带 `web.js`,它们的设置页仍由同一份通用前端读取各自 BoxJS 后渲染。
6
6
 
7
- 嵌入的模块页跟随宿主根元素的 `data-theme`(light/dark)和 `--pp-keyboard-height`(CSS 长度),退出时释放观察器。独立页面使用网页自身或系统主题;通用包不识别任何客户端 User-Agent。
7
+ ## 浏览器入口
8
8
 
9
- 默认样式完全由包内 `pp-*` 类和 CSS 变量提供,不请求任何项目或客户端资源。调用方可通过 CSS 输入覆盖变量、组件,或在该 CSS 中自行引用所属客户端的官方样式。开关采用标准 checkbox 的 switch 属性及 switch 语义,由浏览器呈现原生控件([Safari/iOS 17.4 起显示开关](https://webkit.org/blog/15054/an-html-switch-control/),其他浏览器保留可操作的复选框);单选使用 select,多选保留二级页面。BoxJS 动态生成及修改即保存保持不变。
9
+ 浏览器包只公开一个入口:
10
10
 
11
- 宿主可监听 ModuleFrame 的 `confirm` 事件,调用 `preventDefault()` 接管确认框,再以 `event.detail.resolve(boolean)` 或 `reject(error)` 完成。未接管的独立网页使用浏览器对话框;模块离开后到达的确认结果不会继续写入。
11
+ ```ts
12
+ function mount(boxjs: BoxJSInput): MountedPreferences;
13
+ interface MountedPreferences { destroy(): void }
14
+ ```
15
+
16
+ ```js
17
+ import { mount } from "@nsnanocat/preference-panes/browser";
18
+
19
+ const boxjs = await fetch("/configs/Module", {
20
+ cache: "no-store",
21
+ credentials: "omit",
22
+ }).then(response => response.json());
23
+
24
+ const preferences = mount(boxjs);
25
+ preferences.destroy();
26
+ ```
27
+
28
+ `mount()` 同步建立页面生命周期。BoxJS 规范化、默认值、已存值校验、加载状态和失败重试都由内部流程管理;不公开 API Model、视图 class、构建器或 CSS 第二参数。
29
+
30
+ 前端支持 BoxJS 字段数组、单个 app 和 apps 订阅,并要求输入恰好包含一个模块。模块名、存储根和 Settings 路径都从 `@Root.Module.Settings.key` 字段 ID 推导,不从 app 名称或调用参数补充。
31
+
32
+ 默认样式随包内置。嵌入页面只跟随宿主根元素的 `data-theme` 和 `--pp-keyboard-height`,不识别客户端 User-Agent,也不加载项目 CSS 或客户端 SDK。
33
+
34
+ ## 页面与 API
35
+
36
+ `web.js` 只返回三类通用资源:
37
+
38
+ - `GET /settings/{module}`
39
+ - `GET /settings/assets/index.mjs`
40
+ - `GET /settings/assets/navigation.mjs`
12
41
 
13
- 宿主也可监听 `notice` 事件,通过 `preventDefault()` 接管 `{kind, message}` 提示;被接管时模块不创建网页 Toast、不启用提示计时器。独立使用的通用面板仍提供默认通知。
42
+ 模块页面从 URL `ModuleFrame` 的模块标记取得模块名,直接读取同源 `/configs/{module}`,然后调用 `mount(boxjs)`。页面不接受 JSON/CSS 查询参数、私有请求头或兼容资源别名。
14
43
 
15
- 业务模块分别映射同一 Release 的两个产物:`api.js` 只匹配 `/api/{module}` 及其动作,`web.js` 只匹配 `/settings/{module}`、`/settings/assets/index.mjs` 和 `/settings/assets/navigation.mjs`。发布资源仍把 1.0.0 使用的 `/settings/assets/app.mjs` 映射到同一份 `index.mjs` 正文,兼容尚未更新的已安装模块,但不存在第二个页面入口实现。两者都不包含业务配置,也不需要额外安装独立设置插件。
44
+ `api.js` 只处理:
16
45
 
17
- 网页只调用模块 API:`HEAD /api/{module}` 探测模块,`GET /api/{module}` 取得原始 BoxJS 与当前已存值,`POST /api/{module}/get|set|delete` 执行持久化操作。`api.js` 负责取得 BoxJS、确认字段 ID 并把完整 `@root.path` 直接交给 util `Storage`;网页不直接请求配置 Mock,也不提交存储根。
46
+ - `HEAD /api/{module}`:探测同源 `/configs/{module}`,透传状态与 `X-PreferencePanes-Version`。
47
+ - `POST /api/{module}/get`:读取声明字段或 Settings、Caches、module 子树。
48
+ - `POST /api/{module}/set`:写入当前 BoxJS 声明的字段。
49
+ - `POST /api/{module}/delete`:删除声明字段或模块子树。
50
+
51
+ `GET /api/{module}` 和其它未规定方法返回 `405`。API 每次动作都从 `/configs/{module}` 建立字段目录,只负责字段授权与 util `Storage` 读写,不解释控件、选项或默认值。
18
52
 
19
53
  ```js
20
54
  await fetch("/api/Enhanced/set", {
21
55
  method: "POST",
22
- headers: {
23
- "Content-Type": "application/json",
24
- "X-PreferencePanes-JSON": "/configs/Enhanced",
25
- },
26
- body: JSON.stringify({ key: "Enhanced.Settings.Home.Top_left", value: "mine" }),
56
+ headers: { "Content-Type": "application/json" },
57
+ body: JSON.stringify({
58
+ key: "Enhanced.Settings.Home.Top_left",
59
+ value: "mine",
60
+ }),
27
61
  });
28
62
  ```
29
63
 
30
- 模块 API 返回的 `boxjs` 保持上游原样,`values` 只含实际已存值,不补默认值或解释控件类型。Web `mount()` 独占控件、选项、默认值、展示元数据和已存值校验,再生成页面。写入成功后只更新当前页面快照,不追加读取。旧 `/api/get|set|delete` form 接口和 `/api/module/{module}` 草稿路径均移除。
64
+ 页面初始化时只执行一次 `POST /api/{module}/get` 读取 Settings 子树。写入成功后仅更新当前页面快照;查看 Settings/Caches 时按需读取,清空和重置通过 delete 动作完成。
31
65
 
32
- ## 页面与输入
66
+ ## 宿主集成
33
67
 
34
68
  ```js
35
- import { PreferencesView } from "@nsnanocat/preference-panes/browser";
36
- const model = await fetch("/api/Module", {
37
- headers: { "X-PreferencePanes-JSON": "/configs/Module" },
38
- }).then(response => response.json());
39
- const page = new PreferencesView(model, ".pp-panel { --pp-accent: #16866a; }");
40
- page.destroy();
41
- ```
69
+ import { ModuleFrame, ModuleStatus } from "@nsnanocat/preference-panes/navigation";
42
70
 
43
- 浏览器生命周期依次由 `ModulePage`、`PreferencesView`、`PreferencesPanel` `PreferencesClient` 管理:页面入口读取输入并请求初始模型,视图负责 BoxJS 解析、校验、样式与主题,面板负责控件和导航,客户端只负责 API 请求与页面值快照。`mount(model, css?)` 仍作为创建 `PreferencesView` 的便捷入口。
71
+ const status = new ModuleStatus(statusElement);
72
+ await status.check("/api/Module");
44
73
 
45
- API 支持字段数组、单 app apps 订阅,只扫描 `@Root.Module.Settings.key` 形式的字段 ID。浏览器从 API 返回的同一份 BoxJS 解析控件、名称、图标和默认值;省略 CSS 使用内置样式。
46
-
47
- `/settings/{module}` 由独立 `web.js` 返回模块文档。页面可通过 json/css 查询参数或 X-PreferencePanes-JSON/CSS Header 指定资源 URL;默认 JSON 是 /configs/{module},CSS 默认空。Header 分别优先。`web.js` 不访问网络或存储,浏览器执行其中的 `index.mjs` 后才调用 `api.js`。
48
-
49
- ```js
50
- import { ModuleFrame, Navigation } from "@nsnanocat/preference-panes/navigation";
51
- const frame = new ModuleFrame("/settings/Module", {
52
- headers: { "X-PreferencePanes-JSON": "/configs/Module", "X-PreferencePanes-CSS": "/theme.css" },
53
- signal,
54
- });
74
+ const frame = new ModuleFrame("/settings/Module", { signal });
55
75
  container.append(frame.element);
56
76
  await frame.load();
57
- frame.addEventListener("change", () => { title.textContent = frame.state.title; });
58
- back.onclick = () => frame.back();
59
- frame.destroy();
60
77
  ```
61
78
 
62
- ModuleFrame iframe 元素上保存原请求上下文,HTML 原样加载,不从 about:srcdoc 猜模块、不注入临时 CSS。框架自身管理嵌入模式,通过事件发布标题、忙碌状态和返回能力。Navigation 统一管理 fragment 历史、滑动、滚动保留、加载取消及动画结束后释放;项目提供根页、子页工厂和布局。
63
-
64
- 项目主页可按需使用导出的 `probeModule`、ModuleStatus、ModuleFrame 和 Navigation,也可以自行实现入口。`probeModule("/api/Module", { json: "/configs/Module" })` 只对模块 API 发送一次 HEAD,并原样返回浏览器 `Response`;代理 API 负责探测 BoxJS 上游并透传状态和 `X-PreferencePanes-Version`。ModuleStatus 仅负责把同一响应渲染到状态行。PreferencePanes 不提供主页运行脚本,不识别具体 App,不加载或调用任何客户端 Bridge SDK。调用方若运行在原生 WebView,应在自己的 HTML/页面脚本中直接接入该客户端的官方 SDK,再把 ModuleFrame 事件映射到原生界面。
65
-
66
- 每次进入模块通过 API 取得 JSON 和设置一次,同时读取可选 CSS。二级多选返回复用内存缓存,修改经 Web 校验后提交 API;成功提示、失败回滚由公共组件处理。Caches 按需通过 API 查看/清空,重置只删除指定模块子树。
67
-
68
- 标题栏只显示文字;嵌入模式隐藏模块自身标题栏,由宿主显示原生标题或自己的导航。模块数据操作位于标题栏右侧三点菜单,页面不再平铺维护按钮。`ActionMenu` 是共用的底部操作菜单:独立网页由其三点按钮打开;网页宿主或只有原生按钮、没有原生菜单的 WebView 宿主,将 `frame.state.actions` 传给 `menu.update(actions, busy)`,在宿主按钮点击时调用 `menu.open()`,选择后调用 `frame.perform(id)`。查看设置和缓存都会通过 `POST /api/{module}/get` 按需读取最新子树,再进入可返回的 JSON 详情子页;返回详情页不会触发额外读取,清空和重置设置通过 `/delete` 且仍要求确认。菜单组件提供遮罩、取消按钮、外部点击、Escape 与方向键操作,弹层挂载在文档根部,不依赖模块标题栏是否显示;宿主不访问 iframe 内部 DOM。
79
+ `ModuleFrame` 只携带模块身份和取消信号。宿主通过 `change`、`confirm`、`notice` 事件同步标题、操作菜单、确认框和提示,不读取或改写 iframe 内部 DOM。项目主页、Bilibili JSBridge、原生导航和视觉样式仍由宿主负责。
69
80
 
70
81
  ## 构建与验证
71
82
 
72
- `npm run build` 生成无业务配置的 `dist/api.js`、`dist/web.js` 和公共前端模块;Release 工作流分别上传后端 API 与前端响应脚本。`build(boxjs, css?)` 仅输出 `settings/{module}/index.html`、公共 `settings/assets/index.mjs` 和可选模块 CSS,不复制配置、导航组件或重复 HTML。
83
+ ```sh
84
+ npm run build
85
+ npm run check
86
+ npm run apifox:check
87
+ npm pack --dry-run
88
+ ```
89
+
90
+ 构建生成 `dist/api.js`、`dist/web.js`、`dist/preference-panes.mjs` 和 `dist/module/` 通用页面资源。包不再提供按模块生成 HTML/CSS 的公开 `build()`。
73
91
 
74
- `npm run preview` 提供文件导入测试台,上传 JSON/CSS 后在隔离 iframe 预览;测试存储只在内存中。`npm run check` 检查代码、类型、行为;`npm run apifox:generate` 和 `npm run apifox:check` 维护原生接口文档。
92
+ `npm run preview` 启动 JSON 文件导入测试台,使用同一通用页面、同源配置和内存存储验证模块行为。
75
93
 
76
- [接口规范](apifox/Specification.md) · [Apifox JSON](apifox/preference-panes.apifox.json)
94
+ [接口规范](apifox/Specification.md) · [宿主集成](apifox/HostIntegration.md) · [Apifox JSON](apifox/preference-panes.apifox.json)
package/dist/api.js CHANGED
@@ -1957,31 +1957,21 @@
1957
1957
  const match = /^\/api\/([a-zA-Z0-9_-]+)(?:\/(get|set|delete))?\/?$/.exec(url.pathname);
1958
1958
  if (!match) return;
1959
1959
  const [, module, action] = match;
1960
- const configURL = this.#configURL(request, url, module);
1960
+ const configuration = `${url.origin}/configs/${module}`;
1961
1961
  switch (true) {
1962
1962
  case !action && request.method === "HEAD":
1963
- return this.#probe(request, configURL);
1964
- case !action && request.method === "GET":
1965
- return this.#model(request, module, configURL);
1963
+ return this.#probe(request, configuration);
1966
1964
  case Boolean(action) && request.method === "POST":
1967
- return this.#action(request, module, action, configURL);
1965
+ return this.#action(request, module, action, configuration);
1968
1966
  default:
1969
- return this.#response(request, 405, { error: "Use GET or HEAD for module reads, POST for module actions" });
1967
+ return this.#response(request, 405, { error: "Use HEAD for module probes and POST for module actions" });
1970
1968
  }
1971
1969
  }
1972
1970
 
1973
- #configURL(request, url, module) {
1974
- const headers = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
1975
- const source = headers["x-preferencepanes-json"] ?? `/configs/${module}`;
1976
- if (/^https?:\/\//i.test(source)) return source;
1977
- if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(source)) throw Object.assign(new TypeError("BoxJS resources must use HTTP(S) URLs"), { status: 400 });
1978
- return `${url.origin}/${source.replace(/^\/+/, "")}`;
1979
- }
1980
-
1981
- async #probe(request, configURL) {
1971
+ async #probe(request, configuration) {
1982
1972
  let result;
1983
1973
  try {
1984
- result = await fetch({ url: configURL, method: "HEAD", timeout: 5000, headers: { Accept: "application/json" } });
1974
+ result = await fetch({ url: configuration, method: "HEAD", timeout: 5000, headers: { Accept: "application/json" } });
1985
1975
  } catch (error) {
1986
1976
  return this.#response(request, 502, { error: error.message });
1987
1977
  }
@@ -1989,19 +1979,9 @@
1989
1979
  return this.#response(request, result.statusCode ?? result.status, undefined, version ? { "X-PreferencePanes-Version": version } : {});
1990
1980
  }
1991
1981
 
1992
- async #model(request, module, configURL) {
1993
- const loaded = await this.#load(module, configURL);
1994
- const values = {};
1995
- for (const entry of loaded.entries) {
1996
- const value = Storage.getItem(entry.id, MISSING);
1997
- if (value !== MISSING) values[entry.id.slice(loaded.storageKey.length + 2)] = value;
1998
- }
1999
- return this.#response(request, 200, { module, boxjs: loaded.boxjs, values, configURL }, loaded.version ? { "X-PreferencePanes-Version": loaded.version } : {});
2000
- }
2001
-
2002
- async #action(request, module, action, configURL) {
1982
+ async #action(request, module, action, configuration) {
2003
1983
  const payload = this.#jsonBody(request);
2004
- const target = await this.#load(module, configURL);
1984
+ const target = await this.#load(module, configuration);
2005
1985
  switch (action) {
2006
1986
  case "get": {
2007
1987
  const value = Storage.getItem(payload?.scope ? this.#scopePath(target, payload.scope) : this.#storagePath(target, payload?.key), MISSING);
@@ -2019,10 +1999,10 @@
2019
1999
  }
2020
2000
  }
2021
2001
 
2022
- async #load(module, configURL) {
2002
+ async #load(module, configuration) {
2023
2003
  let result;
2024
2004
  try {
2025
- result = await fetch({ url: configURL, method: "GET", timeout: 5000, headers: { Accept: "application/json" } });
2005
+ result = await fetch({ url: configuration, method: "GET", timeout: 5000, headers: { Accept: "application/json" } });
2026
2006
  } catch (error) {
2027
2007
  throw Object.assign(new Error(`Configuration request failed: ${error.message}`), { status: 502 });
2028
2008
  }
@@ -2053,7 +2033,7 @@
2053
2033
  }
2054
2034
  }
2055
2035
  if (!entries.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
2056
- return { boxjs, entries, module, storageKey, version: this.#header(result.headers, "x-preferencepanes-version") };
2036
+ return { entries, module, storageKey, version: this.#header(result.headers, "x-preferencepanes-version") };
2057
2037
  } catch (error) {
2058
2038
  throw Object.assign(new Error(`Invalid BoxJS: ${error.message}`), { status: 422 });
2059
2039
  }
@@ -9,6 +9,6 @@
9
9
  </head>
10
10
  <body>
11
11
  <main id="preferences"></main>
12
- <script type="module" src="/settings/assets/index.mjs?v=1.1.0"></script>
12
+ <script type="module" src="/settings/assets/index.mjs?v=1.1.1"></script>
13
13
  </body>
14
14
  </html>
@@ -1,21 +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
1
  /**
20
2
  * 创建元素,所有展示文本通过 textContent 写入。
21
3
  * Create elements and assign display text through textContent only.
@@ -154,8 +136,8 @@ function normalizeBoxJs(config, module) {
154
136
  target.owners.add(app);
155
137
  }
156
138
  }
157
- if (module === undefined && modules.size !== 1) throw new TypeError("Import BoxJS JSON for exactly one module");
158
- const target = module === undefined ? modules.values().next().value : modules.get(module);
139
+ if (modules.size !== 1) throw new TypeError("Import BoxJS JSON for exactly one module");
140
+ const target = modules.values().next().value ;
159
141
  if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
160
142
  const metadata = normalizeMetadata(target.owners.size === 1 ? presentation([...target.owners][0]) : {});
161
143
  const fields = [];
@@ -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
  /**
@@ -566,7 +570,7 @@ class PreferencesClient {
566
570
  credentials: "omit",
567
571
  cache: "no-store",
568
572
  signal: controller.signal,
569
- headers: { "Content-Type": "application/json", "X-PreferencePanes-JSON": this.#configURL },
573
+ headers: { "Content-Type": "application/json" },
570
574
  body: JSON.stringify(payload),
571
575
  });
572
576
  if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
@@ -590,6 +594,10 @@ class PreferencesClient {
590
594
  if (this.#saving) throw new Error("A settings write is already in progress");
591
595
  this.#saving = true;
592
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
+ }
593
601
  await this.#send(action, payload);
594
602
  switch (operation) {
595
603
  case "write":
@@ -787,24 +795,23 @@ class PreferencesPanel {
787
795
  #release;
788
796
 
789
797
  /**
790
- * 挂载 API 返回的模块模型表单。
791
- * Mount the module form returned by the API.
798
+ * 挂载 BoxJS 定义对应的模块表单。
799
+ * Mount the module form described by a BoxJS definition.
792
800
  * @param {HTMLElement} root 包内挂载元素 / Internal mount element.
793
- * @param {import("../index.js").ModuleModel & {definition: import("../index.js").ModuleDefinition}} model 已规范化模块模型 / Normalized module model.
801
+ * @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
794
802
  */
795
- constructor(root, model) {
796
- this.#release = this.#mount(root, model);
803
+ constructor(root, definition) {
804
+ this.#release = this.#mount(root, definition);
797
805
  }
798
806
 
799
807
  /**
800
808
  * 建立面板 DOM、交互和会话,并返回其释放操作。
801
809
  * Build panel DOM, interactions, and session, then return its release operation.
802
810
  * @param {HTMLElement} root 包内挂载元素 / Internal mount element.
803
- * @param {import("../index.js").ModuleModel & {definition: import("../index.js").ModuleDefinition}} model 已规范化模块模型 / Normalized module model.
811
+ * @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
804
812
  * @returns {() => void} 释放操作 / Release operation.
805
813
  */
806
- #mount(root, model) {
807
- const { definition } = model;
814
+ #mount(root, definition) {
808
815
  const title = definition.metadata?.name ?? definition.module;
809
816
  const document = root.ownerDocument;
810
817
  const window = document.defaultView;
@@ -896,7 +903,7 @@ class PreferencesPanel {
896
903
  toast.hidden = true;
897
904
  }, 2400);
898
905
  };
899
- const client = new PreferencesClient({ model, definition, notify });
906
+ const client = new PreferencesClient({ definition, notify });
900
907
  /**
901
908
  * 两种菜单入口共用异步错误处理,包含宿主确认框错误。
902
909
  * Share async error handling between both menus, including host-dialog errors.
@@ -924,6 +931,7 @@ class PreferencesPanel {
924
931
  publishNavigation();
925
932
  viewport.replaceChildren(statusView("读取设置…"));
926
933
  try {
934
+ await client.open();
927
935
  if (version === generation) controls();
928
936
  } catch (error) {
929
937
  if (version !== generation) return;
@@ -1312,15 +1320,14 @@ function installDefaultStyles(document) {
1312
1320
  }
1313
1321
 
1314
1322
  /**
1315
- * 管理模块设置视图的模型规范化、样式、主题同步和面板生命周期。
1316
- * 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.
1317
1325
  */
1318
1326
  class PreferencesView {
1319
1327
  #existing;
1320
1328
  #root;
1321
1329
  #base;
1322
1330
  #ownsBase;
1323
- #custom;
1324
1331
  #previousTitle;
1325
1332
  #previousTheme;
1326
1333
  #systemTheme;
@@ -1330,22 +1337,12 @@ class PreferencesView {
1330
1337
  #panel;
1331
1338
 
1332
1339
  /**
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.
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.
1337
1343
  */
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 };
1344
+ constructor(boxjs) {
1345
+ const definition = normalizeBoxJs(boxjs);
1349
1346
  const metadata = definition.metadata ?? {};
1350
1347
  const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
1351
1348
  if (image) resourceURL(image);
@@ -1360,9 +1357,6 @@ class PreferencesView {
1360
1357
  const styles = installDefaultStyles(document);
1361
1358
  this.#base = styles.element;
1362
1359
  this.#ownsBase = styles.owned;
1363
- this.#custom = element("style", "");
1364
- this.#custom.textContent = css;
1365
- document.head.append(this.#custom);
1366
1360
  this.#previousTitle = document.title;
1367
1361
  this.#previousTheme = document.documentElement.dataset.theme;
1368
1362
  this.#systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
@@ -1377,7 +1371,7 @@ class PreferencesView {
1377
1371
  document.title = metadata.name ?? definition.module;
1378
1372
  try {
1379
1373
  this.#root.replaceChildren();
1380
- this.#panel = new PreferencesPanel(this.#root, rendered);
1374
+ this.#panel = new PreferencesPanel(this.#root, definition);
1381
1375
  } catch (error) {
1382
1376
  this.destroy();
1383
1377
  throw error;
@@ -1405,7 +1399,6 @@ class PreferencesView {
1405
1399
  this.#systemTheme.removeEventListener("change", this.#syncAppearance);
1406
1400
  this.#panel?.destroy();
1407
1401
  if (this.#ownsBase) this.#base.remove();
1408
- this.#custom.remove();
1409
1402
  if (this.#existing) this.#root.replaceChildren();
1410
1403
  else this.#root.remove();
1411
1404
  document.title = this.#previousTitle;
@@ -1416,11 +1409,20 @@ class PreferencesView {
1416
1409
  }
1417
1410
 
1418
1411
  /**
1419
- * 管理模块文档的页面输入、初始请求、重载和错误状态。
1420
- * Manage page inputs, initial requests, reloads, and error states for a module document.
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.
1416
+ */
1417
+ function mount(boxjs) {
1418
+ return new PreferencesView(boxjs);
1419
+ }
1420
+
1421
+ /**
1422
+ * 管理模块文档的配置请求、重载和错误状态。
1423
+ * Manage configuration requests, reloads, and error states for a module document.
1421
1424
  */
1422
1425
  class ModulePage {
1423
- #document;
1424
1426
  #window;
1425
1427
  #root;
1426
1428
  #view;
@@ -1431,7 +1433,6 @@ class ModulePage {
1431
1433
  * @param {Document} document 模块文档 / Module document.
1432
1434
  */
1433
1435
  constructor(document) {
1434
- this.#document = document;
1435
1436
  this.#window = document.defaultView;
1436
1437
  this.#root = document.querySelector("#preferences");
1437
1438
  installDefaultStyles(document);
@@ -1439,8 +1440,8 @@ class ModulePage {
1439
1440
  }
1440
1441
 
1441
1442
  /**
1442
- * URL 或代理传递的 Header 导入 JSON/CSS,支持独立文档与 srcdoc。
1443
- * Import JSON/CSS from the URL or proxy-carried headers in standalone and srcdoc documents.
1443
+ * 从规范模块路径读取 BoxJS JSON 并挂载通用前端。
1444
+ * Read BoxJS JSON from the conventional module path and mount the generic frontend.
1444
1445
  * @returns {Promise<void>} 启动完成 / Startup completion.
1445
1446
  */
1446
1447
  async start() {
@@ -1448,12 +1449,13 @@ class ModulePage {
1448
1449
  this.#view?.destroy();
1449
1450
  this.#view = undefined;
1450
1451
  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() : "");
1452
+ const embedded = this.#window.frameElement?.dataset.preferencePanesModule;
1453
+ const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(this.#window.location.pathname);
1454
+ const module = embedded ?? match?.[1];
1455
+ if (!module) throw new TypeError("Open a concrete module URL");
1456
+ const response = await fetch(`/configs/${encodeURIComponent(module)}`, { cache: "no-store", credentials: "omit", headers: { Accept: "application/json" } });
1457
+ if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
1458
+ this.#view = mount(await response.json());
1457
1459
  } catch (error) {
1458
1460
  this.#root.replaceChildren(statusView(`加载失败:${error.message}`, () => this.start()));
1459
1461
  }
@@ -1470,39 +1472,6 @@ class ModulePage {
1470
1472
  this.#view = undefined;
1471
1473
  }
1472
1474
 
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
1475
  /**
1507
1476
  * 从前进后退缓存恢复时重新加载模块。
1508
1477
  * Reload the module when restored from the back-forward cache.
@@ -1515,5 +1484,3 @@ class ModulePage {
1515
1484
  }
1516
1485
 
1517
1486
  new ModulePage(document).start();
1518
-
1519
- export { ModulePage };