beck_macro/vocabulary.rs
1//! What HTML is: the element, attribute and event names `ui:` will write.
2//!
3//! # Why this is a table and not a check
4//!
5//! `ui:` turned any `name=value` into an attribute and any `on_x=` into `data-b-x`, knowing
6//! nothing about either. A misspelling was not a compile error, not a lint and not visible in a
7//! snapshot review — `span(on_mouseenter=…)` shipped a dead attribute to a browser that listens
8//! for five events and passed every gate, and `cls="done"` — the spelling
9//! [`docs/01`](../../../../../docs/01-vision-and-premise.md) §1.3's own sketch uses — silently lost
10//! a page its styling. [`docs/104`](../../../../../docs/104-styling-and-the-component-library.md)
11//! §104.8's Wall 2 is the measurement.
12//!
13//! It lives here, as data, rather than in the `ui` module's expander, for a scheduling reason:
14//! `ui:` is a compiler-provided special case standing in for a user-written macro, and typed
15//! macros retire it ([`docs/10`](../../../../../docs/10-decisions.md) D22). A vocabulary buried in
16//! today's expander would be written a second time when that happens, and the second copy is the
17//! one that would drift. [`docs/12`](../../../../../docs/12-standards-and-conformance.md) §12.4's
18//! three accessibility checks — alt text, accessible name, input label — are scheduled over this
19//! same tree and read [`ELEMENTS`] rather than a list of their own.
20//!
21//! # What is checked, and what is not
22//!
23//! **Events are closed.** [`EVENTS`] is exactly what `beck-rt/client/beck-patch.js` interprets, and
24//! `ui.rs`'s `the_event_vocabulary_is_what_the_client_listens_for` reads the client's source to say
25//! so. An event the client does not handle is a `data-b-*` attribute wired to nothing, so there is
26//! no such thing as a custom one.
27//!
28//! **Attributes are closed with two open prefixes.** `data-` and `aria-` are HTML's own escape
29//! hatches and are admitted by rule rather than by list — which is also the answer to "what if I
30//! need an attribute that is genuinely mine": HTML already decided, and it is `data-`.
31//!
32//! **Elements are not refused**, and the reason is a limit of today's surface rather than a
33//! judgement: inside a `ui:` block, a lowercase call whose arguments are all keyword arguments is
34//! indistinguishable from an element, so refusing an unknown one would refuse a user's own helper
35//! function called by name. [`ELEMENTS`] is therefore a table something else reads — §12.4's
36//! checks, and whatever `ui:` becomes when it is a user-written typed macro, which is where a
37//! `Html`-returning helper stops looking like a `<div>`.
38
39/// The events the client interprets, and what each one is.
40///
41/// Five, and the list is not a design — it is a reading of `beck-patch.js`. `enter` is the odd one:
42/// the DOM event is `keydown` filtered to the Enter key, because "submit on Enter" is what an
43/// `input` wants and a raw keydown is not something a declarative attribute can usefully carry.
44///
45/// The W3C's ARIA Authoring Practices keyboard tables want arrows, `Home`, `End`, `Escape`,
46/// `Space` and typeahead, none of which are here. That gap is the client's to close
47/// ([`docs/104`](../../../../../docs/104-styling-and-the-component-library.md) §104.8), and this
48/// table is what makes it a *refusal* rather than an attribute that does nothing.
49pub const EVENTS: &[(&str, &str)] = &[
50 ("click", "a click on this element"),
51 ("enter", "the Enter key, in a text input"),
52 ("submit", "a form submission"),
53 ("input", "each edit to a control's value"),
54 ("change", "a committed change to a control's value"),
55];
56
57/// Whether `name` is an event `on_` may carry.
58pub fn is_event(name: &str) -> bool {
59 EVENTS.iter().any(|(e, _)| *e == name)
60}
61
62/// Whether `name` is an attribute an element may be given.
63///
64/// `data-` and `aria-` are admitted by prefix: the first is HTML's own extension point and the
65/// second is a namespace with hundreds of members whose spelling is checkable but whose *values*
66/// are where the mistakes are, which is §12.4's job rather than this one.
67pub fn is_attribute(name: &str) -> bool {
68 name.starts_with("data-") || name.starts_with("aria-") || ATTRIBUTES.contains(&name)
69}
70
71/// What an element needs before somebody who cannot see it can use it.
72///
73/// [`docs/12`](../../../../../docs/12-standards-and-conformance.md) §12.4's first three checks, as
74/// a table rather than as three `if`s in the expander — for [`ELEMENTS`]'s own reason. A check that
75/// matched the literal `"imag"` would never fire and no test over correct programs could notice;
76/// as a row it is held to [`ELEMENTS`] by
77/// `every_element_a_check_is_about_is_an_element_this_vocabulary_knows`, which is a gate that goes
78/// red on the typo.
79pub const NAMING: &[(&str, Naming)] = &[
80 ("img", Naming::Alt),
81 ("button", Naming::TextOrLabel),
82 ("input", Naming::Label),
83 ("select", Naming::Label),
84 ("textarea", Naming::Label),
85];
86
87/// How one element is given a name a screen reader can announce.
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub enum Naming {
90 /// `alt`, which HTML requires of every image and whose empty value means "decorative".
91 Alt,
92 /// Its own text, or a label attribute when it has none — the icon-button case.
93 TextOrLabel,
94 /// A label attribute, or an `id` a `label(for=…)` elsewhere points at.
95 Label,
96}
97
98/// What this element needs, if it is one of the ones that need anything.
99pub fn naming(tag: &str) -> Option<Naming> {
100 NAMING.iter().find(|(e, _)| *e == tag).map(|(_, n)| *n)
101}
102
103/// The attributes that give an element an accessible name, in the order a fix-it should offer them.
104///
105/// `title` is last and is included rather than recommended: it is a real naming mechanism and it is
106/// also a tooltip, so a program that has one is not refused, and nothing here suggests reaching for
107/// it first.
108pub const LABELLING: &[&str] = &["aria-label", "aria-labelledby", "title"];
109
110/// Whether `name` is an element this vocabulary knows.
111///
112/// Nothing in the `ui` module refuses on this today — the module documentation says why — but
113/// §12.4's accessibility checks are written against it.
114pub fn is_element(name: &str) -> bool {
115 ELEMENTS.contains(&name)
116}
117
118/// Spellings that are not typos, and what each one means here.
119///
120/// A distance search cannot find these. `cls` is **one** edit from `cols` and two from `class`, so
121/// the nearest name is the wrong one — and `cls=` is what
122/// [`docs/01`](../../../../../docs/01-vision-and-premise.md) §1.3's sketch writes, faithfully,
123/// because the original conversation did. It is therefore the first thing a reader arriving from
124/// that page will type, and the first suggestion they should get.
125pub const ALIASES: &[(&str, &str)] = &[
126 ("cls", "class"),
127 ("classname", "class"),
128 ("class-name", "class"),
129 ("htmlfor", "for"),
130];
131
132/// Spellings of an event that are not typos either.
133///
134/// `keydown` is the case worth having: the client *does* listen for it — filtered to the Enter key,
135/// and registered under the name `enter`, because "submit on Enter" is what a text input wants and
136/// a raw keydown is not something a declarative attribute can usefully carry. So somebody writing
137/// `on_keydown` has guessed the DOM's name for the thing that exists, which no distance search will
138/// find: `keydown` is seven edits from `enter`.
139pub const EVENT_ALIASES: &[(&str, &str)] = &[("keydown", "enter"), ("keypress", "enter")];
140
141/// What an event name that does not exist most likely meant, or nothing.
142///
143/// Nothing is the common answer and it is the right one: `mouseenter`, `focus` and `blur` are
144/// events the client does not have, not misspellings of events it does, and a suggestion there
145/// would send a reader to rewrite a handler that was never going to work.
146pub fn event_suggestion(written: &str) -> Option<&'static str> {
147 if let Some((_, meant)) = EVENT_ALIASES.iter().find(|(from, _)| *from == written) {
148 return Some(meant);
149 }
150 nearest(written, EVENTS.iter().map(|(e, _)| *e))
151}
152
153/// What an attribute name that does not exist most likely meant.
154///
155/// Three rules in order, and the first is the one that matters most in a language whose keyword
156/// arguments are snake_case. `ui:` turns `_` into `-`, so a program that writes `max_length=`
157/// reaches HTML as `max-length` — and the attribute is `maxlength`, with no separator at all.
158/// **Squashing the hyphens out and looking again** catches every attribute of that shape at once —
159/// `maxlength`, `tabindex`, `colspan`, `autofocus`, `novalidate`, `formaction`, `playsinline` — and
160/// it is a rule rather than the forty-line table the same coverage would take by hand.
161pub fn suggestion(written: &str) -> Option<&'static str> {
162 let squashed: String = written.chars().filter(|c| *c != '-').collect();
163 if let Some(found) = ATTRIBUTES.iter().find(|a| **a == squashed) {
164 return Some(found);
165 }
166 if let Some((_, meant)) = ALIASES.iter().find(|(from, _)| *from == written) {
167 return Some(meant);
168 }
169 nearest(written, ATTRIBUTES.iter().copied())
170}
171
172/// The known name closest to `typo`, when one is close enough to be worth suggesting.
173///
174/// Ordinary Levenshtein distance with a threshold that scales with the word. It is the last of
175/// [`suggestion`]'s three rules rather than the only one, because the mistakes this vocabulary
176/// exists to catch are systematic rather than random.
177pub fn nearest<'a>(typo: &str, among: impl Iterator<Item = &'a str>) -> Option<&'a str> {
178 let mut best: Option<(usize, &str)> = None;
179 for candidate in among {
180 let d = distance(typo, candidate);
181 let allowed = if candidate.len().max(typo.len()) <= 4 {
182 2
183 } else {
184 3
185 };
186 if d <= allowed && best.is_none_or(|(bd, _)| d < bd) {
187 best = Some((d, candidate));
188 }
189 }
190 best.map(|(_, name)| name)
191}
192
193/// Levenshtein distance, two rows at a time.
194fn distance(a: &str, b: &str) -> usize {
195 let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
196 let mut prev: Vec<usize> = (0..=b.len()).collect();
197 let mut row = vec![0usize; b.len() + 1];
198 for (i, ca) in a.iter().enumerate() {
199 row[0] = i + 1;
200 for (j, cb) in b.iter().enumerate() {
201 let cost = usize::from(ca != cb);
202 row[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(row[j] + 1);
203 }
204 std::mem::swap(&mut prev, &mut row);
205 }
206 prev[b.len()]
207}
208
209/// Every element name this vocabulary knows: HTML's, and the SVG subset a chart is drawn with.
210///
211/// SVG names that carry a capital — `linearGradient`, `foreignObject`, `clipPath` — are absent
212/// because they cannot be *written* today: the `ui` module takes an element head to be all lowercase,
213/// so a camel-cased tag is read as a function call. Listing what cannot be expressed would make
214/// this table a wish.
215pub const ELEMENTS: &[&str] = &[
216 // Document and sections
217 "html",
218 "head",
219 "body",
220 "title",
221 "base",
222 "link",
223 "meta",
224 "style",
225 "script",
226 "noscript",
227 "template",
228 "slot",
229 "main",
230 "header",
231 "footer",
232 "nav",
233 "article",
234 "aside",
235 "section",
236 "search",
237 "h1",
238 "h2",
239 "h3",
240 "h4",
241 "h5",
242 "h6",
243 "hgroup",
244 "address",
245 // Grouping
246 "p",
247 "hr",
248 "pre",
249 "blockquote",
250 "ol",
251 "ul",
252 "menu",
253 "li",
254 "dl",
255 "dt",
256 "dd",
257 "figure",
258 "figcaption",
259 "div",
260 // Text
261 "a",
262 "em",
263 "strong",
264 "small",
265 "s",
266 "cite",
267 "q",
268 "dfn",
269 "abbr",
270 "ruby",
271 "rt",
272 "rp",
273 "data",
274 "time",
275 "code",
276 "var",
277 "samp",
278 "kbd",
279 "sub",
280 "sup",
281 "i",
282 "b",
283 "u",
284 "mark",
285 "bdi",
286 "bdo",
287 "span",
288 "br",
289 "wbr",
290 "ins",
291 "del",
292 // Embedded
293 "picture",
294 "source",
295 "img",
296 "iframe",
297 "embed",
298 "object",
299 "video",
300 "audio",
301 "track",
302 "map",
303 "area",
304 "canvas",
305 // Tables
306 "table",
307 "caption",
308 "colgroup",
309 "col",
310 "tbody",
311 "thead",
312 "tfoot",
313 "tr",
314 "td",
315 "th",
316 // Forms
317 "form",
318 "label",
319 "input",
320 "button",
321 "select",
322 "datalist",
323 "optgroup",
324 "option",
325 "textarea",
326 "output",
327 "progress",
328 "meter",
329 "fieldset",
330 "legend",
331 // Interactive
332 "details",
333 "summary",
334 "dialog",
335 // SVG, lowercase only — see this table's own note
336 "svg",
337 "g",
338 "defs",
339 "symbol",
340 "use",
341 "path",
342 "rect",
343 "circle",
344 "ellipse",
345 "line",
346 "polyline",
347 "polygon",
348 "text",
349 "tspan",
350 "image",
351 "marker",
352 "mask",
353 "pattern",
354 "stop",
355 "desc",
356 "filter",
357];
358
359/// Every attribute name this vocabulary knows, beyond the `data-` and `aria-` prefixes.
360///
361/// Flat rather than per element. A per-element table is what an HTML validator has and it is a
362/// larger claim than this needs to make: what [`docs/104`](../../../../../docs/104-styling-and-the-component-library.md)
363/// §104.8 measured is *misspellings* — `cls` for `class` — and a name nothing in HTML has is a
364/// misspelling whichever element it lands on. Refusing `colspan` on a `<div>` is a different
365/// feature, and one whose false refusals would cost more than it catches.
366pub const ATTRIBUTES: &[&str] = &[
367 // Global
368 "accesskey",
369 "autocapitalize",
370 "autocorrect",
371 "autofocus",
372 "class",
373 "contenteditable",
374 "dir",
375 "draggable",
376 "enterkeyhint",
377 "hidden",
378 "id",
379 "inert",
380 "inputmode",
381 "is",
382 "itemid",
383 "itemprop",
384 "itemref",
385 "itemscope",
386 "itemtype",
387 "lang",
388 "nonce",
389 "popover",
390 "role",
391 "slot",
392 "spellcheck",
393 "style",
394 "tabindex",
395 "title",
396 "translate",
397 "writingsuggestions",
398 // Links and media
399 "href",
400 "hreflang",
401 "target",
402 "download",
403 "ping",
404 "rel",
405 "referrerpolicy",
406 "src",
407 "srcset",
408 "sizes",
409 "alt",
410 "loading",
411 "decoding",
412 "fetchpriority",
413 "crossorigin",
414 "usemap",
415 "ismap",
416 "width",
417 "height",
418 "poster",
419 "preload",
420 "autoplay",
421 "loop",
422 "muted",
423 "controls",
424 "playsinline",
425 "kind",
426 "srclang",
427 "default",
428 "media",
429 "as",
430 "integrity",
431 "defer",
432 "async",
433 "nomodule",
434 "charset",
435 "content",
436 "http-equiv",
437 "allow",
438 "allowfullscreen",
439 "sandbox",
440 "srcdoc",
441 "coords",
442 "shape",
443 "type",
444 "cite",
445 "datetime",
446 "open",
447 "start",
448 "reversed",
449 "value",
450 "label",
451 "span",
452 // Forms
453 "accept",
454 "accept-charset",
455 "action",
456 "autocomplete",
457 "capture",
458 "checked",
459 "cols",
460 "dirname",
461 "disabled",
462 "enctype",
463 "for",
464 "form",
465 "formaction",
466 "formenctype",
467 "formmethod",
468 "formnovalidate",
469 "formtarget",
470 "list",
471 "max",
472 "maxlength",
473 "method",
474 "min",
475 "minlength",
476 "multiple",
477 "name",
478 "novalidate",
479 "pattern",
480 "placeholder",
481 "readonly",
482 "required",
483 "rows",
484 "selected",
485 "size",
486 "step",
487 "wrap",
488 "high",
489 "low",
490 "optimum",
491 // Tables
492 "colspan",
493 "rowspan",
494 "headers",
495 "scope",
496 "abbr",
497 // Invokers, which are what §104.9 says replace a handler outright
498 "command",
499 "commandfor",
500 "popovertarget",
501 "popovertargetaction",
502 // SVG: geometry, painting and text
503 "viewBox",
504 "preserveAspectRatio",
505 "xmlns",
506 "x",
507 "y",
508 "x1",
509 "y1",
510 "x2",
511 "y2",
512 "cx",
513 "cy",
514 "r",
515 "rx",
516 "ry",
517 "d",
518 "points",
519 "pathLength",
520 "dx",
521 "dy",
522 "rotate",
523 "transform",
524 "fill",
525 "fill-opacity",
526 "fill-rule",
527 "stroke",
528 "stroke-width",
529 "stroke-linecap",
530 "stroke-linejoin",
531 "stroke-dasharray",
532 "stroke-dashoffset",
533 "stroke-opacity",
534 "opacity",
535 "clip-path",
536 "clip-rule",
537 "marker-start",
538 "marker-mid",
539 "marker-end",
540 "text-anchor",
541 "dominant-baseline",
542 "font-family",
543 "font-size",
544 "font-weight",
545 "font-style",
546 "letter-spacing",
547 "offset",
548 "stop-color",
549 "stop-opacity",
550 "gradientUnits",
551 "gradientTransform",
552 "spreadMethod",
553 "patternUnits",
554 "maskUnits",
555 "markerWidth",
556 "markerHeight",
557 "refX",
558 "refY",
559 "orient",
560 "vector-effect",
561 "shape-rendering",
562 "paint-order",
563];
564
565#[cfg(test)]
566mod tests {
567
568 /// Every element a §12.4 check is about is an element this vocabulary knows.
569 ///
570 /// The gate that makes [`NAMING`] a table worth having rather than three tag names moved. A
571 /// check written against `"imag"` would never fire, and nothing that compiles correct programs
572 /// could notice — which is `docs/82` §82.10's pattern exactly: the failure to guard against is
573 /// a check that *cannot* fail.
574 #[test]
575 fn every_element_a_check_is_about_is_an_element_this_vocabulary_knows() {
576 for (element, _) in super::NAMING {
577 assert!(
578 super::is_element(element),
579 "`{element}` is checked for an accessible name and is not an element"
580 );
581 }
582 }
583
584 /// And every attribute those checks accept as a name is one an element may carry.
585 #[test]
586 fn every_labelling_attribute_is_an_attribute() {
587 for name in super::LABELLING {
588 assert!(super::is_attribute(name), "`{name}` is not an attribute");
589 }
590 }
591 use super::*;
592
593 #[test]
594 fn the_tables_are_sorted_within_their_groups_and_hold_no_duplicate() {
595 for (what, table) in [("elements", ELEMENTS), ("attributes", ATTRIBUTES)] {
596 let mut seen: Vec<&str> = table.to_vec();
597 seen.sort_unstable();
598 let count = seen.len();
599 seen.dedup();
600 assert_eq!(seen.len(), count, "{what} holds a name twice");
601 }
602 let mut events: Vec<&str> = EVENTS.iter().map(|(e, _)| *e).collect();
603 events.sort_unstable();
604 events.dedup();
605 assert_eq!(events.len(), EVENTS.len());
606 }
607
608 #[test]
609 fn the_prefixes_are_open_and_everything_else_is_closed() {
610 assert!(is_attribute("data-anything-at-all"));
611 assert!(is_attribute("aria-label"));
612 assert!(is_attribute("class"));
613 assert!(!is_attribute("cls"));
614 assert!(
615 !is_attribute("data"),
616 "`data` alone is the element, not a prefix"
617 );
618 // A `data` *attribute* exists on `<object>`, and is in the table for that reason.
619 assert!(ATTRIBUTES.contains(&"content"));
620 }
621
622 #[test]
623 fn what_a_name_that_does_not_exist_most_likely_meant() {
624 // The rule, which is the one that earns its place: a snake_case guess at an attribute
625 // HTML spells with no separator. `ui:` turns `_` into `-` before this ever sees it.
626 for (written, meant) in [
627 ("max-length", "maxlength"),
628 ("tab-index", "tabindex"),
629 ("col-span", "colspan"),
630 ("auto-focus", "autofocus"),
631 ("no-validate", "novalidate"),
632 ("plays-inline", "playsinline"),
633 ] {
634 assert_eq!(suggestion(written), Some(meant), "{written}");
635 }
636 // The alias, which a distance search gets wrong: `cls` is one edit from `cols`.
637 assert_eq!(nearest("cls", ATTRIBUTES.iter().copied()), Some("cols"));
638 assert_eq!(
639 suggestion("cls"),
640 Some("class"),
641 "the spelling docs/01 §1.3's sketch uses is the case this exists for"
642 );
643 // And an ordinary typo, which the distance search is still there for.
644 assert_eq!(suggestion("hight"), Some("height"));
645 for absent in ["mouseenter", "focus", "blur"] {
646 assert_eq!(
647 event_suggestion(absent),
648 None,
649 "an event the client does not have is not a typo for one it does"
650 );
651 }
652 assert_eq!(
653 event_suggestion("keydown"),
654 Some("enter"),
655 "the client does listen for keydown; it is registered under the key it filters to"
656 );
657 }
658}