@nsnanocat/preference-panes 0.9.15 → 1.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nsnanocat/preference-panes",
3
- "version": "0.9.15",
3
+ "version": "1.0.0",
4
4
  "description": "Shared settings API runtime for JavaScript proxy modules",
5
5
  "author": "VirgilClyne <Virgil@nanocat.me>",
6
6
  "homepage": "https://NSNanoCat.github.io/preference-panes",
package/src/api.mjs ADDED
@@ -0,0 +1,191 @@
1
+ import { URL } from "@nsnanocat/url";
2
+ import { fetch as transport } from "@nsnanocat/util";
3
+ import { $app } from "@nsnanocat/util/lib/app.mjs";
4
+ import { done } from "@nsnanocat/util/lib/done.mjs";
5
+ import { Storage } from "@nsnanocat/util/polyfill/Storage";
6
+ import { validatePathParts } from "./lib/settings-path.mjs";
7
+
8
+ const MISSING = Symbol("missing");
9
+
10
+ /**
11
+ * PreferencePanes 后端 API,只处理模块数据和持久化请求。
12
+ * PreferencePanes backend API handling only module data and persistence requests.
13
+ */
14
+ class API {
15
+ /**
16
+ * 处理当前代理请求并将结果交给宿主。
17
+ * Handle the current proxy request and deliver its result to the host.
18
+ * @returns {Promise<void>} 响应已交给代理宿主 / Response delivered to the proxy host.
19
+ */
20
+ async run() {
21
+ const request = globalThis.$request;
22
+ let result;
23
+ try {
24
+ result = await this.handle(request);
25
+ } catch (error) {
26
+ console.error(`PreferencePanes: ${error.message}`);
27
+ result = this.#response(request, error.status ?? 500, { error: error.message });
28
+ }
29
+ if (!result) done({});
30
+ else done($app === "Quantumult X" ? result : { response: result });
31
+ }
32
+
33
+ /**
34
+ * 处理 `/api/{module}` 及其动作,不接管页面或静态资源。
35
+ * Handle `/api/{module}` and its actions without intercepting pages or static assets.
36
+ * @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
37
+ * @returns {Promise<import("./index.js").SettingsResponse | undefined>} API 响应或未接管 / API response or pass-through.
38
+ */
39
+ async handle(request) {
40
+ const url = new URL(request.url);
41
+ const match = /^\/api\/([a-zA-Z0-9_-]+)(?:\/(get|set|delete))?\/?$/.exec(url.pathname);
42
+ if (!match) return;
43
+ const [, module, action] = match;
44
+ const configURL = this.#configURL(request, url, module);
45
+ switch (true) {
46
+ case !action && request.method === "HEAD":
47
+ return this.#probe(request, configURL);
48
+ case !action && request.method === "GET":
49
+ return this.#model(request, module, configURL);
50
+ case Boolean(action) && request.method === "POST":
51
+ return this.#action(request, module, action, configURL);
52
+ default:
53
+ return this.#response(request, 405, { error: "Use GET or HEAD for module reads, POST for module actions" });
54
+ }
55
+ }
56
+
57
+ #configURL(request, url, module) {
58
+ const headers = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
59
+ const source = headers["x-preferencepanes-json"] ?? `/configs/${module}`;
60
+ if (/^https?:\/\//i.test(source)) return source;
61
+ if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(source)) throw Object.assign(new TypeError("BoxJS resources must use HTTP(S) URLs"), { status: 400 });
62
+ return `${url.origin}/${source.replace(/^\/+/, "")}`;
63
+ }
64
+
65
+ async #probe(request, configURL) {
66
+ let result;
67
+ try {
68
+ result = await transport({ url: configURL, method: "HEAD", timeout: 5000, headers: { Accept: "application/json" } });
69
+ } catch (error) {
70
+ return this.#response(request, 502, { error: error.message });
71
+ }
72
+ const version = this.#header(result.headers, "x-preferencepanes-version");
73
+ return this.#response(request, result.statusCode ?? result.status, undefined, version ? { "X-PreferencePanes-Version": version } : {});
74
+ }
75
+
76
+ async #model(request, module, configURL) {
77
+ const loaded = await this.#load(module, configURL);
78
+ const values = {};
79
+ for (const entry of loaded.entries) {
80
+ const value = Storage.getItem(entry.id, MISSING);
81
+ if (value !== MISSING) values[entry.id.slice(loaded.storageKey.length + 2)] = value;
82
+ }
83
+ return this.#response(request, 200, { module, boxjs: loaded.boxjs, values, configURL }, loaded.version ? { "X-PreferencePanes-Version": loaded.version } : {});
84
+ }
85
+
86
+ async #action(request, module, action, configURL) {
87
+ const payload = this.#jsonBody(request);
88
+ const target = await this.#load(module, configURL);
89
+ switch (action) {
90
+ case "get": {
91
+ const value = Storage.getItem(payload?.scope ? this.#scopePath(target, payload.scope) : this.#storagePath(target, payload?.key), MISSING);
92
+ return value === MISSING ? this.#response(request, 404, { error: "Stored path does not exist" }) : this.#response(request, 200, value);
93
+ }
94
+ case "set":
95
+ if (!Object.hasOwn(payload ?? {}, "value")) throw Object.assign(new TypeError("A value is required"), { status: 400 });
96
+ if (!Storage.setItem(this.#storagePath(target, payload.key), payload.value)) throw new Error("Storage write failed");
97
+ return this.#response(request, 200, { saved: true });
98
+ case "delete": {
99
+ const path = payload?.scope ? this.#scopePath(target, payload.scope) : this.#storagePath(target, payload?.key);
100
+ if (!Storage.removeItem(path)) throw new Error("Storage write failed");
101
+ return this.#response(request, 200, { deleted: true });
102
+ }
103
+ }
104
+ }
105
+
106
+ async #load(module, configURL) {
107
+ let result;
108
+ try {
109
+ result = await transport({ url: configURL, method: "GET", timeout: 5000, headers: { Accept: "application/json" } });
110
+ } catch (error) {
111
+ throw Object.assign(new Error(`Configuration request failed: ${error.message}`), { status: 502 });
112
+ }
113
+ const status = result.statusCode ?? result.status;
114
+ if (status !== 200) throw Object.assign(new Error(`Configuration HTTP ${status}`), { status });
115
+ try {
116
+ const body = typeof result.body === "string" ? result.body : new TextDecoder().decode(result.body);
117
+ const boxjs = JSON.parse(body);
118
+ const apps = Array.isArray(boxjs) ? [{ settings: boxjs }] : (boxjs.apps ?? [boxjs]);
119
+ if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
120
+ const entries = [];
121
+ let storageKey;
122
+ for (const app of apps) {
123
+ if (!app || !Array.isArray(app.settings)) throw new TypeError("Expected BoxJS settings array");
124
+ for (const entry of app.settings) {
125
+ if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
126
+ if (!entry.id.startsWith("@")) {
127
+ if (Array.isArray(boxjs)) throw new TypeError("BoxJS settings require @root.path IDs");
128
+ continue;
129
+ }
130
+ const [root, ...parts] = entry.id.slice(1).split(".");
131
+ if (!root || root.startsWith("@") || parts.length < 2) throw new TypeError("A BoxJS setting must be below a literal storage root and module");
132
+ validatePathParts(parts);
133
+ if (parts[0] !== module) continue;
134
+ if (storageKey && storageKey !== root) throw new TypeError(`A module must use one storage root: ${module}`);
135
+ storageKey = root;
136
+ entries.push(entry);
137
+ }
138
+ }
139
+ if (!entries.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
140
+ return { boxjs, entries, module, storageKey, version: this.#header(result.headers, "x-preferencepanes-version") };
141
+ } catch (error) {
142
+ throw Object.assign(new Error(`Invalid BoxJS: ${error.message}`), { status: 422 });
143
+ }
144
+ }
145
+
146
+ #jsonBody(request) {
147
+ const headers = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
148
+ if (headers["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/json") throw Object.assign(new TypeError("Expected application/json"), { status: 415 });
149
+ if (typeof request.body !== "string" || request.body.length > 65536) throw Object.assign(new TypeError("Expected a JSON body up to 65536 characters"), { status: 400 });
150
+ try {
151
+ return JSON.parse(request.body);
152
+ } catch (error) {
153
+ throw Object.assign(error, { status: 400 });
154
+ }
155
+ }
156
+
157
+ #storagePath(target, key) {
158
+ if (typeof key !== "string") throw Object.assign(new TypeError("A BoxJS field path is required"), { status: 400 });
159
+ const path = `@${target.storageKey}.${key}`;
160
+ if (!target.entries.some(entry => entry.id === path)) throw Object.assign(new TypeError(`Unknown BoxJS field: ${key}`), { status: 400 });
161
+ return path;
162
+ }
163
+
164
+ #scopePath(target, scope) {
165
+ switch (scope) {
166
+ case "settings":
167
+ return `@${target.storageKey}.${target.module}.Settings`;
168
+ case "caches":
169
+ return `@${target.storageKey}.${target.module}.Caches`;
170
+ case "module":
171
+ return `@${target.storageKey}.${target.module}`;
172
+ default:
173
+ throw Object.assign(new TypeError("Scope must be settings, caches or module"), { status: 400 });
174
+ }
175
+ }
176
+
177
+ #response(request, status, body, extraHeaders = {}) {
178
+ return {
179
+ status,
180
+ headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...extraHeaders },
181
+ body: request.method === "HEAD" ? "" : JSON.stringify(body),
182
+ };
183
+ }
184
+
185
+ #header(headers, name) {
186
+ const entry = Object.entries(headers ?? {}).find(([key]) => key.toLowerCase() === name);
187
+ return entry?.[1] === undefined ? undefined : String(entry[1]).trim();
188
+ }
189
+ }
190
+
191
+ new API().run();
@@ -1,41 +1,28 @@
1
- /**
2
- * 模块 JSON Mock 的 HEAD 探测结果。
3
- * Result returned by a module JSON Mock HEAD probe.
4
- * @typedef {object} ModuleProbeResult
5
- * @property {"installed" | "missing"} state 安装状态 / Installation state.
6
- * @property {string | null} version 业务版本,缺失时为 null / Business version, or null when absent.
7
- * @property {number | null} httpStatus HTTP 状态码,网络错误时为 null / HTTP status, or null for network errors.
8
- */
9
-
10
1
  /**
11
2
  * 模块探测请求选项。
12
3
  * Options for a module probe request.
13
4
  * @typedef {object} ModuleProbeOptions
14
5
  * @property {typeof globalThis.fetch} [fetch] 可注入的 fetch / Injectable fetch.
15
6
  * @property {AbortSignal} [signal] 外部取消信号 / External cancellation signal.
7
+ * @property {string} [json] BoxJS JSON 来源,将随探测请求头传递 / BoxJS JSON source sent in the probe header.
16
8
  * @property {number} [timeout] 超时毫秒数,默认 3500 / Timeout in milliseconds, defaults to 3500.
17
9
  */
18
10
 
19
11
  /**
20
- * 通过模块 JSON Mock 的 HEAD 响应检测安装状态和业务版本。
21
- * Detect module installation and business version from a module JSON Mock HEAD response.
22
- * @param {string | URL} url 配置 Mock 地址 / Configuration Mock URL.
12
+ * 通过模块 API 的 HEAD 响应检测安装状态和业务版本。
13
+ * Probe installation and business version from the module API HEAD response.
14
+ * @param {string | URL} url 模块 API 地址 / Module API URL.
23
15
  * @param {ModuleProbeOptions} [options] 请求选项 / Request options.
24
- * @returns {Promise<ModuleProbeResult>} 探测结果 / Probe result.
25
- * @throws {Error} 外部取消请求 / External cancellation.
16
+ * @returns {Promise<Response>} 原始 HTTP 响应,可直接读取 status 和响应头 / Native HTTP response; read status and headers directly.
26
17
  */
27
- export async function probeModule(url, { fetch: request = globalThis.fetch, signal, timeout = 3500 } = {}) {
18
+ export async function probeModule(url, { fetch: request = globalThis.fetch, json, signal, timeout = 3500 } = {}) {
28
19
  const controller = new AbortController();
29
20
  const abort = () => controller.abort();
30
21
  if (signal?.aborted) abort();
31
22
  signal?.addEventListener("abort", abort, { once: true });
32
23
  const timer = setTimeout(() => controller.abort(), timeout);
33
24
  try {
34
- const response = await request(url, { method: "HEAD", cache: "no-store", credentials: "omit", signal: controller.signal });
35
- return response.status === 200 ? { state: "installed", version: response.headers.get("X-PreferencePanes-Version")?.trim() || null, httpStatus: 200 } : { state: "missing", version: null, httpStatus: response.status };
36
- } catch (error) {
37
- if (signal?.aborted) throw error;
38
- return { state: "missing", version: null, httpStatus: null };
25
+ return await request(url, { method: "HEAD", cache: "no-store", credentials: "omit", signal: controller.signal, headers: json ? { "X-PreferencePanes-JSON": json } : undefined });
39
26
  } finally {
40
27
  clearTimeout(timer);
41
28
  signal?.removeEventListener("abort", abort);
@@ -73,9 +60,9 @@ export class ModuleStatus extends EventTarget {
73
60
  /**
74
61
  * 每次进入重新探测,取消旧请求并忽略其迟到结果。
75
62
  * Reprobe on entry, cancelling old requests and ignoring late results.
76
- * @param {string | URL} url 配置 Mock 地址 / Configuration Mock URL.
63
+ * @param {string | URL} url 模块 API 地址 / Module API URL.
77
64
  * @param {ModuleProbeOptions} [options] 请求选项 / Request options.
78
- * @returns {Promise<ModuleProbeResult>} 探测结果 / Probe result.
65
+ * @returns {Promise<Response | undefined>} 原始响应;被取消时无返回值 / Native response; undefined when cancelled.
79
66
  */
80
67
  async check(url, options = {}) {
81
68
  this.#controller?.abort();
@@ -87,13 +74,15 @@ export class ModuleStatus extends EventTarget {
87
74
  externalSignal?.addEventListener("abort", abort, { once: true });
88
75
  this.#render("checking");
89
76
  try {
90
- const result = await probeModule(url, { ...options, signal: controller.signal });
91
- if (controller !== this.#controller) return result;
92
- this.#render(result.state, result.version);
93
- return result;
77
+ const response = await probeModule(url, { ...options, signal: controller.signal });
78
+ if (controller !== this.#controller) return response;
79
+ const version = response.status === 200 ? response.headers.get("X-PreferencePanes-Version")?.trim() || null : null;
80
+ this.#render(response.status === 200 ? "installed" : "missing", version);
81
+ return response;
94
82
  } catch (error) {
95
- if (controller !== this.#controller) return { state: "missing", version: null, httpStatus: null };
96
- throw error;
83
+ if (controller !== this.#controller) return;
84
+ if (externalSignal?.aborted) throw error;
85
+ this.#render("missing");
97
86
  } finally {
98
87
  externalSignal?.removeEventListener("abort", abort);
99
88
  }
@@ -160,28 +160,6 @@ export class ActionMenu {
160
160
  destroy(): void;
161
161
  }
162
162
 
163
- /**
164
- * 模块 JSON Mock 的 HEAD 探测结果。
165
- * Result returned by a module JSON Mock HEAD probe.
166
- */
167
- export interface ModuleProbeResult {
168
- /**
169
- * 安装状态。
170
- * Installation state.
171
- */
172
- state: "installed" | "missing";
173
- /**
174
- * 业务版本,缺失时为 null。
175
- * Business version, or null when absent.
176
- */
177
- version: string | null;
178
- /**
179
- * HTTP 状态码,网络错误时为 null。
180
- * HTTP status, or null for network errors.
181
- */
182
- httpStatus: number | null;
183
- }
184
-
185
163
  /**
186
164
  * 模块探测请求选项。
187
165
  * Options for a module probe request.
@@ -191,18 +169,20 @@ export interface ModuleProbeOptions {
191
169
  fetch?: typeof globalThis.fetch;
192
170
  /** 外部取消信号 / External cancellation signal. */
193
171
  signal?: AbortSignal;
172
+ /** 传给模块 API 的 BoxJS JSON 来源 / BoxJS JSON source sent to the module API. */
173
+ json?: string;
194
174
  /** 超时毫秒数,默认 3500 / Timeout in milliseconds, defaults to 3500. */
195
175
  timeout?: number;
196
176
  }
197
177
 
198
178
  /**
199
- * 通过模块 JSON Mock 的 HEAD 响应检测安装状态和业务版本。
200
- * Detect module installation and business version from a module JSON Mock HEAD response.
201
- * @param url 配置 Mock 地址 / Configuration Mock URL.
179
+ * 通过模块 API 的 HEAD 响应检测安装状态和业务版本。
180
+ * Probe installation and business version from a module API HEAD response.
181
+ * @param url 模块 API 地址 / Module API URL.
202
182
  * @param options 请求选项 / Request options.
203
- * @returns 探测结果 / Probe result.
183
+ * @returns 原始 HTTP 响应 / Native HTTP response.
204
184
  */
205
- export function probeModule(url: string | URL, options?: ModuleProbeOptions): Promise<ModuleProbeResult>;
185
+ export function probeModule(url: string | URL, options?: ModuleProbeOptions): Promise<Response>;
206
186
 
207
187
  /**
208
188
  * HEAD 探测的固定模块状态行。
@@ -221,13 +201,13 @@ export class ModuleStatus extends EventTarget {
221
201
  */
222
202
  readonly state: { status: "checking" | "installed" | "missing"; version: string | null };
223
203
  /**
224
- * 探测配置 Mock
225
- * Probe a configuration Mock.
226
- * @param url 配置地址 / Configuration URL.
204
+ * 探测模块 API
205
+ * Probe the module API.
206
+ * @param url 模块 API 地址 / Module API URL.
227
207
  * @param options 请求选项 / Request options.
228
- * @returns 探测结果 / Probe result.
208
+ * @returns 原始 HTTP 响应;网络错误时状态行显示“未安装” / Native HTTP response; network errors render “未安装”.
229
209
  */
230
- check(url: string | URL, options?: ModuleProbeOptions): Promise<ModuleProbeResult>;
210
+ check(url: string | URL, options?: ModuleProbeOptions): Promise<Response | undefined>;
231
211
  /**
232
212
  * 取消探测。
233
213
  * Cancel probes.
@@ -1,4 +1,3 @@
1
- import { BoxJS } from "../BoxJS.mjs";
2
1
  import { pageInputs } from "../lib/page-inputs.mjs";
3
2
  import { statusView } from "./components.mjs";
4
3
  import { mount } from "./index.mjs";
@@ -30,17 +29,16 @@ async function start() {
30
29
  default:
31
30
  inputs = pageInputs(new URL(location.href));
32
31
  }
33
- const resources = [inputs.json, inputs.css].map(source => {
32
+ const apiURL = new URL(`/api/${encodeURIComponent(inputs.module)}`, inputs.url).href;
33
+ const resources = [inputs.css].map(source => {
34
34
  if (!source) return null;
35
35
  const url = new URL(source, inputs.url);
36
36
  if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Resources must use HTTP(S) URLs");
37
37
  return url.href;
38
38
  });
39
- const [data, style] = await Promise.all(resources.map(url => (url ? fetch(url, { cache: "no-store", credentials: "omit" }) : null)));
40
- if (data.status !== 200 || (style && style.status !== 200)) throw new Error(`HTTP ${data.status !== 200 ? data.status : style.status}`);
41
- const catalog = new BoxJS(await data.json());
42
- if (catalog.module.module !== inputs.module) throw new Error("Imported JSON does not match the module URL");
43
- view = mount(catalog, style ? await style.text() : "");
39
+ const [style, modelResponse] = await Promise.all([...resources.map(url => (url ? fetch(url, { cache: "no-store", credentials: "omit" }) : null)), fetch(apiURL, { cache: "no-store", credentials: "omit", headers: { Accept: "application/json", "X-PreferencePanes-JSON": inputs.json } })]);
40
+ if ((style && style.status !== 200) || modelResponse.status !== 200) throw new Error(`HTTP ${modelResponse.status !== 200 ? modelResponse.status : style.status}`);
41
+ view = mount(await modelResponse.json(), style ? await style.text() : "");
44
42
  } catch (error) {
45
43
  document.querySelector("#preferences").replaceChildren(statusView(`加载失败:${error.message}`, start));
46
44
  }
@@ -1,22 +1,46 @@
1
- import { BoxJS } from "../BoxJS.mjs";
2
- import { validatePathParts } from "./settings-path.mjs";
1
+ import { validatePathParts } from "../lib/settings-path.mjs";
3
2
 
4
3
  /**
5
- * 将 BoxJS 数组、app 或订阅转换为模块字段,保留原文件为唯一字段来源。
6
- * Normalize a BoxJS array, app or subscription using the source JSON as the field authority.
7
- * @param {unknown | BoxJS} config BoxJS JSON 或已解析目录 / BoxJS document or parsed catalog.
8
- * @param {string} module API 第一段模块名 / First API path segment.
9
- * @returns {import("../index.js").ModuleDefinition} 存储根和字段 / Storage root and fields.
10
- * @throws {TypeError} 配置结构、字段路径、默认值或展示属性无效 / Invalid configuration, field path, default or presentation attribute.
4
+ * 将 BoxJS 数组、app 或订阅转换为浏览器字段定义。
5
+ * Normalize a BoxJS array, app or subscription into browser field definitions.
6
+ * @param {unknown} config 原始 BoxJS JSON / Raw BoxJS JSON.
7
+ * @param {string} [module] API 模块路径段;省略时要求输入只有一个模块 / API module path segment; omission requires exactly one module.
8
+ * @returns {import("../index.js").ModuleDefinition} 浏览器字段定义 / Browser field definition.
11
9
  */
12
10
  export function normalizeBoxJs(config, module) {
13
- validatePathParts([module]);
14
- const catalog = config instanceof BoxJS ? config : new BoxJS(config);
15
- const target = catalog.modules.get(module);
11
+ if (!config || typeof config !== "object") throw new TypeError("Expected BoxJS JSON");
12
+ const document = JSON.parse(JSON.stringify(config));
13
+ const apps = Array.isArray(document) ? [{ settings: document }] : (document.apps ?? [document]);
14
+ if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
15
+ const modules = new Map();
16
+ for (const app of apps) {
17
+ if (!app || !Array.isArray(app.settings)) throw new TypeError("Expected BoxJS settings array");
18
+ for (const entry of app.settings) {
19
+ if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
20
+ if (!entry.id.startsWith("@")) {
21
+ if (Array.isArray(document)) throw new TypeError("BoxJS settings require @root.path IDs");
22
+ continue;
23
+ }
24
+ const [storageKey, ...parts] = entry.id.slice(1).split(".");
25
+ if (!storageKey || storageKey.startsWith("@") || parts.length < 2) throw new TypeError("A BoxJS setting must be below a literal storage root and module");
26
+ validatePathParts(parts);
27
+ const name = parts[0];
28
+ let target = modules.get(name);
29
+ if (!target) {
30
+ target = { module: name, storageKey, entries: [], owners: new Set() };
31
+ modules.set(name, target);
32
+ }
33
+ if (target.storageKey !== storageKey) throw new TypeError(`A module must use one storage root: ${name}`);
34
+ target.entries.push(entry);
35
+ target.owners.add(app);
36
+ }
37
+ }
38
+ if (module === undefined && modules.size !== 1) throw new TypeError("Import BoxJS JSON for exactly one module");
39
+ const target = module === undefined ? modules.values().next().value : modules.get(module);
16
40
  if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
17
- const { entries, storageKey, metadata } = target;
41
+ const metadata = normalizeMetadata(target.owners.size === 1 ? presentation([...target.owners][0]) : {});
18
42
  const fields = [];
19
- for (const entry of entries) {
43
+ for (const entry of target.entries) {
20
44
  const parts = entry.id.slice(1).split(".").slice(1);
21
45
  const type = { boolean: "boolean", checkboxes: "array", selects: "select", text: "string", textarea: "string", number: "number" }[entry.type];
22
46
  if (!type) throw new TypeError(`Unsupported BoxJS control: ${entry.type}`);
@@ -46,18 +70,50 @@ export function normalizeBoxJs(config, module) {
46
70
  if (Object.hasOwn(field, "defaultValue") && !validValue(field, field.defaultValue)) throw new TypeError(`Invalid BoxJS val: ${entry.id}`);
47
71
  fields.push(field);
48
72
  }
49
- if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
73
+ if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${target.module}`);
50
74
  const common = fields[0].key.split(".").slice(0, -1);
51
75
  for (const field of fields) while (!field.key.startsWith(`${common.join(".")}.`)) common.pop();
52
76
  return {
53
- module,
54
- storageKey,
77
+ module: target.module,
78
+ storageKey: target.storageKey,
55
79
  fields,
56
80
  settingsPath: common,
57
81
  ...(Object.keys(metadata).length ? { metadata } : {}),
58
82
  };
59
83
  }
60
84
 
85
+ /**
86
+ * 保留字段所属 app 的原始展示信息。
87
+ * Retain raw presentation metadata from the app owning the fields.
88
+ * @param {object} source BoxJS app / BoxJS app.
89
+ * @returns {Record<string, unknown>} 原始展示信息 / Raw presentation metadata.
90
+ */
91
+ function presentation(source) {
92
+ const result = {};
93
+ for (const key of ["id", "name", "author", "repo", "script", "icon", "description", "desc", "icons", "descs"]) {
94
+ if (source[key] === undefined) continue;
95
+ result[key] = source[key];
96
+ }
97
+ return result;
98
+ }
99
+
100
+ /**
101
+ * 校验供浏览器展示的标准 BoxJS 元数据。
102
+ * Validate standard BoxJS metadata used by the browser renderer.
103
+ * @param {Record<string, unknown>} source 原始展示元数据 / Raw presentation metadata.
104
+ * @returns {import("../index.js").BoxJSMetadata} 规范化展示元数据 / Normalized presentation metadata.
105
+ */
106
+ function normalizeMetadata(source) {
107
+ const result = {};
108
+ for (const [key, value] of Object.entries(source)) {
109
+ const multiple = key === "icons" || key === "descs";
110
+ const values = multiple ? value : [value];
111
+ if (!Array.isArray(values) || values.some(item => typeof item !== "string")) throw new TypeError(`Invalid BoxJS app ${key}`);
112
+ result[key] = multiple ? [...values] : value;
113
+ }
114
+ return result;
115
+ }
116
+
61
117
  /**
62
118
  * 归一化 BoxJS 的字符串存储值,不改变普通文本内容。
63
119
  * Normalize BoxJS string persistence without changing free-text values.