@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
@@ -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,5 @@
1
+ /** Rule metadata for `no-unsupported-features`: categorized as a validation rule. */
2
+ declare const _default: {
3
+ readonly category: "validation";
4
+ };
5
+ export default _default;
@@ -0,0 +1,4 @@
1
+ /** Rule metadata for `no-unsupported-features`: categorized as a validation rule. */
2
+ export default {
3
+ category: 'validation',
4
+ };
@@ -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.sort((a, b) => {
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].sort((a, b) => (b.hint.missing?.barelyMatchedElements ?? 0) - (a.hint.missing?.barelyMatchedElements ?? 0))[0] ?? a;
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.sort((a, b) => b.matched.length - a.matched.length)[0];
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. Uses `branchesToPatterns` to handle branching when multiple resolutions are possible.
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. Uses `branchesToPatterns` to handle branching when multiple resolutions are possible.
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
- const branches = [];
90
+ let patterns = [[]];
38
91
  for (const childNode of childNodes) {
39
92
  if (!childNode.is(childNode.ELEMENT_NODE)) {
40
- branches.push(childNode);
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
- branches.push(childNode);
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
- branches.push(childNode);
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 representPattern = [];
57
- for (const childNodes of childNodesPatterns) {
58
- const collection = new Collection([...childNodes]);
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
- representPattern.push({
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
- representPattern.push(child);
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<Model | PermittedContentPattern[]>;
88
+ model: ReadonlyDeep<PermittedContentPattern[] | Model>;
89
89
  min: number;
90
90
  max: number;
91
91
  repeat: RepeatSign;