@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.
- package/.env.example +53 -0
- package/README.md +266 -0
- package/bin/crank.js +737 -0
- package/bookmarklet/crank-bookmarklet.js +2295 -0
- package/bookmarklet/crank-ui-helpers.js +355 -0
- package/bookmarklet/install-snippet.txt +5 -0
- package/bookmarklet/text-source.js +239 -0
- package/companion/agent-queue.js +485 -0
- package/companion/agent-runner.js +334 -0
- package/companion/bin/crank.js +69 -0
- package/companion/dev-loader.js +39 -0
- package/companion/glean-client.js +991 -0
- package/companion/package.json +18 -0
- package/companion/persist-apply.js +189 -0
- package/companion/selection-store.js +175 -0
- package/companion/server.js +1147 -0
- package/companion/text-dispatch.js +419 -0
- package/companion/vizpatch-bridge.js +49 -0
- package/lib/copy-labels.js +86 -0
- package/lib/detect.js +88 -0
- package/lib/env.js +23 -0
- package/lib/init.js +435 -0
- package/lib/load-team-env.js +43 -0
- package/lib/resolve-project.js +203 -0
- package/lib/team-auth.js +82 -0
- package/lib/urls.js +164 -0
- package/mcp-server/index.js +174 -0
- package/mcp-server/package.json +16 -0
- package/mcp-server/tools.js +480 -0
- package/package.json +57 -0
- package/panel/demo.html +62 -0
- package/skill/SKILL.md +60 -0
- package/skills/README.md +16 -0
- package/skills/copy-guidance.md +25 -0
- package/team.env +4 -0
- package/vite.js +75 -0
|
@@ -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(/ /gi, ' ')
|
|
89
|
+
.replace(/&/gi, '&')
|
|
90
|
+
.replace(/</gi, '<')
|
|
91
|
+
.replace(/>/gi, '>')
|
|
92
|
+
.replace(/"/gi, '"')
|
|
93
|
+
.replace(/'/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 };
|