@runner-ai/vite-uuid-tagger 0.0.6 → 0.0.8
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 +5 -3
- package/dist/index.js +211 -250
- package/dist/index.js.map +1 -0
- package/package.json +27 -18
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { Plugin } from
|
|
1
|
+
import { Plugin } from "vite";
|
|
2
2
|
|
|
3
|
+
//#region src/plugin.d.ts
|
|
3
4
|
interface UuidTaggerOptions {
|
|
4
|
-
|
|
5
|
+
projectRoot: string;
|
|
5
6
|
}
|
|
6
7
|
declare function uuidTagger(options: UuidTaggerOptions): Plugin;
|
|
7
|
-
|
|
8
|
+
//#endregion
|
|
8
9
|
export { type UuidTaggerOptions, uuidTagger };
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -1,268 +1,229 @@
|
|
|
1
|
-
|
|
2
|
-
import path2 from "path";
|
|
1
|
+
import path, { dirname, join } from "node:path";
|
|
3
2
|
import { parse } from "@babel/parser";
|
|
4
3
|
import MagicString from "magic-string";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
//#region src/ast-helpers.ts
|
|
7
|
+
/**
|
|
8
|
+
* Elements that cannot have attributes added to them:
|
|
9
|
+
* - Fragment & React.Fragment: virtual containers that don't render
|
|
10
|
+
* as actual DOM elements, so they cannot have data attributes
|
|
11
|
+
*/
|
|
12
|
+
const NON_TAGGABLE_ELEMENTS = ["Fragment", "React.Fragment"];
|
|
13
|
+
/**
|
|
14
|
+
* File extensions that should be processed by this plugin
|
|
15
|
+
*/
|
|
16
|
+
const VALID_EXTENSIONS = new Set([
|
|
17
|
+
".jsx",
|
|
18
|
+
".tsx",
|
|
19
|
+
".js",
|
|
20
|
+
".ts"
|
|
21
|
+
]);
|
|
22
|
+
/**
|
|
23
|
+
* Determines if a file should be skipped by the plugin.
|
|
24
|
+
* Skips node_modules and files without valid JSX/TS extensions.
|
|
25
|
+
*/
|
|
10
26
|
function shouldSkipFile(filename) {
|
|
11
|
-
|
|
27
|
+
return filename.includes("node_modules") || !VALID_EXTENSIONS.has(path.extname(filename));
|
|
12
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Extracts element name from a JSX opening element node.
|
|
31
|
+
* Returns 'div' for `<div>`, 'motion.div' for `<motion.div>`, etc.
|
|
32
|
+
*/
|
|
13
33
|
function getElementName(node) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (node.name.type === "JSXMemberExpression") {
|
|
18
|
-
return `${node.name.object.name}.${node.name.property.name}`;
|
|
19
|
-
}
|
|
20
|
-
return null;
|
|
34
|
+
if (node.name.type === "JSXIdentifier") return node.name.name;
|
|
35
|
+
if (node.name.type === "JSXMemberExpression") return `${node.name.object.name}.${node.name.property.name}`;
|
|
36
|
+
return null;
|
|
21
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Determines if a JSXElement node should be tagged with a UUID.
|
|
40
|
+
* Skips non-JSXElement nodes and Fragment elements.
|
|
41
|
+
*/
|
|
22
42
|
function shouldTagElement(node) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
const elementName = getElementName(openingElement);
|
|
31
|
-
if (!elementName || NON_TAGGABLE_ELEMENTS.includes(elementName)) {
|
|
32
|
-
return false;
|
|
33
|
-
}
|
|
34
|
-
return true;
|
|
43
|
+
if (node.type !== "JSXElement") return false;
|
|
44
|
+
const openingElement = node.openingElement;
|
|
45
|
+
if (!openingElement) return false;
|
|
46
|
+
const elementName = getElementName(openingElement);
|
|
47
|
+
if (!elementName || NON_TAGGABLE_ELEMENTS.includes(elementName)) return false;
|
|
48
|
+
return true;
|
|
35
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Determines if an expression is static (contains no dynamic references).
|
|
52
|
+
* Static means: literals, static objects, static arrays, empty expressions (comments).
|
|
53
|
+
*/
|
|
36
54
|
function isStaticExpression(expression) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
if (expression.type === "ArrayExpression") {
|
|
62
|
-
const arr = expression;
|
|
63
|
-
return arr.elements.every((elem) => {
|
|
64
|
-
if (elem === null) {
|
|
65
|
-
return true;
|
|
66
|
-
}
|
|
67
|
-
if (elem.type === "SpreadElement") {
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
return isStaticExpression(elem);
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
return false;
|
|
55
|
+
if (!expression) return false;
|
|
56
|
+
if (expression.type === "JSXEmptyExpression") return true;
|
|
57
|
+
if ([
|
|
58
|
+
"NumericLiteral",
|
|
59
|
+
"BooleanLiteral",
|
|
60
|
+
"StringLiteral",
|
|
61
|
+
"NullLiteral"
|
|
62
|
+
].includes(expression.type)) return true;
|
|
63
|
+
if (expression.type === "ObjectExpression") return expression.properties.every((prop) => {
|
|
64
|
+
if (prop.type === "SpreadElement") return false;
|
|
65
|
+
if (prop.type === "ObjectProperty") {
|
|
66
|
+
if (prop.computed) return false;
|
|
67
|
+
return isStaticExpression(prop.value);
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
});
|
|
71
|
+
if (expression.type === "ArrayExpression") return expression.elements.every((elem) => {
|
|
72
|
+
if (elem === null) return true;
|
|
73
|
+
if (elem.type === "SpreadElement") return false;
|
|
74
|
+
return isStaticExpression(elem);
|
|
75
|
+
});
|
|
76
|
+
return false;
|
|
74
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Determines if an element can use contenteditable.
|
|
80
|
+
* True when the element has only static text children (no nested elements).
|
|
81
|
+
*/
|
|
75
82
|
function supportsContentEditable(jsxElement) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
});
|
|
88
|
-
if (!hasValidChildren) {
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
return jsxElement.children.some((child) => {
|
|
92
|
-
if (child.type === "JSXText") {
|
|
93
|
-
return child.value.trim().length > 0;
|
|
94
|
-
}
|
|
95
|
-
if (child.type === "JSXExpressionContainer") {
|
|
96
|
-
return child.expression.type !== "JSXEmptyExpression";
|
|
97
|
-
}
|
|
98
|
-
return false;
|
|
99
|
-
});
|
|
83
|
+
if (!jsxElement.children || jsxElement.children.length === 0) return false;
|
|
84
|
+
if (!jsxElement.children.every((child) => {
|
|
85
|
+
if (child.type === "JSXText") return true;
|
|
86
|
+
if (child.type === "JSXExpressionContainer") return isStaticExpression(child.expression);
|
|
87
|
+
return false;
|
|
88
|
+
})) return false;
|
|
89
|
+
return jsxElement.children.some((child) => {
|
|
90
|
+
if (child.type === "JSXText") return child.value.trim().length > 0;
|
|
91
|
+
if (child.type === "JSXExpressionContainer") return child.expression.type !== "JSXEmptyExpression";
|
|
92
|
+
return false;
|
|
93
|
+
});
|
|
100
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Determines if an element's content/attributes can be edited inline.
|
|
97
|
+
* True when the element has static text content or static string attributes.
|
|
98
|
+
*/
|
|
101
99
|
function isInlineEditable(jsxElement) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
if (jsxElement.openingElement.attributes && jsxElement.openingElement.attributes.length > 0) {
|
|
124
|
-
const hasStaticAttributes = jsxElement.openingElement.attributes.some((attr) => {
|
|
125
|
-
if (attr.type === "JSXSpreadAttribute") {
|
|
126
|
-
return false;
|
|
127
|
-
}
|
|
128
|
-
if (attr.value && attr.value.type === "JSXExpressionContainer") {
|
|
129
|
-
return isStaticExpression(attr.value.expression);
|
|
130
|
-
}
|
|
131
|
-
if (attr.value && attr.value.type === "StringLiteral") {
|
|
132
|
-
return true;
|
|
133
|
-
}
|
|
134
|
-
return false;
|
|
135
|
-
});
|
|
136
|
-
if (hasStaticAttributes) {
|
|
137
|
-
return true;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
return false;
|
|
100
|
+
const hasStaticChildren = jsxElement.children && jsxElement.children.length > 0 && jsxElement.children.every((child) => {
|
|
101
|
+
if (child.type === "JSXText") return true;
|
|
102
|
+
if (child.type === "JSXExpressionContainer") return isStaticExpression(child.expression);
|
|
103
|
+
return false;
|
|
104
|
+
});
|
|
105
|
+
const hasEditableContent = jsxElement.children?.some((child) => {
|
|
106
|
+
if (child.type === "JSXText") return child.value.trim().length > 0;
|
|
107
|
+
if (child.type === "JSXExpressionContainer") return child.expression.type !== "JSXEmptyExpression";
|
|
108
|
+
return false;
|
|
109
|
+
});
|
|
110
|
+
if (hasStaticChildren && hasEditableContent) return true;
|
|
111
|
+
if (jsxElement.openingElement.attributes && jsxElement.openingElement.attributes.length > 0) {
|
|
112
|
+
if (jsxElement.openingElement.attributes.some((attr) => {
|
|
113
|
+
if (attr.type === "JSXSpreadAttribute") return false;
|
|
114
|
+
if (attr.value && attr.value.type === "JSXExpressionContainer") return isStaticExpression(attr.value.expression);
|
|
115
|
+
if (attr.value && attr.value.type === "StringLiteral") return true;
|
|
116
|
+
return false;
|
|
117
|
+
})) return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
141
120
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
import { createHash } from "crypto";
|
|
145
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
146
|
-
import { dirname, join } from "path";
|
|
121
|
+
//#endregion
|
|
122
|
+
//#region src/uuid-mapping-manager.ts
|
|
147
123
|
var UUIDMappingManager = class {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
124
|
+
mappingFile;
|
|
125
|
+
mappings;
|
|
126
|
+
constructor(projectRoot) {
|
|
127
|
+
this.mappingFile = join(projectRoot, "public", "uuid-mappings.json");
|
|
128
|
+
this.mappings = this.loadMappings();
|
|
129
|
+
}
|
|
130
|
+
loadMappings() {
|
|
131
|
+
try {
|
|
132
|
+
if (existsSync(this.mappingFile)) {
|
|
133
|
+
const content = readFileSync(this.mappingFile, "utf8");
|
|
134
|
+
return JSON.parse(content);
|
|
135
|
+
}
|
|
136
|
+
} catch {}
|
|
137
|
+
return {};
|
|
138
|
+
}
|
|
139
|
+
saveMappings() {
|
|
140
|
+
const tmpFile = `${this.mappingFile}.${process.pid}.tmp`;
|
|
141
|
+
try {
|
|
142
|
+
const dir = dirname(this.mappingFile);
|
|
143
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
144
|
+
writeFileSync(tmpFile, JSON.stringify(this.mappings, null, 2));
|
|
145
|
+
renameSync(tmpFile, this.mappingFile);
|
|
146
|
+
} catch {
|
|
147
|
+
try {
|
|
148
|
+
if (existsSync(tmpFile)) unlinkSync(tmpFile);
|
|
149
|
+
} catch {}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
generateStableUUID(seed) {
|
|
153
|
+
const hash = createHash("sha256").update(seed).digest("hex");
|
|
154
|
+
return [
|
|
155
|
+
hash.slice(0, 8),
|
|
156
|
+
hash.slice(8, 12),
|
|
157
|
+
hash.slice(12, 16),
|
|
158
|
+
hash.slice(16, 20),
|
|
159
|
+
hash.slice(20, 32)
|
|
160
|
+
].join("-");
|
|
161
|
+
}
|
|
162
|
+
getOrCreateUUID(filePath, line, column, elementName) {
|
|
163
|
+
const existingUUID = Object.keys(this.mappings).find((uuid) => {
|
|
164
|
+
const m = this.mappings[uuid];
|
|
165
|
+
return m.filePath === filePath && m.line === line && m.column === column && m.elementName === elementName;
|
|
166
|
+
});
|
|
167
|
+
if (existingUUID) return existingUUID;
|
|
168
|
+
const seed = `${filePath}:${line}:${column}:${elementName}`;
|
|
169
|
+
const uuid = this.generateStableUUID(seed);
|
|
170
|
+
this.mappings[uuid] = {
|
|
171
|
+
filePath,
|
|
172
|
+
line,
|
|
173
|
+
column,
|
|
174
|
+
elementName,
|
|
175
|
+
lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
|
|
176
|
+
};
|
|
177
|
+
this.saveMappings();
|
|
178
|
+
return uuid;
|
|
179
|
+
}
|
|
204
180
|
};
|
|
205
|
-
|
|
206
|
-
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region src/plugin.ts
|
|
207
183
|
function uuidTagger(options) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
changed++;
|
|
251
|
-
}
|
|
252
|
-
});
|
|
253
|
-
if (changed > 0) {
|
|
254
|
-
return {
|
|
255
|
-
code: magic.toString(),
|
|
256
|
-
map: magic.generateMap({ hires: true })
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
return null;
|
|
260
|
-
} catch {
|
|
261
|
-
return null;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
};
|
|
184
|
+
const { projectRoot } = options;
|
|
185
|
+
const uuidManager = new UUIDMappingManager(projectRoot);
|
|
186
|
+
return {
|
|
187
|
+
name: "vite-uuid-tagger",
|
|
188
|
+
enforce: "pre",
|
|
189
|
+
async transform(code, id) {
|
|
190
|
+
if (shouldSkipFile(id)) return null;
|
|
191
|
+
const ext = path.extname(id);
|
|
192
|
+
if (ext !== ".tsx" && ext !== ".jsx") return null;
|
|
193
|
+
const relativePath = path.relative(projectRoot, id);
|
|
194
|
+
const { walk } = await import("estree-walker");
|
|
195
|
+
try {
|
|
196
|
+
const ast = parse(code, {
|
|
197
|
+
sourceType: "module",
|
|
198
|
+
plugins: ["jsx", "typescript"]
|
|
199
|
+
});
|
|
200
|
+
const magic = new MagicString(code);
|
|
201
|
+
let changed = 0;
|
|
202
|
+
walk(ast, { enter(node) {
|
|
203
|
+
const n = node;
|
|
204
|
+
if (!shouldTagElement(n)) return;
|
|
205
|
+
const openingElement = n.openingElement;
|
|
206
|
+
const elementName = getElementName(openingElement);
|
|
207
|
+
if (!elementName) return;
|
|
208
|
+
const { line = 0, column = 0 } = openingElement.loc?.start ?? {};
|
|
209
|
+
const uuid = uuidManager.getOrCreateUUID(relativePath, line, column, elementName);
|
|
210
|
+
magic.appendLeft(openingElement.name.end ?? 0, ` data-uuid-loc="${uuid}"`);
|
|
211
|
+
if (supportsContentEditable(n)) magic.appendLeft(openingElement.name.end ?? 0, " data-content-editable");
|
|
212
|
+
if (isInlineEditable(n)) magic.appendLeft(openingElement.name.end ?? 0, " data-inline-editable");
|
|
213
|
+
changed++;
|
|
214
|
+
} });
|
|
215
|
+
if (changed > 0) return {
|
|
216
|
+
code: magic.toString(),
|
|
217
|
+
map: magic.generateMap({ hires: true })
|
|
218
|
+
};
|
|
219
|
+
return null;
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
265
225
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
226
|
+
//#endregion
|
|
227
|
+
export { uuidTagger };
|
|
228
|
+
|
|
229
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/ast-helpers.ts","../src/uuid-mapping-manager.ts","../src/plugin.ts"],"sourcesContent":["import path from 'node:path';\n\n/**\n * Elements that cannot have attributes added to them:\n * - Fragment & React.Fragment: virtual containers that don't render\n * as actual DOM elements, so they cannot have data attributes\n */\nconst NON_TAGGABLE_ELEMENTS = ['Fragment', 'React.Fragment'];\n\n/**\n * File extensions that should be processed by this plugin\n */\nconst VALID_EXTENSIONS = new Set(['.jsx', '.tsx', '.js', '.ts']);\n\n// --- Babel AST node interfaces ---\n\ninterface JSXIdentifier {\n type: 'JSXIdentifier';\n name: string;\n}\n\ninterface JSXMemberExpression {\n type: 'JSXMemberExpression';\n object: JSXIdentifier;\n property: JSXIdentifier;\n}\n\ninterface JSXOpeningElement {\n type: 'JSXOpeningElement';\n name: JSXIdentifier | JSXMemberExpression;\n attributes: Array<JSXAttribute | JSXSpreadAttribute>;\n}\n\ninterface JSXAttribute {\n type: 'JSXAttribute';\n name: JSXIdentifier;\n value: StringLiteral | JSXExpressionContainer | null;\n}\n\ninterface JSXSpreadAttribute {\n type: 'JSXSpreadAttribute';\n argument: ASTExpression;\n}\n\ninterface JSXText {\n type: 'JSXText';\n value: string;\n}\n\ninterface JSXExpressionContainer {\n type: 'JSXExpressionContainer';\n expression: ASTExpression;\n}\n\ninterface JSXElement {\n type: 'JSXElement';\n openingElement: JSXOpeningElement;\n children: Array<JSXText | JSXExpressionContainer | JSXElement>;\n}\n\ninterface JSXEmptyExpression {\n type: 'JSXEmptyExpression';\n}\n\ninterface NumericLiteral {\n type: 'NumericLiteral';\n}\n\ninterface BooleanLiteral {\n type: 'BooleanLiteral';\n}\n\ninterface StringLiteral {\n type: 'StringLiteral';\n}\n\ninterface NullLiteral {\n type: 'NullLiteral';\n}\n\ninterface ObjectProperty {\n type: 'ObjectProperty';\n computed: boolean;\n value: ASTExpression;\n}\n\ninterface SpreadElement {\n type: 'SpreadElement';\n}\n\ninterface ObjectExpression {\n type: 'ObjectExpression';\n properties: Array<ObjectProperty | SpreadElement>;\n}\n\ninterface ArrayExpression {\n type: 'ArrayExpression';\n elements: Array<ASTExpression | SpreadElement | null>;\n}\n\ntype ASTExpression =\n | JSXEmptyExpression\n | NumericLiteral\n | BooleanLiteral\n | StringLiteral\n | NullLiteral\n | ObjectExpression\n | ArrayExpression\n | SpreadElement\n | { type: string };\n\n// --- Helper functions ---\n\n/**\n * Determines if a file should be skipped by the plugin.\n * Skips node_modules and files without valid JSX/TS extensions.\n */\nexport function shouldSkipFile(filename: string): boolean {\n return filename.includes('node_modules') || !VALID_EXTENSIONS.has(path.extname(filename));\n}\n\n/**\n * Extracts element name from a JSX opening element node.\n * Returns 'div' for `<div>`, 'motion.div' for `<motion.div>`, etc.\n */\nexport function getElementName(node: JSXOpeningElement): string | null {\n // Handle simple JSX elements: <div>, <MyComponent>, etc.\n if (node.name.type === 'JSXIdentifier') {\n return node.name.name;\n }\n\n // Handle member expressions: <Foo.Bar>, <motion.div>, etc.\n if (node.name.type === 'JSXMemberExpression') {\n return `${node.name.object.name}.${node.name.property.name}`;\n }\n\n // Unknown JSX element type - skip it\n return null;\n}\n\n/**\n * Determines if a JSXElement node should be tagged with a UUID.\n * Skips non-JSXElement nodes and Fragment elements.\n */\nexport function shouldTagElement(node: { type: string; openingElement?: JSXOpeningElement }): boolean {\n if (node.type !== 'JSXElement') {\n return false;\n }\n\n const openingElement = node.openingElement;\n if (!openingElement) {\n return false;\n }\n\n const elementName = getElementName(openingElement);\n if (!elementName || NON_TAGGABLE_ELEMENTS.includes(elementName)) {\n return false;\n }\n\n return true;\n}\n\n/**\n * Determines if an expression is static (contains no dynamic references).\n * Static means: literals, static objects, static arrays, empty expressions (comments).\n */\nexport function isStaticExpression(expression: ASTExpression | null | undefined): boolean {\n if (!expression) {\n return false;\n }\n\n // Comments compile to JSXEmptyExpression\n if (expression.type === 'JSXEmptyExpression') {\n return true;\n }\n\n // Static literals\n if (['NumericLiteral', 'BooleanLiteral', 'StringLiteral', 'NullLiteral'].includes(expression.type)) {\n return true;\n }\n\n // Object literals: check all properties are static\n if (expression.type === 'ObjectExpression') {\n const obj = expression as ObjectExpression;\n return obj.properties.every((prop) => {\n if (prop.type === 'SpreadElement') {\n return false;\n }\n if (prop.type === 'ObjectProperty') {\n if (prop.computed) {\n return false;\n }\n return isStaticExpression(prop.value);\n }\n return false;\n });\n }\n\n // Array literals: check all elements are static\n if (expression.type === 'ArrayExpression') {\n const arr = expression as ArrayExpression;\n return arr.elements.every((elem) => {\n if (elem === null) {\n return true; // Holes in arrays\n }\n if (elem.type === 'SpreadElement') {\n return false;\n }\n return isStaticExpression(elem);\n });\n }\n\n // Everything else is dynamic (Identifier, CallExpression, MemberExpression, etc.)\n return false;\n}\n\n/**\n * Determines if an element can use contenteditable.\n * True when the element has only static text children (no nested elements).\n */\nexport function supportsContentEditable(jsxElement: JSXElement): boolean {\n if (!jsxElement.children || jsxElement.children.length === 0) {\n return false;\n }\n\n const hasValidChildren = jsxElement.children.every((child) => {\n if (child.type === 'JSXText') {\n return true;\n }\n if (child.type === 'JSXExpressionContainer') {\n return isStaticExpression((child as JSXExpressionContainer).expression);\n }\n return false;\n });\n\n if (!hasValidChildren) {\n return false;\n }\n\n // At least one child must have non-whitespace content\n return jsxElement.children.some((child) => {\n if (child.type === 'JSXText') {\n return (child as JSXText).value.trim().length > 0;\n }\n if (child.type === 'JSXExpressionContainer') {\n return (child as JSXExpressionContainer).expression.type !== 'JSXEmptyExpression';\n }\n return false;\n });\n}\n\n/**\n * Determines if an element's content/attributes can be edited inline.\n * True when the element has static text content or static string attributes.\n */\nexport function isInlineEditable(jsxElement: JSXElement): boolean {\n // Check children for static expressions\n const hasStaticChildren =\n jsxElement.children &&\n jsxElement.children.length > 0 &&\n jsxElement.children.every((child) => {\n if (child.type === 'JSXText') {\n return true;\n }\n if (child.type === 'JSXExpressionContainer') {\n return isStaticExpression((child as JSXExpressionContainer).expression);\n }\n return false;\n });\n\n // Check if there's actual editable content (not just comments)\n const hasEditableContent = jsxElement.children?.some((child) => {\n if (child.type === 'JSXText') {\n return (child as JSXText).value.trim().length > 0;\n }\n if (child.type === 'JSXExpressionContainer') {\n return (child as JSXExpressionContainer).expression.type !== 'JSXEmptyExpression';\n }\n return false;\n });\n\n if (hasStaticChildren && hasEditableContent) {\n return true;\n }\n\n // Check attributes for static expressions\n if (jsxElement.openingElement.attributes && jsxElement.openingElement.attributes.length > 0) {\n const hasStaticAttributes = jsxElement.openingElement.attributes.some((attr) => {\n if (attr.type === 'JSXSpreadAttribute') {\n return false;\n }\n\n if (attr.value && attr.value.type === 'JSXExpressionContainer') {\n return isStaticExpression((attr.value as JSXExpressionContainer).expression);\n }\n\n if (attr.value && attr.value.type === 'StringLiteral') {\n return true;\n }\n\n return false;\n });\n\n if (hasStaticAttributes) {\n return true;\n }\n }\n\n return false;\n}\n","import { createHash } from 'node:crypto';\nimport { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\n\nexport interface UUIDMapping {\n column: number;\n elementName: string;\n filePath: string;\n lastUpdated?: string;\n line: number;\n}\n\nexport class UUIDMappingManager {\n private readonly mappingFile: string;\n private readonly mappings: Record<string, UUIDMapping>;\n\n constructor(projectRoot: string) {\n this.mappingFile = join(projectRoot, 'public', 'uuid-mappings.json');\n this.mappings = this.loadMappings();\n }\n\n private loadMappings(): Record<string, UUIDMapping> {\n try {\n if (existsSync(this.mappingFile)) {\n const content = readFileSync(this.mappingFile, 'utf8');\n return JSON.parse(content);\n }\n } catch {\n // Start fresh on error\n }\n return {};\n }\n\n saveMappings(): void {\n // Per-process temp path next to the destination (same filesystem) so the\n // rename below is atomic.\n const tmpFile = `${this.mappingFile}.${process.pid}.tmp`;\n try {\n const dir = dirname(this.mappingFile);\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true });\n }\n // Write to a temp file then atomically rename it into place. A direct\n // writeFileSync truncates the destination first, so a reader that loads\n // the file mid-write sees partial/invalid JSON (RUN-2217); the rename\n // guarantees a reader only ever sees a complete file.\n writeFileSync(tmpFile, JSON.stringify(this.mappings, null, 2));\n renameSync(tmpFile, this.mappingFile);\n } catch {\n // Silent fail — non-critical. Best-effort cleanup so a failed write\n // can't leave a stray partial temp file behind.\n try {\n if (existsSync(tmpFile)) {\n unlinkSync(tmpFile);\n }\n } catch {\n // ignore cleanup failure\n }\n }\n }\n\n private generateStableUUID(seed: string): string {\n const hash = createHash('sha256').update(seed).digest('hex');\n return [hash.slice(0, 8), hash.slice(8, 12), hash.slice(12, 16), hash.slice(16, 20), hash.slice(20, 32)].join('-');\n }\n\n getOrCreateUUID(filePath: string, line: number, column: number, elementName: string): string {\n const existingUUID = Object.keys(this.mappings).find((uuid) => {\n const m = this.mappings[uuid];\n return m.filePath === filePath && m.line === line && m.column === column && m.elementName === elementName;\n });\n\n if (existingUUID) {\n return existingUUID;\n }\n\n const seed = `${filePath}:${line}:${column}:${elementName}`;\n const uuid = this.generateStableUUID(seed);\n\n this.mappings[uuid] = {\n filePath,\n line,\n column,\n elementName,\n lastUpdated: new Date().toISOString(),\n };\n\n this.saveMappings();\n return uuid;\n }\n}\n","import path from 'node:path';\nimport { parse } from '@babel/parser';\nimport MagicString from 'magic-string';\nimport type { Plugin } from 'vite';\nimport {\n getElementName,\n isInlineEditable,\n shouldSkipFile,\n shouldTagElement,\n supportsContentEditable,\n} from './ast-helpers';\nimport { UUIDMappingManager } from './uuid-mapping-manager';\n\nexport interface UuidTaggerOptions {\n projectRoot: string;\n}\n\nexport function uuidTagger(options: UuidTaggerOptions): Plugin {\n const { projectRoot } = options;\n const uuidManager = new UUIDMappingManager(projectRoot);\n\n return {\n name: 'vite-uuid-tagger',\n enforce: 'pre',\n\n async transform(code: string, id: string) {\n if (shouldSkipFile(id)) {\n return null;\n }\n\n const ext = path.extname(id);\n if (ext !== '.tsx' && ext !== '.jsx') {\n return null;\n }\n\n const relativePath = path.relative(projectRoot, id);\n const { walk } = await import('estree-walker');\n\n try {\n const ast = parse(code, {\n sourceType: 'module',\n plugins: ['jsx', 'typescript'],\n });\n\n const magic = new MagicString(code);\n let changed = 0;\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Babel AST is not estree-compatible\n walk(ast as any, {\n enter(node) {\n // Babel AST nodes are not estree-compatible, so we cast through unknown\n const n = node as unknown as Record<string, unknown>;\n if (!shouldTagElement(n as never)) {\n return;\n }\n\n const openingElement = n.openingElement as {\n loc?: { start?: { line: number; column: number } };\n name: { end?: number };\n };\n const elementName = getElementName(openingElement as never);\n if (!elementName) {\n return;\n }\n\n const { line = 0, column = 0 } = openingElement.loc?.start ?? {};\n const uuid = uuidManager.getOrCreateUUID(relativePath, line, column, elementName);\n\n magic.appendLeft(openingElement.name.end ?? 0, ` data-uuid-loc=\"${uuid}\"`);\n\n if (supportsContentEditable(n as never)) {\n magic.appendLeft(openingElement.name.end ?? 0, ' data-content-editable');\n }\n\n if (isInlineEditable(n as never)) {\n magic.appendLeft(openingElement.name.end ?? 0, ' data-inline-editable');\n }\n\n changed++;\n },\n });\n\n if (changed > 0) {\n return {\n code: magic.toString(),\n map: magic.generateMap({ hires: true }),\n };\n }\n return null;\n } catch {\n return null;\n }\n },\n };\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runner-ai/vite-uuid-tagger",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
|
+
"dependencies": {
|
|
5
|
+
"@babel/parser": "7.29.2",
|
|
6
|
+
"estree-walker": "^3.0.3",
|
|
7
|
+
"magic-string": "^0.30.0"
|
|
8
|
+
},
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"@repo/test-utils": "workspace:*",
|
|
11
|
+
"@types/bun": "1.3.14",
|
|
12
|
+
"tsdown": "^0.21.0",
|
|
13
|
+
"typescript": "7.0.1-rc",
|
|
14
|
+
"vite": "^8.1.0"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/adaptive-machines/runner-monorepo.git",
|
|
19
|
+
"directory": "packages/vite-uuid-tagger"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
4
24
|
"type": "module",
|
|
5
25
|
"main": "./dist/index.js",
|
|
6
26
|
"types": "./dist/index.d.ts",
|
|
@@ -10,26 +30,15 @@
|
|
|
10
30
|
"import": "./dist/index.js"
|
|
11
31
|
}
|
|
12
32
|
},
|
|
13
|
-
"files": [
|
|
14
|
-
"dist"
|
|
15
|
-
],
|
|
16
33
|
"scripts": {
|
|
17
|
-
"build": "
|
|
18
|
-
"test": "
|
|
19
|
-
"publish:prod": "bun run build && npm publish --access public"
|
|
20
|
-
|
|
21
|
-
"dependencies": {
|
|
22
|
-
"@babel/parser": "^7.24.0",
|
|
23
|
-
"estree-walker": "^3.0.3",
|
|
24
|
-
"magic-string": "^0.30.0"
|
|
34
|
+
"build": "tsdown",
|
|
35
|
+
"test": "bun test --isolate",
|
|
36
|
+
"publish:prod": "bun run build && npm publish --access public",
|
|
37
|
+
"test:coverage": "bun test --isolate --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir=coverage"
|
|
25
38
|
},
|
|
26
39
|
"peerDependencies": {
|
|
27
40
|
"vite": ">=4.0.0"
|
|
28
41
|
},
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
"vite": "^7.0.0",
|
|
32
|
-
"vitest": "^3.0.0",
|
|
33
|
-
"typescript": "^5.8.0"
|
|
34
|
-
}
|
|
42
|
+
"runnerSourceCommit": "f856159e8354c4e76dff19313f077b24fa408e55",
|
|
43
|
+
"runnerSourceDigest": "3290ad851d4a8cb768bf9b5ddaf58a97287d2f93cf96b85791c413cf9f3926c7"
|
|
35
44
|
}
|