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 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
- this.diagnostics.push({
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