shadscan-vue 0.1.2 → 0.2.1
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/CHANGELOG.md +25 -0
- package/README.md +13 -0
- package/dist/index.js +665 -11
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { cac } from "cac";
|
|
5
5
|
import { glob } from "tinyglobby";
|
|
6
6
|
import { parse } from "@vue/compiler-sfc";
|
|
7
|
-
import { NodeTypes } from "@vue/compiler-dom";
|
|
7
|
+
import { ElementTypes, NodeTypes } from "@vue/compiler-dom";
|
|
8
8
|
import ts from "typescript";
|
|
9
9
|
import { parse as parse$1 } from "jsonc-parser";
|
|
10
10
|
import pc from "picocolors";
|
|
@@ -736,6 +736,19 @@ const collectText = (children) => {
|
|
|
736
736
|
};
|
|
737
737
|
const visibleText = (element) => collectText(element.children).trim();
|
|
738
738
|
/**
|
|
739
|
+
* True when the element projects caller-supplied content through a `<slot>`.
|
|
740
|
+
* The name then lives at the call site, so the component itself cannot be
|
|
741
|
+
* judged nameless — `<a><slot /></a>` is a wrapper, not an anonymous link.
|
|
742
|
+
*/
|
|
743
|
+
const projectsSlotContent = (element) => {
|
|
744
|
+
for (const child of element.children) {
|
|
745
|
+
if (child.type !== NodeTypes.ELEMENT) continue;
|
|
746
|
+
if (child.tagType === ElementTypes.SLOT) return true;
|
|
747
|
+
if (projectsSlotContent(child)) return true;
|
|
748
|
+
}
|
|
749
|
+
return false;
|
|
750
|
+
};
|
|
751
|
+
/**
|
|
739
752
|
* True when the element carries an explicit accessible name via ARIA,
|
|
740
753
|
* a title, or visually-hidden text.
|
|
741
754
|
*/
|
|
@@ -771,6 +784,145 @@ const hasScreenReaderText = (element) => {
|
|
|
771
784
|
};
|
|
772
785
|
const elementChildren = (element) => element.children.filter((child) => child.type === NodeTypes.ELEMENT);
|
|
773
786
|
//#endregion
|
|
787
|
+
//#region src/rules/accessibility/custom-controls-have-labels.ts
|
|
788
|
+
/**
|
|
789
|
+
* Controls that render no intrinsic text, so a screen reader has nothing to
|
|
790
|
+
* announce unless the author supplies a name. SelectTrigger is included rather
|
|
791
|
+
* than Select because the trigger is the focusable control.
|
|
792
|
+
*/
|
|
793
|
+
const CUSTOM_CONTROL_TAGS = /* @__PURE__ */ new Set([
|
|
794
|
+
"checkbox",
|
|
795
|
+
"combobox",
|
|
796
|
+
"input-otp",
|
|
797
|
+
"radio-group",
|
|
798
|
+
"select-trigger",
|
|
799
|
+
"slider",
|
|
800
|
+
"switch",
|
|
801
|
+
"toggle"
|
|
802
|
+
]);
|
|
803
|
+
const LABEL_TAGS = /* @__PURE__ */ new Set([
|
|
804
|
+
"label",
|
|
805
|
+
"form-label",
|
|
806
|
+
"field-label"
|
|
807
|
+
]);
|
|
808
|
+
const isCustomControl = (tag) => CUSTOM_CONTROL_TAGS.has(pascalToKebab(tag));
|
|
809
|
+
const customControlsHaveLabels = {
|
|
810
|
+
id: "custom-controls-have-labels",
|
|
811
|
+
title: "Custom controls have accessible labels",
|
|
812
|
+
description: "Controls such as Checkbox, Switch, Slider, and SelectTrigger render no text of their own, so each needs a label, an aria-label, or an aria-labelledby reference.",
|
|
813
|
+
category: "accessibility",
|
|
814
|
+
severity: "error",
|
|
815
|
+
confidence: "medium",
|
|
816
|
+
maxScore: 4,
|
|
817
|
+
adapters: [
|
|
818
|
+
"nuxt",
|
|
819
|
+
"vite-vue",
|
|
820
|
+
"generic-vue"
|
|
821
|
+
],
|
|
822
|
+
run: async ({ sources, result }) => {
|
|
823
|
+
const files = await sources();
|
|
824
|
+
const labelTargets = /* @__PURE__ */ new Map();
|
|
825
|
+
forEachElement$1(files, (element, file) => {
|
|
826
|
+
if (!LABEL_TAGS.has(pascalToKebab(element.tag))) return;
|
|
827
|
+
const target = findAttribute(element, "for")?.static;
|
|
828
|
+
if (target === void 0) return;
|
|
829
|
+
const targets = labelTargets.get(file.relPath) ?? /* @__PURE__ */ new Set();
|
|
830
|
+
targets.add(target);
|
|
831
|
+
labelTargets.set(file.relPath, targets);
|
|
832
|
+
});
|
|
833
|
+
const findings = [];
|
|
834
|
+
let evaluated = 0;
|
|
835
|
+
forEachElement$1(files, (element, file, ancestors) => {
|
|
836
|
+
if (!isCustomControl(element.tag) || isGeneratedUiPrimitive(file.relPath)) return;
|
|
837
|
+
evaluated += 1;
|
|
838
|
+
const id = findAttribute(element, "id")?.static;
|
|
839
|
+
const labelledByFor = id !== void 0 && labelTargets.get(file.relPath)?.has(id) === true;
|
|
840
|
+
const wrappedInLabel = ancestors.some((ancestor) => LABEL_TAGS.has(pascalToKebab(ancestor.tag)));
|
|
841
|
+
if (labelledByFor || wrappedInLabel || hasAriaName(element)) return;
|
|
842
|
+
findings.push({
|
|
843
|
+
message: `<${element.tag}> has no accessible name.`,
|
|
844
|
+
evidence: [{
|
|
845
|
+
path: file.relPath,
|
|
846
|
+
line: elementLine(element)
|
|
847
|
+
}],
|
|
848
|
+
remediation: "Associate a <Label for=\"…\"> with the control id, wrap the control in a label, or add aria-label / aria-labelledby."
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
if (evaluated === 0) return result.notApplicable();
|
|
852
|
+
return findings.length > 0 ? result.fail(findings) : result.pass();
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
//#endregion
|
|
856
|
+
//#region src/rules/accessibility/dialog-focus-trap-works.ts
|
|
857
|
+
const DIALOG_TAGS = /* @__PURE__ */ new Set([
|
|
858
|
+
"dialog",
|
|
859
|
+
"dialog-root",
|
|
860
|
+
"dialog-content",
|
|
861
|
+
"alert-dialog",
|
|
862
|
+
"alert-dialog-root",
|
|
863
|
+
"alert-dialog-content",
|
|
864
|
+
"drawer",
|
|
865
|
+
"drawer-content",
|
|
866
|
+
"sheet",
|
|
867
|
+
"sheet-content"
|
|
868
|
+
]);
|
|
869
|
+
const DIALOG_ROLES = /* @__PURE__ */ new Set(["dialog", "alertdialog"]);
|
|
870
|
+
/**
|
|
871
|
+
* reka-ui is the headless layer shadcn-vue is built on, so its dialog
|
|
872
|
+
* primitives already own focus containment and restoration. Vaul-vue is the
|
|
873
|
+
* drawer equivalent.
|
|
874
|
+
*/
|
|
875
|
+
const FOCUS_MANAGED_DEPENDENCIES = [
|
|
876
|
+
"reka-ui",
|
|
877
|
+
"radix-vue",
|
|
878
|
+
"vaul-vue",
|
|
879
|
+
"@headlessui/vue"
|
|
880
|
+
];
|
|
881
|
+
const isDialogTag = (tag) => DIALOG_TAGS.has(pascalToKebab(tag));
|
|
882
|
+
const findDialogSurface = (files) => {
|
|
883
|
+
for (const file of files) {
|
|
884
|
+
if (file.sfc?.templateAst === void 0 || isGeneratedUiPrimitive(file.relPath)) continue;
|
|
885
|
+
let found;
|
|
886
|
+
walkTemplate(file.sfc.templateAst, (element) => {
|
|
887
|
+
if (found !== void 0) return;
|
|
888
|
+
const role = findAttribute(element, "role")?.static;
|
|
889
|
+
if (isDialogTag(element.tag) || role !== void 0 && DIALOG_ROLES.has(role)) found = elementLine(element);
|
|
890
|
+
});
|
|
891
|
+
if (found !== void 0) return {
|
|
892
|
+
file,
|
|
893
|
+
line: found
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
const dialogFocusTrapWorks = {
|
|
898
|
+
id: "dialog-focus-trap-works",
|
|
899
|
+
title: "Dialogs trap and restore focus",
|
|
900
|
+
description: "Looks for a focus-managed primitive behind dialog-like surfaces. Focus containment and restoration cannot be proven statically, so this reports what backs the dialog rather than scoring it.",
|
|
901
|
+
category: "accessibility",
|
|
902
|
+
severity: "warning",
|
|
903
|
+
confidence: "low",
|
|
904
|
+
maxScore: 0,
|
|
905
|
+
adapters: [
|
|
906
|
+
"nuxt",
|
|
907
|
+
"vite-vue",
|
|
908
|
+
"generic-vue"
|
|
909
|
+
],
|
|
910
|
+
run: async ({ sources, discovery, result }) => {
|
|
911
|
+
const files = await sources();
|
|
912
|
+
const surface = findDialogSurface(files);
|
|
913
|
+
if (surface === void 0) return result.notApplicable();
|
|
914
|
+
if (FOCUS_MANAGED_DEPENDENCIES.find((dependency) => discovery.dependencies[dependency] !== void 0) !== void 0) return result.pass();
|
|
915
|
+
return result.advisory([{
|
|
916
|
+
message: "A dialog surface was found with no focus-managed primitive behind it.",
|
|
917
|
+
evidence: [{
|
|
918
|
+
path: surface.file.relPath,
|
|
919
|
+
line: surface.line
|
|
920
|
+
}],
|
|
921
|
+
remediation: "Build the dialog on reka-ui (the layer shadcn-vue uses) or another focus-managed primitive, then verify initial focus, Tab containment, Escape, and focus return in a browser."
|
|
922
|
+
}]);
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
//#endregion
|
|
774
926
|
//#region src/rules/accessibility/dialogs-have-accessible-names.ts
|
|
775
927
|
/**
|
|
776
928
|
* shadcn-vue dialog surfaces wrap reka-ui primitives. Both the shadcn wrapper
|
|
@@ -839,6 +991,130 @@ const dialogsHaveAccessibleNames = {
|
|
|
839
991
|
}
|
|
840
992
|
};
|
|
841
993
|
//#endregion
|
|
994
|
+
//#region src/rules/accessibility/keyboard-navigation-works.ts
|
|
995
|
+
const COMPOSITE_WIDGET_TAGS = /* @__PURE__ */ new Set([
|
|
996
|
+
"accordion",
|
|
997
|
+
"accordion-root",
|
|
998
|
+
"combobox",
|
|
999
|
+
"combobox-root",
|
|
1000
|
+
"command",
|
|
1001
|
+
"dropdown-menu",
|
|
1002
|
+
"dropdown-menu-root",
|
|
1003
|
+
"listbox",
|
|
1004
|
+
"menu",
|
|
1005
|
+
"menubar",
|
|
1006
|
+
"navigation-menu",
|
|
1007
|
+
"select",
|
|
1008
|
+
"select-root",
|
|
1009
|
+
"tabs",
|
|
1010
|
+
"tabs-root",
|
|
1011
|
+
"tree"
|
|
1012
|
+
]);
|
|
1013
|
+
const COMPOSITE_ROLES = /* @__PURE__ */ new Set([
|
|
1014
|
+
"grid",
|
|
1015
|
+
"listbox",
|
|
1016
|
+
"menu",
|
|
1017
|
+
"menubar",
|
|
1018
|
+
"tablist",
|
|
1019
|
+
"tree"
|
|
1020
|
+
]);
|
|
1021
|
+
const KEYBOARD_PRIMITIVE_DEPENDENCIES = [
|
|
1022
|
+
"reka-ui",
|
|
1023
|
+
"radix-vue",
|
|
1024
|
+
"@headlessui/vue",
|
|
1025
|
+
"@ariakit/vue",
|
|
1026
|
+
"@zag-js/vue"
|
|
1027
|
+
];
|
|
1028
|
+
const isCompositeTag = (tag) => COMPOSITE_WIDGET_TAGS.has(pascalToKebab(tag));
|
|
1029
|
+
const findCompositeWidget = (files) => {
|
|
1030
|
+
for (const file of files) {
|
|
1031
|
+
if (file.sfc?.templateAst === void 0 || isGeneratedUiPrimitive(file.relPath)) continue;
|
|
1032
|
+
let found;
|
|
1033
|
+
walkTemplate(file.sfc.templateAst, (element) => {
|
|
1034
|
+
if (found !== void 0) return;
|
|
1035
|
+
const role = findAttribute(element, "role")?.static;
|
|
1036
|
+
if (isCompositeTag(element.tag) || role !== void 0 && COMPOSITE_ROLES.has(role)) found = elementLine(element);
|
|
1037
|
+
});
|
|
1038
|
+
if (found !== void 0) return {
|
|
1039
|
+
file,
|
|
1040
|
+
line: found
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
};
|
|
1044
|
+
const keyboardNavigationWorks = {
|
|
1045
|
+
id: "keyboard-navigation-works",
|
|
1046
|
+
title: "Composite widgets support keyboard navigation",
|
|
1047
|
+
description: "Looks for a keyboard-aware primitive behind composite widgets such as menus, tabs, and listboxes. Arrow-key, Home/End, and focus behaviour cannot be proven statically, so this reports what backs the widget rather than scoring it.",
|
|
1048
|
+
category: "accessibility",
|
|
1049
|
+
severity: "warning",
|
|
1050
|
+
confidence: "low",
|
|
1051
|
+
maxScore: 0,
|
|
1052
|
+
adapters: [
|
|
1053
|
+
"nuxt",
|
|
1054
|
+
"vite-vue",
|
|
1055
|
+
"generic-vue"
|
|
1056
|
+
],
|
|
1057
|
+
run: async ({ sources, discovery, result }) => {
|
|
1058
|
+
const files = await sources();
|
|
1059
|
+
const widget = findCompositeWidget(files);
|
|
1060
|
+
if (widget === void 0) return result.notApplicable();
|
|
1061
|
+
if (Object.keys(discovery.dependencies).find((dependency) => dependency.startsWith("@zag-js/") || KEYBOARD_PRIMITIVE_DEPENDENCIES.includes(dependency)) !== void 0) return result.pass();
|
|
1062
|
+
return result.advisory([{
|
|
1063
|
+
message: "A composite widget was found with no keyboard-aware primitive behind it.",
|
|
1064
|
+
evidence: [{
|
|
1065
|
+
path: widget.file.relPath,
|
|
1066
|
+
line: widget.line
|
|
1067
|
+
}],
|
|
1068
|
+
remediation: "Build composite widgets on reka-ui (the layer shadcn-vue uses), or implement and test the widget's arrow-key, Home/End, Enter, Escape, and focus behaviour."
|
|
1069
|
+
}]);
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
//#endregion
|
|
1073
|
+
//#region src/rules/accessibility/status-messages-announced.ts
|
|
1074
|
+
/**
|
|
1075
|
+
* `error.statusMessage` is Nuxt's static field on a full-page error view, not
|
|
1076
|
+
* a status update pushed into an already-rendered page, so the leading
|
|
1077
|
+
* boundary excludes a property access.
|
|
1078
|
+
*/
|
|
1079
|
+
const DYNAMIC_STATUS_PATTERN = /(?<![.\w])(?:statusMessage|successMessage|errorMessage|progressMessage|setStatus|setMessage)\b|(?<![.\w])(?:pending|isPending|isLoading|loading)\s*\?/u;
|
|
1080
|
+
const ANNOUNCEMENT_PATTERN = /role\s*=\s*["'](?:status|alert)["']|aria-live\s*=|<(?:Toaster|ToastProvider|Sonner)\b|\btoast(?:\.|\s*\()/u;
|
|
1081
|
+
const lineOf$5 = (text, pattern) => {
|
|
1082
|
+
const index = text.search(pattern);
|
|
1083
|
+
if (index < 0) return 1;
|
|
1084
|
+
return text.slice(0, index).split("\n").length;
|
|
1085
|
+
};
|
|
1086
|
+
const statusMessagesAnnounced = {
|
|
1087
|
+
id: "status-messages-announced",
|
|
1088
|
+
title: "Status messages are announced",
|
|
1089
|
+
description: "Dynamic status messages should reach assistive technology through a live region or an accessible toast channel, otherwise a sighted user sees the update and a screen reader user does not.",
|
|
1090
|
+
category: "accessibility",
|
|
1091
|
+
severity: "warning",
|
|
1092
|
+
confidence: "medium",
|
|
1093
|
+
maxScore: 3,
|
|
1094
|
+
adapters: [
|
|
1095
|
+
"nuxt",
|
|
1096
|
+
"vite-vue",
|
|
1097
|
+
"generic-vue"
|
|
1098
|
+
],
|
|
1099
|
+
run: async ({ sources, result }) => {
|
|
1100
|
+
const candidates = (await sources()).filter((file) => file.kind === "vue" && !isGeneratedUiPrimitive(file.relPath) && DYNAMIC_STATUS_PATTERN.test(file.text));
|
|
1101
|
+
if (candidates.length === 0) return result.notApplicable();
|
|
1102
|
+
const findings = [];
|
|
1103
|
+
for (const file of candidates) {
|
|
1104
|
+
if (ANNOUNCEMENT_PATTERN.test(file.text)) continue;
|
|
1105
|
+
findings.push({
|
|
1106
|
+
message: "A dynamic status message has no live region or accessible toast channel.",
|
|
1107
|
+
evidence: [{
|
|
1108
|
+
path: file.relPath,
|
|
1109
|
+
line: lineOf$5(file.text, DYNAMIC_STATUS_PATTERN)
|
|
1110
|
+
}],
|
|
1111
|
+
remediation: "Render the update inside role=\"status\" or role=\"alert\" (or an aria-live region), or deliver it through a mounted toast provider."
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
return findings.length > 0 ? result.fail(findings) : result.pass();
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
//#endregion
|
|
842
1118
|
//#region src/rules/accessibility/heading-structure-sane.ts
|
|
843
1119
|
const HEADING_TAG = /^h([1-6])$/u;
|
|
844
1120
|
const isRoutableSurface = (relPath) => relPath.startsWith("pages/") || relPath.startsWith("app/pages/") || relPath.startsWith("src/views/") || relPath.startsWith("layouts/") || relPath.startsWith("app/layouts/") || relPath === "app.vue" || relPath === "app/app.vue" || relPath === "App.vue" || relPath === "src/App.vue";
|
|
@@ -971,7 +1247,7 @@ const iconButtonsHaveLabels = {
|
|
|
971
1247
|
const children = elementChildren(element);
|
|
972
1248
|
if (!(children.length > 0 && children.every((child) => isIconTag$1(child.tag)) || findAttribute(element, "size")?.static === "icon")) return;
|
|
973
1249
|
if (visibleText(element).length > 0) return;
|
|
974
|
-
if (hasAriaName(element) || hasScreenReaderText(element)) return;
|
|
1250
|
+
if (hasAriaName(element) || hasScreenReaderText(element) || projectsSlotContent(element)) return;
|
|
975
1251
|
findings.push({
|
|
976
1252
|
message: `Icon-only <${element.tag}> has no accessible name.`,
|
|
977
1253
|
evidence: [{
|
|
@@ -1176,7 +1452,7 @@ const linksHaveAccessibleNames = {
|
|
|
1176
1452
|
if (!LINK_TAGS$2.has(element.tag)) return;
|
|
1177
1453
|
if (visibleText(element).length > 0) return;
|
|
1178
1454
|
if (hasAriaName(element) || hasScreenReaderText(element)) return;
|
|
1179
|
-
if (hasLabelledImage(element)) return;
|
|
1455
|
+
if (hasLabelledImage(element) || projectsSlotContent(element)) return;
|
|
1180
1456
|
findings.push({
|
|
1181
1457
|
message: `<${element.tag}> has no accessible name.`,
|
|
1182
1458
|
evidence: [{
|
|
@@ -1948,7 +2224,7 @@ const FOCUS_REPLACEMENT_CLASS_PATTERN = /\bfocus(?:-visible)?:(?:ring|outline|bo
|
|
|
1948
2224
|
const isExcludedPath = (relPath) => /(?:^|\/)components\/ui\//u.test(relPath);
|
|
1949
2225
|
const CSS_OUTLINE_NONE_PATTERN = /outline\s*:\s*none/giu;
|
|
1950
2226
|
const FOCUS_VISIBLE_RULE_PATTERN = /:focus(?:-visible)?\b[^{]*\{[^}]*(?:outline|box-shadow|border|ring)[^}]*\}/u;
|
|
1951
|
-
const lineOf$
|
|
2227
|
+
const lineOf$4 = (text, index) => text.slice(0, index).split("\n").length;
|
|
1952
2228
|
const scanTemplate = (file) => {
|
|
1953
2229
|
if (file.kind !== "vue" || file.sfc?.templateAst === void 0) return [];
|
|
1954
2230
|
const findings = [];
|
|
@@ -1976,7 +2252,7 @@ const scanStyle = (style) => {
|
|
|
1976
2252
|
message: `A CSS rule in ${style.relPath} sets \`outline: none\` without a visible :focus-visible replacement.`,
|
|
1977
2253
|
evidence: [{
|
|
1978
2254
|
path: style.relPath,
|
|
1979
|
-
line: lineOf$
|
|
2255
|
+
line: lineOf$4(style.text, match.index)
|
|
1980
2256
|
}],
|
|
1981
2257
|
remediation: "Add a `:focus-visible` rule that restores a visible outline, box-shadow, or border."
|
|
1982
2258
|
});
|
|
@@ -2029,7 +2305,7 @@ const TYPING_GUARD_PATTERN$1 = /\b(?:INPUT|TEXTAREA|SELECT)\b|isContentEditable|
|
|
|
2029
2305
|
const PRINTABLE_SINGLE_KEY_PATTERN = /\.key\s*===\s*['"][a-z0-9]['"]/iu;
|
|
2030
2306
|
const MODIFIER_PATTERN = /metaKey|ctrlKey|altKey/u;
|
|
2031
2307
|
const isSourceFile$1 = (file) => file.kind === "vue" || file.kind === "ts" || file.kind === "js";
|
|
2032
|
-
const lineOf = (text, index) => text.slice(0, index).split("\n").length;
|
|
2308
|
+
const lineOf$3 = (text, index) => text.slice(0, index).split("\n").length;
|
|
2033
2309
|
const globalHotkeysAreSafe = {
|
|
2034
2310
|
id: "global-hotkeys-are-safe",
|
|
2035
2311
|
title: "Global keyboard shortcuts are registered safely",
|
|
@@ -2055,7 +2331,7 @@ const globalHotkeysAreSafe = {
|
|
|
2055
2331
|
message: `A global keydown listener in ${file.relPath} is never removed, leaking across unmounts.`,
|
|
2056
2332
|
evidence: [{
|
|
2057
2333
|
path: file.relPath,
|
|
2058
|
-
line: lineOf(text, matches[0].index)
|
|
2334
|
+
line: lineOf$3(text, matches[0].index)
|
|
2059
2335
|
}],
|
|
2060
2336
|
remediation: "Remove the listener in onUnmounted/onBeforeUnmount, or use VueUse `useEventListener`/`onKeyStroke` which clean up automatically."
|
|
2061
2337
|
});
|
|
@@ -2063,7 +2339,7 @@ const globalHotkeysAreSafe = {
|
|
|
2063
2339
|
message: `A bare printable single-key shortcut in ${file.relPath} has no typing-target guard.`,
|
|
2064
2340
|
evidence: [{
|
|
2065
2341
|
path: file.relPath,
|
|
2066
|
-
line: lineOf(text, matches[0].index)
|
|
2342
|
+
line: lineOf$3(text, matches[0].index)
|
|
2067
2343
|
}],
|
|
2068
2344
|
remediation: "Guard the handler so it ignores events whose target is an INPUT, TEXTAREA, SELECT, or contenteditable element, or gate the shortcut behind a modifier key."
|
|
2069
2345
|
});
|
|
@@ -2303,6 +2579,72 @@ const themeHotkeyPresent = {
|
|
|
2303
2579
|
}
|
|
2304
2580
|
};
|
|
2305
2581
|
//#endregion
|
|
2582
|
+
//#region src/rules/interaction/typing-target-guard.ts
|
|
2583
|
+
const GLOBAL_KEY_BINDING = /addEventListener\s*\(\s*['"]keydown['"]|onKeyStroke\s*\(|useEventListener\s*\([^,]*,\s*['"]keydown['"]/u;
|
|
2584
|
+
const MAGIC_KEYS_SINGLE_LETTER = /\{\s*[^}]*\b(?<!meta_)(?<!ctrl_)(?<!alt_)(?<!shift_)[a-z]\b[^}]*\}\s*=\s*useMagicKeys/u;
|
|
2585
|
+
/**
|
|
2586
|
+
* A shortcut that fires while the user is typing steals the keystroke. The
|
|
2587
|
+
* guard is a check that the event target is not a field: tagName against
|
|
2588
|
+
* INPUT/TEXTAREA/SELECT, an instanceof against the matching element type, a
|
|
2589
|
+
* closest()/matches() selector, or isContentEditable.
|
|
2590
|
+
*/
|
|
2591
|
+
const TYPING_GUARD = /\b(?:INPUT|TEXTAREA|SELECT)\b|HTML(?:Input|TextArea|Select)Element|(?:closest|matches)\s*\(\s*['"][^'"]*(?:input|textarea|select)|isContentEditable|contenteditable/iu;
|
|
2592
|
+
const MODIFIER = /metaKey|ctrlKey|altKey|\b(?:meta|ctrl|alt)_/u;
|
|
2593
|
+
/**
|
|
2594
|
+
* Escape, Enter, Tab and the arrows are expected to work while a field has
|
|
2595
|
+
* focus — Escape closing a dialog is correct behaviour, not a stolen
|
|
2596
|
+
* keystroke. A handler that only reacts to those needs no typing guard.
|
|
2597
|
+
*/
|
|
2598
|
+
const NAVIGATION_KEYS = /^(?:Escape|Enter|Tab|Arrow[A-Za-z]+|Home|End|Page[A-Za-z]+)$/u;
|
|
2599
|
+
const reactsOnlyToNavigationKeys = (text) => {
|
|
2600
|
+
const compared = [...text.matchAll(/\.key\s*===\s*['"]([^'"]+)['"]/gu)].map((match) => match[1]);
|
|
2601
|
+
return compared.length > 0 && compared.every((key) => NAVIGATION_KEYS.test(key));
|
|
2602
|
+
};
|
|
2603
|
+
const lineOf$2 = (text, pattern) => {
|
|
2604
|
+
const index = text.search(pattern);
|
|
2605
|
+
if (index < 0) return 1;
|
|
2606
|
+
return text.slice(0, index).split("\n").length;
|
|
2607
|
+
};
|
|
2608
|
+
const typingTargetGuard = {
|
|
2609
|
+
id: "typing-target-guard",
|
|
2610
|
+
title: "Global shortcuts ignore keystrokes aimed at a field",
|
|
2611
|
+
description: "A global single-key shortcut should not fire while the user is typing. Handlers bound to the document need a check that the event target is not an input, textarea, select, or contenteditable element.",
|
|
2612
|
+
category: "interaction",
|
|
2613
|
+
severity: "warning",
|
|
2614
|
+
confidence: "medium",
|
|
2615
|
+
maxScore: 3,
|
|
2616
|
+
adapters: [
|
|
2617
|
+
"nuxt",
|
|
2618
|
+
"vite-vue",
|
|
2619
|
+
"generic-vue"
|
|
2620
|
+
],
|
|
2621
|
+
run: async ({ sources, result }) => {
|
|
2622
|
+
const files = await sources();
|
|
2623
|
+
const findings = [];
|
|
2624
|
+
let evaluated = 0;
|
|
2625
|
+
for (const file of files) {
|
|
2626
|
+
if (file.kind !== "vue" && file.kind !== "ts" && file.kind !== "js") continue;
|
|
2627
|
+
const bindsGlobalKey = GLOBAL_KEY_BINDING.test(file.text);
|
|
2628
|
+
const bindsBareLetter = MAGIC_KEYS_SINGLE_LETTER.test(file.text);
|
|
2629
|
+
if (!bindsGlobalKey && !bindsBareLetter) continue;
|
|
2630
|
+
if (!bindsBareLetter && MODIFIER.test(file.text) && !GLOBAL_KEY_BINDING.test(file.text)) continue;
|
|
2631
|
+
if (reactsOnlyToNavigationKeys(file.text)) continue;
|
|
2632
|
+
evaluated += 1;
|
|
2633
|
+
if (TYPING_GUARD.test(file.text)) continue;
|
|
2634
|
+
findings.push({
|
|
2635
|
+
message: "A global key handler has no guard for keystrokes aimed at a field.",
|
|
2636
|
+
evidence: [{
|
|
2637
|
+
path: file.relPath,
|
|
2638
|
+
line: lineOf$2(file.text, bindsGlobalKey ? GLOBAL_KEY_BINDING : MAGIC_KEYS_SINGLE_LETTER)
|
|
2639
|
+
}],
|
|
2640
|
+
remediation: "Return early when the event target is an input, textarea, select, or contenteditable element before acting on the key."
|
|
2641
|
+
});
|
|
2642
|
+
}
|
|
2643
|
+
if (evaluated === 0) return result.notApplicable();
|
|
2644
|
+
return findings.length > 0 ? result.fail(findings) : result.pass();
|
|
2645
|
+
}
|
|
2646
|
+
};
|
|
2647
|
+
//#endregion
|
|
2306
2648
|
//#region src/rules/forms/forms-shared.ts
|
|
2307
2649
|
const VALIDATION_PACKAGES = [
|
|
2308
2650
|
"vee-validate",
|
|
@@ -2559,6 +2901,68 @@ const groupedControlsHaveLegend = {
|
|
|
2559
2901
|
}
|
|
2560
2902
|
};
|
|
2561
2903
|
//#endregion
|
|
2904
|
+
//#region src/rules/forms/input-group-composition.ts
|
|
2905
|
+
const INPUT_GROUP_TAG = "input-group";
|
|
2906
|
+
const GROUP_PARTS = /* @__PURE__ */ new Set([
|
|
2907
|
+
"input-group-input",
|
|
2908
|
+
"input-group-textarea",
|
|
2909
|
+
"input-group-addon"
|
|
2910
|
+
]);
|
|
2911
|
+
const RAW_CONTROLS = /* @__PURE__ */ new Set(["input", "textarea"]);
|
|
2912
|
+
const inputGroupComposition = {
|
|
2913
|
+
id: "input-group-composition",
|
|
2914
|
+
title: "InputGroup uses its own parts",
|
|
2915
|
+
description: "An InputGroup wired with a raw input or textarea loses the padding, focus ring, and addon alignment its own parts provide, so the group renders correctly only by accident.",
|
|
2916
|
+
category: "forms",
|
|
2917
|
+
severity: "warning",
|
|
2918
|
+
confidence: "medium",
|
|
2919
|
+
maxScore: 0,
|
|
2920
|
+
adapters: [
|
|
2921
|
+
"nuxt",
|
|
2922
|
+
"vite-vue",
|
|
2923
|
+
"generic-vue"
|
|
2924
|
+
],
|
|
2925
|
+
run: async ({ sources, result }) => {
|
|
2926
|
+
const files = await sources();
|
|
2927
|
+
const findings = [];
|
|
2928
|
+
let instances = 0;
|
|
2929
|
+
for (const file of files) {
|
|
2930
|
+
if (file.sfc?.templateAst === void 0 || isGeneratedUiPrimitive(file.relPath)) continue;
|
|
2931
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2932
|
+
walkTemplate(file.sfc.templateAst, (element, ancestors) => {
|
|
2933
|
+
const tag = pascalToKebab(element.tag);
|
|
2934
|
+
if (tag === INPUT_GROUP_TAG) {
|
|
2935
|
+
groups.set(element, {
|
|
2936
|
+
line: elementLine(element),
|
|
2937
|
+
usesParts: false
|
|
2938
|
+
});
|
|
2939
|
+
return;
|
|
2940
|
+
}
|
|
2941
|
+
const owner = ancestors.find((ancestor) => pascalToKebab(ancestor.tag) === INPUT_GROUP_TAG);
|
|
2942
|
+
if (owner === void 0) return;
|
|
2943
|
+
const entry = groups.get(owner);
|
|
2944
|
+
if (entry === void 0) return;
|
|
2945
|
+
if (GROUP_PARTS.has(tag)) {
|
|
2946
|
+
entry.usesParts = true;
|
|
2947
|
+
return;
|
|
2948
|
+
}
|
|
2949
|
+
if (RAW_CONTROLS.has(tag)) entry.rawControl ??= tag;
|
|
2950
|
+
});
|
|
2951
|
+
instances += groups.size;
|
|
2952
|
+
for (const entry of groups.values()) if (entry.rawControl !== void 0 && !entry.usesParts) findings.push({
|
|
2953
|
+
message: `<InputGroup> wraps a raw <${entry.rawControl}> instead of its own part.`,
|
|
2954
|
+
evidence: [{
|
|
2955
|
+
path: file.relPath,
|
|
2956
|
+
line: entry.line
|
|
2957
|
+
}],
|
|
2958
|
+
remediation: "Use InputGroupInput or InputGroupTextarea inside InputGroup, with InputGroupAddon for leading and trailing content."
|
|
2959
|
+
});
|
|
2960
|
+
}
|
|
2961
|
+
if (instances === 0) return result.notApplicable();
|
|
2962
|
+
return findings.length > 0 ? result.advisory(findings) : result.pass();
|
|
2963
|
+
}
|
|
2964
|
+
};
|
|
2965
|
+
//#endregion
|
|
2562
2966
|
//#region src/rules/forms/invalid-fields-associated-with-errors.ts
|
|
2563
2967
|
const ERROR_COMPONENT = /^(?:form-message|error-message|field-error)$/u;
|
|
2564
2968
|
const isFormControlWrapper = (tag) => pascalToKebab(tag) === "form-control";
|
|
@@ -3114,6 +3518,119 @@ const toastProviderPresent = {
|
|
|
3114
3518
|
}
|
|
3115
3519
|
};
|
|
3116
3520
|
//#endregion
|
|
3521
|
+
//#region src/rules/states/toast-runtime.ts
|
|
3522
|
+
const TOAST_CALL = /\btoast(?:\.(?:success|error|info|warning|promise|custom|message))?\s*\(/u;
|
|
3523
|
+
/**
|
|
3524
|
+
* The Vue toast runtimes shadcn-vue projects use. vue-sonner is what
|
|
3525
|
+
* shadcn-vue's own Sonner block installs; the others cover the common
|
|
3526
|
+
* alternatives.
|
|
3527
|
+
*/
|
|
3528
|
+
const TOAST_DEPENDENCIES = [
|
|
3529
|
+
"vue-sonner",
|
|
3530
|
+
"sonner",
|
|
3531
|
+
"@nuxt/ui",
|
|
3532
|
+
"vue-toastification",
|
|
3533
|
+
"vue3-toastify",
|
|
3534
|
+
"primevue"
|
|
3535
|
+
];
|
|
3536
|
+
const lineOf$1 = (text, pattern) => {
|
|
3537
|
+
const index = text.search(pattern);
|
|
3538
|
+
if (index < 0) return 1;
|
|
3539
|
+
return text.slice(0, index).split("\n").length;
|
|
3540
|
+
};
|
|
3541
|
+
const toastRuntime = {
|
|
3542
|
+
id: "toast-runtime",
|
|
3543
|
+
title: "Toast calls have a runtime behind them",
|
|
3544
|
+
description: "Code that calls toast() needs a toast library installed, otherwise the call resolves to nothing at runtime and the notification silently never appears.",
|
|
3545
|
+
category: "states",
|
|
3546
|
+
severity: "error",
|
|
3547
|
+
confidence: "high",
|
|
3548
|
+
maxScore: 3,
|
|
3549
|
+
adapters: [
|
|
3550
|
+
"nuxt",
|
|
3551
|
+
"vite-vue",
|
|
3552
|
+
"generic-vue"
|
|
3553
|
+
],
|
|
3554
|
+
run: async ({ sources, discovery, result }) => {
|
|
3555
|
+
const caller = (await sources()).find((file) => !isGeneratedUiPrimitive(file.relPath) && TOAST_CALL.test(file.text));
|
|
3556
|
+
if (caller === void 0) return result.notApplicable();
|
|
3557
|
+
if (TOAST_DEPENDENCIES.find((dependency) => discovery.dependencies[dependency] !== void 0) !== void 0) return result.pass();
|
|
3558
|
+
return result.fail([{
|
|
3559
|
+
message: "toast() is called but no toast runtime is installed.",
|
|
3560
|
+
evidence: [{
|
|
3561
|
+
path: caller.relPath,
|
|
3562
|
+
line: lineOf$1(caller.text, TOAST_CALL)
|
|
3563
|
+
}],
|
|
3564
|
+
remediation: "Install a toast runtime (shadcn-vue ships the Sonner block backed by vue-sonner) and mount its provider in the app shell."
|
|
3565
|
+
}]);
|
|
3566
|
+
}
|
|
3567
|
+
};
|
|
3568
|
+
//#endregion
|
|
3569
|
+
//#region src/rules/production-polish/alert-anatomy.ts
|
|
3570
|
+
const ALERT_TAG = "alert";
|
|
3571
|
+
const ALERT_TITLE_TAG = "alert-title";
|
|
3572
|
+
const ALERT_DESCRIPTION_TAG = "alert-description";
|
|
3573
|
+
const ICON_TAG$1 = /(?:^|-)icon$/u;
|
|
3574
|
+
const isIcon = (tag) => {
|
|
3575
|
+
const normalized = pascalToKebab(tag);
|
|
3576
|
+
return ICON_TAG$1.test(normalized) || normalized === "svg";
|
|
3577
|
+
};
|
|
3578
|
+
const alertAnatomy = {
|
|
3579
|
+
id: "alert-anatomy",
|
|
3580
|
+
title: "Alert matches its anatomy",
|
|
3581
|
+
description: "An Alert should carry an AlertTitle and at most one icon. A title-less alert announces as an unlabelled region, and a second icon competes with the first for meaning.",
|
|
3582
|
+
category: "production-polish",
|
|
3583
|
+
severity: "warning",
|
|
3584
|
+
confidence: "medium",
|
|
3585
|
+
maxScore: 0,
|
|
3586
|
+
adapters: [
|
|
3587
|
+
"nuxt",
|
|
3588
|
+
"vite-vue",
|
|
3589
|
+
"generic-vue"
|
|
3590
|
+
],
|
|
3591
|
+
run: async ({ sources, result }) => {
|
|
3592
|
+
const files = await sources();
|
|
3593
|
+
const findings = [];
|
|
3594
|
+
let instances = 0;
|
|
3595
|
+
for (const file of files) {
|
|
3596
|
+
if (file.sfc?.templateAst === void 0 || isGeneratedUiPrimitive(file.relPath)) continue;
|
|
3597
|
+
const alerts = /* @__PURE__ */ new Map();
|
|
3598
|
+
walkTemplate(file.sfc.templateAst, (element, ancestors) => {
|
|
3599
|
+
if (pascalToKebab(element.tag) === ALERT_TAG) {
|
|
3600
|
+
alerts.set(element, {
|
|
3601
|
+
line: elementLine(element),
|
|
3602
|
+
titles: 0,
|
|
3603
|
+
icons: 0
|
|
3604
|
+
});
|
|
3605
|
+
return;
|
|
3606
|
+
}
|
|
3607
|
+
const owner = ancestors.find((ancestor) => pascalToKebab(ancestor.tag) === ALERT_TAG);
|
|
3608
|
+
if (owner === void 0) return;
|
|
3609
|
+
const entry = alerts.get(owner);
|
|
3610
|
+
if (entry === void 0) return;
|
|
3611
|
+
if (pascalToKebab(element.tag) === ALERT_TITLE_TAG) entry.titles += 1;
|
|
3612
|
+
if (isIcon(element.tag)) entry.icons += 1;
|
|
3613
|
+
});
|
|
3614
|
+
instances += alerts.size;
|
|
3615
|
+
for (const entry of alerts.values()) {
|
|
3616
|
+
const problems = [];
|
|
3617
|
+
if (entry.titles === 0) problems.push("no AlertTitle");
|
|
3618
|
+
if (entry.icons > 1) problems.push(`${entry.icons} icons`);
|
|
3619
|
+
if (problems.length > 0) findings.push({
|
|
3620
|
+
message: `<Alert> has ${problems.join(" and ")}.`,
|
|
3621
|
+
evidence: [{
|
|
3622
|
+
path: file.relPath,
|
|
3623
|
+
line: entry.line
|
|
3624
|
+
}],
|
|
3625
|
+
remediation: `Compose Alert from its parts: one AlertTitle, an optional ${ALERT_DESCRIPTION_TAG.replace("-", "")}, and at most one leading icon.`
|
|
3626
|
+
});
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
if (instances === 0) return result.notApplicable();
|
|
3630
|
+
return findings.length > 0 ? result.advisory(findings) : result.pass();
|
|
3631
|
+
}
|
|
3632
|
+
};
|
|
3633
|
+
//#endregion
|
|
3117
3634
|
//#region src/rules/production-polish/polish-shared.ts
|
|
3118
3635
|
const SHELL_FILES = /* @__PURE__ */ new Set([
|
|
3119
3636
|
"app.vue",
|
|
@@ -3242,10 +3759,67 @@ const buttonIconsHaveDataIcon = {
|
|
|
3242
3759
|
}
|
|
3243
3760
|
};
|
|
3244
3761
|
//#endregion
|
|
3762
|
+
//#region src/rules/production-polish/color-contrast-passes.ts
|
|
3763
|
+
const COLOR_STYLE_PATTERN = /(?:color|background(?:-color)?|border-color|fill|stroke)\s*:|\b(?:bg|border|fill|stroke|text)-(?:accent|background|card|destructive|foreground|input|muted|popover|primary|ring|secondary|[a-z]+-\d{2,3})\b|#[\da-f]{3,8}\b|(?:hsl|oklch|rgb)a?\s*\(/iu;
|
|
3764
|
+
const lineOf = (text, pattern) => {
|
|
3765
|
+
const index = text.search(pattern);
|
|
3766
|
+
if (index < 0) return 1;
|
|
3767
|
+
return text.slice(0, index).split("\n").length;
|
|
3768
|
+
};
|
|
3769
|
+
const colorContrastPasses = {
|
|
3770
|
+
id: "color-contrast-passes",
|
|
3771
|
+
title: "Rendered colour contrast meets accessibility thresholds",
|
|
3772
|
+
description: "Marks styled colour pairs for browser verification. Computed contrast depends on cascade, theme, and opacity, none of which can be resolved from source, so this reports where to look rather than scoring it.",
|
|
3773
|
+
category: "production-polish",
|
|
3774
|
+
severity: "warning",
|
|
3775
|
+
confidence: "low",
|
|
3776
|
+
maxScore: 0,
|
|
3777
|
+
adapters: [
|
|
3778
|
+
"nuxt",
|
|
3779
|
+
"vite-vue",
|
|
3780
|
+
"generic-vue"
|
|
3781
|
+
],
|
|
3782
|
+
run: async ({ sources, result }) => {
|
|
3783
|
+
const styled = (await sources()).find((file) => !isGeneratedUiPrimitive(file.relPath) && COLOR_STYLE_PATTERN.test(file.text));
|
|
3784
|
+
if (styled === void 0) return result.notApplicable();
|
|
3785
|
+
return result.advisory([{
|
|
3786
|
+
message: "Colour styling is present, but computed foreground and background contrast cannot be established from source.",
|
|
3787
|
+
evidence: [{
|
|
3788
|
+
path: styled.relPath,
|
|
3789
|
+
line: lineOf(styled.text, COLOR_STYLE_PATTERN)
|
|
3790
|
+
}],
|
|
3791
|
+
remediation: "Check rendered states in every theme and viewport: 4.5:1 for body text, 3:1 for large text, and 3:1 for meaningful UI graphics and boundaries."
|
|
3792
|
+
}]);
|
|
3793
|
+
}
|
|
3794
|
+
};
|
|
3795
|
+
//#endregion
|
|
3245
3796
|
//#region src/rules/production-polish/metadata-title-description-complete.ts
|
|
3246
3797
|
const TITLE_PATTERN = /\b(?:title|titleTemplate)\s*:/u;
|
|
3247
3798
|
const DESCRIPTION_PATTERN = /\bdescription\s*:/u;
|
|
3248
3799
|
const HEAD_CALL_PATTERN = /\b(?:useSeoMeta|useHead)\s*\(/u;
|
|
3800
|
+
const COMPOSABLE_PATH = /(?:^|\/)composables\//u;
|
|
3801
|
+
const EXPORTED_COMPOSABLE = /export\s+(?:const|function|async\s+function)\s+(use[A-Z]\w*)/gu;
|
|
3802
|
+
/**
|
|
3803
|
+
* A page that redirects during setup never renders, so a crawler follows the
|
|
3804
|
+
* redirect and reads the destination's metadata instead. Demanding a title and
|
|
3805
|
+
* description for it reports a failure with nothing to fix.
|
|
3806
|
+
*/
|
|
3807
|
+
const REDIRECT_ONLY = /await\s+navigateTo\s*\(|definePageMeta\s*\(\s*\{[^}]*\bredirect\s*:/su;
|
|
3808
|
+
/**
|
|
3809
|
+
* Extracting the head call into a `usePageSeo`-style composable is the
|
|
3810
|
+
* documented Nuxt pattern, so a page that calls one of those wrappers has
|
|
3811
|
+
* declared its metadata just as surely as one calling useSeoMeta inline.
|
|
3812
|
+
* Collect every project composable that reaches a head call, then treat a call
|
|
3813
|
+
* to it as a metadata declaration.
|
|
3814
|
+
*/
|
|
3815
|
+
const seoComposableNames = (files) => {
|
|
3816
|
+
const names = /* @__PURE__ */ new Set();
|
|
3817
|
+
for (const file of files) {
|
|
3818
|
+
if (!COMPOSABLE_PATH.test(file.relPath) || !HEAD_CALL_PATTERN.test(file.text)) continue;
|
|
3819
|
+
for (const match of file.text.matchAll(EXPORTED_COMPOSABLE)) names.add(match[1]);
|
|
3820
|
+
}
|
|
3821
|
+
return names;
|
|
3822
|
+
};
|
|
3249
3823
|
const metadataTitleDescriptionComplete = {
|
|
3250
3824
|
id: "metadata-title-description-complete",
|
|
3251
3825
|
title: "Routable pages declare a title and description",
|
|
@@ -3260,11 +3834,15 @@ const metadataTitleDescriptionComplete = {
|
|
|
3260
3834
|
"generic-vue"
|
|
3261
3835
|
],
|
|
3262
3836
|
run: async ({ sources, result }) => {
|
|
3263
|
-
const
|
|
3837
|
+
const files = await sources();
|
|
3838
|
+
const pages = files.filter((file) => file.kind === "vue" && isPageFile(file.relPath));
|
|
3264
3839
|
if (pages.length === 0) return result.notApplicable();
|
|
3840
|
+
const wrappers = seoComposableNames(files);
|
|
3841
|
+
const wrapperCall = wrappers.size > 0 ? new RegExp(`\\b(?:${[...wrappers].join("|")})\\s*\\(`, "u") : void 0;
|
|
3265
3842
|
const findings = [];
|
|
3266
3843
|
for (const page of pages) {
|
|
3267
|
-
if (
|
|
3844
|
+
if (REDIRECT_ONLY.test(page.text)) continue;
|
|
3845
|
+
if (!(HEAD_CALL_PATTERN.test(page.text) || wrapperCall?.test(page.text) === true)) {
|
|
3268
3846
|
findings.push({
|
|
3269
3847
|
message: "Page does not declare its own metadata.",
|
|
3270
3848
|
evidence: [{ path: page.relPath }],
|
|
@@ -3560,6 +4138,72 @@ const responsiveShellPresent = {
|
|
|
3560
4138
|
}
|
|
3561
4139
|
};
|
|
3562
4140
|
//#endregion
|
|
4141
|
+
//#region src/rules/production-polish/responsive-visibility.ts
|
|
4142
|
+
/**
|
|
4143
|
+
* `hidden md:block` shows an element from the md breakpoint up. A sibling that
|
|
4144
|
+
* is `md:hidden` covers the smaller range. When only one half of the pair
|
|
4145
|
+
* exists on a surface, one viewport range renders nothing at all.
|
|
4146
|
+
*/
|
|
4147
|
+
const HIDE_AT_BREAKPOINT = /\b(?:sm|md|lg|xl|2xl):hidden\b/u;
|
|
4148
|
+
const SHOW_FROM_BREAKPOINT = /\bhidden\b[^"']*\b(?:sm|md|lg|xl|2xl):(?:block|flex|grid|inline)/u;
|
|
4149
|
+
const classOf = (element) => {
|
|
4150
|
+
const values = [];
|
|
4151
|
+
for (const prop of element.props) {
|
|
4152
|
+
const candidate = prop;
|
|
4153
|
+
if (candidate.name === "class" && typeof candidate.value?.content === "string") values.push(candidate.value.content);
|
|
4154
|
+
}
|
|
4155
|
+
return values.join(" ");
|
|
4156
|
+
};
|
|
4157
|
+
const responsiveVisibility = {
|
|
4158
|
+
id: "responsive-visibility",
|
|
4159
|
+
title: "Responsive show and hide pairs cover every viewport",
|
|
4160
|
+
description: "A surface that hides an element at a breakpoint should present the alternative at the complementary range, otherwise one viewport shows nothing where content is expected.",
|
|
4161
|
+
category: "production-polish",
|
|
4162
|
+
severity: "warning",
|
|
4163
|
+
confidence: "low",
|
|
4164
|
+
maxScore: 0,
|
|
4165
|
+
adapters: [
|
|
4166
|
+
"nuxt",
|
|
4167
|
+
"vite-vue",
|
|
4168
|
+
"generic-vue"
|
|
4169
|
+
],
|
|
4170
|
+
run: async ({ sources, result }) => {
|
|
4171
|
+
const files = await sources();
|
|
4172
|
+
const findings = [];
|
|
4173
|
+
let evaluated = 0;
|
|
4174
|
+
for (const file of files) {
|
|
4175
|
+
if (file.sfc?.templateAst === void 0 || isGeneratedUiPrimitive(file.relPath)) continue;
|
|
4176
|
+
let hides = 0;
|
|
4177
|
+
let shows = 0;
|
|
4178
|
+
let firstHideLine;
|
|
4179
|
+
walkTemplate(file.sfc.templateAst, (element) => {
|
|
4180
|
+
const classes = classOf(element);
|
|
4181
|
+
if (classes === "") return;
|
|
4182
|
+
if (SHOW_FROM_BREAKPOINT.test(classes)) {
|
|
4183
|
+
shows += 1;
|
|
4184
|
+
return;
|
|
4185
|
+
}
|
|
4186
|
+
if (HIDE_AT_BREAKPOINT.test(classes)) {
|
|
4187
|
+
hides += 1;
|
|
4188
|
+
firstHideLine ??= elementLine(element);
|
|
4189
|
+
}
|
|
4190
|
+
});
|
|
4191
|
+
if (hides === 0 && shows === 0) continue;
|
|
4192
|
+
evaluated += 1;
|
|
4193
|
+
if (hides > 0 && shows === 0) findings.push({
|
|
4194
|
+
message: "This surface hides content at a breakpoint without a counterpart shown at the complementary range.",
|
|
4195
|
+
evidence: [{
|
|
4196
|
+
path: file.relPath,
|
|
4197
|
+
line: firstHideLine ?? 1
|
|
4198
|
+
}],
|
|
4199
|
+
remediation: "Pair each breakpoint-hidden element with the alternative for the other range (for example `md:hidden` beside `hidden md:block`), or confirm the omission is deliberate."
|
|
4200
|
+
});
|
|
4201
|
+
}
|
|
4202
|
+
if (evaluated === 0) return result.notApplicable();
|
|
4203
|
+
return findings.length > 0 ? result.advisory(findings) : result.pass();
|
|
4204
|
+
}
|
|
4205
|
+
};
|
|
4206
|
+
//#endregion
|
|
3563
4207
|
//#region src/rules/production-polish/social-preview-present.ts
|
|
3564
4208
|
const OG_IMAGE_PATTERN = /(?:og:image|ogImage|twitter:image|twitterImage)/u;
|
|
3565
4209
|
const OG_TITLE_PATTERN = /(?:og:title|ogTitle|twitter:title|twitterCard|twitter:card)/u;
|
|
@@ -3588,8 +4232,10 @@ const defaultRules = [
|
|
|
3588
4232
|
focusVisibleNotSuppressed,
|
|
3589
4233
|
itemsBelongToGroups,
|
|
3590
4234
|
destructiveActionsConfirmed,
|
|
4235
|
+
typingTargetGuard,
|
|
3591
4236
|
toastProviderPresent,
|
|
3592
4237
|
toastProviderMounted,
|
|
4238
|
+
toastRuntime,
|
|
3593
4239
|
routeLoadingBoundaryPresent,
|
|
3594
4240
|
suspenseFallbackUseful,
|
|
3595
4241
|
emptyStatePresent,
|
|
@@ -3607,6 +4253,10 @@ const defaultRules = [
|
|
|
3607
4253
|
iframesHaveTitle,
|
|
3608
4254
|
interactiveElementsAreSemantic,
|
|
3609
4255
|
noNestedInteractiveControls,
|
|
4256
|
+
customControlsHaveLabels,
|
|
4257
|
+
statusMessagesAnnounced,
|
|
4258
|
+
dialogFocusTrapWorks,
|
|
4259
|
+
keyboardNavigationWorks,
|
|
3610
4260
|
formsHaveLabels,
|
|
3611
4261
|
fieldErrorsRendered,
|
|
3612
4262
|
invalidFieldsAssociatedWithErrors,
|
|
@@ -3614,6 +4264,7 @@ const defaultRules = [
|
|
|
3614
4264
|
groupedControlsHaveLegend,
|
|
3615
4265
|
personalDataAutocompletePresent,
|
|
3616
4266
|
validationWiredToForm,
|
|
4267
|
+
inputGroupComposition,
|
|
3617
4268
|
noStarterCopy,
|
|
3618
4269
|
metadataTitleDescriptionComplete,
|
|
3619
4270
|
{
|
|
@@ -3649,7 +4300,10 @@ const defaultRules = [
|
|
|
3649
4300
|
mobileOverflowAbsent,
|
|
3650
4301
|
animationsRespectReducedMotion,
|
|
3651
4302
|
pointerTargetSizePasses,
|
|
3652
|
-
buttonIconsHaveDataIcon
|
|
4303
|
+
buttonIconsHaveDataIcon,
|
|
4304
|
+
alertAnatomy,
|
|
4305
|
+
responsiveVisibility,
|
|
4306
|
+
colorContrastPasses
|
|
3653
4307
|
];
|
|
3654
4308
|
//#endregion
|
|
3655
4309
|
//#region src/scan.ts
|