@webround/editor 0.0.0-stage → 0.3.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.
Files changed (227) hide show
  1. package/dist/blocks.d.ts +90 -0
  2. package/dist/blocks.js +119 -0
  3. package/dist/bridge.d.ts +96 -0
  4. package/dist/bridge.js +970 -0
  5. package/dist/check/aria.d.ts +19 -0
  6. package/dist/check/aria.js +106 -0
  7. package/dist/check/cli.d.ts +3 -0
  8. package/dist/check/cli.js +30 -0
  9. package/dist/check/css.d.ts +13 -0
  10. package/dist/check/css.js +63 -0
  11. package/dist/check/html.d.ts +8 -0
  12. package/dist/check/html.js +29 -0
  13. package/dist/check/index.d.ts +4 -0
  14. package/dist/check/index.js +5 -0
  15. package/dist/check/links.d.ts +7 -0
  16. package/dist/check/links.js +23 -0
  17. package/dist/check/orphans.d.ts +20 -0
  18. package/dist/check/orphans.js +57 -0
  19. package/dist/check/producer-code.d.ts +53 -0
  20. package/dist/check/producer-code.js +379 -0
  21. package/dist/check/producer-dom.d.ts +21 -0
  22. package/dist/check/producer-dom.js +52 -0
  23. package/dist/check/project.d.ts +20 -0
  24. package/dist/check/project.js +51 -0
  25. package/dist/check/rules.d.ts +7 -0
  26. package/dist/check/rules.js +517 -0
  27. package/dist/check/types.d.ts +160 -0
  28. package/dist/check/types.js +22 -0
  29. package/dist/components/WrAltcha.d.ts +5 -0
  30. package/dist/components/WrAltcha.js +8 -0
  31. package/dist/components/WrButton.d.ts +8 -0
  32. package/dist/components/WrButton.js +34 -0
  33. package/dist/components/WrForm.d.ts +6 -0
  34. package/dist/components/WrForm.js +29 -0
  35. package/dist/components/WrFormPrivacy.d.ts +4 -0
  36. package/dist/components/WrFormPrivacy.js +7 -0
  37. package/dist/components/WrFormSuccess.d.ts +4 -0
  38. package/dist/components/WrFormSuccess.js +12 -0
  39. package/dist/components/WrIframe.d.ts +12 -0
  40. package/dist/components/WrIframe.js +30 -0
  41. package/dist/components/WrImage.d.ts +9 -0
  42. package/dist/components/WrImage.js +28 -0
  43. package/dist/components/WrInput.d.ts +8 -0
  44. package/dist/components/WrInput.js +29 -0
  45. package/dist/components/WrLink.d.ts +10 -0
  46. package/dist/components/WrLink.js +38 -0
  47. package/dist/components/WrNav.d.ts +26 -0
  48. package/dist/components/WrNav.js +30 -0
  49. package/dist/components/WrSection.d.ts +11 -0
  50. package/dist/components/WrSection.js +5 -0
  51. package/dist/components/WrShared.d.ts +12 -0
  52. package/dist/components/WrShared.js +20 -0
  53. package/dist/components/WrText.d.ts +11 -0
  54. package/dist/components/WrText.js +41 -0
  55. package/dist/components/WrVideo.d.ts +9 -0
  56. package/dist/components/WrVideo.js +73 -0
  57. package/dist/components/text-editor/CodeThemeContext.d.ts +8 -0
  58. package/dist/components/text-editor/CodeThemeContext.js +10 -0
  59. package/dist/components/text-editor/ExampleTheme.d.ts +77 -0
  60. package/dist/components/text-editor/ExampleTheme.js +76 -0
  61. package/dist/components/text-editor/ToolbarContext.d.ts +17 -0
  62. package/dist/components/text-editor/ToolbarContext.js +32 -0
  63. package/dist/components/text-editor/WrTextEditor.d.ts +13 -0
  64. package/dist/components/text-editor/WrTextEditor.js +48 -0
  65. package/dist/components/text-editor/common/constants.d.ts +109 -0
  66. package/dist/components/text-editor/common/constants.js +58 -0
  67. package/dist/components/text-editor/common/surface.d.ts +3 -0
  68. package/dist/components/text-editor/common/surface.js +8 -0
  69. package/dist/components/text-editor/common/utils.d.ts +7 -0
  70. package/dist/components/text-editor/common/utils.js +78 -0
  71. package/dist/components/text-editor/hooks/useLexicalCommitOnBlur.d.ts +5 -0
  72. package/dist/components/text-editor/hooks/useLexicalCommitOnBlur.js +41 -0
  73. package/dist/components/text-editor/hooks/useLexicalMarkdown.d.ts +33 -0
  74. package/dist/components/text-editor/hooks/useLexicalMarkdown.js +284 -0
  75. package/dist/components/text-editor/nodes/CodeBlockNode.d.ts +23 -0
  76. package/dist/components/text-editor/nodes/CodeBlockNode.js +138 -0
  77. package/dist/components/text-editor/nodes/HorizontalRuleNode.d.ts +13 -0
  78. package/dist/components/text-editor/nodes/HorizontalRuleNode.js +22 -0
  79. package/dist/components/text-editor/plugins/CodeBlockPlugin.d.ts +2 -0
  80. package/dist/components/text-editor/plugins/CodeBlockPlugin.js +38 -0
  81. package/dist/components/text-editor/plugins/FontPlugin.d.ts +12 -0
  82. package/dist/components/text-editor/plugins/FontPlugin.js +114 -0
  83. package/dist/components/text-editor/plugins/FontSizePlugin.d.ts +7 -0
  84. package/dist/components/text-editor/plugins/FontSizePlugin.js +105 -0
  85. package/dist/components/text-editor/plugins/FontWeightPlugin.d.ts +6 -0
  86. package/dist/components/text-editor/plugins/FontWeightPlugin.js +103 -0
  87. package/dist/components/text-editor/plugins/HTMLTagPlugin.d.ts +7 -0
  88. package/dist/components/text-editor/plugins/HTMLTagPlugin.js +82 -0
  89. package/dist/components/text-editor/plugins/HorizontalRulePlugin.d.ts +2 -0
  90. package/dist/components/text-editor/plugins/HorizontalRulePlugin.js +35 -0
  91. package/dist/components/text-editor/plugins/LineSpacingPlugin.d.ts +7 -0
  92. package/dist/components/text-editor/plugins/LineSpacingPlugin.js +83 -0
  93. package/dist/components/text-editor/plugins/LinkPlugin.d.ts +8 -0
  94. package/dist/components/text-editor/plugins/LinkPlugin.js +183 -0
  95. package/dist/components/text-editor/plugins/ListToolbarPlugin.d.ts +7 -0
  96. package/dist/components/text-editor/plugins/ListToolbarPlugin.js +40 -0
  97. package/dist/components/text-editor/plugins/LoadContentPlugin.d.ts +4 -0
  98. package/dist/components/text-editor/plugins/LoadContentPlugin.js +63 -0
  99. package/dist/components/text-editor/plugins/MarkdownExportPlugin.d.ts +4 -0
  100. package/dist/components/text-editor/plugins/MarkdownExportPlugin.js +17 -0
  101. package/dist/components/text-editor/plugins/MarkdownPastePlugin.d.ts +2 -0
  102. package/dist/components/text-editor/plugins/MarkdownPastePlugin.js +45 -0
  103. package/dist/components/text-editor/plugins/SanitizePastePlugin.d.ts +3 -0
  104. package/dist/components/text-editor/plugins/SanitizePastePlugin.js +40 -0
  105. package/dist/components/text-editor/plugins/TextAlignPlugin.d.ts +7 -0
  106. package/dist/components/text-editor/plugins/TextAlignPlugin.js +65 -0
  107. package/dist/components/text-editor/plugins/TextColorPlugin.d.ts +7 -0
  108. package/dist/components/text-editor/plugins/TextColorPlugin.js +117 -0
  109. package/dist/components/text-editor/plugins/ToolbarPlugin.d.ts +10 -0
  110. package/dist/components/text-editor/plugins/ToolbarPlugin.js +73 -0
  111. package/dist/components/text-editor/theme-colors.d.ts +11 -0
  112. package/dist/components/text-editor/theme-colors.js +28 -0
  113. package/dist/context/WrAppContext.d.ts +35 -0
  114. package/dist/context/WrAppContext.js +113 -0
  115. package/dist/context/WrLayout.d.ts +15 -0
  116. package/dist/context/WrLayout.js +22 -0
  117. package/dist/context/WrPageContext.d.ts +28 -0
  118. package/dist/context/WrPageContext.js +282 -0
  119. package/dist/context/restoreValues.d.ts +2 -0
  120. package/dist/context/restoreValues.js +6 -0
  121. package/dist/contrast.d.ts +45 -0
  122. package/dist/contrast.js +122 -0
  123. package/dist/drop.d.ts +7 -0
  124. package/dist/drop.js +51 -0
  125. package/dist/gaps.d.ts +14 -0
  126. package/dist/gaps.js +50 -0
  127. package/dist/hooks/useWrMessage.d.ts +5 -0
  128. package/dist/hooks/useWrMessage.js +24 -0
  129. package/dist/hooks/useWrSave.d.ts +2 -0
  130. package/dist/hooks/useWrSave.js +11 -0
  131. package/dist/index.css +467 -0
  132. package/dist/index.d.ts +20 -0
  133. package/dist/index.js +20 -0
  134. package/dist/interactive.d.ts +8 -0
  135. package/dist/interactive.js +13 -0
  136. package/dist/measure-rules.d.ts +34 -0
  137. package/dist/measure-rules.js +54 -0
  138. package/dist/measure.d.ts +11 -0
  139. package/dist/measure.js +139 -0
  140. package/dist/messages.d.ts +860 -0
  141. package/dist/messages.js +2 -0
  142. package/dist/nav.d.ts +5 -0
  143. package/dist/nav.js +10 -0
  144. package/dist/operations/animations-store.d.ts +6 -0
  145. package/dist/operations/animations-store.js +63 -0
  146. package/dist/operations/cli.d.ts +3 -0
  147. package/dist/operations/cli.js +52 -0
  148. package/dist/operations/comments-store.d.ts +17 -0
  149. package/dist/operations/comments-store.js +62 -0
  150. package/dist/operations/context.d.ts +12 -0
  151. package/dist/operations/context.js +35 -0
  152. package/dist/operations/define.d.ts +81 -0
  153. package/dist/operations/define.js +86 -0
  154. package/dist/operations/index.d.ts +6 -0
  155. package/dist/operations/index.js +6 -0
  156. package/dist/operations/ops/check.d.ts +5 -0
  157. package/dist/operations/ops/check.js +53 -0
  158. package/dist/operations/ops/comments.d.ts +6 -0
  159. package/dist/operations/ops/comments.js +133 -0
  160. package/dist/operations/ops/content.d.ts +16 -0
  161. package/dist/operations/ops/content.js +73 -0
  162. package/dist/operations/ops/json.d.ts +4 -0
  163. package/dist/operations/ops/json.js +27 -0
  164. package/dist/operations/ops/langs.d.ts +4 -0
  165. package/dist/operations/ops/langs.js +32 -0
  166. package/dist/operations/ops/nodes.d.ts +8 -0
  167. package/dist/operations/ops/nodes.js +270 -0
  168. package/dist/operations/ops/pages.d.ts +8 -0
  169. package/dist/operations/ops/pages.js +154 -0
  170. package/dist/operations/ops/project.d.ts +36 -0
  171. package/dist/operations/ops/project.js +97 -0
  172. package/dist/operations/ops/seo.d.ts +17 -0
  173. package/dist/operations/ops/seo.js +358 -0
  174. package/dist/operations/ops/style.d.ts +76 -0
  175. package/dist/operations/ops/style.js +268 -0
  176. package/dist/operations/ops/stylesets.d.ts +9 -0
  177. package/dist/operations/ops/stylesets.js +145 -0
  178. package/dist/operations/ops/theme.d.ts +6 -0
  179. package/dist/operations/ops/theme.js +304 -0
  180. package/dist/operations/registry.d.ts +8 -0
  181. package/dist/operations/registry.js +19 -0
  182. package/dist/operations/stylesets-store.d.ts +50 -0
  183. package/dist/operations/stylesets-store.js +216 -0
  184. package/dist/registry.d.ts +5 -0
  185. package/dist/registry.js +24 -0
  186. package/dist/save.d.ts +65 -0
  187. package/dist/save.js +86 -0
  188. package/dist/schema.d.ts +69 -0
  189. package/dist/schema.js +110 -0
  190. package/dist/scope.d.ts +41 -0
  191. package/dist/scope.js +44 -0
  192. package/dist/vite-plugins/ast.d.ts +3 -0
  193. package/dist/vite-plugins/ast.js +238 -0
  194. package/dist/vite-plugins/core-swap.d.ts +3 -0
  195. package/dist/vite-plugins/core-swap.js +57 -0
  196. package/dist/vite-plugins/create-page.d.ts +4 -0
  197. package/dist/vite-plugins/create-page.js +59 -0
  198. package/dist/vite-plugins/guard.d.ts +15 -0
  199. package/dist/vite-plugins/guard.js +35 -0
  200. package/dist/vite-plugins/head.d.ts +13 -0
  201. package/dist/vite-plugins/head.js +53 -0
  202. package/dist/vite-plugins/index.d.ts +7 -0
  203. package/dist/vite-plugins/index.js +7 -0
  204. package/dist/vite-plugins/jsx-tree.d.ts +118 -0
  205. package/dist/vite-plugins/jsx-tree.js +686 -0
  206. package/dist/vite-plugins/langs.d.ts +3 -0
  207. package/dist/vite-plugins/langs.js +44 -0
  208. package/dist/vite-plugins/operations.d.ts +10 -0
  209. package/dist/vite-plugins/operations.js +66 -0
  210. package/dist/vite-plugins/pages-api.d.ts +3 -0
  211. package/dist/vite-plugins/pages-api.js +112 -0
  212. package/dist/vite-plugins/pages.d.ts +3 -0
  213. package/dist/vite-plugins/pages.js +64 -0
  214. package/dist/vite-plugins/read.d.ts +3 -0
  215. package/dist/vite-plugins/read.js +45 -0
  216. package/dist/vite-plugins/seo-files.d.ts +8 -0
  217. package/dist/vite-plugins/seo-files.js +42 -0
  218. package/dist/vite-plugins/theme.d.ts +3 -0
  219. package/dist/vite-plugins/theme.js +207 -0
  220. package/dist/vite-plugins/update.d.ts +3 -0
  221. package/dist/vite-plugins/update.js +46 -0
  222. package/dist/vite-plugins/wr-plugins.d.ts +15 -0
  223. package/dist/vite-plugins/wr-plugins.js +34 -0
  224. package/dist/vite-plugins.d.ts +11 -0
  225. package/dist/vite-plugins.js +518 -0
  226. package/package.json +96 -5
  227. package/README.md +0 -3
@@ -0,0 +1,517 @@
1
+ import { CHECK_SITE, issueKey, walk } from './types.js';
2
+ import { wrJsonLdProblems, wrSiteUrlOf } from '@webround/core/seo';
3
+ import { leadsSomewhere } from './links.js';
4
+ import { ARIA_ATTRIBUTES, FOCUSABLE_TAGS, GENERIC_LINK_TEXTS, GENERIC_TAGS, REQUIRED_BY_ROLE, expectedAutocomplete, isKnownRole, isValidAutocomplete, normalizedLinkText } from './aria.js';
5
+ const HEADING = /^h([1-6])$/;
6
+ function everyNode(page) {
7
+ const all = [];
8
+ walk(page.nodes, n => all.push(n));
9
+ return all;
10
+ }
11
+ /** Two elements on different sides of the same condition (`c ? a : b`) are never shown together. */
12
+ function exclusive(a, b) {
13
+ return (a.branch ?? []).some(x => (b.branch ?? []).some(y => x.key === y.key && x.side !== y.side));
14
+ }
15
+ /** Tailwind classes that take the focus outline away (for every state, or on focus), and ones that show the focus some other way. */
16
+ const FOCUS_OUTLINE_REMOVED = /^(?:(?:focus|focus-visible|focus-within):)?(?:outline-none|outline-hidden|outline-0)$/;
17
+ const FOCUS_STYLED = /^(?:[\w-]+:)*(?:focus|focus-visible):(?!outline-none$|outline-hidden$|outline-0$)\S+$/;
18
+ /** Search results show about this much of a title and of a description; the rest is cut. */
19
+ const TITLE_MAX = 60;
20
+ const DESCRIPTION_MAX = 160;
21
+ /** The same rule for title and description: missing in the language the page is read in (the default language's text would be shown instead), or too long. */
22
+ function seoTextRule(field, max) {
23
+ return {
24
+ name: `seo-${field}`,
25
+ severity: 'warning',
26
+ run(page, report) {
27
+ const seo = page.seo;
28
+ // A family of pages (`blog/[slug]`) has one JSON for all: a title or description there would be the same for every one, so none is expected
29
+ if (!seo || seo.noindex || seo.family)
30
+ return;
31
+ const lang = page.lang ?? 'default';
32
+ const text = seo[field][lang]?.trim();
33
+ const fix = { kind: 'seo-page', field, lang };
34
+ if (!text)
35
+ report(null, `The page has no SEO ${field}.`, fix);
36
+ else if (text.length > max)
37
+ report(null, `The SEO ${field} is ${text.length} characters: search results cut it after about ${max}.`, fix);
38
+ },
39
+ };
40
+ }
41
+ export const RULES = [
42
+ seoTextRule('title', TITLE_MAX),
43
+ seoTextRule('description', DESCRIPTION_MAX),
44
+ {
45
+ name: 'syntax-error',
46
+ severity: 'error',
47
+ run(page, report) {
48
+ for (const problem of page.syntax ?? [])
49
+ report(null, `${problem.message} (${problem.file}:${problem.line})`);
50
+ },
51
+ },
52
+ {
53
+ name: 'img-alt',
54
+ severity: 'warning',
55
+ run(page, report) {
56
+ for (const n of everyNode(page)) {
57
+ if (n.tag === 'img' && !n.attrs.alt?.trim())
58
+ report(n, 'Image without alternative text.');
59
+ }
60
+ },
61
+ },
62
+ {
63
+ name: 'single-h1',
64
+ severity: 'warning',
65
+ run(page, report) {
66
+ const h1s = everyNode(page).filter(n => n.tag === 'h1');
67
+ if (h1s.length === 0)
68
+ report(null, 'The page has no <h1>.');
69
+ // An <h1> that cannot be shown with one before it (the other side of a condition) is not one more
70
+ h1s.forEach((h1, index) => { if (h1s.slice(0, index).some(before => !exclusive(before, h1)))
71
+ report(h1, 'More than one <h1> in the page.'); });
72
+ },
73
+ },
74
+ {
75
+ name: 'heading-order',
76
+ severity: 'warning',
77
+ run(page, report) {
78
+ let previous = 0;
79
+ for (const n of everyNode(page)) {
80
+ const level = Number(HEADING.exec(n.tag)?.[1] ?? 0);
81
+ if (!level)
82
+ continue;
83
+ if (previous && level > previous + 1)
84
+ report(n, `Heading jumps from <h${previous}> to <h${level}>.`);
85
+ previous = level;
86
+ }
87
+ },
88
+ },
89
+ {
90
+ name: 'link-name',
91
+ severity: 'error',
92
+ run(page, report) {
93
+ for (const n of everyNode(page)) {
94
+ if (n.tag === 'a' && !n.text && !n.attrs['aria-label']?.trim())
95
+ report(n, 'Link without text or aria-label.');
96
+ }
97
+ },
98
+ },
99
+ {
100
+ name: 'button-name',
101
+ severity: 'error',
102
+ run(page, report) {
103
+ for (const n of everyNode(page)) {
104
+ if (n.tag === 'button' && !n.text && !n.attrs['aria-label']?.trim())
105
+ report(n, 'Button without text or aria-label.');
106
+ }
107
+ },
108
+ },
109
+ {
110
+ name: 'seo-jsonld',
111
+ severity: 'warning',
112
+ run(page, report) {
113
+ for (const problem of page.seo?.jsonLdProblems ?? [])
114
+ report(null, `The structured data of the page is not valid: ${problem}. Search engines ignore it.`);
115
+ },
116
+ },
117
+ {
118
+ name: 'broken-link',
119
+ severity: 'warning',
120
+ run(page, report) {
121
+ const project = page.project;
122
+ if (!project)
123
+ return;
124
+ for (const n of everyNode(page)) {
125
+ const href = n.attrs.href;
126
+ if (n.tag === 'a' && href !== undefined && !leadsSomewhere(href, project))
127
+ report(n, `The link to ${href} leads to no page of the site.`, undefined, href);
128
+ }
129
+ },
130
+ },
131
+ {
132
+ name: 'button-action',
133
+ severity: 'warning',
134
+ run(page, report) {
135
+ for (const n of everyNode(page)) {
136
+ if (n.tag === 'button' && n.acts === false)
137
+ report(n, 'The button does nothing: it has no click handler, is not a submit button and is not inside a form or a link.');
138
+ }
139
+ },
140
+ },
141
+ {
142
+ name: 'iframe-title',
143
+ severity: 'warning',
144
+ run(page, report) {
145
+ for (const n of everyNode(page)) {
146
+ if (n.tag === 'iframe' && !n.attrs.title?.trim() && !n.attrs['aria-label']?.trim() && !n.attrs['{spread}'])
147
+ report(n, 'The frame has no title: a screen reader announces it as "frame" and nothing more.');
148
+ }
149
+ },
150
+ },
151
+ {
152
+ name: 'video-autoplay',
153
+ severity: 'warning',
154
+ run(page, report) {
155
+ for (const n of everyNode(page)) {
156
+ if (n.tag === 'video' && n.attrs.autoplay !== undefined && n.attrs.autoplay !== 'false' && n.attrs.controls === undefined && !n.attrs['{spread}']) {
157
+ report(n, 'The video starts by itself and has no controls: nobody can pause it (WCAG 2.2.2).');
158
+ }
159
+ }
160
+ },
161
+ },
162
+ {
163
+ name: 'click-keyboard',
164
+ severity: 'warning',
165
+ run(page, report) {
166
+ const interactive = new Set(['a', 'button', 'input', 'select', 'textarea', 'summary', 'details', 'label', 'option']);
167
+ for (const n of everyNode(page)) {
168
+ if (n.attrs.onclick && n.kind === 'element' && !interactive.has(n.tag) && !n.attrs.role && n.attrs.tabindex === undefined && !n.attrs.onkeydown && !n.attrs['{spread}']) {
169
+ report(n, `A <${n.tag}> reacts to clicks but cannot be reached or used with the keyboard: use a button, or give it a role, tabIndex and a key handler.`);
170
+ }
171
+ }
172
+ },
173
+ },
174
+ {
175
+ name: 'tabindex-positive',
176
+ severity: 'warning',
177
+ run(page, report) {
178
+ for (const n of everyNode(page)) {
179
+ if (Number(n.attrs.tabindex) > 0)
180
+ report(n, 'tabIndex above 0 changes the order the keyboard goes through the page, and almost always breaks it: use 0, or -1.');
181
+ }
182
+ },
183
+ },
184
+ {
185
+ name: 'form-label',
186
+ severity: 'warning',
187
+ run(page, report) {
188
+ const fields = new Set(['input', 'select', 'textarea']);
189
+ const labelled = new Set(page.labelledIds ?? []);
190
+ for (const n of everyNode(page)) {
191
+ // A WrInput is judged by `form-input-name` (its label is in the JSON, not an attribute)
192
+ if (!fields.has(n.tag) || n.kind === 'input' || n.attrs['{spread}'])
193
+ continue;
194
+ if (['hidden', 'submit', 'button', 'reset', 'image'].includes(n.attrs.type))
195
+ continue;
196
+ if (n.attrs['aria-label']?.trim() || n.attrs['aria-labelledby'] || n.attrs.title?.trim() || n.insideLabel || (n.id && labelled.has(n.id)))
197
+ continue;
198
+ report(n, `A form field has no label: a placeholder is not one. Wrap it in a <label>, or give it an aria-label.`);
199
+ }
200
+ },
201
+ },
202
+ {
203
+ name: 'form-altcha',
204
+ severity: 'error',
205
+ run(page, report) {
206
+ for (const n of everyNode(page)) {
207
+ if (n.kind !== 'form')
208
+ continue;
209
+ let found = false;
210
+ walk(n.children, c => { if (c.kind === 'altcha')
211
+ found = true; });
212
+ if (!found)
213
+ report(n, 'The form has no Altcha: the Webround backend refuses a form without its proof of work, so it can never be sent.');
214
+ }
215
+ },
216
+ },
217
+ {
218
+ name: 'form-input-name',
219
+ severity: 'warning',
220
+ run(page, report) {
221
+ for (const n of everyNode(page)) {
222
+ if (n.kind !== 'input')
223
+ continue;
224
+ const written = Object.values(n.values?.label ?? {}).some(text => text.trim() !== '');
225
+ if (!written && !n.attrs['aria-label']?.trim())
226
+ report(n, 'A form field has no label: a placeholder is not one, and the owner reads this name in the email.');
227
+ }
228
+ },
229
+ },
230
+ {
231
+ name: 'form-policy-pages',
232
+ severity: 'warning',
233
+ run(page, report) {
234
+ const routes = page.project?.routes;
235
+ if (!routes)
236
+ return;
237
+ const form = everyNode(page).find(n => n.kind === 'form');
238
+ if (!form)
239
+ return;
240
+ if (!routes.includes('privacy-policy'))
241
+ report(form, 'The site has no "privacy-policy" page: a form collects personal data, and the consent line must link to a privacy policy.', undefined, 'privacy-policy');
242
+ if (!routes.includes('cookie-policy'))
243
+ report(form, 'The site has no "cookie-policy" page.', undefined, 'cookie-policy');
244
+ },
245
+ },
246
+ {
247
+ name: 'aria-valid-attr',
248
+ severity: 'warning',
249
+ run(page, report) {
250
+ for (const n of everyNode(page)) {
251
+ const unknown = (n.attrs['{aria}'] ?? '').split(' ').filter(name => name && !ARIA_ATTRIBUTES.has(name.slice(5)));
252
+ if (unknown.length > 0)
253
+ report(n, `${unknown.join(', ')} ${unknown.length === 1 ? 'is' : 'are'} not ${unknown.length === 1 ? 'an ARIA attribute' : 'ARIA attributes'}: a typo is ignored by screen readers.`);
254
+ }
255
+ },
256
+ },
257
+ {
258
+ name: 'aria-valid-role',
259
+ severity: 'warning',
260
+ run(page, report) {
261
+ for (const n of everyNode(page)) {
262
+ const roles = (n.attrs.role ?? '').split(/\s+/).filter(role => role && role !== '{…}');
263
+ if (roles.length > 0 && !roles.some(isKnownRole))
264
+ report(n, `"${roles.join(' ')}" is not a role ARIA knows: it is ignored, and the element keeps no role at all.`);
265
+ }
266
+ },
267
+ },
268
+ {
269
+ name: 'aria-required-attr',
270
+ severity: 'warning',
271
+ run(page, report) {
272
+ for (const n of everyNode(page)) {
273
+ const role = (n.attrs.role ?? '').split(/\s+/)[0];
274
+ const needs = REQUIRED_BY_ROLE[role];
275
+ if (!needs || n.attrs['{spread}'])
276
+ continue;
277
+ const has = new Set((n.attrs['{aria}'] ?? '').split(' ').map(name => name.slice(5)));
278
+ const missing = needs.filter(name => !has.has(name));
279
+ if (missing.length > 0)
280
+ report(n, `A ${role} needs ${missing.map(name => `aria-${name}`).join(' and ')}: without it a screen reader cannot say what it is doing.`);
281
+ }
282
+ },
283
+ },
284
+ {
285
+ name: 'aria-hidden-focusable',
286
+ severity: 'warning',
287
+ run(page, report) {
288
+ const focusable = (n) => (FOCUSABLE_TAGS.has(n.tag) || (n.tag === 'a' && n.attrs.href !== undefined) || (n.tag === 'input' && n.attrs.type !== 'hidden') || Number(n.attrs.tabindex) >= 0) && n.attrs.tabindex !== '-1';
289
+ const inside = (n) => n.children.some(child => focusable(child) || inside(child));
290
+ const visit = (nodes) => {
291
+ for (const n of nodes) {
292
+ if (n.attrs['aria-hidden'] === 'true') {
293
+ if (focusable(n) || inside(n))
294
+ report(n, 'The element is hidden from screen readers (aria-hidden) but can still be reached with the keyboard: it is there for one and not for the other.');
295
+ continue; // what is inside is hidden as well: one finding is enough
296
+ }
297
+ visit(n.children);
298
+ }
299
+ };
300
+ visit(page.nodes);
301
+ },
302
+ },
303
+ {
304
+ name: 'aria-reference',
305
+ severity: 'warning',
306
+ run(page, report) {
307
+ const ids = new Set(everyNode(page).map(n => n.id).filter((id) => !!id));
308
+ for (const n of everyNode(page)) {
309
+ for (const attribute of ['aria-labelledby', 'aria-describedby']) {
310
+ const value = n.attrs[attribute];
311
+ if (!value || value === '{…}')
312
+ continue;
313
+ const missing = value.split(/\s+/).filter(id => id && !ids.has(id));
314
+ if (missing.length > 0)
315
+ report(n, `${attribute} points to ${missing.join(', ')}, which ${missing.length === 1 ? 'is' : 'are'} not in the page.`);
316
+ }
317
+ }
318
+ },
319
+ },
320
+ {
321
+ name: 'aria-label-ignored',
322
+ severity: 'warning',
323
+ run(page, report) {
324
+ for (const n of everyNode(page)) {
325
+ // A `WrNav` is the exception: its box is a `div`, but it gives its name to the `<nav>` that the menu draws (`options.ariaLabel`)
326
+ if (n.kind !== 'nav' && n.attrs['aria-label']?.trim() && GENERIC_TAGS.has(n.tag) && !n.attrs.role && !n.attrs['{spread}']) {
327
+ report(n, `An aria-label on a <${n.tag}> is not announced: it is only a container. Give it a role, or use the label on what holds the content (a <section>, a <nav>…).`);
328
+ }
329
+ }
330
+ },
331
+ },
332
+ {
333
+ name: 'link-text-generic',
334
+ severity: 'warning',
335
+ run(page, report) {
336
+ for (const n of everyNode(page)) {
337
+ if (n.tag !== 'a' || n.attrs['aria-label']?.trim())
338
+ continue;
339
+ const text = normalizedLinkText(n.text);
340
+ if (text && GENERIC_LINK_TEXTS.has(text))
341
+ report(n, `The link says only "${n.text.trim()}": out of its sentence (a screen reader lists links on their own) nobody knows where it goes.`);
342
+ }
343
+ },
344
+ },
345
+ {
346
+ name: 'landmark-main',
347
+ severity: 'warning',
348
+ run(page, report) {
349
+ const mains = [...everyNode(page)].filter(n => n.tag === 'main' || n.attrs.role === 'main');
350
+ if (page.source === 'dom') {
351
+ // The rendered page has the layout's <main> in it: one is right, a second one is the page's own
352
+ for (const n of mains.slice(1))
353
+ report(n, 'The page has more than one <main> (there is one main landmark). Make this a <div> or a <section>.');
354
+ return;
355
+ }
356
+ // In the code the layout's <main> is not there (WrLayout wraps every page in it): any <main> of the page's own is a second one
357
+ for (const n of mains)
358
+ report(n, 'The layout already puts the page in a <main>: a second one is an error (there is one main landmark). Make this a <div> or a <section>.');
359
+ },
360
+ },
361
+ {
362
+ name: 'focus-visible',
363
+ severity: 'warning',
364
+ run(page, report) {
365
+ for (const n of everyNode(page)) {
366
+ const classes = (n.attrs.class ?? '').split(/\s+/).filter(Boolean);
367
+ if (classes.length === 0 || n.attrs.class === '{…}')
368
+ continue;
369
+ const interactive = FOCUSABLE_TAGS.has(n.tag) || (n.tag === 'a' && n.attrs.href !== undefined) || (n.tag === 'input' && n.attrs.type !== 'hidden') || Number(n.attrs.tabindex) >= 0 || n.attrs.role === 'button' || n.attrs.role === 'link';
370
+ if (!interactive)
371
+ continue;
372
+ if (classes.some(c => FOCUS_OUTLINE_REMOVED.test(c)) && !classes.some(c => FOCUS_STYLED.test(c) && !FOCUS_OUTLINE_REMOVED.test(c))) {
373
+ report(n, 'The focus outline is removed and nothing replaces it: someone using the keyboard cannot tell where they are (WCAG 2.4.7). Add a focus-visible ring or outline.');
374
+ }
375
+ }
376
+ },
377
+ },
378
+ {
379
+ name: 'motion-reduce',
380
+ severity: 'warning',
381
+ run(page, report) {
382
+ for (const n of everyNode(page)) {
383
+ const classes = (n.attrs.class ?? '').split(/\s+/).filter(Boolean);
384
+ if (classes.length === 0 || n.attrs.class === '{…}')
385
+ continue;
386
+ const moves = classes.filter(c => /^(animate-(?!none$)[\w[\]-]+|scroll-smooth)$/.test(c));
387
+ if (moves.length > 0 && !classes.some(c => /^motion-reduce:/.test(c))) {
388
+ report(n, `${moves.join(', ')} moves without checking whether the person asked for less motion: add motion-reduce:animate-none (or use motion-safe:) (WCAG 2.3.3, and 2.2.2 for what never stops).`);
389
+ }
390
+ }
391
+ },
392
+ },
393
+ {
394
+ name: 'autocomplete',
395
+ severity: 'warning',
396
+ run(page, report) {
397
+ for (const n of everyNode(page)) {
398
+ if (!['input', 'select', 'textarea'].includes(n.tag) || n.attrs['{spread}'])
399
+ continue;
400
+ const given = n.attrs.autocomplete;
401
+ if (given !== undefined && given !== '{…}' && !isValidAutocomplete(given)) {
402
+ report(n, `autoComplete="${given}" is not a value browsers know (it is ignored): use the field's purpose, like email, given-name, tel or postal-code (WCAG 1.3.5).`);
403
+ continue;
404
+ }
405
+ const wanted = expectedAutocomplete(n.attrs);
406
+ if (wanted && given === undefined)
407
+ report(n, `A field that asks for the person's own data should say what it is: add autoComplete="${wanted}", so the browser and assistive tools can fill it (WCAG 1.3.5).`);
408
+ }
409
+ },
410
+ },
411
+ {
412
+ name: 'duplicate-id',
413
+ severity: 'error',
414
+ run(page, report) {
415
+ const seen = new Set();
416
+ for (const n of everyNode(page)) {
417
+ if (!n.id || n.id === '{…}')
418
+ continue; // an id the code computes: which one it is cannot be read, so it is not "the same" as another
419
+ if (seen.has(n.id))
420
+ report(n, `The id "${n.id}" is used more than once.`);
421
+ seen.add(n.id);
422
+ }
423
+ },
424
+ },
425
+ ];
426
+ export const SITE_RULES = [
427
+ {
428
+ name: 'seo-jsonld-site',
429
+ severity: 'warning',
430
+ run: (site, report) => { for (const problem of wrJsonLdProblems(site.seo.jsonLd))
431
+ report(`The structured data of the site is not valid: ${problem}. Search engines ignore it.`, undefined); },
432
+ },
433
+ {
434
+ name: 'focus-visible-css',
435
+ severity: 'warning',
436
+ run: (site, report) => {
437
+ for (const { file, selector } of site.css.focusRemoved)
438
+ report(`${file}: "${selector}" removes the focus outline and puts nothing in its place: someone using the keyboard cannot tell where they are (WCAG 2.4.7).`, undefined, undefined, `${file}:${selector}`);
439
+ },
440
+ },
441
+ {
442
+ name: 'motion-reduce-css',
443
+ severity: 'warning',
444
+ run: (site, report) => { if (site.css.animates && !site.css.reducesMotion)
445
+ report('The stylesheets animate things and none has an @media (prefers-reduced-motion: reduce): people who asked their system for less motion still get it all.'); },
446
+ },
447
+ {
448
+ name: 'html-lang',
449
+ severity: 'warning',
450
+ run: (site, report) => { if (site.index && !site.index.lang)
451
+ report('index.html has no lang on <html>: screen readers cannot tell what language to speak before the page says (it does set it once loaded, but not on the first paint).'); },
452
+ },
453
+ {
454
+ name: 'viewport-zoom',
455
+ severity: 'warning',
456
+ run: (site, report) => {
457
+ const viewport = site.index?.viewport ?? '';
458
+ if (/user-scalable\s*=\s*(no|0)/i.test(viewport) || /maximum-scale\s*=\s*(0?\.\d+|1(\.0+)?)\b/i.test(viewport))
459
+ report('The viewport stops visitors from zooming (user-scalable / maximum-scale): people with low vision need it (WCAG 1.4.4).');
460
+ },
461
+ },
462
+ {
463
+ name: 'seo-favicon',
464
+ severity: 'warning',
465
+ run: (site, report) => { if (!site.seo.favicon?.trim())
466
+ report('The site has no favicon.', { kind: 'seo-site', field: 'favicon' }); },
467
+ },
468
+ {
469
+ name: 'seo-site-url',
470
+ severity: 'warning',
471
+ run: (site, report) => {
472
+ if (!wrSiteUrlOf(site.seo))
473
+ report('The site does not know its public address: canonical links, language alternates and the sitemap need it.', { kind: 'seo-site', field: 'siteUrl' });
474
+ },
475
+ },
476
+ {
477
+ name: 'orphan-key',
478
+ severity: 'warning',
479
+ run(site, report) {
480
+ for (const { scope, keys } of site.orphans) {
481
+ const some = keys.slice(0, 3).join(', ') + (keys.length > 3 ? ` and ${keys.length - 3} more` : '');
482
+ const what = `The values of ${keys.length === 1 ? 'an element that is' : `${keys.length} elements that are`} no longer in the code are still in the JSON (${some}).`;
483
+ // With ids the code computes, a key that looks unused may belong to one of them: nothing is offered to be removed
484
+ if (site.dynamicIds)
485
+ report(`${what} Some elements compute their ids, so check before removing them.`, undefined, scope);
486
+ else
487
+ report(what, { kind: 'prune', scope, keys }, scope);
488
+ }
489
+ },
490
+ },
491
+ ];
492
+ export function runRules(pages, rules = RULES) {
493
+ const issues = [];
494
+ for (const page of pages) {
495
+ for (const rule of rules) {
496
+ rule.run(page, (node, message, fix, subject) => {
497
+ // What it is about: the said subject, else the element's id, else the place of a plain element in its file
498
+ const about = subject ?? node?.id ?? (node?.where ? `${node.tag}@${node.where.file}:${node.where.line}` : undefined);
499
+ issues.push({
500
+ rule: rule.name, severity: rule.severity, page: page.name, message,
501
+ ...(node?.id ? { nodeId: node.id } : {}), ...(node?.where ? { where: node.where } : {}), ...(fix ? { fix } : {}),
502
+ ...(about ? { subject: about } : {}),
503
+ key: issueKey({ rule: rule.name, page: page.name, subject: about, fix }),
504
+ });
505
+ });
506
+ }
507
+ }
508
+ return issues;
509
+ }
510
+ /** What is wrong with the site as a whole, not with one page. */
511
+ export function runSiteRules(site, rules = SITE_RULES) {
512
+ const issues = [];
513
+ for (const rule of rules)
514
+ rule.run(site, (message, fix, page, subject) => issues.push({ rule: rule.name, severity: rule.severity, page: page ?? CHECK_SITE, message, ...(fix ? { fix } : {}), ...(subject ? { subject } : {}), key: issueKey({ rule: rule.name, page: page ?? CHECK_SITE, subject, fix }) }));
515
+ return issues;
516
+ }
517
+ //# sourceMappingURL=rules.js.map
@@ -0,0 +1,160 @@
1
+ import type { WrElementKind } from '@webround/core/kinds';
2
+ /** What an element is for a rule: the Webround kinds keep theirs, plain HTML is just `element` (the rules go by tag). */
3
+ export type CheckKind = WrElementKind | 'element';
4
+ export interface CheckNode {
5
+ /** `data-wr-id` / `id=` of a Wr component; plain HTML has none. */
6
+ id?: string;
7
+ kind: CheckKind;
8
+ /** Lowercase HTML tag the node ends up as (`h1`, `img`, `a`…). */
9
+ tag: string;
10
+ /** Only the attributes rules read, already resolved for the default language (JSON wins over code). */
11
+ attrs: Record<string, string>;
12
+ /** Text the user would read, tags stripped. */
13
+ text: string;
14
+ children: CheckNode[];
15
+ /** The scope (page, `_shared/header`…) it is in, when the tree is read for more than the checks (see the outline). */
16
+ scope?: string;
17
+ /** Only the code producer says it, for a button: does pressing it do anything (a handler, a submit, a form or a link around it)? Absent = not known. */
18
+ acts?: boolean;
19
+ /** Only the code producer says it, for a form field: it sits inside a `<label>`. */
20
+ insideLabel?: boolean;
21
+ /**
22
+ * Only the code producer says it: the sides of the conditions the element is under (`c ? a : b`, or `c && a` against `!c && b`), the outermost first. Two elements on different sides of the same
23
+ * condition (same `key`) are never shown together: a rule that counts what the page shows does not count both.
24
+ */
25
+ branch?: {
26
+ key: string;
27
+ side: string;
28
+ }[];
29
+ /** Shown as hidden in the JSON: the checks do not count it (it does not render), an outline says so. */
30
+ hidden?: true;
31
+ /** What the element says in each language, for the fields that are translated (`content`, `alt`, `ariaLabel`): field → language key → value. */
32
+ values?: Record<string, Record<string, string>>;
33
+ /** Where to look: a file and line for the code producer, nothing for the DOM. */
34
+ where?: {
35
+ file: string;
36
+ line: number;
37
+ };
38
+ }
39
+ export interface CheckPage {
40
+ name: string;
41
+ nodes: CheckNode[];
42
+ /** `dom` when the tree is what a browser rendered (the layout's own elements are in it); absent when it is read from the code. */
43
+ source?: 'dom';
44
+ /** Code the parser could not read (only the code producer knows): such a page cannot be judged, and cannot be shown. */
45
+ syntax?: {
46
+ file: string;
47
+ line: number;
48
+ message: string;
49
+ }[];
50
+ /** The language this tree is read in: `default`, or a language code of the site. Absent when the site has one language. */
51
+ lang?: string;
52
+ /** What the site has that a link can point to (only the code producer knows it): the routes, the languages, the files in `public/`. */
53
+ project?: CheckProject;
54
+ /** The ids of the form fields some `<label htmlFor>` of the code points at. */
55
+ labelledIds?: string[];
56
+ /** What the page tells search engines (only the code producer knows it: the DOM has no `config`): text by language key, `default` first. */
57
+ seo?: {
58
+ title: Record<string, string>;
59
+ description: Record<string, string>;
60
+ noindex: boolean;
61
+ /** The page is a family of pages (`product/[slug]`, `shop/[slug]`): one JSON for all of them, so a title or description written there would be the same for each. What they say comes from the data the page loads (`useWrSeo`). */
62
+ family: boolean;
63
+ /** What is wrong with the page's JSON-LD (empty when it is fine). */
64
+ jsonLdProblems: string[];
65
+ };
66
+ }
67
+ /** Where a link of the site may lead. */
68
+ export interface CheckProject {
69
+ /** The route paths (`about`, `blog/[slug]`: `home` is the home page). */
70
+ routes: string[];
71
+ langs?: {
72
+ default: string;
73
+ available: string[];
74
+ };
75
+ /** Files served as they are (`/robots.txt`, `/brochure.pdf`), as paths from the site's root. */
76
+ publicFiles: string[];
77
+ }
78
+ export type Severity = 'error' | 'warning';
79
+ /** The page name an issue about the whole site carries (the way `_shared/header` names what is not a page). */
80
+ export declare const CHECK_SITE = "_site";
81
+ /** Where a problem is solved when it is not an element of the page: a field of the page's SEO data, or of the site's. */
82
+ export type IssueFix = {
83
+ kind: 'seo-page';
84
+ field: 'title' | 'description'; /** the language key (`default` or a code) the field is missing in */
85
+ lang: string;
86
+ } | {
87
+ kind: 'seo-site';
88
+ field: 'favicon' | 'siteUrl';
89
+ }
90
+ /** Keys of a scope's JSON that no element of the code uses: removed by `json-prune`. */
91
+ | {
92
+ kind: 'prune';
93
+ scope: string;
94
+ keys: string[];
95
+ };
96
+ /** What the checks know of the site as a whole (not of one page). */
97
+ export interface CheckSite {
98
+ seo: import('@webround/core/seo').WrSeoSite;
99
+ /** Keys in the JSON of each scope (page, header, footer) that no element in any file of `src/` has the id of. */
100
+ orphans: {
101
+ scope: string;
102
+ keys: string[];
103
+ }[];
104
+ /** What the project's stylesheets do about the focus ring and about animation. */
105
+ css: import('./css.js').CheckCss;
106
+ /** What `index.html` says (the page every route is served in); null while the project has none. */
107
+ index: {
108
+ lang: string | null;
109
+ viewport: string | null;
110
+ } | null;
111
+ /** Some element has an id the code computes: a key that looks unused may belong to it, so nothing can be said to be unused. */
112
+ dynamicIds: boolean;
113
+ }
114
+ export interface Issue {
115
+ rule: string;
116
+ severity: Severity;
117
+ /** The page, or `CHECK_SITE` for what is about the whole site. */
118
+ page: string;
119
+ /** The languages it happens in (language keys: `default` or a code), when the site has several: ONE issue says where, not one per language. */
120
+ langs?: string[];
121
+ message: string;
122
+ nodeId?: string;
123
+ where?: {
124
+ file: string;
125
+ line: number;
126
+ };
127
+ /** Where to fix it, when there is no element to point at (a missing title, a missing favicon). */
128
+ fix?: IssueFix;
129
+ /** What it is about: the address of a broken link, the id of an element, the place of a plain element in its file. */
130
+ subject?: string;
131
+ /** Who it is: the same problem has the same key every time it is found, in every language, so that a person's decision about it (to silence it) sticks. */
132
+ key: string;
133
+ /** The person decided not to deal with it: it is still there, and counted apart. */
134
+ muted?: true;
135
+ }
136
+ /**
137
+ * The identity of a problem: its rule, where it is (a page, or `_shared` for the header and footer, which every page shows), and what it is about.
138
+ * NOT the words (a count in them changes) nor the languages (one decision covers them all).
139
+ */
140
+ export declare function issueKey(parts: {
141
+ rule: string;
142
+ page: string;
143
+ subject?: string;
144
+ fix?: IssueFix;
145
+ }): string;
146
+ export interface Rule {
147
+ name: string;
148
+ severity: Severity;
149
+ run(page: CheckPage, report: (node: CheckNode | null, message: string, fix?: IssueFix, subject?: string) => void): void;
150
+ }
151
+ /** A rule about the site as a whole. */
152
+ export interface SiteRule {
153
+ name: string;
154
+ severity: Severity;
155
+ run(site: CheckSite, report: (message: string, fix?: IssueFix, page?: string, subject?: string) => void): void;
156
+ }
157
+ /** Depth-first, in document order. */
158
+ export declare function walk(nodes: readonly CheckNode[], visit: (node: CheckNode) => void): void;
159
+ export declare function stripTags(html: string): string;
160
+ //# sourceMappingURL=types.d.ts.map