@nsnanocat/preference-panes 0.6.0 → 0.7.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.
Files changed (41) hide show
  1. package/README.md +60 -116
  2. package/dist/module/app.mjs +1170 -0
  3. package/dist/module/index.html +14 -0
  4. package/dist/module/navigation.mjs +160 -0
  5. package/dist/preference-panes.config.js +839 -813
  6. package/dist/preference-panes.mjs +1052 -825
  7. package/dist/preference-panes.proxy.js +1751 -2123
  8. package/package.json +66 -67
  9. package/src/BoxJS.mjs +86 -0
  10. package/src/Store.mjs +127 -0
  11. package/src/browser/Navigation.d.mts +43 -0
  12. package/src/browser/Navigation.mjs +158 -0
  13. package/src/browser/app.mjs +30 -148
  14. package/src/browser/client.d.mts +150 -0
  15. package/src/browser/client.mjs +191 -212
  16. package/src/browser/components.mjs +59 -0
  17. package/src/browser/index.d.ts +19 -152
  18. package/src/browser/index.mjs +60 -5
  19. package/src/browser/module.html +14 -0
  20. package/src/browser/panel.css +245 -223
  21. package/src/browser/panel.mjs +414 -514
  22. package/src/build.mjs +33 -0
  23. package/src/index.d.ts +227 -133
  24. package/src/index.mjs +3 -6
  25. package/src/lib/boxjs.mjs +77 -99
  26. package/src/lib/page-inputs.mjs +17 -0
  27. package/src/lib/response.mjs +16 -0
  28. package/src/lib/settings-path.mjs +10 -23
  29. package/src/proxy/config.mjs +6 -11
  30. package/src/proxy/handler.mjs +48 -27
  31. package/src/proxy/response.mjs +16 -0
  32. package/dist/preference-panes.request.js +0 -2126
  33. package/dist/settings/app.mjs +0 -1044
  34. package/dist/settings/home.css +0 -134
  35. package/dist/settings/index.html +0 -15
  36. package/dist/settings/panel.css +0 -325
  37. package/src/PreferencesHandler.mjs +0 -50
  38. package/src/SettingsHandler.mjs +0 -144
  39. package/src/browser/home.css +0 -134
  40. package/src/browser/site.html +0 -15
  41. package/src/proxy/request.mjs +0 -5
@@ -1,5 +1,4 @@
1
1
  import { normalizeBoxJs, normalizeStoredValue, validValue } from "../lib/boxjs.mjs";
2
- import { validatePathParts } from "../lib/settings-path.mjs";
3
2
 
4
3
  /**
5
4
  * 单个模块的临时会话;离开页面后丢弃。
@@ -7,222 +6,202 @@ import { validatePathParts } from "../lib/settings-path.mjs";
7
6
  * @typedef {object} ModuleSession
8
7
  * @property {AbortController} controller 读取请求的取消控制器 / Abort controller for reads.
9
8
  * @property {import("../index.js").ModuleDefinition | null} definition 加载完成的配置,加载中为 null / Loaded configuration, or null while loading.
10
- * @property {import("./index.js").ModuleSnapshot["values"]} values 当前显示值 / Current display values.
9
+ * @property {import("./client.mjs").ModuleSnapshot["values"]} values 当前显示值 / Current display values.
11
10
  * @property {boolean} saving 是否正在写入 / Whether a mutation is in progress.
12
11
  */
13
12
 
14
13
  /**
15
14
  * 创建页面会话缓存;打开时重读,选项操作仅在 HTTP 200 后更新缓存。
16
15
  * Create a page-session cache; reload on open and mutate cache only after HTTP 200.
17
- * @param {import("./index.js").PreferencesClientOptions} options 请求与通知 / Requests and notifications.
18
- * @returns {import("./index.js").PreferencesClient} 通用客户端 / Generic client.
16
+ * @param {import("./client.mjs").PreferencesClientOptions} options 包内目录、请求与通知 / Internal catalog, requests and notifications.
17
+ * @returns {import("./client.mjs").PreferencesClient} 通用客户端 / Generic client.
19
18
  */
20
- export function createPreferencesClient({ fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 } = {}) {
21
- /** @type {Map<string, ModuleSession>} 模块会话表 / Module session map. */
22
- const sessions = new Map();
23
- /**
24
- * 发送同源请求,处理超时与取消;数据 GET 的 404 交给调用方处理。
25
- * Send a same-origin request with timeout and cancellation; callers handle missing-data GET responses.
26
- * @param {string} path 相对请求路径 / Relative request path.
27
- * @param {"HEAD" | "GET" | "POST" | "DELETE"} method HTTP 方法 / HTTP method.
28
- * @param {unknown} body POST 值,其它方法忽略 / POST value, ignored by other methods.
29
- * @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
30
- * @param {boolean} [resource=false] 是否为无标记头的配置资源 / Whether this is a config resource without the marker header.
31
- * @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
32
- * @throws {Error} 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
33
- */
34
- async function send(path, method, body, signal, resource = false) {
35
- const controller = new AbortController();
36
- const abort = () => controller.abort();
37
- if (signal?.aborted) abort();
38
- signal?.addEventListener("abort", abort, { once: true });
39
- const timer = setTimeout(abort, timeout);
40
- try {
41
- const response = await request(path, {
42
- method,
43
- credentials: "omit",
44
- cache: "no-store",
45
- signal: controller.signal,
46
- headers: resource ? {} : { "X-Settings-Client": "1", ...(method === "POST" ? { "Content-Type": "application/json" } : {}) },
47
- ...(method === "POST" ? { body: JSON.stringify(body) } : {}),
48
- });
49
- if (response.status !== 200 && !(!resource && method === "GET" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
50
- return response;
51
- } finally {
52
- clearTimeout(timer);
53
- signal?.removeEventListener("abort", abort);
54
- }
55
- }
56
- /**
57
- * 由合法模块名生成配置 Mock 路径。
58
- * Build the config Mock path from a valid module name.
59
- * @param {string} module 模块标识 / Module identifier.
60
- * @returns {string} 配置路径 / Config path.
61
- */
62
- const configPath = module => {
63
- validatePathParts([module]);
64
- return `/configs/${encodeURIComponent(module)}`;
65
- };
66
- /**
67
- * 获取独立快照,避免调用方修改内部缓存。
68
- * Return an independent snapshot so callers cannot mutate the cache.
69
- * @param {string} module 已打开模块 / Open module.
70
- * @returns {import("./index.js").ModuleSnapshot} 会话快照 / Session snapshot.
71
- * @throws {Error} 模块未完成加载 / Module has not finished loading.
72
- */
73
- const snapshot = module => {
74
- const state = sessions.get(module);
75
- if (!state?.definition) throw new Error("Open the module first");
76
- return structuredClone({ definition: state.definition, values: state.values });
77
- };
78
- /**
79
- * 串行修改单键,仅成功后更新仍存活的会话。
80
- * Serialize single-key mutations and update a still-active session only after success.
81
- * @param {string} module 已打开模块 / Open module.
82
- * @param {string} key 完整点分字段路径 / Complete dotted field path.
83
- * @param {"POST" | "DELETE"} method 写入或删除 / Write or delete.
84
- * @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
85
- * @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
86
- * @returns {Promise<void>} 操作完成 / Operation completion.
87
- * @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
88
- */
89
- async function change(module, key, method, value, operation = method === "POST" ? "write" : "delete") {
90
- const state = sessions.get(module);
91
- if (!state?.definition) throw new Error("Open the module first");
92
- if (state.saving) throw new Error("A settings write is already in progress");
93
- const field = state.definition.fields.find(field => field.key === key);
94
- state.saving = true;
95
- try {
96
- if ((operation === "write" || operation === "delete") && (!field || (method === "POST" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
97
- await send(`/api/${key.split(".").map(encodeURIComponent).join("/")}`, method, value);
98
- if (sessions.get(module) === state) {
99
- switch (operation) {
100
- case "write":
101
- state.values[key] = structuredClone(value);
102
- break;
103
- case "delete":
104
- case "clearCaches":
105
- case "reset":
106
- for (const candidate of state.definition.fields) {
107
- if (candidate.key !== key && !candidate.key.startsWith(`${key}.`)) continue;
108
- delete state.values[candidate.key];
109
- if (Object.hasOwn(candidate, "defaultValue")) state.values[candidate.key] = structuredClone(candidate.defaultValue);
110
- }
111
- break;
112
- }
113
- }
114
- notify({ kind: "success", operation, module, key });
115
- } catch (error) {
116
- notify({ kind: "error", operation, module, key, message: error.message });
117
- throw error;
118
- } finally {
119
- state.saving = false;
120
- }
121
- }
122
- return {
123
- /**
124
- * 探测配置 Mock,不读写存储。
125
- * Probe the config Mock without accessing storage.
126
- * @param {string} module 模块标识 / Module identifier.
127
- * @returns {Promise<boolean>} 是否返回 HTTP 200 / Whether HTTP 200 was returned.
128
- */
129
- async probe(module) {
130
- try {
131
- await send(configPath(module), "HEAD", undefined, undefined, true);
132
- return true;
133
- } catch {
134
- return false;
135
- }
136
- },
137
- /**
138
- * 替换旧会话,读取一次配置与一次设置子树。
139
- * Replace the previous session and read config and settings subtree once each.
140
- * @param {string} module 模块标识 / Module identifier.
141
- * @returns {Promise<import("./index.js").ModuleSnapshot>} 新快照 / New snapshot.
142
- * @throws {Error} 读取失败、会话被替换或写入尚未完成 / Read failure, replaced session or unfinished write.
143
- */
144
- async open(module) {
145
- const previous = sessions.get(module);
146
- if (previous?.saving) throw new Error("Cannot refresh while saving");
147
- previous?.controller.abort();
148
- const state = { controller: new AbortController(), definition: null, values: {}, saving: false };
149
- sessions.set(module, state);
150
- try {
151
- const definition = normalizeBoxJs(await (await send(configPath(module), "GET", undefined, state.controller.signal, true)).json(), module);
152
- if (definition.settingsPath.length < 2) throw new TypeError("BoxJS fields must share a settings subtree below the module root");
153
- const response = await send(`/api/${definition.settingsPath.map(encodeURIComponent).join("/")}/`, "GET", undefined, state.controller.signal);
154
- let subtree = response.status === 404 ? {} : await response.json();
155
- if (typeof subtree === "string") subtree = JSON.parse(subtree);
156
- if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
157
- if (sessions.get(module) !== state) throw new Error("Module session was replaced");
158
- state.definition = definition;
159
- for (const field of definition.fields) {
160
- const stored = field.key
161
- .split(".")
162
- .slice(definition.settingsPath.length)
163
- .reduce((parent, part) => Object(parent)[part], subtree);
164
- const value = stored === undefined ? field.defaultValue : stored;
165
- if (value !== undefined) state.values[field.key] = normalizeStoredValue(field, value);
166
- }
167
- return snapshot(module);
168
- } catch (error) {
169
- if (sessions.get(module) === state) sessions.delete(module);
170
- throw error;
171
- }
172
- },
173
- snapshot,
174
- /**
175
- * 按需读取模块 Caches,不自动读取其它设置。
176
- * Read module Caches on demand without refreshing other settings.
177
- * @param {string} module 已打开的模块 / Open module.
178
- * @returns {Promise<unknown>} 缓存值,缺失为 undefined / Cache value, or undefined when absent.
179
- */
180
- async readCaches(module) {
181
- const state = sessions.get(module);
182
- if (!state?.definition) throw new Error("Open the module first");
183
- const response = await send(`/api/${encodeURIComponent(module)}/Caches`, "GET", undefined, state.controller.signal);
184
- return response.status === 404 ? undefined : response.json();
185
- },
186
- /**
187
- * 删除整个 Caches 节点,成功后不追加 GET。
188
- * Delete the entire Caches node without a follow-up GET.
189
- * @param {string} module 已打开模块 / Open module.
190
- * @returns {Promise<void>} 清理完成 / Cleanup completion.
191
- */
192
- clearCaches: module => change(module, `${module}.Caches`, "DELETE", undefined, "clearCaches"),
193
- /**
194
- * 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
195
- * Delete module persistence and reset the page cache using current BoxJS defaults.
196
- * @param {string} module 已打开模块 / Open module.
197
- * @returns {Promise<void>} 重置完成 / Reset completion.
198
- */
199
- reset: module => change(module, module, "DELETE", undefined, "reset"),
200
- /**
201
- * 取消读取并清除会话,不撤销已发送的写入。
202
- * Abort reads and clear the session without undoing dispatched writes.
203
- * @param {string} module 模块标识 / Module identifier.
204
- * @returns {void} 无返回值 / No return value.
205
- */
206
- leave(module) {
207
- sessions.get(module)?.controller.abort();
208
- sessions.delete(module);
209
- },
210
- /**
211
- * 写入单键并更新当前会话。
212
- * Write one key and update the current session.
213
- * @param {string} module 已打开模块 / Open module.
214
- * @param {string} key 点分字段路径 / Dotted field path.
215
- * @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
216
- * @returns {Promise<void>} 写入完成 / Write completion.
217
- */
218
- set: (module, key, value) => change(module, key, "POST", value),
219
- /**
220
- * 删除单键覆盖值并显示默认值。
221
- * Delete one override and display its default value.
222
- * @param {string} module 已打开模块 / Open module.
223
- * @param {string} key 点分字段路径 / Dotted field path.
224
- * @returns {Promise<void>} 删除完成 / Delete completion.
225
- */
226
- remove: (module, key) => change(module, key, "DELETE"),
227
- };
19
+ export function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
20
+ /**
21
+ * 模块会话表
22
+ * Module session map.
23
+ * @type {Map<string, ModuleSession>}
24
+ */
25
+ const sessions = new Map();
26
+ /**
27
+ * 发送同源请求,处理超时与取消;数据 GET 404 交给调用方处理。
28
+ * Send a same-origin request with timeout and cancellation; callers handle missing-data GET responses.
29
+ * @param {string} path 相对请求路径 / Relative request path.
30
+ * @param {"HEAD" | "GET" | "POST" | "DELETE"} method HTTP 方法 / HTTP method.
31
+ * @param {unknown} body POST 值,其它方法忽略 / POST value, ignored by other methods.
32
+ * @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
33
+ * @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
34
+ * @throws {Error} 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
35
+ */
36
+ async function send(path, method, body, signal) {
37
+ const controller = new AbortController();
38
+ const abort = () => controller.abort();
39
+ if (signal?.aborted) abort();
40
+ signal?.addEventListener("abort", abort, { once: true });
41
+ const timer = setTimeout(abort, timeout);
42
+ try {
43
+ const response = await request(path, {
44
+ method,
45
+ credentials: "omit",
46
+ cache: "no-store",
47
+ signal: controller.signal,
48
+ headers: { "X-Settings-Client": "1", ...(method === "POST" ? { "Content-Type": "application/json" } : {}) },
49
+ ...(method === "POST" ? { body: JSON.stringify(body) } : {}),
50
+ });
51
+ if (response.status !== 200 && !(method === "GET" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
52
+ return response;
53
+ } finally {
54
+ clearTimeout(timer);
55
+ signal?.removeEventListener("abort", abort);
56
+ }
57
+ }
58
+ /**
59
+ * 获取独立快照,避免调用方修改内部缓存。
60
+ * Return an independent snapshot so callers cannot mutate the cache.
61
+ * @param {string} module 已打开模块 / Open module.
62
+ * @returns {import("./client.mjs").ModuleSnapshot} 会话快照 / Session snapshot.
63
+ * @throws {Error} 模块未完成加载 / Module has not finished loading.
64
+ */
65
+ const snapshot = module => {
66
+ const state = sessions.get(module);
67
+ if (!state?.definition) throw new Error("Open the module first");
68
+ return structuredClone({ definition: state.definition, values: state.values });
69
+ };
70
+ /**
71
+ * 串行修改单键,仅成功后更新仍存活的会话。
72
+ * Serialize single-key mutations and update a still-active session only after success.
73
+ * @param {string} module 已打开模块 / Open module.
74
+ * @param {string} key 完整点分字段路径 / Complete dotted field path.
75
+ * @param {"POST" | "DELETE"} method 写入或删除 / Write or delete.
76
+ * @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
77
+ * @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
78
+ * @returns {Promise<void>} 操作完成 / Operation completion.
79
+ * @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
80
+ */
81
+ async function change(module, key, method, value, operation = method === "POST" ? "write" : "delete") {
82
+ const state = sessions.get(module);
83
+ if (!state?.definition) throw new Error("Open the module first");
84
+ if (state.saving) throw new Error("A settings write is already in progress");
85
+ const field = state.definition.fields.find(field => field.key === key);
86
+ state.saving = true;
87
+ try {
88
+ if ((operation === "write" || operation === "delete") && (!field || (method === "POST" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
89
+ await send(`/api/${key.split(".").map(encodeURIComponent).join("/")}`, method, value);
90
+ if (sessions.get(module) === state) {
91
+ switch (operation) {
92
+ case "write":
93
+ state.values[key] = structuredClone(value);
94
+ break;
95
+ case "delete":
96
+ case "clearCaches":
97
+ case "reset":
98
+ for (const candidate of state.definition.fields) {
99
+ if (candidate.key !== key && !candidate.key.startsWith(`${key}.`)) continue;
100
+ delete state.values[candidate.key];
101
+ if (Object.hasOwn(candidate, "defaultValue")) state.values[candidate.key] = structuredClone(candidate.defaultValue);
102
+ }
103
+ break;
104
+ }
105
+ }
106
+ notify({ kind: "success", operation, module, key });
107
+ } catch (error) {
108
+ notify({ kind: "error", operation, module, key, message: error.message });
109
+ throw error;
110
+ } finally {
111
+ state.saving = false;
112
+ }
113
+ }
114
+ return {
115
+ /**
116
+ * 从已导入的 JSON 创建新会话,只读取一次设置值。
117
+ * Create a session from imported JSON and read stored settings once.
118
+ * @param {string} module 模块标识 / Module identifier.
119
+ * @returns {Promise<import("./client.mjs").ModuleSnapshot>} 新快照 / New snapshot.
120
+ * @throws {Error} 读取失败、会话被替换或写入尚未完成 / Read failure, replaced session or unfinished write.
121
+ */
122
+ async open(module) {
123
+ const binding = catalog.modules.get(module);
124
+ if (!binding) throw new TypeError(`No BoxJS settings for module: ${module}`);
125
+ const previous = sessions.get(module);
126
+ if (previous?.saving) throw new Error("Cannot refresh while saving");
127
+ previous?.controller.abort();
128
+ const state = { controller: new AbortController(), definition: null, values: {}, saving: false };
129
+ sessions.set(module, state);
130
+ try {
131
+ const definition = normalizeBoxJs(catalog.select(module), module);
132
+ const response = await send(`/api/${definition.settingsPath.map(encodeURIComponent).join("/")}/`, "GET", undefined, state.controller.signal);
133
+ let subtree = response.status === 404 ? {} : await response.json();
134
+ if (typeof subtree === "string") subtree = JSON.parse(subtree);
135
+ if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
136
+ if (sessions.get(module) !== state) throw new Error("Module session was replaced");
137
+ state.definition = definition;
138
+ for (const field of definition.fields) {
139
+ const stored = field.key
140
+ .split(".")
141
+ .slice(definition.settingsPath.length)
142
+ .reduce((parent, part) => Object(parent)[part], subtree);
143
+ const value = stored === undefined ? field.defaultValue : stored;
144
+ if (value !== undefined) state.values[field.key] = normalizeStoredValue(field, value);
145
+ }
146
+ return snapshot(module);
147
+ } catch (error) {
148
+ if (sessions.get(module) === state) sessions.delete(module);
149
+ throw error;
150
+ }
151
+ },
152
+ snapshot,
153
+ /**
154
+ * 按需读取模块 Caches,不自动读取其它设置。
155
+ * Read module Caches on demand without refreshing other settings.
156
+ * @param {string} module 已打开的模块 / Open module.
157
+ * @returns {Promise<unknown>} 缓存值,缺失为 undefined / Cache value, or undefined when absent.
158
+ */
159
+ async readCaches(module) {
160
+ const state = sessions.get(module);
161
+ if (!state?.definition) throw new Error("Open the module first");
162
+ const response = await send(`/api/${encodeURIComponent(module)}/Caches`, "GET", undefined, state.controller.signal);
163
+ return response.status === 404 ? undefined : response.json();
164
+ },
165
+ /**
166
+ * 删除整个 Caches 节点,成功后不追加 GET。
167
+ * Delete the entire Caches node without a follow-up GET.
168
+ * @param {string} module 已打开模块 / Open module.
169
+ * @returns {Promise<void>} 清理完成 / Cleanup completion.
170
+ */
171
+ clearCaches: module => change(module, `${module}.Caches`, "DELETE", undefined, "clearCaches"),
172
+ /**
173
+ * 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
174
+ * Delete module persistence and reset the page cache using current BoxJS defaults.
175
+ * @param {string} module 已打开模块 / Open module.
176
+ * @returns {Promise<void>} 重置完成 / Reset completion.
177
+ */
178
+ reset: module => change(module, module, "DELETE", undefined, "reset"),
179
+ /**
180
+ * 取消读取并清除会话,不撤销已发送的写入。
181
+ * Abort reads and clear the session without undoing dispatched writes.
182
+ * @param {string} module 模块标识 / Module identifier.
183
+ * @returns {void} 无返回值 / No return value.
184
+ */
185
+ leave(module) {
186
+ sessions.get(module)?.controller.abort();
187
+ sessions.delete(module);
188
+ },
189
+ /**
190
+ * 写入单键并更新当前会话。
191
+ * Write one key and update the current session.
192
+ * @param {string} module 已打开模块 / Open module.
193
+ * @param {string} key 点分字段路径 / Dotted field path.
194
+ * @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
195
+ * @returns {Promise<void>} 写入完成 / Write completion.
196
+ */
197
+ set: (module, key, value) => change(module, key, "POST", value),
198
+ /**
199
+ * 删除单键覆盖值并显示默认值。
200
+ * Delete one override and display its default value.
201
+ * @param {string} module 已打开模块 / Open module.
202
+ * @param {string} key 点分字段路径 / Dotted field path.
203
+ * @returns {Promise<void>} 删除完成 / Delete completion.
204
+ */
205
+ remove: (module, key) => change(module, key, "DELETE"),
206
+ };
228
207
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * 创建元素,所有展示文本通过 textContent 写入。
3
+ * Create elements and assign display text through textContent only.
4
+ * @template {keyof HTMLElementTagNameMap} T
5
+ * @param {T} tag 元素标签 / Element tag.
6
+ * @param {string} className 样式类名 / CSS class.
7
+ * @param {string} [text] 纯文本 / Plain text.
8
+ * @returns {HTMLElementTagNameMap[T]} 创建的元素 / Created element.
9
+ */
10
+ export function element(tag, className, text) {
11
+ const node = document.createElement(tag);
12
+ node.className = className;
13
+ if (text !== undefined) node.textContent = text;
14
+ return node;
15
+ }
16
+
17
+ /**
18
+ * 元数据地址只允许 HTTP(S) 和相对地址。
19
+ * Allow only HTTP(S) and relative metadata addresses.
20
+ * @param {string} value 元数据地址 / Metadata address.
21
+ * @returns {string} 完整地址 / Absolute address.
22
+ */
23
+ export function resourceURL(value) {
24
+ const url = new URL(value, document.baseURI);
25
+ if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Metadata URLs must use HTTP(S)");
26
+ return url.href;
27
+ }
28
+
29
+ /**
30
+ * 展示标准 BoxJS 图标;icons 保持透明/彩色语义,不解释为亮暗版本。
31
+ * Display standard BoxJS icons, preserving transparent/color rather than light/dark semantics.
32
+ * @param {import("../index.js").BoxJSMetadata} metadata 展示信息 / Presentation metadata.
33
+ * @param {string} className 样式 / CSS class.
34
+ * @returns {HTMLImageElement | null} 图标或无图标 / Icon or no icon.
35
+ */
36
+ export function icon(metadata, className) {
37
+ const source = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
38
+ if (!source) return null;
39
+ const image = element("img", className);
40
+ image.src = resourceURL(source);
41
+ image.alt = "";
42
+ return image;
43
+ }
44
+
45
+ /**
46
+ * 共享加载失败视图,不创建配置表单或数据读取。
47
+ * Share a load-error view without creating controls or reading settings.
48
+ * @param {Error} error 失败原因 / Failure reason.
49
+ * @param {() => unknown} retry 重试动作 / Retry action.
50
+ * @returns {HTMLElement} 错误视图 / Error view.
51
+ */
52
+ export function errorView(error, retry) {
53
+ const view = element("section", "pp-error");
54
+ const button = element("button", "", "重新读取");
55
+ button.type = "button";
56
+ button.onclick = retry;
57
+ view.append(element("p", "", `加载失败:${error.message}`), button);
58
+ return view;
59
+ }