dsh-custom-theme 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 +479 -0
- package/cordis.patch.yml +14 -0
- package/lib/client.js +1512 -0
- package/package.json +50 -0
- package/src/index.mjs +289 -0
- package/src/themes.mjs +121 -0
- package/themes/gov.css +53 -0
- package/themes/monokai-pro.css +39 -0
- package/themes/one-dark.css +39 -0
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-custom-theme",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "User-editable CSS themes and background images for the DSH Web GUI and Desktop app, with pickers in Settings",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Sparrived",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.mjs",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/Sparrived/dsh-custom-theme.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Sparrived/dsh-custom-theme/issues"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/Sparrived/dsh-custom-theme#readme",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./src/index.mjs",
|
|
19
|
+
"./client": "./lib/client.js",
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"dsh": {
|
|
23
|
+
"client": {
|
|
24
|
+
"platform": "web",
|
|
25
|
+
"inject": [
|
|
26
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"bundle": {
|
|
30
|
+
"patch": "./cordis.patch.yml"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"src",
|
|
35
|
+
"lib/client.js",
|
|
36
|
+
"themes",
|
|
37
|
+
"cordis.patch.yml",
|
|
38
|
+
"README.md"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"test": "node --test \"test/**/*.test.mjs\"",
|
|
42
|
+
"test:browser": "node test/browser/appearance.mjs"
|
|
43
|
+
},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"dsh",
|
|
46
|
+
"cordis",
|
|
47
|
+
"plugin",
|
|
48
|
+
"theme"
|
|
49
|
+
]
|
|
50
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-custom-theme — Host half.
|
|
3
|
+
*
|
|
4
|
+
* Owns a user-editable CSS theme directory (`$DSH_HOME/themes`) and serves it to
|
|
5
|
+
* the browser half over a prefix route on the Web Host. Bundled themes are
|
|
6
|
+
* seeded once, and a user who drops an extra `.css` file into the directory gets
|
|
7
|
+
* a new entry in the Appearance picker without touching any configuration.
|
|
8
|
+
*
|
|
9
|
+
* Imports no third-party modules: the row must stay loadable by a profile that
|
|
10
|
+
* links this package directly, with no peer resolution for a Schemastery `Config`
|
|
11
|
+
* schema yet. `config` is therefore read defensively.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
BUNDLED_THEME_IDS,
|
|
21
|
+
MAX_BACKGROUND_BYTES,
|
|
22
|
+
MAX_THEME_BYTES,
|
|
23
|
+
SEED_VERSION,
|
|
24
|
+
backgroundContentType,
|
|
25
|
+
backgroundsDirectory,
|
|
26
|
+
isBackgroundName,
|
|
27
|
+
isBundledThemeId,
|
|
28
|
+
isThemeId,
|
|
29
|
+
orderThemeIds,
|
|
30
|
+
themesDirectory,
|
|
31
|
+
} from './themes.mjs'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Prefix route owned by this plugin. `dsh-client-ui-theme` and the SPA dist use
|
|
35
|
+
* other paths.
|
|
36
|
+
*
|
|
37
|
+
* No trailing slash: the webserver matches a prefix `p` against `p` itself and
|
|
38
|
+
* `p/<anything>`, so registering `'/dsh-custom-theme/'` would only ever match
|
|
39
|
+
* `'/dsh-custom-theme//…'` and every real request would fall through.
|
|
40
|
+
*/
|
|
41
|
+
const ROUTE_PATH = '/dsh-custom-theme'
|
|
42
|
+
|
|
43
|
+
/** Seeded stylesheets live beside the package root, one level above this module. */
|
|
44
|
+
const SEED_ROOT = fileURLToPath(new URL('../themes/', import.meta.url))
|
|
45
|
+
|
|
46
|
+
export const name = 'dsh-custom-theme'
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the DSH home, honouring the environment override the runtime sets.
|
|
50
|
+
* @returns The home directory whose `themes` directory this plugin owns.
|
|
51
|
+
*/
|
|
52
|
+
function dshHome() {
|
|
53
|
+
const configured = process.env.DSH_HOME
|
|
54
|
+
return typeof configured === 'string' && configured.trim() !== '' ? configured : join(homedir(), '.dsh')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Copy each bundled stylesheet into the theme directory.
|
|
59
|
+
*
|
|
60
|
+
* Within one seed generation an existing file is left alone, so a user edit
|
|
61
|
+
* survives every restart and a deletion is only repaired. When the generation
|
|
62
|
+
* advances the bundled stylesheets are written again, because a fixed or extended
|
|
63
|
+
* bundled palette has to reach a machine that already holds the older copy. Only
|
|
64
|
+
* bundled ids are ever written; anything else in the directory is the user's.
|
|
65
|
+
*
|
|
66
|
+
* The theme directory is owned by this plugin, and `themes/*.css` documents that
|
|
67
|
+
* a customised palette belongs in a copy under its own name rather than in an
|
|
68
|
+
* edited bundled file.
|
|
69
|
+
* @param directory - Theme directory, created when missing.
|
|
70
|
+
* @returns Ids written by this call, for the startup log.
|
|
71
|
+
*/
|
|
72
|
+
async function seedThemes(directory) {
|
|
73
|
+
await mkdir(directory, { recursive: true })
|
|
74
|
+
const marker = join(directory, '.seed-version')
|
|
75
|
+
let generation = null
|
|
76
|
+
try {
|
|
77
|
+
generation = Number.parseInt(await readFile(marker, 'utf8'), 10)
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error.code !== 'ENOENT') throw error
|
|
80
|
+
}
|
|
81
|
+
// A missing marker is a directory this plugin has not seeded, so every bundled
|
|
82
|
+
// file is written even if an unrelated file of the same name is already there.
|
|
83
|
+
const stale = generation !== SEED_VERSION
|
|
84
|
+
const seeded = []
|
|
85
|
+
for (const id of BUNDLED_THEME_IDS) {
|
|
86
|
+
const target = join(directory, `${id}.css`)
|
|
87
|
+
if (!stale) {
|
|
88
|
+
try {
|
|
89
|
+
await readFile(target)
|
|
90
|
+
continue
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error.code !== 'ENOENT') throw error
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
await writeFile(target, await readFile(join(SEED_ROOT, `${id}.css`), 'utf8'), 'utf8')
|
|
96
|
+
seeded.push(id)
|
|
97
|
+
}
|
|
98
|
+
if (generation !== SEED_VERSION) await writeFile(marker, String(SEED_VERSION), 'utf8')
|
|
99
|
+
return seeded
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* List the theme ids present in the directory.
|
|
104
|
+
* @param directory - Theme directory.
|
|
105
|
+
* @returns Ordered ids without the `.css` suffix.
|
|
106
|
+
*/
|
|
107
|
+
async function scanThemes(directory) {
|
|
108
|
+
const entries = await readdir(directory, { withFileTypes: true })
|
|
109
|
+
const ids = entries
|
|
110
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.css') && !entry.name.startsWith('.'))
|
|
111
|
+
.map((entry) => entry.name.slice(0, -'.css'.length))
|
|
112
|
+
return orderThemeIds(ids)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Write one response. Handlers own the raw `ServerResponse`.
|
|
117
|
+
* @param res - Response owned by this handler.
|
|
118
|
+
* @param status - HTTP status code.
|
|
119
|
+
* @param contentType - Response media type.
|
|
120
|
+
* @param body - Response body as text or bytes; empty for error statuses.
|
|
121
|
+
*/
|
|
122
|
+
function send(res, status, contentType, body = '') {
|
|
123
|
+
const payload = Buffer.isBuffer(body) ? body : Buffer.from(body)
|
|
124
|
+
res.writeHead(status, {
|
|
125
|
+
'content-type': contentType,
|
|
126
|
+
'cache-control': 'no-store',
|
|
127
|
+
'content-length': payload.length,
|
|
128
|
+
})
|
|
129
|
+
res.end(payload)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Percent-decode a request path.
|
|
134
|
+
* @param value - Raw path below the route prefix.
|
|
135
|
+
* @returns Decoded text, or `undefined` when the escape sequence is malformed.
|
|
136
|
+
*/
|
|
137
|
+
function decodePath(value) {
|
|
138
|
+
try {
|
|
139
|
+
return decodeURIComponent(value)
|
|
140
|
+
} catch {
|
|
141
|
+
return undefined
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* List the background images present in the directory.
|
|
147
|
+
* @param directory - Background directory.
|
|
148
|
+
* @returns File names in alphabet order; an absent directory lists nothing.
|
|
149
|
+
*/
|
|
150
|
+
async function scanBackgrounds(directory) {
|
|
151
|
+
let entries
|
|
152
|
+
try {
|
|
153
|
+
entries = await readdir(directory, { withFileTypes: true })
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (error.code === 'ENOENT') return []
|
|
156
|
+
throw error
|
|
157
|
+
}
|
|
158
|
+
return entries
|
|
159
|
+
.filter((entry) => entry.isFile() && !entry.name.startsWith('.') && isBackgroundName(entry.name))
|
|
160
|
+
.map((entry) => entry.name)
|
|
161
|
+
.sort((left, right) => left.localeCompare(right))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Read one background image under the file-name whitelist.
|
|
166
|
+
* @param directory - Background directory.
|
|
167
|
+
* @param name - Candidate file name from the request path.
|
|
168
|
+
* @returns The image bytes, or `undefined` when no such image exists or it is oversized.
|
|
169
|
+
*/
|
|
170
|
+
async function readBackground(directory, name) {
|
|
171
|
+
if (!isBackgroundName(name)) return undefined
|
|
172
|
+
let bytes
|
|
173
|
+
try {
|
|
174
|
+
bytes = await readFile(join(directory, name))
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error.code === 'ENOENT' || error.code === 'EISDIR') return undefined
|
|
177
|
+
throw error
|
|
178
|
+
}
|
|
179
|
+
return bytes.length > MAX_BACKGROUND_BYTES ? undefined : bytes
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Read one theme stylesheet under the id whitelist.
|
|
184
|
+
* @param directory - Theme directory.
|
|
185
|
+
* @param id - Candidate theme id from the request path.
|
|
186
|
+
* @returns Stylesheet text, or `undefined` when no such theme exists or it is oversized.
|
|
187
|
+
*/
|
|
188
|
+
async function readTheme(directory, id) {
|
|
189
|
+
if (!isThemeId(id)) return undefined
|
|
190
|
+
let text
|
|
191
|
+
try {
|
|
192
|
+
text = await readFile(join(directory, `${id}.css`), 'utf8')
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (error.code === 'ENOENT' || error.code === 'EISDIR') return undefined
|
|
195
|
+
throw error
|
|
196
|
+
}
|
|
197
|
+
return Buffer.byteLength(text) > MAX_THEME_BYTES ? undefined : text
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Answer the listing, stylesheet and background routes. Every other path under
|
|
202
|
+
* the prefix is a 404; the route is `prefix`, so this handler owns the whole
|
|
203
|
+
* subtree.
|
|
204
|
+
* @param req - Request from the application origin.
|
|
205
|
+
* @param res - Response owned by this handler.
|
|
206
|
+
* @param paths - `themes` and `backgrounds` directories, plus `ready`, which
|
|
207
|
+
* settles once the initial seeding pass finished, so a request that arrives
|
|
208
|
+
* during startup never scans a directory that does not exist yet.
|
|
209
|
+
*/
|
|
210
|
+
async function handleRoute(req, res, paths) {
|
|
211
|
+
await paths.ready
|
|
212
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
213
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
214
|
+
send(res, 405, 'text/plain; charset=utf-8', 'method not allowed')
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
const rest = decodePath(url.pathname.slice(ROUTE_PATH.length + 1))
|
|
218
|
+
if (rest === undefined) {
|
|
219
|
+
send(res, 400, 'text/plain; charset=utf-8', 'malformed path')
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
if (rest === 'themes') {
|
|
223
|
+
const themes = (await scanThemes(paths.themes)).map((id) => ({ id, bundled: isBundledThemeId(id) }))
|
|
224
|
+
send(res, 200, 'application/json; charset=utf-8', JSON.stringify({ themes, dir: paths.themes }))
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
if (rest === 'backgrounds') {
|
|
228
|
+
const names = await scanBackgrounds(paths.backgrounds)
|
|
229
|
+
const backgrounds = names.map((name) => ({ name, url: `${ROUTE_PATH}/background/${encodeURIComponent(name)}` }))
|
|
230
|
+
send(res, 200, 'application/json; charset=utf-8', JSON.stringify({ backgrounds, dir: paths.backgrounds }))
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
const theme = /^theme\/([^/]+)\.css$/u.exec(rest)
|
|
234
|
+
if (theme !== null) {
|
|
235
|
+
const text = await readTheme(paths.themes, theme[1])
|
|
236
|
+
if (text === undefined) {
|
|
237
|
+
send(res, 404, 'text/plain; charset=utf-8', 'theme not found')
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
send(res, 200, 'text/css; charset=utf-8', text)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
const background = /^background\/([^/]+)$/u.exec(rest)
|
|
244
|
+
const bytes = background === null ? undefined : await readBackground(paths.backgrounds, background[1])
|
|
245
|
+
if (bytes === undefined) {
|
|
246
|
+
send(res, 404, 'text/plain; charset=utf-8', 'background not found')
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
send(res, 200, backgroundContentType(background[1]), bytes)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Seed the theme directory and expose both directories over the Web Host.
|
|
254
|
+
*
|
|
255
|
+
* The route is registered through `ctx.inject` so a profile without
|
|
256
|
+
* `dsh-host-webserver` still seeds the theme directory instead of failing to
|
|
257
|
+
* load.
|
|
258
|
+
* @param ctx - Host Cordis context of this row.
|
|
259
|
+
* @param config - Row configuration; `themesDir` and `backgroundsDir` override
|
|
260
|
+
* the default locations.
|
|
261
|
+
*/
|
|
262
|
+
export function apply(ctx, config) {
|
|
263
|
+
const themes = themesDirectory(config, dshHome())
|
|
264
|
+
const backgrounds = backgroundsDirectory(config, dshHome())
|
|
265
|
+
ctx.logger.info('dsh-custom-theme: themes directory %s', themes)
|
|
266
|
+
ctx.logger.info('dsh-custom-theme: backgrounds directory %s', backgrounds)
|
|
267
|
+
const ready = Promise.all([
|
|
268
|
+
seedThemes(themes).then((ids) => {
|
|
269
|
+
if (ids.length > 0) ctx.logger.info('dsh-custom-theme: seeded %s', ids.join(', '))
|
|
270
|
+
}),
|
|
271
|
+
// The background directory holds only user-supplied images, so it is created
|
|
272
|
+
// but never seeded; the card lists it as empty until something is dropped in.
|
|
273
|
+
mkdir(backgrounds, { recursive: true }),
|
|
274
|
+
]).then(() => {}, (error) => {
|
|
275
|
+
ctx.logger.warn('dsh-custom-theme: preparing directories failed: %s', error.message)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
ctx.inject(['webServer'], (child) => {
|
|
279
|
+
child.effect(() => child.webServer.register({
|
|
280
|
+
kind: 'prefix',
|
|
281
|
+
path: ROUTE_PATH,
|
|
282
|
+
handler: (req, res) => handleRoute(req, res, { themes, backgrounds, ready }).catch((error) => {
|
|
283
|
+
child.logger.warn('dsh-custom-theme: %s failed: %s', req.url, error.message)
|
|
284
|
+
if (!res.headersSent) send(res, 500, 'text/plain; charset=utf-8', 'theme read failed')
|
|
285
|
+
}),
|
|
286
|
+
}), 'dsh-custom-theme: route')
|
|
287
|
+
child.logger.info('dsh-custom-theme: serving %s', ROUTE_PATH)
|
|
288
|
+
})
|
|
289
|
+
}
|
package/src/themes.mjs
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Pure theme-directory model: id validation, picker ordering, directory
|
|
2
|
+
// resolution. No filesystem and no Cordis, so `node --test` drives it directly.
|
|
3
|
+
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
/** Distinct bundle themes, in picker order. Mirrors the ids of `themes/*.css`. */
|
|
7
|
+
export const BUNDLED_THEME_IDS = ['gov', 'monokai-pro', 'one-dark']
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Seed generation.
|
|
11
|
+
*
|
|
12
|
+
* Bumping it re-syncs the bundled stylesheets on the next start, because a
|
|
13
|
+
* corrected or extended bundled palette has to reach a machine that already has
|
|
14
|
+
* the older copy. Files whose id is not bundled are never touched, and
|
|
15
|
+
* `themes/*.css` is documented as managed so a customised palette belongs in a
|
|
16
|
+
* copy under its own name.
|
|
17
|
+
*/
|
|
18
|
+
export const SEED_VERSION = 2
|
|
19
|
+
|
|
20
|
+
/** Refuse to serve or read a stylesheet larger than this; a theme is a few KiB. */
|
|
21
|
+
export const MAX_THEME_BYTES = 1024 * 1024
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Theme ids that may reach the filesystem. Must start alphanumeric, so a name
|
|
25
|
+
* can never be `.`, `..`, or a dotfile, and contains no separator.
|
|
26
|
+
*/
|
|
27
|
+
const THEME_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether a request-supplied id may be turned into a path.
|
|
31
|
+
* @param value - Candidate id from a request path or a directory entry.
|
|
32
|
+
* @returns True when the id is safe to join under the theme directory.
|
|
33
|
+
*/
|
|
34
|
+
export function isThemeId(value) {
|
|
35
|
+
return typeof value === 'string' && THEME_ID.test(value)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the theme directory: an explicit `themesDir` config value wins, then
|
|
40
|
+
* `<home>/themes`.
|
|
41
|
+
* @param config - Row configuration from the profile patch; every field is optional.
|
|
42
|
+
* @param home - The DSH home directory, resolved by the caller.
|
|
43
|
+
* @returns Theme directory path.
|
|
44
|
+
*/
|
|
45
|
+
export function themesDirectory(config, home) {
|
|
46
|
+
if (typeof config?.themesDir === 'string' && config.themesDir.trim() !== '') return config.themesDir
|
|
47
|
+
return join(home, 'themes')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Order the ids found in the theme directory: bundled ids first in their fixed
|
|
52
|
+
* order, then every other id in alphabet order.
|
|
53
|
+
* @param ids - Ids present in the directory, in any order and possibly with duplicates.
|
|
54
|
+
* @returns Ordered, de-duplicated ids.
|
|
55
|
+
*/
|
|
56
|
+
export function orderThemeIds(ids) {
|
|
57
|
+
const unique = [...new Set(ids)].filter(isThemeId)
|
|
58
|
+
const bundled = BUNDLED_THEME_IDS.filter((id) => unique.includes(id))
|
|
59
|
+
const extra = unique
|
|
60
|
+
.filter((id) => !BUNDLED_THEME_IDS.includes(id))
|
|
61
|
+
.sort((left, right) => left.localeCompare(right))
|
|
62
|
+
return [...bundled, ...extra]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether an id is one of the themes this package seeds.
|
|
67
|
+
* @param id - Candidate id.
|
|
68
|
+
* @returns True for a bundled id.
|
|
69
|
+
*/
|
|
70
|
+
export function isBundledThemeId(id) {
|
|
71
|
+
return BUNDLED_THEME_IDS.includes(id)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Background image extensions this plugin serves, with their media types. */
|
|
75
|
+
const BACKGROUND_TYPES = new Map([
|
|
76
|
+
['.png', 'image/png'],
|
|
77
|
+
['.jpg', 'image/jpeg'],
|
|
78
|
+
['.jpeg', 'image/jpeg'],
|
|
79
|
+
['.webp', 'image/webp'],
|
|
80
|
+
['.gif', 'image/gif'],
|
|
81
|
+
['.avif', 'image/avif'],
|
|
82
|
+
['.bmp', 'image/bmp'],
|
|
83
|
+
])
|
|
84
|
+
|
|
85
|
+
/** Refuse to serve an image larger than this. */
|
|
86
|
+
export const MAX_BACKGROUND_BYTES = 16 * 1024 * 1024
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether a request-supplied name may be turned into a path under the
|
|
90
|
+
* background directory: a valid id plus one served image extension.
|
|
91
|
+
* @param value - Candidate file name from a request path or a directory entry.
|
|
92
|
+
* @returns True when the name is safe to join under the background directory.
|
|
93
|
+
*/
|
|
94
|
+
export function isBackgroundName(value) {
|
|
95
|
+
if (typeof value !== 'string') return false
|
|
96
|
+
const dot = value.lastIndexOf('.')
|
|
97
|
+
if (dot <= 0) return false
|
|
98
|
+
return isThemeId(value.slice(0, dot)) && BACKGROUND_TYPES.has(value.slice(dot).toLowerCase())
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Media type for a background file name.
|
|
103
|
+
* @param name - File name already accepted by {@link isBackgroundName}.
|
|
104
|
+
* @returns The media type; `application/octet-stream` when the extension is unknown.
|
|
105
|
+
*/
|
|
106
|
+
export function backgroundContentType(name) {
|
|
107
|
+
return BACKGROUND_TYPES.get(name.slice(name.lastIndexOf('.')).toLowerCase()) ?? 'application/octet-stream'
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolve the background directory: an explicit `backgroundsDir` config value
|
|
112
|
+
* wins, then `<home>/backgrounds`. Nothing is seeded here; the directory only
|
|
113
|
+
* holds images the user put there.
|
|
114
|
+
* @param config - Row configuration from the profile patch; every field is optional.
|
|
115
|
+
* @param home - The DSH home directory, resolved by the caller.
|
|
116
|
+
* @returns Background directory path.
|
|
117
|
+
*/
|
|
118
|
+
export function backgroundsDirectory(config, home) {
|
|
119
|
+
if (typeof config?.backgroundsDir === 'string' && config.backgroundsDir.trim() !== '') return config.backgroundsDir
|
|
120
|
+
return join(home, 'backgrounds')
|
|
121
|
+
}
|
package/themes/gov.css
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 政务主题 — 宣纸底色、中国红品牌色、国徽金强调色。
|
|
3
|
+
*
|
|
4
|
+
* Ported from Deeptop's `themes/gov.css`, which defines both palettes in one file
|
|
5
|
+
* exactly as this one does: `:root` is the light set and `:root[data-theme="dark"]`
|
|
6
|
+
* the dark set. The plugin reads each set separately and hands them to the
|
|
7
|
+
* official theme runtime, so the appearance switch moves between them.
|
|
8
|
+
*
|
|
9
|
+
* Deeptop's own variable names map onto the official `--dsw-*` alias tokens:
|
|
10
|
+
* --surface → bg-base, --surface-raised → bg-layer-1, --surface-soft → bg-layer-2,
|
|
11
|
+
* --line → border-l1, --line-strong → border-l2, --accent → brand-primary,
|
|
12
|
+
* --ink → label-primary, --ink-muted → label-secondary, --danger/--good/--warning
|
|
13
|
+
* → the state colours, --rail → the sidebar fill. `color-scheme` is deliberately
|
|
14
|
+
* not carried over: the shell and the appearance preference own it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
:root {
|
|
18
|
+
--dsw-alias-bg-base: #f4eee3;
|
|
19
|
+
--dsw-alias-bg-layer-1: #fbf6ec;
|
|
20
|
+
--dsw-alias-bg-layer-2: #eae2d2;
|
|
21
|
+
--dsw-alias-bg-overlay: #fbf6ec;
|
|
22
|
+
--dsw-alias-border-l1: #d6cdb8;
|
|
23
|
+
--dsw-alias-border-l2: #b8ac92;
|
|
24
|
+
--dsw-alias-brand-primary: #c8161d;
|
|
25
|
+
--dsw-alias-label-primary: #1a1714;
|
|
26
|
+
--dsw-alias-label-secondary: #5a544a;
|
|
27
|
+
--dsw-alias-state-error-primary: #a02a2a;
|
|
28
|
+
--dsw-alias-state-idle-primary: #8a8270;
|
|
29
|
+
--dsw-alias-state-success-primary: #5a7d3d;
|
|
30
|
+
--dsw-alias-state-warn-primary: #b6812a;
|
|
31
|
+
--dsw-specific-sidebar-fill: #e6ddc9;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
:root[data-theme="dark"] {
|
|
35
|
+
--dsw-alias-bg-base: #1f1a14;
|
|
36
|
+
--dsw-alias-bg-layer-1: #2a231a;
|
|
37
|
+
--dsw-alias-bg-layer-2: #3b3324;
|
|
38
|
+
--dsw-alias-bg-overlay: #2a231a;
|
|
39
|
+
--dsw-alias-border-l1: #3b3324;
|
|
40
|
+
--dsw-alias-border-l2: #54483a;
|
|
41
|
+
--dsw-alias-brand-primary: #e64c50;
|
|
42
|
+
--dsw-alias-label-primary: #f0e6d2;
|
|
43
|
+
--dsw-alias-label-secondary: #a89a82;
|
|
44
|
+
--dsw-alias-state-error-primary: #e64c50;
|
|
45
|
+
--dsw-alias-state-idle-primary: #7a6f5c;
|
|
46
|
+
--dsw-alias-state-success-primary: #82ac5c;
|
|
47
|
+
--dsw-alias-state-warn-primary: #d4a24c;
|
|
48
|
+
--dsw-specific-sidebar-fill: #1f1a14;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
body {
|
|
52
|
+
font-family: "Source Han Serif SC", "Noto Serif SC", "Songti SC", SimSun, serif;
|
|
53
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Monokai Pro — both palettes in one file, ported from Deeptop's
|
|
3
|
+
* `themes/monokai-pro.css`. Mapping onto the official `--dsw-*` alias tokens is
|
|
4
|
+
* documented in `gov.css`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
:root {
|
|
8
|
+
--dsw-alias-bg-base: #fbfaf9;
|
|
9
|
+
--dsw-alias-bg-layer-1: #ffffff;
|
|
10
|
+
--dsw-alias-bg-layer-2: #f1efed;
|
|
11
|
+
--dsw-alias-bg-overlay: #ffffff;
|
|
12
|
+
--dsw-alias-border-l1: #ddd9d7;
|
|
13
|
+
--dsw-alias-border-l2: #cbc6c4;
|
|
14
|
+
--dsw-alias-brand-primary: #2d9bb3;
|
|
15
|
+
--dsw-alias-label-primary: #2d2a2e;
|
|
16
|
+
--dsw-alias-label-secondary: #5e5a60;
|
|
17
|
+
--dsw-alias-state-error-primary: #d6455f;
|
|
18
|
+
--dsw-alias-state-idle-primary: #8a868b;
|
|
19
|
+
--dsw-alias-state-success-primary: #3f9d5c;
|
|
20
|
+
--dsw-alias-state-warn-primary: #b37a1e;
|
|
21
|
+
--dsw-specific-sidebar-fill: #efedeb;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
:root[data-theme="dark"] {
|
|
25
|
+
--dsw-alias-bg-base: #2d2a2e;
|
|
26
|
+
--dsw-alias-bg-layer-1: #373438;
|
|
27
|
+
--dsw-alias-bg-layer-2: #3f3c41;
|
|
28
|
+
--dsw-alias-bg-overlay: #373438;
|
|
29
|
+
--dsw-alias-border-l1: #3a373b;
|
|
30
|
+
--dsw-alias-border-l2: #4d4a4f;
|
|
31
|
+
--dsw-alias-brand-primary: #78dce8;
|
|
32
|
+
--dsw-alias-label-primary: #fcfcfa;
|
|
33
|
+
--dsw-alias-label-secondary: #c5c2c7;
|
|
34
|
+
--dsw-alias-state-error-primary: #ff6188;
|
|
35
|
+
--dsw-alias-state-idle-primary: #9a979d;
|
|
36
|
+
--dsw-alias-state-success-primary: #a9dc76;
|
|
37
|
+
--dsw-alias-state-warn-primary: #ffd866;
|
|
38
|
+
--dsw-specific-sidebar-fill: #262326;
|
|
39
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* One Dark — both palettes in one file, ported from Deeptop's
|
|
3
|
+
* `themes/one-dark.css`. Mapping onto the official `--dsw-*` alias tokens is
|
|
4
|
+
* documented in `gov.css`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
:root {
|
|
8
|
+
--dsw-alias-bg-base: #f6f8fb;
|
|
9
|
+
--dsw-alias-bg-layer-1: #ffffff;
|
|
10
|
+
--dsw-alias-bg-layer-2: #eef2f6;
|
|
11
|
+
--dsw-alias-bg-overlay: #ffffff;
|
|
12
|
+
--dsw-alias-border-l1: #d9e0e8;
|
|
13
|
+
--dsw-alias-border-l2: #c3cedb;
|
|
14
|
+
--dsw-alias-brand-primary: #4078c0;
|
|
15
|
+
--dsw-alias-label-primary: #282c34;
|
|
16
|
+
--dsw-alias-label-secondary: #5f6672;
|
|
17
|
+
--dsw-alias-state-error-primary: #d73a49;
|
|
18
|
+
--dsw-alias-state-idle-primary: #8b93a1;
|
|
19
|
+
--dsw-alias-state-success-primary: #22863a;
|
|
20
|
+
--dsw-alias-state-warn-primary: #b08800;
|
|
21
|
+
--dsw-specific-sidebar-fill: #edf1f6;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
:root[data-theme="dark"] {
|
|
25
|
+
--dsw-alias-bg-base: #282c34;
|
|
26
|
+
--dsw-alias-bg-layer-1: #2c313a;
|
|
27
|
+
--dsw-alias-bg-layer-2: #323842;
|
|
28
|
+
--dsw-alias-bg-overlay: #2c313a;
|
|
29
|
+
--dsw-alias-border-l1: #3a404c;
|
|
30
|
+
--dsw-alias-border-l2: #4b5263;
|
|
31
|
+
--dsw-alias-brand-primary: #61afef;
|
|
32
|
+
--dsw-alias-label-primary: #abb2bf;
|
|
33
|
+
--dsw-alias-label-secondary: #9da5b4;
|
|
34
|
+
--dsw-alias-state-error-primary: #e06c75;
|
|
35
|
+
--dsw-alias-state-idle-primary: #5c6370;
|
|
36
|
+
--dsw-alias-state-success-primary: #98c379;
|
|
37
|
+
--dsw-alias-state-warn-primary: #e5c07b;
|
|
38
|
+
--dsw-specific-sidebar-fill: #21252b;
|
|
39
|
+
}
|