@nsnanocat/preference-panes 0.7.1 → 0.8.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/src/Store.mjs CHANGED
@@ -2,83 +2,70 @@ import { URL } from "@nsnanocat/url";
2
2
  import { Lodash as _ } from "@nsnanocat/util/polyfill/Lodash.mjs";
3
3
  import { Storage } from "@nsnanocat/util/polyfill/Storage";
4
4
  import { response } from "./lib/response.mjs";
5
- import { parseSettingsPathname } from "./lib/settings-path.mjs";
5
+ import { validatePathParts } from "./lib/settings-path.mjs";
6
6
 
7
7
  /**
8
- * 根据 BoxJS 目录桥接持久化存储,不下载配置或解析控件。
9
- * Bridge persistence using the BoxJS catalog without downloading configuration or interpreting controls.
8
+ * 无配置绑定的本地存储桥接;form 字段名就是完整 @root.path。
9
+ * Unbound local storage bridge; the form field name is the complete @root.path.
10
10
  */
11
11
  export class Store {
12
- #catalog;
13
-
14
- /**
15
- * 复用包内已解析的目录,构造时不访问网络或存储。
16
- * Reuse the parsed internal catalog without network or persistence access during construction.
17
- * @param {import("./BoxJS.mjs").BoxJS} catalog BoxJS 路径目录 / BoxJS path catalog.
18
- */
19
- constructor(catalog) {
20
- this.#catalog = catalog;
21
- }
22
-
23
12
  /**
24
- * GET 返回指定值,POST 替换指定值,DELETE 删除指定键或整个模块。
25
- * GET returns a value, POST replaces it, and DELETE removes a key or the entire module.
13
+ * POST /api/get、set、delete;不下载配置、不解析控件、不鉴权。
14
+ * POST /api/get, set or delete without config downloads, control parsing or authentication.
26
15
  * @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
27
- * @param {URL} [url] 包内复用的已解析地址 / Parsed URL reused within the package.
28
- * @returns {Promise<import("./index.js").SettingsResponse | undefined>} 响应或非接管请求 / Response, or undefined for an unhandled request.
16
+ * @param {URL} [url] 已解析地址 / Parsed URL.
17
+ * @returns {Promise<import("./index.js").SettingsResponse | undefined>} 操作结果 / Operation result.
29
18
  */
30
19
  async handle(request, url = new URL(request.url)) {
31
20
  if (!url.pathname.startsWith("/api/")) return;
32
21
  const reply = (status, data) => response(request, status, data);
33
- let parts;
22
+ const action = url.pathname.slice(5);
23
+ if (!["get", "set", "delete"].includes(action)) return reply(404, { error: "Unknown action" });
24
+ if (request.method !== "POST") return reply(405, { error: "Use POST with a form body" });
25
+ const headers = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
26
+ if (headers["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/x-www-form-urlencoded") return reply(415, { error: "Expected application/x-www-form-urlencoded" });
27
+ if (typeof request.body !== "string" || request.body.length > 65536) return reply(400, { error: "Expected a form body up to 65536 characters" });
28
+ let parts, value;
34
29
  try {
35
- parts = parseSettingsPathname(url.pathname);
30
+ const fields = request.body.split("&");
31
+ if (fields.length !== 1) throw new TypeError("Send exactly one storage key");
32
+ const separator = fields[0].indexOf("=");
33
+ if (separator < 0) throw new TypeError("Expected @root.path=value");
34
+ const key = decodeURIComponent(fields[0].slice(0, separator).replace(/\+/g, " "));
35
+ value = decodeURIComponent(fields[0].slice(separator + 1).replace(/\+/g, " "));
36
+ if (!key.startsWith("@")) throw new TypeError("Storage keys must start with @");
37
+ parts = validatePathParts(key.slice(1).split("."));
38
+ if (parts.length < 2) throw new TypeError("Specify a storage root and child path");
36
39
  } catch (error) {
37
40
  return reply(400, { error: error.message });
38
41
  }
39
- const binding = this.#catalog.modules.get(parts[0]);
40
- if (!binding) return reply(404, { error: "Module is not declared in BoxJS" });
41
- const requestHeaders = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
42
- if (requestHeaders["x-settings-client"] !== "1" || (requestHeaders.origin && requestHeaders.origin !== url.origin)) return reply(403, { error: "Forbidden settings client" });
43
- let value;
44
- switch (request.method) {
45
- case "HEAD":
46
- return reply(200, undefined);
47
- case "GET":
48
- case "DELETE":
49
- break;
50
- case "POST":
51
- if (requestHeaders["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/json") return reply(415, { error: "Expected application/json" });
52
- if (typeof request.body !== "string") return reply(400, { error: "Expected a JSON string body" });
53
- if (request.body.length > 65536) return reply(413, { error: "Body exceeds 65536 UTF-16 code units" });
54
- try {
55
- value = JSON.parse(request.body);
56
- } catch {
57
- return reply(400, { error: "Invalid JSON" });
58
- }
59
- break;
60
- default:
61
- return { ...reply(405, { error: "Method not allowed" }), headers: { ...reply(405).headers, Allow: "HEAD, GET, POST, DELETE" } };
42
+ if (action === "set") {
43
+ try {
44
+ value = JSON.parse(value);
45
+ } catch (error) {
46
+ if (!(error instanceof SyntaxError)) throw error;
47
+ }
62
48
  }
49
+ const [storageKey, ...path] = parts;
63
50
  try {
64
- const root = Storage.getItem(binding.storageKey, {});
65
- if (!isRecord(root)) throw new TypeError("stored root must be an object");
66
- const parent = storageParent(root, parts, request.method === "POST");
67
- const key = parts.at(-1);
68
- switch (request.method) {
69
- case "GET": {
51
+ const root = Storage.getItem(storageKey, {});
52
+ if (!isRecord(root)) throw new TypeError("Stored root must be an object");
53
+ const parent = storageParent(root, path, action === "set");
54
+ const key = path.at(-1);
55
+ switch (action) {
56
+ case "get": {
70
57
  const result = parent ? _.get(parent, [key]) : undefined;
71
58
  return result === undefined ? reply(404, { error: "Stored path does not exist" }) : reply(200, result);
72
59
  }
73
- case "POST":
60
+ case "set":
74
61
  _.set(parent, [key], value);
75
62
  break;
76
- case "DELETE":
63
+ case "delete":
77
64
  if (parent) _.unset(parent, [key]);
78
65
  break;
79
66
  }
80
- if (!Storage.setItem(binding.storageKey, root)) throw new Error("Storage write failed");
81
- return reply(200, request.method === "POST" ? { saved: true } : { deleted: true });
67
+ if (!Storage.setItem(storageKey, root)) throw new Error("Storage write failed");
68
+ return reply(200, action === "set" ? { saved: true } : { deleted: true });
82
69
  } catch (error) {
83
70
  return reply(500, { error: error.message });
84
71
  }
@@ -0,0 +1,83 @@
1
+ import { pageInputs } from "../lib/page-inputs.mjs";
2
+
3
+ /**
4
+ * 模块文档容器:原始 HTML 不改写,请求上下文随 iframe 元素传递。
5
+ * Module document container: preserve HTML verbatim and carry request context on the iframe element.
6
+ */
7
+ export class ModuleFrame extends EventTarget {
8
+ #url;
9
+ #options;
10
+ #controller = new AbortController();
11
+ #abort = () => this.destroy();
12
+ #state;
13
+ #change = event => {
14
+ this.#state = event.detail;
15
+ this.dispatchEvent(new Event("change"));
16
+ };
17
+
18
+ /**
19
+ * 建立 iframe 与请求输入;调用方挂载 element 后调用 load。
20
+ * Create the iframe and request inputs; callers mount element and then call load.
21
+ * @param {string | URL} url 模块请求地址 / Module request URL.
22
+ * @param {RequestInit} [options] 原生请求头和取消信号 / Native headers and cancellation signal.
23
+ */
24
+ constructor(url, options = {}) {
25
+ super();
26
+ this.#url = new URL(url, document.baseURI);
27
+ this.#options = { ...options, headers: new Headers(options.headers) };
28
+ const inputs = pageInputs(this.#url, Object.fromEntries(this.#options.headers));
29
+ this.element = document.createElement("iframe");
30
+ this.element.title = `${inputs.module} 设置`;
31
+ this.element.dataset.preferencePanes = JSON.stringify(inputs);
32
+ this.element.addEventListener("preferencepanes:change", this.#change);
33
+ this.#state = { title: inputs.module, module: inputs.module, busy: false, canGoBack: true };
34
+ options.signal?.addEventListener("abort", this.#abort, { once: true });
35
+ }
36
+
37
+ /**
38
+ * 当前模块导航状态。
39
+ * Current module navigation state.
40
+ */
41
+ get state() {
42
+ return { ...this.#state };
43
+ }
44
+
45
+ /**
46
+ * 获取原始 HTML;晚到响应在退出后不得重新挂载。
47
+ * Fetch unmodified HTML; a late response must not remount after departure.
48
+ * @returns {Promise<void>} HTML 已交给 iframe;表单状态通过 change 事件提供 / HTML assigned; form state is reported through change.
49
+ */
50
+ async load() {
51
+ if (this.#options.signal?.aborted) this.destroy();
52
+ const timer = setTimeout(() => this.#controller.abort(), 10000);
53
+ try {
54
+ const response = await fetch(this.#url, { cache: "no-store", credentials: "omit", ...this.#options, signal: this.#controller.signal });
55
+ if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
56
+ const html = await response.text();
57
+ this.#controller.signal.throwIfAborted();
58
+ this.element.srcdoc = html;
59
+ } finally {
60
+ clearTimeout(timer);
61
+ }
62
+ }
63
+
64
+ /**
65
+ * 使用 iframe 的联合历史返回;写入期间不导航。
66
+ * Navigate joint iframe history back, except while a write is pending.
67
+ * @returns {void} 无返回值 / No return value.
68
+ */
69
+ back() {
70
+ if (!this.#state.busy && this.#state.canGoBack) this.element.contentWindow.history.back();
71
+ }
72
+
73
+ /**
74
+ * 取消加载与事件订阅;节点保留到 Navigation 的退出动画结束。
75
+ * Cancel loading and subscriptions; Navigation retains the node until its exit animation ends.
76
+ * @returns {void} 无返回值 / No return value.
77
+ */
78
+ destroy() {
79
+ this.#controller.abort();
80
+ this.#options.signal?.removeEventListener("abort", this.#abort);
81
+ this.element.removeEventListener("preferencepanes:change", this.#change);
82
+ }
83
+ }
@@ -1,3 +1,45 @@
1
+ /**
2
+ * 原始 HTML 的模块 iframe 容器;通过元素传递请求上下文。
3
+ * Iframe container preserving module HTML and carrying context on the element.
4
+ */
5
+ export class ModuleFrame extends EventTarget {
6
+ /**
7
+ * 创建容器。
8
+ * Create a container.
9
+ * @param url 模块地址 / Module URL.
10
+ * @param options 原生请求参数 / Native request options.
11
+ */
12
+ constructor(url: string | URL, options?: RequestInit);
13
+ /**
14
+ * 宿主挂载节点。
15
+ * Host-mounted element.
16
+ */
17
+ readonly element: HTMLIFrameElement;
18
+ /**
19
+ * change 事件对应的导航状态。
20
+ * Navigation state exposed with change events.
21
+ */
22
+ readonly state: { title: string; module: string; busy: boolean; canGoBack: boolean };
23
+ /**
24
+ * 获取原始 HTML。
25
+ * Fetch unmodified HTML.
26
+ * @returns 加载完成 / Load completion.
27
+ */
28
+ load(): Promise<void>;
29
+ /**
30
+ * 沿模块历史返回。
31
+ * Go back through module history.
32
+ * @returns 无返回值 / No return value.
33
+ */
34
+ back(): void;
35
+ /**
36
+ * 取消请求与事件订阅。
37
+ * Cancel requests and subscriptions.
38
+ * @returns 无返回值 / No return value.
39
+ */
40
+ destroy(): void;
41
+ }
42
+
1
43
  /**
2
44
  * 同一文档的根页/子页导航,不定义页面布局或模块业务。
3
45
  * Home/detail navigation within a document, without layout or module business rules.
@@ -1,3 +1,5 @@
1
+ export { ModuleFrame } from "./ModuleFrame.mjs";
2
+
1
3
  /**
2
4
  * 同一文档内的主页/子页导航;iframe 各自的实例通过浏览器联合历史协作。
3
5
  * Navigate home/detail views within a document; iframe instances cooperate through joint browser history.
@@ -14,7 +14,19 @@ async function start() {
14
14
  view?.destroy();
15
15
  view = undefined;
16
16
  const context = document.querySelector('meta[name="preference-panes-inputs"]');
17
- const inputs = context ? JSON.parse(decodeURIComponent(context.content)) : pageInputs(new URL(location.href));
17
+ const embedded = window.frameElement?.dataset.preferencePanes;
18
+ let inputs;
19
+ switch (true) {
20
+ case embedded !== undefined:
21
+ inputs = JSON.parse(embedded);
22
+ document.documentElement.dataset.preferencePanesEmbedded = "";
23
+ break;
24
+ case context !== null:
25
+ inputs = JSON.parse(decodeURIComponent(context.content));
26
+ break;
27
+ default:
28
+ inputs = pageInputs(new URL(location.href));
29
+ }
18
30
  const resources = [inputs.json, inputs.css].map(source => {
19
31
  if (!source) return null;
20
32
  const url = new URL(source, inputs.url);
@@ -111,8 +111,8 @@ export interface PreferencesClient {
111
111
  */
112
112
  set(module: string, key: string, value: SettingsScalar | SettingsScalar[]): Promise<void>;
113
113
  /**
114
- * DELETE 单个覆盖值,HTTP 200 后显示默认值,不追加 GET。
115
- * DELETE an override and display its default after HTTP 200, without a follow-up GET.
114
+ * POST /api/delete 删除覆盖值,200 后显示默认值,不追加读取。
115
+ * POST /api/delete removes an override and displays its default after 200, without rereading.
116
116
  * @param module 已打开的模块 / Open module.
117
117
  * @param key 完整点分字段路径 / Complete dotted field path.
118
118
  * @returns 操作完成 / Completion of the operation.
@@ -24,31 +24,31 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
24
24
  */
25
25
  const sessions = new Map();
26
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.
27
+ * form 发送完整存储键;读取 404 交给调用方处理。
28
+ * Send a complete storage key as form data; callers handle missing reads.
29
+ * @param {string} path 完整 @root.path / Complete @root.path.
30
+ * @param {"get" | "set" | "delete"} action 存储操作 / Storage operation.
31
+ * @param {unknown} body set 值,其它操作忽略 / Set value, ignored by other operations.
32
32
  * @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
33
33
  * @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
34
34
  * @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
35
35
  */
36
- async function send(path, method, body, signal) {
36
+ async function send(path, action, body, signal) {
37
37
  const controller = new AbortController();
38
38
  const abort = () => controller.abort();
39
39
  if (signal?.aborted) abort();
40
40
  signal?.addEventListener("abort", abort, { once: true });
41
41
  const timer = setTimeout(abort, timeout);
42
42
  try {
43
- const response = await request(path, {
44
- method,
43
+ const response = await request(`/api/${action}`, {
44
+ method: "POST",
45
45
  credentials: "omit",
46
46
  cache: "no-store",
47
47
  signal: controller.signal,
48
- headers: { "X-Settings-Client": "1", ...(method === "POST" ? { "Content-Type": "application/json" } : {}) },
49
- ...(method === "POST" ? { body: JSON.stringify(body) } : {}),
48
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
49
+ body: new URLSearchParams([[path, action === "set" ? JSON.stringify(body) : ""]]).toString(),
50
50
  });
51
- if (response.status !== 200 && !(method === "GET" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
51
+ if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
52
52
  return response;
53
53
  } finally {
54
54
  clearTimeout(timer);
@@ -72,21 +72,21 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
72
72
  * Serialize single-key mutations and update a still-active session only after success.
73
73
  * @param {string} module 已打开模块 / Open module.
74
74
  * @param {string} key 完整点分字段路径 / Complete dotted field path.
75
- * @param {"POST" | "DELETE"} method 写入或删除 / Write or delete.
75
+ * @param {"set" | "delete"} action 写入或删除 / Write or delete.
76
76
  * @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
77
77
  * @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
78
78
  * @returns {Promise<void>} 操作完成 / Operation completion.
79
79
  * @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
80
80
  */
81
- async function change(module, key, method, value, operation = method === "POST" ? "write" : "delete") {
81
+ async function change(module, key, action, value, operation = action === "set" ? "write" : "delete") {
82
82
  const state = sessions.get(module);
83
83
  if (!state?.definition) throw new Error("Open the module first");
84
84
  if (state.saving) throw new Error("A settings write is already in progress");
85
85
  const field = state.definition.fields.find(field => field.key === key);
86
86
  state.saving = true;
87
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);
88
+ if ((operation === "write" || operation === "delete") && (!field || (action === "set" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
89
+ await send(`@${state.definition.storageKey}.${key}`, action, value);
90
90
  if (sessions.get(module) === state) {
91
91
  switch (operation) {
92
92
  case "write":
@@ -129,7 +129,7 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
129
129
  sessions.set(module, state);
130
130
  try {
131
131
  const definition = normalizeBoxJs(catalog.select(module), module);
132
- const response = await send(`/api/${definition.settingsPath.map(encodeURIComponent).join("/")}/`, "GET", undefined, state.controller.signal);
132
+ const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
133
133
  let subtree = response.status === 404 ? {} : await response.json();
134
134
  if (typeof subtree === "string") subtree = JSON.parse(subtree);
135
135
  if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
@@ -159,7 +159,7 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
159
159
  async readCaches(module) {
160
160
  const state = sessions.get(module);
161
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);
162
+ const response = await send(`@${state.definition.storageKey}.${module}.Caches`, "get", undefined, state.controller.signal);
163
163
  return response.status === 404 ? undefined : response.json();
164
164
  },
165
165
  /**
@@ -168,14 +168,14 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
168
168
  * @param {string} module 已打开模块 / Open module.
169
169
  * @returns {Promise<void>} 清理完成 / Cleanup completion.
170
170
  */
171
- clearCaches: module => change(module, `${module}.Caches`, "DELETE", undefined, "clearCaches"),
171
+ clearCaches: module => change(module, `${module}.Caches`, "delete", undefined, "clearCaches"),
172
172
  /**
173
173
  * 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
174
174
  * Delete module persistence and reset the page cache using current BoxJS defaults.
175
175
  * @param {string} module 已打开模块 / Open module.
176
176
  * @returns {Promise<void>} 重置完成 / Reset completion.
177
177
  */
178
- reset: module => change(module, module, "DELETE", undefined, "reset"),
178
+ reset: module => change(module, module, "delete", undefined, "reset"),
179
179
  /**
180
180
  * 取消读取并清除会话,不撤销已发送的写入。
181
181
  * Abort reads and clear the session without undoing dispatched writes.
@@ -194,7 +194,7 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
194
194
  * @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
195
195
  * @returns {Promise<void>} 写入完成 / Write completion.
196
196
  */
197
- set: (module, key, value) => change(module, key, "POST", value),
197
+ set: (module, key, value) => change(module, key, "set", value),
198
198
  /**
199
199
  * 删除单键覆盖值并显示默认值。
200
200
  * Delete one override and display its default value.
@@ -202,6 +202,6 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
202
202
  * @param {string} key 点分字段路径 / Dotted field path.
203
203
  * @returns {Promise<void>} 删除完成 / Delete completion.
204
204
  */
205
- remove: (module, key) => change(module, key, "DELETE"),
205
+ remove: (module, key) => change(module, key, "delete"),
206
206
  };
207
207
  }
@@ -91,10 +91,19 @@
91
91
  height: calc(100vh - 52px - env(safe-area-inset-top));
92
92
  overflow: hidden;
93
93
  }
94
+ :root[data-preference-panes-embedded] .pp-header {
95
+ display: none;
96
+ }
97
+ :root[data-preference-panes-embedded] .pp-viewport {
98
+ height: 100vh;
99
+ }
94
100
  @supports (height: 100dvh) {
95
101
  .pp-viewport {
96
102
  height: calc(100dvh - 52px - env(safe-area-inset-top));
97
103
  }
104
+ :root[data-preference-panes-embedded] .pp-viewport {
105
+ height: 100dvh;
106
+ }
98
107
  }
99
108
  .pp-fields,
100
109
  .pp-choice-page {
@@ -33,6 +33,17 @@ export function mountPanel(root, catalog) {
33
33
  header.append(back, brand, node("span", "pp-nav-spacer"));
34
34
  shell.append(header, viewport, toast);
35
35
  root.append(shell);
36
+ // 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
37
+ // Embedded mode publishes navigation state without host reads or mutations of the module DOM.
38
+ const publishNavigation = () => {
39
+ const frame = window.frameElement;
40
+ if (!frame?.dataset.preferencePanes) return;
41
+ frame.dispatchEvent(
42
+ new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
43
+ detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled },
44
+ }),
45
+ );
46
+ };
36
47
  let timer,
37
48
  navigation,
38
49
  generation = 0,
@@ -83,6 +94,7 @@ export function mountPanel(root, catalog) {
83
94
  active = module;
84
95
  back.disabled = window.history.length <= 1;
85
96
  heading.textContent = module;
97
+ publishNavigation();
86
98
  viewport.replaceChildren(node("p", "pp-loading", "读取设置…"));
87
99
  try {
88
100
  await client.open(module);
@@ -90,6 +102,7 @@ export function mountPanel(root, catalog) {
90
102
  } catch (error) {
91
103
  if (version !== generation) return;
92
104
  viewport.replaceChildren(errorView(error, () => open(module)));
105
+ publishNavigation();
93
106
  }
94
107
  }
95
108
  /**
@@ -121,6 +134,7 @@ export function mountPanel(root, catalog) {
121
134
  const editor = editors.get(navigation.current);
122
135
  heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
123
136
  back.disabled = saving || !navigation.canGoBack;
137
+ publishNavigation();
124
138
  };
125
139
  /**
126
140
  * 串行执行模块操作,保持输入可编辑。
@@ -134,6 +148,7 @@ export function mountPanel(root, catalog) {
134
148
  pendingWrites++;
135
149
  saving = true;
136
150
  back.disabled = true;
151
+ publishNavigation();
137
152
  return (queue = queue
138
153
  .then(action)
139
154
  .then(() => {
@@ -149,6 +164,7 @@ export function mountPanel(root, catalog) {
149
164
  saving = pendingWrites > 0;
150
165
  if (destroyed && !saving) client.leave(active);
151
166
  back.disabled = saving || !navigation.canGoBack;
167
+ publishNavigation();
152
168
  }));
153
169
  }
154
170
  const metadata = definition.metadata;
package/src/build.mjs CHANGED
@@ -2,8 +2,8 @@ import { readFile } from "node:fs/promises";
2
2
  import { BoxJS } from "./BoxJS.mjs";
3
3
 
4
4
  /**
5
- * 从两个配置输入生成一个模块页及其读写与配置 Mock,不生成项目主页。
6
- * Build one module page, its storage script and config Mock without a project landing page.
5
+ * 仅生成模块前端文件,不复制配置或生成绑定业务的读写脚本。
6
+ * Build module frontend files without copying configuration or generating bound storage scripts.
7
7
  * @param {unknown} boxjs 仅包含一个模块的 BoxJS JSON / BoxJS JSON containing exactly one module.
8
8
  * @param {string} [css] 可选自定义 CSS 正文 / Optional custom CSS text.
9
9
  * @returns {Promise<Record<string, string>>} 相对路径到文件正文的映射 / Relative file paths mapped to file contents.
@@ -12,22 +12,12 @@ export async function build(boxjs, css = "") {
12
12
  if (typeof css !== "string") throw new TypeError("CSS must be a string");
13
13
  const catalog = new BoxJS(boxjs);
14
14
  const module = catalog.module.module;
15
- const [html, app, navigation, proxy, mock] = await Promise.all([
16
- readFile(new URL("../dist/module/index.html", import.meta.url), "utf8"),
17
- readFile(new URL("../dist/module/app.mjs", import.meta.url), "utf8"),
18
- readFile(new URL("../dist/module/navigation.mjs", import.meta.url), "utf8"),
19
- readFile(new URL("../dist/preference-panes.proxy.js", import.meta.url), "utf8"),
20
- readFile(new URL("../dist/preference-panes.config.js", import.meta.url), "utf8"),
21
- ]);
22
- const config = JSON.stringify(catalog.select(module));
15
+ const [html, app, navigation] = await Promise.all([readFile(new URL("../dist/module/index.html", import.meta.url), "utf8"), readFile(new URL("../dist/module/app.mjs", import.meta.url), "utf8"), readFile(new URL("../dist/module/navigation.mjs", import.meta.url), "utf8")]);
23
16
  return {
24
17
  [`settings/${module}/index.html`]: html,
25
18
  [`settings/assets/${module}.html`]: html,
26
19
  "settings/assets/app.mjs": app,
27
20
  "settings/assets/navigation.mjs": navigation,
28
- [`settings/assets/${module}.boxjs.json`]: config,
29
21
  [`settings/assets/${module}.css`]: css,
30
- [`settings/assets/${module}.request.js`]: `${proxy}\nPreferencePanes.run(${config},${JSON.stringify(css)});\n`,
31
- [`settings/assets/${module}.config.js`]: `${mock}\nPreferencePanes.mock(${config});\n`,
32
22
  };
33
23
  }
package/src/index.d.ts CHANGED
@@ -92,8 +92,8 @@ export interface SettingsRequest {
92
92
  */
93
93
  headers?: Record<string, string | undefined>;
94
94
  /**
95
- * POST 的正文为 JSON 值本身;DELETE 无正文
96
- * POST contains the JSON value itself; DELETE has no body.
95
+ * POST 的正文为一个 form 字段,字段名是完整 @root.path
96
+ * POST contains one form field whose name is the complete @root.path.
97
97
  */
98
98
  body?: string;
99
99
  }
@@ -113,8 +113,8 @@ export interface SettingsResponse {
113
113
  */
114
114
  headers: Record<string, string>;
115
115
  /**
116
- * JSON 文本;HEAD 始终为空字符串
117
- * JSON text; always an empty string for HEAD.
116
+ * JSON 或页面资源正文
117
+ * JSON or page resource body.
118
118
  */
119
119
  body: string;
120
120
  }
@@ -269,8 +269,8 @@ export interface BoxJSSubscription extends BoxJSMetadata {
269
269
  */
270
270
  export type BoxJSInput = BoxJSSetting[] | BoxJSApp | BoxJSSubscription;
271
271
  /**
272
- * 生成具体模块的页面、代理和配置 Mock,不生成项目入口页。
273
- * Build a concrete module's page, proxy and config Mock without a project landing page.
272
+ * 生成模块前端文件,不复制配置、不生成绑定模块的代理脚本。
273
+ * Build module frontend files without copying configuration or producing bound proxy scripts.
274
274
  * @param boxjs 恰好包含一个模块的 BoxJS JSON / BoxJS JSON describing exactly one module.
275
275
  * @param css 可选 CSS 正文 / Optional CSS text.
276
276
  * @returns 相对路径到文件内容的映射 / Relative paths mapped to file contents.
@@ -11,7 +11,7 @@ export function pageInputs(url, headers = {}) {
11
11
  const module = match[1];
12
12
  const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
13
13
  const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
14
- const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? `/settings/assets/${module}.css`;
14
+ const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? "";
15
15
  if (!json.trim()) throw new TypeError("JSON resource URL is required");
16
16
  return { url: url.href, module, json, css };
17
17
  }
@@ -1,21 +1,3 @@
1
- /**
2
- * 解析已经取得的 pathname,避免重复构造 URL。
3
- * Parse an existing pathname without constructing another URL.
4
- * @param {string} pathname 以 / 开头的 URL pathname / URL pathname beginning with /.
5
- * @returns {string[] | undefined} 解码后的路径,非 API 路径不处理 / Decoded path, or undefined outside /api/.
6
- * @throws {TypeError} 转义编码或路径片段非法 / Invalid percent encoding or path segments.
7
- */
8
- export function parseSettingsPathname(pathname) {
9
- if (!pathname.startsWith("/api/")) return;
10
- let parts;
11
- try {
12
- parts = pathname.slice(5).replace(/\/$/, "").split("/").map(decodeURIComponent);
13
- } catch {
14
- throw new TypeError("Invalid encoded key path");
15
- }
16
- return validatePathParts(parts);
17
- }
18
-
19
1
  /**
20
2
  * 校验原始路径片段,不进行 URL 编码转换。
21
3
  * Validate raw path segments without URL encoding conversion.