@godxjp/ui 32.12.1 → 32.14.0
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/DESIGN.md +1 -1
- package/README.md +2 -2
- package/agent/DESIGN.md +1 -1
- package/agent/START-HERE.md +4 -4
- package/agent/components/ListRow.json +1 -1
- package/agent/components/PageContainer.json +2 -2
- package/agent/components/Segmented.json +1 -0
- package/agent/components/Tabs.json +3 -2
- package/agent/components.json +7 -5
- package/agent/index.json +4 -4
- package/agent/llms.txt +5 -5
- package/agent/patterns/inertia-list-page.json +1 -1
- package/agent/patterns.json +1 -1
- package/agent/tokens.json +38 -2
- package/dist/components/layout/topbar.js +1 -0
- package/dist/contracts/measurement.json +1 -1
- package/dist/styles/base.css +19 -19
- package/dist/styles/data-display-layout.css +16 -17
- package/dist/styles/layers.json +1 -1
- package/dist/styles/layout.css +1 -1
- package/dist/styles/shell-layout.css +1 -0
- package/dist/styles.css +1 -1
- package/dist/tokens/components/data-display.css +10 -0
- package/dist/tokens/semantic/layout.css +1 -1
- package/package.json +5 -2
- package/scripts/_agent-setup.mjs +118 -0
- package/scripts/agent-guard.mjs +176 -0
- package/scripts/cli.mjs +25 -2
- package/scripts/codemod.mjs +313 -42
- package/scripts/init-agent-kit.mjs +14 -0
- package/scripts/mcp-call.mjs +40 -0
- package/scripts/ui-audit.mjs +77 -0
- package/scripts/user-guard.mjs +78 -0
- package/scripts/visual-audit.mjs +75 -9
package/scripts/codemod.mjs
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
* Transforms (each one idempotent: running twice changes nothing the second time):
|
|
11
11
|
* 1. fonts are opt-in (gh#1221): after an import of "@godxjp/ui/styles", add the fonts import.
|
|
12
12
|
* 2. with --godx (gh#1220): add the preset stylesheet after it, and `preset={godxPreset}` (+ its
|
|
13
|
-
* import) to every
|
|
13
|
+
* import) to every element bound to @godxjp/ui's AppProvider (aliases followed) that has no
|
|
14
|
+
* `preset`. An app's own component that happens to be named AppProvider is not one (gh#1327).
|
|
15
|
+
* Rules only rewrite code: never string or template text, comments, or JSX text, and a file no
|
|
16
|
+
* rule applies to is left byte-identical (gh#1327).
|
|
14
17
|
* 3. component renames/moves (gh#1223): import specifiers rewritten from RENAMES below.
|
|
15
18
|
* Reported, not changed: an `<AppProvider` with no `defaultLocale` and no preset now starts in `en`
|
|
16
19
|
* (or `<html lang>`), not `vi` (gh#1219).
|
|
@@ -304,6 +307,248 @@ const GODX_TOKENS_CSS = "@godxjp/ui/themes/godx-tokens.css";
|
|
|
304
307
|
const CORE_IMPORT_CSS = /^([ \t]*)@import\s+(["'])@godxjp\/ui\/styles\/core\2\s*;[ \t]*$/m;
|
|
305
308
|
const CORE_IMPORT_JS = /^([ \t]*)import\s+(["'])@godxjp\/ui\/styles\/core\2\s*;?[ \t]*$/m;
|
|
306
309
|
|
|
310
|
+
/**
|
|
311
|
+
* gh#1327: which characters of `source` are code. String and template text, regex literals,
|
|
312
|
+
* comments and JSX text are not, so no rule ever rewrites `"…within <AppProvider>"` or a
|
|
313
|
+
* commented-out import. A small hand lexer, not a parser: the codemod ships with no dependencies.
|
|
314
|
+
* Where it is unsure (a TS generic arrow in .tsx), it treats the text as code, as before.
|
|
315
|
+
*/
|
|
316
|
+
export function codeMask(file, src) {
|
|
317
|
+
const n = src.length;
|
|
318
|
+
const code = new Uint8Array(n);
|
|
319
|
+
const mark = (from, to) => code.fill(1, from, Math.min(to, n));
|
|
320
|
+
if (file.endsWith(".css")) {
|
|
321
|
+
let i = 0;
|
|
322
|
+
while (i < n) {
|
|
323
|
+
const c = src[i];
|
|
324
|
+
if (c === "/" && src[i + 1] === "*") {
|
|
325
|
+
const end = src.indexOf("*/", i + 2);
|
|
326
|
+
i = end < 0 ? n : end + 2;
|
|
327
|
+
} else if (c === '"' || c === "'") {
|
|
328
|
+
i = skipQuoted(src, i);
|
|
329
|
+
} else {
|
|
330
|
+
code[i] = 1;
|
|
331
|
+
i += 1;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return code;
|
|
335
|
+
}
|
|
336
|
+
const jsx = !/\.[mc]?ts$/.test(file);
|
|
337
|
+
const isId = (c) => c !== undefined && /[\w$]/.test(c);
|
|
338
|
+
const EXPR_WORDS =
|
|
339
|
+
/^(return|case|default|yield|await|typeof|void|delete|in|of|new|else|do|throw|instanceof)$/;
|
|
340
|
+
|
|
341
|
+
// Code until an unmatched `}` (when `closer`) or the end. Returns the index of that `}`.
|
|
342
|
+
function scanCode(i, closer) {
|
|
343
|
+
let depth = 0;
|
|
344
|
+
let prev = ""; // last significant character
|
|
345
|
+
let word = ""; // last identifier, when prev ended one
|
|
346
|
+
const exprStart = () =>
|
|
347
|
+
prev === "" || "([{,;:=!&|?+-*%^~<>}".includes(prev) || EXPR_WORDS.test(word);
|
|
348
|
+
while (i < n) {
|
|
349
|
+
const c = src[i];
|
|
350
|
+
if (c === "/" && src[i + 1] === "/") {
|
|
351
|
+
const end = src.indexOf("\n", i);
|
|
352
|
+
i = end < 0 ? n : end;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (c === "/" && src[i + 1] === "*") {
|
|
356
|
+
const end = src.indexOf("*/", i + 2);
|
|
357
|
+
i = end < 0 ? n : end + 2;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (c === '"' || c === "'") {
|
|
361
|
+
i = skipQuoted(src, i);
|
|
362
|
+
prev = '"';
|
|
363
|
+
word = "";
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (c === "`") {
|
|
367
|
+
i = scanTemplate(i);
|
|
368
|
+
prev = '"';
|
|
369
|
+
word = "";
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (c === "/" && exprStart()) {
|
|
373
|
+
i = skipRegex(i);
|
|
374
|
+
prev = '"';
|
|
375
|
+
word = "";
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (c === "<" && jsx && exprStart()) {
|
|
379
|
+
const end = scanJsxElement(i);
|
|
380
|
+
if (end > 0) {
|
|
381
|
+
i = end;
|
|
382
|
+
prev = ")";
|
|
383
|
+
word = "";
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
code[i] = 1;
|
|
388
|
+
if (c === "{") depth += 1;
|
|
389
|
+
if (c === "}") {
|
|
390
|
+
if (depth === 0 && closer) return i;
|
|
391
|
+
depth -= 1;
|
|
392
|
+
}
|
|
393
|
+
if (/\s/.test(c)) {
|
|
394
|
+
if (isId(prev)) prev = " "; // `else return`: two words, not `elsereturn`
|
|
395
|
+
} else {
|
|
396
|
+
if (isId(c)) word = isId(prev) && word ? word + c : c;
|
|
397
|
+
else word = "";
|
|
398
|
+
prev = c;
|
|
399
|
+
}
|
|
400
|
+
i += 1;
|
|
401
|
+
}
|
|
402
|
+
return n;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function scanTemplate(i) {
|
|
406
|
+
i += 1; // opening backtick
|
|
407
|
+
while (i < n) {
|
|
408
|
+
const c = src[i];
|
|
409
|
+
if (c === "\\") i += 2;
|
|
410
|
+
else if (c === "`") return i + 1;
|
|
411
|
+
else if (c === "$" && src[i + 1] === "{") {
|
|
412
|
+
mark(i, i + 2);
|
|
413
|
+
i = scanCode(i + 2, true);
|
|
414
|
+
mark(i, i + 1);
|
|
415
|
+
i += 1;
|
|
416
|
+
} else i += 1;
|
|
417
|
+
}
|
|
418
|
+
return n;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function skipRegex(i) {
|
|
422
|
+
let inClass = false;
|
|
423
|
+
i += 1;
|
|
424
|
+
while (i < n && src[i] !== "\n") {
|
|
425
|
+
const c = src[i];
|
|
426
|
+
if (c === "\\") {
|
|
427
|
+
i += 2;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (c === "/" && !inClass) {
|
|
431
|
+
i += 1;
|
|
432
|
+
while (isId(src[i])) i += 1;
|
|
433
|
+
return i;
|
|
434
|
+
}
|
|
435
|
+
if (c === "[") inClass = true;
|
|
436
|
+
if (c === "]") inClass = false;
|
|
437
|
+
i += 1;
|
|
438
|
+
}
|
|
439
|
+
return i;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// A JSX element (or fragment) starting at `<`. Returns the index after it, or -1 when the `<`
|
|
443
|
+
// is not JSX (a comparison, a TS generic).
|
|
444
|
+
function scanJsxElement(start) {
|
|
445
|
+
let i = start + 1;
|
|
446
|
+
while (/\s/.test(src[i] ?? "")) i += 1;
|
|
447
|
+
if (src[i] === ">") {
|
|
448
|
+
mark(start, i + 1);
|
|
449
|
+
return scanJsxChildren(i + 1);
|
|
450
|
+
}
|
|
451
|
+
const name = /^[A-Za-z_$][\w$.:-]*/.exec(src.slice(i, i + 200));
|
|
452
|
+
if (!name) return -1;
|
|
453
|
+
i += name[0].length;
|
|
454
|
+
// `<T,>(x) =>` / `<T extends …>`: a generic arrow in .tsx, not an element.
|
|
455
|
+
const after = /^\s*(,|extends\b)/.exec(src.slice(i, i + 20));
|
|
456
|
+
if (after) return -1;
|
|
457
|
+
mark(start, i);
|
|
458
|
+
while (i < n) {
|
|
459
|
+
const c = src[i];
|
|
460
|
+
if (c === '"' || c === "'") {
|
|
461
|
+
const end = src.indexOf(c, i + 1); // JSX attribute strings have no escapes
|
|
462
|
+
i = end < 0 ? n : end + 1;
|
|
463
|
+
} else if (c === "/" && src[i + 1] === "/") {
|
|
464
|
+
const end = src.indexOf("\n", i);
|
|
465
|
+
i = end < 0 ? n : end;
|
|
466
|
+
} else if (c === "/" && src[i + 1] === "*") {
|
|
467
|
+
const end = src.indexOf("*/", i + 2);
|
|
468
|
+
i = end < 0 ? n : end + 2;
|
|
469
|
+
} else if (c === "{") {
|
|
470
|
+
mark(i, i + 1);
|
|
471
|
+
i = scanCode(i + 1, true);
|
|
472
|
+
mark(i, i + 1);
|
|
473
|
+
i += 1;
|
|
474
|
+
} else if (c === "/" && src[i + 1] === ">") {
|
|
475
|
+
mark(i, i + 2);
|
|
476
|
+
return i + 2;
|
|
477
|
+
} else if (c === ">") {
|
|
478
|
+
mark(i, i + 1);
|
|
479
|
+
return scanJsxChildren(i + 1);
|
|
480
|
+
} else {
|
|
481
|
+
code[i] = 1;
|
|
482
|
+
i += 1;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return n;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Children until the matching closing tag. Returns the index after it.
|
|
489
|
+
function scanJsxChildren(i) {
|
|
490
|
+
while (i < n) {
|
|
491
|
+
const c = src[i];
|
|
492
|
+
if (c === "{") {
|
|
493
|
+
mark(i, i + 1);
|
|
494
|
+
i = scanCode(i + 1, true);
|
|
495
|
+
mark(i, i + 1);
|
|
496
|
+
i += 1;
|
|
497
|
+
} else if (c === "<" && src[i + 1] === "/") {
|
|
498
|
+
const end = src.indexOf(">", i);
|
|
499
|
+
mark(i, end < 0 ? n : end + 1);
|
|
500
|
+
return end < 0 ? n : end + 1;
|
|
501
|
+
} else if (c === "<") {
|
|
502
|
+
const end = scanJsxElement(i);
|
|
503
|
+
i = end > 0 ? end : i + 1;
|
|
504
|
+
} else i += 1; // JSX text: apostrophes and quotes here are prose, not strings
|
|
505
|
+
}
|
|
506
|
+
return n;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
scanCode(0, false);
|
|
510
|
+
return code;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function skipQuoted(src, i) {
|
|
514
|
+
const q = src[i];
|
|
515
|
+
i += 1;
|
|
516
|
+
while (i < src.length && src[i] !== q && src[i] !== "\n") i += src[i] === "\\" ? 2 : 1;
|
|
517
|
+
return i + 1;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const globalRe = (re) => (re.global ? re : new RegExp(re.source, `${re.flags}g`));
|
|
521
|
+
/** `source.replace(re, fn)`, applied only to matches that start in code (gh#1327). */
|
|
522
|
+
function replaceInCode(file, source, re, fn) {
|
|
523
|
+
const code = codeMask(file, source);
|
|
524
|
+
return source.replace(globalRe(re), (...args) => {
|
|
525
|
+
const offset = args[args.length - 2];
|
|
526
|
+
return code[offset] ? fn(...args) : args[0];
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
/** Every match of `re` that starts in code. */
|
|
530
|
+
function codeMatches(file, source, re) {
|
|
531
|
+
const code = codeMask(file, source);
|
|
532
|
+
return [...source.matchAll(globalRe(re))].filter((m) => code[m.index]);
|
|
533
|
+
}
|
|
534
|
+
const escapeRe = (s) => s.replace(/[$.]/g, "\\$&");
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* gh#1327: the local names `AppProvider` from @godxjp/ui (any subpath) is bound to in this file,
|
|
538
|
+
* aliases resolved. A JSX element named AppProvider that is the app's own wrapper is not one.
|
|
539
|
+
*/
|
|
540
|
+
function providerBindings(file, source) {
|
|
541
|
+
const names = new Set();
|
|
542
|
+
for (const m of codeMatches(file, source, IMPORT_RE)) {
|
|
543
|
+
if (m[1] || !/^@godxjp\/ui(\/|$)/.test(m[4])) continue;
|
|
544
|
+
for (const raw of m[2].split(",")) {
|
|
545
|
+
const [name, alias] = raw.trim().split(/\s+as\s+/);
|
|
546
|
+
if (name === "AppProvider") names.add(alias ?? name);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return [...names];
|
|
550
|
+
}
|
|
551
|
+
|
|
307
552
|
export function transformSource(file, source, { godx = false } = {}) {
|
|
308
553
|
let out = source;
|
|
309
554
|
const notes = [];
|
|
@@ -312,7 +557,7 @@ export function transformSource(file, source, { godx = false } = {}) {
|
|
|
312
557
|
const coreImport = isCss ? CORE_IMPORT_CSS : CORE_IMPORT_JS;
|
|
313
558
|
const written = (indent, q, spec) =>
|
|
314
559
|
isCss ? `${indent}@import ${q}${spec}${q};` : `${indent}import ${q}${spec}${q};`;
|
|
315
|
-
const m = out
|
|
560
|
+
const [m] = codeMatches(file, out, styleImport);
|
|
316
561
|
if (m) {
|
|
317
562
|
const [line, indent, q] = m;
|
|
318
563
|
const add = [];
|
|
@@ -323,58 +568,68 @@ export function transformSource(file, source, { godx = false } = {}) {
|
|
|
323
568
|
} else if (!out.includes(FONTS) && !out.includes("@godxjp/ui/styles/fonts")) {
|
|
324
569
|
add.push(FONTS);
|
|
325
570
|
}
|
|
326
|
-
if (add.length)
|
|
327
|
-
|
|
571
|
+
if (add.length) {
|
|
572
|
+
const at = m.index + line.length;
|
|
573
|
+
out = `${out.slice(0, at)}\n${add.map((spec) => written(indent, q, spec)).join("\n")}${out.slice(at)}`;
|
|
574
|
+
}
|
|
328
575
|
}
|
|
329
576
|
// styles/core (a shadow root or an app with its own fonts): the colour tokens, never the fonts.
|
|
330
|
-
const c = out
|
|
577
|
+
const [c] = codeMatches(file, out, coreImport);
|
|
331
578
|
if (c && godx && !out.includes(GODX_TOKENS_CSS) && !out.includes(GODX_CSS)) {
|
|
332
579
|
const [line, indent, q] = c;
|
|
333
|
-
|
|
580
|
+
const at = c.index + line.length;
|
|
581
|
+
out = `${out.slice(0, at)}\n${written(indent, q, GODX_TOKENS_CSS)}${out.slice(at)}`;
|
|
334
582
|
}
|
|
335
583
|
// A file the 32.0.0 codemod already touched carries BOTH godx.css and fonts.css: drop the second.
|
|
336
584
|
if (out.includes(GODX_CSS)) {
|
|
337
|
-
|
|
585
|
+
const [f] = codeMatches(
|
|
586
|
+
file,
|
|
587
|
+
out,
|
|
338
588
|
isCss
|
|
339
589
|
? /^[ \t]*@import\s+["']@godxjp\/ui\/styles\/fonts(?:\.css)?["']\s*;[ \t]*\n/m
|
|
340
590
|
: /^[ \t]*import\s+["']@godxjp\/ui\/styles\/fonts(?:\.css)?["']\s*;?[ \t]*\n/m,
|
|
341
|
-
"",
|
|
342
591
|
);
|
|
592
|
+
if (f) out = out.slice(0, f.index) + out.slice(f.index + f[0].length);
|
|
343
593
|
}
|
|
344
|
-
if (!isCss
|
|
345
|
-
|
|
346
|
-
for (const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
594
|
+
if (!isCss) {
|
|
595
|
+
// gh#1327: only elements bound to @godxjp/ui's AppProvider, under whatever local name.
|
|
596
|
+
for (const name of providerBindings(file, out)) {
|
|
597
|
+
const tagRe = new RegExp(`<${escapeRe(name)}(?=[\\s>/])[^>]*>`, "g");
|
|
598
|
+
out = replaceInCode(file, out, tagRe, (tag) => {
|
|
599
|
+
if (/\bpreset=/.test(tag)) return tag;
|
|
600
|
+
if (godx) return `<${name} preset={godxPreset}${tag.slice(name.length + 1)}`;
|
|
601
|
+
if (!/\bdefaultLocale=/.test(tag)) {
|
|
602
|
+
notes.push(
|
|
603
|
+
"<AppProvider> has no defaultLocale and no preset: v32 starts in <html lang> or en (was vi). " +
|
|
604
|
+
'Add defaultLocale="vi" if that is what you meant, or rerun with --godx.',
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
return tag;
|
|
608
|
+
});
|
|
356
609
|
}
|
|
357
610
|
if (
|
|
358
611
|
godx &&
|
|
359
|
-
out
|
|
612
|
+
codeMatches(file, out, /preset=\{godxPreset\}/).length &&
|
|
360
613
|
!/\bgodxPreset\b[^\n]*from\s+["']@godxjp\/ui\/themes\/godx["']/.test(out)
|
|
361
614
|
) {
|
|
362
|
-
out = addImport(out, 'import { godxPreset } from "@godxjp/ui/themes/godx";');
|
|
615
|
+
out = addImport(file, out, 'import { godxPreset } from "@godxjp/ui/themes/godx";');
|
|
363
616
|
}
|
|
364
617
|
}
|
|
365
|
-
if (!isCss) out = applyRenames(out, notes);
|
|
366
|
-
if (!isCss) out = narrowLocaleType(out, notes, godx);
|
|
618
|
+
if (!isCss) out = applyRenames(file, out, notes);
|
|
619
|
+
if (!isCss) out = narrowLocaleType(file, out, notes, godx);
|
|
367
620
|
return { out, changed: out !== source, notes };
|
|
368
621
|
}
|
|
369
622
|
|
|
370
623
|
const IMPORT_RE =
|
|
371
624
|
/import\s*(type\s+)?\{([^}]*)\}\s*from\s*(["'])(@godxjp\/[\w-]+(?:\/[\w-]+)?)\3\s*;?/g;
|
|
372
625
|
|
|
373
|
-
|
|
626
|
+
const DROPPED = "\u0000codemod-dropped-import\u0000";
|
|
627
|
+
|
|
628
|
+
function applyRenames(file, source, notes) {
|
|
374
629
|
let out = source;
|
|
375
630
|
const moved = new Map(); // toMod -> [{ spec, typeOnly }]
|
|
376
631
|
const jsxRenames = [];
|
|
377
|
-
out = out
|
|
632
|
+
out = replaceInCode(file, out, IMPORT_RE, (stmt, typeKw, body, q, mod) => {
|
|
378
633
|
const keep = [];
|
|
379
634
|
let touched = false;
|
|
380
635
|
for (const raw of body
|
|
@@ -398,16 +653,24 @@ function applyRenames(source, notes) {
|
|
|
398
653
|
const list = moved.get(toMod) ?? [];
|
|
399
654
|
list.push({ spec: newSpec, typeOnly: Boolean(typeKw) || inlineType });
|
|
400
655
|
moved.set(toMod, list);
|
|
401
|
-
|
|
656
|
+
// gh#1327: an aliased import keeps its local name, but still needs the merge's props.
|
|
657
|
+
if (alias && (r.add || r.map)) jsxRenames.push([alias, alias, r]);
|
|
658
|
+
else if (!alias && (toName !== name || r.add || r.map)) jsxRenames.push([name, toName, r]);
|
|
402
659
|
if (r.note) notes.push(`${name} → ${toName}${toMod !== mod ? ` (${toMod})` : ""}: ${r.note}`);
|
|
403
660
|
}
|
|
404
661
|
if (!touched) return stmt;
|
|
405
|
-
return keep.length
|
|
662
|
+
return keep.length
|
|
663
|
+
? `import ${typeKw ?? ""}{ ${keep.join(", ")} } from ${q}${mod}${q};`
|
|
664
|
+
: DROPPED;
|
|
406
665
|
});
|
|
666
|
+
// An emptied import goes with its own line break; nothing else is re-spaced (gh#1327).
|
|
667
|
+
out = out.replace(new RegExp(`${DROPPED}[ \\t]*\\n?`, "g"), "");
|
|
407
668
|
for (const [from, to, r] of jsxRenames) {
|
|
408
669
|
// Opening tags first, so the props the merge needs land on the element itself.
|
|
409
|
-
out =
|
|
410
|
-
|
|
670
|
+
out = replaceInCode(
|
|
671
|
+
file,
|
|
672
|
+
out,
|
|
673
|
+
new RegExp(`<${escapeRe(from)}(?=[\\s>/])([^>]*?)(/?)>`, "g"),
|
|
411
674
|
(_all, attrs, selfClose) => {
|
|
412
675
|
let a = attrs;
|
|
413
676
|
for (const [prop, values] of Object.entries(r.map ?? {})) {
|
|
@@ -429,7 +692,12 @@ function applyRenames(source, notes) {
|
|
|
429
692
|
},
|
|
430
693
|
);
|
|
431
694
|
// Closing tags and compound members (`</Callout>`, `<Callout.Title>`).
|
|
432
|
-
out =
|
|
695
|
+
out = replaceInCode(
|
|
696
|
+
file,
|
|
697
|
+
out,
|
|
698
|
+
new RegExp(`(</|<)${escapeRe(from)}(?=[.>])`, "g"),
|
|
699
|
+
(_all, open) => `${open}${to}`,
|
|
700
|
+
);
|
|
433
701
|
}
|
|
434
702
|
for (const [toMod, specs] of moved) {
|
|
435
703
|
for (const typeOnly of [false, true]) {
|
|
@@ -438,26 +706,27 @@ function applyRenames(source, notes) {
|
|
|
438
706
|
const existing = new RegExp(
|
|
439
707
|
`import\\s*${typeOnly ? "type\\s+" : ""}\\{([^}]*)\\}\\s*from\\s*(["'])${toMod.replace(/[/]/g, "\\/")}\\2\\s*;?`,
|
|
440
708
|
);
|
|
441
|
-
const m = out
|
|
709
|
+
const [m] = codeMatches(file, out, existing);
|
|
442
710
|
if (m) {
|
|
443
711
|
const have = m[1]
|
|
444
712
|
.split(",")
|
|
445
713
|
.map((x) => x.trim())
|
|
446
714
|
.filter(Boolean);
|
|
447
715
|
const merged = [...new Set([...have, ...names])];
|
|
448
|
-
out =
|
|
449
|
-
|
|
450
|
-
`import ${typeOnly ? "type " : ""}{ ${merged.join(", ")} } from ${m[2]}${toMod}${m[2]}
|
|
451
|
-
|
|
716
|
+
out =
|
|
717
|
+
out.slice(0, m.index) +
|
|
718
|
+
`import ${typeOnly ? "type " : ""}{ ${merged.join(", ")} } from ${m[2]}${toMod}${m[2]};` +
|
|
719
|
+
out.slice(m.index + m[0].length);
|
|
452
720
|
} else {
|
|
453
721
|
out = addImport(
|
|
722
|
+
file,
|
|
454
723
|
out,
|
|
455
724
|
`import ${typeOnly ? "type " : ""}{ ${[...new Set(names)].join(", ")} } from "${toMod}";`,
|
|
456
725
|
);
|
|
457
726
|
}
|
|
458
727
|
}
|
|
459
728
|
}
|
|
460
|
-
return out
|
|
729
|
+
return out;
|
|
461
730
|
}
|
|
462
731
|
|
|
463
732
|
/**
|
|
@@ -466,8 +735,10 @@ function applyRenames(source, notes) {
|
|
|
466
735
|
* A GoDX product only uses the built-ins, so under --godx the type is renamed (behaviour unchanged).
|
|
467
736
|
* Otherwise it is reported: the app decides whether it will register other locales.
|
|
468
737
|
*/
|
|
469
|
-
function narrowLocaleType(source, notes, godx) {
|
|
470
|
-
const imp =
|
|
738
|
+
function narrowLocaleType(file, source, notes, godx) {
|
|
739
|
+
const [imp] = codeMatches(
|
|
740
|
+
file,
|
|
741
|
+
source,
|
|
471
742
|
/import\s*(type\s+)?\{[^}]*\bAppLocale\b[^}]*\}\s*from\s*["']@godxjp\/ui(?:\/app)?["']/,
|
|
472
743
|
);
|
|
473
744
|
if (!imp) return source;
|
|
@@ -477,11 +748,11 @@ function narrowLocaleType(source, notes, godx) {
|
|
|
477
748
|
);
|
|
478
749
|
return source;
|
|
479
750
|
}
|
|
480
|
-
return source
|
|
751
|
+
return replaceInCode(file, source, /\bAppLocale\b/g, () => "BuiltInLocale");
|
|
481
752
|
}
|
|
482
753
|
|
|
483
|
-
function addImport(source, line) {
|
|
484
|
-
const imports =
|
|
754
|
+
function addImport(file, source, line) {
|
|
755
|
+
const imports = codeMatches(file, source, /^import[^;]*;[ \t]*$/gm);
|
|
485
756
|
if (!imports.length) return `${line}\n${source}`;
|
|
486
757
|
const last = imports[imports.length - 1];
|
|
487
758
|
const at = last.index + last[0].length;
|
|
@@ -4,11 +4,25 @@ import {
|
|
|
4
4
|
ensureClaudeHooks,
|
|
5
5
|
ensureClaudeMd,
|
|
6
6
|
ensureMcpJson,
|
|
7
|
+
ensureUserGuard,
|
|
7
8
|
outOfProjectUiMcpReport,
|
|
8
9
|
shouldSkip,
|
|
9
10
|
writeWorkflowMd,
|
|
10
11
|
} from "./_agent-setup.mjs";
|
|
11
12
|
|
|
13
|
+
// `--user` (gh#1324): the user-level guard. It touches no project file, so it needs no consumer
|
|
14
|
+
// package.json and is not refused anywhere — it is checked before shouldSkip on purpose.
|
|
15
|
+
if (process.argv.includes("--user")) {
|
|
16
|
+
console.log("\n @godxjp/ui user-level guard:");
|
|
17
|
+
for (const line of ensureUserGuard()) console.log(` • ${line}`);
|
|
18
|
+
console.log(`
|
|
19
|
+
Every Claude Code session now loads it, wherever it starts. Restart running sessions.
|
|
20
|
+
A .tsx/.jsx write in a repo whose .mcp.json mounts @godxjp/ui-mcp is blocked until the agent
|
|
21
|
+
consults the catalog; without the MCP it can use npx --no-install @godxjp/ui mcp-call <tool>.
|
|
22
|
+
`);
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
|
|
12
26
|
const root = process.env.INIT_CWD || process.cwd();
|
|
13
27
|
|
|
14
28
|
const skip = shouldSkip(root);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `godxjp-ui mcp-call <tool> [json-args]` — query the godx-ui catalog without an MCP client
|
|
4
|
+
* (gh#1324). It launches EXACTLY what the MCP client would — the repo's `.mcp.json` catalog entry
|
|
5
|
+
* (command, args, env), or the version-pinned default when there is none — with `call <tool> …`
|
|
6
|
+
* appended, so the answer comes from the same pinned server and the same `dispatchTool`.
|
|
7
|
+
*
|
|
8
|
+
* Network: the same as the MCP itself — `npx @godxjp/ui-mcp@<pin>` may download the server on a
|
|
9
|
+
* cold npx cache, exactly as starting the MCP would.
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
|
|
13
|
+
import { findUiMcpConfig, mcpServerFor } from "./_agent-setup.mjs";
|
|
14
|
+
|
|
15
|
+
const root = process.env.INIT_CWD || process.cwd();
|
|
16
|
+
const found = findUiMcpConfig(root);
|
|
17
|
+
const entry = found?.entry ?? mcpServerFor(root);
|
|
18
|
+
const args = [...(Array.isArray(entry.args) ? entry.args : []), "call", ...process.argv.slice(2)];
|
|
19
|
+
|
|
20
|
+
const r = spawnSync(entry.command, args, {
|
|
21
|
+
cwd: found?.dir ?? root,
|
|
22
|
+
env: { ...process.env, ...(entry.env ?? {}) },
|
|
23
|
+
// stdin closed: a server too old to know `call` starts its stdio loop, reads EOF and exits,
|
|
24
|
+
// instead of waiting forever on the agent's terminal.
|
|
25
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
26
|
+
encoding: "utf8",
|
|
27
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
28
|
+
});
|
|
29
|
+
if (r.error) {
|
|
30
|
+
console.error(`mcp-call: could not launch ${entry.command}: ${r.error.message}`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
if (!r.stdout && r.status === 0) {
|
|
34
|
+
console.error(
|
|
35
|
+
`mcp-call: ${[entry.command, ...(entry.args ?? [])].join(" ")} printed nothing — that ` +
|
|
36
|
+
"@godxjp/ui-mcp predates `call`. Upgrade @godxjp/ui and run `npx godxjp-ui sync-rules`.",
|
|
37
|
+
);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
process.stdout.write(r.stdout ?? "", () => process.exit(r.status ?? 1));
|
package/scripts/ui-audit.mjs
CHANGED
|
@@ -337,6 +337,9 @@ const CARD_LIMIT = 5;
|
|
|
337
337
|
const BADGE_LIMIT = 5;
|
|
338
338
|
const ALERT_LIMIT = 4;
|
|
339
339
|
const STATCARD_ROW = 3;
|
|
340
|
+
/** design-complex-admin anti-pattern #3: 3+ fields is a create flow, not a filter (gh#1324). */
|
|
341
|
+
const FORM_ON_LIST_FIELDS = 3;
|
|
342
|
+
const LIST_TAGS = new Set(["DataTable", "Table", "List", "ListRow"]);
|
|
340
343
|
|
|
341
344
|
/**
|
|
342
345
|
* @type {{id:string, severity:'error'|'warn', test:RegExp, message:string, standard?:string,
|
|
@@ -987,6 +990,16 @@ const RULES = [
|
|
|
987
990
|
matches: kpiRowMatches,
|
|
988
991
|
message: `${STATCARD_ROW}+ StatCards outside a dashboard — a row count is not a KPI. Keep StatCard rows for a dashboard with comparison or trend (delta); a list shows its total in Pagination showTotal.`,
|
|
989
992
|
},
|
|
993
|
+
{
|
|
994
|
+
id: "form-on-list-page",
|
|
995
|
+
replacement:
|
|
996
|
+
"the create form on its own route (or a focused Dialog/Sheet); the list page links to it",
|
|
997
|
+
scope: "consumer",
|
|
998
|
+
severity: "warn",
|
|
999
|
+
spansElement: true,
|
|
1000
|
+
matches: formOnListPageMatches,
|
|
1001
|
+
message: `A Form with ${FORM_ON_LIST_FIELDS}+ FormFields and a DataTable/Table/List/ListRow on the same page — a create form inlined beside its list. Give create its own route (or a focused Dialog/Sheet) and keep the list page a list (design-complex-admin anti-pattern #3).`,
|
|
1002
|
+
},
|
|
990
1003
|
{
|
|
991
1004
|
id: "empty-state-icon-in-card",
|
|
992
1005
|
replacement: 'EmptyState title + action, no icon (or variant="compact")',
|
|
@@ -1783,6 +1796,70 @@ function* nestedCardMatches(source) {
|
|
|
1783
1796
|
}
|
|
1784
1797
|
}
|
|
1785
1798
|
|
|
1799
|
+
/** Overlay surfaces: a form inside one is the focused create flow, not an inline one. */
|
|
1800
|
+
const OVERLAY_TAG = /^(?:Dialog|AlertDialog|Sheet|Drawer|Popover|Modal)/;
|
|
1801
|
+
/**
|
|
1802
|
+
* `onSubmit={(e) => e.preventDefault()}` — a Form that never submits is a filter or parameter bar
|
|
1803
|
+
* laid out with FormFields, not a create flow. Measured on the consumer repos (gh#1324): all 4
|
|
1804
|
+
* first-draft hits had this shape (2 filter bars, 1 review-workspace editor, 1 precheck parameter
|
|
1805
|
+
* form), and none was a create form.
|
|
1806
|
+
*/
|
|
1807
|
+
const NO_OP_SUBMIT =
|
|
1808
|
+
/\bonSubmit\s*=\s*\{\s*\(?\s*(\w+)\s*\)?\s*=>\s*(?:\{\s*)?\1\.preventDefault\(\)\s*;?\s*\}?\s*\}/;
|
|
1809
|
+
|
|
1810
|
+
/**
|
|
1811
|
+
* `form-on-list-page`: a Form holding ≥FORM_ON_LIST_FIELDS FormFields and a list surface
|
|
1812
|
+
* (DataTable / Table / List / ListRow) in the same file — unless each sits in a DIFFERENT
|
|
1813
|
+
* PageContainer (a file that renders a list page and a create page pairs nothing across them; a
|
|
1814
|
+
* create sub-component with no PageContainer of its own still pairs with the page's list) —
|
|
1815
|
+
* neither inside an overlay, the list not inside the
|
|
1816
|
+
* Form (an editor with a line-items Table is a form, not a list page), and the Form actually
|
|
1817
|
+
* submitting (see NO_OP_SUBMIT). One finding per such Form.
|
|
1818
|
+
*/
|
|
1819
|
+
function* formOnListPageMatches(source) {
|
|
1820
|
+
const opened = []; // Forms whose children frame has not been seen yet
|
|
1821
|
+
const byFrame = new Map(); // a Form's children-frame id → { index, openTag, scope, skip, fields }
|
|
1822
|
+
const listScopes = new Set(); // PageContainer children-frame ids (or "file") holding a list
|
|
1823
|
+
for (const { tag, index, openTag, stack } of jsxElements(source)) {
|
|
1824
|
+
// A Form's children frame is pushed when its opening tag closes, so the first time an unknown
|
|
1825
|
+
// one shows up in a stack it belongs to the LATEST Form still waiting (a Form with no element
|
|
1826
|
+
// children never claims one and simply stays behind).
|
|
1827
|
+
for (const f of stack) {
|
|
1828
|
+
if (f.tag === "Form" && !f.attr && !byFrame.has(f.id) && opened.length) {
|
|
1829
|
+
byFrame.set(f.id, opened.pop());
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
const overlay = stack.some((f) => OVERLAY_TAG.test(f.tag));
|
|
1833
|
+
const scope = stack.findLast((f) => f.tag === "PageContainer" && !f.attr)?.id ?? "file";
|
|
1834
|
+
if (tag === "Form") {
|
|
1835
|
+
if (!/\/\s*>$/.test(openTag)) {
|
|
1836
|
+
const attrs = topLevelAttrs(openTag);
|
|
1837
|
+
// No onSubmit / action / spread: the Form cannot submit, so it is a filter or parameter
|
|
1838
|
+
// bar laid out with FormFields, like the NO_OP_SUBMIT shape.
|
|
1839
|
+
const submits = /\b(?:onSubmit|action)\s*=/.test(attrs) || /\{\s*\.\.\./.test(openTag);
|
|
1840
|
+
const skip = overlay || !submits || NO_OP_SUBMIT.test(openTag);
|
|
1841
|
+
opened.push({ index, openTag, scope, skip, fields: 0 });
|
|
1842
|
+
}
|
|
1843
|
+
} else if (tag === "FormField") {
|
|
1844
|
+
const form = stack.findLast((f) => f.tag === "Form" && !f.attr);
|
|
1845
|
+
const entry = form && byFrame.get(form.id);
|
|
1846
|
+
// `label={false}` is a layout slot (the submit row), not a field the user fills in.
|
|
1847
|
+
const isField = !/\blabel\s*=\s*\{\s*false\s*\}/.test(openTag);
|
|
1848
|
+
if (entry && isField) entry.fields += 1;
|
|
1849
|
+
} else if (LIST_TAGS.has(tag) && !overlay && !stack.some((f) => f.tag === "Form")) {
|
|
1850
|
+
listScopes.add(scope);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
for (const form of byFrame.values()) {
|
|
1854
|
+
const samePage =
|
|
1855
|
+
listScopes.size > 0 &&
|
|
1856
|
+
(form.scope === "file" || listScopes.has("file") || listScopes.has(form.scope));
|
|
1857
|
+
if (!form.skip && form.fields >= FORM_ON_LIST_FIELDS && samePage) {
|
|
1858
|
+
yield { 0: `${form.fields} FormFields beside a list: ${form.openTag}`, index: form.index };
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1786
1863
|
/** ≥3 StatCards in a file whose path does not say it is a dashboard. */
|
|
1787
1864
|
function* kpiRowMatches(source, rel = "") {
|
|
1788
1865
|
if (/dashboard/i.test(rel)) return;
|