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: {