Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions modules/store/catalog.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
searchHaystack,
type VaultModule,
} from "./catalog.ts";
import { kindOfMeta } from "../../packages/kit/src/vault-metadata.ts";
import { KIND_CASES } from "../../packages/kit/test/kind-cases.ts";

const mod = (over: Partial<VaultModule> = {}): VaultModule => ({
id: "sample",
Expand Down Expand Up @@ -106,6 +108,12 @@ describe("card derivations", () => {
assert.equal(kindOf({ tags: ["retro", "dark"] }), "extension");
});

it("kindOf follows the kit's kindOfMeta, defaulting to extension", () => {
for (const meta of KIND_CASES) {
assert.equal(kindOf(meta as never), kindOfMeta(meta) ?? "extension", JSON.stringify(meta));
}
});

it("displayVersion strips the classmap build-metadata suffix", () => {
assert.equal(displayVersion("1.2.0+cm-1020094-19f856aefd5"), "1.2.0");
assert.equal(displayVersion("1.2.0"), "1.2.0");
Expand Down
3 changes: 2 additions & 1 deletion modules/store/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,8 @@ const KINDS: ModuleKind[] = ["extension", "theme", "snippet", "app", "lib"];
// is inert: it never joins the single-theme contest.
export const kindOf = (meta: { kind?: string; tags?: string[] } | undefined): ModuleKind => {
if (meta?.kind && (KINDS as string[]).includes(meta.kind)) return meta.kind as ModuleKind;
return KINDS.find((kind) => meta?.tags?.includes(kind)) ?? "extension";
const tags = Array.isArray(meta?.tags) ? meta.tags : [];
return KINDS.find((kind) => tags.includes(kind)) ?? "extension";
};

// Vault version keys carry a "+cm-<classmap>-<hash>" build-metadata suffix
Expand Down
2 changes: 1 addition & 1 deletion modules/store/metadata.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "store",
"kind": "app",
"version": "1.8.0",
"version": "1.8.1",
"authors": ["spicetify"],
"description": "Browse, install, and manage v3 modules from vaults",
"entries": {
Expand Down
6 changes: 4 additions & 2 deletions packages/kit/src/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";

import { KIND_OF_META_SOURCE } from "./vault-metadata.ts";

export interface LocalModuleRecord {
metadata: Record<string, unknown>;
files: Record<string, string>;
Expand Down Expand Up @@ -299,8 +301,8 @@ export function pushExpression(rec: LocalModuleRecord, id: string, nonce: string
if (reenabled) await (M.reload ?? M.enable)(id);
// Re-enabling a theme the loader just unloaded would fight the
// single-active-theme invariant and knock the pushed theme back off.
// metadata.json says "kind"; modules published before it carry "tags".
const themed = (meta) => meta?.kind === "theme" || (meta?.tags ?? []).includes("theme");
const kindOf = ${KIND_OF_META_SOURCE};
const themed = (meta) => kindOf(meta) === "theme";
const pushedIsTheme = themed(rec.metadata);
const isTheme = (mid) => themed(M.manifest?.modules?.find((m) => m.identifier === mid));
for (const other of before) {
Expand Down
4 changes: 3 additions & 1 deletion packages/kit/src/stdlib-boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";

import { kindOfMeta } from "./vault-metadata.ts";

export const STDLIB_BOUNDARY_WARNING_RULES = ["ambient-client", "client-dom", "direct-map"] as const;
export type StdlibBoundaryWarningRule = (typeof STDLIB_BOUNDARY_WARNING_RULES)[number];
export type StdlibBoundarySourceRule = StdlibBoundaryWarningRule | "private-stdlib-import";
Expand Down Expand Up @@ -237,7 +239,7 @@ export function checkExternalStdlibBoundary(root: string, metadata: unknown): Ex
const out = [...parsed.findings];
const entries = meta.entries as { js?: unknown } | undefined;
if (typeof entries?.js !== "string" || meta.name === "stdlib") return out;
const isTheme = meta.kind === "theme" || (Array.isArray(meta.tags) && meta.tags.some((tag) => tag === "theme"));
const isTheme = kindOfMeta(meta) === "theme";

const dependencies = meta.dependencies;
const missingDependency =
Expand Down
9 changes: 9 additions & 0 deletions packages/kit/src/vault-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ export const kindOfMeta = (meta: Record<string, unknown>): VaultKind | undefined
return KINDS.find((kind) => tags.includes(kind));
};

// kindOfMeta as JavaScript source, for expressions evaluated inside the
// client where kit code cannot be imported. It also accepts a missing meta.
export const KIND_OF_META_SOURCE = `((meta) => {
const kinds = ${JSON.stringify(KINDS)};
if (typeof meta?.kind === "string" && kinds.includes(meta.kind)) return meta.kind;
const tags = Array.isArray(meta?.tags) ? meta.tags : [];
return kinds.find((kind) => tags.includes(kind));
})`;

// metadata.json authors are plain names; author objects (with a github)
// pass through, so an artifact may declare either.
const normalizeAuthors = (authors: unknown[]): VaultAuthor[] =>
Expand Down
29 changes: 29 additions & 0 deletions packages/kit/test/kind-cases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright (C) 2026 Afonso Jorge Ramos
* SPDX-License-Identifier: GPL-3.0-or-later
*/

// metadata.json shapes every copy of the kind rule must read the same way:
// `kind` wins, `tags` is the fallback, unknown kinds are ignored.
export const KIND_CASES: Record<string, unknown>[] = [
{},
{ kind: "theme" },
{ kind: "extension" },
{ kind: "snippet" },
{ kind: "app" },
{ kind: "lib" },
{ kind: "nonsense" },
{ kind: 5, tags: ["theme"] },
{ tags: ["theme"] },
{ tags: ["retro", "theme", "dark"] },
{ tags: ["snippet"] },
{ tags: ["retro", "dark"] },
{ tags: ["theme", "extension"] },
{ tags: [] },
{ tags: "theme" },
{ tags: [1, "theme"] },
{ kind: "extension", tags: ["theme"] },
{ kind: "theme", tags: ["extension"] },
{ kind: "nonsense", tags: ["theme"] },
{ kind: "Theme" },
];
49 changes: 49 additions & 0 deletions packages/kit/test/push.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
type LocalModuleRecord,
pushExpression,
} from "../src/push.ts";
import { KIND_OF_META_SOURCE, kindOfMeta } from "../src/vault-metadata.ts";
import { KIND_CASES } from "./kind-cases.ts";

// Build a record whose serialized size (code units) is ~bytes via a filler.
function recOfSize(bytes: number): LocalModuleRecord {
Expand Down Expand Up @@ -106,3 +108,50 @@ for (const declared of [{ kind: "theme" }, { tags: ["theme"] }]) {
assert.deepEqual(enabled, [], "the old theme stays unloaded");
});
}

test("the in-page kind rule matches kindOfMeta", () => {
const inPage = runInNewContext(KIND_OF_META_SOURCE) as (meta: unknown) => unknown;
for (const meta of KIND_CASES) assert.equal(inPage(meta), kindOfMeta(meta), JSON.stringify(meta));
assert.equal(inPage(undefined), undefined);
});

// Installing unloads every other module, so what push re-enables shows which
// modules it judged to be themes.
async function reenabledAfterPush(pushed: Record<string, unknown>, other: Record<string, unknown>) {
const modules = [
{ identifier: "other", ...other },
{ identifier: "pushed", ...pushed },
];
const state = new Set(["other"]);
const enabled: string[] = [];
const Modules = {
manifest: { modules },
report: { failed: {} },
list: () => modules.map((m) => ({ identifier: m.identifier, loaded: state.has(m.identifier) })),
installLocal: async (id: string) => {
state.clear();
state.add(id);
return {};
},
enable: async (id: string) => {
enabled.push(id);
state.add(id);
},
};
const rec = { metadata: { identifier: "pushed", version: "0.1.0", ...pushed }, files: {} } as never;
await runInNewContext(pushExpression(rec, "pushed", "n", false), {
Spicetify: { Modules },
setTimeout,
JSON,
globalThis: { Spicetify: { Modules } },
});
return enabled.includes("other");
}

for (const meta of KIND_CASES) {
test(`push judges ${JSON.stringify(meta)} a theme exactly when kindOfMeta does`, async () => {
const theme = kindOfMeta(meta) === "theme";
assert.equal(await reenabledAfterPush(meta, { kind: "theme" }), !theme, "as the pushed module");
assert.equal(await reenabledAfterPush({ kind: "theme" }, meta), !theme, "as an installed module");
});
}
8 changes: 8 additions & 0 deletions packages/kit/test/stdlib-boundary.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,12 @@ describe("external stdlib boundary", () => {
assert.ok(!findings.includes("warn:stdlib-boundary.ambient-client"));
assert.ok(!findings.includes("warn:stdlib-boundary.client-dom"));
});

it("reads the kind the way kindOfMeta does", () => {
const files = { "index.ts": "export function load() {}" };
const legacyTheme = moduleFixture(files, { kind: undefined, tags: ["theme"], dependencies: {} });
assert.ok(!rules(legacyTheme).includes("error:stdlib-boundary.dependency"), "tags is the fallback");
const declared = moduleFixture(files, { kind: "extension", tags: ["theme"], dependencies: {} });
assert.ok(rules(declared).includes("error:stdlib-boundary.dependency"), "kind wins over tags");
});
});
3 changes: 2 additions & 1 deletion scripts/client-boundary.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from "node:path";
import { test } from "node:test";

import { checkSource } from "../packages/kit/src/check.ts";
import { kindOfMeta } from "../packages/kit/src/vault-metadata.ts";

const modulesRoot = path.resolve("modules");

Expand All @@ -28,7 +29,7 @@ test("hosted extensions use the stdlib client capability boundary", () => {
const directory = path.join(modulesRoot, entry);
if (!statSync(directory).isDirectory()) continue;
const metadata = JSON.parse(readFileSync(path.join(directory, "metadata.json"), "utf8"));
if (metadata.kind !== "extension") continue;
if (kindOfMeta(metadata) !== "extension") continue;

for (const file of sourceFiles(directory)) {
const relative = path.relative(directory, file);
Expand Down
41 changes: 41 additions & 0 deletions scripts/kind-of-meta.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright (C) 2026 Afonso Jorge Ramos
* SPDX-License-Identifier: GPL-3.0-or-later
*/

import assert from "node:assert/strict";
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { test } from "node:test";

const root = path.resolve(import.meta.dirname, "..");
const SCANNED = ["packages/kit/src", "scripts"];
const SOURCE = /\.[cm]?[jt]sx?$/;
// Legacy `tags` is read only by kindOfMeta and its in-page twin, which
// packages/kit/test/push.test.mts holds equal to it.
const ALLOWED = new Set(["packages/kit/src/vault-metadata.ts", "scripts/kind-of-meta.test.mts"]);
const TAGS_READ = /\.tags\b|\[\s*["'`]tags["'`]\s*\]/;

function sources(directory: string): string[] {
return readdirSync(directory).flatMap((entry) => {
const full = path.join(directory, entry);
if (statSync(full).isDirectory()) return entry === "node_modules" ? [] : sources(full);
return SOURCE.test(entry) ? [full] : [];
});
}

test("kit and scripts read a module's kind only through kindOfMeta", () => {
const reads: string[] = [];
for (const directory of SCANNED) {
for (const file of sources(path.join(root, directory))) {
const relative = path.relative(root, file).split(path.sep).join("/");
if (ALLOWED.has(relative)) continue;
readFileSync(file, "utf8")
.split("\n")
.forEach((line, index) => {
if (TAGS_READ.test(line)) reads.push(`${relative}:${index + 1}: ${line.trim()}`);
});
}
}
assert.deepEqual(reads, [], "use kindOfMeta from packages/kit/src/vault-metadata.ts");
});
7 changes: 3 additions & 4 deletions scripts/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";

import { kindOfMeta } from "../packages/kit/src/vault-metadata.ts";

const REPO_RAW = "https://raw.githubusercontent.com/spicetify/modules/main/previews";

// 24x24 path markup, drawn in currentColor.
Expand Down Expand Up @@ -56,8 +58,6 @@ const prettify = (id: string) =>
.map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
.join(" ");

const KINDS = ["extension", "theme", "snippet", "app", "lib"];

function render(id: string, title: string, category: string, icon: string, accent: string): string {
const paths = ICONS[icon] ?? ICONS.bolt;
return `<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">
Expand Down Expand Up @@ -97,8 +97,7 @@ function main(): void {
accent: FALLBACK_ACCENTS[[...id].reduce((a, c) => a + c.charCodeAt(0), 0) % FALLBACK_ACCENTS.length],
};
const title = style.title ?? prettify(meta.name ?? id);
const category =
(KINDS.includes(meta.kind) ? meta.kind : KINDS.find((k) => (meta.tags ?? []).includes(k))) ?? "module";
const category = kindOfMeta(meta) ?? "module";
const out = path.join("previews", `${id}.svg`);
writeFileSync(out, render(id, title, category, style.icon, style.accent));
meta.preview = `${REPO_RAW}/${id}.svg`;
Expand Down
6 changes: 4 additions & 2 deletions scripts/settings-ownership.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import { describe, it } from "node:test";

import { kindOfMeta } from "../packages/kit/src/vault-metadata.ts";

const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");

const settingsPageModules = [
Expand Down Expand Up @@ -48,8 +50,8 @@ describe("first-party settings ownership", () => {
const discovered = readdirSync(new URL("../modules/", import.meta.url))
.filter((id) => {
try {
const metadata = JSON.parse(read(`modules/${id}/metadata.json`)) as { kind?: string };
return metadata.kind === "app" || metadata.kind === "extension";
const kind = kindOfMeta(JSON.parse(read(`modules/${id}/metadata.json`)));
return kind === "app" || kind === "extension";
} catch {
return false;
}
Expand Down
Loading