@markuplint/rules 4.12.0 → 5.0.0-alpha.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 (83) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/lib/attr-check.d.ts +5 -2
  3. package/lib/attr-check.js +13 -3
  4. package/lib/character-reference/index.d.ts +2 -1
  5. package/lib/character-reference/index.js +5 -1
  6. package/lib/correct-aspect-ratio/index.d.ts +5 -0
  7. package/lib/correct-aspect-ratio/index.js +80 -0
  8. package/lib/correct-aspect-ratio/meta.d.ts +4 -0
  9. package/lib/correct-aspect-ratio/meta.js +3 -0
  10. package/lib/correct-aspect-ratio/resolve-image-size.d.ts +24 -0
  11. package/lib/correct-aspect-ratio/resolve-image-size.js +165 -0
  12. package/lib/create-message.d.ts +2 -2
  13. package/lib/create-message.js +4 -1
  14. package/lib/deprecated-element/index.d.ts +2 -4
  15. package/lib/deprecated-element/index.js +4 -6
  16. package/lib/end-tag/index.js +1 -1
  17. package/lib/heading-levels/index.js +0 -1
  18. package/lib/helpers.d.ts +1 -0
  19. package/lib/helpers.js +8 -15
  20. package/lib/index.d.ts +30 -41
  21. package/lib/index.js +12 -0
  22. package/lib/invalid-attr/index.d.ts +12 -28
  23. package/lib/invalid-attr/index.js +50 -116
  24. package/lib/link-types/index.d.ts +10 -0
  25. package/lib/link-types/index.js +180 -0
  26. package/lib/link-types/meta.d.ts +5 -0
  27. package/lib/link-types/meta.js +4 -0
  28. package/lib/neighbor-popovers/index.js +3 -5
  29. package/lib/no-orphaned-end-tag/index.d.ts +2 -2
  30. package/lib/no-orphaned-end-tag/index.js +3 -4
  31. package/lib/no-refer-to-non-existent-id/index.d.ts +1 -1
  32. package/lib/no-refer-to-non-existent-id/index.js +2 -2
  33. package/lib/no-unsupported-features/compat-data.d.ts +71 -0
  34. package/lib/no-unsupported-features/compat-data.js +204 -0
  35. package/lib/no-unsupported-features/index.d.ts +24 -0
  36. package/lib/no-unsupported-features/index.js +139 -0
  37. package/lib/no-unsupported-features/meta.d.ts +5 -0
  38. package/lib/no-unsupported-features/meta.js +4 -0
  39. package/lib/no-unsupported-features/resolve-browsers.d.ts +30 -0
  40. package/lib/no-unsupported-features/resolve-browsers.js +125 -0
  41. package/lib/permitted-contents/choice.js +1 -1
  42. package/lib/permitted-contents/count-pattern.js +1 -1
  43. package/lib/permitted-contents/order.js +1 -1
  44. package/lib/permitted-contents/represent-transparent-nodes.d.ts +26 -1
  45. package/lib/permitted-contents/represent-transparent-nodes.js +96 -13
  46. package/lib/permitted-contents/utils.d.ts +1 -1
  47. package/lib/permitted-contents/utils.js +22 -35
  48. package/lib/redundant-accessible-name/index.d.ts +10 -0
  49. package/lib/redundant-accessible-name/index.js +123 -0
  50. package/lib/redundant-accessible-name/meta.d.ts +5 -0
  51. package/lib/redundant-accessible-name/meta.js +4 -0
  52. package/lib/require-accessible-name/index.d.ts +3 -8
  53. package/lib/require-accessible-name/index.js +6 -5
  54. package/lib/require-dialog-autofocus/index.d.ts +22 -0
  55. package/lib/require-dialog-autofocus/index.js +62 -0
  56. package/lib/require-dialog-autofocus/meta.d.ts +5 -0
  57. package/lib/require-dialog-autofocus/meta.js +4 -0
  58. package/lib/required-attr/index.d.ts +8 -1
  59. package/lib/required-attr/index.js +27 -4
  60. package/lib/required-element/index.js +1 -1
  61. package/lib/srcset-sizes-constraint/index.d.ts +6 -0
  62. package/lib/srcset-sizes-constraint/index.js +108 -0
  63. package/lib/srcset-sizes-constraint/meta.d.ts +5 -0
  64. package/lib/srcset-sizes-constraint/meta.js +4 -0
  65. package/lib/srcset-sizes-constraint/parse-srcset.d.ts +40 -0
  66. package/lib/srcset-sizes-constraint/parse-srcset.js +56 -0
  67. package/lib/table-row-column-alignment/grid.js +22 -29
  68. package/lib/wai-aria/checkings/abstract-role.js +5 -2
  69. package/lib/wai-aria/checkings/disallowed-prop.js +5 -2
  70. package/lib/wai-aria/checkings/implicit-role.js +5 -2
  71. package/lib/wai-aria/checkings/non-existent-role.d.ts +1 -0
  72. package/lib/wai-aria/checkings/non-existent-role.js +9 -2
  73. package/lib/wai-aria/checkings/permitted-roles.js +5 -2
  74. package/lib/wai-aria/checkings/presentational-children.js +3 -2
  75. package/lib/wai-aria/checkings/required-accessibility-parent-role.d.ts +25 -0
  76. package/lib/wai-aria/checkings/required-accessibility-parent-role.js +56 -0
  77. package/lib/wai-aria/checkings/required-owned-elements.d.ts +5 -4
  78. package/lib/wai-aria/checkings/required-owned-elements.js +57 -28
  79. package/lib/wai-aria/checkings/required-prop.js +5 -2
  80. package/lib/wai-aria/index.js +12 -4
  81. package/lib/wai-aria/types.d.ts +9 -2
  82. package/package.json +15 -10
  83. package/schema.json +18 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,64 @@
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.0](https://github.com/markuplint/markuplint/compare/v4.14.1...v5.0.0-alpha.0) (2026-02-20)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **rules:** address review feedback for require-dialog-autofocus ([6fcadb2](https://github.com/markuplint/markuplint/commit/6fcadb2ab0e6a974e8ba6f644eff914a7840b8ef))
11
+ - 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))
12
+
13
+ - feat(rules)!: simplify invalid-attr options and remove attrs ([3ced12d](https://github.com/markuplint/markuplint/commit/3ced12d200f12ba4c9e177c9aaca25e7e24a9151))
14
+ - feat(rules)!: add no-unsupported-features rule for browser support checks ([8525a96](https://github.com/markuplint/markuplint/commit/8525a96fb04f634820362a9a73d6541284b53686))
15
+ - feat(rules)!: change ignoreOmittedElements default to true ([5ec04a7](https://github.com/markuplint/markuplint/commit/5ec04a7d63cbf19846b42124c22653197f603a59)), closes [#3136](https://github.com/markuplint/markuplint/issues/3136)
16
+ - refactor(rules)!: replace selfClosingSolidus with tagCloseChar ([f9cd9d8](https://github.com/markuplint/markuplint/commit/f9cd9d81bfb0ba49c2578eace7b04a9a1ebdd12a))
17
+
18
+ ### Features
19
+
20
+ - **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)
21
+ - **ml-spec,rules:** adopt ARIA 1.3 property names with 1.2 compat ([4f7e54d](https://github.com/markuplint/markuplint/commit/4f7e54d21593495d36e48fbe8ad27f8be85ab5ef))
22
+ - **rules:** add correct-aspect-ratio rule ([4068582](https://github.com/markuplint/markuplint/commit/40685828da697dfa8565288438a5d027dd0fae45))
23
+ - **rules:** add ignoreAttrs option to required-attr rule ([#690](https://github.com/markuplint/markuplint/issues/690)) ([fe4ce5e](https://github.com/markuplint/markuplint/commit/fe4ce5e283bc9e5284d209322dc0bbe3cbe07d55))
24
+ - **rules:** add link-types rule for rel attribute validation ([95d22a5](https://github.com/markuplint/markuplint/commit/95d22a596c1f786c385b7e99c3b818434995f4b5))
25
+ - **rules:** add redundant-accessible-name rule for detecting overridden accessible name sources ([63b89d4](https://github.com/markuplint/markuplint/commit/63b89d47bf5056f823d5b27cda4fda2b96419bb3))
26
+ - **rules:** add require-dialog-autofocus rule ([2d8d650](https://github.com/markuplint/markuplint/commit/2d8d650f8bc82e706687d292b27c310f3552b418)), closes [#689](https://github.com/markuplint/markuplint/issues/689)
27
+ - **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))
28
+ - **rules:** add srcset-sizes-constraint rule ([#1051](https://github.com/markuplint/markuplint/issues/1051)) ([bcc624a](https://github.com/markuplint/markuplint/commit/bcc624a3c6535ef85ad155b05d7571198983277a))
29
+ - **rules:** recognize DPub ARIA roles in wai-aria rule ([3dd32ff](https://github.com/markuplint/markuplint/commit/3dd32ffa7e5e706bad33bf9b9ff1785de8c8e630))
30
+ - **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)
31
+ - **rules:** support ARIA 1.3 generic transparency in owned elements check ([c1d6991](https://github.com/markuplint/markuplint/commit/c1d6991cb197d511c5751bba183fde1208001590))
32
+ - **rules:** support source elements inside picture in correct-aspect-ratio ([3cd3dc9](https://github.com/markuplint/markuplint/commit/3cd3dc9981a04f4ff5b78b97c28cc7852d31886e))
33
+ - **rules:** use ruleCommonSettings.ariaVersion fallback in ARIA rules ([07ccd8a](https://github.com/markuplint/markuplint/commit/07ccd8acd8b9db11ccf473b419a3d01cc782e15c))
34
+
35
+ ### Performance Improvements
36
+
37
+ - **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)
38
+
39
+ ### BREAKING CHANGES
40
+
41
+ - Remove the deprecated `attrs` option and the
42
+ `{ type: X }` value wrapper from `invalid-attr` rule options.
43
+
44
+ * Remove `attrs` option (deprecated since v3.7.0)
45
+ * Remove `{ type: X }` wrapper; specify type strings directly
46
+ * Deprecate object format for `allowAttrs`/`disallowAttrs`
47
+ * Unify value type validation via the types API
48
+
49
+ - non-standard element/attribute detection moved from
50
+ deprecated-element to no-unsupported-features with checkNonStandard option.
51
+ Users of recommended preset are unaffected (auto-enabled via compat preset).
52
+ - The `ignoreOmittedElements` option in the
53
+ `required-element` rule now defaults to `true` instead of `false`.
54
+ Ghost (omitted) elements implicitly created by the HTML parser
55
+ are no longer counted as satisfying the requirement by default.
56
+ Users who relied on the previous behavior should explicitly set
57
+ `ignoreOmittedElements: false`.
58
+ - The end-tag rule now checks tagCloseChar for
59
+ self-closing detection instead of the removed selfClosingSolidus
60
+ property.
61
+
62
+ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
63
+
6
64
  # [4.12.0](https://github.com/markuplint/markuplint/compare/@markuplint/rules@4.11.2...@markuplint/rules@4.12.0) (2026-02-10)
7
65
 
8
66
  ### Features
@@ -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: t('{0} is {1:c}', t('the "{0*}" {1}', name, 'attribute'), 'disallowed'),
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,5 @@
1
+ type Options = {
2
+ readonly documentRoot?: string;
3
+ };
4
+ declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<boolean, Options>>;
5
+ export default _default;
@@ -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,4 @@
1
+ declare const _default: {
2
+ readonly category: "validation";
3
+ };
4
+ export default _default;
@@ -0,0 +1,3 @@
1
+ export default {
2
+ category: 'validation',
3
+ };
@@ -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
+ }
@@ -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,
@@ -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, obsolete, or non-standard HTML
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, obsolete, or non-standard in the
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, obsolete, or non-standard HTML
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, obsolete, or non-standard in the
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 || spec.nonStandard)) {
22
- const message = t('{0} is {1:c}', t('the "{0*}" {1}', el.localName, 'element'), spec.deprecated ? 'deprecated' : spec.obsolete == null ? 'non-standard' : 'obsolete');
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,
@@ -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.selfClosingSolidus?.raw) {
28
+ if ((document.endTag === 'xml' || el.isForeignElement) && el.tagCloseChar.startsWith('/')) {
29
29
  return;
30
30
  }
31
31
  report({
@@ -8,7 +8,6 @@ import meta from './meta.js';
8
8
  */
9
9
  export default createRule({
10
10
  meta: meta,
11
- defaultValue: true,
12
11
  defaultOptions: null,
13
12
  verify({ document, report, t }) {
14
13
  const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
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
- let invalid = attrCheck(t, name, value, false, spec);
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
- [(_Collection_items = new WeakMap(), Symbol.iterator)]() {
237
- return __classPrivateFieldGet(this, _Collection_items, "f").values();
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
- __classPrivateFieldGet(this, _Collection_items, "f").add(item);
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([...__classPrivateFieldGet(this, _Collection_items, "f")]);
252
+ return Object.freeze([...this.#items]);
260
253
  }
261
254
  }
262
255
  /**