pulse-js-framework 1.7.9 → 1.7.11
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/README.md +1 -0
- package/cli/lint.js +442 -3
- package/compiler/lexer.js +29 -1
- package/compiler/parser.js +262 -18
- package/compiler/transformer/export.js +41 -2
- package/compiler/transformer/expressions.js +148 -5
- package/compiler/transformer/imports.js +16 -0
- package/compiler/transformer/index.js +46 -0
- package/compiler/transformer/view.js +397 -27
- package/loader/vite-plugin.js +27 -4
- package/package.json +9 -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 +39 -4
- package/runtime/dom-element.js +192 -1
- package/runtime/dom.js +8 -2
- 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/README.md
CHANGED
|
@@ -249,6 +249,7 @@ const count: Pulse<number> = pulse(0);
|
|
|
249
249
|
| [HMR Demo](examples/hmr) | Hot module replacement |
|
|
250
250
|
| [Router Demo](examples/router) | SPA routing |
|
|
251
251
|
| [Store Demo](examples/store) | State with undo/redo |
|
|
252
|
+
| [Electron App](examples/electron) | Desktop notes app |
|
|
252
253
|
|
|
253
254
|
## Documentation
|
|
254
255
|
|
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
|
@@ -33,6 +33,7 @@ export const TokenType = {
|
|
|
33
33
|
|
|
34
34
|
// Directives
|
|
35
35
|
AT: 'AT', // @
|
|
36
|
+
DIRECTIVE_MOD: 'DIRECTIVE_MOD', // .modifier after @directive (e.g., @click.prevent)
|
|
36
37
|
PAGE: 'PAGE',
|
|
37
38
|
ROUTE: 'ROUTE',
|
|
38
39
|
IF: 'IF',
|
|
@@ -499,10 +500,31 @@ export class Lexer {
|
|
|
499
500
|
continue;
|
|
500
501
|
}
|
|
501
502
|
|
|
502
|
-
// At-sign for directives
|
|
503
|
+
// At-sign for directives with optional modifiers
|
|
503
504
|
if (char === '@') {
|
|
504
505
|
this.advance();
|
|
505
506
|
this.tokens.push(new Token(TokenType.AT, '@', startLine, startColumn));
|
|
507
|
+
|
|
508
|
+
// After @, read directive name (if identifier follows)
|
|
509
|
+
if (/[a-zA-Z]/.test(this.current())) {
|
|
510
|
+
// Read the directive name
|
|
511
|
+
const nameToken = this.readIdentifier();
|
|
512
|
+
this.tokens.push(nameToken);
|
|
513
|
+
|
|
514
|
+
// Read modifiers: .prevent, .stop, .enter, etc.
|
|
515
|
+
while (!this.isEOF() && this.current() === '.' && /[a-zA-Z]/.test(this.peek())) {
|
|
516
|
+
this.advance(); // skip '.'
|
|
517
|
+
const modStartLine = this.line;
|
|
518
|
+
const modStartColumn = this.column;
|
|
519
|
+
let modName = '';
|
|
520
|
+
while (!this.isEOF() && /[a-zA-Z0-9]/.test(this.current())) {
|
|
521
|
+
modName += this.advance();
|
|
522
|
+
}
|
|
523
|
+
if (modName) {
|
|
524
|
+
this.tokens.push(new Token(TokenType.DIRECTIVE_MOD, modName, modStartLine, modStartColumn));
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
506
528
|
continue;
|
|
507
529
|
}
|
|
508
530
|
|
|
@@ -697,6 +719,12 @@ export class Lexer {
|
|
|
697
719
|
let inView = false;
|
|
698
720
|
let parenDepth = 0;
|
|
699
721
|
|
|
722
|
+
// After @ token, next word is directive name (not selector)
|
|
723
|
+
const lastToken = this.tokens[this.tokens.length - 1];
|
|
724
|
+
if (lastToken && lastToken.type === TokenType.AT) {
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
|
|
700
728
|
for (let i = this.tokens.length - 1; i >= 0; i--) {
|
|
701
729
|
const token = this.tokens[i];
|
|
702
730
|
|