@markuplint/rules 4.12.0 → 5.0.0-alpha.1
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 +70 -0
- package/lib/attr-check.d.ts +5 -2
- package/lib/attr-check.js +13 -3
- package/lib/character-reference/index.d.ts +2 -1
- package/lib/character-reference/index.js +5 -1
- package/lib/correct-aspect-ratio/index.d.ts +5 -0
- package/lib/correct-aspect-ratio/index.js +80 -0
- package/lib/correct-aspect-ratio/meta.d.ts +4 -0
- package/lib/correct-aspect-ratio/meta.js +3 -0
- package/lib/correct-aspect-ratio/resolve-image-size.d.ts +24 -0
- package/lib/correct-aspect-ratio/resolve-image-size.js +165 -0
- package/lib/create-message.d.ts +2 -2
- package/lib/create-message.js +4 -1
- package/lib/deprecated-element/index.d.ts +2 -4
- package/lib/deprecated-element/index.js +4 -6
- package/lib/end-tag/index.js +1 -1
- package/lib/heading-levels/index.js +0 -1
- package/lib/helpers.d.ts +1 -0
- package/lib/helpers.js +8 -15
- package/lib/index.d.ts +31 -42
- package/lib/index.js +12 -0
- package/lib/invalid-attr/index.d.ts +12 -28
- package/lib/invalid-attr/index.js +50 -116
- package/lib/landmark-roles/index.d.ts +3 -3
- package/lib/landmark-roles/index.js +2 -2
- package/lib/link-types/index.d.ts +10 -0
- package/lib/link-types/index.js +180 -0
- package/lib/link-types/meta.d.ts +5 -0
- package/lib/link-types/meta.js +4 -0
- package/lib/neighbor-popovers/index.js +3 -5
- package/lib/no-orphaned-end-tag/index.d.ts +2 -2
- package/lib/no-orphaned-end-tag/index.js +3 -4
- package/lib/no-refer-to-non-existent-id/index.d.ts +1 -1
- package/lib/no-refer-to-non-existent-id/index.js +2 -2
- package/lib/no-unsupported-features/compat-data.d.ts +71 -0
- package/lib/no-unsupported-features/compat-data.js +204 -0
- package/lib/no-unsupported-features/index.d.ts +24 -0
- package/lib/no-unsupported-features/index.js +139 -0
- package/lib/no-unsupported-features/meta.d.ts +5 -0
- package/lib/no-unsupported-features/meta.js +4 -0
- package/lib/no-unsupported-features/resolve-browsers.d.ts +30 -0
- package/lib/no-unsupported-features/resolve-browsers.js +125 -0
- package/lib/permitted-contents/choice.js +1 -1
- package/lib/permitted-contents/count-pattern.js +1 -1
- package/lib/permitted-contents/order.js +1 -1
- package/lib/permitted-contents/represent-transparent-nodes.d.ts +26 -1
- package/lib/permitted-contents/represent-transparent-nodes.js +96 -13
- package/lib/permitted-contents/utils.d.ts +1 -1
- package/lib/permitted-contents/utils.js +22 -35
- package/lib/redundant-accessible-name/index.d.ts +10 -0
- package/lib/redundant-accessible-name/index.js +123 -0
- package/lib/redundant-accessible-name/meta.d.ts +5 -0
- package/lib/redundant-accessible-name/meta.js +4 -0
- package/lib/require-accessible-name/index.d.ts +3 -8
- package/lib/require-accessible-name/index.js +6 -5
- package/lib/require-dialog-autofocus/index.d.ts +22 -0
- package/lib/require-dialog-autofocus/index.js +62 -0
- package/lib/require-dialog-autofocus/meta.d.ts +5 -0
- package/lib/require-dialog-autofocus/meta.js +4 -0
- package/lib/required-attr/index.d.ts +8 -1
- package/lib/required-attr/index.js +27 -4
- package/lib/required-element/index.js +1 -1
- package/lib/srcset-sizes-constraint/index.d.ts +6 -0
- package/lib/srcset-sizes-constraint/index.js +108 -0
- package/lib/srcset-sizes-constraint/meta.d.ts +5 -0
- package/lib/srcset-sizes-constraint/meta.js +4 -0
- package/lib/srcset-sizes-constraint/parse-srcset.d.ts +40 -0
- package/lib/srcset-sizes-constraint/parse-srcset.js +56 -0
- package/lib/table-row-column-alignment/grid.js +22 -29
- package/lib/wai-aria/checkings/abstract-role.js +5 -2
- package/lib/wai-aria/checkings/disallowed-prop.js +5 -2
- package/lib/wai-aria/checkings/implicit-role.js +5 -2
- package/lib/wai-aria/checkings/non-existent-role.d.ts +1 -0
- package/lib/wai-aria/checkings/non-existent-role.js +9 -2
- package/lib/wai-aria/checkings/permitted-roles.js +5 -2
- package/lib/wai-aria/checkings/presentational-children.js +3 -2
- package/lib/wai-aria/checkings/required-accessibility-parent-role.d.ts +25 -0
- package/lib/wai-aria/checkings/required-accessibility-parent-role.js +56 -0
- package/lib/wai-aria/checkings/required-owned-elements.d.ts +5 -4
- package/lib/wai-aria/checkings/required-owned-elements.js +57 -28
- package/lib/wai-aria/checkings/required-prop.js +5 -2
- package/lib/wai-aria/index.js +12 -4
- package/lib/wai-aria/types.d.ts +9 -2
- package/package.json +15 -10
- package/schema.json +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,76 @@
|
|
|
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.1](https://github.com/markuplint/markuplint/compare/v5.0.0-alpha.0...v5.0.0-alpha.1) (2026-02-22)
|
|
7
|
+
|
|
8
|
+
- fix(rules)!: remove complementary from top-level landmark check ([4643651](https://github.com/markuplint/markuplint/commit/4643651d256276ae0473d4756e72277d59398e3d))
|
|
9
|
+
|
|
10
|
+
### BREAKING CHANGES
|
|
11
|
+
|
|
12
|
+
- The landmark-roles rule no longer checks that
|
|
13
|
+
complementary landmarks are top-level. This aligns with axe-core's
|
|
14
|
+
deprecation of landmark-complementary-is-top-level.
|
|
15
|
+
|
|
16
|
+
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
|
17
|
+
|
|
18
|
+
# [5.0.0-alpha.0](https://github.com/markuplint/markuplint/compare/v4.14.1...v5.0.0-alpha.0) (2026-02-20)
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
- **rules:** address review feedback for require-dialog-autofocus ([6fcadb2](https://github.com/markuplint/markuplint/commit/6fcadb2ab0e6a974e8ba6f644eff914a7840b8ef))
|
|
23
|
+
- treat orphaned end tags as bogus instead of plain text ([#1575](https://github.com/markuplint/markuplint/issues/1575)) ([557199a](https://github.com/markuplint/markuplint/commit/557199a6960ab35573a544f9a33c00e98eb9967e))
|
|
24
|
+
|
|
25
|
+
- feat(rules)!: simplify invalid-attr options and remove attrs ([3ced12d](https://github.com/markuplint/markuplint/commit/3ced12d200f12ba4c9e177c9aaca25e7e24a9151))
|
|
26
|
+
- feat(rules)!: add no-unsupported-features rule for browser support checks ([8525a96](https://github.com/markuplint/markuplint/commit/8525a96fb04f634820362a9a73d6541284b53686))
|
|
27
|
+
- feat(rules)!: change ignoreOmittedElements default to true ([5ec04a7](https://github.com/markuplint/markuplint/commit/5ec04a7d63cbf19846b42124c22653197f603a59)), closes [#3136](https://github.com/markuplint/markuplint/issues/3136)
|
|
28
|
+
- refactor(rules)!: replace selfClosingSolidus with tagCloseChar ([f9cd9d8](https://github.com/markuplint/markuplint/commit/f9cd9d81bfb0ba49c2578eace7b04a9a1ebdd12a))
|
|
29
|
+
|
|
30
|
+
### Features
|
|
31
|
+
|
|
32
|
+
- **html-spec:** require href or imagesrcset on link element ([e6a2631](https://github.com/markuplint/markuplint/commit/e6a26318ed8e4be9ba4e81884eb0b879a816efb5)), closes [#717](https://github.com/markuplint/markuplint/issues/717)
|
|
33
|
+
- **ml-spec,rules:** adopt ARIA 1.3 property names with 1.2 compat ([4f7e54d](https://github.com/markuplint/markuplint/commit/4f7e54d21593495d36e48fbe8ad27f8be85ab5ef))
|
|
34
|
+
- **rules:** add correct-aspect-ratio rule ([4068582](https://github.com/markuplint/markuplint/commit/40685828da697dfa8565288438a5d027dd0fae45))
|
|
35
|
+
- **rules:** add ignoreAttrs option to required-attr rule ([#690](https://github.com/markuplint/markuplint/issues/690)) ([fe4ce5e](https://github.com/markuplint/markuplint/commit/fe4ce5e283bc9e5284d209322dc0bbe3cbe07d55))
|
|
36
|
+
- **rules:** add link-types rule for rel attribute validation ([95d22a5](https://github.com/markuplint/markuplint/commit/95d22a596c1f786c385b7e99c3b818434995f4b5))
|
|
37
|
+
- **rules:** add redundant-accessible-name rule for detecting overridden accessible name sources ([63b89d4](https://github.com/markuplint/markuplint/commit/63b89d47bf5056f823d5b27cda4fda2b96419bb3))
|
|
38
|
+
- **rules:** add require-dialog-autofocus rule ([2d8d650](https://github.com/markuplint/markuplint/commit/2d8d650f8bc82e706687d292b27c310f3552b418)), closes [#689](https://github.com/markuplint/markuplint/issues/689)
|
|
39
|
+
- **rules:** add required context role check to wai-aria rule ([#970](https://github.com/markuplint/markuplint/issues/970)) ([1433a5a](https://github.com/markuplint/markuplint/commit/1433a5ae429aca4e7dc0a1a7f514a184eb423c03))
|
|
40
|
+
- **rules:** add srcset-sizes-constraint rule ([#1051](https://github.com/markuplint/markuplint/issues/1051)) ([bcc624a](https://github.com/markuplint/markuplint/commit/bcc624a3c6535ef85ad155b05d7571198983277a))
|
|
41
|
+
- **rules:** recognize DPub ARIA roles in wai-aria rule ([3dd32ff](https://github.com/markuplint/markuplint/commit/3dd32ffa7e5e706bad33bf9b9ff1785de8c8e630))
|
|
42
|
+
- **rules:** suggest similar attribute names for typos in invalid-attr rule ([337c7c5](https://github.com/markuplint/markuplint/commit/337c7c56b792cf7bc72baa3a2528f0c93892d550)), closes [#1487](https://github.com/markuplint/markuplint/issues/1487)
|
|
43
|
+
- **rules:** support ARIA 1.3 generic transparency in owned elements check ([c1d6991](https://github.com/markuplint/markuplint/commit/c1d6991cb197d511c5751bba183fde1208001590))
|
|
44
|
+
- **rules:** support source elements inside picture in correct-aspect-ratio ([3cd3dc9](https://github.com/markuplint/markuplint/commit/3cd3dc9981a04f4ff5b78b97c28cc7852d31886e))
|
|
45
|
+
- **rules:** use ruleCommonSettings.ariaVersion fallback in ARIA rules ([07ccd8a](https://github.com/markuplint/markuplint/commit/07ccd8acd8b9db11ccf473b419a3d01cc782e15c))
|
|
46
|
+
|
|
47
|
+
### Performance Improvements
|
|
48
|
+
|
|
49
|
+
- **rules:** fix exponential slowdown in transparent element resolution ([f657e6a](https://github.com/markuplint/markuplint/commit/f657e6a0156933c3acafb8e3975fbc31488b7cca)), closes [#3249](https://github.com/markuplint/markuplint/issues/3249)
|
|
50
|
+
|
|
51
|
+
### BREAKING CHANGES
|
|
52
|
+
|
|
53
|
+
- Remove the deprecated `attrs` option and the
|
|
54
|
+
`{ type: X }` value wrapper from `invalid-attr` rule options.
|
|
55
|
+
|
|
56
|
+
* Remove `attrs` option (deprecated since v3.7.0)
|
|
57
|
+
* Remove `{ type: X }` wrapper; specify type strings directly
|
|
58
|
+
* Deprecate object format for `allowAttrs`/`disallowAttrs`
|
|
59
|
+
* Unify value type validation via the types API
|
|
60
|
+
|
|
61
|
+
- non-standard element/attribute detection moved from
|
|
62
|
+
deprecated-element to no-unsupported-features with checkNonStandard option.
|
|
63
|
+
Users of recommended preset are unaffected (auto-enabled via compat preset).
|
|
64
|
+
- The `ignoreOmittedElements` option in the
|
|
65
|
+
`required-element` rule now defaults to `true` instead of `false`.
|
|
66
|
+
Ghost (omitted) elements implicitly created by the HTML parser
|
|
67
|
+
are no longer counted as satisfying the requirement by default.
|
|
68
|
+
Users who relied on the previous behavior should explicitly set
|
|
69
|
+
`ignoreOmittedElements: false`.
|
|
70
|
+
- The end-tag rule now checks tagCloseChar for
|
|
71
|
+
self-closing detection instead of the removed selfClosingSolidus
|
|
72
|
+
property.
|
|
73
|
+
|
|
74
|
+
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
|
75
|
+
|
|
6
76
|
# [4.12.0](https://github.com/markuplint/markuplint/compare/@markuplint/rules@4.11.2...@markuplint/rules@4.12.0) (2026-02-10)
|
|
7
77
|
|
|
8
78
|
### Features
|
package/lib/attr-check.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Translator } from '@markuplint/i18n';
|
|
2
2
|
import type { Attribute as AttrSpec, AttributeType } from '@markuplint/ml-spec';
|
|
3
|
+
import type { Type } from '@markuplint/types';
|
|
3
4
|
import type { ReadonlyDeep } from 'type-fest';
|
|
4
5
|
/**
|
|
5
6
|
* Discriminated union tag representing the kind of attribute invalidity.
|
|
@@ -40,10 +41,12 @@ type Loc = {
|
|
|
40
41
|
* @param value - The attribute value to validate
|
|
41
42
|
* @param isCustomRule - When `true`, skips the built-in bypass for `data-*`, `aria-*`, and `adapt-*` attributes
|
|
42
43
|
* @param spec - The attribute specification to validate against; if absent, the attribute is considered non-existent
|
|
44
|
+
* @param allAttrNames - Optional list of all valid attribute names for the element;
|
|
45
|
+
* used to suggest a similar attribute name via Levenshtein distance when the attribute is non-existent
|
|
43
46
|
* @returns `false` if the attribute is valid, a single `Invalid` object for existence/disallowed errors,
|
|
44
47
|
* or an array of `Invalid<'invalid-value'>` objects for value validation failures
|
|
45
48
|
*/
|
|
46
|
-
export declare function attrCheck(t: Translator, name: string, value: string, isCustomRule: boolean, spec?: AttrSpec): Invalid | Invalid<'invalid-value'>[] | false;
|
|
49
|
+
export declare function attrCheck(t: Translator, name: string, value: string, isCustomRule: boolean, spec?: AttrSpec, allAttrNames?: readonly string[]): Invalid | Invalid<'invalid-value'>[] | false;
|
|
47
50
|
/**
|
|
48
51
|
* Validates an attribute value against a single attribute type definition.
|
|
49
52
|
* Returns `false` if the value is valid, or a tuple of `[message, location]`
|
|
@@ -57,5 +60,5 @@ export declare function attrCheck(t: Translator, name: string, value: string, is
|
|
|
57
60
|
* @param type - The attribute type definition to validate against
|
|
58
61
|
* @returns `false` if the value is valid, or a `[message, location]` tuple on failure
|
|
59
62
|
*/
|
|
60
|
-
export declare function valueCheck(t: Translator, name: string, value: string, type: ReadonlyDeep<AttributeType>): [string, Loc] | false;
|
|
63
|
+
export declare function valueCheck(t: Translator, name: string, value: string, type: ReadonlyDeep<AttributeType | Type>): [string, Loc] | false;
|
|
61
64
|
export {};
|
package/lib/attr-check.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { toNonNullableArrayFromItemOrArray } from '@markuplint/shared';
|
|
2
|
-
import { check } from '@markuplint/types';
|
|
2
|
+
import { check, getCandidate } from '@markuplint/types';
|
|
3
3
|
import { createMessageValueExpected } from './create-message.js';
|
|
4
4
|
import { log } from './debug.js';
|
|
5
5
|
/**
|
|
@@ -18,10 +18,12 @@ import { log } from './debug.js';
|
|
|
18
18
|
* @param value - The attribute value to validate
|
|
19
19
|
* @param isCustomRule - When `true`, skips the built-in bypass for `data-*`, `aria-*`, and `adapt-*` attributes
|
|
20
20
|
* @param spec - The attribute specification to validate against; if absent, the attribute is considered non-existent
|
|
21
|
+
* @param allAttrNames - Optional list of all valid attribute names for the element;
|
|
22
|
+
* used to suggest a similar attribute name via Levenshtein distance when the attribute is non-existent
|
|
21
23
|
* @returns `false` if the attribute is valid, a single `Invalid` object for existence/disallowed errors,
|
|
22
24
|
* or an array of `Invalid<'invalid-value'>` objects for value validation failures
|
|
23
25
|
*/
|
|
24
|
-
export function attrCheck(t, name, value, isCustomRule, spec) {
|
|
26
|
+
export function attrCheck(t, name, value, isCustomRule, spec, allAttrNames) {
|
|
25
27
|
if (!isCustomRule) {
|
|
26
28
|
if (/^data-.+$/.test(name)) {
|
|
27
29
|
// Ignore checking because "data-*" attribute is any type
|
|
@@ -41,9 +43,17 @@ export function attrCheck(t, name, value, isCustomRule, spec) {
|
|
|
41
43
|
// Existence
|
|
42
44
|
if (!spec) {
|
|
43
45
|
log('The "%s" attribute DOES\'NT EXIST in the spec', name);
|
|
46
|
+
const baseMessage = t('{0} is {1:c}', t('the "{0*}" {1}', name, 'attribute'), 'disallowed');
|
|
47
|
+
const candidate = allAttrNames ? getCandidate(name, allAttrNames) : undefined;
|
|
48
|
+
if (candidate) {
|
|
49
|
+
return {
|
|
50
|
+
invalidType: 'non-existent',
|
|
51
|
+
message: baseMessage + t('. ') + t('Did you mean "{0*}"?', candidate),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
44
54
|
return {
|
|
45
55
|
invalidType: 'non-existent',
|
|
46
|
-
message:
|
|
56
|
+
message: baseMessage,
|
|
47
57
|
};
|
|
48
58
|
}
|
|
49
59
|
const nameCaseSensitive = /[A-Z]/.test(spec.name);
|
|
@@ -5,7 +5,8 @@ import type { RuleConfigValue } from '@markuplint/ml-config';
|
|
|
5
5
|
*
|
|
6
6
|
* Scans text nodes and attribute values for the characters `"`, `&`, `<`,
|
|
7
7
|
* and `>` that are not already part of a valid character reference. Text
|
|
8
|
-
* inside `<script>` and `<style>` elements is excluded
|
|
8
|
+
* inside `<script>` and `<style>` elements is excluded, as are bogus
|
|
9
|
+
* text nodes (e.g., orphaned end tags).
|
|
9
10
|
*/
|
|
10
11
|
declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<RuleConfigValue, undefined>>;
|
|
11
12
|
export default _default;
|
|
@@ -16,13 +16,17 @@ const ignoreParentElement = new Set(['script', 'style']);
|
|
|
16
16
|
*
|
|
17
17
|
* Scans text nodes and attribute values for the characters `"`, `&`, `<`,
|
|
18
18
|
* and `>` that are not already part of a valid character reference. Text
|
|
19
|
-
* inside `<script>` and `<style>` elements is excluded
|
|
19
|
+
* inside `<script>` and `<style>` elements is excluded, as are bogus
|
|
20
|
+
* text nodes (e.g., orphaned end tags).
|
|
20
21
|
*/
|
|
21
22
|
export default createRule({
|
|
22
23
|
meta: meta,
|
|
23
24
|
async verify({ document, report, t }) {
|
|
24
25
|
const targetNodes = [];
|
|
25
26
|
await document.walkOn('Text', node => {
|
|
27
|
+
if (node.isBogus) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
26
30
|
if (node.parentNode && ignoreParentElement.has(node.parentNode.nodeName.toLowerCase())) {
|
|
27
31
|
return;
|
|
28
32
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createRule } from '@markuplint/ml-core';
|
|
2
|
+
import meta from './meta.js';
|
|
3
|
+
import { getImageDimensions } from './resolve-image-size.js';
|
|
4
|
+
export default createRule({
|
|
5
|
+
meta: meta,
|
|
6
|
+
defaultSeverity: 'warning',
|
|
7
|
+
defaultOptions: {},
|
|
8
|
+
async verify({ document, report }) {
|
|
9
|
+
await document.walkOn('Element', async (el) => {
|
|
10
|
+
const localName = el.localName;
|
|
11
|
+
// Only check img and source (inside picture) elements
|
|
12
|
+
if (localName !== 'img' && localName !== 'source') {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
// source is only relevant inside <picture>
|
|
16
|
+
if (localName === 'source' && el.parentElement?.localName !== 'picture') {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (el.hasSpreadAttr) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const widthAttr = el.getAttributeNode('width');
|
|
23
|
+
const heightAttr = el.getAttributeNode('height');
|
|
24
|
+
if (!widthAttr || !heightAttr) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (widthAttr.isDynamicValue || heightAttr.isDynamicValue) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
// Resolve the image URL from src (img) or srcset (source)
|
|
31
|
+
let src;
|
|
32
|
+
if (localName === 'img') {
|
|
33
|
+
const srcAttr = el.getAttributeNode('src');
|
|
34
|
+
if (!srcAttr || srcAttr.isDynamicValue) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
src = srcAttr.value;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const srcsetAttr = el.getAttributeNode('srcset');
|
|
41
|
+
if (!srcsetAttr || srcsetAttr.isDynamicValue) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const extracted = extractFirstSrcsetUrl(srcsetAttr.value);
|
|
45
|
+
if (!extracted) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
src = extracted;
|
|
49
|
+
}
|
|
50
|
+
const widthStr = widthAttr.value;
|
|
51
|
+
const heightStr = heightAttr.value;
|
|
52
|
+
// Parse width/height as integers; skip if non-numeric
|
|
53
|
+
const attrWidth = Number.parseInt(widthStr, 10);
|
|
54
|
+
const attrHeight = Number.parseInt(heightStr, 10);
|
|
55
|
+
if (!Number.isFinite(attrWidth) || !Number.isFinite(attrHeight) || attrWidth <= 0 || attrHeight <= 0) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const documentRoot = el.rule.options?.documentRoot;
|
|
59
|
+
const dimensions = await getImageDimensions(src, documentRoot, document.filename);
|
|
60
|
+
if (!dimensions) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const { width: actualWidth, height: actualHeight } = dimensions;
|
|
64
|
+
// Cross-multiplication comparison to avoid floating-point errors
|
|
65
|
+
if (attrWidth * actualHeight !== attrHeight * actualWidth) {
|
|
66
|
+
report({
|
|
67
|
+
scope: el,
|
|
68
|
+
message: `The aspect ratio of the image (${actualWidth}:${actualHeight}) does not match the width/height attributes (${widthStr}:${heightStr})`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
function extractFirstSrcsetUrl(srcset) {
|
|
75
|
+
const first = srcset.trim().split(',')[0]?.trim();
|
|
76
|
+
if (!first) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return first.split(/\s+/)[0] ?? null;
|
|
80
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
interface ImageDimensions {
|
|
2
|
+
readonly width: number;
|
|
3
|
+
readonly height: number;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Reset module-level caches. **Only for testing.**
|
|
7
|
+
*/
|
|
8
|
+
export declare function _resetCacheForTesting(): void;
|
|
9
|
+
/**
|
|
10
|
+
* Get the dimensions of an image file, using a cache keyed by absolute path,
|
|
11
|
+
* file size, and the original `src` value.
|
|
12
|
+
*
|
|
13
|
+
* Query strings and fragments in `src` are stripped when resolving the file
|
|
14
|
+
* path, but the original `src` is kept in the cache key so that different
|
|
15
|
+
* cache-busting suffixes produce separate cache entries (mirroring browser
|
|
16
|
+
* cache-busting semantics).
|
|
17
|
+
*
|
|
18
|
+
* @param src - The `src` attribute value
|
|
19
|
+
* @param documentRoot - Root directory for absolute paths
|
|
20
|
+
* @param documentFilename - The filename of the document being linted
|
|
21
|
+
* @returns The image dimensions, or `null` if the image cannot be resolved or read
|
|
22
|
+
*/
|
|
23
|
+
export declare function getImageDimensions(src: string, documentRoot: string | undefined, documentFilename: string | undefined): Promise<ImageDimensions | null>;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { readFile, stat, mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { imageSize } from 'image-size';
|
|
6
|
+
// In-memory cache: key = "absolutePath:fileSize:src"
|
|
7
|
+
const memoryCache = new Map();
|
|
8
|
+
let cacheLoaded = false;
|
|
9
|
+
let _cacheDir;
|
|
10
|
+
function getCacheDir() {
|
|
11
|
+
if (_cacheDir != null) {
|
|
12
|
+
return _cacheDir;
|
|
13
|
+
}
|
|
14
|
+
const req = createRequire(import.meta.url);
|
|
15
|
+
const { version } = req('../../package.json');
|
|
16
|
+
_cacheDir = path.join(tmpdir(), `markuplint-v${version}`);
|
|
17
|
+
return _cacheDir;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Reset module-level caches. **Only for testing.**
|
|
21
|
+
*/
|
|
22
|
+
export function _resetCacheForTesting() {
|
|
23
|
+
memoryCache.clear();
|
|
24
|
+
cacheLoaded = false;
|
|
25
|
+
_cacheDir = undefined;
|
|
26
|
+
}
|
|
27
|
+
function getCacheFilePath() {
|
|
28
|
+
return path.join(getCacheDir(), 'image-size-cache.json');
|
|
29
|
+
}
|
|
30
|
+
async function loadCacheFromDisk() {
|
|
31
|
+
if (cacheLoaded) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
cacheLoaded = true;
|
|
35
|
+
let raw;
|
|
36
|
+
try {
|
|
37
|
+
raw = await readFile(getCacheFilePath(), 'utf8');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
let data;
|
|
43
|
+
try {
|
|
44
|
+
data = JSON.parse(raw);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
for (const [key, value] of Object.entries(data)) {
|
|
50
|
+
memoryCache.set(key, value);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function saveCacheToDisk() {
|
|
54
|
+
const dir = getCacheDir();
|
|
55
|
+
try {
|
|
56
|
+
await mkdir(dir, { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const obj = {};
|
|
62
|
+
for (const [key, value] of memoryCache) {
|
|
63
|
+
obj[key] = value;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
await writeFile(getCacheFilePath(), JSON.stringify(obj));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Ignore write failures
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function stripQueryAndFragment(src) {
|
|
73
|
+
const qIndex = src.indexOf('?');
|
|
74
|
+
const hIndex = src.indexOf('#');
|
|
75
|
+
let end = src.length;
|
|
76
|
+
if (qIndex !== -1)
|
|
77
|
+
end = Math.min(end, qIndex);
|
|
78
|
+
if (hIndex !== -1)
|
|
79
|
+
end = Math.min(end, hIndex);
|
|
80
|
+
return src.slice(0, end);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the absolute path of an image from its `src` attribute value.
|
|
84
|
+
*
|
|
85
|
+
* Query strings (`?...`) and fragments (`#...`) are stripped before resolving
|
|
86
|
+
* the file path so that cache-busting suffixes do not prevent file lookup.
|
|
87
|
+
*
|
|
88
|
+
* @param src - The `src` attribute value
|
|
89
|
+
* @param documentRoot - Root directory for absolute paths (defaults to cwd)
|
|
90
|
+
* @param documentFilename - The filename of the document being linted
|
|
91
|
+
* @returns The absolute path, or `null` if the path cannot be resolved (remote URLs, data URIs)
|
|
92
|
+
*/
|
|
93
|
+
function resolveImagePath(src, documentRoot, documentFilename) {
|
|
94
|
+
// Skip remote URLs and data URIs
|
|
95
|
+
if (/^https?:\/\//i.test(src) || /^data:/i.test(src)) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const cleanSrc = stripQueryAndFragment(src);
|
|
99
|
+
// Absolute path (starts with /)
|
|
100
|
+
if (cleanSrc.startsWith('/')) {
|
|
101
|
+
return path.join(documentRoot ?? process.cwd(), cleanSrc);
|
|
102
|
+
}
|
|
103
|
+
// Relative path
|
|
104
|
+
if (documentFilename) {
|
|
105
|
+
return path.resolve(path.dirname(documentFilename), cleanSrc);
|
|
106
|
+
}
|
|
107
|
+
return path.join(documentRoot ?? process.cwd(), cleanSrc);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Get the dimensions of an image file, using a cache keyed by absolute path,
|
|
111
|
+
* file size, and the original `src` value.
|
|
112
|
+
*
|
|
113
|
+
* Query strings and fragments in `src` are stripped when resolving the file
|
|
114
|
+
* path, but the original `src` is kept in the cache key so that different
|
|
115
|
+
* cache-busting suffixes produce separate cache entries (mirroring browser
|
|
116
|
+
* cache-busting semantics).
|
|
117
|
+
*
|
|
118
|
+
* @param src - The `src` attribute value
|
|
119
|
+
* @param documentRoot - Root directory for absolute paths
|
|
120
|
+
* @param documentFilename - The filename of the document being linted
|
|
121
|
+
* @returns The image dimensions, or `null` if the image cannot be resolved or read
|
|
122
|
+
*/
|
|
123
|
+
export async function getImageDimensions(src, documentRoot, documentFilename) {
|
|
124
|
+
const absolutePath = resolveImagePath(src, documentRoot, documentFilename);
|
|
125
|
+
if (absolutePath == null) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
await loadCacheFromDisk();
|
|
129
|
+
// Get file size for cache key
|
|
130
|
+
let fileSize;
|
|
131
|
+
try {
|
|
132
|
+
const fileStat = await stat(absolutePath);
|
|
133
|
+
fileSize = fileStat.size;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const cacheKey = `${absolutePath}:${fileSize}:${src}`;
|
|
139
|
+
const cached = memoryCache.get(cacheKey);
|
|
140
|
+
if (cached) {
|
|
141
|
+
return cached;
|
|
142
|
+
}
|
|
143
|
+
// Read and measure the image
|
|
144
|
+
let buffer;
|
|
145
|
+
try {
|
|
146
|
+
buffer = await readFile(absolutePath);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
let result;
|
|
152
|
+
try {
|
|
153
|
+
result = imageSize(buffer);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
if (result.width == null || result.height == null) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const dimensions = { width: result.width, height: result.height };
|
|
162
|
+
memoryCache.set(cacheKey, dimensions);
|
|
163
|
+
await saveCacheToDisk();
|
|
164
|
+
return dimensions;
|
|
165
|
+
}
|
package/lib/create-message.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Translator } from '@markuplint/i18n';
|
|
2
2
|
import type { AttributeType } from '@markuplint/ml-spec';
|
|
3
|
-
import type { UnmatchedResult } from '@markuplint/types';
|
|
3
|
+
import type { UnmatchedResult, Type } from '@markuplint/types';
|
|
4
4
|
import type { ReadonlyDeep } from 'type-fest';
|
|
5
5
|
/**
|
|
6
6
|
* Builds a human-readable error message explaining why an attribute value
|
|
@@ -15,7 +15,7 @@ import type { ReadonlyDeep } from 'type-fest';
|
|
|
15
15
|
* details about the mismatch (reason, candidate, part name, etc.)
|
|
16
16
|
* @returns A localized error message describing the expected value
|
|
17
17
|
*/
|
|
18
|
-
export declare function createMessageValueExpected(t: Translator, baseTarget: string, type: ReadonlyDeep<AttributeType>, matches: UnmatchedResult): string;
|
|
18
|
+
export declare function createMessageValueExpected(t: Translator, baseTarget: string, type: ReadonlyDeep<AttributeType | Type>, matches: UnmatchedResult): string;
|
|
19
19
|
/**
|
|
20
20
|
* Assembles the detailed portion of a value-expected error message by
|
|
21
21
|
* inspecting the mismatch reason and composing reason, expectation,
|
package/lib/create-message.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isList, isKeyword, isEnum, isNumber, isDirective } from '@markuplint/types';
|
|
1
|
+
import { isList, isKeyword, isEnum, isNumber, isDirective, isPattern } from '@markuplint/types';
|
|
2
2
|
/**
|
|
3
3
|
* Builds a human-readable error message explaining why an attribute value
|
|
4
4
|
* does not match its expected type. Handles both plain types and list types
|
|
@@ -258,6 +258,9 @@ function createExpectedObject(type, matches, t) {
|
|
|
258
258
|
else if (isDirective(type)) {
|
|
259
259
|
expectedObject.push(t('a {0}', 'directive'));
|
|
260
260
|
}
|
|
261
|
+
else if (isPattern(type)) {
|
|
262
|
+
expectedObject.push(t('{0} ({1})', 'regular expression', type.pattern));
|
|
263
|
+
}
|
|
261
264
|
const expects = expectedObject.length === 0
|
|
262
265
|
? null
|
|
263
266
|
: 1 < expectedObject.length
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Rule that reports the use of deprecated
|
|
3
|
-
* elements.
|
|
2
|
+
* Rule that reports the use of deprecated or obsolete HTML elements.
|
|
4
3
|
*
|
|
5
4
|
* Walks HTML and SVG elements and checks their spec status. Reports any
|
|
6
|
-
* element that is marked as deprecated
|
|
7
|
-
* HTML specification.
|
|
5
|
+
* element that is marked as deprecated or obsolete in the HTML specification.
|
|
8
6
|
*/
|
|
9
7
|
declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<import("@markuplint/ml-core").RuleConfigValue, undefined>>;
|
|
10
8
|
export default _default;
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { createRule, getSpec } from '@markuplint/ml-core';
|
|
2
2
|
import meta from './meta.js';
|
|
3
3
|
/**
|
|
4
|
-
* Rule that reports the use of deprecated
|
|
5
|
-
* elements.
|
|
4
|
+
* Rule that reports the use of deprecated or obsolete HTML elements.
|
|
6
5
|
*
|
|
7
6
|
* Walks HTML and SVG elements and checks their spec status. Reports any
|
|
8
|
-
* element that is marked as deprecated
|
|
9
|
-
* HTML specification.
|
|
7
|
+
* element that is marked as deprecated or obsolete in the HTML specification.
|
|
10
8
|
*/
|
|
11
9
|
export default createRule({
|
|
12
10
|
meta: meta,
|
|
@@ -18,8 +16,8 @@ export default createRule({
|
|
|
18
16
|
return;
|
|
19
17
|
}
|
|
20
18
|
const spec = getSpec(el, document.specs.specs);
|
|
21
|
-
if (spec && (spec.obsolete != null || spec.deprecated
|
|
22
|
-
const message = t('{0} is {1:c}', t('the "{0*}" {1}', el.localName, 'element'), spec.deprecated ? 'deprecated' :
|
|
19
|
+
if (spec && (spec.obsolete != null || spec.deprecated)) {
|
|
20
|
+
const message = t('{0} is {1:c}', t('the "{0*}" {1}', el.localName, 'element'), spec.deprecated ? 'deprecated' : 'obsolete');
|
|
23
21
|
report({
|
|
24
22
|
scope: el,
|
|
25
23
|
message,
|
package/lib/end-tag/index.js
CHANGED
|
@@ -25,7 +25,7 @@ export default createRule({
|
|
|
25
25
|
if (el.closeTag != null) {
|
|
26
26
|
return;
|
|
27
27
|
}
|
|
28
|
-
if ((document.endTag === 'xml' || el.isForeignElement) && el.
|
|
28
|
+
if ((document.endTag === 'xml' || el.isForeignElement) && el.tagCloseChar.startsWith('/')) {
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
31
|
report({
|
package/lib/helpers.d.ts
CHANGED
|
@@ -96,6 +96,7 @@ export declare function toNormalizedValue(value: string, spec: Attribute): strin
|
|
|
96
96
|
* @returns `true` if the accessible name could change dynamically, `false` otherwise
|
|
97
97
|
*/
|
|
98
98
|
export declare function accnameMayBeMutable(el: Element<any, any>, document: Document<any, any>): boolean;
|
|
99
|
+
export declare const labelable: string[];
|
|
99
100
|
/**
|
|
100
101
|
* Finds the `<label>` element associated with a labelable form element.
|
|
101
102
|
* First checks for an ancestor `<label>`, then looks for a `<label>` whose
|
package/lib/helpers.js
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
2
|
-
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
3
|
-
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
4
|
-
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
5
|
-
};
|
|
6
|
-
var _Collection_items;
|
|
7
|
-
// @ts-ignore
|
|
8
|
-
import structuredClone from '@ungap/structured-clone';
|
|
9
1
|
import { attrCheck } from './attr-check.js';
|
|
10
2
|
/**
|
|
11
3
|
* Tests whether an element matches the condition specified in an attribute spec.
|
|
@@ -103,7 +95,8 @@ node, attrSpecs,
|
|
|
103
95
|
log) {
|
|
104
96
|
const spec = attrSpecs.find(s => s.name.toLowerCase() === name.toLowerCase());
|
|
105
97
|
log?.('Spec of the %s attr: %o', name, spec);
|
|
106
|
-
|
|
98
|
+
const allAttrNames = attrSpecs.map(s => s.name);
|
|
99
|
+
let invalid = attrCheck(t, name, value, false, spec, allAttrNames);
|
|
107
100
|
if (invalid === false &&
|
|
108
101
|
spec &&
|
|
109
102
|
spec.condition != null &&
|
|
@@ -180,7 +173,7 @@ document) {
|
|
|
180
173
|
}
|
|
181
174
|
return false;
|
|
182
175
|
}
|
|
183
|
-
const labelable = ['button', 'input:not([type=hidden])', 'meter', 'output', 'progress', 'select', 'textarea'];
|
|
176
|
+
export const labelable = ['button', 'input:not([type=hidden])', 'meter', 'output', 'progress', 'select', 'textarea'];
|
|
184
177
|
/**
|
|
185
178
|
* Finds the `<label>` element associated with a labelable form element.
|
|
186
179
|
* First checks for an ancestor `<label>`, then looks for a `<label>` whose
|
|
@@ -218,6 +211,7 @@ document) {
|
|
|
218
211
|
* @template T - The type of items stored in the collection
|
|
219
212
|
*/
|
|
220
213
|
export class Collection {
|
|
214
|
+
#items = new Set();
|
|
221
215
|
/**
|
|
222
216
|
* Creates a new collection, optionally pre-populated with the given items.
|
|
223
217
|
* Any `null` or `undefined` values are silently ignored.
|
|
@@ -225,7 +219,6 @@ export class Collection {
|
|
|
225
219
|
* @param items - Initial items to add to the collection
|
|
226
220
|
*/
|
|
227
221
|
constructor(...items) {
|
|
228
|
-
_Collection_items.set(this, new Set());
|
|
229
222
|
this.add(...items);
|
|
230
223
|
}
|
|
231
224
|
/**
|
|
@@ -233,8 +226,8 @@ export class Collection {
|
|
|
233
226
|
*
|
|
234
227
|
* @returns An iterator that yields each item in insertion order
|
|
235
228
|
*/
|
|
236
|
-
[
|
|
237
|
-
return
|
|
229
|
+
[Symbol.iterator]() {
|
|
230
|
+
return this.#items.values();
|
|
238
231
|
}
|
|
239
232
|
/**
|
|
240
233
|
* Adds one or more items to the collection.
|
|
@@ -247,7 +240,7 @@ export class Collection {
|
|
|
247
240
|
if (item == null) {
|
|
248
241
|
continue;
|
|
249
242
|
}
|
|
250
|
-
|
|
243
|
+
this.#items.add(item);
|
|
251
244
|
}
|
|
252
245
|
}
|
|
253
246
|
/**
|
|
@@ -256,7 +249,7 @@ export class Collection {
|
|
|
256
249
|
* @returns A read-only array containing all items in insertion order
|
|
257
250
|
*/
|
|
258
251
|
toArray() {
|
|
259
|
-
return Object.freeze([...
|
|
252
|
+
return Object.freeze([...this.#items]);
|
|
260
253
|
}
|
|
261
254
|
}
|
|
262
255
|
/**
|