@matthesketh/utopia-compiler 0.3.1 → 0.5.0

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/dist/index.cjs CHANGED
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  SFCParseError: () => SFCParseError,
24
+ checkA11y: () => checkA11y,
24
25
  compile: () => compile,
25
26
  compileStyle: () => compileStyle,
26
27
  compileTemplate: () => compileTemplate,
@@ -31,16 +32,19 @@ __export(index_exports, {
31
32
  module.exports = __toCommonJS(index_exports);
32
33
 
33
34
  // src/parser.ts
35
+ var BLOCK_RE = /<(template|script|style|test)([\s][^>]*)?\s*>/g;
36
+ var ATTR_RE = /([a-zA-Z_][\w-]*)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
34
37
  function parse(source, filename = "anonymous.utopia") {
35
38
  const descriptor = {
36
39
  template: null,
37
40
  script: null,
38
41
  style: null,
42
+ test: null,
39
43
  filename
40
44
  };
41
- const blockRe = /<(template|script|style)([\s][^>]*)?\s*>/g;
45
+ BLOCK_RE.lastIndex = 0;
42
46
  let match;
43
- while ((match = blockRe.exec(source)) !== null) {
47
+ while ((match = BLOCK_RE.exec(source)) !== null) {
44
48
  const tagName = match[1];
45
49
  const attrString = match[2] || "";
46
50
  const openTagStart = match.index;
@@ -71,11 +75,10 @@ function parse(source, filename = "anonymous.utopia") {
71
75
  );
72
76
  }
73
77
  descriptor[tagName] = block;
74
- blockRe.lastIndex = blockEnd;
78
+ BLOCK_RE.lastIndex = blockEnd;
75
79
  }
76
80
  return descriptor;
77
81
  }
78
- var ATTR_RE = /([a-zA-Z_][\w-]*)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
79
82
  function parseAttributes(raw) {
80
83
  const attrs = {};
81
84
  let m;
@@ -112,6 +115,16 @@ var SFCParseError = class extends Error {
112
115
  };
113
116
 
114
117
  // src/template-compiler.ts
118
+ var TAG_NAME_CHAR_RE = /[a-zA-Z0-9\-_]/;
119
+ var ATTR_NAME_CHAR_RE = /[a-zA-Z0-9\-_:@.]/;
120
+ var ATTR_VALUE_END_RE = /[\s/>]/;
121
+ var WHITESPACE_RE = /\s/;
122
+ var TAG_START_CHAR_RE = /[a-zA-Z]/;
123
+ var U_FOR_EXPR_RE = /^\s*(?:\(\s*(\w+)\s*(?:,\s*(\w+)\s*)?\)|(\w+))\s+in\s+(.+)$/;
124
+ var COMPONENT_TAG_RE = /^[A-Z][a-zA-Z0-9_$]*$/;
125
+ var BACKSLASH_RE = /\\/g;
126
+ var SINGLE_QUOTE_RE = /'/g;
127
+ var HTML_ENTITY_RE = /&(?:#(\d+)|#x([0-9a-fA-F]+)|(\w+));/g;
115
128
  function compileTemplate(template, options = {}) {
116
129
  const ast = parseTemplate(template);
117
130
  return generate(ast, options);
@@ -293,7 +306,7 @@ var TemplateParser = class {
293
306
  // ---- Low-level helpers --------------------------------------------------
294
307
  readTagName() {
295
308
  const start = this.pos;
296
- while (this.pos < this.source.length && /[a-zA-Z0-9\-_]/.test(this.source[this.pos])) {
309
+ while (this.pos < this.source.length && TAG_NAME_CHAR_RE.test(this.source[this.pos])) {
297
310
  this.pos++;
298
311
  }
299
312
  const name = this.source.slice(start, this.pos);
@@ -302,7 +315,7 @@ var TemplateParser = class {
302
315
  }
303
316
  readAttributeName() {
304
317
  const start = this.pos;
305
- while (this.pos < this.source.length && /[a-zA-Z0-9\-_:@.]/.test(this.source[this.pos])) {
318
+ while (this.pos < this.source.length && ATTR_NAME_CHAR_RE.test(this.source[this.pos])) {
306
319
  this.pos++;
307
320
  }
308
321
  return this.source.slice(start, this.pos);
@@ -319,13 +332,13 @@ var TemplateParser = class {
319
332
  return value;
320
333
  }
321
334
  const start = this.pos;
322
- while (this.pos < this.source.length && !/[\s/>]/.test(this.source[this.pos])) {
335
+ while (this.pos < this.source.length && !ATTR_VALUE_END_RE.test(this.source[this.pos])) {
323
336
  this.pos++;
324
337
  }
325
338
  return this.source.slice(start, this.pos);
326
339
  }
327
340
  skipWhitespace() {
328
- while (this.pos < this.source.length && /\s/.test(this.source[this.pos])) {
341
+ while (this.pos < this.source.length && WHITESPACE_RE.test(this.source[this.pos])) {
329
342
  this.pos++;
330
343
  }
331
344
  }
@@ -335,7 +348,7 @@ var TemplateParser = class {
335
348
  /** Returns true when the char after `<` looks like the start of a tag name. */
336
349
  peekTagStart() {
337
350
  const next = this.source[this.pos + 1];
338
- return next !== void 0 && /[a-zA-Z]/.test(next);
351
+ return next !== void 0 && TAG_START_CHAR_RE.test(next);
339
352
  }
340
353
  expect(str) {
341
354
  if (!this.lookingAt(str)) {
@@ -405,7 +418,7 @@ function classifyDirective(name, value) {
405
418
  return null;
406
419
  }
407
420
  function isDirectiveKind(s) {
408
- return s === "on" || s === "bind" || s === "if" || s === "else" || s === "for" || s === "model";
421
+ return s === "on" || s === "bind" || s === "if" || s === "else" || s === "else-if" || s === "for" || s === "model";
409
422
  }
410
423
  var CodeGenerator = class {
411
424
  constructor(options) {
@@ -496,7 +509,8 @@ ${fnBody}
496
509
  }
497
510
  }
498
511
  for (const dir of node.directives) {
499
- if (dir.kind === "if" || dir.kind === "else" || dir.kind === "for") continue;
512
+ if (dir.kind === "if" || dir.kind === "else" || dir.kind === "else-if" || dir.kind === "for")
513
+ continue;
500
514
  this.genDirective(elVar, dir, scope);
501
515
  }
502
516
  this.genChildren(elVar, node.children, scope);
@@ -582,7 +596,7 @@ ${fnBody}
582
596
  this.emit(`addEventListener(${elVar}, 'input', (e) => ${signalRef}.set(e.target.value))`);
583
597
  }
584
598
  // ---- Structural: u-if ---------------------------------------------------
585
- genIf(node, dir, scope, elseNode) {
599
+ genIf(node, dir, scope, elseIfChain, elseNode) {
586
600
  this.helpers.add("createIf");
587
601
  const anchorVar = this.freshVar();
588
602
  this.helpers.add("createComment");
@@ -590,7 +604,7 @@ ${fnBody}
590
604
  const condition = this.resolveExpression(dir.expression, scope);
591
605
  const strippedNode = {
592
606
  ...node,
593
- directives: node.directives.filter((d) => d.kind !== "if")
607
+ directives: node.directives.filter((d) => d.kind !== "if" && d.kind !== "else-if")
594
608
  };
595
609
  const trueFnVar = this.freshVar();
596
610
  const savedCode = this.code;
@@ -605,7 +619,29 @@ ${fnBody}
605
619
  this.emit(` return ${innerVar}`);
606
620
  this.emit(`}`);
607
621
  let elseArg = "";
608
- if (elseNode) {
622
+ if (elseIfChain && elseIfChain.length > 0) {
623
+ const firstElseIf = elseIfChain[0];
624
+ const remainingElseIfs = elseIfChain.slice(1);
625
+ const falseFnVar = this.freshVar();
626
+ const savedCode2 = this.code;
627
+ this.code = [];
628
+ const nestedAnchor = this.genIf(
629
+ firstElseIf.node,
630
+ firstElseIf.dir,
631
+ scope,
632
+ remainingElseIfs.length > 0 ? remainingElseIfs : void 0,
633
+ elseNode
634
+ );
635
+ const nestedLines = [...this.code];
636
+ this.code = savedCode2;
637
+ this.emit(`const ${falseFnVar} = () => {`);
638
+ for (const line of nestedLines) {
639
+ this.emit(` ${line}`);
640
+ }
641
+ this.emit(` return ${nestedAnchor}`);
642
+ this.emit(`}`);
643
+ elseArg = `, ${falseFnVar}`;
644
+ } else if (elseNode) {
609
645
  const falseFnVar = this.freshVar();
610
646
  const strippedElse = {
611
647
  ...elseNode,
@@ -633,9 +669,7 @@ ${fnBody}
633
669
  const anchorVar = this.freshVar();
634
670
  this.helpers.add("createComment");
635
671
  this.emit(`const ${anchorVar} = createComment('u-for')`);
636
- const forMatch = dir.expression.match(
637
- /^\s*(?:\(\s*(\w+)\s*(?:,\s*(\w+)\s*)?\)|(\w+))\s+in\s+(.+)$/
638
- );
672
+ const forMatch = dir.expression.match(U_FOR_EXPR_RE);
639
673
  if (!forMatch) {
640
674
  throw new Error(`Invalid u-for expression: "${dir.expression}"`);
641
675
  }
@@ -671,7 +705,7 @@ ${fnBody}
671
705
  this.emitOrDefer(`createFor(${anchorVar}, () => ${listExpr}, ${renderFnVar}${keyArg})`);
672
706
  return anchorVar;
673
707
  }
674
- // ---- Children processing (handles u-if / u-else pairing) ----------------
708
+ // ---- Children processing (handles u-if / u-else-if / u-else pairing) ----
675
709
  genChildren(parentVar, children, scope) {
676
710
  this.deferredCallsStack.push([]);
677
711
  let i = 0;
@@ -680,24 +714,39 @@ ${fnBody}
680
714
  if (child.type === 1 /* Element */) {
681
715
  const ifDir = child.directives.find((d) => d.kind === "if");
682
716
  if (ifDir) {
717
+ const elseIfChain = [];
683
718
  let elseNode;
684
719
  let skipTo = i + 1;
685
720
  for (let j = i + 1; j < children.length; j++) {
686
721
  const next = children[j];
687
722
  if (next.type === 2 /* Text */ && !next.content.trim()) continue;
688
- if (next.type === 1 /* Element */ && next.directives.some((d) => d.kind === "else")) {
689
- elseNode = next;
690
- skipTo = j + 1;
723
+ if (next.type === 1 /* Element */) {
724
+ const elseIfDir = next.directives.find((d) => d.kind === "else-if");
725
+ if (elseIfDir) {
726
+ elseIfChain.push({ node: next, dir: elseIfDir });
727
+ skipTo = j + 1;
728
+ continue;
729
+ }
730
+ if (next.directives.some((d) => d.kind === "else")) {
731
+ elseNode = next;
732
+ skipTo = j + 1;
733
+ }
691
734
  }
692
735
  break;
693
736
  }
694
- const childVar2 = this.genIf(child, ifDir, scope, elseNode);
737
+ const childVar2 = this.genIf(
738
+ child,
739
+ ifDir,
740
+ scope,
741
+ elseIfChain.length > 0 ? elseIfChain : void 0,
742
+ elseNode
743
+ );
695
744
  this.helpers.add("appendChild");
696
745
  this.emit(`appendChild(${parentVar}, ${childVar2})`);
697
746
  i = skipTo;
698
747
  continue;
699
748
  }
700
- if (child.directives.some((d) => d.kind === "else")) {
749
+ if (child.directives.some((d) => d.kind === "else" || d.kind === "else-if")) {
701
750
  i++;
702
751
  continue;
703
752
  }
@@ -823,10 +872,10 @@ ${fnBody}
823
872
  }
824
873
  };
825
874
  function isComponentTag(tag) {
826
- return /^[A-Z][a-zA-Z0-9_$]*$/.test(tag);
875
+ return COMPONENT_TAG_RE.test(tag);
827
876
  }
828
877
  function escapeStr(s) {
829
- return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
878
+ return s.replace(BACKSLASH_RE, "\\\\").replace(SINGLE_QUOTE_RE, "\\'");
830
879
  }
831
880
  var ENTITY_MAP = {
832
881
  "&amp;": "&",
@@ -855,7 +904,8 @@ var ENTITY_MAP = {
855
904
  "&divide;": "\xF7"
856
905
  };
857
906
  function decodeEntities(text) {
858
- return text.replace(/&(?:#(\d+)|#x([0-9a-fA-F]+)|(\w+));/g, (match, dec, hex, named) => {
907
+ HTML_ENTITY_RE.lastIndex = 0;
908
+ return text.replace(HTML_ENTITY_RE, (match, dec, hex, named) => {
859
909
  if (dec) {
860
910
  const code = parseInt(dec, 10);
861
911
  if (code >= 0 && code <= 1114111) {
@@ -888,6 +938,9 @@ function generate(ast, options) {
888
938
  }
889
939
 
890
940
  // src/style-compiler.ts
941
+ var WHITESPACE_RE2 = /\s/;
942
+ var KEYFRAMES_RE = /^@(?:-\w+-)?keyframes\b/;
943
+ var PSEUDO_SELECTOR_RE = /(?:::?[\w-]+(?:\([^)]*\))?)+$/;
891
944
  function compileStyle(options) {
892
945
  const { source, filename, scoped, scopeId: overrideScopeId } = options;
893
946
  if (!scoped) {
@@ -908,7 +961,7 @@ function scopeSelectors(css, scopeId) {
908
961
  const result = [];
909
962
  let pos = 0;
910
963
  while (pos < css.length) {
911
- if (/\s/.test(css[pos])) {
964
+ if (WHITESPACE_RE2.test(css[pos])) {
912
965
  result.push(css[pos]);
913
966
  pos++;
914
967
  continue;
@@ -957,7 +1010,7 @@ function consumeAtRule(css, pos, scopeId) {
957
1010
  return { text: css.slice(start), end: css.length };
958
1011
  }
959
1012
  const header = css.slice(start, headerEnd);
960
- const isKeyframes = /^@(?:-\w+-)?keyframes\b/.test(header.trim());
1013
+ const isKeyframes = KEYFRAMES_RE.test(header.trim());
961
1014
  depth = 1;
962
1015
  let bodyStart = headerEnd + 1;
963
1016
  let bodyEnd = headerEnd + 1;
@@ -1000,8 +1053,7 @@ function consumeRuleSet(css, pos, scopeId) {
1000
1053
  function scopeSingleSelector(selector, scopeId) {
1001
1054
  if (!selector) return selector;
1002
1055
  const attr = `[${scopeId}]`;
1003
- const pseudoRe = /(?:::?[\w-]+(?:\([^)]*\))?)+$/;
1004
- const pseudoMatch = selector.match(pseudoRe);
1056
+ const pseudoMatch = selector.match(PSEUDO_SELECTOR_RE);
1005
1057
  if (pseudoMatch) {
1006
1058
  const beforePseudo = selector.slice(0, pseudoMatch.index);
1007
1059
  return `${beforePseudo}${attr}${pseudoMatch[0]}`;
@@ -1009,6 +1061,256 @@ function scopeSingleSelector(selector, scopeId) {
1009
1061
  return `${selector}${attr}`;
1010
1062
  }
1011
1063
 
1064
+ // src/a11y.ts
1065
+ var HEADING_LEVEL_RE = /^h([1-6])$/;
1066
+ var VALID_ARIA_ROLES = /* @__PURE__ */ new Set([
1067
+ "alert",
1068
+ "alertdialog",
1069
+ "application",
1070
+ "article",
1071
+ "banner",
1072
+ "button",
1073
+ "cell",
1074
+ "checkbox",
1075
+ "columnheader",
1076
+ "combobox",
1077
+ "complementary",
1078
+ "contentinfo",
1079
+ "definition",
1080
+ "dialog",
1081
+ "directory",
1082
+ "document",
1083
+ "feed",
1084
+ "figure",
1085
+ "form",
1086
+ "grid",
1087
+ "gridcell",
1088
+ "group",
1089
+ "heading",
1090
+ "img",
1091
+ "link",
1092
+ "list",
1093
+ "listbox",
1094
+ "listitem",
1095
+ "log",
1096
+ "main",
1097
+ "marquee",
1098
+ "math",
1099
+ "menu",
1100
+ "menubar",
1101
+ "menuitem",
1102
+ "menuitemcheckbox",
1103
+ "menuitemradio",
1104
+ "meter",
1105
+ "navigation",
1106
+ "none",
1107
+ "note",
1108
+ "option",
1109
+ "presentation",
1110
+ "progressbar",
1111
+ "radio",
1112
+ "radiogroup",
1113
+ "region",
1114
+ "row",
1115
+ "rowgroup",
1116
+ "rowheader",
1117
+ "scrollbar",
1118
+ "search",
1119
+ "searchbox",
1120
+ "separator",
1121
+ "slider",
1122
+ "spinbutton",
1123
+ "status",
1124
+ "switch",
1125
+ "tab",
1126
+ "table",
1127
+ "tablist",
1128
+ "tabpanel",
1129
+ "term",
1130
+ "textbox",
1131
+ "timer",
1132
+ "toolbar",
1133
+ "tooltip",
1134
+ "tree",
1135
+ "treegrid",
1136
+ "treeitem"
1137
+ ]);
1138
+ var INTERACTIVE_ELEMENTS = /* @__PURE__ */ new Set([
1139
+ "a",
1140
+ "button",
1141
+ "input",
1142
+ "select",
1143
+ "textarea",
1144
+ "details",
1145
+ "summary"
1146
+ ]);
1147
+ function getAttr(node, name) {
1148
+ return node.attrs.find((a) => a.name === name);
1149
+ }
1150
+ function hasAttr(node, name) {
1151
+ return node.attrs.some((a) => a.name === name);
1152
+ }
1153
+ function hasDirective(node, kind, arg) {
1154
+ return node.directives.some((d) => d.kind === kind && (arg === void 0 || d.arg === arg));
1155
+ }
1156
+ function hasBoundAttr(node, name) {
1157
+ return node.directives.some((d) => d.kind === "bind" && d.arg === name);
1158
+ }
1159
+ function hasTextContent(node) {
1160
+ return node.children.some(
1161
+ (c) => c.type === 2 /* Text */ && c.content.trim() !== "" || c.type === 3 /* Interpolation */
1162
+ );
1163
+ }
1164
+ var rules = {
1165
+ "img-alt"(node, warnings) {
1166
+ if (node.tag !== "img") return;
1167
+ if (hasAttr(node, "alt") || hasBoundAttr(node, "alt")) return;
1168
+ if (hasAttr(node, "aria-label") || hasAttr(node, "aria-labelledby")) return;
1169
+ const role = getAttr(node, "role");
1170
+ if (role && (role.value === "presentation" || role.value === "none")) return;
1171
+ warnings.push({
1172
+ rule: "img-alt",
1173
+ message: "<img> element must have an alt attribute",
1174
+ tag: node.tag
1175
+ });
1176
+ },
1177
+ "click-keyboard"(node, warnings) {
1178
+ const hasClick = hasDirective(node, "on", "click");
1179
+ if (!hasClick) return;
1180
+ if (INTERACTIVE_ELEMENTS.has(node.tag)) return;
1181
+ const hasKeyboard = hasDirective(node, "on", "keydown") || hasDirective(node, "on", "keyup") || hasDirective(node, "on", "keypress");
1182
+ const role = getAttr(node, "role");
1183
+ const hasInteractiveRole = role && (role.value === "button" || role.value === "link");
1184
+ if (!hasKeyboard && !hasInteractiveRole) {
1185
+ warnings.push({
1186
+ rule: "click-keyboard",
1187
+ message: `<${node.tag}> with @click handler should also have a keyboard event handler or role="button"`,
1188
+ tag: node.tag
1189
+ });
1190
+ }
1191
+ if (!hasAttr(node, "tabindex") && !hasBoundAttr(node, "tabindex")) {
1192
+ warnings.push({
1193
+ rule: "click-keyboard",
1194
+ message: `<${node.tag}> with @click handler should have tabindex="0" for keyboard accessibility`,
1195
+ tag: node.tag
1196
+ });
1197
+ }
1198
+ },
1199
+ "anchor-content"(node, warnings) {
1200
+ if (node.tag !== "a") return;
1201
+ if (hasTextContent(node)) return;
1202
+ if (hasAttr(node, "aria-label") || hasAttr(node, "aria-labelledby")) return;
1203
+ const hasChildElements = node.children.some((c) => c.type === 1 /* Element */);
1204
+ if (hasChildElements) return;
1205
+ warnings.push({
1206
+ rule: "anchor-content",
1207
+ message: "<a> element must have content, aria-label, or aria-labelledby",
1208
+ tag: node.tag
1209
+ });
1210
+ },
1211
+ "form-label"(node, warnings) {
1212
+ const formElements = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
1213
+ if (!formElements.has(node.tag)) return;
1214
+ const type = getAttr(node, "type");
1215
+ if (type && type.value === "hidden") return;
1216
+ if (hasAttr(node, "aria-label") || hasAttr(node, "aria-labelledby")) return;
1217
+ if (hasAttr(node, "id")) return;
1218
+ if (hasAttr(node, "title")) return;
1219
+ warnings.push({
1220
+ rule: "form-label",
1221
+ message: `<${node.tag}> should have an id (with matching <label>), aria-label, or aria-labelledby`,
1222
+ tag: node.tag
1223
+ });
1224
+ },
1225
+ "no-distracting"(node, warnings) {
1226
+ if (node.tag === "marquee" || node.tag === "blink") {
1227
+ warnings.push({
1228
+ rule: "no-distracting",
1229
+ message: `<${node.tag}> is distracting and inaccessible \u2014 do not use`,
1230
+ tag: node.tag
1231
+ });
1232
+ }
1233
+ },
1234
+ "heading-order"(node, warnings, ctx) {
1235
+ const match = node.tag.match(HEADING_LEVEL_RE);
1236
+ if (!match) return;
1237
+ const level = parseInt(match[1], 10);
1238
+ if (ctx.lastHeadingLevel > 0 && level > ctx.lastHeadingLevel + 1) {
1239
+ warnings.push({
1240
+ rule: "heading-order",
1241
+ message: `Heading level <${node.tag}> skips from <h${ctx.lastHeadingLevel}> \u2014 headings should not skip levels`,
1242
+ tag: node.tag
1243
+ });
1244
+ }
1245
+ ctx.lastHeadingLevel = level;
1246
+ },
1247
+ "aria-role"(node, warnings) {
1248
+ const role = getAttr(node, "role");
1249
+ if (!role || !role.value) return;
1250
+ if (!VALID_ARIA_ROLES.has(role.value)) {
1251
+ warnings.push({
1252
+ rule: "aria-role",
1253
+ message: `Invalid ARIA role "${role.value}" on <${node.tag}>`,
1254
+ tag: node.tag
1255
+ });
1256
+ }
1257
+ },
1258
+ "no-positive-tabindex"(node, warnings) {
1259
+ const tabindex = getAttr(node, "tabindex");
1260
+ if (!tabindex || !tabindex.value) return;
1261
+ const val = parseInt(tabindex.value, 10);
1262
+ if (!isNaN(val) && val > 0) {
1263
+ warnings.push({
1264
+ rule: "no-positive-tabindex",
1265
+ message: `Avoid positive tabindex="${tabindex.value}" \u2014 it disrupts natural tab order`,
1266
+ tag: node.tag
1267
+ });
1268
+ }
1269
+ },
1270
+ "media-captions"(node, warnings) {
1271
+ if (node.tag !== "video" && node.tag !== "audio") return;
1272
+ const hasTrack = node.children.some(
1273
+ (c) => c.type === 1 /* Element */ && c.tag === "track"
1274
+ );
1275
+ if (hasTrack) return;
1276
+ if (hasAttr(node, "aria-label") || hasAttr(node, "aria-labelledby")) return;
1277
+ warnings.push({
1278
+ rule: "media-captions",
1279
+ message: `<${node.tag}> should have a <track> element for captions`,
1280
+ tag: node.tag
1281
+ });
1282
+ },
1283
+ "anchor-valid"(node, warnings) {
1284
+ if (node.tag !== "a") return;
1285
+ if (hasAttr(node, "href") || hasBoundAttr(node, "href")) return;
1286
+ const role = getAttr(node, "role");
1287
+ if (role && role.value === "button") return;
1288
+ warnings.push({
1289
+ rule: "anchor-valid",
1290
+ message: "<a> element should have an href attribute",
1291
+ tag: node.tag
1292
+ });
1293
+ }
1294
+ };
1295
+ function walkNodes(nodes, enabledRules, warnings, ctx) {
1296
+ for (const node of nodes) {
1297
+ if (node.type === 1 /* Element */) {
1298
+ for (const rule of enabledRules) {
1299
+ rule(node, warnings, ctx);
1300
+ }
1301
+ walkNodes(node.children, enabledRules, warnings, ctx);
1302
+ }
1303
+ }
1304
+ }
1305
+ function checkA11y(ast, options) {
1306
+ const disabled = new Set(options?.disable ?? []);
1307
+ const enabledRules = Object.entries(rules).filter(([id]) => !disabled.has(id)).map(([, fn]) => fn);
1308
+ const warnings = [];
1309
+ const ctx = { lastHeadingLevel: 0 };
1310
+ walkNodes(ast, enabledRules, warnings, ctx);
1311
+ return warnings;
1312
+ }
1313
+
1012
1314
  // src/index.ts
1013
1315
  function compile(source, options = {}) {
1014
1316
  const filename = options.filename ?? "anonymous.utopia";
@@ -1027,11 +1329,16 @@ function compile(source, options = {}) {
1027
1329
  scopeId = styleResult.scopeId;
1028
1330
  }
1029
1331
  let renderModule = "";
1332
+ let a11yWarnings = [];
1030
1333
  if (descriptor.template) {
1031
1334
  const templateResult = compileTemplate(descriptor.template.content, {
1032
1335
  scopeId: scopeId ?? void 0
1033
1336
  });
1034
1337
  renderModule = templateResult.code;
1338
+ if (options.a11y !== false) {
1339
+ const ast = parseTemplate(descriptor.template.content);
1340
+ a11yWarnings = checkA11y(ast, options.a11y ?? void 0);
1341
+ }
1035
1342
  }
1036
1343
  const { imports, body } = splitModuleParts(renderModule);
1037
1344
  const scriptContent = descriptor.script?.content ?? "";
@@ -1047,7 +1354,7 @@ function compile(source, options = {}) {
1047
1354
  }
1048
1355
  parts.push("export default { render: __render }");
1049
1356
  const code = parts.join("\n\n") + "\n";
1050
- return { code, css };
1357
+ return { code, css, a11y: a11yWarnings };
1051
1358
  }
1052
1359
  function splitModuleParts(moduleCode) {
1053
1360
  const idx = moduleCode.indexOf("\n\n");
@@ -1061,6 +1368,7 @@ function splitModuleParts(moduleCode) {
1061
1368
  // Annotate the CommonJS export names for ESM import in node:
1062
1369
  0 && (module.exports = {
1063
1370
  SFCParseError,
1371
+ checkA11y,
1064
1372
  compile,
1065
1373
  compileStyle,
1066
1374
  compileTemplate,
package/dist/index.d.cts CHANGED
@@ -14,6 +14,7 @@ interface SFCDescriptor {
14
14
  template: SFCBlock | null;
15
15
  script: SFCBlock | null;
16
16
  style: SFCBlock | null;
17
+ test: SFCBlock | null;
17
18
  filename: string;
18
19
  }
19
20
  /**
@@ -84,7 +85,7 @@ interface Directive {
84
85
  expression: string;
85
86
  modifiers: string[];
86
87
  }
87
- type DirectiveKind = 'on' | 'bind' | 'if' | 'else' | 'for' | 'model';
88
+ type DirectiveKind = 'on' | 'bind' | 'if' | 'else' | 'else-if' | 'for' | 'model';
88
89
  /** Exported for testing — parse a template string into an AST. */
89
90
  declare function parseTemplate(source: string): TemplateNode[];
90
91
 
@@ -119,11 +120,38 @@ declare function compileStyle(options: StyleCompileOptions): StyleCompileResult;
119
120
  */
120
121
  declare function generateScopeId(filename: string): string;
121
122
 
123
+ interface A11yWarning {
124
+ /** Rule ID (e.g. 'img-alt'). */
125
+ rule: string;
126
+ /** Human-readable message. */
127
+ message: string;
128
+ /** The element tag that triggered the warning. */
129
+ tag: string;
130
+ }
131
+ interface A11yOptions {
132
+ /** Rules to disable (by rule ID). */
133
+ disable?: string[];
134
+ }
135
+ /**
136
+ * Check a parsed template AST for accessibility issues.
137
+ *
138
+ * Returns an array of warnings. An empty array means no issues found.
139
+ *
140
+ * ```ts
141
+ * const ast = parseTemplate('<img src="photo.jpg">');
142
+ * const warnings = checkA11y(ast);
143
+ * // [{ rule: 'img-alt', message: '...', tag: 'img' }]
144
+ * ```
145
+ */
146
+ declare function checkA11y(ast: TemplateNode[], options?: A11yOptions): A11yWarning[];
147
+
122
148
  interface CompileOptions {
123
149
  /** Filename for error messages and scope-id generation. */
124
150
  filename?: string;
125
151
  /** Override the scope ID for testing. */
126
152
  scopeId?: string;
153
+ /** Accessibility checking options. Pass false to disable entirely. */
154
+ a11y?: A11yOptions | false;
127
155
  }
128
156
  interface CompileResult {
129
157
  /** The compiled JavaScript module source. */
@@ -132,6 +160,8 @@ interface CompileResult {
132
160
  css: string;
133
161
  /** Source map (reserved for future use). */
134
162
  map?: unknown;
163
+ /** Accessibility warnings from template analysis. */
164
+ a11y: A11yWarning[];
135
165
  }
136
166
  /**
137
167
  * Compile a `.utopia` single-file component source string.
@@ -153,4 +183,4 @@ interface CompileResult {
153
183
  */
154
184
  declare function compile(source: string, options?: CompileOptions): CompileResult;
155
185
 
156
- export { type CompileOptions, type CompileResult, type SFCBlock, type SFCDescriptor, SFCParseError, type StyleCompileOptions, type StyleCompileResult, type TemplateCompileOptions, type TemplateCompileResult, compile, compileStyle, compileTemplate, generateScopeId, parse, parseTemplate };
186
+ export { type A11yOptions, type A11yWarning, type CompileOptions, type CompileResult, type SFCBlock, type SFCDescriptor, SFCParseError, type StyleCompileOptions, type StyleCompileResult, type TemplateCompileOptions, type TemplateCompileResult, checkA11y, compile, compileStyle, compileTemplate, generateScopeId, parse, parseTemplate };
package/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ interface SFCDescriptor {
14
14
  template: SFCBlock | null;
15
15
  script: SFCBlock | null;
16
16
  style: SFCBlock | null;
17
+ test: SFCBlock | null;
17
18
  filename: string;
18
19
  }
19
20
  /**
@@ -84,7 +85,7 @@ interface Directive {
84
85
  expression: string;
85
86
  modifiers: string[];
86
87
  }
87
- type DirectiveKind = 'on' | 'bind' | 'if' | 'else' | 'for' | 'model';
88
+ type DirectiveKind = 'on' | 'bind' | 'if' | 'else' | 'else-if' | 'for' | 'model';
88
89
  /** Exported for testing — parse a template string into an AST. */
89
90
  declare function parseTemplate(source: string): TemplateNode[];
90
91
 
@@ -119,11 +120,38 @@ declare function compileStyle(options: StyleCompileOptions): StyleCompileResult;
119
120
  */
120
121
  declare function generateScopeId(filename: string): string;
121
122
 
123
+ interface A11yWarning {
124
+ /** Rule ID (e.g. 'img-alt'). */
125
+ rule: string;
126
+ /** Human-readable message. */
127
+ message: string;
128
+ /** The element tag that triggered the warning. */
129
+ tag: string;
130
+ }
131
+ interface A11yOptions {
132
+ /** Rules to disable (by rule ID). */
133
+ disable?: string[];
134
+ }
135
+ /**
136
+ * Check a parsed template AST for accessibility issues.
137
+ *
138
+ * Returns an array of warnings. An empty array means no issues found.
139
+ *
140
+ * ```ts
141
+ * const ast = parseTemplate('<img src="photo.jpg">');
142
+ * const warnings = checkA11y(ast);
143
+ * // [{ rule: 'img-alt', message: '...', tag: 'img' }]
144
+ * ```
145
+ */
146
+ declare function checkA11y(ast: TemplateNode[], options?: A11yOptions): A11yWarning[];
147
+
122
148
  interface CompileOptions {
123
149
  /** Filename for error messages and scope-id generation. */
124
150
  filename?: string;
125
151
  /** Override the scope ID for testing. */
126
152
  scopeId?: string;
153
+ /** Accessibility checking options. Pass false to disable entirely. */
154
+ a11y?: A11yOptions | false;
127
155
  }
128
156
  interface CompileResult {
129
157
  /** The compiled JavaScript module source. */
@@ -132,6 +160,8 @@ interface CompileResult {
132
160
  css: string;
133
161
  /** Source map (reserved for future use). */
134
162
  map?: unknown;
163
+ /** Accessibility warnings from template analysis. */
164
+ a11y: A11yWarning[];
135
165
  }
136
166
  /**
137
167
  * Compile a `.utopia` single-file component source string.
@@ -153,4 +183,4 @@ interface CompileResult {
153
183
  */
154
184
  declare function compile(source: string, options?: CompileOptions): CompileResult;
155
185
 
156
- export { type CompileOptions, type CompileResult, type SFCBlock, type SFCDescriptor, SFCParseError, type StyleCompileOptions, type StyleCompileResult, type TemplateCompileOptions, type TemplateCompileResult, compile, compileStyle, compileTemplate, generateScopeId, parse, parseTemplate };
186
+ export { type A11yOptions, type A11yWarning, type CompileOptions, type CompileResult, type SFCBlock, type SFCDescriptor, SFCParseError, type StyleCompileOptions, type StyleCompileResult, type TemplateCompileOptions, type TemplateCompileResult, checkA11y, compile, compileStyle, compileTemplate, generateScopeId, parse, parseTemplate };
package/dist/index.js CHANGED
@@ -1,14 +1,17 @@
1
1
  // src/parser.ts
2
+ var BLOCK_RE = /<(template|script|style|test)([\s][^>]*)?\s*>/g;
3
+ var ATTR_RE = /([a-zA-Z_][\w-]*)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
2
4
  function parse(source, filename = "anonymous.utopia") {
3
5
  const descriptor = {
4
6
  template: null,
5
7
  script: null,
6
8
  style: null,
9
+ test: null,
7
10
  filename
8
11
  };
9
- const blockRe = /<(template|script|style)([\s][^>]*)?\s*>/g;
12
+ BLOCK_RE.lastIndex = 0;
10
13
  let match;
11
- while ((match = blockRe.exec(source)) !== null) {
14
+ while ((match = BLOCK_RE.exec(source)) !== null) {
12
15
  const tagName = match[1];
13
16
  const attrString = match[2] || "";
14
17
  const openTagStart = match.index;
@@ -39,11 +42,10 @@ function parse(source, filename = "anonymous.utopia") {
39
42
  );
40
43
  }
41
44
  descriptor[tagName] = block;
42
- blockRe.lastIndex = blockEnd;
45
+ BLOCK_RE.lastIndex = blockEnd;
43
46
  }
44
47
  return descriptor;
45
48
  }
46
- var ATTR_RE = /([a-zA-Z_][\w-]*)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
47
49
  function parseAttributes(raw) {
48
50
  const attrs = {};
49
51
  let m;
@@ -80,6 +82,16 @@ var SFCParseError = class extends Error {
80
82
  };
81
83
 
82
84
  // src/template-compiler.ts
85
+ var TAG_NAME_CHAR_RE = /[a-zA-Z0-9\-_]/;
86
+ var ATTR_NAME_CHAR_RE = /[a-zA-Z0-9\-_:@.]/;
87
+ var ATTR_VALUE_END_RE = /[\s/>]/;
88
+ var WHITESPACE_RE = /\s/;
89
+ var TAG_START_CHAR_RE = /[a-zA-Z]/;
90
+ var U_FOR_EXPR_RE = /^\s*(?:\(\s*(\w+)\s*(?:,\s*(\w+)\s*)?\)|(\w+))\s+in\s+(.+)$/;
91
+ var COMPONENT_TAG_RE = /^[A-Z][a-zA-Z0-9_$]*$/;
92
+ var BACKSLASH_RE = /\\/g;
93
+ var SINGLE_QUOTE_RE = /'/g;
94
+ var HTML_ENTITY_RE = /&(?:#(\d+)|#x([0-9a-fA-F]+)|(\w+));/g;
83
95
  function compileTemplate(template, options = {}) {
84
96
  const ast = parseTemplate(template);
85
97
  return generate(ast, options);
@@ -261,7 +273,7 @@ var TemplateParser = class {
261
273
  // ---- Low-level helpers --------------------------------------------------
262
274
  readTagName() {
263
275
  const start = this.pos;
264
- while (this.pos < this.source.length && /[a-zA-Z0-9\-_]/.test(this.source[this.pos])) {
276
+ while (this.pos < this.source.length && TAG_NAME_CHAR_RE.test(this.source[this.pos])) {
265
277
  this.pos++;
266
278
  }
267
279
  const name = this.source.slice(start, this.pos);
@@ -270,7 +282,7 @@ var TemplateParser = class {
270
282
  }
271
283
  readAttributeName() {
272
284
  const start = this.pos;
273
- while (this.pos < this.source.length && /[a-zA-Z0-9\-_:@.]/.test(this.source[this.pos])) {
285
+ while (this.pos < this.source.length && ATTR_NAME_CHAR_RE.test(this.source[this.pos])) {
274
286
  this.pos++;
275
287
  }
276
288
  return this.source.slice(start, this.pos);
@@ -287,13 +299,13 @@ var TemplateParser = class {
287
299
  return value;
288
300
  }
289
301
  const start = this.pos;
290
- while (this.pos < this.source.length && !/[\s/>]/.test(this.source[this.pos])) {
302
+ while (this.pos < this.source.length && !ATTR_VALUE_END_RE.test(this.source[this.pos])) {
291
303
  this.pos++;
292
304
  }
293
305
  return this.source.slice(start, this.pos);
294
306
  }
295
307
  skipWhitespace() {
296
- while (this.pos < this.source.length && /\s/.test(this.source[this.pos])) {
308
+ while (this.pos < this.source.length && WHITESPACE_RE.test(this.source[this.pos])) {
297
309
  this.pos++;
298
310
  }
299
311
  }
@@ -303,7 +315,7 @@ var TemplateParser = class {
303
315
  /** Returns true when the char after `<` looks like the start of a tag name. */
304
316
  peekTagStart() {
305
317
  const next = this.source[this.pos + 1];
306
- return next !== void 0 && /[a-zA-Z]/.test(next);
318
+ return next !== void 0 && TAG_START_CHAR_RE.test(next);
307
319
  }
308
320
  expect(str) {
309
321
  if (!this.lookingAt(str)) {
@@ -373,7 +385,7 @@ function classifyDirective(name, value) {
373
385
  return null;
374
386
  }
375
387
  function isDirectiveKind(s) {
376
- return s === "on" || s === "bind" || s === "if" || s === "else" || s === "for" || s === "model";
388
+ return s === "on" || s === "bind" || s === "if" || s === "else" || s === "else-if" || s === "for" || s === "model";
377
389
  }
378
390
  var CodeGenerator = class {
379
391
  constructor(options) {
@@ -464,7 +476,8 @@ ${fnBody}
464
476
  }
465
477
  }
466
478
  for (const dir of node.directives) {
467
- if (dir.kind === "if" || dir.kind === "else" || dir.kind === "for") continue;
479
+ if (dir.kind === "if" || dir.kind === "else" || dir.kind === "else-if" || dir.kind === "for")
480
+ continue;
468
481
  this.genDirective(elVar, dir, scope);
469
482
  }
470
483
  this.genChildren(elVar, node.children, scope);
@@ -550,7 +563,7 @@ ${fnBody}
550
563
  this.emit(`addEventListener(${elVar}, 'input', (e) => ${signalRef}.set(e.target.value))`);
551
564
  }
552
565
  // ---- Structural: u-if ---------------------------------------------------
553
- genIf(node, dir, scope, elseNode) {
566
+ genIf(node, dir, scope, elseIfChain, elseNode) {
554
567
  this.helpers.add("createIf");
555
568
  const anchorVar = this.freshVar();
556
569
  this.helpers.add("createComment");
@@ -558,7 +571,7 @@ ${fnBody}
558
571
  const condition = this.resolveExpression(dir.expression, scope);
559
572
  const strippedNode = {
560
573
  ...node,
561
- directives: node.directives.filter((d) => d.kind !== "if")
574
+ directives: node.directives.filter((d) => d.kind !== "if" && d.kind !== "else-if")
562
575
  };
563
576
  const trueFnVar = this.freshVar();
564
577
  const savedCode = this.code;
@@ -573,7 +586,29 @@ ${fnBody}
573
586
  this.emit(` return ${innerVar}`);
574
587
  this.emit(`}`);
575
588
  let elseArg = "";
576
- if (elseNode) {
589
+ if (elseIfChain && elseIfChain.length > 0) {
590
+ const firstElseIf = elseIfChain[0];
591
+ const remainingElseIfs = elseIfChain.slice(1);
592
+ const falseFnVar = this.freshVar();
593
+ const savedCode2 = this.code;
594
+ this.code = [];
595
+ const nestedAnchor = this.genIf(
596
+ firstElseIf.node,
597
+ firstElseIf.dir,
598
+ scope,
599
+ remainingElseIfs.length > 0 ? remainingElseIfs : void 0,
600
+ elseNode
601
+ );
602
+ const nestedLines = [...this.code];
603
+ this.code = savedCode2;
604
+ this.emit(`const ${falseFnVar} = () => {`);
605
+ for (const line of nestedLines) {
606
+ this.emit(` ${line}`);
607
+ }
608
+ this.emit(` return ${nestedAnchor}`);
609
+ this.emit(`}`);
610
+ elseArg = `, ${falseFnVar}`;
611
+ } else if (elseNode) {
577
612
  const falseFnVar = this.freshVar();
578
613
  const strippedElse = {
579
614
  ...elseNode,
@@ -601,9 +636,7 @@ ${fnBody}
601
636
  const anchorVar = this.freshVar();
602
637
  this.helpers.add("createComment");
603
638
  this.emit(`const ${anchorVar} = createComment('u-for')`);
604
- const forMatch = dir.expression.match(
605
- /^\s*(?:\(\s*(\w+)\s*(?:,\s*(\w+)\s*)?\)|(\w+))\s+in\s+(.+)$/
606
- );
639
+ const forMatch = dir.expression.match(U_FOR_EXPR_RE);
607
640
  if (!forMatch) {
608
641
  throw new Error(`Invalid u-for expression: "${dir.expression}"`);
609
642
  }
@@ -639,7 +672,7 @@ ${fnBody}
639
672
  this.emitOrDefer(`createFor(${anchorVar}, () => ${listExpr}, ${renderFnVar}${keyArg})`);
640
673
  return anchorVar;
641
674
  }
642
- // ---- Children processing (handles u-if / u-else pairing) ----------------
675
+ // ---- Children processing (handles u-if / u-else-if / u-else pairing) ----
643
676
  genChildren(parentVar, children, scope) {
644
677
  this.deferredCallsStack.push([]);
645
678
  let i = 0;
@@ -648,24 +681,39 @@ ${fnBody}
648
681
  if (child.type === 1 /* Element */) {
649
682
  const ifDir = child.directives.find((d) => d.kind === "if");
650
683
  if (ifDir) {
684
+ const elseIfChain = [];
651
685
  let elseNode;
652
686
  let skipTo = i + 1;
653
687
  for (let j = i + 1; j < children.length; j++) {
654
688
  const next = children[j];
655
689
  if (next.type === 2 /* Text */ && !next.content.trim()) continue;
656
- if (next.type === 1 /* Element */ && next.directives.some((d) => d.kind === "else")) {
657
- elseNode = next;
658
- skipTo = j + 1;
690
+ if (next.type === 1 /* Element */) {
691
+ const elseIfDir = next.directives.find((d) => d.kind === "else-if");
692
+ if (elseIfDir) {
693
+ elseIfChain.push({ node: next, dir: elseIfDir });
694
+ skipTo = j + 1;
695
+ continue;
696
+ }
697
+ if (next.directives.some((d) => d.kind === "else")) {
698
+ elseNode = next;
699
+ skipTo = j + 1;
700
+ }
659
701
  }
660
702
  break;
661
703
  }
662
- const childVar2 = this.genIf(child, ifDir, scope, elseNode);
704
+ const childVar2 = this.genIf(
705
+ child,
706
+ ifDir,
707
+ scope,
708
+ elseIfChain.length > 0 ? elseIfChain : void 0,
709
+ elseNode
710
+ );
663
711
  this.helpers.add("appendChild");
664
712
  this.emit(`appendChild(${parentVar}, ${childVar2})`);
665
713
  i = skipTo;
666
714
  continue;
667
715
  }
668
- if (child.directives.some((d) => d.kind === "else")) {
716
+ if (child.directives.some((d) => d.kind === "else" || d.kind === "else-if")) {
669
717
  i++;
670
718
  continue;
671
719
  }
@@ -791,10 +839,10 @@ ${fnBody}
791
839
  }
792
840
  };
793
841
  function isComponentTag(tag) {
794
- return /^[A-Z][a-zA-Z0-9_$]*$/.test(tag);
842
+ return COMPONENT_TAG_RE.test(tag);
795
843
  }
796
844
  function escapeStr(s) {
797
- return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
845
+ return s.replace(BACKSLASH_RE, "\\\\").replace(SINGLE_QUOTE_RE, "\\'");
798
846
  }
799
847
  var ENTITY_MAP = {
800
848
  "&amp;": "&",
@@ -823,7 +871,8 @@ var ENTITY_MAP = {
823
871
  "&divide;": "\xF7"
824
872
  };
825
873
  function decodeEntities(text) {
826
- return text.replace(/&(?:#(\d+)|#x([0-9a-fA-F]+)|(\w+));/g, (match, dec, hex, named) => {
874
+ HTML_ENTITY_RE.lastIndex = 0;
875
+ return text.replace(HTML_ENTITY_RE, (match, dec, hex, named) => {
827
876
  if (dec) {
828
877
  const code = parseInt(dec, 10);
829
878
  if (code >= 0 && code <= 1114111) {
@@ -856,6 +905,9 @@ function generate(ast, options) {
856
905
  }
857
906
 
858
907
  // src/style-compiler.ts
908
+ var WHITESPACE_RE2 = /\s/;
909
+ var KEYFRAMES_RE = /^@(?:-\w+-)?keyframes\b/;
910
+ var PSEUDO_SELECTOR_RE = /(?:::?[\w-]+(?:\([^)]*\))?)+$/;
859
911
  function compileStyle(options) {
860
912
  const { source, filename, scoped, scopeId: overrideScopeId } = options;
861
913
  if (!scoped) {
@@ -876,7 +928,7 @@ function scopeSelectors(css, scopeId) {
876
928
  const result = [];
877
929
  let pos = 0;
878
930
  while (pos < css.length) {
879
- if (/\s/.test(css[pos])) {
931
+ if (WHITESPACE_RE2.test(css[pos])) {
880
932
  result.push(css[pos]);
881
933
  pos++;
882
934
  continue;
@@ -925,7 +977,7 @@ function consumeAtRule(css, pos, scopeId) {
925
977
  return { text: css.slice(start), end: css.length };
926
978
  }
927
979
  const header = css.slice(start, headerEnd);
928
- const isKeyframes = /^@(?:-\w+-)?keyframes\b/.test(header.trim());
980
+ const isKeyframes = KEYFRAMES_RE.test(header.trim());
929
981
  depth = 1;
930
982
  let bodyStart = headerEnd + 1;
931
983
  let bodyEnd = headerEnd + 1;
@@ -968,8 +1020,7 @@ function consumeRuleSet(css, pos, scopeId) {
968
1020
  function scopeSingleSelector(selector, scopeId) {
969
1021
  if (!selector) return selector;
970
1022
  const attr = `[${scopeId}]`;
971
- const pseudoRe = /(?:::?[\w-]+(?:\([^)]*\))?)+$/;
972
- const pseudoMatch = selector.match(pseudoRe);
1023
+ const pseudoMatch = selector.match(PSEUDO_SELECTOR_RE);
973
1024
  if (pseudoMatch) {
974
1025
  const beforePseudo = selector.slice(0, pseudoMatch.index);
975
1026
  return `${beforePseudo}${attr}${pseudoMatch[0]}`;
@@ -977,6 +1028,256 @@ function scopeSingleSelector(selector, scopeId) {
977
1028
  return `${selector}${attr}`;
978
1029
  }
979
1030
 
1031
+ // src/a11y.ts
1032
+ var HEADING_LEVEL_RE = /^h([1-6])$/;
1033
+ var VALID_ARIA_ROLES = /* @__PURE__ */ new Set([
1034
+ "alert",
1035
+ "alertdialog",
1036
+ "application",
1037
+ "article",
1038
+ "banner",
1039
+ "button",
1040
+ "cell",
1041
+ "checkbox",
1042
+ "columnheader",
1043
+ "combobox",
1044
+ "complementary",
1045
+ "contentinfo",
1046
+ "definition",
1047
+ "dialog",
1048
+ "directory",
1049
+ "document",
1050
+ "feed",
1051
+ "figure",
1052
+ "form",
1053
+ "grid",
1054
+ "gridcell",
1055
+ "group",
1056
+ "heading",
1057
+ "img",
1058
+ "link",
1059
+ "list",
1060
+ "listbox",
1061
+ "listitem",
1062
+ "log",
1063
+ "main",
1064
+ "marquee",
1065
+ "math",
1066
+ "menu",
1067
+ "menubar",
1068
+ "menuitem",
1069
+ "menuitemcheckbox",
1070
+ "menuitemradio",
1071
+ "meter",
1072
+ "navigation",
1073
+ "none",
1074
+ "note",
1075
+ "option",
1076
+ "presentation",
1077
+ "progressbar",
1078
+ "radio",
1079
+ "radiogroup",
1080
+ "region",
1081
+ "row",
1082
+ "rowgroup",
1083
+ "rowheader",
1084
+ "scrollbar",
1085
+ "search",
1086
+ "searchbox",
1087
+ "separator",
1088
+ "slider",
1089
+ "spinbutton",
1090
+ "status",
1091
+ "switch",
1092
+ "tab",
1093
+ "table",
1094
+ "tablist",
1095
+ "tabpanel",
1096
+ "term",
1097
+ "textbox",
1098
+ "timer",
1099
+ "toolbar",
1100
+ "tooltip",
1101
+ "tree",
1102
+ "treegrid",
1103
+ "treeitem"
1104
+ ]);
1105
+ var INTERACTIVE_ELEMENTS = /* @__PURE__ */ new Set([
1106
+ "a",
1107
+ "button",
1108
+ "input",
1109
+ "select",
1110
+ "textarea",
1111
+ "details",
1112
+ "summary"
1113
+ ]);
1114
+ function getAttr(node, name) {
1115
+ return node.attrs.find((a) => a.name === name);
1116
+ }
1117
+ function hasAttr(node, name) {
1118
+ return node.attrs.some((a) => a.name === name);
1119
+ }
1120
+ function hasDirective(node, kind, arg) {
1121
+ return node.directives.some((d) => d.kind === kind && (arg === void 0 || d.arg === arg));
1122
+ }
1123
+ function hasBoundAttr(node, name) {
1124
+ return node.directives.some((d) => d.kind === "bind" && d.arg === name);
1125
+ }
1126
+ function hasTextContent(node) {
1127
+ return node.children.some(
1128
+ (c) => c.type === 2 /* Text */ && c.content.trim() !== "" || c.type === 3 /* Interpolation */
1129
+ );
1130
+ }
1131
+ var rules = {
1132
+ "img-alt"(node, warnings) {
1133
+ if (node.tag !== "img") return;
1134
+ if (hasAttr(node, "alt") || hasBoundAttr(node, "alt")) return;
1135
+ if (hasAttr(node, "aria-label") || hasAttr(node, "aria-labelledby")) return;
1136
+ const role = getAttr(node, "role");
1137
+ if (role && (role.value === "presentation" || role.value === "none")) return;
1138
+ warnings.push({
1139
+ rule: "img-alt",
1140
+ message: "<img> element must have an alt attribute",
1141
+ tag: node.tag
1142
+ });
1143
+ },
1144
+ "click-keyboard"(node, warnings) {
1145
+ const hasClick = hasDirective(node, "on", "click");
1146
+ if (!hasClick) return;
1147
+ if (INTERACTIVE_ELEMENTS.has(node.tag)) return;
1148
+ const hasKeyboard = hasDirective(node, "on", "keydown") || hasDirective(node, "on", "keyup") || hasDirective(node, "on", "keypress");
1149
+ const role = getAttr(node, "role");
1150
+ const hasInteractiveRole = role && (role.value === "button" || role.value === "link");
1151
+ if (!hasKeyboard && !hasInteractiveRole) {
1152
+ warnings.push({
1153
+ rule: "click-keyboard",
1154
+ message: `<${node.tag}> with @click handler should also have a keyboard event handler or role="button"`,
1155
+ tag: node.tag
1156
+ });
1157
+ }
1158
+ if (!hasAttr(node, "tabindex") && !hasBoundAttr(node, "tabindex")) {
1159
+ warnings.push({
1160
+ rule: "click-keyboard",
1161
+ message: `<${node.tag}> with @click handler should have tabindex="0" for keyboard accessibility`,
1162
+ tag: node.tag
1163
+ });
1164
+ }
1165
+ },
1166
+ "anchor-content"(node, warnings) {
1167
+ if (node.tag !== "a") return;
1168
+ if (hasTextContent(node)) return;
1169
+ if (hasAttr(node, "aria-label") || hasAttr(node, "aria-labelledby")) return;
1170
+ const hasChildElements = node.children.some((c) => c.type === 1 /* Element */);
1171
+ if (hasChildElements) return;
1172
+ warnings.push({
1173
+ rule: "anchor-content",
1174
+ message: "<a> element must have content, aria-label, or aria-labelledby",
1175
+ tag: node.tag
1176
+ });
1177
+ },
1178
+ "form-label"(node, warnings) {
1179
+ const formElements = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
1180
+ if (!formElements.has(node.tag)) return;
1181
+ const type = getAttr(node, "type");
1182
+ if (type && type.value === "hidden") return;
1183
+ if (hasAttr(node, "aria-label") || hasAttr(node, "aria-labelledby")) return;
1184
+ if (hasAttr(node, "id")) return;
1185
+ if (hasAttr(node, "title")) return;
1186
+ warnings.push({
1187
+ rule: "form-label",
1188
+ message: `<${node.tag}> should have an id (with matching <label>), aria-label, or aria-labelledby`,
1189
+ tag: node.tag
1190
+ });
1191
+ },
1192
+ "no-distracting"(node, warnings) {
1193
+ if (node.tag === "marquee" || node.tag === "blink") {
1194
+ warnings.push({
1195
+ rule: "no-distracting",
1196
+ message: `<${node.tag}> is distracting and inaccessible \u2014 do not use`,
1197
+ tag: node.tag
1198
+ });
1199
+ }
1200
+ },
1201
+ "heading-order"(node, warnings, ctx) {
1202
+ const match = node.tag.match(HEADING_LEVEL_RE);
1203
+ if (!match) return;
1204
+ const level = parseInt(match[1], 10);
1205
+ if (ctx.lastHeadingLevel > 0 && level > ctx.lastHeadingLevel + 1) {
1206
+ warnings.push({
1207
+ rule: "heading-order",
1208
+ message: `Heading level <${node.tag}> skips from <h${ctx.lastHeadingLevel}> \u2014 headings should not skip levels`,
1209
+ tag: node.tag
1210
+ });
1211
+ }
1212
+ ctx.lastHeadingLevel = level;
1213
+ },
1214
+ "aria-role"(node, warnings) {
1215
+ const role = getAttr(node, "role");
1216
+ if (!role || !role.value) return;
1217
+ if (!VALID_ARIA_ROLES.has(role.value)) {
1218
+ warnings.push({
1219
+ rule: "aria-role",
1220
+ message: `Invalid ARIA role "${role.value}" on <${node.tag}>`,
1221
+ tag: node.tag
1222
+ });
1223
+ }
1224
+ },
1225
+ "no-positive-tabindex"(node, warnings) {
1226
+ const tabindex = getAttr(node, "tabindex");
1227
+ if (!tabindex || !tabindex.value) return;
1228
+ const val = parseInt(tabindex.value, 10);
1229
+ if (!isNaN(val) && val > 0) {
1230
+ warnings.push({
1231
+ rule: "no-positive-tabindex",
1232
+ message: `Avoid positive tabindex="${tabindex.value}" \u2014 it disrupts natural tab order`,
1233
+ tag: node.tag
1234
+ });
1235
+ }
1236
+ },
1237
+ "media-captions"(node, warnings) {
1238
+ if (node.tag !== "video" && node.tag !== "audio") return;
1239
+ const hasTrack = node.children.some(
1240
+ (c) => c.type === 1 /* Element */ && c.tag === "track"
1241
+ );
1242
+ if (hasTrack) return;
1243
+ if (hasAttr(node, "aria-label") || hasAttr(node, "aria-labelledby")) return;
1244
+ warnings.push({
1245
+ rule: "media-captions",
1246
+ message: `<${node.tag}> should have a <track> element for captions`,
1247
+ tag: node.tag
1248
+ });
1249
+ },
1250
+ "anchor-valid"(node, warnings) {
1251
+ if (node.tag !== "a") return;
1252
+ if (hasAttr(node, "href") || hasBoundAttr(node, "href")) return;
1253
+ const role = getAttr(node, "role");
1254
+ if (role && role.value === "button") return;
1255
+ warnings.push({
1256
+ rule: "anchor-valid",
1257
+ message: "<a> element should have an href attribute",
1258
+ tag: node.tag
1259
+ });
1260
+ }
1261
+ };
1262
+ function walkNodes(nodes, enabledRules, warnings, ctx) {
1263
+ for (const node of nodes) {
1264
+ if (node.type === 1 /* Element */) {
1265
+ for (const rule of enabledRules) {
1266
+ rule(node, warnings, ctx);
1267
+ }
1268
+ walkNodes(node.children, enabledRules, warnings, ctx);
1269
+ }
1270
+ }
1271
+ }
1272
+ function checkA11y(ast, options) {
1273
+ const disabled = new Set(options?.disable ?? []);
1274
+ const enabledRules = Object.entries(rules).filter(([id]) => !disabled.has(id)).map(([, fn]) => fn);
1275
+ const warnings = [];
1276
+ const ctx = { lastHeadingLevel: 0 };
1277
+ walkNodes(ast, enabledRules, warnings, ctx);
1278
+ return warnings;
1279
+ }
1280
+
980
1281
  // src/index.ts
981
1282
  function compile(source, options = {}) {
982
1283
  const filename = options.filename ?? "anonymous.utopia";
@@ -995,11 +1296,16 @@ function compile(source, options = {}) {
995
1296
  scopeId = styleResult.scopeId;
996
1297
  }
997
1298
  let renderModule = "";
1299
+ let a11yWarnings = [];
998
1300
  if (descriptor.template) {
999
1301
  const templateResult = compileTemplate(descriptor.template.content, {
1000
1302
  scopeId: scopeId ?? void 0
1001
1303
  });
1002
1304
  renderModule = templateResult.code;
1305
+ if (options.a11y !== false) {
1306
+ const ast = parseTemplate(descriptor.template.content);
1307
+ a11yWarnings = checkA11y(ast, options.a11y ?? void 0);
1308
+ }
1003
1309
  }
1004
1310
  const { imports, body } = splitModuleParts(renderModule);
1005
1311
  const scriptContent = descriptor.script?.content ?? "";
@@ -1015,7 +1321,7 @@ function compile(source, options = {}) {
1015
1321
  }
1016
1322
  parts.push("export default { render: __render }");
1017
1323
  const code = parts.join("\n\n") + "\n";
1018
- return { code, css };
1324
+ return { code, css, a11y: a11yWarnings };
1019
1325
  }
1020
1326
  function splitModuleParts(moduleCode) {
1021
1327
  const idx = moduleCode.indexOf("\n\n");
@@ -1028,6 +1334,7 @@ function splitModuleParts(moduleCode) {
1028
1334
  }
1029
1335
  export {
1030
1336
  SFCParseError,
1337
+ checkA11y,
1031
1338
  compile,
1032
1339
  compileStyle,
1033
1340
  compileTemplate,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matthesketh/utopia-compiler",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Compiler for .utopia single-file components",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,7 +39,7 @@
39
39
  "dist"
40
40
  ],
41
41
  "dependencies": {
42
- "@matthesketh/utopia-core": "0.3.1"
42
+ "@matthesketh/utopia-core": "0.5.0"
43
43
  },
44
44
  "scripts": {
45
45
  "build": "tsup src/index.ts --format esm,cjs --dts",