@matthesketh/utopia-compiler 0.3.1 → 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 +306 -12
- package/dist/index.d.cts +31 -2
- package/dist/index.d.ts +31 -2
- package/dist/index.js +305 -12
- package/package.json +2 -2
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")
|
|
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 (
|
|
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 */
|
|
689
|
-
|
|
690
|
-
|
|
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(
|
|
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")
|
|
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 (
|
|
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 */
|
|
657
|
-
|
|
658
|
-
|
|
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(
|
|
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
|
+
"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.
|
|
42
|
+
"@matthesketh/utopia-core": "0.4.0"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
45
|
"build": "tsup src/index.ts --format esm,cjs --dts",
|