@matthesketh/utopia-compiler 0.3.0 → 0.4.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,
@@ -405,7 +406,7 @@ function classifyDirective(name, value) {
405
406
  return null;
406
407
  }
407
408
  function isDirectiveKind(s) {
408
- return s === "on" || s === "bind" || s === "if" || s === "else" || s === "for" || s === "model";
409
+ return s === "on" || s === "bind" || s === "if" || s === "else" || s === "else-if" || s === "for" || s === "model";
409
410
  }
410
411
  var CodeGenerator = class {
411
412
  constructor(options) {
@@ -496,7 +497,8 @@ ${fnBody}
496
497
  }
497
498
  }
498
499
  for (const dir of node.directives) {
499
- if (dir.kind === "if" || dir.kind === "else" || dir.kind === "for") continue;
500
+ if (dir.kind === "if" || dir.kind === "else" || dir.kind === "else-if" || dir.kind === "for")
501
+ continue;
500
502
  this.genDirective(elVar, dir, scope);
501
503
  }
502
504
  this.genChildren(elVar, node.children, scope);
@@ -582,7 +584,7 @@ ${fnBody}
582
584
  this.emit(`addEventListener(${elVar}, 'input', (e) => ${signalRef}.set(e.target.value))`);
583
585
  }
584
586
  // ---- Structural: u-if ---------------------------------------------------
585
- genIf(node, dir, scope, elseNode) {
587
+ genIf(node, dir, scope, elseIfChain, elseNode) {
586
588
  this.helpers.add("createIf");
587
589
  const anchorVar = this.freshVar();
588
590
  this.helpers.add("createComment");
@@ -590,7 +592,7 @@ ${fnBody}
590
592
  const condition = this.resolveExpression(dir.expression, scope);
591
593
  const strippedNode = {
592
594
  ...node,
593
- directives: node.directives.filter((d) => d.kind !== "if")
595
+ directives: node.directives.filter((d) => d.kind !== "if" && d.kind !== "else-if")
594
596
  };
595
597
  const trueFnVar = this.freshVar();
596
598
  const savedCode = this.code;
@@ -605,7 +607,29 @@ ${fnBody}
605
607
  this.emit(` return ${innerVar}`);
606
608
  this.emit(`}`);
607
609
  let elseArg = "";
608
- if (elseNode) {
610
+ if (elseIfChain && elseIfChain.length > 0) {
611
+ const firstElseIf = elseIfChain[0];
612
+ const remainingElseIfs = elseIfChain.slice(1);
613
+ const falseFnVar = this.freshVar();
614
+ const savedCode2 = this.code;
615
+ this.code = [];
616
+ const nestedAnchor = this.genIf(
617
+ firstElseIf.node,
618
+ firstElseIf.dir,
619
+ scope,
620
+ remainingElseIfs.length > 0 ? remainingElseIfs : void 0,
621
+ elseNode
622
+ );
623
+ const nestedLines = [...this.code];
624
+ this.code = savedCode2;
625
+ this.emit(`const ${falseFnVar} = () => {`);
626
+ for (const line of nestedLines) {
627
+ this.emit(` ${line}`);
628
+ }
629
+ this.emit(` return ${nestedAnchor}`);
630
+ this.emit(`}`);
631
+ elseArg = `, ${falseFnVar}`;
632
+ } else if (elseNode) {
609
633
  const falseFnVar = this.freshVar();
610
634
  const strippedElse = {
611
635
  ...elseNode,
@@ -671,7 +695,7 @@ ${fnBody}
671
695
  this.emitOrDefer(`createFor(${anchorVar}, () => ${listExpr}, ${renderFnVar}${keyArg})`);
672
696
  return anchorVar;
673
697
  }
674
- // ---- Children processing (handles u-if / u-else pairing) ----------------
698
+ // ---- Children processing (handles u-if / u-else-if / u-else pairing) ----
675
699
  genChildren(parentVar, children, scope) {
676
700
  this.deferredCallsStack.push([]);
677
701
  let i = 0;
@@ -680,24 +704,39 @@ ${fnBody}
680
704
  if (child.type === 1 /* Element */) {
681
705
  const ifDir = child.directives.find((d) => d.kind === "if");
682
706
  if (ifDir) {
707
+ const elseIfChain = [];
683
708
  let elseNode;
684
709
  let skipTo = i + 1;
685
710
  for (let j = i + 1; j < children.length; j++) {
686
711
  const next = children[j];
687
712
  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;
713
+ if (next.type === 1 /* Element */) {
714
+ const elseIfDir = next.directives.find((d) => d.kind === "else-if");
715
+ if (elseIfDir) {
716
+ elseIfChain.push({ node: next, dir: elseIfDir });
717
+ skipTo = j + 1;
718
+ continue;
719
+ }
720
+ if (next.directives.some((d) => d.kind === "else")) {
721
+ elseNode = next;
722
+ skipTo = j + 1;
723
+ }
691
724
  }
692
725
  break;
693
726
  }
694
- const childVar2 = this.genIf(child, ifDir, scope, elseNode);
727
+ const childVar2 = this.genIf(
728
+ child,
729
+ ifDir,
730
+ scope,
731
+ elseIfChain.length > 0 ? elseIfChain : void 0,
732
+ elseNode
733
+ );
695
734
  this.helpers.add("appendChild");
696
735
  this.emit(`appendChild(${parentVar}, ${childVar2})`);
697
736
  i = skipTo;
698
737
  continue;
699
738
  }
700
- if (child.directives.some((d) => d.kind === "else")) {
739
+ if (child.directives.some((d) => d.kind === "else" || d.kind === "else-if")) {
701
740
  i++;
702
741
  continue;
703
742
  }
@@ -1009,6 +1048,255 @@ function scopeSingleSelector(selector, scopeId) {
1009
1048
  return `${selector}${attr}`;
1010
1049
  }
1011
1050
 
1051
+ // src/a11y.ts
1052
+ var VALID_ARIA_ROLES = /* @__PURE__ */ new Set([
1053
+ "alert",
1054
+ "alertdialog",
1055
+ "application",
1056
+ "article",
1057
+ "banner",
1058
+ "button",
1059
+ "cell",
1060
+ "checkbox",
1061
+ "columnheader",
1062
+ "combobox",
1063
+ "complementary",
1064
+ "contentinfo",
1065
+ "definition",
1066
+ "dialog",
1067
+ "directory",
1068
+ "document",
1069
+ "feed",
1070
+ "figure",
1071
+ "form",
1072
+ "grid",
1073
+ "gridcell",
1074
+ "group",
1075
+ "heading",
1076
+ "img",
1077
+ "link",
1078
+ "list",
1079
+ "listbox",
1080
+ "listitem",
1081
+ "log",
1082
+ "main",
1083
+ "marquee",
1084
+ "math",
1085
+ "menu",
1086
+ "menubar",
1087
+ "menuitem",
1088
+ "menuitemcheckbox",
1089
+ "menuitemradio",
1090
+ "meter",
1091
+ "navigation",
1092
+ "none",
1093
+ "note",
1094
+ "option",
1095
+ "presentation",
1096
+ "progressbar",
1097
+ "radio",
1098
+ "radiogroup",
1099
+ "region",
1100
+ "row",
1101
+ "rowgroup",
1102
+ "rowheader",
1103
+ "scrollbar",
1104
+ "search",
1105
+ "searchbox",
1106
+ "separator",
1107
+ "slider",
1108
+ "spinbutton",
1109
+ "status",
1110
+ "switch",
1111
+ "tab",
1112
+ "table",
1113
+ "tablist",
1114
+ "tabpanel",
1115
+ "term",
1116
+ "textbox",
1117
+ "timer",
1118
+ "toolbar",
1119
+ "tooltip",
1120
+ "tree",
1121
+ "treegrid",
1122
+ "treeitem"
1123
+ ]);
1124
+ var INTERACTIVE_ELEMENTS = /* @__PURE__ */ new Set([
1125
+ "a",
1126
+ "button",
1127
+ "input",
1128
+ "select",
1129
+ "textarea",
1130
+ "details",
1131
+ "summary"
1132
+ ]);
1133
+ function getAttr(node, name) {
1134
+ return node.attrs.find((a) => a.name === name);
1135
+ }
1136
+ function hasAttr(node, name) {
1137
+ return node.attrs.some((a) => a.name === name);
1138
+ }
1139
+ function hasDirective(node, kind, arg) {
1140
+ return node.directives.some((d) => d.kind === kind && (arg === void 0 || d.arg === arg));
1141
+ }
1142
+ function hasBoundAttr(node, name) {
1143
+ return node.directives.some((d) => d.kind === "bind" && d.arg === name);
1144
+ }
1145
+ function hasTextContent(node) {
1146
+ return node.children.some(
1147
+ (c) => c.type === 2 /* Text */ && c.content.trim() !== "" || c.type === 3 /* Interpolation */
1148
+ );
1149
+ }
1150
+ var rules = {
1151
+ "img-alt"(node, warnings) {
1152
+ if (node.tag !== "img") return;
1153
+ if (hasAttr(node, "alt") || hasBoundAttr(node, "alt")) return;
1154
+ if (hasAttr(node, "aria-label") || hasAttr(node, "aria-labelledby")) return;
1155
+ const role = getAttr(node, "role");
1156
+ if (role && (role.value === "presentation" || role.value === "none")) return;
1157
+ warnings.push({
1158
+ rule: "img-alt",
1159
+ message: "<img> element must have an alt attribute",
1160
+ tag: node.tag
1161
+ });
1162
+ },
1163
+ "click-keyboard"(node, warnings) {
1164
+ const hasClick = hasDirective(node, "on", "click");
1165
+ if (!hasClick) return;
1166
+ if (INTERACTIVE_ELEMENTS.has(node.tag)) return;
1167
+ const hasKeyboard = hasDirective(node, "on", "keydown") || hasDirective(node, "on", "keyup") || hasDirective(node, "on", "keypress");
1168
+ const role = getAttr(node, "role");
1169
+ const hasInteractiveRole = role && (role.value === "button" || role.value === "link");
1170
+ if (!hasKeyboard && !hasInteractiveRole) {
1171
+ warnings.push({
1172
+ rule: "click-keyboard",
1173
+ message: `<${node.tag}> with @click handler should also have a keyboard event handler or role="button"`,
1174
+ tag: node.tag
1175
+ });
1176
+ }
1177
+ if (!hasAttr(node, "tabindex") && !hasBoundAttr(node, "tabindex")) {
1178
+ warnings.push({
1179
+ rule: "click-keyboard",
1180
+ message: `<${node.tag}> with @click handler should have tabindex="0" for keyboard accessibility`,
1181
+ tag: node.tag
1182
+ });
1183
+ }
1184
+ },
1185
+ "anchor-content"(node, warnings) {
1186
+ if (node.tag !== "a") return;
1187
+ if (hasTextContent(node)) return;
1188
+ if (hasAttr(node, "aria-label") || hasAttr(node, "aria-labelledby")) return;
1189
+ const hasChildElements = node.children.some((c) => c.type === 1 /* Element */);
1190
+ if (hasChildElements) return;
1191
+ warnings.push({
1192
+ rule: "anchor-content",
1193
+ message: "<a> element must have content, aria-label, or aria-labelledby",
1194
+ tag: node.tag
1195
+ });
1196
+ },
1197
+ "form-label"(node, warnings) {
1198
+ const formElements = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
1199
+ if (!formElements.has(node.tag)) return;
1200
+ const type = getAttr(node, "type");
1201
+ if (type && type.value === "hidden") return;
1202
+ if (hasAttr(node, "aria-label") || hasAttr(node, "aria-labelledby")) return;
1203
+ if (hasAttr(node, "id")) return;
1204
+ if (hasAttr(node, "title")) return;
1205
+ warnings.push({
1206
+ rule: "form-label",
1207
+ message: `<${node.tag}> should have an id (with matching <label>), aria-label, or aria-labelledby`,
1208
+ tag: node.tag
1209
+ });
1210
+ },
1211
+ "no-distracting"(node, warnings) {
1212
+ if (node.tag === "marquee" || node.tag === "blink") {
1213
+ warnings.push({
1214
+ rule: "no-distracting",
1215
+ message: `<${node.tag}> is distracting and inaccessible \u2014 do not use`,
1216
+ tag: node.tag
1217
+ });
1218
+ }
1219
+ },
1220
+ "heading-order"(node, warnings, ctx) {
1221
+ const match = node.tag.match(/^h([1-6])$/);
1222
+ if (!match) return;
1223
+ const level = parseInt(match[1], 10);
1224
+ if (ctx.lastHeadingLevel > 0 && level > ctx.lastHeadingLevel + 1) {
1225
+ warnings.push({
1226
+ rule: "heading-order",
1227
+ message: `Heading level <${node.tag}> skips from <h${ctx.lastHeadingLevel}> \u2014 headings should not skip levels`,
1228
+ tag: node.tag
1229
+ });
1230
+ }
1231
+ ctx.lastHeadingLevel = level;
1232
+ },
1233
+ "aria-role"(node, warnings) {
1234
+ const role = getAttr(node, "role");
1235
+ if (!role || !role.value) return;
1236
+ if (!VALID_ARIA_ROLES.has(role.value)) {
1237
+ warnings.push({
1238
+ rule: "aria-role",
1239
+ message: `Invalid ARIA role "${role.value}" on <${node.tag}>`,
1240
+ tag: node.tag
1241
+ });
1242
+ }
1243
+ },
1244
+ "no-positive-tabindex"(node, warnings) {
1245
+ const tabindex = getAttr(node, "tabindex");
1246
+ if (!tabindex || !tabindex.value) return;
1247
+ const val = parseInt(tabindex.value, 10);
1248
+ if (!isNaN(val) && val > 0) {
1249
+ warnings.push({
1250
+ rule: "no-positive-tabindex",
1251
+ message: `Avoid positive tabindex="${tabindex.value}" \u2014 it disrupts natural tab order`,
1252
+ tag: node.tag
1253
+ });
1254
+ }
1255
+ },
1256
+ "media-captions"(node, warnings) {
1257
+ if (node.tag !== "video" && node.tag !== "audio") return;
1258
+ const hasTrack = node.children.some(
1259
+ (c) => c.type === 1 /* Element */ && c.tag === "track"
1260
+ );
1261
+ if (hasTrack) return;
1262
+ if (hasAttr(node, "aria-label") || hasAttr(node, "aria-labelledby")) return;
1263
+ warnings.push({
1264
+ rule: "media-captions",
1265
+ message: `<${node.tag}> should have a <track> element for captions`,
1266
+ tag: node.tag
1267
+ });
1268
+ },
1269
+ "anchor-valid"(node, warnings) {
1270
+ if (node.tag !== "a") return;
1271
+ if (hasAttr(node, "href") || hasBoundAttr(node, "href")) return;
1272
+ const role = getAttr(node, "role");
1273
+ if (role && role.value === "button") return;
1274
+ warnings.push({
1275
+ rule: "anchor-valid",
1276
+ message: "<a> element should have an href attribute",
1277
+ tag: node.tag
1278
+ });
1279
+ }
1280
+ };
1281
+ function walkNodes(nodes, enabledRules, warnings, ctx) {
1282
+ for (const node of nodes) {
1283
+ if (node.type === 1 /* Element */) {
1284
+ for (const rule of enabledRules) {
1285
+ rule(node, warnings, ctx);
1286
+ }
1287
+ walkNodes(node.children, enabledRules, warnings, ctx);
1288
+ }
1289
+ }
1290
+ }
1291
+ function checkA11y(ast, options) {
1292
+ const disabled = new Set(options?.disable ?? []);
1293
+ const enabledRules = Object.entries(rules).filter(([id]) => !disabled.has(id)).map(([, fn]) => fn);
1294
+ const warnings = [];
1295
+ const ctx = { lastHeadingLevel: 0 };
1296
+ walkNodes(ast, enabledRules, warnings, ctx);
1297
+ return warnings;
1298
+ }
1299
+
1012
1300
  // src/index.ts
1013
1301
  function compile(source, options = {}) {
1014
1302
  const filename = options.filename ?? "anonymous.utopia";
@@ -1027,11 +1315,16 @@ function compile(source, options = {}) {
1027
1315
  scopeId = styleResult.scopeId;
1028
1316
  }
1029
1317
  let renderModule = "";
1318
+ let a11yWarnings = [];
1030
1319
  if (descriptor.template) {
1031
1320
  const templateResult = compileTemplate(descriptor.template.content, {
1032
1321
  scopeId: scopeId ?? void 0
1033
1322
  });
1034
1323
  renderModule = templateResult.code;
1324
+ if (options.a11y !== false) {
1325
+ const ast = parseTemplate(descriptor.template.content);
1326
+ a11yWarnings = checkA11y(ast, options.a11y ?? void 0);
1327
+ }
1035
1328
  }
1036
1329
  const { imports, body } = splitModuleParts(renderModule);
1037
1330
  const scriptContent = descriptor.script?.content ?? "";
@@ -1047,7 +1340,7 @@ function compile(source, options = {}) {
1047
1340
  }
1048
1341
  parts.push("export default { render: __render }");
1049
1342
  const code = parts.join("\n\n") + "\n";
1050
- return { code, css };
1343
+ return { code, css, a11y: a11yWarnings };
1051
1344
  }
1052
1345
  function splitModuleParts(moduleCode) {
1053
1346
  const idx = moduleCode.indexOf("\n\n");
@@ -1061,6 +1354,7 @@ function splitModuleParts(moduleCode) {
1061
1354
  // Annotate the CommonJS export names for ESM import in node:
1062
1355
  0 && (module.exports = {
1063
1356
  SFCParseError,
1357
+ checkA11y,
1064
1358
  compile,
1065
1359
  compileStyle,
1066
1360
  compileTemplate,
package/dist/index.d.cts CHANGED
@@ -84,7 +84,7 @@ interface Directive {
84
84
  expression: string;
85
85
  modifiers: string[];
86
86
  }
87
- type DirectiveKind = 'on' | 'bind' | 'if' | 'else' | 'for' | 'model';
87
+ type DirectiveKind = 'on' | 'bind' | 'if' | 'else' | 'else-if' | 'for' | 'model';
88
88
  /** Exported for testing — parse a template string into an AST. */
89
89
  declare function parseTemplate(source: string): TemplateNode[];
90
90
 
@@ -119,11 +119,38 @@ declare function compileStyle(options: StyleCompileOptions): StyleCompileResult;
119
119
  */
120
120
  declare function generateScopeId(filename: string): string;
121
121
 
122
+ interface A11yWarning {
123
+ /** Rule ID (e.g. 'img-alt'). */
124
+ rule: string;
125
+ /** Human-readable message. */
126
+ message: string;
127
+ /** The element tag that triggered the warning. */
128
+ tag: string;
129
+ }
130
+ interface A11yOptions {
131
+ /** Rules to disable (by rule ID). */
132
+ disable?: string[];
133
+ }
134
+ /**
135
+ * Check a parsed template AST for accessibility issues.
136
+ *
137
+ * Returns an array of warnings. An empty array means no issues found.
138
+ *
139
+ * ```ts
140
+ * const ast = parseTemplate('<img src="photo.jpg">');
141
+ * const warnings = checkA11y(ast);
142
+ * // [{ rule: 'img-alt', message: '...', tag: 'img' }]
143
+ * ```
144
+ */
145
+ declare function checkA11y(ast: TemplateNode[], options?: A11yOptions): A11yWarning[];
146
+
122
147
  interface CompileOptions {
123
148
  /** Filename for error messages and scope-id generation. */
124
149
  filename?: string;
125
150
  /** Override the scope ID for testing. */
126
151
  scopeId?: string;
152
+ /** Accessibility checking options. Pass false to disable entirely. */
153
+ a11y?: A11yOptions | false;
127
154
  }
128
155
  interface CompileResult {
129
156
  /** The compiled JavaScript module source. */
@@ -132,6 +159,8 @@ interface CompileResult {
132
159
  css: string;
133
160
  /** Source map (reserved for future use). */
134
161
  map?: unknown;
162
+ /** Accessibility warnings from template analysis. */
163
+ a11y: A11yWarning[];
135
164
  }
136
165
  /**
137
166
  * Compile a `.utopia` single-file component source string.
@@ -153,4 +182,4 @@ interface CompileResult {
153
182
  */
154
183
  declare function compile(source: string, options?: CompileOptions): CompileResult;
155
184
 
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 };
185
+ 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
@@ -84,7 +84,7 @@ interface Directive {
84
84
  expression: string;
85
85
  modifiers: string[];
86
86
  }
87
- type DirectiveKind = 'on' | 'bind' | 'if' | 'else' | 'for' | 'model';
87
+ type DirectiveKind = 'on' | 'bind' | 'if' | 'else' | 'else-if' | 'for' | 'model';
88
88
  /** Exported for testing — parse a template string into an AST. */
89
89
  declare function parseTemplate(source: string): TemplateNode[];
90
90
 
@@ -119,11 +119,38 @@ declare function compileStyle(options: StyleCompileOptions): StyleCompileResult;
119
119
  */
120
120
  declare function generateScopeId(filename: string): string;
121
121
 
122
+ interface A11yWarning {
123
+ /** Rule ID (e.g. 'img-alt'). */
124
+ rule: string;
125
+ /** Human-readable message. */
126
+ message: string;
127
+ /** The element tag that triggered the warning. */
128
+ tag: string;
129
+ }
130
+ interface A11yOptions {
131
+ /** Rules to disable (by rule ID). */
132
+ disable?: string[];
133
+ }
134
+ /**
135
+ * Check a parsed template AST for accessibility issues.
136
+ *
137
+ * Returns an array of warnings. An empty array means no issues found.
138
+ *
139
+ * ```ts
140
+ * const ast = parseTemplate('<img src="photo.jpg">');
141
+ * const warnings = checkA11y(ast);
142
+ * // [{ rule: 'img-alt', message: '...', tag: 'img' }]
143
+ * ```
144
+ */
145
+ declare function checkA11y(ast: TemplateNode[], options?: A11yOptions): A11yWarning[];
146
+
122
147
  interface CompileOptions {
123
148
  /** Filename for error messages and scope-id generation. */
124
149
  filename?: string;
125
150
  /** Override the scope ID for testing. */
126
151
  scopeId?: string;
152
+ /** Accessibility checking options. Pass false to disable entirely. */
153
+ a11y?: A11yOptions | false;
127
154
  }
128
155
  interface CompileResult {
129
156
  /** The compiled JavaScript module source. */
@@ -132,6 +159,8 @@ interface CompileResult {
132
159
  css: string;
133
160
  /** Source map (reserved for future use). */
134
161
  map?: unknown;
162
+ /** Accessibility warnings from template analysis. */
163
+ a11y: A11yWarning[];
135
164
  }
136
165
  /**
137
166
  * Compile a `.utopia` single-file component source string.
@@ -153,4 +182,4 @@ interface CompileResult {
153
182
  */
154
183
  declare function compile(source: string, options?: CompileOptions): CompileResult;
155
184
 
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 };
185
+ 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
@@ -373,7 +373,7 @@ function classifyDirective(name, value) {
373
373
  return null;
374
374
  }
375
375
  function isDirectiveKind(s) {
376
- return s === "on" || s === "bind" || s === "if" || s === "else" || s === "for" || s === "model";
376
+ return s === "on" || s === "bind" || s === "if" || s === "else" || s === "else-if" || s === "for" || s === "model";
377
377
  }
378
378
  var CodeGenerator = class {
379
379
  constructor(options) {
@@ -464,7 +464,8 @@ ${fnBody}
464
464
  }
465
465
  }
466
466
  for (const dir of node.directives) {
467
- if (dir.kind === "if" || dir.kind === "else" || dir.kind === "for") continue;
467
+ if (dir.kind === "if" || dir.kind === "else" || dir.kind === "else-if" || dir.kind === "for")
468
+ continue;
468
469
  this.genDirective(elVar, dir, scope);
469
470
  }
470
471
  this.genChildren(elVar, node.children, scope);
@@ -550,7 +551,7 @@ ${fnBody}
550
551
  this.emit(`addEventListener(${elVar}, 'input', (e) => ${signalRef}.set(e.target.value))`);
551
552
  }
552
553
  // ---- Structural: u-if ---------------------------------------------------
553
- genIf(node, dir, scope, elseNode) {
554
+ genIf(node, dir, scope, elseIfChain, elseNode) {
554
555
  this.helpers.add("createIf");
555
556
  const anchorVar = this.freshVar();
556
557
  this.helpers.add("createComment");
@@ -558,7 +559,7 @@ ${fnBody}
558
559
  const condition = this.resolveExpression(dir.expression, scope);
559
560
  const strippedNode = {
560
561
  ...node,
561
- directives: node.directives.filter((d) => d.kind !== "if")
562
+ directives: node.directives.filter((d) => d.kind !== "if" && d.kind !== "else-if")
562
563
  };
563
564
  const trueFnVar = this.freshVar();
564
565
  const savedCode = this.code;
@@ -573,7 +574,29 @@ ${fnBody}
573
574
  this.emit(` return ${innerVar}`);
574
575
  this.emit(`}`);
575
576
  let elseArg = "";
576
- if (elseNode) {
577
+ if (elseIfChain && elseIfChain.length > 0) {
578
+ const firstElseIf = elseIfChain[0];
579
+ const remainingElseIfs = elseIfChain.slice(1);
580
+ const falseFnVar = this.freshVar();
581
+ const savedCode2 = this.code;
582
+ this.code = [];
583
+ const nestedAnchor = this.genIf(
584
+ firstElseIf.node,
585
+ firstElseIf.dir,
586
+ scope,
587
+ remainingElseIfs.length > 0 ? remainingElseIfs : void 0,
588
+ elseNode
589
+ );
590
+ const nestedLines = [...this.code];
591
+ this.code = savedCode2;
592
+ this.emit(`const ${falseFnVar} = () => {`);
593
+ for (const line of nestedLines) {
594
+ this.emit(` ${line}`);
595
+ }
596
+ this.emit(` return ${nestedAnchor}`);
597
+ this.emit(`}`);
598
+ elseArg = `, ${falseFnVar}`;
599
+ } else if (elseNode) {
577
600
  const falseFnVar = this.freshVar();
578
601
  const strippedElse = {
579
602
  ...elseNode,
@@ -639,7 +662,7 @@ ${fnBody}
639
662
  this.emitOrDefer(`createFor(${anchorVar}, () => ${listExpr}, ${renderFnVar}${keyArg})`);
640
663
  return anchorVar;
641
664
  }
642
- // ---- Children processing (handles u-if / u-else pairing) ----------------
665
+ // ---- Children processing (handles u-if / u-else-if / u-else pairing) ----
643
666
  genChildren(parentVar, children, scope) {
644
667
  this.deferredCallsStack.push([]);
645
668
  let i = 0;
@@ -648,24 +671,39 @@ ${fnBody}
648
671
  if (child.type === 1 /* Element */) {
649
672
  const ifDir = child.directives.find((d) => d.kind === "if");
650
673
  if (ifDir) {
674
+ const elseIfChain = [];
651
675
  let elseNode;
652
676
  let skipTo = i + 1;
653
677
  for (let j = i + 1; j < children.length; j++) {
654
678
  const next = children[j];
655
679
  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;
680
+ if (next.type === 1 /* Element */) {
681
+ const elseIfDir = next.directives.find((d) => d.kind === "else-if");
682
+ if (elseIfDir) {
683
+ elseIfChain.push({ node: next, dir: elseIfDir });
684
+ skipTo = j + 1;
685
+ continue;
686
+ }
687
+ if (next.directives.some((d) => d.kind === "else")) {
688
+ elseNode = next;
689
+ skipTo = j + 1;
690
+ }
659
691
  }
660
692
  break;
661
693
  }
662
- const childVar2 = this.genIf(child, ifDir, scope, elseNode);
694
+ const childVar2 = this.genIf(
695
+ child,
696
+ ifDir,
697
+ scope,
698
+ elseIfChain.length > 0 ? elseIfChain : void 0,
699
+ elseNode
700
+ );
663
701
  this.helpers.add("appendChild");
664
702
  this.emit(`appendChild(${parentVar}, ${childVar2})`);
665
703
  i = skipTo;
666
704
  continue;
667
705
  }
668
- if (child.directives.some((d) => d.kind === "else")) {
706
+ if (child.directives.some((d) => d.kind === "else" || d.kind === "else-if")) {
669
707
  i++;
670
708
  continue;
671
709
  }
@@ -977,6 +1015,255 @@ function scopeSingleSelector(selector, scopeId) {
977
1015
  return `${selector}${attr}`;
978
1016
  }
979
1017
 
1018
+ // src/a11y.ts
1019
+ var VALID_ARIA_ROLES = /* @__PURE__ */ new Set([
1020
+ "alert",
1021
+ "alertdialog",
1022
+ "application",
1023
+ "article",
1024
+ "banner",
1025
+ "button",
1026
+ "cell",
1027
+ "checkbox",
1028
+ "columnheader",
1029
+ "combobox",
1030
+ "complementary",
1031
+ "contentinfo",
1032
+ "definition",
1033
+ "dialog",
1034
+ "directory",
1035
+ "document",
1036
+ "feed",
1037
+ "figure",
1038
+ "form",
1039
+ "grid",
1040
+ "gridcell",
1041
+ "group",
1042
+ "heading",
1043
+ "img",
1044
+ "link",
1045
+ "list",
1046
+ "listbox",
1047
+ "listitem",
1048
+ "log",
1049
+ "main",
1050
+ "marquee",
1051
+ "math",
1052
+ "menu",
1053
+ "menubar",
1054
+ "menuitem",
1055
+ "menuitemcheckbox",
1056
+ "menuitemradio",
1057
+ "meter",
1058
+ "navigation",
1059
+ "none",
1060
+ "note",
1061
+ "option",
1062
+ "presentation",
1063
+ "progressbar",
1064
+ "radio",
1065
+ "radiogroup",
1066
+ "region",
1067
+ "row",
1068
+ "rowgroup",
1069
+ "rowheader",
1070
+ "scrollbar",
1071
+ "search",
1072
+ "searchbox",
1073
+ "separator",
1074
+ "slider",
1075
+ "spinbutton",
1076
+ "status",
1077
+ "switch",
1078
+ "tab",
1079
+ "table",
1080
+ "tablist",
1081
+ "tabpanel",
1082
+ "term",
1083
+ "textbox",
1084
+ "timer",
1085
+ "toolbar",
1086
+ "tooltip",
1087
+ "tree",
1088
+ "treegrid",
1089
+ "treeitem"
1090
+ ]);
1091
+ var INTERACTIVE_ELEMENTS = /* @__PURE__ */ new Set([
1092
+ "a",
1093
+ "button",
1094
+ "input",
1095
+ "select",
1096
+ "textarea",
1097
+ "details",
1098
+ "summary"
1099
+ ]);
1100
+ function getAttr(node, name) {
1101
+ return node.attrs.find((a) => a.name === name);
1102
+ }
1103
+ function hasAttr(node, name) {
1104
+ return node.attrs.some((a) => a.name === name);
1105
+ }
1106
+ function hasDirective(node, kind, arg) {
1107
+ return node.directives.some((d) => d.kind === kind && (arg === void 0 || d.arg === arg));
1108
+ }
1109
+ function hasBoundAttr(node, name) {
1110
+ return node.directives.some((d) => d.kind === "bind" && d.arg === name);
1111
+ }
1112
+ function hasTextContent(node) {
1113
+ return node.children.some(
1114
+ (c) => c.type === 2 /* Text */ && c.content.trim() !== "" || c.type === 3 /* Interpolation */
1115
+ );
1116
+ }
1117
+ var rules = {
1118
+ "img-alt"(node, warnings) {
1119
+ if (node.tag !== "img") return;
1120
+ if (hasAttr(node, "alt") || hasBoundAttr(node, "alt")) return;
1121
+ if (hasAttr(node, "aria-label") || hasAttr(node, "aria-labelledby")) return;
1122
+ const role = getAttr(node, "role");
1123
+ if (role && (role.value === "presentation" || role.value === "none")) return;
1124
+ warnings.push({
1125
+ rule: "img-alt",
1126
+ message: "<img> element must have an alt attribute",
1127
+ tag: node.tag
1128
+ });
1129
+ },
1130
+ "click-keyboard"(node, warnings) {
1131
+ const hasClick = hasDirective(node, "on", "click");
1132
+ if (!hasClick) return;
1133
+ if (INTERACTIVE_ELEMENTS.has(node.tag)) return;
1134
+ const hasKeyboard = hasDirective(node, "on", "keydown") || hasDirective(node, "on", "keyup") || hasDirective(node, "on", "keypress");
1135
+ const role = getAttr(node, "role");
1136
+ const hasInteractiveRole = role && (role.value === "button" || role.value === "link");
1137
+ if (!hasKeyboard && !hasInteractiveRole) {
1138
+ warnings.push({
1139
+ rule: "click-keyboard",
1140
+ message: `<${node.tag}> with @click handler should also have a keyboard event handler or role="button"`,
1141
+ tag: node.tag
1142
+ });
1143
+ }
1144
+ if (!hasAttr(node, "tabindex") && !hasBoundAttr(node, "tabindex")) {
1145
+ warnings.push({
1146
+ rule: "click-keyboard",
1147
+ message: `<${node.tag}> with @click handler should have tabindex="0" for keyboard accessibility`,
1148
+ tag: node.tag
1149
+ });
1150
+ }
1151
+ },
1152
+ "anchor-content"(node, warnings) {
1153
+ if (node.tag !== "a") return;
1154
+ if (hasTextContent(node)) return;
1155
+ if (hasAttr(node, "aria-label") || hasAttr(node, "aria-labelledby")) return;
1156
+ const hasChildElements = node.children.some((c) => c.type === 1 /* Element */);
1157
+ if (hasChildElements) return;
1158
+ warnings.push({
1159
+ rule: "anchor-content",
1160
+ message: "<a> element must have content, aria-label, or aria-labelledby",
1161
+ tag: node.tag
1162
+ });
1163
+ },
1164
+ "form-label"(node, warnings) {
1165
+ const formElements = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
1166
+ if (!formElements.has(node.tag)) return;
1167
+ const type = getAttr(node, "type");
1168
+ if (type && type.value === "hidden") return;
1169
+ if (hasAttr(node, "aria-label") || hasAttr(node, "aria-labelledby")) return;
1170
+ if (hasAttr(node, "id")) return;
1171
+ if (hasAttr(node, "title")) return;
1172
+ warnings.push({
1173
+ rule: "form-label",
1174
+ message: `<${node.tag}> should have an id (with matching <label>), aria-label, or aria-labelledby`,
1175
+ tag: node.tag
1176
+ });
1177
+ },
1178
+ "no-distracting"(node, warnings) {
1179
+ if (node.tag === "marquee" || node.tag === "blink") {
1180
+ warnings.push({
1181
+ rule: "no-distracting",
1182
+ message: `<${node.tag}> is distracting and inaccessible \u2014 do not use`,
1183
+ tag: node.tag
1184
+ });
1185
+ }
1186
+ },
1187
+ "heading-order"(node, warnings, ctx) {
1188
+ const match = node.tag.match(/^h([1-6])$/);
1189
+ if (!match) return;
1190
+ const level = parseInt(match[1], 10);
1191
+ if (ctx.lastHeadingLevel > 0 && level > ctx.lastHeadingLevel + 1) {
1192
+ warnings.push({
1193
+ rule: "heading-order",
1194
+ message: `Heading level <${node.tag}> skips from <h${ctx.lastHeadingLevel}> \u2014 headings should not skip levels`,
1195
+ tag: node.tag
1196
+ });
1197
+ }
1198
+ ctx.lastHeadingLevel = level;
1199
+ },
1200
+ "aria-role"(node, warnings) {
1201
+ const role = getAttr(node, "role");
1202
+ if (!role || !role.value) return;
1203
+ if (!VALID_ARIA_ROLES.has(role.value)) {
1204
+ warnings.push({
1205
+ rule: "aria-role",
1206
+ message: `Invalid ARIA role "${role.value}" on <${node.tag}>`,
1207
+ tag: node.tag
1208
+ });
1209
+ }
1210
+ },
1211
+ "no-positive-tabindex"(node, warnings) {
1212
+ const tabindex = getAttr(node, "tabindex");
1213
+ if (!tabindex || !tabindex.value) return;
1214
+ const val = parseInt(tabindex.value, 10);
1215
+ if (!isNaN(val) && val > 0) {
1216
+ warnings.push({
1217
+ rule: "no-positive-tabindex",
1218
+ message: `Avoid positive tabindex="${tabindex.value}" \u2014 it disrupts natural tab order`,
1219
+ tag: node.tag
1220
+ });
1221
+ }
1222
+ },
1223
+ "media-captions"(node, warnings) {
1224
+ if (node.tag !== "video" && node.tag !== "audio") return;
1225
+ const hasTrack = node.children.some(
1226
+ (c) => c.type === 1 /* Element */ && c.tag === "track"
1227
+ );
1228
+ if (hasTrack) return;
1229
+ if (hasAttr(node, "aria-label") || hasAttr(node, "aria-labelledby")) return;
1230
+ warnings.push({
1231
+ rule: "media-captions",
1232
+ message: `<${node.tag}> should have a <track> element for captions`,
1233
+ tag: node.tag
1234
+ });
1235
+ },
1236
+ "anchor-valid"(node, warnings) {
1237
+ if (node.tag !== "a") return;
1238
+ if (hasAttr(node, "href") || hasBoundAttr(node, "href")) return;
1239
+ const role = getAttr(node, "role");
1240
+ if (role && role.value === "button") return;
1241
+ warnings.push({
1242
+ rule: "anchor-valid",
1243
+ message: "<a> element should have an href attribute",
1244
+ tag: node.tag
1245
+ });
1246
+ }
1247
+ };
1248
+ function walkNodes(nodes, enabledRules, warnings, ctx) {
1249
+ for (const node of nodes) {
1250
+ if (node.type === 1 /* Element */) {
1251
+ for (const rule of enabledRules) {
1252
+ rule(node, warnings, ctx);
1253
+ }
1254
+ walkNodes(node.children, enabledRules, warnings, ctx);
1255
+ }
1256
+ }
1257
+ }
1258
+ function checkA11y(ast, options) {
1259
+ const disabled = new Set(options?.disable ?? []);
1260
+ const enabledRules = Object.entries(rules).filter(([id]) => !disabled.has(id)).map(([, fn]) => fn);
1261
+ const warnings = [];
1262
+ const ctx = { lastHeadingLevel: 0 };
1263
+ walkNodes(ast, enabledRules, warnings, ctx);
1264
+ return warnings;
1265
+ }
1266
+
980
1267
  // src/index.ts
981
1268
  function compile(source, options = {}) {
982
1269
  const filename = options.filename ?? "anonymous.utopia";
@@ -995,11 +1282,16 @@ function compile(source, options = {}) {
995
1282
  scopeId = styleResult.scopeId;
996
1283
  }
997
1284
  let renderModule = "";
1285
+ let a11yWarnings = [];
998
1286
  if (descriptor.template) {
999
1287
  const templateResult = compileTemplate(descriptor.template.content, {
1000
1288
  scopeId: scopeId ?? void 0
1001
1289
  });
1002
1290
  renderModule = templateResult.code;
1291
+ if (options.a11y !== false) {
1292
+ const ast = parseTemplate(descriptor.template.content);
1293
+ a11yWarnings = checkA11y(ast, options.a11y ?? void 0);
1294
+ }
1003
1295
  }
1004
1296
  const { imports, body } = splitModuleParts(renderModule);
1005
1297
  const scriptContent = descriptor.script?.content ?? "";
@@ -1015,7 +1307,7 @@ function compile(source, options = {}) {
1015
1307
  }
1016
1308
  parts.push("export default { render: __render }");
1017
1309
  const code = parts.join("\n\n") + "\n";
1018
- return { code, css };
1310
+ return { code, css, a11y: a11yWarnings };
1019
1311
  }
1020
1312
  function splitModuleParts(moduleCode) {
1021
1313
  const idx = moduleCode.indexOf("\n\n");
@@ -1028,6 +1320,7 @@ function splitModuleParts(moduleCode) {
1028
1320
  }
1029
1321
  export {
1030
1322
  SFCParseError,
1323
+ checkA11y,
1031
1324
  compile,
1032
1325
  compileStyle,
1033
1326
  compileTemplate,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matthesketh/utopia-compiler",
3
- "version": "0.3.0",
3
+ "version": "0.4.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.0"
42
+ "@matthesketh/utopia-core": "0.4.0"
43
43
  },
44
44
  "scripts": {
45
45
  "build": "tsup src/index.ts --format esm,cjs --dts",