mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
fix: put plugin-only tailwind classes in core's utilities layer, SDK 1.0.4
This commit is contained in:
1 parent
d6c4ec01b5
commit
878f1ebaa3
6 files changed
+169
-13
No files matched your search
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# 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
|
## 1.0.3
|
||||||
|
|
||||||
Plugin API 1.2.0.
|
Plugin API 1.2.0.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
|
|||||||
import { staticUrlImports } from "../lib/static-url-imports.mjs";
|
import { staticUrlImports } from "../lib/static-url-imports.mjs";
|
||||||
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
|
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
|
||||||
import { openapi } from "./openapi.mjs";
|
import { openapi } from "./openapi.mjs";
|
||||||
|
import { splitByCoreClasses } from "../lib/css-classes.mjs";
|
||||||
|
|
||||||
const BACKEND_ENTRIES = [
|
const BACKEND_ENTRIES = [
|
||||||
"src/backend/index.ts",
|
"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)),
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
"..",
|
"..",
|
||||||
"..",
|
"..",
|
||||||
"dist",
|
"dist",
|
||||||
"host",
|
"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
|
* 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
|
* Moves a plugin's Tailwind layers under their own names. Core orders
|
||||||
* termix-plugin-utilities above its base styles and below its own utilities,
|
* 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
|
* 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(
|
return css.replace(
|
||||||
/@layer (properties|utilities)(?=[\s{;,])/g,
|
new RegExp(`@layer (${layers})(?=[\\s{;,])`, "g"),
|
||||||
"@layer termix-plugin-$1",
|
"@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) {
|
export async function buildTailwind(cwd, outDir) {
|
||||||
const { compile, optimize } = await import("@tailwindcss/node");
|
const { compile, optimize } = await import("@tailwindcss/node");
|
||||||
const { Scanner } = await import("@tailwindcss/oxide");
|
const { Scanner } = await import("@tailwindcss/oxide");
|
||||||
@@ -137,10 +147,9 @@ export async function buildTailwind(cwd, outDir) {
|
|||||||
`@import ${JSON.stringify(THEME_CSS.replaceAll("\\", "/"))};`,
|
`@import ${JSON.stringify(THEME_CSS.replaceAll("\\", "/"))};`,
|
||||||
'@import "tailwindcss/utilities.css" layer(utilities);',
|
'@import "tailwindcss/utilities.css" layer(utilities);',
|
||||||
].join("\n");
|
].join("\n");
|
||||||
const compiler = await compile(input, {
|
// build() adds to what a compiler already emitted, so each half gets its own.
|
||||||
base: path.dirname(THEME_CSS),
|
const compiler = () =>
|
||||||
onDependency: () => {},
|
compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
|
||||||
});
|
|
||||||
const scanner = new Scanner({
|
const scanner = new Scanner({
|
||||||
sources: [
|
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
|
// Flattened and minified the way core's own CSS is, so it does not rely
|
||||||
// on native CSS nesting.
|
// on native CSS nesting.
|
||||||
const css = lowerPluginLayers(
|
const emit = async (candidates, options) =>
|
||||||
optimize(compiler.build(scanner.scan()), { minify: true }).code.trim(),
|
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;
|
if (!css) return;
|
||||||
const target = path.join(outDir, "frontend.css");
|
const target = path.join(outDir, "frontend.css");
|
||||||
const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : "";
|
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 };
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"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.",
|
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*
|
*
|
||||||
* dist/host/ui.js @termix-ssh/plugin-sdk/ui, the shell's UI kit
|
* 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/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
|
* 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
|
* 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 { fileURLToPath } from "node:url";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import * as esbuild from "esbuild";
|
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 root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const outDir = path.join(root, "packages", "plugin-sdk", "dist", "host");
|
const outDir = path.join(root, "packages", "plugin-sdk", "dist", "host");
|
||||||
@@ -137,4 +141,30 @@ fs.copyFileSync(
|
|||||||
path.join(outDir, "theme.css"),
|
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)}`);
|
console.log(`Built the SDK host modules into ${path.relative(root, outDir)}`);
|
||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
build,
|
build,
|
||||||
lowerPluginLayers,
|
lowerPluginLayers,
|
||||||
} from "../packages/plugin-sdk/cli/commands/build.mjs";
|
} from "../packages/plugin-sdk/cli/commands/build.mjs";
|
||||||
|
import {
|
||||||
|
classNamesInCss,
|
||||||
|
splitByCoreClasses,
|
||||||
|
} from "../packages/plugin-sdk/cli/lib/css-classes.mjs";
|
||||||
|
|
||||||
const cleanups: Array<() => void> = [];
|
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 = <div className="hidden md:${width}" />;\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", () => {
|
it("is ordered in core's CSS between base and core's utilities", () => {
|
||||||
const css = fs.readFileSync(
|
const css = fs.readFileSync(
|
||||||
path.join(__dirname, "..", "src", "ui", "index.css"),
|
path.join(__dirname, "..", "src", "ui", "index.css"),
|
||||||
|
|||||||
Reference in new issue
Block a user