@svelte-vitals/core 0.55.0 → 0.55.2
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/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal.d.ts +80 -18
- package/dist/internal.js +638 -50
- package/dist/{json-DcTYXhKc.d.ts → json-BQf9Qb2D.d.ts} +160 -99
- package/dist/{markdown-CgcwmqWX.js → markdown-DgHBAvYl.js} +646 -311
- package/package.json +3 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { parse } from "svelte/compiler";
|
|
2
1
|
import { aria, roles } from "aria-query";
|
|
2
|
+
import { parse } from "svelte/compiler";
|
|
3
3
|
//#region src/types.ts
|
|
4
4
|
const defaultProject = {
|
|
5
5
|
hasRobotsTxt: false,
|
|
@@ -153,6 +153,19 @@ const seoTitlePresence = {
|
|
|
153
153
|
};
|
|
154
154
|
//#endregion
|
|
155
155
|
//#region src/rules/seo/head-tag-rule.ts
|
|
156
|
+
/** Open Graph keys are read from `property=`; `name="og:*"` is the common slip. */
|
|
157
|
+
function ogMisspelled(key) {
|
|
158
|
+
return {
|
|
159
|
+
match: (t) => t.kind === "meta" && t.name === key && t.property === void 0,
|
|
160
|
+
label: `<meta name="${key}"> should be <meta property="${key}">`,
|
|
161
|
+
recommendation: `Open Graph reads ${key} from the property attribute: change name="${key}" to property="${key}".`,
|
|
162
|
+
fix: {
|
|
163
|
+
description: `Change name="${key}" to property="${key}" on the existing tag.`,
|
|
164
|
+
snippet: `<meta property="${key}" content="…" />`,
|
|
165
|
+
lang: "svelte"
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
}
|
|
156
169
|
function detect(head, match) {
|
|
157
170
|
const matches = head.tags.filter(match);
|
|
158
171
|
const rank = (t) => (t.value !== "absent" ? 2 : 0) + (t.presence === "own" ? 1 : 0);
|
|
@@ -180,7 +193,8 @@ function headTagRule(opts) {
|
|
|
180
193
|
async check(ctx) {
|
|
181
194
|
return (opts.appliesTo ? ctx.heads.filter(opts.appliesTo) : ctx.heads).map((head) => {
|
|
182
195
|
const detection = detect(head, opts.match);
|
|
183
|
-
const
|
|
196
|
+
const misspelled = detection.presence === "none" && opts.misspelled && head.tags.some(opts.misspelled.match) ? opts.misspelled : void 0;
|
|
197
|
+
const message = misspelled ? misspelled.label : detection.presence === "none" ? `Missing ${opts.label}` : detection.value === "absent" ? `Empty ${opts.label}` : opts.label;
|
|
184
198
|
return {
|
|
185
199
|
id: opts.id,
|
|
186
200
|
category: "seo",
|
|
@@ -189,9 +203,9 @@ function headTagRule(opts) {
|
|
|
189
203
|
route: head.route,
|
|
190
204
|
location: head.file,
|
|
191
205
|
message,
|
|
192
|
-
recommendation: opts.recommendation,
|
|
206
|
+
recommendation: misspelled?.recommendation ?? opts.recommendation,
|
|
193
207
|
docsUrl,
|
|
194
|
-
...opts.fix ? { fix: { ...opts.fix } } : {}
|
|
208
|
+
...misspelled ? { fix: { ...misspelled.fix } } : opts.fix ? { fix: { ...opts.fix } } : {}
|
|
195
209
|
};
|
|
196
210
|
});
|
|
197
211
|
}
|
|
@@ -236,6 +250,7 @@ const seoOgImage = headTagRule({
|
|
|
236
250
|
title: "Open Graph image",
|
|
237
251
|
severity: "warning",
|
|
238
252
|
match: (t) => t.kind === "meta" && t.property === "og:image",
|
|
253
|
+
misspelled: ogMisspelled("og:image"),
|
|
239
254
|
label: "<meta property=\"og:image\">",
|
|
240
255
|
recommendation: "Add <meta property=\"og:image\">, or set openGraph.images on your meta component.",
|
|
241
256
|
rationale: "og:image is the preview thumbnail shown when the page is shared on social platforms; without it links render bare and get fewer clicks.",
|
|
@@ -252,6 +267,7 @@ const seoOgTitle = headTagRule({
|
|
|
252
267
|
title: "Open Graph title",
|
|
253
268
|
severity: "warning",
|
|
254
269
|
match: (t) => t.kind === "meta" && t.property === "og:title",
|
|
270
|
+
misspelled: ogMisspelled("og:title"),
|
|
255
271
|
label: "<meta property=\"og:title\">",
|
|
256
272
|
recommendation: "Add <meta property=\"og:title\">, or set openGraph.title on your meta component.",
|
|
257
273
|
rationale: "og:title controls the headline shown when the page is shared on social platforms, independent of the document <title>.",
|
|
@@ -510,7 +526,7 @@ const performanceResponsiveImage = imageRule({
|
|
|
510
526
|
snippet: "<img src=\"/hero.jpg\" srcset=\"/hero-800.jpg 800w, /hero-1600.jpg 1600w\" sizes=\"100vw\" width=\"1600\" height=\"900\" alt=\"…\" />",
|
|
511
527
|
lang: "svelte"
|
|
512
528
|
},
|
|
513
|
-
ok: (img) => img.hasSrcset
|
|
529
|
+
ok: (img) => img.hasSrcset || img.svg === true
|
|
514
530
|
});
|
|
515
531
|
//#endregion
|
|
516
532
|
//#region src/rules/perf/link-rule.ts
|
|
@@ -730,9 +746,9 @@ function withFailedRulesOff(config, failedRuleIds) {
|
|
|
730
746
|
}
|
|
731
747
|
/**
|
|
732
748
|
* The rules that only pay off for a page that appears in a search result, turned off by
|
|
733
|
-
* `seo: { indexable: false }
|
|
734
|
-
*
|
|
735
|
-
*
|
|
749
|
+
* `seo: { indexable: false }` — plus `seo/indexability`, whose "is this noindex intentional?"
|
|
750
|
+
* the switch answers. Every other SEO rule is deliberately absent, and a test fails on an SEO
|
|
751
|
+
* rule classified neither way. Design:
|
|
736
752
|
* docs/superpowers/specs/2026-09-21-noindex-seo-rules-design.md.
|
|
737
753
|
*/
|
|
738
754
|
const SEARCH_RESULT_RULES = [
|
|
@@ -746,7 +762,13 @@ const SEARCH_RESULT_RULES = [
|
|
|
746
762
|
"seo/sitemap-xml",
|
|
747
763
|
"seo/sitemap-in-robots",
|
|
748
764
|
"seo/title-length",
|
|
749
|
-
"seo/description-length"
|
|
765
|
+
"seo/description-length",
|
|
766
|
+
"seo/description-presence",
|
|
767
|
+
"seo/duplicate-title",
|
|
768
|
+
"seo/duplicate-description",
|
|
769
|
+
"seo/hreflang",
|
|
770
|
+
"seo/ssr-disabled",
|
|
771
|
+
"seo/indexability"
|
|
750
772
|
];
|
|
751
773
|
/**
|
|
752
774
|
* Expand `seo: { indexable: false }` into the `'off'` entries a user writes by hand today, so
|
|
@@ -1246,7 +1268,7 @@ const seoTwitterCard = headTagRule({
|
|
|
1246
1268
|
id: "seo/twitter-card",
|
|
1247
1269
|
title: "Twitter Card",
|
|
1248
1270
|
severity: "info",
|
|
1249
|
-
match: (t) => t.kind === "meta" && t.name === "twitter:card",
|
|
1271
|
+
match: (t) => t.kind === "meta" && (t.name === "twitter:card" || t.property === "twitter:card"),
|
|
1250
1272
|
label: "<meta name=\"twitter:card\">",
|
|
1251
1273
|
recommendation: "Add <meta name=\"twitter:card\" content=\"summary_large_image\"> so X/Twitter renders a rich card.",
|
|
1252
1274
|
rationale: "twitter:card selects how the page renders when shared on X/Twitter; without it the platform falls back to a basic link (Open Graph tags are used as fallbacks for the rest).",
|
|
@@ -1263,6 +1285,7 @@ const seoOgDescription = headTagRule({
|
|
|
1263
1285
|
title: "Open Graph description",
|
|
1264
1286
|
severity: "info",
|
|
1265
1287
|
match: (t) => t.kind === "meta" && t.property === "og:description",
|
|
1288
|
+
misspelled: ogMisspelled("og:description"),
|
|
1266
1289
|
label: "<meta property=\"og:description\">",
|
|
1267
1290
|
recommendation: "Add <meta property=\"og:description\">, or set openGraph.description on your meta component.",
|
|
1268
1291
|
rationale: "og:description is the summary shown under the title in social previews; without it platforms guess or show nothing, lowering click-through. The Open Graph protocol lists it as an optional property.",
|
|
@@ -1279,6 +1302,7 @@ const seoOgUrl = headTagRule({
|
|
|
1279
1302
|
title: "Open Graph URL",
|
|
1280
1303
|
severity: "warning",
|
|
1281
1304
|
match: (t) => t.kind === "meta" && t.property === "og:url",
|
|
1305
|
+
misspelled: ogMisspelled("og:url"),
|
|
1282
1306
|
label: "<meta property=\"og:url\">",
|
|
1283
1307
|
recommendation: "Add <meta property=\"og:url\"> with the canonical URL, or set openGraph.url on your meta component.",
|
|
1284
1308
|
rationale: "og:url tells social platforms the canonical address to attribute shares and likes to, consolidating engagement on one URL. The Open Graph protocol lists it as a required property.",
|
|
@@ -2078,6 +2102,223 @@ const seoHreflang = {
|
|
|
2078
2102
|
}
|
|
2079
2103
|
};
|
|
2080
2104
|
//#endregion
|
|
2105
|
+
//#region src/rules/a11y/aria-data.ts
|
|
2106
|
+
/**
|
|
2107
|
+
* ARIA names `aria-query@5.3.2` does not carry. It is not the clean ARIA 1.2 snapshot it looks
|
|
2108
|
+
* like — it holds 1.2's 48 attributes plus three 1.3 additions — so the gap is patched by name
|
|
2109
|
+
* rather than by version. Every entry here is defined in the ARIA 1.3 editor's draft, which is
|
|
2110
|
+
* what makes reporting it as an unknown name wrong — how far implementations have got is a
|
|
2111
|
+
* separate question this rule does not ask. Re-check this list whenever the aria-query dependency
|
|
2112
|
+
* moves.
|
|
2113
|
+
*/
|
|
2114
|
+
const ARIA_1_3_ROLES = /* @__PURE__ */ new Set([
|
|
2115
|
+
"comment",
|
|
2116
|
+
"image",
|
|
2117
|
+
"sectionheader",
|
|
2118
|
+
"sectionfooter",
|
|
2119
|
+
"suggestion"
|
|
2120
|
+
]);
|
|
2121
|
+
const ARIA_1_3_ATTRIBUTES = /* @__PURE__ */ new Set(["aria-colindextext", "aria-rowindextext"]);
|
|
2122
|
+
/**
|
|
2123
|
+
* Roles whose `requiredProps` row in aria-query is stale. Neither ARIA 1.2 nor the 1.3 editor's
|
|
2124
|
+
* draft lists required states or properties for these — the 1.3 draft re-lists inherited
|
|
2125
|
+
* requirements where they apply (`menuitemradio` → `aria-checked`), so the absence is deliberate
|
|
2126
|
+
* rather than an inheritance the table left implicit. Without this, idiomatic APG listbox and
|
|
2127
|
+
* tree markup is flagged.
|
|
2128
|
+
*/
|
|
2129
|
+
const NO_REQUIRED_PROPS = /* @__PURE__ */ new Set(["option", "treeitem"]);
|
|
2130
|
+
function isKnownRole(role) {
|
|
2131
|
+
return ARIA_1_3_ROLES.has(role) || roles.has(role);
|
|
2132
|
+
}
|
|
2133
|
+
function isAbstractRole(role) {
|
|
2134
|
+
return roles.get(role)?.abstract === true;
|
|
2135
|
+
}
|
|
2136
|
+
/** A concrete (non-abstract) role is the one a user agent resolves a role token list to. */
|
|
2137
|
+
function isConcreteRole(role) {
|
|
2138
|
+
return isKnownRole(role) && !isAbstractRole(role);
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* The role a user agent applies: the first token naming a concrete role, or undefined when none
|
|
2142
|
+
* does. Shared so the role rules cannot disagree about what a fallback list means.
|
|
2143
|
+
*/
|
|
2144
|
+
function resolveRole(tokens) {
|
|
2145
|
+
return tokens.find(isConcreteRole);
|
|
2146
|
+
}
|
|
2147
|
+
function isKnownAriaAttribute(name) {
|
|
2148
|
+
return ARIA_1_3_ATTRIBUTES.has(name) || aria.has(name);
|
|
2149
|
+
}
|
|
2150
|
+
function requiredAriaProps(role) {
|
|
2151
|
+
if (NO_REQUIRED_PROPS.has(role)) return [];
|
|
2152
|
+
const def = roles.get(role);
|
|
2153
|
+
return def ? Object.keys(def.requiredProps) : [];
|
|
2154
|
+
}
|
|
2155
|
+
function ariaValueKind(name) {
|
|
2156
|
+
const def = aria.get(name);
|
|
2157
|
+
if (!def) return void 0;
|
|
2158
|
+
return {
|
|
2159
|
+
type: def.type,
|
|
2160
|
+
...def.values ? { values: def.values.map(String) } : {}
|
|
2161
|
+
};
|
|
2162
|
+
}
|
|
2163
|
+
//#endregion
|
|
2164
|
+
//#region src/a11y.ts
|
|
2165
|
+
/**
|
|
2166
|
+
* Branch-aware occurrence fold (design "Control-flow semantics"): within a branch
|
|
2167
|
+
* occurrences sum, across the arms of one exclusive block the arm with the most
|
|
2168
|
+
* occurrences wins (tie → lowest branch index) and ITS occurrences are the group's
|
|
2169
|
+
* representatives — so a caller's count is always `list.length`, with a location per
|
|
2170
|
+
* representative. `{#each}`/`{#snippet}` occurrences render 0..N times and drop out.
|
|
2171
|
+
* The max is per key: there is no scalar total to maximize.
|
|
2172
|
+
*/
|
|
2173
|
+
function foldOccurrences(nodes) {
|
|
2174
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
2175
|
+
for (const node of nodes) {
|
|
2176
|
+
if (node.repeatable) continue;
|
|
2177
|
+
const list = byKey.get(node.key);
|
|
2178
|
+
if (list) list.push(node);
|
|
2179
|
+
else byKey.set(node.key, [node]);
|
|
2180
|
+
}
|
|
2181
|
+
const folded = /* @__PURE__ */ new Map();
|
|
2182
|
+
for (const [key, list] of byKey) folded.set(key, foldAt(list, 0));
|
|
2183
|
+
return folded;
|
|
2184
|
+
}
|
|
2185
|
+
function foldAt(nodes, depth) {
|
|
2186
|
+
const unconditional = [];
|
|
2187
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2188
|
+
for (const node of nodes) {
|
|
2189
|
+
const step = node.path[depth];
|
|
2190
|
+
if (!step) {
|
|
2191
|
+
unconditional.push(node);
|
|
2192
|
+
continue;
|
|
2193
|
+
}
|
|
2194
|
+
let branches = groups.get(step.group);
|
|
2195
|
+
if (!branches) groups.set(step.group, branches = /* @__PURE__ */ new Map());
|
|
2196
|
+
const list = branches.get(step.branch);
|
|
2197
|
+
if (list) list.push(node);
|
|
2198
|
+
else branches.set(step.branch, [node]);
|
|
2199
|
+
}
|
|
2200
|
+
const representatives = [...unconditional];
|
|
2201
|
+
for (const branches of groups.values()) {
|
|
2202
|
+
let best = [];
|
|
2203
|
+
let bestBranch = Number.POSITIVE_INFINITY;
|
|
2204
|
+
for (const [branch, list] of branches) {
|
|
2205
|
+
const arm = foldAt(list, depth + 1);
|
|
2206
|
+
if (arm.length > best.length || arm.length === best.length && branch < bestBranch) {
|
|
2207
|
+
best = arm;
|
|
2208
|
+
bestBranch = branch;
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
representatives.push(...best);
|
|
2212
|
+
}
|
|
2213
|
+
return representatives;
|
|
2214
|
+
}
|
|
2215
|
+
/**
|
|
2216
|
+
* Decode a fragment identifier the way navigation does before matching an element id
|
|
2217
|
+
* (`href="#caf%C3%A9"` targets `id="café"`). Malformed escapes are kept verbatim —
|
|
2218
|
+
* the browser would also fail to decode them, so the raw text is the comparable form.
|
|
2219
|
+
*/
|
|
2220
|
+
function decodeFragmentId(fragment) {
|
|
2221
|
+
try {
|
|
2222
|
+
return decodeURIComponent(fragment);
|
|
2223
|
+
} catch {
|
|
2224
|
+
return fragment;
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
/** Whitespace-split tokens of a (possibly undefined) literal attribute value. */
|
|
2228
|
+
function splitTokens(value) {
|
|
2229
|
+
return value ? value.trim().split(/\s+/).filter(Boolean) : [];
|
|
2230
|
+
}
|
|
2231
|
+
/** Explicit `role` values that map to the landmark kinds the route rules inspect. */
|
|
2232
|
+
const LANDMARK_ROLES = /* @__PURE__ */ new Set([
|
|
2233
|
+
"main",
|
|
2234
|
+
"banner",
|
|
2235
|
+
"contentinfo",
|
|
2236
|
+
"complementary"
|
|
2237
|
+
]);
|
|
2238
|
+
/** Sectioning content (HTML-AAM): a `<header>`/`<footer>` below one of these is not a landmark. */
|
|
2239
|
+
const SECTIONING_TAGS = /* @__PURE__ */ new Set([
|
|
2240
|
+
"article",
|
|
2241
|
+
"aside",
|
|
2242
|
+
"main",
|
|
2243
|
+
"nav",
|
|
2244
|
+
"section"
|
|
2245
|
+
]);
|
|
2246
|
+
/**
|
|
2247
|
+
* Tags whose landmark-ness depends on sectioning ancestry. A provider that cannot see ancestry
|
|
2248
|
+
* (the per-file AST walk) uses this same set to mark the deferral (topLevel) — reading it from
|
|
2249
|
+
* here keeps the deferral set from drifting when the policy widens.
|
|
2250
|
+
*/
|
|
2251
|
+
const ANCESTRY_DEPENDENT_TAGS = /* @__PURE__ */ new Set(["header", "footer"]);
|
|
2252
|
+
/** Attributes that give an element the accessible name the `<aside>` landmark decision reads. */
|
|
2253
|
+
const NAMING_ATTRS$1 = ["aria-label", "aria-labelledby"];
|
|
2254
|
+
/**
|
|
2255
|
+
* HTML-AAM: an `<aside>` scoped to `body` or `main` is a `complementary` landmark; scoped to
|
|
2256
|
+
* sectioning content it is one only when it has an accessible name. `main` is deliberately absent
|
|
2257
|
+
* — it is a scope in which an `aside` *is* a landmark, unlike the sectioning set that decides
|
|
2258
|
+
* `<header>`/`<footer>`.
|
|
2259
|
+
*/
|
|
2260
|
+
const ASIDE_DEMOTING_TAGS = /* @__PURE__ */ new Set([
|
|
2261
|
+
"article",
|
|
2262
|
+
"aside",
|
|
2263
|
+
"nav",
|
|
2264
|
+
"section"
|
|
2265
|
+
]);
|
|
2266
|
+
/**
|
|
2267
|
+
* The landmark kind an element contributes ('main'|'banner'|'contentinfo'|'complementary'), or
|
|
2268
|
+
* undefined. The one home for the decision both providers apply — the source AST walk and the
|
|
2269
|
+
* rendered HTML walk each feed it their own tree's context. ARIA fallback role lists resolve to
|
|
2270
|
+
* the first token naming a concrete role (resolveRole).
|
|
2271
|
+
*/
|
|
2272
|
+
function resolveLandmark(input) {
|
|
2273
|
+
const { tag, roleTokens, named, insideSectioning, insideAsideDemoting } = input;
|
|
2274
|
+
if (roleTokens !== void 0) {
|
|
2275
|
+
const role = resolveRole(roleTokens);
|
|
2276
|
+
return role && LANDMARK_ROLES.has(role) ? role : void 0;
|
|
2277
|
+
}
|
|
2278
|
+
if (tag === "main") return "main";
|
|
2279
|
+
if (tag !== void 0 && ANCESTRY_DEPENDENT_TAGS.has(tag)) return insideSectioning ? void 0 : tag === "header" ? "banner" : "contentinfo";
|
|
2280
|
+
if (tag === "aside") return !insideAsideDemoting || named ? "complementary" : void 0;
|
|
2281
|
+
}
|
|
2282
|
+
/**
|
|
2283
|
+
* Attributes whose (whitespace-tokenized) values reference element ids: the ARIA id-reference and
|
|
2284
|
+
* id-reference-list properties, and HTML's own (`for`, `list`, `headers`, `form`, the popover and
|
|
2285
|
+
* command targets). `href="#…"` is handled separately — its value is a URL, not a token list.
|
|
2286
|
+
*/
|
|
2287
|
+
const IDREF_ATTRS = [
|
|
2288
|
+
"for",
|
|
2289
|
+
"list",
|
|
2290
|
+
"headers",
|
|
2291
|
+
"form",
|
|
2292
|
+
"popovertarget",
|
|
2293
|
+
"commandfor",
|
|
2294
|
+
"aria-labelledby",
|
|
2295
|
+
"aria-describedby",
|
|
2296
|
+
"aria-controls",
|
|
2297
|
+
"aria-activedescendant",
|
|
2298
|
+
"aria-owns",
|
|
2299
|
+
"aria-details",
|
|
2300
|
+
"aria-errormessage",
|
|
2301
|
+
"aria-flowto"
|
|
2302
|
+
];
|
|
2303
|
+
/**
|
|
2304
|
+
* Whether a decoded URL fragment is HTML's "top of the document" indicator: `#top` (ASCII
|
|
2305
|
+
* case-insensitive) scrolls to the top when no element has that id, so it is never a missing
|
|
2306
|
+
* reference. Compare AFTER percent-decoding — `#%74op` navigates identically to `#top`.
|
|
2307
|
+
*/
|
|
2308
|
+
function isTopFragment(id) {
|
|
2309
|
+
return id.toLowerCase() === "top";
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* A fragment with its text directive removed. Everything from the first `:~:` on is user-agent
|
|
2313
|
+
* instructions for finding text and names no element, while anything before it is still an
|
|
2314
|
+
* ordinary element fragment — `#section:~:text=hi` targets `id="section"`, `#:~:text=hi` targets
|
|
2315
|
+
* nothing. Returns an empty string when the fragment is a directive and nothing else.
|
|
2316
|
+
*/
|
|
2317
|
+
function stripTextDirective(fragment) {
|
|
2318
|
+
const i = fragment.indexOf(":~:");
|
|
2319
|
+
return i === -1 ? fragment : fragment.slice(0, i);
|
|
2320
|
+
}
|
|
2321
|
+
//#endregion
|
|
2081
2322
|
//#region src/rules/seo/single-h1.ts
|
|
2082
2323
|
const docsUrl$6 = docsUrlFor("seo/single-h1");
|
|
2083
2324
|
const passRecommendation = "Use exactly one <h1> per page for its main topic; demote extra top-level headings to <h2>+.";
|
|
@@ -2092,10 +2333,24 @@ const multipleRecommendation = "A single, clear <h1> is the conventional signal
|
|
|
2092
2333
|
* signal, but no official source documents a ranking penalty for several (2026-08-09 v1
|
|
2093
2334
|
* rule-validity review, P2 #11) — so it's flagged as a style nit, not a defect. Exactly
|
|
2094
2335
|
* one passes. A route whose headings were not collected (channel unset) emits nothing, as does
|
|
2095
|
-
* one whose `dynamicHeading` flag says
|
|
2336
|
+
* one whose `dynamicHeading` flag says a heading of undeterminable level may be its <h1>. A
|
|
2096
2337
|
* global `rules: { 'seo/single-h1': <severity> }` override flattens both arms to one
|
|
2097
2338
|
* severity (design, `applyRuleSeverities`).
|
|
2098
2339
|
*/
|
|
2340
|
+
/**
|
|
2341
|
+
* The <h1>s that can render together, in input order: the arms of one `{#if}`/`{#await}`/
|
|
2342
|
+
* `<svelte:boundary>` contribute only their largest arm. Separate blocks still sum, since both may be true.
|
|
2343
|
+
*/
|
|
2344
|
+
function renderableH1s(headings) {
|
|
2345
|
+
const nodes = headings.filter((h) => h.level === 1).map((heading) => ({
|
|
2346
|
+
key: "h1",
|
|
2347
|
+
path: heading.path ?? [],
|
|
2348
|
+
repeatable: false,
|
|
2349
|
+
heading
|
|
2350
|
+
}));
|
|
2351
|
+
const kept = new Set([...foldOccurrences(nodes).values()].flat().map((n) => n.heading));
|
|
2352
|
+
return headings.filter((h) => kept.has(h));
|
|
2353
|
+
}
|
|
2099
2354
|
const seoSingleH1 = {
|
|
2100
2355
|
id: "seo/single-h1",
|
|
2101
2356
|
title: "Heading hierarchy",
|
|
@@ -2106,7 +2361,7 @@ const seoSingleH1 = {
|
|
|
2106
2361
|
async check(ctx) {
|
|
2107
2362
|
const out = [];
|
|
2108
2363
|
for (const route of ctx.headings ?? []) {
|
|
2109
|
-
const h1 = [...route.headings, ...route.componentHeadings ?? []]
|
|
2364
|
+
const h1 = renderableH1s([...route.headings, ...route.componentHeadings ?? []]);
|
|
2110
2365
|
let problem;
|
|
2111
2366
|
let where = {};
|
|
2112
2367
|
if (h1.length === 0) {
|
|
@@ -2240,6 +2495,13 @@ const seoDuplicateDescription = uniquenessRule({
|
|
|
2240
2495
|
rationale: "Duplicate meta descriptions give search engines no per-page summary, so they are often ignored or rewritten."
|
|
2241
2496
|
});
|
|
2242
2497
|
//#endregion
|
|
2498
|
+
//#region src/headings.ts
|
|
2499
|
+
/** Two headings can never render together: different arms of one block. */
|
|
2500
|
+
function exclusiveHeadings(a, b) {
|
|
2501
|
+
if (!a.path || !b.path) return false;
|
|
2502
|
+
return a.path.some((s) => b.path.some((t) => s.group === t.group && s.branch !== t.branch));
|
|
2503
|
+
}
|
|
2504
|
+
//#endregion
|
|
2243
2505
|
//#region src/rules/seo/heading-level-skip.ts
|
|
2244
2506
|
const docsUrl$5 = docsUrlFor("seo/heading-level-skip");
|
|
2245
2507
|
const recommendation$5 = "Increase heading levels one step at a time (do not jump, e.g. from <h2> straight to <h4>).";
|
|
@@ -2260,20 +2522,16 @@ const seoHeadingLevelSkip = {
|
|
|
2260
2522
|
const out = [];
|
|
2261
2523
|
for (const route of ctx.headings ?? []) {
|
|
2262
2524
|
if (route.headings.length === 0) continue;
|
|
2263
|
-
let prev = route.headings[0].level;
|
|
2264
2525
|
let skip;
|
|
2265
|
-
for (let i = 1; i < route.headings.length; i++) {
|
|
2526
|
+
for (let i = 1; i < route.headings.length && !skip; i++) {
|
|
2266
2527
|
const h = route.headings[i];
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
break;
|
|
2275
|
-
}
|
|
2276
|
-
prev = h.level;
|
|
2528
|
+
const prev = route.headings.slice(0, i).reverse().find((p) => !exclusiveHeadings(p, h));
|
|
2529
|
+
if (prev && h.level > prev.level + 1) skip = {
|
|
2530
|
+
level: h.level,
|
|
2531
|
+
prev: prev.level,
|
|
2532
|
+
line: h.line,
|
|
2533
|
+
file: h.file
|
|
2534
|
+
};
|
|
2277
2535
|
}
|
|
2278
2536
|
out.push(skip ? {
|
|
2279
2537
|
id: "seo/heading-level-skip",
|
|
@@ -2403,11 +2661,94 @@ function kitModuleRule(opts) {
|
|
|
2403
2661
|
});
|
|
2404
2662
|
}
|
|
2405
2663
|
//#endregion
|
|
2406
|
-
//#region src/
|
|
2407
|
-
/** The root layout —
|
|
2664
|
+
//#region src/kit-module.ts
|
|
2665
|
+
/** The root layout — its page options cover the whole app. */
|
|
2408
2666
|
const ROOT_LAYOUT_RE = /^src\/routes\/\+layout(\.server)?\.(ts|js)$/;
|
|
2409
|
-
/** `ssr` is a page option — it has no effect in `+server` endpoints or hooks files. */
|
|
2667
|
+
/** Files whose `ssr` export is a page option — it has no effect in `+server` endpoints or hooks files. */
|
|
2410
2668
|
const PAGE_OPTION_FILE_RE = /\+(page|layout)(\.server)?\.(ts|js)$/;
|
|
2669
|
+
const appSsrCache = /* @__PURE__ */ new WeakMap();
|
|
2670
|
+
/**
|
|
2671
|
+
* Whether no page is ever server-rendered: the root layout exports `ssr = false` and no page-option
|
|
2672
|
+
* file might turn it back on. Universal loads and components then run only in the browser; server
|
|
2673
|
+
* files (`+page.server`, `+server`, hooks) still run on the server.
|
|
2674
|
+
*/
|
|
2675
|
+
function appSsrDisabled(kitModules) {
|
|
2676
|
+
if (!kitModules) return false;
|
|
2677
|
+
let hit = appSsrCache.get(kitModules);
|
|
2678
|
+
if (hit === void 0) {
|
|
2679
|
+
hit = kitModules.some((m) => m.ssrDisabled && ROOT_LAYOUT_RE.test(m.file)) && !kitModules.some((m) => PAGE_OPTION_FILE_RE.test(m.file) && (m.ssrEnabled || m.parseFailed));
|
|
2680
|
+
appSsrCache.set(kitModules, hit);
|
|
2681
|
+
}
|
|
2682
|
+
return hit;
|
|
2683
|
+
}
|
|
2684
|
+
const ROUTES_DIR$1 = "src/routes";
|
|
2685
|
+
/** A `+page`/`+layout` file: its directory, kind, and the `@segment` layout reset (on `.svelte` names). */
|
|
2686
|
+
const ROUTE_FILE_RE = /^(src\/routes(?:\/.+)?)\/\+(page|layout)(?:@([^/]*))?(?:\.server)?\.(?:svelte|ts|js)$/;
|
|
2687
|
+
const parentDir = (dir) => dir === ROUTES_DIR$1 ? void 0 : dir.slice(0, dir.lastIndexOf("/"));
|
|
2688
|
+
/** The nearest ancestor-or-self directory a `@segment` reset names (`''` is the root). */
|
|
2689
|
+
function resetTarget(dir, segment) {
|
|
2690
|
+
for (let d = dir; d !== void 0; d = parentDir(d)) if (segment === "" ? d === ROUTES_DIR$1 : d.slice(d.lastIndexOf("/") + 1) === segment) return d;
|
|
2691
|
+
}
|
|
2692
|
+
const routeSsrCache = /* @__PURE__ */ new WeakMap();
|
|
2693
|
+
/**
|
|
2694
|
+
* Per route directory, whether its page (`page:<dir>`) or layout (`layout:<dir>`) is never
|
|
2695
|
+
* server-rendered. A page takes the nearest `ssr` export along its own files and then its layout
|
|
2696
|
+
* chain, following `+page@x`/`+layout@x` resets as SvelteKit does; an export other than a literal
|
|
2697
|
+
* `false`, or an unparsed option file, counts as SSR on. A layout is off only when every page using
|
|
2698
|
+
* it is — and the root layout also needs its own `ssr = false`, since it wraps the 404 page.
|
|
2699
|
+
*/
|
|
2700
|
+
function routeSsrOff(scope) {
|
|
2701
|
+
const kitModules = scope.kitModules ?? [];
|
|
2702
|
+
let hit = routeSsrCache.get(scope);
|
|
2703
|
+
if (hit) return hit;
|
|
2704
|
+
const pages = /* @__PURE__ */ new Map();
|
|
2705
|
+
const layouts = /* @__PURE__ */ new Map();
|
|
2706
|
+
const files = [
|
|
2707
|
+
...scope.sourceFiles ?? [],
|
|
2708
|
+
...(scope.components ?? []).map((c) => c.file),
|
|
2709
|
+
...kitModules.map((m) => m.file)
|
|
2710
|
+
];
|
|
2711
|
+
for (const file of files) {
|
|
2712
|
+
const match = ROUTE_FILE_RE.exec(file);
|
|
2713
|
+
if (!match) continue;
|
|
2714
|
+
const map = match[2] === "page" ? pages : layouts;
|
|
2715
|
+
if (match[3] !== void 0 || !map.has(match[1])) map.set(match[1], match[3]);
|
|
2716
|
+
}
|
|
2717
|
+
const option = /* @__PURE__ */ new Map();
|
|
2718
|
+
for (const m of kitModules) {
|
|
2719
|
+
const match = ROUTE_FILE_RE.exec(m.file);
|
|
2720
|
+
if (!match) continue;
|
|
2721
|
+
const key = `${match[2]}:${match[1]}`;
|
|
2722
|
+
if (m.ssrEnabled || m.parseFailed) option.set(key, "on");
|
|
2723
|
+
else if (m.ssrDisabled && !option.has(key)) option.set(key, "off");
|
|
2724
|
+
}
|
|
2725
|
+
hit = /* @__PURE__ */ new Map();
|
|
2726
|
+
for (const [dir, reset] of pages) {
|
|
2727
|
+
const chain = [];
|
|
2728
|
+
for (let d = reset === void 0 ? dir : resetTarget(dir, reset); d !== void 0;) {
|
|
2729
|
+
const layoutReset = layouts.get(d);
|
|
2730
|
+
if (layouts.has(d)) chain.push(d);
|
|
2731
|
+
d = layoutReset === void 0 ? parentDir(d) : resetTarget(parentDir(d), layoutReset);
|
|
2732
|
+
}
|
|
2733
|
+
const off = [`page:${dir}`, ...chain.map((l) => `layout:${l}`)].map((k) => option.get(k)).find(Boolean) === "off";
|
|
2734
|
+
hit.set(`page:${dir}`, off);
|
|
2735
|
+
for (const l of chain) hit.set(`layout:${l}`, (hit.get(`layout:${l}`) ?? true) && off);
|
|
2736
|
+
}
|
|
2737
|
+
if (option.get(`layout:${ROUTES_DIR$1}`) !== "off") hit.delete(`layout:${ROUTES_DIR$1}`);
|
|
2738
|
+
routeSsrCache.set(scope, hit);
|
|
2739
|
+
return hit;
|
|
2740
|
+
}
|
|
2741
|
+
/** Whether a `+page`/`+layout` file (module or component) belongs to a route that is never server-rendered. */
|
|
2742
|
+
function routeNeverSsr(file, scope) {
|
|
2743
|
+
const match = ROUTE_FILE_RE.exec(file);
|
|
2744
|
+
return !!match && routeSsrOff(scope).get(`${match[2]}:${match[1]}`) === true;
|
|
2745
|
+
}
|
|
2746
|
+
/** Whether a universal `+page`/`+layout` module never runs on the server — its own `ssr = false`, an app-wide one, or one its route inherits. */
|
|
2747
|
+
function universalNeverSsr(m, scope) {
|
|
2748
|
+
return m.kind === "universal" && (m.ssrDisabled !== void 0 || appSsrDisabled(scope.kitModules) || routeNeverSsr(m.file, scope));
|
|
2749
|
+
}
|
|
2750
|
+
//#endregion
|
|
2751
|
+
//#region src/rules/seo/ssr-disabled.ts
|
|
2411
2752
|
const seoSsrDisabled = kitModuleRule({
|
|
2412
2753
|
id: "seo/ssr-disabled",
|
|
2413
2754
|
title: "SSR disabled",
|
|
@@ -2749,9 +3090,19 @@ function messageFor(link) {
|
|
|
2749
3090
|
if (link.kind === "goto") return `goto('${link.path}') is root-relative — it navigates outside this project's kit.paths.base and 404s in production. Use goto(resolve('${link.path}')) with resolve from '$app/paths'.`;
|
|
2750
3091
|
return `redirect(…, '${link.path}') is root-relative — the Location header points outside this project's kit.paths.base and 404s in production. Use resolve('${link.path}') from '$app/paths'.`;
|
|
2751
3092
|
}
|
|
3093
|
+
/**
|
|
3094
|
+
* A literal that already carries a statically known base is correct as written; only a dynamic
|
|
3095
|
+
* base (no `value`) leaves every root-relative literal suspect.
|
|
3096
|
+
*/
|
|
3097
|
+
function startsWithBase(path, base) {
|
|
3098
|
+
if (base === void 0 || !path.startsWith(base)) return false;
|
|
3099
|
+
if (/(^|\/)(\.|%2e){2}(\/|$)/i.test(path.split(/[?#]/)[0])) return false;
|
|
3100
|
+
const next = path.charAt(base.length);
|
|
3101
|
+
return next === "" || next === "/" || next === "?" || next === "#";
|
|
3102
|
+
}
|
|
2752
3103
|
/** Emit one file's PASS/PENALIZED results — same shapes as componentRule/kitModuleRule. */
|
|
2753
|
-
function emitFile$1(out, file, links, suppressions) {
|
|
2754
|
-
const bad = links.filter((l) => !(l.line > 0 && isSuppressed(suppressions, ID$8, l.line)));
|
|
3104
|
+
function emitFile$1(out, file, links, suppressions, base) {
|
|
3105
|
+
const bad = links.filter((l) => !startsWithBase(l.path, base) && !(l.line > 0 && isSuppressed(suppressions, ID$8, l.line)));
|
|
2755
3106
|
if (bad.length === 0) {
|
|
2756
3107
|
out.push({
|
|
2757
3108
|
id: ID$8,
|
|
@@ -2796,16 +3147,17 @@ const correctnessBasePathNavigation = {
|
|
|
2796
3147
|
fix: { ...FIX$2 },
|
|
2797
3148
|
async check(ctx) {
|
|
2798
3149
|
if (!ctx.project.kitPathsBase) return [];
|
|
3150
|
+
const base = ctx.project.kitPathsBase.value;
|
|
2799
3151
|
const out = [];
|
|
2800
3152
|
for (const c of ctx.components ?? []) {
|
|
2801
3153
|
const links = c.basePathLinks ?? [];
|
|
2802
3154
|
if (links.length === 0) continue;
|
|
2803
|
-
emitFile$1(out, c.file, links, c.suppressions);
|
|
3155
|
+
emitFile$1(out, c.file, links, c.suppressions, base);
|
|
2804
3156
|
}
|
|
2805
3157
|
for (const m of ctx.kitModules ?? []) {
|
|
2806
3158
|
const links = m.basePathLinks ?? [];
|
|
2807
3159
|
if (links.length === 0) continue;
|
|
2808
|
-
emitFile$1(out, m.file, links, m.suppressions);
|
|
3160
|
+
emitFile$1(out, m.file, links, m.suppressions, base);
|
|
2809
3161
|
}
|
|
2810
3162
|
return out;
|
|
2811
3163
|
}
|
|
@@ -2872,7 +3224,8 @@ const correctnessServerBrowserGlobal = {
|
|
|
2872
3224
|
})), c.suppressions);
|
|
2873
3225
|
}
|
|
2874
3226
|
for (const m of ctx.kitModules ?? []) {
|
|
2875
|
-
const
|
|
3227
|
+
const neverSsr = universalNeverSsr(m, ctx);
|
|
3228
|
+
const refs = (m.browserGlobalRefs ?? []).filter((r) => !(neverSsr && r.inHandler));
|
|
2876
3229
|
if (refs.length === 0) continue;
|
|
2877
3230
|
emitFile(out, m.file, refs.map((r) => ({
|
|
2878
3231
|
line: r.line,
|
|
@@ -2891,7 +3244,7 @@ const correctnessInstanceBrowserGlobal = componentRule({
|
|
|
2891
3244
|
label: "Server-safe component init",
|
|
2892
3245
|
recommendation: "Move browser-only code into onMount or $effect (they never run on the server), or guard it with browser from $app/environment (or a typeof check).",
|
|
2893
3246
|
rationale: "A component instance script runs on the server on every SSR render, where window/document/localStorage do not exist. Warning, not critical: a component rendered only behind a parent {#if browser} (or a client-only dynamic import) is a legitimate pattern that static analysis cannot prove cross-file.",
|
|
2894
|
-
applies: (c) => (c.browserGlobalRefs ?? []).some((r) => r.context === "instance"),
|
|
3247
|
+
applies: (c, _o, ctx) => (c.browserGlobalRefs ?? []).some((r) => r.context === "instance") && !appSsrDisabled(ctx.kitModules) && !routeNeverSsr(c.file, ctx),
|
|
2895
3248
|
bad: (c) => (c.browserGlobalRefs ?? []).filter((r) => r.context === "instance").map((r) => ({
|
|
2896
3249
|
line: r.line,
|
|
2897
3250
|
message: `${r.name} is accessed during component initialisation — during SSR this runs on the server, where ${r.name} is not defined`
|
|
@@ -2937,7 +3290,7 @@ const securityHandlerStateWrite = kitModuleRule({
|
|
|
2937
3290
|
label: "Load/handler purity",
|
|
2938
3291
|
recommendation: "Return the data from load (or the action) and pass it via page data instead of writing it to module state; per-user data belongs in cookies/locals plus a database. Caches and rate limiters keyed by non-personal data are the benign shape — if that describes this write, add an inline suppression.",
|
|
2939
3292
|
rationale: "SvelteKit's docs mark this NEVER-DO-THIS: the server is one long-lived process shared by every user, so module state written during a request is visible to ALL later requests.",
|
|
2940
|
-
applies: (m) => m.importedStateWrites.length > 0 && !(m
|
|
3293
|
+
applies: (m, ctx) => m.importedStateWrites.length > 0 && !universalNeverSsr(m, ctx),
|
|
2941
3294
|
bad: (m) => m.importedStateWrites.map((w) => ({
|
|
2942
3295
|
line: w.line,
|
|
2943
3296
|
message: `a server-executed handler writes imported module state "${w.name}" — module state is shared across all requests on the server; if this holds per-request or per-user data, one user's data can leak to another. Caches and rate limiters keyed by non-personal data are the benign shape — verify which this is`
|
|
@@ -2952,7 +3305,7 @@ const securityServerModuleState = kitModuleRule({
|
|
|
2952
3305
|
label: "Server module state",
|
|
2953
3306
|
recommendation: "Do not keep request data in module scope on the server — authenticate with cookies/locals and persist per-user data in a database. For a deliberate process-wide cache, prefer a const container (e.g. a Map) or add an inline suppression.",
|
|
2954
3307
|
rationale: "Module scope on the server is one shared, long-lived instance (SvelteKit docs: \"Avoid shared state on the server\"): a value reassigned during one user's request is served to every other user, and it silently resets on every deploy or restart.",
|
|
2955
|
-
applies: (m) => m.moduleStateReassignments.length > 0,
|
|
3308
|
+
applies: (m, ctx) => m.moduleStateReassignments.length > 0 && !universalNeverSsr(m, ctx),
|
|
2956
3309
|
bad: (m) => m.moduleStateReassignments.map((r) => ({
|
|
2957
3310
|
line: r.line,
|
|
2958
3311
|
message: r.inHandler ? `module-scope variable "${r.name}" is reassigned from a request handler — its value is shared across all requests on the server` : `module-scope variable "${r.name}" is reassigned from a function — if it runs during a request, the value is shared across all requests on the server`
|
|
@@ -2971,7 +3324,7 @@ const securitySharedStateImport = kitModuleRule({
|
|
|
2971
3324
|
label: "Server state imports",
|
|
2972
3325
|
recommendation: "Keep module-scope $state out of server-executed code: return data from load and share it via page data or the context API. If the module is genuinely client-only, restructure so server files do not import it, or add an inline suppression.",
|
|
2973
3326
|
rationale: "A .svelte.ts module with module-scope $state is one shared instance on the server: mutated, it leaks data between users; read-only, every request sees the same boot-time value instead of per-user data.",
|
|
2974
|
-
applies: (m) => m.runesModuleImports.length > 0 && !(m
|
|
3327
|
+
applies: (m, ctx) => m.runesModuleImports.length > 0 && !universalNeverSsr(m, ctx),
|
|
2975
3328
|
bad: (m, ctx) => {
|
|
2976
3329
|
const stateFiles = new Set((ctx.components ?? []).filter((c) => c.moduleStateDecls.length > 0).map((c) => c.file));
|
|
2977
3330
|
const writtenOutside = new Set(m.importedStateWritesOutsideHandlers.map((w) => w.name));
|
|
@@ -3252,7 +3605,8 @@ function rootObjectName(node) {
|
|
|
3252
3605
|
* `then`/`catch` value/error bindings, and a fragment's own `{@const ...}` /
|
|
3253
3606
|
* `{let ...}` / `{const ...}` declaration tags (attributed to the enclosing Fragment,
|
|
3254
3607
|
* shadowing the whole fragment like a block's `let` — a write to one of these
|
|
3255
|
-
* template-locals, possible for `{let}`, is still not a write to the outer binding)
|
|
3608
|
+
* template-locals, possible for `{let}`, is still not a write to the outer binding), and an
|
|
3609
|
+
* element's or component's `let:` directive bindings.
|
|
3256
3610
|
* Used by `walkScoped` so a write/mutation detector
|
|
3257
3611
|
* doesn't misattribute a write to one of these locals as a write to an outer `$state`/prop
|
|
3258
3612
|
* of the same name (issue #140 — originally a deliberately partial mitigation that left
|
|
@@ -3278,7 +3632,20 @@ function scopeIntroducedNames(node) {
|
|
|
3278
3632
|
else if (node.type === "AwaitBlock") {
|
|
3279
3633
|
if (node.value) addBoundNames(node.value, introduced);
|
|
3280
3634
|
if (node.error) addBoundNames(node.error, introduced);
|
|
3281
|
-
} else if (node.
|
|
3635
|
+
} else if (Array.isArray(node.attributes) && node.fragment) for (const attr of node.attributes) {
|
|
3636
|
+
if (attr?.type !== "LetDirective") continue;
|
|
3637
|
+
const e = attr.expression;
|
|
3638
|
+
const asPattern = {
|
|
3639
|
+
ObjectExpression: "ObjectPattern",
|
|
3640
|
+
ArrayExpression: "ArrayPattern"
|
|
3641
|
+
}[e?.type];
|
|
3642
|
+
if (e) addBoundNames(asPattern ? {
|
|
3643
|
+
...e,
|
|
3644
|
+
type: asPattern
|
|
3645
|
+
} : e, introduced);
|
|
3646
|
+
else introduced.add(attr.name);
|
|
3647
|
+
}
|
|
3648
|
+
else if (node.type === "Fragment") {
|
|
3282
3649
|
for (const child of node.nodes ?? []) if (child?.type === "ConstTag" || child?.type === "DeclarationTag") for (const d of child.declaration?.declarations ?? []) addBoundNames(d.id, introduced);
|
|
3283
3650
|
}
|
|
3284
3651
|
return introduced;
|
|
@@ -3334,11 +3701,21 @@ function walkEvalScope(node, visit, shadowed = /* @__PURE__ */ new Set(), bounda
|
|
|
3334
3701
|
const scope = introduced.size > 0 ? /* @__PURE__ */ new Set([...shadowed, ...introduced]) : shadowed;
|
|
3335
3702
|
if (visit(node, scope)) return;
|
|
3336
3703
|
if (boundaries.has(node.type)) return;
|
|
3704
|
+
const outer = introduced.size > 0 ? outerScopeKeys(node) : void 0;
|
|
3337
3705
|
for (const key of Object.keys(node)) {
|
|
3338
3706
|
if (WALK_IGNORED_KEYS.has(key)) continue;
|
|
3339
|
-
walkEvalScope(node[key], visit, scope, boundaries);
|
|
3707
|
+
if (!outer?.has(key)) walkEvalScope(node[key], visit, scope, boundaries);
|
|
3708
|
+
else if (!Array.isArray(node[key])) walkEvalScope(node[key], visit, shadowed, boundaries);
|
|
3709
|
+
else for (const child of node[key]) walkEvalScope(child, visit, child?.type === "LetDirective" ? scope : shadowed, boundaries);
|
|
3340
3710
|
}
|
|
3341
3711
|
}
|
|
3712
|
+
/** Children of a binding construct that are evaluated before its bindings exist. */
|
|
3713
|
+
function outerScopeKeys(node) {
|
|
3714
|
+
if (node.type === "EachBlock" || node.type === "AwaitBlock") return OUTER_SCOPE_KEYS;
|
|
3715
|
+
if (Array.isArray(node.attributes) && node.fragment) return ATTRIBUTE_KEYS;
|
|
3716
|
+
}
|
|
3717
|
+
const OUTER_SCOPE_KEYS = /* @__PURE__ */ new Set(["expression"]);
|
|
3718
|
+
const ATTRIBUTE_KEYS = /* @__PURE__ */ new Set(["attributes"]);
|
|
3342
3719
|
/**
|
|
3343
3720
|
* Unwrap a top-level statement's `export`/`export default` wrapper to the declaration
|
|
3344
3721
|
* (or expression) it wraps; a non-export statement is returned as-is. Used so pattern 2
|
|
@@ -3443,12 +3820,13 @@ function collectNamedImportAliases(program, moduleSource, names) {
|
|
|
3443
3820
|
}
|
|
3444
3821
|
const BROWSER_GUARD_NAMES = /* @__PURE__ */ new Set(["browser"]);
|
|
3445
3822
|
/**
|
|
3446
|
-
* Local names of `browser` value-imported from '$app/environment'
|
|
3823
|
+
* Local names of `browser` value-imported from '$app/environment' or its SvelteKit 3 name
|
|
3824
|
+
* '$app/env' (alias-resolved) —
|
|
3447
3825
|
* the guard binding recognised by the browser-global scanner (correctness/server-browser-global, correctness/instance-browser-global).
|
|
3448
3826
|
* Shared with the Kit-module parser.
|
|
3449
3827
|
*/
|
|
3450
3828
|
function collectBrowserGuardImports(program) {
|
|
3451
|
-
return collectNamedImportAliases(program, "$app/environment", BROWSER_GUARD_NAMES);
|
|
3829
|
+
return /* @__PURE__ */ new Set([...collectNamedImportAliases(program, "$app/environment", BROWSER_GUARD_NAMES), ...collectNamedImportAliases(program, "$app/env", BROWSER_GUARD_NAMES)]);
|
|
3452
3830
|
}
|
|
3453
3831
|
/**
|
|
3454
3832
|
* Names bound at the program's top level: every import's local name plus every
|
|
@@ -3484,10 +3862,12 @@ function guardTerminates(consequent) {
|
|
|
3484
3862
|
/**
|
|
3485
3863
|
* Whether a guard-clause test establishes a browser environment: it references the
|
|
3486
3864
|
* `$app/environment` `browser` binding, or contains a
|
|
3487
|
-
* `typeof <tracked-global
|
|
3865
|
+
* `typeof <tracked-global>` comparison against any type string (`'undefined'`, `'function'`, …)
|
|
3866
|
+
* (correctness/server-browser-global, correctness/instance-browser-global).
|
|
3488
3867
|
* Over-matching here only widens the skip — a conservative miss, never a false positive.
|
|
3489
3868
|
*/
|
|
3490
3869
|
function isBrowserGuardTest(test, guardBindings) {
|
|
3870
|
+
if (test?.type === "LogicalExpression" && test.operator !== "&&") return isBrowserGuardTest(test.left, guardBindings) && isBrowserGuardTest(test.right, guardBindings);
|
|
3491
3871
|
let guarded = false;
|
|
3492
3872
|
walkEstree(test, (n) => {
|
|
3493
3873
|
if (n.type === "Identifier" && guardBindings.has(n.name)) guarded = true;
|
|
@@ -3499,8 +3879,9 @@ function isBrowserGuardTest(test, guardBindings) {
|
|
|
3499
3879
|
].includes(n.operator)) {
|
|
3500
3880
|
const sides = [n.left, n.right];
|
|
3501
3881
|
const hasTypeofGlobal = sides.some((s) => s?.type === "UnaryExpression" && s.operator === "typeof" && s.argument?.type === "Identifier" && BROWSER_GLOBALS.has(s.argument.name));
|
|
3502
|
-
const
|
|
3503
|
-
|
|
3882
|
+
const type = sides.find((s) => s?.type === "Literal" && typeof s.value === "string")?.value;
|
|
3883
|
+
const positive = n.operator === "===" || n.operator === "==";
|
|
3884
|
+
if (hasTypeofGlobal && type !== void 0 && (type === "undefined" || positive)) guarded = true;
|
|
3504
3885
|
}
|
|
3505
3886
|
});
|
|
3506
3887
|
return guarded;
|
|
@@ -3558,7 +3939,7 @@ function collectBrowserGlobalRefs(program, source, extra) {
|
|
|
3558
3939
|
if (typeof n !== "object" || typeof n.type !== "string") return;
|
|
3559
3940
|
if (EVAL_SCOPE_BOUNDARIES.has(n.type)) return;
|
|
3560
3941
|
if ((n.type === "IfStatement" || n.type === "ConditionalExpression") && isBrowserGuardTest(n.test, guards)) return;
|
|
3561
|
-
if (n.type === "LogicalExpression" && isBrowserGuardTest(n.left, guards)) return;
|
|
3942
|
+
if (n.type === "LogicalExpression" && (n.operator === "&&" ? isBrowserGuardTest(n.left, guards) : n.left?.type === "UnaryExpression" && n.left.operator === "!" && isBrowserGuardTest(n.left.argument, guards))) return;
|
|
3562
3943
|
const introduced = scopeIntroducedNames(n);
|
|
3563
3944
|
const scope = introduced.size > 0 ? /* @__PURE__ */ new Set([...shadowed, ...introduced]) : shadowed;
|
|
3564
3945
|
switch (n.type) {
|
|
@@ -3770,6 +4151,16 @@ function findFalseOptOut(program, source, name) {
|
|
|
3770
4151
|
});
|
|
3771
4152
|
return hit;
|
|
3772
4153
|
}
|
|
4154
|
+
/** Whether the module exports a binding named `name` in any form, re-exports and unresolved aliases included. */
|
|
4155
|
+
function exportsName(program, name) {
|
|
4156
|
+
return (program.body ?? []).some((stmt) => {
|
|
4157
|
+
if (stmt?.type !== "ExportNamedDeclaration" || stmt.exportKind === "type") return false;
|
|
4158
|
+
const decl = stmt.declaration;
|
|
4159
|
+
if (decl?.type === "VariableDeclaration") return decl.declarations?.some((d) => d?.id?.name === name);
|
|
4160
|
+
if (decl) return decl.id?.name === name;
|
|
4161
|
+
return (stmt.specifiers ?? []).some((s) => s?.exportKind !== "type" && (s?.exported?.name ?? s?.exported?.value) === name);
|
|
4162
|
+
});
|
|
4163
|
+
}
|
|
3773
4164
|
/**
|
|
3774
4165
|
* The exported `load` function node (inline `export function load` / `export const
|
|
3775
4166
|
* load = …`, `satisfies`/`as` unwrapped) or a same-file alias export. Cross-file
|
|
@@ -3799,7 +4190,15 @@ function collectAwaits(node, out = []) {
|
|
|
3799
4190
|
}
|
|
3800
4191
|
return out;
|
|
3801
4192
|
}
|
|
4193
|
+
const MUTATING_METHODS = /* @__PURE__ */ new Set([
|
|
4194
|
+
"push",
|
|
4195
|
+
"unshift",
|
|
4196
|
+
"splice",
|
|
4197
|
+
"add",
|
|
4198
|
+
"set"
|
|
4199
|
+
]);
|
|
3802
4200
|
const REDIRECT_NAMES = /* @__PURE__ */ new Set(["redirect"]);
|
|
4201
|
+
const EXIT_NAMES = /* @__PURE__ */ new Set(["redirect", "error"]);
|
|
3803
4202
|
/**
|
|
3804
4203
|
* Root-relative `redirect(status, '/…')` targets (correctness/base-path-navigation). Argument 1
|
|
3805
4204
|
* is the location (argument 0 is the status). Only a plain string literal counts, which
|
|
@@ -3819,6 +4218,74 @@ function collectRedirectCalls(node, locals, out = []) {
|
|
|
3819
4218
|
}
|
|
3820
4219
|
return out;
|
|
3821
4220
|
}
|
|
4221
|
+
/** Whether `node` holds a `return` outside nested functions. */
|
|
4222
|
+
function containsReturn(node) {
|
|
4223
|
+
if (Array.isArray(node)) return node.some(containsReturn);
|
|
4224
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string" || isFunctionNode(node)) return false;
|
|
4225
|
+
if (node.type === "ReturnStatement") return true;
|
|
4226
|
+
return Object.keys(node).some((key) => !WALK_IGNORED_KEYS.has(key) && containsReturn(node[key]));
|
|
4227
|
+
}
|
|
4228
|
+
/**
|
|
4229
|
+
* Whether the exported `load` redirects or errors on every call: every path through its body reaches a
|
|
4230
|
+
* `redirect(…)` / `error(…)` (thrown or not) before any `return` — through both arms of an `if`/`else`,
|
|
4231
|
+
* through a `try` whose `catch` redirects or rethrows, and through a call to a top-level function
|
|
4232
|
+
* of the same file that itself always redirects. A redirect under an `if` without an `else`, in a
|
|
4233
|
+
* loop, or in a function declared inside `load` does not count.
|
|
4234
|
+
*/
|
|
4235
|
+
function loadNeverRenders(program, locals) {
|
|
4236
|
+
const load = locals.size > 0 ? findLoadFunction(program) : void 0;
|
|
4237
|
+
if (!load?.body) return false;
|
|
4238
|
+
const topLevel = collectTopLevelBindings(program);
|
|
4239
|
+
const fnRedirects = (fn, seen, observed) => {
|
|
4240
|
+
const shadow = /* @__PURE__ */ new Set();
|
|
4241
|
+
for (const p of fn.params ?? []) addBoundNames(p, shadow);
|
|
4242
|
+
const frame = {
|
|
4243
|
+
seen: /* @__PURE__ */ new Set([...seen, fn]),
|
|
4244
|
+
observed
|
|
4245
|
+
};
|
|
4246
|
+
return fn.body?.type === "BlockStatement" ? always(fn.body.body, shadow, void 0, frame) : isRedirect(fn.body, shadow, frame, observed);
|
|
4247
|
+
};
|
|
4248
|
+
const isRedirect = (expr, shadow, frame, returned = false) => {
|
|
4249
|
+
let e = unwrapTs(expr);
|
|
4250
|
+
const awaited = e?.type === "AwaitExpression";
|
|
4251
|
+
if (awaited) e = unwrapTs(e.argument);
|
|
4252
|
+
if (e?.type !== "CallExpression" || e.callee?.type !== "Identifier" || shadow.has(e.callee.name)) return false;
|
|
4253
|
+
if (locals.has(e.callee.name)) return true;
|
|
4254
|
+
const fn = topLevel.get(e.callee.name);
|
|
4255
|
+
if (!isFunctionNode(fn) || fn.generator || frame.seen.has(fn) || fn.async && !awaited && !returned) return false;
|
|
4256
|
+
return fnRedirects(fn, frame.seen, awaited || returned);
|
|
4257
|
+
};
|
|
4258
|
+
const within = (body, outer, param) => {
|
|
4259
|
+
const own = /* @__PURE__ */ new Set();
|
|
4260
|
+
addBoundNames(param, own);
|
|
4261
|
+
for (const stmt of body ?? []) if (stmt?.type === "VariableDeclaration") for (const d of stmt.declarations ?? []) addBoundNames(d?.id, own);
|
|
4262
|
+
else if ((stmt?.type === "FunctionDeclaration" || stmt?.type === "ClassDeclaration") && stmt.id) addBoundNames(stmt.id, own);
|
|
4263
|
+
return own.size === 0 ? outer : /* @__PURE__ */ new Set([...outer, ...own]);
|
|
4264
|
+
};
|
|
4265
|
+
const redirects = (stmt, shadow, rethrow, frame) => {
|
|
4266
|
+
if (stmt?.type === "ExpressionStatement") return isRedirect(stmt.expression, shadow, frame);
|
|
4267
|
+
if (stmt?.type === "ThrowStatement" || stmt?.type === "ReturnStatement") {
|
|
4268
|
+
if (isRedirect(stmt.argument, shadow, frame, stmt.type === "ReturnStatement" && frame.observed)) return true;
|
|
4269
|
+
return stmt.type === "ThrowStatement" && stmt.argument?.type === "Identifier" && stmt.argument.name === rethrow;
|
|
4270
|
+
}
|
|
4271
|
+
if (stmt?.type === "BlockStatement") return always(stmt.body, shadow, rethrow, frame);
|
|
4272
|
+
if (stmt?.type === "IfStatement") return !!stmt.alternate && redirects(stmt.consequent, shadow, rethrow, frame) && redirects(stmt.alternate, shadow, rethrow, frame);
|
|
4273
|
+
if (stmt?.type === "TryStatement") {
|
|
4274
|
+
const param = stmt.handler?.param?.type === "Identifier" ? stmt.handler.param.name : void 0;
|
|
4275
|
+
return always(stmt.block?.body, shadow, void 0, frame) && (!stmt.handler || always(stmt.handler.body?.body, within([], shadow, stmt.handler.param), param, frame)) && !containsReturn(stmt.finalizer);
|
|
4276
|
+
}
|
|
4277
|
+
return false;
|
|
4278
|
+
};
|
|
4279
|
+
const always = (body, outer, rethrow, frame) => {
|
|
4280
|
+
const shadow = within(body, outer);
|
|
4281
|
+
for (const stmt of body ?? []) {
|
|
4282
|
+
if (redirects(stmt, shadow, rethrow, frame)) return true;
|
|
4283
|
+
if (containsReturn(stmt)) return false;
|
|
4284
|
+
}
|
|
4285
|
+
return false;
|
|
4286
|
+
};
|
|
4287
|
+
return fnRedirects(load, /* @__PURE__ */ new Set(), true);
|
|
4288
|
+
}
|
|
3822
4289
|
/**
|
|
3823
4290
|
* Whether `await`'s argument is a `parent()` / `<x>.parent()` call (Kit's parent-load
|
|
3824
4291
|
* step, exempt from performance/load-waterfall and performance/sequential-awaits). Any `<expr>.parent()` member call matches — over-broad
|
|
@@ -3851,6 +4318,26 @@ function isBodyParseCall(arg) {
|
|
|
3851
4318
|
const callee = e.callee;
|
|
3852
4319
|
return callee?.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && BODY_METHODS.has(callee.property.name);
|
|
3853
4320
|
}
|
|
4321
|
+
const WORK_STARTING = /* @__PURE__ */ new Set([
|
|
4322
|
+
"CallExpression",
|
|
4323
|
+
"NewExpression",
|
|
4324
|
+
"ImportExpression",
|
|
4325
|
+
"TaggedTemplateExpression"
|
|
4326
|
+
]);
|
|
4327
|
+
/** Whether an await argument starts work (a call, tagged template, `new`, or `import()`), as opposed to awaiting an existing promise. */
|
|
4328
|
+
function startsWork(arg) {
|
|
4329
|
+
let e = unwrapTs(arg);
|
|
4330
|
+
if (e?.type === "ChainExpression") e = e.expression;
|
|
4331
|
+
return WORK_STARTING.has(e?.type);
|
|
4332
|
+
}
|
|
4333
|
+
/** Whether evaluating `node` may start work: a work-starting expression anywhere outside a nested function. */
|
|
4334
|
+
function containsWork(node) {
|
|
4335
|
+
if (Array.isArray(node)) return node.some(containsWork);
|
|
4336
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string") return false;
|
|
4337
|
+
if (isFunctionNode(node)) return false;
|
|
4338
|
+
if (WORK_STARTING.has(node.type)) return true;
|
|
4339
|
+
return Object.keys(node).some((key) => !WALK_IGNORED_KEYS.has(key) && containsWork(node[key]));
|
|
4340
|
+
}
|
|
3854
4341
|
/**
|
|
3855
4342
|
* Whether the expression references any tainted name. Threads nested-function
|
|
3856
4343
|
* shadowing (`scopeIntroducedNames`) so a callback parameter that shadows a
|
|
@@ -3883,15 +4370,36 @@ function refsTainted(node, tainted) {
|
|
|
3883
4370
|
return hit;
|
|
3884
4371
|
}
|
|
3885
4372
|
/**
|
|
4373
|
+
* Awaits that only run when a tainted value says so — `a ?? await x`, `t ? await x : y` — and so
|
|
4374
|
+
* cannot start before the earlier await that decides them.
|
|
4375
|
+
*/
|
|
4376
|
+
function guardedAwaits(node, tainted, out = /* @__PURE__ */ new Set()) {
|
|
4377
|
+
if (Array.isArray(node)) {
|
|
4378
|
+
for (const child of node) guardedAwaits(child, tainted, out);
|
|
4379
|
+
return out;
|
|
4380
|
+
}
|
|
4381
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string" || isFunctionNode(node)) return out;
|
|
4382
|
+
if (node.type === "LogicalExpression" && refsTainted(node.left, tainted)) for (const a of collectAwaits(node.right)) out.add(a);
|
|
4383
|
+
else if (node.type === "ConditionalExpression" && refsTainted(node.test, tainted)) for (const a of collectAwaits([node.consequent, node.alternate])) out.add(a);
|
|
4384
|
+
for (const key of Object.keys(node)) {
|
|
4385
|
+
if (WALK_IGNORED_KEYS.has(key)) continue;
|
|
4386
|
+
guardedAwaits(node[key], tainted, out);
|
|
4387
|
+
}
|
|
4388
|
+
return out;
|
|
4389
|
+
}
|
|
4390
|
+
/**
|
|
3886
4391
|
* performance/load-waterfall, performance/sequential-awaits — forward-taint analysis of the exported `load`'s straight-line
|
|
3887
4392
|
* statements (direct `try` blocks inlined; `if`/loops/`switch` are not classified
|
|
3888
4393
|
* but still propagate taint from their assignments; nested functions are never
|
|
3889
4394
|
* entered). One await site per statement; a site whose awaits' argument subtrees
|
|
3890
4395
|
* reference an earlier site's bindings (transitively, through intermediate consts
|
|
3891
|
-
* and assignments — member-expression targets taint their root object)
|
|
3892
|
-
*
|
|
3893
|
-
*
|
|
3894
|
-
*
|
|
4396
|
+
* and assignments — member-expression targets taint their root object), or that a
|
|
4397
|
+
* tainted `??`/`&&`/`||`/`?:` guard decides (`guardedAwaits`), is
|
|
4398
|
+
* dependent, anchored at the first dependent await — unless it starts no work and its
|
|
4399
|
+
* promise came straight from an await result (`await deferred.state` after
|
|
4400
|
+
* `await parent()`); otherwise independent when a prior site exists, unless every
|
|
4401
|
+
* await merely resumes an already-created promise (a bare identifier argument starts
|
|
4402
|
+
* no request). `await parent()` and
|
|
3895
4403
|
* response-body reads are never sites, but their bindings taint. Lines are
|
|
3896
4404
|
* returned in ORIGINAL-source coordinates (the −1 wrap shift is applied here).
|
|
3897
4405
|
*/
|
|
@@ -3905,12 +4413,27 @@ function collectLoadWaterfalls(program, wrapped) {
|
|
|
3905
4413
|
};
|
|
3906
4414
|
const line = (start) => Math.max(0, lineOf(wrapped, start) - 1);
|
|
3907
4415
|
const tainted = /* @__PURE__ */ new Set();
|
|
4416
|
+
const syncTainted = /* @__PURE__ */ new Set();
|
|
3908
4417
|
let sawAwaitSite = false;
|
|
3909
|
-
const
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
4418
|
+
const taintTarget = (target, rhs) => {
|
|
4419
|
+
const awaited = collectAwaits(rhs).length > 0;
|
|
4420
|
+
if (!awaited && !refsTainted(rhs, tainted)) return;
|
|
4421
|
+
const names = /* @__PURE__ */ new Set();
|
|
4422
|
+
if (target?.type === "MemberExpression") {
|
|
4423
|
+
const root = rootObjectName(target);
|
|
4424
|
+
if (root) names.add(root);
|
|
4425
|
+
} else addBoundNames(target, names);
|
|
4426
|
+
for (const name of names) {
|
|
4427
|
+
tainted.add(name);
|
|
4428
|
+
if (!awaited && (containsWork(rhs) || refsTainted(rhs, syncTainted))) syncTainted.add(name);
|
|
4429
|
+
}
|
|
4430
|
+
};
|
|
4431
|
+
const taintMutation = (call) => {
|
|
4432
|
+
const callee = unwrapTs(call?.callee);
|
|
4433
|
+
if (call?.type !== "CallExpression" || callee?.type !== "MemberExpression" || callee.computed) return;
|
|
4434
|
+
if (!MUTATING_METHODS.has(callee.property?.name) || !(call.arguments ?? []).some((a) => refsTainted(a, tainted))) return;
|
|
4435
|
+
const root = rootObjectName(callee);
|
|
4436
|
+
if (root) tainted.add(root);
|
|
3914
4437
|
};
|
|
3915
4438
|
const taintOnly = (node) => {
|
|
3916
4439
|
if (Array.isArray(node)) {
|
|
@@ -3919,10 +4442,14 @@ function collectLoadWaterfalls(program, wrapped) {
|
|
|
3919
4442
|
}
|
|
3920
4443
|
if (!node || typeof node !== "object" || typeof node.type !== "string") return;
|
|
3921
4444
|
if (isFunctionNode(node)) return;
|
|
3922
|
-
if (node.type === "AssignmentExpression")
|
|
3923
|
-
|
|
4445
|
+
if (node.type === "AssignmentExpression") taintTarget(node.left, node.right);
|
|
4446
|
+
else if (node.type === "CallExpression") taintMutation(node);
|
|
4447
|
+
else if ((node.type === "ForOfStatement" || node.type === "ForInStatement") && refsTainted(node.right, tainted)) {
|
|
4448
|
+
const names = /* @__PURE__ */ new Set();
|
|
4449
|
+
addBoundNames(node.left?.type === "VariableDeclaration" ? node.left.declarations?.[0]?.id : node.left, names);
|
|
4450
|
+
for (const name of names) tainted.add(name);
|
|
3924
4451
|
} else if (node.type === "VariableDeclaration") {
|
|
3925
|
-
for (const d of node.declarations ?? []) if (d?.id && d.init
|
|
4452
|
+
for (const d of node.declarations ?? []) if (d?.id && d.init) taintTarget(d.id, d.init);
|
|
3926
4453
|
}
|
|
3927
4454
|
for (const key of Object.keys(node)) {
|
|
3928
4455
|
if (WALK_IGNORED_KEYS.has(key)) continue;
|
|
@@ -3941,12 +4468,13 @@ function collectLoadWaterfalls(program, wrapped) {
|
|
|
3941
4468
|
if (stmt.type === "VariableDeclaration" || stmt.type === "ExpressionStatement" || stmt.type === "ReturnStatement") {
|
|
3942
4469
|
const sites = collectAwaits(stmt).filter((a) => !isParentCall(a.argument) && !isBodyParseCall(a.argument));
|
|
3943
4470
|
if (sites.length > 0) {
|
|
3944
|
-
const
|
|
4471
|
+
const guarded = guardedAwaits(stmt, tainted);
|
|
4472
|
+
const dependent = sites.filter((a) => (guarded.has(a) || refsTainted(a.argument, tainted)) && (startsWork(a.argument) || refsTainted(a.argument, syncTainted)));
|
|
3945
4473
|
if (dependent.length > 0) {
|
|
3946
4474
|
const anchor = dependent.reduce((m, a) => a.start < m.start ? a : m);
|
|
3947
4475
|
dependentLines.push(line(anchor.start));
|
|
3948
4476
|
} else if (sawAwaitSite) {
|
|
3949
|
-
const workSites = sites.filter((a) =>
|
|
4477
|
+
const workSites = sites.filter((a) => startsWork(a.argument));
|
|
3950
4478
|
if (workSites.length > 0) {
|
|
3951
4479
|
const anchor = workSites.reduce((m, a) => a.start < m.start ? a : m);
|
|
3952
4480
|
independentLines.push(line(anchor.start));
|
|
@@ -3954,15 +4482,12 @@ function collectLoadWaterfalls(program, wrapped) {
|
|
|
3954
4482
|
}
|
|
3955
4483
|
sawAwaitSite = true;
|
|
3956
4484
|
}
|
|
3957
|
-
if (stmt.type === "VariableDeclaration")
|
|
3958
|
-
if (
|
|
3959
|
-
|
|
3960
|
-
}
|
|
3961
|
-
else if (stmt.type === "ExpressionStatement") {
|
|
4485
|
+
if (stmt.type === "VariableDeclaration") {
|
|
4486
|
+
for (const d of stmt.declarations ?? []) if (d?.id && d.init) taintTarget(d.id, d.init);
|
|
4487
|
+
} else if (stmt.type === "ExpressionStatement") {
|
|
3962
4488
|
const expr = unwrapTs(stmt.expression);
|
|
3963
|
-
if (expr?.type === "AssignmentExpression")
|
|
3964
|
-
|
|
3965
|
-
}
|
|
4489
|
+
if (expr?.type === "AssignmentExpression") taintTarget(expr.left, expr.right);
|
|
4490
|
+
else taintMutation(expr);
|
|
3966
4491
|
}
|
|
3967
4492
|
} else taintOnly(stmt);
|
|
3968
4493
|
}
|
|
@@ -3999,23 +4524,19 @@ function walkKit(node, handlerFns, startupFns, visit, shadowed = /* @__PURE__ */
|
|
|
3999
4524
|
}
|
|
4000
4525
|
}
|
|
4001
4526
|
/**
|
|
4002
|
-
* Normalize a posix path, resolving `.` and `..` segments — string-only, no I/O.
|
|
4003
|
-
*
|
|
4004
|
-
* its root (e.g. `../../../../src/lib/server/db` from a shallow route file): the
|
|
4005
|
-
* real target lies outside the project tree we can see, so there is no
|
|
4006
|
-
* project-relative path to return at all.
|
|
4527
|
+
* Normalize a posix path, resolving `.` and `..` segments — string-only, no I/O. `..` segments
|
|
4528
|
+
* that climb above the root are kept as a leading `../` run (`../../plugins/x`).
|
|
4007
4529
|
*/
|
|
4008
4530
|
function normalizePosix(path) {
|
|
4009
4531
|
const out = [];
|
|
4010
4532
|
for (const seg of path.split("/")) {
|
|
4011
4533
|
if (seg === "" || seg === ".") continue;
|
|
4012
|
-
if (seg === "..")
|
|
4013
|
-
|
|
4014
|
-
out.pop();
|
|
4015
|
-
} else out.push(seg);
|
|
4534
|
+
if (seg === ".." && out.length > 0 && out[out.length - 1] !== "..") out.pop();
|
|
4535
|
+
else out.push(seg);
|
|
4016
4536
|
}
|
|
4017
4537
|
return out.join("/");
|
|
4018
4538
|
}
|
|
4539
|
+
const escapesRoot = (path) => path === ".." || path.startsWith("../");
|
|
4019
4540
|
/**
|
|
4020
4541
|
* What `resolveRepoLocalPath` assumes when no config was read: SvelteKit's own `$lib`, at the
|
|
4021
4542
|
* default `src/lib`. This single entry is the whole of this analyzer's pre-alias behaviour.
|
|
@@ -4042,8 +4563,12 @@ function aliasMatches(entry, spec) {
|
|
|
4042
4563
|
* be a project-local module: the caller's `aliases` list decides which non-relative
|
|
4043
4564
|
* specifiers resolve, defaulting to `$lib` → `src/lib`; `./`/`../` resolve against the
|
|
4044
4565
|
* importing file's directory; bare packages and other aliases are skipped (they can't
|
|
4045
|
-
* be resolved to a project-local path at all).
|
|
4046
|
-
*
|
|
4566
|
+
* be resolved to a project-local path at all). A path above the project root (a leading `../`
|
|
4567
|
+
* run) resolves only inside a directory a `kit.alias` value itself names above the root
|
|
4568
|
+
* (`$plugins: '../../plugins'` in a monorepo app): the user's config says the app builds from
|
|
4569
|
+
* there, so it is read like `src/` — likewise inside a workspace package's directory an entry
|
|
4570
|
+
* names as its `root` — and anywhere else above the root is undefined, as nothing says the app
|
|
4571
|
+
* reaches it. Also undefined when a
|
|
4047
4572
|
* matched alias's value is itself absolute (e.g. `/opt/shared/src`, or a posixified Windows
|
|
4048
4573
|
* drive-letter path like `C:/shared/src`): an absolute target is outside the analyzed project by
|
|
4049
4574
|
* definition, and without this check `normalizePosix` would quietly drop the leading empty
|
|
@@ -4052,7 +4577,7 @@ function aliasMatches(entry, spec) {
|
|
|
4052
4577
|
* Exported from the package's public barrel because `architecture/private-scope-import`
|
|
4053
4578
|
* and `architecture/route-component-import` (inside `packages/core`) both need resolution
|
|
4054
4579
|
* that is not restricted to runes modules, unlike `resolveRunesModuleSpecifier` — and
|
|
4055
|
-
* because
|
|
4580
|
+
* because component resolution (`packages/cli/src/providers/source/resolve.ts`), which
|
|
4056
4581
|
* drives transitive `<head>`/heading resolution, delegates its alias/`$lib`/relative
|
|
4057
4582
|
* mapping here too, rather than duplicating it. This is the single site for every
|
|
4058
4583
|
* repo-local specifier resolution in the repo.
|
|
@@ -4064,9 +4589,15 @@ function resolveRepoLocalPath(spec, importerFile, aliases = DEFAULT_KIT_ALIASES)
|
|
|
4064
4589
|
const entry = aliases.find((a) => aliasMatches(a, spec));
|
|
4065
4590
|
if (entry?.replacement == null) return void 0;
|
|
4066
4591
|
if (entry.replacement.startsWith("/") || /^[A-Za-z]:\//.test(entry.replacement)) return void 0;
|
|
4067
|
-
path = entry.replacement + spec.slice(entry.find.length);
|
|
4592
|
+
path = entry.replacement + spec.slice(entry.find.length) + (entry.suffix ?? "");
|
|
4068
4593
|
}
|
|
4069
|
-
|
|
4594
|
+
const normalized = normalizePosix(path);
|
|
4595
|
+
if (!escapesRoot(normalized)) return normalized;
|
|
4596
|
+
return aliases.some((a) => {
|
|
4597
|
+
const named = a.root ?? a.replacement;
|
|
4598
|
+
const dir = named === null ? "" : normalizePosix(named);
|
|
4599
|
+
return escapesRoot(dir) && (normalized === dir || normalized.startsWith(`${dir}/`));
|
|
4600
|
+
}) ? normalized : void 0;
|
|
4070
4601
|
}
|
|
4071
4602
|
/**
|
|
4072
4603
|
* Resolve an import specifier to a repo-relative `.svelte.ts`/`.svelte.js` path, or
|
|
@@ -4074,7 +4605,7 @@ function resolveRepoLocalPath(spec, importerFile, aliases = DEFAULT_KIT_ALIASES)
|
|
|
4074
4605
|
* `aliases` (defaulting to `$lib` → `src/lib` when omitted), so a project's declared
|
|
4075
4606
|
* `kit.alias`/`kit.files.lib` resolve here exactly as they do at rule time; `./`/`../`
|
|
4076
4607
|
* resolve against the importing file's directory; bare packages, unmatched aliases, and
|
|
4077
|
-
* a
|
|
4608
|
+
* a path above the project root outside every `kit.alias` directory are skipped. An
|
|
4078
4609
|
* extensionless `…/x.svelte` specifier canonicalises to `….svelte.ts` (security/shared-state-import also
|
|
4079
4610
|
* tries the `.js` sibling when matching).
|
|
4080
4611
|
*/
|
|
@@ -4109,8 +4640,8 @@ function libServerRoot(aliases) {
|
|
|
4109
4640
|
* an equivalent relative path, resolving to exactly that directory — importing the
|
|
4110
4641
|
* index file rather than a named submodule) is exempt too. A specifier that
|
|
4111
4642
|
* resolves to undefined — a bare package, an alias that can't map to a repo path, or
|
|
4112
|
-
* a relative specifier whose `..` segments escape the
|
|
4113
|
-
* `
|
|
4643
|
+
* a relative specifier whose `..` segments escape the project root (outside every
|
|
4644
|
+
* `kit.alias` directory, see `resolveRepoLocalPath`) — is conservatively NOT local state: we can't see that file, so
|
|
4114
4645
|
* we don't flag writes to it. Installed packages (drizzle, redis, @vercel/kv, …) are
|
|
4115
4646
|
* excluded: `.set()`/`.update()` on those is persistence, not shared-module-state
|
|
4116
4647
|
* mutation. When the `$lib` entry is opaque (`libServerRoot` returns undefined — an unreadable
|
|
@@ -4141,15 +4672,25 @@ const IN_MEMORY_CTORS = /* @__PURE__ */ new Set([
|
|
|
4141
4672
|
"WeakMap",
|
|
4142
4673
|
"WeakSet"
|
|
4143
4674
|
]);
|
|
4144
|
-
/**
|
|
4675
|
+
/**
|
|
4676
|
+
* Whether an initializer positively identifies an in-memory container. An object literal with a
|
|
4677
|
+
* spread is a composite of values this parse cannot see — typically a facade over clients and
|
|
4678
|
+
* imported modules (`{ ...prismaModels, ...handlers }`) — so it counts only when one of its own
|
|
4679
|
+
* properties is itself a container.
|
|
4680
|
+
*/
|
|
4145
4681
|
function isInMemoryInit(init) {
|
|
4146
4682
|
if (!init) return false;
|
|
4147
|
-
if (init.type === "
|
|
4683
|
+
if (init.type === "ArrayExpression") return true;
|
|
4684
|
+
if (init.type === "ObjectExpression") {
|
|
4685
|
+
const props = init.properties ?? [];
|
|
4686
|
+
if (!props.some((p) => p?.type === "SpreadElement")) return true;
|
|
4687
|
+
return props.some((p) => p?.type === "Property" && isInMemoryInit(p.value));
|
|
4688
|
+
}
|
|
4148
4689
|
return init.type === "NewExpression" && init.callee?.type === "Identifier" && IN_MEMORY_CTORS.has(init.callee.name);
|
|
4149
4690
|
}
|
|
4150
4691
|
/**
|
|
4151
4692
|
* The exported bindings of a module that are initialized to an in-memory container — an object
|
|
4152
|
-
* or array literal, or `new Map`/`Set`/`WeakMap`/`WeakSet`. Used to arbitrate a `.set()` call on
|
|
4693
|
+
* or array literal (a spread literal only as `isInMemoryInit` allows), or `new Map`/`Set`/`WeakMap`/`WeakSet`. Used to arbitrate a `.set()` call on
|
|
4153
4694
|
* an import from under the `$lib` server root, where the call shape alone cannot separate a
|
|
4154
4695
|
* hand-rolled store from a database client.
|
|
4155
4696
|
*
|
|
@@ -4198,6 +4739,7 @@ function parseKitModuleFacts(source, filename, aliases) {
|
|
|
4198
4739
|
const line = (start) => Math.max(0, lineOf(wrapped, start) - 1);
|
|
4199
4740
|
const importedSpecifiers = /* @__PURE__ */ new Map();
|
|
4200
4741
|
const importedNames = /* @__PURE__ */ new Map();
|
|
4742
|
+
const namespaceImports = /* @__PURE__ */ new Set();
|
|
4201
4743
|
for (const stmt of program.body ?? []) {
|
|
4202
4744
|
if (stmt?.type !== "ImportDeclaration" || stmt.importKind === "type") continue;
|
|
4203
4745
|
const spec = typeof stmt.source?.value === "string" ? stmt.source.value : "";
|
|
@@ -4206,6 +4748,7 @@ function parseKitModuleFacts(source, filename, aliases) {
|
|
|
4206
4748
|
if (s?.importKind === "type" || s?.local?.type !== "Identifier") continue;
|
|
4207
4749
|
names.push(s.local.name);
|
|
4208
4750
|
importedSpecifiers.set(s.local.name, spec);
|
|
4751
|
+
if (s.type === "ImportNamespaceSpecifier") namespaceImports.add(s.local.name);
|
|
4209
4752
|
importedNames.set(s.local.name, s.type === "ImportSpecifier" && s.imported?.type === "Identifier" ? s.imported.name : s.local.name);
|
|
4210
4753
|
}
|
|
4211
4754
|
if (names.length === 0) continue;
|
|
@@ -4303,7 +4846,8 @@ function parseKitModuleFacts(source, filename, aliases) {
|
|
|
4303
4846
|
};
|
|
4304
4847
|
} else if (n.type === "CallExpression" && n.callee?.type === "MemberExpression") {
|
|
4305
4848
|
const method = n.callee.property?.type === "Identifier" ? n.callee.property.name : void 0;
|
|
4306
|
-
|
|
4849
|
+
const nsFunction = n.callee.object?.type === "Identifier" && namespaceImports.has(n.callee.object.name);
|
|
4850
|
+
if ((method === "set" || method === "update") && !nsFunction) {
|
|
4307
4851
|
const r = importedRoot(n.callee.object);
|
|
4308
4852
|
const spec = r ? importedSpecifiers.get(r) : void 0;
|
|
4309
4853
|
if (r && spec !== void 0) {
|
|
@@ -4381,7 +4925,9 @@ function parseKitModuleFacts(source, filename, aliases) {
|
|
|
4381
4925
|
browserGlobalRefs: byLine(browserGlobalRefs),
|
|
4382
4926
|
basePathLinks: byLine(basePathLinks),
|
|
4383
4927
|
...ssrOptOut ? { ssrDisabled: { line: Math.max(0, ssrOptOut.line - 1) } } : {},
|
|
4928
|
+
...!ssrOptOut && exportsName(program, "ssr") ? { ssrEnabled: true } : {},
|
|
4384
4929
|
...csrOptOut ? { csrDisabled: { line: Math.max(0, csrOptOut.line - 1) } } : {},
|
|
4930
|
+
...loadNeverRenders(program, collectNamedImportAliases(program, "@sveltejs/kit", EXIT_NAMES)) ? { loadNeverRenders: true } : {},
|
|
4385
4931
|
...waterfalls.dependentLines.length > 0 || waterfalls.independentLines.length > 0 ? { loadWaterfalls: waterfalls } : {},
|
|
4386
4932
|
suppressions
|
|
4387
4933
|
};
|
|
@@ -5603,8 +6149,8 @@ function isRouteEntry(path) {
|
|
|
5603
6149
|
/** The route entries this component imports, with the line each import sits on. */
|
|
5604
6150
|
function routeEntryImports(c, ctx) {
|
|
5605
6151
|
const out = [];
|
|
5606
|
-
for (const { source, line, type } of c.importSpans ?? []) {
|
|
5607
|
-
if (type) continue;
|
|
6152
|
+
for (const { source, line, type, named } of c.importSpans ?? []) {
|
|
6153
|
+
if (type || named) continue;
|
|
5608
6154
|
const target = resolveRepoLocalPath(source, c.file, ctx.project.kitAliases);
|
|
5609
6155
|
if (target !== void 0 && isRouteEntry(target)) out.push({
|
|
5610
6156
|
line,
|
|
@@ -5950,223 +6496,6 @@ const performanceIframeLoading = componentRule({
|
|
|
5950
6496
|
}))
|
|
5951
6497
|
});
|
|
5952
6498
|
//#endregion
|
|
5953
|
-
//#region src/rules/a11y/aria-data.ts
|
|
5954
|
-
/**
|
|
5955
|
-
* ARIA names `aria-query@5.3.2` does not carry. It is not the clean ARIA 1.2 snapshot it looks
|
|
5956
|
-
* like — it holds 1.2's 48 attributes plus three 1.3 additions — so the gap is patched by name
|
|
5957
|
-
* rather than by version. Every entry here is defined in the ARIA 1.3 editor's draft, which is
|
|
5958
|
-
* what makes reporting it as an unknown name wrong — how far implementations have got is a
|
|
5959
|
-
* separate question this rule does not ask. Re-check this list whenever the aria-query dependency
|
|
5960
|
-
* moves.
|
|
5961
|
-
*/
|
|
5962
|
-
const ARIA_1_3_ROLES = /* @__PURE__ */ new Set([
|
|
5963
|
-
"comment",
|
|
5964
|
-
"image",
|
|
5965
|
-
"sectionheader",
|
|
5966
|
-
"sectionfooter",
|
|
5967
|
-
"suggestion"
|
|
5968
|
-
]);
|
|
5969
|
-
const ARIA_1_3_ATTRIBUTES = /* @__PURE__ */ new Set(["aria-colindextext", "aria-rowindextext"]);
|
|
5970
|
-
/**
|
|
5971
|
-
* Roles whose `requiredProps` row in aria-query is stale. Neither ARIA 1.2 nor the 1.3 editor's
|
|
5972
|
-
* draft lists required states or properties for these — the 1.3 draft re-lists inherited
|
|
5973
|
-
* requirements where they apply (`menuitemradio` → `aria-checked`), so the absence is deliberate
|
|
5974
|
-
* rather than an inheritance the table left implicit. Without this, idiomatic APG listbox and
|
|
5975
|
-
* tree markup is flagged.
|
|
5976
|
-
*/
|
|
5977
|
-
const NO_REQUIRED_PROPS = /* @__PURE__ */ new Set(["option", "treeitem"]);
|
|
5978
|
-
function isKnownRole(role) {
|
|
5979
|
-
return ARIA_1_3_ROLES.has(role) || roles.has(role);
|
|
5980
|
-
}
|
|
5981
|
-
function isAbstractRole(role) {
|
|
5982
|
-
return roles.get(role)?.abstract === true;
|
|
5983
|
-
}
|
|
5984
|
-
/** A concrete (non-abstract) role is the one a user agent resolves a role token list to. */
|
|
5985
|
-
function isConcreteRole(role) {
|
|
5986
|
-
return isKnownRole(role) && !isAbstractRole(role);
|
|
5987
|
-
}
|
|
5988
|
-
/**
|
|
5989
|
-
* The role a user agent applies: the first token naming a concrete role, or undefined when none
|
|
5990
|
-
* does. Shared so the role rules cannot disagree about what a fallback list means.
|
|
5991
|
-
*/
|
|
5992
|
-
function resolveRole(tokens) {
|
|
5993
|
-
return tokens.find(isConcreteRole);
|
|
5994
|
-
}
|
|
5995
|
-
function isKnownAriaAttribute(name) {
|
|
5996
|
-
return ARIA_1_3_ATTRIBUTES.has(name) || aria.has(name);
|
|
5997
|
-
}
|
|
5998
|
-
function requiredAriaProps(role) {
|
|
5999
|
-
if (NO_REQUIRED_PROPS.has(role)) return [];
|
|
6000
|
-
const def = roles.get(role);
|
|
6001
|
-
return def ? Object.keys(def.requiredProps) : [];
|
|
6002
|
-
}
|
|
6003
|
-
function ariaValueKind(name) {
|
|
6004
|
-
const def = aria.get(name);
|
|
6005
|
-
if (!def) return void 0;
|
|
6006
|
-
return {
|
|
6007
|
-
type: def.type,
|
|
6008
|
-
...def.values ? { values: def.values.map(String) } : {}
|
|
6009
|
-
};
|
|
6010
|
-
}
|
|
6011
|
-
//#endregion
|
|
6012
|
-
//#region src/a11y.ts
|
|
6013
|
-
/**
|
|
6014
|
-
* Branch-aware occurrence fold (design "Control-flow semantics"): within a branch
|
|
6015
|
-
* occurrences sum, across the arms of one exclusive block the arm with the most
|
|
6016
|
-
* occurrences wins (tie → lowest branch index) and ITS occurrences are the group's
|
|
6017
|
-
* representatives — so a caller's count is always `list.length`, with a location per
|
|
6018
|
-
* representative. `{#each}`/`{#snippet}` occurrences render 0..N times and drop out.
|
|
6019
|
-
* The max is per key: there is no scalar total to maximize.
|
|
6020
|
-
*/
|
|
6021
|
-
function foldOccurrences(nodes) {
|
|
6022
|
-
const byKey = /* @__PURE__ */ new Map();
|
|
6023
|
-
for (const node of nodes) {
|
|
6024
|
-
if (node.repeatable) continue;
|
|
6025
|
-
const list = byKey.get(node.key);
|
|
6026
|
-
if (list) list.push(node);
|
|
6027
|
-
else byKey.set(node.key, [node]);
|
|
6028
|
-
}
|
|
6029
|
-
const folded = /* @__PURE__ */ new Map();
|
|
6030
|
-
for (const [key, list] of byKey) folded.set(key, foldAt(list, 0));
|
|
6031
|
-
return folded;
|
|
6032
|
-
}
|
|
6033
|
-
function foldAt(nodes, depth) {
|
|
6034
|
-
const unconditional = [];
|
|
6035
|
-
const groups = /* @__PURE__ */ new Map();
|
|
6036
|
-
for (const node of nodes) {
|
|
6037
|
-
const step = node.path[depth];
|
|
6038
|
-
if (!step) {
|
|
6039
|
-
unconditional.push(node);
|
|
6040
|
-
continue;
|
|
6041
|
-
}
|
|
6042
|
-
let branches = groups.get(step.group);
|
|
6043
|
-
if (!branches) groups.set(step.group, branches = /* @__PURE__ */ new Map());
|
|
6044
|
-
const list = branches.get(step.branch);
|
|
6045
|
-
if (list) list.push(node);
|
|
6046
|
-
else branches.set(step.branch, [node]);
|
|
6047
|
-
}
|
|
6048
|
-
const representatives = [...unconditional];
|
|
6049
|
-
for (const branches of groups.values()) {
|
|
6050
|
-
let best = [];
|
|
6051
|
-
let bestBranch = Number.POSITIVE_INFINITY;
|
|
6052
|
-
for (const [branch, list] of branches) {
|
|
6053
|
-
const arm = foldAt(list, depth + 1);
|
|
6054
|
-
if (arm.length > best.length || arm.length === best.length && branch < bestBranch) {
|
|
6055
|
-
best = arm;
|
|
6056
|
-
bestBranch = branch;
|
|
6057
|
-
}
|
|
6058
|
-
}
|
|
6059
|
-
representatives.push(...best);
|
|
6060
|
-
}
|
|
6061
|
-
return representatives;
|
|
6062
|
-
}
|
|
6063
|
-
/**
|
|
6064
|
-
* Decode a fragment identifier the way navigation does before matching an element id
|
|
6065
|
-
* (`href="#caf%C3%A9"` targets `id="café"`). Malformed escapes are kept verbatim —
|
|
6066
|
-
* the browser would also fail to decode them, so the raw text is the comparable form.
|
|
6067
|
-
*/
|
|
6068
|
-
function decodeFragmentId(fragment) {
|
|
6069
|
-
try {
|
|
6070
|
-
return decodeURIComponent(fragment);
|
|
6071
|
-
} catch {
|
|
6072
|
-
return fragment;
|
|
6073
|
-
}
|
|
6074
|
-
}
|
|
6075
|
-
/** Whitespace-split tokens of a (possibly undefined) literal attribute value. */
|
|
6076
|
-
function splitTokens(value) {
|
|
6077
|
-
return value ? value.trim().split(/\s+/).filter(Boolean) : [];
|
|
6078
|
-
}
|
|
6079
|
-
/** Explicit `role` values that map to the landmark kinds the route rules inspect. */
|
|
6080
|
-
const LANDMARK_ROLES = /* @__PURE__ */ new Set([
|
|
6081
|
-
"main",
|
|
6082
|
-
"banner",
|
|
6083
|
-
"contentinfo",
|
|
6084
|
-
"complementary"
|
|
6085
|
-
]);
|
|
6086
|
-
/** Sectioning content (HTML-AAM): a `<header>`/`<footer>` below one of these is not a landmark. */
|
|
6087
|
-
const SECTIONING_TAGS = /* @__PURE__ */ new Set([
|
|
6088
|
-
"article",
|
|
6089
|
-
"aside",
|
|
6090
|
-
"main",
|
|
6091
|
-
"nav",
|
|
6092
|
-
"section"
|
|
6093
|
-
]);
|
|
6094
|
-
/**
|
|
6095
|
-
* Tags whose landmark-ness depends on sectioning ancestry. A provider that cannot see ancestry
|
|
6096
|
-
* (the per-file AST walk) uses this same set to mark the deferral (topLevel) — reading it from
|
|
6097
|
-
* here keeps the deferral set from drifting when the policy widens.
|
|
6098
|
-
*/
|
|
6099
|
-
const ANCESTRY_DEPENDENT_TAGS = /* @__PURE__ */ new Set(["header", "footer"]);
|
|
6100
|
-
/** Attributes that give an element the accessible name the `<aside>` landmark decision reads. */
|
|
6101
|
-
const NAMING_ATTRS$1 = ["aria-label", "aria-labelledby"];
|
|
6102
|
-
/**
|
|
6103
|
-
* HTML-AAM: an `<aside>` scoped to `body` or `main` is a `complementary` landmark; scoped to
|
|
6104
|
-
* sectioning content it is one only when it has an accessible name. `main` is deliberately absent
|
|
6105
|
-
* — it is a scope in which an `aside` *is* a landmark, unlike the sectioning set that decides
|
|
6106
|
-
* `<header>`/`<footer>`.
|
|
6107
|
-
*/
|
|
6108
|
-
const ASIDE_DEMOTING_TAGS = /* @__PURE__ */ new Set([
|
|
6109
|
-
"article",
|
|
6110
|
-
"aside",
|
|
6111
|
-
"nav",
|
|
6112
|
-
"section"
|
|
6113
|
-
]);
|
|
6114
|
-
/**
|
|
6115
|
-
* The landmark kind an element contributes ('main'|'banner'|'contentinfo'|'complementary'), or
|
|
6116
|
-
* undefined. The one home for the decision both providers apply — the source AST walk and the
|
|
6117
|
-
* rendered HTML walk each feed it their own tree's context. ARIA fallback role lists resolve to
|
|
6118
|
-
* the first token naming a concrete role (resolveRole).
|
|
6119
|
-
*/
|
|
6120
|
-
function resolveLandmark(input) {
|
|
6121
|
-
const { tag, roleTokens, named, insideSectioning, insideAsideDemoting } = input;
|
|
6122
|
-
if (roleTokens !== void 0) {
|
|
6123
|
-
const role = resolveRole(roleTokens);
|
|
6124
|
-
return role && LANDMARK_ROLES.has(role) ? role : void 0;
|
|
6125
|
-
}
|
|
6126
|
-
if (tag === "main") return "main";
|
|
6127
|
-
if (tag !== void 0 && ANCESTRY_DEPENDENT_TAGS.has(tag)) return insideSectioning ? void 0 : tag === "header" ? "banner" : "contentinfo";
|
|
6128
|
-
if (tag === "aside") return !insideAsideDemoting || named ? "complementary" : void 0;
|
|
6129
|
-
}
|
|
6130
|
-
/**
|
|
6131
|
-
* Attributes whose (whitespace-tokenized) values reference element ids: the ARIA id-reference and
|
|
6132
|
-
* id-reference-list properties, and HTML's own (`for`, `list`, `headers`, `form`, the popover and
|
|
6133
|
-
* command targets). `href="#…"` is handled separately — its value is a URL, not a token list.
|
|
6134
|
-
*/
|
|
6135
|
-
const IDREF_ATTRS = [
|
|
6136
|
-
"for",
|
|
6137
|
-
"list",
|
|
6138
|
-
"headers",
|
|
6139
|
-
"form",
|
|
6140
|
-
"popovertarget",
|
|
6141
|
-
"commandfor",
|
|
6142
|
-
"aria-labelledby",
|
|
6143
|
-
"aria-describedby",
|
|
6144
|
-
"aria-controls",
|
|
6145
|
-
"aria-activedescendant",
|
|
6146
|
-
"aria-owns",
|
|
6147
|
-
"aria-details",
|
|
6148
|
-
"aria-errormessage",
|
|
6149
|
-
"aria-flowto"
|
|
6150
|
-
];
|
|
6151
|
-
/**
|
|
6152
|
-
* Whether a decoded URL fragment is HTML's "top of the document" indicator: `#top` (ASCII
|
|
6153
|
-
* case-insensitive) scrolls to the top when no element has that id, so it is never a missing
|
|
6154
|
-
* reference. Compare AFTER percent-decoding — `#%74op` navigates identically to `#top`.
|
|
6155
|
-
*/
|
|
6156
|
-
function isTopFragment(id) {
|
|
6157
|
-
return id.toLowerCase() === "top";
|
|
6158
|
-
}
|
|
6159
|
-
/**
|
|
6160
|
-
* A fragment with its text directive removed. Everything from the first `:~:` on is user-agent
|
|
6161
|
-
* instructions for finding text and names no element, while anything before it is still an
|
|
6162
|
-
* ordinary element fragment — `#section:~:text=hi` targets `id="section"`, `#:~:text=hi` targets
|
|
6163
|
-
* nothing. Returns an empty string when the fragment is a directive and nothing else.
|
|
6164
|
-
*/
|
|
6165
|
-
function stripTextDirective(fragment) {
|
|
6166
|
-
const i = fragment.indexOf(":~:");
|
|
6167
|
-
return i === -1 ? fragment : fragment.slice(0, i);
|
|
6168
|
-
}
|
|
6169
|
-
//#endregion
|
|
6170
6499
|
//#region src/rules/a11y/invalid-role.ts
|
|
6171
6500
|
const a11yInvalidRole = componentRule({
|
|
6172
6501
|
id: "a11y/invalid-role",
|
|
@@ -6866,7 +7195,7 @@ const ELEMENT_FACT_OVERRIDES = {
|
|
|
6866
7195
|
};
|
|
6867
7196
|
/**
|
|
6868
7197
|
* The role(s) an `AriaElementFact` may have. Explicit first: a literal role's first concrete token
|
|
6869
|
-
* (`resolveRole`); an expression role, or a spread with no literal role, makes the role unknowable
|
|
7198
|
+
* (`resolveRole`); an expression role, or a spread or `use:` action with no literal role, makes the role unknowable
|
|
6870
7199
|
* and yields `undefined`. Otherwise the element's implicit role — never one role but the set of
|
|
6871
7200
|
* candidates the dataset's conditional outcomes leave open, since the selectors that decide between
|
|
6872
7201
|
* them are not evaluated (design 2026-08-19-aria-role-table-rules, "Which role an element has").
|
|
@@ -6881,7 +7210,7 @@ function roleCandidates(e) {
|
|
|
6881
7210
|
namingProhibited: false
|
|
6882
7211
|
} : void 0;
|
|
6883
7212
|
}
|
|
6884
|
-
if (e.hasSpread) return void 0;
|
|
7213
|
+
if (e.hasSpread || e.hasAction) return void 0;
|
|
6885
7214
|
const el = Object.hasOwn(HTML_SPEC.elements, e.tag) ? HTML_SPEC.elements[e.tag] : void 0;
|
|
6886
7215
|
if (!el) return void 0;
|
|
6887
7216
|
const aria = (Object.hasOwn(ELEMENT_FACT_OVERRIDES, e.tag) ? ELEMENT_FACT_OVERRIDES[e.tag] : void 0) ?? el.aria;
|
|
@@ -7448,7 +7777,12 @@ function surplusRule(spec) {
|
|
|
7448
7777
|
first ??= reps[0];
|
|
7449
7778
|
for (let i = 1; i < reps.length; i++) {
|
|
7450
7779
|
surplus = true;
|
|
7451
|
-
|
|
7780
|
+
const finding = result(route.route, PENALIZED$1, reps[i], spec.message(key, i, reps.length, reps[0]));
|
|
7781
|
+
const recommendation = spec.recommendationFor?.(reps[i], reps[0]);
|
|
7782
|
+
out.push(recommendation ? {
|
|
7783
|
+
...finding,
|
|
7784
|
+
recommendation
|
|
7785
|
+
} : finding);
|
|
7452
7786
|
}
|
|
7453
7787
|
}
|
|
7454
7788
|
if (first && !surplus) out.push(result(route.route, PASS, {
|
|
@@ -7626,6 +7960,7 @@ const a11yIdDuplication = surplusRule({
|
|
|
7626
7960
|
recommendation: "Every id in a route should be unique.",
|
|
7627
7961
|
map: (route) => Object.entries(route.ids).sort(([, a], [, b]) => a[0].file.localeCompare(b[0].file) || a[0].line - b[0].line),
|
|
7628
7962
|
message: (id, _i, _n, first) => first.file === "src/app.html" ? `Duplicate id "${id}" — also defined by the src/app.html shell (line ${first.line})` : `Duplicate id "${id}"`,
|
|
7963
|
+
recommendationFor: (rep, first) => first.file === "src/app.html" || first.line === 0 ? void 0 : rep.file === first.file && rep.line === first.line ? "This id is hardcoded in a component rendered more than once on this route. Generate it per instance with $props.id() (Svelte 5.20+): `const uid = $props.id();`, then `id=\"{uid}-name\"`." : "Rename one of them. Separate {#if} blocks are treated as able to render together; if these never render at the same time, make them branches of one {#if}…{:else}.",
|
|
7629
7964
|
passMessage: "No duplicate ids"
|
|
7630
7965
|
});
|
|
7631
7966
|
//#endregion
|
|
@@ -8293,4 +8628,4 @@ function formatMarkdownReport(results, config, meta) {
|
|
|
8293
8628
|
return lines.join("\n");
|
|
8294
8629
|
}
|
|
8295
8630
|
//#endregion
|
|
8296
|
-
export {
|
|
8631
|
+
export { performanceMinifyDisabled as $, applyRuleSeverities as $n, correctnessOrphanLifecycle as $t, a11yAbbrTitle as A, seoImageAlt as An, defaultProject as Ar, walkEstree as At, a11yUseList as B, seoSitemapInRobots as Bn, parseSvelte as Bt, a11yDisallowedElement as C, foldOccurrences as Cn, effectiveSeverity as Cr, collectSvelteLifecycleImports as Ct, a11yDeprecatedElement as D, stripTextDirective as Dn, isPenalized as Dr, scopeIntroducedNames as Dt, a11yDeprecatedAttr as E, splitTokens as En, docsUrlFor as Er, rootObjectName as Et, isInteractiveContainer as F, seoJsonLdPlaceholder as Fn, attrTextOf as Ft, a11yInvalidAriaValue as G, seoIndexability as Gn, securitySharedStateImport as Gt, a11yAccessibleName as H, seoOgUrl as Hn, valueFromNodes as Ht, isInteractiveElement as I, seoJsonLdDateFormat as In, attrValue as It, a11yInvalidRole as J, resolveRuleOptions as Jn, securityJavascriptUrl as Jt, a11yRequiredAriaProps as K, performancePreconnect as Kn, securityServerModuleState as Kt, literalTabindexValue as L, seoJsonLdRelativeUrl as Ln, attrValueOf as Lt, a11yNoAutofocus as M, seoDescriptionLength as Mn, walkScoped as Mt, a11yNoAccesskey as N, seoTitleLength as Nn, CHILD_NODE_KEYS as Nt, HTML_SPEC as O, resolveRole as On, CATEGORIES as Or, unwrapExport as Ot, a11yPositiveTabindex as P, seoJsonLdRequiredProps as Pn, attrText as Pt, performanceLoadWaterfall as Q, applyOverrides as Qn, correctnessBasePathNavigation as Qt, a11yRequireDatetime as R, seoJsonLdDeprecatedType as Rn, findAttr as Rt, a11yPermittedContents as S, decodeFragmentId as Sn, classify as Sr, collectSuppressions as St, a11yDisallowedAriaProps as T, resolveLandmark as Tn, summarize as Tr, parseModuleProgram as Tt, a11yAriaHiddenFocus as U, seoOgDescription as Un, architecturePropCount as Ut, a11yLabelHasControl as V, seoViewport as Vn, textFromNodes as Vt, a11yInteractiveNesting as W, seoTwitterCard as Wn, architectureComponentSize as Wt, performanceStateRaw as X, validateRuleSetting as Xn, correctnessInstanceBrowserGlobal as Xt, performanceIframeLoading as Y, shouldSkipRangeCheck as Yn, securityRawHtml as Yt, performanceSequentialAwaits as Z, SEARCH_RESULT_RULES as Zn, correctnessServerBrowserGlobal as Zt, a11yIdDuplication as _, ANCESTRY_DEPENDENT_TAGS as _n, seoOgTitle as _r, collectBrowserGlobalRefs as _t, formatJsonReport as a, correctnessPropMutation as an, withIndexableOff as ar, architectureReservedDirectoryNames as at, a11yDoctype as b, NAMING_ATTRS$1 as bn, seoDescriptionPresence as br, collectNamedImportAliases as bt, formatGithubReport as c, correctnessEffectAsDerived as cn, performanceFontPreloadCrossorigin as cr, architecturePrivateScopeImport as ct, ruleMetaById as d, seoSsrDisabled as dn, performanceImageLoadingHint as dr, parseInMemoryExports as dt, correctnessOrphanEffect as en, compileOverrides as er, performanceNamespaceImport as et, severityToSarifLevel as f, routeNeverSsr as fn, performanceImageDimensions as fr, parseKitModuleFacts as ft, a11yNoMissingIdRef as g, seoSingleH1 as gn, seoJsonLd as gr, addBoundNames as gt, a11yUnverifiedIdRef as h, seoDuplicateTitle as hn, seoRobotsTxt as hr, WALK_IGNORED_KEYS as ht, buildJsonReport as i, correctnessStalePropDerivation as in, withFailedRulesOff as ir, architectureReservedNamePlacement as it, a11yNoDuplicateDt as j, seoCharset as jn, defineConfig as jr, walkEvalScope as jt, a11yPatternTitle as k, seoHreflang as kn, defaultConfig as kr, unwrapTs as kt, SEVERITY_RANK as l, correctnessEachIndexKey as ln, performancePreloadMissingAs as lr, DEFAULT_KIT_ALIASES as lt, explainRule as m, seoDuplicateDescription as mn, seoSitemapXml as mr, isRootRelativePath as mt, mdEscape as n, correctnessCheckableBindValue as nn, selectRules as nr, architectureDocLinkTarget as nt, computeHealth as o, correctnessUnmutatedState as on, performanceRenderBlockingScript as or, architectureDirectoryNaming as ot, allRules as p, seoHeadingLevelSkip as pn, seoHtmlLang as pr, resolveRepoLocalPath as pt, a11yUnknownAriaAttribute as q, isPlainObject as qn, securityHandlerStateWrite as qt, terminalSafe as r, correctnessNonreactiveBuiltinState as rn, settingSeverity as rr, architectureRouteComponentImport as rt, computeScore as s, correctnessEffectAsOnMount as sn, performanceLcpImage as sr, architectureUnitEntryFile as st, formatMarkdownReport as t, correctnessAutoplayMuted as tn, formatFailedRuleWarning as tr, performanceHeavyImport as tt, messageText as u, correctnessEachKey as un, performanceResponsiveImage as ur, collectTopLevelBindings as ut, a11yTopLevelLandmark as v, ASIDE_DEMOTING_TAGS as vn, seoOgImage as vr, collectBrowserGuardImports as vt, a11yDeprecatedAria as w, isTopFragment as wn, hasFailureAtOrAbove as wr, matchLifecycleCall as wt, a11yRequiredElement as x, SECTIONING_TAGS as xn, seoTitlePresence as xr, collectProgramBindings as xt, a11yDuplicateLandmark as y, IDREF_ATTRS as yn, seoCanonicalUrl as yr, collectDerivedGuardBindings as yt, a11yPlaceholderLabelOption as z, seoJsonLdValidity as zn, lineOf as zt };
|