fix: keep plugin css below core utilities and version plugin assets by their css

This commit is contained in:
LukeGus committed 2026-09-29 19:06:57 -05:00
1 parent 80944301dd
commit 1a6f9ffad6
5 files changed
+92 -21

No files matched your search

+16 -3
View File
@@ -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") : "";
+39 -1
View File
@@ -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"'),
);
});
});
+23 -14
View File
@@ -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<string, { stamp: string; version: string }>();
/**
* 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;
}
+11
View File
@@ -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,
+3 -3
View File
@@ -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");