diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index fa28c31cf..4d36e6e74 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.0.4 + +### Fixed + +- `termix-plugin build` puts a plugin's own Tailwind classes in core's utilities layer, so responsive, hover and group variants and `className` overrides on SDK components work. Classes core already ships stay below core's utilities as before. Rebuild a plugin to pick it up + ## 1.0.3 Plugin API 1.2.0. diff --git a/packages/plugin-sdk/cli/commands/build.mjs b/packages/plugin-sdk/cli/commands/build.mjs index 84de515b9..23967aa86 100644 --- a/packages/plugin-sdk/cli/commands/build.mjs +++ b/packages/plugin-sdk/cli/commands/build.mjs @@ -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") : ""; diff --git a/packages/plugin-sdk/cli/lib/css-classes.mjs b/packages/plugin-sdk/cli/lib/css-classes.mjs new file mode 100644 index 000000000..5296962fc --- /dev/null +++ b/packages/plugin-sdk/cli/lib/css-classes.mjs @@ -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 }; +} diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index d1305bba2..3bb1dc4a8 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.3", + "version": "1.0.4", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { diff --git a/scripts/build-sdk-host.mjs b/scripts/build-sdk-host.mjs index 71d455459..c6176bfe6 100644 --- a/scripts/build-sdk-host.mjs +++ b/scripts/build-sdk-host.mjs @@ -3,6 +3,7 @@ * * dist/host/ui.js @termix-ssh/plugin-sdk/ui, the shell's UI kit * dist/host/testing-host.js what renderWithApp() renders plugins into + * dist/host/core-classes.json the classes core's CSS has rules for * * Inside Termix the page's import map points @termix-ssh/plugin-sdk/ui at the * shell's own copy, so ui.js is never loaded there. It exists so a plugin in @@ -18,6 +19,9 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; import * as esbuild from "esbuild"; +import { compile, optimize } from "@tailwindcss/node"; +import { Scanner } from "@tailwindcss/oxide"; +import { classNamesInCss } from "../packages/plugin-sdk/cli/lib/css-classes.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const outDir = path.join(root, "packages", "plugin-sdk", "dist", "host"); @@ -137,4 +141,30 @@ fs.copyFileSync( path.join(outDir, "theme.css"), ); +// Every class core's own CSS has a rule for, found the way the Vite build +// finds them, so termix-plugin build knows which plugin classes it shares. +const indexCss = path.join(root, "src/ui/index.css"); +const compiler = await compile(fs.readFileSync(indexCss, "utf8"), { + base: path.dirname(indexCss), + onDependency: () => {}, +}); +const rootSource = + compiler.root === "none" + ? [] + : compiler.root === null + ? [{ base: root, pattern: "**/*", negated: false }] + : [{ ...compiler.root, negated: false }]; +const scanner = new Scanner({ + sources: [...rootSource, ...compiler.sources], +}); +const coreClasses = [ + ...classNamesInCss( + optimize(compiler.build(scanner.scan()), { minify: true }).code, + ), +].sort(); +fs.writeFileSync( + path.join(outDir, "core-classes.json"), + `${JSON.stringify(coreClasses)}\n`, +); + console.log(`Built the SDK host modules into ${path.relative(root, outDir)}`); diff --git a/scripts/plugin-build.test.ts b/scripts/plugin-build.test.ts index f56253f59..0d2b125c5 100644 --- a/scripts/plugin-build.test.ts +++ b/scripts/plugin-build.test.ts @@ -7,6 +7,10 @@ import { build, lowerPluginLayers, } from "../packages/plugin-sdk/cli/commands/build.mjs"; +import { + classNamesInCss, + splitByCoreClasses, +} from "../packages/plugin-sdk/cli/lib/css-classes.mjs"; const cleanups: Array<() => void> = []; @@ -102,6 +106,64 @@ describe("plugin CSS layers", () => { ); }); + it("can leave a plugin's own classes in core's utilities layer", () => { + expect( + lowerPluginLayers( + "@layer properties{a{b:c}}@layer utilities{.w-px{width:1px}}", + { utilities: false }, + ), + ).toBe( + "@layer termix-plugin-properties{a{b:c}}@layer utilities{.w-px{width:1px}}", + ); + }); + + it("reads class names from selectors, not declarations", () => { + const names = classNamesInCss( + String.raw`.w-28{width:calc(var(--spacing) * 28)}@media (width>=48rem){.md\:w-44{width:0.5rem}}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.top-1\/2{top:50%}.\32 xl\:flex{display:flex}`, + ); + expect([...names].sort()).toEqual( + [ + "2xl:flex", + "group", + "group-hover:opacity-100", + "md:w-44", + "top-1/2", + "w-28", + ].sort(), + ); + }); + + it("splits a plugin's classes by whether core ships them", () => { + expect( + splitByCoreClasses( + ["w-28", "md:w-44", "hidden"], + new Set(["w-28", "hidden"]), + ), + ).toEqual({ shared: ["w-28", "hidden"], own: ["md:w-44"] }); + }); + + it("keeps classes core ships below core and the plugin's own beside core's", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + // Core's class list comes from scanning this repo, this file included, + // so the plugin-only class is put together here instead of written out. + const width = ["w-", "[321px]"].join(""); + const dir = fixturePlugin(); + fs.mkdirSync(path.join(dir, "src", "frontend"), { recursive: true }); + fs.writeFileSync( + path.join(dir, "src", "frontend", "index.tsx"), + `export const a =
;\n`, + ); + + await build({ cwd: dir }); + + const css = fs.readFileSync(path.join(dir, "dist", "frontend.css"), "utf8"); + const lowered = css.slice(css.indexOf("@layer termix-plugin-utilities")); + const own = css.slice(css.lastIndexOf("@layer utilities{")); + expect(lowered).toMatch(/^@layer termix-plugin-utilities\{[^@]*\.hidden\{/); + expect(own).toContain(String.raw`.md\:w-\[321px\]`); + expect(own).not.toContain(".hidden{"); + }); + it("is ordered in core's CSS between base and core's utilities", () => { const css = fs.readFileSync( path.join(__dirname, "..", "src", "ui", "index.css"),