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.
@@ -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
+ }