pulse-js-framework 1.7.8 → 1.7.10
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/cli/lint.js +442 -3
- package/compiler/lexer.js +6 -0
- package/compiler/parser.js +144 -1
- package/compiler/transformer/imports.js +15 -0
- package/compiler/transformer/index.js +46 -0
- package/compiler/transformer/view.js +180 -5
- package/package.json +14 -2
- package/runtime/a11y.js +1005 -0
- package/runtime/devtools/a11y-audit.js +442 -0
- package/runtime/devtools/diagnostics.js +403 -0
- package/runtime/devtools/index.js +53 -0
- package/runtime/devtools/time-travel.js +189 -0
- package/runtime/devtools.js +138 -497
- package/runtime/dom-binding.js +7 -4
- package/runtime/dom-element.js +192 -1
- package/runtime/dom.js +8 -2
- package/runtime/http.js +837 -0
- package/runtime/index.js +2 -0
- package/runtime/native.js +2 -2
- package/runtime/security.js +461 -0
- package/runtime/utils.js +37 -16
- package/types/a11y.d.ts +336 -0
package/cli/lint.js
CHANGED
|
@@ -20,6 +20,18 @@ export const LintRules = {
|
|
|
20
20
|
// Security rules (warnings)
|
|
21
21
|
'xss-vulnerability': { severity: 'warning', fixable: false },
|
|
22
22
|
|
|
23
|
+
// Accessibility rules (warnings)
|
|
24
|
+
'a11y-img-alt': { severity: 'warning', fixable: true }, // Can add alt=""
|
|
25
|
+
'a11y-button-text': { severity: 'warning', fixable: false },
|
|
26
|
+
'a11y-link-text': { severity: 'warning', fixable: false },
|
|
27
|
+
'a11y-input-label': { severity: 'warning', fixable: false },
|
|
28
|
+
'a11y-click-key': { severity: 'warning', fixable: false },
|
|
29
|
+
'a11y-no-autofocus': { severity: 'warning', fixable: true }, // Can remove autofocus
|
|
30
|
+
'a11y-no-positive-tabindex': { severity: 'warning', fixable: true }, // Can change to 0
|
|
31
|
+
'a11y-heading-order': { severity: 'warning', fixable: false },
|
|
32
|
+
'a11y-aria-props': { severity: 'warning', fixable: false },
|
|
33
|
+
'a11y-role-props': { severity: 'warning', fixable: false },
|
|
34
|
+
|
|
23
35
|
// Usage rules (warnings)
|
|
24
36
|
'unused-import': { severity: 'warning', fixable: true },
|
|
25
37
|
'unused-state': { severity: 'warning', fixable: false },
|
|
@@ -136,6 +148,9 @@ export class SemanticAnalyzer {
|
|
|
136
148
|
// Phase 5: Security checks (XSS detection)
|
|
137
149
|
this.checkSecurity();
|
|
138
150
|
|
|
151
|
+
// Phase 6: Accessibility checks
|
|
152
|
+
this.checkAccessibility();
|
|
153
|
+
|
|
139
154
|
return this.diagnostics;
|
|
140
155
|
}
|
|
141
156
|
|
|
@@ -639,17 +654,374 @@ export class SemanticAnalyzer {
|
|
|
639
654
|
}
|
|
640
655
|
}
|
|
641
656
|
|
|
657
|
+
/**
|
|
658
|
+
* Check for accessibility issues
|
|
659
|
+
*/
|
|
660
|
+
checkAccessibility() {
|
|
661
|
+
if (!this.ast.view) return;
|
|
662
|
+
|
|
663
|
+
this.lastHeadingLevel = 0;
|
|
664
|
+
this.checkViewAccessibility(this.ast.view);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Recursively check view nodes for accessibility
|
|
669
|
+
*/
|
|
670
|
+
checkViewAccessibility(node) {
|
|
671
|
+
if (!node) return;
|
|
672
|
+
|
|
673
|
+
const children = node.children || [];
|
|
674
|
+
for (const child of children) {
|
|
675
|
+
if (!child) continue;
|
|
676
|
+
this.checkNodeAccessibility(child);
|
|
677
|
+
this.checkViewAccessibility(child);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Check a single node for accessibility issues
|
|
683
|
+
*/
|
|
684
|
+
checkNodeAccessibility(node) {
|
|
685
|
+
if (!node || node.type !== 'Element') return;
|
|
686
|
+
|
|
687
|
+
const selector = node.selector || node.tag || '';
|
|
688
|
+
const tagMatch = selector.match(/^([a-z][a-z0-9]*)/i);
|
|
689
|
+
const tagName = tagMatch ? tagMatch[1].toLowerCase() : '';
|
|
690
|
+
|
|
691
|
+
// Skip custom components (PascalCase)
|
|
692
|
+
if (/^[A-Z]/.test(tagMatch?.[1] || '')) return;
|
|
693
|
+
|
|
694
|
+
const line = node.line || 1;
|
|
695
|
+
const column = node.column || 1;
|
|
696
|
+
|
|
697
|
+
// Extract attributes from selector and directives
|
|
698
|
+
const attrs = this.extractAttributes(node);
|
|
699
|
+
|
|
700
|
+
// Rule: img-alt - Images must have alt attribute
|
|
701
|
+
if (tagName === 'img') {
|
|
702
|
+
if (!attrs.has('alt') && !attrs.has('aria-label') && !attrs.has('aria-labelledby')) {
|
|
703
|
+
const selector = node.selector || 'img';
|
|
704
|
+
this.addDiagnostic('warning', 'a11y-img-alt',
|
|
705
|
+
'Image missing alt attribute. Add alt="" for decorative images or descriptive text for informative images.',
|
|
706
|
+
line, column, {
|
|
707
|
+
type: 'replace',
|
|
708
|
+
oldText: selector,
|
|
709
|
+
newText: selector + '[alt=""]',
|
|
710
|
+
description: 'Add empty alt attribute for decorative image'
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Rule: button-text - Buttons must have accessible name
|
|
716
|
+
if (tagName === 'button') {
|
|
717
|
+
const hasText = this.hasTextContent(node);
|
|
718
|
+
const hasAriaLabel = attrs.has('aria-label') || attrs.has('aria-labelledby') || attrs.has('title');
|
|
719
|
+
if (!hasText && !hasAriaLabel) {
|
|
720
|
+
this.addDiagnostic('warning', 'a11y-button-text',
|
|
721
|
+
'Button has no accessible name. Add text content, aria-label, or aria-labelledby.',
|
|
722
|
+
line, column);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Rule: link-text - Links must have accessible name
|
|
727
|
+
if (tagName === 'a') {
|
|
728
|
+
const hasText = this.hasTextContent(node);
|
|
729
|
+
const hasAriaLabel = attrs.has('aria-label') || attrs.has('aria-labelledby');
|
|
730
|
+
const hasImgAlt = this.hasChildWithAlt(node);
|
|
731
|
+
if (!hasText && !hasAriaLabel && !hasImgAlt) {
|
|
732
|
+
this.addDiagnostic('warning', 'a11y-link-text',
|
|
733
|
+
'Link has no accessible name. Add text content, aria-label, or an image with alt.',
|
|
734
|
+
line, column);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Rule: input-label - Form inputs must have labels
|
|
739
|
+
if (['input', 'select', 'textarea'].includes(tagName)) {
|
|
740
|
+
const inputType = attrs.get('type') || 'text';
|
|
741
|
+
if (!['hidden', 'submit', 'button', 'reset', 'image'].includes(inputType)) {
|
|
742
|
+
const hasLabel = attrs.has('aria-label') || attrs.has('aria-labelledby') || attrs.has('id');
|
|
743
|
+
const hasPlaceholder = attrs.has('placeholder');
|
|
744
|
+
if (!hasLabel) {
|
|
745
|
+
const msg = hasPlaceholder
|
|
746
|
+
? 'Input uses placeholder but missing label. Placeholder is not a substitute for label.'
|
|
747
|
+
: 'Form input missing label. Add aria-label, aria-labelledby, or associate with <label>.';
|
|
748
|
+
this.addDiagnostic('warning', 'a11y-input-label', msg, line, column);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Rule: click-key - Click handlers on non-interactive elements need keyboard support
|
|
754
|
+
if (['div', 'span', 'section', 'article', 'aside', 'main', 'nav', 'header', 'footer', 'p', 'li'].includes(tagName)) {
|
|
755
|
+
const hasClick = this.hasDirective(node, 'click');
|
|
756
|
+
const hasKeyHandler = this.hasDirective(node, 'keydown') || this.hasDirective(node, 'keyup') || this.hasDirective(node, 'keypress');
|
|
757
|
+
const hasRole = attrs.has('role');
|
|
758
|
+
const hasTabindex = attrs.has('tabindex');
|
|
759
|
+
|
|
760
|
+
if (hasClick && !hasKeyHandler && !hasRole) {
|
|
761
|
+
this.addDiagnostic('warning', 'a11y-click-key',
|
|
762
|
+
`Click handler on <${tagName}> requires keyboard support. Add role="button", tabindex="0", and onKeyDown handler, or use <button>.`,
|
|
763
|
+
line, column);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Rule: no-autofocus - Avoid autofocus as it can disorient screen readers
|
|
768
|
+
if (attrs.has('autofocus')) {
|
|
769
|
+
const selector = node.selector || '';
|
|
770
|
+
// Create fix to remove [autofocus] from selector
|
|
771
|
+
const fixedSelector = selector.replace(/\[autofocus\]/gi, '');
|
|
772
|
+
this.addDiagnostic('warning', 'a11y-no-autofocus',
|
|
773
|
+
'Avoid autofocus - it can disorient screen reader users and cause accessibility issues.',
|
|
774
|
+
line, column, {
|
|
775
|
+
type: 'replace',
|
|
776
|
+
oldText: selector,
|
|
777
|
+
newText: fixedSelector,
|
|
778
|
+
description: 'Remove autofocus attribute'
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Rule: no-positive-tabindex - Avoid positive tabindex
|
|
783
|
+
const tabindex = attrs.get('tabindex');
|
|
784
|
+
if (tabindex && parseInt(tabindex, 10) > 0) {
|
|
785
|
+
const selector = node.selector || '';
|
|
786
|
+
// Create fix to change tabindex to 0
|
|
787
|
+
const fixedSelector = selector.replace(/\[tabindex=["']?\d+["']?\]/gi, '[tabindex="0"]');
|
|
788
|
+
this.addDiagnostic('warning', 'a11y-no-positive-tabindex',
|
|
789
|
+
'Avoid positive tabindex values. Use tabindex="0" or "-1" and rely on DOM order.',
|
|
790
|
+
line, column, {
|
|
791
|
+
type: 'replace',
|
|
792
|
+
oldText: selector,
|
|
793
|
+
newText: fixedSelector,
|
|
794
|
+
description: 'Change tabindex to 0'
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Rule: heading-order - Headings should follow hierarchy
|
|
799
|
+
const headingMatch = tagName.match(/^h([1-6])$/);
|
|
800
|
+
if (headingMatch) {
|
|
801
|
+
const level = parseInt(headingMatch[1], 10);
|
|
802
|
+
if (this.lastHeadingLevel > 0 && level > this.lastHeadingLevel + 1) {
|
|
803
|
+
this.addDiagnostic('warning', 'a11y-heading-order',
|
|
804
|
+
`Heading level skipped: <h${this.lastHeadingLevel}> to <h${level}>. Use sequential heading levels.`,
|
|
805
|
+
line, column);
|
|
806
|
+
}
|
|
807
|
+
this.lastHeadingLevel = level;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Rule: aria-props - Check ARIA attribute validity
|
|
811
|
+
this.checkAriaProps(node, attrs, line, column);
|
|
812
|
+
|
|
813
|
+
// Rule: role-props - Check role requirements
|
|
814
|
+
this.checkRoleProps(node, attrs, tagName, line, column);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Extract attributes from node selector and directives
|
|
819
|
+
*/
|
|
820
|
+
extractAttributes(node) {
|
|
821
|
+
const attrs = new Map();
|
|
822
|
+
const selector = node.selector || '';
|
|
823
|
+
|
|
824
|
+
// Parse attributes from selector [attr=value] or [attr]
|
|
825
|
+
const attrRegex = /\[([a-z][a-z0-9-]*)(?:=["']?([^"'\]]+)["']?)?\]/gi;
|
|
826
|
+
let match;
|
|
827
|
+
while ((match = attrRegex.exec(selector)) !== null) {
|
|
828
|
+
attrs.set(match[1].toLowerCase(), match[2] || true);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Check directives for attributes
|
|
832
|
+
for (const directive of node.directives || []) {
|
|
833
|
+
if (directive.name === 'attr' || directive.name === 'bind') {
|
|
834
|
+
const attrName = directive.arg || directive.attribute;
|
|
835
|
+
if (attrName) {
|
|
836
|
+
attrs.set(attrName.toLowerCase(), directive.value || true);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Check props for component-like attribute passing
|
|
842
|
+
if (node.props) {
|
|
843
|
+
for (const prop of Object.keys(node.props)) {
|
|
844
|
+
attrs.set(prop.toLowerCase(), node.props[prop]);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
return attrs;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Check if node has text content
|
|
853
|
+
*/
|
|
854
|
+
hasTextContent(node) {
|
|
855
|
+
if (node.textContent && node.textContent.length > 0) {
|
|
856
|
+
return node.textContent.some(t => {
|
|
857
|
+
// Direct string
|
|
858
|
+
if (typeof t === 'string' && t.trim().length > 0) return true;
|
|
859
|
+
// Interpolation
|
|
860
|
+
if (typeof t === 'object' && t.type === 'Interpolation') return true;
|
|
861
|
+
// TextNode with parts
|
|
862
|
+
if (typeof t === 'object' && t.type === 'TextNode' && t.parts) {
|
|
863
|
+
return t.parts.some(part =>
|
|
864
|
+
(typeof part === 'string' && part.trim().length > 0) ||
|
|
865
|
+
(typeof part === 'object')
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
return false;
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
// Check children for text nodes
|
|
872
|
+
if (node.children) {
|
|
873
|
+
return node.children.some(child =>
|
|
874
|
+
child && (child.type === 'TextNode' || this.hasTextContent(child))
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Check if node has child with alt attribute
|
|
882
|
+
*/
|
|
883
|
+
hasChildWithAlt(node) {
|
|
884
|
+
if (!node.children) return false;
|
|
885
|
+
return node.children.some(child => {
|
|
886
|
+
if (!child) return false;
|
|
887
|
+
const selector = child.selector || child.tag || '';
|
|
888
|
+
if (/^img/i.test(selector) && /\[alt/.test(selector)) {
|
|
889
|
+
return true;
|
|
890
|
+
}
|
|
891
|
+
return this.hasChildWithAlt(child);
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Check if node has a specific directive
|
|
897
|
+
*/
|
|
898
|
+
hasDirective(node, name) {
|
|
899
|
+
if (!node.directives) return false;
|
|
900
|
+
return node.directives.some(d =>
|
|
901
|
+
d.name === name ||
|
|
902
|
+
d.event === name ||
|
|
903
|
+
(d.type === 'EventDirective' && d.event === name)
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Check ARIA attribute validity
|
|
909
|
+
*/
|
|
910
|
+
checkAriaProps(node, attrs, line, column) {
|
|
911
|
+
const validAriaAttrs = new Set([
|
|
912
|
+
'aria-activedescendant', 'aria-atomic', 'aria-autocomplete', 'aria-braillelabel',
|
|
913
|
+
'aria-brailleroledescription', 'aria-busy', 'aria-checked', 'aria-colcount',
|
|
914
|
+
'aria-colindex', 'aria-colindextext', 'aria-colspan', 'aria-controls',
|
|
915
|
+
'aria-current', 'aria-describedby', 'aria-description', 'aria-details',
|
|
916
|
+
'aria-disabled', 'aria-dropeffect', 'aria-errormessage', 'aria-expanded',
|
|
917
|
+
'aria-flowto', 'aria-grabbed', 'aria-haspopup', 'aria-hidden', 'aria-invalid',
|
|
918
|
+
'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-level', 'aria-live',
|
|
919
|
+
'aria-modal', 'aria-multiline', 'aria-multiselectable', 'aria-orientation',
|
|
920
|
+
'aria-owns', 'aria-placeholder', 'aria-posinset', 'aria-pressed', 'aria-readonly',
|
|
921
|
+
'aria-relevant', 'aria-required', 'aria-roledescription', 'aria-rowcount',
|
|
922
|
+
'aria-rowindex', 'aria-rowindextext', 'aria-rowspan', 'aria-selected',
|
|
923
|
+
'aria-setsize', 'aria-sort', 'aria-valuemax', 'aria-valuemin', 'aria-valuenow',
|
|
924
|
+
'aria-valuetext'
|
|
925
|
+
]);
|
|
926
|
+
|
|
927
|
+
for (const [attr] of attrs) {
|
|
928
|
+
if (attr.startsWith('aria-') && !validAriaAttrs.has(attr)) {
|
|
929
|
+
this.addDiagnostic('warning', 'a11y-aria-props',
|
|
930
|
+
`Invalid ARIA attribute: ${attr}`,
|
|
931
|
+
line, column);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Check role-specific required attributes
|
|
938
|
+
*/
|
|
939
|
+
checkRoleProps(node, attrs, tagName, line, column) {
|
|
940
|
+
const role = attrs.get('role');
|
|
941
|
+
if (!role) return;
|
|
942
|
+
|
|
943
|
+
const roleRequirements = {
|
|
944
|
+
'checkbox': ['aria-checked'],
|
|
945
|
+
'combobox': ['aria-expanded'],
|
|
946
|
+
'heading': ['aria-level'],
|
|
947
|
+
'meter': ['aria-valuenow'],
|
|
948
|
+
'option': [],
|
|
949
|
+
'progressbar': [],
|
|
950
|
+
'radio': ['aria-checked'],
|
|
951
|
+
'scrollbar': ['aria-controls', 'aria-valuenow'],
|
|
952
|
+
'separator': [],
|
|
953
|
+
'slider': ['aria-valuenow'],
|
|
954
|
+
'spinbutton': [],
|
|
955
|
+
'switch': ['aria-checked'],
|
|
956
|
+
'tab': [],
|
|
957
|
+
'tabpanel': [],
|
|
958
|
+
'treeitem': []
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
const required = roleRequirements[role];
|
|
962
|
+
if (required && required.length > 0) {
|
|
963
|
+
for (const reqAttr of required) {
|
|
964
|
+
if (!attrs.has(reqAttr)) {
|
|
965
|
+
this.addDiagnostic('warning', 'a11y-role-props',
|
|
966
|
+
`Role "${role}" requires ${reqAttr} attribute`,
|
|
967
|
+
line, column);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Check for redundant roles on semantic elements
|
|
973
|
+
const implicitRoles = {
|
|
974
|
+
'button': 'button',
|
|
975
|
+
'a': 'link',
|
|
976
|
+
'input': 'textbox',
|
|
977
|
+
'img': 'img',
|
|
978
|
+
'nav': 'navigation',
|
|
979
|
+
'main': 'main',
|
|
980
|
+
'header': 'banner',
|
|
981
|
+
'footer': 'contentinfo',
|
|
982
|
+
'aside': 'complementary',
|
|
983
|
+
'form': 'form',
|
|
984
|
+
'table': 'table',
|
|
985
|
+
'ul': 'list',
|
|
986
|
+
'ol': 'list',
|
|
987
|
+
'li': 'listitem'
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
if (implicitRoles[tagName] === role) {
|
|
991
|
+
this.addDiagnostic('warning', 'a11y-role-props',
|
|
992
|
+
`Redundant role="${role}" on <${tagName}> - element has implicit role`,
|
|
993
|
+
line, column);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
642
997
|
/**
|
|
643
998
|
* Add a diagnostic message
|
|
999
|
+
* @param {string} severity - 'error' | 'warning' | 'info'
|
|
1000
|
+
* @param {string} code - Rule code
|
|
1001
|
+
* @param {string} message - Diagnostic message
|
|
1002
|
+
* @param {number} line - Line number
|
|
1003
|
+
* @param {number} column - Column number
|
|
1004
|
+
* @param {Object} [fix] - Optional fix information
|
|
1005
|
+
* @param {string} [fix.type] - Fix type: 'replace' | 'insert' | 'delete'
|
|
1006
|
+
* @param {string} [fix.oldText] - Text to replace (for 'replace')
|
|
1007
|
+
* @param {string} [fix.newText] - Replacement text
|
|
1008
|
+
* @param {number} [fix.start] - Start position in source
|
|
1009
|
+
* @param {number} [fix.end] - End position in source
|
|
644
1010
|
*/
|
|
645
|
-
addDiagnostic(severity, code, message, line, column) {
|
|
646
|
-
|
|
1011
|
+
addDiagnostic(severity, code, message, line, column, fix = null) {
|
|
1012
|
+
const diag = {
|
|
647
1013
|
severity,
|
|
648
1014
|
code,
|
|
649
1015
|
message,
|
|
650
1016
|
line: line || 1,
|
|
651
1017
|
column: column || 1
|
|
652
|
-
}
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
if (fix && LintRules[code]?.fixable) {
|
|
1021
|
+
diag.fix = fix;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
this.diagnostics.push(diag);
|
|
653
1025
|
}
|
|
654
1026
|
}
|
|
655
1027
|
|
|
@@ -702,6 +1074,47 @@ export async function lintFile(filePath, options = {}) {
|
|
|
702
1074
|
};
|
|
703
1075
|
}
|
|
704
1076
|
|
|
1077
|
+
/**
|
|
1078
|
+
* Apply fixes to a source file
|
|
1079
|
+
* @param {string} source - Original source code
|
|
1080
|
+
* @param {Array} diagnostics - Diagnostics with fixes
|
|
1081
|
+
* @returns {{fixed: string, count: number}} Fixed source and fix count
|
|
1082
|
+
*/
|
|
1083
|
+
export function applyFixes(source, diagnostics) {
|
|
1084
|
+
// Get fixable diagnostics, sorted by line in reverse order to avoid offset issues
|
|
1085
|
+
const fixable = diagnostics
|
|
1086
|
+
.filter(d => d.fix && d.fix.oldText && d.fix.newText)
|
|
1087
|
+
.sort((a, b) => b.line - a.line);
|
|
1088
|
+
|
|
1089
|
+
if (fixable.length === 0) {
|
|
1090
|
+
return { fixed: source, count: 0 };
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
let fixed = source;
|
|
1094
|
+
let count = 0;
|
|
1095
|
+
|
|
1096
|
+
for (const diag of fixable) {
|
|
1097
|
+
const { fix } = diag;
|
|
1098
|
+
|
|
1099
|
+
// Find and replace the old text with new text
|
|
1100
|
+
// We do line-by-line replacement to be more precise
|
|
1101
|
+
const lines = fixed.split('\n');
|
|
1102
|
+
const lineIndex = diag.line - 1;
|
|
1103
|
+
|
|
1104
|
+
if (lineIndex >= 0 && lineIndex < lines.length) {
|
|
1105
|
+
const line = lines[lineIndex];
|
|
1106
|
+
if (line.includes(fix.oldText)) {
|
|
1107
|
+
lines[lineIndex] = line.replace(fix.oldText, fix.newText);
|
|
1108
|
+
count++;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
fixed = lines.join('\n');
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
return { fixed, count };
|
|
1116
|
+
}
|
|
1117
|
+
|
|
705
1118
|
/**
|
|
706
1119
|
* Lint files and return summary
|
|
707
1120
|
* @param {string[]} files - Files to lint
|
|
@@ -718,11 +1131,36 @@ async function lintFiles(files, options = {}) {
|
|
|
718
1131
|
let totalErrors = 0;
|
|
719
1132
|
let totalWarnings = 0;
|
|
720
1133
|
let totalInfo = 0;
|
|
1134
|
+
let totalFixed = 0;
|
|
721
1135
|
|
|
722
1136
|
for (const file of files) {
|
|
723
1137
|
const result = await lintFile(file, { fix });
|
|
724
1138
|
const relPath = relativePath(file);
|
|
725
1139
|
|
|
1140
|
+
// Apply fixes if requested
|
|
1141
|
+
if (fix && result.diagnostics.some(d => d.fix)) {
|
|
1142
|
+
const source = readFileSync(file, 'utf-8');
|
|
1143
|
+
const { fixed, count } = applyFixes(source, result.diagnostics);
|
|
1144
|
+
|
|
1145
|
+
if (count > 0) {
|
|
1146
|
+
if (dryRun) {
|
|
1147
|
+
if (!quiet) {
|
|
1148
|
+
log.info(`\n${relPath} - ${count} fix(es) available (dry run)`);
|
|
1149
|
+
}
|
|
1150
|
+
} else {
|
|
1151
|
+
writeFileSync(file, fixed, 'utf-8');
|
|
1152
|
+
if (!quiet) {
|
|
1153
|
+
log.success(`\n${relPath} - ${count} fix(es) applied`);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
totalFixed += count;
|
|
1157
|
+
|
|
1158
|
+
// Re-lint to show remaining issues
|
|
1159
|
+
const recheck = await lintFile(file, {});
|
|
1160
|
+
result.diagnostics = recheck.diagnostics;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
726
1164
|
if (result.diagnostics.length > 0 && !quiet) {
|
|
727
1165
|
log.info(`\n${relPath}`);
|
|
728
1166
|
|
|
@@ -742,6 +1180,7 @@ async function lintFiles(files, options = {}) {
|
|
|
742
1180
|
errors: totalErrors,
|
|
743
1181
|
warnings: totalWarnings,
|
|
744
1182
|
info: totalInfo,
|
|
1183
|
+
fixed: totalFixed,
|
|
745
1184
|
elapsed: timer.elapsed()
|
|
746
1185
|
};
|
|
747
1186
|
}
|
package/compiler/lexer.js
CHANGED
|
@@ -697,6 +697,12 @@ export class Lexer {
|
|
|
697
697
|
let inView = false;
|
|
698
698
|
let parenDepth = 0;
|
|
699
699
|
|
|
700
|
+
// After @ token, next word is directive name (not selector)
|
|
701
|
+
const lastToken = this.tokens[this.tokens.length - 1];
|
|
702
|
+
if (lastToken && lastToken.type === TokenType.AT) {
|
|
703
|
+
return false;
|
|
704
|
+
}
|
|
705
|
+
|
|
700
706
|
for (let i = this.tokens.length - 1; i >= 0; i--) {
|
|
701
707
|
const token = this.tokens[i];
|
|
702
708
|
|
package/compiler/parser.js
CHANGED
|
@@ -27,6 +27,12 @@ export const NodeType = {
|
|
|
27
27
|
IfDirective: 'IfDirective',
|
|
28
28
|
EachDirective: 'EachDirective',
|
|
29
29
|
EventDirective: 'EventDirective',
|
|
30
|
+
|
|
31
|
+
// Accessibility directives
|
|
32
|
+
A11yDirective: 'A11yDirective',
|
|
33
|
+
LiveDirective: 'LiveDirective',
|
|
34
|
+
FocusTrapDirective: 'FocusTrapDirective',
|
|
35
|
+
|
|
30
36
|
Property: 'Property',
|
|
31
37
|
ObjectLiteral: 'ObjectLiteral',
|
|
32
38
|
ArrayLiteral: 'ArrayLiteral',
|
|
@@ -770,6 +776,20 @@ export class Parser {
|
|
|
770
776
|
return this.parseEachDirective();
|
|
771
777
|
}
|
|
772
778
|
|
|
779
|
+
// Accessibility directives
|
|
780
|
+
if (name === 'a11y') {
|
|
781
|
+
return this.parseA11yDirective();
|
|
782
|
+
}
|
|
783
|
+
if (name === 'live') {
|
|
784
|
+
return this.parseLiveDirective();
|
|
785
|
+
}
|
|
786
|
+
if (name === 'focusTrap') {
|
|
787
|
+
return this.parseFocusTrapDirective();
|
|
788
|
+
}
|
|
789
|
+
if (name === 'srOnly') {
|
|
790
|
+
return this.parseSrOnlyDirective();
|
|
791
|
+
}
|
|
792
|
+
|
|
773
793
|
// Event directive like @click
|
|
774
794
|
return this.parseEventDirective(name);
|
|
775
795
|
}
|
|
@@ -781,7 +801,21 @@ export class Parser {
|
|
|
781
801
|
this.expect(TokenType.AT);
|
|
782
802
|
const name = this.expect(TokenType.IDENT).value;
|
|
783
803
|
|
|
784
|
-
//
|
|
804
|
+
// Check for a11y directives
|
|
805
|
+
if (name === 'a11y') {
|
|
806
|
+
return this.parseA11yDirective();
|
|
807
|
+
}
|
|
808
|
+
if (name === 'live') {
|
|
809
|
+
return this.parseLiveDirective();
|
|
810
|
+
}
|
|
811
|
+
if (name === 'focusTrap') {
|
|
812
|
+
return this.parseFocusTrapDirective();
|
|
813
|
+
}
|
|
814
|
+
if (name === 'srOnly') {
|
|
815
|
+
return this.parseSrOnlyDirective();
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Event directive (click, submit, etc.)
|
|
785
819
|
this.expect(TokenType.LPAREN);
|
|
786
820
|
const expression = this.parseExpression();
|
|
787
821
|
this.expect(TokenType.RPAREN);
|
|
@@ -866,6 +900,115 @@ export class Parser {
|
|
|
866
900
|
return new ASTNode(NodeType.EventDirective, { event, handler, children });
|
|
867
901
|
}
|
|
868
902
|
|
|
903
|
+
/**
|
|
904
|
+
* Parse @a11y directive - sets aria attributes
|
|
905
|
+
* @a11y(label="Close menu") or @a11y(label="Close", describedby="desc")
|
|
906
|
+
*/
|
|
907
|
+
parseA11yDirective() {
|
|
908
|
+
this.expect(TokenType.LPAREN);
|
|
909
|
+
|
|
910
|
+
const attrs = {};
|
|
911
|
+
|
|
912
|
+
// Parse key=value pairs
|
|
913
|
+
while (!this.is(TokenType.RPAREN) && !this.is(TokenType.EOF)) {
|
|
914
|
+
const key = this.expect(TokenType.IDENT).value;
|
|
915
|
+
this.expect(TokenType.EQ);
|
|
916
|
+
|
|
917
|
+
let value;
|
|
918
|
+
if (this.is(TokenType.STRING)) {
|
|
919
|
+
value = this.advance().value;
|
|
920
|
+
} else if (this.is(TokenType.TRUE)) {
|
|
921
|
+
value = true;
|
|
922
|
+
this.advance();
|
|
923
|
+
} else if (this.is(TokenType.FALSE)) {
|
|
924
|
+
value = false;
|
|
925
|
+
this.advance();
|
|
926
|
+
} else if (this.is(TokenType.IDENT)) {
|
|
927
|
+
// Treat unquoted identifier as a string (e.g., role=dialog -> "dialog")
|
|
928
|
+
value = this.advance().value;
|
|
929
|
+
} else {
|
|
930
|
+
value = this.parseExpression();
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
attrs[key] = value;
|
|
934
|
+
|
|
935
|
+
if (this.is(TokenType.COMMA)) {
|
|
936
|
+
this.advance();
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
this.expect(TokenType.RPAREN);
|
|
941
|
+
|
|
942
|
+
return new ASTNode(NodeType.A11yDirective, { attrs });
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Parse @live directive - creates live region for screen readers
|
|
947
|
+
* @live(polite) or @live(assertive)
|
|
948
|
+
*/
|
|
949
|
+
parseLiveDirective() {
|
|
950
|
+
this.expect(TokenType.LPAREN);
|
|
951
|
+
|
|
952
|
+
let priority = 'polite';
|
|
953
|
+
if (this.is(TokenType.IDENT)) {
|
|
954
|
+
priority = this.advance().value;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
this.expect(TokenType.RPAREN);
|
|
958
|
+
|
|
959
|
+
return new ASTNode(NodeType.LiveDirective, { priority });
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Parse @focusTrap directive - traps focus within element
|
|
964
|
+
* @focusTrap or @focusTrap(autoFocus=true)
|
|
965
|
+
*/
|
|
966
|
+
parseFocusTrapDirective() {
|
|
967
|
+
const options = {};
|
|
968
|
+
|
|
969
|
+
if (this.is(TokenType.LPAREN)) {
|
|
970
|
+
this.advance();
|
|
971
|
+
|
|
972
|
+
while (!this.is(TokenType.RPAREN) && !this.is(TokenType.EOF)) {
|
|
973
|
+
const key = this.expect(TokenType.IDENT).value;
|
|
974
|
+
|
|
975
|
+
if (this.is(TokenType.EQ)) {
|
|
976
|
+
this.advance();
|
|
977
|
+
if (this.is(TokenType.TRUE)) {
|
|
978
|
+
options[key] = true;
|
|
979
|
+
this.advance();
|
|
980
|
+
} else if (this.is(TokenType.FALSE)) {
|
|
981
|
+
options[key] = false;
|
|
982
|
+
this.advance();
|
|
983
|
+
} else if (this.is(TokenType.STRING)) {
|
|
984
|
+
options[key] = this.advance().value;
|
|
985
|
+
} else {
|
|
986
|
+
options[key] = this.parseExpression();
|
|
987
|
+
}
|
|
988
|
+
} else {
|
|
989
|
+
options[key] = true;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (this.is(TokenType.COMMA)) {
|
|
993
|
+
this.advance();
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
this.expect(TokenType.RPAREN);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
return new ASTNode(NodeType.FocusTrapDirective, { options });
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Parse @srOnly directive - visually hidden but accessible text
|
|
1005
|
+
*/
|
|
1006
|
+
parseSrOnlyDirective() {
|
|
1007
|
+
return new ASTNode(NodeType.A11yDirective, {
|
|
1008
|
+
attrs: { srOnly: true }
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
|
|
869
1012
|
/**
|
|
870
1013
|
* Parse expression
|
|
871
1014
|
*/
|
|
@@ -47,6 +47,21 @@ export function generateImports(transformer) {
|
|
|
47
47
|
|
|
48
48
|
lines.push(`import { ${runtimeImports.join(', ')} } from '${options.runtime}';`);
|
|
49
49
|
|
|
50
|
+
// A11y imports (if a11y features are used)
|
|
51
|
+
const a11yImports = [];
|
|
52
|
+
if (transformer.usesA11y.srOnly) {
|
|
53
|
+
a11yImports.push('srOnly');
|
|
54
|
+
}
|
|
55
|
+
if (transformer.usesA11y.trapFocus) {
|
|
56
|
+
a11yImports.push('trapFocus');
|
|
57
|
+
}
|
|
58
|
+
if (transformer.usesA11y.announce) {
|
|
59
|
+
a11yImports.push('announce');
|
|
60
|
+
}
|
|
61
|
+
if (a11yImports.length > 0) {
|
|
62
|
+
lines.push(`import { ${a11yImports.join(', ')} } from '${options.runtime}/a11y';`);
|
|
63
|
+
}
|
|
64
|
+
|
|
50
65
|
// Router imports (if router block exists)
|
|
51
66
|
if (ast.router) {
|
|
52
67
|
lines.push(`import { createRouter } from '${options.runtime}/router';`);
|