@ahmednawaz/crank 0.1.6 → 0.1.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/.env.example +2 -1
- package/AGENTS.md +3 -2
- package/README.md +16 -14
- package/bookmarklet/crank-bookmarklet.js +3 -3
- package/bookmarklet/crank-ui-helpers.js +5 -5
- package/bookmarklet/crank-ui.css +1991 -0
- package/bookmarklet/text-source.js +1 -1
- package/companion/agent-queue.js +1 -1
- package/companion/asset-bridge.js +28 -0
- package/companion/package.json +3 -0
- package/companion/persist-apply.js +1 -1
- package/companion/server.js +13 -11
- package/companion/text-dispatch.js +3 -5
- package/companion/text-writer.js +580 -0
- package/mcp-server/tools.js +1 -1
- package/package.json +4 -1
- package/companion/vizpatch-bridge.js +0 -49
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const { load } = require('cheerio');
|
|
3
|
+
const babelParser = require('@babel/parser');
|
|
4
|
+
const babelTraverse = require('@babel/traverse').default;
|
|
5
|
+
|
|
6
|
+
function writeTextChange(filePath, selectorText, value, options = {}) {
|
|
7
|
+
if (!filePath || !selectorText) {
|
|
8
|
+
throw new Error('filePath and selectorText are required');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (isHtmlFile(filePath)) {
|
|
12
|
+
return writeHtmlTextChange(filePath, selectorText, value, options);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (isScriptSourceFile(filePath)) {
|
|
16
|
+
return writeScriptTextChange(filePath, selectorText, value, options);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
throw new Error('Text save is supported for HTML and JS/TS source files only');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeHtmlTextChange(filePath, selectorText, value, options = {}) {
|
|
23
|
+
|
|
24
|
+
const html = fs.readFileSync(filePath, 'utf8');
|
|
25
|
+
const $ = load(html, { decodeEntities: false });
|
|
26
|
+
const nodes = $(selectorText);
|
|
27
|
+
|
|
28
|
+
if (!nodes.length) {
|
|
29
|
+
const oldValue = String(options.oldValue || '').trim();
|
|
30
|
+
if (!oldValue) {
|
|
31
|
+
console.error('NO_SELECTOR_MATCH', { selectorText, reason: 'no oldValue' });
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log('SELECTOR_FAILED', { selectorText, trying: 'oldValue fallback', oldValue });
|
|
36
|
+
|
|
37
|
+
// Try exact match first
|
|
38
|
+
let replaced = replaceHtmlTextByOldValue($, oldValue, String(value || ''));
|
|
39
|
+
if (replaced) {
|
|
40
|
+
fs.writeFileSync(filePath, $.html(), 'utf8');
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Try fuzzy match for split/wrapped nodes
|
|
45
|
+
console.log('EXACT_MATCH_FAILED', { trying: 'fuzzy match with normalization' });
|
|
46
|
+
replaced = replaceHtmlTextFuzzy($, oldValue, String(value || ''));
|
|
47
|
+
if (replaced) {
|
|
48
|
+
fs.writeFileSync(filePath, $.html(), 'utf8');
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.error('NO_MATCHING_TEXT_NODE', { filePath, selectorText, oldValue });
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Update all matching nodes to keep behavior aligned with the active selector scope.
|
|
57
|
+
nodes.each(function () {
|
|
58
|
+
const preferredTextNode = findPreferredTextNode($(this).get(0));
|
|
59
|
+
if (preferredTextNode) {
|
|
60
|
+
preferredTextNode.data = String(value || '');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Fallback when no text node exists under the matched element.
|
|
65
|
+
$(this).text(String(value || ''));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
fs.writeFileSync(filePath, $.html(), 'utf8');
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function replaceHtmlTextByOldValue($, oldValue, newValue) {
|
|
73
|
+
const expected = String(oldValue || '').trim();
|
|
74
|
+
if (!expected) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let replaced = false;
|
|
79
|
+
|
|
80
|
+
$('*').each(function () {
|
|
81
|
+
if (replaced) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const parent = this;
|
|
86
|
+
const tag = String(parent && parent.name || '').toLowerCase();
|
|
87
|
+
if (tag === 'script' || tag === 'style') {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
$(parent).contents().each(function () {
|
|
92
|
+
if (replaced) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const node = this;
|
|
97
|
+
if (!node || node.type !== 'text') {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const raw = String(node.data || '');
|
|
102
|
+
const rawTrimmed = raw.trim();
|
|
103
|
+
if (!rawTrimmed || rawTrimmed !== expected) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const leading = raw.match(/^\s*/);
|
|
108
|
+
const trailing = raw.match(/\s*$/);
|
|
109
|
+
const lead = leading ? leading[0] : '';
|
|
110
|
+
const tail = trailing ? trailing[0] : '';
|
|
111
|
+
node.data = lead + String(newValue || '') + tail;
|
|
112
|
+
replaced = true;
|
|
113
|
+
return false;
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return replaced;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function replaceHtmlTextFuzzy($, oldValue, newValue) {
|
|
121
|
+
const expected = String(oldValue || '').trim().replace(/\s+/g, ' ');
|
|
122
|
+
if (!expected) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let replaced = false;
|
|
127
|
+
|
|
128
|
+
// Collect text nodes with normalized spacing
|
|
129
|
+
const textNodes = [];
|
|
130
|
+
$('*').each(function () {
|
|
131
|
+
const tag = String(this && this.name || '').toLowerCase();
|
|
132
|
+
if (tag === 'script' || tag === 'style') {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
$(this).contents().each(function () {
|
|
137
|
+
const node = this;
|
|
138
|
+
if (node && node.type === 'text') {
|
|
139
|
+
const raw = String(node.data || '');
|
|
140
|
+
const normalized = raw.trim().replace(/\s+/g, ' ');
|
|
141
|
+
if (normalized) {
|
|
142
|
+
textNodes.push({ node, raw, normalized });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Try to find and replace: check individual nodes, then check concatenated adjacent nodes
|
|
149
|
+
for (let i = 0; i < textNodes.length && !replaced; i++) {
|
|
150
|
+
// Single node match
|
|
151
|
+
if (textNodes[i].normalized === expected) {
|
|
152
|
+
const raw = textNodes[i].raw;
|
|
153
|
+
const leading = raw.match(/^\s*/);
|
|
154
|
+
const trailing = raw.match(/\s*$/);
|
|
155
|
+
const lead = leading ? leading[0] : '';
|
|
156
|
+
const tail = trailing ? trailing[0] : '';
|
|
157
|
+
textNodes[i].node.data = lead + newValue + tail;
|
|
158
|
+
replaced = true;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Try concatenating adjacent text nodes (for wrapped text)
|
|
163
|
+
let combined = textNodes[i].normalized;
|
|
164
|
+
let endIdx = i;
|
|
165
|
+
while (endIdx + 1 < textNodes.length && combined.length < expected.length * 2) {
|
|
166
|
+
combined += ' ' + textNodes[endIdx + 1].normalized;
|
|
167
|
+
endIdx++;
|
|
168
|
+
|
|
169
|
+
if (combined === expected) {
|
|
170
|
+
// Found match across multiple nodes - replace all with empty and put text in first
|
|
171
|
+
const firstNode = textNodes[i].node;
|
|
172
|
+
const firstRaw = textNodes[i].raw;
|
|
173
|
+
const leading = firstRaw.match(/^\s*/);
|
|
174
|
+
const trailing = textNodes[endIdx].raw.match(/\s*$/);
|
|
175
|
+
const lead = leading ? leading[0] : '';
|
|
176
|
+
const tail = trailing ? trailing[0] : '';
|
|
177
|
+
|
|
178
|
+
firstNode.data = lead + newValue + tail;
|
|
179
|
+
|
|
180
|
+
// Clear intermediate nodes
|
|
181
|
+
for (let j = i + 1; j <= endIdx; j++) {
|
|
182
|
+
textNodes[j].node.data = '';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
replaced = true;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return replaced;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
function writeScriptTextChange(filePath, selectorText, value, options) {
|
|
196
|
+
const oldValue = String(options && options.oldValue || '');
|
|
197
|
+
const newValue = String(value || '');
|
|
198
|
+
|
|
199
|
+
if (!oldValue) {
|
|
200
|
+
throw new Error('Text save for JS/TS source requires previous text value');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (oldValue === newValue) {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const source = fs.readFileSync(filePath, 'utf8');
|
|
208
|
+
let updated = source;
|
|
209
|
+
let totalReplacements = 0;
|
|
210
|
+
|
|
211
|
+
const selectorMatcher = buildTextScriptSelectorMatcher(selectorText);
|
|
212
|
+
if (selectorMatcher) {
|
|
213
|
+
const astResult = tryParseScriptAst(source);
|
|
214
|
+
if (astResult && astResult.ast) {
|
|
215
|
+
const replacement = findSingleJsxTextReplacement(astResult.ast, selectorMatcher, oldValue, newValue);
|
|
216
|
+
if (replacement) {
|
|
217
|
+
updated = source.slice(0, replacement.start) + replacement.text + source.slice(replacement.end);
|
|
218
|
+
totalReplacements = 1;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Fallback path for sources we couldn't safely parse/match by selector.
|
|
224
|
+
// This remains intentionally conservative: replace only one occurrence.
|
|
225
|
+
if (!totalReplacements) {
|
|
226
|
+
console.log('SCRIPT_TEXT_SEARCH', {
|
|
227
|
+
filePath: filePath.split('/').pop(),
|
|
228
|
+
oldValue,
|
|
229
|
+
foundInSource: source.includes(oldValue)
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const jsxTextPattern = new RegExp('>(\\s*)' + escapeRegExp(oldValue) + '(\\s*)<');
|
|
233
|
+
const jsxMatch = updated.match(jsxTextPattern);
|
|
234
|
+
if (jsxMatch && jsxMatch.index !== undefined) {
|
|
235
|
+
const full = String(jsxMatch[0] || '');
|
|
236
|
+
const lead = String(jsxMatch[1] || '');
|
|
237
|
+
const tail = String(jsxMatch[2] || '');
|
|
238
|
+
const replacement = '>' + lead + newValue + tail + '<';
|
|
239
|
+
updated = updated.slice(0, jsxMatch.index) + replacement + updated.slice(jsxMatch.index + full.length);
|
|
240
|
+
totalReplacements = 1;
|
|
241
|
+
console.log('JSX_TEXT_REPLACEMENT', { oldValue, newValue });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Replace exact string literals where the entire literal equals old value.
|
|
246
|
+
if (!totalReplacements) {
|
|
247
|
+
const literalPatterns = [
|
|
248
|
+
{
|
|
249
|
+
regex: new RegExp("'" + escapeRegExp(escapeForSingleQuotedLiteral(oldValue)) + "'"),
|
|
250
|
+
replacement: '\'' + escapeForSingleQuotedLiteral(newValue) + '\''
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
regex: new RegExp('"' + escapeRegExp(escapeForDoubleQuotedLiteral(oldValue)) + '"'),
|
|
254
|
+
replacement: '"' + escapeForDoubleQuotedLiteral(newValue) + '"'
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
regex: new RegExp('`' + escapeRegExp(escapeForTemplateLiteral(oldValue)) + '`'),
|
|
258
|
+
replacement: '`' + escapeForTemplateLiteral(newValue) + '`'
|
|
259
|
+
}
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
for (const entry of literalPatterns) {
|
|
263
|
+
const literalMatch = updated.match(entry.regex);
|
|
264
|
+
if (literalMatch && literalMatch.index !== undefined) {
|
|
265
|
+
const matched = String(literalMatch[0] || '');
|
|
266
|
+
updated = updated.slice(0, literalMatch.index) + entry.replacement + updated.slice(literalMatch.index + matched.length);
|
|
267
|
+
totalReplacements = 1;
|
|
268
|
+
console.log('LITERAL_REPLACEMENT', { oldValue, newValue });
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Fallback: if strict JSX/literal matching failed but source contains old text,
|
|
275
|
+
// replace the first exact occurrence. This covers cases where runtime text comes
|
|
276
|
+
// from patterns not handled by the regexes above.
|
|
277
|
+
if (!totalReplacements && source.includes(oldValue)) {
|
|
278
|
+
const firstIdx = updated.indexOf(oldValue);
|
|
279
|
+
if (firstIdx !== -1) {
|
|
280
|
+
updated = updated.slice(0, firstIdx) + newValue + updated.slice(firstIdx + oldValue.length);
|
|
281
|
+
totalReplacements += 1;
|
|
282
|
+
console.log('FALLBACK_TEXT_REPLACEMENT', {
|
|
283
|
+
filePath: filePath.split('/').pop(),
|
|
284
|
+
oldValue,
|
|
285
|
+
newValue,
|
|
286
|
+
index: firstIdx
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!totalReplacements || updated === source) {
|
|
292
|
+
console.log('SCRIPT_NO_MATCH', {
|
|
293
|
+
filePath: filePath.split('/').pop(),
|
|
294
|
+
oldValue,
|
|
295
|
+
newValue,
|
|
296
|
+
replacements: totalReplacements,
|
|
297
|
+
hint: 'Text was not found in JSX or string literal format. Check if text is split across nodes or formatted differently.'
|
|
298
|
+
});
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
console.log('SCRIPT_REPLACEMENT_SUCCESS', { filePath: filePath.split('/').pop(), replacements: totalReplacements });
|
|
303
|
+
fs.writeFileSync(filePath, updated, 'utf8');
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function tryParseScriptAst(source) {
|
|
308
|
+
try {
|
|
309
|
+
return {
|
|
310
|
+
ast: babelParser.parse(source, {
|
|
311
|
+
sourceType: 'unambiguous',
|
|
312
|
+
plugins: [
|
|
313
|
+
'jsx',
|
|
314
|
+
'typescript',
|
|
315
|
+
'classProperties',
|
|
316
|
+
'objectRestSpread',
|
|
317
|
+
'optionalChaining',
|
|
318
|
+
'nullishCoalescingOperator'
|
|
319
|
+
],
|
|
320
|
+
ranges: true,
|
|
321
|
+
errorRecovery: true
|
|
322
|
+
})
|
|
323
|
+
};
|
|
324
|
+
} catch (_err) {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function buildTextScriptSelectorMatcher(selectorText) {
|
|
330
|
+
const raw = String(selectorText || '').trim();
|
|
331
|
+
if (!raw) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const parts = raw
|
|
336
|
+
.split(',')
|
|
337
|
+
.map((part) => parseTextScriptSelectorPart(part))
|
|
338
|
+
.filter(Boolean);
|
|
339
|
+
|
|
340
|
+
if (!parts.length) {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { parts };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function parseTextScriptSelectorPart(selectorPart) {
|
|
348
|
+
const part = String(selectorPart || '').trim();
|
|
349
|
+
if (!part) {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const rightMost = part.split(/[>+~\s]+/).filter(Boolean).pop() || '';
|
|
354
|
+
if (!rightMost) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (/\[[^\]]+\]/.test(rightMost) || /:/.test(rightMost)) {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const cleaned = rightMost.trim();
|
|
363
|
+
const tagMatch = cleaned.match(/^[a-zA-Z][a-zA-Z0-9-]*/);
|
|
364
|
+
const idMatches = cleaned.match(/#[a-zA-Z0-9_-]+/g) || [];
|
|
365
|
+
const classMatches = cleaned.match(/\.[a-zA-Z0-9_-]+/g) || [];
|
|
366
|
+
|
|
367
|
+
if (!idMatches.length && !classMatches.length) {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
tag: tagMatch ? tagMatch[0] : '',
|
|
373
|
+
ids: idMatches.map((token) => token.slice(1)),
|
|
374
|
+
classes: classMatches.map((token) => token.slice(1))
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function findSingleJsxTextReplacement(ast, selectorMatcher, oldValue, newValue) {
|
|
379
|
+
const oldNormalized = normalizeWhitespace(oldValue);
|
|
380
|
+
let replacement = null;
|
|
381
|
+
|
|
382
|
+
babelTraverse(ast, {
|
|
383
|
+
JSXElement(pathRef) {
|
|
384
|
+
if (replacement) {
|
|
385
|
+
pathRef.stop();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const opening = pathRef.node && pathRef.node.openingElement;
|
|
390
|
+
if (!opening || !jsxOpeningMatchesTextSelector(opening, selectorMatcher)) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const jsxTextChildren = Array.isArray(pathRef.node.children)
|
|
395
|
+
? pathRef.node.children.filter((child) => child && child.type === 'JSXText')
|
|
396
|
+
: [];
|
|
397
|
+
|
|
398
|
+
const preferred = jsxTextChildren.find((child) => {
|
|
399
|
+
const normalized = normalizeWhitespace(child.value);
|
|
400
|
+
if (!normalized) {
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
if (!oldNormalized) {
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
return normalized === oldNormalized;
|
|
407
|
+
}) || null;
|
|
408
|
+
|
|
409
|
+
if (!preferred || !Number.isInteger(preferred.start) || !Number.isInteger(preferred.end)) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const raw = String(preferred.value || '');
|
|
414
|
+
const leading = raw.match(/^\s*/);
|
|
415
|
+
const trailing = raw.match(/\s*$/);
|
|
416
|
+
replacement = {
|
|
417
|
+
start: preferred.start,
|
|
418
|
+
end: preferred.end,
|
|
419
|
+
text: String((leading ? leading[0] : '') + String(newValue || '') + (trailing ? trailing[0] : ''))
|
|
420
|
+
};
|
|
421
|
+
pathRef.stop();
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
return replacement;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function jsxOpeningMatchesTextSelector(opening, selectorMatcher) {
|
|
429
|
+
if (!opening || !selectorMatcher || !Array.isArray(selectorMatcher.parts)) {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return selectorMatcher.parts.some((matcher) => jsxOpeningMatchesSingleSelector(opening, matcher));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function jsxOpeningMatchesSingleSelector(opening, matcher) {
|
|
437
|
+
if (!opening || !matcher) {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const tagName = getJsxTagName(opening.name);
|
|
442
|
+
if (matcher.tag && String(tagName || '').toLowerCase() !== String(matcher.tag || '').toLowerCase()) {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const idValue = getJsxAttributeStringValue(opening, 'id');
|
|
447
|
+
if (Array.isArray(matcher.ids) && matcher.ids.length && (!idValue || !matcher.ids.includes(idValue))) {
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const classValue = getJsxAttributeStringValue(opening, 'className') || getJsxAttributeStringValue(opening, 'class');
|
|
452
|
+
const classTokens = String(classValue || '')
|
|
453
|
+
.split(/\s+/)
|
|
454
|
+
.map((token) => token.trim())
|
|
455
|
+
.filter(Boolean);
|
|
456
|
+
|
|
457
|
+
return (matcher.classes || []).every((cls) => classTokens.includes(cls));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function getJsxTagName(nameNode) {
|
|
461
|
+
if (!nameNode) {
|
|
462
|
+
return '';
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (nameNode.type === 'JSXIdentifier') {
|
|
466
|
+
return String(nameNode.name || '');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (nameNode.type === 'JSXMemberExpression') {
|
|
470
|
+
return String(nameNode.property && nameNode.property.name || '');
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return '';
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function getJsxAttributeStringValue(opening, attrName) {
|
|
477
|
+
const attrs = opening && Array.isArray(opening.attributes) ? opening.attributes : [];
|
|
478
|
+
for (const attr of attrs) {
|
|
479
|
+
if (!attr || attr.type !== 'JSXAttribute' || !attr.name || attr.name.type !== 'JSXIdentifier') {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (attr.name.name !== attrName) {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (!attr.value) {
|
|
488
|
+
return '';
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (attr.value.type === 'StringLiteral') {
|
|
492
|
+
return String(attr.value.value || '');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (attr.value.type === 'JSXExpressionContainer' && attr.value.expression && attr.value.expression.type === 'StringLiteral') {
|
|
496
|
+
return String(attr.value.expression.value || '');
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return '';
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function normalizeWhitespace(value) {
|
|
504
|
+
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function findPreferredTextNode(root) {
|
|
508
|
+
if (!root || !Array.isArray(root.children)) {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Prefer direct non-empty text node first.
|
|
513
|
+
for (const child of root.children) {
|
|
514
|
+
if (child && child.type === 'text' && String(child.data || '').trim()) {
|
|
515
|
+
return child;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Then search depth-first for the first meaningful text node.
|
|
520
|
+
for (const child of root.children) {
|
|
521
|
+
if (!child || child.type === 'text') {
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const tag = String(child.name || '').toLowerCase();
|
|
526
|
+
if (tag === 'script' || tag === 'style') {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const nested = findPreferredTextNode(child);
|
|
531
|
+
if (nested) {
|
|
532
|
+
return nested;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function isHtmlFile(filePath) {
|
|
540
|
+
const lower = String(filePath || '').toLowerCase();
|
|
541
|
+
return lower.endsWith('.html') || lower.endsWith('.htm');
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function isScriptSourceFile(filePath) {
|
|
545
|
+
const lower = String(filePath || '').toLowerCase();
|
|
546
|
+
return lower.endsWith('.js')
|
|
547
|
+
|| lower.endsWith('.jsx')
|
|
548
|
+
|| lower.endsWith('.ts')
|
|
549
|
+
|| lower.endsWith('.tsx')
|
|
550
|
+
|| lower.endsWith('.mjs')
|
|
551
|
+
|| lower.endsWith('.cjs')
|
|
552
|
+
|| lower.endsWith('.vue')
|
|
553
|
+
|| lower.endsWith('.svelte')
|
|
554
|
+
|| lower.endsWith('.astro');
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function escapeRegExp(value) {
|
|
558
|
+
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function escapeForSingleQuotedLiteral(value) {
|
|
562
|
+
return String(value || '')
|
|
563
|
+
.replace(/\\/g, '\\\\')
|
|
564
|
+
.replace(/'/g, "\\'");
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function escapeForDoubleQuotedLiteral(value) {
|
|
568
|
+
return String(value || '')
|
|
569
|
+
.replace(/\\/g, '\\\\')
|
|
570
|
+
.replace(/"/g, '\\"');
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function escapeForTemplateLiteral(value) {
|
|
574
|
+
return String(value || '')
|
|
575
|
+
.replace(/\\/g, '\\\\')
|
|
576
|
+
.replace(/`/g, '\\`')
|
|
577
|
+
.replace(/\$\{/g, '\\${');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
module.exports = { writeTextChange };
|
package/mcp-server/tools.js
CHANGED
|
@@ -105,7 +105,7 @@ export const toolDefinitions = [
|
|
|
105
105
|
{
|
|
106
106
|
name: 'apply_copy_variant',
|
|
107
107
|
description:
|
|
108
|
-
'Apply a chosen Glean copy variant: update live DOM in connected browsers AND persist editable hardcoded strings to source via
|
|
108
|
+
'Apply a chosen Glean copy variant: update live DOM in connected browsers AND persist editable hardcoded strings to source via Crank text-writer. Set persist=false for DOM-only preview.',
|
|
109
109
|
inputSchema: {
|
|
110
110
|
type: 'object',
|
|
111
111
|
properties: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ahmednawaz/crank",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Internal Motive UX copy iteration: select UI text → Glean variants → MCP apply. Cursor, Replit, Claude.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -37,8 +37,11 @@
|
|
|
37
37
|
"node": ">=18"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
+
"@babel/parser": "^7.28.0",
|
|
41
|
+
"@babel/traverse": "^7.28.0",
|
|
40
42
|
"@cursor/sdk": "^1.0.23",
|
|
41
43
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
44
|
+
"cheerio": "^1.0.0",
|
|
42
45
|
"cors": "^2.8.5",
|
|
43
46
|
"express": "^4.21.2",
|
|
44
47
|
"ws": "^8.18.0",
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Resolve sibling Vizpatch install so Crank can reuse text-writer, UI CSS, etc.
|
|
5
|
-
*/
|
|
6
|
-
const fs = require('fs');
|
|
7
|
-
const path = require('path');
|
|
8
|
-
|
|
9
|
-
function resolveVizpatchRoot() {
|
|
10
|
-
if (process.env.VIZPATCH_ROOT) {
|
|
11
|
-
return path.resolve(process.env.VIZPATCH_ROOT);
|
|
12
|
-
}
|
|
13
|
-
const candidates = [
|
|
14
|
-
path.resolve(__dirname, '..', '..', 'vizpatch'),
|
|
15
|
-
path.resolve(__dirname, '..', '..', 'Downloads', 'vizpatch'),
|
|
16
|
-
path.resolve(process.cwd(), '..', 'vizpatch'),
|
|
17
|
-
'/Users/ahmed.nawaz/Downloads/vizpatch'
|
|
18
|
-
];
|
|
19
|
-
for (const candidate of candidates) {
|
|
20
|
-
if (fs.existsSync(path.join(candidate, 'companion', 'text-writer.js'))) {
|
|
21
|
-
return candidate;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
return null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function requireVizpatch(relPath) {
|
|
28
|
-
const root = resolveVizpatchRoot();
|
|
29
|
-
if (!root) {
|
|
30
|
-
throw new Error(
|
|
31
|
-
'Vizpatch not found. Set VIZPATCH_ROOT to the vizpatch checkout path.'
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
return require(path.join(root, relPath));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function vizpatchAsset(...parts) {
|
|
38
|
-
const root = resolveVizpatchRoot();
|
|
39
|
-
if (!root) {
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
return path.join(root, ...parts);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
module.exports = {
|
|
46
|
-
resolveVizpatchRoot,
|
|
47
|
-
requireVizpatch,
|
|
48
|
-
vizpatchAsset
|
|
49
|
-
};
|