dsh-cool-theme 0.5.0 → 0.6.1
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 +3 -2
- package/README.zh.md +3 -2
- package/lib/client.js +476 -32
- package/lib/index.js +532 -6
- package/lib/types/client/components.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +22 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/presets.d.ts +8 -836
- package/lib/types/client/presets.d.ts.map +1 -1
- package/lib/types/client/theme-files.d.ts +14 -0
- package/lib/types/client/theme-files.d.ts.map +1 -1
- package/lib/types/client/theme.d.ts.map +1 -1
- package/lib/types/contract.d.ts +35 -0
- package/lib/types/contract.d.ts.map +1 -1
- package/lib/types/host/index.d.ts.map +1 -1
- package/lib/types/host/theme-files.d.ts +6 -0
- package/lib/types/host/theme-files.d.ts.map +1 -1
- 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 +2 -2
package/lib/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { deflateRawSync, inflateRawSync } from "node:zlib";
|
|
4
5
|
//#region src/contract.ts
|
|
5
6
|
/**
|
|
6
7
|
* The Host's theme-file API, all below one prefix route. Kept here so the two
|
|
@@ -8,6 +9,103 @@ import { join } from "node:path";
|
|
|
8
9
|
*/
|
|
9
10
|
const THEME_API_PREFIX = "/cool-theme/api";
|
|
10
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
|
+
}
|
|
11
109
|
//#endregion
|
|
12
110
|
//#region src/host/theme-files.ts
|
|
13
111
|
/**
|
|
@@ -47,7 +145,7 @@ function themeDir(id) {
|
|
|
47
145
|
function documentPath(id) {
|
|
48
146
|
return join(themeDir(id), DOCUMENT_NAME);
|
|
49
147
|
}
|
|
50
|
-
function isRecord$
|
|
148
|
+
function isRecord$2(value) {
|
|
51
149
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
52
150
|
}
|
|
53
151
|
/**
|
|
@@ -64,7 +162,7 @@ async function readDocument(id) {
|
|
|
64
162
|
}
|
|
65
163
|
try {
|
|
66
164
|
const parsed = JSON.parse(raw);
|
|
67
|
-
if (!isRecord$
|
|
165
|
+
if (!isRecord$2(parsed)) return null;
|
|
68
166
|
if (!isThemeId(parsed.id) || parsed.id !== id) return null;
|
|
69
167
|
if (typeof parsed.name !== "string" || typeof parsed.base !== "string") return null;
|
|
70
168
|
const assets = Array.isArray(parsed.assets) ? parsed.assets.filter(isAsset) : [];
|
|
@@ -83,7 +181,7 @@ async function readDocument(id) {
|
|
|
83
181
|
}
|
|
84
182
|
}
|
|
85
183
|
function isAsset(value) {
|
|
86
|
-
if (!isRecord$
|
|
184
|
+
if (!isRecord$2(value)) return false;
|
|
87
185
|
return typeof value.name === "string" && (value.kind === "image" || value.kind === "video") && typeof value.mime === "string" && typeof value.bytes === "number";
|
|
88
186
|
}
|
|
89
187
|
/**
|
|
@@ -143,6 +241,355 @@ async function deleteTheme(id) {
|
|
|
143
241
|
force: true
|
|
144
242
|
});
|
|
145
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
|
+
}
|
|
146
593
|
//#endregion
|
|
147
594
|
//#region src/host/index.ts
|
|
148
595
|
const name = "dsh-cool-theme";
|
|
@@ -176,6 +623,10 @@ function methodAllowed(req, res, allowed) {
|
|
|
176
623
|
}
|
|
177
624
|
/** Read a bounded request body; a theme document is small by construction. */
|
|
178
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) {
|
|
179
630
|
const chunks = [];
|
|
180
631
|
let size = 0;
|
|
181
632
|
for await (const chunk of req) {
|
|
@@ -184,7 +635,7 @@ async function readBody(req, limit = 1e6) {
|
|
|
184
635
|
if (size > limit) throw new Error("request body too large");
|
|
185
636
|
chunks.push(buf);
|
|
186
637
|
}
|
|
187
|
-
return Buffer.concat(chunks)
|
|
638
|
+
return Buffer.concat(chunks);
|
|
188
639
|
}
|
|
189
640
|
function isRecord(value) {
|
|
190
641
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -260,6 +711,64 @@ async function handleDelete(res, id) {
|
|
|
260
711
|
await deleteTheme(id);
|
|
261
712
|
sendJson(res, 200, { ok: true });
|
|
262
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
|
+
}
|
|
263
772
|
function apply(ctx) {
|
|
264
773
|
const route = (handler) => ctx.webServer.register({
|
|
265
774
|
kind: "prefix",
|
|
@@ -284,11 +793,28 @@ function apply(ctx) {
|
|
|
284
793
|
if (req.method === "GET") return await handleList(res);
|
|
285
794
|
return await handlePut(req, res);
|
|
286
795
|
}
|
|
796
|
+
if (pathname === THEME_API_PATH_IMPORT) {
|
|
797
|
+
if (!methodAllowed(req, res, ["POST"])) return;
|
|
798
|
+
return await handleImport(req, res);
|
|
799
|
+
}
|
|
287
800
|
const rest = pathname.startsWith(`${THEME_API_PATH_THEMES}/`) ? pathname.slice(THEME_API_PATH_THEMES.length + 1) : null;
|
|
288
|
-
|
|
801
|
+
const segment = rest?.indexOf("/") ?? -1;
|
|
802
|
+
if (rest !== null && rest !== "import" && isThemeId(rest)) {
|
|
289
803
|
if (!methodAllowed(req, res, ["DELETE"])) return;
|
|
290
804
|
return await handleDelete(res, rest);
|
|
291
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
|
+
}
|
|
292
818
|
sendJson(res, 404, {
|
|
293
819
|
ok: false,
|
|
294
820
|
error: "unknown route"
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"components.d.ts","sourceRoot":"","sources":["../../../src/client/components.tsx"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,OAAO,EAAE,KAAK,QAAQ,EAAiB,MAAM,cAAc,CAAA;AAC3D,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,cAAc,CAAA;AAE5C,OAAO,EACL,KAAK,WAAW,EAGhB,KAAK,UAAU,EAIhB,MAAM,aAAa,CAAA;AAEpB,mEAAmE;AACnE,eAAO,MAAM,gBAAgB,WAAmB,CAAA;AAChD,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,OAAO,gBAAgB,CAAA;AAE1D,MAAM,MAAM,WAAW,GAAG;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,WAAW,CAAA;CAAE,CAAA;
|
|
1
|
+
{"version":3,"file":"components.d.ts","sourceRoot":"","sources":["../../../src/client/components.tsx"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,OAAO,EAAE,KAAK,QAAQ,EAAiB,MAAM,cAAc,CAAA;AAC3D,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,cAAc,CAAA;AAE5C,OAAO,EACL,KAAK,WAAW,EAGhB,KAAK,UAAU,EAIhB,MAAM,aAAa,CAAA;AAEpB,mEAAmE;AACnE,eAAO,MAAM,gBAAgB,WAAmB,CAAA;AAChD,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,OAAO,gBAAgB,CAAA;AAE1D,MAAM,MAAM,WAAW,GAAG;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,WAAW,CAAA;CAAE,CAAA;AAwiBhE,wBAAgB,UAAU,CAAC,KAAK,EAAE;IAChC,KAAK,EAAE,GAAG,CAAA;IACV,YAAY,EAAE,MAAM,SAAS,CAAA;IAC7B,YAAY,EAAE,CAAC,CAAC,EAAE,SAAS,KAAK,IAAI,CAAA;IACpC,SAAS,EAAE,MAAM,WAAW,CAAA;IAC5B,SAAS,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAA;IACvC,2EAA2E;IAC3E,aAAa,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,IAAI,CAAA;IACvC,WAAW,EAAE,MAAM,WAAW,CAAA;IAC9B,KAAK,EAAE;QACL,qDAAqD;QACrD,IAAI,EAAE,MAAM,UAAU,EAAE,CAAA;QACxB,QAAQ,EAAE,MAAM,MAAM,GAAG,IAAI,CAAA;QAC7B,IAAI,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,WAAW,GAAG,IAAI,CAAA;QACxC,kEAAkE;QAClE,SAAS,EAAE,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAA;QACtC,gFAAgF;QAChF,OAAO,EAAE,MAAM,OAAO,CAAC,UAAU,EAAE,CAAC,CAAA;QACpC,yEAAyE;QACzE,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,EAAE,CAAC,CAAA;QAC/C,oEAAoE;QACpE,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,EAAE,CAAC,CAAA;QAC3D,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,EAAE,CAAC,CAAA;KAC9C,CAAA;IACD;;;;OAIG;IACH,YAAY,EAAE,MAAM,CAAC,CAAC,KAAK,EAAE,GAAG,KAAK,GAAG,CAAC,GAAG,IAAI,CAAA;IAChD,CAAC,EAAE,CAAC,GAAG,EAAE,QAAQ,KAAK,MAAM,CAAA;IAC5B;;;;OAIG;IACH,cAAc,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAA;CACpC;;;;;;;gBA83BA"}
|
|
@@ -39,6 +39,8 @@ export declare const zh: {
|
|
|
39
39
|
'custom.save': string;
|
|
40
40
|
'custom.add': string;
|
|
41
41
|
'custom.edit': string;
|
|
42
|
+
'custom.export': string;
|
|
43
|
+
'custom.import': string;
|
|
42
44
|
'custom.name.placeholder': string;
|
|
43
45
|
'custom.defaultName': string;
|
|
44
46
|
'custom.inUse': string;
|
|
@@ -47,6 +49,15 @@ export declare const zh: {
|
|
|
47
49
|
'custom.toast.saved': string;
|
|
48
50
|
'custom.toast.failed': string;
|
|
49
51
|
'custom.toast.unavailable': string;
|
|
52
|
+
'custom.toast.exported': string;
|
|
53
|
+
'custom.toast.imported': string;
|
|
54
|
+
'custom.importFailed.notArchive': string;
|
|
55
|
+
'custom.importFailed.noDocument': string;
|
|
56
|
+
'custom.importFailed.badDocument': string;
|
|
57
|
+
'custom.importFailed.tooNew': string;
|
|
58
|
+
'custom.importFailed.media': string;
|
|
59
|
+
'custom.importFailed.tooLarge': string;
|
|
60
|
+
'custom.importFailed.other': string;
|
|
50
61
|
'custom.delete.title': string;
|
|
51
62
|
'custom.delete.desc': string;
|
|
52
63
|
'custom.unsaved.title': string;
|
|
@@ -103,6 +114,8 @@ export declare const en: {
|
|
|
103
114
|
'custom.save': string;
|
|
104
115
|
'custom.add': string;
|
|
105
116
|
'custom.edit': string;
|
|
117
|
+
'custom.export': string;
|
|
118
|
+
'custom.import': string;
|
|
106
119
|
'custom.name.placeholder': string;
|
|
107
120
|
'custom.defaultName': string;
|
|
108
121
|
'custom.inUse': string;
|
|
@@ -111,6 +124,15 @@ export declare const en: {
|
|
|
111
124
|
'custom.toast.saved': string;
|
|
112
125
|
'custom.toast.failed': string;
|
|
113
126
|
'custom.toast.unavailable': string;
|
|
127
|
+
'custom.toast.exported': string;
|
|
128
|
+
'custom.toast.imported': string;
|
|
129
|
+
'custom.importFailed.notArchive': string;
|
|
130
|
+
'custom.importFailed.noDocument': string;
|
|
131
|
+
'custom.importFailed.badDocument': string;
|
|
132
|
+
'custom.importFailed.tooNew': string;
|
|
133
|
+
'custom.importFailed.media': string;
|
|
134
|
+
'custom.importFailed.tooLarge': string;
|
|
135
|
+
'custom.importFailed.other': string;
|
|
114
136
|
'custom.delete.title': string;
|
|
115
137
|
'custom.delete.desc': string;
|
|
116
138
|
'custom.unsaved.title': string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"locales.d.ts","sourceRoot":"","sources":["../../../src/client/locales.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,mEAAmE;AACnE,eAAO,MAAM,EAAE
|
|
1
|
+
{"version":3,"file":"locales.d.ts","sourceRoot":"","sources":["../../../src/client/locales.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,mEAAmE;AACnE,eAAO,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyEmB,CAAA;AAElC,8CAA8C;AAC9C,MAAM,MAAM,QAAQ,GAAG,MAAM,OAAO,EAAE,CAAA;AAEtC,mEAAmE;AACnE,eAAO,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyEqB,CAAA"}
|