cortico 0.1.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.
Files changed (311) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -0
  3. package/package.json +43 -0
  4. package/src/boot.ts +46 -0
  5. package/src/bot.ts +1342 -0
  6. package/src/config-file.ts +41 -0
  7. package/src/core/README.md +92 -0
  8. package/src/core/billing.ts +26 -0
  9. package/src/core/blobs.ts +106 -0
  10. package/src/core/bus.ts +311 -0
  11. package/src/core/config-schema.ts +201 -0
  12. package/src/core/config.ts +262 -0
  13. package/src/core/core.ts +699 -0
  14. package/src/core/cost.ts +244 -0
  15. package/src/core/event-store.ts +304 -0
  16. package/src/core/fork.ts +113 -0
  17. package/src/core/generation.ts +118 -0
  18. package/src/core/instance-lock.ts +215 -0
  19. package/src/core/ipc-logger.ts +105 -0
  20. package/src/core/language.ts +62 -0
  21. package/src/core/log-context.ts +38 -0
  22. package/src/core/loop.ts +1858 -0
  23. package/src/core/markers.ts +48 -0
  24. package/src/core/prefix.ts +121 -0
  25. package/src/core/run.ts +95 -0
  26. package/src/core/secrets.ts +18 -0
  27. package/src/core/session.ts +56 -0
  28. package/src/core/sessions.ts +261 -0
  29. package/src/core/state.ts +104 -0
  30. package/src/core/template.ts +72 -0
  31. package/src/core/timers.ts +129 -0
  32. package/src/core/tool-log.ts +135 -0
  33. package/src/core/transcript.ts +92 -0
  34. package/src/core/truncate.ts +121 -0
  35. package/src/core/types.ts +1140 -0
  36. package/src/core/usage-log.ts +79 -0
  37. package/src/core/util.ts +386 -0
  38. package/src/deploy-listing.ts +160 -0
  39. package/src/deploy.ts +162 -0
  40. package/src/extensions/README.md +72 -0
  41. package/src/extensions/dry-mount.ts +390 -0
  42. package/src/extensions/manifest.ts +153 -0
  43. package/src/extensions/runtime.ts +42 -0
  44. package/src/extensions.ts +519 -0
  45. package/src/launcher.ts +285 -0
  46. package/src/paths.ts +155 -0
  47. package/src/protocol/open-responses/LICENSE +201 -0
  48. package/src/protocol/open-responses/README.md +14 -0
  49. package/src/protocol/open-responses/context-helpers.ts +27 -0
  50. package/src/protocol/open-responses/context-log.ts +59 -0
  51. package/src/protocol/open-responses/context.ts +89 -0
  52. package/src/protocol/open-responses/generated.ts +115 -0
  53. package/src/protocol/open-responses/index.ts +33 -0
  54. package/src/protocol/open-responses/openapi.json +4230 -0
  55. package/src/protocol/open-responses/stream.ts +146 -0
  56. package/src/protocol/open-responses/tokens.ts +31 -0
  57. package/src/providers/README.md +86 -0
  58. package/src/providers/base.ts +111 -0
  59. package/src/providers/configuration.ts +97 -0
  60. package/src/providers/console/config.ts +21 -0
  61. package/src/providers/console/settings.ts +443 -0
  62. package/src/providers/console/strings.ts +74 -0
  63. package/src/providers/console/types.ts +11 -0
  64. package/src/providers/llamacpp/archive.ts +148 -0
  65. package/src/providers/llamacpp/catalog.ts +247 -0
  66. package/src/providers/llamacpp/config.ts +36 -0
  67. package/src/providers/llamacpp/console/client.ts +6 -0
  68. package/src/providers/llamacpp/console/models-panel.ts +120 -0
  69. package/src/providers/llamacpp/console/runtime-panel.ts +173 -0
  70. package/src/providers/llamacpp/console/server.ts +153 -0
  71. package/src/providers/llamacpp/index.ts +95 -0
  72. package/src/providers/llamacpp/native.ts +51 -0
  73. package/src/providers/llamacpp/options.ts +117 -0
  74. package/src/providers/llamacpp/runtime-store.ts +139 -0
  75. package/src/providers/llamacpp/runtime.ts +214 -0
  76. package/src/providers/llamacpp/server.ts +286 -0
  77. package/src/providers/llamacpp/strings.ts +230 -0
  78. package/src/providers/openai-responses-compat/config.ts +20 -0
  79. package/src/providers/openai-responses-compat/index.ts +89 -0
  80. package/src/providers/openai-responses-compat/native.ts +127 -0
  81. package/src/providers/openai-responses-compat/strings.ts +19 -0
  82. package/src/providers/pricebook.ts +75 -0
  83. package/src/providers/registry.ts +137 -0
  84. package/src/providers/strings.ts +71 -0
  85. package/src/providers/transport/chat.ts +156 -0
  86. package/src/providers/transport/errors.ts +48 -0
  87. package/src/providers/transport/history.ts +68 -0
  88. package/src/providers/transport/native-input.ts +83 -0
  89. package/src/providers/transport/native-types.ts +13 -0
  90. package/src/providers/transport/response-assembly.ts +191 -0
  91. package/src/providers/transport/response-http.ts +196 -0
  92. package/src/providers/transport/response-meters.ts +27 -0
  93. package/src/providers/transport/responses-input.ts +56 -0
  94. package/src/web/README.md +84 -0
  95. package/src/web/client/console-pages/builtins/llm-settings/panel.ts +625 -0
  96. package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +168 -0
  97. package/src/web/client/console-pages/builtins/llm-settings/strings.ts +213 -0
  98. package/src/web/client/console-pages/builtins.ts +10 -0
  99. package/src/web/client/console-pages/context.ts +135 -0
  100. package/src/web/client/console-pages/host.ts +633 -0
  101. package/src/web/client/console-pages/loader.ts +132 -0
  102. package/src/web/client/console-pages/strings.ts +90 -0
  103. package/src/web/client/console-pages/tools/strings.ts +49 -0
  104. package/src/web/client/console-pages/tools/view.ts +215 -0
  105. package/src/web/client/core/api.ts +200 -0
  106. package/src/web/client/core/language.ts +56 -0
  107. package/src/web/client/core/lifecycle.ts +132 -0
  108. package/src/web/client/core/router.ts +199 -0
  109. package/src/web/client/core/stream.ts +157 -0
  110. package/src/web/client/core/websocket.ts +82 -0
  111. package/src/web/client/features/appearance/index.ts +333 -0
  112. package/src/web/client/features/appearance/strings.ts +87 -0
  113. package/src/web/client/features/config/index.ts +11 -0
  114. package/src/web/client/features/config/strings.ts +41 -0
  115. package/src/web/client/features/config/view.ts +428 -0
  116. package/src/web/client/features/core/events.ts +197 -0
  117. package/src/web/client/features/core/index.ts +254 -0
  118. package/src/web/client/features/core/run.ts +56 -0
  119. package/src/web/client/features/core/runlog.ts +214 -0
  120. package/src/web/client/features/core/sessions.ts +53 -0
  121. package/src/web/client/features/core/strings.ts +157 -0
  122. package/src/web/client/features/extensions/index.ts +422 -0
  123. package/src/web/client/features/extensions/strings.ts +164 -0
  124. package/src/web/client/features/feature.ts +93 -0
  125. package/src/web/client/features/live/context.ts +280 -0
  126. package/src/web/client/features/live/fork.ts +156 -0
  127. package/src/web/client/features/live/index.ts +392 -0
  128. package/src/web/client/features/live/onboarding.ts +113 -0
  129. package/src/web/client/features/live/protocol.ts +122 -0
  130. package/src/web/client/features/live/sessions.ts +71 -0
  131. package/src/web/client/features/live/status.ts +68 -0
  132. package/src/web/client/features/live/strings.ts +252 -0
  133. package/src/web/client/features/live/timeline.ts +590 -0
  134. package/src/web/client/features/prompts/editor.ts +292 -0
  135. package/src/web/client/features/prompts/index.ts +346 -0
  136. package/src/web/client/features/prompts/strings.ts +136 -0
  137. package/src/web/client/features/prompts/view.ts +256 -0
  138. package/src/web/client/features/providers/index.ts +100 -0
  139. package/src/web/client/features/providers/strings.ts +23 -0
  140. package/src/web/client/features/settings/general.ts +28 -0
  141. package/src/web/client/features/settings/index.ts +82 -0
  142. package/src/web/client/features/settings/strings.ts +33 -0
  143. package/src/web/client/features/storage/index.ts +1 -0
  144. package/src/web/client/features/storage/strings.ts +45 -0
  145. package/src/web/client/features/storage/view.ts +154 -0
  146. package/src/web/client/features/usage/chart.ts +486 -0
  147. package/src/web/client/features/usage/index.ts +407 -0
  148. package/src/web/client/features/usage/labels.ts +46 -0
  149. package/src/web/client/features/usage/range.ts +36 -0
  150. package/src/web/client/features/usage/state.ts +37 -0
  151. package/src/web/client/features/usage/strings.ts +191 -0
  152. package/src/web/client/features/usage/tooltip.ts +86 -0
  153. package/src/web/client/features/usage/types.ts +96 -0
  154. package/src/web/client/features/worlds/index.ts +424 -0
  155. package/src/web/client/features/worlds/strings.ts +121 -0
  156. package/src/web/client/main.ts +273 -0
  157. package/src/web/client/shell/avatar.ts +233 -0
  158. package/src/web/client/shell/index.ts +549 -0
  159. package/src/web/client/shell/strings.ts +89 -0
  160. package/src/web/client/theme/handoff.ts +43 -0
  161. package/src/web/client/theme/palette.ts +109 -0
  162. package/src/web/client/theme/registry.ts +130 -0
  163. package/src/web/client/theme/storage.ts +72 -0
  164. package/src/web/client/theme/strings.ts +123 -0
  165. package/src/web/client/theme/studio.ts +366 -0
  166. package/src/web/client/ui/actions.ts +109 -0
  167. package/src/web/client/ui/data.ts +185 -0
  168. package/src/web/client/ui/dom.ts +23 -0
  169. package/src/web/client/ui/fields.ts +191 -0
  170. package/src/web/client/ui/format.ts +75 -0
  171. package/src/web/client/ui/icons.ts +183 -0
  172. package/src/web/client/ui/images.ts +91 -0
  173. package/src/web/client/ui/index.ts +89 -0
  174. package/src/web/client/ui/lamp.ts +125 -0
  175. package/src/web/client/ui/log.ts +98 -0
  176. package/src/web/client/ui/overlay.ts +184 -0
  177. package/src/web/client/ui/page.ts +8 -0
  178. package/src/web/client/ui/prompt-input.tsx +311 -0
  179. package/src/web/client/ui/sheet.ts +131 -0
  180. package/src/web/client/ui/strings.ts +71 -0
  181. package/src/web/console-pages.ts +367 -0
  182. package/src/web/files.ts +107 -0
  183. package/src/web/path-picker.ts +296 -0
  184. package/src/web/public/index.html +17 -0
  185. package/src/web/public/styles.css +1715 -0
  186. package/src/web/server.ts +1883 -0
  187. package/src/web/shared/client-panel.ts +603 -0
  188. package/src/web/shared/console-protocol.ts +560 -0
  189. package/src/web/shared/css.d.ts +1 -0
  190. package/src/web/shared/path-picker.ts +23 -0
  191. package/src/web/shared/theme.ts +256 -0
  192. package/src/web/theme-store.ts +37 -0
  193. package/src/world.ts +449 -0
  194. package/src/worlds/bilibili/ENV_PROMPT.md +5 -0
  195. package/src/worlds/bilibili/README.md +176 -0
  196. package/src/worlds/bilibili/audience-admission.ts +995 -0
  197. package/src/worlds/bilibili/client.ts +528 -0
  198. package/src/worlds/bilibili/coalescing-buffer.ts +125 -0
  199. package/src/worlds/bilibili/config.ts +185 -0
  200. package/src/worlds/bilibili/console/client.ts +202 -0
  201. package/src/worlds/bilibili/definition.ts +35 -0
  202. package/src/worlds/bilibili/gift-frame.ts +131 -0
  203. package/src/worlds/bilibili/normalize.ts +452 -0
  204. package/src/worlds/bilibili/overlay/announcement.ts +76 -0
  205. package/src/worlds/bilibili/overlay/assets.ts +82 -0
  206. package/src/worlds/bilibili/overlay/model.ts +494 -0
  207. package/src/worlds/bilibili/overlay/project.ts +202 -0
  208. package/src/worlds/bilibili/overlay/server.ts +286 -0
  209. package/src/worlds/bilibili/overlay/types.ts +217 -0
  210. package/src/worlds/bilibili/overlay/web/app.js +690 -0
  211. package/src/worlds/bilibili/overlay/web/editor.css +297 -0
  212. package/src/worlds/bilibili/overlay/web/editor.html +102 -0
  213. package/src/worlds/bilibili/overlay/web/editor.js +2116 -0
  214. package/src/worlds/bilibili/overlay/web/overlay.html +13 -0
  215. package/src/worlds/bilibili/overlay/web/styles.css +258 -0
  216. package/src/worlds/bilibili/protobuf.ts +123 -0
  217. package/src/worlds/bilibili/wire.ts +94 -0
  218. package/src/worlds/bilibili/world.ts +1389 -0
  219. package/src/worlds/console-fixture/ENV_PROMPT.md +1 -0
  220. package/src/worlds/console-fixture/console/client.ts +72 -0
  221. package/src/worlds/console-fixture/world.ts +73 -0
  222. package/src/worlds/index.ts +15 -0
  223. package/src/worlds/minecraft/ENV_PROMPT.md +32 -0
  224. package/src/worlds/minecraft/ENV_PROMPT_CAMERA.md +2 -0
  225. package/src/worlds/minecraft/LICENSE-mineflayer-pathfinder.txt +21 -0
  226. package/src/worlds/minecraft/README.md +917 -0
  227. package/src/worlds/minecraft/blueprint-plan.ts +915 -0
  228. package/src/worlds/minecraft/blueprint-registry.ts +566 -0
  229. package/src/worlds/minecraft/blueprint-repair.ts +236 -0
  230. package/src/worlds/minecraft/blueprint-resource.ts +272 -0
  231. package/src/worlds/minecraft/blueprint.ts +492 -0
  232. package/src/worlds/minecraft/body-lease.ts +340 -0
  233. package/src/worlds/minecraft/bridge.ts +749 -0
  234. package/src/worlds/minecraft/check.ts +651 -0
  235. package/src/worlds/minecraft/chests.ts +292 -0
  236. package/src/worlds/minecraft/client-launch.ts +272 -0
  237. package/src/worlds/minecraft/client-options.ts +85 -0
  238. package/src/worlds/minecraft/client-skins.ts +164 -0
  239. package/src/worlds/minecraft/client-window.ps1 +91 -0
  240. package/src/worlds/minecraft/client.ts +423 -0
  241. package/src/worlds/minecraft/combat-context.ts +109 -0
  242. package/src/worlds/minecraft/combat.ts +1552 -0
  243. package/src/worlds/minecraft/config.ts +499 -0
  244. package/src/worlds/minecraft/console/access.ts +218 -0
  245. package/src/worlds/minecraft/console/client.ts +244 -0
  246. package/src/worlds/minecraft/console/log.ts +110 -0
  247. package/src/worlds/minecraft/console/mount.ts +420 -0
  248. package/src/worlds/minecraft/console/skin.ts +257 -0
  249. package/src/worlds/minecraft/console/style.css +154 -0
  250. package/src/worlds/minecraft/console/world.ts +449 -0
  251. package/src/worlds/minecraft/deaths.ts +135 -0
  252. package/src/worlds/minecraft/definition.ts +18 -0
  253. package/src/worlds/minecraft/engine-child.ts +274 -0
  254. package/src/worlds/minecraft/engine-ipc.ts +98 -0
  255. package/src/worlds/minecraft/entity-facts.ts +132 -0
  256. package/src/worlds/minecraft/escape.ts +407 -0
  257. package/src/worlds/minecraft/executor.ts +14494 -0
  258. package/src/worlds/minecraft/explored.ts +156 -0
  259. package/src/worlds/minecraft/geometry.ts +205 -0
  260. package/src/worlds/minecraft/goal-plan.ts +647 -0
  261. package/src/worlds/minecraft/item-break.ts +126 -0
  262. package/src/worlds/minecraft/item-facts.ts +146 -0
  263. package/src/worlds/minecraft/item-pick.ts +67 -0
  264. package/src/worlds/minecraft/level-dat.ts +176 -0
  265. package/src/worlds/minecraft/log.ts +132 -0
  266. package/src/worlds/minecraft/mineflayer-fixes.ts +1180 -0
  267. package/src/worlds/minecraft/names.ts +377 -0
  268. package/src/worlds/minecraft/pathfinder-lib.d.ts +13 -0
  269. package/src/worlds/minecraft/pathfinder-perf.ts +796 -0
  270. package/src/worlds/minecraft/piglin.ts +49 -0
  271. package/src/worlds/minecraft/policy.ts +547 -0
  272. package/src/worlds/minecraft/precheck.ts +655 -0
  273. package/src/worlds/minecraft/proxy.ts +445 -0
  274. package/src/worlds/minecraft/ranged.ts +635 -0
  275. package/src/worlds/minecraft/readouts.ts +102 -0
  276. package/src/worlds/minecraft/round.ts +26 -0
  277. package/src/worlds/minecraft/search-observation.ts +92 -0
  278. package/src/worlds/minecraft/server-config.ts +467 -0
  279. package/src/worlds/minecraft/server.ts +658 -0
  280. package/src/worlds/minecraft/show.ts +84 -0
  281. package/src/worlds/minecraft/skills.ts +1763 -0
  282. package/src/worlds/minecraft/terrain.ts +1735 -0
  283. package/src/worlds/minecraft/window.ts +96 -0
  284. package/src/worlds/minecraft/works.ts +179 -0
  285. package/src/worlds/minecraft/world.ts +6438 -0
  286. package/src/worlds/qq/ENV_PROMPT.md +5 -0
  287. package/src/worlds/qq/config.ts +108 -0
  288. package/src/worlds/qq/console/client.ts +126 -0
  289. package/src/worlds/qq/console/events.ts +155 -0
  290. package/src/worlds/qq/console/gate.ts +232 -0
  291. package/src/worlds/qq/console/roster.ts +203 -0
  292. package/src/worlds/qq/console/style.css +65 -0
  293. package/src/worlds/qq/conversation.ts +28 -0
  294. package/src/worlds/qq/definition.ts +62 -0
  295. package/src/worlds/qq/driver.ts +375 -0
  296. package/src/worlds/qq/history-tools.ts +209 -0
  297. package/src/worlds/qq/normalize.ts +323 -0
  298. package/src/worlds/qq/qface-map.ts +282 -0
  299. package/src/worlds/qq/vision-prompt.ts +27 -0
  300. package/src/worlds/qq/vision.ts +642 -0
  301. package/src/worlds/qq/vlm.ts +142 -0
  302. package/src/worlds/qq/world.ts +1503 -0
  303. package/src/worlds/terminal/ENV_PROMPT.md +11 -0
  304. package/src/worlds/terminal/config.ts +9 -0
  305. package/src/worlds/terminal/definition.ts +14 -0
  306. package/src/worlds/terminal/world.ts +700 -0
  307. package/src/worlds/websearch/ENV_PROMPT.md +1 -0
  308. package/src/worlds/websearch/brave-client.ts +261 -0
  309. package/src/worlds/websearch/config.ts +75 -0
  310. package/src/worlds/websearch/definition.ts +20 -0
  311. package/src/worlds/websearch/world.ts +171 -0
@@ -0,0 +1,366 @@
1
+ /** 管理当前颜色方案、明暗模式、自定义方案与未保存预览。首屏由 applyStoredTheme 同步应用;订阅者接收变化,图表据此重绘。 */
2
+
3
+ import { toDisposable, type Disposable } from '../../shared/client-panel.ts';
4
+ import {
5
+ THEME_MODES,
6
+ builtinSchemes,
7
+ clonePalette,
8
+ cloneScheme,
9
+ defaultStoredTheme,
10
+ fallbackPalette,
11
+ normalizePalette,
12
+ resolveDefaultSchemeId,
13
+ schemeSwatches,
14
+ schemeText,
15
+ type InjectedTheme,
16
+ type StoredTheme,
17
+ type ThemeAppearance,
18
+ type ThemeMode,
19
+ type ThemePalette,
20
+ type ThemeScheme,
21
+ } from './registry.ts';
22
+ import { applyPalette } from './palette.ts';
23
+ import { readInjectedTheme, readLegacyLocalTheme, saveDeploymentTheme } from './storage.ts';
24
+ import { S } from './strings.ts';
25
+
26
+ /** 方案的元信息(不含调色板本身)。 */
27
+ export interface ThemeSchemeInfo {
28
+ id: string;
29
+ name: string;
30
+ note: string;
31
+ builtin: boolean;
32
+ custom: boolean;
33
+ }
34
+
35
+ /** 方案卡:元信息 + 一条四格色带。 */
36
+ export interface ThemeSchemeCard extends ThemeSchemeInfo {
37
+ swatches: string[];
38
+ }
39
+
40
+ export interface ThemeSnapshot {
41
+ selectedId: string;
42
+ /** 用户选的挡位(可能是 `system`) */
43
+ mode: ThemeMode;
44
+ /** 解析之后真正生效的明暗 */
45
+ appearance: ThemeAppearance;
46
+ scheme: ThemeSchemeInfo;
47
+ schemes: ThemeSchemeCard[];
48
+ /** 当前生效的调色板;预览期间是那份草稿。 */
49
+ palette: ThemePalette;
50
+ /** 最近一次回写部署的失败原因;成功后为 null。当前选择在本页仍然生效。 */
51
+ saveError: string | null;
52
+ }
53
+
54
+ export interface ThemeChange {
55
+ readonly snapshot: ThemeSnapshot;
56
+ /** 是否为未保存的预览变化;订阅者可据此决定是否重绘。 */
57
+ readonly preview: boolean;
58
+ }
59
+
60
+ export type ThemeChangeListener = (change: ThemeChange) => void;
61
+
62
+ /** `matchMedia('(prefers-color-scheme: dark)')` 的最小面。 */
63
+ export interface MediaQueryLike {
64
+ readonly matches: boolean;
65
+ addEventListener(type: 'change', listener: () => void): void;
66
+ removeEventListener(type: 'change', listener: () => void): void;
67
+ }
68
+
69
+ export interface ThemeStudioDeps {
70
+ doc: Document;
71
+ /** 不给则读服务端注入的那段 JSON。 */
72
+ injected?: InjectedTheme;
73
+ /** 部署没有记录时可迁移的本机旧记录;不给则读 localStorage。 */
74
+ legacy?: StoredTheme | null;
75
+ /** 回写部署的主题记录;不给则 POST `/api/theme`。 */
76
+ save?(state: StoredTheme): Promise<void>;
77
+ /** 不给则取 `doc.defaultView.matchMedia(...)`;显式给 `null` = 系统挡位当浅色。 */
78
+ media?: MediaQueryLike | null;
79
+ /** 订阅方回调里抛的错落这儿。默认吞掉——一张图重画失败不该拖垮换肤本身。 */
80
+ onError?(err: unknown): void;
81
+ }
82
+
83
+ /** 系统深色偏好。拿不到(老浏览器、假文档)就是 `null`,一律当浅色。 */
84
+ function systemDarkQuery(doc: Document): MediaQueryLike | null {
85
+ try {
86
+ return doc.defaultView?.matchMedia('(prefers-color-scheme: dark)') ?? null;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ export class ThemeStudio {
93
+ private readonly doc: Document;
94
+ private readonly save: (state: StoredTheme) => Promise<void>;
95
+ private readonly media: MediaQueryLike | null;
96
+ private readonly onError: (err: unknown) => void;
97
+ /** 记录里没有可用选择时选哪个方案;删掉自定义方案后也回到它。 */
98
+ private readonly defaultSchemeId: string;
99
+ private readonly listeners = new Set<ThemeChangeListener>();
100
+ private state: StoredTheme;
101
+ private previewPalette: ThemePalette | null = null;
102
+ private saveError: string | null = null;
103
+ private closed = false;
104
+
105
+ /** 系统挡位下跟着系统走。方法引用存下来,`dispose()` 才摘得掉。 */
106
+ private readonly onMediaChange = (): void => {
107
+ if (this.state.mode === 'system') this.apply();
108
+ };
109
+
110
+ constructor(deps: ThemeStudioDeps) {
111
+ this.doc = deps.doc;
112
+ this.save = deps.save ?? saveDeploymentTheme;
113
+ this.media = deps.media === undefined ? systemDarkQuery(deps.doc) : deps.media;
114
+ this.onError = deps.onError ?? ((): void => {});
115
+ const injected = deps.injected ?? readInjectedTheme(deps.doc);
116
+ this.defaultSchemeId = resolveDefaultSchemeId(injected.defaultScheme);
117
+ if (injected.theme) {
118
+ this.state = injected.theme;
119
+ } else {
120
+ // 部署还没有记录:把这台机器上的旧记录交上去,此后它归部署。
121
+ const legacy = deps.legacy === undefined ? readLegacyLocalTheme(deps.doc) : deps.legacy;
122
+ this.state = legacy ?? { ...defaultStoredTheme(), selectedId: this.defaultSchemeId };
123
+ if (legacy) this.persist();
124
+ }
125
+ this.media?.addEventListener('change', this.onMediaChange);
126
+ }
127
+
128
+ // ── 读 ──────────────────────────────────────────────────────────────
129
+
130
+ /** 内置在前、自定义在后。每次现算,调用方拿到的是副本。 */
131
+ schemes(): ThemeScheme[] {
132
+ return [...builtinSchemes(), ...this.state.custom.map((s) => ({ ...cloneScheme(s), custom: true }))];
133
+ }
134
+
135
+ /** 当前方案。记录指向的 id 不存在(方案在别处被删了)就退回部署默认方案。 */
136
+ currentScheme(): ThemeScheme {
137
+ const all = this.schemes();
138
+ return all.find((s) => s.id === this.state.selectedId)
139
+ ?? all.find((s) => s.id === this.defaultSchemeId)
140
+ ?? all[0];
141
+ }
142
+
143
+ get appearance(): ThemeAppearance {
144
+ if (this.state.mode !== 'system') return this.state.mode;
145
+ return this.media?.matches ? 'dark' : 'light';
146
+ }
147
+
148
+ get mode(): ThemeMode {
149
+ return this.state.mode;
150
+ }
151
+
152
+ snapshot(): ThemeSnapshot {
153
+ const scheme = this.currentScheme();
154
+ const appearance = this.appearance;
155
+ return {
156
+ selectedId: scheme.id,
157
+ mode: this.state.mode,
158
+ appearance,
159
+ scheme: info(scheme),
160
+ schemes: this.schemes().map((item) => ({ ...info(item), swatches: schemeSwatches(item, appearance) })),
161
+ palette: clonePalette(this.previewPalette ?? scheme.palettes[appearance]),
162
+ saveError: this.saveError,
163
+ };
164
+ }
165
+
166
+ // ── 写 ──────────────────────────────────────────────────────────────
167
+
168
+ /** 应用保存的选择;首屏传 notify=false,不发变化通知。 */
169
+ apply(notify = true): void {
170
+ this.previewPalette = null;
171
+ const scheme = this.currentScheme();
172
+ // 选中的方案没了(记录里指向一个已删的自定义方案)→ 把纠正结果落回部署
173
+ if (scheme.id !== this.state.selectedId) {
174
+ this.state.selectedId = scheme.id;
175
+ this.persist();
176
+ }
177
+ const appearance = this.appearance;
178
+ applyPalette(
179
+ this.doc,
180
+ normalizePalette(scheme.palettes[appearance], fallbackPalette(appearance)),
181
+ { appearance, schemeId: scheme.id },
182
+ );
183
+ if (notify) this.emit(false);
184
+ }
185
+
186
+ select(id: string): boolean {
187
+ if (!this.schemes().some((s) => s.id === id)) return false;
188
+ this.state.selectedId = id;
189
+ this.persist();
190
+ this.apply();
191
+ return true;
192
+ }
193
+
194
+ setMode(mode: ThemeMode): boolean {
195
+ if (!THEME_MODES.includes(mode)) return false;
196
+ this.state.mode = mode;
197
+ this.persist();
198
+ this.apply();
199
+ return true;
200
+ }
201
+
202
+ /** 试色:刷到文档但**不回写部署**。离开这一页或调 `resetPreview()` 就没了。 */
203
+ preview(palette: ThemePalette): void {
204
+ const scheme = this.currentScheme();
205
+ const appearance = this.appearance;
206
+ this.previewPalette = normalizePalette(palette, scheme.palettes[appearance]);
207
+ applyPalette(this.doc, this.previewPalette, { appearance, schemeId: scheme.id });
208
+ this.emit(true);
209
+ }
210
+
211
+ resetPreview(): void {
212
+ this.apply();
213
+ }
214
+
215
+ /**
216
+ * 另存为新的自定义方案并切过去。
217
+ *
218
+ * **只覆盖当前明暗变体**,另一半从源方案原样拷过来:用户在浅色下调完色,黑夜
219
+ * 变体不该跟着变成一份浅色配色的暗抄本。
220
+ */
221
+ saveAs(name: string | null | undefined, palette: ThemePalette): string {
222
+ const source = this.currentScheme();
223
+ const appearance = this.appearance;
224
+ const copy: ThemeScheme = {
225
+ id: `custom-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
226
+ name: String(name ?? '').trim().slice(0, 40) || S.copyOf(source.name),
227
+ note: S.customNote,
228
+ palettes: cloneScheme(source).palettes,
229
+ custom: true,
230
+ };
231
+ copy.palettes[appearance] = normalizePalette(palette, source.palettes[appearance]);
232
+ this.state.custom.push(copy);
233
+ this.state.selectedId = copy.id;
234
+ this.persist();
235
+ this.apply();
236
+ return copy.id;
237
+ }
238
+
239
+ /** 覆盖当前自定义方案。选中的是内置方案时返回 `false`(内置不可写)。 */
240
+ saveCurrent(name: string | null | undefined, palette: ThemePalette): boolean {
241
+ const scheme = this.state.custom.find((s) => s.id === this.state.selectedId);
242
+ if (!scheme) return false;
243
+ const appearance = this.appearance;
244
+ scheme.name = String(name ?? '').trim().slice(0, 40) || scheme.name;
245
+ scheme.palettes[appearance] = normalizePalette(palette, scheme.palettes[appearance]);
246
+ this.persist();
247
+ this.apply();
248
+ return true;
249
+ }
250
+
251
+ /** 删掉当前自定义方案并切回默认方案。选中内置方案时返回 `false`。 */
252
+ removeCurrent(): boolean {
253
+ const before = this.state.custom.length;
254
+ this.state.custom = this.state.custom.filter((s) => s.id !== this.state.selectedId);
255
+ if (this.state.custom.length === before) return false;
256
+ this.state.selectedId = this.defaultSchemeId;
257
+ this.persist();
258
+ this.apply();
259
+ return true;
260
+ }
261
+
262
+ // ── 订阅 ────────────────────────────────────────────────────────────
263
+
264
+ /**
265
+ * 订阅主题变化。返回的 `Disposable` 一 `dispose()` 就退订——页面把它交给自己的
266
+ * `lifecycle` 即可,不必记得在卸载时手动摘。
267
+ *
268
+ * 重复登记同一个函数只算一次(`Set` 语义);回调里再登记/退订不影响本轮派发。
269
+ */
270
+ onChange(listener: ThemeChangeListener): Disposable {
271
+ this.listeners.add(listener);
272
+ return toDisposable(() => {
273
+ this.listeners.delete(listener);
274
+ });
275
+ }
276
+
277
+ /** 摘掉系统深色监听并清空订阅。进程级实例通常活到页面关闭,测试与热重载用。 */
278
+ dispose(): void {
279
+ if (this.closed) return;
280
+ this.closed = true;
281
+ this.media?.removeEventListener('change', this.onMediaChange);
282
+ this.listeners.clear();
283
+ }
284
+
285
+ /** 回写部署。失败记在快照里,由外观页呈现;当前选择在本页仍然生效。 */
286
+ private persist(): void {
287
+ const sending = JSON.parse(JSON.stringify(this.state)) as StoredTheme;
288
+ void this.save(sending).then(
289
+ () => {
290
+ if (this.saveError === null) return;
291
+ this.saveError = null;
292
+ this.emit(false);
293
+ },
294
+ (err: unknown) => {
295
+ this.saveError = err instanceof Error ? err.message : String(err);
296
+ this.emit(false);
297
+ },
298
+ );
299
+ }
300
+
301
+ /** 逐个通知订阅者;单个回调失败不阻止其他回调。 */
302
+ private emit(preview: boolean): void {
303
+ if (this.listeners.size === 0) return;
304
+ const change: ThemeChange = { snapshot: this.snapshot(), preview };
305
+ for (const listener of [...this.listeners]) {
306
+ try {
307
+ listener(change);
308
+ } catch (err) {
309
+ this.onError(err);
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ function info(scheme: ThemeScheme): ThemeSchemeInfo {
316
+ const text = schemeText(scheme);
317
+ return {
318
+ id: scheme.id,
319
+ name: text.name,
320
+ note: text.note,
321
+ builtin: !!scheme.builtin,
322
+ custom: !!scheme.custom,
323
+ };
324
+ }
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // 进程级实例
328
+ // ---------------------------------------------------------------------------
329
+
330
+ let shared: ThemeStudio | null = null;
331
+
332
+ /**
333
+ * 取(必要时新建)那个唯一的实例。
334
+ *
335
+ * `deps` 只在**第一次**(真正新建的那次)生效;之后再传会被忽略,因为半路换回写
336
+ * 目标或换文档只会让两份状态对不上。测试要换一套 deps,先 `disposeThemeStudio()`。
337
+ */
338
+ export function getThemeStudio(deps?: Partial<ThemeStudioDeps>): ThemeStudio {
339
+ if (shared) return shared;
340
+ const doc = deps?.doc ?? (typeof document === 'undefined' ? null : document);
341
+ if (!doc) throw new Error('主题需要一个 Document');
342
+ shared = new ThemeStudio({ ...deps, doc });
343
+ return shared;
344
+ }
345
+
346
+ /**
347
+ * **首屏第一件事**:读服务端注入的部署主题记录、把颜色刷到 `documentElement`。
348
+ *
349
+ * 同步执行、不等任何路由,所以内核入口可以在第一行直接调它。部署还没有记录且这台
350
+ * 机器存过旧记录时,构造函数会把旧记录回写一次。
351
+ * 返回当前快照(想据此做点别的判断时用),不需要就丢掉。
352
+ */
353
+ export function applyStoredTheme(
354
+ doc?: Document,
355
+ deps?: Omit<Partial<ThemeStudioDeps>, 'doc'>,
356
+ ): ThemeSnapshot {
357
+ const studio = getThemeStudio(doc ? { ...deps, doc } : deps);
358
+ studio.apply(false);
359
+ return studio.snapshot();
360
+ }
361
+
362
+ /** 丢掉进程级实例(测试与热重载)。生产代码里没有它的用处。 */
363
+ export function disposeThemeStudio(): void {
364
+ shared?.dispose();
365
+ shared = null;
366
+ }
@@ -0,0 +1,109 @@
1
+ /** 操作期间禁用控件,以及复制文本并报告结果。 */
2
+
3
+ import type {
4
+ ConsoleCopyOptions,
5
+ ConsoleDisablable,
6
+ Disposable,
7
+ } from '../../shared/client-panel.ts';
8
+ import { toDisposable } from '../../shared/client-panel.ts';
9
+ import { h } from './dom.ts';
10
+ import { button } from './sheet.ts';
11
+ import type { OverlayEnv } from './overlay.ts';
12
+ import { drawer } from './overlay.ts';
13
+ import { S } from './strings.ts';
14
+
15
+ /** 临时禁用控件;dispose 恢复各自原值。重复控件只记录第一次的值。 */
16
+ export function disable(els: readonly ConsoleDisablable[]): Disposable {
17
+ const saved: { el: { disabled: boolean }; was: boolean }[] = [];
18
+ const seen = new Set<object>();
19
+ for (const el of els) {
20
+ if (!el || seen.has(el)) continue;
21
+ seen.add(el);
22
+ saved.push({ el, was: el.disabled });
23
+ el.disabled = true;
24
+ }
25
+ // toDisposable 自带幂等标志,重复 dispose 不会二次写回
26
+ return toDisposable(() => {
27
+ for (const s of saved) s.el.disabled = s.was;
28
+ });
29
+ }
30
+
31
+ /**
32
+ * 剪贴板从哪儿取。
33
+ *
34
+ * 先问文档自己的 `defaultView`、再退到全局:这个模块不许在顶层引用 `window`
35
+ * (测试跑在 node 里,未来还可能是离屏文档 / iframe——那时候 `globalThis` 上的
36
+ * 那个根本不是这份文档的窗口)。
37
+ */
38
+ function navigatorOf(doc: Document): Navigator | null {
39
+ return doc.defaultView?.navigator ?? (globalThis as { navigator?: Navigator }).navigator ?? null;
40
+ }
41
+
42
+ /** Clipboard API 不可用或失败时,使用可聚焦的离屏 textarea 与 execCommand 复制。 */
43
+ function execCopy(env: OverlayEnv, value: string): boolean {
44
+ const doc = env.doc as Document & { execCommand?: (cmd: string) => boolean };
45
+ if (typeof doc.execCommand !== 'function') return false;
46
+ const ta = h(doc, 'textarea');
47
+ ta.value = value;
48
+ // 必须真的在 DOM 里、且可聚焦,才选得中;挪到屏外并透明化是为了不闪一下
49
+ ta.setAttribute('style', 'position:fixed;top:0;left:-9999px;opacity:0');
50
+ env.host.appendChild(ta);
51
+ try {
52
+ ta.focus();
53
+ ta.select();
54
+ return doc.execCommand('copy') === true;
55
+ } catch {
56
+ return false;
57
+ } finally {
58
+ ta.remove();
59
+ }
60
+ }
61
+
62
+ /** 两条路依次试。都不通给 false,由调用方去出声。 */
63
+ async function writeClipboard(env: OverlayEnv, value: string): Promise<boolean> {
64
+ const nav = navigatorOf(env.doc);
65
+ const clip = nav?.clipboard as Clipboard | undefined;
66
+ if (typeof clip?.writeText === 'function') {
67
+ try {
68
+ await clip.writeText(value);
69
+ return true;
70
+ } catch {
71
+ // 权限被拒 / 文档没聚焦:掉到降级路线,别在这儿就放弃
72
+ }
73
+ }
74
+ return execCopy(env, value);
75
+ }
76
+
77
+ /** 函数形式的文本在点击时读取;show 使用所属 UI 实例的单条 toast。 */
78
+ export function copyButton(
79
+ env: OverlayEnv,
80
+ show: (text: string, tone?: 'ok' | 'bad') => void,
81
+ text: string | (() => string),
82
+ opts?: ConsoleCopyOptions,
83
+ ): HTMLButtonElement {
84
+ const o = opts ?? {};
85
+ const label = o.label ?? S.copy;
86
+ const run = async (): Promise<void> => {
87
+ let value: string;
88
+ try {
89
+ value = typeof text === 'function' ? text() : text;
90
+ } catch {
91
+ // 惰性取值自己炸了(那串值背后的对象已经没了):照样出声,别静默
92
+ show(S.copyNothing, 'bad');
93
+ return;
94
+ }
95
+ if (await writeClipboard(env, value)) {
96
+ show(o.okText ?? S.copied, 'ok');
97
+ return;
98
+ }
99
+ // 两种复制方式均失败时展示文本,供手动选取。
100
+ show(S.copyFailed, 'bad');
101
+ drawer(env, label, value);
102
+ };
103
+
104
+ return button(env.doc, env.signal, label, {
105
+ size: o.size ?? 'sm',
106
+ variant: o.variant,
107
+ onClick: () => void run(),
108
+ });
109
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * 数据表、键值表与概览读数复用现存 .tablewrap、table.data、table.kvtable、.statgrid 样式。table 返回 clear/addRow 句柄供增量刷新,单元格使用 textContent,不把名称或正文解析为 HTML。
3
+ */
4
+
5
+ import type {
6
+ ConsoleCell,
7
+ ConsoleKvRow,
8
+ ConsoleProgress,
9
+ ConsoleProgressOptions,
10
+ ConsoleStat,
11
+ ConsoleTable,
12
+ ConsoleTableOptions,
13
+ } from '../../shared/client-panel.ts';
14
+ import { h } from './dom.ts';
15
+ import { consoleFormat } from './format.ts';
16
+
17
+ /** 一格 → `<td>`。 */
18
+ function cell(doc: Document, spec: ConsoleCell): HTMLTableCellElement {
19
+ const td = h(doc, 'td');
20
+ if (spec == null) return td;
21
+ if (typeof spec === 'string' || typeof spec === 'number') {
22
+ td.textContent = String(spec);
23
+ return td;
24
+ }
25
+ // 节点直接放进去(pill / 按钮 / 链接这类)
26
+ if (!isCellSpec(spec)) {
27
+ td.appendChild(spec);
28
+ return td;
29
+ }
30
+ if (spec.cls) td.className = spec.cls;
31
+ if (spec.el) td.appendChild(spec.el);
32
+ else if (spec.text != null) td.textContent = String(spec.text);
33
+ return td;
34
+ }
35
+
36
+ /**
37
+ * 分辨"描述对象"与"现成节点"。
38
+ *
39
+ * 不用 `instanceof HTMLElement`:这个模块在 node 里被测(没有那个全局),而且未来若
40
+ * 换到离屏文档/iframe,跨 realm 的 `instanceof` 本来就会假阴性。改按结构判断——
41
+ * 描述对象是纯字面量,不会有 `nodeType`。
42
+ */
43
+ function isCellSpec(
44
+ v: HTMLElement | { text?: string | number | null; el?: HTMLElement; cls?: string },
45
+ ): v is { text?: string | number | null; el?: HTMLElement; cls?: string } {
46
+ return (v as { nodeType?: number }).nodeType === undefined;
47
+ }
48
+
49
+ /** 数据表。 */
50
+ export function table(doc: Document, opts?: ConsoleTableOptions): ConsoleTable {
51
+ const head = opts?.head ?? [];
52
+ const el = h(doc, 'div', 'tablewrap');
53
+ // 外框限高才会滚,表头的 position:sticky 也才有意义;不给就让它自然铺开
54
+ if (opts?.maxHeight) el.setAttribute('style', 'max-height:' + opts.maxHeight + ';overflow:auto');
55
+ const tbl = h(doc, 'table', 'data');
56
+ if (head.length) {
57
+ const thead = h(doc, 'thead');
58
+ const tr = h(doc, 'tr');
59
+ for (const t of head) tr.appendChild(h(doc, 'th', null, t));
60
+ thead.appendChild(tr);
61
+ tbl.appendChild(thead);
62
+ }
63
+ const body = h(doc, 'tbody');
64
+ tbl.appendChild(body);
65
+ el.appendChild(tbl);
66
+
67
+ return {
68
+ el,
69
+ body,
70
+ addRow(cells) {
71
+ const tr = h(doc, 'tr');
72
+ for (const c of cells) tr.appendChild(cell(doc, c));
73
+ body.appendChild(tr);
74
+ return tr;
75
+ },
76
+ clear(empty) {
77
+ while (body.children.length) body.children[0].remove();
78
+ if (empty == null) return;
79
+ const tr = h(doc, 'tr');
80
+ const td = h(doc, 'td', 'placeholder');
81
+ td.colSpan = Math.max(1, head.length);
82
+ td.textContent = empty;
83
+ tr.appendChild(td);
84
+ body.appendChild(tr);
85
+ },
86
+ };
87
+ }
88
+
89
+ /** 键值表无表头;tr 直接挂在 table 下。 */
90
+ export function kv(doc: Document, rows: readonly ConsoleKvRow[]): HTMLTableElement {
91
+ const el = h(doc, 'table', 'kvtable');
92
+ for (const row of rows) {
93
+ const tr = h(doc, 'tr');
94
+ tr.appendChild(h(doc, 'td', null, row.k));
95
+ const td = h(doc, 'td');
96
+ const v = row.v;
97
+ if (v == null) td.textContent = '';
98
+ else if (typeof v === 'string' || typeof v === 'number') td.textContent = String(v);
99
+ else td.appendChild(v);
100
+ tr.appendChild(td);
101
+ el.appendChild(tr);
102
+ }
103
+ return el;
104
+ }
105
+
106
+ /** 概览读数。`.k` 小标题 + `.v` 大数字(`unit` 落成 `.v` 里的 `<small>`)。 */
107
+ export function stat(doc: Document, item: ConsoleStat): HTMLDivElement {
108
+ const el = h(doc, 'div', item.accent ? 'stat accent' : 'stat');
109
+ el.appendChild(h(doc, 'div', 'k', item.k));
110
+ const v = h(doc, 'div', 'v');
111
+ if (typeof item.v === 'string' || typeof item.v === 'number') v.textContent = String(item.v);
112
+ else v.appendChild(item.v);
113
+ if (item.unit) v.appendChild(h(doc, 'small', null, item.unit));
114
+ el.appendChild(v);
115
+ return el;
116
+ }
117
+
118
+ /** 读数网格。`items` 可以不给(先建空网格,之后自己往里 append)。 */
119
+ export function statgrid(doc: Document, items?: readonly ConsoleStat[]): HTMLDivElement {
120
+ const el = h(doc, 'div', 'statgrid');
121
+ for (const it of items ?? []) el.appendChild(stat(doc, it));
122
+ return el;
123
+ }
124
+
125
+ /**
126
+ * 满值归一。0 与非有限数一律当 1——除数是 0 的话读数会印成 `NaN%`,
127
+ * 而"总量还不知道"恰恰是进度条最常见的初始状态。
128
+ */
129
+ function normMax(m: number | undefined): number {
130
+ return typeof m === 'number' && Number.isFinite(m) && m > 0 ? m : 1;
131
+ }
132
+
133
+ /** 填充比例,夹在 0..1。读数用的是原始 value,只有条的宽度被夹。 */
134
+ function fillRatio(value: number, max: number): number {
135
+ if (!Number.isFinite(value)) return 0;
136
+ return Math.min(1, Math.max(0, value / max));
137
+ }
138
+
139
+ /** 占比条通过 role=progressbar 与 aria-value* 提供无障碍读数。 */
140
+ export function progress(doc: Document, opts?: ConsoleProgressOptions): ConsoleProgress {
141
+ const o = opts ?? {};
142
+ const cls =
143
+ o.tone === 'warn' ? 'progress warnc' : o.tone === 'accent' ? 'progress dreamc' : 'progress';
144
+ const el = h(doc, 'div', cls);
145
+ el.setAttribute('role', 'progressbar');
146
+ const head = h(doc, 'div', 'progresshead');
147
+ const label = h(doc, 'span', 'progresslabel', o.label ?? '');
148
+ const num = h(doc, 'span', 'progressnum');
149
+ head.append(label, num);
150
+ const track = h(doc, 'div', 'progresstrack');
151
+ const fill = h(doc, 'div', 'progressfill');
152
+ track.appendChild(fill);
153
+ el.append(head, track);
154
+
155
+ let max = normMax(o.max);
156
+ let value = Number.isFinite(o.value) ? (o.value as number) : 0;
157
+ const fmt = o.format ?? ((v: number, m: number): string => consoleFormat.percent(v / m));
158
+
159
+ const paint = (): void => {
160
+ fill.setAttribute('style', 'width:' + (fillRatio(value, max) * 100).toFixed(1) + '%');
161
+ num.textContent = fmt(value, max);
162
+ el.setAttribute('aria-valuemin', '0');
163
+ el.setAttribute('aria-valuemax', String(max));
164
+ el.setAttribute('aria-valuenow', String(value));
165
+ };
166
+ paint();
167
+
168
+ return {
169
+ el,
170
+ get value(): number {
171
+ return value;
172
+ },
173
+ get max(): number {
174
+ return max;
175
+ },
176
+ setValue(v: number, m?: number): void {
177
+ if (m !== undefined) max = normMax(m);
178
+ value = Number.isFinite(v) ? v : 0;
179
+ paint();
180
+ },
181
+ setLabel(text: string): void {
182
+ label.textContent = text;
183
+ },
184
+ };
185
+ }
@@ -0,0 +1,23 @@
1
+ /** DOM 节点构造与 HTML 转义;调用方提供 document,节点与字符串分别处理。 */
2
+
3
+ /** `h(doc, 'div', 'sheet', '文本')` */
4
+ export function h<K extends keyof HTMLElementTagNameMap>(
5
+ doc: Document,
6
+ tag: K,
7
+ cls?: string | null,
8
+ text?: string | null,
9
+ ): HTMLElementTagNameMap[K] {
10
+ const el = doc.createElement(tag);
11
+ if (cls) el.className = cls;
12
+ if (text != null) el.textContent = text;
13
+ return el;
14
+ }
15
+
16
+ /** HTML 转义。只转 `&<>"'` 五个字符。 */
17
+ export function esc(s: unknown): string {
18
+ return String(s ?? '').replace(
19
+ /[&<>"']/g,
20
+ (c) =>
21
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] as string,
22
+ );
23
+ }