@lovrozagar/flare 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/package.json +1 -1
- package/src/plugins/cn-theme.ts +36 -0
- package/src/plugins/sx-ast/index.ts +157 -14
- package/src/plugins/sx-ast/rewrite.ts +11 -2
- package/src/plugins/tw-compile.ts +152 -38
- package/src/styles/cn-vendor/NOTICE.md +9 -5
- package/src/styles/cn-vendor/compiler.ts +1160 -0
- package/src/styles/cn-vendor/config.ts +136 -0
- package/src/styles/cn-vendor/default-config.generated.ts +26 -0
- package/src/styles/cn-vendor/theme-css.ts +180 -0
- package/src/styles/cn-vendor/validators.ts +122 -0
package/README.md
CHANGED
|
@@ -766,6 +766,26 @@ import { styles, cn } from "@lovrozagar/flare/styles";
|
|
|
766
766
|
|
|
767
767
|
`tw=` on `styles()` or as a JSX attribute is **dropped**. Put utilities in `class=`. `css=` compiles through the same plugin (not a `data-c` hash).
|
|
768
768
|
|
|
769
|
+
### Which class strings compile
|
|
770
|
+
|
|
771
|
+
Only string literals inside a `class=` expression get CSS: plain literals, arrays, ternary branches, the right side of `&&`, call arguments (`cn("a", on() && "b")`) and template literals. Literals elsewhere get none: module constants and lookups (`variants[props.v]`, so cva-style config objects), `class={(state) => "…"}` functions, and props not named `class`. Write variants as `&&` arms inside `cn(...)`.
|
|
772
|
+
|
|
773
|
+
### Theme
|
|
774
|
+
|
|
775
|
+
`sx.twCssPath` is the app's Tailwind entry stylesheet. Relative and package `@import`s resolve from the importing file, and `@plugin` loads relative or installed plugins.
|
|
776
|
+
|
|
777
|
+
- **Merging follows the theme.** `cn` (and the compile-time static merge) uses tables compiled from that theme, so custom scale names merge: with `--radius-control`, `cn("rounded-control", "rounded-lg")` is `rounded-lg`, and a custom `--text-body` size no longer conflicts with a `text-muted` color. Without `twCssPath`, `cn` uses the default Tailwind tables. In dev, editing the theme rebuilds the CSS and the tables.
|
|
778
|
+
- **Theme values are inlined by default** (`sx.themeVars: "inline"`): `.bg-canvas { background-color: light-dark(#f9f9fb, #18191b) }`. `"reference"` keeps `var(--background-color-canvas)` and ships the referenced theme vars in `@layer theme`, so a page can change them at runtime (a live theme editor).
|
|
779
|
+
- **Tailwind's own stacks stay live.** Element-local `--tw-*` vars (shadow, ring, transform and filter stacks) are never inlined, and their `@property` rules ship, so `shadow-sm ring-1` composes on one element.
|
|
780
|
+
|
|
781
|
+
### Strict classes
|
|
782
|
+
|
|
783
|
+
`sx.strict` fails the module (a build error; the dev overlay in dev) when an app-layer class literal compiles to no CSS, for example a reset palette color or a typo. `{ allow: ["prose"] }` lets non-Tailwind class names through; `group` and `peer` markers always pass. `{ deny: [/^-?m[lr]-/] }` rejects utilities even when they compile. Library-layer modules (`/node_modules/`) are not checked.
|
|
784
|
+
|
|
785
|
+
```ts
|
|
786
|
+
flare({ sx: { tw: true, twCssPath: "./src/theme.css", strict: { allow: ["prose"], deny: [/^text-(left|right)$/] } } });
|
|
787
|
+
```
|
|
788
|
+
|
|
769
789
|
## Fonts and images
|
|
770
790
|
|
|
771
791
|
```tsx
|
|
@@ -1088,6 +1108,9 @@ export default defineConfig({
|
|
|
1088
1108
|
| `purge` | off | Dead CSS / `data-testid` strip |
|
|
1089
1109
|
| `serviceWorker` | off | `sw.js` |
|
|
1090
1110
|
| `sx.tw` | compile `class=` Tailwind | |
|
|
1111
|
+
| `sx.twCssPath` | `@import "tailwindcss"` | Theme entry stylesheet; `cn` merges with its scales |
|
|
1112
|
+
| `sx.themeVars` | `"inline"` | `"reference"` keeps theme `var()`s for runtime theming |
|
|
1113
|
+
| `sx.strict` | off | Unknown or denied class literals fail the build |
|
|
1091
1114
|
| `image.quality` / `widths` / `exclude` | image pipeline | |
|
|
1092
1115
|
| `assetsBase` | `"/assets"` | Must start with `/`, no trailing `/` (`"/"` = root-relative) |
|
|
1093
1116
|
| `entry.client` / `entry.server` | `src/client`, `src/server` | |
|
package/package.json
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { compileToSource, compileToTables, mergeConfigs } from "../styles/cn-vendor/compiler.ts";
|
|
2
|
+
import { getDefaultCnConfig } from "../styles/cn-vendor/default-config.generated.ts";
|
|
3
|
+
import { createEngine, wrapClsx } from "../styles/cn-vendor/engine.ts";
|
|
4
|
+
import { themeFromCss } from "../styles/cn-vendor/theme-css.ts";
|
|
5
|
+
import { resolveStylesheetPath } from "./tw-compile.ts";
|
|
6
|
+
|
|
7
|
+
export interface ThemeCn {
|
|
8
|
+
/** `cn` merging with the app theme's scales (custom radius, spacing, font size, shadow names). */
|
|
9
|
+
cn: (...inputs: Parameters<ReturnType<typeof wrapClsx>>) => string;
|
|
10
|
+
/** Same merge for one space-separated string — what the compile-time static merge runs. */
|
|
11
|
+
mergeString: (input: string) => string;
|
|
12
|
+
/** Every stylesheet read, so dev can rebuild when one changes. */
|
|
13
|
+
files: string[];
|
|
14
|
+
/** An ES module whose default export is the compiled tables, in the shape of `cn-vendor/tables.generated.ts`. */
|
|
15
|
+
source: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Compile `cn` merge tables from a Tailwind v4 entry stylesheet: the default tailwind-merge config
|
|
20
|
+
* extended (or overridden, for `--<ns>-*: initial`) by the `@theme` names it and its relative and
|
|
21
|
+
* package imports declare.
|
|
22
|
+
*/
|
|
23
|
+
export function createThemeCn(cssPath: string): ThemeCn {
|
|
24
|
+
const { extension, files } = themeFromCss([cssPath], (specifier, fromDir) =>
|
|
25
|
+
resolveStylesheetPath(specifier, fromDir),
|
|
26
|
+
);
|
|
27
|
+
const config = mergeConfigs(getDefaultCnConfig(), extension);
|
|
28
|
+
const { tables, validatorImpls, prefix } = compileToTables(config);
|
|
29
|
+
const engine = createEngine(tables, validatorImpls, { prefix });
|
|
30
|
+
return {
|
|
31
|
+
cn: wrapClsx(engine.mergeString, engine),
|
|
32
|
+
files,
|
|
33
|
+
mergeString: engine.mergeString,
|
|
34
|
+
source: compileToSource(config, { banner: `// GENERATED by @lovrozagar/flare from ${cssPath}. Do not edit.` }),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -1,12 +1,37 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import type { Plugin } from "vite";
|
|
4
|
-
import {
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import type { Plugin, ViteDevServer } from "vite";
|
|
4
|
+
import { createThemeCn } from "../cn-theme.ts";
|
|
5
|
+
import type { ThemeCn } from "../cn-theme.ts";
|
|
6
|
+
import {
|
|
7
|
+
extractDeclarations,
|
|
8
|
+
extractPrefaceCss,
|
|
9
|
+
extractPropertyRules,
|
|
10
|
+
initTailwindCompiler,
|
|
11
|
+
themeVarsBlock,
|
|
12
|
+
} from "../tw-compile.ts";
|
|
5
13
|
import type { TailwindCompiler } from "../tw-compile.ts";
|
|
6
14
|
import { rewriteModule } from "./rewrite.ts";
|
|
7
15
|
|
|
16
|
+
export interface SxStrictOptions {
|
|
17
|
+
/** Tokens that may compile to no CSS (non-Tailwind classes such as `prose`). Markers (`group`, `peer`) are always allowed. */
|
|
18
|
+
allow?: string[];
|
|
19
|
+
/** Tokens rejected even when they compile (e.g. physical-direction utilities). */
|
|
20
|
+
deny?: RegExp[];
|
|
21
|
+
}
|
|
22
|
+
|
|
8
23
|
export interface SxAstOptions {
|
|
9
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Fail the module (build error, dev overlay) when an app-layer class literal compiles to no CSS
|
|
26
|
+
* or matches a `deny` pattern. Library-layer modules are not checked. Requires Tailwind (`tw`/`twCssPath`).
|
|
27
|
+
*/
|
|
28
|
+
strict?: boolean | SxStrictOptions;
|
|
29
|
+
/**
|
|
30
|
+
* How utilities reference theme values. `"inline"` (default) resolves them to their values at
|
|
31
|
+
* build time. `"reference"` keeps `var(--…)` and emits the referenced theme vars, so a page can
|
|
32
|
+
* change the theme at runtime (live theme editors).
|
|
33
|
+
*/
|
|
34
|
+
themeVars?: "inline" | "reference";
|
|
10
35
|
/** Absolute path prefixes that map to the "sx" layer (lib code). Default: ["/node_modules/"]. */
|
|
11
36
|
libPaths?: string[];
|
|
12
37
|
/** Override layer detection per module id. Return null to fall back to libPaths heuristic. */
|
|
@@ -53,6 +78,10 @@ interface PluginState {
|
|
|
53
78
|
* Emitted verbatim before atomic utility rules so browser defaults are normalized.
|
|
54
79
|
*/
|
|
55
80
|
twPrefaceCss: string;
|
|
81
|
+
/** `@property` rules for the Tailwind locals the emitted utilities use, by variable name. */
|
|
82
|
+
properties: Map<string, string>;
|
|
83
|
+
/** themeVars "reference": theme vars the emitted utilities reference. */
|
|
84
|
+
referencedVars: Set<string>;
|
|
56
85
|
}
|
|
57
86
|
|
|
58
87
|
interface LibManifestShape {
|
|
@@ -132,11 +161,25 @@ function resolveLayer(
|
|
|
132
161
|
}
|
|
133
162
|
|
|
134
163
|
/** Compose the final CSS text from the class pool, wrapped in @layer blocks. */
|
|
164
|
+
/* Tailwind's fallback for browsers without @property: the same initial values, set directly. */
|
|
165
|
+
const PROPERTIES_SUPPORTS =
|
|
166
|
+
"((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b))))";
|
|
167
|
+
|
|
168
|
+
function propertiesFallback(properties: Map<string, string>): string {
|
|
169
|
+
const decls = [...properties].map(([name, rule]) => {
|
|
170
|
+
const initial = /initial-value:\s*([^;]+);/.exec(rule);
|
|
171
|
+
return `${name}: ${initial ? initial[1].trim() : "initial"};`;
|
|
172
|
+
});
|
|
173
|
+
return `@layer properties { @supports ${PROPERTIES_SUPPORTS} { *, ::before, ::after, ::backdrop { ${decls.join(" ")} } } }`;
|
|
174
|
+
}
|
|
175
|
+
|
|
135
176
|
function composeCss(
|
|
136
177
|
classPool: Map<string, string>,
|
|
137
178
|
layerByClass: Map<string, "sx" | "app">,
|
|
138
179
|
skip: Set<string>,
|
|
139
180
|
twPrefaceCss: string,
|
|
181
|
+
properties: Map<string, string> = new Map(),
|
|
182
|
+
themeBlock = "",
|
|
140
183
|
): string {
|
|
141
184
|
const sxRules: string[] = [];
|
|
142
185
|
const appRules: string[] = [];
|
|
@@ -159,19 +202,38 @@ function composeCss(
|
|
|
159
202
|
appRules.sort(atLast);
|
|
160
203
|
|
|
161
204
|
const parts: string[] = [];
|
|
205
|
+
/* First statement, so the fallback layer ranks below every other layer. */
|
|
206
|
+
if (properties.size > 0) parts.push("@layer properties;");
|
|
162
207
|
if (twPrefaceCss) parts.push(twPrefaceCss);
|
|
208
|
+
if (themeBlock) parts.push(themeBlock);
|
|
163
209
|
parts.push(LAYER_PRELUDE);
|
|
164
210
|
if (sxRules.length > 0) parts.push(`@layer sx { ${sxRules.join(" ")} }`);
|
|
165
211
|
if (appRules.length > 0) parts.push(`@layer app { ${appRules.join(" ")} }`);
|
|
212
|
+
if (properties.size > 0) {
|
|
213
|
+
parts.push(...properties.values());
|
|
214
|
+
parts.push(propertiesFallback(properties));
|
|
215
|
+
}
|
|
166
216
|
|
|
167
217
|
return parts.join("\n");
|
|
168
218
|
}
|
|
169
219
|
|
|
220
|
+
/* Flare's own `cn` tables module. With a twCssPath, the plugin serves tables compiled from that
|
|
221
|
+
theme in its place, so `cn` merges the app's custom scale names (`rounded-control`). */
|
|
222
|
+
const CN_TABLES_RE = /[\\/]styles[\\/]cn-vendor[\\/]tables\.generated\.ts(?:\?.*)?$/;
|
|
223
|
+
|
|
224
|
+
function lineOf(code: string, token: string): number {
|
|
225
|
+
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
226
|
+
const m = new RegExp(`(?<![\\w:/-])${escaped}(?![\\w-])`).exec(code);
|
|
227
|
+
return m ? code.slice(0, m.index).split("\n").length : 1;
|
|
228
|
+
}
|
|
229
|
+
|
|
170
230
|
const DEV_CSS_VIRTUAL_ID = "virtual:flare-sx-dev-css";
|
|
171
231
|
const DEV_CSS_RESOLVED_ID = "\0virtual:flare-sx-dev-css";
|
|
172
232
|
|
|
173
233
|
export function createSxAstPlugin(opts: SxAstOptions = {}, assetsBase: string = "/assets"): Plugin {
|
|
174
234
|
const libPaths = opts.libPaths ?? ["/node_modules/"];
|
|
235
|
+
const strict: SxStrictOptions | null = opts.strict ? (opts.strict === true ? {} : opts.strict) : null;
|
|
236
|
+
const strictAllow = new Set(strict?.allow ?? []);
|
|
175
237
|
/* On-disk dir mirrors URL prefix — emit must land where bundleHref points. */
|
|
176
238
|
const assetsDir = assetsBase === "" ? "assets" : assetsBase.slice(1);
|
|
177
239
|
const state: PluginState = {
|
|
@@ -181,10 +243,35 @@ export function createSxAstPlugin(opts: SxAstOptions = {}, assetsBase: string =
|
|
|
181
243
|
providedByLibs: new Set(),
|
|
182
244
|
twCompiler: null,
|
|
183
245
|
twPrefaceCss: "",
|
|
246
|
+
properties: new Map(),
|
|
247
|
+
referencedVars: new Set(),
|
|
184
248
|
};
|
|
249
|
+
const referenceVars = opts.themeVars === "reference";
|
|
250
|
+
const themeBlockOf = (): string =>
|
|
251
|
+
referenceVars && state.twCompiler
|
|
252
|
+
? themeVarsBlock(state.referencedVars, state.twCompiler.themeVars, state.twPrefaceCss)
|
|
253
|
+
: "";
|
|
185
254
|
|
|
186
255
|
let mode: "dev" | "prod" = "dev";
|
|
187
256
|
let root = process.cwd();
|
|
257
|
+
let themeCn: ThemeCn | null = null;
|
|
258
|
+
let devServer: ViteDevServer | null = null;
|
|
259
|
+
const themeCnFor = (): ThemeCn | null => {
|
|
260
|
+
if (!opts.twCssPath) return null;
|
|
261
|
+
if (!themeCn) {
|
|
262
|
+
themeCn = createThemeCn(resolve(opts.twCssPath));
|
|
263
|
+
devServer?.watcher.add(themeCn.files);
|
|
264
|
+
}
|
|
265
|
+
return themeCn;
|
|
266
|
+
};
|
|
267
|
+
/* Throws on failure: a broken Tailwind entry fails the build (non-zero exit) instead of
|
|
268
|
+
shipping class= tokens with no CSS behind them. */
|
|
269
|
+
const loadTw = async (): Promise<void> => {
|
|
270
|
+
const compiler = await initTailwindCompiler(opts.twCssPath);
|
|
271
|
+
/* Zero-class build captures theme vars + preflight (base layer) verbatim. */
|
|
272
|
+
state.twPrefaceCss = extractPrefaceCss(compiler.build([]));
|
|
273
|
+
state.twCompiler = compiler;
|
|
274
|
+
};
|
|
188
275
|
|
|
189
276
|
return {
|
|
190
277
|
async buildStart(this: { environment?: { config?: { root?: string } } }) {
|
|
@@ -192,16 +279,31 @@ export function createSxAstPlugin(opts: SxAstOptions = {}, assetsBase: string =
|
|
|
192
279
|
if (opts.pruneFromLibManifests) {
|
|
193
280
|
state.providedByLibs = scanLibManifests(root);
|
|
194
281
|
}
|
|
195
|
-
if ((opts.tw || opts.twCssPath) && state.twCompiler === null)
|
|
282
|
+
if ((opts.tw || opts.twCssPath) && state.twCompiler === null) await loadTw();
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
configureServer(server) {
|
|
286
|
+
devServer = server;
|
|
287
|
+
if (themeCn) server.watcher.add(themeCn.files);
|
|
288
|
+
/* A theme edit changes both the CSS every utility compiles to and the cn merge tables:
|
|
289
|
+
drop both, forget emitted rules, and re-transform every module from scratch. */
|
|
290
|
+
server.watcher.on("change", async (file) => {
|
|
291
|
+
if (!themeCn?.files.includes(resolve(file))) return;
|
|
196
292
|
try {
|
|
197
|
-
|
|
198
|
-
/* Zero-class build captures theme vars + preflight (base layer) verbatim. */
|
|
199
|
-
state.twPrefaceCss = extractPrefaceCss(state.twCompiler.build([]));
|
|
293
|
+
await loadTw();
|
|
200
294
|
} catch (e) {
|
|
201
|
-
|
|
202
|
-
|
|
295
|
+
server.config.logger.error(`[flare:sx-ast] ${e instanceof Error ? e.message : String(e)}`);
|
|
296
|
+
return;
|
|
203
297
|
}
|
|
204
|
-
|
|
298
|
+
themeCn = null;
|
|
299
|
+
state.classPool.clear();
|
|
300
|
+
state.layerByClass.clear();
|
|
301
|
+
state.moduleManifest.clear();
|
|
302
|
+
state.properties.clear();
|
|
303
|
+
state.referencedVars.clear();
|
|
304
|
+
server.moduleGraph.invalidateAll();
|
|
305
|
+
server.ws.send({ type: "full-reload" });
|
|
306
|
+
});
|
|
205
307
|
},
|
|
206
308
|
|
|
207
309
|
configResolved(config) {
|
|
@@ -217,9 +319,20 @@ export function createSxAstPlugin(opts: SxAstOptions = {}, assetsBase: string =
|
|
|
217
319
|
},
|
|
218
320
|
|
|
219
321
|
load(id: string): { code: string; moduleType: string } | null {
|
|
322
|
+
if (CN_TABLES_RE.test(id)) {
|
|
323
|
+
const theme = themeCnFor();
|
|
324
|
+
return theme ? { code: theme.source, moduleType: "js" } : null;
|
|
325
|
+
}
|
|
220
326
|
if (id !== DEV_CSS_RESOLVED_ID) return null;
|
|
221
327
|
/* Each import() re-runs load — no caching — so SSR always gets latest state. */
|
|
222
|
-
const css = composeCss(
|
|
328
|
+
const css = composeCss(
|
|
329
|
+
state.classPool,
|
|
330
|
+
state.layerByClass,
|
|
331
|
+
state.providedByLibs,
|
|
332
|
+
state.twPrefaceCss,
|
|
333
|
+
state.properties,
|
|
334
|
+
themeBlockOf(),
|
|
335
|
+
);
|
|
223
336
|
const classNames = [...state.classPool.keys()];
|
|
224
337
|
return {
|
|
225
338
|
code: `export function getDevSxCss() { return ${JSON.stringify(css)} }\nexport function getDevSxClasses() { return ${JSON.stringify(classNames)} }`,
|
|
@@ -229,7 +342,14 @@ export function createSxAstPlugin(opts: SxAstOptions = {}, assetsBase: string =
|
|
|
229
342
|
|
|
230
343
|
generateBundle() {
|
|
231
344
|
const self = this as unknown as { emitFile: (f: { type: string; fileName: string; source: string }) => void };
|
|
232
|
-
const css = composeCss(
|
|
345
|
+
const css = composeCss(
|
|
346
|
+
state.classPool,
|
|
347
|
+
state.layerByClass,
|
|
348
|
+
state.providedByLibs,
|
|
349
|
+
state.twPrefaceCss,
|
|
350
|
+
state.properties,
|
|
351
|
+
themeBlockOf(),
|
|
352
|
+
);
|
|
233
353
|
self.emitFile({ fileName: `${assetsDir}/flare-global.css`, source: css, type: "asset" });
|
|
234
354
|
|
|
235
355
|
if (opts.manifest) {
|
|
@@ -276,6 +396,7 @@ export function createSxAstPlugin(opts: SxAstOptions = {}, assetsBase: string =
|
|
|
276
396
|
const moduleRules: Array<{ cls: string; rule: string }> = [];
|
|
277
397
|
|
|
278
398
|
const tw = state.twCompiler;
|
|
399
|
+
const violations: string[] = [];
|
|
279
400
|
const result = rewriteModule(code, {
|
|
280
401
|
cssEmit: (rule) => {
|
|
281
402
|
/*
|
|
@@ -293,16 +414,38 @@ export function createSxAstPlugin(opts: SxAstOptions = {}, assetsBase: string =
|
|
|
293
414
|
moduleRules.push({ cls, rule });
|
|
294
415
|
},
|
|
295
416
|
layer,
|
|
417
|
+
mergeClassList: themeCnFor()?.mergeString,
|
|
418
|
+
onClassToken:
|
|
419
|
+
strict && layer === "app"
|
|
420
|
+
? (token, compiled) => {
|
|
421
|
+
if (!compiled && !strictAllow.has(token)) {
|
|
422
|
+
violations.push(`${id}:${lineOf(code, token)} "${token}" compiles to no CSS`);
|
|
423
|
+
}
|
|
424
|
+
const denied = strict.deny?.find((re) => re.test(token));
|
|
425
|
+
if (denied) violations.push(`${id}:${lineOf(code, token)} "${token}" matches deny ${denied}`);
|
|
426
|
+
}
|
|
427
|
+
: undefined,
|
|
296
428
|
mode,
|
|
297
429
|
sourcePath: id,
|
|
298
430
|
twCompile: tw
|
|
299
431
|
? (token: string) => {
|
|
300
432
|
const output = tw.build([token]);
|
|
301
|
-
|
|
433
|
+
for (const [name, rule] of extractPropertyRules(output)) state.properties.set(name, rule);
|
|
434
|
+
const decls = extractDeclarations(output, [token], referenceVars ? undefined : tw.themeVars);
|
|
435
|
+
if (referenceVars) {
|
|
436
|
+
for (const m of decls.matchAll(/var\((--[\w-]+)/g)) state.referencedVars.add(m[1]);
|
|
437
|
+
}
|
|
438
|
+
return decls || null;
|
|
302
439
|
}
|
|
303
440
|
: undefined,
|
|
304
441
|
});
|
|
305
442
|
|
|
443
|
+
if (violations.length > 0) {
|
|
444
|
+
throw new Error(
|
|
445
|
+
`[flare:sx-ast] sx.strict rejected ${violations.length} class token(s):\n ${violations.join("\n ")}`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
306
449
|
/* Track which classes this module emitted; layer already set in cssEmit above */
|
|
307
450
|
if (result !== null) {
|
|
308
451
|
for (const cls of result.emittedClasses) {
|
|
@@ -15,6 +15,13 @@ export interface RewriteCtx {
|
|
|
15
15
|
* null for unknown tokens. Omit to disable Tailwind compilation entirely.
|
|
16
16
|
*/
|
|
17
17
|
twCompile?: (token: string) => string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Compile-time merge for a static class string. Defaults to the built-in tables; the sx plugin
|
|
20
|
+
* passes the app theme's merge so custom scale names (`rounded-control`) collapse correctly.
|
|
21
|
+
*/
|
|
22
|
+
mergeClassList?: (input: string) => string;
|
|
23
|
+
/** Called for every literal class token compiled through Tailwind (markers excluded); `compiled` is false when it produced no CSS. */
|
|
24
|
+
onClassToken?: (token: string, compiled: boolean) => void;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
export interface RewriteResult {
|
|
@@ -183,6 +190,7 @@ function compileTwFromExpr(expr: unknown, ctx: RewriteCtx): void {
|
|
|
183
190
|
for (const token of raw.split(/\s+/).filter(Boolean)) {
|
|
184
191
|
if (MARKER_TOKEN_RE.test(token)) continue;
|
|
185
192
|
const body = ctx.twCompile(token);
|
|
193
|
+
ctx.onClassToken?.(token, Boolean(body));
|
|
186
194
|
if (!body) continue;
|
|
187
195
|
ctx.cssEmit(buildTwRule(token, body));
|
|
188
196
|
}
|
|
@@ -197,6 +205,7 @@ function compileTwFromString(value: string, ctx: RewriteCtx): void {
|
|
|
197
205
|
for (const token of value.split(/\s+/).filter(Boolean)) {
|
|
198
206
|
if (MARKER_TOKEN_RE.test(token)) continue;
|
|
199
207
|
const body = ctx.twCompile(token);
|
|
208
|
+
ctx.onClassToken?.(token, Boolean(body));
|
|
200
209
|
if (!body) continue;
|
|
201
210
|
ctx.cssEmit(buildTwRule(token, body));
|
|
202
211
|
}
|
|
@@ -488,7 +497,7 @@ export function rewriteModule(source: string, ctx: RewriteCtx): RewriteResult |
|
|
|
488
497
|
if (classAttr) {
|
|
489
498
|
const resolved = resolveClassAttr(source, classAttr);
|
|
490
499
|
if (resolved.kind === "literal") {
|
|
491
|
-
const merged = mergeClassList(resolved.value);
|
|
500
|
+
const merged = (ctx.mergeClassList ?? mergeClassList)(resolved.value);
|
|
492
501
|
classLiteral = merged;
|
|
493
502
|
if (resolved.rewrite || merged !== resolved.value) classNeedsRewrite = true;
|
|
494
503
|
if (resolved.rewrite) foldedStaticCn = true;
|
|
@@ -707,7 +716,7 @@ export function rewriteModule(source: string, ctx: RewriteCtx): RewriteResult |
|
|
|
707
716
|
} else if (classAttr) {
|
|
708
717
|
const existing = resolveClassAttr(source, classAttr);
|
|
709
718
|
if (existing.kind === "literal") {
|
|
710
|
-
const merged = mergeClassList(existing.value);
|
|
719
|
+
const merged = (ctx.mergeClassList ?? mergeClassList)(existing.value);
|
|
711
720
|
if (merged) {
|
|
712
721
|
replace(classAttr.start, classAttr.end, `class={cn("${merged}", ${compileCssCall})}`);
|
|
713
722
|
neededImports.add("cn");
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
-
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
5
|
|
|
5
6
|
export interface TailwindCompiler {
|
|
6
7
|
build: (classes: string[]) => string;
|
|
@@ -36,18 +37,18 @@ function cssEscapeClass(cls: string): string {
|
|
|
36
37
|
return cls.replace(/([^a-zA-Z0-9_-])/g, "\\$1");
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
/**
|
|
41
|
+
* What follows a requested utility's own class in `selector`: "" for the bare class, the variant
|
|
42
|
+
* suffix (`[aria-pressed="true"]`, `:focus-visible:disabled`, `:where(...)`, ` .child`) otherwise,
|
|
43
|
+
* or null when the selector is not that utility (`.p-40` is not `.p-4`).
|
|
44
|
+
*/
|
|
45
|
+
function suffixAfterUtility(selector: string, prefixes: Set<string>): string | null {
|
|
40
46
|
for (const prefix of prefixes) {
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
selector.startsWith(`${prefix} `) ||
|
|
45
|
-
selector.startsWith(`${prefix},`)
|
|
46
|
-
) {
|
|
47
|
-
return true;
|
|
48
|
-
}
|
|
47
|
+
if (!selector.startsWith(prefix)) continue;
|
|
48
|
+
const suffix = selector.slice(prefix.length);
|
|
49
|
+
if (suffix === "" || /^[:[ ,]/.test(suffix)) return suffix;
|
|
49
50
|
}
|
|
50
|
-
return
|
|
51
|
+
return null;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
function extractLayerContent(css: string, atStart: number): string {
|
|
@@ -107,13 +108,15 @@ function extractDeclsInner(css: string, selectorSet?: Set<string>): string {
|
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
110
|
const body = remaining.slice(braceStart + 1, i).trim();
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
result.push(
|
|
111
|
+
if (body.length > 0) {
|
|
112
|
+
if (selectorSet) {
|
|
113
|
+
/* Keep the whole variant suffix: flattened attribute variants and chained pseudos. */
|
|
114
|
+
const suffix = suffixAfterUtility(selector, selectorSet);
|
|
115
|
+
if (suffix === "" || suffix?.startsWith(",")) result.push(body);
|
|
116
|
+
else if (suffix !== null) result.push(`&${suffix} { ${body} }`);
|
|
115
117
|
} else {
|
|
116
|
-
|
|
118
|
+
const pseudo = extractPseudo(selector);
|
|
119
|
+
result.push(pseudo ? `&${pseudo} { ${body} }` : body);
|
|
117
120
|
}
|
|
118
121
|
}
|
|
119
122
|
remaining = remaining.slice(i + 1).trim();
|
|
@@ -121,11 +124,56 @@ function extractDeclsInner(css: string, selectorSet?: Set<string>): string {
|
|
|
121
124
|
return result.join(";");
|
|
122
125
|
}
|
|
123
126
|
|
|
127
|
+
/** Tailwind's element-local stacks (`--tw-shadow`, `--tw-scale-x`, …): never theme values. */
|
|
128
|
+
function isLocalVar(name: string): boolean {
|
|
129
|
+
return name.startsWith("--tw-");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The `@property` rules in a build output, keyed by variable name, whitespace-normalized
|
|
134
|
+
* (`@property --tw-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; }`).
|
|
135
|
+
* Utilities reference these locals with `var()`; without the registration (and its initial
|
|
136
|
+
* value) a stack like `box-shadow: var(--tw-inset-shadow), …, var(--tw-shadow)` is invalid.
|
|
137
|
+
*/
|
|
138
|
+
export function extractPropertyRules(cssOutput: string): Map<string, string> {
|
|
139
|
+
const rules = new Map<string, string>();
|
|
140
|
+
for (const m of cssOutput.matchAll(/@property\s+(--[\w-]+)\s*\{([^}]*)\}/g)) {
|
|
141
|
+
const decls = m[2]
|
|
142
|
+
.split(";")
|
|
143
|
+
.map((d) => d.trim().replace(/\s+/g, " "))
|
|
144
|
+
.filter(Boolean);
|
|
145
|
+
rules.set(m[1], `@property ${m[1]} { ${decls.map((d) => `${d};`).join(" ")} }`);
|
|
146
|
+
}
|
|
147
|
+
return rules;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `@layer theme { :root, :host { … } }` defining the theme vars `referenced` needs: each one and,
|
|
152
|
+
* transitively, the vars its value references. Locals (`--tw-*`), vars the preface already defines,
|
|
153
|
+
* and names Tailwind never emitted are skipped. Empty string when nothing is needed.
|
|
154
|
+
*/
|
|
155
|
+
export function themeVarsBlock(referenced: Set<string>, themeVars: Map<string, string>, preface: string): string {
|
|
156
|
+
const decls: string[] = [];
|
|
157
|
+
const seen = new Set<string>();
|
|
158
|
+
const queue = [...referenced];
|
|
159
|
+
while (queue.length > 0) {
|
|
160
|
+
const name = queue.shift() as string;
|
|
161
|
+
if (seen.has(name) || isLocalVar(name)) continue;
|
|
162
|
+
seen.add(name);
|
|
163
|
+
if (new RegExp(`${name.replace(/[-]/g, "\\-")}\\s*:`).test(preface)) continue;
|
|
164
|
+
const value = themeVars.get(name);
|
|
165
|
+
if (value === undefined) continue;
|
|
166
|
+
decls.push(`${name}: ${value};`);
|
|
167
|
+
for (const m of value.matchAll(/var\((--[\w-]+)/g)) queue.push(m[1]);
|
|
168
|
+
}
|
|
169
|
+
return decls.length > 0 ? `@layer theme { :root, :host { ${decls.join(" ")} } }` : "";
|
|
170
|
+
}
|
|
171
|
+
|
|
124
172
|
function resolveThemeVars(css: string, themeVars: Map<string, string>): string {
|
|
125
173
|
let result = css;
|
|
126
174
|
for (let i = 0; i < 5; i++) {
|
|
127
175
|
const next = result.replace(/var\((--[\w-]+)\)/g, (full, name: string) => {
|
|
128
|
-
return themeVars.get(name) ?? full;
|
|
176
|
+
return isLocalVar(name) ? full : (themeVars.get(name) ?? full);
|
|
129
177
|
});
|
|
130
178
|
if (next === result) break;
|
|
131
179
|
result = next;
|
|
@@ -179,8 +227,80 @@ export function extractDeclarations(
|
|
|
179
227
|
|
|
180
228
|
const TAILWIND_MODULE: string = "tailwindcss";
|
|
181
229
|
|
|
182
|
-
/**
|
|
230
|
+
/** `@scope/name/sub/path` → `["@scope/name", "sub/path"]`; `name` → `["name", ""]`. */
|
|
231
|
+
function splitBareSpecifier(id: string): [string, string] {
|
|
232
|
+
const parts = id.split("/");
|
|
233
|
+
const nameParts = id.startsWith("@") ? 2 : 1;
|
|
234
|
+
return [parts.slice(0, nameParts).join("/"), parts.slice(nameParts).join("/")];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* A bare CSS specifier from `fromDir`: a package root resolves to its `style` entry
|
|
239
|
+
* (`exports["."].style`, then `style`); a subpath goes through the package's exports.
|
|
240
|
+
*/
|
|
241
|
+
function resolveBareStylesheet(id: string, fromDir: string): string {
|
|
242
|
+
const req = createRequire(join(fromDir, "noop.css"));
|
|
243
|
+
const [name, subpath] = splitBareSpecifier(id);
|
|
244
|
+
if (subpath) return req.resolve(id);
|
|
245
|
+
const pkgPath = req.resolve(`${name}/package.json`);
|
|
246
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
|
|
247
|
+
exports?: Record<string, { style?: string } | string> | string;
|
|
248
|
+
style?: string;
|
|
249
|
+
};
|
|
250
|
+
const rootExport = typeof pkg.exports === "object" ? pkg.exports["."] : undefined;
|
|
251
|
+
const style = (typeof rootExport === "object" ? rootExport.style : undefined) ?? pkg.style;
|
|
252
|
+
if (!style) throw new Error(`package "${name}" has no "style" entry to import as CSS`);
|
|
253
|
+
return join(dirname(pkgPath), style);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Where an `@import` points. Relative and absolute ids resolve against the importing
|
|
258
|
+
* stylesheet's directory (`base`). Bare ids resolve from `base` like Node would, then from
|
|
259
|
+
* Flare's own install (the optional `tailwindcss` peer usually sits beside Flare).
|
|
260
|
+
*/
|
|
261
|
+
export function resolveStylesheetPath(id: string, base: string): string {
|
|
262
|
+
if (id.startsWith(".") || isAbsolute(id)) return resolve(base, id);
|
|
263
|
+
try {
|
|
264
|
+
return resolveBareStylesheet(id, base);
|
|
265
|
+
} catch (fromBase) {
|
|
266
|
+
try {
|
|
267
|
+
return resolveBareStylesheet(id, dirname(fileURLToPath(import.meta.url)));
|
|
268
|
+
} catch {
|
|
269
|
+
throw new Error(
|
|
270
|
+
`cannot resolve @import "${id}" from ${base}: ${fromBase instanceof Error ? fromBase.message : String(fromBase)}`,
|
|
271
|
+
{ cause: fromBase },
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Where an `@plugin` / `@config` module points: relative and absolute ids from the referencing
|
|
279
|
+
* stylesheet's directory, bare ids through Node resolution from there, then from Flare's install.
|
|
280
|
+
*/
|
|
281
|
+
export function resolveModulePath(id: string, base: string): string {
|
|
282
|
+
if (id.startsWith(".") || isAbsolute(id)) return resolve(base, id);
|
|
283
|
+
try {
|
|
284
|
+
return createRequire(join(base, "noop.js")).resolve(id);
|
|
285
|
+
} catch (fromBase) {
|
|
286
|
+
try {
|
|
287
|
+
return createRequire(import.meta.url).resolve(id);
|
|
288
|
+
} catch {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`cannot resolve @plugin "${id}" from ${base}: ${fromBase instanceof Error ? fromBase.message : String(fromBase)}`,
|
|
291
|
+
{ cause: fromBase },
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Initialize a Tailwind v4 compiler from an optional CSS entry file. The entry's imports
|
|
299
|
+
* resolve from its own directory (no entry: the process cwd). Throws on any failure —
|
|
300
|
+
* callers fail the build, never fall back to pass-through.
|
|
301
|
+
*/
|
|
183
302
|
export async function initTailwindCompiler(cssPath?: string): Promise<TailwindCompiler> {
|
|
303
|
+
const entry = cssPath ? resolve(cssPath) : undefined;
|
|
184
304
|
try {
|
|
185
305
|
/* Optional peer: a non-literal specifier keeps consumers without tailwindcss typechecking. */
|
|
186
306
|
const tw = (await import(/* @vite-ignore */ TAILWIND_MODULE)) as { compile?: unknown; default?: unknown };
|
|
@@ -191,12 +311,7 @@ export async function initTailwindCompiler(cssPath?: string): Promise<TailwindCo
|
|
|
191
311
|
);
|
|
192
312
|
}
|
|
193
313
|
|
|
194
|
-
|
|
195
|
-
if (cssPath) {
|
|
196
|
-
cssContent = readFileSync(resolve(cssPath), "utf-8");
|
|
197
|
-
} else {
|
|
198
|
-
cssContent = '@import "tailwindcss";';
|
|
199
|
-
}
|
|
314
|
+
const cssContent = entry ? readFileSync(entry, "utf-8") : '@import "tailwindcss";';
|
|
200
315
|
|
|
201
316
|
const compiler = await (
|
|
202
317
|
compileFn as (
|
|
@@ -206,18 +321,17 @@ export async function initTailwindCompiler(cssPath?: string): Promise<TailwindCo
|
|
|
206
321
|
build: (classes: string[]) => string;
|
|
207
322
|
}>
|
|
208
323
|
)(cssContent, {
|
|
324
|
+
/* Without `base`, Tailwind resolves the entry's own @imports against "" — the process cwd. */
|
|
325
|
+
base: entry ? dirname(entry) : process.cwd(),
|
|
209
326
|
loadStylesheet: (id: string, base: string) => {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
218
|
-
const resolved = resolve(base, id);
|
|
219
|
-
const content = readFileSync(resolved, "utf-8");
|
|
220
|
-
return { base: dirname(resolved), content, path: resolved };
|
|
327
|
+
const path = resolveStylesheetPath(id, base);
|
|
328
|
+
return { base: dirname(path), content: readFileSync(path, "utf-8"), path };
|
|
329
|
+
},
|
|
330
|
+
/* `@plugin` and `@config`: a plugin's default export (function or plugin object). */
|
|
331
|
+
loadModule: async (id: string, base: string) => {
|
|
332
|
+
const path = resolveModulePath(id, base);
|
|
333
|
+
const mod = (await import(/* @vite-ignore */ pathToFileURL(path).href)) as { default?: unknown };
|
|
334
|
+
return { base: dirname(path), module: mod.default ?? mod, path };
|
|
221
335
|
},
|
|
222
336
|
});
|
|
223
337
|
|
|
@@ -226,7 +340,7 @@ export async function initTailwindCompiler(cssPath?: string): Promise<TailwindCo
|
|
|
226
340
|
const trackingBuild = (classes: string[]): string => {
|
|
227
341
|
const output = originalBuild(classes);
|
|
228
342
|
for (const m of output.matchAll(/(--[\w-]+)\s*:\s*([^;]+)/g)) {
|
|
229
|
-
if (!themeVars.has(m[1])) {
|
|
343
|
+
if (!isLocalVar(m[1]) && !themeVars.has(m[1])) {
|
|
230
344
|
themeVars.set(m[1], m[2].trim());
|
|
231
345
|
}
|
|
232
346
|
}
|
|
@@ -236,7 +350,7 @@ export async function initTailwindCompiler(cssPath?: string): Promise<TailwindCo
|
|
|
236
350
|
return { build: trackingBuild, themeVars };
|
|
237
351
|
} catch (e: unknown) {
|
|
238
352
|
throw new Error(
|
|
239
|
-
`
|
|
353
|
+
`Tailwind init failed for ${entry ?? 'the default @import "tailwindcss" entry'}: ${e instanceof Error ? e.message : String(e)}`,
|
|
240
354
|
{ cause: e },
|
|
241
355
|
);
|
|
242
356
|
}
|