@svelte-vitals/core 0.55.1 → 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-TIGneC1p.d.ts → json-BQf9Qb2D.d.ts} +156 -95
- package/dist/{markdown-BOH3FPQl.js → markdown-DgHBAvYl.js} +636 -307
- 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
|
|
@@ -1252,7 +1268,7 @@ const seoTwitterCard = headTagRule({
|
|
|
1252
1268
|
id: "seo/twitter-card",
|
|
1253
1269
|
title: "Twitter Card",
|
|
1254
1270
|
severity: "info",
|
|
1255
|
-
match: (t) => t.kind === "meta" && t.name === "twitter:card",
|
|
1271
|
+
match: (t) => t.kind === "meta" && (t.name === "twitter:card" || t.property === "twitter:card"),
|
|
1256
1272
|
label: "<meta name=\"twitter:card\">",
|
|
1257
1273
|
recommendation: "Add <meta name=\"twitter:card\" content=\"summary_large_image\"> so X/Twitter renders a rich card.",
|
|
1258
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).",
|
|
@@ -1269,6 +1285,7 @@ const seoOgDescription = headTagRule({
|
|
|
1269
1285
|
title: "Open Graph description",
|
|
1270
1286
|
severity: "info",
|
|
1271
1287
|
match: (t) => t.kind === "meta" && t.property === "og:description",
|
|
1288
|
+
misspelled: ogMisspelled("og:description"),
|
|
1272
1289
|
label: "<meta property=\"og:description\">",
|
|
1273
1290
|
recommendation: "Add <meta property=\"og:description\">, or set openGraph.description on your meta component.",
|
|
1274
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.",
|
|
@@ -1285,6 +1302,7 @@ const seoOgUrl = headTagRule({
|
|
|
1285
1302
|
title: "Open Graph URL",
|
|
1286
1303
|
severity: "warning",
|
|
1287
1304
|
match: (t) => t.kind === "meta" && t.property === "og:url",
|
|
1305
|
+
misspelled: ogMisspelled("og:url"),
|
|
1288
1306
|
label: "<meta property=\"og:url\">",
|
|
1289
1307
|
recommendation: "Add <meta property=\"og:url\"> with the canonical URL, or set openGraph.url on your meta component.",
|
|
1290
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.",
|
|
@@ -2084,6 +2102,223 @@ const seoHreflang = {
|
|
|
2084
2102
|
}
|
|
2085
2103
|
};
|
|
2086
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
|
|
2087
2322
|
//#region src/rules/seo/single-h1.ts
|
|
2088
2323
|
const docsUrl$6 = docsUrlFor("seo/single-h1");
|
|
2089
2324
|
const passRecommendation = "Use exactly one <h1> per page for its main topic; demote extra top-level headings to <h2>+.";
|
|
@@ -2098,10 +2333,24 @@ const multipleRecommendation = "A single, clear <h1> is the conventional signal
|
|
|
2098
2333
|
* signal, but no official source documents a ranking penalty for several (2026-08-09 v1
|
|
2099
2334
|
* rule-validity review, P2 #11) — so it's flagged as a style nit, not a defect. Exactly
|
|
2100
2335
|
* one passes. A route whose headings were not collected (channel unset) emits nothing, as does
|
|
2101
|
-
* one whose `dynamicHeading` flag says
|
|
2336
|
+
* one whose `dynamicHeading` flag says a heading of undeterminable level may be its <h1>. A
|
|
2102
2337
|
* global `rules: { 'seo/single-h1': <severity> }` override flattens both arms to one
|
|
2103
2338
|
* severity (design, `applyRuleSeverities`).
|
|
2104
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
|
+
}
|
|
2105
2354
|
const seoSingleH1 = {
|
|
2106
2355
|
id: "seo/single-h1",
|
|
2107
2356
|
title: "Heading hierarchy",
|
|
@@ -2112,7 +2361,7 @@ const seoSingleH1 = {
|
|
|
2112
2361
|
async check(ctx) {
|
|
2113
2362
|
const out = [];
|
|
2114
2363
|
for (const route of ctx.headings ?? []) {
|
|
2115
|
-
const h1 = [...route.headings, ...route.componentHeadings ?? []]
|
|
2364
|
+
const h1 = renderableH1s([...route.headings, ...route.componentHeadings ?? []]);
|
|
2116
2365
|
let problem;
|
|
2117
2366
|
let where = {};
|
|
2118
2367
|
if (h1.length === 0) {
|
|
@@ -2246,6 +2495,13 @@ const seoDuplicateDescription = uniquenessRule({
|
|
|
2246
2495
|
rationale: "Duplicate meta descriptions give search engines no per-page summary, so they are often ignored or rewritten."
|
|
2247
2496
|
});
|
|
2248
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
|
|
2249
2505
|
//#region src/rules/seo/heading-level-skip.ts
|
|
2250
2506
|
const docsUrl$5 = docsUrlFor("seo/heading-level-skip");
|
|
2251
2507
|
const recommendation$5 = "Increase heading levels one step at a time (do not jump, e.g. from <h2> straight to <h4>).";
|
|
@@ -2266,20 +2522,16 @@ const seoHeadingLevelSkip = {
|
|
|
2266
2522
|
const out = [];
|
|
2267
2523
|
for (const route of ctx.headings ?? []) {
|
|
2268
2524
|
if (route.headings.length === 0) continue;
|
|
2269
|
-
let prev = route.headings[0].level;
|
|
2270
2525
|
let skip;
|
|
2271
|
-
for (let i = 1; i < route.headings.length; i++) {
|
|
2526
|
+
for (let i = 1; i < route.headings.length && !skip; i++) {
|
|
2272
2527
|
const h = route.headings[i];
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
break;
|
|
2281
|
-
}
|
|
2282
|
-
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
|
+
};
|
|
2283
2535
|
}
|
|
2284
2536
|
out.push(skip ? {
|
|
2285
2537
|
id: "seo/heading-level-skip",
|
|
@@ -2409,11 +2661,94 @@ function kitModuleRule(opts) {
|
|
|
2409
2661
|
});
|
|
2410
2662
|
}
|
|
2411
2663
|
//#endregion
|
|
2412
|
-
//#region src/
|
|
2413
|
-
/** The root layout —
|
|
2664
|
+
//#region src/kit-module.ts
|
|
2665
|
+
/** The root layout — its page options cover the whole app. */
|
|
2414
2666
|
const ROOT_LAYOUT_RE = /^src\/routes\/\+layout(\.server)?\.(ts|js)$/;
|
|
2415
|
-
/** `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. */
|
|
2416
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
|
|
2417
2752
|
const seoSsrDisabled = kitModuleRule({
|
|
2418
2753
|
id: "seo/ssr-disabled",
|
|
2419
2754
|
title: "SSR disabled",
|
|
@@ -2755,9 +3090,19 @@ function messageFor(link) {
|
|
|
2755
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'.`;
|
|
2756
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'.`;
|
|
2757
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
|
+
}
|
|
2758
3103
|
/** Emit one file's PASS/PENALIZED results — same shapes as componentRule/kitModuleRule. */
|
|
2759
|
-
function emitFile$1(out, file, links, suppressions) {
|
|
2760
|
-
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)));
|
|
2761
3106
|
if (bad.length === 0) {
|
|
2762
3107
|
out.push({
|
|
2763
3108
|
id: ID$8,
|
|
@@ -2802,16 +3147,17 @@ const correctnessBasePathNavigation = {
|
|
|
2802
3147
|
fix: { ...FIX$2 },
|
|
2803
3148
|
async check(ctx) {
|
|
2804
3149
|
if (!ctx.project.kitPathsBase) return [];
|
|
3150
|
+
const base = ctx.project.kitPathsBase.value;
|
|
2805
3151
|
const out = [];
|
|
2806
3152
|
for (const c of ctx.components ?? []) {
|
|
2807
3153
|
const links = c.basePathLinks ?? [];
|
|
2808
3154
|
if (links.length === 0) continue;
|
|
2809
|
-
emitFile$1(out, c.file, links, c.suppressions);
|
|
3155
|
+
emitFile$1(out, c.file, links, c.suppressions, base);
|
|
2810
3156
|
}
|
|
2811
3157
|
for (const m of ctx.kitModules ?? []) {
|
|
2812
3158
|
const links = m.basePathLinks ?? [];
|
|
2813
3159
|
if (links.length === 0) continue;
|
|
2814
|
-
emitFile$1(out, m.file, links, m.suppressions);
|
|
3160
|
+
emitFile$1(out, m.file, links, m.suppressions, base);
|
|
2815
3161
|
}
|
|
2816
3162
|
return out;
|
|
2817
3163
|
}
|
|
@@ -2878,7 +3224,8 @@ const correctnessServerBrowserGlobal = {
|
|
|
2878
3224
|
})), c.suppressions);
|
|
2879
3225
|
}
|
|
2880
3226
|
for (const m of ctx.kitModules ?? []) {
|
|
2881
|
-
const
|
|
3227
|
+
const neverSsr = universalNeverSsr(m, ctx);
|
|
3228
|
+
const refs = (m.browserGlobalRefs ?? []).filter((r) => !(neverSsr && r.inHandler));
|
|
2882
3229
|
if (refs.length === 0) continue;
|
|
2883
3230
|
emitFile(out, m.file, refs.map((r) => ({
|
|
2884
3231
|
line: r.line,
|
|
@@ -2897,7 +3244,7 @@ const correctnessInstanceBrowserGlobal = componentRule({
|
|
|
2897
3244
|
label: "Server-safe component init",
|
|
2898
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).",
|
|
2899
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.",
|
|
2900
|
-
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),
|
|
2901
3248
|
bad: (c) => (c.browserGlobalRefs ?? []).filter((r) => r.context === "instance").map((r) => ({
|
|
2902
3249
|
line: r.line,
|
|
2903
3250
|
message: `${r.name} is accessed during component initialisation — during SSR this runs on the server, where ${r.name} is not defined`
|
|
@@ -2943,7 +3290,7 @@ const securityHandlerStateWrite = kitModuleRule({
|
|
|
2943
3290
|
label: "Load/handler purity",
|
|
2944
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.",
|
|
2945
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.",
|
|
2946
|
-
applies: (m) => m.importedStateWrites.length > 0 && !(m
|
|
3293
|
+
applies: (m, ctx) => m.importedStateWrites.length > 0 && !universalNeverSsr(m, ctx),
|
|
2947
3294
|
bad: (m) => m.importedStateWrites.map((w) => ({
|
|
2948
3295
|
line: w.line,
|
|
2949
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`
|
|
@@ -2958,7 +3305,7 @@ const securityServerModuleState = kitModuleRule({
|
|
|
2958
3305
|
label: "Server module state",
|
|
2959
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.",
|
|
2960
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.",
|
|
2961
|
-
applies: (m) => m.moduleStateReassignments.length > 0,
|
|
3308
|
+
applies: (m, ctx) => m.moduleStateReassignments.length > 0 && !universalNeverSsr(m, ctx),
|
|
2962
3309
|
bad: (m) => m.moduleStateReassignments.map((r) => ({
|
|
2963
3310
|
line: r.line,
|
|
2964
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`
|
|
@@ -2977,7 +3324,7 @@ const securitySharedStateImport = kitModuleRule({
|
|
|
2977
3324
|
label: "Server state imports",
|
|
2978
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.",
|
|
2979
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.",
|
|
2980
|
-
applies: (m) => m.runesModuleImports.length > 0 && !(m
|
|
3327
|
+
applies: (m, ctx) => m.runesModuleImports.length > 0 && !universalNeverSsr(m, ctx),
|
|
2981
3328
|
bad: (m, ctx) => {
|
|
2982
3329
|
const stateFiles = new Set((ctx.components ?? []).filter((c) => c.moduleStateDecls.length > 0).map((c) => c.file));
|
|
2983
3330
|
const writtenOutside = new Set(m.importedStateWritesOutsideHandlers.map((w) => w.name));
|
|
@@ -3258,7 +3605,8 @@ function rootObjectName(node) {
|
|
|
3258
3605
|
* `then`/`catch` value/error bindings, and a fragment's own `{@const ...}` /
|
|
3259
3606
|
* `{let ...}` / `{const ...}` declaration tags (attributed to the enclosing Fragment,
|
|
3260
3607
|
* shadowing the whole fragment like a block's `let` — a write to one of these
|
|
3261
|
-
* 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.
|
|
3262
3610
|
* Used by `walkScoped` so a write/mutation detector
|
|
3263
3611
|
* doesn't misattribute a write to one of these locals as a write to an outer `$state`/prop
|
|
3264
3612
|
* of the same name (issue #140 — originally a deliberately partial mitigation that left
|
|
@@ -3284,7 +3632,20 @@ function scopeIntroducedNames(node) {
|
|
|
3284
3632
|
else if (node.type === "AwaitBlock") {
|
|
3285
3633
|
if (node.value) addBoundNames(node.value, introduced);
|
|
3286
3634
|
if (node.error) addBoundNames(node.error, introduced);
|
|
3287
|
-
} 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") {
|
|
3288
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);
|
|
3289
3650
|
}
|
|
3290
3651
|
return introduced;
|
|
@@ -3340,11 +3701,21 @@ function walkEvalScope(node, visit, shadowed = /* @__PURE__ */ new Set(), bounda
|
|
|
3340
3701
|
const scope = introduced.size > 0 ? /* @__PURE__ */ new Set([...shadowed, ...introduced]) : shadowed;
|
|
3341
3702
|
if (visit(node, scope)) return;
|
|
3342
3703
|
if (boundaries.has(node.type)) return;
|
|
3704
|
+
const outer = introduced.size > 0 ? outerScopeKeys(node) : void 0;
|
|
3343
3705
|
for (const key of Object.keys(node)) {
|
|
3344
3706
|
if (WALK_IGNORED_KEYS.has(key)) continue;
|
|
3345
|
-
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);
|
|
3346
3710
|
}
|
|
3347
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"]);
|
|
3348
3719
|
/**
|
|
3349
3720
|
* Unwrap a top-level statement's `export`/`export default` wrapper to the declaration
|
|
3350
3721
|
* (or expression) it wraps; a non-export statement is returned as-is. Used so pattern 2
|
|
@@ -3449,12 +3820,13 @@ function collectNamedImportAliases(program, moduleSource, names) {
|
|
|
3449
3820
|
}
|
|
3450
3821
|
const BROWSER_GUARD_NAMES = /* @__PURE__ */ new Set(["browser"]);
|
|
3451
3822
|
/**
|
|
3452
|
-
* 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) —
|
|
3453
3825
|
* the guard binding recognised by the browser-global scanner (correctness/server-browser-global, correctness/instance-browser-global).
|
|
3454
3826
|
* Shared with the Kit-module parser.
|
|
3455
3827
|
*/
|
|
3456
3828
|
function collectBrowserGuardImports(program) {
|
|
3457
|
-
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)]);
|
|
3458
3830
|
}
|
|
3459
3831
|
/**
|
|
3460
3832
|
* Names bound at the program's top level: every import's local name plus every
|
|
@@ -3490,10 +3862,12 @@ function guardTerminates(consequent) {
|
|
|
3490
3862
|
/**
|
|
3491
3863
|
* Whether a guard-clause test establishes a browser environment: it references the
|
|
3492
3864
|
* `$app/environment` `browser` binding, or contains a
|
|
3493
|
-
* `typeof <tracked-global
|
|
3865
|
+
* `typeof <tracked-global>` comparison against any type string (`'undefined'`, `'function'`, …)
|
|
3866
|
+
* (correctness/server-browser-global, correctness/instance-browser-global).
|
|
3494
3867
|
* Over-matching here only widens the skip — a conservative miss, never a false positive.
|
|
3495
3868
|
*/
|
|
3496
3869
|
function isBrowserGuardTest(test, guardBindings) {
|
|
3870
|
+
if (test?.type === "LogicalExpression" && test.operator !== "&&") return isBrowserGuardTest(test.left, guardBindings) && isBrowserGuardTest(test.right, guardBindings);
|
|
3497
3871
|
let guarded = false;
|
|
3498
3872
|
walkEstree(test, (n) => {
|
|
3499
3873
|
if (n.type === "Identifier" && guardBindings.has(n.name)) guarded = true;
|
|
@@ -3505,8 +3879,9 @@ function isBrowserGuardTest(test, guardBindings) {
|
|
|
3505
3879
|
].includes(n.operator)) {
|
|
3506
3880
|
const sides = [n.left, n.right];
|
|
3507
3881
|
const hasTypeofGlobal = sides.some((s) => s?.type === "UnaryExpression" && s.operator === "typeof" && s.argument?.type === "Identifier" && BROWSER_GLOBALS.has(s.argument.name));
|
|
3508
|
-
const
|
|
3509
|
-
|
|
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;
|
|
3510
3885
|
}
|
|
3511
3886
|
});
|
|
3512
3887
|
return guarded;
|
|
@@ -3564,7 +3939,7 @@ function collectBrowserGlobalRefs(program, source, extra) {
|
|
|
3564
3939
|
if (typeof n !== "object" || typeof n.type !== "string") return;
|
|
3565
3940
|
if (EVAL_SCOPE_BOUNDARIES.has(n.type)) return;
|
|
3566
3941
|
if ((n.type === "IfStatement" || n.type === "ConditionalExpression") && isBrowserGuardTest(n.test, guards)) return;
|
|
3567
|
-
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;
|
|
3568
3943
|
const introduced = scopeIntroducedNames(n);
|
|
3569
3944
|
const scope = introduced.size > 0 ? /* @__PURE__ */ new Set([...shadowed, ...introduced]) : shadowed;
|
|
3570
3945
|
switch (n.type) {
|
|
@@ -3776,6 +4151,16 @@ function findFalseOptOut(program, source, name) {
|
|
|
3776
4151
|
});
|
|
3777
4152
|
return hit;
|
|
3778
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
|
+
}
|
|
3779
4164
|
/**
|
|
3780
4165
|
* The exported `load` function node (inline `export function load` / `export const
|
|
3781
4166
|
* load = …`, `satisfies`/`as` unwrapped) or a same-file alias export. Cross-file
|
|
@@ -3805,7 +4190,15 @@ function collectAwaits(node, out = []) {
|
|
|
3805
4190
|
}
|
|
3806
4191
|
return out;
|
|
3807
4192
|
}
|
|
4193
|
+
const MUTATING_METHODS = /* @__PURE__ */ new Set([
|
|
4194
|
+
"push",
|
|
4195
|
+
"unshift",
|
|
4196
|
+
"splice",
|
|
4197
|
+
"add",
|
|
4198
|
+
"set"
|
|
4199
|
+
]);
|
|
3808
4200
|
const REDIRECT_NAMES = /* @__PURE__ */ new Set(["redirect"]);
|
|
4201
|
+
const EXIT_NAMES = /* @__PURE__ */ new Set(["redirect", "error"]);
|
|
3809
4202
|
/**
|
|
3810
4203
|
* Root-relative `redirect(status, '/…')` targets (correctness/base-path-navigation). Argument 1
|
|
3811
4204
|
* is the location (argument 0 is the status). Only a plain string literal counts, which
|
|
@@ -3825,6 +4218,74 @@ function collectRedirectCalls(node, locals, out = []) {
|
|
|
3825
4218
|
}
|
|
3826
4219
|
return out;
|
|
3827
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
|
+
}
|
|
3828
4289
|
/**
|
|
3829
4290
|
* Whether `await`'s argument is a `parent()` / `<x>.parent()` call (Kit's parent-load
|
|
3830
4291
|
* step, exempt from performance/load-waterfall and performance/sequential-awaits). Any `<expr>.parent()` member call matches — over-broad
|
|
@@ -3857,6 +4318,26 @@ function isBodyParseCall(arg) {
|
|
|
3857
4318
|
const callee = e.callee;
|
|
3858
4319
|
return callee?.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && BODY_METHODS.has(callee.property.name);
|
|
3859
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
|
+
}
|
|
3860
4341
|
/**
|
|
3861
4342
|
* Whether the expression references any tainted name. Threads nested-function
|
|
3862
4343
|
* shadowing (`scopeIntroducedNames`) so a callback parameter that shadows a
|
|
@@ -3889,15 +4370,36 @@ function refsTainted(node, tainted) {
|
|
|
3889
4370
|
return hit;
|
|
3890
4371
|
}
|
|
3891
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
|
+
/**
|
|
3892
4391
|
* performance/load-waterfall, performance/sequential-awaits — forward-taint analysis of the exported `load`'s straight-line
|
|
3893
4392
|
* statements (direct `try` blocks inlined; `if`/loops/`switch` are not classified
|
|
3894
4393
|
* but still propagate taint from their assignments; nested functions are never
|
|
3895
4394
|
* entered). One await site per statement; a site whose awaits' argument subtrees
|
|
3896
4395
|
* reference an earlier site's bindings (transitively, through intermediate consts
|
|
3897
|
-
* and assignments — member-expression targets taint their root object)
|
|
3898
|
-
*
|
|
3899
|
-
*
|
|
3900
|
-
*
|
|
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
|
|
3901
4403
|
* response-body reads are never sites, but their bindings taint. Lines are
|
|
3902
4404
|
* returned in ORIGINAL-source coordinates (the −1 wrap shift is applied here).
|
|
3903
4405
|
*/
|
|
@@ -3911,12 +4413,27 @@ function collectLoadWaterfalls(program, wrapped) {
|
|
|
3911
4413
|
};
|
|
3912
4414
|
const line = (start) => Math.max(0, lineOf(wrapped, start) - 1);
|
|
3913
4415
|
const tainted = /* @__PURE__ */ new Set();
|
|
4416
|
+
const syncTainted = /* @__PURE__ */ new Set();
|
|
3914
4417
|
let sawAwaitSite = false;
|
|
3915
|
-
const
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
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);
|
|
3920
4437
|
};
|
|
3921
4438
|
const taintOnly = (node) => {
|
|
3922
4439
|
if (Array.isArray(node)) {
|
|
@@ -3925,10 +4442,14 @@ function collectLoadWaterfalls(program, wrapped) {
|
|
|
3925
4442
|
}
|
|
3926
4443
|
if (!node || typeof node !== "object" || typeof node.type !== "string") return;
|
|
3927
4444
|
if (isFunctionNode(node)) return;
|
|
3928
|
-
if (node.type === "AssignmentExpression")
|
|
3929
|
-
|
|
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);
|
|
3930
4451
|
} else if (node.type === "VariableDeclaration") {
|
|
3931
|
-
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);
|
|
3932
4453
|
}
|
|
3933
4454
|
for (const key of Object.keys(node)) {
|
|
3934
4455
|
if (WALK_IGNORED_KEYS.has(key)) continue;
|
|
@@ -3947,12 +4468,13 @@ function collectLoadWaterfalls(program, wrapped) {
|
|
|
3947
4468
|
if (stmt.type === "VariableDeclaration" || stmt.type === "ExpressionStatement" || stmt.type === "ReturnStatement") {
|
|
3948
4469
|
const sites = collectAwaits(stmt).filter((a) => !isParentCall(a.argument) && !isBodyParseCall(a.argument));
|
|
3949
4470
|
if (sites.length > 0) {
|
|
3950
|
-
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)));
|
|
3951
4473
|
if (dependent.length > 0) {
|
|
3952
4474
|
const anchor = dependent.reduce((m, a) => a.start < m.start ? a : m);
|
|
3953
4475
|
dependentLines.push(line(anchor.start));
|
|
3954
4476
|
} else if (sawAwaitSite) {
|
|
3955
|
-
const workSites = sites.filter((a) =>
|
|
4477
|
+
const workSites = sites.filter((a) => startsWork(a.argument));
|
|
3956
4478
|
if (workSites.length > 0) {
|
|
3957
4479
|
const anchor = workSites.reduce((m, a) => a.start < m.start ? a : m);
|
|
3958
4480
|
independentLines.push(line(anchor.start));
|
|
@@ -3960,15 +4482,12 @@ function collectLoadWaterfalls(program, wrapped) {
|
|
|
3960
4482
|
}
|
|
3961
4483
|
sawAwaitSite = true;
|
|
3962
4484
|
}
|
|
3963
|
-
if (stmt.type === "VariableDeclaration")
|
|
3964
|
-
if (
|
|
3965
|
-
|
|
3966
|
-
}
|
|
3967
|
-
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") {
|
|
3968
4488
|
const expr = unwrapTs(stmt.expression);
|
|
3969
|
-
if (expr?.type === "AssignmentExpression")
|
|
3970
|
-
|
|
3971
|
-
}
|
|
4489
|
+
if (expr?.type === "AssignmentExpression") taintTarget(expr.left, expr.right);
|
|
4490
|
+
else taintMutation(expr);
|
|
3972
4491
|
}
|
|
3973
4492
|
} else taintOnly(stmt);
|
|
3974
4493
|
}
|
|
@@ -4005,23 +4524,19 @@ function walkKit(node, handlerFns, startupFns, visit, shadowed = /* @__PURE__ */
|
|
|
4005
4524
|
}
|
|
4006
4525
|
}
|
|
4007
4526
|
/**
|
|
4008
|
-
* Normalize a posix path, resolving `.` and `..` segments — string-only, no I/O.
|
|
4009
|
-
*
|
|
4010
|
-
* its root (e.g. `../../../../src/lib/server/db` from a shallow route file): the
|
|
4011
|
-
* real target lies outside the project tree we can see, so there is no
|
|
4012
|
-
* 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`).
|
|
4013
4529
|
*/
|
|
4014
4530
|
function normalizePosix(path) {
|
|
4015
4531
|
const out = [];
|
|
4016
4532
|
for (const seg of path.split("/")) {
|
|
4017
4533
|
if (seg === "" || seg === ".") continue;
|
|
4018
|
-
if (seg === "..")
|
|
4019
|
-
|
|
4020
|
-
out.pop();
|
|
4021
|
-
} else out.push(seg);
|
|
4534
|
+
if (seg === ".." && out.length > 0 && out[out.length - 1] !== "..") out.pop();
|
|
4535
|
+
else out.push(seg);
|
|
4022
4536
|
}
|
|
4023
4537
|
return out.join("/");
|
|
4024
4538
|
}
|
|
4539
|
+
const escapesRoot = (path) => path === ".." || path.startsWith("../");
|
|
4025
4540
|
/**
|
|
4026
4541
|
* What `resolveRepoLocalPath` assumes when no config was read: SvelteKit's own `$lib`, at the
|
|
4027
4542
|
* default `src/lib`. This single entry is the whole of this analyzer's pre-alias behaviour.
|
|
@@ -4048,8 +4563,12 @@ function aliasMatches(entry, spec) {
|
|
|
4048
4563
|
* be a project-local module: the caller's `aliases` list decides which non-relative
|
|
4049
4564
|
* specifiers resolve, defaulting to `$lib` → `src/lib`; `./`/`../` resolve against the
|
|
4050
4565
|
* importing file's directory; bare packages and other aliases are skipped (they can't
|
|
4051
|
-
* be resolved to a project-local path at all).
|
|
4052
|
-
*
|
|
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
|
|
4053
4572
|
* matched alias's value is itself absolute (e.g. `/opt/shared/src`, or a posixified Windows
|
|
4054
4573
|
* drive-letter path like `C:/shared/src`): an absolute target is outside the analyzed project by
|
|
4055
4574
|
* definition, and without this check `normalizePosix` would quietly drop the leading empty
|
|
@@ -4058,7 +4577,7 @@ function aliasMatches(entry, spec) {
|
|
|
4058
4577
|
* Exported from the package's public barrel because `architecture/private-scope-import`
|
|
4059
4578
|
* and `architecture/route-component-import` (inside `packages/core`) both need resolution
|
|
4060
4579
|
* that is not restricted to runes modules, unlike `resolveRunesModuleSpecifier` — and
|
|
4061
|
-
* because
|
|
4580
|
+
* because component resolution (`packages/cli/src/providers/source/resolve.ts`), which
|
|
4062
4581
|
* drives transitive `<head>`/heading resolution, delegates its alias/`$lib`/relative
|
|
4063
4582
|
* mapping here too, rather than duplicating it. This is the single site for every
|
|
4064
4583
|
* repo-local specifier resolution in the repo.
|
|
@@ -4070,9 +4589,15 @@ function resolveRepoLocalPath(spec, importerFile, aliases = DEFAULT_KIT_ALIASES)
|
|
|
4070
4589
|
const entry = aliases.find((a) => aliasMatches(a, spec));
|
|
4071
4590
|
if (entry?.replacement == null) return void 0;
|
|
4072
4591
|
if (entry.replacement.startsWith("/") || /^[A-Za-z]:\//.test(entry.replacement)) return void 0;
|
|
4073
|
-
path = entry.replacement + spec.slice(entry.find.length);
|
|
4592
|
+
path = entry.replacement + spec.slice(entry.find.length) + (entry.suffix ?? "");
|
|
4074
4593
|
}
|
|
4075
|
-
|
|
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;
|
|
4076
4601
|
}
|
|
4077
4602
|
/**
|
|
4078
4603
|
* Resolve an import specifier to a repo-relative `.svelte.ts`/`.svelte.js` path, or
|
|
@@ -4080,7 +4605,7 @@ function resolveRepoLocalPath(spec, importerFile, aliases = DEFAULT_KIT_ALIASES)
|
|
|
4080
4605
|
* `aliases` (defaulting to `$lib` → `src/lib` when omitted), so a project's declared
|
|
4081
4606
|
* `kit.alias`/`kit.files.lib` resolve here exactly as they do at rule time; `./`/`../`
|
|
4082
4607
|
* resolve against the importing file's directory; bare packages, unmatched aliases, and
|
|
4083
|
-
* a
|
|
4608
|
+
* a path above the project root outside every `kit.alias` directory are skipped. An
|
|
4084
4609
|
* extensionless `…/x.svelte` specifier canonicalises to `….svelte.ts` (security/shared-state-import also
|
|
4085
4610
|
* tries the `.js` sibling when matching).
|
|
4086
4611
|
*/
|
|
@@ -4115,8 +4640,8 @@ function libServerRoot(aliases) {
|
|
|
4115
4640
|
* an equivalent relative path, resolving to exactly that directory — importing the
|
|
4116
4641
|
* index file rather than a named submodule) is exempt too. A specifier that
|
|
4117
4642
|
* resolves to undefined — a bare package, an alias that can't map to a repo path, or
|
|
4118
|
-
* a relative specifier whose `..` segments escape the
|
|
4119
|
-
* `
|
|
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
|
|
4120
4645
|
* we don't flag writes to it. Installed packages (drizzle, redis, @vercel/kv, …) are
|
|
4121
4646
|
* excluded: `.set()`/`.update()` on those is persistence, not shared-module-state
|
|
4122
4647
|
* mutation. When the `$lib` entry is opaque (`libServerRoot` returns undefined — an unreadable
|
|
@@ -4147,15 +4672,25 @@ const IN_MEMORY_CTORS = /* @__PURE__ */ new Set([
|
|
|
4147
4672
|
"WeakMap",
|
|
4148
4673
|
"WeakSet"
|
|
4149
4674
|
]);
|
|
4150
|
-
/**
|
|
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
|
+
*/
|
|
4151
4681
|
function isInMemoryInit(init) {
|
|
4152
4682
|
if (!init) return false;
|
|
4153
|
-
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
|
+
}
|
|
4154
4689
|
return init.type === "NewExpression" && init.callee?.type === "Identifier" && IN_MEMORY_CTORS.has(init.callee.name);
|
|
4155
4690
|
}
|
|
4156
4691
|
/**
|
|
4157
4692
|
* The exported bindings of a module that are initialized to an in-memory container — an object
|
|
4158
|
-
* 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
|
|
4159
4694
|
* an import from under the `$lib` server root, where the call shape alone cannot separate a
|
|
4160
4695
|
* hand-rolled store from a database client.
|
|
4161
4696
|
*
|
|
@@ -4204,6 +4739,7 @@ function parseKitModuleFacts(source, filename, aliases) {
|
|
|
4204
4739
|
const line = (start) => Math.max(0, lineOf(wrapped, start) - 1);
|
|
4205
4740
|
const importedSpecifiers = /* @__PURE__ */ new Map();
|
|
4206
4741
|
const importedNames = /* @__PURE__ */ new Map();
|
|
4742
|
+
const namespaceImports = /* @__PURE__ */ new Set();
|
|
4207
4743
|
for (const stmt of program.body ?? []) {
|
|
4208
4744
|
if (stmt?.type !== "ImportDeclaration" || stmt.importKind === "type") continue;
|
|
4209
4745
|
const spec = typeof stmt.source?.value === "string" ? stmt.source.value : "";
|
|
@@ -4212,6 +4748,7 @@ function parseKitModuleFacts(source, filename, aliases) {
|
|
|
4212
4748
|
if (s?.importKind === "type" || s?.local?.type !== "Identifier") continue;
|
|
4213
4749
|
names.push(s.local.name);
|
|
4214
4750
|
importedSpecifiers.set(s.local.name, spec);
|
|
4751
|
+
if (s.type === "ImportNamespaceSpecifier") namespaceImports.add(s.local.name);
|
|
4215
4752
|
importedNames.set(s.local.name, s.type === "ImportSpecifier" && s.imported?.type === "Identifier" ? s.imported.name : s.local.name);
|
|
4216
4753
|
}
|
|
4217
4754
|
if (names.length === 0) continue;
|
|
@@ -4309,7 +4846,8 @@ function parseKitModuleFacts(source, filename, aliases) {
|
|
|
4309
4846
|
};
|
|
4310
4847
|
} else if (n.type === "CallExpression" && n.callee?.type === "MemberExpression") {
|
|
4311
4848
|
const method = n.callee.property?.type === "Identifier" ? n.callee.property.name : void 0;
|
|
4312
|
-
|
|
4849
|
+
const nsFunction = n.callee.object?.type === "Identifier" && namespaceImports.has(n.callee.object.name);
|
|
4850
|
+
if ((method === "set" || method === "update") && !nsFunction) {
|
|
4313
4851
|
const r = importedRoot(n.callee.object);
|
|
4314
4852
|
const spec = r ? importedSpecifiers.get(r) : void 0;
|
|
4315
4853
|
if (r && spec !== void 0) {
|
|
@@ -4387,7 +4925,9 @@ function parseKitModuleFacts(source, filename, aliases) {
|
|
|
4387
4925
|
browserGlobalRefs: byLine(browserGlobalRefs),
|
|
4388
4926
|
basePathLinks: byLine(basePathLinks),
|
|
4389
4927
|
...ssrOptOut ? { ssrDisabled: { line: Math.max(0, ssrOptOut.line - 1) } } : {},
|
|
4928
|
+
...!ssrOptOut && exportsName(program, "ssr") ? { ssrEnabled: true } : {},
|
|
4390
4929
|
...csrOptOut ? { csrDisabled: { line: Math.max(0, csrOptOut.line - 1) } } : {},
|
|
4930
|
+
...loadNeverRenders(program, collectNamedImportAliases(program, "@sveltejs/kit", EXIT_NAMES)) ? { loadNeverRenders: true } : {},
|
|
4391
4931
|
...waterfalls.dependentLines.length > 0 || waterfalls.independentLines.length > 0 ? { loadWaterfalls: waterfalls } : {},
|
|
4392
4932
|
suppressions
|
|
4393
4933
|
};
|
|
@@ -5609,8 +6149,8 @@ function isRouteEntry(path) {
|
|
|
5609
6149
|
/** The route entries this component imports, with the line each import sits on. */
|
|
5610
6150
|
function routeEntryImports(c, ctx) {
|
|
5611
6151
|
const out = [];
|
|
5612
|
-
for (const { source, line, type } of c.importSpans ?? []) {
|
|
5613
|
-
if (type) continue;
|
|
6152
|
+
for (const { source, line, type, named } of c.importSpans ?? []) {
|
|
6153
|
+
if (type || named) continue;
|
|
5614
6154
|
const target = resolveRepoLocalPath(source, c.file, ctx.project.kitAliases);
|
|
5615
6155
|
if (target !== void 0 && isRouteEntry(target)) out.push({
|
|
5616
6156
|
line,
|
|
@@ -5956,223 +6496,6 @@ const performanceIframeLoading = componentRule({
|
|
|
5956
6496
|
}))
|
|
5957
6497
|
});
|
|
5958
6498
|
//#endregion
|
|
5959
|
-
//#region src/rules/a11y/aria-data.ts
|
|
5960
|
-
/**
|
|
5961
|
-
* ARIA names `aria-query@5.3.2` does not carry. It is not the clean ARIA 1.2 snapshot it looks
|
|
5962
|
-
* like — it holds 1.2's 48 attributes plus three 1.3 additions — so the gap is patched by name
|
|
5963
|
-
* rather than by version. Every entry here is defined in the ARIA 1.3 editor's draft, which is
|
|
5964
|
-
* what makes reporting it as an unknown name wrong — how far implementations have got is a
|
|
5965
|
-
* separate question this rule does not ask. Re-check this list whenever the aria-query dependency
|
|
5966
|
-
* moves.
|
|
5967
|
-
*/
|
|
5968
|
-
const ARIA_1_3_ROLES = /* @__PURE__ */ new Set([
|
|
5969
|
-
"comment",
|
|
5970
|
-
"image",
|
|
5971
|
-
"sectionheader",
|
|
5972
|
-
"sectionfooter",
|
|
5973
|
-
"suggestion"
|
|
5974
|
-
]);
|
|
5975
|
-
const ARIA_1_3_ATTRIBUTES = /* @__PURE__ */ new Set(["aria-colindextext", "aria-rowindextext"]);
|
|
5976
|
-
/**
|
|
5977
|
-
* Roles whose `requiredProps` row in aria-query is stale. Neither ARIA 1.2 nor the 1.3 editor's
|
|
5978
|
-
* draft lists required states or properties for these — the 1.3 draft re-lists inherited
|
|
5979
|
-
* requirements where they apply (`menuitemradio` → `aria-checked`), so the absence is deliberate
|
|
5980
|
-
* rather than an inheritance the table left implicit. Without this, idiomatic APG listbox and
|
|
5981
|
-
* tree markup is flagged.
|
|
5982
|
-
*/
|
|
5983
|
-
const NO_REQUIRED_PROPS = /* @__PURE__ */ new Set(["option", "treeitem"]);
|
|
5984
|
-
function isKnownRole(role) {
|
|
5985
|
-
return ARIA_1_3_ROLES.has(role) || roles.has(role);
|
|
5986
|
-
}
|
|
5987
|
-
function isAbstractRole(role) {
|
|
5988
|
-
return roles.get(role)?.abstract === true;
|
|
5989
|
-
}
|
|
5990
|
-
/** A concrete (non-abstract) role is the one a user agent resolves a role token list to. */
|
|
5991
|
-
function isConcreteRole(role) {
|
|
5992
|
-
return isKnownRole(role) && !isAbstractRole(role);
|
|
5993
|
-
}
|
|
5994
|
-
/**
|
|
5995
|
-
* The role a user agent applies: the first token naming a concrete role, or undefined when none
|
|
5996
|
-
* does. Shared so the role rules cannot disagree about what a fallback list means.
|
|
5997
|
-
*/
|
|
5998
|
-
function resolveRole(tokens) {
|
|
5999
|
-
return tokens.find(isConcreteRole);
|
|
6000
|
-
}
|
|
6001
|
-
function isKnownAriaAttribute(name) {
|
|
6002
|
-
return ARIA_1_3_ATTRIBUTES.has(name) || aria.has(name);
|
|
6003
|
-
}
|
|
6004
|
-
function requiredAriaProps(role) {
|
|
6005
|
-
if (NO_REQUIRED_PROPS.has(role)) return [];
|
|
6006
|
-
const def = roles.get(role);
|
|
6007
|
-
return def ? Object.keys(def.requiredProps) : [];
|
|
6008
|
-
}
|
|
6009
|
-
function ariaValueKind(name) {
|
|
6010
|
-
const def = aria.get(name);
|
|
6011
|
-
if (!def) return void 0;
|
|
6012
|
-
return {
|
|
6013
|
-
type: def.type,
|
|
6014
|
-
...def.values ? { values: def.values.map(String) } : {}
|
|
6015
|
-
};
|
|
6016
|
-
}
|
|
6017
|
-
//#endregion
|
|
6018
|
-
//#region src/a11y.ts
|
|
6019
|
-
/**
|
|
6020
|
-
* Branch-aware occurrence fold (design "Control-flow semantics"): within a branch
|
|
6021
|
-
* occurrences sum, across the arms of one exclusive block the arm with the most
|
|
6022
|
-
* occurrences wins (tie → lowest branch index) and ITS occurrences are the group's
|
|
6023
|
-
* representatives — so a caller's count is always `list.length`, with a location per
|
|
6024
|
-
* representative. `{#each}`/`{#snippet}` occurrences render 0..N times and drop out.
|
|
6025
|
-
* The max is per key: there is no scalar total to maximize.
|
|
6026
|
-
*/
|
|
6027
|
-
function foldOccurrences(nodes) {
|
|
6028
|
-
const byKey = /* @__PURE__ */ new Map();
|
|
6029
|
-
for (const node of nodes) {
|
|
6030
|
-
if (node.repeatable) continue;
|
|
6031
|
-
const list = byKey.get(node.key);
|
|
6032
|
-
if (list) list.push(node);
|
|
6033
|
-
else byKey.set(node.key, [node]);
|
|
6034
|
-
}
|
|
6035
|
-
const folded = /* @__PURE__ */ new Map();
|
|
6036
|
-
for (const [key, list] of byKey) folded.set(key, foldAt(list, 0));
|
|
6037
|
-
return folded;
|
|
6038
|
-
}
|
|
6039
|
-
function foldAt(nodes, depth) {
|
|
6040
|
-
const unconditional = [];
|
|
6041
|
-
const groups = /* @__PURE__ */ new Map();
|
|
6042
|
-
for (const node of nodes) {
|
|
6043
|
-
const step = node.path[depth];
|
|
6044
|
-
if (!step) {
|
|
6045
|
-
unconditional.push(node);
|
|
6046
|
-
continue;
|
|
6047
|
-
}
|
|
6048
|
-
let branches = groups.get(step.group);
|
|
6049
|
-
if (!branches) groups.set(step.group, branches = /* @__PURE__ */ new Map());
|
|
6050
|
-
const list = branches.get(step.branch);
|
|
6051
|
-
if (list) list.push(node);
|
|
6052
|
-
else branches.set(step.branch, [node]);
|
|
6053
|
-
}
|
|
6054
|
-
const representatives = [...unconditional];
|
|
6055
|
-
for (const branches of groups.values()) {
|
|
6056
|
-
let best = [];
|
|
6057
|
-
let bestBranch = Number.POSITIVE_INFINITY;
|
|
6058
|
-
for (const [branch, list] of branches) {
|
|
6059
|
-
const arm = foldAt(list, depth + 1);
|
|
6060
|
-
if (arm.length > best.length || arm.length === best.length && branch < bestBranch) {
|
|
6061
|
-
best = arm;
|
|
6062
|
-
bestBranch = branch;
|
|
6063
|
-
}
|
|
6064
|
-
}
|
|
6065
|
-
representatives.push(...best);
|
|
6066
|
-
}
|
|
6067
|
-
return representatives;
|
|
6068
|
-
}
|
|
6069
|
-
/**
|
|
6070
|
-
* Decode a fragment identifier the way navigation does before matching an element id
|
|
6071
|
-
* (`href="#caf%C3%A9"` targets `id="café"`). Malformed escapes are kept verbatim —
|
|
6072
|
-
* the browser would also fail to decode them, so the raw text is the comparable form.
|
|
6073
|
-
*/
|
|
6074
|
-
function decodeFragmentId(fragment) {
|
|
6075
|
-
try {
|
|
6076
|
-
return decodeURIComponent(fragment);
|
|
6077
|
-
} catch {
|
|
6078
|
-
return fragment;
|
|
6079
|
-
}
|
|
6080
|
-
}
|
|
6081
|
-
/** Whitespace-split tokens of a (possibly undefined) literal attribute value. */
|
|
6082
|
-
function splitTokens(value) {
|
|
6083
|
-
return value ? value.trim().split(/\s+/).filter(Boolean) : [];
|
|
6084
|
-
}
|
|
6085
|
-
/** Explicit `role` values that map to the landmark kinds the route rules inspect. */
|
|
6086
|
-
const LANDMARK_ROLES = /* @__PURE__ */ new Set([
|
|
6087
|
-
"main",
|
|
6088
|
-
"banner",
|
|
6089
|
-
"contentinfo",
|
|
6090
|
-
"complementary"
|
|
6091
|
-
]);
|
|
6092
|
-
/** Sectioning content (HTML-AAM): a `<header>`/`<footer>` below one of these is not a landmark. */
|
|
6093
|
-
const SECTIONING_TAGS = /* @__PURE__ */ new Set([
|
|
6094
|
-
"article",
|
|
6095
|
-
"aside",
|
|
6096
|
-
"main",
|
|
6097
|
-
"nav",
|
|
6098
|
-
"section"
|
|
6099
|
-
]);
|
|
6100
|
-
/**
|
|
6101
|
-
* Tags whose landmark-ness depends on sectioning ancestry. A provider that cannot see ancestry
|
|
6102
|
-
* (the per-file AST walk) uses this same set to mark the deferral (topLevel) — reading it from
|
|
6103
|
-
* here keeps the deferral set from drifting when the policy widens.
|
|
6104
|
-
*/
|
|
6105
|
-
const ANCESTRY_DEPENDENT_TAGS = /* @__PURE__ */ new Set(["header", "footer"]);
|
|
6106
|
-
/** Attributes that give an element the accessible name the `<aside>` landmark decision reads. */
|
|
6107
|
-
const NAMING_ATTRS$1 = ["aria-label", "aria-labelledby"];
|
|
6108
|
-
/**
|
|
6109
|
-
* HTML-AAM: an `<aside>` scoped to `body` or `main` is a `complementary` landmark; scoped to
|
|
6110
|
-
* sectioning content it is one only when it has an accessible name. `main` is deliberately absent
|
|
6111
|
-
* — it is a scope in which an `aside` *is* a landmark, unlike the sectioning set that decides
|
|
6112
|
-
* `<header>`/`<footer>`.
|
|
6113
|
-
*/
|
|
6114
|
-
const ASIDE_DEMOTING_TAGS = /* @__PURE__ */ new Set([
|
|
6115
|
-
"article",
|
|
6116
|
-
"aside",
|
|
6117
|
-
"nav",
|
|
6118
|
-
"section"
|
|
6119
|
-
]);
|
|
6120
|
-
/**
|
|
6121
|
-
* The landmark kind an element contributes ('main'|'banner'|'contentinfo'|'complementary'), or
|
|
6122
|
-
* undefined. The one home for the decision both providers apply — the source AST walk and the
|
|
6123
|
-
* rendered HTML walk each feed it their own tree's context. ARIA fallback role lists resolve to
|
|
6124
|
-
* the first token naming a concrete role (resolveRole).
|
|
6125
|
-
*/
|
|
6126
|
-
function resolveLandmark(input) {
|
|
6127
|
-
const { tag, roleTokens, named, insideSectioning, insideAsideDemoting } = input;
|
|
6128
|
-
if (roleTokens !== void 0) {
|
|
6129
|
-
const role = resolveRole(roleTokens);
|
|
6130
|
-
return role && LANDMARK_ROLES.has(role) ? role : void 0;
|
|
6131
|
-
}
|
|
6132
|
-
if (tag === "main") return "main";
|
|
6133
|
-
if (tag !== void 0 && ANCESTRY_DEPENDENT_TAGS.has(tag)) return insideSectioning ? void 0 : tag === "header" ? "banner" : "contentinfo";
|
|
6134
|
-
if (tag === "aside") return !insideAsideDemoting || named ? "complementary" : void 0;
|
|
6135
|
-
}
|
|
6136
|
-
/**
|
|
6137
|
-
* Attributes whose (whitespace-tokenized) values reference element ids: the ARIA id-reference and
|
|
6138
|
-
* id-reference-list properties, and HTML's own (`for`, `list`, `headers`, `form`, the popover and
|
|
6139
|
-
* command targets). `href="#…"` is handled separately — its value is a URL, not a token list.
|
|
6140
|
-
*/
|
|
6141
|
-
const IDREF_ATTRS = [
|
|
6142
|
-
"for",
|
|
6143
|
-
"list",
|
|
6144
|
-
"headers",
|
|
6145
|
-
"form",
|
|
6146
|
-
"popovertarget",
|
|
6147
|
-
"commandfor",
|
|
6148
|
-
"aria-labelledby",
|
|
6149
|
-
"aria-describedby",
|
|
6150
|
-
"aria-controls",
|
|
6151
|
-
"aria-activedescendant",
|
|
6152
|
-
"aria-owns",
|
|
6153
|
-
"aria-details",
|
|
6154
|
-
"aria-errormessage",
|
|
6155
|
-
"aria-flowto"
|
|
6156
|
-
];
|
|
6157
|
-
/**
|
|
6158
|
-
* Whether a decoded URL fragment is HTML's "top of the document" indicator: `#top` (ASCII
|
|
6159
|
-
* case-insensitive) scrolls to the top when no element has that id, so it is never a missing
|
|
6160
|
-
* reference. Compare AFTER percent-decoding — `#%74op` navigates identically to `#top`.
|
|
6161
|
-
*/
|
|
6162
|
-
function isTopFragment(id) {
|
|
6163
|
-
return id.toLowerCase() === "top";
|
|
6164
|
-
}
|
|
6165
|
-
/**
|
|
6166
|
-
* A fragment with its text directive removed. Everything from the first `:~:` on is user-agent
|
|
6167
|
-
* instructions for finding text and names no element, while anything before it is still an
|
|
6168
|
-
* ordinary element fragment — `#section:~:text=hi` targets `id="section"`, `#:~:text=hi` targets
|
|
6169
|
-
* nothing. Returns an empty string when the fragment is a directive and nothing else.
|
|
6170
|
-
*/
|
|
6171
|
-
function stripTextDirective(fragment) {
|
|
6172
|
-
const i = fragment.indexOf(":~:");
|
|
6173
|
-
return i === -1 ? fragment : fragment.slice(0, i);
|
|
6174
|
-
}
|
|
6175
|
-
//#endregion
|
|
6176
6499
|
//#region src/rules/a11y/invalid-role.ts
|
|
6177
6500
|
const a11yInvalidRole = componentRule({
|
|
6178
6501
|
id: "a11y/invalid-role",
|
|
@@ -6872,7 +7195,7 @@ const ELEMENT_FACT_OVERRIDES = {
|
|
|
6872
7195
|
};
|
|
6873
7196
|
/**
|
|
6874
7197
|
* The role(s) an `AriaElementFact` may have. Explicit first: a literal role's first concrete token
|
|
6875
|
-
* (`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
|
|
6876
7199
|
* and yields `undefined`. Otherwise the element's implicit role — never one role but the set of
|
|
6877
7200
|
* candidates the dataset's conditional outcomes leave open, since the selectors that decide between
|
|
6878
7201
|
* them are not evaluated (design 2026-08-19-aria-role-table-rules, "Which role an element has").
|
|
@@ -6887,7 +7210,7 @@ function roleCandidates(e) {
|
|
|
6887
7210
|
namingProhibited: false
|
|
6888
7211
|
} : void 0;
|
|
6889
7212
|
}
|
|
6890
|
-
if (e.hasSpread) return void 0;
|
|
7213
|
+
if (e.hasSpread || e.hasAction) return void 0;
|
|
6891
7214
|
const el = Object.hasOwn(HTML_SPEC.elements, e.tag) ? HTML_SPEC.elements[e.tag] : void 0;
|
|
6892
7215
|
if (!el) return void 0;
|
|
6893
7216
|
const aria = (Object.hasOwn(ELEMENT_FACT_OVERRIDES, e.tag) ? ELEMENT_FACT_OVERRIDES[e.tag] : void 0) ?? el.aria;
|
|
@@ -7454,7 +7777,12 @@ function surplusRule(spec) {
|
|
|
7454
7777
|
first ??= reps[0];
|
|
7455
7778
|
for (let i = 1; i < reps.length; i++) {
|
|
7456
7779
|
surplus = true;
|
|
7457
|
-
|
|
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);
|
|
7458
7786
|
}
|
|
7459
7787
|
}
|
|
7460
7788
|
if (first && !surplus) out.push(result(route.route, PASS, {
|
|
@@ -7632,6 +7960,7 @@ const a11yIdDuplication = surplusRule({
|
|
|
7632
7960
|
recommendation: "Every id in a route should be unique.",
|
|
7633
7961
|
map: (route) => Object.entries(route.ids).sort(([, a], [, b]) => a[0].file.localeCompare(b[0].file) || a[0].line - b[0].line),
|
|
7634
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}.",
|
|
7635
7964
|
passMessage: "No duplicate ids"
|
|
7636
7965
|
});
|
|
7637
7966
|
//#endregion
|
|
@@ -8299,4 +8628,4 @@ function formatMarkdownReport(results, config, meta) {
|
|
|
8299
8628
|
return lines.join("\n");
|
|
8300
8629
|
}
|
|
8301
8630
|
//#endregion
|
|
8302
|
-
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 };
|