@vegastack/design 0.8.34 → 0.8.36
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/bin/doctor.mjs
CHANGED
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
// (`prose` · `default` · `full`) and pads with the shared page gutter, so a `max-w-*`, `mx-*`,
|
|
23
23
|
// `w-*` or padding class on one is a page choosing its own width or gutter, and fails.
|
|
24
24
|
//
|
|
25
|
+
// And it warns where code works around a DS part instead of using it — a Tailwind `!` on a ui part,
|
|
26
|
+
// a width class on a popover or menu popup, a hand-written `<header>` inside overlay content, an
|
|
27
|
+
// `AppShellPage size` that disagrees with the route map. Those four warn by default and fail under
|
|
28
|
+
// `--strict`, so a consumer can take the release, fix what they report, and then turn the gate on.
|
|
29
|
+
//
|
|
25
30
|
// Read-only: it never writes, installs, or edits. Exit 0 = all good, 1 = a real problem,
|
|
26
31
|
// so it composes into CI as `vegastack-design doctor`.
|
|
27
32
|
|
|
@@ -52,11 +57,19 @@ Usage: vegastack-design doctor [options]
|
|
|
52
57
|
|
|
53
58
|
Checks a consuming project's VegaStack setup and reports what is wrong and how to fix it.
|
|
54
59
|
Also scans the project's own source (not node_modules, build output, or the components.json
|
|
55
|
-
\`ui\` alias directory)
|
|
56
|
-
|
|
60
|
+
\`ui\` alias directory) and reports each finding as file:line:
|
|
61
|
+
|
|
62
|
+
retired vocabulary a utility, token or component the shadcn reset retired (fails)
|
|
63
|
+
page layout an AppShellPage class that sets the page's width or gutter (fails)
|
|
64
|
+
important on DS parts a Tailwind \`!\` modifier in the className of a ui part (warns)
|
|
65
|
+
popup width w-*/min-w-*/max-w-* on a popover, menu or select popup (warns)
|
|
66
|
+
overlay header a hand-written <header> inside Sheet/Drawer/Dialog content (warns)
|
|
67
|
+
page width vs route map an AppShellPage size in an App Router page or layout that
|
|
68
|
+
disagrees with the definePageWidths route map (warns)
|
|
57
69
|
|
|
58
70
|
Options:
|
|
59
71
|
--dir <path> Project root to inspect (default: the current directory)
|
|
72
|
+
--strict Fail (exit 1) on the four composition checks instead of warning
|
|
60
73
|
-h, --help Show this help
|
|
61
74
|
|
|
62
75
|
Exit codes: 0 = no problems · 1 = at least one failure · 2 = bad usage
|
|
@@ -384,6 +397,9 @@ function importsIntoUi(specDir, { file, uiAliases, uiDirs }) {
|
|
|
384
397
|
* opens only where an expression can begin (after `=`, `(`, `,`, `[`, `{`, `:`, `?`, an operator,
|
|
385
398
|
* `=>`, or a keyword such as `return`), and a comment only after whitespace or code punctuation,
|
|
386
399
|
* never after `:` (`https://`) or a word (`src/*.ts`).
|
|
400
|
+
*
|
|
401
|
+
* `blankStrings` blanks EVERY string's content (quotes kept): the structure copy the composition
|
|
402
|
+
* scan finds tags in, where no string may open or close one.
|
|
387
403
|
*/
|
|
388
404
|
const EXPRESSION_KEYWORDS = new Set([
|
|
389
405
|
"return",
|
|
@@ -419,9 +435,34 @@ function canStartExpression(src, i) {
|
|
|
419
435
|
}
|
|
420
436
|
const canStartComment = (src, i) => i === 0 || /[\s;,{}()[\]]/.test(src[i - 1]);
|
|
421
437
|
|
|
438
|
+
/**
|
|
439
|
+
* The index of the quote closing the string that opens at `i` (or of the line end / file end where
|
|
440
|
+
* an unterminated one gives up). A template's `${…}` is skipped whole — braces counted, strings and
|
|
441
|
+
* nested templates inside it skipped — so a backtick in an interpolation does not end the template.
|
|
442
|
+
*/
|
|
443
|
+
function stringEnd(src, i) {
|
|
444
|
+
const q = src[i];
|
|
445
|
+
let j = i + 1;
|
|
446
|
+
while (j < src.length && src[j] !== q) {
|
|
447
|
+
if (src[j] === "\\") j += 1;
|
|
448
|
+
else if (src[j] === "\n" && q !== "`") break;
|
|
449
|
+
else if (q === "`" && src[j] === "$" && src[j + 1] === "{") {
|
|
450
|
+
let depth = 0;
|
|
451
|
+
for (j += 1; j < src.length; j += 1) {
|
|
452
|
+
const ch = src[j];
|
|
453
|
+
if (ch === '"' || ch === "'" || ch === "`") j = stringEnd(src, j);
|
|
454
|
+
else if (ch === "{") depth += 1;
|
|
455
|
+
else if (ch === "}" && (depth -= 1) === 0) break;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
j += 1;
|
|
459
|
+
}
|
|
460
|
+
return j;
|
|
461
|
+
}
|
|
462
|
+
|
|
422
463
|
export function maskNonUsage(
|
|
423
464
|
src,
|
|
424
|
-
{ script = true, lineComments = script } = {},
|
|
465
|
+
{ script = true, lineComments = script, blankStrings = false } = {},
|
|
425
466
|
) {
|
|
426
467
|
const out = src.split("");
|
|
427
468
|
const blank = (from, to) => {
|
|
@@ -454,13 +495,9 @@ export function maskNonUsage(
|
|
|
454
495
|
(c === '"' || c === "'" || c === "`") &&
|
|
455
496
|
(!script || canStartExpression(src, i))
|
|
456
497
|
) {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
else if (src[j] === "\n" && c !== "`") break;
|
|
461
|
-
j += 1;
|
|
462
|
-
}
|
|
463
|
-
if (script && isProse(src.slice(i + 1, j))) blank(i + 1, j);
|
|
498
|
+
const j = stringEnd(src, i);
|
|
499
|
+
if (script && (blankStrings || isProse(src.slice(i + 1, j))))
|
|
500
|
+
blank(i + 1, j);
|
|
464
501
|
i = j + 1;
|
|
465
502
|
} else {
|
|
466
503
|
i += 1;
|
|
@@ -641,6 +678,719 @@ export function scanPageLayout(root, { skipDirs = [] } = {}) {
|
|
|
641
678
|
return { findings, deprecated };
|
|
642
679
|
}
|
|
643
680
|
|
|
681
|
+
// ---- composition: DS parts used as designed --------------------------------------------------
|
|
682
|
+
// Four lexical checks over the project's own JSX. No parser: each file is read twice over the same
|
|
683
|
+
// offsets — a STRUCTURE copy with comments and every string's content blanked (quotes kept), where
|
|
684
|
+
// tags, attributes and braces are found, and the source itself, where values are read. Blanking the
|
|
685
|
+
// strings is what keeps a `"</SheetContent>"` or a `'<header>'` in a string from opening or closing
|
|
686
|
+
// anything, and an apostrophe in JSX text ("Don't") from swallowing the rest of a tag.
|
|
687
|
+
// They report as WARNINGS by default and as failures under `--strict`, so a consumer can adopt a
|
|
688
|
+
// new release, fix what it reports, and then turn the gate on.
|
|
689
|
+
//
|
|
690
|
+
// Known limits, all toward silence: a part re-exported through the project's own module is not a
|
|
691
|
+
// ui import; a `<header>` counts only when it sits inside the overlay content in the SAME file (a
|
|
692
|
+
// child component's header is not seen); a JSX attribute string ending in a backslash
|
|
693
|
+
// (`title="C:\"`) or spanning lines can desynchronise that tag's scan; route-map rows count only as
|
|
694
|
+
// string literals; a regex literal holding a brace inside a JSX attribute expression can end that
|
|
695
|
+
// expression early; and an `AppShellPage` outside an App Router page or layout file, or with a
|
|
696
|
+
// `size={…}` expression, is counted rather than compared.
|
|
697
|
+
|
|
698
|
+
/** Popover and menu popups whose width the part owns: `PopoverContent size`, or the longest item. */
|
|
699
|
+
export const WIDTH_OWNING_CONTENT = [
|
|
700
|
+
"PopoverContent",
|
|
701
|
+
"DropdownMenuContent",
|
|
702
|
+
"ContextMenuContent",
|
|
703
|
+
"MenubarContent",
|
|
704
|
+
"SelectContent",
|
|
705
|
+
"ComboboxContent",
|
|
706
|
+
"HoverCardContent",
|
|
707
|
+
];
|
|
708
|
+
|
|
709
|
+
/** Overlay panels whose header the part owns, with the part that renders it. */
|
|
710
|
+
export const OVERLAY_HEADERS = {
|
|
711
|
+
SheetContent: "SheetHeader",
|
|
712
|
+
DrawerContent: "DrawerHeader",
|
|
713
|
+
DialogContent: "DialogHeader",
|
|
714
|
+
AlertDialogContent: "AlertDialogHeader",
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
/** From a `{` at `j` in the structure copy, the index just past its matching `}`. */
|
|
718
|
+
function skipBraces(scan, j) {
|
|
719
|
+
let depth = 0;
|
|
720
|
+
for (let k = j; k < scan.length; k += 1) {
|
|
721
|
+
const c = scan[k];
|
|
722
|
+
if (c === "{") depth += 1;
|
|
723
|
+
else if (c === "}") {
|
|
724
|
+
depth -= 1;
|
|
725
|
+
if (depth === 0) return k + 1;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return scan.length;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** From a `<` at `j` (TSX type arguments: `<DataGrid<Row> …>`), the index just past its `>`. */
|
|
732
|
+
function skipTypeArguments(scan, j) {
|
|
733
|
+
let depth = 0;
|
|
734
|
+
let braces = 0; // an object type: `<DataGrid<{ id: string; name: string }>`
|
|
735
|
+
const limit = Math.min(scan.length, j + 2000);
|
|
736
|
+
for (let k = j; k < limit; k += 1) {
|
|
737
|
+
const c = scan[k];
|
|
738
|
+
if (c === "{") braces += 1;
|
|
739
|
+
else if (c === "}") {
|
|
740
|
+
if ((braces -= 1) < 0) return -1;
|
|
741
|
+
} else if (braces > 0) continue;
|
|
742
|
+
else if (c === "<") depth += 1;
|
|
743
|
+
else if (c === ">" && scan[k - 1] !== "=") {
|
|
744
|
+
depth -= 1;
|
|
745
|
+
if (depth === 0) return k + 1;
|
|
746
|
+
} else if (c === ";") return -1;
|
|
747
|
+
}
|
|
748
|
+
return -1;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** The `>` closing an opening tag whose attributes start at `j`, or -1 when it is not a tag. */
|
|
752
|
+
function tagEnd(scan, j) {
|
|
753
|
+
const limit = Math.min(scan.length, j + 20000);
|
|
754
|
+
let k = j;
|
|
755
|
+
while (k < limit) {
|
|
756
|
+
const c = scan[k];
|
|
757
|
+
if (c === '"' || c === "'") {
|
|
758
|
+
const close = scan.indexOf(c, k + 1);
|
|
759
|
+
if (close === -1) return -1;
|
|
760
|
+
k = close + 1;
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (c === "{") {
|
|
764
|
+
k = skipBraces(scan, k);
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
if (c === ">") return k;
|
|
768
|
+
if (c === "<" || c === ";" || c === "}" || c === ")") return -1;
|
|
769
|
+
k += 1;
|
|
770
|
+
}
|
|
771
|
+
return -1;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const OPEN_TAG = /<([A-Za-z][\w.]*)(?=[\s/><])/y;
|
|
775
|
+
const CLOSE_TAG = /<\/\s*([A-Za-z][\w.]*)?\s*>/y;
|
|
776
|
+
|
|
777
|
+
/** Every JSX opening and closing tag in a structure copy, in source order. */
|
|
778
|
+
export function jsxTags(scan) {
|
|
779
|
+
const tags = [];
|
|
780
|
+
for (let i = scan.indexOf("<"); i !== -1; i = scan.indexOf("<", i + 1)) {
|
|
781
|
+
CLOSE_TAG.lastIndex = i;
|
|
782
|
+
const close = CLOSE_TAG.exec(scan);
|
|
783
|
+
if (close) {
|
|
784
|
+
tags.push({ kind: "close", name: close[1] ?? "", index: i });
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
OPEN_TAG.lastIndex = i;
|
|
788
|
+
const open = OPEN_TAG.exec(scan);
|
|
789
|
+
if (!open) continue;
|
|
790
|
+
let start = i + open[0].length;
|
|
791
|
+
if (scan[start] === "<") {
|
|
792
|
+
start = skipTypeArguments(scan, start);
|
|
793
|
+
if (start === -1) continue;
|
|
794
|
+
}
|
|
795
|
+
const end = tagEnd(scan, start);
|
|
796
|
+
if (end === -1) continue;
|
|
797
|
+
tags.push({
|
|
798
|
+
kind: "open",
|
|
799
|
+
name: open[1],
|
|
800
|
+
attrsAt: start,
|
|
801
|
+
attrsEnd: end,
|
|
802
|
+
selfClosing: scan[end - 1] === "/",
|
|
803
|
+
index: i,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
return tags;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* An opening tag's own attributes — never one on a JSX element nested in an attribute's value
|
|
811
|
+
* (`render={<div className="…" />}`): `name → { from, to, kind }`, `kind` "string" or "expr".
|
|
812
|
+
*/
|
|
813
|
+
export function tagAttributes(tag, scan) {
|
|
814
|
+
const out = new Map();
|
|
815
|
+
const spreads = []; // the order of each `{...props}`, which may set any attribute after it
|
|
816
|
+
out.set("...", spreads);
|
|
817
|
+
const NAME = /[^\s=/>{"']+/y;
|
|
818
|
+
let k = tag.attrsAt;
|
|
819
|
+
while (k < tag.attrsEnd) {
|
|
820
|
+
const c = scan[k];
|
|
821
|
+
if (c === "{") {
|
|
822
|
+
spreads.push(out.size);
|
|
823
|
+
k = skipBraces(scan, k);
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
NAME.lastIndex = k;
|
|
827
|
+
const name = NAME.exec(scan);
|
|
828
|
+
if (!name) {
|
|
829
|
+
k += 1;
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
let v = k + name[0].length;
|
|
833
|
+
while (v < tag.attrsEnd && /\s/.test(scan[v])) v += 1;
|
|
834
|
+
if (scan[v] !== "=") {
|
|
835
|
+
out.set(name[0], { kind: "flag", order: out.size });
|
|
836
|
+
k = v;
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
v += 1;
|
|
840
|
+
while (v < tag.attrsEnd && /\s/.test(scan[v])) v += 1;
|
|
841
|
+
const q = scan[v];
|
|
842
|
+
if (q === '"' || q === "'") {
|
|
843
|
+
const close = scan.indexOf(q, v + 1);
|
|
844
|
+
out.set(name[0], {
|
|
845
|
+
kind: "string",
|
|
846
|
+
from: v + 1,
|
|
847
|
+
to: close,
|
|
848
|
+
order: out.size,
|
|
849
|
+
});
|
|
850
|
+
k = close + 1;
|
|
851
|
+
} else if (q === "{") {
|
|
852
|
+
const close = skipBraces(scan, v);
|
|
853
|
+
out.set(name[0], {
|
|
854
|
+
kind: "expr",
|
|
855
|
+
from: v + 1,
|
|
856
|
+
to: close - 1,
|
|
857
|
+
order: out.size,
|
|
858
|
+
});
|
|
859
|
+
k = close;
|
|
860
|
+
} else k = v + 1;
|
|
861
|
+
}
|
|
862
|
+
return out;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** An attribute's string value when it is a literal (`size="full"`, `size={"full"}`), else null. */
|
|
866
|
+
function literalValue(attr, scan, read) {
|
|
867
|
+
if (!attr || attr.kind === "flag") return null;
|
|
868
|
+
if (attr.kind === "string")
|
|
869
|
+
return { text: read.slice(attr.from, attr.to), at: attr.from };
|
|
870
|
+
const m = /^(\s*)(["'`])([^"'`]*)\2\s*$/.exec(scan.slice(attr.from, attr.to));
|
|
871
|
+
if (!m) return null;
|
|
872
|
+
const at = attr.from + m[1].length + 1;
|
|
873
|
+
const text = read.slice(at, at + m[3].length);
|
|
874
|
+
return m[2] === "`" && text.includes("${") ? null : { text, at };
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* The literal text a template spells out: its static parts, and inside each `${…}` every string
|
|
879
|
+
* literal and (recursively) every nested template — the classes a conditional interpolation adds.
|
|
880
|
+
*/
|
|
881
|
+
function templateFragments(text, at) {
|
|
882
|
+
const out = [];
|
|
883
|
+
let start = 0;
|
|
884
|
+
let k = 0;
|
|
885
|
+
while (k < text.length) {
|
|
886
|
+
if (text[k] === "\\") {
|
|
887
|
+
k += 2;
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
if (text[k] === "$" && text[k + 1] === "{") {
|
|
891
|
+
out.push({ text: text.slice(start, k), at: at + start });
|
|
892
|
+
let depth = 0;
|
|
893
|
+
let e = k + 1;
|
|
894
|
+
for (; e < text.length; e += 1) {
|
|
895
|
+
const c = text[e];
|
|
896
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
897
|
+
const close = Math.min(stringEnd(text, e), text.length);
|
|
898
|
+
const inner = text.slice(e + 1, close);
|
|
899
|
+
if (c === "`") out.push(...templateFragments(inner, at + e + 1));
|
|
900
|
+
else out.push({ text: inner, at: at + e + 1 });
|
|
901
|
+
e = close;
|
|
902
|
+
} else if (c === "{") depth += 1;
|
|
903
|
+
else if (c === "}" && (depth -= 1) === 0) break;
|
|
904
|
+
}
|
|
905
|
+
k = e + 1;
|
|
906
|
+
start = k;
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
k += 1;
|
|
910
|
+
}
|
|
911
|
+
out.push({ text: text.slice(start), at: at + start });
|
|
912
|
+
return out;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** The class tokens a `className` value spells out in string literals, each with its offset. */
|
|
916
|
+
function classTokens(attr, scan, read) {
|
|
917
|
+
const spans = [];
|
|
918
|
+
if (attr.kind === "string")
|
|
919
|
+
spans.push({ text: read.slice(attr.from, attr.to), at: attr.from });
|
|
920
|
+
else if (attr.kind === "expr")
|
|
921
|
+
for (let k = attr.from; k < attr.to; k += 1) {
|
|
922
|
+
const q = scan[k];
|
|
923
|
+
if (q !== '"' && q !== "'" && q !== "`") continue;
|
|
924
|
+
const close = scan.indexOf(q, k + 1);
|
|
925
|
+
if (close === -1 || close > attr.to) break;
|
|
926
|
+
const text = read.slice(k + 1, close);
|
|
927
|
+
if (q === "`") spans.push(...templateFragments(text, k + 1));
|
|
928
|
+
else spans.push({ text, at: k + 1 });
|
|
929
|
+
k = close;
|
|
930
|
+
}
|
|
931
|
+
const tokens = [];
|
|
932
|
+
for (const span of spans)
|
|
933
|
+
for (const t of span.text.matchAll(/\S+/g))
|
|
934
|
+
tokens.push({ token: t[0], at: span.at + t.index });
|
|
935
|
+
return tokens;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** The utility a class token applies, with its variants (`md:`, `data-[x]:`) removed. */
|
|
939
|
+
function utilityOf(token) {
|
|
940
|
+
let depth = 0;
|
|
941
|
+
let last = 0;
|
|
942
|
+
for (let k = 0; k < token.length; k += 1) {
|
|
943
|
+
const c = token[k];
|
|
944
|
+
if (c === "[" || c === "(") depth += 1;
|
|
945
|
+
else if (c === "]" || c === ")") depth -= 1;
|
|
946
|
+
else if (c === ":" && depth === 0) last = k + 1;
|
|
947
|
+
}
|
|
948
|
+
return token.slice(last);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const isImportant = (utility) =>
|
|
952
|
+
utility.length > 1 &&
|
|
953
|
+
/^!?-?[a-z[(@*]/.test(utility) &&
|
|
954
|
+
(utility.startsWith("!") || utility.endsWith("!"));
|
|
955
|
+
const isWidthClass = (utility) =>
|
|
956
|
+
/^-?(?:w|min-w|max-w)-\S/.test(utility.replace(/^!|!$/g, ""));
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* The project's names for parts imported from the ui alias directory: `local → part`, with a
|
|
960
|
+
* namespace import (`import * as Ui from "@/components/ui/popover"`) recorded as `Ui → "*"`.
|
|
961
|
+
*/
|
|
962
|
+
export function uiImports(src, file, { uiAliases, uiDirs }) {
|
|
963
|
+
const intoUi = (spec) => {
|
|
964
|
+
if (uiAliases.some((a) => spec === a || spec.startsWith(`${a}/`)))
|
|
965
|
+
return true;
|
|
966
|
+
if (!/^\.\.?(?:\/|$)/.test(spec)) return false;
|
|
967
|
+
const target = resolve(dirname(file), spec);
|
|
968
|
+
return uiDirs.some((d) => target === d || target.startsWith(d + sep));
|
|
969
|
+
};
|
|
970
|
+
const names = new Map();
|
|
971
|
+
for (const m of src.matchAll(
|
|
972
|
+
/\bimport\s+(?:type\s+)?([\w$*{}\s,]+?)\s*from\s*["']([^"']+)["']/g,
|
|
973
|
+
)) {
|
|
974
|
+
if (!intoUi(m[2])) continue;
|
|
975
|
+
const clause = m[1];
|
|
976
|
+
const ns = /\*\s*as\s+([\w$]+)/.exec(clause);
|
|
977
|
+
if (ns) names.set(ns[1], "*");
|
|
978
|
+
const named = /\{([^}]*)\}/.exec(clause);
|
|
979
|
+
for (const part of named ? named[1].split(",") : []) {
|
|
980
|
+
const p = /^\s*(?:type\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/.exec(part);
|
|
981
|
+
if (p) names.set(p[2] ?? p[1], p[1]);
|
|
982
|
+
}
|
|
983
|
+
const def = /^\s*([\w$]+)\s*(?:,|$)/.exec(clause);
|
|
984
|
+
if (def && def[1] !== "type") names.set(def[1], def[1]);
|
|
985
|
+
}
|
|
986
|
+
return names;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** The ui part a tag renders, or null when the tag is not one. */
|
|
990
|
+
function partOf(name, imports) {
|
|
991
|
+
const dot = name.indexOf(".");
|
|
992
|
+
if (dot !== -1)
|
|
993
|
+
return imports.get(name.slice(0, dot)) === "*" ? name.slice(dot + 1) : null;
|
|
994
|
+
const part = imports.get(name);
|
|
995
|
+
return part && part !== "*" ? part : null;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// ---- the page-width route map ----------------------------------------------------------------
|
|
999
|
+
// Copied from the `page-layout` registry lib (packages/ui/registry/lib/page-layout.ts) — the
|
|
1000
|
+
// doctor runs before anything is installed and cannot import TypeScript. Keep them in step.
|
|
1001
|
+
|
|
1002
|
+
const ROUTE_RANK = {
|
|
1003
|
+
static: 0,
|
|
1004
|
+
dynamic: 1,
|
|
1005
|
+
"catch-all": 2,
|
|
1006
|
+
"optional-catch-all": 3,
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
export function routeSegments(path) {
|
|
1010
|
+
return path
|
|
1011
|
+
.split("?")[0]
|
|
1012
|
+
.split("#")[0]
|
|
1013
|
+
.split("/")
|
|
1014
|
+
.filter((s) => s !== "" && !/^\(.*\)$/.test(s) && !s.startsWith("@"));
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const PARSED = new Map();
|
|
1018
|
+
function parseRoute(route) {
|
|
1019
|
+
let parsed = PARSED.get(route);
|
|
1020
|
+
if (parsed) return parsed;
|
|
1021
|
+
parsed = routeSegments(route).map((s) => {
|
|
1022
|
+
if (/^\[\[\.\.\.[^\]]+\]\]$/.test(s)) return { kind: "optional-catch-all" };
|
|
1023
|
+
if (/^\[\.\.\.[^\]]+\]$/.test(s)) return { kind: "catch-all" };
|
|
1024
|
+
if (/^\[[^\]]+\]$/.test(s)) return { kind: "dynamic" };
|
|
1025
|
+
return { kind: "static", value: s };
|
|
1026
|
+
});
|
|
1027
|
+
if (PARSED.size < 10000) PARSED.set(route, parsed);
|
|
1028
|
+
return parsed;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
export function matchRoute(route, pathname) {
|
|
1032
|
+
const pattern = parseRoute(route);
|
|
1033
|
+
const path = routeSegments(pathname);
|
|
1034
|
+
for (let i = 0; i < pattern.length; i += 1) {
|
|
1035
|
+
const seg = pattern[i];
|
|
1036
|
+
if (seg.kind === "optional-catch-all") return true;
|
|
1037
|
+
if (seg.kind === "catch-all") return path.length > i;
|
|
1038
|
+
if (i >= path.length) return false;
|
|
1039
|
+
if (seg.kind === "static" && seg.value !== path[i]) return false;
|
|
1040
|
+
}
|
|
1041
|
+
return path.length === pattern.length;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function bySpecificity(a, b) {
|
|
1045
|
+
const [ra, rb] = [parseRoute(a), parseRoute(b)];
|
|
1046
|
+
for (let i = 0; i < Math.min(ra.length, rb.length); i += 1) {
|
|
1047
|
+
const d = ROUTE_RANK[ra[i].kind] - ROUTE_RANK[rb[i].kind];
|
|
1048
|
+
if (d !== 0) return d;
|
|
1049
|
+
}
|
|
1050
|
+
return rb.length - ra.length;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/** A value no static row names: what a page's `[id]` stands for when it is matched as a path. */
|
|
1054
|
+
const ANY_SEGMENT = "\u0000";
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Concrete sample paths a pattern serves, as segment arrays: `[id]` is one value no static row
|
|
1058
|
+
* names, `[...rest]` one to `depth` of them, `[[...rest]]` zero to `depth`.
|
|
1059
|
+
*/
|
|
1060
|
+
function samplePaths(route, depth) {
|
|
1061
|
+
let paths = [[]];
|
|
1062
|
+
for (const seg of parseRoute(route)) {
|
|
1063
|
+
const from = seg.kind === "optional-catch-all" ? 0 : 1;
|
|
1064
|
+
const to = seg.kind.endsWith("catch-all") ? depth : 1;
|
|
1065
|
+
const next = [];
|
|
1066
|
+
for (const p of paths)
|
|
1067
|
+
for (let n = from; n <= to; n += 1)
|
|
1068
|
+
next.push(
|
|
1069
|
+
seg.kind === "static"
|
|
1070
|
+
? [...p, seg.value]
|
|
1071
|
+
: [...p, ...Array(n).fill(ANY_SEGMENT)],
|
|
1072
|
+
);
|
|
1073
|
+
paths = next;
|
|
1074
|
+
}
|
|
1075
|
+
return paths;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* The widths the route map gives the paths a page's route pattern renders. A static route with its
|
|
1080
|
+
* own row: that row. Otherwise sample paths — the page pattern's, every map row's, and each pair of
|
|
1081
|
+
* those merged position by position (`/en/[slug]` with `/[lang]/settings` gives `/en/settings`),
|
|
1082
|
+
* with catch-alls one segment longer than the longest pattern — kept when this page, not a more
|
|
1083
|
+
* specific page, renders them by Next's precedence, and resolved to the most specific row serving
|
|
1084
|
+
* each exactly as `widthOfPath` picks, or `default` where none does. `pages` are every App Router
|
|
1085
|
+
* page's route. One width when every sample agrees.
|
|
1086
|
+
*/
|
|
1087
|
+
export function widthsForRoute(map, route, pages = []) {
|
|
1088
|
+
const key = `/${routeSegments(route).join("/")}`;
|
|
1089
|
+
if (map.has(key) && parseRoute(key).every((s) => s.kind === "static"))
|
|
1090
|
+
return [{ width: map.get(key).width, row: key }];
|
|
1091
|
+
const pagePatterns = [...new Set([key, ...pages])].sort(bySpecificity);
|
|
1092
|
+
const rows = [...map.keys()].sort(bySpecificity);
|
|
1093
|
+
const depth =
|
|
1094
|
+
Math.min(
|
|
1095
|
+
8,
|
|
1096
|
+
Math.max(...[key, ...pages, ...rows].map((r) => parseRoute(r).length)),
|
|
1097
|
+
) + 1;
|
|
1098
|
+
const own = samplePaths(key, depth);
|
|
1099
|
+
const candidates = new Set(own.map((p) => `/${p.join("/")}`));
|
|
1100
|
+
for (const row of rows)
|
|
1101
|
+
for (const r of samplePaths(row, depth)) {
|
|
1102
|
+
candidates.add(`/${r.join("/")}`);
|
|
1103
|
+
for (const p of own) {
|
|
1104
|
+
if (p.length !== r.length) continue;
|
|
1105
|
+
const merged = p.map((seg, i) => (seg === ANY_SEGMENT ? r[i] : seg));
|
|
1106
|
+
if (merged.every((seg, i) => r[i] === ANY_SEGMENT || seg === r[i]))
|
|
1107
|
+
candidates.add(`/${merged.join("/")}`);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
const seen = new Map();
|
|
1111
|
+
for (const path of candidates) {
|
|
1112
|
+
if (!matchRoute(key, path)) continue;
|
|
1113
|
+
if (pagePatterns.find((p) => matchRoute(p, path)) !== key) continue;
|
|
1114
|
+
const row = rows.find((r) => matchRoute(r, path));
|
|
1115
|
+
const width = row ? map.get(row).width : "default";
|
|
1116
|
+
seen.set(`${width} ${row}`, { width, row: row ?? null });
|
|
1117
|
+
}
|
|
1118
|
+
return [...seen.values()];
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
const ROUTE_ROW = /(["'])(\/[^"'\s]*)\1\s*:\s*(["'])(prose|default|full)\3/g;
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Route-map rows a source declares: the `"/route": "prose" | "default" | "full"` rows of the
|
|
1125
|
+
* object literal passed to `definePageWidths(…)`, and of every `widths: { … }` object literal (a
|
|
1126
|
+
* module registry that contributes its own routes to the map).
|
|
1127
|
+
*/
|
|
1128
|
+
export function routeMapRows(src, scan = src) {
|
|
1129
|
+
// Calls and object bounds come from the structure copy, so a `"}"` or a quoted example call
|
|
1130
|
+
// neither ends a map early nor starts one; the rows are read from the source.
|
|
1131
|
+
const spans = [];
|
|
1132
|
+
for (const m of scan.matchAll(
|
|
1133
|
+
/\bdefinePageWidths\s*(?:<[^>()]*>)?\s*\(\s*\{|\bwidths\s*:\s*\{/g,
|
|
1134
|
+
)) {
|
|
1135
|
+
const open = m.index + m[0].length - 1;
|
|
1136
|
+
spans.push([open, skipBraces(scan, open)]);
|
|
1137
|
+
}
|
|
1138
|
+
const rows = [];
|
|
1139
|
+
for (const [from, to] of spans)
|
|
1140
|
+
for (const m of src.slice(from, to).matchAll(ROUTE_ROW))
|
|
1141
|
+
rows.push({ route: m[2], width: m[4], index: from + m.index });
|
|
1142
|
+
return rows;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* A Next.js App Router page or layout's route: `src/app/(app)/tasks/[id]/page.tsx` → `/tasks/[id]`.
|
|
1147
|
+
* Route groups and `@slot`s are not segments; an intercepting segment (`(.)x`, `(..)x`,
|
|
1148
|
+
* `(..)(..)x`, `(...)x`) renders the route it intercepts — the same level, one or two levels up,
|
|
1149
|
+
* or the root.
|
|
1150
|
+
*/
|
|
1151
|
+
export function appRouteOf(relPath) {
|
|
1152
|
+
const parts = relPath.split("/");
|
|
1153
|
+
const file = parts.pop();
|
|
1154
|
+
if (!/^(?:page|layout)\.[jt]sx?$/.test(file)) return null;
|
|
1155
|
+
const app = parts.indexOf("app");
|
|
1156
|
+
if (app === -1) return null;
|
|
1157
|
+
const route = [];
|
|
1158
|
+
for (const part of parts.slice(app + 1)) {
|
|
1159
|
+
const intercept = /^((?:\(\.{1,3}\))+)(.+)$/.exec(part);
|
|
1160
|
+
if (intercept) {
|
|
1161
|
+
for (const marker of intercept[1].match(/\(\.{1,3}\)/g)) {
|
|
1162
|
+
if (marker === "(...)") route.length = 0;
|
|
1163
|
+
else if (marker === "(..)") route.pop();
|
|
1164
|
+
}
|
|
1165
|
+
route.push(...routeSegments(intercept[2]));
|
|
1166
|
+
} else route.push(...routeSegments(part));
|
|
1167
|
+
}
|
|
1168
|
+
return `/${route.join("/")}`;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const COMPOSITION_EXTENSIONS = /\.[cm]?[jt]sx?$/;
|
|
1172
|
+
const TEST_FILE = /(?:^|\/)__tests__\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
1173
|
+
|
|
1174
|
+
/** 1-based line of an offset, by binary search over the newline offsets. */
|
|
1175
|
+
function lineIndex(text) {
|
|
1176
|
+
const breaks = [];
|
|
1177
|
+
for (let k = text.indexOf("\n"); k !== -1; k = text.indexOf("\n", k + 1))
|
|
1178
|
+
breaks.push(k);
|
|
1179
|
+
return (index) => {
|
|
1180
|
+
let lo = 0;
|
|
1181
|
+
let hi = breaks.length;
|
|
1182
|
+
while (lo < hi) {
|
|
1183
|
+
const mid = (lo + hi) >> 1;
|
|
1184
|
+
if (breaks[mid] < index) lo = mid + 1;
|
|
1185
|
+
else hi = mid;
|
|
1186
|
+
}
|
|
1187
|
+
return lo + 1;
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* The four composition rules over the project's own source (the ui alias dirs, node_modules and
|
|
1193
|
+
* build output skipped): `!` on a DS part, a width class on a popover or menu popup, a hand-written
|
|
1194
|
+
* `<header>` inside an overlay panel, and an `AppShellPage size` that disagrees with the route map.
|
|
1195
|
+
*/
|
|
1196
|
+
export function scanComposition(root, { skipDirs = [], uiAlias = null } = {}) {
|
|
1197
|
+
const skip = new Set(skipDirs.map((d) => resolve(d)));
|
|
1198
|
+
const uiDirs = [...skip];
|
|
1199
|
+
const uiAliases = [
|
|
1200
|
+
...new Set(
|
|
1201
|
+
[uiAlias, "@/components/ui"]
|
|
1202
|
+
.filter((a) => typeof a === "string" && a.length > 0)
|
|
1203
|
+
.map((a) => a.replace(/\/+$/, "")),
|
|
1204
|
+
),
|
|
1205
|
+
];
|
|
1206
|
+
const files = [];
|
|
1207
|
+
const walk = (dir) => {
|
|
1208
|
+
if (files.length >= SCAN_MAX_FILES) return;
|
|
1209
|
+
let entries;
|
|
1210
|
+
try {
|
|
1211
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1212
|
+
} catch {
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
for (const e of entries) {
|
|
1216
|
+
if (e.name.startsWith(".") || SCAN_SKIP_DIRS.has(e.name)) continue;
|
|
1217
|
+
const full = join(dir, e.name);
|
|
1218
|
+
if (e.isDirectory()) {
|
|
1219
|
+
if (!skip.has(resolve(full))) walk(full);
|
|
1220
|
+
} else if (e.isFile() && COMPOSITION_EXTENSIONS.test(e.name)) {
|
|
1221
|
+
files.push(full);
|
|
1222
|
+
if (files.length >= SCAN_MAX_FILES) return;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
};
|
|
1226
|
+
walk(root);
|
|
1227
|
+
files.sort();
|
|
1228
|
+
// Every App Router page: its folder (a layout wraps the pages under its own folder) and route.
|
|
1229
|
+
const pageFiles = files
|
|
1230
|
+
.map((f) => relative(root, f).split(sep).join("/"))
|
|
1231
|
+
.filter((f) => /(?:^|\/)page\.[jt]sx?$/.test(f))
|
|
1232
|
+
.map((f) => ({ dir: f.slice(0, f.lastIndexOf("/")), route: appRouteOf(f) }))
|
|
1233
|
+
.filter((p) => p.route != null);
|
|
1234
|
+
const pageRoutes = [...new Set(pageFiles.map((p) => p.route))];
|
|
1235
|
+
|
|
1236
|
+
const important = [];
|
|
1237
|
+
const widths = [];
|
|
1238
|
+
const headers = [];
|
|
1239
|
+
const pages = [];
|
|
1240
|
+
const map = new Map();
|
|
1241
|
+
const conflicts = [];
|
|
1242
|
+
for (const full of files) {
|
|
1243
|
+
try {
|
|
1244
|
+
if (statSync(full).size > SCAN_MAX_BYTES) continue;
|
|
1245
|
+
} catch {
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1248
|
+
const raw = readIfExists(full);
|
|
1249
|
+
if (raw == null) continue;
|
|
1250
|
+
const file = relative(root, full).split(sep).join("/");
|
|
1251
|
+
const read = raw.replace(/\r/g, "");
|
|
1252
|
+
// Comments (and prose strings) blanked: a commented-out row or an example in a doc block is
|
|
1253
|
+
// not part of the map, and an import in a comment imports nothing.
|
|
1254
|
+
const src = maskNonUsage(read, { script: true });
|
|
1255
|
+
const scan = maskNonUsage(read, { script: true, blankStrings: true });
|
|
1256
|
+
const lineAt = lineIndex(read);
|
|
1257
|
+
if (!TEST_FILE.test(file))
|
|
1258
|
+
for (const row of routeMapRows(src, scan)) {
|
|
1259
|
+
const at = { width: row.width, file, line: lineAt(row.index) };
|
|
1260
|
+
const known = map.get(row.route);
|
|
1261
|
+
if (!known) map.set(row.route, at);
|
|
1262
|
+
else if (known.width !== row.width)
|
|
1263
|
+
conflicts.push({
|
|
1264
|
+
file,
|
|
1265
|
+
line: at.line,
|
|
1266
|
+
match: `route-map row "${row.route}": "${row.width}"`,
|
|
1267
|
+
hint: `${known.file}:${known.line} already says "${known.width}"`,
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
if (!/\.[jt]sx$/.test(full) || !read.includes("<")) continue;
|
|
1271
|
+
|
|
1272
|
+
const imports = uiImports(src, full, { uiAliases, uiDirs });
|
|
1273
|
+
const overlays = [];
|
|
1274
|
+
const route = appRouteOf(file);
|
|
1275
|
+
for (const tag of jsxTags(scan)) {
|
|
1276
|
+
const part = partOf(tag.name, imports);
|
|
1277
|
+
if (tag.kind === "close") {
|
|
1278
|
+
if (part && part in OVERLAY_HEADERS) {
|
|
1279
|
+
const at = overlays.lastIndexOf(part);
|
|
1280
|
+
if (at !== -1) overlays.splice(at, 1);
|
|
1281
|
+
}
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
if (tag.name === "header" && overlays.length > 0) {
|
|
1285
|
+
const inside = overlays[overlays.length - 1];
|
|
1286
|
+
headers.push({
|
|
1287
|
+
file,
|
|
1288
|
+
line: lineAt(tag.index),
|
|
1289
|
+
match: `<header> in ${inside}`,
|
|
1290
|
+
hint: OVERLAY_HEADERS[inside],
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
const isPage = part === "AppShellPage" || tag.name === "AppShellPage";
|
|
1294
|
+
if (!part && !isPage) continue;
|
|
1295
|
+
const attrs = tagAttributes(tag, scan);
|
|
1296
|
+
if (isPage) {
|
|
1297
|
+
// A spread after `size` (or anywhere, with no `size`) may set it: that size is dynamic.
|
|
1298
|
+
const size = attrs.get("size");
|
|
1299
|
+
const spreads = attrs.get("...");
|
|
1300
|
+
pages.push({
|
|
1301
|
+
file,
|
|
1302
|
+
line: lineAt(tag.index),
|
|
1303
|
+
route,
|
|
1304
|
+
size: spreads.some((at) => at > (size?.order ?? -1))
|
|
1305
|
+
? null
|
|
1306
|
+
: size == null
|
|
1307
|
+
? "default"
|
|
1308
|
+
: (literalValue(size, scan, read)?.text ?? null),
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
if (!part) continue;
|
|
1312
|
+
if (part in OVERLAY_HEADERS && !tag.selfClosing) overlays.push(part);
|
|
1313
|
+
const cls = attrs.get("className");
|
|
1314
|
+
if (!cls || cls.kind === "flag") continue;
|
|
1315
|
+
for (const { token, at } of classTokens(cls, scan, read)) {
|
|
1316
|
+
const utility = utilityOf(token);
|
|
1317
|
+
if (isImportant(utility))
|
|
1318
|
+
important.push({ file, line: lineAt(at), match: `${part} ${token}` });
|
|
1319
|
+
if (WIDTH_OWNING_CONTENT.includes(part) && isWidthClass(utility))
|
|
1320
|
+
widths.push({ file, line: lineAt(at), match: `${part} ${token}` });
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// AppShellPage size vs the route map: route files only, literal (or absent) sizes only.
|
|
1326
|
+
const mismatches = [...conflicts];
|
|
1327
|
+
let compared = 0;
|
|
1328
|
+
let dynamic = 0;
|
|
1329
|
+
let outside = 0;
|
|
1330
|
+
for (const page of pages) {
|
|
1331
|
+
if (page.route == null) {
|
|
1332
|
+
outside += 1;
|
|
1333
|
+
continue;
|
|
1334
|
+
}
|
|
1335
|
+
if (page.size == null) {
|
|
1336
|
+
dynamic += 1;
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
if (map.size === 0) continue;
|
|
1340
|
+
compared += 1;
|
|
1341
|
+
const size = page.size === "narrow" ? "prose" : page.size;
|
|
1342
|
+
// A page answers for its own route; a layout for every page in its folder and below (a route
|
|
1343
|
+
// group's layout wraps only its group), or its own route when it wraps none.
|
|
1344
|
+
const layoutDir = /(?:^|\/)layout\.[jt]sx?$/.test(page.file)
|
|
1345
|
+
? page.file.slice(0, page.file.lastIndexOf("/"))
|
|
1346
|
+
: null;
|
|
1347
|
+
const wrapped =
|
|
1348
|
+
layoutDir == null
|
|
1349
|
+
? [page.route]
|
|
1350
|
+
: pageFiles
|
|
1351
|
+
.filter(
|
|
1352
|
+
(p) => p.dir === layoutDir || p.dir.startsWith(`${layoutDir}/`),
|
|
1353
|
+
)
|
|
1354
|
+
.map((p) => p.route);
|
|
1355
|
+
const seen = new Map();
|
|
1356
|
+
for (const route of wrapped.length ? wrapped : [page.route])
|
|
1357
|
+
for (const answer of widthsForRoute(map, route, pageRoutes))
|
|
1358
|
+
seen.set(`${answer.width} ${answer.row}`, answer);
|
|
1359
|
+
const answers = [...seen.values()];
|
|
1360
|
+
if (answers.some((a) => a.width !== size))
|
|
1361
|
+
mismatches.push({
|
|
1362
|
+
file: page.file,
|
|
1363
|
+
line: page.line,
|
|
1364
|
+
match: `size="${page.size}" on ${page.route}`,
|
|
1365
|
+
hint: answers
|
|
1366
|
+
.map(({ width, row }) =>
|
|
1367
|
+
row
|
|
1368
|
+
? `the map says "${width}" (${row}, ${map.get(row).file}:${map.get(row).line})`
|
|
1369
|
+
: `the map has no row for it, so it is "default"`,
|
|
1370
|
+
)
|
|
1371
|
+
.join("; and for other paths it serves, "),
|
|
1372
|
+
...(layoutDir == null ? {} : { layout: true }),
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const byPlace = (a, b) =>
|
|
1377
|
+
a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line;
|
|
1378
|
+
return {
|
|
1379
|
+
files: files.length,
|
|
1380
|
+
important: important.sort(byPlace),
|
|
1381
|
+
widths: widths.sort(byPlace),
|
|
1382
|
+
headers: headers.sort(byPlace),
|
|
1383
|
+
routeMap: {
|
|
1384
|
+
rows: map.size,
|
|
1385
|
+
files: [...new Set([...map.values()].map((r) => r.file))].sort(),
|
|
1386
|
+
compared,
|
|
1387
|
+
dynamic,
|
|
1388
|
+
outside,
|
|
1389
|
+
mismatches: mismatches.sort(byPlace),
|
|
1390
|
+
},
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
|
|
644
1394
|
export function main(argv = []) {
|
|
645
1395
|
if (argv.includes("-h") || argv.includes("--help")) {
|
|
646
1396
|
console.log(USAGE);
|
|
@@ -653,6 +1403,7 @@ export function main(argv = []) {
|
|
|
653
1403
|
return 2;
|
|
654
1404
|
}
|
|
655
1405
|
const root = dirFlag === -1 ? process.cwd() : argv[dirFlag + 1];
|
|
1406
|
+
const strict = argv.includes("--strict");
|
|
656
1407
|
|
|
657
1408
|
if (!existsSync(root)) {
|
|
658
1409
|
console.error(`doctor: no such directory: ${root}`);
|
|
@@ -882,8 +1633,74 @@ export function main(argv = []) {
|
|
|
882
1633
|
});
|
|
883
1634
|
}
|
|
884
1635
|
|
|
1636
|
+
// ---- 9–12. DS parts used as designed ------------------------------------------------------
|
|
1637
|
+
// Warnings unless --strict: a consumer adopts the release, fixes what these report, then gates.
|
|
1638
|
+
const composition = scanComposition(root, {
|
|
1639
|
+
skipDirs,
|
|
1640
|
+
uiAlias: componentsJson?.aliases?.ui ?? null,
|
|
1641
|
+
});
|
|
1642
|
+
const level = strict ? "fail" : "warn";
|
|
1643
|
+
const compositionCheck = (name, findings, detail, fix, okDetail) => {
|
|
1644
|
+
if (findings.length === 0) return ok(name, okDetail);
|
|
1645
|
+
results.push({
|
|
1646
|
+
level,
|
|
1647
|
+
name,
|
|
1648
|
+
detail: `${findings.length} ${detail}`,
|
|
1649
|
+
fix,
|
|
1650
|
+
findings: findings.map(
|
|
1651
|
+
(f) =>
|
|
1652
|
+
`${f.file}:${f.line} ${f.match}${f.hint ? ` → ${f.hint}` : ""}`,
|
|
1653
|
+
),
|
|
1654
|
+
});
|
|
1655
|
+
};
|
|
1656
|
+
compositionCheck(
|
|
1657
|
+
"important on DS parts",
|
|
1658
|
+
composition.important,
|
|
1659
|
+
"Tailwind `!` modifier(s) forcing a ui part's own classes",
|
|
1660
|
+
"use the part's variant or prop for it (ask for one in the design system if none fits) — `!` beats the part's own classes on every state and breaks on the next pull",
|
|
1661
|
+
"no ui part is forced with `!`",
|
|
1662
|
+
);
|
|
1663
|
+
compositionCheck(
|
|
1664
|
+
"popup width",
|
|
1665
|
+
composition.widths,
|
|
1666
|
+
"width class(es) on a popover, menu or select popup",
|
|
1667
|
+
"delete them — PopoverContent takes size (default · md · lg · fit); menus, selects and comboboxes size to their longest item",
|
|
1668
|
+
"no popover or menu popup sets its own width",
|
|
1669
|
+
);
|
|
1670
|
+
compositionCheck(
|
|
1671
|
+
"overlay header",
|
|
1672
|
+
composition.headers,
|
|
1673
|
+
"hand-written <header>(s) inside Sheet, Drawer or Dialog content",
|
|
1674
|
+
"use the overlay's own header part (SheetHeader · DrawerHeader · DialogHeader · AlertDialogHeader), or RecordPeek for a record panel",
|
|
1675
|
+
"no overlay content hand-writes a <header> (same-file nesting only)",
|
|
1676
|
+
);
|
|
1677
|
+
const rm = composition.routeMap;
|
|
1678
|
+
const coverage = `${rm.compared} App Router page/layout size(s) compared${
|
|
1679
|
+
rm.dynamic ? `, ${rm.dynamic} dynamic size={…} skipped` : ""
|
|
1680
|
+
}${
|
|
1681
|
+
rm.outside
|
|
1682
|
+
? `, ${rm.outside} AppShellPage(s) outside page/layout files not checked (this rule reads route files only)`
|
|
1683
|
+
: ""
|
|
1684
|
+
}`;
|
|
1685
|
+
if (rm.rows === 0) {
|
|
1686
|
+
results.push({
|
|
1687
|
+
level: "skip",
|
|
1688
|
+
name: "page width vs route map",
|
|
1689
|
+
detail:
|
|
1690
|
+
'skipped (no route map) — no definePageWidths( call or widths: { "/route": … } rows found',
|
|
1691
|
+
});
|
|
1692
|
+
} else {
|
|
1693
|
+
compositionCheck(
|
|
1694
|
+
"page width vs route map",
|
|
1695
|
+
rm.mismatches,
|
|
1696
|
+
`AppShellPage size(s) disagree with the route map (${rm.rows} row(s) in ${rm.files.join(", ")}; ${coverage})`,
|
|
1697
|
+
"set size to the map's width, or change the map's row — a page and its skeleton read the same map (https://design.vegastack.com/docs/foundations/page-layout)",
|
|
1698
|
+
`${rm.rows} route-map row(s) in ${rm.files.join(", ")}; ${coverage}`,
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
885
1702
|
// ---- report -------------------------------------------------------------------------------
|
|
886
|
-
const glyph = { ok: "✓", warn: "!", fail: "✗" };
|
|
1703
|
+
const glyph = { ok: "✓", warn: "!", fail: "✗", skip: "–" };
|
|
887
1704
|
console.log("");
|
|
888
1705
|
for (const r of results) {
|
|
889
1706
|
console.log(
|