@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.
- package/CHANGELOG.md +58 -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 +30 -41
- 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/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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { createRule, getAttrSpecs, getSpec } from '@markuplint/ml-core';
|
|
2
|
+
import { checkAttributeSupport, checkElementSupport } from './compat-data.js';
|
|
3
|
+
import meta from './meta.js';
|
|
4
|
+
import { clearBrowserslistCache, resolveTargetBrowsers } from './resolve-browsers.js';
|
|
5
|
+
/**
|
|
6
|
+
* Rule that warns when using HTML elements or attributes that are not
|
|
7
|
+
* supported by the project's target browsers (via browserslist),
|
|
8
|
+
* or that are experimental/non-standard.
|
|
9
|
+
*/
|
|
10
|
+
export default createRule({
|
|
11
|
+
meta: meta,
|
|
12
|
+
defaultSeverity: 'warning',
|
|
13
|
+
defaultOptions: {
|
|
14
|
+
checkExperimental: false,
|
|
15
|
+
checkNonStandard: false,
|
|
16
|
+
},
|
|
17
|
+
async verify({ document, report, t }) {
|
|
18
|
+
const options = document.rule.options;
|
|
19
|
+
const targetBrowsers = resolveTargetBrowsers(document.filename, {
|
|
20
|
+
browserslist: options.browserslist,
|
|
21
|
+
browserslistConfig: options.browserslistConfig,
|
|
22
|
+
browserslistEnv: options.browserslistEnv,
|
|
23
|
+
});
|
|
24
|
+
const hasTargetBrowsers = targetBrowsers != null && targetBrowsers.length > 0;
|
|
25
|
+
const checkExperimental = options.checkExperimental ?? false;
|
|
26
|
+
const checkNonStandard = options.checkNonStandard ?? false;
|
|
27
|
+
// If no browserslist config and no experimental/nonStandard check, rule is no-op
|
|
28
|
+
if (!hasTargetBrowsers && !checkExperimental && !checkNonStandard) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const ignoreFeatures = new Set(options.ignoreFeatures);
|
|
32
|
+
await document.walkOn('Element', async (el) => {
|
|
33
|
+
// Only check HTML elements
|
|
34
|
+
if (el.namespaceURI !== 'http://www.w3.org/1999/xhtml' || el.elementType !== 'html') {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const elName = el.localName;
|
|
38
|
+
const elOptions = el.rule.options;
|
|
39
|
+
const elIgnoreFeatures = new Set(elOptions.ignoreFeatures ?? ignoreFeatures);
|
|
40
|
+
// Check element itself
|
|
41
|
+
if (!elIgnoreFeatures.has(elName)) {
|
|
42
|
+
// Experimental check (spec-based, no BCD needed)
|
|
43
|
+
if (elOptions.checkExperimental ?? checkExperimental) {
|
|
44
|
+
const spec = getSpec(el, document.specs.specs);
|
|
45
|
+
if (spec?.experimental) {
|
|
46
|
+
report({
|
|
47
|
+
scope: el,
|
|
48
|
+
message: t('{0} is {1:c}', t('the "{0*}" {1}', elName, 'element'), 'experimental'),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// NonStandard check (spec-based, no BCD needed)
|
|
53
|
+
if (elOptions.checkNonStandard ?? checkNonStandard) {
|
|
54
|
+
const spec = getSpec(el, document.specs.specs);
|
|
55
|
+
if (spec?.nonStandard) {
|
|
56
|
+
report({
|
|
57
|
+
scope: el,
|
|
58
|
+
message: t('{0} is {1:c}', t('the "{0*}" {1}', elName, 'element'), 'non-standard'),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Browser support check (BCD-based)
|
|
63
|
+
if (hasTargetBrowsers) {
|
|
64
|
+
const unsupported = await checkElementSupport(elName, targetBrowsers);
|
|
65
|
+
if (unsupported.length > 0) {
|
|
66
|
+
report({
|
|
67
|
+
scope: el,
|
|
68
|
+
message: formatUnsupportedMessage(t, elName, 'element', unsupported),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Check attributes
|
|
74
|
+
const attrSpecs = getAttrSpecs(el, document.specs);
|
|
75
|
+
for (const attr of el.attributes) {
|
|
76
|
+
if (attr.isDirective) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const attrName = attr.name;
|
|
80
|
+
const ignoreKey = `${elName}[${attrName}]`;
|
|
81
|
+
if (elIgnoreFeatures.has(ignoreKey)) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const attrSpec = attrSpecs?.find(s => s.name === attrName);
|
|
85
|
+
// Experimental attribute check
|
|
86
|
+
if ((elOptions.checkExperimental ?? checkExperimental) && attrSpec?.experimental) {
|
|
87
|
+
report({
|
|
88
|
+
scope: attr,
|
|
89
|
+
line: attr.nameNode?.startLine,
|
|
90
|
+
col: attr.nameNode?.startCol,
|
|
91
|
+
raw: attr.nameNode?.raw,
|
|
92
|
+
message: t('{0} is {1:c}', t('the "{0*}" {1}', attrName, 'attribute'), 'experimental'),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
// NonStandard attribute check
|
|
96
|
+
if ((elOptions.checkNonStandard ?? checkNonStandard) && attrSpec?.nonStandard) {
|
|
97
|
+
report({
|
|
98
|
+
scope: attr,
|
|
99
|
+
line: attr.nameNode?.startLine,
|
|
100
|
+
col: attr.nameNode?.startCol,
|
|
101
|
+
raw: attr.nameNode?.raw,
|
|
102
|
+
message: t('{0} is {1:c}', t('the "{0*}" {1}', attrName, 'attribute'), 'non-standard'),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
// Browser support attribute check
|
|
106
|
+
if (hasTargetBrowsers) {
|
|
107
|
+
const unsupported = await checkAttributeSupport(elName, attrName, targetBrowsers);
|
|
108
|
+
if (unsupported.length > 0) {
|
|
109
|
+
report({
|
|
110
|
+
scope: attr,
|
|
111
|
+
line: attr.nameNode?.startLine,
|
|
112
|
+
col: attr.nameNode?.startCol,
|
|
113
|
+
raw: attr.nameNode?.raw,
|
|
114
|
+
message: formatUnsupportedMessage(t, attrName, 'attribute', unsupported),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
clearBrowserslistCache();
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
/**
|
|
124
|
+
* Format an unsupported feature message listing affected browsers.
|
|
125
|
+
*/
|
|
126
|
+
function formatUnsupportedMessage(t, featureName, featureType, unsupported) {
|
|
127
|
+
const browserDetails = unsupported
|
|
128
|
+
.map(u => {
|
|
129
|
+
if (u.addedVersion === false) {
|
|
130
|
+
if (u.removedVersion) {
|
|
131
|
+
return `${u.displayName} (removed in ${u.removedVersion})`;
|
|
132
|
+
}
|
|
133
|
+
return `${u.displayName} (not supported)`;
|
|
134
|
+
}
|
|
135
|
+
return `${u.displayName} (added in ${u.addedVersion}, target: ${u.targetVersion})`;
|
|
136
|
+
})
|
|
137
|
+
.join(', ');
|
|
138
|
+
return t('{0} is not supported in {1}', t('the "{0*}" {1}', featureName, featureType), browserDetails);
|
|
139
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { TargetBrowser } from './compat-data.js';
|
|
2
|
+
/**
|
|
3
|
+
* Options for resolving target browsers.
|
|
4
|
+
*/
|
|
5
|
+
export interface BrowserslistOptions {
|
|
6
|
+
readonly browserslist?: string | readonly string[];
|
|
7
|
+
readonly browserslistConfig?: string;
|
|
8
|
+
readonly browserslistEnv?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Resolve target browsers from browserslist configuration.
|
|
12
|
+
*
|
|
13
|
+
* Priority:
|
|
14
|
+
* 1. `options.browserslist` (explicit query)
|
|
15
|
+
* 2. `options.browserslistConfig` (explicit config file path)
|
|
16
|
+
* 3. Auto-detect from `filename` path
|
|
17
|
+
*
|
|
18
|
+
* When no configuration is found, returns null (rule becomes no-op).
|
|
19
|
+
* For duplicate browsers, the minimum version is kept.
|
|
20
|
+
*
|
|
21
|
+
* @param filename - The document filename for auto-detection
|
|
22
|
+
* @param options - Browserslist resolution options
|
|
23
|
+
* @returns Array of target browsers, or null if no config found
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveTargetBrowsers(filename: string | undefined, options: BrowserslistOptions): readonly TargetBrowser[] | null;
|
|
26
|
+
/**
|
|
27
|
+
* Clear the internal browserslist config cache.
|
|
28
|
+
* Useful for testing.
|
|
29
|
+
*/
|
|
30
|
+
export declare function clearBrowserslistCache(): void;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import browserslist from 'browserslist';
|
|
2
|
+
import { isVersionSatisfied, parseVersion, toBcdBrowserId } from './compat-data.js';
|
|
3
|
+
/**
|
|
4
|
+
* Display names for BCD browser identifiers.
|
|
5
|
+
*/
|
|
6
|
+
const BROWSER_DISPLAY_NAMES = new Map([
|
|
7
|
+
['chrome', 'Chrome'],
|
|
8
|
+
['chrome_android', 'Chrome Android'],
|
|
9
|
+
['edge', 'Edge'],
|
|
10
|
+
['firefox', 'Firefox'],
|
|
11
|
+
['firefox_android', 'Firefox Android'],
|
|
12
|
+
['ie', 'Internet Explorer'],
|
|
13
|
+
['opera', 'Opera'],
|
|
14
|
+
['opera_android', 'Opera Android'],
|
|
15
|
+
['safari', 'Safari'],
|
|
16
|
+
['safari_ios', 'Safari iOS'],
|
|
17
|
+
['samsunginternet_android', 'Samsung Internet'],
|
|
18
|
+
['webview_android', 'WebView Android'],
|
|
19
|
+
]);
|
|
20
|
+
const configCache = new Map();
|
|
21
|
+
/**
|
|
22
|
+
* Resolve target browsers from browserslist configuration.
|
|
23
|
+
*
|
|
24
|
+
* Priority:
|
|
25
|
+
* 1. `options.browserslist` (explicit query)
|
|
26
|
+
* 2. `options.browserslistConfig` (explicit config file path)
|
|
27
|
+
* 3. Auto-detect from `filename` path
|
|
28
|
+
*
|
|
29
|
+
* When no configuration is found, returns null (rule becomes no-op).
|
|
30
|
+
* For duplicate browsers, the minimum version is kept.
|
|
31
|
+
*
|
|
32
|
+
* @param filename - The document filename for auto-detection
|
|
33
|
+
* @param options - Browserslist resolution options
|
|
34
|
+
* @returns Array of target browsers, or null if no config found
|
|
35
|
+
*/
|
|
36
|
+
export function resolveTargetBrowsers(filename, options) {
|
|
37
|
+
if (options.browserslist != null) {
|
|
38
|
+
const queries = Array.isArray(options.browserslist) ? options.browserslist : [options.browserslist];
|
|
39
|
+
const cacheKey = `query:${queries.join(',')}`;
|
|
40
|
+
if (configCache.has(cacheKey)) {
|
|
41
|
+
return configCache.get(cacheKey) ?? null;
|
|
42
|
+
}
|
|
43
|
+
const result = parseBrowsersList(browserslist(queries));
|
|
44
|
+
configCache.set(cacheKey, result);
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
const configPath = options.browserslistConfig ?? filename;
|
|
48
|
+
if (!configPath) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const env = options.browserslistEnv;
|
|
52
|
+
const cacheKey = `path:${configPath}:${env ?? ''}`;
|
|
53
|
+
if (configCache.has(cacheKey)) {
|
|
54
|
+
return configCache.get(cacheKey) ?? null;
|
|
55
|
+
}
|
|
56
|
+
const config = browserslist.loadConfig({
|
|
57
|
+
path: configPath,
|
|
58
|
+
env,
|
|
59
|
+
...(options.browserslistConfig ? { config: options.browserslistConfig } : {}),
|
|
60
|
+
});
|
|
61
|
+
if (!config) {
|
|
62
|
+
configCache.set(cacheKey, null);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const result = parseBrowsersList(browserslist(config));
|
|
66
|
+
configCache.set(cacheKey, result);
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Parse browserslist output into TargetBrowser array.
|
|
71
|
+
*
|
|
72
|
+
* For duplicate browsers, keeps the minimum version to ensure
|
|
73
|
+
* compatibility with the widest range of target browsers.
|
|
74
|
+
*/
|
|
75
|
+
function parseBrowsersList(browsers) {
|
|
76
|
+
const browserMap = new Map();
|
|
77
|
+
for (const entry of browsers) {
|
|
78
|
+
const parts = entry.split(' ');
|
|
79
|
+
if (parts.length < 2) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const [name, rawVersion] = parts;
|
|
83
|
+
if (!name || !rawVersion) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
// Handle hyphenated version ranges (e.g., "16.3-16.4") by taking the minimum
|
|
87
|
+
const version = rawVersion.includes('-') ? rawVersion.split('-')[0] : rawVersion;
|
|
88
|
+
const bcdId = toBcdBrowserId(name);
|
|
89
|
+
if (!bcdId) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const existing = browserMap.get(bcdId);
|
|
93
|
+
const displayName = BROWSER_DISPLAY_NAMES.get(bcdId) ?? name;
|
|
94
|
+
if (existing) {
|
|
95
|
+
// Keep minimum version — treat "preview" (NaN) as the highest possible version
|
|
96
|
+
const [newMajor] = parseVersion(version);
|
|
97
|
+
if (!Number.isNaN(newMajor)) {
|
|
98
|
+
// New version is concrete — replace if existing is "preview" or new < existing
|
|
99
|
+
const [existingMajor] = parseVersion(existing.version);
|
|
100
|
+
if (Number.isNaN(existingMajor) || !isVersionSatisfied(version, existing.version)) {
|
|
101
|
+
browserMap.set(bcdId, { version, displayName });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// If new version is "preview", never replace (preview is the highest)
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
browserMap.set(bcdId, { version, displayName });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (browserMap.size === 0) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const result = [];
|
|
114
|
+
for (const [browser, { version, displayName }] of browserMap) {
|
|
115
|
+
result.push({ browser, version, displayName });
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Clear the internal browserslist config cache.
|
|
121
|
+
* Useful for testing.
|
|
122
|
+
*/
|
|
123
|
+
export function clearBrowserslistCache() {
|
|
124
|
+
configCache.clear();
|
|
125
|
+
}
|
|
@@ -51,7 +51,7 @@ childNodes, specs, options, depth) {
|
|
|
51
51
|
indexes.set(result, i);
|
|
52
52
|
i++;
|
|
53
53
|
}
|
|
54
|
-
const barelyMatchedResult = unmatchedResults.
|
|
54
|
+
const barelyMatchedResult = unmatchedResults.toSorted((a, b) => {
|
|
55
55
|
if (a.type !== b.type) {
|
|
56
56
|
if (a.type === 'UNEXPECTED_EXTRA_NODE') {
|
|
57
57
|
return -1;
|
|
@@ -167,6 +167,6 @@ b) {
|
|
|
167
167
|
if (a.type === 'MATCHED' || a.type === 'MATCHED_ZERO' || a.type === 'UNEXPECTED_EXTRA_NODE') {
|
|
168
168
|
return a;
|
|
169
169
|
}
|
|
170
|
-
const result = [a, b].
|
|
170
|
+
const result = [a, b].toSorted((a, b) => (b.hint.missing?.barelyMatchedElements ?? 0) - (a.hint.missing?.barelyMatchedElements ?? 0))[0] ?? a;
|
|
171
171
|
return result;
|
|
172
172
|
}
|
|
@@ -44,7 +44,7 @@ childNodes, specs, options, depth) {
|
|
|
44
44
|
afterBacktrack = true;
|
|
45
45
|
continue;
|
|
46
46
|
}
|
|
47
|
-
const barelyMatchedResult = unmatchedResults.
|
|
47
|
+
const barelyMatchedResult = unmatchedResults.toSorted((a, b) => b.matched.length - a.matched.length)[0];
|
|
48
48
|
if (!barelyMatchedResult) {
|
|
49
49
|
throw new Error('Unreachable code');
|
|
50
50
|
}
|
|
@@ -26,12 +26,37 @@ type TransparentNode = {
|
|
|
26
26
|
* 3. Replaces the transparent element with its remaining (unmatched) children.
|
|
27
27
|
* 4. Validates that each remaining child satisfies the transparent model's condition selector.
|
|
28
28
|
* 5. Recursively resolves parent-level transparent nodes to propagate errors up the tree.
|
|
29
|
-
* 6.
|
|
29
|
+
* 6. Builds patterns incrementally, capping cross-products to avoid exponential blowup.
|
|
30
|
+
*
|
|
31
|
+
* ### Algorithm — incremental pattern building
|
|
32
|
+
*
|
|
33
|
+
* Patterns are built incrementally as each child node is visited:
|
|
34
|
+
*
|
|
35
|
+
* - **Non-transparent child**: appended to every existing pattern.
|
|
36
|
+
* Pattern count stays the same.
|
|
37
|
+
* - **Single-branch transparent child** (non-conditional, or
|
|
38
|
+
* `evaluateConditionalChildNodes` disabled): all unmatched children
|
|
39
|
+
* are spread into every pattern. Pattern count stays the same.
|
|
40
|
+
* - **Multi-branch transparent child** (conditional, e.g. `v-if`/`v-else`):
|
|
41
|
+
* a cross-product of existing patterns × branch groups is computed,
|
|
42
|
+
* capped at {@link MAX_PATTERNS}. If the cap would be exceeded, a
|
|
43
|
+
* conservative fallback merges all branch children into every pattern.
|
|
44
|
+
*
|
|
45
|
+
* ### Complexity
|
|
46
|
+
*
|
|
47
|
+
* - **Previous algorithm** (`branchesToPatterns` Cartesian product):
|
|
48
|
+
* O(K^N) time and space, where N = number of transparent elements,
|
|
49
|
+
* K = average branch count per element. 12 elements with 2 children
|
|
50
|
+
* each produced 4096 patterns and took 30+ seconds.
|
|
51
|
+
* - **Current algorithm**: O(N × K × min(|patterns|, MAX_PATTERNS))
|
|
52
|
+
* time; O(MAX_PATTERNS × P) space where P = average pattern length.
|
|
53
|
+
* The same 12-element case now produces 1 pattern in <100 ms.
|
|
30
54
|
*
|
|
31
55
|
* @param childNodes - The child nodes of the element being validated, some of which may be transparent.
|
|
32
56
|
* @param specs - The resolved spec data for content model lookups.
|
|
33
57
|
* @param options - Validation behavior options.
|
|
34
58
|
* @returns An array of possible transparent node resolutions, each with flattened nodes and accumulated errors.
|
|
59
|
+
* @see https://github.com/markuplint/markuplint/issues/3249
|
|
35
60
|
*/
|
|
36
61
|
export declare function representTransparentNodes(childNodes: readonly ChildNode[], specs: Specs, options: Options): TransparentNode[];
|
|
37
62
|
export {};
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { getContentModel } from '@markuplint/ml-spec';
|
|
2
|
-
import { branchesToPatterns } from '@markuplint/shared';
|
|
3
2
|
import { order } from './order.js';
|
|
4
3
|
import { Collection, isTransparent, matches } from './utils.js';
|
|
5
4
|
/**
|
|
@@ -8,6 +7,35 @@ import { Collection, isTransparent, matches } from './utils.js';
|
|
|
8
7
|
* to enable special handling in error messages and debug output.
|
|
9
8
|
*/
|
|
10
9
|
export const transparentMode = new Map();
|
|
10
|
+
/**
|
|
11
|
+
* Maximum number of patterns to generate before falling back to a
|
|
12
|
+
* conservative all-children-merged approach. Prevents exponential
|
|
13
|
+
* blowup when many conditional transparent elements are siblings.
|
|
14
|
+
*
|
|
15
|
+
* ### Rationale
|
|
16
|
+
*
|
|
17
|
+
* When N transparent elements each have K conditional branches (e.g.,
|
|
18
|
+
* `v-if`/`v-else`), the exact cross-product yields K^N patterns.
|
|
19
|
+
* With K=2 and N=10, this is 2^10 = 1024 — still manageable.
|
|
20
|
+
* At N=11 (2^11 = 2048) the cap triggers, switching to a
|
|
21
|
+
* conservative fallback that merges all branch children into every
|
|
22
|
+
* pattern. This keeps runtime linear while covering most real-world
|
|
23
|
+
* conditional structures.
|
|
24
|
+
*
|
|
25
|
+
* ### Fallback precision
|
|
26
|
+
*
|
|
27
|
+
* When the cap is exceeded, the fallback produces an
|
|
28
|
+
* **over-approximation**: all children from all branches are included
|
|
29
|
+
* in every pattern. This may cause false negatives (valid violations
|
|
30
|
+
* go undetected because the merged pattern appears to satisfy the
|
|
31
|
+
* content model) but will never cause false positives (spurious
|
|
32
|
+
* violations are not introduced). In practice, HTML documents rarely
|
|
33
|
+
* exceed 10 conditional transparent siblings, so the cap is
|
|
34
|
+
* transparent to most users.
|
|
35
|
+
*
|
|
36
|
+
* @see https://github.com/markuplint/markuplint/issues/3249
|
|
37
|
+
*/
|
|
38
|
+
const MAX_PATTERNS = 1024;
|
|
11
39
|
/**
|
|
12
40
|
* Resolves transparent content model elements by replacing them with their
|
|
13
41
|
* children for validation purposes. In HTML, elements like `<a>`, `<ins>`, and `<del>`
|
|
@@ -20,12 +48,37 @@ export const transparentMode = new Map();
|
|
|
20
48
|
* 3. Replaces the transparent element with its remaining (unmatched) children.
|
|
21
49
|
* 4. Validates that each remaining child satisfies the transparent model's condition selector.
|
|
22
50
|
* 5. Recursively resolves parent-level transparent nodes to propagate errors up the tree.
|
|
23
|
-
* 6.
|
|
51
|
+
* 6. Builds patterns incrementally, capping cross-products to avoid exponential blowup.
|
|
52
|
+
*
|
|
53
|
+
* ### Algorithm — incremental pattern building
|
|
54
|
+
*
|
|
55
|
+
* Patterns are built incrementally as each child node is visited:
|
|
56
|
+
*
|
|
57
|
+
* - **Non-transparent child**: appended to every existing pattern.
|
|
58
|
+
* Pattern count stays the same.
|
|
59
|
+
* - **Single-branch transparent child** (non-conditional, or
|
|
60
|
+
* `evaluateConditionalChildNodes` disabled): all unmatched children
|
|
61
|
+
* are spread into every pattern. Pattern count stays the same.
|
|
62
|
+
* - **Multi-branch transparent child** (conditional, e.g. `v-if`/`v-else`):
|
|
63
|
+
* a cross-product of existing patterns × branch groups is computed,
|
|
64
|
+
* capped at {@link MAX_PATTERNS}. If the cap would be exceeded, a
|
|
65
|
+
* conservative fallback merges all branch children into every pattern.
|
|
66
|
+
*
|
|
67
|
+
* ### Complexity
|
|
68
|
+
*
|
|
69
|
+
* - **Previous algorithm** (`branchesToPatterns` Cartesian product):
|
|
70
|
+
* O(K^N) time and space, where N = number of transparent elements,
|
|
71
|
+
* K = average branch count per element. 12 elements with 2 children
|
|
72
|
+
* each produced 4096 patterns and took 30+ seconds.
|
|
73
|
+
* - **Current algorithm**: O(N × K × min(|patterns|, MAX_PATTERNS))
|
|
74
|
+
* time; O(MAX_PATTERNS × P) space where P = average pattern length.
|
|
75
|
+
* The same 12-element case now produces 1 pattern in <100 ms.
|
|
24
76
|
*
|
|
25
77
|
* @param childNodes - The child nodes of the element being validated, some of which may be transparent.
|
|
26
78
|
* @param specs - The resolved spec data for content model lookups.
|
|
27
79
|
* @param options - Validation behavior options.
|
|
28
80
|
* @returns An array of possible transparent node resolutions, each with flattened nodes and accumulated errors.
|
|
81
|
+
* @see https://github.com/markuplint/markuplint/issues/3249
|
|
29
82
|
*/
|
|
30
83
|
export function representTransparentNodes(
|
|
31
84
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
@@ -34,28 +87,34 @@ childNodes, specs, options) {
|
|
|
34
87
|
const parentResults = parentElement
|
|
35
88
|
? representTransparentNodes([parentElement], specs, options)
|
|
36
89
|
: [{ nodes: [], errors: [] }];
|
|
37
|
-
|
|
90
|
+
let patterns = [[]];
|
|
38
91
|
for (const childNode of childNodes) {
|
|
39
92
|
if (!childNode.is(childNode.ELEMENT_NODE)) {
|
|
40
|
-
|
|
93
|
+
for (const p of patterns) {
|
|
94
|
+
p.push(childNode);
|
|
95
|
+
}
|
|
41
96
|
continue;
|
|
42
97
|
}
|
|
43
98
|
const models = getContentModel(childNode, specs.specs);
|
|
44
99
|
if (models == null || typeof models === 'boolean') {
|
|
45
|
-
|
|
100
|
+
for (const p of patterns) {
|
|
101
|
+
p.push(childNode);
|
|
102
|
+
}
|
|
46
103
|
continue;
|
|
47
104
|
}
|
|
48
105
|
const noTransparentModels = models.filter(m => !isTransparent(m));
|
|
49
106
|
if (noTransparentModels.length === models.length) {
|
|
50
|
-
|
|
107
|
+
for (const p of patterns) {
|
|
108
|
+
p.push(childNode);
|
|
109
|
+
}
|
|
51
110
|
continue;
|
|
52
111
|
}
|
|
53
112
|
const childNodesPatterns = options.evaluateConditionalChildNodes
|
|
54
113
|
? childNode.conditionalChildNodes().map(childNodes => [...childNodes])
|
|
55
114
|
: [[...childNode.childNodes].filter(child => !(child.is(child.TEXT_NODE) && child.isWhitespace()))];
|
|
56
|
-
const
|
|
57
|
-
for (const
|
|
58
|
-
const collection = new Collection([...
|
|
115
|
+
const branchGroups = [];
|
|
116
|
+
for (const branchChildNodes of childNodesPatterns) {
|
|
117
|
+
const collection = new Collection([...branchChildNodes]);
|
|
59
118
|
let unmatched;
|
|
60
119
|
if (noTransparentModels.length > 0) {
|
|
61
120
|
const result = order(noTransparentModels, collection.unmatched, specs, options, Number.POSITIVE_INFINITY);
|
|
@@ -68,6 +127,7 @@ childNodes, specs, options) {
|
|
|
68
127
|
if (!transparent || !isTransparent(transparent)) {
|
|
69
128
|
throw new Error('Unreachable code');
|
|
70
129
|
}
|
|
130
|
+
const branchChildren = [];
|
|
71
131
|
for (const _child of unmatched) {
|
|
72
132
|
const child = _child;
|
|
73
133
|
if (transparentMode.has(child)) {
|
|
@@ -77,7 +137,7 @@ childNodes, specs, options) {
|
|
|
77
137
|
if (child.is(child.ELEMENT_NODE)) {
|
|
78
138
|
const transparentCondMatched = matches(transparent.transparent, child, specs);
|
|
79
139
|
if (!transparentCondMatched.matched) {
|
|
80
|
-
|
|
140
|
+
branchChildren.push({
|
|
81
141
|
type: 'TRANSPARENT_MODEL_DISALLOWS',
|
|
82
142
|
matched: [],
|
|
83
143
|
unmatched: [childNode],
|
|
@@ -91,12 +151,35 @@ childNodes, specs, options) {
|
|
|
91
151
|
continue;
|
|
92
152
|
}
|
|
93
153
|
}
|
|
94
|
-
|
|
154
|
+
branchChildren.push(child);
|
|
155
|
+
}
|
|
156
|
+
branchGroups.push(branchChildren);
|
|
157
|
+
}
|
|
158
|
+
if (branchGroups.every(g => g.length === 0)) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (branchGroups.length === 1) {
|
|
162
|
+
const singleGroup = branchGroups[0];
|
|
163
|
+
for (const p of patterns) {
|
|
164
|
+
p.push(...singleGroup);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else if (patterns.length * branchGroups.length <= MAX_PATTERNS) {
|
|
168
|
+
const newPatterns = [];
|
|
169
|
+
for (const p of patterns) {
|
|
170
|
+
for (const group of branchGroups) {
|
|
171
|
+
newPatterns.push([...p, ...group]);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
patterns = newPatterns;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
const allChildren = branchGroups.flat();
|
|
178
|
+
for (const p of patterns) {
|
|
179
|
+
p.push(...allChildren);
|
|
95
180
|
}
|
|
96
181
|
}
|
|
97
|
-
branches.push(representPattern);
|
|
98
182
|
}
|
|
99
|
-
const patterns = branchesToPatterns(branches);
|
|
100
183
|
const result = parentResults.flatMap(parentResult => {
|
|
101
184
|
const patternResults = patterns.map(pattern => {
|
|
102
185
|
const nodes = pattern.filter((node) => 'nodeName' in node);
|
|
@@ -85,7 +85,7 @@ export declare function isTransparent(content: ReadonlyDeep<PermittedContentPatt
|
|
|
85
85
|
* @returns An object with `model`, `min`, `max`, `repeat` sign, and optional `missingType`.
|
|
86
86
|
*/
|
|
87
87
|
export declare function normalizeModel(pattern: ReadonlyDeep<PermittedContentRequire> | ReadonlyDeep<PermittedContentOptional> | ReadonlyDeep<PermittedContentOneOrMore> | ReadonlyDeep<PermittedContentZeroOrMore>): {
|
|
88
|
-
model: ReadonlyDeep<
|
|
88
|
+
model: ReadonlyDeep<PermittedContentPattern[] | Model>;
|
|
89
89
|
min: number;
|
|
90
90
|
max: number;
|
|
91
91
|
repeat: RepeatSign;
|