@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.
Files changed (115) hide show
  1. package/bin/spicetify-kit.js +4 -0
  2. package/dist/build.js +335 -0
  3. package/dist/check.js +284 -0
  4. package/dist/classmap.js +187 -0
  5. package/dist/cli.js +53 -0
  6. package/dist/create.js +429 -0
  7. package/dist/dev.js +100 -0
  8. package/dist/from-theme.js +96 -0
  9. package/dist/install.js +60 -0
  10. package/dist/launch.js +122 -0
  11. package/dist/pack.js +31 -0
  12. package/dist/push.js +211 -0
  13. package/dist/vault.js +108 -0
  14. package/package.json +49 -0
  15. package/vendor/classmaps/1020096/classmap-19f856aefd5.json +109 -0
  16. package/vendor/shims/chunks.d.ts +10 -0
  17. package/vendor/shims/remote-modules.d.ts +6 -0
  18. package/vendor/shims/spicetify.d.ts +2437 -0
  19. package/vendor/stdlib/deps.ts +24 -0
  20. package/vendor/stdlib/index.ts +24 -0
  21. package/vendor/stdlib/lib/modal.tsx +128 -0
  22. package/vendor/stdlib/lib/popover.ts +84 -0
  23. package/vendor/stdlib/lib/primitives-classes.ts +66 -0
  24. package/vendor/stdlib/lib/primitives-vanilla.ts +277 -0
  25. package/vendor/stdlib/lib/primitives.tsx +314 -0
  26. package/vendor/stdlib/load.ts +17 -0
  27. package/vendor/stdlib/metadata.json +18 -0
  28. package/vendor/stdlib/mixin.ts +32 -0
  29. package/vendor/stdlib/mod.ts +26 -0
  30. package/vendor/stdlib/src/chunks.d.ts +10 -0
  31. package/vendor/stdlib/src/client.ts +114 -0
  32. package/vendor/stdlib/src/createIconComponent.tsx +29 -0
  33. package/vendor/stdlib/src/events.mix.ts +55 -0
  34. package/vendor/stdlib/src/events.ts +58 -0
  35. package/vendor/stdlib/src/expose/GraphQL.ts +40 -0
  36. package/vendor/stdlib/src/expose/Platform.ts +48 -0
  37. package/vendor/stdlib/src/expose/React.ts +95 -0
  38. package/vendor/stdlib/src/expose/ReactFlipToolkitSpring.ts +27 -0
  39. package/vendor/stdlib/src/expose/ReduxStore.ts +39 -0
  40. package/vendor/stdlib/src/expose/SettingsSection.ts +60 -0
  41. package/vendor/stdlib/src/expose/Snackbar.ts +34 -0
  42. package/vendor/stdlib/src/expose/Tippy.ts +26 -0
  43. package/vendor/stdlib/src/expose/enqueueImageSnackbar.ts +25 -0
  44. package/vendor/stdlib/src/expose/index.ts +14 -0
  45. package/vendor/stdlib/src/expose/jsx-runtime.ts +46 -0
  46. package/vendor/stdlib/src/expose/react-dom-shim.ts +50 -0
  47. package/vendor/stdlib/src/expose/react-shim.ts +116 -0
  48. package/vendor/stdlib/src/logger.ts +35 -0
  49. package/vendor/stdlib/src/playbar-compat.tsx +136 -0
  50. package/vendor/stdlib/src/registers/index.ts +176 -0
  51. package/vendor/stdlib/src/registers/menu.ts +160 -0
  52. package/vendor/stdlib/src/registers/mount.ts +307 -0
  53. package/vendor/stdlib/src/registers/nativeAnchors.ts +49 -0
  54. package/vendor/stdlib/src/registers/navlink.tsx +165 -0
  55. package/vendor/stdlib/src/registers/order.ts +22 -0
  56. package/vendor/stdlib/src/registers/panel.ts +168 -0
  57. package/vendor/stdlib/src/registers/playbarButton.tsx +93 -0
  58. package/vendor/stdlib/src/registers/playbarWidget.tsx +71 -0
  59. package/vendor/stdlib/src/registers/registry.ts +12 -0
  60. package/vendor/stdlib/src/registers/root.ts +101 -0
  61. package/vendor/stdlib/src/registers/route.ts +123 -0
  62. package/vendor/stdlib/src/registers/settingsRow.tsx +56 -0
  63. package/vendor/stdlib/src/registers/settingsSection.ts +59 -0
  64. package/vendor/stdlib/src/registers/topbarLeftButton.tsx +86 -0
  65. package/vendor/stdlib/src/registers/topbarRightButton.tsx +87 -0
  66. package/vendor/stdlib/src/storage.ts +163 -0
  67. package/vendor/stdlib/src/util.ts +62 -0
  68. package/vendor/stdlib/src/utils/index.ts +16 -0
  69. package/vendor/stdlib/src/webpack/ClassNames.gen.ts +10 -0
  70. package/vendor/stdlib/src/webpack/ClassNames.ts +6 -0
  71. package/vendor/stdlib/src/webpack/ClassNames.xpui.ts +16 -0
  72. package/vendor/stdlib/src/webpack/ComponentLibrary.gen.ts +10 -0
  73. package/vendor/stdlib/src/webpack/ComponentLibrary.ts +6 -0
  74. package/vendor/stdlib/src/webpack/ComponentLibrary.xpui.ts +18 -0
  75. package/vendor/stdlib/src/webpack/FilterContext.gen.ts +10 -0
  76. package/vendor/stdlib/src/webpack/FilterContext.ts +6 -0
  77. package/vendor/stdlib/src/webpack/FilterContext.xpui.ts +10 -0
  78. package/vendor/stdlib/src/webpack/Mousetrap.gen.ts +10 -0
  79. package/vendor/stdlib/src/webpack/Mousetrap.ts +6 -0
  80. package/vendor/stdlib/src/webpack/Mousetrap.xpui.ts +12 -0
  81. package/vendor/stdlib/src/webpack/React.gen.ts +14 -0
  82. package/vendor/stdlib/src/webpack/React.ts +6 -0
  83. package/vendor/stdlib/src/webpack/React.xpui.ts +15 -0
  84. package/vendor/stdlib/src/webpack/ReactComponents.desktop.ts +17 -0
  85. package/vendor/stdlib/src/webpack/ReactComponents.gen.ts +74 -0
  86. package/vendor/stdlib/src/webpack/ReactComponents.panel.ts +19 -0
  87. package/vendor/stdlib/src/webpack/ReactComponents.ts +50 -0
  88. package/vendor/stdlib/src/webpack/ReactComponents.xpui.ts +164 -0
  89. package/vendor/stdlib/src/webpack/ReactFlipToolkit.gen.ts +12 -0
  90. package/vendor/stdlib/src/webpack/ReactFlipToolkit.ts +6 -0
  91. package/vendor/stdlib/src/webpack/ReactFlipToolkit.xpui.ts +14 -0
  92. package/vendor/stdlib/src/webpack/ReactHooks.fullscreen.ts +15 -0
  93. package/vendor/stdlib/src/webpack/ReactHooks.gen.ts +32 -0
  94. package/vendor/stdlib/src/webpack/ReactHooks.ts +6 -0
  95. package/vendor/stdlib/src/webpack/ReactHooks.xpui.ts +38 -0
  96. package/vendor/stdlib/src/webpack/ReactQuery.gen.ts +26 -0
  97. package/vendor/stdlib/src/webpack/ReactQuery.ts +6 -0
  98. package/vendor/stdlib/src/webpack/ReactQuery.xpui.ts +46 -0
  99. package/vendor/stdlib/src/webpack/ReactRouter.gen.ts +12 -0
  100. package/vendor/stdlib/src/webpack/ReactRouter.ts +6 -0
  101. package/vendor/stdlib/src/webpack/ReactRouter.xpui.ts +22 -0
  102. package/vendor/stdlib/src/webpack/Snackbar.gen.ts +12 -0
  103. package/vendor/stdlib/src/webpack/Snackbar.ts +6 -0
  104. package/vendor/stdlib/src/webpack/Snackbar.xpui.ts +27 -0
  105. package/vendor/stdlib/src/webpack/URI.gen.ts +26 -0
  106. package/vendor/stdlib/src/webpack/URI.ts +82 -0
  107. package/vendor/stdlib/src/webpack/URI.xpui.ts +111 -0
  108. package/vendor/stdlib/src/webpack/capture-readiness.ts +49 -0
  109. package/vendor/stdlib/src/webpack/index.ts +186 -0
  110. package/vendor/stdlib/src/webpack/misc.gen.ts +16 -0
  111. package/vendor/stdlib/src/webpack/misc.ts +6 -0
  112. package/vendor/stdlib/src/webpack/misc.xpui.ts +27 -0
  113. package/vendor/stdlib/src/wpunpk.mix.ts +104 -0
  114. package/vendor/stdlib/src/wpunpk.ts +36 -0
  115. package/vendor/stdlib/vendor/rxjs.d.ts +5 -0
@@ -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
+ }