diff --git a/scripts/svelte-size-guards.ts b/scripts/svelte-size-guards.ts new file mode 100644 index 00000000..2546ef58 --- /dev/null +++ b/scripts/svelte-size-guards.ts @@ -0,0 +1,22 @@ +import { normalizePath, type Plugin } from "vite"; + +export function svelteSizeGuards(): Plugin { + return { + name: "podnotes-svelte-size-guards", + generateBundle(_options, bundle) { + for (const output of Object.values(bundle)) { + if (output.type !== "chunk") continue; + for (const [id, module] of Object.entries(output.modules)) { + if ( + normalizePath(id).includes("/node_modules/clsx/") && + module.renderedLength > 0 + ) { + this.error( + `${output.fileName} includes clsx (${id}); use class directives or a string template instead of class={expression}.`, + ); + } + } + } + }, + }; +} diff --git a/tests/tooling/svelteSizeGuards.test.ts b/tests/tooling/svelteSizeGuards.test.ts new file mode 100644 index 00000000..02c6e325 --- /dev/null +++ b/tests/tooling/svelteSizeGuards.test.ts @@ -0,0 +1,73 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "vite"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const root = fileURLToPath(new URL("../..", import.meta.url)); + +describe("Svelte size guards in the production build", { timeout: 20_000 }, () => { + let dir: string; + + beforeEach(() => { + const scratch = path.join(root, ".obsidian-e2e-artifacts"); + mkdirSync(scratch, { recursive: true }); + dir = mkdtempSync(path.join(scratch, "svelte-size-guards-")); + vi.stubEnv("NODE_ENV", "production"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(dir, { recursive: true, force: true }); + }); + + function buildComponent(source: string, extra = "") { + writeFileSync(path.join(dir, "Component.svelte"), source); + const entry = path.join(dir, "entry.js"); + writeFileSync( + entry, + `export { default as Component } from "./Component.svelte";\n${extra}`, + ); + return build({ + configFile: path.join(root, "vite.config.ts"), + configLoader: "bundle", + mode: "production", + logLevel: "silent", + build: { write: false, lib: { entry } }, + }); + } + + it.each([ + ["no runes syntax", "

Plain markup

"], + [ + "a string class", + '

Text

', + ], + [ + "a class directive", + "

Text

", + ], + ])("accepts a component with %s", async (_name, source) => { + await expect(buildComponent(source)).resolves.toBeDefined(); + }); + + it("rejects legacy prop declarations", async () => { + await expect( + buildComponent("

{value}

"), + ).rejects.toThrow(/Cannot use.*export let.*runes mode/); + }); + + it("rejects a dynamic class that retains clsx after minification", async () => { + await expect( + buildComponent( + "

Text

", + ), + ).rejects.toThrow(/main\.js.*includes clsx/); + }); + + it("rejects a direct clsx dependency", async () => { + await expect( + buildComponent("

Plain markup

", 'export { clsx } from "clsx";'), + ).rejects.toThrow(/main\.js.*includes clsx/); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 585beee1..80522b7c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,7 @@ import { createBuildArtifactPlugin } from "./scripts/build-artifacts.ts"; import { stripCssComments } from "./scripts/css-comments.ts"; import { compactCss } from "./scripts/compact-css.ts"; import { svelteWithoutHydration } from "./scripts/svelte-without-hydration.ts"; +import { svelteSizeGuards } from "./scripts/svelte-size-guards.ts"; import { syntaxFloor } from "./scripts/syntax-floor.ts"; // Obsidian's Android app sets no WebView minimum and its own code is ES2020, and its @@ -68,6 +69,7 @@ export default defineConfig(({ mode }) => { }, }, compilerOptions: { + runes: true, css: "injected", discloseVersion: false, // Svelte skips a style whose id is already in the document, and Obsidian @@ -77,6 +79,7 @@ export default defineConfig(({ mode }) => { }), // Development builds keep their source maps, which this transform does not update. isProd && svelteWithoutHydration(), + svelteSizeGuards(), syntaxFloor(), createBuildArtifactPlugin({ isProduction: isProd, outDir, rootDir }), ],