@markuplint/rules 5.0.0-alpha.2 → 5.0.0-dev.5
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/CHANGELOG.md +14 -0
- package/lib/attr-duplication/index.js +2 -0
- package/lib/attr-duplication/meta.d.ts +1 -0
- package/lib/attr-duplication/meta.js +1 -0
- package/lib/attr-order/index.d.ts +14 -0
- package/lib/attr-order/index.js +292 -0
- package/lib/attr-order/meta.d.ts +5 -0
- package/lib/attr-order/meta.js +4 -0
- package/lib/attr-value-quotes/meta.d.ts +1 -0
- package/lib/attr-value-quotes/meta.js +1 -0
- package/lib/case-sensitive-attr-name/meta.d.ts +1 -0
- package/lib/case-sensitive-attr-name/meta.js +1 -0
- package/lib/case-sensitive-tag-name/meta.d.ts +1 -0
- package/lib/case-sensitive-tag-name/meta.js +1 -0
- package/lib/head-element-order/index.d.ts +8 -0
- package/lib/head-element-order/index.js +147 -0
- package/lib/head-element-order/meta.d.ts +5 -0
- package/lib/head-element-order/meta.js +4 -0
- package/lib/helpers.d.ts +36 -1
- package/lib/helpers.js +68 -0
- package/lib/index.d.ts +10 -1
- package/lib/index.js +4 -0
- package/lib/ineffective-attr/index.js +2 -0
- package/lib/ineffective-attr/meta.d.ts +1 -0
- package/lib/ineffective-attr/meta.js +1 -0
- package/lib/no-boolean-attr-value/index.js +2 -0
- package/lib/no-boolean-attr-value/meta.d.ts +1 -0
- package/lib/no-boolean-attr-value/meta.js +1 -0
- package/lib/no-consecutive-br/index.js +3 -1
- package/lib/no-consecutive-br/meta.d.ts +1 -0
- package/lib/no-consecutive-br/meta.js +1 -0
- package/lib/no-default-value/index.js +2 -1
- package/lib/no-default-value/meta.d.ts +1 -0
- package/lib/no-default-value/meta.js +1 -0
- package/lib/no-duplicate-dt/index.js +2 -1
- package/lib/no-orphaned-end-tag/index.js +1 -0
- package/lib/no-orphaned-end-tag/meta.d.ts +1 -0
- package/lib/no-orphaned-end-tag/meta.js +1 -0
- package/lib/no-unsupported-features/compat-data.js +13 -1
- package/lib/no-use-event-handler-attr/index.d.ts +4 -1
- package/lib/no-use-event-handler-attr/index.js +43 -1
- package/lib/wai-aria/checkings/non-existent-role.js +1 -4
- package/lib/wai-aria/checkings/required-owned-elements.js +3 -1
- package/package.json +8 -8
- package/schema.json +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# [5.0.0-alpha.3](https://github.com/markuplint/markuplint/compare/v5.0.0-alpha.2...v5.0.0-alpha.3) (2026-02-26)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
- **rules:** remove redundant untranslated message from non-existent-role ([6c002bb](https://github.com/markuplint/markuplint/commit/6c002bb03d44c9a5561c4a40840820e1ada31025))
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
- **rules:** add attr-order rule for attribute sorting ([993c0a5](https://github.com/markuplint/markuplint/commit/993c0a53823206dfa24ba699bffc27e25b11ab00))
|
|
15
|
+
- **rules:** add fix callbacks to 6 rules and extract shared helpers ([a3bf3c6](https://github.com/markuplint/markuplint/commit/a3bf3c69c028917548c2cc762e1562e8d99dbd9b))
|
|
16
|
+
- **rules:** add fixable flag to rule metadata ([10e3f26](https://github.com/markuplint/markuplint/commit/10e3f266a3431a0cae9eb402f02f00031421ac7b))
|
|
17
|
+
- **rules:** add head-element-order rule for head element sorting ([4f72f35](https://github.com/markuplint/markuplint/commit/4f72f350fe2fceafbb811e3538e771834fb87854))
|
|
18
|
+
- **rules:** support event name array for no-use-event-handler-attr ([5e854b2](https://github.com/markuplint/markuplint/commit/5e854b2198aff43b83fd5b8a8f93f840c50536ea))
|
|
19
|
+
|
|
6
20
|
# [5.0.0-alpha.2](https://github.com/markuplint/markuplint/compare/v5.0.0-alpha.1...v5.0.0-alpha.2) (2026-02-23)
|
|
7
21
|
|
|
8
22
|
### Features
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createRule } from '@markuplint/ml-core';
|
|
2
|
+
import { removeAttr } from '../helpers.js';
|
|
2
3
|
import meta from './meta.js';
|
|
3
4
|
/**
|
|
4
5
|
* Rule that checks for duplicate attributes on the same element.
|
|
@@ -29,6 +30,7 @@ export default createRule({
|
|
|
29
30
|
raw: scope.raw,
|
|
30
31
|
},
|
|
31
32
|
message,
|
|
33
|
+
fix: fixer => removeAttr(fixer, attr),
|
|
32
34
|
});
|
|
33
35
|
}
|
|
34
36
|
else {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type PredefinedGroup = 'global' | 'event' | 'aria' | 'data' | 'spread';
|
|
2
|
+
type SortOrder = 'alphabetical' | 'source-order' | string[];
|
|
3
|
+
type OrderEntry = string | {
|
|
4
|
+
name?: string;
|
|
5
|
+
pattern?: string;
|
|
6
|
+
group?: PredefinedGroup;
|
|
7
|
+
order?: SortOrder;
|
|
8
|
+
};
|
|
9
|
+
type Value = OrderEntry[];
|
|
10
|
+
type Options = {
|
|
11
|
+
alphabetical?: boolean;
|
|
12
|
+
};
|
|
13
|
+
declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<Value, Options>>;
|
|
14
|
+
export default _default;
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { createRule } from '@markuplint/ml-core';
|
|
2
|
+
import meta from './meta.js';
|
|
3
|
+
function compareStrings(a, b) {
|
|
4
|
+
if (a < b) {
|
|
5
|
+
return -1;
|
|
6
|
+
}
|
|
7
|
+
if (a > b) {
|
|
8
|
+
return 1;
|
|
9
|
+
}
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
function buildAttrContent(attr) {
|
|
13
|
+
const parts = [
|
|
14
|
+
attr.nameNode,
|
|
15
|
+
attr.spacesBeforeEqual,
|
|
16
|
+
attr.equal,
|
|
17
|
+
attr.spacesAfterEqual,
|
|
18
|
+
attr.startQuote,
|
|
19
|
+
attr.valueNode,
|
|
20
|
+
attr.endQuote,
|
|
21
|
+
];
|
|
22
|
+
return parts.map(p => p?.raw ?? '').join('');
|
|
23
|
+
}
|
|
24
|
+
function getAttrRange(attrs) {
|
|
25
|
+
if (attrs.length === 0) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const first = attrs[0];
|
|
29
|
+
const last = attrs.at(-1);
|
|
30
|
+
const firstToken = first.tokens.spacesBeforeName ?? first.tokens.nameNode;
|
|
31
|
+
const lastAttr = last.tokens;
|
|
32
|
+
const lastTokens = [
|
|
33
|
+
lastAttr.endQuote,
|
|
34
|
+
lastAttr.valueNode,
|
|
35
|
+
lastAttr.startQuote,
|
|
36
|
+
lastAttr.spacesAfterEqual,
|
|
37
|
+
lastAttr.equal,
|
|
38
|
+
lastAttr.spacesBeforeEqual,
|
|
39
|
+
lastAttr.nameNode,
|
|
40
|
+
lastAttr.spacesBeforeName,
|
|
41
|
+
];
|
|
42
|
+
const lastToken = lastTokens.find(t => t != null && t.raw.length > 0);
|
|
43
|
+
if (!firstToken || !lastToken) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return [firstToken.startOffset, lastToken.startOffset + lastToken.raw.length];
|
|
47
|
+
}
|
|
48
|
+
function compileEntries(
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
50
|
+
entries,
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
52
|
+
globalAttrs,
|
|
53
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
54
|
+
eventAttrs) {
|
|
55
|
+
return entries.map((entry, entryIndex) => {
|
|
56
|
+
if (typeof entry === 'string') {
|
|
57
|
+
return {
|
|
58
|
+
entryIndex,
|
|
59
|
+
order: 'alphabetical',
|
|
60
|
+
match: (name) => name === entry,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const order = entry.order ?? 'alphabetical';
|
|
64
|
+
if (entry.name != null) {
|
|
65
|
+
return {
|
|
66
|
+
entryIndex,
|
|
67
|
+
order,
|
|
68
|
+
match: (name) => name === entry.name,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (entry.pattern != null) {
|
|
72
|
+
let re;
|
|
73
|
+
try {
|
|
74
|
+
re = new RegExp(entry.pattern);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Invalid regex pattern — skip this entry
|
|
78
|
+
return {
|
|
79
|
+
entryIndex,
|
|
80
|
+
order,
|
|
81
|
+
match: () => false,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
entryIndex,
|
|
86
|
+
order,
|
|
87
|
+
match: (name) => re.test(name),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (entry.group != null) {
|
|
91
|
+
const group = entry.group;
|
|
92
|
+
const groupOrder = group === 'spread' && entry.order == null ? 'source-order' : order;
|
|
93
|
+
switch (group) {
|
|
94
|
+
case 'global': {
|
|
95
|
+
return {
|
|
96
|
+
entryIndex,
|
|
97
|
+
order: groupOrder,
|
|
98
|
+
match: (name) => globalAttrs.has(name),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
case 'event': {
|
|
102
|
+
return {
|
|
103
|
+
entryIndex,
|
|
104
|
+
order: groupOrder,
|
|
105
|
+
match: (name) => eventAttrs.has(name),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
case 'aria': {
|
|
109
|
+
return {
|
|
110
|
+
entryIndex,
|
|
111
|
+
order: groupOrder,
|
|
112
|
+
match: (name) => /^aria-.+$/.test(name),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
case 'data': {
|
|
116
|
+
return {
|
|
117
|
+
entryIndex,
|
|
118
|
+
order: groupOrder,
|
|
119
|
+
match: (name) => /^data-.+$/.test(name),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
case 'spread': {
|
|
123
|
+
return {
|
|
124
|
+
entryIndex,
|
|
125
|
+
order: groupOrder,
|
|
126
|
+
match: (_name, isSpread) => isSpread,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
entryIndex,
|
|
133
|
+
order,
|
|
134
|
+
match: () => false,
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function matchEntry(
|
|
139
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
140
|
+
compiledEntries, name, isSpread, fallbackIndex) {
|
|
141
|
+
for (const entry of compiledEntries) {
|
|
142
|
+
if (entry.match(name, isSpread)) {
|
|
143
|
+
return { entryIndex: entry.entryIndex, order: entry.order };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { entryIndex: fallbackIndex, order: 'alphabetical' };
|
|
147
|
+
}
|
|
148
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
149
|
+
function compareWithinGroup(a, b, order) {
|
|
150
|
+
if (order === 'alphabetical') {
|
|
151
|
+
return compareStrings(a.name, b.name);
|
|
152
|
+
}
|
|
153
|
+
if (order === 'source-order') {
|
|
154
|
+
return a.index - b.index;
|
|
155
|
+
}
|
|
156
|
+
if (Array.isArray(order)) {
|
|
157
|
+
const ai = order.indexOf(a.name);
|
|
158
|
+
const bi = order.indexOf(b.name);
|
|
159
|
+
const aIdx = ai === -1 ? order.length : ai;
|
|
160
|
+
const bIdx = bi === -1 ? order.length : bi;
|
|
161
|
+
if (aIdx !== bIdx) {
|
|
162
|
+
return aIdx - bIdx;
|
|
163
|
+
}
|
|
164
|
+
if (ai === -1 && bi === -1) {
|
|
165
|
+
return compareStrings(a.name, b.name);
|
|
166
|
+
}
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
function sortAttrs(attrInfos,
|
|
172
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
173
|
+
compiledEntries, alphabetical) {
|
|
174
|
+
const fallbackIndex = compiledEntries.length;
|
|
175
|
+
const tagged = attrInfos.map(info => {
|
|
176
|
+
const { entryIndex, order } = matchEntry(compiledEntries, info.name, info.isSpread, fallbackIndex);
|
|
177
|
+
return { info, entryIndex, order };
|
|
178
|
+
});
|
|
179
|
+
return tagged
|
|
180
|
+
.toSorted((a, b) => {
|
|
181
|
+
if (a.entryIndex !== b.entryIndex) {
|
|
182
|
+
return a.entryIndex - b.entryIndex;
|
|
183
|
+
}
|
|
184
|
+
const isFallback = a.entryIndex === fallbackIndex;
|
|
185
|
+
if (isFallback) {
|
|
186
|
+
return alphabetical ? compareStrings(a.info.name, b.info.name) : a.info.index - b.info.index;
|
|
187
|
+
}
|
|
188
|
+
return compareWithinGroup(a.info, b.info, a.order);
|
|
189
|
+
})
|
|
190
|
+
.map(t => t.info);
|
|
191
|
+
}
|
|
192
|
+
export default createRule({
|
|
193
|
+
meta: meta,
|
|
194
|
+
defaultSeverity: 'warning',
|
|
195
|
+
defaultValue: [],
|
|
196
|
+
defaultOptions: {
|
|
197
|
+
alphabetical: true,
|
|
198
|
+
},
|
|
199
|
+
async verify({ document, report, t }) {
|
|
200
|
+
const globalAttrs = new Set();
|
|
201
|
+
const eventAttrs = new Set();
|
|
202
|
+
const defs = document.specs.def?.['#globalAttrs'];
|
|
203
|
+
if (defs != null) {
|
|
204
|
+
const htmlGlobal = defs['#HTMLGlobalAttrs'];
|
|
205
|
+
if (htmlGlobal) {
|
|
206
|
+
for (const name of Object.keys(htmlGlobal)) {
|
|
207
|
+
globalAttrs.add(name);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const eventGlobal = defs['#GlobalEventAttrs'];
|
|
211
|
+
if (eventGlobal) {
|
|
212
|
+
for (const name of Object.keys(eventGlobal)) {
|
|
213
|
+
eventAttrs.add(name);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
let cachedValue;
|
|
218
|
+
let cachedCompiled;
|
|
219
|
+
await document.walkOn('Element', node => {
|
|
220
|
+
const attrs = node.getAttributeTokens();
|
|
221
|
+
if (attrs.length <= 1) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const value = node.rule.value;
|
|
225
|
+
const options = node.rule.options;
|
|
226
|
+
const alphabetical = options.alphabetical ?? true;
|
|
227
|
+
const ms = node.rule.severity === 'error' ? 'must' : 'should';
|
|
228
|
+
const entries = value.length > 0 ? value : [];
|
|
229
|
+
const isAlphabeticalOnly = entries.length === 0;
|
|
230
|
+
let compiledEntries;
|
|
231
|
+
if (cachedValue === value) {
|
|
232
|
+
compiledEntries = cachedCompiled;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
compiledEntries = compileEntries(entries, globalAttrs, eventAttrs);
|
|
236
|
+
cachedValue = value;
|
|
237
|
+
cachedCompiled = compiledEntries;
|
|
238
|
+
}
|
|
239
|
+
const attrInfos = attrs.map((attr, i) => {
|
|
240
|
+
const nameNode = attr.nameNode;
|
|
241
|
+
return {
|
|
242
|
+
name: attr.name,
|
|
243
|
+
index: i,
|
|
244
|
+
isSpread: nameNode == null,
|
|
245
|
+
spacing: attr.spacesBeforeName?.raw ?? '',
|
|
246
|
+
content: buildAttrContent(attr),
|
|
247
|
+
tokens: attr,
|
|
248
|
+
scopeNode: nameNode ?? attr.spacesBeforeName,
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
const sorted = isAlphabeticalOnly
|
|
252
|
+
? alphabetical
|
|
253
|
+
? [...attrInfos].toSorted((a, b) => compareStrings(a.name, b.name))
|
|
254
|
+
: attrInfos
|
|
255
|
+
: sortAttrs(attrInfos, compiledEntries, alphabetical);
|
|
256
|
+
// Find first violation
|
|
257
|
+
for (let i = 0; i < attrInfos.length; i++) {
|
|
258
|
+
if (attrInfos[i] !== sorted[i]) {
|
|
259
|
+
const actual = attrInfos[i];
|
|
260
|
+
const expected = sorted[i];
|
|
261
|
+
const scope = expected.scopeNode;
|
|
262
|
+
if (!scope) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
report({
|
|
266
|
+
scope: {
|
|
267
|
+
rule: node.rule,
|
|
268
|
+
startLine: scope.startLine,
|
|
269
|
+
startCol: scope.startCol,
|
|
270
|
+
raw: scope.raw,
|
|
271
|
+
},
|
|
272
|
+
message: t(`{0} ${ms} be before {1}`, `"${expected.name || '(spread)'}"`, `"${actual.name || '(spread)'}"`),
|
|
273
|
+
fix: fixer => {
|
|
274
|
+
const range = getAttrRange(attrInfos);
|
|
275
|
+
if (!range) {
|
|
276
|
+
return [];
|
|
277
|
+
}
|
|
278
|
+
const newText = sorted
|
|
279
|
+
.map((info, j) => {
|
|
280
|
+
const spacing = attrInfos[j].spacing;
|
|
281
|
+
return spacing + info.content;
|
|
282
|
+
})
|
|
283
|
+
.join('');
|
|
284
|
+
return fixer.replaceRange(range, newText);
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
},
|
|
292
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
type OrderEntry = {
|
|
2
|
+
readonly selector: string;
|
|
3
|
+
readonly order?: 'source-order' | 'alphabetical';
|
|
4
|
+
readonly attr?: string;
|
|
5
|
+
};
|
|
6
|
+
export type Value = readonly (string | OrderEntry)[];
|
|
7
|
+
declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<Value, undefined>>;
|
|
8
|
+
export default _default;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createRule } from '@markuplint/ml-core';
|
|
2
|
+
import meta from './meta.js';
|
|
3
|
+
const DEFAULT_VALUE = [
|
|
4
|
+
'meta[charset]',
|
|
5
|
+
'meta[http-equiv]',
|
|
6
|
+
'meta[name="viewport"]',
|
|
7
|
+
'title',
|
|
8
|
+
{ selector: 'meta', order: 'alphabetical', attr: 'name' },
|
|
9
|
+
'link',
|
|
10
|
+
'style',
|
|
11
|
+
'script',
|
|
12
|
+
];
|
|
13
|
+
function normalizeEntries(value) {
|
|
14
|
+
return value.map(entry => {
|
|
15
|
+
if (typeof entry === 'string') {
|
|
16
|
+
return { selector: entry, order: 'source-order' };
|
|
17
|
+
}
|
|
18
|
+
return { selector: entry.selector, order: entry.order ?? 'source-order', attr: entry.attr };
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export default createRule({
|
|
22
|
+
meta: meta,
|
|
23
|
+
defaultSeverity: 'warning',
|
|
24
|
+
defaultValue: DEFAULT_VALUE,
|
|
25
|
+
verify({ document, report, t }) {
|
|
26
|
+
const head = document.querySelector('head');
|
|
27
|
+
if (!head) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const children = [...head.children];
|
|
31
|
+
if (children.length <= 1) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const value = head.rule.value ?? DEFAULT_VALUE;
|
|
35
|
+
const entries = normalizeEntries(value);
|
|
36
|
+
// Use entries.length as the unmatched group index instead of Infinity
|
|
37
|
+
// to avoid potential Infinity arithmetic issues (e.g. Infinity - Infinity = NaN).
|
|
38
|
+
const unmatchedGroupIndex = entries.length;
|
|
39
|
+
// Use offset-based text extraction instead of AST traversal because
|
|
40
|
+
// raw text elements (script, style) have empty childNodes in the AST.
|
|
41
|
+
const docString = document.toString();
|
|
42
|
+
const blocks = children.map((el, sourceIndex) => {
|
|
43
|
+
let groupIndex = unmatchedGroupIndex;
|
|
44
|
+
for (const [i, entry_] of entries.entries()) {
|
|
45
|
+
if (el.matches(entry_.selector)) {
|
|
46
|
+
groupIndex = i;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const entry = groupIndex < entries.length ? entries[groupIndex] : undefined;
|
|
51
|
+
let subKey;
|
|
52
|
+
if (entry && entry.order === 'alphabetical' && entry.attr) {
|
|
53
|
+
subKey = el.getAttribute(entry.attr) ?? '';
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
subKey = sourceIndex;
|
|
57
|
+
}
|
|
58
|
+
const endOffset = getElementEndOffset(el);
|
|
59
|
+
const elementText = docString.slice(el.startOffset, endOffset);
|
|
60
|
+
return {
|
|
61
|
+
startOffset: el.startOffset,
|
|
62
|
+
groupIndex,
|
|
63
|
+
subKey,
|
|
64
|
+
sourceIndex,
|
|
65
|
+
elementText,
|
|
66
|
+
localName: el.localName,
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
const sorted = blocks.toSorted((a, b) => {
|
|
70
|
+
if (a.groupIndex !== b.groupIndex) {
|
|
71
|
+
return a.groupIndex - b.groupIndex;
|
|
72
|
+
}
|
|
73
|
+
if (typeof a.subKey === 'string' && typeof b.subKey === 'string') {
|
|
74
|
+
// Use simple comparison operators instead of localeCompare to ensure
|
|
75
|
+
// consistent ordering across different environments and locales.
|
|
76
|
+
if (a.subKey < b.subKey) {
|
|
77
|
+
return -1;
|
|
78
|
+
}
|
|
79
|
+
if (a.subKey > b.subKey) {
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return a.sourceIndex - b.sourceIndex;
|
|
84
|
+
});
|
|
85
|
+
// Only the first violation carries a fix because it replaces
|
|
86
|
+
// the entire <head> content at once to reorder all elements.
|
|
87
|
+
const ms = head.rule.severity === 'error' ? 'must' : 'should';
|
|
88
|
+
let firstViolation = true;
|
|
89
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
90
|
+
if (blocks[i].sourceIndex !== sorted[i].sourceIndex) {
|
|
91
|
+
const currentEl = children[i];
|
|
92
|
+
const expectedInfo = sorted[i];
|
|
93
|
+
const currentInfo = blocks[i];
|
|
94
|
+
report({
|
|
95
|
+
scope: currentEl,
|
|
96
|
+
message: t(`{0} ${ms} be before {1}`, t('the "{0*}" {1}', expectedInfo.localName, 'element'), t('the "{0*}" {1}', currentInfo.localName, 'element')),
|
|
97
|
+
fix: firstViolation
|
|
98
|
+
? fixer => {
|
|
99
|
+
const headOpenEnd = head.startOffset + head.raw.length;
|
|
100
|
+
const lastChild = children.at(-1);
|
|
101
|
+
const headCloseStart = head.closeTag
|
|
102
|
+
? head.closeTag.startOffset
|
|
103
|
+
: getElementEndOffset(lastChild);
|
|
104
|
+
const originalContent = docString.slice(headOpenEnd, headCloseStart);
|
|
105
|
+
const newContent = buildFixedContent(originalContent, headOpenEnd, blocks, sorted);
|
|
106
|
+
return fixer.replaceRange([headOpenEnd, headCloseStart], newContent);
|
|
107
|
+
}
|
|
108
|
+
: undefined,
|
|
109
|
+
});
|
|
110
|
+
firstViolation = false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
function getElementEndOffset(el) {
|
|
116
|
+
if (el.closeTag) {
|
|
117
|
+
return el.closeTag.startOffset + el.closeTag.raw.length;
|
|
118
|
+
}
|
|
119
|
+
return el.startOffset + el.raw.length;
|
|
120
|
+
}
|
|
121
|
+
function buildFixedContent(originalContent, headOpenEnd, blocks, sorted) {
|
|
122
|
+
const prefixes = [];
|
|
123
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
124
|
+
const block = blocks[i];
|
|
125
|
+
const elStart = block.startOffset - headOpenEnd;
|
|
126
|
+
let prefixStart;
|
|
127
|
+
if (i === 0) {
|
|
128
|
+
prefixStart = 0;
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
const prevBlock = blocks[i - 1];
|
|
132
|
+
const prevEnd = prevBlock.startOffset - headOpenEnd + prevBlock.elementText.length;
|
|
133
|
+
prefixStart = prevEnd;
|
|
134
|
+
}
|
|
135
|
+
prefixes.push(originalContent.slice(prefixStart, elStart));
|
|
136
|
+
}
|
|
137
|
+
const lastBlock = blocks.at(-1);
|
|
138
|
+
const trailStart = lastBlock.startOffset - headOpenEnd + lastBlock.elementText.length;
|
|
139
|
+
const trailing = originalContent.slice(trailStart);
|
|
140
|
+
let result = '';
|
|
141
|
+
for (const [i, element] of sorted.entries()) {
|
|
142
|
+
result += prefixes[i];
|
|
143
|
+
result += element.elementText;
|
|
144
|
+
}
|
|
145
|
+
result += trailing;
|
|
146
|
+
return result;
|
|
147
|
+
}
|
package/lib/helpers.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Log } from './debug.js';
|
|
2
2
|
import type { Translator } from '@markuplint/i18n';
|
|
3
|
-
import type { PlainData } from '@markuplint/ml-config';
|
|
3
|
+
import type { FixToken, IRuleFixer, PlainData, TextEdit } from '@markuplint/ml-config';
|
|
4
4
|
import type { Element, RuleConfigValue, Document } from '@markuplint/ml-core';
|
|
5
5
|
import type { Attribute } from '@markuplint/ml-spec';
|
|
6
6
|
import type { WritableDeep } from 'type-fest';
|
|
@@ -155,3 +155,38 @@ export declare class Collection<T> {
|
|
|
155
155
|
* @returns A mutable deep clone of the input value
|
|
156
156
|
*/
|
|
157
157
|
export declare function deepCopy<T>(value: T): WritableDeep<T>;
|
|
158
|
+
/**
|
|
159
|
+
* Creates a fix that removes an entire attribute (name + value + surrounding whitespace).
|
|
160
|
+
* Used by rules that need to remove a whole attribute from an element.
|
|
161
|
+
*
|
|
162
|
+
* @param fixer - The rule fixer instance for building TextEdits
|
|
163
|
+
* @param attr - The attribute token parts to remove (from `spacesBeforeName` through `endQuote`)
|
|
164
|
+
* @returns A TextEdit (or empty array if no valid tokens exist) that removes the attribute
|
|
165
|
+
*/
|
|
166
|
+
export declare function removeAttr(fixer: IRuleFixer, attr: {
|
|
167
|
+
readonly spacesBeforeName: FixToken | null;
|
|
168
|
+
readonly nameNode: FixToken | null;
|
|
169
|
+
readonly spacesBeforeEqual: FixToken | null;
|
|
170
|
+
readonly equal: FixToken | null;
|
|
171
|
+
readonly spacesAfterEqual: FixToken | null;
|
|
172
|
+
readonly startQuote: FixToken | null;
|
|
173
|
+
readonly valueNode: FixToken | null;
|
|
174
|
+
readonly endQuote: FixToken | null;
|
|
175
|
+
}): TextEdit | TextEdit[];
|
|
176
|
+
/**
|
|
177
|
+
* Creates a fix that removes only the value portion of an attribute
|
|
178
|
+
* (equals sign, quotes, and value), keeping the attribute name.
|
|
179
|
+
* Used by `no-boolean-attr-value` to convert `disabled="disabled"` to `disabled`.
|
|
180
|
+
*
|
|
181
|
+
* @param fixer - The rule fixer instance for building TextEdits
|
|
182
|
+
* @param attr - The attribute token parts to remove (from `spacesBeforeEqual` through `endQuote`)
|
|
183
|
+
* @returns A TextEdit (or empty array if no valid tokens exist) that removes the value portion
|
|
184
|
+
*/
|
|
185
|
+
export declare function removeAttrValue(fixer: IRuleFixer, attr: {
|
|
186
|
+
readonly spacesBeforeEqual: FixToken | null;
|
|
187
|
+
readonly equal: FixToken | null;
|
|
188
|
+
readonly spacesAfterEqual: FixToken | null;
|
|
189
|
+
readonly startQuote: FixToken | null;
|
|
190
|
+
readonly valueNode: FixToken | null;
|
|
191
|
+
readonly endQuote: FixToken | null;
|
|
192
|
+
}): TextEdit | TextEdit[];
|
package/lib/helpers.js
CHANGED
|
@@ -263,3 +263,71 @@ export class Collection {
|
|
|
263
263
|
export function deepCopy(value) {
|
|
264
264
|
return structuredClone(value);
|
|
265
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* Computes a removal range from a list of nullable tokens.
|
|
268
|
+
* Filters out null/undefined tokens and tokens with empty `raw`,
|
|
269
|
+
* then returns the range spanning from the first to the last token.
|
|
270
|
+
*
|
|
271
|
+
* @param tokens - An array of nullable tokens to compute the range from
|
|
272
|
+
* @returns `[start, end]` tuple, or `null` if no valid tokens
|
|
273
|
+
*/
|
|
274
|
+
function tokenRange(tokens) {
|
|
275
|
+
const valid = tokens.filter((t) => t != null && t.raw.length > 0);
|
|
276
|
+
const first = valid[0];
|
|
277
|
+
const last = valid.at(-1);
|
|
278
|
+
if (!first || !last) {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
return [first.startOffset, last.startOffset + last.raw.length];
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Creates a fix that removes an entire attribute (name + value + surrounding whitespace).
|
|
285
|
+
* Used by rules that need to remove a whole attribute from an element.
|
|
286
|
+
*
|
|
287
|
+
* @param fixer - The rule fixer instance for building TextEdits
|
|
288
|
+
* @param attr - The attribute token parts to remove (from `spacesBeforeName` through `endQuote`)
|
|
289
|
+
* @returns A TextEdit (or empty array if no valid tokens exist) that removes the attribute
|
|
290
|
+
*/
|
|
291
|
+
export function removeAttr(
|
|
292
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
293
|
+
fixer, attr) {
|
|
294
|
+
const range = tokenRange([
|
|
295
|
+
attr.spacesBeforeName,
|
|
296
|
+
attr.nameNode,
|
|
297
|
+
attr.spacesBeforeEqual,
|
|
298
|
+
attr.equal,
|
|
299
|
+
attr.spacesAfterEqual,
|
|
300
|
+
attr.startQuote,
|
|
301
|
+
attr.valueNode,
|
|
302
|
+
attr.endQuote,
|
|
303
|
+
]);
|
|
304
|
+
if (!range) {
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
return fixer.removeRange(range);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Creates a fix that removes only the value portion of an attribute
|
|
311
|
+
* (equals sign, quotes, and value), keeping the attribute name.
|
|
312
|
+
* Used by `no-boolean-attr-value` to convert `disabled="disabled"` to `disabled`.
|
|
313
|
+
*
|
|
314
|
+
* @param fixer - The rule fixer instance for building TextEdits
|
|
315
|
+
* @param attr - The attribute token parts to remove (from `spacesBeforeEqual` through `endQuote`)
|
|
316
|
+
* @returns A TextEdit (or empty array if no valid tokens exist) that removes the value portion
|
|
317
|
+
*/
|
|
318
|
+
export function removeAttrValue(
|
|
319
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
320
|
+
fixer, attr) {
|
|
321
|
+
const range = tokenRange([
|
|
322
|
+
attr.spacesBeforeEqual,
|
|
323
|
+
attr.equal,
|
|
324
|
+
attr.spacesAfterEqual,
|
|
325
|
+
attr.startQuote,
|
|
326
|
+
attr.valueNode,
|
|
327
|
+
attr.endQuote,
|
|
328
|
+
]);
|
|
329
|
+
if (!range) {
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
return fixer.removeRange(range);
|
|
333
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
*/
|
|
13
13
|
declare const rules: {
|
|
14
14
|
readonly 'attr-duplication': Readonly<import("@markuplint/ml-core").RuleSeed<import("@markuplint/ml-core").RuleConfigValue, undefined>>;
|
|
15
|
+
readonly 'attr-order': Readonly<import("@markuplint/ml-core").RuleSeed<(string | {
|
|
16
|
+
name?: string;
|
|
17
|
+
pattern?: string;
|
|
18
|
+
group?: "global" | "event" | "aria" | "data" | "spread";
|
|
19
|
+
order?: "alphabetical" | string[] | "source-order";
|
|
20
|
+
})[], {
|
|
21
|
+
alphabetical?: boolean;
|
|
22
|
+
}>>;
|
|
15
23
|
readonly 'attr-value-quotes': Readonly<import("@markuplint/ml-core").RuleSeed<import("./attr-value-quotes/index.js").Type, undefined>>;
|
|
16
24
|
readonly 'case-sensitive-attr-name': Readonly<import("@markuplint/ml-core").RuleSeed<import("./case-sensitive-attr-name/index.js").Value, undefined>>;
|
|
17
25
|
readonly 'case-sensitive-tag-name': Readonly<import("@markuplint/ml-core").RuleSeed<import("./case-sensitive-tag-name/index.js").Value, undefined>>;
|
|
@@ -27,6 +35,7 @@ declare const rules: {
|
|
|
27
35
|
denyObsoleteType: boolean;
|
|
28
36
|
}>>;
|
|
29
37
|
readonly 'end-tag': Readonly<import("@markuplint/ml-core").RuleSeed<boolean, undefined>>;
|
|
38
|
+
readonly 'head-element-order': Readonly<import("@markuplint/ml-core").RuleSeed<import("./head-element-order/index.js").Value, undefined>>;
|
|
30
39
|
readonly 'heading-levels': Readonly<import("@markuplint/ml-core").RuleSeed<boolean, null>>;
|
|
31
40
|
readonly 'id-duplication': Readonly<import("@markuplint/ml-core").RuleSeed<import("@markuplint/ml-core").RuleConfigValue, undefined>>;
|
|
32
41
|
readonly 'ineffective-attr': Readonly<import("@markuplint/ml-core").RuleSeed<import("@markuplint/ml-core").RuleConfigValue, undefined>>;
|
|
@@ -74,7 +83,7 @@ declare const rules: {
|
|
|
74
83
|
readonly checkExperimental?: boolean;
|
|
75
84
|
readonly checkNonStandard?: boolean;
|
|
76
85
|
}>>;
|
|
77
|
-
readonly 'no-use-event-handler-attr': Readonly<import("@markuplint/ml-core").RuleSeed<boolean, {
|
|
86
|
+
readonly 'no-use-event-handler-attr': Readonly<import("@markuplint/ml-core").RuleSeed<boolean | readonly string[], {
|
|
78
87
|
ignore?: string | string[];
|
|
79
88
|
}>>;
|
|
80
89
|
readonly 'permitted-contents': Readonly<import("@markuplint/ml-core").RuleSeed<import("./permitted-contents/types.js").TagRule[], import("./permitted-contents/types.js").Options>>;
|
package/lib/index.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* areas such as accessibility, HTML validity, naming conventions, and coding style.
|
|
8
8
|
*/
|
|
9
9
|
import AttrDuplication from './attr-duplication/index.js';
|
|
10
|
+
import AttrOrder from './attr-order/index.js';
|
|
10
11
|
import AttrValueQuotes from './attr-value-quotes/index.js';
|
|
11
12
|
import CaseSensitiveAttrName from './case-sensitive-attr-name/index.js';
|
|
12
13
|
import CaseSensitiveTagName from './case-sensitive-tag-name/index.js';
|
|
@@ -18,6 +19,7 @@ import DeprecatedElement from './deprecated-element/index.js';
|
|
|
18
19
|
import DisallowedElement from './disallowed-element/index.js';
|
|
19
20
|
import Doctype from './doctype/index.js';
|
|
20
21
|
import EndTag from './end-tag/index.js';
|
|
22
|
+
import HeadElementOrder from './head-element-order/index.js';
|
|
21
23
|
import HeadingLevels from './heading-levels/index.js';
|
|
22
24
|
import IdDuplication from './id-duplication/index.js';
|
|
23
25
|
import IneffectiveAttr from './ineffective-attr/index.js';
|
|
@@ -56,6 +58,7 @@ import WaiAria from './wai-aria/index.js';
|
|
|
56
58
|
*/
|
|
57
59
|
const rules = {
|
|
58
60
|
'attr-duplication': AttrDuplication,
|
|
61
|
+
'attr-order': AttrOrder,
|
|
59
62
|
'attr-value-quotes': AttrValueQuotes,
|
|
60
63
|
'case-sensitive-attr-name': CaseSensitiveAttrName,
|
|
61
64
|
'case-sensitive-tag-name': CaseSensitiveTagName,
|
|
@@ -67,6 +70,7 @@ const rules = {
|
|
|
67
70
|
'disallowed-element': DisallowedElement,
|
|
68
71
|
doctype: Doctype,
|
|
69
72
|
'end-tag': EndTag,
|
|
73
|
+
'head-element-order': HeadElementOrder,
|
|
70
74
|
'heading-levels': HeadingLevels,
|
|
71
75
|
'id-duplication': IdDuplication,
|
|
72
76
|
'ineffective-attr': IneffectiveAttr,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createRule, getAttrSpecs } from '@markuplint/ml-core';
|
|
2
2
|
import { toNoEmptyStringArrayFromStringOrArray } from '@markuplint/shared';
|
|
3
|
+
import { removeAttr } from '../helpers.js';
|
|
3
4
|
import meta from './meta.js';
|
|
4
5
|
/**
|
|
5
6
|
* Rule that detects attributes that have no effect on their owner element.
|
|
@@ -29,6 +30,7 @@ export default createRule({
|
|
|
29
30
|
message: t('{0} is {1:c}', t('the "{0*}" {1}', name, 'attribute'), 'ineffective') +
|
|
30
31
|
t('. ') +
|
|
31
32
|
t("It doesn't need {0}", t('the {0}', 'attribute')),
|
|
33
|
+
fix: fixer => removeAttr(fixer, attr),
|
|
32
34
|
});
|
|
33
35
|
}
|
|
34
36
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createRule, getAttrSpecs } from '@markuplint/ml-core';
|
|
2
|
+
import { removeAttrValue } from '../helpers.js';
|
|
2
3
|
import meta from './meta.js';
|
|
3
4
|
/**
|
|
4
5
|
* Rule that disallows explicit values on boolean attributes.
|
|
@@ -45,6 +46,7 @@ export default createRule({
|
|
|
45
46
|
message: t('{0} is {1}', t('the "{0*}" {1}', name, 'attribute'), t('a {0}', 'boolean attribute')) +
|
|
46
47
|
t('. ') +
|
|
47
48
|
t("It doesn't need {0}", t('the {0}', 'value')),
|
|
49
|
+
fix: fixer => removeAttrValue(fixer, attr),
|
|
48
50
|
});
|
|
49
51
|
}
|
|
50
52
|
});
|
|
@@ -19,9 +19,11 @@ export default createRule({
|
|
|
19
19
|
next = next.nextSibling;
|
|
20
20
|
}
|
|
21
21
|
if (next && next.nodeName === 'BR') {
|
|
22
|
+
const target = next;
|
|
22
23
|
report({
|
|
23
|
-
scope:
|
|
24
|
+
scope: target,
|
|
24
25
|
message: t('{0} detected', t('Consecutive {0}', t('"{0*}" {1}', 'br', 'elements'))),
|
|
26
|
+
fix: fixer => fixer.remove({ startOffset: target.startOffset, raw: target.raw }),
|
|
25
27
|
});
|
|
26
28
|
}
|
|
27
29
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRule, getAttrSpecs } from '@markuplint/ml-core';
|
|
2
|
-
import { toNormalizedValue } from '../helpers.js';
|
|
2
|
+
import { removeAttr, toNormalizedValue } from '../helpers.js';
|
|
3
3
|
import meta from './meta.js';
|
|
4
4
|
/**
|
|
5
5
|
* Rule that disallows explicitly setting an attribute to its default value.
|
|
@@ -33,6 +33,7 @@ export default createRule({
|
|
|
33
33
|
col: attr.valueNode?.startCol,
|
|
34
34
|
raw: attr.valueNode?.raw,
|
|
35
35
|
message: t('It is {0}', t('the {0}', 'default value')),
|
|
36
|
+
fix: fixer => removeAttr(fixer, attr),
|
|
36
37
|
});
|
|
37
38
|
}
|
|
38
39
|
});
|
|
@@ -14,7 +14,8 @@ export default createRule({
|
|
|
14
14
|
const dtList = [...dl.querySelectorAll(':scope > dt, :scope > div > dt')];
|
|
15
15
|
const names = new Set();
|
|
16
16
|
for (const dt of dtList) {
|
|
17
|
-
//
|
|
17
|
+
// Support for alternative text for images and accessible names for contained elements
|
|
18
|
+
// is not yet implemented; currently only textContent is used for comparison.
|
|
18
19
|
const name = dt.textContent?.trim();
|
|
19
20
|
if (!name) {
|
|
20
21
|
continue;
|
|
@@ -24,12 +24,24 @@ let bcdPromise;
|
|
|
24
24
|
* at startup, even when this rule is disabled. Dynamic import defers the
|
|
25
25
|
* cost until the rule actually runs.
|
|
26
26
|
*
|
|
27
|
+
* Uses `forLegacyNode` to avoid `ERR_IMPORT_ATTRIBUTE_MISSING` on Node.js >= 22.
|
|
28
|
+
* The main entry (`@mdn/browser-compat-data`) points directly to `data.json`,
|
|
29
|
+
* which requires `import ... with { type: "json" }` on Node.js >= 22.
|
|
30
|
+
* The `forLegacyNode` wrapper uses `fs.readFileSync` instead, compatible
|
|
31
|
+
* with all Node.js versions.
|
|
32
|
+
*
|
|
33
|
+
* NOTE: If `forLegacyNode` is removed in a future BCD version, migrate to:
|
|
34
|
+
* `import('...', { with: { type: 'json' } })`
|
|
35
|
+
* once the minimum supported Node.js version supports import attributes.
|
|
36
|
+
*
|
|
27
37
|
* Uses a Promise-based cache to prevent race conditions when multiple
|
|
28
38
|
* concurrent calls trigger the import simultaneously.
|
|
29
39
|
*/
|
|
30
40
|
function loadBcd() {
|
|
31
41
|
if (!bcdPromise) {
|
|
32
|
-
|
|
42
|
+
// `?? mod` is a defensive fallback for CJS interop environments
|
|
43
|
+
// where `default` may not be set on the module namespace object.
|
|
44
|
+
bcdPromise = import('@mdn/browser-compat-data/forLegacyNode').then(mod => mod.default ?? mod);
|
|
33
45
|
}
|
|
34
46
|
return bcdPromise;
|
|
35
47
|
}
|
|
@@ -10,6 +10,9 @@ type Options = {
|
|
|
10
10
|
* Reports any attribute on an HTML element whose name starts with `on`,
|
|
11
11
|
* indicating an inline event handler. An `ignore` option allows specific
|
|
12
12
|
* attribute names or patterns to be excluded from the check.
|
|
13
|
+
*
|
|
14
|
+
* The value can be `true` (disallow all event handlers) or a `string[]`
|
|
15
|
+
* of event names (without the `on` prefix) to selectively disallow.
|
|
13
16
|
*/
|
|
14
|
-
declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<boolean, Options>>;
|
|
17
|
+
declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<boolean | readonly string[], Options>>;
|
|
15
18
|
export default _default;
|
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import { createRule } from '@markuplint/ml-core';
|
|
2
2
|
import { match } from '../helpers.js';
|
|
3
3
|
import meta from './meta.js';
|
|
4
|
+
/**
|
|
5
|
+
* Extracts the event name from an attribute name by stripping the `on` prefix
|
|
6
|
+
* and lowercasing. Returns `null` if the attribute is not an event handler.
|
|
7
|
+
*/
|
|
8
|
+
function extractEventName(attrName) {
|
|
9
|
+
const m = /^on(.+)/i.exec(attrName);
|
|
10
|
+
return m?.[1] ? m[1].toLowerCase() : null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Tests whether an event name matches a pattern. The pattern is either a plain
|
|
14
|
+
* string (compared case-insensitively) or a regex literal (`/pattern/flags`).
|
|
15
|
+
*/
|
|
16
|
+
function matchEventName(eventName, pattern) {
|
|
17
|
+
const regexMatch = /^\/(.*)\/([gim])*$/.exec(pattern);
|
|
18
|
+
if (regexMatch?.[1]) {
|
|
19
|
+
return new RegExp(regexMatch[1], regexMatch[2]).test(eventName);
|
|
20
|
+
}
|
|
21
|
+
return eventName === pattern.toLowerCase();
|
|
22
|
+
}
|
|
4
23
|
/**
|
|
5
24
|
* Rule that disallows inline event handler attributes (e.g., `onclick`,
|
|
6
25
|
* `onchange`).
|
|
@@ -8,6 +27,9 @@ import meta from './meta.js';
|
|
|
8
27
|
* Reports any attribute on an HTML element whose name starts with `on`,
|
|
9
28
|
* indicating an inline event handler. An `ignore` option allows specific
|
|
10
29
|
* attribute names or patterns to be excluded from the check.
|
|
30
|
+
*
|
|
31
|
+
* The value can be `true` (disallow all event handlers) or a `string[]`
|
|
32
|
+
* of event names (without the `on` prefix) to selectively disallow.
|
|
11
33
|
*/
|
|
12
34
|
export default createRule({
|
|
13
35
|
meta: meta,
|
|
@@ -29,7 +51,12 @@ export default createRule({
|
|
|
29
51
|
return;
|
|
30
52
|
}
|
|
31
53
|
}
|
|
32
|
-
|
|
54
|
+
const eventName = extractEventName(name);
|
|
55
|
+
if (eventName == null) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const { value } = attr.rule;
|
|
59
|
+
if (value === true) {
|
|
33
60
|
report({
|
|
34
61
|
scope: attr,
|
|
35
62
|
raw: attr.raw,
|
|
@@ -37,6 +64,21 @@ export default createRule({
|
|
|
37
64
|
col: attr.startCol,
|
|
38
65
|
message: t('{0} is disallowed', t('the "{0*}" {1}', name, 'attribute')),
|
|
39
66
|
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
for (const pattern of value) {
|
|
71
|
+
if (matchEventName(eventName, pattern)) {
|
|
72
|
+
report({
|
|
73
|
+
scope: attr,
|
|
74
|
+
raw: attr.raw,
|
|
75
|
+
line: attr.startLine,
|
|
76
|
+
col: attr.startCol,
|
|
77
|
+
message: t('{0} is disallowed', t('the "{0*}" {1}', name, 'attribute')),
|
|
78
|
+
});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
40
82
|
}
|
|
41
83
|
});
|
|
42
84
|
},
|
|
@@ -30,10 +30,7 @@ export const checkingNonExistentRole = ({ attr }) => t => {
|
|
|
30
30
|
if (!role) {
|
|
31
31
|
return {
|
|
32
32
|
scope: token,
|
|
33
|
-
message: t('{0} according to {1}', t('{0} does not exist', t('the "{0*}" {1}', token.raw, 'role')), 'the WAI-ARIA specification') +
|
|
34
|
-
t('.') +
|
|
35
|
-
// TODO: Translate
|
|
36
|
-
` This "${token.raw}" role does not exist in WAI-ARIA.`,
|
|
33
|
+
message: t('{0} according to {1}', t('{0} does not exist', t('the "{0*}" {1}', token.raw, 'role')), 'the WAI-ARIA specification') + t('.'),
|
|
37
34
|
};
|
|
38
35
|
}
|
|
39
36
|
}
|
|
@@ -33,7 +33,9 @@ export const checkingRequiredOwnedElements = ({ el, role }) => t => {
|
|
|
33
33
|
if (el.matches('[aria-busy="true" i]')) {
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
36
|
-
// TODO: Needs to resolve `aria-
|
|
36
|
+
// TODO: Needs to resolve `aria-owns` references to include virtually owned elements.
|
|
37
|
+
// Currently, only DOM-tree children are checked. Elements referenced via `aria-owns`
|
|
38
|
+
// should also be considered as owned elements per the ARIA specification.
|
|
37
39
|
const ariaVersion = el.rule.options?.version ?? el.ownerMLDocument.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
|
|
38
40
|
const children = classifyChildren(el, role, ariaVersion);
|
|
39
41
|
if (children.some(([, type]) => type === 'BUSY')) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markuplint/rules",
|
|
3
|
-
"version": "5.0.0-
|
|
3
|
+
"version": "5.0.0-dev.5+e96392f56",
|
|
4
4
|
"description": "Built-in rules of markuplint",
|
|
5
5
|
"repository": "git@github.com:markuplint/markuplint.git",
|
|
6
6
|
"author": "Yusuke Hirao <yusukehirao@me.com>",
|
|
@@ -27,12 +27,12 @@
|
|
|
27
27
|
"./lib/permitted-contents/debug.js": "./lib/permitted-contents/debug.browser.js"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@markuplint/html-spec": "5.0.0-
|
|
31
|
-
"@markuplint/ml-core": "5.0.0-
|
|
32
|
-
"@markuplint/ml-spec": "5.0.0-
|
|
33
|
-
"@markuplint/selector": "5.0.0-
|
|
34
|
-
"@markuplint/shared": "5.0.0-
|
|
35
|
-
"@markuplint/types": "5.0.0-
|
|
30
|
+
"@markuplint/html-spec": "5.0.0-dev.5+e96392f56",
|
|
31
|
+
"@markuplint/ml-core": "5.0.0-dev.5+e96392f56",
|
|
32
|
+
"@markuplint/ml-spec": "5.0.0-dev.5+e96392f56",
|
|
33
|
+
"@markuplint/selector": "5.0.0-dev.5+e96392f56",
|
|
34
|
+
"@markuplint/shared": "5.0.0-dev.5+e96392f56",
|
|
35
|
+
"@markuplint/types": "5.0.0-dev.5+e96392f56",
|
|
36
36
|
"@mdn/browser-compat-data": "^7.3.2",
|
|
37
37
|
"@types/debug": "4.1.12",
|
|
38
38
|
"ansi-colors": "4.1.3",
|
|
@@ -42,5 +42,5 @@
|
|
|
42
42
|
"image-size": "^2.0.2",
|
|
43
43
|
"type-fest": "5.4.4"
|
|
44
44
|
},
|
|
45
|
-
"gitHead": "
|
|
45
|
+
"gitHead": "e96392f56e4bc8165ba59622b41c822703a96372"
|
|
46
46
|
}
|
package/schema.json
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
"attr-duplication": {
|
|
10
10
|
"$ref": "https://raw.githubusercontent.com/markuplint/markuplint/main/packages/%40markuplint/rules/src/attr-duplication/schema.json"
|
|
11
11
|
},
|
|
12
|
+
"attr-order": {
|
|
13
|
+
"$ref": "https://raw.githubusercontent.com/markuplint/markuplint/main/packages/%40markuplint/rules/src/attr-order/schema.json"
|
|
14
|
+
},
|
|
12
15
|
"attr-value-quotes": {
|
|
13
16
|
"$ref": "https://raw.githubusercontent.com/markuplint/markuplint/main/packages/%40markuplint/rules/src/attr-value-quotes/schema.json"
|
|
14
17
|
},
|
|
@@ -42,6 +45,9 @@
|
|
|
42
45
|
"end-tag": {
|
|
43
46
|
"$ref": "https://raw.githubusercontent.com/markuplint/markuplint/main/packages/%40markuplint/rules/src/end-tag/schema.json"
|
|
44
47
|
},
|
|
48
|
+
"head-element-order": {
|
|
49
|
+
"$ref": "https://raw.githubusercontent.com/markuplint/markuplint/main/packages/%40markuplint/rules/src/head-element-order/schema.json"
|
|
50
|
+
},
|
|
45
51
|
"heading-levels": {
|
|
46
52
|
"$ref": "https://raw.githubusercontent.com/markuplint/markuplint/main/packages/%40markuplint/rules/src/heading-levels/schema.json"
|
|
47
53
|
},
|