@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 +64 -46
- package/dist/api.js +11 -31
- package/dist/module/index.html +1 -1
- package/dist/module/index.mjs +76 -109
- package/dist/module/navigation.mjs +15 -32
- package/dist/preference-panes.mjs +62 -52
- package/dist/web.js +2 -23
- package/package.json +2 -2
- package/src/api.mjs +11 -31
- package/src/browser/ModuleFrame.mjs +13 -13
- package/src/browser/ModuleStatus.mjs +2 -3
- package/src/browser/Navigation.d.mts +2 -4
- package/src/browser/client.d.mts +5 -5
- package/src/browser/client.mjs +38 -10
- package/src/browser/index.d.ts +5 -21
- package/src/browser/index.mjs +13 -48
- package/src/browser/mount.mjs +16 -32
- package/src/browser/panel.mjs +9 -9
- package/src/index.d.ts +0 -22
- package/src/index.mjs +3 -3
- package/src/web.mjs +1 -5
- package/src/build.mjs +0 -20
- package/src/lib/page-inputs.mjs +0 -17
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nsnanocat/preference-panes",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.1.1",
|
|
4
|
+
"description": "Generic BoxJS settings frontend and persistence API for JavaScript proxy modules",
|
|
5
5
|
"author": "VirgilClyne <Virgil@nanocat.me>",
|
|
6
6
|
"homepage": "https://NSNanoCat.github.io/preference-panes",
|
|
7
7
|
"keywords": [
|
package/src/api.mjs
CHANGED
|
@@ -41,31 +41,21 @@ class API {
|
|
|
41
41
|
const match = /^\/api\/([a-zA-Z0-9_-]+)(?:\/(get|set|delete))?\/?$/.exec(url.pathname);
|
|
42
42
|
if (!match) return;
|
|
43
43
|
const [, module, action] = match;
|
|
44
|
-
const
|
|
44
|
+
const configuration = `${url.origin}/configs/${module}`;
|
|
45
45
|
switch (true) {
|
|
46
46
|
case !action && request.method === "HEAD":
|
|
47
|
-
return this.#probe(request,
|
|
48
|
-
case !action && request.method === "GET":
|
|
49
|
-
return this.#model(request, module, configURL);
|
|
47
|
+
return this.#probe(request, configuration);
|
|
50
48
|
case Boolean(action) && request.method === "POST":
|
|
51
|
-
return this.#action(request, module, action,
|
|
49
|
+
return this.#action(request, module, action, configuration);
|
|
52
50
|
default:
|
|
53
|
-
return this.#response(request, 405, { error: "Use
|
|
51
|
+
return this.#response(request, 405, { error: "Use HEAD for module probes and POST for module actions" });
|
|
54
52
|
}
|
|
55
53
|
}
|
|
56
54
|
|
|
57
|
-
#
|
|
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) {
|
|
55
|
+
async #probe(request, configuration) {
|
|
66
56
|
let result;
|
|
67
57
|
try {
|
|
68
|
-
result = await transport({ url:
|
|
58
|
+
result = await transport({ url: configuration, method: "HEAD", timeout: 5000, headers: { Accept: "application/json" } });
|
|
69
59
|
} catch (error) {
|
|
70
60
|
return this.#response(request, 502, { error: error.message });
|
|
71
61
|
}
|
|
@@ -73,19 +63,9 @@ class API {
|
|
|
73
63
|
return this.#response(request, result.statusCode ?? result.status, undefined, version ? { "X-PreferencePanes-Version": version } : {});
|
|
74
64
|
}
|
|
75
65
|
|
|
76
|
-
async #
|
|
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) {
|
|
66
|
+
async #action(request, module, action, configuration) {
|
|
87
67
|
const payload = this.#jsonBody(request);
|
|
88
|
-
const target = await this.#load(module,
|
|
68
|
+
const target = await this.#load(module, configuration);
|
|
89
69
|
switch (action) {
|
|
90
70
|
case "get": {
|
|
91
71
|
const value = Storage.getItem(payload?.scope ? this.#scopePath(target, payload.scope) : this.#storagePath(target, payload?.key), MISSING);
|
|
@@ -103,10 +83,10 @@ class API {
|
|
|
103
83
|
}
|
|
104
84
|
}
|
|
105
85
|
|
|
106
|
-
async #load(module,
|
|
86
|
+
async #load(module, configuration) {
|
|
107
87
|
let result;
|
|
108
88
|
try {
|
|
109
|
-
result = await transport({ url:
|
|
89
|
+
result = await transport({ url: configuration, method: "GET", timeout: 5000, headers: { Accept: "application/json" } });
|
|
110
90
|
} catch (error) {
|
|
111
91
|
throw Object.assign(new Error(`Configuration request failed: ${error.message}`), { status: 502 });
|
|
112
92
|
}
|
|
@@ -137,7 +117,7 @@ class API {
|
|
|
137
117
|
}
|
|
138
118
|
}
|
|
139
119
|
if (!entries.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
140
|
-
return {
|
|
120
|
+
return { entries, module, storageKey, version: this.#header(result.headers, "x-preferencepanes-version") };
|
|
141
121
|
} catch (error) {
|
|
142
122
|
throw Object.assign(new Error(`Invalid BoxJS: ${error.message}`), { status: 422 });
|
|
143
123
|
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { pageInputs } from "../lib/page-inputs.mjs";
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
|
-
* 模块文档容器:原始 HTML
|
|
5
|
-
* Module document container: preserve HTML verbatim and
|
|
2
|
+
* 模块文档容器:原始 HTML 不改写,只向 iframe 标记模块身份。
|
|
3
|
+
* Module document container: preserve HTML verbatim and mark only the module identity on the iframe.
|
|
6
4
|
*/
|
|
7
5
|
export class ModuleFrame extends EventTarget {
|
|
8
6
|
#url;
|
|
@@ -24,23 +22,25 @@ export class ModuleFrame extends EventTarget {
|
|
|
24
22
|
};
|
|
25
23
|
|
|
26
24
|
/**
|
|
27
|
-
* 建立 iframe
|
|
28
|
-
* Create the iframe
|
|
25
|
+
* 建立 iframe;调用方挂载 element 后调用 load。
|
|
26
|
+
* Create the iframe; callers mount element and then call load.
|
|
29
27
|
* @param {string | URL} url 模块请求地址 / Module request URL.
|
|
30
|
-
* @param {
|
|
28
|
+
* @param {{signal?: AbortSignal}} [options] 外部取消信号 / External cancellation signal.
|
|
31
29
|
*/
|
|
32
30
|
constructor(url, options = {}) {
|
|
33
31
|
super();
|
|
34
32
|
this.#url = new URL(url, document.baseURI);
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(this.#url.pathname);
|
|
34
|
+
if (!match) throw new TypeError("Open a concrete module URL");
|
|
35
|
+
this.#options = options;
|
|
37
36
|
this.element = document.createElement("iframe");
|
|
38
|
-
this.element.title = `${
|
|
39
|
-
this.element.dataset.preferencePanes =
|
|
37
|
+
this.element.title = `${match[1]} 设置`;
|
|
38
|
+
this.element.dataset.preferencePanes = "true";
|
|
39
|
+
this.element.dataset.preferencePanesModule = match[1];
|
|
40
40
|
this.element.addEventListener("preferencepanes:change", this.#change);
|
|
41
41
|
this.element.addEventListener("preferencepanes:confirm", this.#confirmation);
|
|
42
42
|
this.element.addEventListener("preferencepanes:notice", this.#notice);
|
|
43
|
-
this.#state = { title:
|
|
43
|
+
this.#state = { title: match[1], module: match[1], busy: false, canGoBack: true, actions: [] };
|
|
44
44
|
options.signal?.addEventListener("abort", this.#abort, { once: true });
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -61,7 +61,7 @@ export class ModuleFrame extends EventTarget {
|
|
|
61
61
|
if (this.#options.signal?.aborted) this.destroy();
|
|
62
62
|
const timer = setTimeout(() => this.#controller.abort(), 10000);
|
|
63
63
|
try {
|
|
64
|
-
const response = await fetch(this.#url, { cache: "no-store", credentials: "omit",
|
|
64
|
+
const response = await fetch(this.#url, { cache: "no-store", credentials: "omit", signal: this.#controller.signal });
|
|
65
65
|
if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
|
|
66
66
|
const html = await response.text();
|
|
67
67
|
this.#controller.signal.throwIfAborted();
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* @typedef {object} ModuleProbeOptions
|
|
5
5
|
* @property {typeof globalThis.fetch} [fetch] 可注入的 fetch / Injectable fetch.
|
|
6
6
|
* @property {AbortSignal} [signal] 外部取消信号 / External cancellation signal.
|
|
7
|
-
* @property {string} [json] BoxJS JSON 来源,将随探测请求头传递 / BoxJS JSON source sent in the probe header.
|
|
8
7
|
* @property {number} [timeout] 超时毫秒数,默认 3500 / Timeout in milliseconds, defaults to 3500.
|
|
9
8
|
*/
|
|
10
9
|
|
|
@@ -15,14 +14,14 @@
|
|
|
15
14
|
* @param {ModuleProbeOptions} [options] 请求选项 / Request options.
|
|
16
15
|
* @returns {Promise<Response>} 原始 HTTP 响应,可直接读取 status 和响应头 / Native HTTP response; read status and headers directly.
|
|
17
16
|
*/
|
|
18
|
-
export async function probeModule(url, { fetch: request = globalThis.fetch,
|
|
17
|
+
export async function probeModule(url, { fetch: request = globalThis.fetch, signal, timeout = 3500 } = {}) {
|
|
19
18
|
const controller = new AbortController();
|
|
20
19
|
const abort = () => controller.abort();
|
|
21
20
|
if (signal?.aborted) abort();
|
|
22
21
|
signal?.addEventListener("abort", abort, { once: true });
|
|
23
22
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
24
23
|
try {
|
|
25
|
-
return await request(url, { method: "HEAD", cache: "no-store", credentials: "omit", signal: controller.signal
|
|
24
|
+
return await request(url, { method: "HEAD", cache: "no-store", credentials: "omit", signal: controller.signal });
|
|
26
25
|
} finally {
|
|
27
26
|
clearTimeout(timer);
|
|
28
27
|
signal?.removeEventListener("abort", abort);
|
|
@@ -11,9 +11,9 @@ export class ModuleFrame extends EventTarget {
|
|
|
11
11
|
* 创建容器。
|
|
12
12
|
* Create a container.
|
|
13
13
|
* @param url 模块地址 / Module URL.
|
|
14
|
-
* @param options
|
|
14
|
+
* @param options 外部取消选项 / External cancellation options.
|
|
15
15
|
*/
|
|
16
|
-
constructor(url: string | URL, options?:
|
|
16
|
+
constructor(url: string | URL, options?: { signal?: AbortSignal });
|
|
17
17
|
/**
|
|
18
18
|
* 宿主挂载节点。
|
|
19
19
|
* Host-mounted element.
|
|
@@ -169,8 +169,6 @@ export interface ModuleProbeOptions {
|
|
|
169
169
|
fetch?: typeof globalThis.fetch;
|
|
170
170
|
/** 外部取消信号 / External cancellation signal. */
|
|
171
171
|
signal?: AbortSignal;
|
|
172
|
-
/** 传给模块 API 的 BoxJS JSON 来源 / BoxJS JSON source sent to the module API. */
|
|
173
|
-
json?: string;
|
|
174
172
|
/** 超时毫秒数,默认 3500 / Timeout in milliseconds, defaults to 3500. */
|
|
175
173
|
timeout?: number;
|
|
176
174
|
}
|
package/src/browser/client.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ModuleDefinition,
|
|
1
|
+
import type { ModuleDefinition, SettingsScalar } from "../index.js";
|
|
2
2
|
/**
|
|
3
3
|
* 单键写入或删除的通知事件。
|
|
4
4
|
* Notification for a single-key write or delete.
|
|
@@ -16,12 +16,10 @@ export interface Notification {
|
|
|
16
16
|
message?: string;
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
20
|
-
* Browser page client options; the
|
|
19
|
+
* 浏览器页面客户端选项;字段定义来自 BoxJS。
|
|
20
|
+
* Browser page client options; the field definition comes from BoxJS.
|
|
21
21
|
*/
|
|
22
22
|
export interface PreferencesClientOptions {
|
|
23
|
-
/** API 返回的模块模型 / Module model returned by the API. */
|
|
24
|
-
model: ModuleModel;
|
|
25
23
|
/** 用于渲染的归一化字段定义 / Normalized field definition for rendering. */
|
|
26
24
|
definition: ModuleDefinition;
|
|
27
25
|
/** 默认使用浏览器 fetch / Defaults to browser fetch. */
|
|
@@ -48,6 +46,8 @@ export interface ModuleSnapshot {
|
|
|
48
46
|
export class PreferencesClient {
|
|
49
47
|
/** 创建页面客户端 / Create the page client. */
|
|
50
48
|
constructor(options: PreferencesClientOptions);
|
|
49
|
+
/** 读取设置并建立页面快照 / Read settings and establish the page snapshot. */
|
|
50
|
+
open(): Promise<ModuleSnapshot>;
|
|
51
51
|
/** 获取页面快照,不发请求 / Get a page snapshot without a request. */
|
|
52
52
|
snapshot(): ModuleSnapshot;
|
|
53
53
|
/** 读取 Settings 子树 / Read the Settings subtree. */
|
package/src/browser/client.mjs
CHANGED
|
@@ -1,31 +1,55 @@
|
|
|
1
|
+
import { normalizeStoredValue, validValue } from "./boxjs.mjs";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* 管理单模块页面的 API 请求、值快照和会话终止。
|
|
3
5
|
* Manage API requests, value snapshots, and session termination for one module page.
|
|
4
6
|
*/
|
|
5
7
|
export class PreferencesClient {
|
|
6
8
|
#module;
|
|
7
|
-
#configURL;
|
|
8
9
|
#definition;
|
|
9
10
|
#request;
|
|
10
11
|
#notify;
|
|
11
12
|
#timeout;
|
|
12
13
|
#session = new AbortController();
|
|
13
|
-
#values;
|
|
14
|
+
#values = {};
|
|
14
15
|
#saving = false;
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
|
-
*
|
|
18
|
-
* Create a page client that
|
|
19
|
-
* @param {import("./client.mjs").PreferencesClientOptions} options
|
|
18
|
+
* 创建从 BoxJS 定义读取和持久化设置的页面客户端。
|
|
19
|
+
* Create a page client that reads and persists settings from a BoxJS definition.
|
|
20
|
+
* @param {import("./client.mjs").PreferencesClientOptions} options 字段定义、请求与通知 / Field definition, requests, and notifications.
|
|
20
21
|
*/
|
|
21
|
-
constructor({
|
|
22
|
-
this.#module =
|
|
23
|
-
this.#configURL = model.configURL;
|
|
22
|
+
constructor({ definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
|
|
23
|
+
this.#module = definition.module;
|
|
24
24
|
this.#definition = definition;
|
|
25
25
|
this.#request = request;
|
|
26
26
|
this.#notify = notify;
|
|
27
27
|
this.#timeout = timeout;
|
|
28
|
-
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 读取一次 Settings 子树并建立页面值快照。
|
|
32
|
+
* Read the Settings subtree once and establish the page value snapshot.
|
|
33
|
+
* @returns {Promise<import("./client.mjs").ModuleSnapshot>} 页面快照 / Page snapshot.
|
|
34
|
+
*/
|
|
35
|
+
async open() {
|
|
36
|
+
let subtree = await this.readSettings();
|
|
37
|
+
if (subtree === undefined) subtree = {};
|
|
38
|
+
if (typeof subtree === "string") subtree = JSON.parse(subtree);
|
|
39
|
+
if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
|
|
40
|
+
const values = {};
|
|
41
|
+
for (const field of this.#definition.fields) {
|
|
42
|
+
const stored = field.key
|
|
43
|
+
.split(".")
|
|
44
|
+
.slice(this.#definition.settingsPath.length)
|
|
45
|
+
.reduce((parent, part) => Object(parent)[part], subtree);
|
|
46
|
+
const value = normalizeStoredValue(field, stored === undefined ? field.defaultValue : stored);
|
|
47
|
+
if (value === undefined) continue;
|
|
48
|
+
if (!validValue(field, value)) throw new TypeError(`Invalid stored value: ${field.key}`);
|
|
49
|
+
values[field.key] = value;
|
|
50
|
+
}
|
|
51
|
+
this.#values = values;
|
|
52
|
+
return this.snapshot();
|
|
29
53
|
}
|
|
30
54
|
|
|
31
55
|
/**
|
|
@@ -124,7 +148,7 @@ export class PreferencesClient {
|
|
|
124
148
|
credentials: "omit",
|
|
125
149
|
cache: "no-store",
|
|
126
150
|
signal: controller.signal,
|
|
127
|
-
headers: { "Content-Type": "application/json"
|
|
151
|
+
headers: { "Content-Type": "application/json" },
|
|
128
152
|
body: JSON.stringify(payload),
|
|
129
153
|
});
|
|
130
154
|
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
@@ -148,6 +172,10 @@ export class PreferencesClient {
|
|
|
148
172
|
if (this.#saving) throw new Error("A settings write is already in progress");
|
|
149
173
|
this.#saving = true;
|
|
150
174
|
try {
|
|
175
|
+
if (operation === "write") {
|
|
176
|
+
const field = this.#definition.fields.find(candidate => candidate.key === key);
|
|
177
|
+
if (!field || !validValue(field, payload.value)) throw new TypeError("Invalid setting value");
|
|
178
|
+
}
|
|
151
179
|
await this.#send(action, payload);
|
|
152
180
|
switch (operation) {
|
|
153
181
|
case "write":
|
package/src/browser/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { BoxJSInput } from "../index.js";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* 具体模块设置页的生命周期句柄。
|
|
@@ -9,25 +9,9 @@ export interface MountedPreferences {
|
|
|
9
9
|
destroy(): void;
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
export class PreferencesView implements MountedPreferences {
|
|
16
|
-
/**
|
|
17
|
-
* 使用模块 API 返回的模型挂载设置页。
|
|
18
|
-
* Mount a settings page from the model returned by the module API.
|
|
19
|
-
* @param model 模块 API 模型 / Module API model.
|
|
20
|
-
* @param css 可选 CSS 正文 / Optional CSS text.
|
|
21
|
-
*/
|
|
22
|
-
constructor(model: ModuleModel, css?: string);
|
|
23
|
-
/** 移除页面、样式和监听器 / Remove page, styles and listeners. */
|
|
24
|
-
destroy(): void;
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* 使用模块 API 返回的模型挂载设置页;CSS 仅覆盖当前模块。
|
|
28
|
-
* Mount a settings page from a module API model; CSS only overrides this module.
|
|
29
|
-
* @param model 模块 API 模型 / Module API model.
|
|
30
|
-
* @param css 可选 CSS 正文 / Optional CSS text.
|
|
12
|
+
* 使用原始 BoxJS JSON 挂载设置页。
|
|
13
|
+
* Mount a settings page from raw BoxJS JSON.
|
|
14
|
+
* @param boxjs 恰好包含一个模块的 BoxJS JSON / BoxJS JSON describing exactly one module.
|
|
31
15
|
* @returns 模块视图 / Module view.
|
|
32
16
|
*/
|
|
33
|
-
export function mount(
|
|
17
|
+
export function mount(boxjs: BoxJSInput): MountedPreferences;
|
package/src/browser/index.mjs
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
import { pageInputs } from "../lib/page-inputs.mjs";
|
|
2
1
|
import { statusView } from "./components.mjs";
|
|
3
|
-
import {
|
|
2
|
+
import { mount } from "./mount.mjs";
|
|
4
3
|
import { installDefaultStyles } from "./styles.mjs";
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
|
-
*
|
|
8
|
-
* Manage
|
|
6
|
+
* 管理模块文档的配置请求、重载和错误状态。
|
|
7
|
+
* Manage configuration requests, reloads, and error states for a module document.
|
|
9
8
|
*/
|
|
10
|
-
|
|
11
|
-
#document;
|
|
9
|
+
class ModulePage {
|
|
12
10
|
#window;
|
|
13
11
|
#root;
|
|
14
12
|
#view;
|
|
@@ -19,7 +17,6 @@ export class ModulePage {
|
|
|
19
17
|
* @param {Document} document 模块文档 / Module document.
|
|
20
18
|
*/
|
|
21
19
|
constructor(document) {
|
|
22
|
-
this.#document = document;
|
|
23
20
|
this.#window = document.defaultView;
|
|
24
21
|
this.#root = document.querySelector("#preferences");
|
|
25
22
|
installDefaultStyles(document);
|
|
@@ -27,8 +24,8 @@ export class ModulePage {
|
|
|
27
24
|
}
|
|
28
25
|
|
|
29
26
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
27
|
+
* 从规范模块路径读取 BoxJS JSON 并挂载通用前端。
|
|
28
|
+
* Read BoxJS JSON from the conventional module path and mount the generic frontend.
|
|
32
29
|
* @returns {Promise<void>} 启动完成 / Startup completion.
|
|
33
30
|
*/
|
|
34
31
|
async start() {
|
|
@@ -36,12 +33,13 @@ export class ModulePage {
|
|
|
36
33
|
this.#view?.destroy();
|
|
37
34
|
this.#view = undefined;
|
|
38
35
|
this.#root.replaceChildren(statusView("读取设置…"));
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
const embedded = this.#window.frameElement?.dataset.preferencePanesModule;
|
|
37
|
+
const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(this.#window.location.pathname);
|
|
38
|
+
const module = embedded ?? match?.[1];
|
|
39
|
+
if (!module) throw new TypeError("Open a concrete module URL");
|
|
40
|
+
const response = await fetch(`/configs/${encodeURIComponent(module)}`, { cache: "no-store", credentials: "omit", headers: { Accept: "application/json" } });
|
|
41
|
+
if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
|
|
42
|
+
this.#view = mount(await response.json());
|
|
45
43
|
} catch (error) {
|
|
46
44
|
this.#root.replaceChildren(statusView(`加载失败:${error.message}`, () => this.start()));
|
|
47
45
|
}
|
|
@@ -58,39 +56,6 @@ export class ModulePage {
|
|
|
58
56
|
this.#view = undefined;
|
|
59
57
|
}
|
|
60
58
|
|
|
61
|
-
/**
|
|
62
|
-
* 读取嵌入参数、文档元数据或当前 URL 输入。
|
|
63
|
-
* Read embedded parameters, document metadata, or current URL inputs.
|
|
64
|
-
* @returns {ReturnType<typeof pageInputs>} 页面输入 / Page inputs.
|
|
65
|
-
*/
|
|
66
|
-
#readInputs() {
|
|
67
|
-
const context = this.#document.querySelector('meta[name="preference-panes-inputs"]');
|
|
68
|
-
const embedded = this.#window.frameElement?.dataset.preferencePanes;
|
|
69
|
-
switch (true) {
|
|
70
|
-
case embedded !== undefined:
|
|
71
|
-
this.#document.documentElement.dataset.preferencePanesEmbedded = "";
|
|
72
|
-
return JSON.parse(embedded);
|
|
73
|
-
case context !== null:
|
|
74
|
-
return JSON.parse(decodeURIComponent(context.content));
|
|
75
|
-
default:
|
|
76
|
-
return pageInputs(new URL(this.#window.location.href));
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* 将可选页面资源限制为 HTTP(S) 地址。
|
|
82
|
-
* Restrict an optional page resource to an HTTP(S) URL.
|
|
83
|
-
* @param {string | undefined} source 资源地址 / Resource location.
|
|
84
|
-
* @param {string} baseURL 页面基准地址 / Page base URL.
|
|
85
|
-
* @returns {string | null} 绝对资源地址 / Absolute resource URL.
|
|
86
|
-
*/
|
|
87
|
-
#resourceURL(source, baseURL) {
|
|
88
|
-
if (!source) return null;
|
|
89
|
-
const url = new URL(source, baseURL);
|
|
90
|
-
if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Resources must use HTTP(S) URLs");
|
|
91
|
-
return url.href;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
59
|
/**
|
|
95
60
|
* 从前进后退缓存恢复时重新加载模块。
|
|
96
61
|
* Reload the module when restored from the back-forward cache.
|
package/src/browser/mount.mjs
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
import { normalizeBoxJs
|
|
1
|
+
import { normalizeBoxJs } from "./boxjs.mjs";
|
|
2
2
|
import { element, resourceURL } from "./components.mjs";
|
|
3
3
|
import { PreferencesPanel } from "./panel.mjs";
|
|
4
4
|
import { installDefaultStyles } from "./styles.mjs";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
* Manage
|
|
7
|
+
* 管理 BoxJS 规范化、主题同步和面板生命周期。
|
|
8
|
+
* Manage BoxJS normalization, theme synchronization, and panel lifecycle.
|
|
9
9
|
*/
|
|
10
|
-
|
|
10
|
+
class PreferencesView {
|
|
11
11
|
#existing;
|
|
12
12
|
#root;
|
|
13
13
|
#base;
|
|
14
14
|
#ownsBase;
|
|
15
|
-
#custom;
|
|
16
15
|
#previousTitle;
|
|
17
16
|
#previousTheme;
|
|
18
17
|
#systemTheme;
|
|
@@ -22,22 +21,12 @@ export class PreferencesView {
|
|
|
22
21
|
#panel;
|
|
23
22
|
|
|
24
23
|
/**
|
|
25
|
-
*
|
|
26
|
-
* Mount a settings page from
|
|
27
|
-
* @param {import("../index.js").
|
|
28
|
-
* @param {string} [css] 可选 CSS 正文 / Optional module-scoped CSS text.
|
|
24
|
+
* 使用原始 BoxJS JSON 挂载设置页。
|
|
25
|
+
* Mount a settings page from raw BoxJS JSON.
|
|
26
|
+
* @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
|
|
29
27
|
*/
|
|
30
|
-
constructor(
|
|
31
|
-
|
|
32
|
-
const definition = normalizeBoxJs(model.boxjs, model.module);
|
|
33
|
-
const values = { ...model.values };
|
|
34
|
-
for (const field of definition.fields) {
|
|
35
|
-
if (values[field.key] === undefined) continue;
|
|
36
|
-
values[field.key] = normalizeStoredValue(field, values[field.key]);
|
|
37
|
-
if (!validValue(field, values[field.key])) throw new TypeError(`Invalid stored value: ${field.key}`);
|
|
38
|
-
}
|
|
39
|
-
for (const field of definition.fields) if (values[field.key] === undefined && Object.hasOwn(field, "defaultValue")) values[field.key] = structuredClone(field.defaultValue);
|
|
40
|
-
const rendered = { ...model, definition, values };
|
|
28
|
+
constructor(boxjs) {
|
|
29
|
+
const definition = normalizeBoxJs(boxjs);
|
|
41
30
|
const metadata = definition.metadata ?? {};
|
|
42
31
|
const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
|
|
43
32
|
if (image) resourceURL(image);
|
|
@@ -52,9 +41,6 @@ export class PreferencesView {
|
|
|
52
41
|
const styles = installDefaultStyles(document);
|
|
53
42
|
this.#base = styles.element;
|
|
54
43
|
this.#ownsBase = styles.owned;
|
|
55
|
-
this.#custom = element("style", "");
|
|
56
|
-
this.#custom.textContent = css;
|
|
57
|
-
document.head.append(this.#custom);
|
|
58
44
|
this.#previousTitle = document.title;
|
|
59
45
|
this.#previousTheme = document.documentElement.dataset.theme;
|
|
60
46
|
this.#systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
|
@@ -69,7 +55,7 @@ export class PreferencesView {
|
|
|
69
55
|
document.title = metadata.name ?? definition.module;
|
|
70
56
|
try {
|
|
71
57
|
this.#root.replaceChildren();
|
|
72
|
-
this.#panel = new PreferencesPanel(this.#root,
|
|
58
|
+
this.#panel = new PreferencesPanel(this.#root, definition);
|
|
73
59
|
} catch (error) {
|
|
74
60
|
this.destroy();
|
|
75
61
|
throw error;
|
|
@@ -97,7 +83,6 @@ export class PreferencesView {
|
|
|
97
83
|
this.#systemTheme.removeEventListener("change", this.#syncAppearance);
|
|
98
84
|
this.#panel?.destroy();
|
|
99
85
|
if (this.#ownsBase) this.#base.remove();
|
|
100
|
-
this.#custom.remove();
|
|
101
86
|
if (this.#existing) this.#root.replaceChildren();
|
|
102
87
|
else this.#root.remove();
|
|
103
88
|
document.title = this.#previousTitle;
|
|
@@ -108,12 +93,11 @@ export class PreferencesView {
|
|
|
108
93
|
}
|
|
109
94
|
|
|
110
95
|
/**
|
|
111
|
-
*
|
|
112
|
-
* Mount a settings page from
|
|
113
|
-
* @param {import("../index.js").
|
|
114
|
-
* @
|
|
115
|
-
* @returns {PreferencesView} 模块视图 / Module view.
|
|
96
|
+
* 使用原始 BoxJS JSON 挂载设置页。
|
|
97
|
+
* Mount a settings page from raw BoxJS JSON.
|
|
98
|
+
* @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
|
|
99
|
+
* @returns {import("./index.js").MountedPreferences} 模块视图 / Module view.
|
|
116
100
|
*/
|
|
117
|
-
export function mount(
|
|
118
|
-
return new PreferencesView(
|
|
101
|
+
export function mount(boxjs) {
|
|
102
|
+
return new PreferencesView(boxjs);
|
|
119
103
|
}
|
package/src/browser/panel.mjs
CHANGED
|
@@ -12,24 +12,23 @@ export class PreferencesPanel {
|
|
|
12
12
|
#release;
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* 挂载
|
|
16
|
-
* Mount the module form
|
|
15
|
+
* 挂载 BoxJS 定义对应的模块表单。
|
|
16
|
+
* Mount the module form described by a BoxJS definition.
|
|
17
17
|
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
18
|
-
* @param {import("../index.js").
|
|
18
|
+
* @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
|
|
19
19
|
*/
|
|
20
|
-
constructor(root,
|
|
21
|
-
this.#release = this.#mount(root,
|
|
20
|
+
constructor(root, definition) {
|
|
21
|
+
this.#release = this.#mount(root, definition);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* 建立面板 DOM、交互和会话,并返回其释放操作。
|
|
26
26
|
* Build panel DOM, interactions, and session, then return its release operation.
|
|
27
27
|
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
28
|
-
* @param {import("../index.js").
|
|
28
|
+
* @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
|
|
29
29
|
* @returns {() => void} 释放操作 / Release operation.
|
|
30
30
|
*/
|
|
31
|
-
#mount(root,
|
|
32
|
-
const { definition } = model;
|
|
31
|
+
#mount(root, definition) {
|
|
33
32
|
const title = definition.metadata?.name ?? definition.module;
|
|
34
33
|
const document = root.ownerDocument;
|
|
35
34
|
const window = document.defaultView;
|
|
@@ -121,7 +120,7 @@ export class PreferencesPanel {
|
|
|
121
120
|
toast.hidden = true;
|
|
122
121
|
}, 2400);
|
|
123
122
|
};
|
|
124
|
-
const client = new PreferencesClient({
|
|
123
|
+
const client = new PreferencesClient({ definition, notify });
|
|
125
124
|
/**
|
|
126
125
|
* 两种菜单入口共用异步错误处理,包含宿主确认框错误。
|
|
127
126
|
* Share async error handling between both menus, including host-dialog errors.
|
|
@@ -149,6 +148,7 @@ export class PreferencesPanel {
|
|
|
149
148
|
publishNavigation();
|
|
150
149
|
viewport.replaceChildren(statusView("读取设置…"));
|
|
151
150
|
try {
|
|
151
|
+
await client.open();
|
|
152
152
|
if (version === generation) controls();
|
|
153
153
|
} catch (error) {
|
|
154
154
|
if (version !== generation) return;
|
package/src/index.d.ts
CHANGED
|
@@ -268,25 +268,3 @@ export interface BoxJSSubscription extends BoxJSMetadata {
|
|
|
268
268
|
* The sole data configuration input.
|
|
269
269
|
*/
|
|
270
270
|
export type BoxJSInput = BoxJSSetting[] | BoxJSApp | BoxJSSubscription;
|
|
271
|
-
/**
|
|
272
|
-
* 模块 API 返回的原始 BoxJS 与当前值模型。
|
|
273
|
-
* Raw BoxJS and current-value model returned by the module API.
|
|
274
|
-
*/
|
|
275
|
-
export interface ModuleModel {
|
|
276
|
-
/** 模块路径段 / Module path segment. */
|
|
277
|
-
module: string;
|
|
278
|
-
/** API 获取的原始 BoxJS JSON / Raw BoxJS JSON fetched by the API. */
|
|
279
|
-
boxjs: BoxJSInput;
|
|
280
|
-
/** API 读取到的原始已保存字段值 / Raw persisted field values read by the API. */
|
|
281
|
-
values: Record<string, JsonValue>;
|
|
282
|
-
/** 后续 API 动作使用的 BoxJS JSON 地址 / BoxJS JSON URL used by later API actions. */
|
|
283
|
-
configURL: string;
|
|
284
|
-
}
|
|
285
|
-
/**
|
|
286
|
-
* 生成模块前端文件,不复制配置、不生成绑定模块的代理脚本。
|
|
287
|
-
* Build module frontend files without copying configuration or producing bound proxy scripts.
|
|
288
|
-
* @param boxjs 恰好包含一个模块的 BoxJS JSON / BoxJS JSON describing exactly one module.
|
|
289
|
-
* @param css 可选 CSS 正文 / Optional CSS text.
|
|
290
|
-
* @returns 相对路径到文件内容的映射 / Relative paths mapped to file contents.
|
|
291
|
-
*/
|
|
292
|
-
export function build(boxjs: BoxJSInput, css?: string): Promise<Record<string, string>>;
|
package/src/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* PreferencePanes 公共类型根入口;浏览器运行时由子路径导出。
|
|
3
|
+
* PreferencePanes public type root; browser runtimes are exported from subpaths.
|
|
4
4
|
* @module @nsnanocat/preference-panes
|
|
5
5
|
*/
|
|
6
|
-
export {
|
|
6
|
+
export {};
|