@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovrozagar/flare",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Solid meta-framework. Server-driven, NDJSON streaming, renderToStream.",
5
5
  "keywords": [
6
6
  "flare",
@@ -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 { extractDeclarations, extractPrefaceCss, initTailwindCompiler } from "../tw-compile.ts";
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
- strict?: boolean;
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
- state.twCompiler = await initTailwindCompiler(opts.twCssPath);
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
- /* Warn but don't fail the build — class= tokens pass through without CSS emit. */
202
- console.warn(`[flare:sx-ast] Tailwind compiler init failed: ${e instanceof Error ? e.message : String(e)}`);
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(state.classPool, state.layerByClass, state.providedByLibs, state.twPrefaceCss);
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(state.classPool, state.layerByClass, state.providedByLibs, state.twPrefaceCss);
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
- return extractDeclarations(output, [token], tw.themeVars) || null;
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
- function selectorMatchesAny(selector: string, prefixes: Set<string>): boolean {
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
- selector === prefix ||
43
- selector.startsWith(`${prefix}:`) ||
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 false;
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
- const matchesFilter = !selectorSet || selectorMatchesAny(selector, selectorSet);
111
- if (matchesFilter && body.length > 0) {
112
- const pseudo = extractPseudo(selector);
113
- if (pseudo) {
114
- result.push(`&${pseudo} { ${body} }`);
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
- result.push(body);
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
- /** Initialize a Tailwind v4 compiler from an optional CSS entry file. */
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
- let cssContent: string;
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
- if (id === "tailwindcss") {
211
- const esmRequire = createRequire(import.meta.url);
212
- const pkgPath = esmRequire.resolve("tailwindcss/package.json");
213
- const pkgDir = dirname(pkgPath);
214
- const cssPath2 = join(pkgDir, "index.css");
215
- const content = readFileSync(cssPath2, "utf-8");
216
- return { base: pkgDir, content, path: cssPath2 };
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
- `tailwindcss init failed: ${e instanceof Error ? e.message : String(e)}. Check tailwind.config.ts for syntax errors.`,
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
  }