diff --git a/scripts/compact-css.ts b/scripts/compact-css.ts new file mode 100644 index 00000000..a70ac830 --- /dev/null +++ b/scripts/compact-css.ts @@ -0,0 +1,29 @@ +import postcss from "postcss"; + +export function compactCss(css: string): string { + const root = postcss.parse(css); + const whitespace = /^[ \t\n\r\f]*$/; + root.walk((node) => { + if (node.type === "rule" || node.type === "atrule") node.raws.semicolon = false; + if (whitespace.test(node.raws.before ?? "")) node.raws.before = ""; + if ( + (node.type === "rule" || node.type === "atrule") && + whitespace.test(node.raws.after ?? "") + ) + node.raws.after = ""; + if ( + node.type === "decl" && + !node.prop.startsWith("--") && + /^[ \t\n\r\f]*:[ \t\n\r\f]*$/.test(node.raws.between ?? "") + ) { + node.raws.between = ":"; + } else if ( + (node.type === "rule" || node.type === "atrule") && + whitespace.test(node.raws.between ?? "") + ) { + node.raws.between = ""; + } + }); + if (whitespace.test(root.raws.after ?? "")) root.raws.after = ""; + return root.toString(); +} diff --git a/tests/tooling/buildOptions.test.ts b/tests/tooling/buildOptions.test.ts new file mode 100644 index 00000000..3b68ff85 --- /dev/null +++ b/tests/tooling/buildOptions.test.ts @@ -0,0 +1,60 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { runInNewContext } from "node:vm"; +import { build } from "vite"; +import { expect, it, vi } from "vitest"; + +const root = fileURLToPath(new URL("../..", import.meta.url)); + +it("preserves getter reads and compact injected CSS through the production build", async () => { + const scratch = path.join(root, ".obsidian-e2e-artifacts"); + mkdirSync(scratch, { recursive: true }); + const dir = mkdtempSync(path.join(scratch, "build-options-")); + vi.stubEnv("NODE_ENV", "production"); + try { + writeFileSync( + path.join(dir, "Component.svelte"), + '
Example
', + ); + const entry = path.join(dir, "entry.js"); + writeFileSync( + entry, + `export { default as Component } from "./Component.svelte"; + export function readGetter(object) { object.current; }`, + ); + const result = await build({ + configFile: path.join(root, "vite.config.ts"), + configLoader: "bundle", + mode: "production", + logLevel: "silent", + plugins: [ + { + name: "assert-production", + configResolved(config) { + expect(config.isProduction).toBe(true); + }, + }, + ], + build: { write: false, lib: { entry } }, + }); + if (!Array.isArray(result)) throw new Error("Expected library build outputs"); + const output = result.flatMap((chunk) => chunk.output); + expect(output.map((chunk) => chunk.fileName)).toEqual(["main.js"]); + const chunk = output[0]; + if (chunk?.type !== "chunk") throw new Error("Expected a JavaScript chunk"); + expect(chunk.code).toMatch(/\.fixture\.pn-[a-z0-9]+\{--empty: ;width:calc\(100% - 2px\)\}/); + const exports: { readGetter?: (object: { current: number }) => void } = {}; + runInNewContext(chunk.code, { exports, module: { exports } }); + let reads = 0; + exports.readGetter?.({ + get current() { + return ++reads; + }, + }); + expect(reads).toBe(1); + } finally { + vi.unstubAllEnvs(); + rmSync(dir, { recursive: true, force: true }); + } +}, 20_000); diff --git a/tests/tooling/compactCss.test.ts b/tests/tooling/compactCss.test.ts new file mode 100644 index 00000000..8a215b0d --- /dev/null +++ b/tests/tooling/compactCss.test.ts @@ -0,0 +1,49 @@ +import { compile, preprocess } from "svelte/compiler"; +import { describe, expect, it } from "vitest"; +import { stripCssComments } from "../../scripts/css-comments"; +import { compactCss } from "../../scripts/compact-css"; + +describe("compactCss", () => { + it("removes formatting around rules, at-rules, and ordinary declarations", () => { + expect(compactCss("@media (width > 10px) { .a \n\t{ color: red; } }")).toBe( + "@media (width > 10px){.a{color:red}}", + ); + }); + + it.each([ + ['.a .b { content: "two words"; }', '.a .b{content:"two words"}'], + [".a { width: calc(100% - 2px); }", ".a{width:calc(100% - 2px)}"], + [".a { --empty: ; --tokens: one two; }", ".a{--empty: ;--tokens: one two}"], + [".a { background: url(/a/*b*/c.png); }", ".a{background:url(/a/*b*/c.png)}"], + [".a /**/ .b { color: r/**/ed; }", ".a /**/ .b{color:r/**/ed}"], + ["/*! Keep this */ .a { color: red; }", "/*! Keep this */.a{color:red}"], + ["@import url(example.css) screen;", "@import url(example.css) screen;"], + [".a\u00a0{ color: red; }", ".a\u00a0{color:red}"], + [".a { --empty: ; }", ".a{--empty: }"], + [".a /* keep */ { color: red; }", ".a /* keep */ {color:red}"], + [".a { color /* keep */ : red; }", ".a{color /* keep */ : red}"], + [".a { color: red; background: blue; }", ".a{color:red;background:blue}"], + [".a { color: red; /*! keep */ }", ".a{color:red/*! keep */}"], + [" \n\t", ""], + ])("preserves tokens in %s", (input, expected) => { + expect(compactCss(input)).toBe(expected); + expect(compactCss(expected)).toBe(expected); + }); + + it("compiles compact CSS into the injected stylesheet without changing values", async () => { + const source = `
Example
`; + const processed = await preprocess(source, { + style: ({ content }) => ({ code: compactCss(stripCssComments(content)) }), + }); + const result = compile(processed.code, { + css: "injected", + cssHash: () => "podnotes-test", + }); + expect(result.js.code).toContain(".a.podnotes-test{--empty: ;width:calc(100% - 2px)}"); + expect(result.js.code).toContain("@media (min-width: 10px){.a.podnotes-test{color:red}"); + expect(result.js.code).toContain("$.append_styles"); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 4e5bbfae..585beee1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,7 @@ import { svelte } from "@sveltejs/vite-plugin-svelte"; import { defineConfig } from "vite"; 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 { syntaxFloor } from "./scripts/syntax-floor.ts"; @@ -60,7 +61,12 @@ export default defineConfig(({ mode }) => { plugins: [ svelte({ // Svelte copies style comments verbatim into the CSS it injects from main.js. - preprocess: { style: ({ content }) => ({ code: stripCssComments(content) }) }, + preprocess: { + style: ({ content }) => { + const css = stripCssComments(content); + return { code: isProd ? compactCss(css) : css }; + }, + }, compilerOptions: { css: "injected", discloseVersion: false, @@ -96,7 +102,7 @@ export default defineConfig(({ mode }) => { // before the Oxc minifier below, which mangles names and drops console calls. minify: isProd ? "terser" : false, // Reading a Svelte state property subscribes to it, so a read is not pure. - terserOptions: { compress: { pure_getters: false }, mangle: false }, + terserOptions: { compress: { pure_getters: false, passes: 2 }, mangle: false }, rollupOptions: { external, output: {