@ahmednawaz/crank 0.1.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.
@@ -0,0 +1,419 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Text save / resolve — reuses Vizpatch text-writer + Vizpatch-like source resolution.
5
+ *
6
+ * Resolution order:
7
+ * 1. Explicit file / pathHints (route-like absolute paths resolved under project root)
8
+ * 2. URL pathname → basename search (e.g. /demo.html → panel/demo.html)
9
+ * 3. Content search for oldValue (exact, whitespace-normalized, tag-stripped)
10
+ */
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { requireVizpatch, resolveVizpatchRoot } = require('./vizpatch-bridge');
14
+
15
+ let writeTextChange;
16
+ try {
17
+ writeTextChange = requireVizpatch('companion/text-writer.js').writeTextChange;
18
+ } catch (err) {
19
+ writeTextChange = null;
20
+ console.warn('[Crank] text-writer unavailable:', err.message);
21
+ }
22
+
23
+ const TEXT_EXTS = new Set([
24
+ '.html',
25
+ '.htm',
26
+ '.js',
27
+ '.jsx',
28
+ '.ts',
29
+ '.tsx',
30
+ '.mjs',
31
+ '.cjs',
32
+ '.vue',
33
+ '.svelte',
34
+ '.astro'
35
+ ]);
36
+
37
+ const SKIP_DIRS = new Set([
38
+ 'node_modules',
39
+ '.git',
40
+ 'dist',
41
+ 'build',
42
+ '.next',
43
+ 'coverage',
44
+ '.turbo',
45
+ '.cache'
46
+ ]);
47
+
48
+ function walkFiles(root, out, depth = 0) {
49
+ if (depth > 10 || out.length > 800) {
50
+ return;
51
+ }
52
+ let entries;
53
+ try {
54
+ entries = fs.readdirSync(root, { withFileTypes: true });
55
+ } catch {
56
+ return;
57
+ }
58
+ for (const entry of entries) {
59
+ if (SKIP_DIRS.has(entry.name)) {
60
+ continue;
61
+ }
62
+ const full = path.join(root, entry.name);
63
+ if (entry.isDirectory()) {
64
+ walkFiles(full, out, depth + 1);
65
+ } else if (entry.isFile() && TEXT_EXTS.has(path.extname(entry.name).toLowerCase())) {
66
+ out.push(full);
67
+ }
68
+ }
69
+ }
70
+
71
+ function normalizeWs(value) {
72
+ return String(value || '')
73
+ .replace(/\s+/g, ' ')
74
+ .trim();
75
+ }
76
+
77
+ /** Soften DOM vs source differences after tag stripping (e.g. "crank ." vs "crank."). */
78
+ function softenText(value) {
79
+ return normalizeWs(value)
80
+ .replace(/\s+([.,!?;:])/g, '$1')
81
+ .replace(/([({\[])\s+/g, '$1')
82
+ .replace(/\s+([)}\]])/g, '$1');
83
+ }
84
+
85
+ function stripTags(value) {
86
+ return String(value || '')
87
+ .replace(/<[^>]+>/g, ' ')
88
+ .replace(/&nbsp;/gi, ' ')
89
+ .replace(/&amp;/gi, '&')
90
+ .replace(/&lt;/gi, '<')
91
+ .replace(/&gt;/gi, '>')
92
+ .replace(/&quot;/gi, '"')
93
+ .replace(/&#39;/gi, "'");
94
+ }
95
+
96
+ function fileContainsNeedle(content, needle) {
97
+ const expected = String(needle || '').trim();
98
+ if (!expected) {
99
+ return true;
100
+ }
101
+ if (content.includes(expected)) {
102
+ return true;
103
+ }
104
+ const softNeedle = softenText(expected);
105
+ if (softNeedle && softenText(content).includes(softNeedle)) {
106
+ return true;
107
+ }
108
+ // DOM textContent often concatenates across inline tags; source still has markup.
109
+ const stripped = softenText(stripTags(content));
110
+ if (softNeedle && stripped.includes(softNeedle)) {
111
+ return true;
112
+ }
113
+ return false;
114
+ }
115
+
116
+ function pathHintsFromUrl(sourceUrl) {
117
+ const value = String(sourceUrl || '').trim();
118
+ if (!value) {
119
+ return [];
120
+ }
121
+ let parsed;
122
+ try {
123
+ parsed = new URL(value);
124
+ } catch {
125
+ return [];
126
+ }
127
+ const hints = [];
128
+ const push = (hint) => {
129
+ const h = String(hint || '').trim();
130
+ if (h && !hints.includes(h)) {
131
+ hints.push(h);
132
+ }
133
+ };
134
+
135
+ let pathname = decodeURIComponent(parsed.pathname || '/').trim() || '/';
136
+ if (!pathname.startsWith('/')) {
137
+ pathname = '/' + pathname;
138
+ }
139
+
140
+ if (pathname === '/') {
141
+ push('/index.html');
142
+ push('index.html');
143
+ return hints;
144
+ }
145
+
146
+ push(pathname);
147
+ push(pathname.replace(/^\//, ''));
148
+ const base = path.posix.basename(pathname);
149
+ if (base) {
150
+ push(base);
151
+ }
152
+
153
+ if (!/\.[a-z0-9]+$/i.test(pathname)) {
154
+ push(pathname + '.html');
155
+ push(pathname.replace(/\/+$/, '') + '/index.html');
156
+ }
157
+ if (pathname.endsWith('/')) {
158
+ push(pathname + 'index.html');
159
+ }
160
+ return hints;
161
+ }
162
+
163
+ /**
164
+ * Resolve a hint that may look absolute (e.g. "/demo.html") but is a URL route.
165
+ */
166
+ function resolveHintCandidates(hint, root) {
167
+ const raw = String(hint || '').trim();
168
+ if (!raw) {
169
+ return [];
170
+ }
171
+ const out = [];
172
+ if (path.isAbsolute(raw)) {
173
+ out.push(path.normalize(raw));
174
+ // Route-like: /demo.html → <root>/demo.html
175
+ const rel = raw.replace(/^\/+/, '');
176
+ if (rel) {
177
+ out.push(path.normalize(path.join(root, rel)));
178
+ }
179
+ } else {
180
+ out.push(path.normalize(path.resolve(root, raw)));
181
+ }
182
+ return out;
183
+ }
184
+
185
+ function findByBasename(root, basename, needle) {
186
+ if (!basename) {
187
+ return null;
188
+ }
189
+ const files = [];
190
+ walkFiles(root, files);
191
+ const matches = files.filter(
192
+ (f) => path.basename(f).toLowerCase() === String(basename).toLowerCase()
193
+ );
194
+ if (!matches.length) {
195
+ return null;
196
+ }
197
+ if (!needle) {
198
+ return matches[0];
199
+ }
200
+ for (const candidate of matches) {
201
+ try {
202
+ if (fileContainsNeedle(fs.readFileSync(candidate, 'utf8'), needle)) {
203
+ return candidate;
204
+ }
205
+ } catch {
206
+ // skip
207
+ }
208
+ }
209
+ // Basename matched the page URL — prefer it even if oldValue is nested-markup mismatched.
210
+ return matches[0];
211
+ }
212
+
213
+ function buildFailureReason(root, needle, attempted) {
214
+ const snippet = normalizeWs(needle).slice(0, 80);
215
+ if (!needle) {
216
+ return (
217
+ 'No file hint and no oldValue to search. ' +
218
+ `Project root: ${root}. Set CRANK_PROJECT_ROOT to your app repo if this is wrong.`
219
+ );
220
+ }
221
+ const tried = (attempted || []).slice(0, 8).join(', ') || '(none)';
222
+ return (
223
+ `Text source file could not be resolved for "${snippet}". ` +
224
+ `Project root: ${root}. ` +
225
+ `No searchable file contained that string (nested tags / i18n / wrong root are common causes). ` +
226
+ `Tried: ${tried}. ` +
227
+ `Fix: start companion from the app being edited or set CRANK_PROJECT_ROOT.`
228
+ );
229
+ }
230
+
231
+ function resolveTextSourceFile({
232
+ file,
233
+ oldValue,
234
+ projectRoot,
235
+ pathHints,
236
+ sourceUrl
237
+ }) {
238
+ const attempted = [];
239
+ const seen = new Set();
240
+ const root = projectRoot || process.cwd();
241
+ const needle = String(oldValue || '').trim();
242
+
243
+ const note = (candidate) => {
244
+ const normalized = path.normalize(String(candidate || ''));
245
+ if (!normalized || seen.has(normalized)) {
246
+ return false;
247
+ }
248
+ seen.add(normalized);
249
+ attempted.push(normalized);
250
+ return true;
251
+ };
252
+
253
+ const acceptIfExists = (candidate, requireNeedle) => {
254
+ const abs = path.normalize(candidate);
255
+ note(abs);
256
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
257
+ return null;
258
+ }
259
+ if (!TEXT_EXTS.has(path.extname(abs).toLowerCase())) {
260
+ return null;
261
+ }
262
+ if (requireNeedle && needle) {
263
+ try {
264
+ if (!fileContainsNeedle(fs.readFileSync(abs, 'utf8'), needle)) {
265
+ return null;
266
+ }
267
+ } catch {
268
+ return null;
269
+ }
270
+ }
271
+ return abs;
272
+ };
273
+
274
+ // 1) Explicit file
275
+ if (file) {
276
+ for (const candidate of resolveHintCandidates(file, root)) {
277
+ const hit = acceptIfExists(candidate, Boolean(needle));
278
+ if (hit) {
279
+ return { filePath: hit, attemptedFiles: attempted.slice(0, 40) };
280
+ }
281
+ // If explicit file exists but needle missing, still prefer it (writer may fuzzy-match).
282
+ const exists = acceptIfExists(candidate, false);
283
+ if (exists) {
284
+ return { filePath: exists, attemptedFiles: attempted.slice(0, 40) };
285
+ }
286
+ }
287
+ }
288
+
289
+ // 2) Merged path hints (caller + URL-derived)
290
+ const urlHints = pathHintsFromUrl(sourceUrl);
291
+ const hints = []
292
+ .concat(Array.isArray(pathHints) ? pathHints : [])
293
+ .concat(urlHints);
294
+ const uniqueHints = [];
295
+ for (const hint of hints) {
296
+ const h = String(hint || '').trim();
297
+ if (h && !uniqueHints.includes(h)) {
298
+ uniqueHints.push(h);
299
+ }
300
+ }
301
+
302
+ for (const hint of uniqueHints) {
303
+ for (const candidate of resolveHintCandidates(hint, root)) {
304
+ const hit = acceptIfExists(candidate, Boolean(needle));
305
+ if (hit) {
306
+ return { filePath: hit, attemptedFiles: attempted.slice(0, 40) };
307
+ }
308
+ }
309
+ }
310
+
311
+ // 3) Basename search from URL / hints (demo.html served from panel/)
312
+ for (const hint of uniqueHints) {
313
+ const base = path.posix.basename(String(hint).replace(/\\/g, '/'));
314
+ if (!base || base === '/' || !base.includes('.')) {
315
+ continue;
316
+ }
317
+ const found = findByBasename(root, base, needle);
318
+ if (found) {
319
+ note(found);
320
+ return { filePath: found, attemptedFiles: attempted.slice(0, 40) };
321
+ }
322
+ }
323
+
324
+ // 4) Global content search
325
+ if (!needle) {
326
+ return {
327
+ filePath: null,
328
+ reason: buildFailureReason(root, needle, attempted),
329
+ attemptedFiles: attempted
330
+ };
331
+ }
332
+
333
+ const files = [];
334
+ walkFiles(root, files);
335
+ for (const candidate of files) {
336
+ note(candidate);
337
+ try {
338
+ const text = fs.readFileSync(candidate, 'utf8');
339
+ if (fileContainsNeedle(text, needle)) {
340
+ return { filePath: candidate, attemptedFiles: attempted.slice(0, 40) };
341
+ }
342
+ } catch {
343
+ // skip
344
+ }
345
+ }
346
+
347
+ return {
348
+ filePath: null,
349
+ reason: buildFailureReason(root, needle, attempted),
350
+ attemptedFiles: attempted.slice(0, 40)
351
+ };
352
+ }
353
+
354
+ function saveTextChange(payload, projectRoot) {
355
+ if (!writeTextChange) {
356
+ return {
357
+ success: false,
358
+ error:
359
+ 'Vizpatch text-writer not available. Set VIZPATCH_ROOT to ' +
360
+ (resolveVizpatchRoot() || '(missing)')
361
+ };
362
+ }
363
+
364
+ const selectorText = payload.selectorText;
365
+ if (!selectorText) {
366
+ return { success: false, error: 'selectorText is required for text save' };
367
+ }
368
+
369
+ const sourceUrl = payload.sourceUrl || payload.pageUrl || '';
370
+ const resolution = resolveTextSourceFile({
371
+ file: payload.file,
372
+ oldValue: payload.oldValue,
373
+ projectRoot,
374
+ pathHints: payload.pathHints,
375
+ sourceUrl
376
+ });
377
+
378
+ if (!resolution.filePath) {
379
+ return {
380
+ success: false,
381
+ error: resolution.reason || 'Text source file could not be resolved',
382
+ attemptedFiles: resolution.attemptedFiles || [],
383
+ projectRoot
384
+ };
385
+ }
386
+
387
+ try {
388
+ const changed = writeTextChange(
389
+ resolution.filePath,
390
+ selectorText,
391
+ payload.value,
392
+ {
393
+ oldValue: payload.oldValue,
394
+ componentName: payload.componentName,
395
+ sourceUrl
396
+ }
397
+ );
398
+ if (!changed) {
399
+ return {
400
+ success: false,
401
+ error:
402
+ `No matching text node found for ${selectorText} in ${resolution.filePath}. ` +
403
+ `oldValue may not appear as a hardcoded literal (dynamic/i18n/nested markup).`,
404
+ file: resolution.filePath,
405
+ attemptedFiles: resolution.attemptedFiles || []
406
+ };
407
+ }
408
+ return { success: true, verified: true, file: resolution.filePath };
409
+ } catch (err) {
410
+ return { success: false, error: err.message, file: resolution.filePath };
411
+ }
412
+ }
413
+
414
+ module.exports = {
415
+ saveTextChange,
416
+ resolveTextSourceFile,
417
+ pathHintsFromUrl,
418
+ writeTextChangeAvailable: () => Boolean(writeTextChange)
419
+ };
@@ -0,0 +1,49 @@
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
+ };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Display/draft labels for copy units — semantic roles when obvious,
5
+ * otherwise numbered Copy 1..N (never forced Heading/Body/CTA).
6
+ */
7
+
8
+ function normalizeRole(role) {
9
+ return String(role || 'text').toLowerCase();
10
+ }
11
+
12
+ function isTextLikeRole(role) {
13
+ const r = normalizeRole(role);
14
+ return r === 'text' || r === 'label' || r === 'body';
15
+ }
16
+
17
+ function hasSemanticStructure(nodes) {
18
+ return (Array.isArray(nodes) ? nodes : []).some((n) => {
19
+ const r = normalizeRole(n.role);
20
+ return r === 'heading' || r === 'action' || r === 'button';
21
+ });
22
+ }
23
+
24
+ function textLikeNodes(nodes) {
25
+ return (Array.isArray(nodes) ? nodes : []).filter((n) =>
26
+ isTextLikeRole(n.role)
27
+ );
28
+ }
29
+
30
+ /**
31
+ * @param {object} node
32
+ * @param {number} index - index in full nodes array
33
+ * @param {object[]} nodes
34
+ * @returns {string}
35
+ */
36
+ function unitDisplayLabel(node, index, nodes) {
37
+ const list = Array.isArray(nodes) ? nodes : [];
38
+ const role = normalizeRole(node && node.role);
39
+ if (role === 'heading') return 'Heading';
40
+ if (role === 'action' || role === 'button') return 'CTA';
41
+ if (role === 'label') return 'Label';
42
+
43
+ const semantic = hasSemanticStructure(list);
44
+ const texts = textLikeNodes(list);
45
+
46
+ if (semantic) {
47
+ const bodyIndex = texts.findIndex((n) => n === node);
48
+ if (bodyIndex >= 0) {
49
+ return texts.length > 1 ? `Body ${bodyIndex + 1}` : 'Body';
50
+ }
51
+ }
52
+
53
+ if (list.length <= 1) return 'Copy 1';
54
+ return `Copy ${index + 1}`;
55
+ }
56
+
57
+ /**
58
+ * @param {object[]} nodes
59
+ * @returns {object[]}
60
+ */
61
+ function assignUnitLabels(nodes) {
62
+ const list = Array.isArray(nodes) ? nodes : [];
63
+ return list.map((node, index) =>
64
+ Object.assign({}, node, {
65
+ label: node.label || unitDisplayLabel(node, index, list)
66
+ })
67
+ );
68
+ }
69
+
70
+ /**
71
+ * Draft label for Glean (same as display, with trailing colon added by caller).
72
+ * @param {object} node
73
+ * @param {number} index
74
+ * @param {object[]} nodes
75
+ */
76
+ function unitDraftLabel(node, index, nodes) {
77
+ return unitDisplayLabel(node, index, nodes);
78
+ }
79
+
80
+ module.exports = {
81
+ unitDisplayLabel,
82
+ unitDraftLabel,
83
+ assignUnitLabels,
84
+ hasSemanticStructure,
85
+ isTextLikeRole
86
+ };
package/lib/detect.js ADDED
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ function exists(p) {
8
+ try {
9
+ return fs.existsSync(p);
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ function readJson(file, fallback = null) {
16
+ try {
17
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
18
+ } catch {
19
+ return fallback;
20
+ }
21
+ }
22
+
23
+ function writeJson(file, data) {
24
+ fs.mkdirSync(path.dirname(file), { recursive: true });
25
+ fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
26
+ }
27
+
28
+ function detectEnvironment(projectRoot) {
29
+ const env = process.env;
30
+ const isReplit = Boolean(
31
+ env.REPL_ID || env.REPL_SLUG || env.REPLIT_DEV_DOMAIN || env.REPLIT_DB_URL
32
+ );
33
+ const hasVite =
34
+ exists(path.join(projectRoot, 'vite.config.ts')) ||
35
+ exists(path.join(projectRoot, 'vite.config.js')) ||
36
+ exists(path.join(projectRoot, 'vite.config.mjs')) ||
37
+ exists(path.join(projectRoot, 'vite.config.cjs'));
38
+ const hasPackageJson = exists(path.join(projectRoot, 'package.json'));
39
+ const hasCursor = exists(path.join(projectRoot, '.cursor')) ||
40
+ exists(path.join(os.homedir(), '.cursor'));
41
+ const hasVsCode = exists(path.join(projectRoot, '.vscode')) ||
42
+ exists(path.join(projectRoot, '.vscode'.replace('s', 's')));
43
+ const claudeConfig = path.join(
44
+ os.homedir(),
45
+ 'Library',
46
+ 'Application Support',
47
+ 'Claude',
48
+ 'claude_desktop_config.json'
49
+ );
50
+ const hasClaudeDesktop = process.platform === 'darwin' && exists(claudeConfig);
51
+
52
+ return {
53
+ isReplit,
54
+ hasVite,
55
+ hasPackageJson,
56
+ hasCursor,
57
+ hasVsCode: exists(path.join(projectRoot, '.vscode')),
58
+ hasClaudeDesktop,
59
+ claudeConfig,
60
+ replitDomain: env.REPLIT_DEV_DOMAIN || null,
61
+ packageJson: hasPackageJson
62
+ ? readJson(path.join(projectRoot, 'package.json'), {})
63
+ : null
64
+ };
65
+ }
66
+
67
+ function findViteConfig(projectRoot) {
68
+ for (const name of [
69
+ 'vite.config.ts',
70
+ 'vite.config.js',
71
+ 'vite.config.mjs',
72
+ 'vite.config.cjs'
73
+ ]) {
74
+ const full = path.join(projectRoot, name);
75
+ if (exists(full)) {
76
+ return full;
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ module.exports = {
83
+ exists,
84
+ readJson,
85
+ writeJson,
86
+ detectEnvironment,
87
+ findViteConfig
88
+ };
package/lib/env.js ADDED
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Crank env resolution — CRANK_* variables only.
5
+ */
6
+
7
+ function env(name, fallback = '') {
8
+ const crankKey = name.startsWith('CRANK_') ? name : `CRANK_${name}`;
9
+ const raw = process.env[crankKey];
10
+ return raw != null && String(raw).trim() !== '' ? String(raw) : fallback;
11
+ }
12
+
13
+ function envInt(name, fallback) {
14
+ const n = Number(env(name, String(fallback)));
15
+ return Number.isFinite(n) ? n : fallback;
16
+ }
17
+
18
+ function envBool(name, fallback = false) {
19
+ const raw = env(name, fallback ? 'true' : 'false');
20
+ return String(raw).toLowerCase() !== 'false';
21
+ }
22
+
23
+ module.exports = { env, envInt, envBool };