dsh-tiddlywiki 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.
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.bundle.js +1417 -0
- package/lib/client.js +1427 -0
- package/lib/index.js +2153 -0
- package/lib/index.js.map +1 -0
- package/package.json +63 -0
- package/src/client/editor-popup.ts +121 -0
- package/src/client/index.ts +76 -0
- package/src/client/note-widget.ts +210 -0
- package/src/client/panel.ts +304 -0
- package/src/client/settings-page.ts +397 -0
- package/src/client/sidebar-entry.ts +148 -0
- package/src/client/state.ts +39 -0
- package/src/client/styles.ts +235 -0
- package/src/client/toast.ts +22 -0
- package/src/host/admin.ts +408 -0
- package/src/host/config.ts +86 -0
- package/src/host/git.ts +218 -0
- package/src/host/routes.ts +233 -0
- package/src/host/seed-notes.ts +62 -0
- package/src/host/tools.ts +254 -0
- package/src/host/tw-api.ts +157 -0
- package/src/host/wiki.ts +287 -0
- package/src/index.ts +331 -0
- package/src/sdk.ts +198 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client stylesheet injection (design doc D5 — pure DOM, no React).
|
|
3
|
+
* All rules are `dsh-tw-*` scoped; the tag is stable per plugin so the HMR
|
|
4
|
+
* driver can identify it on rebuild.
|
|
5
|
+
*
|
|
6
|
+
* Theme: uses the live `--dsw-alias-*` design tokens (same family as the
|
|
7
|
+
* shutdown launcher) with fallbacks, so it follows light/dark automatically.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-tiddlywiki/client/styles
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const STYLE_ID = 'dsh-tiddlywiki-styles'
|
|
13
|
+
|
|
14
|
+
const CSS_TEXT = `
|
|
15
|
+
/* ── sidebar entry ───────────────────────────────────────────── */
|
|
16
|
+
.dsh-tw-entry {
|
|
17
|
+
display: flex; align-items: center; gap: 8px; position: relative;
|
|
18
|
+
width: calc(100% - 8px); margin: 2px 4px; padding: 6px 10px;
|
|
19
|
+
border: none; border-radius: 8px; background: transparent;
|
|
20
|
+
color: var(--dsw-alias-label-secondary, inherit); font: inherit; font-size: 13px;
|
|
21
|
+
cursor: pointer; text-align: left;
|
|
22
|
+
}
|
|
23
|
+
.dsh-tw-entry:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); color: var(--dsw-alias-label-primary, inherit); }
|
|
24
|
+
.dsh-tw-entry[data-active="true"] { background: var(--dsw-alias-interactive-bg-active, rgba(128,128,128,.18)); color: var(--dsw-alias-label-primary, inherit); font-weight: 500; }
|
|
25
|
+
.dsh-tw-entry svg { flex: none; }
|
|
26
|
+
.dsh-tw-entry .dsh-tw-entry-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
27
|
+
|
|
28
|
+
/* ── center-column panel (fixed overlay, JS-pinned to the column rect) ── */
|
|
29
|
+
.dsh-tw-view {
|
|
30
|
+
display: none; flex-direction: column; min-height: 0; box-sizing: border-box;
|
|
31
|
+
background: var(--dsw-alias-bg-layer-1, var(--dsw-bg, #fff));
|
|
32
|
+
}
|
|
33
|
+
html[data-dsh-tw-active] .dsh-tw-view { display: flex; }
|
|
34
|
+
/* Hide the conversation content the panel overlays (all three column gens). */
|
|
35
|
+
html[data-dsh-tw-active] [data-pane="conversation"] > :not([data-dsh-tw-view]),
|
|
36
|
+
html[data-dsh-tw-active] [class*="centerCol"] > :not([data-dsh-tw-view]),
|
|
37
|
+
html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view]) { display: none !important; }
|
|
38
|
+
|
|
39
|
+
.dsh-tw-panel-bar {
|
|
40
|
+
display: flex; align-items: center; gap: 8px; padding: 6px 10px;
|
|
41
|
+
border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.18));
|
|
42
|
+
background: var(--dsw-alias-bg-layer-1, transparent);
|
|
43
|
+
font-size: 12px; color: var(--dsw-alias-label-secondary, inherit);
|
|
44
|
+
flex: none;
|
|
45
|
+
}
|
|
46
|
+
.dsh-tw-panel-bar button {
|
|
47
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
|
|
48
|
+
background: transparent; color: inherit; font: inherit; font-size: 12px;
|
|
49
|
+
padding: 2px 8px; border-radius: 6px; cursor: pointer;
|
|
50
|
+
}
|
|
51
|
+
.dsh-tw-panel-bar button:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
|
|
52
|
+
.dsh-tw-panel-title { font-weight: 600; margin-right: auto; }
|
|
53
|
+
.dsh-tw-status-chip { font-variant-numeric: tabular-nums; }
|
|
54
|
+
.dsh-tw-status-chip[data-state="running"] { color: var(--dsw-alias-state-success-primary, #3eaa5f); }
|
|
55
|
+
.dsh-tw-status-chip[data-state="starting"] { color: var(--dsw-alias-state-warning-primary, #d9822b); }
|
|
56
|
+
.dsh-tw-status-chip[data-state="failed"], .dsh-tw-status-chip[data-state="stopped"] { color: var(--dsw-alias-state-error-primary, #d13b3b); }
|
|
57
|
+
|
|
58
|
+
.dsh-tw-panel-frame { flex: 1; min-height: 0; border: 0; width: 100%; display: block; background: #fff; }
|
|
59
|
+
/* [hidden] must beat the author display rules above (UA hidden is overridden). */
|
|
60
|
+
.dsh-tw-panel-frame[hidden],
|
|
61
|
+
.dsh-tw-panel-error[hidden] { display: none !important; }
|
|
62
|
+
|
|
63
|
+
.dsh-tw-panel-error {
|
|
64
|
+
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
65
|
+
gap: 10px; color: var(--dsw-alias-label-secondary, #666); font-size: 13px; text-align: center; padding: 20px;
|
|
66
|
+
}
|
|
67
|
+
.dsh-tw-panel-error button {
|
|
68
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25)); background: transparent;
|
|
69
|
+
color: inherit; font: inherit; padding: 6px 14px; border-radius: 8px; cursor: pointer;
|
|
70
|
+
}
|
|
71
|
+
.dsh-tw-panel-error button:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
|
|
72
|
+
.dsh-tw-panel-error code { font-size: 11px; opacity: .8; max-width: 80%; overflow-wrap: anywhere; }
|
|
73
|
+
|
|
74
|
+
/* ── floating quick-note widget ────────────────────────────────
|
|
75
|
+
Positioned ABOVE the shutdown launcher FAB (fixed right:24 bottom:24,
|
|
76
|
+
z-index 900, 46px) and BELOW its confirm overlay (z-index 1000). */
|
|
77
|
+
.dsh-tw-note {
|
|
78
|
+
position: fixed; right: 24px; bottom: 88px; z-index: 950;
|
|
79
|
+
display: flex; flex-direction: column; align-items: flex-end; gap: 10px;
|
|
80
|
+
font-family: inherit;
|
|
81
|
+
}
|
|
82
|
+
.dsh-tw-note-card {
|
|
83
|
+
width: 340px; max-width: calc(100vw - 40px);
|
|
84
|
+
background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
|
|
85
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.15));
|
|
86
|
+
border-radius: 14px; box-shadow: var(--dsw-shadow-lv3, 0 8px 30px rgba(0,0,0,.22));
|
|
87
|
+
padding: 12px; display: flex; flex-direction: column; gap: 10px;
|
|
88
|
+
font-size: 13px;
|
|
89
|
+
}
|
|
90
|
+
.dsh-tw-note-card[hidden] { display: none; }
|
|
91
|
+
.dsh-tw-note-head {
|
|
92
|
+
display: flex; align-items: center; gap: 8px;
|
|
93
|
+
font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-primary, #222);
|
|
94
|
+
}
|
|
95
|
+
.dsh-tw-note-head .dsh-tw-note-label { margin-right: auto; display: flex; align-items: center; gap: 6px; }
|
|
96
|
+
.dsh-tw-note-close {
|
|
97
|
+
border: none; background: transparent; color: var(--dsw-alias-label-secondary, #888);
|
|
98
|
+
font: inherit; font-size: 15px; line-height: 1; padding: 2px 6px; border-radius: 6px; cursor: pointer;
|
|
99
|
+
}
|
|
100
|
+
.dsh-tw-note-close:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); color: var(--dsw-alias-label-primary, #222); }
|
|
101
|
+
.dsh-tw-note-fields { display: flex; gap: 8px; }
|
|
102
|
+
.dsh-tw-note-fields input {
|
|
103
|
+
flex: 1; min-width: 0; border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
|
|
104
|
+
border-radius: 8px; padding: 6px 9px; font: inherit; font-size: 12px;
|
|
105
|
+
background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, inherit);
|
|
106
|
+
transition: border-color 120ms ease, box-shadow 120ms ease;
|
|
107
|
+
}
|
|
108
|
+
.dsh-tw-note-fields input:focus, .dsh-tw-note-text:focus {
|
|
109
|
+
outline: none; border-color: var(--dsw-alias-brand-primary, #3e63dd);
|
|
110
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 25%, transparent);
|
|
111
|
+
}
|
|
112
|
+
.dsh-tw-note-text {
|
|
113
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18)); border-radius: 8px;
|
|
114
|
+
padding: 8px 9px; font: inherit; font-size: 13px; min-height: 120px; resize: vertical;
|
|
115
|
+
background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, inherit);
|
|
116
|
+
transition: border-color 120ms ease, box-shadow 120ms ease;
|
|
117
|
+
}
|
|
118
|
+
.dsh-tw-note-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
119
|
+
.dsh-tw-note-hint { font-size: 11px; color: var(--dsw-alias-label-dimmed, #999); }
|
|
120
|
+
.dsh-tw-note-save {
|
|
121
|
+
border: 1px solid transparent; background: var(--dsw-alias-brand-primary, #3e63dd); color: #fff;
|
|
122
|
+
font: inherit; font-size: 12px; padding: 6px 16px; border-radius: 8px; cursor: pointer;
|
|
123
|
+
transition: filter 120ms ease;
|
|
124
|
+
}
|
|
125
|
+
.dsh-tw-note-save:hover:not(:disabled) { filter: brightness(1.08); }
|
|
126
|
+
.dsh-tw-note-save:disabled { opacity: .55; cursor: default; }
|
|
127
|
+
.dsh-tw-note-edit {
|
|
128
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
|
|
129
|
+
background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
|
|
130
|
+
font: inherit; font-size: 12px; padding: 6px 10px; border-radius: 8px; cursor: pointer;
|
|
131
|
+
transition: filter 120ms ease;
|
|
132
|
+
}
|
|
133
|
+
.dsh-tw-note-edit:hover:not(:disabled) { filter: brightness(.96); }
|
|
134
|
+
.dsh-tw-note-edit:disabled { opacity: .55; cursor: default; }
|
|
135
|
+
/* ── Native-editor popup iframe (quick-note "在 TW 中编辑") ──────────────── */
|
|
136
|
+
.dsh-tw-editor-popup {
|
|
137
|
+
position: fixed; left: 0; right: 0; top: 0; bottom: 0; margin: auto;
|
|
138
|
+
width: min(880px, 92vw); height: min(640px, 86vh);
|
|
139
|
+
display: flex; flex-direction: column; overflow: hidden;
|
|
140
|
+
background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
|
|
141
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.2));
|
|
142
|
+
border-radius: 12px; box-shadow: 0 14px 44px rgba(0,0,0,.3);
|
|
143
|
+
z-index: 980; font-family: inherit; font-size: 13px;
|
|
144
|
+
}
|
|
145
|
+
.dsh-tw-editor-bar {
|
|
146
|
+
display: flex; align-items: center; gap: 8px; flex: 0 0 auto;
|
|
147
|
+
padding: 7px 8px 7px 14px; cursor: move; user-select: none;
|
|
148
|
+
background: var(--dsw-alias-bg-layer-1, #f4f5f7);
|
|
149
|
+
border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.1));
|
|
150
|
+
}
|
|
151
|
+
.dsh-tw-editor-title { flex: 1; min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
152
|
+
.dsh-tw-editor-close {
|
|
153
|
+
border: none; background: transparent; cursor: pointer; font-size: 14px; line-height: 1;
|
|
154
|
+
color: var(--dsw-alias-label-dimmed, #888); padding: 5px 8px; border-radius: 6px;
|
|
155
|
+
}
|
|
156
|
+
.dsh-tw-editor-close:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); color: var(--dsw-alias-label-primary, #222); }
|
|
157
|
+
.dsh-tw-editor-frame { flex: 1; min-height: 0; width: 100%; border: 0; background: #fff; }
|
|
158
|
+
.dsh-tw-editor-resize { position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize; }
|
|
159
|
+
.dsh-tw-note-toggle {
|
|
160
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
|
|
161
|
+
background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
|
|
162
|
+
font: inherit; font-size: 13px; padding: 9px 16px; border-radius: 999px;
|
|
163
|
+
box-shadow: var(--dsw-shadow-lv3, 0 4px 16px rgba(0,0,0,.16)); cursor: pointer;
|
|
164
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
165
|
+
transition: background-color 120ms ease;
|
|
166
|
+
}
|
|
167
|
+
.dsh-tw-note-toggle:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.1)); }
|
|
168
|
+
|
|
169
|
+
/* ── toast ──────────────────────────────────────────────────── */
|
|
170
|
+
.dsh-tw-toast {
|
|
171
|
+
position: fixed; left: 50%; bottom: 96px; transform: translateX(-50%);
|
|
172
|
+
background: var(--dsw-alias-bg-inverse, rgba(30,30,30,.92)); color: var(--dsw-alias-label-inverse, #fff);
|
|
173
|
+
font-size: 13px; padding: 8px 16px; border-radius: 999px; z-index: 10001;
|
|
174
|
+
opacity: 0; transition: opacity .2s ease; pointer-events: none;
|
|
175
|
+
max-width: 80vw; overflow-wrap: anywhere;
|
|
176
|
+
}
|
|
177
|
+
.dsh-tw-toast.dsh-tw-toast-show { opacity: 1; }
|
|
178
|
+
|
|
179
|
+
/* ── settings page (config panel §13) ────────────────────────── */
|
|
180
|
+
.dsh-tw-settings { display: flex; flex-direction: column; gap: 10px; padding: 12px 16px; min-width: 0; }
|
|
181
|
+
.dsh-tw-settings-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
182
|
+
.dsh-tw-settings-chip {
|
|
183
|
+
font-size: 12px; padding: 2px 10px; border-radius: 999px;
|
|
184
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
|
|
185
|
+
}
|
|
186
|
+
.dsh-tw-settings-chip[data-state="running"] { color: var(--dsw-alias-state-success-primary, #3eaa5f); }
|
|
187
|
+
.dsh-tw-settings-chip[data-state="starting"] { color: var(--dsw-alias-state-warning-primary, #d9822b); }
|
|
188
|
+
.dsh-tw-settings-chip[data-state="failed"], .dsh-tw-settings-chip[data-state="stopped"] { color: var(--dsw-alias-state-error-primary, #d13b3b); }
|
|
189
|
+
.dsh-tw-settings-section { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.18)); }
|
|
190
|
+
.dsh-tw-settings-h { margin: 0; font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary, #222); }
|
|
191
|
+
.dsh-tw-settings-field { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--dsw-alias-label-secondary, inherit); }
|
|
192
|
+
.dsh-tw-settings-field-check { cursor: pointer; }
|
|
193
|
+
.dsh-tw-settings-label { flex: 0 0 170px; }
|
|
194
|
+
.dsh-tw-settings-input {
|
|
195
|
+
flex: 1; min-width: 0; font: inherit; font-size: 12px; padding: 4px 8px; border-radius: 6px;
|
|
196
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.28));
|
|
197
|
+
background: var(--dsw-alias-bg-input, transparent); color: var(--dsw-alias-label-primary, #222);
|
|
198
|
+
}
|
|
199
|
+
.dsh-tw-settings-input:focus {
|
|
200
|
+
outline: none; border-color: var(--dsw-alias-brand-primary, #3e63dd);
|
|
201
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 22%, transparent);
|
|
202
|
+
}
|
|
203
|
+
.dsh-tw-settings-list { display: flex; flex-direction: column; gap: 2px; max-height: 240px; overflow: auto; }
|
|
204
|
+
.dsh-tw-settings-plugin { display: flex; align-items: center; gap: 8px; padding: 3px 4px; border-radius: 6px; font-size: 12px; }
|
|
205
|
+
.dsh-tw-settings-plugin:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.08)); }
|
|
206
|
+
.dsh-tw-settings-name { font-weight: 500; flex: 0 0 130px; color: var(--dsw-alias-label-primary, #222); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
207
|
+
.dsh-tw-settings-head { padding: 2px 4px 4px; font-size: 11px; }
|
|
208
|
+
.dsh-tw-settings-col { flex: 0 0 14px; text-align: center; color: var(--dsw-alias-label-dimmed, #999); }
|
|
209
|
+
.dsh-tw-settings-row.dsh-tw-settings-plugin input { flex: 0 0 auto; margin: 0; }
|
|
210
|
+
.dsh-tw-settings-muted { color: var(--dsw-alias-label-dimmed, #999); font-size: 12px; }
|
|
211
|
+
.dsh-tw-settings-btn {
|
|
212
|
+
align-self: flex-start; border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.28));
|
|
213
|
+
background: transparent; color: var(--dsw-alias-label-primary, #222);
|
|
214
|
+
font: inherit; font-size: 12px; padding: 4px 12px; border-radius: 7px; cursor: pointer;
|
|
215
|
+
}
|
|
216
|
+
.dsh-tw-settings-btn:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.1)); }
|
|
217
|
+
.dsh-tw-settings-btn:disabled { opacity: .55; cursor: default; }
|
|
218
|
+
.dsh-tw-settings-btn.dsh-tw-settings-primary {
|
|
219
|
+
background: var(--dsw-alias-brand-primary, #3e63dd); border-color: transparent; color: #fff;
|
|
220
|
+
}
|
|
221
|
+
.dsh-tw-settings-error { color: var(--dsw-alias-state-error-primary, #d13b3b); font-size: 12px; }
|
|
222
|
+
.dsh-tw-settings-search { flex: 0 0 auto; max-width: 220px; }
|
|
223
|
+
.dsh-tw-settings-check { accent-color: var(--dsw-alias-brand-primary, #3e63dd); }
|
|
224
|
+
`
|
|
225
|
+
|
|
226
|
+
export function injectStyles(): void {
|
|
227
|
+
if (typeof document === 'undefined') return
|
|
228
|
+
let el = document.getElementById(STYLE_ID)
|
|
229
|
+
if (el !== null) return
|
|
230
|
+
el = document.createElement('style')
|
|
231
|
+
el.id = STYLE_ID
|
|
232
|
+
el.dataset.plugin = 'dsh-tiddlywiki'
|
|
233
|
+
el.textContent = CSS_TEXT
|
|
234
|
+
document.head.append(el)
|
|
235
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot transient toast, shared by the quick-note widget and the settings
|
|
3
|
+
* page. Reuses a single body-level element (`.dsh-tw-toast`) with a show
|
|
4
|
+
* class; the element is created lazily and styled by styles.ts.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-tiddlywiki/client/toast
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function toast(message: string): void {
|
|
10
|
+
let el = document.querySelector<HTMLElement>('.dsh-tw-toast')
|
|
11
|
+
if (el === null) {
|
|
12
|
+
el = document.createElement('div')
|
|
13
|
+
el.className = 'dsh-tw-toast'
|
|
14
|
+
document.body.append(el)
|
|
15
|
+
}
|
|
16
|
+
el.textContent = message
|
|
17
|
+
el.classList.add('dsh-tw-toast-show')
|
|
18
|
+
clearTimeout((el as HTMLElement & { __twToastTimer?: number }).__twToastTimer)
|
|
19
|
+
;(el as HTMLElement & { __twToastTimer?: number }).__twToastTimer = window.setTimeout(() => {
|
|
20
|
+
el?.classList.remove('dsh-tw-toast-show')
|
|
21
|
+
}, 2_500)
|
|
22
|
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin surface for the plugin settings page (design doc §13, config panel).
|
|
3
|
+
*
|
|
4
|
+
* - dynamic plugin/theme management: enumerate the bundled catalog from the
|
|
5
|
+
* installed tiddlywiki package, read/write the wiki's `tiddlywiki.info`
|
|
6
|
+
* plugins/themes arrays, then restart the TW child so the change applies;
|
|
7
|
+
* - extensible config: the settings page reads/writes a config tiddler
|
|
8
|
+
* ($:/plugins/dsh-tiddlywiki/config, a JSON string) that overlays the
|
|
9
|
+
* cordis `config:` block — future config fields just extend the shape.
|
|
10
|
+
*
|
|
11
|
+
* Routes (all under ROUTE_PREFIX/admin, JSON):
|
|
12
|
+
* GET /admin/state current info + catalog + effective config + status
|
|
13
|
+
* POST /admin/info { plugins?, themes? } → write info → restart TW
|
|
14
|
+
* POST /admin/config { ...patch } → write config tiddler
|
|
15
|
+
* POST /admin/restart restart the TW child
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-tiddlywiki/host/admin
|
|
18
|
+
*/
|
|
19
|
+
import { createRequire } from 'node:module'
|
|
20
|
+
import { readFile, writeFile, readdir } from 'node:fs/promises'
|
|
21
|
+
import { dirname, join } from 'node:path'
|
|
22
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
23
|
+
import type { TiddlyWebClient } from './tw-api.ts'
|
|
24
|
+
import type { WikiServer } from './wiki.ts'
|
|
25
|
+
import { ROUTE_PREFIX, type WebServerFace } from './routes.ts'
|
|
26
|
+
import { CONFIG_TIDDLER, type ConfigStore, type PluginConfigShape } from './config.ts'
|
|
27
|
+
|
|
28
|
+
/** One bundled plugin/theme from the catalog. */
|
|
29
|
+
export interface CatalogEntry {
|
|
30
|
+
/** Short name as used in tiddlywiki.info, e.g. "tiddlywiki/katex". */
|
|
31
|
+
name: string
|
|
32
|
+
/** Full tiddler title, e.g. "$:/plugins/tiddlywiki/katex". */
|
|
33
|
+
title: string
|
|
34
|
+
label: string
|
|
35
|
+
description: string
|
|
36
|
+
/** Dependent theme NAMES (converted from plugin.info `dependents`), e.g. heavier → ["tiddlywiki/snowwhite"]. */
|
|
37
|
+
dependents?: string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Catalog {
|
|
41
|
+
plugins: CatalogEntry[]
|
|
42
|
+
themes: CatalogEntry[]
|
|
43
|
+
/** Bundled language plugins (tiddlywiki package `languages/` dir). */
|
|
44
|
+
languages: CatalogEntry[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Shape of tiddlywiki.info (plugins/themes/languages + build/description). */
|
|
48
|
+
export interface WikiInfo {
|
|
49
|
+
description?: string
|
|
50
|
+
plugins: string[]
|
|
51
|
+
themes: string[]
|
|
52
|
+
languages?: string[]
|
|
53
|
+
build?: Record<string, unknown>
|
|
54
|
+
[key: string]: unknown
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Resolve the installed tiddlywiki package root (for the catalog). */
|
|
58
|
+
export function resolveTwRoot(): string {
|
|
59
|
+
const require = createRequire(import.meta.url)
|
|
60
|
+
return dirname(require.resolve('tiddlywiki/package.json'))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Read the wiki's tiddlywiki.info. */
|
|
64
|
+
export async function readWikiInfo(wikiPath: string): Promise<WikiInfo> {
|
|
65
|
+
let raw: string
|
|
66
|
+
try {
|
|
67
|
+
raw = await readFile(join(wikiPath, 'tiddlywiki.info'), 'utf8')
|
|
68
|
+
} catch {
|
|
69
|
+
return { plugins: [], themes: [], languages: [] }
|
|
70
|
+
}
|
|
71
|
+
const parsed = JSON.parse(raw) as Partial<WikiInfo>
|
|
72
|
+
return {
|
|
73
|
+
description: parsed.description,
|
|
74
|
+
plugins: parsed.plugins ?? [],
|
|
75
|
+
themes: parsed.themes ?? [],
|
|
76
|
+
languages: parsed.languages ?? [],
|
|
77
|
+
...parsed,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Write the wiki's tiddlywiki.info (pretty-printed, ordering preserved). */
|
|
82
|
+
export async function writeWikiInfo(wikiPath: string, info: WikiInfo): Promise<void> {
|
|
83
|
+
await writeFile(join(wikiPath, 'tiddlywiki.info'), `${JSON.stringify(info, null, 4)}\n`, 'utf8')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Enumerate bundled official plugins + themes + languages of tiddlywiki. */
|
|
87
|
+
export async function bundledCatalog(twRoot: string): Promise<Catalog> {
|
|
88
|
+
// TW themes are SKINS layered on the vanilla base (which carries the full
|
|
89
|
+
// 70KB base stylesheet). A theme whose stylesheet body is empty is a broken
|
|
90
|
+
// stub (e.g. tight-heavier in some releases) — skip it so the settings list
|
|
91
|
+
// never offers a no-op theme. vanilla itself is always kept.
|
|
92
|
+
const themeHasCss = async (dir: string): Promise<boolean> => {
|
|
93
|
+
for (const name of ['base.tid', 'styles.tid']) {
|
|
94
|
+
try {
|
|
95
|
+
const raw = await readFile(join(twRoot, 'themes', 'tiddlywiki', dir, name), 'utf8')
|
|
96
|
+
const body = raw
|
|
97
|
+
.replace(/^[\s\S]*?\r?\n\r?\n/, '')
|
|
98
|
+
.split('\n')
|
|
99
|
+
.filter((line) => !/^\\rules\b/.test(line.trim()))
|
|
100
|
+
.join('\n')
|
|
101
|
+
.trim()
|
|
102
|
+
if (body.length > 0) return true
|
|
103
|
+
} catch {
|
|
104
|
+
/* file absent */
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
const scan = async (sub: 'plugins' | 'themes'): Promise<CatalogEntry[]> => {
|
|
110
|
+
const root = join(twRoot, sub, 'tiddlywiki')
|
|
111
|
+
let dirs: string[]
|
|
112
|
+
try {
|
|
113
|
+
dirs = await readdir(root)
|
|
114
|
+
} catch {
|
|
115
|
+
return []
|
|
116
|
+
}
|
|
117
|
+
const out: CatalogEntry[] = []
|
|
118
|
+
for (const dir of dirs) {
|
|
119
|
+
let info: { name?: string; description?: string; dependents?: string[] } = {}
|
|
120
|
+
try {
|
|
121
|
+
info = JSON.parse(await readFile(join(root, dir, 'plugin.info'), 'utf8')) as typeof info
|
|
122
|
+
} catch {
|
|
123
|
+
info = {}
|
|
124
|
+
}
|
|
125
|
+
if (sub === 'themes' && dir !== 'vanilla' && !(await themeHasCss(dir))) continue
|
|
126
|
+
out.push({
|
|
127
|
+
name: `tiddlywiki/${dir}`,
|
|
128
|
+
title: sub === 'plugins' ? `$:/plugins/tiddlywiki/${dir}` : `$:/themes/tiddlywiki/${dir}`,
|
|
129
|
+
label: info.name ?? dir,
|
|
130
|
+
description: info.description ?? '',
|
|
131
|
+
// plugin.info `dependents` are full plugin titles → convert to names.
|
|
132
|
+
dependents: Array.isArray(info.dependents)
|
|
133
|
+
? info.dependents.map((dep) => dep.replace(/^\$:\/themes\/tiddlywiki\//, 'tiddlywiki/'))
|
|
134
|
+
: undefined,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
out.sort((a, b) => a.name.localeCompare(b.name))
|
|
138
|
+
return out
|
|
139
|
+
}
|
|
140
|
+
// Language plugins live in the package ROOT `languages/` dir (not plugins/),
|
|
141
|
+
// and are enabled via the tiddlywiki.info `languages` array (boot resolves
|
|
142
|
+
// them through $tw.config.languagesPath). Fully offline — official builds.
|
|
143
|
+
const scanLanguages = async (): Promise<CatalogEntry[]> => {
|
|
144
|
+
const root = join(twRoot, 'languages')
|
|
145
|
+
let dirs: string[]
|
|
146
|
+
try {
|
|
147
|
+
dirs = await readdir(root)
|
|
148
|
+
} catch {
|
|
149
|
+
return []
|
|
150
|
+
}
|
|
151
|
+
const out: CatalogEntry[] = []
|
|
152
|
+
for (const dir of dirs) {
|
|
153
|
+
let info: { name?: string; description?: string } = {}
|
|
154
|
+
try {
|
|
155
|
+
info = JSON.parse(await readFile(join(root, dir, 'plugin.info'), 'utf8')) as typeof info
|
|
156
|
+
} catch {
|
|
157
|
+
info = {}
|
|
158
|
+
}
|
|
159
|
+
out.push({
|
|
160
|
+
name: dir,
|
|
161
|
+
title: `$:/languages/${dir}`,
|
|
162
|
+
label: info.name ?? dir,
|
|
163
|
+
description: info.description ?? '',
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
out.sort((a, b) => a.name.localeCompare(b.name))
|
|
167
|
+
return out
|
|
168
|
+
}
|
|
169
|
+
const [plugins, themes, languages] = await Promise.all([scan('plugins'), scan('themes'), scanLanguages()])
|
|
170
|
+
return { plugins, themes, languages }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Normalize a theme selection into the tiddlywiki.info `themes` array.
|
|
175
|
+
*
|
|
176
|
+
* TW themes are SKINS with a dependency chain (plugin.info `dependents`):
|
|
177
|
+
* vanilla ← snowwhite ← heavier / centralised / readonly / starlight
|
|
178
|
+
* vanilla ← tight / seamless
|
|
179
|
+
* The ACTIVE theme is `$:/theme`, and switching to it registers the theme PLUS
|
|
180
|
+
* its transitive dependents (boot.js accumulatePlugin) — if a dependent isn't
|
|
181
|
+
* loaded, the vanilla base stylesheet is lost and the UI breaks. So we always
|
|
182
|
+
* emit the transitive closure, dependency-first (base first, active overlay
|
|
183
|
+
* last), and force vanilla in as the base. Empty selection → vanilla.
|
|
184
|
+
*/
|
|
185
|
+
export function normalizeThemes(selected: string[], deps: Record<string, string[]> = {}): string[] {
|
|
186
|
+
const sel = selected.filter((name) => typeof name === 'string' && name.length > 0)
|
|
187
|
+
if (sel.length === 0) sel.push('tiddlywiki/vanilla')
|
|
188
|
+
const out: string[] = []
|
|
189
|
+
const seen = new Set<string>()
|
|
190
|
+
const visit = (name: string): void => {
|
|
191
|
+
if (seen.has(name)) return
|
|
192
|
+
seen.add(name)
|
|
193
|
+
for (const dep of deps[name] ?? []) {
|
|
194
|
+
if (dep !== name) visit(dep)
|
|
195
|
+
}
|
|
196
|
+
out.push(name)
|
|
197
|
+
}
|
|
198
|
+
for (const name of sel) visit(name)
|
|
199
|
+
if (!out.includes('tiddlywiki/vanilla')) out.unshift('tiddlywiki/vanilla')
|
|
200
|
+
return out
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Ensure a language code (e.g. "zh-Hans") is in tiddlywiki.info `languages`.
|
|
205
|
+
* Returns whether tiddlywiki.info changed (caller decides whether to restart).
|
|
206
|
+
*/
|
|
207
|
+
export async function ensureLanguage(wikiPath: string, twRoot: string, lang: string): Promise<boolean> {
|
|
208
|
+
if (typeof lang !== 'string' || lang.trim().length === 0) return false
|
|
209
|
+
const code = lang.trim()
|
|
210
|
+
const catalog = await bundledCatalog(twRoot)
|
|
211
|
+
if (!catalog.languages.some((l) => l.name === code)) {
|
|
212
|
+
throw new Error(`unknown language plugin: ${code}`)
|
|
213
|
+
}
|
|
214
|
+
const info = await readWikiInfo(wikiPath)
|
|
215
|
+
const current = info.languages ?? []
|
|
216
|
+
if (current.includes(code)) return false
|
|
217
|
+
info.languages = [...current, code]
|
|
218
|
+
await writeWikiInfo(wikiPath, info)
|
|
219
|
+
return true
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function json(res: ServerResponse, payload: unknown, status = 200): void {
|
|
223
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
224
|
+
res.end(JSON.stringify(payload))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function readBody(req: IncomingMessage, limit = 1024 * 1024): Promise<string> {
|
|
228
|
+
return new Promise((resolveP, rejectP) => {
|
|
229
|
+
let size = 0
|
|
230
|
+
const chunks: Buffer[] = []
|
|
231
|
+
req.on('data', (chunk: Buffer) => {
|
|
232
|
+
size += chunk.length
|
|
233
|
+
if (size > limit) {
|
|
234
|
+
rejectP(new Error('body too large'))
|
|
235
|
+
req.destroy()
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
chunks.push(chunk)
|
|
239
|
+
})
|
|
240
|
+
req.on('end', () => resolveP(Buffer.concat(chunks).toString('utf8')))
|
|
241
|
+
req.on('error', rejectP)
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export interface AdminDeps {
|
|
246
|
+
server: WikiServer
|
|
247
|
+
getClient: () => TiddlyWebClient | undefined
|
|
248
|
+
getWikiPath: () => string
|
|
249
|
+
twRoot: () => string
|
|
250
|
+
config: ConfigStore
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: AdminDeps): () => void {
|
|
254
|
+
const handleState = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
255
|
+
try {
|
|
256
|
+
const wikiPath = deps.getWikiPath()
|
|
257
|
+
const [info, catalog] = await Promise.all([readWikiInfo(wikiPath), bundledCatalog(deps.twRoot())])
|
|
258
|
+
let git: unknown = null
|
|
259
|
+
try {
|
|
260
|
+
const { GitFace } = await import('./git.ts')
|
|
261
|
+
git = await new GitFace().status(wikiPath)
|
|
262
|
+
} catch {
|
|
263
|
+
git = null
|
|
264
|
+
}
|
|
265
|
+
json(res, {
|
|
266
|
+
ok: true,
|
|
267
|
+
server: deps.server.status(),
|
|
268
|
+
info: { plugins: info.plugins, themes: info.themes, languages: info.languages ?? [] },
|
|
269
|
+
catalog,
|
|
270
|
+
config: deps.config.get(),
|
|
271
|
+
git,
|
|
272
|
+
})
|
|
273
|
+
} catch (err) {
|
|
274
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const handleInfo = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
279
|
+
try {
|
|
280
|
+
const body = JSON.parse(await readBody(req)) as { plugins?: unknown; themes?: unknown; themeActive?: unknown; languages?: unknown }
|
|
281
|
+
const wikiPath = deps.getWikiPath()
|
|
282
|
+
const info = await readWikiInfo(wikiPath)
|
|
283
|
+
const catalog = await bundledCatalog(deps.twRoot())
|
|
284
|
+
const known = new Set([...catalog.plugins, ...catalog.themes].map((c) => c.name))
|
|
285
|
+
const knownLangs = new Set(catalog.languages.map((c) => c.name))
|
|
286
|
+
const applyList = (field: 'plugins' | 'themes', raw: unknown): string[] => {
|
|
287
|
+
if (!Array.isArray(raw)) return info[field]
|
|
288
|
+
const next: string[] = []
|
|
289
|
+
for (const name of raw) {
|
|
290
|
+
if (typeof name !== 'string') continue
|
|
291
|
+
if (!known.has(name) && !info[field].includes(name)) {
|
|
292
|
+
throw new Error(`unknown plugin/theme: ${name}`)
|
|
293
|
+
}
|
|
294
|
+
if (!next.includes(name)) next.push(name)
|
|
295
|
+
}
|
|
296
|
+
return next
|
|
297
|
+
}
|
|
298
|
+
const applyLanguages = (raw: unknown): string[] => {
|
|
299
|
+
if (!Array.isArray(raw)) return info.languages ?? []
|
|
300
|
+
const next: string[] = []
|
|
301
|
+
for (const code of raw) {
|
|
302
|
+
if (typeof code !== 'string') continue
|
|
303
|
+
if (!knownLangs.has(code) && !(info.languages ?? []).includes(code)) {
|
|
304
|
+
throw new Error(`unknown language plugin: ${code}`)
|
|
305
|
+
}
|
|
306
|
+
if (!next.includes(code)) next.push(code)
|
|
307
|
+
}
|
|
308
|
+
return next
|
|
309
|
+
}
|
|
310
|
+
info.plugins = applyList('plugins', body.plugins)
|
|
311
|
+
// Activate the chosen theme: load its full dependency chain AND set
|
|
312
|
+
// `$:/theme` so the browser's themeManager actually applies it (the
|
|
313
|
+
// `themes` array alone only makes the plugin available).
|
|
314
|
+
let activatedTheme: string | undefined
|
|
315
|
+
if (Array.isArray(body.themes)) {
|
|
316
|
+
const themeDeps: Record<string, string[]> = {}
|
|
317
|
+
for (const theme of catalog.themes) {
|
|
318
|
+
if (theme.dependents && theme.dependents.length > 0) themeDeps[theme.name] = theme.dependents
|
|
319
|
+
}
|
|
320
|
+
let selected = applyList('themes', body.themes)
|
|
321
|
+
// Explicit active-theme pick (new two-layer UI): validate and auto-add
|
|
322
|
+
// it to the loaded set so its dependency closure is loaded. Without an
|
|
323
|
+
// explicit pick, activate the deepest loaded overlay (old single-radio).
|
|
324
|
+
const explicitActive = typeof body.themeActive === 'string' && body.themeActive.length > 0
|
|
325
|
+
if (explicitActive) {
|
|
326
|
+
const activeName = body.themeActive as string
|
|
327
|
+
if (known.has(activeName) || info.themes.includes(activeName)) {
|
|
328
|
+
if (!selected.includes(activeName)) selected.push(activeName)
|
|
329
|
+
activatedTheme = activeName
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
info.themes = normalizeThemes(selected, themeDeps)
|
|
333
|
+
if (activatedTheme === undefined && info.themes.length > 0) {
|
|
334
|
+
activatedTheme = info.themes[info.themes.length - 1]
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
info.themes = applyList('themes', body.themes)
|
|
338
|
+
}
|
|
339
|
+
if (Array.isArray(body.languages)) info.languages = applyLanguages(body.languages)
|
|
340
|
+
await writeWikiInfo(wikiPath, info)
|
|
341
|
+
await deps.server.restart()
|
|
342
|
+
// Activate the chosen theme tiddler (mirrors TW's own Control Panel).
|
|
343
|
+
if (activatedTheme !== undefined) {
|
|
344
|
+
const client = deps.getClient()
|
|
345
|
+
if (client !== undefined) {
|
|
346
|
+
await client
|
|
347
|
+
.put({ title: '$:/theme', text: `$:/themes/${activatedTheme}`, type: 'text/vnd.tiddlywiki', tags: [] })
|
|
348
|
+
.catch(() => undefined)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// After a languages change, pin the active language tiddler: first
|
|
352
|
+
// enabled language, or en-GB when none is enabled. Only when the request
|
|
353
|
+
// actually carried a languages array (plugins/themes restarts skip this).
|
|
354
|
+
if (Array.isArray(body.languages)) {
|
|
355
|
+
const client = deps.getClient()
|
|
356
|
+
if (client !== undefined) {
|
|
357
|
+
const langs = info.languages ?? []
|
|
358
|
+
const active = langs.length > 0 ? `$:/languages/${langs[0]}` : '$:/languages/en-GB'
|
|
359
|
+
await client.put({ title: '$:/language', text: active, type: 'text/plain', tags: [] }).catch(() => undefined)
|
|
360
|
+
// Keep the startup auto-apply hint (config uiLanguage) consistent with
|
|
361
|
+
// the active language, so a later dsh-web restart doesn't re-enable
|
|
362
|
+
// a language the user just disabled here.
|
|
363
|
+
const hint = langs.length > 0 ? langs[0] : ''
|
|
364
|
+
if ((deps.config.get().uiLanguage ?? '') !== hint) {
|
|
365
|
+
await deps.config.set(client, { uiLanguage: hint }).catch(() => undefined)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
json(res, { ok: true, info: { plugins: info.plugins, themes: info.themes, languages: info.languages ?? [] } })
|
|
370
|
+
} catch (err) {
|
|
371
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 400)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const handleConfig = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
376
|
+
try {
|
|
377
|
+
const body = JSON.parse(await readBody(req)) as PluginConfigShape
|
|
378
|
+
const client = deps.getClient()
|
|
379
|
+
if (client === undefined) {
|
|
380
|
+
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
await deps.config.set(client, body)
|
|
384
|
+
json(res, { ok: true, config: deps.config.get() })
|
|
385
|
+
} catch (err) {
|
|
386
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 400)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const handleRestart = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
391
|
+
try {
|
|
392
|
+
await deps.server.restart()
|
|
393
|
+
json(res, { ok: true, status: deps.server.status().status })
|
|
394
|
+
} catch (err) {
|
|
395
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const disposers = [
|
|
400
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/state`, handler: (req, res) => { void handleState(req, res) } }),
|
|
401
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/info`, handler: (req, res) => { void handleInfo(req, res) } }),
|
|
402
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/config`, handler: (req, res) => { void handleConfig(req, res) } }),
|
|
403
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/restart`, handler: (req, res) => { void handleRestart(req, res) } }),
|
|
404
|
+
]
|
|
405
|
+
return () => {
|
|
406
|
+
for (const dispose of disposers) dispose()
|
|
407
|
+
}
|
|
408
|
+
}
|