clava 0.4.0 → 0.4.2

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/src/types.ts CHANGED
@@ -76,11 +76,11 @@ type ComponentPropKey<R extends ComponentResult> =
76
76
 
77
77
  // Key source types - what can be passed as additional parameters to splitProps
78
78
  export type KeySourceArray = readonly string[];
79
- export type KeySourceComponent = {
79
+ export interface KeySourceComponent {
80
80
  propKeys: readonly string[];
81
81
  variantKeys: readonly string[];
82
82
  getVariants: () => Record<string, unknown>;
83
- };
83
+ }
84
84
  export type KeySource = KeySourceArray | KeySourceComponent;
85
85
 
86
86
  // Check if source is a component (has getVariants)
package/src/utils.ts CHANGED
@@ -39,7 +39,9 @@ export function hyphenToCamel(str: string) {
39
39
  }
40
40
  // Fast path: no hyphen -> return as-is
41
41
  let hyphenIndex = str.indexOf("-");
42
- if (hyphenIndex === -1) return str;
42
+ if (hyphenIndex === -1) {
43
+ return str;
44
+ }
43
45
 
44
46
  let result = "";
45
47
  let lastIndex = 0;
@@ -91,7 +93,9 @@ export function camelToHyphen(str: string) {
91
93
  lastIndex = i + 1;
92
94
  }
93
95
 
94
- if (lastIndex === 0) return str;
96
+ if (lastIndex === 0) {
97
+ return str;
98
+ }
95
99
  return result + str.slice(lastIndex);
96
100
  }
97
101
 
@@ -102,7 +106,9 @@ export function camelToHyphen(str: string) {
102
106
  * parseLengthValue("2em"); // "2em"
103
107
  */
104
108
  export function parseLengthValue(value: string | number) {
105
- if (typeof value === "string") return value;
109
+ if (typeof value === "string") {
110
+ return value;
111
+ }
106
112
  return `${value}px`;
107
113
  }
108
114
 
@@ -135,7 +141,10 @@ export function htmlStyleToStyleValue(styleString: string) {
135
141
  }
136
142
  if (i >= len || styleString.charCodeAt(i) === 59) {
137
143
  // No colon found - skip this declaration
138
- if (i < len) i++; // skip ';'
144
+ if (i < len) {
145
+ // Skip ';'.
146
+ i++;
147
+ }
139
148
  continue;
140
149
  }
141
150
  let propEnd = i;
@@ -147,12 +156,17 @@ export function htmlStyleToStyleValue(styleString: string) {
147
156
  }
148
157
  if (propEnd === propStart) {
149
158
  // Empty property - skip
150
- while (i < len && styleString.charCodeAt(i) !== 59) i++;
151
- if (i < len) i++;
159
+ while (i < len && styleString.charCodeAt(i) !== 59) {
160
+ i++;
161
+ }
162
+ if (i < len) {
163
+ i++;
164
+ }
152
165
  continue;
153
166
  }
154
167
  const property = styleString.slice(propStart, propEnd);
155
- i++; // skip ':'
168
+ // Skip ':'.
169
+ i++;
156
170
  // Skip whitespace before value
157
171
  while (i < len) {
158
172
  const c = styleString.charCodeAt(i);
@@ -160,14 +174,19 @@ export function htmlStyleToStyleValue(styleString: string) {
160
174
  i++;
161
175
  }
162
176
  const valStart = i;
163
- while (i < len && styleString.charCodeAt(i) !== 59) i++;
177
+ while (i < len && styleString.charCodeAt(i) !== 59) {
178
+ i++;
179
+ }
164
180
  let valEnd = i;
165
181
  while (valEnd > valStart) {
166
182
  const c = styleString.charCodeAt(valEnd - 1);
167
183
  if (c !== 32 && c !== 9 && c !== 10 && c !== 13) break;
168
184
  valEnd--;
169
185
  }
170
- if (i < len) i++; // skip ';'
186
+ if (i < len) {
187
+ // Skip ';'.
188
+ i++;
189
+ }
171
190
  if (valEnd === valStart) continue;
172
191
  const value = styleString.slice(valStart, valEnd);
173
192
  // CSS property names and values are dynamic - cast required for index access
@@ -229,7 +248,9 @@ export function styleValueToHTMLStyle(style: StyleValue): string {
229
248
  if (!hasOwn.call(style, key)) continue;
230
249
  const value = (style as Record<string, unknown>)[key];
231
250
  if (value == null) continue;
232
- if (result) result += "; ";
251
+ if (result) {
252
+ result += "; ";
253
+ }
233
254
  result += camelToHyphen(key);
234
255
  result += ": ";
235
256
  result += value as string | number;
package/tests/_utils.ts CHANGED
@@ -87,12 +87,16 @@ export function getModeComponent<
87
87
  V extends Variants = {},
88
88
  const E extends AnyComponent[] = [],
89
89
  >(mode: M, component: CVComponent<V, E>) {
90
- if (!mode) return component;
90
+ if (!mode) {
91
+ return component;
92
+ }
91
93
  return component[mode];
92
94
  }
93
95
 
94
96
  function getClass(props: ComponentResult) {
95
- if ("class" in props) return props.class;
97
+ if ("class" in props) {
98
+ return props.class;
99
+ }
96
100
  return props.className;
97
101
  }
98
102
 
@@ -1,18 +1,93 @@
1
1
  import { execFile } from "node:child_process";
2
- import { readFile } from "node:fs/promises";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
3
4
  import { dirname, join } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
6
  import { promisify } from "node:util";
7
+ import { withPackageBuildLock } from "test-utils/build-lock";
8
+ import { build } from "vite";
6
9
  import { expect, test } from "vitest";
7
10
 
8
11
  const root = join(dirname(fileURLToPath(import.meta.url)), "..");
9
12
  const exec = promisify(execFile);
13
+ let buildPromise: Promise<unknown> | undefined;
14
+
15
+ function buildPackage() {
16
+ buildPromise ??= exec("pnpm", ["--dir", root, "build"]);
17
+ return buildPromise;
18
+ }
10
19
 
11
20
  test("build preserves the production warning guard for consumers", async () => {
12
- await exec("pnpm", ["--dir", root, "build"]);
21
+ await withPackageBuildLock(async () => {
22
+ await buildPackage();
23
+
24
+ const code = await readFile(join(root, "dist/index.js"), "utf8");
25
+ expect(code).toMatch(
26
+ /warnRefineLimit[\s\S]*?process\.env\.NODE_ENV === "production"[\s\S]*?console\.warn\(/,
27
+ );
28
+ });
29
+ }, 60_000);
30
+
31
+ test("vite removes warning logic from the production bundle", async () => {
32
+ await withPackageBuildLock(async () => {
33
+ await buildPackage();
34
+
35
+ const tempDir = await mkdtemp(join(tmpdir(), "clava-vite-"));
36
+ try {
37
+ const entry = join(tempDir, "entry.js");
38
+ const bundle = join(tempDir, "dist/bundle.js");
39
+ const clavaUrl = pathToFileURL(join(root, "dist/index.js")).href;
40
+
41
+ await writeFile(
42
+ entry,
43
+ `
44
+ import { cv } from ${JSON.stringify(clavaUrl)};
45
+
46
+ export const button = cv({
47
+ variants: {
48
+ tone: {
49
+ primary: "primary",
50
+ },
51
+ },
52
+ refine({ setVariants }) {
53
+ setVariants({ tone: "primary" });
54
+ },
55
+ });
56
+
57
+ button();
58
+ `,
59
+ );
60
+
61
+ await build({
62
+ configFile: false,
63
+ logLevel: "silent",
64
+ root: tempDir,
65
+ mode: "production",
66
+ define: {
67
+ "process.env.NODE_ENV": JSON.stringify("production"),
68
+ },
69
+ build: {
70
+ emptyOutDir: true,
71
+ lib: {
72
+ entry,
73
+ fileName: () => "bundle.js",
74
+ formats: ["es"],
75
+ },
76
+ minify: true,
77
+ outDir: "dist",
78
+ },
79
+ });
13
80
 
14
- const code = await readFile(join(root, "dist/index.js"), "utf8");
15
- expect(code).toMatch(
16
- /process\.env\.NODE_ENV !== "production"[\s\S]*?console\.warn\(/,
17
- );
81
+ const code = await readFile(bundle, "utf8");
82
+ expect(code).not.toContain("console.warn");
83
+ expect(code).not.toContain("Clava: Maximum refine iterations exceeded");
84
+ expect(code).not.toContain("Variant(s) that did not stabilize");
85
+ expect(code).not.toContain("Latest variant changes before warning");
86
+ expect(code).not.toContain("Component created at");
87
+ expect(code).not.toContain("captureStackTrace");
88
+ expect(code).not.toMatch(/\.warned\b|["']warned["']/);
89
+ } finally {
90
+ await rm(tempDir, { force: true, recursive: true });
91
+ }
92
+ });
18
93
  }, 60_000);
@@ -333,8 +333,13 @@ describe("non-idempotent transformClass", () => {
333
333
  });
334
334
  });
335
335
 
336
- const toUpperCase = (className: string) => className.toUpperCase();
337
- const toLowerCase = (className: string) => className.toLowerCase();
336
+ function toUpperCase(className: string) {
337
+ return className.toUpperCase();
338
+ }
339
+
340
+ function toLowerCase(className: string) {
341
+ return className.toLowerCase();
342
+ }
338
343
 
339
344
  describe("extend across `create()` factories", () => {
340
345
  // The extend's own `transformClass` must apply to its own classes even when
@@ -0,0 +1,28 @@
1
+ import { expect, test } from "vitest";
2
+ import { formatCreationStack } from "../src/refine-warning.ts";
3
+
4
+ test("formatCreationStack returns the first app frame", () => {
5
+ const stack = [
6
+ "Error",
7
+ " at captureCreationFrame (/repo/packages/clava/src/refine-warning.ts:31:10)",
8
+ " at cv (/repo/packages/clava/src/index.ts:734:9)",
9
+ " at eval (/repo/app/src/button.ts:12:21)",
10
+ " at ModuleJob.run (node:internal/modules/esm/module_job:343:25)",
11
+ ].join("\n");
12
+
13
+ expect(formatCreationStack({ stack })).toBe(
14
+ " at eval (/repo/app/src/button.ts:12:21)",
15
+ );
16
+ });
17
+
18
+ test("formatCreationStack omits dependency and runtime frames", () => {
19
+ const stack = [
20
+ "Error",
21
+ " at captureCreationFrame (/repo/packages/clava/src/refine-warning.ts:31:10)",
22
+ " at cv(/repo/packages/clava/src/index.ts:734:9)",
23
+ " at eval (/repo/node_modules/package/index.js:1:1)",
24
+ " at ModuleJob.run (node:internal/modules/esm/module_job:343:25)",
25
+ ].join("\n");
26
+
27
+ expect(formatCreationStack({ stack })).toBeUndefined();
28
+ });
@@ -125,6 +125,125 @@ for (const config of Object.values(CONFIGS)) {
125
125
  }
126
126
  });
127
127
 
128
+ test("refine converges with undefined setDefaultVariants", () => {
129
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
130
+ const base = cv({
131
+ variants: {
132
+ invert: { true: "invert" },
133
+ offset: (value: boolean | undefined) =>
134
+ value ? "offset" : undefined,
135
+ push: (value: number | undefined) =>
136
+ value === undefined ? undefined : `push-${value}`,
137
+ },
138
+ refine: ({ variants, setDefaultVariants }) => {
139
+ setDefaultVariants({
140
+ offset: !variants.invert,
141
+ push: variants.invert ? 20 : undefined,
142
+ });
143
+ },
144
+ });
145
+ const component = getModeComponent(mode, cv({ extend: [base] }));
146
+
147
+ try {
148
+ const props = component();
149
+ expect(getStyleClass(props)).toEqual({
150
+ class: cls("offset"),
151
+ });
152
+ expect(component.getVariants()).toEqual({
153
+ offset: true,
154
+ push: undefined,
155
+ });
156
+ expect(warn).not.toHaveBeenCalled();
157
+ } finally {
158
+ warn.mockRestore();
159
+ }
160
+ });
161
+
162
+ test("refine converges with undefined setDefaultVariants in nested extends", () => {
163
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
164
+ const calls = {
165
+ layer: 0,
166
+ frame: 0,
167
+ control: 0,
168
+ };
169
+ const layer = cv({
170
+ variants: {
171
+ layer: { true: "layer" },
172
+ invert: { true: "invert" },
173
+ offset: (value: boolean | undefined) =>
174
+ value ? "offset" : undefined,
175
+ push: (value: number | undefined) =>
176
+ value === undefined ? undefined : `push-${value}`,
177
+ },
178
+ defaultVariants: {
179
+ layer: true,
180
+ },
181
+ refine: ({ variants, setDefaultVariants }) => {
182
+ calls.layer += 1;
183
+ setDefaultVariants({
184
+ offset: !variants.invert,
185
+ push: variants.invert ? 20 : undefined,
186
+ });
187
+ },
188
+ });
189
+ const frame = cv({
190
+ extend: [layer],
191
+ variants: {
192
+ frame: { true: "frame" },
193
+ },
194
+ defaultVariants: {
195
+ frame: true,
196
+ },
197
+ refine: () => {
198
+ calls.frame += 1;
199
+ },
200
+ });
201
+ const control = cv({
202
+ extend: [frame],
203
+ variants: {
204
+ control: { true: "control" },
205
+ },
206
+ defaultVariants: {
207
+ control: true,
208
+ offset: true,
209
+ },
210
+ refine: () => {
211
+ calls.control += 1;
212
+ },
213
+ });
214
+ const component = getModeComponent(mode, cv({ extend: [control] }));
215
+
216
+ try {
217
+ const props = component();
218
+ expect(getStyleClass(props)).toEqual({
219
+ class: cls("layer offset frame control"),
220
+ });
221
+ expect(calls).toEqual({
222
+ layer: 2,
223
+ frame: 2,
224
+ control: 2,
225
+ });
226
+ calls.layer = 0;
227
+ calls.frame = 0;
228
+ calls.control = 0;
229
+ expect(component.getVariants()).toEqual({
230
+ layer: true,
231
+ offset: true,
232
+ push: undefined,
233
+ frame: true,
234
+ control: true,
235
+ });
236
+ expect(calls).toEqual({
237
+ layer: 2,
238
+ frame: 2,
239
+ control: 2,
240
+ });
241
+ expect(warn).not.toHaveBeenCalled();
242
+ } finally {
243
+ warn.mockRestore();
244
+ }
245
+ });
246
+
128
247
  test("refine with setDefaultVariants", () => {
129
248
  const component = getModeComponent(
130
249
  mode,
@@ -714,6 +833,19 @@ for (const config of Object.values(CONFIGS)) {
714
833
  expect(warn).toHaveBeenCalledWith(
715
834
  expect.stringContaining("Maximum refine iterations exceeded"),
716
835
  );
836
+ expect(warn).toHaveBeenCalledWith(
837
+ expect.stringMatching(
838
+ /Variant\(s\) that did not stabilize: [^\n]*\bsize\b/,
839
+ ),
840
+ );
841
+ expect(warn).toHaveBeenCalledWith(
842
+ expect.stringMatching(
843
+ /Latest variant changes before warning: [^\n]*\bsize: "(sm|lg)" -> "(sm|lg)"/,
844
+ ),
845
+ );
846
+ expect(warn).toHaveBeenCalledWith(
847
+ expect.stringContaining("Component created at:"),
848
+ );
717
849
  } finally {
718
850
  warn.mockRestore();
719
851
  }
@@ -737,6 +869,19 @@ for (const config of Object.values(CONFIGS)) {
737
869
  expect(warn).toHaveBeenCalledWith(
738
870
  expect.stringContaining("Maximum refine iterations exceeded"),
739
871
  );
872
+ expect(warn).toHaveBeenCalledWith(
873
+ expect.stringMatching(
874
+ /Variant\(s\) that did not stabilize: [^\n]*\bsize\b/,
875
+ ),
876
+ );
877
+ expect(warn).toHaveBeenCalledWith(
878
+ expect.stringMatching(
879
+ /Latest variant changes before warning: [^\n]*\bsize: "(sm|lg)" -> "(sm|lg)"/,
880
+ ),
881
+ );
882
+ expect(warn).toHaveBeenCalledWith(
883
+ expect.stringContaining("Component created at:"),
884
+ );
740
885
  } finally {
741
886
  warn.mockRestore();
742
887
  }
@@ -765,6 +910,187 @@ for (const config of Object.values(CONFIGS)) {
765
910
  }
766
911
  });
767
912
 
913
+ test("refine warning names the variant key that did not stabilize", () => {
914
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
915
+ const component = getModeComponent(
916
+ mode,
917
+ cv({
918
+ variants: {
919
+ size: { sm: "sm", lg: "lg" },
920
+ color: { red: "red", blue: "blue" },
921
+ },
922
+ defaultVariants: { size: "sm", color: "red" },
923
+ refine: ({ variants, setVariants }) => {
924
+ setVariants({ size: variants.size === "sm" ? "lg" : "sm" });
925
+ },
926
+ }),
927
+ );
928
+
929
+ try {
930
+ component();
931
+ expect(warn).toHaveBeenCalledWith(
932
+ expect.stringMatching(
933
+ /Variant\(s\) that did not stabilize: [^\n]*\bsize\b/,
934
+ ),
935
+ );
936
+ expect(warn).toHaveBeenCalledWith(
937
+ expect.not.stringMatching(
938
+ /Variant\(s\) that did not stabilize: [^\n]*\bcolor\b/,
939
+ ),
940
+ );
941
+ } finally {
942
+ warn.mockRestore();
943
+ }
944
+ });
945
+
946
+ test("refine warning reports keys that oscillate at different cadences", () => {
947
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
948
+ // `size` flips every iteration, `color` flips every other iteration, so
949
+ // the final pair may agree on one of them — the warning must still name
950
+ // both keys because each contributed to a transition.
951
+ const component = getModeComponent(
952
+ mode,
953
+ cv({
954
+ variants: {
955
+ size: { sm: "sm", lg: "lg" },
956
+ color: { red: "red", blue: "blue" },
957
+ },
958
+ defaultVariants: { size: "sm", color: "red" },
959
+ refine: ({ variants, setVariants }) => {
960
+ setVariants({
961
+ size: variants.size === "sm" ? "lg" : "sm",
962
+ color:
963
+ variants.size === "sm"
964
+ ? variants.color === "red"
965
+ ? "blue"
966
+ : "red"
967
+ : variants.color,
968
+ });
969
+ },
970
+ }),
971
+ );
972
+
973
+ try {
974
+ component();
975
+ expect(warn).toHaveBeenCalledWith(
976
+ expect.stringMatching(
977
+ /Variant\(s\) that did not stabilize: [^\n]*\bsize\b/,
978
+ ),
979
+ );
980
+ expect(warn).toHaveBeenCalledWith(
981
+ expect.stringMatching(
982
+ /Variant\(s\) that did not stabilize: [^\n]*\bcolor\b/,
983
+ ),
984
+ );
985
+ expect(warn).toHaveBeenCalledWith(
986
+ expect.stringMatching(
987
+ /Latest variant changes before warning: [^\n]*\bsize\b[^\n]*\bcolor\b/,
988
+ ),
989
+ );
990
+ } finally {
991
+ warn.mockRestore();
992
+ }
993
+ });
994
+
995
+ test("refine warning includes the component creation stack", () => {
996
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
997
+ const component = getModeComponent(
998
+ mode,
999
+ cv({
1000
+ variants: { size: { sm: "sm", lg: "lg" } },
1001
+ defaultVariants: { size: "sm" },
1002
+ refine: ({ variants, setVariants }) => {
1003
+ setVariants({ size: variants.size === "sm" ? "lg" : "sm" });
1004
+ },
1005
+ }),
1006
+ );
1007
+
1008
+ try {
1009
+ component();
1010
+ expect(warn).toHaveBeenCalledWith(
1011
+ expect.stringContaining("Component created at:"),
1012
+ );
1013
+ expect(warn).toHaveBeenCalledWith(
1014
+ expect.stringContaining("refine.test.ts"),
1015
+ );
1016
+ expect(warn).toHaveBeenCalledWith(
1017
+ expect.not.stringContaining("node_modules"),
1018
+ );
1019
+ } finally {
1020
+ warn.mockRestore();
1021
+ }
1022
+ });
1023
+
1024
+ test("refine warning fallback stack skips internal creation frames", () => {
1025
+ const ErrorWithCaptureStackTrace = Error as ErrorConstructor & {
1026
+ captureStackTrace?: (
1027
+ targetObject: object,
1028
+ constructorOpt?: Function,
1029
+ ) => void;
1030
+ };
1031
+ const captureStackTrace = ErrorWithCaptureStackTrace.captureStackTrace;
1032
+ Reflect.deleteProperty(ErrorWithCaptureStackTrace, "captureStackTrace");
1033
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
1034
+ const component = getModeComponent(
1035
+ mode,
1036
+ cv({
1037
+ variants: { size: { sm: "sm", lg: "lg" } },
1038
+ defaultVariants: { size: "sm" },
1039
+ refine: ({ variants, setVariants }) => {
1040
+ setVariants({ size: variants.size === "sm" ? "lg" : "sm" });
1041
+ },
1042
+ }),
1043
+ );
1044
+
1045
+ try {
1046
+ component();
1047
+ expect(warn).toHaveBeenCalledWith(
1048
+ expect.stringContaining("Component created at:"),
1049
+ );
1050
+ expect(warn).toHaveBeenCalledWith(
1051
+ expect.stringContaining("refine.test.ts"),
1052
+ );
1053
+ expect(warn).toHaveBeenCalledWith(
1054
+ expect.not.stringContaining("captureCreationFrame"),
1055
+ );
1056
+ } finally {
1057
+ if (captureStackTrace) {
1058
+ ErrorWithCaptureStackTrace.captureStackTrace = captureStackTrace;
1059
+ }
1060
+ warn.mockRestore();
1061
+ }
1062
+ });
1063
+
1064
+ test("refine warning omits the creation stack when cv ran in production", () => {
1065
+ let captured = "";
1066
+ const warn = vi
1067
+ .spyOn(console, "warn")
1068
+ .mockImplementation((message: unknown) => {
1069
+ captured = String(message);
1070
+ });
1071
+ vi.stubEnv("NODE_ENV", "production");
1072
+ const component = getModeComponent(
1073
+ mode,
1074
+ cv({
1075
+ variants: { size: { sm: "sm", lg: "lg" } },
1076
+ defaultVariants: { size: "sm" },
1077
+ refine: ({ variants, setVariants }) => {
1078
+ setVariants({ size: variants.size === "sm" ? "lg" : "sm" });
1079
+ },
1080
+ }),
1081
+ );
1082
+
1083
+ try {
1084
+ vi.unstubAllEnvs();
1085
+ component();
1086
+ expect(captured).toContain("Maximum refine iterations exceeded");
1087
+ expect(captured).not.toContain("Component created at:");
1088
+ } finally {
1089
+ vi.unstubAllEnvs();
1090
+ warn.mockRestore();
1091
+ }
1092
+ });
1093
+
768
1094
  test("refine setDefaultVariants when explicitly passing undefined", () => {
769
1095
  const component = getModeComponent(
770
1096
  mode,