@nuvio/ast-engine 0.1.0 → 0.5.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/dist/index.d.ts CHANGED
@@ -1,5 +1,19 @@
1
1
  import { PatchOp } from '@nuvio/shared';
2
2
 
3
+ type ClassNameMode = "literal-only" | "cn-basic";
4
+ type Breakpoint = "base" | "sm" | "md" | "lg" | "xl";
5
+ type BreakpointBuckets = {
6
+ base: string[];
7
+ sm: string[];
8
+ md: string[];
9
+ lg: string[];
10
+ xl: string[];
11
+ passthrough: string[];
12
+ };
13
+ declare function parseClassNameByBreakpoint(className: string): BreakpointBuckets;
14
+ declare function mergeAtBreakpoint(className: string, fragment: string, activeBreakpoint: Breakpoint): string;
15
+ /** Strip allowlisted utilities from the active breakpoint (inverse of mergeAtBreakpoint). */
16
+ declare function removeAtBreakpoint(className: string, fragment: string, activeBreakpoint: Breakpoint): string;
3
17
  type ApplyPatchToSourceResult = {
4
18
  ok: true;
5
19
  source: string;
@@ -12,7 +26,10 @@ type ApplyPatchToSourceResult = {
12
26
  /**
13
27
  * Apply Phase 2 patch operations to TSX/JSX source for a single `data-nuvio-id` host.
14
28
  */
15
- declare function applyPatchToSource(source: string, filePath: string, hostId: string, ops: readonly PatchOp[]): Promise<ApplyPatchToSourceResult>;
29
+ declare function applyPatchToSource(source: string, filePath: string, hostId: string, ops: readonly PatchOp[], options?: {
30
+ classNameMode?: ClassNameMode;
31
+ activeBreakpoint?: Breakpoint;
32
+ }): Promise<ApplyPatchToSourceResult>;
16
33
 
17
34
  /**
18
35
  * Phase 2 — conservative Tailwind v3-style allowlist for `mergeTailwindClassName`.
@@ -20,4 +37,4 @@ declare function applyPatchToSource(source: string, filePath: string, hostId: st
20
37
  */
21
38
  declare function validateTailwindFragment(fragment: string): void;
22
39
 
23
- export { type ApplyPatchToSourceResult, applyPatchToSource, validateTailwindFragment };
40
+ export { type ApplyPatchToSourceResult, applyPatchToSource, mergeAtBreakpoint, parseClassNameByBreakpoint, removeAtBreakpoint, validateTailwindFragment };
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // src/apply-patch.ts
2
- import { createRequire } from "module";
2
+ import { createRequire as createRequire2 } from "module";
3
3
  import path from "path";
4
- import { parse } from "@babel/parser";
5
- import * as t from "@babel/types";
4
+ import { parse as parse2 } from "@babel/parser";
5
+ import * as t2 from "@babel/types";
6
6
  import prettier from "prettier";
7
7
  import { twMerge } from "tailwind-merge";
8
8
 
@@ -17,8 +17,13 @@ var BG_COLOR_OPACITY = /^bg-(slate|sky|neutral)-(800|900|950)\/(50|75|80)$/;
17
17
  var ROUNDED = /^rounded$|^rounded-(none|sm|md|lg|xl|2xl|3xl|full)$/;
18
18
  var LAYOUT = /^(flex|inline-flex|block|inline|inline-block|grid|inline-grid|hidden|contents)$/;
19
19
  var FLEX = /^(flex-row|flex-col|flex-wrap|flex-1|grow|shrink|basis-0|items-(start|end|center|baseline|stretch)|justify-(start|end|center|between|around|evenly))$/;
20
+ var GRID_COLS = /^grid-cols-(1|2|3|4|5|6|7|8|9|10|11|12)$/;
20
21
  var BORDER_W = /^border(-(0|2|4|8))?$/;
22
+ var BORDER_COLOR = /^border-(inherit|current|transparent|black|white|(slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(50|100|200|300|400|500|600|700|800|900|950))$/;
23
+ var RING = /^ring(-(0|1|2|4|8))?$/;
24
+ var RING_COLOR = /^ring-(inherit|current|transparent|black|white|(slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(50|100|200|300|400|500|600|700|800|900|950))$/;
21
25
  var TEXT_ALIGN = /^text-(left|center|right|justify|start|end)$/;
26
+ var TRACKING = /^tracking-(tighter|tight|normal|wide|wider|widest)$/;
22
27
  var OPACITY = /^opacity-(0|5|10|15|20|25|30|40|50|60|70|75|80|90|95|100)$/;
23
28
  var SHADOW = /^shadow$|^shadow-(sm|md|lg|xl|2xl|inner|none)$/;
24
29
  var W_WIDTH = /^w-(auto|full|screen|min|max|fit|px|0|0\.5|1|1\.5|2|2\.5|3|3\.5|4|5|6|7|8|9|10|11|12|14|16|20|24|28|32|36|40|44|48|52|56|60|64|72|80|96|1\/2|1\/3|2\/3|1\/4|3\/4)$/;
@@ -31,33 +36,136 @@ function normalizeTailwindToken(raw) {
31
36
  function validateTailwindFragment(fragment) {
32
37
  const tokens = fragment.trim().split(/\s+/).filter(Boolean);
33
38
  for (const raw of tokens) {
34
- const t2 = normalizeTailwindToken(raw);
35
- if (!t2) {
39
+ const t3 = normalizeTailwindToken(raw);
40
+ if (!t3) {
36
41
  continue;
37
42
  }
38
- if (SPACING.test(t2) || TEXT_SIZE.test(t2) || FONT_WEIGHT.test(t2) || LEADING.test(t2) || COLOR_SOLID.test(t2) || COLOR_SCALE.test(t2) || BG_COLOR_OPACITY.test(t2) || ROUNDED.test(t2) || LAYOUT.test(t2) || FLEX.test(t2) || BORDER_W.test(t2) || TEXT_ALIGN.test(t2) || OPACITY.test(t2) || SHADOW.test(t2) || W_WIDTH.test(t2) || H_HEIGHT.test(t2) || MAX_W.test(t2) || MIN_H.test(t2)) {
43
+ if (SPACING.test(t3) || TEXT_SIZE.test(t3) || FONT_WEIGHT.test(t3) || LEADING.test(t3) || COLOR_SOLID.test(t3) || COLOR_SCALE.test(t3) || BG_COLOR_OPACITY.test(t3) || ROUNDED.test(t3) || LAYOUT.test(t3) || FLEX.test(t3) || GRID_COLS.test(t3) || BORDER_W.test(t3) || BORDER_COLOR.test(t3) || RING.test(t3) || RING_COLOR.test(t3) || TEXT_ALIGN.test(t3) || TRACKING.test(t3) || OPACITY.test(t3) || SHADOW.test(t3) || W_WIDTH.test(t3) || H_HEIGHT.test(t3) || MAX_W.test(t3) || MIN_H.test(t3)) {
39
44
  continue;
40
45
  }
41
- throw new Error(`Unknown or disallowed Tailwind utility: ${t2}`);
46
+ throw new Error(`Unknown or disallowed Tailwind utility: ${t3}`);
42
47
  }
43
48
  }
44
49
 
45
- // src/apply-patch.ts
50
+ // src/set-table-data-field.ts
51
+ import { parse } from "@babel/parser";
52
+ import * as t from "@babel/types";
53
+ import { createRequire } from "module";
46
54
  var require2 = createRequire(import.meta.url);
47
55
  var traverse = require2("@babel/traverse").default;
48
- var generate = require2("@babel/generator").default;
56
+ function applySetTableDataField(ast, arrayName, rowKey, field, value) {
57
+ let updated = false;
58
+ traverse(ast, {
59
+ VariableDeclarator(path2) {
60
+ if (updated || !t.isIdentifier(path2.node.id) || path2.node.id.name !== arrayName) {
61
+ return;
62
+ }
63
+ if (!t.isArrayExpression(path2.node.init)) {
64
+ return;
65
+ }
66
+ for (const el of path2.node.init.elements) {
67
+ if (!el || !t.isObjectExpression(el)) {
68
+ continue;
69
+ }
70
+ let idValue = null;
71
+ for (const prop of el.properties) {
72
+ if (!t.isObjectProperty(prop) || !t.isIdentifier(prop.key)) {
73
+ continue;
74
+ }
75
+ if (prop.key.name === "id" && t.isNumericLiteral(prop.value)) {
76
+ idValue = String(prop.value.value);
77
+ }
78
+ }
79
+ if (idValue !== rowKey) {
80
+ continue;
81
+ }
82
+ for (const prop of el.properties) {
83
+ if (!t.isObjectProperty(prop) || !t.isIdentifier(prop.key)) {
84
+ continue;
85
+ }
86
+ if (prop.key.name !== field) {
87
+ continue;
88
+ }
89
+ if (t.isStringLiteral(prop.value)) {
90
+ prop.value.value = value;
91
+ updated = true;
92
+ path2.stop();
93
+ return;
94
+ }
95
+ }
96
+ }
97
+ }
98
+ });
99
+ if (!updated) {
100
+ throw new Error(
101
+ `No ${arrayName} entry with id=${rowKey} and string field "${field}" found in source`
102
+ );
103
+ }
104
+ }
105
+
106
+ // src/apply-patch.ts
107
+ var require3 = createRequire2(import.meta.url);
108
+ var traverse2 = require3("@babel/traverse").default;
109
+ var generate = require3("@babel/generator").default;
110
+ function extractRowKeysFromSource(ast) {
111
+ const keys = [];
112
+ traverse2(ast, {
113
+ VariableDeclarator(path2) {
114
+ if (!t2.isIdentifier(path2.node.id) || path2.node.id.name !== "tableData") {
115
+ return;
116
+ }
117
+ if (!t2.isArrayExpression(path2.node.init)) {
118
+ return;
119
+ }
120
+ for (const el of path2.node.init.elements) {
121
+ if (!el || !t2.isObjectExpression(el)) {
122
+ continue;
123
+ }
124
+ for (const prop of el.properties) {
125
+ if (t2.isObjectProperty(prop) && t2.isIdentifier(prop.key, { name: "id" }) && t2.isNumericLiteral(prop.value)) {
126
+ keys.push(String(prop.value.value));
127
+ }
128
+ }
129
+ }
130
+ }
131
+ });
132
+ return keys;
133
+ }
134
+ function templateLiteralMatchesHostId(attr, hostId, rowKeys) {
135
+ if (!t2.isJSXExpressionContainer(attr.value)) {
136
+ return false;
137
+ }
138
+ const expr = attr.value.expression;
139
+ if (!t2.isTemplateLiteral(expr) || expr.expressions.length !== 1) {
140
+ return false;
141
+ }
142
+ const ex = expr.expressions[0];
143
+ const mapOk = t2.isIdentifier(ex, { name: "product" }) || t2.isIdentifier(ex, { name: "item" }) || t2.isMemberExpression(ex) && !ex.computed && t2.isIdentifier(ex.object, { name: "product" }) && t2.isIdentifier(ex.property, { name: "id" });
144
+ if (!mapOk) {
145
+ return false;
146
+ }
147
+ const prefix = expr.quasis[0]?.value.cooked ?? "";
148
+ const suffix = expr.quasis[1]?.value.cooked ?? "";
149
+ return rowKeys.some((key) => `${prefix}${key}${suffix}` === hostId);
150
+ }
49
151
  function findHostOpening(ast, hostId) {
152
+ const rowKeys = extractRowKeysFromSource(ast);
50
153
  let found = null;
51
- traverse(ast, {
154
+ traverse2(ast, {
52
155
  JSXOpeningElement(path2) {
53
156
  for (const attr of path2.node.attributes) {
54
- if (!t.isJSXAttribute(attr)) {
157
+ if (!t2.isJSXAttribute(attr)) {
55
158
  continue;
56
159
  }
57
- if (!t.isJSXIdentifier(attr.name, { name: "data-nuvio-id" })) {
160
+ if (!t2.isJSXIdentifier(attr.name, { name: "data-nuvio-id" })) {
58
161
  continue;
59
162
  }
60
- if (t.isStringLiteral(attr.value) && attr.value.value === hostId) {
163
+ if (t2.isStringLiteral(attr.value) && attr.value.value === hostId) {
164
+ found = path2;
165
+ path2.stop();
166
+ return;
167
+ }
168
+ if (rowKeys.length > 0 && templateLiteralMatchesHostId(attr, hostId, rowKeys)) {
61
169
  found = path2;
62
170
  path2.stop();
63
171
  return;
@@ -75,41 +183,189 @@ function applySetText(openingPath, text) {
75
183
  const jsx = parent;
76
184
  const { children } = jsx.node;
77
185
  if (children.length === 0) {
78
- jsx.node.children = [t.jsxText(text)];
186
+ jsx.node.children = [t2.jsxText(text)];
79
187
  return;
80
188
  }
81
- if (children.length === 1 && t.isJSXText(children[0])) {
189
+ if (children.length === 1 && t2.isJSXText(children[0])) {
82
190
  children[0].value = text;
83
191
  return;
84
192
  }
85
- if (children.length === 1 && t.isJSXExpressionContainer(children[0]) && t.isStringLiteral(children[0].expression)) {
193
+ if (children.length === 1 && t2.isJSXExpressionContainer(children[0]) && t2.isStringLiteral(children[0].expression)) {
86
194
  children[0].expression.value = text;
87
195
  return;
88
196
  }
89
- jsx.node.children = [t.jsxText(text)];
197
+ jsx.node.children = [t2.jsxText(text)];
198
+ }
199
+ function emptyBreakpointBuckets() {
200
+ return {
201
+ base: [],
202
+ sm: [],
203
+ md: [],
204
+ lg: [],
205
+ xl: [],
206
+ passthrough: []
207
+ };
208
+ }
209
+ function classifyTokenByBreakpoint(token) {
210
+ if (!token.includes(":")) {
211
+ return { bp: "base", value: token };
212
+ }
213
+ const m = token.match(/^(sm|md|lg|xl):(.*)$/);
214
+ if (!m) {
215
+ return { bp: "passthrough", value: token };
216
+ }
217
+ if (!m[2] || m[2].includes(":")) {
218
+ return { bp: "passthrough", value: token };
219
+ }
220
+ return { bp: m[1], value: m[2] };
221
+ }
222
+ function parseClassNameByBreakpoint(className) {
223
+ const buckets = emptyBreakpointBuckets();
224
+ const tokens = className.trim().split(/\s+/).filter(Boolean);
225
+ for (const tok of tokens) {
226
+ const parsed = classifyTokenByBreakpoint(tok);
227
+ if (parsed.bp === "passthrough") {
228
+ buckets.passthrough.push(parsed.value);
229
+ continue;
230
+ }
231
+ buckets[parsed.bp].push(parsed.value);
232
+ }
233
+ return buckets;
234
+ }
235
+ function prefixTokenForBreakpoint(token, bp) {
236
+ return bp === "base" ? token : `${bp}:${token}`;
237
+ }
238
+ var BREAKPOINT_ORDER = ["base", "sm", "md", "lg", "xl"];
239
+ function serializeClassNameFromBuckets(buckets) {
240
+ const out = [];
241
+ const mergedBase = buckets.base.join(" ").trim();
242
+ if (mergedBase) {
243
+ out.push(mergedBase);
244
+ }
245
+ for (const bp of BREAKPOINT_ORDER.slice(1)) {
246
+ const merged = buckets[bp].join(" ").trim();
247
+ if (merged) {
248
+ out.push(
249
+ ...merged.split(/\s+/).filter(Boolean).map((t3) => prefixTokenForBreakpoint(t3, bp))
250
+ );
251
+ }
252
+ }
253
+ out.push(...buckets.passthrough);
254
+ return out.join(" ").trim();
255
+ }
256
+ function flattenTokensAtBreakpointFromBuckets(buckets, activeBreakpoint) {
257
+ const idx = BREAKPOINT_ORDER.indexOf(activeBreakpoint);
258
+ const out = [];
259
+ for (const tok of buckets.passthrough) {
260
+ if (/^dark:/.test(tok)) {
261
+ out.push(tok.replace(/^dark:/, ""));
262
+ }
263
+ }
264
+ for (let i = 0; i <= idx; i++) {
265
+ out.push(...buckets[BREAKPOINT_ORDER[i]]);
266
+ }
267
+ for (const tok of buckets.passthrough) {
268
+ if (!/^dark:/.test(tok)) {
269
+ out.push(tok);
270
+ }
271
+ }
272
+ return out;
273
+ }
274
+ function mergeAtBreakpoint(className, fragment, activeBreakpoint) {
275
+ const buckets = parseClassNameByBreakpoint(className);
276
+ const incomingBuckets = emptyBreakpointBuckets();
277
+ for (const tok of fragment.trim().split(/\s+/).filter(Boolean)) {
278
+ const parsed = classifyTokenByBreakpoint(tok);
279
+ if (parsed.bp === "passthrough") {
280
+ incomingBuckets.passthrough.push(parsed.value);
281
+ continue;
282
+ }
283
+ const targetBp = parsed.bp === "base" ? activeBreakpoint : parsed.bp;
284
+ incomingBuckets[targetBp].push(parsed.value);
285
+ }
286
+ const mergedBase = twMerge(buckets.base.join(" "), incomingBuckets.base.join(" ")).trim();
287
+ const mergedSm = twMerge(buckets.sm.join(" "), incomingBuckets.sm.join(" ")).trim();
288
+ const mergedMd = twMerge(buckets.md.join(" "), incomingBuckets.md.join(" ")).trim();
289
+ const mergedLg = twMerge(buckets.lg.join(" "), incomingBuckets.lg.join(" ")).trim();
290
+ const mergedXl = twMerge(buckets.xl.join(" "), incomingBuckets.xl.join(" ")).trim();
291
+ return serializeClassNameFromBuckets({
292
+ base: mergedBase ? mergedBase.split(/\s+/).filter(Boolean) : [],
293
+ sm: mergedSm ? mergedSm.split(/\s+/).filter(Boolean) : [],
294
+ md: mergedMd ? mergedMd.split(/\s+/).filter(Boolean) : [],
295
+ lg: mergedLg ? mergedLg.split(/\s+/).filter(Boolean) : [],
296
+ xl: mergedXl ? mergedXl.split(/\s+/).filter(Boolean) : [],
297
+ passthrough: [...buckets.passthrough, ...incomingBuckets.passthrough]
298
+ });
299
+ }
300
+ function removeAtBreakpoint(className, fragment, activeBreakpoint) {
301
+ const buckets = parseClassNameByBreakpoint(className);
302
+ const toRemove = fragment.trim().split(/\s+/).filter(Boolean);
303
+ if (toRemove.length === 0) {
304
+ return className.trim();
305
+ }
306
+ const idx = BREAKPOINT_ORDER.indexOf(activeBreakpoint);
307
+ for (const tok of toRemove) {
308
+ buckets[activeBreakpoint] = buckets[activeBreakpoint].filter((t3) => t3 !== tok);
309
+ const stillPresent = flattenTokensAtBreakpointFromBuckets(buckets, activeBreakpoint).includes(tok);
310
+ if (stillPresent) {
311
+ for (let i = idx; i >= 0; i--) {
312
+ const bp = BREAKPOINT_ORDER[i];
313
+ if (buckets[bp].includes(tok)) {
314
+ buckets[bp] = buckets[bp].filter((t3) => t3 !== tok);
315
+ break;
316
+ }
317
+ }
318
+ }
319
+ }
320
+ return serializeClassNameFromBuckets(buckets);
90
321
  }
91
- function readStringLiteralClassName(opening) {
322
+ function getClassNameBinding(opening, classNameMode) {
92
323
  for (const attr of opening.attributes) {
93
- if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: "className" })) {
94
- if (t.isStringLiteral(attr.value)) {
95
- return attr.value.value;
324
+ if (t2.isJSXAttribute(attr) && t2.isJSXIdentifier(attr.name, { name: "className" })) {
325
+ if (t2.isStringLiteral(attr.value)) {
326
+ const literal = attr.value;
327
+ return {
328
+ read: () => literal.value,
329
+ write: (next) => {
330
+ attr.value = t2.stringLiteral(next);
331
+ }
332
+ };
333
+ }
334
+ if (classNameMode === "cn-basic" && t2.isJSXExpressionContainer(attr.value) && t2.isCallExpression(attr.value.expression) && (t2.isIdentifier(attr.value.expression.callee) && (attr.value.expression.callee.name === "cn" || attr.value.expression.callee.name === "clsx") || false)) {
335
+ const call = attr.value.expression;
336
+ if (call.arguments.every((arg) => t2.isStringLiteral(arg))) {
337
+ return {
338
+ read: () => call.arguments.map((arg) => arg.value).join(" "),
339
+ write: (next) => {
340
+ call.arguments = [t2.stringLiteral(next)];
341
+ }
342
+ };
343
+ }
96
344
  }
97
345
  return null;
98
346
  }
99
347
  }
100
- return "";
348
+ return {
349
+ read: () => "",
350
+ write: (next) => {
351
+ opening.attributes.push(
352
+ t2.jsxAttribute(t2.jsxIdentifier("className"), t2.stringLiteral(next))
353
+ );
354
+ }
355
+ };
101
356
  }
102
357
  function parentSupportsLayoutMoves(parentOpening) {
103
- const cls = readStringLiteralClassName(parentOpening);
104
- if (cls === null) {
358
+ const binding = getClassNameBinding(parentOpening, "literal-only");
359
+ if (!binding) {
105
360
  return false;
106
361
  }
362
+ const cls = binding.read();
107
363
  return /\b(flex|inline-flex|grid|inline-grid)\b/.test(cls) || /\b(flex-|grid-)/.test(cls);
108
364
  }
109
365
  function collectJsxElementChildIndices(parent) {
110
366
  const indices = [];
111
367
  parent.children.forEach((child, i) => {
112
- if (t.isJSXElement(child)) {
368
+ if (t2.isJSXElement(child)) {
113
369
  indices.push(i);
114
370
  }
115
371
  });
@@ -151,34 +407,34 @@ function applyMoveSibling(openingPath, direction) {
151
407
  parent.children[hostIndex] = parent.children[swapIndex];
152
408
  parent.children[swapIndex] = hostNode;
153
409
  }
154
- function applySetHidden(openingPath, hidden) {
410
+ function applySetHidden(openingPath, hidden, classNameMode, activeBreakpoint) {
155
411
  if (hidden) {
156
- applyMergeClassName(openingPath, "hidden");
412
+ applyMergeClassName(openingPath, "hidden", classNameMode, activeBreakpoint);
157
413
  return;
158
414
  }
159
415
  const opening = openingPath.node;
160
416
  let clsAttr;
161
417
  for (const attr of opening.attributes) {
162
- if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: "className" })) {
418
+ if (t2.isJSXAttribute(attr) && t2.isJSXIdentifier(attr.name, { name: "className" })) {
163
419
  clsAttr = attr;
164
420
  break;
165
421
  }
166
422
  }
167
- if (!clsAttr || !t.isStringLiteral(clsAttr.value)) {
423
+ if (!clsAttr || !t2.isStringLiteral(clsAttr.value)) {
168
424
  return;
169
425
  }
170
426
  const tokens = clsAttr.value.value.split(/\s+/).filter((tok) => tok && tok !== "hidden");
171
- clsAttr.value = t.stringLiteral(twMerge(tokens.join(" ")));
427
+ clsAttr.value = t2.stringLiteral(twMerge(tokens.join(" ")));
172
428
  }
173
429
  function collectNuvioIds(ast) {
174
430
  const ids = /* @__PURE__ */ new Set();
175
- traverse(ast, {
431
+ traverse2(ast, {
176
432
  JSXOpeningElement(path2) {
177
433
  for (const attr of path2.node.attributes) {
178
- if (!t.isJSXAttribute(attr) || !t.isJSXIdentifier(attr.name, { name: "data-nuvio-id" })) {
434
+ if (!t2.isJSXAttribute(attr) || !t2.isJSXIdentifier(attr.name, { name: "data-nuvio-id" })) {
179
435
  continue;
180
436
  }
181
- if (t.isStringLiteral(attr.value)) {
437
+ if (t2.isStringLiteral(attr.value)) {
182
438
  ids.add(attr.value.value);
183
439
  }
184
440
  }
@@ -188,17 +444,41 @@ function collectNuvioIds(ast) {
188
444
  }
189
445
  function setNuvioIdOnOpening(opening, id) {
190
446
  for (const attr of opening.attributes) {
191
- if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: "data-nuvio-id" })) {
192
- if (t.isStringLiteral(attr.value)) {
447
+ if (t2.isJSXAttribute(attr) && t2.isJSXIdentifier(attr.name, { name: "data-nuvio-id" })) {
448
+ if (t2.isStringLiteral(attr.value)) {
193
449
  attr.value.value = id;
194
450
  return;
195
451
  }
196
452
  }
197
453
  }
198
454
  opening.attributes.push(
199
- t.jsxAttribute(t.jsxIdentifier("data-nuvio-id"), t.stringLiteral(id))
455
+ t2.jsxAttribute(t2.jsxIdentifier("data-nuvio-id"), t2.stringLiteral(id))
200
456
  );
201
457
  }
458
+ function remapDescendantNuvioIds(element, taken) {
459
+ const stack = [];
460
+ for (const child of element.children) {
461
+ if (t2.isJSXElement(child)) {
462
+ stack.push(child);
463
+ }
464
+ }
465
+ while (stack.length > 0) {
466
+ const el = stack.pop();
467
+ for (const attr of el.openingElement.attributes) {
468
+ if (!t2.isJSXAttribute(attr) || !t2.isJSXIdentifier(attr.name, { name: "data-nuvio-id" }) || !t2.isStringLiteral(attr.value)) {
469
+ continue;
470
+ }
471
+ const nextId = uniqueDuplicateId(attr.value.value, taken);
472
+ attr.value.value = nextId;
473
+ taken.add(nextId);
474
+ }
475
+ for (const child of el.children) {
476
+ if (t2.isJSXElement(child)) {
477
+ stack.push(child);
478
+ }
479
+ }
480
+ }
481
+ }
202
482
  function uniqueDuplicateId(baseId, taken) {
203
483
  const candidate = `${baseId}.copy`;
204
484
  if (!taken.has(candidate)) {
@@ -221,11 +501,13 @@ function applyDuplicateHost(ast, openingPath, hostId) {
221
501
  }
222
502
  const taken = collectNuvioIds(ast);
223
503
  const newId = uniqueDuplicateId(hostId, taken);
224
- const clone = t.cloneNode(hostPath.node, true);
225
- if (!t.isJSXElement(clone)) {
504
+ const clone = t2.cloneNode(hostPath.node, true);
505
+ if (!t2.isJSXElement(clone)) {
226
506
  throw new Error("Failed to clone host element");
227
507
  }
228
508
  setNuvioIdOnOpening(clone.openingElement, newId);
509
+ taken.add(newId);
510
+ remapDescendantNuvioIds(clone, taken);
229
511
  const parent = parentPath.node;
230
512
  const hostIndex = parent.children.indexOf(hostPath.node);
231
513
  if (hostIndex < 0) {
@@ -234,32 +516,36 @@ function applyDuplicateHost(ast, openingPath, hostId) {
234
516
  parent.children.splice(hostIndex + 1, 0, clone);
235
517
  return newId;
236
518
  }
237
- function applyMergeClassName(openingPath, fragment) {
519
+ function applyMergeClassName(openingPath, fragment, classNameMode, activeBreakpoint) {
238
520
  validateTailwindFragment(fragment);
239
521
  const opening = openingPath.node;
240
- let clsAttr;
241
- for (const attr of opening.attributes) {
242
- if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: "className" })) {
243
- clsAttr = attr;
244
- break;
245
- }
246
- }
247
- if (!clsAttr) {
248
- opening.attributes.push(
249
- t.jsxAttribute(t.jsxIdentifier("className"), t.stringLiteral(fragment.trim()))
522
+ const binding = getClassNameBinding(opening, classNameMode);
523
+ if (!binding) {
524
+ throw new Error(
525
+ classNameMode === "cn-basic" ? "className must be a string literal or simple cn()/clsx() string list in cn-basic mode" : "className must be a string literal for Phase 2 patches"
250
526
  );
251
- return;
252
527
  }
253
- if (!t.isStringLiteral(clsAttr.value)) {
254
- throw new Error("className must be a string literal for Phase 2 patches");
528
+ const current = binding.read();
529
+ binding.write(mergeAtBreakpoint(current, fragment.trim(), activeBreakpoint));
530
+ }
531
+ function applyRemoveClassName(openingPath, fragment, classNameMode, activeBreakpoint) {
532
+ validateTailwindFragment(fragment);
533
+ const opening = openingPath.node;
534
+ const binding = getClassNameBinding(opening, classNameMode);
535
+ if (!binding) {
536
+ throw new Error(
537
+ classNameMode === "cn-basic" ? "className must be a string literal or simple cn()/clsx() string list in cn-basic mode" : "className must be a string literal for Phase 2 patches"
538
+ );
255
539
  }
256
- const current = clsAttr.value.value;
257
- clsAttr.value = t.stringLiteral(twMerge(current, fragment.trim()));
540
+ const current = binding.read();
541
+ binding.write(removeAtBreakpoint(current, fragment.trim(), activeBreakpoint));
258
542
  }
259
- async function applyPatchToSource(source, filePath, hostId, ops) {
543
+ async function applyPatchToSource(source, filePath, hostId, ops, options) {
544
+ const classNameMode = options?.classNameMode ?? "literal-only";
545
+ const activeBreakpoint = options?.activeBreakpoint ?? "base";
260
546
  let ast;
261
547
  try {
262
- ast = parse(source, {
548
+ ast = parse2(source, {
263
549
  sourceType: "module",
264
550
  plugins: ["typescript", "jsx"],
265
551
  sourceFilename: filePath
@@ -267,22 +553,52 @@ async function applyPatchToSource(source, filePath, hostId, ops) {
267
553
  } catch (e) {
268
554
  return { ok: false, code: "parse_error", message: String(e) };
269
555
  }
270
- const openingPath = findHostOpening(ast, hostId);
271
- if (!openingPath) {
556
+ const tableDataOps = ops.filter((o) => o.kind === "setTableDataField");
557
+ const hostOps = ops.filter((o) => o.kind !== "setTableDataField");
558
+ const openingPath = hostOps.length > 0 ? findHostOpening(ast, hostId) : tableDataOps.length > 0 ? null : findHostOpening(ast, hostId);
559
+ if (hostOps.length > 0 && !openingPath) {
560
+ return { ok: false, code: "host_not_found", message: `No JSX host with data-nuvio-id="${hostId}"` };
561
+ }
562
+ if (ops.length === 0) {
563
+ return { ok: false, code: "patch_rejected", message: "No patch operations" };
564
+ }
565
+ if (hostOps.length === 0 && tableDataOps.length === 0) {
272
566
  return { ok: false, code: "host_not_found", message: `No JSX host with data-nuvio-id="${hostId}"` };
273
567
  }
274
568
  let duplicateNewId;
275
569
  try {
276
570
  for (const op of ops) {
277
- if (op.kind === "setText") {
571
+ if (op.kind === "setTableDataField") {
572
+ applySetTableDataField(ast, op.arrayName, op.rowKey, op.field, op.value);
573
+ } else if (op.kind === "setText") {
574
+ if (!openingPath) {
575
+ throw new Error("setText requires a JSX host");
576
+ }
278
577
  applySetText(openingPath, op.text);
279
578
  } else if (op.kind === "mergeTailwindClassName") {
280
- applyMergeClassName(openingPath, op.classNameFragment);
579
+ if (!openingPath) {
580
+ throw new Error("mergeTailwindClassName requires a JSX host");
581
+ }
582
+ applyMergeClassName(openingPath, op.classNameFragment, classNameMode, activeBreakpoint);
583
+ } else if (op.kind === "removeTailwindClassName") {
584
+ if (!openingPath) {
585
+ throw new Error("removeTailwindClassName requires a JSX host");
586
+ }
587
+ applyRemoveClassName(openingPath, op.classNameFragment, classNameMode, activeBreakpoint);
281
588
  } else if (op.kind === "moveSibling") {
589
+ if (!openingPath) {
590
+ throw new Error("moveSibling requires a JSX host");
591
+ }
282
592
  applyMoveSibling(openingPath, op.direction);
283
593
  } else if (op.kind === "setHidden") {
284
- applySetHidden(openingPath, op.hidden);
594
+ if (!openingPath) {
595
+ throw new Error("setHidden requires a JSX host");
596
+ }
597
+ applySetHidden(openingPath, op.hidden, classNameMode, activeBreakpoint);
285
598
  } else if (op.kind === "duplicateHost") {
599
+ if (!openingPath) {
600
+ throw new Error("duplicateHost requires a JSX host");
601
+ }
286
602
  duplicateNewId = applyDuplicateHost(ast, openingPath, hostId);
287
603
  }
288
604
  }
@@ -311,12 +627,16 @@ async function applyPatchToSource(source, filePath, hostId, ops) {
311
627
  return `set text (${op.text.length} char${op.text.length === 1 ? "" : "s"})`;
312
628
  case "mergeTailwindClassName":
313
629
  return `merge className (${op.classNameFragment.trim()})`;
630
+ case "removeTailwindClassName":
631
+ return `remove className (${op.classNameFragment.trim()})`;
314
632
  case "moveSibling":
315
633
  return `move sibling ${op.direction}`;
316
634
  case "setHidden":
317
635
  return op.hidden ? "hide element" : "show element";
318
636
  case "duplicateHost":
319
637
  return duplicateNewId ? `duplicate host \u2192 ${duplicateNewId}` : "duplicate host";
638
+ case "setTableDataField":
639
+ return `update ${op.arrayName}[${op.rowKey}].${op.field}`;
320
640
  }
321
641
  });
322
642
  const diffSummary = `${base}: ${opBits.join("; ")}`;
@@ -324,5 +644,8 @@ async function applyPatchToSource(source, filePath, hostId, ops) {
324
644
  }
325
645
  export {
326
646
  applyPatchToSource,
647
+ mergeAtBreakpoint,
648
+ parseClassNameByBreakpoint,
649
+ removeAtBreakpoint,
327
650
  validateTailwindFragment
328
651
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuvio/ast-engine",
3
- "version": "0.1.0",
3
+ "version": "0.5.1",
4
4
  "description": "Nuvio AST patch engine: parse TSX/JSX, apply whitelist Tailwind merges and text edits, format with Prettier.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,7 +41,7 @@
41
41
  "@babel/types": "^7.26.9",
42
42
  "prettier": "^3.5.1",
43
43
  "tailwind-merge": "^2.6.0",
44
- "@nuvio/shared": "0.1.0"
44
+ "@nuvio/shared": "0.5.1"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/babel__traverse": "^7.20.7",