dsh-cool-theme 0.4.0 → 0.6.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/README.md +10 -10
- package/README.zh.md +10 -10
- package/lib/client.js +1940 -75
- package/lib/index.js +830 -6
- package/lib/types/client/components.d.ts +43 -3
- package/lib/types/client/components.d.ts.map +1 -1
- package/lib/types/client/custom.d.ts +124 -0
- package/lib/types/client/custom.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +124 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/presets.d.ts +19 -835
- package/lib/types/client/presets.d.ts.map +1 -1
- package/lib/types/client/theme-files.d.ts +51 -0
- package/lib/types/client/theme-files.d.ts.map +1 -0
- package/lib/types/client/theme.d.ts.map +1 -1
- package/lib/types/client/tokens.d.ts +16 -3
- package/lib/types/client/tokens.d.ts.map +1 -1
- package/lib/types/contract.d.ts +78 -0
- package/lib/types/contract.d.ts.map +1 -1
- package/lib/types/host/index.d.ts +6 -3
- package/lib/types/host/index.d.ts.map +1 -1
- package/lib/types/host/theme-files.d.ts +44 -0
- package/lib/types/host/theme-files.d.ts.map +1 -0
- package/lib/types/host/theme-share.d.ts +47 -0
- package/lib/types/host/theme-share.d.ts.map +1 -0
- package/lib/types/host/zip.d.ts +39 -0
- package/lib/types/host/zip.d.ts.map +1 -0
- package/lib/types/presets-ids.d.ts +17 -0
- package/lib/types/presets-ids.d.ts.map +1 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,11 +1,835 @@
|
|
|
1
|
-
|
|
1
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { deflateRawSync, inflateRawSync } from "node:zlib";
|
|
5
|
+
//#region src/contract.ts
|
|
6
|
+
/**
|
|
7
|
+
* The Host's theme-file API, all below one prefix route. Kept here so the two
|
|
8
|
+
* halves cannot drift on a path.
|
|
9
|
+
*/
|
|
10
|
+
const THEME_API_PREFIX = "/cool-theme/api";
|
|
11
|
+
const THEME_API_PATH_THEMES = `${THEME_API_PREFIX}/themes`;
|
|
12
|
+
/**
|
|
13
|
+
* Share archive: one theme directory zipped. The document keeps the name it has
|
|
14
|
+
* on disk, so an archive is a copy of the directory rather than a new format,
|
|
15
|
+
* and media added later travels with it under {@link THEME_ARCHIVE_ASSETS_DIR}.
|
|
16
|
+
*/
|
|
17
|
+
const THEME_ARCHIVE_DOCUMENT = "theme.json";
|
|
18
|
+
const THEME_ARCHIVE_ASSETS_DIR = "assets";
|
|
19
|
+
/** Content type of an exported archive, and the extension a name gets. */
|
|
20
|
+
const THEME_ARCHIVE_MIME = "application/zip";
|
|
21
|
+
const THEME_ARCHIVE_EXTENSION = ".zip";
|
|
22
|
+
/**
|
|
23
|
+
* Import bounds. A theme is a small JSON document, so anything past these caps
|
|
24
|
+
* is refused before it is parsed rather than after it has been held in memory.
|
|
25
|
+
*/
|
|
26
|
+
const THEME_ARCHIVE_MAX_BYTES = 8388608;
|
|
27
|
+
/** Cap on one uncompressed entry, so a zip bomb cannot expand without bound. */
|
|
28
|
+
const THEME_ARCHIVE_MAX_ENTRY_BYTES = 4194304;
|
|
29
|
+
const THEME_API_PATH_IMPORT = `${THEME_API_PATH_THEMES}/import`;
|
|
30
|
+
/**
|
|
31
|
+
* Name the Host suggests for a downloaded archive. The archive keeps the
|
|
32
|
+
* theme's name so a user can tell two exports apart, with the characters a
|
|
33
|
+
* filesystem refuses folded to spaces; the id is the fallback for a name that
|
|
34
|
+
* folds away to nothing.
|
|
35
|
+
*/
|
|
36
|
+
function archiveFileName(name, id) {
|
|
37
|
+
const folded = name.replace(/[\\/:*?"<>|]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 64);
|
|
38
|
+
const base = folded === "" ? id : folded;
|
|
39
|
+
return base.endsWith(".zip") ? base : `${base}${THEME_ARCHIVE_EXTENSION}`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* `base`, else `${base} 2`, `${base} 3`, … An imported theme keeps the name it
|
|
43
|
+
* was shared under — that name is the point of the share — so a clash is
|
|
44
|
+
* resolved by numbering rather than by renaming the incoming theme. The Host
|
|
45
|
+
* owns this because it owns the roster the names have to be free in.
|
|
46
|
+
*/
|
|
47
|
+
function freeThemeName(base, taken) {
|
|
48
|
+
const name = base.trim() === "" ? "theme" : base.trim();
|
|
49
|
+
const used = new Set(taken.map((entry) => entry.trim()));
|
|
50
|
+
if (!used.has(name)) return name;
|
|
51
|
+
for (let n = 2;; n += 1) {
|
|
52
|
+
const candidate = `${name} ${n}`;
|
|
53
|
+
if (!used.has(candidate)) return candidate;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/presets-ids.ts
|
|
58
|
+
/**
|
|
59
|
+
* The preset ids, in one place both halves can read.
|
|
60
|
+
*
|
|
61
|
+
* `presets.ts` carries the colour maps and is client-only; the Host needs the
|
|
62
|
+
* same id vocabulary to tell an archive that derives from a preset we ship from
|
|
63
|
+
* one that does not, so the names live here and that file checks itself against
|
|
64
|
+
* them.
|
|
65
|
+
*/
|
|
66
|
+
const PRESET_IDS = [
|
|
67
|
+
"aura",
|
|
68
|
+
"ayu",
|
|
69
|
+
"catppuccin",
|
|
70
|
+
"catppuccin-frappe",
|
|
71
|
+
"catppuccin-macchiato",
|
|
72
|
+
"cobalt2",
|
|
73
|
+
"cursor",
|
|
74
|
+
"dracula",
|
|
75
|
+
"dsh",
|
|
76
|
+
"everforest",
|
|
77
|
+
"flexoki",
|
|
78
|
+
"github",
|
|
79
|
+
"gruvbox",
|
|
80
|
+
"kanagawa",
|
|
81
|
+
"lucent-orng",
|
|
82
|
+
"material",
|
|
83
|
+
"matrix",
|
|
84
|
+
"mercury",
|
|
85
|
+
"monokai",
|
|
86
|
+
"nightowl",
|
|
87
|
+
"nord",
|
|
88
|
+
"onedark",
|
|
89
|
+
"opencode",
|
|
90
|
+
"orng",
|
|
91
|
+
"osaka-jade",
|
|
92
|
+
"palenight",
|
|
93
|
+
"rosepine",
|
|
94
|
+
"solarized",
|
|
95
|
+
"synthwave84",
|
|
96
|
+
"system",
|
|
97
|
+
"tokyonight",
|
|
98
|
+
"vercel",
|
|
99
|
+
"vesper",
|
|
100
|
+
"zenburn"
|
|
101
|
+
];
|
|
102
|
+
/**
|
|
103
|
+
* Whether a theme may name `base` and still be resolved here. `native` is the
|
|
104
|
+
* legacy alias for `dsh`, so a theme that carries it stays usable.
|
|
105
|
+
*/
|
|
106
|
+
function isKnownBase(value) {
|
|
107
|
+
return value === "native" || PRESET_IDS.includes(value);
|
|
108
|
+
}
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/host/theme-files.ts
|
|
2
111
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
112
|
+
* Theme files owned by the Host.
|
|
113
|
+
*
|
|
114
|
+
* One directory per theme below `$DSH_HOME/cool-theme/themes/<id>/`, holding the
|
|
115
|
+
* document at `theme.json` and — once themes carry media — the originals under
|
|
116
|
+
* `assets/`. Keeping a theme a self-contained directory is what makes the later
|
|
117
|
+
* export a copy of that directory rather than a reassembly of scattered rows.
|
|
6
118
|
*/
|
|
119
|
+
/** Directory name of the theme document inside one theme's own directory. */
|
|
120
|
+
const DOCUMENT_NAME = "theme.json";
|
|
121
|
+
/**
|
|
122
|
+
* Theme ids become directory names, so they are restricted to a conservative
|
|
123
|
+
* slug: anything else could escape or alias the themes root.
|
|
124
|
+
*/
|
|
125
|
+
const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
126
|
+
function isThemeId(value) {
|
|
127
|
+
return typeof value === "string" && ID_PATTERN.test(value);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The harness home, resolved the way the rest of DSH resolves it: an explicit
|
|
131
|
+
* `DSH_HOME`, else `~/.dsh`.
|
|
132
|
+
*/
|
|
133
|
+
function harnessHome() {
|
|
134
|
+
const configured = process.env.DSH_HOME?.trim();
|
|
135
|
+
return configured && configured !== "" ? configured : join(homedir(), ".dsh");
|
|
136
|
+
}
|
|
137
|
+
/** Root of every stored theme. */
|
|
138
|
+
function themesRoot() {
|
|
139
|
+
return join(harnessHome(), "cool-theme", "themes");
|
|
140
|
+
}
|
|
141
|
+
/** One theme's own directory. Callers must have validated `id`. */
|
|
142
|
+
function themeDir(id) {
|
|
143
|
+
return join(themesRoot(), id);
|
|
144
|
+
}
|
|
145
|
+
function documentPath(id) {
|
|
146
|
+
return join(themeDir(id), DOCUMENT_NAME);
|
|
147
|
+
}
|
|
148
|
+
function isRecord$2(value) {
|
|
149
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Read one document back into the shape this plugin writes. Returns null when
|
|
153
|
+
* the file is missing or unusable; a corrupt theme must not take the roster
|
|
154
|
+
* down with it, so the caller skips it and keeps the rest.
|
|
155
|
+
*/
|
|
156
|
+
async function readDocument(id) {
|
|
157
|
+
let raw;
|
|
158
|
+
try {
|
|
159
|
+
raw = await readFile(documentPath(id), "utf8");
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const parsed = JSON.parse(raw);
|
|
165
|
+
if (!isRecord$2(parsed)) return null;
|
|
166
|
+
if (!isThemeId(parsed.id) || parsed.id !== id) return null;
|
|
167
|
+
if (typeof parsed.name !== "string" || typeof parsed.base !== "string") return null;
|
|
168
|
+
const assets = Array.isArray(parsed.assets) ? parsed.assets.filter(isAsset) : [];
|
|
169
|
+
return {
|
|
170
|
+
v: typeof parsed.v === "number" ? parsed.v : 1,
|
|
171
|
+
id: parsed.id,
|
|
172
|
+
name: parsed.name,
|
|
173
|
+
base: parsed.base,
|
|
174
|
+
theme: parsed.theme,
|
|
175
|
+
assets,
|
|
176
|
+
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
177
|
+
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : (/* @__PURE__ */ new Date(0)).toISOString()
|
|
178
|
+
};
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function isAsset(value) {
|
|
184
|
+
if (!isRecord$2(value)) return false;
|
|
185
|
+
return typeof value.name === "string" && (value.kind === "image" || value.kind === "video") && typeof value.mime === "string" && typeof value.bytes === "number";
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Write one document where a reader either sees the previous file or the next
|
|
189
|
+
* one — never a half-written one: the bytes land in a sibling temp file that is
|
|
190
|
+
* renamed over the target.
|
|
191
|
+
*/
|
|
192
|
+
async function writeDocument(doc) {
|
|
193
|
+
const dir = themeDir(doc.id);
|
|
194
|
+
await mkdir(dir, { recursive: true });
|
|
195
|
+
const temp = join(dir, `${DOCUMENT_NAME}.tmp`);
|
|
196
|
+
await writeFile(temp, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
|
|
197
|
+
await rename(temp, documentPath(doc.id));
|
|
198
|
+
}
|
|
199
|
+
/** Every stored theme, oldest by creation time first; unreadable directories are skipped. */
|
|
200
|
+
async function listThemes() {
|
|
201
|
+
let entries;
|
|
202
|
+
try {
|
|
203
|
+
entries = await readdir(themesRoot());
|
|
204
|
+
} catch {
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
const documents = [];
|
|
208
|
+
for (const id of entries) {
|
|
209
|
+
if (!isThemeId(id)) continue;
|
|
210
|
+
const doc = await readDocument(id);
|
|
211
|
+
if (doc !== null) documents.push(doc);
|
|
212
|
+
}
|
|
213
|
+
documents.sort((a, b) => a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0);
|
|
214
|
+
return documents;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Insert or replace one document. `createdAt` is the caller's when the theme is
|
|
218
|
+
* new and the stored value when it already exists, so a later rename or edit
|
|
219
|
+
* cannot rewrite when the theme was made.
|
|
220
|
+
*/
|
|
221
|
+
async function putTheme(input) {
|
|
222
|
+
const existing = await readDocument(input.id);
|
|
223
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
224
|
+
const doc = {
|
|
225
|
+
v: 1,
|
|
226
|
+
id: input.id,
|
|
227
|
+
name: input.name,
|
|
228
|
+
base: input.base,
|
|
229
|
+
theme: input.theme,
|
|
230
|
+
assets: input.assets,
|
|
231
|
+
createdAt: existing?.createdAt ?? now,
|
|
232
|
+
updatedAt: now
|
|
233
|
+
};
|
|
234
|
+
await writeDocument(doc);
|
|
235
|
+
return doc;
|
|
236
|
+
}
|
|
237
|
+
/** Remove one theme directory. A missing directory is already the goal. */
|
|
238
|
+
async function deleteTheme(id) {
|
|
239
|
+
await rm(themeDir(id), {
|
|
240
|
+
recursive: true,
|
|
241
|
+
force: true
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* A fresh theme id. The same shape the client mints (`ct_` + time + noise), so
|
|
246
|
+
* an id never says which half created it, and `isThemeId` is the only rule both
|
|
247
|
+
* halves apply.
|
|
248
|
+
*/
|
|
249
|
+
function newThemeId() {
|
|
250
|
+
return `ct_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
|
251
|
+
}
|
|
252
|
+
//#endregion
|
|
253
|
+
//#region src/host/zip.ts
|
|
254
|
+
/**
|
|
255
|
+
* The little of ZIP that sharing a theme needs.
|
|
256
|
+
*
|
|
257
|
+
* A theme is one small JSON document plus whatever media sits beside it, so the
|
|
258
|
+
* archive format is worth exactly this much: a writer that emits stored/deflated
|
|
259
|
+
* entries with a central directory, and a reader that walks that directory back.
|
|
260
|
+
* Node owns the compression (`zlib.deflateRawSync` / `inflateRawSync`); the two
|
|
261
|
+
* checksums and the record layout are all that has to be written here, which
|
|
262
|
+
* keeps the plugin dependency-free.
|
|
263
|
+
*
|
|
264
|
+
* Deliberately unsupported, because nothing this plugin writes ever produces
|
|
265
|
+
* them: zip64, encrypted entries, multi-disk archives, and data descriptors.
|
|
266
|
+
*/
|
|
267
|
+
/** Local file header. */
|
|
268
|
+
const SIG_LOCAL = 67324752;
|
|
269
|
+
/** Central directory file header. */
|
|
270
|
+
const SIG_CENTRAL = 33639248;
|
|
271
|
+
/** End of central directory. */
|
|
272
|
+
const SIG_EOCD = 101010256;
|
|
273
|
+
/** What a reader must find at the start of every entry this writer emits. */
|
|
274
|
+
const FLAG_UTF8 = 2048;
|
|
275
|
+
const METHOD_STORE = 0;
|
|
276
|
+
const METHOD_DEFLATE = 8;
|
|
277
|
+
/** zip64 escape markers; anything carrying one is refused rather than misread. */
|
|
278
|
+
const ZIP64_LIMIT = 4294967295;
|
|
279
|
+
const ZIP64_ENTRIES = 65535;
|
|
280
|
+
const U32_LIMIT = 4294967295;
|
|
281
|
+
const CRC_TABLE = (() => {
|
|
282
|
+
const table = /* @__PURE__ */ new Uint32Array(256);
|
|
283
|
+
for (let i = 0; i < 256; i += 1) {
|
|
284
|
+
let value = i;
|
|
285
|
+
for (let bit = 0; bit < 8; bit += 1) value = (value & 1) === 1 ? 3988292384 ^ value >>> 1 : value >>> 1;
|
|
286
|
+
table[i] = value >>> 0;
|
|
287
|
+
}
|
|
288
|
+
return table;
|
|
289
|
+
})();
|
|
290
|
+
/** CRC-32 of one entry, as the archive records it. */
|
|
291
|
+
function crc32(data) {
|
|
292
|
+
let crc = 4294967295;
|
|
293
|
+
for (let i = 0; i < data.length; i += 1) crc = CRC_TABLE[(crc ^ data[i]) & 255] ^ crc >>> 8;
|
|
294
|
+
return (crc ^ 4294967295) >>> 0;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Pack entries into one archive. Directory entries are not emitted: every
|
|
298
|
+
* consumer this format targets infers them from the entry paths.
|
|
299
|
+
*/
|
|
300
|
+
function createZip(entries) {
|
|
301
|
+
const locals = [];
|
|
302
|
+
const centrals = [];
|
|
303
|
+
let offset = 0;
|
|
304
|
+
for (const entry of entries) {
|
|
305
|
+
const name = Buffer.from(entry.name, "utf8");
|
|
306
|
+
const raw = entry.data;
|
|
307
|
+
if (raw.byteLength > U32_LIMIT) throw new Error(`entry too large to archive: ${entry.name}`);
|
|
308
|
+
const deflated = deflateRawSync(raw);
|
|
309
|
+
const stored = deflated.byteLength >= raw.byteLength;
|
|
310
|
+
const body = stored ? raw : deflated;
|
|
311
|
+
const method = stored ? METHOD_STORE : METHOD_DEFLATE;
|
|
312
|
+
const crc = crc32(raw);
|
|
313
|
+
const local = Buffer.alloc(30);
|
|
314
|
+
local.writeUInt32LE(SIG_LOCAL, 0);
|
|
315
|
+
local.writeUInt16LE(20, 4);
|
|
316
|
+
local.writeUInt16LE(FLAG_UTF8, 6);
|
|
317
|
+
local.writeUInt16LE(method, 8);
|
|
318
|
+
local.writeUInt16LE(0, 10);
|
|
319
|
+
local.writeUInt16LE(0, 12);
|
|
320
|
+
local.writeUInt32LE(crc, 14);
|
|
321
|
+
local.writeUInt32LE(body.byteLength, 18);
|
|
322
|
+
local.writeUInt32LE(raw.byteLength, 22);
|
|
323
|
+
local.writeUInt16LE(name.byteLength, 26);
|
|
324
|
+
local.writeUInt16LE(0, 28);
|
|
325
|
+
locals.push(local, name, body);
|
|
326
|
+
const central = Buffer.alloc(46);
|
|
327
|
+
central.writeUInt32LE(SIG_CENTRAL, 0);
|
|
328
|
+
central.writeUInt16LE(20, 4);
|
|
329
|
+
central.writeUInt16LE(20, 6);
|
|
330
|
+
central.writeUInt16LE(FLAG_UTF8, 8);
|
|
331
|
+
central.writeUInt16LE(method, 10);
|
|
332
|
+
central.writeUInt16LE(0, 12);
|
|
333
|
+
central.writeUInt16LE(0, 14);
|
|
334
|
+
central.writeUInt32LE(crc, 16);
|
|
335
|
+
central.writeUInt32LE(body.byteLength, 20);
|
|
336
|
+
central.writeUInt32LE(raw.byteLength, 24);
|
|
337
|
+
central.writeUInt16LE(name.byteLength, 28);
|
|
338
|
+
central.writeUInt16LE(0, 30);
|
|
339
|
+
central.writeUInt16LE(0, 32);
|
|
340
|
+
central.writeUInt16LE(0, 34);
|
|
341
|
+
central.writeUInt16LE(0, 36);
|
|
342
|
+
central.writeUInt32LE(0, 38);
|
|
343
|
+
central.writeUInt32LE(offset, 42);
|
|
344
|
+
centrals.push(central, name);
|
|
345
|
+
offset += local.byteLength + name.byteLength + body.byteLength;
|
|
346
|
+
}
|
|
347
|
+
const centralBuffer = Buffer.concat(centrals);
|
|
348
|
+
if (entries.length > ZIP64_ENTRIES || offset > U32_LIMIT || centralBuffer.byteLength > U32_LIMIT) throw new Error("archive too large for the supported zip flavour");
|
|
349
|
+
const end = Buffer.alloc(22);
|
|
350
|
+
end.writeUInt32LE(SIG_EOCD, 0);
|
|
351
|
+
end.writeUInt16LE(0, 4);
|
|
352
|
+
end.writeUInt16LE(0, 6);
|
|
353
|
+
end.writeUInt16LE(entries.length, 8);
|
|
354
|
+
end.writeUInt16LE(entries.length, 10);
|
|
355
|
+
end.writeUInt32LE(centralBuffer.byteLength, 12);
|
|
356
|
+
end.writeUInt32LE(offset, 16);
|
|
357
|
+
end.writeUInt16LE(0, 20);
|
|
358
|
+
return Buffer.concat([
|
|
359
|
+
...locals,
|
|
360
|
+
centralBuffer,
|
|
361
|
+
end
|
|
362
|
+
]);
|
|
363
|
+
}
|
|
364
|
+
function findEndOfCentralDirectory(input) {
|
|
365
|
+
const earliest = Math.max(0, input.length - 22 - 65535);
|
|
366
|
+
for (let at = input.length - 22; at >= earliest; at -= 1) if (input.readUInt32LE(at) === SIG_EOCD) return at;
|
|
367
|
+
return -1;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Entry bytes from one archive, with `filter` deciding which entries are worth
|
|
371
|
+
* inflating. Throws on anything this writer would not have produced, so a file
|
|
372
|
+
* that is not one of our archives fails as "unreadable" rather than as a
|
|
373
|
+
* half-parsed theme.
|
|
374
|
+
*/
|
|
375
|
+
function readZip(input, options = {}) {
|
|
376
|
+
const { filter, maxEntryBytes } = options;
|
|
377
|
+
const end = findEndOfCentralDirectory(input);
|
|
378
|
+
if (end < 0) throw new Error("not a zip archive");
|
|
379
|
+
const count = input.readUInt16LE(end + 10);
|
|
380
|
+
const centralSize = input.readUInt32LE(end + 12);
|
|
381
|
+
const centralOffset = input.readUInt32LE(end + 16);
|
|
382
|
+
if (count === ZIP64_ENTRIES || centralOffset === ZIP64_LIMIT || centralSize === ZIP64_LIMIT) throw new Error("zip64 archives are not supported");
|
|
383
|
+
if (centralOffset + centralSize > input.length) throw new Error("zip central directory is out of range");
|
|
384
|
+
const entries = [];
|
|
385
|
+
let at = centralOffset;
|
|
386
|
+
for (let index = 0; index < count; index += 1) {
|
|
387
|
+
if (at + 46 > input.length || input.readUInt32LE(at) !== SIG_CENTRAL) throw new Error("zip central directory is malformed");
|
|
388
|
+
const method = input.readUInt16LE(at + 10);
|
|
389
|
+
const compressedSize = input.readUInt32LE(at + 20);
|
|
390
|
+
const uncompressedSize = input.readUInt32LE(at + 24);
|
|
391
|
+
const nameLength = input.readUInt16LE(at + 28);
|
|
392
|
+
const extraLength = input.readUInt16LE(at + 30);
|
|
393
|
+
const commentLength = input.readUInt16LE(at + 32);
|
|
394
|
+
const localOffset = input.readUInt32LE(at + 42);
|
|
395
|
+
if (compressedSize === ZIP64_LIMIT || uncompressedSize === ZIP64_LIMIT || localOffset === ZIP64_LIMIT) throw new Error("zip64 archives are not supported");
|
|
396
|
+
const name = input.toString("utf8", at + 46, at + 46 + nameLength);
|
|
397
|
+
if (localOffset + 30 > input.length || input.readUInt32LE(localOffset) !== SIG_LOCAL) throw new Error("zip local header is malformed");
|
|
398
|
+
const localNameLength = input.readUInt16LE(localOffset + 26);
|
|
399
|
+
const localExtraLength = input.readUInt16LE(localOffset + 28);
|
|
400
|
+
const dataStart = localOffset + 30 + localNameLength + localExtraLength;
|
|
401
|
+
const dataEnd = dataStart + compressedSize;
|
|
402
|
+
if (dataEnd > input.length) throw new Error("zip entry data is out of range");
|
|
403
|
+
if (filter === void 0 || filter(name)) {
|
|
404
|
+
if (maxEntryBytes !== void 0 && uncompressedSize > maxEntryBytes) throw new Error(`zip entry too large: ${name}`);
|
|
405
|
+
const body = input.subarray(dataStart, dataEnd);
|
|
406
|
+
if (method === METHOD_STORE) entries.push({
|
|
407
|
+
name,
|
|
408
|
+
data: Buffer.from(body)
|
|
409
|
+
});
|
|
410
|
+
else if (method === METHOD_DEFLATE) entries.push({
|
|
411
|
+
name,
|
|
412
|
+
data: inflateRawSync(body)
|
|
413
|
+
});
|
|
414
|
+
else throw new Error(`unsupported zip compression method ${String(method)}`);
|
|
415
|
+
}
|
|
416
|
+
at += 46 + nameLength + extraLength + commentLength;
|
|
417
|
+
}
|
|
418
|
+
return entries;
|
|
419
|
+
}
|
|
420
|
+
//#endregion
|
|
421
|
+
//#region src/host/theme-share.ts
|
|
422
|
+
/**
|
|
423
|
+
* Theme sharing: packing one stored theme into an archive and reading one back.
|
|
424
|
+
*
|
|
425
|
+
* An archive is the theme directory itself — the same `theme.json` and `assets/`
|
|
426
|
+
* a theme occupies on disk — so sharing never invents a second format. The Host
|
|
427
|
+
* owns both halves because the browser can neither read the theme's files nor
|
|
428
|
+
* decompress an archive on its behalf.
|
|
429
|
+
*
|
|
430
|
+
* Reading is deliberately strict: an archive either validates as one of ours or
|
|
431
|
+
* is refused with a reason. Nothing half-valid is ever handed to the client to
|
|
432
|
+
* repair, because a foreign or damaged file must not be able to seed the roster.
|
|
433
|
+
*/
|
|
434
|
+
function isRecord$1(value) {
|
|
435
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
436
|
+
}
|
|
437
|
+
/** Accepted media extensions, mapped to the type the Host serves them with. */
|
|
438
|
+
const ASSET_MIME = {
|
|
439
|
+
".png": {
|
|
440
|
+
kind: "image",
|
|
441
|
+
mime: "image/png"
|
|
442
|
+
},
|
|
443
|
+
".jpg": {
|
|
444
|
+
kind: "image",
|
|
445
|
+
mime: "image/jpeg"
|
|
446
|
+
},
|
|
447
|
+
".jpeg": {
|
|
448
|
+
kind: "image",
|
|
449
|
+
mime: "image/jpeg"
|
|
450
|
+
},
|
|
451
|
+
".webp": {
|
|
452
|
+
kind: "image",
|
|
453
|
+
mime: "image/webp"
|
|
454
|
+
},
|
|
455
|
+
".gif": {
|
|
456
|
+
kind: "image",
|
|
457
|
+
mime: "image/gif"
|
|
458
|
+
},
|
|
459
|
+
".avif": {
|
|
460
|
+
kind: "image",
|
|
461
|
+
mime: "image/avif"
|
|
462
|
+
},
|
|
463
|
+
".svg": {
|
|
464
|
+
kind: "image",
|
|
465
|
+
mime: "image/svg+xml"
|
|
466
|
+
},
|
|
467
|
+
".mp4": {
|
|
468
|
+
kind: "video",
|
|
469
|
+
mime: "video/mp4"
|
|
470
|
+
},
|
|
471
|
+
".webm": {
|
|
472
|
+
kind: "video",
|
|
473
|
+
mime: "video/webm"
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
/** A share refused for a reason worth showing the user. */
|
|
477
|
+
var ArchiveError = class extends Error {};
|
|
478
|
+
/**
|
|
479
|
+
* Pack one theme directory. A theme with no media still gets an archive holding
|
|
480
|
+
* its document alone, so sharing a plain theme needs no second code path.
|
|
481
|
+
*/
|
|
482
|
+
async function packTheme(id) {
|
|
483
|
+
const raw = await readFile(join(themeDir(id), THEME_ARCHIVE_DOCUMENT), "utf8");
|
|
484
|
+
const doc = JSON.parse(raw);
|
|
485
|
+
const entries = [{
|
|
486
|
+
name: THEME_ARCHIVE_DOCUMENT,
|
|
487
|
+
data: Buffer.from(`${JSON.stringify(doc, null, 2)}\n`, "utf8")
|
|
488
|
+
}];
|
|
489
|
+
const assets = [];
|
|
490
|
+
const dir = join(themeDir(id), THEME_ARCHIVE_ASSETS_DIR);
|
|
491
|
+
let names = [];
|
|
492
|
+
try {
|
|
493
|
+
names = await readdir(dir);
|
|
494
|
+
} catch {
|
|
495
|
+
names = [];
|
|
496
|
+
}
|
|
497
|
+
for (const name of names.sort()) {
|
|
498
|
+
const type = ASSET_MIME[extensionOf(name)];
|
|
499
|
+
if (!type) continue;
|
|
500
|
+
const path = join(dir, name);
|
|
501
|
+
if (!(await stat(path)).isFile()) continue;
|
|
502
|
+
const bytes = await readFile(path);
|
|
503
|
+
entries.push({
|
|
504
|
+
name: `${THEME_ARCHIVE_ASSETS_DIR}/${name}`,
|
|
505
|
+
data: bytes
|
|
506
|
+
});
|
|
507
|
+
assets.push({
|
|
508
|
+
name,
|
|
509
|
+
kind: type.kind,
|
|
510
|
+
mime: type.mime,
|
|
511
|
+
bytes: bytes.byteLength
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
const packed = {
|
|
515
|
+
id: doc.id,
|
|
516
|
+
name: doc.name,
|
|
517
|
+
base: doc.base,
|
|
518
|
+
theme: doc.theme,
|
|
519
|
+
assets
|
|
520
|
+
};
|
|
521
|
+
return {
|
|
522
|
+
fileName: doc.name,
|
|
523
|
+
bytes: createZip(entries),
|
|
524
|
+
document: packed
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
function extensionOf(name) {
|
|
528
|
+
const dot = name.lastIndexOf(".");
|
|
529
|
+
return dot < 0 ? "" : name.slice(dot).toLowerCase();
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Read an archive back into a theme. Only the document and its media are
|
|
533
|
+
* accepted; every path is required to be exactly one of those two shapes, so a
|
|
534
|
+
* directory entry or a nested path is refused rather than normalised.
|
|
535
|
+
*/
|
|
536
|
+
function unpackTheme(archive) {
|
|
537
|
+
let entries;
|
|
538
|
+
try {
|
|
539
|
+
entries = readZip(archive, {
|
|
540
|
+
maxEntryBytes: THEME_ARCHIVE_MAX_ENTRY_BYTES,
|
|
541
|
+
filter: (name) => name === "theme.json" || name.startsWith(`assets/`) && name.length > 7
|
|
542
|
+
});
|
|
543
|
+
} catch (error) {
|
|
544
|
+
throw new ArchiveError(error instanceof Error ? error.message : String(error));
|
|
545
|
+
}
|
|
546
|
+
if (entries.length > 256) throw new ArchiveError("archive carries too many files");
|
|
547
|
+
const documentEntry = entries.find((entry) => entry.name === THEME_ARCHIVE_DOCUMENT);
|
|
548
|
+
if (!documentEntry) throw new ArchiveError(`archive is missing ${THEME_ARCHIVE_DOCUMENT}`);
|
|
549
|
+
let parsed;
|
|
550
|
+
try {
|
|
551
|
+
parsed = JSON.parse(documentEntry.data.toString("utf8").replace(/^\uFEFF/, ""));
|
|
552
|
+
} catch {
|
|
553
|
+
throw new ArchiveError(`${THEME_ARCHIVE_DOCUMENT} is not valid JSON`);
|
|
554
|
+
}
|
|
555
|
+
if (!isRecord$1(parsed)) throw new ArchiveError(`${THEME_ARCHIVE_DOCUMENT} must be an object`);
|
|
556
|
+
const version = typeof parsed.v === "number" ? parsed.v : 1;
|
|
557
|
+
if (version > 1) throw new ArchiveError(`theme format v${String(version)} is newer than this plugin reads`);
|
|
558
|
+
if (!isThemeId(parsed.id)) throw new ArchiveError("theme id must be a safe slug");
|
|
559
|
+
if (typeof parsed.name !== "string" || parsed.name.trim() === "") throw new ArchiveError("theme name must be a non-empty string");
|
|
560
|
+
if (typeof parsed.base !== "string") throw new ArchiveError("theme base must be a string");
|
|
561
|
+
if (!isRecord$1(parsed.theme)) throw new ArchiveError("theme colours must be an object");
|
|
562
|
+
const assets = [];
|
|
563
|
+
for (const entry of entries) {
|
|
564
|
+
if (entry.name === "theme.json") continue;
|
|
565
|
+
const name = entry.name.slice(7);
|
|
566
|
+
if (name.includes("/") || name.includes("\\") || name === "" || name.startsWith(".")) throw new ArchiveError(`archive contains an unusable media path: ${entry.name}`);
|
|
567
|
+
const type = ASSET_MIME[extensionOf(name)];
|
|
568
|
+
if (!type) throw new ArchiveError(`unsupported media type: ${name}`);
|
|
569
|
+
assets.push({
|
|
570
|
+
name,
|
|
571
|
+
bytes: entry.data,
|
|
572
|
+
kind: type.kind,
|
|
573
|
+
mime: type.mime
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
if (assets.length > 0) throw new ArchiveError(`this theme carries media (${assets.map((asset) => asset.name).join(", ")}); remove the ${THEME_ARCHIVE_ASSETS_DIR} folder from the archive and share it again`);
|
|
577
|
+
return {
|
|
578
|
+
document: {
|
|
579
|
+
id: parsed.id,
|
|
580
|
+
name: parsed.name,
|
|
581
|
+
base: parsed.base,
|
|
582
|
+
theme: parsed.theme,
|
|
583
|
+
assets: assets.map((asset) => ({
|
|
584
|
+
name: asset.name,
|
|
585
|
+
kind: asset.kind,
|
|
586
|
+
mime: asset.mime,
|
|
587
|
+
bytes: asset.bytes.byteLength
|
|
588
|
+
}))
|
|
589
|
+
},
|
|
590
|
+
assets
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
//#endregion
|
|
594
|
+
//#region src/host/index.ts
|
|
7
595
|
const name = "dsh-cool-theme";
|
|
8
|
-
const inject = [];
|
|
9
|
-
|
|
596
|
+
const inject = ["webServer"];
|
|
597
|
+
/** Refuse cross-site callers: this API reads and writes the user's own files. */
|
|
598
|
+
function sameOrigin(req) {
|
|
599
|
+
const origin = req.headers.origin;
|
|
600
|
+
if (typeof origin !== "string" || origin === "" || origin === "null") return true;
|
|
601
|
+
const host = req.headers.host;
|
|
602
|
+
if (typeof host !== "string" || host === "") return false;
|
|
603
|
+
try {
|
|
604
|
+
return new URL(origin).host === host;
|
|
605
|
+
} catch {
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
function sendJson(res, status, body) {
|
|
610
|
+
res.writeHead(status, {
|
|
611
|
+
"content-type": "application/json; charset=utf-8",
|
|
612
|
+
"cache-control": "no-store"
|
|
613
|
+
});
|
|
614
|
+
res.end(JSON.stringify(body));
|
|
615
|
+
}
|
|
616
|
+
/** Answer 405 unless the request uses one of the allowed methods. */
|
|
617
|
+
function methodAllowed(req, res, allowed) {
|
|
618
|
+
const method = req.method ?? "GET";
|
|
619
|
+
if (allowed.includes(method)) return true;
|
|
620
|
+
res.writeHead(405, { allow: allowed.join(", ") });
|
|
621
|
+
res.end();
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
/** Read a bounded request body; a theme document is small by construction. */
|
|
625
|
+
async function readBody(req, limit = 1e6) {
|
|
626
|
+
return (await readBuffer(req, limit)).toString("utf8");
|
|
627
|
+
}
|
|
628
|
+
/** Read a bounded binary body; the archive route needs the bytes themselves. */
|
|
629
|
+
async function readBuffer(req, limit) {
|
|
630
|
+
const chunks = [];
|
|
631
|
+
let size = 0;
|
|
632
|
+
for await (const chunk of req) {
|
|
633
|
+
const buf = chunk;
|
|
634
|
+
size += buf.byteLength;
|
|
635
|
+
if (size > limit) throw new Error("request body too large");
|
|
636
|
+
chunks.push(buf);
|
|
637
|
+
}
|
|
638
|
+
return Buffer.concat(chunks);
|
|
639
|
+
}
|
|
640
|
+
function isRecord(value) {
|
|
641
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
642
|
+
}
|
|
643
|
+
/** The roster, newest first, as the browser half reads it. */
|
|
644
|
+
async function handleList(res) {
|
|
645
|
+
sendJson(res, 200, {
|
|
646
|
+
ok: true,
|
|
647
|
+
themes: await listThemes()
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Upsert the posted documents. The browser half sends exactly the entries it
|
|
652
|
+
* changed, so one round trip covers save, rename and duplicate.
|
|
653
|
+
*/
|
|
654
|
+
async function handlePut(req, res) {
|
|
655
|
+
let payload;
|
|
656
|
+
try {
|
|
657
|
+
payload = JSON.parse(await readBody(req));
|
|
658
|
+
} catch {
|
|
659
|
+
sendJson(res, 400, {
|
|
660
|
+
ok: false,
|
|
661
|
+
error: "invalid JSON body"
|
|
662
|
+
});
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const incoming = isRecord(payload) && Array.isArray(payload.themes) ? payload.themes : null;
|
|
666
|
+
if (incoming === null) {
|
|
667
|
+
sendJson(res, 400, {
|
|
668
|
+
ok: false,
|
|
669
|
+
error: "expected { themes: [...] }"
|
|
670
|
+
});
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
const saved = [];
|
|
674
|
+
for (const raw of incoming) {
|
|
675
|
+
if (!isRecord(raw) || !isThemeId(raw.id)) {
|
|
676
|
+
sendJson(res, 400, {
|
|
677
|
+
ok: false,
|
|
678
|
+
error: "theme id must be a safe slug"
|
|
679
|
+
});
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (typeof raw.name !== "string" || raw.name.trim() === "") {
|
|
683
|
+
sendJson(res, 400, {
|
|
684
|
+
ok: false,
|
|
685
|
+
error: "theme name must be a non-empty string"
|
|
686
|
+
});
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (typeof raw.base !== "string") {
|
|
690
|
+
sendJson(res, 400, {
|
|
691
|
+
ok: false,
|
|
692
|
+
error: "theme base must be a string"
|
|
693
|
+
});
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
saved.push(await putTheme({
|
|
697
|
+
id: raw.id,
|
|
698
|
+
name: raw.name,
|
|
699
|
+
base: raw.base,
|
|
700
|
+
theme: raw.theme,
|
|
701
|
+
assets: Array.isArray(raw.assets) ? raw.assets : []
|
|
702
|
+
}));
|
|
703
|
+
}
|
|
704
|
+
sendJson(res, 200, {
|
|
705
|
+
ok: true,
|
|
706
|
+
themes: saved
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
/** Delete one theme directory. */
|
|
710
|
+
async function handleDelete(res, id) {
|
|
711
|
+
await deleteTheme(id);
|
|
712
|
+
sendJson(res, 200, { ok: true });
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Download one theme as an archive. The body is binary, so it is the only route
|
|
716
|
+
* here that does not answer JSON — and the only one that sets a filename.
|
|
717
|
+
*/
|
|
718
|
+
async function handleExport(res, id) {
|
|
719
|
+
const packed = await packTheme(id);
|
|
720
|
+
const name = archiveFileName(packed.fileName, id);
|
|
721
|
+
res.writeHead(200, {
|
|
722
|
+
"content-type": THEME_ARCHIVE_MIME,
|
|
723
|
+
"content-length": packed.bytes.byteLength,
|
|
724
|
+
"content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(name)}`,
|
|
725
|
+
"cache-control": "no-store"
|
|
726
|
+
});
|
|
727
|
+
res.end(packed.bytes);
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Read an uploaded archive back into a theme and store it.
|
|
731
|
+
*
|
|
732
|
+
* The Host owns the whole import: it validates the archive, names the theme and
|
|
733
|
+
* writes it, so an import is one file write that cannot half-happen. The client
|
|
734
|
+
* only learns what was stored, which keeps the roster's own writes — which
|
|
735
|
+
* rebuild a document from the current draft — from having to grow a second path
|
|
736
|
+
* just for imports.
|
|
737
|
+
*/
|
|
738
|
+
async function handleImport(req, res) {
|
|
739
|
+
let archive;
|
|
740
|
+
try {
|
|
741
|
+
archive = await readBuffer(req, THEME_ARCHIVE_MAX_BYTES);
|
|
742
|
+
} catch (error) {
|
|
743
|
+
sendJson(res, 413, {
|
|
744
|
+
ok: false,
|
|
745
|
+
error: error instanceof Error ? error.message : String(error)
|
|
746
|
+
});
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
let unpacked;
|
|
750
|
+
try {
|
|
751
|
+
unpacked = unpackTheme(archive);
|
|
752
|
+
} catch (error) {
|
|
753
|
+
if (!(error instanceof ArchiveError)) throw error;
|
|
754
|
+
sendJson(res, 400, {
|
|
755
|
+
ok: false,
|
|
756
|
+
error: error.message
|
|
757
|
+
});
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
const taken = (await listThemes()).map((doc) => doc.name);
|
|
761
|
+
sendJson(res, 200, {
|
|
762
|
+
ok: true,
|
|
763
|
+
theme: await putTheme({
|
|
764
|
+
id: newThemeId(),
|
|
765
|
+
name: freeThemeName(unpacked.document.name, taken),
|
|
766
|
+
base: isKnownBase(unpacked.document.base) ? unpacked.document.base : "dsh",
|
|
767
|
+
theme: unpacked.document.theme,
|
|
768
|
+
assets: []
|
|
769
|
+
})
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
function apply(ctx) {
|
|
773
|
+
const route = (handler) => ctx.webServer.register({
|
|
774
|
+
kind: "prefix",
|
|
775
|
+
path: THEME_API_PREFIX,
|
|
776
|
+
handler: (req, res) => {
|
|
777
|
+
if (!sameOrigin(req)) {
|
|
778
|
+
sendJson(res, 403, {
|
|
779
|
+
ok: false,
|
|
780
|
+
error: "cross-origin request refused"
|
|
781
|
+
});
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
return handler(req, res);
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
ctx.effect(() => {
|
|
788
|
+
const dispose = route(async (req, res) => {
|
|
789
|
+
const pathname = new URL(req.url ?? "/", "http://dsh.invalid").pathname;
|
|
790
|
+
try {
|
|
791
|
+
if (pathname === THEME_API_PATH_THEMES) {
|
|
792
|
+
if (!methodAllowed(req, res, ["GET", "POST"])) return;
|
|
793
|
+
if (req.method === "GET") return await handleList(res);
|
|
794
|
+
return await handlePut(req, res);
|
|
795
|
+
}
|
|
796
|
+
if (pathname === THEME_API_PATH_IMPORT) {
|
|
797
|
+
if (!methodAllowed(req, res, ["POST"])) return;
|
|
798
|
+
return await handleImport(req, res);
|
|
799
|
+
}
|
|
800
|
+
const rest = pathname.startsWith(`${THEME_API_PATH_THEMES}/`) ? pathname.slice(THEME_API_PATH_THEMES.length + 1) : null;
|
|
801
|
+
const segment = rest?.indexOf("/") ?? -1;
|
|
802
|
+
if (rest !== null && rest !== "import" && isThemeId(rest)) {
|
|
803
|
+
if (!methodAllowed(req, res, ["DELETE"])) return;
|
|
804
|
+
return await handleDelete(res, rest);
|
|
805
|
+
}
|
|
806
|
+
if (rest !== null && segment > 0 && rest.slice(segment + 1) === "export") {
|
|
807
|
+
const id = rest.slice(0, segment);
|
|
808
|
+
if (!isThemeId(id)) {
|
|
809
|
+
sendJson(res, 400, {
|
|
810
|
+
ok: false,
|
|
811
|
+
error: "theme id must be a safe slug"
|
|
812
|
+
});
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
if (!methodAllowed(req, res, ["GET"])) return;
|
|
816
|
+
return await handleExport(res, id);
|
|
817
|
+
}
|
|
818
|
+
sendJson(res, 404, {
|
|
819
|
+
ok: false,
|
|
820
|
+
error: "unknown route"
|
|
821
|
+
});
|
|
822
|
+
} catch (error) {
|
|
823
|
+
sendJson(res, 500, {
|
|
824
|
+
ok: false,
|
|
825
|
+
error: error instanceof Error ? error.message : String(error)
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
return () => {
|
|
830
|
+
dispose();
|
|
831
|
+
};
|
|
832
|
+
}, "dsh-cool-theme: theme-file routes");
|
|
833
|
+
}
|
|
10
834
|
//#endregion
|
|
11
835
|
export { apply, inject, name };
|