@spicetify/kit 0.1.0 → 0.2.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/dist/check.js +9 -5
- package/dist/create.js +10 -11
- package/dist/from-theme.js +2 -1
- package/dist/stdlib-boundary.js +259 -0
- package/dist/version.js +8 -0
- package/package.json +52 -48
- package/vendor/shims/spicetify.d.ts +14 -2
- package/vendor/stdlib/lib/dialog-lifecycle.ts +53 -0
- package/vendor/stdlib/lib/floating-position.ts +57 -0
- package/vendor/stdlib/lib/floating.tsx +298 -0
- package/vendor/stdlib/lib/modal.tsx +20 -113
- package/vendor/stdlib/lib/popover.ts +26 -19
- package/vendor/stdlib/lib/primitives-classes.ts +12 -0
- package/vendor/stdlib/lib/primitives-vanilla.ts +27 -3
- package/vendor/stdlib/lib/primitives.tsx +176 -26
- package/vendor/stdlib/metadata.json +1 -1
- package/vendor/stdlib/mod.ts +8 -5
- package/vendor/stdlib/src/client.ts +65 -2
- package/vendor/stdlib/src/registers/index.ts +40 -3
- package/vendor/stdlib/src/registers/panel-logic.ts +371 -0
- package/vendor/stdlib/src/registers/panel.ts +63 -151
- package/vendor/stdlib/src/registers/playbarButton.tsx +1 -1
- package/vendor/stdlib/src/registers/route.ts +2 -1
- package/vendor/stdlib/src/registers/settingsRow.tsx +4 -4
- package/vendor/stdlib/src/registers/settingsSection.ts +61 -41
- package/vendor/stdlib/src/registers/topbarLeftButton.tsx +1 -1
- package/vendor/stdlib/src/registers/topbarRightButton.tsx +3 -5
- package/vendor/stdlib/src/settings/corsProxy.tsx +102 -0
package/dist/check.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { resolveModuleDir } from "./build.js";
|
|
16
|
+
import { checkExternalStdlibBoundary } from "./stdlib-boundary.js";
|
|
16
17
|
const SEMVER = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/;
|
|
17
18
|
// The shape of a client webpack hash: a separator-free alphanumeric token,
|
|
18
19
|
// 8+ chars, mixed case (real module classes are lower-kebab, e.g.
|
|
@@ -89,15 +90,15 @@ export function checkSource(rel, text, options = {}) {
|
|
|
89
90
|
});
|
|
90
91
|
}
|
|
91
92
|
// A second React instance breaks hooks/context identity (one-React
|
|
92
|
-
// rule).
|
|
93
|
-
//
|
|
93
|
+
// rule). The public stdlib barrel and the build-time bare-specifier shim
|
|
94
|
+
// both resolve to the client's captured instance.
|
|
94
95
|
if (/from\s+['"]https?:\/\/[^'"]*react[^'"]*['"]/.test(line) &&
|
|
95
96
|
!line.includes("jsx-runtime") &&
|
|
96
97
|
!/expose\/React/.test(rel)) {
|
|
97
98
|
out.push({
|
|
98
99
|
severity: "warn",
|
|
99
100
|
rule: "one-react",
|
|
100
|
-
message: "import React from stdlib's
|
|
101
|
+
message: "import React from stdlib's public barrel or the bare 'react' shim, never a second copy",
|
|
101
102
|
file: at,
|
|
102
103
|
});
|
|
103
104
|
}
|
|
@@ -256,11 +257,14 @@ export function checkModule(dir) {
|
|
|
256
257
|
];
|
|
257
258
|
}
|
|
258
259
|
findings.push(...checkMetadata(meta));
|
|
260
|
+
findings.push(...checkExternalStdlibBoundary(dir, meta));
|
|
259
261
|
findings.push(...checkEntryShim(dir, meta));
|
|
260
262
|
findings.push(...checkStructure(dir, meta));
|
|
261
|
-
const enforceClientBoundary = meta.kind === "extension";
|
|
262
263
|
for (const { file, text } of readSources(dir)) {
|
|
263
|
-
|
|
264
|
+
// The stdlib-boundary audit above owns ambient-client findings for full
|
|
265
|
+
// modules. Keep checkSource's standalone compatibility API but avoid a
|
|
266
|
+
// duplicate warning here.
|
|
267
|
+
findings.push(...checkSource(path.relative(dir, file), text, { clientCapabilities: false }));
|
|
264
268
|
}
|
|
265
269
|
return findings;
|
|
266
270
|
}
|
package/dist/create.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { KIT_DEPENDENCY_RANGE } from "./version.js";
|
|
18
19
|
// The stdlib range a fresh module declares tracks the stdlib it will compile
|
|
19
20
|
// against: the vendored copy in a published kit, the workspace copy in the
|
|
20
21
|
// monorepo. A hardcoded literal here went stale once (^0.3.0 outlived the
|
|
@@ -77,8 +78,7 @@ export default async function (ctx: ModuleRuntimeContext) {
|
|
|
77
78
|
return `${header}
|
|
78
79
|
import { client, createRegistrar } from "/modules/stdlib/mod.ts";
|
|
79
80
|
import type { ModuleRuntimeContext } from "/modules/stdlib/mod.ts";
|
|
80
|
-
import { React } from "/modules/stdlib/
|
|
81
|
-
import { NavLink } from "/modules/stdlib/src/registers/navlink.tsx";
|
|
81
|
+
import { NavLink, React } from "/modules/stdlib/mod.ts";
|
|
82
82
|
import { Button } from "/modules/stdlib/lib/primitives.js";
|
|
83
83
|
import { nowPlaying } from "./logic.ts";
|
|
84
84
|
|
|
@@ -104,9 +104,7 @@ export default async function (ctx: ModuleRuntimeContext) {
|
|
|
104
104
|
return `${header}
|
|
105
105
|
import { client, createRegistrar } from "/modules/stdlib/mod.ts";
|
|
106
106
|
import type { ModuleRuntimeContext } from "/modules/stdlib/mod.ts";
|
|
107
|
-
import { React } from "/modules/stdlib/
|
|
108
|
-
import { Platform } from "/modules/stdlib/src/expose/Platform.ts";
|
|
109
|
-
import { TopbarRightButton } from "/modules/stdlib/src/registers/topbarRightButton.tsx";
|
|
107
|
+
import { Platform, React } from "/modules/stdlib/mod.ts";
|
|
110
108
|
import { nowPlaying } from "./logic.ts";
|
|
111
109
|
|
|
112
110
|
const ROUTE = "${route}";
|
|
@@ -123,10 +121,11 @@ const Page = () => (
|
|
|
123
121
|
export default async function (ctx: ModuleRuntimeContext) {
|
|
124
122
|
const registrar = createRegistrar(ctx);
|
|
125
123
|
|
|
126
|
-
registrar.
|
|
127
|
-
"
|
|
128
|
-
|
|
129
|
-
|
|
124
|
+
registrar.placeButton("topbar-right", {
|
|
125
|
+
label: "${name}",
|
|
126
|
+
icon: ICON,
|
|
127
|
+
onClick: () => Platform.getHistory().push(ROUTE),
|
|
128
|
+
});
|
|
130
129
|
registrar.registerRoute(ROUTE, <Page />);
|
|
131
130
|
}
|
|
132
131
|
`;
|
|
@@ -270,7 +269,7 @@ notification = 1C1C30
|
|
|
270
269
|
dev: "spicetify-kit dev .",
|
|
271
270
|
check: "spicetify-kit check .",
|
|
272
271
|
},
|
|
273
|
-
devDependencies: { "@spicetify/kit":
|
|
272
|
+
devDependencies: { "@spicetify/kit": KIT_DEPENDENCY_RANGE },
|
|
274
273
|
}, null, "\t")}\n`);
|
|
275
274
|
writeFileSync(path.join(dir, ".gitignore"), "node_modules/\ndist/\n");
|
|
276
275
|
}
|
|
@@ -374,7 +373,7 @@ export async function load(ctx: ModuleRuntimeContext) {
|
|
|
374
373
|
test: 'node --test "test/*.test.mts"',
|
|
375
374
|
},
|
|
376
375
|
devDependencies: {
|
|
377
|
-
"@spicetify/kit":
|
|
376
|
+
"@spicetify/kit": KIT_DEPENDENCY_RANGE,
|
|
378
377
|
"@types/react": "^18",
|
|
379
378
|
"@types/react-dom": "^18",
|
|
380
379
|
"happy-dom": "^20",
|
package/dist/from-theme.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { cpSync, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
15
15
|
import path from "node:path";
|
|
16
|
+
import { KIT_DEPENDENCY_RANGE } from "./version.js";
|
|
16
17
|
const USAGE = 'spicetify-kit from-theme <classic-theme-dir> [--name <id>] [--author "..."] [--bare]';
|
|
17
18
|
export async function runFromTheme(argv, cwd = process.cwd()) {
|
|
18
19
|
const source = argv.find((a) => !a.startsWith("--"));
|
|
@@ -76,7 +77,7 @@ export async function runFromTheme(argv, cwd = process.cwd()) {
|
|
|
76
77
|
dev: "spicetify-kit dev .",
|
|
77
78
|
},
|
|
78
79
|
devDependencies: {
|
|
79
|
-
"@spicetify/kit":
|
|
80
|
+
"@spicetify/kit": KIT_DEPENDENCY_RANGE,
|
|
80
81
|
},
|
|
81
82
|
}, null, "\t")}\n`);
|
|
82
83
|
writeFileSync(path.join(dir, ".gitignore"), "node_modules/\ndist/\n");
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 spicetify
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
export const STDLIB_BOUNDARY_WARNING_RULES = ["ambient-client", "client-dom", "direct-map"];
|
|
8
|
+
const SOURCE_EXTENSIONS = new Set([".js", ".jsx", ".mjs", ".ts", ".tsx"]);
|
|
9
|
+
const STYLE_EXTENSIONS = new Set([".css", ".scss"]);
|
|
10
|
+
const PUBLIC_STDLIB_IMPORT = /^\/modules\/stdlib\/(?:mod\.(?:js|ts)|lib\/primitives(?:-classes|-vanilla)?\.(?:js|ts|tsx))$/;
|
|
11
|
+
const CLIENT_DOM_PATTERN = /(?:^|[^A-Za-z0-9_-])(?:Root__|main-[A-Za-z]|player-controls__|playback-progressbar|npv-)|\[data-testid(?:=|\])/;
|
|
12
|
+
const posix = (value) => value.split(path.sep).join("/");
|
|
13
|
+
const lineAt = (text, index) => text.slice(0, index).split("\n").length;
|
|
14
|
+
// Preserve string contents and character offsets while removing comments, so
|
|
15
|
+
// imports/selectors still match and diagnostics retain their original line.
|
|
16
|
+
function withoutComments(text) {
|
|
17
|
+
let out = "";
|
|
18
|
+
let state = "code";
|
|
19
|
+
let escaped = false;
|
|
20
|
+
for (let i = 0; i < text.length; i++) {
|
|
21
|
+
const char = text[i];
|
|
22
|
+
const next = text[i + 1];
|
|
23
|
+
if (state === "line") {
|
|
24
|
+
if (char === "\n") {
|
|
25
|
+
state = "code";
|
|
26
|
+
out += char;
|
|
27
|
+
}
|
|
28
|
+
else
|
|
29
|
+
out += " ";
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (state === "block") {
|
|
33
|
+
if (char === "*" && next === "/") {
|
|
34
|
+
out += " ";
|
|
35
|
+
i++;
|
|
36
|
+
state = "code";
|
|
37
|
+
}
|
|
38
|
+
else
|
|
39
|
+
out += char === "\n" ? "\n" : " ";
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (state === "code") {
|
|
43
|
+
if (char === "/" && next === "/") {
|
|
44
|
+
out += " ";
|
|
45
|
+
i++;
|
|
46
|
+
state = "line";
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (char === "/" && next === "*") {
|
|
50
|
+
out += " ";
|
|
51
|
+
i++;
|
|
52
|
+
state = "block";
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (char === "'")
|
|
56
|
+
state = "single";
|
|
57
|
+
else if (char === '"')
|
|
58
|
+
state = "double";
|
|
59
|
+
else if (char === "`")
|
|
60
|
+
state = "template";
|
|
61
|
+
out += char;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
out += char;
|
|
65
|
+
if (escaped) {
|
|
66
|
+
escaped = false;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (char === "\\") {
|
|
70
|
+
escaped = true;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if ((state === "single" && char === "'") ||
|
|
74
|
+
(state === "double" && char === '"') ||
|
|
75
|
+
(state === "template" && char === "`")) {
|
|
76
|
+
state = "code";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
function firstMatch(source, pattern) {
|
|
82
|
+
pattern.lastIndex = 0;
|
|
83
|
+
return pattern.exec(source);
|
|
84
|
+
}
|
|
85
|
+
export function inspectStdlibBoundaryFile(file, text) {
|
|
86
|
+
const extension = path.extname(file);
|
|
87
|
+
if (!SOURCE_EXTENSIONS.has(extension) && !STYLE_EXTENSIONS.has(extension))
|
|
88
|
+
return [];
|
|
89
|
+
const normalizedFile = posix(file);
|
|
90
|
+
const source = withoutComments(text);
|
|
91
|
+
const findings = [];
|
|
92
|
+
if (SOURCE_EXTENSIONS.has(extension)) {
|
|
93
|
+
const imports = /(?:\bfrom\s*|\bimport\s*(?:\(\s*)?)["'](\/modules\/stdlib\/[^"']+)["']/g;
|
|
94
|
+
for (const match of source.matchAll(imports)) {
|
|
95
|
+
if (PUBLIC_STDLIB_IMPORT.test(match[1]))
|
|
96
|
+
continue;
|
|
97
|
+
findings.push({
|
|
98
|
+
file: normalizedFile,
|
|
99
|
+
line: lineAt(source, match.index),
|
|
100
|
+
rule: "private-stdlib-import",
|
|
101
|
+
detail: `${match[1]} is private; import through /modules/stdlib/mod.js or the primitives kit`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const ambient = firstMatch(source, /\bSpicetify\s*(?:\?\.|\.)|\b(?:globalThis|window)\s*\.\s*Spicetify\b/);
|
|
105
|
+
if (ambient) {
|
|
106
|
+
findings.push({
|
|
107
|
+
file: normalizedFile,
|
|
108
|
+
line: lineAt(source, ambient.index),
|
|
109
|
+
rule: "ambient-client",
|
|
110
|
+
detail: "imports should use stdlib's typed client capability instead of ambient Spicetify.*",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const directMap = firstMatch(source, /\bMAP\s*\./);
|
|
114
|
+
if (directMap) {
|
|
115
|
+
findings.push({
|
|
116
|
+
file: normalizedFile,
|
|
117
|
+
line: lineAt(source, directMap.index),
|
|
118
|
+
rule: "direct-map",
|
|
119
|
+
detail: "MAP.* is direct client coupling; prefer a semantic stdlib surface",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const clientDom = firstMatch(source, CLIENT_DOM_PATTERN);
|
|
124
|
+
if (clientDom) {
|
|
125
|
+
findings.push({
|
|
126
|
+
file: normalizedFile,
|
|
127
|
+
line: lineAt(source, clientDom.index),
|
|
128
|
+
rule: "client-dom",
|
|
129
|
+
detail: "Spotify-owned DOM selectors should be isolated and documented",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return findings;
|
|
133
|
+
}
|
|
134
|
+
function parseExceptions(meta) {
|
|
135
|
+
const configured = meta.stdlibBoundary;
|
|
136
|
+
if (configured === undefined)
|
|
137
|
+
return { exceptions: [], findings: [] };
|
|
138
|
+
const invalid = (message) => ({
|
|
139
|
+
severity: "error",
|
|
140
|
+
rule: "metadata.stdlib-boundary",
|
|
141
|
+
message,
|
|
142
|
+
file: "metadata.json",
|
|
143
|
+
});
|
|
144
|
+
if (typeof configured !== "object" || configured === null || Array.isArray(configured)) {
|
|
145
|
+
return { exceptions: [], findings: [invalid("stdlibBoundary must be an object")] };
|
|
146
|
+
}
|
|
147
|
+
const values = configured.exceptions;
|
|
148
|
+
if (!Array.isArray(values)) {
|
|
149
|
+
return { exceptions: [], findings: [invalid("stdlibBoundary.exceptions must be an array")] };
|
|
150
|
+
}
|
|
151
|
+
const exceptions = [];
|
|
152
|
+
const findings = [];
|
|
153
|
+
const allowedRules = new Set([
|
|
154
|
+
...STDLIB_BOUNDARY_WARNING_RULES,
|
|
155
|
+
"missing-stdlib-dependency",
|
|
156
|
+
]);
|
|
157
|
+
for (const [index, value] of values.entries()) {
|
|
158
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
159
|
+
findings.push(invalid(`stdlibBoundary.exceptions[${index}] must be an object`));
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const candidate = value;
|
|
163
|
+
const validFile = typeof candidate.file === "string" &&
|
|
164
|
+
candidate.file.length > 0 &&
|
|
165
|
+
!candidate.file.includes("\\") &&
|
|
166
|
+
!path.posix.isAbsolute(candidate.file) &&
|
|
167
|
+
path.posix.normalize(candidate.file) === candidate.file &&
|
|
168
|
+
!candidate.file.startsWith("../");
|
|
169
|
+
const validRules = Array.isArray(candidate.rules) &&
|
|
170
|
+
candidate.rules.length > 0 &&
|
|
171
|
+
candidate.rules.every((rule) => typeof rule === "string" && allowedRules.has(rule));
|
|
172
|
+
if (!validFile || !validRules || typeof candidate.reason !== "string" || !candidate.reason.trim()) {
|
|
173
|
+
findings.push(invalid(`stdlibBoundary.exceptions[${index}] needs a normalized relative file, supported rules, and a reason`));
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
exceptions.push(candidate);
|
|
177
|
+
}
|
|
178
|
+
return { exceptions, findings };
|
|
179
|
+
}
|
|
180
|
+
function boundaryFiles(root) {
|
|
181
|
+
const files = [];
|
|
182
|
+
const walk = (directory) => {
|
|
183
|
+
for (const entry of readdirSync(directory)) {
|
|
184
|
+
if (["assets", "dist", "node_modules", "public"].includes(entry))
|
|
185
|
+
continue;
|
|
186
|
+
const full = path.join(directory, entry);
|
|
187
|
+
if (statSync(full).isDirectory())
|
|
188
|
+
walk(full);
|
|
189
|
+
else if (!/\.test\.[cm]?[jt]sx?$/.test(entry))
|
|
190
|
+
files.push(full);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
walk(root);
|
|
194
|
+
return files;
|
|
195
|
+
}
|
|
196
|
+
export function checkExternalStdlibBoundary(root, metadata) {
|
|
197
|
+
if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata))
|
|
198
|
+
return [];
|
|
199
|
+
const meta = metadata;
|
|
200
|
+
const parsed = parseExceptions(meta);
|
|
201
|
+
const out = [...parsed.findings];
|
|
202
|
+
const entries = meta.entries;
|
|
203
|
+
if (typeof entries?.js !== "string" || meta.name === "stdlib")
|
|
204
|
+
return out;
|
|
205
|
+
const isTheme = meta.kind === "theme" || (Array.isArray(meta.tags) && meta.tags.some((tag) => tag === "theme"));
|
|
206
|
+
const dependencies = meta.dependencies;
|
|
207
|
+
const missingDependency = !isTheme &&
|
|
208
|
+
typeof dependencies === "object" &&
|
|
209
|
+
dependencies !== null &&
|
|
210
|
+
!Array.isArray(dependencies) &&
|
|
211
|
+
typeof dependencies.stdlib !== "string";
|
|
212
|
+
const sources = boundaryFiles(root).flatMap((absolute) => {
|
|
213
|
+
const file = posix(path.relative(root, absolute));
|
|
214
|
+
return inspectStdlibBoundaryFile(file, readFileSync(absolute, "utf8"));
|
|
215
|
+
});
|
|
216
|
+
const exceptionAllows = (file, rule) => parsed.exceptions.some((item) => item.file === file && item.rules.includes(rule));
|
|
217
|
+
if (missingDependency && !exceptionAllows("metadata.json", "missing-stdlib-dependency")) {
|
|
218
|
+
out.push({
|
|
219
|
+
severity: "error",
|
|
220
|
+
rule: "stdlib-boundary.dependency",
|
|
221
|
+
message: "executable modules must declare a direct stdlib dependency",
|
|
222
|
+
file: "metadata.json",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
for (const finding of sources) {
|
|
226
|
+
if (finding.rule === "private-stdlib-import") {
|
|
227
|
+
out.push({
|
|
228
|
+
severity: "error",
|
|
229
|
+
rule: "stdlib-boundary.private-import",
|
|
230
|
+
message: finding.detail,
|
|
231
|
+
file: `${finding.file}:${finding.line}`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
else if (!isTheme && !exceptionAllows(finding.file, finding.rule)) {
|
|
235
|
+
out.push({
|
|
236
|
+
severity: "warn",
|
|
237
|
+
rule: `stdlib-boundary.${finding.rule}`,
|
|
238
|
+
message: `${finding.detail}; document an exception only when the coupling is intrinsic`,
|
|
239
|
+
file: `${finding.file}:${finding.line}`,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
for (const exception of parsed.exceptions) {
|
|
244
|
+
for (const rule of exception.rules) {
|
|
245
|
+
const present = rule === "missing-stdlib-dependency"
|
|
246
|
+
? exception.file === "metadata.json" && missingDependency
|
|
247
|
+
: sources.some((finding) => finding.file === exception.file && finding.rule === rule);
|
|
248
|
+
if (!present) {
|
|
249
|
+
out.push({
|
|
250
|
+
severity: "error",
|
|
251
|
+
rule: "stdlib-boundary.stale-exception",
|
|
252
|
+
message: `${exception.file} no longer has ${rule}; remove the exception`,
|
|
253
|
+
file: "metadata.json",
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
// Kept in lockstep with package.json by release-please. Scaffolds must depend
|
|
6
|
+
// on the kit version that generated them, especially across pre-1.0 minors.
|
|
7
|
+
export const KIT_VERSION = "0.2.0"; // x-release-please-version
|
|
8
|
+
export const KIT_DEPENDENCY_RANGE = `^${KIT_VERSION}`;
|
package/package.json
CHANGED
|
@@ -1,49 +1,53 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
2
|
+
"name": "@spicetify/kit",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Developer kit for spicetify v3 modules: scaffold, build, hot-push, and pack",
|
|
5
|
+
"license": "GPL-3.0-or-later",
|
|
6
|
+
"homepage": "https://github.com/spicetify/modules#readme",
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/spicetify/modules/issues"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/spicetify/modules.git",
|
|
13
|
+
"directory": "packages/kit"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"spicetify-kit": "bin/spicetify-kit.js"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./dist/cli.js",
|
|
21
|
+
"./create": "./dist/create.js",
|
|
22
|
+
"./build": "./dist/build.js",
|
|
23
|
+
"./dev": "./dist/dev.js",
|
|
24
|
+
"./pack": "./dist/pack.js",
|
|
25
|
+
"./from-theme": "./dist/from-theme.js",
|
|
26
|
+
"./check": "./dist/check.js",
|
|
27
|
+
"./vault": "./dist/vault.js",
|
|
28
|
+
"./install": "./dist/install.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"bin",
|
|
32
|
+
"dist",
|
|
33
|
+
"vendor"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"spicetify": {
|
|
42
|
+
"stdlibVersion": "1.10.0"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsc -p tsconfig.build.json",
|
|
46
|
+
"test": "node --test \"test/*.test.mts\"",
|
|
47
|
+
"prepack": "node scripts/sync-vendor.ts && tsc -p tsconfig.build.json"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"rolldown": "^1.2.0",
|
|
51
|
+
"sass-embedded": "^1.80.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -508,16 +508,28 @@ declare namespace Spicetify {
|
|
|
508
508
|
const CosmosAPI: any;
|
|
509
509
|
/**
|
|
510
510
|
* Reaches hosts that refuse the client's origin, through the local daemon
|
|
511
|
-
* when it is running and the hosted proxy otherwise.
|
|
512
|
-
*
|
|
511
|
+
* when it is running and the hosted proxy otherwise. Its global
|
|
512
|
+
* configuration API can replace the chain with one custom template.
|
|
513
513
|
*/
|
|
514
514
|
namespace CORSProxy {
|
|
515
|
+
type Mode = "automatic" | "custom";
|
|
516
|
+
interface Configuration {
|
|
517
|
+
mode: Mode;
|
|
518
|
+
template: string | null;
|
|
519
|
+
automaticTemplates: [string, string];
|
|
520
|
+
}
|
|
515
521
|
/** The proxied URL for `target` using the first template in the chain. */
|
|
516
522
|
function url(target: string): string;
|
|
517
523
|
/** Fetches `target` proxied, falling back to the next template only when a request gets no answer. */
|
|
518
524
|
function fetch(target: string, options?: RequestInit): Promise<Response>;
|
|
519
525
|
/** The active chain, in order of preference. */
|
|
520
526
|
function templates(): string[];
|
|
527
|
+
/** Describes the global proxy mode without exposing its storage details. */
|
|
528
|
+
function configuration(): Configuration;
|
|
529
|
+
/** Applies the automatic chain or a validated custom URL template immediately. */
|
|
530
|
+
function configure(options: { mode: "automatic" } | { mode: "custom"; template: string }): Configuration;
|
|
531
|
+
/** True when a custom absolute URL template contains the required `{url}` placeholder. */
|
|
532
|
+
function isValidTemplate(template: string): boolean;
|
|
521
533
|
}
|
|
522
534
|
/**
|
|
523
535
|
* Async wrappers of CosmosAPI
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const FOCUSABLE_SELECTOR = [
|
|
7
|
+
"button:not([disabled])",
|
|
8
|
+
"[href]",
|
|
9
|
+
"input:not([disabled])",
|
|
10
|
+
"select:not([disabled])",
|
|
11
|
+
"textarea:not([disabled])",
|
|
12
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
13
|
+
].join(",");
|
|
14
|
+
|
|
15
|
+
const focusableElements = (dialog: HTMLElement): HTMLElement[] =>
|
|
16
|
+
[...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
|
|
17
|
+
(element) => !element.hidden && element.getAttribute("aria-hidden") !== "true",
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export function activateDialog(dialog: HTMLElement, close: () => void): () => void {
|
|
21
|
+
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
22
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
23
|
+
if (event.key === "Escape") {
|
|
24
|
+
event.preventDefault();
|
|
25
|
+
event.stopPropagation();
|
|
26
|
+
close();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (event.key !== "Tab") return;
|
|
30
|
+
const focusable = focusableElements(dialog);
|
|
31
|
+
if (focusable.length === 0) {
|
|
32
|
+
event.preventDefault();
|
|
33
|
+
dialog.focus();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const first = focusable[0];
|
|
37
|
+
const last = focusable.at(-1)!;
|
|
38
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
last.focus();
|
|
41
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
42
|
+
event.preventDefault();
|
|
43
|
+
first.focus();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
48
|
+
(focusableElements(dialog)[0] ?? dialog).focus();
|
|
49
|
+
return () => {
|
|
50
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
51
|
+
if (previousFocus?.isConnected) previousFocus.focus();
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface AnchorRect {
|
|
7
|
+
left: number;
|
|
8
|
+
top: number;
|
|
9
|
+
right: number;
|
|
10
|
+
bottom: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SurfaceSize {
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ViewportSize {
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FloatingPositionOptions {
|
|
24
|
+
align: "center" | "start";
|
|
25
|
+
gap: number;
|
|
26
|
+
margin?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FloatingPosition {
|
|
30
|
+
left: number;
|
|
31
|
+
top: number;
|
|
32
|
+
placement: "above" | "below";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const clamp = (value: number, minimum: number, maximum: number): number =>
|
|
36
|
+
Math.min(Math.max(value, minimum), Math.max(minimum, maximum));
|
|
37
|
+
|
|
38
|
+
export function calculateFloatingPosition(
|
|
39
|
+
anchor: AnchorRect,
|
|
40
|
+
surface: SurfaceSize,
|
|
41
|
+
viewport: ViewportSize,
|
|
42
|
+
options: FloatingPositionOptions,
|
|
43
|
+
): FloatingPosition {
|
|
44
|
+
const margin = options.margin ?? 8;
|
|
45
|
+
const spaceBelow = viewport.height - anchor.bottom - margin;
|
|
46
|
+
const spaceAbove = anchor.top - margin;
|
|
47
|
+
const placement = spaceBelow >= surface.height + options.gap || spaceBelow >= spaceAbove ? "below" : "above";
|
|
48
|
+
const desiredTop = placement === "below" ? anchor.bottom + options.gap : anchor.top - options.gap - surface.height;
|
|
49
|
+
const anchorCenter = anchor.left + (anchor.right - anchor.left) / 2;
|
|
50
|
+
const desiredLeft = options.align === "center" ? anchorCenter - surface.width / 2 : anchor.left;
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
left: Math.round(clamp(desiredLeft, margin, viewport.width - surface.width - margin)),
|
|
54
|
+
top: Math.round(clamp(desiredTop, margin, viewport.height - surface.height - margin)),
|
|
55
|
+
placement,
|
|
56
|
+
};
|
|
57
|
+
}
|