@spicetify/kit 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/bin/spicetify-kit.js +4 -0
- package/dist/build.js +335 -0
- package/dist/check.js +284 -0
- package/dist/classmap.js +187 -0
- package/dist/cli.js +53 -0
- package/dist/create.js +429 -0
- package/dist/dev.js +100 -0
- package/dist/from-theme.js +96 -0
- package/dist/install.js +60 -0
- package/dist/launch.js +122 -0
- package/dist/pack.js +31 -0
- package/dist/push.js +211 -0
- package/dist/vault.js +108 -0
- package/package.json +49 -0
- package/vendor/classmaps/1020096/classmap-19f856aefd5.json +109 -0
- package/vendor/shims/chunks.d.ts +10 -0
- package/vendor/shims/remote-modules.d.ts +6 -0
- package/vendor/shims/spicetify.d.ts +2437 -0
- package/vendor/stdlib/deps.ts +24 -0
- package/vendor/stdlib/index.ts +24 -0
- package/vendor/stdlib/lib/modal.tsx +128 -0
- package/vendor/stdlib/lib/popover.ts +84 -0
- package/vendor/stdlib/lib/primitives-classes.ts +66 -0
- package/vendor/stdlib/lib/primitives-vanilla.ts +277 -0
- package/vendor/stdlib/lib/primitives.tsx +314 -0
- package/vendor/stdlib/load.ts +17 -0
- package/vendor/stdlib/metadata.json +18 -0
- package/vendor/stdlib/mixin.ts +32 -0
- package/vendor/stdlib/mod.ts +26 -0
- package/vendor/stdlib/src/chunks.d.ts +10 -0
- package/vendor/stdlib/src/client.ts +114 -0
- package/vendor/stdlib/src/createIconComponent.tsx +29 -0
- package/vendor/stdlib/src/events.mix.ts +55 -0
- package/vendor/stdlib/src/events.ts +58 -0
- package/vendor/stdlib/src/expose/GraphQL.ts +40 -0
- package/vendor/stdlib/src/expose/Platform.ts +48 -0
- package/vendor/stdlib/src/expose/React.ts +95 -0
- package/vendor/stdlib/src/expose/ReactFlipToolkitSpring.ts +27 -0
- package/vendor/stdlib/src/expose/ReduxStore.ts +39 -0
- package/vendor/stdlib/src/expose/SettingsSection.ts +60 -0
- package/vendor/stdlib/src/expose/Snackbar.ts +34 -0
- package/vendor/stdlib/src/expose/Tippy.ts +26 -0
- package/vendor/stdlib/src/expose/enqueueImageSnackbar.ts +25 -0
- package/vendor/stdlib/src/expose/index.ts +14 -0
- package/vendor/stdlib/src/expose/jsx-runtime.ts +46 -0
- package/vendor/stdlib/src/expose/react-dom-shim.ts +50 -0
- package/vendor/stdlib/src/expose/react-shim.ts +116 -0
- package/vendor/stdlib/src/logger.ts +35 -0
- package/vendor/stdlib/src/playbar-compat.tsx +136 -0
- package/vendor/stdlib/src/registers/index.ts +176 -0
- package/vendor/stdlib/src/registers/menu.ts +160 -0
- package/vendor/stdlib/src/registers/mount.ts +307 -0
- package/vendor/stdlib/src/registers/nativeAnchors.ts +49 -0
- package/vendor/stdlib/src/registers/navlink.tsx +165 -0
- package/vendor/stdlib/src/registers/order.ts +22 -0
- package/vendor/stdlib/src/registers/panel.ts +168 -0
- package/vendor/stdlib/src/registers/playbarButton.tsx +93 -0
- package/vendor/stdlib/src/registers/playbarWidget.tsx +71 -0
- package/vendor/stdlib/src/registers/registry.ts +12 -0
- package/vendor/stdlib/src/registers/root.ts +101 -0
- package/vendor/stdlib/src/registers/route.ts +123 -0
- package/vendor/stdlib/src/registers/settingsRow.tsx +56 -0
- package/vendor/stdlib/src/registers/settingsSection.ts +59 -0
- package/vendor/stdlib/src/registers/topbarLeftButton.tsx +86 -0
- package/vendor/stdlib/src/registers/topbarRightButton.tsx +87 -0
- package/vendor/stdlib/src/storage.ts +163 -0
- package/vendor/stdlib/src/util.ts +62 -0
- package/vendor/stdlib/src/utils/index.ts +16 -0
- package/vendor/stdlib/src/webpack/ClassNames.gen.ts +10 -0
- package/vendor/stdlib/src/webpack/ClassNames.ts +6 -0
- package/vendor/stdlib/src/webpack/ClassNames.xpui.ts +16 -0
- package/vendor/stdlib/src/webpack/ComponentLibrary.gen.ts +10 -0
- package/vendor/stdlib/src/webpack/ComponentLibrary.ts +6 -0
- package/vendor/stdlib/src/webpack/ComponentLibrary.xpui.ts +18 -0
- package/vendor/stdlib/src/webpack/FilterContext.gen.ts +10 -0
- package/vendor/stdlib/src/webpack/FilterContext.ts +6 -0
- package/vendor/stdlib/src/webpack/FilterContext.xpui.ts +10 -0
- package/vendor/stdlib/src/webpack/Mousetrap.gen.ts +10 -0
- package/vendor/stdlib/src/webpack/Mousetrap.ts +6 -0
- package/vendor/stdlib/src/webpack/Mousetrap.xpui.ts +12 -0
- package/vendor/stdlib/src/webpack/React.gen.ts +14 -0
- package/vendor/stdlib/src/webpack/React.ts +6 -0
- package/vendor/stdlib/src/webpack/React.xpui.ts +15 -0
- package/vendor/stdlib/src/webpack/ReactComponents.desktop.ts +17 -0
- package/vendor/stdlib/src/webpack/ReactComponents.gen.ts +74 -0
- package/vendor/stdlib/src/webpack/ReactComponents.panel.ts +19 -0
- package/vendor/stdlib/src/webpack/ReactComponents.ts +50 -0
- package/vendor/stdlib/src/webpack/ReactComponents.xpui.ts +164 -0
- package/vendor/stdlib/src/webpack/ReactFlipToolkit.gen.ts +12 -0
- package/vendor/stdlib/src/webpack/ReactFlipToolkit.ts +6 -0
- package/vendor/stdlib/src/webpack/ReactFlipToolkit.xpui.ts +14 -0
- package/vendor/stdlib/src/webpack/ReactHooks.fullscreen.ts +15 -0
- package/vendor/stdlib/src/webpack/ReactHooks.gen.ts +32 -0
- package/vendor/stdlib/src/webpack/ReactHooks.ts +6 -0
- package/vendor/stdlib/src/webpack/ReactHooks.xpui.ts +38 -0
- package/vendor/stdlib/src/webpack/ReactQuery.gen.ts +26 -0
- package/vendor/stdlib/src/webpack/ReactQuery.ts +6 -0
- package/vendor/stdlib/src/webpack/ReactQuery.xpui.ts +46 -0
- package/vendor/stdlib/src/webpack/ReactRouter.gen.ts +12 -0
- package/vendor/stdlib/src/webpack/ReactRouter.ts +6 -0
- package/vendor/stdlib/src/webpack/ReactRouter.xpui.ts +22 -0
- package/vendor/stdlib/src/webpack/Snackbar.gen.ts +12 -0
- package/vendor/stdlib/src/webpack/Snackbar.ts +6 -0
- package/vendor/stdlib/src/webpack/Snackbar.xpui.ts +27 -0
- package/vendor/stdlib/src/webpack/URI.gen.ts +26 -0
- package/vendor/stdlib/src/webpack/URI.ts +82 -0
- package/vendor/stdlib/src/webpack/URI.xpui.ts +111 -0
- package/vendor/stdlib/src/webpack/capture-readiness.ts +49 -0
- package/vendor/stdlib/src/webpack/index.ts +186 -0
- package/vendor/stdlib/src/webpack/misc.gen.ts +16 -0
- package/vendor/stdlib/src/webpack/misc.ts +6 -0
- package/vendor/stdlib/src/webpack/misc.xpui.ts +27 -0
- package/vendor/stdlib/src/wpunpk.mix.ts +104 -0
- package/vendor/stdlib/src/wpunpk.ts +36 -0
- package/vendor/stdlib/vendor/rxjs.d.ts +5 -0
package/dist/classmap.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
const CLASSMAPS_REPO = "spicetify/classmaps";
|
|
10
|
+
function cacheDir() {
|
|
11
|
+
const base = process.env.XDG_CACHE_HOME ?? path.join(homedir(), ".cache");
|
|
12
|
+
return path.join(base, "spicetify-kit", "classmaps");
|
|
13
|
+
}
|
|
14
|
+
function latestClassmapFile(dir) {
|
|
15
|
+
if (!existsSync(dir))
|
|
16
|
+
return null;
|
|
17
|
+
const files = readdirSync(dir)
|
|
18
|
+
.filter((f) => /^classmap(-.*)?\.json$/.test(f))
|
|
19
|
+
.sort();
|
|
20
|
+
return files.length ? path.join(dir, files[files.length - 1]) : null;
|
|
21
|
+
}
|
|
22
|
+
function classmapKeyFromPath(filePath) {
|
|
23
|
+
return path.basename(path.dirname(filePath));
|
|
24
|
+
}
|
|
25
|
+
function localClassmapsDirs(cwd, config) {
|
|
26
|
+
const dirs = [];
|
|
27
|
+
if (config.classmapsDir)
|
|
28
|
+
dirs.push(path.resolve(cwd, config.classmapsDir));
|
|
29
|
+
dirs.push(path.join(cwd, "..", "classmaps"), path.join(cwd, "classmaps"));
|
|
30
|
+
return dirs;
|
|
31
|
+
}
|
|
32
|
+
// The classmap snapshot vendored into the published kit at prepack time
|
|
33
|
+
// (sync-vendor.ts), so a standalone author's first build works offline.
|
|
34
|
+
function vendoredClassmapsDir() {
|
|
35
|
+
// Env override is a test seam; in production it is the kit's own vendor dir.
|
|
36
|
+
return (process.env.SPICETIFY_KIT_VENDOR_CLASSMAPS ??
|
|
37
|
+
path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "vendor", "classmaps"));
|
|
38
|
+
}
|
|
39
|
+
// pickFromRoots resolves the newest classmap key across a set of <root>/<key>/
|
|
40
|
+
// dirs (or a specific key when wantKey is given). A higher key wins; on a tie
|
|
41
|
+
// the earlier root wins.
|
|
42
|
+
function pickFromRoots(roots, wantKey) {
|
|
43
|
+
let best = null;
|
|
44
|
+
for (const root of roots) {
|
|
45
|
+
if (!existsSync(root))
|
|
46
|
+
continue;
|
|
47
|
+
for (const k of readdirSync(root).filter((d) => /^\d{7}$/.test(d))) {
|
|
48
|
+
if (wantKey && k !== wantKey)
|
|
49
|
+
continue;
|
|
50
|
+
const file = latestClassmapFile(path.join(root, k));
|
|
51
|
+
if (file && (!best || k > best.key))
|
|
52
|
+
best = { key: k, file };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return best ? { path: best.file, key: best.key } : null;
|
|
56
|
+
}
|
|
57
|
+
async function githubJson(url) {
|
|
58
|
+
const res = await fetch(url, { headers: { accept: "application/vnd.github+json" } });
|
|
59
|
+
if (!res.ok)
|
|
60
|
+
throw new Error(`${url} -> HTTP ${res.status}`);
|
|
61
|
+
return res.json();
|
|
62
|
+
}
|
|
63
|
+
// fetchRemoteClassmap downloads a classmap (by key, or the newest key when
|
|
64
|
+
// none is given) from the published classmaps repo, caching it so later
|
|
65
|
+
// builds work offline.
|
|
66
|
+
async function fetchRemoteClassmap(key, opts = {}) {
|
|
67
|
+
const cache = cacheDir();
|
|
68
|
+
try {
|
|
69
|
+
let resolvedKey = key;
|
|
70
|
+
if (!resolvedKey) {
|
|
71
|
+
const entries = await githubJson(`https://api.github.com/repos/${CLASSMAPS_REPO}/contents/`);
|
|
72
|
+
const keys = entries
|
|
73
|
+
.filter((e) => e.type === "dir" && /^\d{7}$/.test(e.name))
|
|
74
|
+
.map((e) => e.name)
|
|
75
|
+
.sort();
|
|
76
|
+
if (!keys.length)
|
|
77
|
+
throw new Error("no classmap keys in remote repo");
|
|
78
|
+
resolvedKey = keys[keys.length - 1];
|
|
79
|
+
}
|
|
80
|
+
const cachedDir = path.join(cache, resolvedKey);
|
|
81
|
+
// --refresh (skipCache) bypasses the cache so a newer published map is
|
|
82
|
+
// fetched even when an older one is already cached.
|
|
83
|
+
const cached = opts.skipCache ? null : latestClassmapFile(cachedDir);
|
|
84
|
+
if (cached)
|
|
85
|
+
return { path: cached, key: resolvedKey };
|
|
86
|
+
const files = await githubJson(`https://api.github.com/repos/${CLASSMAPS_REPO}/contents/${resolvedKey}`);
|
|
87
|
+
const names = files
|
|
88
|
+
.filter((f) => f.type === "file" && /^classmap(-.*)?\.json$/.test(f.name))
|
|
89
|
+
.map((f) => f.name)
|
|
90
|
+
.sort();
|
|
91
|
+
if (!names.length)
|
|
92
|
+
throw new Error(`no classmap json under key ${resolvedKey}`);
|
|
93
|
+
const name = names[names.length - 1];
|
|
94
|
+
const raw = await fetch(`https://raw.githubusercontent.com/${CLASSMAPS_REPO}/main/${resolvedKey}/${name}`);
|
|
95
|
+
if (!raw.ok)
|
|
96
|
+
throw new Error(`classmap download -> HTTP ${raw.status}`);
|
|
97
|
+
const body = await raw.text();
|
|
98
|
+
JSON.parse(body);
|
|
99
|
+
mkdirSync(cachedDir, { recursive: true });
|
|
100
|
+
const target = path.join(cachedDir, name);
|
|
101
|
+
writeFileSync(target, body);
|
|
102
|
+
return { path: target, key: resolvedKey };
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
// Offline fallback: newest cached key, if any exists.
|
|
106
|
+
if (existsSync(cache)) {
|
|
107
|
+
const keys = readdirSync(cache)
|
|
108
|
+
.filter((d) => /^\d{7}$/.test(d))
|
|
109
|
+
.sort();
|
|
110
|
+
for (let i = keys.length - 1; i >= 0; i--) {
|
|
111
|
+
if (key && keys[i] !== key)
|
|
112
|
+
continue;
|
|
113
|
+
const file = latestClassmapFile(path.join(cache, keys[i]));
|
|
114
|
+
if (file)
|
|
115
|
+
return { path: file, key: keys[i] };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
throw new Error(`cannot fetch classmap from ${CLASSMAPS_REPO}: ${e.message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export async function resolveClassmap({ flag, config, cwd, refresh = false, }) {
|
|
122
|
+
const explicit = flag ?? config.classmap ?? null;
|
|
123
|
+
// A direct path (not a bare 7-digit key) wins outright.
|
|
124
|
+
if (explicit && !/^\d{7}$/.test(explicit)) {
|
|
125
|
+
const asPath = path.resolve(cwd, explicit);
|
|
126
|
+
if (existsSync(asPath))
|
|
127
|
+
return { path: asPath, key: classmapKeyFromPath(asPath) };
|
|
128
|
+
}
|
|
129
|
+
const wantKey = explicit && /^\d{7}$/.test(explicit) ? explicit : null;
|
|
130
|
+
// --refresh forces a network fetch (keyed cache-skip); a failed refresh
|
|
131
|
+
// warns and falls back to the normal resolution order below (KTD5).
|
|
132
|
+
if (refresh) {
|
|
133
|
+
try {
|
|
134
|
+
return await fetchRemoteClassmap(wantKey, { skipCache: true });
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
console.warn(`[classmap] --refresh failed (${e.message}); using local sources`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Local classmaps dirs (a monorepo checkout or a configured classmapsDir).
|
|
141
|
+
const local = pickFromRoots(localClassmapsDirs(cwd, config), wantKey);
|
|
142
|
+
if (local)
|
|
143
|
+
return local;
|
|
144
|
+
// Newest key across the vendored snapshot and the cache — offline-first, so
|
|
145
|
+
// a standalone author's first build needs no network. Cache is not strictly
|
|
146
|
+
// after vendored: a user who refreshed to a newer map is not shadowed.
|
|
147
|
+
const offline = pickFromRoots([vendoredClassmapsDir(), cacheDir()], wantKey);
|
|
148
|
+
if (offline)
|
|
149
|
+
return offline;
|
|
150
|
+
// Back-compat: a plain classmap.json next to the project.
|
|
151
|
+
if (!wantKey && existsSync(path.join(cwd, "classmap.json"))) {
|
|
152
|
+
return { path: path.join(cwd, "classmap.json"), key: "" };
|
|
153
|
+
}
|
|
154
|
+
// Last resort: fetch from the published classmaps repo.
|
|
155
|
+
return fetchRemoteClassmap(wantKey);
|
|
156
|
+
}
|
|
157
|
+
export function loadConfig(cwd) {
|
|
158
|
+
const configPath = path.join(cwd, "stitch.config.json");
|
|
159
|
+
if (!existsSync(configPath))
|
|
160
|
+
return {};
|
|
161
|
+
try {
|
|
162
|
+
return JSON.parse(readFileSync(configPath, "utf8"));
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return {};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// generateClassmapDts emits a global MAP declaration so module sources get
|
|
169
|
+
// typed classmap paths without importing anything.
|
|
170
|
+
export function generateClassmapDts(classmap) {
|
|
171
|
+
const render = (node, indent) => {
|
|
172
|
+
const pad = "\t".repeat(indent);
|
|
173
|
+
const lines = ["{"];
|
|
174
|
+
for (const key of Object.keys(node).sort()) {
|
|
175
|
+
const value = node[key];
|
|
176
|
+
if (typeof value === "string") {
|
|
177
|
+
lines.push(`${pad}\t${JSON.stringify(key)}: string;`);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
lines.push(`${pad}\t${JSON.stringify(key)}: ${render(value, indent + 1)};`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
lines.push(`${pad}}`);
|
|
184
|
+
return lines.join("\n");
|
|
185
|
+
};
|
|
186
|
+
return `declare global {\n\tconst MAP: ${render(classmap, 1)};\n}\n\nexport {};\n`;
|
|
187
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
const USAGE = `spicetify-kit - developer kit for spicetify v3 modules
|
|
6
|
+
|
|
7
|
+
usage: spicetify-kit <command> [args]
|
|
8
|
+
|
|
9
|
+
create <name> scaffold a new module project (--bare for monorepo layout)
|
|
10
|
+
check [module] audit a module against the module standard (advisory)
|
|
11
|
+
from-theme <dir> migrate a classic theme (user.css + color.ini) to a module
|
|
12
|
+
build [module...] bundle modules (rolldown + scss) into dist/
|
|
13
|
+
dev <module> watch, rebuild, and hot-push into a running client
|
|
14
|
+
pack <dist-dir> zip a built module and print its sha256
|
|
15
|
+
vault add <dir> record a built module into a vault file (--artifact <url>)
|
|
16
|
+
install <zip|dir> sideload a packed module into a running client
|
|
17
|
+
|
|
18
|
+
run a command with --help for its flags`;
|
|
19
|
+
export async function main(argv) {
|
|
20
|
+
const [command, ...rest] = argv;
|
|
21
|
+
try {
|
|
22
|
+
switch (command) {
|
|
23
|
+
case "create":
|
|
24
|
+
return await (await import("./create.js")).runCreate(rest);
|
|
25
|
+
case "check":
|
|
26
|
+
return await (await import("./check.js")).runCheck(rest);
|
|
27
|
+
case "from-theme":
|
|
28
|
+
return await (await import("./from-theme.js")).runFromTheme(rest);
|
|
29
|
+
case "build":
|
|
30
|
+
return await (await import("./build.js")).runBuild(rest);
|
|
31
|
+
case "dev":
|
|
32
|
+
return await (await import("./dev.js")).runDev(rest);
|
|
33
|
+
case "pack":
|
|
34
|
+
return await (await import("./pack.js")).runPack(rest);
|
|
35
|
+
case "vault":
|
|
36
|
+
return await (await import("./vault.js")).runVault(rest);
|
|
37
|
+
case "install":
|
|
38
|
+
return await (await import("./install.js")).runInstall(rest);
|
|
39
|
+
case undefined:
|
|
40
|
+
case "--help":
|
|
41
|
+
case "-h":
|
|
42
|
+
case "help":
|
|
43
|
+
console.log(USAGE);
|
|
44
|
+
return;
|
|
45
|
+
default:
|
|
46
|
+
throw new Error(`unknown command: ${command}\n\n${USAGE}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
console.error(e.message ?? e);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
}
|
package/dist/create.js
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* create - scaffold a new spicetify v3 module.
|
|
7
|
+
*
|
|
8
|
+
* Standalone (default): creates ./<name>/ as a self-contained project
|
|
9
|
+
* with metadata, sources, tsconfig wired to the kit's vendored stdlib
|
|
10
|
+
* types, and a package.json ready for `spicetify-kit build/dev`.
|
|
11
|
+
*
|
|
12
|
+
* --bare: emits only the module sources into modules/<name>/, for use
|
|
13
|
+
* inside the spicetify modules monorepo.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
// The stdlib range a fresh module declares tracks the stdlib it will compile
|
|
19
|
+
// against: the vendored copy in a published kit, the workspace copy in the
|
|
20
|
+
// monorepo. A hardcoded literal here went stale once (^0.3.0 outlived the
|
|
21
|
+
// 1.0.0 bump) and only the loader's compat vouch saved the scaffolds.
|
|
22
|
+
function stdlibRange() {
|
|
23
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const candidates = [
|
|
25
|
+
path.join(here, "..", "vendor", "stdlib", "metadata.json"),
|
|
26
|
+
path.join(here, "..", "..", "..", "modules", "stdlib", "metadata.json"),
|
|
27
|
+
];
|
|
28
|
+
for (const p of candidates) {
|
|
29
|
+
try {
|
|
30
|
+
const version = JSON.parse(readFileSync(p, "utf8")).version;
|
|
31
|
+
if (typeof version === "string" && version)
|
|
32
|
+
return `^${version}`;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* try the next source */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return "^1.0.0";
|
|
39
|
+
}
|
|
40
|
+
const USAGE = 'spicetify-kit create <name> [--template basic|extension|app|theme] [--description "..."] [--author "..."] [--bare]';
|
|
41
|
+
const HELP = `${USAGE}
|
|
42
|
+
|
|
43
|
+
templates:
|
|
44
|
+
basic a topbar button plus a route page (default)
|
|
45
|
+
extension behavior-only (a songchange listener) with a testable logic.ts
|
|
46
|
+
app a navlink plus a full route page built from the primitives
|
|
47
|
+
theme a css-only theme (color.ini + index.css; no TypeScript tooling)`;
|
|
48
|
+
const ICON_LITERAL = `'<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" stroke-width="1.5"/>'`;
|
|
49
|
+
// modTemplate returns the mod.tsx for a template. Every template follows the
|
|
50
|
+
// standard: it registers through createRegistrar (which auto-disposes),
|
|
51
|
+
// imports React only from stdlib, and self-subscribes/disposes its own
|
|
52
|
+
// listeners.
|
|
53
|
+
function modTemplate(template, name, header) {
|
|
54
|
+
const route = `/bespoke/${name}`;
|
|
55
|
+
if (template === "extension") {
|
|
56
|
+
return `${header}
|
|
57
|
+
import { client, type ModuleRuntimeContext } from "/modules/stdlib/mod.ts";
|
|
58
|
+
import { nowPlaying } from "./logic.ts";
|
|
59
|
+
|
|
60
|
+
// Extensions add behavior (and optionally small UI via a register). The
|
|
61
|
+
// golden rules: subscribe to client state yourself, and undo everything on
|
|
62
|
+
// unload via ctx.defer — a module that lingers after a reload is a bug.
|
|
63
|
+
// The typed client capability surface keeps the ambient wrapper behind one
|
|
64
|
+
// stdlib boundary. Testable logic lives in ./logic.ts and receives plain data.
|
|
65
|
+
|
|
66
|
+
export default async function (ctx: ModuleRuntimeContext) {
|
|
67
|
+
const onSongChange = () => {
|
|
68
|
+
// Runs on every track change. Replace with your behavior.
|
|
69
|
+
console.log("[${name}]", nowPlaying(client.player.data?.item));
|
|
70
|
+
};
|
|
71
|
+
client.player.addEventListener("songchange", onSongChange);
|
|
72
|
+
ctx.defer(() => client.player.removeEventListener("songchange", onSongChange));
|
|
73
|
+
}
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
76
|
+
if (template === "app") {
|
|
77
|
+
return `${header}
|
|
78
|
+
import { client, createRegistrar } from "/modules/stdlib/mod.ts";
|
|
79
|
+
import type { ModuleRuntimeContext } from "/modules/stdlib/mod.ts";
|
|
80
|
+
import { React } from "/modules/stdlib/src/expose/React.ts";
|
|
81
|
+
import { NavLink } from "/modules/stdlib/src/registers/navlink.tsx";
|
|
82
|
+
import { Button } from "/modules/stdlib/lib/primitives.js";
|
|
83
|
+
import { nowPlaying } from "./logic.ts";
|
|
84
|
+
|
|
85
|
+
const ROUTE = "${route}";
|
|
86
|
+
const ICON = ${ICON_LITERAL};
|
|
87
|
+
|
|
88
|
+
const Page = () => (
|
|
89
|
+
<div className="${name}-page">
|
|
90
|
+
<h1>${name}</h1>
|
|
91
|
+
<p>A full page at ${route}. Build it from the React primitives (lib/primitives).</p>
|
|
92
|
+
<p>Now playing: {nowPlaying(client.player.data?.item)}</p>
|
|
93
|
+
<Button variant="secondary" onClick={() => {}}>A kit button</Button>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
export default async function (ctx: ModuleRuntimeContext) {
|
|
98
|
+
const registrar = createRegistrar(ctx);
|
|
99
|
+
registrar.register("navlink", <NavLink localizedApp="${name}" appRoutePath={ROUTE} icon={ICON} activeIcon={ICON} />);
|
|
100
|
+
registrar.registerRoute(ROUTE, <Page />);
|
|
101
|
+
}
|
|
102
|
+
`;
|
|
103
|
+
}
|
|
104
|
+
return `${header}
|
|
105
|
+
import { client, createRegistrar } from "/modules/stdlib/mod.ts";
|
|
106
|
+
import type { ModuleRuntimeContext } from "/modules/stdlib/mod.ts";
|
|
107
|
+
import { React } from "/modules/stdlib/src/expose/React.ts";
|
|
108
|
+
import { Platform } from "/modules/stdlib/src/expose/Platform.ts";
|
|
109
|
+
import { TopbarRightButton } from "/modules/stdlib/src/registers/topbarRightButton.tsx";
|
|
110
|
+
import { nowPlaying } from "./logic.ts";
|
|
111
|
+
|
|
112
|
+
const ROUTE = "${route}";
|
|
113
|
+
const ICON = ${ICON_LITERAL};
|
|
114
|
+
|
|
115
|
+
const Page = () => (
|
|
116
|
+
<div className="${name}-page">
|
|
117
|
+
<h1>${name}</h1>
|
|
118
|
+
<p>Hello from ${name}. Edit mod.tsx and run the dev command to iterate live.</p>
|
|
119
|
+
<p>Now playing: {nowPlaying(client.player.data?.item)}</p>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
export default async function (ctx: ModuleRuntimeContext) {
|
|
124
|
+
const registrar = createRegistrar(ctx);
|
|
125
|
+
|
|
126
|
+
registrar.register(
|
|
127
|
+
"topbarRightButton",
|
|
128
|
+
<TopbarRightButton label="${name}" icon={ICON} onClick={() => Platform.getHistory().push(ROUTE)} />,
|
|
129
|
+
);
|
|
130
|
+
registrar.registerRoute(ROUTE, <Page />);
|
|
131
|
+
}
|
|
132
|
+
`;
|
|
133
|
+
}
|
|
134
|
+
// Pure, client-free logic — unit-testable. mod.tsx passes plain capability
|
|
135
|
+
// values into these functions, so a test exercises them without a client.
|
|
136
|
+
function logicTemplate(header) {
|
|
137
|
+
return `${header}
|
|
138
|
+
/**
|
|
139
|
+
* Pure, client-free module logic. Keep functions here dependency-free (no
|
|
140
|
+
* /modules/* or client imports) so they are unit-testable; mod.tsx passes
|
|
141
|
+
* plain values into them. Starter tests import this file,
|
|
142
|
+
* never mod.tsx.
|
|
143
|
+
*/
|
|
144
|
+
|
|
145
|
+
export function nowPlaying(item: { name?: string } | undefined): string {
|
|
146
|
+
return item?.name ?? "nothing playing";
|
|
147
|
+
}
|
|
148
|
+
`;
|
|
149
|
+
}
|
|
150
|
+
// The happy-dom harness the standard's test loop needs, written locally so a
|
|
151
|
+
// scaffolded project owns it and needs no cross-package resolution.
|
|
152
|
+
function setupTemplate(header) {
|
|
153
|
+
return `${header}
|
|
154
|
+
// DOM test harness: installs happy-dom's document/window and the common
|
|
155
|
+
// element/event constructors onto globalThis so DOM-building logic can be
|
|
156
|
+
// unit-tested under \`node --test\` with no browser. Import this FIRST in any
|
|
157
|
+
// *.test.mts that touches the DOM.
|
|
158
|
+
|
|
159
|
+
import { Window } from "happy-dom";
|
|
160
|
+
|
|
161
|
+
const win = new Window({ url: "https://xpui.app.spotify.com" });
|
|
162
|
+
|
|
163
|
+
for (
|
|
164
|
+
const key of [
|
|
165
|
+
"document",
|
|
166
|
+
"window",
|
|
167
|
+
"Node",
|
|
168
|
+
"Element",
|
|
169
|
+
"HTMLElement",
|
|
170
|
+
"HTMLButtonElement",
|
|
171
|
+
"HTMLInputElement",
|
|
172
|
+
"HTMLDivElement",
|
|
173
|
+
"HTMLSpanElement",
|
|
174
|
+
"Event",
|
|
175
|
+
"CustomEvent",
|
|
176
|
+
"MouseEvent",
|
|
177
|
+
"KeyboardEvent",
|
|
178
|
+
] as const
|
|
179
|
+
) {
|
|
180
|
+
(globalThis as Record<string, unknown>)[key] = key === "window"
|
|
181
|
+
? win
|
|
182
|
+
: (win as unknown as Record<string, unknown>)[key];
|
|
183
|
+
}
|
|
184
|
+
`;
|
|
185
|
+
}
|
|
186
|
+
// setupImport is the test's harness import; logicImport is derived from where
|
|
187
|
+
// the test file sits relative to logic.ts (co-located for bare, one up for the
|
|
188
|
+
// non-bare test/ dir).
|
|
189
|
+
function testTemplate(header, setupImport, logicImport) {
|
|
190
|
+
return `${header}
|
|
191
|
+
import "${setupImport}";
|
|
192
|
+
|
|
193
|
+
import assert from "node:assert/strict";
|
|
194
|
+
import { test } from "node:test";
|
|
195
|
+
|
|
196
|
+
import { nowPlaying } from "${logicImport}";
|
|
197
|
+
|
|
198
|
+
test("nowPlaying returns the track name, or a fallback when idle", () => {
|
|
199
|
+
assert.equal(nowPlaying({ name: "A Song" }), "A Song");
|
|
200
|
+
assert.equal(nowPlaying(undefined), "nothing playing");
|
|
201
|
+
});
|
|
202
|
+
`;
|
|
203
|
+
}
|
|
204
|
+
// Themes are css-only modules (KTD6): no js entry, no TypeScript tooling. The
|
|
205
|
+
// loader applies a scheme from color.ini ([Section] names are switchable
|
|
206
|
+
// schemes), and index.css restyles the client through the --spice-* variables.
|
|
207
|
+
function writeThemeModule(dir, name, description, author, bare, cwd) {
|
|
208
|
+
writeFileSync(path.join(dir, "metadata.json"), `${JSON.stringify({
|
|
209
|
+
name,
|
|
210
|
+
tags: ["theme"],
|
|
211
|
+
version: "0.1.0",
|
|
212
|
+
authors: [author],
|
|
213
|
+
description,
|
|
214
|
+
entries: { css: "index.css" },
|
|
215
|
+
hasMixins: false,
|
|
216
|
+
dependencies: {},
|
|
217
|
+
}, null, "\t")}\n`);
|
|
218
|
+
// Two example schemes; each [Section] becomes a switchable scheme. Keys are
|
|
219
|
+
// hex (no #); the loader exposes them as --spice-<key>.
|
|
220
|
+
writeFileSync(path.join(dir, "color.ini"), `[Base]
|
|
221
|
+
text = FFFFFF
|
|
222
|
+
subtext = A7A7A7
|
|
223
|
+
main = 121212
|
|
224
|
+
sidebar = 000000
|
|
225
|
+
player = 181818
|
|
226
|
+
card = 242424
|
|
227
|
+
button = 1ED760
|
|
228
|
+
button-active = 1FDF64
|
|
229
|
+
button-disabled = 3E3E3E
|
|
230
|
+
selected-row = 1ED760
|
|
231
|
+
notification = 303030
|
|
232
|
+
|
|
233
|
+
[Midnight]
|
|
234
|
+
text = E6E6FA
|
|
235
|
+
subtext = 9A9AC0
|
|
236
|
+
main = 0A0A14
|
|
237
|
+
sidebar = 05050B
|
|
238
|
+
player = 10101C
|
|
239
|
+
card = 16162A
|
|
240
|
+
button = 7B68EE
|
|
241
|
+
button-active = 9385F0
|
|
242
|
+
button-disabled = 2A2A3A
|
|
243
|
+
selected-row = 7B68EE
|
|
244
|
+
notification = 1C1C30
|
|
245
|
+
`);
|
|
246
|
+
writeFileSync(path.join(dir, "index.css"), `/*
|
|
247
|
+
* ${name} — a spicetify theme.
|
|
248
|
+
*
|
|
249
|
+
* color.ini defines the palette; the loader exposes each key as a --spice-*
|
|
250
|
+
* CSS variable and applies your chosen [Section] as the active scheme. Add
|
|
251
|
+
* rules here that consume those variables to restyle the client.
|
|
252
|
+
*/
|
|
253
|
+
|
|
254
|
+
.main-view-container__scroll-node {
|
|
255
|
+
background-color: var(--spice-main);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.main-nowPlayingBar-nowPlayingBar {
|
|
259
|
+
background-color: var(--spice-player);
|
|
260
|
+
}
|
|
261
|
+
`);
|
|
262
|
+
if (!bare) {
|
|
263
|
+
writeFileSync(path.join(dir, "package.json"), `${JSON.stringify({
|
|
264
|
+
name,
|
|
265
|
+
private: true,
|
|
266
|
+
type: "module",
|
|
267
|
+
// css-only: no tsc, no TypeScript or React devDeps.
|
|
268
|
+
scripts: {
|
|
269
|
+
build: "spicetify-kit build .",
|
|
270
|
+
dev: "spicetify-kit dev .",
|
|
271
|
+
check: "spicetify-kit check .",
|
|
272
|
+
},
|
|
273
|
+
devDependencies: { "@spicetify/kit": "^0.1.0" },
|
|
274
|
+
}, null, "\t")}\n`);
|
|
275
|
+
writeFileSync(path.join(dir, ".gitignore"), "node_modules/\ndist/\n");
|
|
276
|
+
}
|
|
277
|
+
const rel = path.relative(cwd, dir) || ".";
|
|
278
|
+
console.log(`created ${rel}/ (theme)`);
|
|
279
|
+
console.log("notes:");
|
|
280
|
+
console.log(" - edit color.ini schemes and index.css; each [Section] is a switchable scheme");
|
|
281
|
+
console.log(" - add preview images under assets/ and set metadata.preview to the first one");
|
|
282
|
+
if (!bare) {
|
|
283
|
+
console.log("next steps:");
|
|
284
|
+
console.log(` cd ${rel} && npm install`);
|
|
285
|
+
console.log(" npm run dev # hot-push into a running client");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
export async function runCreate(argv, cwd = process.cwd()) {
|
|
289
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
290
|
+
console.log(HELP);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const name = argv.find((a) => !a.startsWith("--"));
|
|
294
|
+
const flag = (n) => {
|
|
295
|
+
const i = argv.indexOf(`--${n}`);
|
|
296
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
297
|
+
};
|
|
298
|
+
const bare = argv.includes("--bare");
|
|
299
|
+
const template = (flag("template") ?? "basic");
|
|
300
|
+
if (!["basic", "extension", "app", "theme"].includes(template)) {
|
|
301
|
+
throw new Error(`${USAGE}\ntemplate must be basic, extension, app, or theme`);
|
|
302
|
+
}
|
|
303
|
+
if (!name || !/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
304
|
+
throw new Error(`${USAGE}\nname must be kebab-case (it doubles as the module identifier)`);
|
|
305
|
+
}
|
|
306
|
+
const dir = bare ? path.join(cwd, "modules", name) : path.join(cwd, name);
|
|
307
|
+
if (existsSync(dir))
|
|
308
|
+
throw new Error(`${dir} already exists`);
|
|
309
|
+
const description = flag("description") ?? `${name} module`;
|
|
310
|
+
const author = flag("author") ?? "spicetify";
|
|
311
|
+
const year = new Date().getFullYear();
|
|
312
|
+
const header = `/*\n * Copyright (C) ${year} ${author}\n * SPDX-License-Identifier: GPL-3.0-or-later\n */\n`;
|
|
313
|
+
mkdirSync(dir, { recursive: true });
|
|
314
|
+
if (template === "theme") {
|
|
315
|
+
writeThemeModule(dir, name, description, author, bare, cwd);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const tag = template === "app" ? "app" : "extension";
|
|
319
|
+
// Extensions are behavior-only; templates that render a page ship css.
|
|
320
|
+
const hasCss = template !== "extension";
|
|
321
|
+
const entries = hasCss ? { js: "index.js", css: "index.css" } : { js: "index.js" };
|
|
322
|
+
writeFileSync(path.join(dir, "metadata.json"), `${JSON.stringify({
|
|
323
|
+
name,
|
|
324
|
+
tags: [tag],
|
|
325
|
+
version: "0.1.0",
|
|
326
|
+
authors: [author],
|
|
327
|
+
description,
|
|
328
|
+
entries,
|
|
329
|
+
hasMixins: false,
|
|
330
|
+
dependencies: { stdlib: stdlibRange() },
|
|
331
|
+
}, null, "\t")}\n`);
|
|
332
|
+
// The loader imports index.js and calls load(); the shim defers the
|
|
333
|
+
// real entry so module code only evaluates once dependencies are up.
|
|
334
|
+
writeFileSync(path.join(dir, "index.ts"), `${header}
|
|
335
|
+
import type { ModuleRuntimeContext } from "/modules/stdlib/mod.ts";
|
|
336
|
+
|
|
337
|
+
export async function load(ctx: ModuleRuntimeContext) {
|
|
338
|
+
return (await import("./mod.js")).default(ctx);
|
|
339
|
+
}
|
|
340
|
+
`);
|
|
341
|
+
writeFileSync(path.join(dir, "mod.tsx"), modTemplate(template, name, header));
|
|
342
|
+
// Testable seam: pure logic lives in logic.ts, with a starter test that
|
|
343
|
+
// imports it (never mod.tsx — JSX and /modules/* URLs do not run in Node).
|
|
344
|
+
writeFileSync(path.join(dir, "logic.ts"), logicTemplate(header));
|
|
345
|
+
if (bare) {
|
|
346
|
+
// Monorepo: a co-located test the root test glob picks up, using
|
|
347
|
+
// stdlib's shared harness.
|
|
348
|
+
writeFileSync(path.join(dir, `${name}.test.mts`), testTemplate(header, "../stdlib/lib/test-setup.mts", "./logic.ts"));
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
mkdirSync(path.join(dir, "test"), { recursive: true });
|
|
352
|
+
writeFileSync(path.join(dir, "test", "setup.mts"), setupTemplate(header));
|
|
353
|
+
writeFileSync(path.join(dir, "test", `${name}.test.mts`), testTemplate(header, "./setup.mts", "../logic.ts"));
|
|
354
|
+
}
|
|
355
|
+
if (hasCss) {
|
|
356
|
+
writeFileSync(path.join(dir, "index.scss"), `.${name}-page {
|
|
357
|
+
padding: 24px 32px;
|
|
358
|
+
color: var(--spice-text);
|
|
359
|
+
}
|
|
360
|
+
`);
|
|
361
|
+
}
|
|
362
|
+
if (!bare) {
|
|
363
|
+
writeFileSync(path.join(dir, "package.json"), `${JSON.stringify({
|
|
364
|
+
name,
|
|
365
|
+
private: true,
|
|
366
|
+
type: "module",
|
|
367
|
+
// node --test with a test/-scoped glob; escaped double quotes
|
|
368
|
+
// (single quotes break cmd.exe, bare ** can match node_modules).
|
|
369
|
+
engines: { node: ">=22.6" },
|
|
370
|
+
scripts: {
|
|
371
|
+
build: "spicetify-kit build .",
|
|
372
|
+
dev: "spicetify-kit dev .",
|
|
373
|
+
check: "tsc && spicetify-kit check .",
|
|
374
|
+
test: 'node --test "test/*.test.mts"',
|
|
375
|
+
},
|
|
376
|
+
devDependencies: {
|
|
377
|
+
"@spicetify/kit": "^0.1.0",
|
|
378
|
+
"@types/react": "^18",
|
|
379
|
+
"@types/react-dom": "^18",
|
|
380
|
+
"happy-dom": "^20",
|
|
381
|
+
rxjs: "^7.8.1",
|
|
382
|
+
typescript: "^7",
|
|
383
|
+
},
|
|
384
|
+
}, null, "\t")}\n`);
|
|
385
|
+
// Editor/typecheck config: runtime URLs map to the kit's vendored
|
|
386
|
+
// stdlib sources.
|
|
387
|
+
writeFileSync(path.join(dir, "tsconfig.json"), `${JSON.stringify({
|
|
388
|
+
compilerOptions: {
|
|
389
|
+
target: "ES2022",
|
|
390
|
+
module: "ESNext",
|
|
391
|
+
moduleResolution: "Bundler",
|
|
392
|
+
allowImportingTsExtensions: true,
|
|
393
|
+
noEmit: true,
|
|
394
|
+
jsx: "react-jsx",
|
|
395
|
+
lib: ["ES2024", "ESNext.Disposable", "DOM", "DOM.Iterable"],
|
|
396
|
+
strict: false,
|
|
397
|
+
skipLibCheck: true,
|
|
398
|
+
resolveJsonModule: true,
|
|
399
|
+
types: [],
|
|
400
|
+
paths: {
|
|
401
|
+
"/modules/*": ["./node_modules/@spicetify/kit/vendor/*"],
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
include: ["**/*"],
|
|
405
|
+
files: [
|
|
406
|
+
"./node_modules/@spicetify/kit/vendor/shims/remote-modules.d.ts",
|
|
407
|
+
"./node_modules/@spicetify/kit/vendor/shims/chunks.d.ts",
|
|
408
|
+
"./node_modules/@spicetify/kit/vendor/shims/spicetify.d.ts",
|
|
409
|
+
],
|
|
410
|
+
exclude: ["node_modules", "dist"],
|
|
411
|
+
}, null, "\t")}\n`);
|
|
412
|
+
// Placeholder MAP declaration; the first build replaces it with the
|
|
413
|
+
// typed shape generated from the resolved classmap.
|
|
414
|
+
writeFileSync(path.join(dir, "classmap.d.ts"), "// Regenerated with real classmap paths on every build.\ndeclare global {\n\tconst MAP: any;\n}\n\nexport {};\n");
|
|
415
|
+
writeFileSync(path.join(dir, ".gitignore"), "node_modules/\ndist/\n");
|
|
416
|
+
}
|
|
417
|
+
const rel = path.relative(cwd, dir) || ".";
|
|
418
|
+
console.log(`created ${rel}/`);
|
|
419
|
+
console.log("next steps:");
|
|
420
|
+
if (bare) {
|
|
421
|
+
console.log(` node scripts/stitch.ts modules/${name} # one-off build into dist/`);
|
|
422
|
+
console.log(` node scripts/dev.ts modules/${name} # watch + hot-push into a running client`);
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
console.log(` cd ${rel} && npm install`);
|
|
426
|
+
console.log(" npm run dev # watch + hot-push into a running client (Spotify started with --remote-debugging-port=9229)");
|
|
427
|
+
console.log(" npm run build # one-off build into dist/");
|
|
428
|
+
}
|
|
429
|
+
}
|