pulse-js-framework 1.7.9 → 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 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
@@ -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
 
@@ -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
- // Event directive
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';`);