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}