@lovrozagar/flare 0.2.1 → 0.3.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
@@ -744,6 +744,8 @@ import { styles, cn } from "@lovrozagar/flare/styles";
744
744
  <div class={cn("base", on() && "active")} />
745
745
  ```
746
746
 
747
+ `cn` merges Tailwind conflicts: the last utility in a group wins. A static `class="px-2 px-4"` compiles to `px-4`. Runtime `cn` is tree-shaken unless a dynamic class list exists.
748
+
747
749
  `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).
748
750
 
749
751
  ## Fonts and images
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovrozagar/flare",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Solid meta-framework. Server-driven, NDJSON streaming, renderToStream.",
5
5
  "keywords": [
6
6
  "flare",
@@ -281,6 +281,8 @@ function createSsrBuildPlugin(
281
281
  entryFileNames: "server.js",
282
282
  },
283
283
  },
284
+ /* @cloudflare/vite-plugin then sets upload_source_maps, so minified worker stack traces remap */
285
+ sourcemap: true,
284
286
  },
285
287
  },
286
288
  },
@@ -1,5 +1,6 @@
1
1
  import MagicString from "magic-string";
2
2
  import type { Expression, JSXAttribute, JSXOpeningElement, Node, ObjectExpression, Program } from "oxc-parser";
3
+ import { mergeClassList } from "../../styles/cn.ts";
3
4
  import { parseSource } from "./parser.ts";
4
5
  import { evaluateSxObject } from "./evaluator.ts";
5
6
  import { emitAtomic } from "./atomic.ts";
@@ -82,7 +83,13 @@ function isDOMElement(tag: string): boolean {
82
83
  return tag.length > 0 && tag[0] !== "" && tag[0] === tag[0].toLowerCase();
83
84
  }
84
85
 
85
- function isCnCall(expr: Expression): boolean {
86
+ function isCnCall(expr: unknown): expr is {
87
+ type: "CallExpression";
88
+ callee: { type: "Identifier"; name: string };
89
+ arguments: Expression[];
90
+ start: number;
91
+ end: number;
92
+ } {
86
93
  if (!isCallExpression(expr)) return false;
87
94
  return expr.callee.type === "Identifier" && expr.callee.name === "cn";
88
95
  }
@@ -236,67 +243,91 @@ function buildTwRule(token: string, body: string): string {
236
243
  return `.${escaped} { ${body} }`;
237
244
  }
238
245
 
239
- function allStaticStrings(elements: Array<Expression | null>): string[] | null {
240
- const result: string[] = [];
241
- for (const el of elements) {
242
- if (el === null) continue;
243
- if (!isStringLiteral(el)) return null;
244
- result.push(el.value);
246
+ function isIgnorableClassNode(el: unknown): boolean {
247
+ if (isIdentifier(el) && el.name === "undefined") return true;
248
+ if (typeof el !== "object" || el === null) return false;
249
+ const node = el as { type?: string; value?: unknown };
250
+ return node.type === "Literal" && (node.value === null || node.value === false);
251
+ }
252
+
253
+ /*
254
+ * Fully static cn() arguments become one class string (before conflict merge).
255
+ * Any dynamic argument returns null so the call stays at runtime.
256
+ */
257
+ function staticCnParts(args: readonly unknown[]): string[] | null {
258
+ const parts: string[] = [];
259
+ for (const arg of args) {
260
+ if (arg === null) continue;
261
+ if (isStringLiteral(arg)) {
262
+ if (arg.value) parts.push(arg.value);
263
+ continue;
264
+ }
265
+ if (isIgnorableClassNode(arg)) continue;
266
+ if (isArrayExpression(arg)) {
267
+ const inner = staticCnParts(arg.elements);
268
+ if (inner === null) return null;
269
+ parts.push(...inner);
270
+ continue;
271
+ }
272
+ if (isCnCall(arg)) {
273
+ const inner = staticCnParts(arg.arguments);
274
+ if (inner === null) return null;
275
+ parts.push(...inner);
276
+ continue;
277
+ }
278
+ return null;
245
279
  }
246
- return result;
280
+ return parts;
247
281
  }
248
282
 
249
283
  /*
250
- * Resolve a mixed-type class array to either a literal string or a template expr string.
251
- * LogicalExpression `cond && "str"` → ternary guard in template.
284
+ * Static arrays become a literal. Mixed arrays become a cn() call so runtime
285
+ * merge sees every token, including ones hidden behind a conditional.
252
286
  */
253
287
  function resolveClassArray(
254
288
  source: string,
255
289
  elements: Array<Expression | null>,
256
- ): { kind: "literal"; value: string } | { kind: "template"; expr: string } {
257
- const allStatic = allStaticStrings(elements);
258
- if (allStatic !== null) {
259
- return { kind: "literal", value: allStatic.filter(Boolean).join(" ") };
260
- }
261
-
262
- const parts: string[] = [];
263
- let first = true;
290
+ ): { kind: "literal"; value: string } | { kind: "cn"; expr: string } {
291
+ const args: string[] = [];
292
+ let dynamic = false;
264
293
  for (const el of elements) {
265
- if (el === null) continue;
294
+ if (el === null || isIgnorableClassNode(el)) continue;
266
295
  if (isStringLiteral(el)) {
267
- parts.push(first ? el.value : ` ${el.value}`);
268
- first = false;
269
- } else if (
270
- typeof el === "object" &&
271
- el !== null &&
272
- (el as { type: string }).type === "LogicalExpression" &&
273
- (el as { operator?: string }).operator === "&&"
274
- ) {
275
- const logic = el as { left: Expression; right: Expression };
276
- const leftSrc = srcOf(source, logic.left as { start: number; end: number });
277
- const rightEl = logic.right;
278
- if (isStringLiteral(rightEl)) {
279
- const sep = first ? "" : " ";
280
- parts.push(`\${${leftSrc} ? "${sep}${rightEl.value}" : ""}`);
281
- } else {
282
- const sep = first ? "" : " ";
283
- const rightSrc = srcOf(source, rightEl as { start: number; end: number });
284
- parts.push(`\${${leftSrc} ? \`${sep}\${${rightSrc}}\` : ""}`);
285
- }
286
- first = false;
287
- } else {
288
- /* Skip null/false literals and the `undefined` identifier — they contribute nothing. */
289
- const isNullLit = (el as { type: string }).type === "Literal" && (el as { value: unknown }).value === null;
290
- const isFalseLit = (el as { type: string }).type === "Literal" && (el as { value: unknown }).value === false;
291
- const isUndefined = isIdentifier(el) && el.name === "undefined";
292
- if (isNullLit || isFalseLit || isUndefined) continue;
293
- const sep = first ? "" : " ";
294
- parts.push(`${sep}\${${srcOf(source, el as { start: number; end: number })}}`);
295
- first = false;
296
+ if (el.value) args.push(JSON.stringify(el.value));
297
+ continue;
296
298
  }
299
+ dynamic = true;
300
+ args.push(srcOf(source, el as { start: number; end: number }));
301
+ }
302
+ if (!dynamic) {
303
+ return { kind: "literal", value: args.map((part) => JSON.parse(part) as string).join(" ") };
297
304
  }
305
+ return { expr: `cn(${args.join(", ")})`, kind: "cn" };
306
+ }
298
307
 
299
- return { expr: parts.join(""), kind: "template" };
308
+ function dropUnusedCnImport(code: string): string {
309
+ const withoutImports = code.replace(/import\s*\{[^}]*\}\s*from\s*["'][^"']+["']\s*;?/g, "");
310
+ if (/\bcn\b/.test(withoutImports)) return code;
311
+ return code.replace(
312
+ /import\s*\{([^}]*)\}\s*from\s*(["'](?:@lovrozagar\/flare\/styles|flare\/styles)["'])\s*;?/g,
313
+ (full, specifiers: string, from: string) => {
314
+ const parts = specifiers
315
+ .split(",")
316
+ .map((part) => part.trim())
317
+ .filter(Boolean);
318
+ const kept = parts.filter(
319
+ (part) =>
320
+ (part
321
+ .split(/\s+as\s+/)
322
+ .pop()
323
+ ?.trim() ?? part) !== "cn",
324
+ );
325
+ if (kept.length === parts.length) return full;
326
+ if (kept.length === 0) return "";
327
+ const semicolon = full.trimEnd().endsWith(";") ? ";" : "";
328
+ return `import { ${kept.join(", ")} } from ${from}${semicolon}`;
329
+ },
330
+ );
300
331
  }
301
332
 
302
333
  function findAttr(opening: JSXOpeningElement, name: string): JSXAttribute | null {
@@ -327,7 +358,9 @@ function hasSpread(opening: JSXOpeningElement): { identName: string } | null {
327
358
  function resolveClassAttr(
328
359
  source: string,
329
360
  classAttr: JSXAttribute,
330
- ): { kind: "literal"; value: string } | { kind: "expr"; expr: string; wasArray: boolean; wasDynamic: boolean } {
361
+ ):
362
+ | { kind: "literal"; value: string; rewrite?: boolean }
363
+ | { kind: "expr"; expr: string; wasArray: boolean; wasDynamic: boolean } {
331
364
  const cv = classAttr.value;
332
365
  if (cv === null) return { kind: "literal", value: "" };
333
366
 
@@ -339,15 +372,18 @@ function resolveClassAttr(
339
372
  if (isArrayExpression(expr)) {
340
373
  const resolved = resolveClassArray(source, expr.elements);
341
374
  if (resolved.kind === "literal") return { kind: "literal", value: resolved.value };
342
- return { expr: `\`${resolved.expr}\``, kind: "expr", wasArray: true, wasDynamic: false };
375
+ return { expr: resolved.expr, kind: "expr", wasArray: true, wasDynamic: true };
343
376
  }
344
377
  if (isCnCall(expr)) {
345
- /* Explicit cn() — preserve as-is */
378
+ const parts = staticCnParts(expr.arguments);
379
+ if (parts !== null) {
380
+ return { kind: "literal", rewrite: true, value: parts.join(" ") };
381
+ }
346
382
  return {
347
383
  expr: srcOf(source, expr as { start: number; end: number }),
348
384
  kind: "expr",
349
385
  wasArray: false,
350
- wasDynamic: false,
386
+ wasDynamic: true,
351
387
  };
352
388
  }
353
389
  /* Dynamic expression → wrap in cn() */
@@ -379,6 +415,7 @@ export function rewriteModule(source: string, ctx: RewriteCtx): RewriteResult |
379
415
  const insertsBefore: Array<{ pos: number; text: string }> = [];
380
416
 
381
417
  const neededImports = new Set<FlareImport>();
418
+ let foldedStaticCn = false;
382
419
  const emittedClasses = new Set<string>();
383
420
  let changed = false;
384
421
 
@@ -451,26 +488,19 @@ export function rewriteModule(source: string, ctx: RewriteCtx): RewriteResult |
451
488
  if (classAttr) {
452
489
  const resolved = resolveClassAttr(source, classAttr);
453
490
  if (resolved.kind === "literal") {
454
- classLiteral = resolved.value;
491
+ const merged = mergeClassList(resolved.value);
492
+ classLiteral = merged;
493
+ if (resolved.rewrite || merged !== resolved.value) classNeedsRewrite = true;
494
+ if (resolved.rewrite) foldedStaticCn = true;
495
+ compileTwFromString(merged, ctx);
455
496
  } else {
456
497
  classExpr = resolved.expr;
457
498
  classWasArray = resolved.wasArray;
458
499
  classWasDynamic = resolved.wasDynamic;
459
500
  if (classWasDynamic) neededImports.add("cn");
460
- }
461
-
462
- /* Compile Tailwind utilities found in class= string literals */
463
- const cv = classAttr.value;
464
- if (cv === null) {
465
- /* boolean attr — nothing to compile */
466
- } else if (isStringLiteral(cv)) {
467
- compileTwFromString(cv.value, ctx);
468
- } else if (isJSXExpressionContainer(cv)) {
469
- const expr = cv.expression;
470
- if (isStringLiteral(expr)) {
471
- compileTwFromString(expr.value, ctx);
472
- } else {
473
- compileTwFromExpr(expr, ctx);
501
+ const cv = classAttr.value;
502
+ if (cv !== null && isJSXExpressionContainer(cv)) {
503
+ compileTwFromExpr(cv.expression, ctx);
474
504
  }
475
505
  }
476
506
  }
@@ -601,7 +631,12 @@ export function rewriteModule(source: string, ctx: RewriteCtx): RewriteResult |
601
631
  return isArrayExpression((cv as { expression: unknown }).expression);
602
632
  })()
603
633
  : false;
604
- if (classAttr && (classAttrWasArray || classWasArray || classWasDynamic || classNeedsRewrite)) {
634
+ const cssRewritesClass = cssAttr !== null && cssAttr.value !== null;
635
+ if (
636
+ classAttr &&
637
+ !cssRewritesClass &&
638
+ (classAttrWasArray || classWasArray || classWasDynamic || classNeedsRewrite)
639
+ ) {
605
640
  emitClassAttr(classAttr, classAttr.start, classLiteral, classExpr);
606
641
  } else if (!classAttr && spread && dom) {
607
642
  /* Spread on DOM with no class attr — don't inject anything */
@@ -672,8 +707,9 @@ export function rewriteModule(source: string, ctx: RewriteCtx): RewriteResult |
672
707
  } else if (classAttr) {
673
708
  const existing = resolveClassAttr(source, classAttr);
674
709
  if (existing.kind === "literal") {
675
- if (existing.value) {
676
- replace(classAttr.start, classAttr.end, `class={cn("${existing.value}", ${compileCssCall})}`);
710
+ const merged = mergeClassList(existing.value);
711
+ if (merged) {
712
+ replace(classAttr.start, classAttr.end, `class={cn("${merged}", ${compileCssCall})}`);
677
713
  neededImports.add("cn");
678
714
  } else {
679
715
  replace(classAttr.start, classAttr.end, `class={${compileCssCall}}`);
@@ -759,5 +795,6 @@ export function rewriteModule(source: string, ctx: RewriteCtx): RewriteResult |
759
795
  }
760
796
  }
761
797
 
762
- return { code: ms.toString(), emittedClasses, map: null };
798
+ const code = foldedStaticCn ? dropUnusedCnImport(ms.toString()) : ms.toString();
799
+ return { code, emittedClasses, map: null };
763
800
  }
@@ -0,0 +1,18 @@
1
+ # Vendored from shadcn-ui/cn
2
+
3
+ MIT License
4
+
5
+ Copyright (c) 2026 shadcn
6
+
7
+ https://github.com/shadcn-ui/cn
8
+
9
+ - tag: `cn@0.4.0`
10
+ - commit: `84db83298f69a229d6f1ffc5d8c8d99ef451fb49`
11
+
12
+ Copied files (runtime slice only):
13
+
14
+ - `engine.ts`
15
+ - `tables.generated.ts`
16
+ - `types.ts`
17
+
18
+ Not copied: `compiler.ts`, `build.ts`, `config.ts`, `default-config.generated.ts`, `vite.ts`, `next.ts`, `lite.ts`, `theme-css.ts`, `validators.ts`, `index.ts`.