diff --git a/packages/plugin-sdk/cli/commands/build.mjs b/packages/plugin-sdk/cli/commands/build.mjs index 3c70bf0bf..7a124268f 100644 --- a/packages/plugin-sdk/cli/commands/build.mjs +++ b/packages/plugin-sdk/cli/commands/build.mjs @@ -112,6 +112,19 @@ const THEME_CSS = path.join( * otherwise have no CSS at all. Appended to whatever CSS the bundle already * produced (a library's stylesheet the plugin imports). */ +/** + * Moves a plugin's Tailwind layers under their own names. Core orders + * termix-plugin-utilities above its base styles and below its own utilities, + * so a plugin's copy of `.hidden` can never beat core's `md:flex` just + * because the plugin's file loaded later. + */ +export function lowerPluginLayers(css) { + return css.replace( + /@layer (properties|utilities)(?=[\s{;,])/g, + "@layer termix-plugin-$1", + ); +} + export async function buildTailwind(cwd, outDir) { const { compile, optimize } = await import("@tailwindcss/node"); const { Scanner } = await import("@tailwindcss/oxide"); @@ -138,9 +151,9 @@ export async function buildTailwind(cwd, outDir) { }); // Flattened and minified the way core's own CSS is, so it does not rely // on native CSS nesting. - const css = optimize(compiler.build(scanner.scan()), { - minify: true, - }).code.trim(); + const css = lowerPluginLayers( + optimize(compiler.build(scanner.scan()), { minify: true }).code.trim(), + ); if (!css) return; const target = path.join(outDir, "frontend.css"); const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : ""; diff --git a/scripts/plugin-build.test.ts b/scripts/plugin-build.test.ts index 75b1472b7..f56253f59 100644 --- a/scripts/plugin-build.test.ts +++ b/scripts/plugin-build.test.ts @@ -3,7 +3,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { build } from "../packages/plugin-sdk/cli/commands/build.mjs"; +import { + build, + lowerPluginLayers, +} from "../packages/plugin-sdk/cli/commands/build.mjs"; const cleanups: Array<() => void> = []; @@ -84,3 +87,38 @@ describe("termix-plugin build", () => { expect(mod.mode).toBe("production"); }); }); + +describe("plugin CSS layers", () => { + it("renames the Tailwind layers so core's utilities always win", () => { + expect( + lowerPluginLayers( + "@layer properties{a{b:c}}@layer utilities{.flex{display:flex}}", + ), + ).toBe( + "@layer termix-plugin-properties{a{b:c}}@layer termix-plugin-utilities{.flex{display:flex}}", + ); + expect(lowerPluginLayers("@layer utilities-extra{}")).toBe( + "@layer utilities-extra{}", + ); + }); + + it("is ordered in core's CSS between base and core's utilities", () => { + const css = fs.readFileSync( + path.join(__dirname, "..", "src", "ui", "index.css"), + "utf8", + ); + const order = /@layer ([^;{]+);/.exec(css)?.[1].split(/[\s,]+/); + expect(order).toEqual([ + "properties", + "termix-plugin-properties", + "theme", + "base", + "components", + "termix-plugin-utilities", + "utilities", + ]); + expect(css.indexOf("@layer properties,")).toBeLessThan( + css.indexOf('@import "tailwindcss"'), + ); + }); +}); diff --git a/src/backend/plugins/assets.ts b/src/backend/plugins/assets.ts index 928f6b7a1..873724593 100644 --- a/src/backend/plugins/assets.ts +++ b/src/backend/plugins/assets.ts @@ -93,11 +93,16 @@ export function describePluginFrontend( const entry = frontendEntry(plugin); if (!entry || !fileExists(entry.file)) return { ...none, locales }; - const assetVersion = contentVersion(entry.file, plugin.manifest.version); + const cssFile = path.join(entry.dir, "frontend.css"); + const css = fileExists(cssFile); + const assetVersion = contentVersion( + css ? [entry.file, cssFile] : [entry.file], + plugin.manifest.version, + ); return { frontend: true, - css: fileExists(path.join(entry.dir, "frontend.css")), + css, assetVersion, locales: locales.sort(), }; @@ -108,21 +113,25 @@ const versionCache = new Map(); /** * A hash of the bundle's bytes, not its mtime: assets are cached as immutable * for a year, and a rebuilt .tmxplug has a fixed mtime, so a same-size rebuild - * would otherwise keep serving the stale copy. + * would otherwise keep serving the stale copy. The CSS is hashed with the JS + * because both are served under the same version. */ -function contentVersion(file: string, version: string): string { - const stat = fs.statSync(file); +function contentVersion(files: string[], version: string): string { // ctime, unlike mtime, cannot be set by tar, so an unpack always changes it. - const stamp = `${version}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`; - const cached = versionCache.get(file); + const stamp = [ + version, + ...files.map((file) => { + const stat = fs.statSync(file); + return `${file}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`; + }), + ].join("|"); + const key = files.join("|"); + const cached = versionCache.get(key); if (cached?.stamp === stamp) return cached.version; - const hashed = crypto - .createHash("sha256") - .update(version) - .update(fs.readFileSync(file)) - .digest("hex") - .slice(0, 12); - versionCache.set(file, { stamp, version: hashed }); + const hash = crypto.createHash("sha256").update(version); + for (const file of files) hash.update(fs.readFileSync(file)); + const hashed = hash.digest("hex").slice(0, 12); + versionCache.set(key, { stamp, version: hashed }); return hashed; } diff --git a/src/backend/tests/plugins/assets.test.ts b/src/backend/tests/plugins/assets.test.ts index 74dca927c..bc088dd9c 100644 --- a/src/backend/tests/plugins/assets.test.ts +++ b/src/backend/tests/plugins/assets.test.ts @@ -63,6 +63,17 @@ describe("describePluginFrontend", () => { expect(info.locales).toEqual(["de_DE", "en"]); }); + it("changes the version when only the stylesheet changes", () => { + const before = describePluginFrontend(plugin).assetVersion; + const css = path.join(root, "gizmo", "dist", "frontend.css"); + fs.writeFileSync(css, ".gizmo{color:red}"); + try { + expect(describePluginFrontend(plugin).assetVersion).not.toBe(before); + } finally { + fs.writeFileSync(css, ".gizmo{}"); + } + }); + it("reports nothing for an unknown plugin", () => { expect(describePluginFrontend(undefined)).toEqual({ frontend: false, diff --git a/src/ui/index.css b/src/ui/index.css index 4fa30b079..916780970 100644 --- a/src/ui/index.css +++ b/src/ui/index.css @@ -1,3 +1,6 @@ +/* Plugin utilities (termix-plugin build) rank above base, below core's own. */ +@layer properties, termix-plugin-properties, theme, base, components, + termix-plugin-utilities, utilities; @import "tailwindcss"; @import "tw-animate-css"; @import "./theme.css"; @@ -13,9 +16,6 @@ @import "@fontsource/source-code-pro/700.css"; @import "@fontsource/source-code-pro/700-italic.css"; -/* Plugins compile their own classes (termix-plugin build). */ -@source not "../../plugins"; - @font-face { font-family: "Caskaydia Cove Nerd Font Mono"; src: url("/fonts/CaskaydiaCoveNerdFontMono-Regular.ttf") format("truetype");