fix: put plugin-only tailwind classes in core's utilities layer, SDK 1.0.4

This commit is contained in:
LukeGus committed 2026-10-08 13:38:52 -05:00
1 parent d6c4ec01b5
commit 878f1ebaa3
6 files changed
+169 -13

No files matched your search

+35 -12
View File
@@ -7,6 +7,7 @@ import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
import { staticUrlImports } from "../lib/static-url-imports.mjs";
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
import { openapi } from "./openapi.mjs";
import { splitByCoreClasses } from "../lib/css-classes.mjs";
const BACKEND_ENTRIES = [
"src/backend/index.ts",
@@ -97,14 +98,15 @@ export function applyPatches(cwd, pluginId) {
}
}
const THEME_CSS = path.join(
const HOST_DIR = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"dist",
"host",
"theme.css",
);
const THEME_CSS = path.join(HOST_DIR, "theme.css");
const CORE_CLASSES = path.join(HOST_DIR, "core-classes.json");
/**
* Compiles the Tailwind classes the plugin's frontend uses into
@@ -117,15 +119,23 @@ const THEME_CSS = path.join(
* 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.
* because the plugin's file loaded later. Only classes core also ships go
* there; `utilities: false` keeps the rest in core's own utilities layer.
*/
export function lowerPluginLayers(css) {
export function lowerPluginLayers(css, { utilities = true } = {}) {
const layers = utilities ? "properties|utilities" : "properties";
return css.replace(
/@layer (properties|utilities)(?=[\s{;,])/g,
new RegExp(`@layer (${layers})(?=[\\s{;,])`, "g"),
"@layer termix-plugin-$1",
);
}
/** Classes core's CSS has rules for, or null with an SDK built before 1.0.4. */
function readCoreClasses() {
if (!fs.existsSync(CORE_CLASSES)) return null;
return new Set(JSON.parse(fs.readFileSync(CORE_CLASSES, "utf8")));
}
export async function buildTailwind(cwd, outDir) {
const { compile, optimize } = await import("@tailwindcss/node");
const { Scanner } = await import("@tailwindcss/oxide");
@@ -137,10 +147,9 @@ export async function buildTailwind(cwd, outDir) {
`@import ${JSON.stringify(THEME_CSS.replaceAll("\\", "/"))};`,
'@import "tailwindcss/utilities.css" layer(utilities);',
].join("\n");
const compiler = await compile(input, {
base: path.dirname(THEME_CSS),
onDependency: () => {},
});
// build() adds to what a compiler already emitted, so each half gets its own.
const compiler = () =>
compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
const scanner = new Scanner({
sources: [
{
@@ -152,9 +161,23 @@ 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 = lowerPluginLayers(
optimize(compiler.build(scanner.scan()), { minify: true }).code.trim(),
);
const emit = async (candidates, options) =>
candidates.length === 0
? ""
: lowerPluginLayers(
optimize((await compiler()).build(candidates), {
minify: true,
}).code.trim(),
options,
);
const candidates = scanner.scan();
const coreClasses = readCoreClasses();
const { shared, own } = coreClasses
? splitByCoreClasses(candidates, coreClasses)
: { shared: candidates, own: [] };
const css = [await emit(shared), await emit(own, { utilities: false })]
.filter(Boolean)
.join("\n");
if (!css) return;
const target = path.join(outDir, "frontend.css");
const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : "";
@@ -0,0 +1,35 @@
/**
* The class names a stylesheet has rules for, unescaped, so `.md\:w-44`
* reads as the candidate `md:w-44` a plugin writes.
*/
export function classNamesInCss(css) {
const names = new Set();
// Declarations hold numbers like 0.25rem, so only selectors are read.
const selectors = css.replace(/\{[^{}]*\}/g, "{}");
for (const match of selectors.matchAll(
/\.((?:\\[0-9a-fA-F]{1,6} ?|\\.|[\w-])+)/g,
)) {
names.add(
match[1].replace(/\\([0-9a-fA-F]{1,6}) ?|\\(.)/g, (_all, hex, char) =>
hex ? String.fromCodePoint(parseInt(hex, 16)) : char,
),
);
}
return names;
}
/**
* Splits a plugin's candidates by whether core's own CSS already has them.
* A class core ships keeps the plugin's copy below core's utilities, so it can
* never reorder core's rules. Any other class has no rule in core to fight,
* so it goes in core's utilities layer and overrides the way it would in one
* Tailwind build.
*/
export function splitByCoreClasses(candidates, coreClasses) {
const shared = [];
const own = [];
for (const candidate of candidates) {
(coreClasses.has(candidate) ? shared : own).push(candidate);
}
return { shared, own };
}