@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/build.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* build - bundle v3 modules for the spicetify modular runtime.
|
|
7
|
+
*
|
|
8
|
+
* Bundles each module with rolldown (TS/TSX), compiles SCSS entries, and
|
|
9
|
+
* writes <out>/<name>@<version>/ with metadata.json and the
|
|
10
|
+
* spicetify-module.json sidecar. Modules ship MAP-intact: class
|
|
11
|
+
* references are remapped at apply/install time against the exact
|
|
12
|
+
* installed classmap, so one build serves every supported Spotify
|
|
13
|
+
* version.
|
|
14
|
+
*/
|
|
15
|
+
import { rolldown } from "rolldown";
|
|
16
|
+
import * as sass from "sass-embedded";
|
|
17
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, watch, writeFileSync, } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { checkModule } from "./check.js";
|
|
20
|
+
import { generateClassmapDts, loadConfig, resolveClassmap } from "./classmap.js";
|
|
21
|
+
const EXTERNALS = [/^https?:\/\//];
|
|
22
|
+
// Runtime URLs the react family resolves to (see the resolveId plugin).
|
|
23
|
+
// react-dom/server has no client instance to share, so it stays on esm.sh;
|
|
24
|
+
// nothing imports it at boot.
|
|
25
|
+
const REACT_RUNTIME_URLS = {
|
|
26
|
+
react: "/modules/stdlib/src/expose/react-shim.js",
|
|
27
|
+
"react/jsx-runtime": "/modules/stdlib/src/expose/jsx-runtime.js",
|
|
28
|
+
"react-dom": "/modules/stdlib/src/expose/react-dom-shim.js",
|
|
29
|
+
"react-dom/client": "/modules/stdlib/src/expose/react-dom-shim.js",
|
|
30
|
+
"react-dom/server": "https://esm.sh/react-dom@18.3.1/server",
|
|
31
|
+
};
|
|
32
|
+
const USAGE = `spicetify-kit build [module...] [--classmap <key|path>] [--out <dir>]
|
|
33
|
+
|
|
34
|
+
module... module folders to build; a folder containing
|
|
35
|
+
metadata.json is a module, otherwise it is resolved
|
|
36
|
+
inside --modules (default: all modules in ./modules,
|
|
37
|
+
or the current directory when it is itself a module)
|
|
38
|
+
--classmap, -c classmap key (e.g. 1020094; fetched from the published
|
|
39
|
+
spicetify/classmaps repo and cached when not present
|
|
40
|
+
locally) or a direct path to a classmap json
|
|
41
|
+
--out, -o output dir (default: ./dist)
|
|
42
|
+
--modules, -m modules dir (default: ./modules)
|
|
43
|
+
--no-check skip the module-standard check (error-tier findings
|
|
44
|
+
otherwise abort the build)
|
|
45
|
+
--watch rebuild a single module on change (no hot-push)
|
|
46
|
+
--refresh force a classmap refetch, bypassing vendored/cache`;
|
|
47
|
+
export function readMetadata(dir) {
|
|
48
|
+
return JSON.parse(readFileSync(path.join(dir, "metadata.json"), "utf8"));
|
|
49
|
+
}
|
|
50
|
+
// A watch event should trigger a rebuild unless it is the generated
|
|
51
|
+
// classmap.d.ts (reacting to it loops) or a dotfile.
|
|
52
|
+
export function shouldRebuildOnChange(file) {
|
|
53
|
+
return !!file && !file.endsWith(".d.ts") && !file.startsWith(".");
|
|
54
|
+
}
|
|
55
|
+
async function buildJs(inputDir, outputDir, tree, cwd) {
|
|
56
|
+
// Multi-entry: mod.ts (when present) is the module's public barrel and
|
|
57
|
+
// gets a stable facade at the dist root so OTHER modules can import it
|
|
58
|
+
// at runtime (e.g. /modules/stdlib/mod.js).
|
|
59
|
+
const input = { index: path.join(inputDir, "index.ts") };
|
|
60
|
+
const barrel = path.join(inputDir, "mod.ts");
|
|
61
|
+
if (existsSync(barrel))
|
|
62
|
+
input.mod = barrel;
|
|
63
|
+
// Tree modules are runtime libraries: every source file becomes an
|
|
64
|
+
// entry so dependent modules can deep-import any path, not just what
|
|
65
|
+
// the module's own graph happens to reach.
|
|
66
|
+
if (tree) {
|
|
67
|
+
const SKIP_DIRS = new Set(["node_modules", "assets", "public"]);
|
|
68
|
+
const SKIP_FILES = new Set(["CODEGEN.ts"]);
|
|
69
|
+
const addTreeEntries = (dir) => {
|
|
70
|
+
for (const entry of readdirSync(dir)) {
|
|
71
|
+
const full = path.join(dir, entry);
|
|
72
|
+
if (statSync(full).isDirectory()) {
|
|
73
|
+
if (!SKIP_DIRS.has(entry))
|
|
74
|
+
addTreeEntries(full);
|
|
75
|
+
}
|
|
76
|
+
else if (/\.tsx?$/.test(entry) && !entry.endsWith(".d.ts") && !SKIP_FILES.has(entry)) {
|
|
77
|
+
const name = path.relative(inputDir, full).replace(/\.tsx?$/, "");
|
|
78
|
+
input[name] ??= full;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
addTreeEntries(inputDir);
|
|
83
|
+
}
|
|
84
|
+
const bundle = await rolldown({
|
|
85
|
+
input,
|
|
86
|
+
external: [...EXTERNALS, /^\/modules\//],
|
|
87
|
+
transform: {
|
|
88
|
+
jsx: {
|
|
89
|
+
// Emit "react/jsx-runtime" so the plugin below externalizes it
|
|
90
|
+
// to the stdlib-local runtime.
|
|
91
|
+
importSource: "react",
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
// One React rule, enforced at resolution: npm-style react specifiers
|
|
95
|
+
// become external runtime URLs into stdlib's client-instance shims,
|
|
96
|
+
// never bundled copies. This must be a resolveId plugin, not a
|
|
97
|
+
// resolve.alias entry — externals are tested on the raw specifier, so
|
|
98
|
+
// an aliased runtime URL is followed on to the local source file and
|
|
99
|
+
// inlined into every module (which is how the jsx runtime briefly
|
|
100
|
+
// dragged stdlib's React capture into each built .tsx module).
|
|
101
|
+
plugins: [
|
|
102
|
+
{
|
|
103
|
+
name: "react-runtime-urls",
|
|
104
|
+
resolveId: (source) => REACT_RUNTIME_URLS[source] ? { id: REACT_RUNTIME_URLS[source], external: true } : null,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
resolve: {
|
|
108
|
+
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".json"],
|
|
109
|
+
alias: {
|
|
110
|
+
"/modules": [path.join(cwd, "modules")],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
await bundle.write({
|
|
115
|
+
dir: outputDir,
|
|
116
|
+
format: "esm",
|
|
117
|
+
sourcemap: true,
|
|
118
|
+
preserveModules: tree,
|
|
119
|
+
// Leaf modules ship as one chunk: local installs run entries through
|
|
120
|
+
// blob URLs, where relative chunk imports cannot resolve.
|
|
121
|
+
...(!tree && Object.keys(input).length === 1 ? { codeSplitting: false } : {}),
|
|
122
|
+
});
|
|
123
|
+
// Staged modules serve .js; sources import "/modules/*.ts" URLs.
|
|
124
|
+
const rewriteRuntimeTs = (dir) => {
|
|
125
|
+
for (const entry of readdirSync(dir)) {
|
|
126
|
+
const full = path.join(dir, entry);
|
|
127
|
+
if (statSync(full).isDirectory()) {
|
|
128
|
+
rewriteRuntimeTs(full);
|
|
129
|
+
}
|
|
130
|
+
else if (entry.endsWith(".js")) {
|
|
131
|
+
const content = readFileSync(full, "utf8");
|
|
132
|
+
const rewritten = content.replace(/"(\/modules\/[^"]+)\.tsx?"/g, '"$1.js"');
|
|
133
|
+
if (rewritten !== content)
|
|
134
|
+
writeFileSync(full, rewritten);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
rewriteRuntimeTs(outputDir);
|
|
139
|
+
}
|
|
140
|
+
function buildCss(inputDir, outputDir) {
|
|
141
|
+
const scss = path.join(inputDir, "index.scss");
|
|
142
|
+
const css = path.join(inputDir, "index.css");
|
|
143
|
+
if (existsSync(scss)) {
|
|
144
|
+
const result = sass.compile(scss, { style: "compressed" });
|
|
145
|
+
writeFileSync(path.join(outputDir, "index.css"), result.css);
|
|
146
|
+
}
|
|
147
|
+
else if (existsSync(css)) {
|
|
148
|
+
cpSync(css, path.join(outputDir, "index.css"));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function copyAssets(inputDir, outputDir) {
|
|
152
|
+
for (const entry of readdirSync(inputDir)) {
|
|
153
|
+
if (["assets", "public"].includes(entry)) {
|
|
154
|
+
cpSync(path.join(inputDir, entry), path.join(outputDir, entry), { recursive: true });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// buildModule builds a single module dir into outDir and returns the dist
|
|
159
|
+
// path. The classmap resolution is passed in so multi-module runs and the
|
|
160
|
+
// dev loop resolve it once.
|
|
161
|
+
export async function buildModule(inputDir, outDir, resolved, cwd, opts = {}) {
|
|
162
|
+
const metadata = readMetadata(inputDir);
|
|
163
|
+
const identifier = `${metadata.name}@${metadata.version}`;
|
|
164
|
+
// The standard's error tier is enforced at build (KTD4): error-tier
|
|
165
|
+
// findings abort before any dist output, warnings print and continue.
|
|
166
|
+
// "warn" (the dev loop) never blocks; "off" is --no-check.
|
|
167
|
+
const check = opts.check ?? "enforce";
|
|
168
|
+
if (check !== "off") {
|
|
169
|
+
const findings = checkModule(inputDir);
|
|
170
|
+
for (const f of findings) {
|
|
171
|
+
const tag = f.severity === "error" ? "error" : "warn ";
|
|
172
|
+
console.error(` ${tag} [${f.rule}] ${f.file ?? ""} ${f.message}`);
|
|
173
|
+
}
|
|
174
|
+
const errors = findings.filter((f) => f.severity === "error");
|
|
175
|
+
if (check === "enforce" && errors.length) {
|
|
176
|
+
throw new Error(`${metadata.name}: ${errors.length} error-tier finding(s) against the module standard; ` +
|
|
177
|
+
"fix them, or build with --no-check.");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const outputDir = path.join(outDir, identifier);
|
|
181
|
+
rmSync(outputDir, { recursive: true, force: true });
|
|
182
|
+
mkdirSync(outputDir, { recursive: true });
|
|
183
|
+
console.log(`stitch ${identifier}`);
|
|
184
|
+
// Theme modules are css-only: no js entry means nothing to bundle.
|
|
185
|
+
if (metadata.entries.js)
|
|
186
|
+
await buildJs(inputDir, outputDir, metadata.tree ?? false, cwd);
|
|
187
|
+
buildCss(inputDir, outputDir);
|
|
188
|
+
copyAssets(inputDir, outputDir);
|
|
189
|
+
// The loader applies color schemes from a color.ini shipped with the
|
|
190
|
+
// module (sections become switchable schemes).
|
|
191
|
+
if (existsSync(path.join(inputDir, "color.ini"))) {
|
|
192
|
+
cpSync(path.join(inputDir, "color.ini"), path.join(outputDir, "color.ini"));
|
|
193
|
+
}
|
|
194
|
+
const classmap = JSON.parse(readFileSync(resolved.path, "utf8"));
|
|
195
|
+
writeFileSync(path.join(inputDir, "classmap.d.ts"), generateClassmapDts(classmap));
|
|
196
|
+
writeFileSync(path.join(outputDir, "metadata.json"), JSON.stringify(metadata, null, 2) + "\n");
|
|
197
|
+
writeFileSync(path.join(outputDir, "spicetify-module.json"), JSON.stringify({ installed_version: metadata.version, classmap_base: resolved.key, allow_stale: false }, null, 2) + "\n");
|
|
198
|
+
console.log(` -> ${outputDir}`);
|
|
199
|
+
return outputDir;
|
|
200
|
+
}
|
|
201
|
+
function parseArgs(argv) {
|
|
202
|
+
const out = {
|
|
203
|
+
targets: [],
|
|
204
|
+
classmap: null,
|
|
205
|
+
outDir: null,
|
|
206
|
+
modulesDir: null,
|
|
207
|
+
noCheck: false,
|
|
208
|
+
refresh: false,
|
|
209
|
+
watch: false,
|
|
210
|
+
};
|
|
211
|
+
for (let i = 0; i < argv.length; i++) {
|
|
212
|
+
const a = argv[i];
|
|
213
|
+
const next = () => argv[++i];
|
|
214
|
+
switch (a) {
|
|
215
|
+
case "--no-check":
|
|
216
|
+
out.noCheck = true;
|
|
217
|
+
break;
|
|
218
|
+
case "--refresh":
|
|
219
|
+
out.refresh = true;
|
|
220
|
+
break;
|
|
221
|
+
case "--watch":
|
|
222
|
+
out.watch = true;
|
|
223
|
+
break;
|
|
224
|
+
case "--classmap":
|
|
225
|
+
case "-c":
|
|
226
|
+
out.classmap = next();
|
|
227
|
+
break;
|
|
228
|
+
case "--out":
|
|
229
|
+
case "-o":
|
|
230
|
+
out.outDir = path.resolve(next());
|
|
231
|
+
break;
|
|
232
|
+
case "--modules":
|
|
233
|
+
case "-m":
|
|
234
|
+
out.modulesDir = path.resolve(next());
|
|
235
|
+
break;
|
|
236
|
+
case "--help":
|
|
237
|
+
case "-h":
|
|
238
|
+
console.log(USAGE);
|
|
239
|
+
process.exit(0);
|
|
240
|
+
default:
|
|
241
|
+
if (a.startsWith("-"))
|
|
242
|
+
throw new Error(`unknown flag: ${a}`);
|
|
243
|
+
out.targets.push(a);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
// Sibling content roots next to the modules dir: the org repo splits
|
|
249
|
+
// content by kind (modules/, themes/, snippets/) purely for repo
|
|
250
|
+
// navigation; the runtime namespace stays /modules/<id> regardless.
|
|
251
|
+
export function contentRoots(modulesDir) {
|
|
252
|
+
const siblings = ["themes", "snippets"].map((name) => path.join(path.dirname(modulesDir), name));
|
|
253
|
+
return [modulesDir, ...siblings.filter((dir) => existsSync(dir))];
|
|
254
|
+
}
|
|
255
|
+
// resolveModuleDir accepts a module folder directly (metadata.json
|
|
256
|
+
// present) or a name inside any content root.
|
|
257
|
+
export function resolveModuleDir(target, modulesDir, cwd) {
|
|
258
|
+
const direct = path.resolve(cwd, target);
|
|
259
|
+
if (existsSync(path.join(direct, "metadata.json")))
|
|
260
|
+
return direct;
|
|
261
|
+
for (const root of contentRoots(modulesDir)) {
|
|
262
|
+
const nested = path.join(root, target);
|
|
263
|
+
if (existsSync(path.join(nested, "metadata.json")))
|
|
264
|
+
return nested;
|
|
265
|
+
}
|
|
266
|
+
throw new Error(`no metadata.json under ${direct} or ${contentRoots(modulesDir).join(", ")}`);
|
|
267
|
+
}
|
|
268
|
+
export async function runBuild(argv, cwd = process.cwd()) {
|
|
269
|
+
const args = parseArgs(argv);
|
|
270
|
+
const config = loadConfig(cwd);
|
|
271
|
+
const resolved = await resolveClassmap({ flag: args.classmap, config, cwd, refresh: args.refresh });
|
|
272
|
+
if (!resolved.path)
|
|
273
|
+
throw new Error("no classmap found (pass --classmap <key|path>)");
|
|
274
|
+
console.log(`classmap: ${resolved.path}${resolved.key ? ` (key ${resolved.key})` : ""}`);
|
|
275
|
+
const modulesDir = args.modulesDir ?? (config.modulesDir ? path.resolve(cwd, config.modulesDir) : path.join(cwd, "modules"));
|
|
276
|
+
const outDir = args.outDir ?? (config.outDir ? path.resolve(cwd, config.outDir) : path.join(cwd, "dist"));
|
|
277
|
+
let targets = args.targets;
|
|
278
|
+
if (!targets.length) {
|
|
279
|
+
if (existsSync(path.join(cwd, "metadata.json"))) {
|
|
280
|
+
targets = ["."];
|
|
281
|
+
}
|
|
282
|
+
else if (existsSync(modulesDir)) {
|
|
283
|
+
// Batch build spans every content root; targets are paths, so
|
|
284
|
+
// a same-named dir in two roots resolves unambiguously.
|
|
285
|
+
targets = contentRoots(modulesDir).flatMap((root) => readdirSync(root)
|
|
286
|
+
.filter((d) => statSync(path.join(root, d)).isDirectory())
|
|
287
|
+
.map((d) => path.join(root, d)));
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
throw new Error(`nothing to build: no metadata.json in ${cwd} and no ${modulesDir}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// --watch rebuilds a single module on change (no hot-push), for authors who
|
|
294
|
+
// cannot use the CDP dev loop. It never blocks: a failed build logs and the
|
|
295
|
+
// watcher keeps running.
|
|
296
|
+
if (args.watch) {
|
|
297
|
+
const moduleDir = resolveModuleDir(targets[0], modulesDir, cwd);
|
|
298
|
+
const rebuild = async () => {
|
|
299
|
+
try {
|
|
300
|
+
await buildModule(moduleDir, outDir, resolved, cwd, { check: args.noCheck ? "off" : "warn" });
|
|
301
|
+
}
|
|
302
|
+
catch (e) {
|
|
303
|
+
console.error(`[build] ${e.message}`);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
await rebuild();
|
|
307
|
+
console.log(`[build] watching ${moduleDir} (ctrl-c to stop)`);
|
|
308
|
+
let timer;
|
|
309
|
+
let loggedDts = false;
|
|
310
|
+
watch(moduleDir, { recursive: true }, (_e, file) => {
|
|
311
|
+
if (!file)
|
|
312
|
+
return;
|
|
313
|
+
// The build regenerates classmap.d.ts into the source dir; reacting
|
|
314
|
+
// to it would loop. Note the skip once so it is not a mystery.
|
|
315
|
+
if (file.endsWith(".d.ts")) {
|
|
316
|
+
if (!loggedDts) {
|
|
317
|
+
console.log("[build] ignoring generated classmap.d.ts changes");
|
|
318
|
+
loggedDts = true;
|
|
319
|
+
}
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (file.startsWith("."))
|
|
323
|
+
return;
|
|
324
|
+
clearTimeout(timer);
|
|
325
|
+
timer = setTimeout(() => void rebuild(), 200);
|
|
326
|
+
});
|
|
327
|
+
await new Promise(() => { });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
for (const target of targets) {
|
|
331
|
+
await buildModule(resolveModuleDir(target, modulesDir, cwd), outDir, resolved, cwd, {
|
|
332
|
+
check: args.noCheck ? "off" : "enforce",
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
package/dist/check.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* check - audit a module against the Spicetify Module Standard
|
|
7
|
+
* (docs/module-standard.md). Advisory by design: it reports findings and
|
|
8
|
+
* exits 0, so it guides without blocking. The rules split into a few
|
|
9
|
+
* reliably-checkable structural facts (metadata schema, the entry shim)
|
|
10
|
+
* and heuristic nudges (likely-hardcoded hashed classnames, a second
|
|
11
|
+
* React copy, hand-rolled shared chrome) that flag the common footguns.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { resolveModuleDir } from "./build.js";
|
|
16
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/;
|
|
17
|
+
// The shape of a client webpack hash: a separator-free alphanumeric token,
|
|
18
|
+
// 8+ chars, mixed case (real module classes are lower-kebab, e.g.
|
|
19
|
+
// spicetify-button). Such a token must be a MAP.* reference the CLI
|
|
20
|
+
// remaps, not a hardcoded literal that dies on the next client update.
|
|
21
|
+
const isHashyClass = (token) => token.length >= 8 && /^[A-Za-z0-9]+$/.test(token) && /[a-z]/.test(token) && /[A-Z]/.test(token);
|
|
22
|
+
// checkMetadata validates the metadata.json contract. Structural, so these
|
|
23
|
+
// are the one place check emits errors rather than warnings.
|
|
24
|
+
export function checkMetadata(meta) {
|
|
25
|
+
const out = [];
|
|
26
|
+
const err = (rule, message) => out.push({ severity: "error", rule, message, file: "metadata.json" });
|
|
27
|
+
if (typeof meta !== "object" || meta === null) {
|
|
28
|
+
err("metadata.shape", "metadata.json must be a JSON object");
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
const m = meta;
|
|
32
|
+
if (typeof m.name !== "string" || !/^[a-z][a-z0-9-]*$/.test(m.name)) {
|
|
33
|
+
err("metadata.name", "name must be a kebab-case string (it is the module identifier)");
|
|
34
|
+
}
|
|
35
|
+
if (typeof m.version !== "string" || !SEMVER.test(m.version)) {
|
|
36
|
+
err("metadata.version", "version must be semver (e.g. 1.0.0)");
|
|
37
|
+
}
|
|
38
|
+
if (typeof m.description !== "string")
|
|
39
|
+
err("metadata.description", "description must be a string");
|
|
40
|
+
if (!Array.isArray(m.authors))
|
|
41
|
+
err("metadata.authors", "authors must be an array");
|
|
42
|
+
if (typeof m.entries !== "object" || m.entries === null) {
|
|
43
|
+
err("metadata.entries", "entries must be an object with js and/or css");
|
|
44
|
+
}
|
|
45
|
+
if (typeof m.dependencies !== "object" || m.dependencies === null) {
|
|
46
|
+
err("metadata.dependencies", "dependencies must be an object (use {} for none)");
|
|
47
|
+
}
|
|
48
|
+
if ("hasMixins" in m && typeof m.hasMixins !== "boolean") {
|
|
49
|
+
err("metadata.hasMixins", "hasMixins must be a boolean when present");
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
function readSources(dir) {
|
|
54
|
+
const out = [];
|
|
55
|
+
const skip = new Set(["node_modules", "dist", "assets", "public"]);
|
|
56
|
+
const walk = (d) => {
|
|
57
|
+
for (const entry of readdirSync(d)) {
|
|
58
|
+
const full = path.join(d, entry);
|
|
59
|
+
if (statSync(full).isDirectory()) {
|
|
60
|
+
if (!skip.has(entry))
|
|
61
|
+
walk(full);
|
|
62
|
+
}
|
|
63
|
+
else if (/\.tsx?$/.test(entry) && !entry.endsWith(".d.ts") && !/\.test\.[cm]?tsx?$/.test(entry)) {
|
|
64
|
+
out.push({ file: full, text: readFileSync(full, "utf8") });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
walk(dir);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
// checkSource runs the heuristic, per-file rules. Warnings only.
|
|
72
|
+
export function checkSource(rel, text, options = {}) {
|
|
73
|
+
const out = [];
|
|
74
|
+
const lines = text.split("\n");
|
|
75
|
+
lines.forEach((line, i) => {
|
|
76
|
+
const at = `${rel}:${i + 1}`;
|
|
77
|
+
const trimmed = line.trimStart();
|
|
78
|
+
const isComment = trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*");
|
|
79
|
+
const isClientAdapter = /(^|\/)client\.ts$/.test(rel);
|
|
80
|
+
if (options.clientCapabilities !== false &&
|
|
81
|
+
!isComment &&
|
|
82
|
+
!isClientAdapter &&
|
|
83
|
+
/\b(?:globalThis\.)?Spicetify(?:\?\.|\.)/.test(line)) {
|
|
84
|
+
out.push({
|
|
85
|
+
severity: "warn",
|
|
86
|
+
rule: "client-capabilities",
|
|
87
|
+
message: "import the typed client capability surface from stdlib instead of reading the ambient Spicetify global",
|
|
88
|
+
file: at,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
// A second React instance breaks hooks/context identity (one-React
|
|
92
|
+
// rule). expose/React.ts is stdlib's sanctioned single source and is
|
|
93
|
+
// exempt.
|
|
94
|
+
if (/from\s+['"]https?:\/\/[^'"]*react[^'"]*['"]/.test(line) &&
|
|
95
|
+
!line.includes("jsx-runtime") &&
|
|
96
|
+
!/expose\/React/.test(rel)) {
|
|
97
|
+
out.push({
|
|
98
|
+
severity: "warn",
|
|
99
|
+
rule: "one-react",
|
|
100
|
+
message: "import React from stdlib's expose or the bare 'react' shim, never a second copy",
|
|
101
|
+
file: at,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// className string that looks like a raw client hash.
|
|
105
|
+
if (/class[nN]ame/.test(line)) {
|
|
106
|
+
for (const q of line.matchAll(/['"]([A-Za-z0-9]+)['"]/g)) {
|
|
107
|
+
if (isHashyClass(q[1])) {
|
|
108
|
+
out.push({
|
|
109
|
+
severity: "warn",
|
|
110
|
+
rule: "map-intact",
|
|
111
|
+
message: `"${q[1]}" looks like a hardcoded client hash; reference it via MAP.* so the CLI remaps it per version`,
|
|
112
|
+
file: at,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Hand-rolled SHARED chrome (a kit primitive exists for it) instead
|
|
118
|
+
// of the component kit. Store-specific classes are not flagged.
|
|
119
|
+
if (/\b(createElement|el)\(\s*['"](select|input|button|textarea)['"]\s*,\s*['"](spicetify-select|spicetify-searchbar|spicetify-button)\b/.test(line)) {
|
|
120
|
+
out.push({
|
|
121
|
+
severity: "warn",
|
|
122
|
+
rule: "use-the-kit",
|
|
123
|
+
message: "build shared chrome from the kit (Button/Select/TextInput/...) instead of hand-rolling it",
|
|
124
|
+
file: at,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
// A hand-rolled context-menu row, in any form (className=, a template
|
|
128
|
+
// literal, an el() call). The kit's MenuItem owns Spotify's menu-item
|
|
129
|
+
// class via MENU_ITEM_CLASS, so modules never name it directly; the
|
|
130
|
+
// kit's own source is the one legitimate home for the literal.
|
|
131
|
+
if (/main-contextMenu-menuItemButton/.test(line) && !/primitives/.test(rel)) {
|
|
132
|
+
out.push({
|
|
133
|
+
severity: "warn",
|
|
134
|
+
rule: "use-the-kit",
|
|
135
|
+
message: "render context-menu rows with the kit's MenuItem instead of hardcoding main-contextMenu-menuItemButton",
|
|
136
|
+
file: at,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
function checkEntryShim(dir, meta) {
|
|
143
|
+
// css-only modules (themes) declare no js entry, so there is no loader
|
|
144
|
+
// shim to require; enforcing it would fail every theme build (R4).
|
|
145
|
+
if (!meta.entries?.js)
|
|
146
|
+
return [];
|
|
147
|
+
const index = path.join(dir, "index.ts");
|
|
148
|
+
if (!existsSync(index)) {
|
|
149
|
+
return [
|
|
150
|
+
{
|
|
151
|
+
severity: "error",
|
|
152
|
+
rule: "entry-shim",
|
|
153
|
+
message: "missing index.ts entry (should defer to ./mod.js so module code loads only once deps are up)",
|
|
154
|
+
file: "index.ts",
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
const text = readFileSync(index, "utf8");
|
|
159
|
+
if (!/import\(["']\.\/mod\.js["']\)|export\s+(async\s+)?function\s+(load|preload|mixin)/.test(text)) {
|
|
160
|
+
return [
|
|
161
|
+
{
|
|
162
|
+
severity: "warn",
|
|
163
|
+
rule: "entry-shim",
|
|
164
|
+
message: "index.ts should export load/preload/mixin (typically deferring to ./mod.js)",
|
|
165
|
+
file: "index.ts",
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
// checkStructure runs the modularity rules the standard implies but the
|
|
172
|
+
// original ports predate: importable exports, a client-free logic core, and
|
|
173
|
+
// tests. Warnings only — a ratchet, not a flag day: `create` scaffolds all
|
|
174
|
+
// three, so new modules start clean while ports surface their drift.
|
|
175
|
+
export function checkStructure(dir, meta) {
|
|
176
|
+
// css-only themes have no logic to structure or test.
|
|
177
|
+
if (!meta.entries?.js)
|
|
178
|
+
return [];
|
|
179
|
+
const out = [];
|
|
180
|
+
const skip = new Set(["node_modules", "dist", "assets", "public"]);
|
|
181
|
+
let hasTests = false;
|
|
182
|
+
const walk = (d) => {
|
|
183
|
+
for (const entry of readdirSync(d)) {
|
|
184
|
+
const full = path.join(d, entry);
|
|
185
|
+
if (statSync(full).isDirectory()) {
|
|
186
|
+
if (!skip.has(entry))
|
|
187
|
+
walk(full);
|
|
188
|
+
}
|
|
189
|
+
else if (entry.endsWith(".test.mts")) {
|
|
190
|
+
hasTests = true;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
walk(dir);
|
|
195
|
+
if (!hasTests) {
|
|
196
|
+
out.push({
|
|
197
|
+
severity: "warn",
|
|
198
|
+
rule: "tests",
|
|
199
|
+
message: "no *.test.mts anywhere in the module; the standard expects testable logic in a client-free file with colocated node --test coverage",
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
const sources = readSources(dir).filter(({ file }) => path.basename(file) !== "index.ts");
|
|
203
|
+
if (!sources.length)
|
|
204
|
+
return out;
|
|
205
|
+
// Structural rules exist to break up monoliths, not to tax small DOM-glue
|
|
206
|
+
// ports: a 30-line theme toggle has no core worth extracting, and live
|
|
207
|
+
// verification covers it. Below the floor all three rules stay silent.
|
|
208
|
+
const totalLines = sources.reduce((n, { text }) => n + text.split("\n").length, 0);
|
|
209
|
+
if (totalLines < 200) {
|
|
210
|
+
return out.filter((f) => f.rule !== "tests");
|
|
211
|
+
}
|
|
212
|
+
const hasNamedExport = sources.some(({ text }) => /^export (const|let|function|class|async function) /m.test(text));
|
|
213
|
+
if (!hasNamedExport) {
|
|
214
|
+
out.push({
|
|
215
|
+
severity: "warn",
|
|
216
|
+
rule: "exportable-logic",
|
|
217
|
+
message: "nothing importable: every declaration sits behind the default export, so no unit can be imported or tested. Hoist logic to top-level named exports",
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const clientRef = /\b(?:Spicetify|client)\.|\bMAP\./;
|
|
221
|
+
const hasPureFile = sources.some(({ text }) => !clientRef.test(text));
|
|
222
|
+
if (!hasPureFile) {
|
|
223
|
+
out.push({
|
|
224
|
+
severity: "warn",
|
|
225
|
+
rule: "pure-core",
|
|
226
|
+
message: "no client-free source file: every file references Spicetify or MAP. Move parsers/decisions into a dependency-free file (see store/catalog.ts) so they can run under node --test",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
export function checkModule(dir) {
|
|
232
|
+
const findings = [];
|
|
233
|
+
const metaPath = path.join(dir, "metadata.json");
|
|
234
|
+
if (!existsSync(metaPath)) {
|
|
235
|
+
return [
|
|
236
|
+
{
|
|
237
|
+
severity: "error",
|
|
238
|
+
rule: "metadata.missing",
|
|
239
|
+
message: "no metadata.json — not a module",
|
|
240
|
+
file: "metadata.json",
|
|
241
|
+
},
|
|
242
|
+
];
|
|
243
|
+
}
|
|
244
|
+
let meta;
|
|
245
|
+
try {
|
|
246
|
+
meta = JSON.parse(readFileSync(metaPath, "utf8"));
|
|
247
|
+
}
|
|
248
|
+
catch (e) {
|
|
249
|
+
return [
|
|
250
|
+
{
|
|
251
|
+
severity: "error",
|
|
252
|
+
rule: "metadata.parse",
|
|
253
|
+
message: `metadata.json is not valid JSON: ${e.message}`,
|
|
254
|
+
file: "metadata.json",
|
|
255
|
+
},
|
|
256
|
+
];
|
|
257
|
+
}
|
|
258
|
+
findings.push(...checkMetadata(meta));
|
|
259
|
+
findings.push(...checkEntryShim(dir, meta));
|
|
260
|
+
findings.push(...checkStructure(dir, meta));
|
|
261
|
+
const enforceClientBoundary = meta.kind === "extension";
|
|
262
|
+
for (const { file, text } of readSources(dir)) {
|
|
263
|
+
findings.push(...checkSource(path.relative(dir, file), text, { clientCapabilities: enforceClientBoundary }));
|
|
264
|
+
}
|
|
265
|
+
return findings;
|
|
266
|
+
}
|
|
267
|
+
export async function runCheck(argv, cwd = process.cwd()) {
|
|
268
|
+
const target = argv.find((a) => !a.startsWith("--")) ?? ".";
|
|
269
|
+
const modulesDir = path.join(cwd, "modules");
|
|
270
|
+
const dir = resolveModuleDir(target, modulesDir, cwd);
|
|
271
|
+
const findings = checkModule(dir);
|
|
272
|
+
const name = path.basename(dir);
|
|
273
|
+
if (!findings.length) {
|
|
274
|
+
console.log(`✓ ${name}: golden — no standard findings`);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const errors = findings.filter((f) => f.severity === "error").length;
|
|
278
|
+
const warns = findings.length - errors;
|
|
279
|
+
for (const f of findings) {
|
|
280
|
+
const tag = f.severity === "error" ? "error" : "warn ";
|
|
281
|
+
console.log(` ${tag} [${f.rule}] ${f.file ?? ""} ${f.message}`);
|
|
282
|
+
}
|
|
283
|
+
console.log(`\n${name}: ${errors} error(s), ${warns} warning(s) against the module standard (advisory).`);
|
|
284
|
+
}
|