@svelte-vitals/core 0.55.1 → 0.55.3
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 -2
- package/dist/internal.d.ts +559 -21
- package/dist/internal.js +994 -117
- package/dist/{json-TIGneC1p.d.ts → json-DmLgFEwV.d.ts} +235 -99
- package/dist/{markdown-BOH3FPQl.js → markdown-CsQNXeWj.js} +1421 -355
- package/package.json +4 -4
|
@@ -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,
|
|
@@ -152,7 +152,182 @@ const seoTitlePresence = {
|
|
|
152
152
|
}
|
|
153
153
|
};
|
|
154
154
|
//#endregion
|
|
155
|
+
//#region src/kit-module.ts
|
|
156
|
+
/** The root layout — its page options cover the whole app. */
|
|
157
|
+
const ROOT_LAYOUT_RE = /^src\/routes\/\+layout(\.server)?\.(ts|js)$/;
|
|
158
|
+
/** Files whose `ssr` export is a page option — it has no effect in `+server` endpoints or hooks files. */
|
|
159
|
+
const PAGE_OPTION_FILE_RE = /\+(page|layout)(\.server)?\.(ts|js)$/;
|
|
160
|
+
const appSsrCache = /* @__PURE__ */ new WeakMap();
|
|
161
|
+
/**
|
|
162
|
+
* Whether no page is ever server-rendered: the hash router, or a root layout exporting `ssr = false`
|
|
163
|
+
* that no page-option file might turn back on. Universal loads and components then run only in the
|
|
164
|
+
* browser; server files (`+page.server`, `+server`, hooks) still run on the server.
|
|
165
|
+
*/
|
|
166
|
+
function appSsrDisabled(scope) {
|
|
167
|
+
if (scope.project?.kitHashRouter) return true;
|
|
168
|
+
const kitModules = scope.kitModules;
|
|
169
|
+
if (!kitModules) return false;
|
|
170
|
+
let hit = appSsrCache.get(kitModules);
|
|
171
|
+
if (hit === void 0) {
|
|
172
|
+
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));
|
|
173
|
+
appSsrCache.set(kitModules, hit);
|
|
174
|
+
}
|
|
175
|
+
return hit;
|
|
176
|
+
}
|
|
177
|
+
const ROUTES_DIR$1 = "src/routes";
|
|
178
|
+
/** A `+page`/`+layout` file: its directory, kind, and the `@segment` layout reset (on `.svelte` names). */
|
|
179
|
+
const ROUTE_FILE_RE = /^(src\/routes(?:\/.+)?)\/\+(page|layout)(?:@([^/]*))?(?:\.server)?\.(?:svelte|ts|js)$/;
|
|
180
|
+
const parentDir = (dir) => dir === ROUTES_DIR$1 ? void 0 : dir.slice(0, dir.lastIndexOf("/"));
|
|
181
|
+
/** The nearest ancestor-or-self directory a `@segment` reset names (`''` is the root). */
|
|
182
|
+
function resetTarget(dir, segment) {
|
|
183
|
+
for (let d = dir; d !== void 0; d = parentDir(d)) if (segment === "" ? d === ROUTES_DIR$1 : d.slice(d.lastIndexOf("/") + 1) === segment) return d;
|
|
184
|
+
}
|
|
185
|
+
const routeOptionCache = {
|
|
186
|
+
ssr: /* @__PURE__ */ new WeakMap(),
|
|
187
|
+
csr: /* @__PURE__ */ new WeakMap()
|
|
188
|
+
};
|
|
189
|
+
const optionOn = (m, option) => m.parseFailed || (option === "ssr" ? m.ssrEnabled : m.csrEnabled);
|
|
190
|
+
const optionOff = (m, option) => (option === "ssr" ? m.ssrDisabled : m.csrDisabled) !== void 0;
|
|
191
|
+
/**
|
|
192
|
+
* Per route directory, whether `ssr` or `csr` is off for its page (`page:<dir>`) or layout
|
|
193
|
+
* (`layout:<dir>`). A page takes the nearest export of the option along its own files and then its
|
|
194
|
+
* layout chain, following `+page@x`/`+layout@x` resets as SvelteKit does; an export other than
|
|
195
|
+
* `false` (or `dev`), or an unparsed option file, counts as on. A layout is off only when every page
|
|
196
|
+
* using it is — and the root layout also needs its own opt-out, since it wraps the 404 page.
|
|
197
|
+
*/
|
|
198
|
+
function routeOptionOff(scope, which) {
|
|
199
|
+
const kitModules = scope.kitModules ?? [];
|
|
200
|
+
let hit = routeOptionCache[which].get(scope);
|
|
201
|
+
if (hit) return hit;
|
|
202
|
+
const own = {
|
|
203
|
+
universal: /* @__PURE__ */ new Map(),
|
|
204
|
+
server: /* @__PURE__ */ new Map()
|
|
205
|
+
};
|
|
206
|
+
for (const m of kitModules) {
|
|
207
|
+
const match = ROUTE_FILE_RE.exec(m.file);
|
|
208
|
+
if (!match) continue;
|
|
209
|
+
const key = `${match[2]}:${match[1]}`;
|
|
210
|
+
if (optionOn(m, which)) own[m.kind].set(key, "on");
|
|
211
|
+
else if (optionOff(m, which)) own[m.kind].set(key, "off");
|
|
212
|
+
}
|
|
213
|
+
const option = new Map([...own.server, ...own.universal]);
|
|
214
|
+
hit = /* @__PURE__ */ new Map();
|
|
215
|
+
for (const [dir, chain] of pageChains(scope)) {
|
|
216
|
+
const off = [`page:${dir}`, ...chain.map((l) => `layout:${l}`)].map((k) => option.get(k)).find(Boolean) === "off";
|
|
217
|
+
hit.set(`page:${dir}`, off);
|
|
218
|
+
for (const l of chain) hit.set(`layout:${l}`, (hit.get(`layout:${l}`) ?? true) && off);
|
|
219
|
+
}
|
|
220
|
+
if (option.get(`layout:${ROUTES_DIR$1}`) !== "off") hit.delete(`layout:${ROUTES_DIR$1}`);
|
|
221
|
+
routeOptionCache[which].set(scope, hit);
|
|
222
|
+
return hit;
|
|
223
|
+
}
|
|
224
|
+
const chainCache = /* @__PURE__ */ new WeakMap();
|
|
225
|
+
/** Per page directory, the layout directories that wrap it, nearest first, following `+page@x`/`+layout@x` resets as SvelteKit does. */
|
|
226
|
+
function pageChains(scope) {
|
|
227
|
+
let hit = chainCache.get(scope);
|
|
228
|
+
if (hit) return hit;
|
|
229
|
+
const pages = /* @__PURE__ */ new Map();
|
|
230
|
+
const layouts = /* @__PURE__ */ new Map();
|
|
231
|
+
const files = [
|
|
232
|
+
...scope.sourceFiles ?? [],
|
|
233
|
+
...(scope.components ?? []).map((c) => c.file),
|
|
234
|
+
...(scope.kitModules ?? []).map((m) => m.file)
|
|
235
|
+
];
|
|
236
|
+
for (const file of files) {
|
|
237
|
+
const match = ROUTE_FILE_RE.exec(file);
|
|
238
|
+
if (!match) continue;
|
|
239
|
+
const map = match[2] === "page" ? pages : layouts;
|
|
240
|
+
if (match[3] !== void 0 || !map.has(match[1])) map.set(match[1], match[3]);
|
|
241
|
+
}
|
|
242
|
+
hit = /* @__PURE__ */ new Map();
|
|
243
|
+
for (const [dir, reset] of pages) {
|
|
244
|
+
const chain = [];
|
|
245
|
+
for (let d = reset === void 0 ? dir : resetTarget(dir, reset); d !== void 0;) {
|
|
246
|
+
const layoutReset = layouts.get(d);
|
|
247
|
+
if (layouts.has(d)) chain.push(d);
|
|
248
|
+
d = layoutReset === void 0 ? parentDir(d) : resetTarget(parentDir(d), layoutReset);
|
|
249
|
+
}
|
|
250
|
+
hit.set(dir, chain);
|
|
251
|
+
}
|
|
252
|
+
chainCache.set(scope, hit);
|
|
253
|
+
return hit;
|
|
254
|
+
}
|
|
255
|
+
/** Whether a `+page` file's route sends requests without a session away: its own load or a wrapping layout's does (`loadGated`). */
|
|
256
|
+
function routeGated(file, scope) {
|
|
257
|
+
const match = ROUTE_FILE_RE.exec(file);
|
|
258
|
+
if (!match || match[2] !== "page") return false;
|
|
259
|
+
const gated = /* @__PURE__ */ new Set();
|
|
260
|
+
for (const m of scope.kitModules ?? []) {
|
|
261
|
+
const g = m.loadGated && m.kind === "server" ? ROUTE_FILE_RE.exec(m.file) : null;
|
|
262
|
+
if (g) gated.add(`${g[2]}:${g[1]}`);
|
|
263
|
+
}
|
|
264
|
+
if (gated.size === 0) return false;
|
|
265
|
+
const chain = pageChains(scope).get(match[1]) ?? [];
|
|
266
|
+
return gated.has(`page:${match[1]}`) || chain.some((l) => gated.has(`layout:${l}`));
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Whether a `+page` file's route never renders because a layout above it redirects or errors in its
|
|
270
|
+
* `load` on every call (`loadNeverRenders`). Not the root layout: one that sent every request away
|
|
271
|
+
* would leave the app nothing to serve, so that reading is more likely wrong than the app, and acting
|
|
272
|
+
* on it would silence every route.
|
|
273
|
+
*/
|
|
274
|
+
function layoutNeverRenders(file, scope) {
|
|
275
|
+
const match = ROUTE_FILE_RE.exec(file);
|
|
276
|
+
if (!match || match[2] !== "page") return false;
|
|
277
|
+
const dead = /* @__PURE__ */ new Set();
|
|
278
|
+
for (const m of scope.kitModules ?? []) {
|
|
279
|
+
const layout = m.loadNeverRenders ? ROUTE_FILE_RE.exec(m.file) : null;
|
|
280
|
+
if (layout?.[2] === "layout" && layout[1] !== ROUTES_DIR$1) dead.add(layout[1]);
|
|
281
|
+
}
|
|
282
|
+
return dead.size > 0 && (pageChains(scope).get(match[1]) ?? []).some((dir) => dead.has(dir));
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Whether a `+page` file's route never renders because its own `load` redirects or errors on every
|
|
286
|
+
* request, a build-time `if (building)` aside (`loadNeverRendersOutsideBuild`), and nothing on its chain
|
|
287
|
+
* exports `prerender`, so the build never renders it either.
|
|
288
|
+
*/
|
|
289
|
+
function pageRendersOnlyWhenBuilding(file, scope) {
|
|
290
|
+
const match = ROUTE_FILE_RE.exec(file);
|
|
291
|
+
if (!match || match[2] !== "page") return false;
|
|
292
|
+
const at = (m) => ROUTE_FILE_RE.exec(m.file);
|
|
293
|
+
const modules = scope.kitModules ?? [];
|
|
294
|
+
if (!modules.some((m) => m.loadNeverRendersOutsideBuild && at(m)?.[2] === "page" && at(m)?.[1] === match[1])) return false;
|
|
295
|
+
const layouts = /* @__PURE__ */ new Set([match[1], ...pageChains(scope).get(match[1]) ?? []]);
|
|
296
|
+
return !modules.some((m) => {
|
|
297
|
+
const where = m.prerenderExported ? at(m) : null;
|
|
298
|
+
return !!where && (where[2] === "page" ? where[1] === match[1] : layouts.has(where[1]));
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
/** Whether a `+page`/`+layout` file (module or component) belongs to a route that is never server-rendered. */
|
|
302
|
+
function routeNeverSsr(file, scope) {
|
|
303
|
+
const match = ROUTE_FILE_RE.exec(file);
|
|
304
|
+
return !!match && (appSsrDisabled(scope) || routeOptionOff(scope, "ssr").get(`${match[2]}:${match[1]}`) === true);
|
|
305
|
+
}
|
|
306
|
+
/** Whether a universal `+page`/`+layout` module never runs in the browser: every page it serves has `csr` off, its own or inherited. */
|
|
307
|
+
function universalNeverCsr(m, scope) {
|
|
308
|
+
if (m.kind !== "universal") return false;
|
|
309
|
+
const match = ROUTE_FILE_RE.exec(m.file);
|
|
310
|
+
return !!match && routeOptionOff(scope, "csr").get(`${match[2]}:${match[1]}`) === true;
|
|
311
|
+
}
|
|
312
|
+
/** Whether a universal `+page`/`+layout` module never runs on the server — its own `ssr = false`, an app-wide one, or one its route inherits. */
|
|
313
|
+
function universalNeverSsr(m, scope) {
|
|
314
|
+
return m.kind === "universal" && (m.ssrDisabled !== void 0 || appSsrDisabled(scope) || routeNeverSsr(m.file, scope));
|
|
315
|
+
}
|
|
316
|
+
//#endregion
|
|
155
317
|
//#region src/rules/seo/head-tag-rule.ts
|
|
318
|
+
/** Open Graph keys are read from `property=`; `name="og:*"` is the common slip. */
|
|
319
|
+
function ogMisspelled(key) {
|
|
320
|
+
return {
|
|
321
|
+
match: (t) => t.kind === "meta" && t.name === key && t.property === void 0,
|
|
322
|
+
label: `<meta name="${key}"> should be <meta property="${key}">`,
|
|
323
|
+
recommendation: `Open Graph reads ${key} from the property attribute: change name="${key}" to property="${key}".`,
|
|
324
|
+
fix: {
|
|
325
|
+
description: `Change name="${key}" to property="${key}" on the existing tag.`,
|
|
326
|
+
snippet: `<meta property="${key}" content="…" />`,
|
|
327
|
+
lang: "svelte"
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
}
|
|
156
331
|
function detect(head, match) {
|
|
157
332
|
const matches = head.tags.filter(match);
|
|
158
333
|
const rank = (t) => (t.value !== "absent" ? 2 : 0) + (t.presence === "own" ? 1 : 0);
|
|
@@ -166,6 +341,7 @@ function detect(head, match) {
|
|
|
166
341
|
value: "absent"
|
|
167
342
|
};
|
|
168
343
|
}
|
|
344
|
+
const GATED_NOTE = "Reported as info: this route's load turns away visitors without a session (a redirect or a 401/403), so search engines never index it.";
|
|
169
345
|
/** Build a route-scope rule asserting the presence of a single head tag (design §11). */
|
|
170
346
|
function headTagRule(opts) {
|
|
171
347
|
const docsUrl = docsUrlFor(opts.id);
|
|
@@ -180,18 +356,20 @@ function headTagRule(opts) {
|
|
|
180
356
|
async check(ctx) {
|
|
181
357
|
return (opts.appliesTo ? ctx.heads.filter(opts.appliesTo) : ctx.heads).map((head) => {
|
|
182
358
|
const detection = detect(head, opts.match);
|
|
183
|
-
const
|
|
359
|
+
const misspelled = detection.presence === "none" && opts.misspelled && head.tags.some(opts.misspelled.match) ? opts.misspelled : void 0;
|
|
360
|
+
const message = misspelled ? misspelled.label : detection.presence === "none" ? `Missing ${opts.label}` : detection.value === "absent" ? `Empty ${opts.label}` : opts.label;
|
|
361
|
+
const gated = opts.infoWhenGated && routeGated(head.file, ctx);
|
|
184
362
|
return {
|
|
185
363
|
id: opts.id,
|
|
186
364
|
category: "seo",
|
|
187
|
-
severity: opts.severity,
|
|
365
|
+
severity: gated ? "info" : opts.severity,
|
|
188
366
|
detection,
|
|
189
367
|
route: head.route,
|
|
190
368
|
location: head.file,
|
|
191
369
|
message,
|
|
192
|
-
recommendation: opts.recommendation,
|
|
370
|
+
recommendation: misspelled?.recommendation ?? (gated ? `${opts.recommendation} ${GATED_NOTE}` : opts.recommendation),
|
|
193
371
|
docsUrl,
|
|
194
|
-
...opts.fix ? { fix: { ...opts.fix } } : {}
|
|
372
|
+
...misspelled ? { fix: { ...misspelled.fix } } : opts.fix ? { fix: { ...opts.fix } } : {}
|
|
195
373
|
};
|
|
196
374
|
});
|
|
197
375
|
}
|
|
@@ -203,6 +381,7 @@ const seoDescriptionPresence = headTagRule({
|
|
|
203
381
|
id: "seo/description-presence",
|
|
204
382
|
title: "Description presence",
|
|
205
383
|
severity: "warning",
|
|
384
|
+
infoWhenGated: true,
|
|
206
385
|
match: (t) => t.kind === "meta" && t.name === "description",
|
|
207
386
|
label: "<meta name=\"description\">",
|
|
208
387
|
recommendation: "Add a <meta name=\"description\"> in <svelte:head>, or set the description on your meta component.",
|
|
@@ -214,12 +393,50 @@ const seoDescriptionPresence = headTagRule({
|
|
|
214
393
|
}
|
|
215
394
|
});
|
|
216
395
|
//#endregion
|
|
396
|
+
//#region src/head.ts
|
|
397
|
+
/** Whether a `<link>`'s `rel` holds `token`: `rel` is a set of space-separated keywords (`dns-prefetch preconnect`). */
|
|
398
|
+
function hasRel(tag, token) {
|
|
399
|
+
return tag.rel?.split(/\s+/).includes(token) ?? false;
|
|
400
|
+
}
|
|
401
|
+
const JS_MIME_TYPES = /* @__PURE__ */ new Set([
|
|
402
|
+
"application/ecmascript",
|
|
403
|
+
"application/javascript",
|
|
404
|
+
"application/x-ecmascript",
|
|
405
|
+
"application/x-javascript",
|
|
406
|
+
"text/ecmascript",
|
|
407
|
+
"text/javascript",
|
|
408
|
+
"text/javascript1.0",
|
|
409
|
+
"text/javascript1.1",
|
|
410
|
+
"text/javascript1.2",
|
|
411
|
+
"text/javascript1.3",
|
|
412
|
+
"text/javascript1.4",
|
|
413
|
+
"text/javascript1.5",
|
|
414
|
+
"text/jscript",
|
|
415
|
+
"text/livescript",
|
|
416
|
+
"text/x-ecmascript",
|
|
417
|
+
"text/x-javascript"
|
|
418
|
+
]);
|
|
419
|
+
/** HTML strips only ASCII whitespace from a `type` before matching — `trim()` would also strip
|
|
420
|
+
* U+00A0 etc. and misread a data block as a classic script. */
|
|
421
|
+
const ASCII_WHITESPACE_EDGES = /^[\t\n\f\r ]+|[\t\n\f\r ]+$/g;
|
|
422
|
+
/**
|
|
423
|
+
* Whether a `type` attribute (undefined = absent) makes a `<script>` a classic,
|
|
424
|
+
* render-blocking-capable script. Per the HTML spec, absent and the literal empty string are
|
|
425
|
+
* classic; any other value is ASCII-whitespace-stripped and must match a JavaScript MIME type —
|
|
426
|
+
* so a whitespace-only `type` is a data block, not a classic script.
|
|
427
|
+
*/
|
|
428
|
+
function isClassicScriptType(type) {
|
|
429
|
+
if (type === void 0 || type === "") return true;
|
|
430
|
+
return JS_MIME_TYPES.has(type.replace(ASCII_WHITESPACE_EDGES, "").toLowerCase());
|
|
431
|
+
}
|
|
432
|
+
//#endregion
|
|
217
433
|
//#region src/rules/seo/canonical-url.ts
|
|
218
434
|
const seoCanonicalUrl = headTagRule({
|
|
219
435
|
id: "seo/canonical-url",
|
|
220
436
|
title: "Canonical URL",
|
|
221
437
|
severity: "warning",
|
|
222
|
-
|
|
438
|
+
infoWhenGated: true,
|
|
439
|
+
match: (t) => t.kind === "link" && hasRel(t, "canonical"),
|
|
223
440
|
label: "<link rel=\"canonical\">",
|
|
224
441
|
recommendation: "Add <link rel=\"canonical\"> in <svelte:head>, or set the canonical prop on your meta component.",
|
|
225
442
|
rationale: "A canonical URL tells search engines which URL is authoritative, preventing duplicate-content dilution across query-string variants of the same page.",
|
|
@@ -236,6 +453,7 @@ const seoOgImage = headTagRule({
|
|
|
236
453
|
title: "Open Graph image",
|
|
237
454
|
severity: "warning",
|
|
238
455
|
match: (t) => t.kind === "meta" && t.property === "og:image",
|
|
456
|
+
misspelled: ogMisspelled("og:image"),
|
|
239
457
|
label: "<meta property=\"og:image\">",
|
|
240
458
|
recommendation: "Add <meta property=\"og:image\">, or set openGraph.images on your meta component.",
|
|
241
459
|
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 +470,7 @@ const seoOgTitle = headTagRule({
|
|
|
252
470
|
title: "Open Graph title",
|
|
253
471
|
severity: "warning",
|
|
254
472
|
match: (t) => t.kind === "meta" && t.property === "og:title",
|
|
473
|
+
misspelled: ogMisspelled("og:title"),
|
|
255
474
|
label: "<meta property=\"og:title\">",
|
|
256
475
|
recommendation: "Add <meta property=\"og:title\">, or set openGraph.title on your meta component.",
|
|
257
476
|
rationale: "og:title controls the headline shown when the page is shared on social platforms, independent of the document <title>.",
|
|
@@ -510,7 +729,7 @@ const performanceResponsiveImage = imageRule({
|
|
|
510
729
|
snippet: "<img src=\"/hero.jpg\" srcset=\"/hero-800.jpg 800w, /hero-1600.jpg 1600w\" sizes=\"100vw\" width=\"1600\" height=\"900\" alt=\"…\" />",
|
|
511
730
|
lang: "svelte"
|
|
512
731
|
},
|
|
513
|
-
ok: (img) => img.hasSrcset
|
|
732
|
+
ok: (img) => img.hasSrcset || img.svg === true
|
|
514
733
|
});
|
|
515
734
|
//#endregion
|
|
516
735
|
//#region src/rules/perf/link-rule.ts
|
|
@@ -541,7 +760,7 @@ const performancePreloadMissingAs = linkRule({
|
|
|
541
760
|
snippet: "<link rel=\"preload\" href=\"/app.css\" as=\"style\" />",
|
|
542
761
|
lang: "html"
|
|
543
762
|
},
|
|
544
|
-
relevant: (t) => t
|
|
763
|
+
relevant: (t) => hasRel(t, "preload"),
|
|
545
764
|
ok: (t) => t.hasAs === true
|
|
546
765
|
});
|
|
547
766
|
//#endregion
|
|
@@ -558,7 +777,7 @@ const performanceFontPreloadCrossorigin = linkRule({
|
|
|
558
777
|
snippet: "<link rel=\"preload\" href=\"/inter.woff2\" as=\"font\" type=\"font/woff2\" crossorigin />",
|
|
559
778
|
lang: "html"
|
|
560
779
|
},
|
|
561
|
-
relevant: (t) => t
|
|
780
|
+
relevant: (t) => hasRel(t, "preload") && t.as === "font",
|
|
562
781
|
ok: (t) => t.hasCrossorigin === true
|
|
563
782
|
});
|
|
564
783
|
//#endregion
|
|
@@ -647,7 +866,8 @@ const performanceRenderBlockingScript = {
|
|
|
647
866
|
async check(ctx) {
|
|
648
867
|
const out = [];
|
|
649
868
|
for (const head of ctx.heads) {
|
|
650
|
-
const
|
|
869
|
+
const clientHead = routeNeverSsr(head.file, ctx);
|
|
870
|
+
const scripts = head.tags.filter((t) => t.kind === "script" && !(clientHead && t.file !== "src/app.html"));
|
|
651
871
|
if (scripts.length === 0) continue;
|
|
652
872
|
const blocking = scripts.filter((t) => t.blocking);
|
|
653
873
|
if (blocking.length > 0) for (const tag of blocking) out.push({
|
|
@@ -1159,7 +1379,7 @@ const performancePreconnect = {
|
|
|
1159
1379
|
if (tag.kind !== "link" && tag.kind !== "script" || typeof tag.href !== "string") continue;
|
|
1160
1380
|
const host = hostOf(tag.href);
|
|
1161
1381
|
if (!host) continue;
|
|
1162
|
-
if (tag.kind === "link" && (tag
|
|
1382
|
+
if (tag.kind === "link" && (hasRel(tag, "preconnect") || hasRel(tag, "dns-prefetch"))) {
|
|
1163
1383
|
covered.add(host);
|
|
1164
1384
|
continue;
|
|
1165
1385
|
}
|
|
@@ -1252,7 +1472,7 @@ const seoTwitterCard = headTagRule({
|
|
|
1252
1472
|
id: "seo/twitter-card",
|
|
1253
1473
|
title: "Twitter Card",
|
|
1254
1474
|
severity: "info",
|
|
1255
|
-
match: (t) => t.kind === "meta" && t.name === "twitter:card",
|
|
1475
|
+
match: (t) => t.kind === "meta" && (t.name === "twitter:card" || t.property === "twitter:card"),
|
|
1256
1476
|
label: "<meta name=\"twitter:card\">",
|
|
1257
1477
|
recommendation: "Add <meta name=\"twitter:card\" content=\"summary_large_image\"> so X/Twitter renders a rich card.",
|
|
1258
1478
|
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 +1489,7 @@ const seoOgDescription = headTagRule({
|
|
|
1269
1489
|
title: "Open Graph description",
|
|
1270
1490
|
severity: "info",
|
|
1271
1491
|
match: (t) => t.kind === "meta" && t.property === "og:description",
|
|
1492
|
+
misspelled: ogMisspelled("og:description"),
|
|
1272
1493
|
label: "<meta property=\"og:description\">",
|
|
1273
1494
|
recommendation: "Add <meta property=\"og:description\">, or set openGraph.description on your meta component.",
|
|
1274
1495
|
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 +1506,7 @@ const seoOgUrl = headTagRule({
|
|
|
1285
1506
|
title: "Open Graph URL",
|
|
1286
1507
|
severity: "warning",
|
|
1287
1508
|
match: (t) => t.kind === "meta" && t.property === "og:url",
|
|
1509
|
+
misspelled: ogMisspelled("og:url"),
|
|
1288
1510
|
label: "<meta property=\"og:url\">",
|
|
1289
1511
|
recommendation: "Add <meta property=\"og:url\"> with the canonical URL, or set openGraph.url on your meta component.",
|
|
1290
1512
|
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.",
|
|
@@ -2042,7 +2264,7 @@ const seoHreflang = {
|
|
|
2042
2264
|
async check(ctx) {
|
|
2043
2265
|
const out = [];
|
|
2044
2266
|
for (const head of ctx.heads) {
|
|
2045
|
-
const alternates = head.tags.filter((t) => t.kind === "link" && t
|
|
2267
|
+
const alternates = head.tags.filter((t) => t.kind === "link" && hasRel(t, "alternate") && typeof t.hreflang === "string");
|
|
2046
2268
|
if (alternates.length === 0) continue;
|
|
2047
2269
|
const values = alternates.map((t) => t.hreflang);
|
|
2048
2270
|
const badTag = alternates.find((t) => !isValidHreflang(t.hreflang));
|
|
@@ -2084,6 +2306,223 @@ const seoHreflang = {
|
|
|
2084
2306
|
}
|
|
2085
2307
|
};
|
|
2086
2308
|
//#endregion
|
|
2309
|
+
//#region src/rules/a11y/aria-data.ts
|
|
2310
|
+
/**
|
|
2311
|
+
* ARIA names `aria-query@5.3.2` does not carry. It is not the clean ARIA 1.2 snapshot it looks
|
|
2312
|
+
* like — it holds 1.2's 48 attributes plus three 1.3 additions — so the gap is patched by name
|
|
2313
|
+
* rather than by version. Every entry here is defined in the ARIA 1.3 editor's draft, which is
|
|
2314
|
+
* what makes reporting it as an unknown name wrong — how far implementations have got is a
|
|
2315
|
+
* separate question this rule does not ask. Re-check this list whenever the aria-query dependency
|
|
2316
|
+
* moves.
|
|
2317
|
+
*/
|
|
2318
|
+
const ARIA_1_3_ROLES = /* @__PURE__ */ new Set([
|
|
2319
|
+
"comment",
|
|
2320
|
+
"image",
|
|
2321
|
+
"sectionheader",
|
|
2322
|
+
"sectionfooter",
|
|
2323
|
+
"suggestion"
|
|
2324
|
+
]);
|
|
2325
|
+
const ARIA_1_3_ATTRIBUTES = /* @__PURE__ */ new Set(["aria-colindextext", "aria-rowindextext"]);
|
|
2326
|
+
/**
|
|
2327
|
+
* Roles whose `requiredProps` row in aria-query is stale. Neither ARIA 1.2 nor the 1.3 editor's
|
|
2328
|
+
* draft lists required states or properties for these — the 1.3 draft re-lists inherited
|
|
2329
|
+
* requirements where they apply (`menuitemradio` → `aria-checked`), so the absence is deliberate
|
|
2330
|
+
* rather than an inheritance the table left implicit. Without this, idiomatic APG listbox and
|
|
2331
|
+
* tree markup is flagged.
|
|
2332
|
+
*/
|
|
2333
|
+
const NO_REQUIRED_PROPS = /* @__PURE__ */ new Set(["option", "treeitem"]);
|
|
2334
|
+
function isKnownRole(role) {
|
|
2335
|
+
return ARIA_1_3_ROLES.has(role) || roles.has(role);
|
|
2336
|
+
}
|
|
2337
|
+
function isAbstractRole(role) {
|
|
2338
|
+
return roles.get(role)?.abstract === true;
|
|
2339
|
+
}
|
|
2340
|
+
/** A concrete (non-abstract) role is the one a user agent resolves a role token list to. */
|
|
2341
|
+
function isConcreteRole(role) {
|
|
2342
|
+
return isKnownRole(role) && !isAbstractRole(role);
|
|
2343
|
+
}
|
|
2344
|
+
/**
|
|
2345
|
+
* The role a user agent applies: the first token naming a concrete role, or undefined when none
|
|
2346
|
+
* does. Shared so the role rules cannot disagree about what a fallback list means.
|
|
2347
|
+
*/
|
|
2348
|
+
function resolveRole(tokens) {
|
|
2349
|
+
return tokens.find(isConcreteRole);
|
|
2350
|
+
}
|
|
2351
|
+
function isKnownAriaAttribute(name) {
|
|
2352
|
+
return ARIA_1_3_ATTRIBUTES.has(name) || aria.has(name);
|
|
2353
|
+
}
|
|
2354
|
+
function requiredAriaProps(role) {
|
|
2355
|
+
if (NO_REQUIRED_PROPS.has(role)) return [];
|
|
2356
|
+
const def = roles.get(role);
|
|
2357
|
+
return def ? Object.keys(def.requiredProps) : [];
|
|
2358
|
+
}
|
|
2359
|
+
function ariaValueKind(name) {
|
|
2360
|
+
const def = aria.get(name);
|
|
2361
|
+
if (!def) return void 0;
|
|
2362
|
+
return {
|
|
2363
|
+
type: def.type,
|
|
2364
|
+
...def.values ? { values: def.values.map(String) } : {}
|
|
2365
|
+
};
|
|
2366
|
+
}
|
|
2367
|
+
//#endregion
|
|
2368
|
+
//#region src/a11y.ts
|
|
2369
|
+
/**
|
|
2370
|
+
* Branch-aware occurrence fold (design "Control-flow semantics"): within a branch
|
|
2371
|
+
* occurrences sum, across the arms of one exclusive block the arm with the most
|
|
2372
|
+
* occurrences wins (tie → lowest branch index) and ITS occurrences are the group's
|
|
2373
|
+
* representatives — so a caller's count is always `list.length`, with a location per
|
|
2374
|
+
* representative. `{#each}`/`{#snippet}` occurrences render 0..N times and drop out.
|
|
2375
|
+
* The max is per key: there is no scalar total to maximize.
|
|
2376
|
+
*/
|
|
2377
|
+
function foldOccurrences(nodes) {
|
|
2378
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
2379
|
+
for (const node of nodes) {
|
|
2380
|
+
if (node.repeatable) continue;
|
|
2381
|
+
const list = byKey.get(node.key);
|
|
2382
|
+
if (list) list.push(node);
|
|
2383
|
+
else byKey.set(node.key, [node]);
|
|
2384
|
+
}
|
|
2385
|
+
const folded = /* @__PURE__ */ new Map();
|
|
2386
|
+
for (const [key, list] of byKey) folded.set(key, foldAt(list, 0));
|
|
2387
|
+
return folded;
|
|
2388
|
+
}
|
|
2389
|
+
function foldAt(nodes, depth) {
|
|
2390
|
+
const unconditional = [];
|
|
2391
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2392
|
+
for (const node of nodes) {
|
|
2393
|
+
const step = node.path[depth];
|
|
2394
|
+
if (!step) {
|
|
2395
|
+
unconditional.push(node);
|
|
2396
|
+
continue;
|
|
2397
|
+
}
|
|
2398
|
+
let branches = groups.get(step.group);
|
|
2399
|
+
if (!branches) groups.set(step.group, branches = /* @__PURE__ */ new Map());
|
|
2400
|
+
const list = branches.get(step.branch);
|
|
2401
|
+
if (list) list.push(node);
|
|
2402
|
+
else branches.set(step.branch, [node]);
|
|
2403
|
+
}
|
|
2404
|
+
const representatives = [...unconditional];
|
|
2405
|
+
for (const branches of groups.values()) {
|
|
2406
|
+
let best = [];
|
|
2407
|
+
let bestBranch = Number.POSITIVE_INFINITY;
|
|
2408
|
+
for (const [branch, list] of branches) {
|
|
2409
|
+
const arm = foldAt(list, depth + 1);
|
|
2410
|
+
if (arm.length > best.length || arm.length === best.length && branch < bestBranch) {
|
|
2411
|
+
best = arm;
|
|
2412
|
+
bestBranch = branch;
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
representatives.push(...best);
|
|
2416
|
+
}
|
|
2417
|
+
return representatives;
|
|
2418
|
+
}
|
|
2419
|
+
/**
|
|
2420
|
+
* Decode a fragment identifier the way navigation does before matching an element id
|
|
2421
|
+
* (`href="#caf%C3%A9"` targets `id="café"`). Malformed escapes are kept verbatim —
|
|
2422
|
+
* the browser would also fail to decode them, so the raw text is the comparable form.
|
|
2423
|
+
*/
|
|
2424
|
+
function decodeFragmentId(fragment) {
|
|
2425
|
+
try {
|
|
2426
|
+
return decodeURIComponent(fragment);
|
|
2427
|
+
} catch {
|
|
2428
|
+
return fragment;
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
/** Whitespace-split tokens of a (possibly undefined) literal attribute value. */
|
|
2432
|
+
function splitTokens(value) {
|
|
2433
|
+
return value ? value.trim().split(/\s+/).filter(Boolean) : [];
|
|
2434
|
+
}
|
|
2435
|
+
/** Explicit `role` values that map to the landmark kinds the route rules inspect. */
|
|
2436
|
+
const LANDMARK_ROLES = /* @__PURE__ */ new Set([
|
|
2437
|
+
"main",
|
|
2438
|
+
"banner",
|
|
2439
|
+
"contentinfo",
|
|
2440
|
+
"complementary"
|
|
2441
|
+
]);
|
|
2442
|
+
/** Sectioning content (HTML-AAM): a `<header>`/`<footer>` below one of these is not a landmark. */
|
|
2443
|
+
const SECTIONING_TAGS = /* @__PURE__ */ new Set([
|
|
2444
|
+
"article",
|
|
2445
|
+
"aside",
|
|
2446
|
+
"main",
|
|
2447
|
+
"nav",
|
|
2448
|
+
"section"
|
|
2449
|
+
]);
|
|
2450
|
+
/**
|
|
2451
|
+
* Tags whose landmark-ness depends on sectioning ancestry. A provider that cannot see ancestry
|
|
2452
|
+
* (the per-file AST walk) uses this same set to mark the deferral (topLevel) — reading it from
|
|
2453
|
+
* here keeps the deferral set from drifting when the policy widens.
|
|
2454
|
+
*/
|
|
2455
|
+
const ANCESTRY_DEPENDENT_TAGS = /* @__PURE__ */ new Set(["header", "footer"]);
|
|
2456
|
+
/** Attributes that give an element the accessible name the `<aside>` landmark decision reads. */
|
|
2457
|
+
const NAMING_ATTRS$1 = ["aria-label", "aria-labelledby"];
|
|
2458
|
+
/**
|
|
2459
|
+
* HTML-AAM: an `<aside>` scoped to `body` or `main` is a `complementary` landmark; scoped to
|
|
2460
|
+
* sectioning content it is one only when it has an accessible name. `main` is deliberately absent
|
|
2461
|
+
* — it is a scope in which an `aside` *is* a landmark, unlike the sectioning set that decides
|
|
2462
|
+
* `<header>`/`<footer>`.
|
|
2463
|
+
*/
|
|
2464
|
+
const ASIDE_DEMOTING_TAGS = /* @__PURE__ */ new Set([
|
|
2465
|
+
"article",
|
|
2466
|
+
"aside",
|
|
2467
|
+
"nav",
|
|
2468
|
+
"section"
|
|
2469
|
+
]);
|
|
2470
|
+
/**
|
|
2471
|
+
* The landmark kind an element contributes ('main'|'banner'|'contentinfo'|'complementary'), or
|
|
2472
|
+
* undefined. The one home for the decision both providers apply — the source AST walk and the
|
|
2473
|
+
* rendered HTML walk each feed it their own tree's context. ARIA fallback role lists resolve to
|
|
2474
|
+
* the first token naming a concrete role (resolveRole).
|
|
2475
|
+
*/
|
|
2476
|
+
function resolveLandmark(input) {
|
|
2477
|
+
const { tag, roleTokens, named, insideSectioning, insideAsideDemoting } = input;
|
|
2478
|
+
if (roleTokens !== void 0) {
|
|
2479
|
+
const role = resolveRole(roleTokens);
|
|
2480
|
+
return role && LANDMARK_ROLES.has(role) ? role : void 0;
|
|
2481
|
+
}
|
|
2482
|
+
if (tag === "main") return "main";
|
|
2483
|
+
if (tag !== void 0 && ANCESTRY_DEPENDENT_TAGS.has(tag)) return insideSectioning ? void 0 : tag === "header" ? "banner" : "contentinfo";
|
|
2484
|
+
if (tag === "aside") return !insideAsideDemoting || named ? "complementary" : void 0;
|
|
2485
|
+
}
|
|
2486
|
+
/**
|
|
2487
|
+
* Attributes whose (whitespace-tokenized) values reference element ids: the ARIA id-reference and
|
|
2488
|
+
* id-reference-list properties, and HTML's own (`for`, `list`, `headers`, `form`, the popover and
|
|
2489
|
+
* command targets). `href="#…"` is handled separately — its value is a URL, not a token list.
|
|
2490
|
+
*/
|
|
2491
|
+
const IDREF_ATTRS = [
|
|
2492
|
+
"for",
|
|
2493
|
+
"list",
|
|
2494
|
+
"headers",
|
|
2495
|
+
"form",
|
|
2496
|
+
"popovertarget",
|
|
2497
|
+
"commandfor",
|
|
2498
|
+
"aria-labelledby",
|
|
2499
|
+
"aria-describedby",
|
|
2500
|
+
"aria-controls",
|
|
2501
|
+
"aria-activedescendant",
|
|
2502
|
+
"aria-owns",
|
|
2503
|
+
"aria-details",
|
|
2504
|
+
"aria-errormessage",
|
|
2505
|
+
"aria-flowto"
|
|
2506
|
+
];
|
|
2507
|
+
/**
|
|
2508
|
+
* Whether a decoded URL fragment is HTML's "top of the document" indicator: `#top` (ASCII
|
|
2509
|
+
* case-insensitive) scrolls to the top when no element has that id, so it is never a missing
|
|
2510
|
+
* reference. Compare AFTER percent-decoding — `#%74op` navigates identically to `#top`.
|
|
2511
|
+
*/
|
|
2512
|
+
function isTopFragment(id) {
|
|
2513
|
+
return id.toLowerCase() === "top";
|
|
2514
|
+
}
|
|
2515
|
+
/**
|
|
2516
|
+
* A fragment with its text directive removed. Everything from the first `:~:` on is user-agent
|
|
2517
|
+
* instructions for finding text and names no element, while anything before it is still an
|
|
2518
|
+
* ordinary element fragment — `#section:~:text=hi` targets `id="section"`, `#:~:text=hi` targets
|
|
2519
|
+
* nothing. Returns an empty string when the fragment is a directive and nothing else.
|
|
2520
|
+
*/
|
|
2521
|
+
function stripTextDirective(fragment) {
|
|
2522
|
+
const i = fragment.indexOf(":~:");
|
|
2523
|
+
return i === -1 ? fragment : fragment.slice(0, i);
|
|
2524
|
+
}
|
|
2525
|
+
//#endregion
|
|
2087
2526
|
//#region src/rules/seo/single-h1.ts
|
|
2088
2527
|
const docsUrl$6 = docsUrlFor("seo/single-h1");
|
|
2089
2528
|
const passRecommendation = "Use exactly one <h1> per page for its main topic; demote extra top-level headings to <h2>+.";
|
|
@@ -2098,10 +2537,24 @@ const multipleRecommendation = "A single, clear <h1> is the conventional signal
|
|
|
2098
2537
|
* signal, but no official source documents a ranking penalty for several (2026-08-09 v1
|
|
2099
2538
|
* rule-validity review, P2 #11) — so it's flagged as a style nit, not a defect. Exactly
|
|
2100
2539
|
* one passes. A route whose headings were not collected (channel unset) emits nothing, as does
|
|
2101
|
-
* one whose `dynamicHeading` flag says
|
|
2540
|
+
* one whose `dynamicHeading` flag says a heading of undeterminable level may be its <h1>. A
|
|
2102
2541
|
* global `rules: { 'seo/single-h1': <severity> }` override flattens both arms to one
|
|
2103
2542
|
* severity (design, `applyRuleSeverities`).
|
|
2104
2543
|
*/
|
|
2544
|
+
/**
|
|
2545
|
+
* The <h1>s that can render together, in input order: the arms of one `{#if}`/`{#await}`/
|
|
2546
|
+
* `<svelte:boundary>` contribute only their largest arm. Separate blocks still sum, since both may be true.
|
|
2547
|
+
*/
|
|
2548
|
+
function renderableH1s(headings) {
|
|
2549
|
+
const nodes = headings.filter((h) => h.level === 1 && !h.aria).map((heading) => ({
|
|
2550
|
+
key: "h1",
|
|
2551
|
+
path: heading.path ?? [],
|
|
2552
|
+
repeatable: false,
|
|
2553
|
+
heading
|
|
2554
|
+
}));
|
|
2555
|
+
const kept = new Set([...foldOccurrences(nodes).values()].flat().map((n) => n.heading));
|
|
2556
|
+
return headings.filter((h) => kept.has(h));
|
|
2557
|
+
}
|
|
2105
2558
|
const seoSingleH1 = {
|
|
2106
2559
|
id: "seo/single-h1",
|
|
2107
2560
|
title: "Heading hierarchy",
|
|
@@ -2112,7 +2565,7 @@ const seoSingleH1 = {
|
|
|
2112
2565
|
async check(ctx) {
|
|
2113
2566
|
const out = [];
|
|
2114
2567
|
for (const route of ctx.headings ?? []) {
|
|
2115
|
-
const h1 = [...route.headings, ...route.componentHeadings ?? []]
|
|
2568
|
+
const h1 = renderableH1s([...route.headings, ...route.componentHeadings ?? []]);
|
|
2116
2569
|
let problem;
|
|
2117
2570
|
let where = {};
|
|
2118
2571
|
if (h1.length === 0) {
|
|
@@ -2122,7 +2575,7 @@ const seoSingleH1 = {
|
|
|
2122
2575
|
severity: "warning",
|
|
2123
2576
|
recommendation: missingRecommendation
|
|
2124
2577
|
};
|
|
2125
|
-
const first = route.headings
|
|
2578
|
+
const first = route.headings.find((h) => !h.aria);
|
|
2126
2579
|
if (first) where = {
|
|
2127
2580
|
location: first.file,
|
|
2128
2581
|
...first.line > 0 ? { line: first.line } : {}
|
|
@@ -2246,8 +2699,23 @@ const seoDuplicateDescription = uniquenessRule({
|
|
|
2246
2699
|
rationale: "Duplicate meta descriptions give search engines no per-page summary, so they are often ignored or rewritten."
|
|
2247
2700
|
});
|
|
2248
2701
|
//#endregion
|
|
2702
|
+
//#region src/headings.ts
|
|
2703
|
+
/** Sort comparator for `order`: element by element, a prefix first; headings without one keep their place. */
|
|
2704
|
+
function documentOrder(a, b) {
|
|
2705
|
+
if (!a.order || !b.order) return 0;
|
|
2706
|
+
for (let i = 0; i < Math.min(a.order.length, b.order.length); i++) if (a.order[i] !== b.order[i]) return a.order[i] - b.order[i];
|
|
2707
|
+
return a.order.length - b.order.length;
|
|
2708
|
+
}
|
|
2709
|
+
/** Two headings can never render together: different arms of one block. */
|
|
2710
|
+
function exclusiveHeadings(a, b) {
|
|
2711
|
+
if (!a.path || !b.path) return false;
|
|
2712
|
+
return a.path.some((s) => b.path.some((t) => s.group === t.group && s.branch !== t.branch));
|
|
2713
|
+
}
|
|
2714
|
+
//#endregion
|
|
2249
2715
|
//#region src/rules/seo/heading-level-skip.ts
|
|
2250
2716
|
const docsUrl$5 = docsUrlFor("seo/heading-level-skip");
|
|
2717
|
+
/** Every arm `p` sits in that can be skipped, `h` sits in too: whenever `h` renders, so does `p` (or one like it). */
|
|
2718
|
+
const rendersWith = (p, h) => (p.path ?? []).every((s) => s.always || (h.path ?? []).some((t) => t.group === s.group && t.branch === s.branch));
|
|
2251
2719
|
const recommendation$5 = "Increase heading levels one step at a time (do not jump, e.g. from <h2> straight to <h4>).";
|
|
2252
2720
|
/**
|
|
2253
2721
|
* seo/heading-level-skip — Skipped heading level. Walking a route's body headings in
|
|
@@ -2266,20 +2734,36 @@ const seoHeadingLevelSkip = {
|
|
|
2266
2734
|
const out = [];
|
|
2267
2735
|
for (const route of ctx.headings ?? []) {
|
|
2268
2736
|
if (route.headings.length === 0) continue;
|
|
2269
|
-
|
|
2737
|
+
const placed = (route.componentHeadings ?? []).filter((h) => h.order);
|
|
2738
|
+
const own = new Set(route.headings);
|
|
2739
|
+
const headings = [...route.headings, ...placed].sort(documentOrder);
|
|
2270
2740
|
let skip;
|
|
2271
|
-
for (let i = 1; i <
|
|
2272
|
-
const h =
|
|
2273
|
-
if (h
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2741
|
+
for (let i = 1; i < headings.length && !skip; i++) {
|
|
2742
|
+
const h = headings[i];
|
|
2743
|
+
if (!own.has(h)) continue;
|
|
2744
|
+
const before = headings.slice(0, i).filter((p) => !exclusiveHeadings(p, h) && (own.has(p) || rendersWith(p, h)));
|
|
2745
|
+
const prev = before.at(-1);
|
|
2746
|
+
if (!prev) continue;
|
|
2747
|
+
const levels = [prev.level];
|
|
2748
|
+
const arms = /* @__PURE__ */ new Set();
|
|
2749
|
+
const looped = prev.path?.some((s) => s.repeat && !h.path?.some((t) => t.group === s.group));
|
|
2750
|
+
for (let j = looped ? -1 : before.length - 2; j >= 0; j--) {
|
|
2751
|
+
const q = before[j];
|
|
2752
|
+
const step = q.path?.find((s) => prev.path?.some((t) => t.group === s.group && t.branch !== s.branch));
|
|
2753
|
+
const arm = step && `${step.group}:${step.branch}`;
|
|
2754
|
+
if (arm && !arms.has(arm)) {
|
|
2755
|
+
arms.add(arm);
|
|
2756
|
+
levels.push(q.level);
|
|
2757
|
+
}
|
|
2281
2758
|
}
|
|
2282
|
-
|
|
2759
|
+
const known = levels.filter((level) => level > 0);
|
|
2760
|
+
const lowest = Math.min(...known);
|
|
2761
|
+
if (known.length === levels.length && h.level > lowest + 1) skip = {
|
|
2762
|
+
level: h.level,
|
|
2763
|
+
prev: lowest,
|
|
2764
|
+
line: h.line,
|
|
2765
|
+
file: h.file
|
|
2766
|
+
};
|
|
2283
2767
|
}
|
|
2284
2768
|
out.push(skip ? {
|
|
2285
2769
|
id: "seo/heading-level-skip",
|
|
@@ -2410,10 +2894,6 @@ function kitModuleRule(opts) {
|
|
|
2410
2894
|
}
|
|
2411
2895
|
//#endregion
|
|
2412
2896
|
//#region src/rules/seo/ssr-disabled.ts
|
|
2413
|
-
/** The root layout — disabling SSR there turns the whole app into an SPA. */
|
|
2414
|
-
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. */
|
|
2416
|
-
const PAGE_OPTION_FILE_RE = /\+(page|layout)(\.server)?\.(ts|js)$/;
|
|
2417
2897
|
const seoSsrDisabled = kitModuleRule({
|
|
2418
2898
|
id: "seo/ssr-disabled",
|
|
2419
2899
|
title: "SSR disabled",
|
|
@@ -2755,9 +3235,19 @@ function messageFor(link) {
|
|
|
2755
3235
|
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
3236
|
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
3237
|
}
|
|
3238
|
+
/**
|
|
3239
|
+
* A literal that already carries a statically known base is correct as written; only a dynamic
|
|
3240
|
+
* base (no `value`) leaves every root-relative literal suspect.
|
|
3241
|
+
*/
|
|
3242
|
+
function startsWithBase(path, base) {
|
|
3243
|
+
if (base === void 0 || !path.startsWith(base)) return false;
|
|
3244
|
+
if (/(^|\/)(\.|%2e){2}(\/|$)/i.test(path.split(/[?#]/)[0])) return false;
|
|
3245
|
+
const next = path.charAt(base.length);
|
|
3246
|
+
return next === "" || next === "/" || next === "?" || next === "#";
|
|
3247
|
+
}
|
|
2758
3248
|
/** 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)));
|
|
3249
|
+
function emitFile$1(out, file, links, suppressions, base) {
|
|
3250
|
+
const bad = links.filter((l) => !startsWithBase(l.path, base) && !(l.line > 0 && isSuppressed(suppressions, ID$8, l.line)));
|
|
2761
3251
|
if (bad.length === 0) {
|
|
2762
3252
|
out.push({
|
|
2763
3253
|
id: ID$8,
|
|
@@ -2802,16 +3292,17 @@ const correctnessBasePathNavigation = {
|
|
|
2802
3292
|
fix: { ...FIX$2 },
|
|
2803
3293
|
async check(ctx) {
|
|
2804
3294
|
if (!ctx.project.kitPathsBase) return [];
|
|
3295
|
+
const base = ctx.project.kitPathsBase.value;
|
|
2805
3296
|
const out = [];
|
|
2806
3297
|
for (const c of ctx.components ?? []) {
|
|
2807
3298
|
const links = c.basePathLinks ?? [];
|
|
2808
3299
|
if (links.length === 0) continue;
|
|
2809
|
-
emitFile$1(out, c.file, links, c.suppressions);
|
|
3300
|
+
emitFile$1(out, c.file, links, c.suppressions, base);
|
|
2810
3301
|
}
|
|
2811
3302
|
for (const m of ctx.kitModules ?? []) {
|
|
2812
3303
|
const links = m.basePathLinks ?? [];
|
|
2813
3304
|
if (links.length === 0) continue;
|
|
2814
|
-
emitFile$1(out, m.file, links, m.suppressions);
|
|
3305
|
+
emitFile$1(out, m.file, links, m.suppressions, base);
|
|
2815
3306
|
}
|
|
2816
3307
|
return out;
|
|
2817
3308
|
}
|
|
@@ -2878,7 +3369,8 @@ const correctnessServerBrowserGlobal = {
|
|
|
2878
3369
|
})), c.suppressions);
|
|
2879
3370
|
}
|
|
2880
3371
|
for (const m of ctx.kitModules ?? []) {
|
|
2881
|
-
const
|
|
3372
|
+
const neverSsr = universalNeverSsr(m, ctx);
|
|
3373
|
+
const refs = (m.browserGlobalRefs ?? []).filter((r) => !(neverSsr && r.inHandler));
|
|
2882
3374
|
if (refs.length === 0) continue;
|
|
2883
3375
|
emitFile(out, m.file, refs.map((r) => ({
|
|
2884
3376
|
line: r.line,
|
|
@@ -2897,7 +3389,7 @@ const correctnessInstanceBrowserGlobal = componentRule({
|
|
|
2897
3389
|
label: "Server-safe component init",
|
|
2898
3390
|
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
3391
|
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"),
|
|
3392
|
+
applies: (c, _o, ctx) => (c.browserGlobalRefs ?? []).some((r) => r.context === "instance") && !appSsrDisabled(ctx) && !routeNeverSsr(c.file, ctx),
|
|
2901
3393
|
bad: (c) => (c.browserGlobalRefs ?? []).filter((r) => r.context === "instance").map((r) => ({
|
|
2902
3394
|
line: r.line,
|
|
2903
3395
|
message: `${r.name} is accessed during component initialisation — during SSR this runs on the server, where ${r.name} is not defined`
|
|
@@ -2943,7 +3435,7 @@ const securityHandlerStateWrite = kitModuleRule({
|
|
|
2943
3435
|
label: "Load/handler purity",
|
|
2944
3436
|
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
3437
|
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
|
|
3438
|
+
applies: (m, ctx) => m.importedStateWrites.length > 0 && !universalNeverSsr(m, ctx),
|
|
2947
3439
|
bad: (m) => m.importedStateWrites.map((w) => ({
|
|
2948
3440
|
line: w.line,
|
|
2949
3441
|
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 +3450,7 @@ const securityServerModuleState = kitModuleRule({
|
|
|
2958
3450
|
label: "Server module state",
|
|
2959
3451
|
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
3452
|
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,
|
|
3453
|
+
applies: (m, ctx) => m.moduleStateReassignments.length > 0 && !universalNeverSsr(m, ctx),
|
|
2962
3454
|
bad: (m) => m.moduleStateReassignments.map((r) => ({
|
|
2963
3455
|
line: r.line,
|
|
2964
3456
|
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 +3469,7 @@ const securitySharedStateImport = kitModuleRule({
|
|
|
2977
3469
|
label: "Server state imports",
|
|
2978
3470
|
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
3471
|
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
|
|
3472
|
+
applies: (m, ctx) => m.runesModuleImports.length > 0 && !universalNeverSsr(m, ctx),
|
|
2981
3473
|
bad: (m, ctx) => {
|
|
2982
3474
|
const stateFiles = new Set((ctx.components ?? []).filter((c) => c.moduleStateDecls.length > 0).map((c) => c.file));
|
|
2983
3475
|
const writtenOutside = new Set(m.importedStateWritesOutsideHandlers.map((w) => w.name));
|
|
@@ -3085,7 +3577,8 @@ const PREPROCESSED_STYLE_RE = /(<style\b[^>]*\slang\s*=\s*['"]?[^'"\s>]+['"]?[^>
|
|
|
3085
3577
|
* the text scan's imprecision cannot reach it. A file that does not parse is already a hard failure,
|
|
3086
3578
|
* which the retry can only improve on.
|
|
3087
3579
|
*/
|
|
3088
|
-
function parseSvelte(
|
|
3580
|
+
function parseSvelte(raw, filename) {
|
|
3581
|
+
const source = raw.charCodeAt(0) === 65279 ? ` ${raw.slice(1)}` : raw;
|
|
3089
3582
|
try {
|
|
3090
3583
|
return parse(source, {
|
|
3091
3584
|
modern: true,
|
|
@@ -3151,12 +3644,25 @@ function attrValue(attributes, name) {
|
|
|
3151
3644
|
const attr = findAttr(attributes, name);
|
|
3152
3645
|
return attr ? attrValueOf(attr) : "absent";
|
|
3153
3646
|
}
|
|
3647
|
+
const lineStarts = /* @__PURE__ */ new Map();
|
|
3154
3648
|
function lineOf(source, offset) {
|
|
3155
3649
|
if (typeof offset !== "number" || offset < 0) return 0;
|
|
3156
|
-
let
|
|
3650
|
+
let starts = lineStarts.get(source);
|
|
3651
|
+
if (!starts) {
|
|
3652
|
+
starts = [0];
|
|
3653
|
+
for (let i = source.indexOf("\n"); i !== -1; i = source.indexOf("\n", i + 1)) starts.push(i + 1);
|
|
3654
|
+
if (lineStarts.size >= 8) lineStarts.delete(lineStarts.keys().next().value);
|
|
3655
|
+
lineStarts.set(source, starts);
|
|
3656
|
+
}
|
|
3157
3657
|
const end = Math.min(offset, source.length);
|
|
3158
|
-
|
|
3159
|
-
|
|
3658
|
+
let lo = 0;
|
|
3659
|
+
let hi = starts.length;
|
|
3660
|
+
while (lo < hi) {
|
|
3661
|
+
const mid = lo + hi >> 1;
|
|
3662
|
+
if (starts[mid] <= end) lo = mid + 1;
|
|
3663
|
+
else hi = mid;
|
|
3664
|
+
}
|
|
3665
|
+
return lo;
|
|
3160
3666
|
}
|
|
3161
3667
|
/**
|
|
3162
3668
|
* An element's attribute by name, matched **case-insensitively**: HTML attribute names are, so
|
|
@@ -3189,7 +3695,7 @@ function attrTextOf(attr) {
|
|
|
3189
3695
|
//#region src/module-ast.ts
|
|
3190
3696
|
function unwrapTs(expr) {
|
|
3191
3697
|
let cur = expr;
|
|
3192
|
-
while (cur
|
|
3698
|
+
while (cur != null && (cur.type === "TSSatisfiesExpression" || cur.type === "TSAsExpression" || cur.type === "TSNonNullExpression")) cur = cur.expression;
|
|
3193
3699
|
return cur;
|
|
3194
3700
|
}
|
|
3195
3701
|
/** AST metadata keys every walker skips — never traversed as child nodes. Shared with the Kit-module parser (the security kit-module rules). */
|
|
@@ -3242,8 +3748,8 @@ function addBoundNames(id, acc) {
|
|
|
3242
3748
|
}
|
|
3243
3749
|
/** The base identifier name of a (possibly nested) member expression or identifier, else undefined. Shared with the Kit-module parser (the security kit-module rules). */
|
|
3244
3750
|
function rootObjectName(node) {
|
|
3245
|
-
let cur = node;
|
|
3246
|
-
while (cur?.type === "MemberExpression") cur = cur.object;
|
|
3751
|
+
let cur = unwrapTs(node);
|
|
3752
|
+
while (cur?.type === "MemberExpression") cur = unwrapTs(cur.object);
|
|
3247
3753
|
return cur?.type === "Identifier" ? cur.name : void 0;
|
|
3248
3754
|
}
|
|
3249
3755
|
/**
|
|
@@ -3258,7 +3764,8 @@ function rootObjectName(node) {
|
|
|
3258
3764
|
* `then`/`catch` value/error bindings, and a fragment's own `{@const ...}` /
|
|
3259
3765
|
* `{let ...}` / `{const ...}` declaration tags (attributed to the enclosing Fragment,
|
|
3260
3766
|
* 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)
|
|
3767
|
+
* template-locals, possible for `{let}`, is still not a write to the outer binding), and an
|
|
3768
|
+
* element's or component's `let:` directive bindings.
|
|
3262
3769
|
* Used by `walkScoped` so a write/mutation detector
|
|
3263
3770
|
* doesn't misattribute a write to one of these locals as a write to an outer `$state`/prop
|
|
3264
3771
|
* of the same name (issue #140 — originally a deliberately partial mitigation that left
|
|
@@ -3284,7 +3791,20 @@ function scopeIntroducedNames(node) {
|
|
|
3284
3791
|
else if (node.type === "AwaitBlock") {
|
|
3285
3792
|
if (node.value) addBoundNames(node.value, introduced);
|
|
3286
3793
|
if (node.error) addBoundNames(node.error, introduced);
|
|
3287
|
-
} else if (node.
|
|
3794
|
+
} else if (Array.isArray(node.attributes) && node.fragment) for (const attr of node.attributes) {
|
|
3795
|
+
if (attr?.type !== "LetDirective") continue;
|
|
3796
|
+
const e = attr.expression;
|
|
3797
|
+
const asPattern = {
|
|
3798
|
+
ObjectExpression: "ObjectPattern",
|
|
3799
|
+
ArrayExpression: "ArrayPattern"
|
|
3800
|
+
}[e?.type];
|
|
3801
|
+
if (e) addBoundNames(asPattern ? {
|
|
3802
|
+
...e,
|
|
3803
|
+
type: asPattern
|
|
3804
|
+
} : e, introduced);
|
|
3805
|
+
else introduced.add(attr.name);
|
|
3806
|
+
}
|
|
3807
|
+
else if (node.type === "Fragment") {
|
|
3288
3808
|
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
3809
|
}
|
|
3290
3810
|
return introduced;
|
|
@@ -3340,11 +3860,21 @@ function walkEvalScope(node, visit, shadowed = /* @__PURE__ */ new Set(), bounda
|
|
|
3340
3860
|
const scope = introduced.size > 0 ? /* @__PURE__ */ new Set([...shadowed, ...introduced]) : shadowed;
|
|
3341
3861
|
if (visit(node, scope)) return;
|
|
3342
3862
|
if (boundaries.has(node.type)) return;
|
|
3863
|
+
const outer = introduced.size > 0 ? outerScopeKeys(node) : void 0;
|
|
3343
3864
|
for (const key of Object.keys(node)) {
|
|
3344
3865
|
if (WALK_IGNORED_KEYS.has(key)) continue;
|
|
3345
|
-
walkEvalScope(node[key], visit, scope, boundaries);
|
|
3866
|
+
if (!outer?.has(key)) walkEvalScope(node[key], visit, scope, boundaries);
|
|
3867
|
+
else if (!Array.isArray(node[key])) walkEvalScope(node[key], visit, shadowed, boundaries);
|
|
3868
|
+
else for (const child of node[key]) walkEvalScope(child, visit, child?.type === "LetDirective" ? scope : shadowed, boundaries);
|
|
3346
3869
|
}
|
|
3347
3870
|
}
|
|
3871
|
+
/** Children of a binding construct that are evaluated before its bindings exist. */
|
|
3872
|
+
function outerScopeKeys(node) {
|
|
3873
|
+
if (node.type === "EachBlock" || node.type === "AwaitBlock") return OUTER_SCOPE_KEYS;
|
|
3874
|
+
if (Array.isArray(node.attributes) && node.fragment) return ATTRIBUTE_KEYS;
|
|
3875
|
+
}
|
|
3876
|
+
const OUTER_SCOPE_KEYS = /* @__PURE__ */ new Set(["expression"]);
|
|
3877
|
+
const ATTRIBUTE_KEYS = /* @__PURE__ */ new Set(["attributes"]);
|
|
3348
3878
|
/**
|
|
3349
3879
|
* Unwrap a top-level statement's `export`/`export default` wrapper to the declaration
|
|
3350
3880
|
* (or expression) it wraps; a non-export statement is returned as-is. Used so pattern 2
|
|
@@ -3449,12 +3979,13 @@ function collectNamedImportAliases(program, moduleSource, names) {
|
|
|
3449
3979
|
}
|
|
3450
3980
|
const BROWSER_GUARD_NAMES = /* @__PURE__ */ new Set(["browser"]);
|
|
3451
3981
|
/**
|
|
3452
|
-
* Local names of `browser` value-imported from '$app/environment'
|
|
3982
|
+
* Local names of `browser` value-imported from '$app/environment' or its SvelteKit 3 name
|
|
3983
|
+
* '$app/env' (alias-resolved) —
|
|
3453
3984
|
* the guard binding recognised by the browser-global scanner (correctness/server-browser-global, correctness/instance-browser-global).
|
|
3454
3985
|
* Shared with the Kit-module parser.
|
|
3455
3986
|
*/
|
|
3456
3987
|
function collectBrowserGuardImports(program) {
|
|
3457
|
-
return collectNamedImportAliases(program, "$app/environment", BROWSER_GUARD_NAMES);
|
|
3988
|
+
return /* @__PURE__ */ new Set([...collectNamedImportAliases(program, "$app/environment", BROWSER_GUARD_NAMES), ...collectNamedImportAliases(program, "$app/env", BROWSER_GUARD_NAMES)]);
|
|
3458
3989
|
}
|
|
3459
3990
|
/**
|
|
3460
3991
|
* Names bound at the program's top level: every import's local name plus every
|
|
@@ -3470,6 +4001,11 @@ function collectProgramBindings(program) {
|
|
|
3470
4001
|
for (const s of stmt.specifiers ?? []) if (s?.local?.type === "Identifier") bound.add(s.local.name);
|
|
3471
4002
|
continue;
|
|
3472
4003
|
}
|
|
4004
|
+
if (stmt?.type === "LabeledStatement" && stmt.label?.name === "$") {
|
|
4005
|
+
const e = stmt.body?.type === "ExpressionStatement" ? stmt.body.expression : void 0;
|
|
4006
|
+
if (e?.type === "AssignmentExpression" && e.operator === "=") addBoundNames(e.left, bound);
|
|
4007
|
+
continue;
|
|
4008
|
+
}
|
|
3473
4009
|
const decl = unwrapExport(stmt);
|
|
3474
4010
|
if (decl?.type === "VariableDeclaration") for (const d of decl.declarations ?? []) addBoundNames(d?.id, bound);
|
|
3475
4011
|
else if ((decl?.type === "FunctionDeclaration" || decl?.type === "ClassDeclaration") && decl.id?.type === "Identifier") bound.add(decl.id.name);
|
|
@@ -3490,10 +4026,12 @@ function guardTerminates(consequent) {
|
|
|
3490
4026
|
/**
|
|
3491
4027
|
* Whether a guard-clause test establishes a browser environment: it references the
|
|
3492
4028
|
* `$app/environment` `browser` binding, or contains a
|
|
3493
|
-
* `typeof <tracked-global
|
|
4029
|
+
* `typeof <tracked-global>` comparison against any type string (`'undefined'`, `'function'`, …)
|
|
4030
|
+
* (correctness/server-browser-global, correctness/instance-browser-global).
|
|
3494
4031
|
* Over-matching here only widens the skip — a conservative miss, never a false positive.
|
|
3495
4032
|
*/
|
|
3496
4033
|
function isBrowserGuardTest(test, guardBindings) {
|
|
4034
|
+
if (test?.type === "LogicalExpression" && test.operator !== "&&") return isBrowserGuardTest(test.left, guardBindings) && isBrowserGuardTest(test.right, guardBindings);
|
|
3497
4035
|
let guarded = false;
|
|
3498
4036
|
walkEstree(test, (n) => {
|
|
3499
4037
|
if (n.type === "Identifier" && guardBindings.has(n.name)) guarded = true;
|
|
@@ -3505,12 +4043,20 @@ function isBrowserGuardTest(test, guardBindings) {
|
|
|
3505
4043
|
].includes(n.operator)) {
|
|
3506
4044
|
const sides = [n.left, n.right];
|
|
3507
4045
|
const hasTypeofGlobal = sides.some((s) => s?.type === "UnaryExpression" && s.operator === "typeof" && s.argument?.type === "Identifier" && BROWSER_GLOBALS.has(s.argument.name));
|
|
3508
|
-
const
|
|
3509
|
-
|
|
4046
|
+
const type = sides.find((s) => s?.type === "Literal" && typeof s.value === "string")?.value;
|
|
4047
|
+
const positive = n.operator === "===" || n.operator === "==";
|
|
4048
|
+
if (hasTypeofGlobal && type !== void 0 && (type === "undefined" || positive)) guarded = true;
|
|
3510
4049
|
}
|
|
3511
4050
|
});
|
|
3512
4051
|
return guarded;
|
|
3513
4052
|
}
|
|
4053
|
+
/** The tracked global `G` of a `typeof G === 'undefined'` test: the test is false only where `G` is defined. */
|
|
4054
|
+
function missingGlobalOf(n) {
|
|
4055
|
+
if (n?.type !== "BinaryExpression" || n.operator !== "===" && n.operator !== "==") return void 0;
|
|
4056
|
+
const sides = [n.left, n.right];
|
|
4057
|
+
if (!sides.some((s) => s?.type === "Literal" && s.value === "undefined")) return void 0;
|
|
4058
|
+
return sides.find((s) => s?.type === "UnaryExpression" && s.operator === "typeof" && s.argument?.type === "Identifier" && BROWSER_GLOBALS.has(s.argument.name))?.argument.name;
|
|
4059
|
+
}
|
|
3514
4060
|
/**
|
|
3515
4061
|
* Names of derived guard bindings (one level, no fixpoint chasing): a top-level
|
|
3516
4062
|
* (export-unwrapped) `const`/`let` declarator whose init passes `isBrowserGuardTest` —
|
|
@@ -3530,6 +4076,95 @@ function collectDerivedGuardBindings(program, guards) {
|
|
|
3530
4076
|
}
|
|
3531
4077
|
return derived;
|
|
3532
4078
|
}
|
|
4079
|
+
const DOM_ONLY_GLOBALS = /* @__PURE__ */ new Set(["window", "document"]);
|
|
4080
|
+
/**
|
|
4081
|
+
* Unlike `isBrowserGuardTest` this keeps the polarity and each outcome apart, because a write the
|
|
4082
|
+
* guard leaves reachable on the server must still be reported: `!browser && url.search` being falsy
|
|
4083
|
+
* proves nothing.
|
|
4084
|
+
*/
|
|
4085
|
+
function guardImplies(test, guards, shadowed) {
|
|
4086
|
+
if (test?.type === "Identifier") return !shadowed.has(test.name) && guards.get(test.name) || {};
|
|
4087
|
+
if (test?.type === "UnaryExpression" && test.operator === "!") {
|
|
4088
|
+
const inner = guardImplies(test.argument, guards, shadowed);
|
|
4089
|
+
return {
|
|
4090
|
+
truthy: inner.falsy,
|
|
4091
|
+
falsy: inner.truthy
|
|
4092
|
+
};
|
|
4093
|
+
}
|
|
4094
|
+
if (test?.type === "LogicalExpression" && (test.operator === "&&" || test.operator === "||")) {
|
|
4095
|
+
const a = guardImplies(test.left, guards, shadowed);
|
|
4096
|
+
const b = guardImplies(test.right, guards, shadowed);
|
|
4097
|
+
const agree = (x, y) => x === y ? x : void 0;
|
|
4098
|
+
return test.operator === "&&" ? {
|
|
4099
|
+
truthy: a.truthy ?? b.truthy,
|
|
4100
|
+
falsy: agree(a.falsy, b.falsy)
|
|
4101
|
+
} : {
|
|
4102
|
+
truthy: agree(a.truthy, b.truthy),
|
|
4103
|
+
falsy: a.falsy ?? b.falsy
|
|
4104
|
+
};
|
|
4105
|
+
}
|
|
4106
|
+
if (test?.type === "BinaryExpression" && [
|
|
4107
|
+
"===",
|
|
4108
|
+
"!==",
|
|
4109
|
+
"==",
|
|
4110
|
+
"!="
|
|
4111
|
+
].includes(test.operator)) {
|
|
4112
|
+
const sides = [test.left, test.right];
|
|
4113
|
+
const hasTypeofGlobal = sides.some((s) => s?.type === "UnaryExpression" && s.operator === "typeof" && s.argument?.type === "Identifier" && DOM_ONLY_GLOBALS.has(s.argument.name) && !shadowed.has(s.argument.name));
|
|
4114
|
+
const type = sides.find((s) => s?.type === "Literal" && typeof s.value === "string")?.value;
|
|
4115
|
+
if (!hasTypeofGlobal || type === void 0) return {};
|
|
4116
|
+
const equal = test.operator === "===" || test.operator === "==";
|
|
4117
|
+
if (type === "undefined") return equal ? {
|
|
4118
|
+
truthy: "server",
|
|
4119
|
+
falsy: "browser"
|
|
4120
|
+
} : {
|
|
4121
|
+
truthy: "browser",
|
|
4122
|
+
falsy: "server"
|
|
4123
|
+
};
|
|
4124
|
+
return equal ? { truthy: "browser" } : { falsy: "browser" };
|
|
4125
|
+
}
|
|
4126
|
+
return {};
|
|
4127
|
+
}
|
|
4128
|
+
/**
|
|
4129
|
+
* Source ranges only the browser reaches: the arm of an `if`/ternary its browser guard selects, the
|
|
4130
|
+
* right side of `browser && x` or `!browser || x`, and the statements after a terminating early return
|
|
4131
|
+
* the server always takes (`if (!browser) return;`) in their block. A top-level `const` derived from a
|
|
4132
|
+
* guard (`const serverOnly = !browser`) carries its own polarity.
|
|
4133
|
+
*/
|
|
4134
|
+
function browserOnlyRanges(program, envGuards) {
|
|
4135
|
+
const guards = new Map([...envGuards].map((name) => [name, {
|
|
4136
|
+
truthy: "browser",
|
|
4137
|
+
falsy: "server"
|
|
4138
|
+
}]));
|
|
4139
|
+
const bound = collectProgramBindings(program);
|
|
4140
|
+
for (const name of envGuards) bound.delete(name);
|
|
4141
|
+
for (const stmt of program.body ?? []) {
|
|
4142
|
+
const decl = unwrapExport(stmt);
|
|
4143
|
+
if (decl?.type !== "VariableDeclaration" || decl.kind !== "const") continue;
|
|
4144
|
+
for (const d of decl.declarations ?? []) {
|
|
4145
|
+
const implied = d?.id?.type === "Identifier" && d.init ? guardImplies(d.init, guards, bound) : {};
|
|
4146
|
+
if (implied.truthy || implied.falsy) guards.set(d.id.name, implied);
|
|
4147
|
+
}
|
|
4148
|
+
}
|
|
4149
|
+
for (const name of guards.keys()) bound.delete(name);
|
|
4150
|
+
const out = [];
|
|
4151
|
+
const push = (n) => n && out.push([n.start, n.end]);
|
|
4152
|
+
walkScoped(program, (n, shadowed) => {
|
|
4153
|
+
const implies = (test) => guardImplies(test, guards, shadowed);
|
|
4154
|
+
if (n.type === "IfStatement" || n.type === "ConditionalExpression") {
|
|
4155
|
+
const implied = implies(n.test);
|
|
4156
|
+
if (implied.truthy === "browser") push(n.consequent);
|
|
4157
|
+
if (implied.falsy === "browser") push(n.alternate);
|
|
4158
|
+
} else if (n.type === "LogicalExpression" && (n.operator === "&&" || n.operator === "||")) {
|
|
4159
|
+
if (implies(n.left)[n.operator === "&&" ? "truthy" : "falsy"] === "browser") push(n.right);
|
|
4160
|
+
} else if (n.type === "BlockStatement" || n.type === "Program") {
|
|
4161
|
+
const body = n.body ?? [];
|
|
4162
|
+
const at = body.findIndex((stmt) => stmt?.type === "IfStatement" && implies(stmt.test).falsy === "browser" && guardTerminates(stmt.consequent));
|
|
4163
|
+
if (at >= 0 && at < body.length - 1) out.push([body[at + 1].start, body[body.length - 1].end]);
|
|
4164
|
+
}
|
|
4165
|
+
}, bound);
|
|
4166
|
+
return out;
|
|
4167
|
+
}
|
|
3533
4168
|
/**
|
|
3534
4169
|
* Browser-global reads in code that executes when `program` (or a passed function body)
|
|
3535
4170
|
* is evaluated (correctness/server-browser-global, correctness/instance-browser-global). Position-aware — only read positions match: never a
|
|
@@ -3564,7 +4199,13 @@ function collectBrowserGlobalRefs(program, source, extra) {
|
|
|
3564
4199
|
if (typeof n !== "object" || typeof n.type !== "string") return;
|
|
3565
4200
|
if (EVAL_SCOPE_BOUNDARIES.has(n.type)) return;
|
|
3566
4201
|
if ((n.type === "IfStatement" || n.type === "ConditionalExpression") && isBrowserGuardTest(n.test, guards)) return;
|
|
3567
|
-
if (n.type === "LogicalExpression" && isBrowserGuardTest(n.left, guards)) return;
|
|
4202
|
+
if (n.type === "LogicalExpression" && (n.operator === "&&" ? isBrowserGuardTest(n.left, guards) : n.left?.type === "UnaryExpression" && n.left.operator === "!" && isBrowserGuardTest(n.left.argument, guards))) return;
|
|
4203
|
+
const missing = n.type === "LogicalExpression" && n.operator === "||" ? missingGlobalOf(n.left) : void 0;
|
|
4204
|
+
if (missing && !bound.has(missing) && !shadowed.has(missing)) {
|
|
4205
|
+
visit(n.left, shadowed);
|
|
4206
|
+
if (!DOM_ONLY_GLOBALS.has(missing)) visit(n.right, /* @__PURE__ */ new Set([...shadowed, missing]));
|
|
4207
|
+
return;
|
|
4208
|
+
}
|
|
3568
4209
|
const introduced = scopeIntroducedNames(n);
|
|
3569
4210
|
const scope = introduced.size > 0 ? /* @__PURE__ */ new Set([...shadowed, ...introduced]) : shadowed;
|
|
3570
4211
|
switch (n.type) {
|
|
@@ -3659,6 +4300,11 @@ const HANDLER_NAMES = /* @__PURE__ */ new Set([
|
|
|
3659
4300
|
"OPTIONS",
|
|
3660
4301
|
"fallback"
|
|
3661
4302
|
]);
|
|
4303
|
+
/** Whether `arg` can be a store updater: a function literal, or a function this module declares. */
|
|
4304
|
+
function isUpdaterArgument(arg, localFunctions) {
|
|
4305
|
+
const e = arg ? unwrapTs(arg) : void 0;
|
|
4306
|
+
return isFunctionNode(e) || e?.type === "Identifier" && localFunctions.has(e.name);
|
|
4307
|
+
}
|
|
3662
4308
|
function isFunctionNode(n) {
|
|
3663
4309
|
return n?.type === "FunctionDeclaration" || n?.type === "FunctionExpression" || n?.type === "ArrowFunctionExpression";
|
|
3664
4310
|
}
|
|
@@ -3760,7 +4406,7 @@ function collectStartupFunctions(program) {
|
|
|
3760
4406
|
}
|
|
3761
4407
|
/**
|
|
3762
4408
|
* The `export const ssr = false` / `export const csr = false` opt-out, when
|
|
3763
|
-
* present: inline form (`satisfies`/`as` unwrapped) or same-file alias export
|
|
4409
|
+
* present (`= dev` too): inline form (`satisfies`/`as` unwrapped) or same-file alias export
|
|
3764
4410
|
* (`const ssr = false; export { ssr };`). Returns the declaration's line in the
|
|
3765
4411
|
* WRAPPED source (the caller applies the −1 shift). An `ssr = false` file never
|
|
3766
4412
|
* runs on the server — correctness/server-browser-global skips its browser-global scan, and seo/ssr-disabled
|
|
@@ -3768,14 +4414,41 @@ function collectStartupFunctions(program) {
|
|
|
3768
4414
|
* performance/load-waterfall exempts it (see `csrDisabled` on `KitModuleFacts`).
|
|
3769
4415
|
*/
|
|
3770
4416
|
function findFalseOptOut(program, source, name) {
|
|
4417
|
+
const dev = collectNamedImportAliases(program, "$app/environment", DEV_NAMES);
|
|
3771
4418
|
let hit;
|
|
3772
4419
|
forEachNamedExport(program, (exported, value, anchor) => {
|
|
3773
|
-
|
|
3774
|
-
|
|
4420
|
+
const literal = value?.type === "Literal" && value.value === false;
|
|
4421
|
+
if (exported !== name || !(literal || value?.type === "Identifier" && dev.has(value.name))) return void 0;
|
|
4422
|
+
hit = {
|
|
4423
|
+
line: lineOf(source, anchor.start),
|
|
4424
|
+
literal
|
|
4425
|
+
};
|
|
3775
4426
|
return true;
|
|
3776
4427
|
});
|
|
3777
4428
|
return hit;
|
|
3778
4429
|
}
|
|
4430
|
+
const DEV_NAMES = /* @__PURE__ */ new Set(["dev"]);
|
|
4431
|
+
const BUILDING_NAMES = /* @__PURE__ */ new Set(["building"]);
|
|
4432
|
+
/** Whether the module exports `name` as the literal `true` (inline or same-file alias export). */
|
|
4433
|
+
function exportsTrue(program, name) {
|
|
4434
|
+
let hit = false;
|
|
4435
|
+
forEachNamedExport(program, (exported, value) => {
|
|
4436
|
+
if (exported !== name) return void 0;
|
|
4437
|
+
hit = value?.type === "Literal" && value.value === true;
|
|
4438
|
+
return true;
|
|
4439
|
+
});
|
|
4440
|
+
return hit;
|
|
4441
|
+
}
|
|
4442
|
+
/** Whether the module exports a binding named `name` in any form, re-exports and unresolved aliases included. */
|
|
4443
|
+
function exportsName(program, name) {
|
|
4444
|
+
return (program.body ?? []).some((stmt) => {
|
|
4445
|
+
if (stmt?.type !== "ExportNamedDeclaration" || stmt.exportKind === "type") return false;
|
|
4446
|
+
const decl = stmt.declaration;
|
|
4447
|
+
if (decl?.type === "VariableDeclaration") return decl.declarations?.some((d) => d?.id?.name === name);
|
|
4448
|
+
if (decl) return decl.id?.name === name;
|
|
4449
|
+
return (stmt.specifiers ?? []).some((s) => s?.exportKind !== "type" && (s?.exported?.name ?? s?.exported?.value) === name);
|
|
4450
|
+
});
|
|
4451
|
+
}
|
|
3779
4452
|
/**
|
|
3780
4453
|
* The exported `load` function node (inline `export function load` / `export const
|
|
3781
4454
|
* load = …`, `satisfies`/`as` unwrapped) or a same-file alias export. Cross-file
|
|
@@ -3805,7 +4478,25 @@ function collectAwaits(node, out = []) {
|
|
|
3805
4478
|
}
|
|
3806
4479
|
return out;
|
|
3807
4480
|
}
|
|
4481
|
+
const MUTATING_METHODS = /* @__PURE__ */ new Set([
|
|
4482
|
+
"push",
|
|
4483
|
+
"unshift",
|
|
4484
|
+
"splice",
|
|
4485
|
+
"add",
|
|
4486
|
+
"set"
|
|
4487
|
+
]);
|
|
4488
|
+
const ITERATING_METHODS = /* @__PURE__ */ new Set([
|
|
4489
|
+
"forEach",
|
|
4490
|
+
"map",
|
|
4491
|
+
"flatMap",
|
|
4492
|
+
"filter",
|
|
4493
|
+
"some",
|
|
4494
|
+
"every",
|
|
4495
|
+
"reduce"
|
|
4496
|
+
]);
|
|
3808
4497
|
const REDIRECT_NAMES = /* @__PURE__ */ new Set(["redirect"]);
|
|
4498
|
+
const EXIT_NAMES = /* @__PURE__ */ new Set(["redirect", "error"]);
|
|
4499
|
+
const ERROR_NAMES = /* @__PURE__ */ new Set(["error"]);
|
|
3809
4500
|
/**
|
|
3810
4501
|
* Root-relative `redirect(status, '/…')` targets (correctness/base-path-navigation). Argument 1
|
|
3811
4502
|
* is the location (argument 0 is the status). Only a plain string literal counts, which
|
|
@@ -3826,6 +4517,359 @@ function collectRedirectCalls(node, locals, out = []) {
|
|
|
3826
4517
|
return out;
|
|
3827
4518
|
}
|
|
3828
4519
|
/**
|
|
4520
|
+
* The string literals a `transformPageChunk` option replaces in the page HTML (`html.replace(TOKEN, …)`,
|
|
4521
|
+
* `.replaceAll`), wherever the file passes one to `resolve`, a same-file `const` holding the string
|
|
4522
|
+
* included. A token the hook builds at run time is not read.
|
|
4523
|
+
*/
|
|
4524
|
+
function pageChunkTokens(program) {
|
|
4525
|
+
const consts = /* @__PURE__ */ new Map();
|
|
4526
|
+
for (const stmt of program.body ?? []) {
|
|
4527
|
+
const decl = stmt?.type === "ExportNamedDeclaration" ? stmt.declaration : stmt;
|
|
4528
|
+
if (decl?.type !== "VariableDeclaration" || decl.kind !== "const") continue;
|
|
4529
|
+
for (const d of decl.declarations ?? []) {
|
|
4530
|
+
const init = unwrapTs(d.init);
|
|
4531
|
+
if (d.id?.type === "Identifier" && init?.type === "Literal" && typeof init.value === "string") consts.set(d.id.name, init.value);
|
|
4532
|
+
}
|
|
4533
|
+
}
|
|
4534
|
+
const out = /* @__PURE__ */ new Set();
|
|
4535
|
+
const visit = (n, inChunk) => {
|
|
4536
|
+
if (Array.isArray(n)) return n.forEach((c) => visit(c, inChunk));
|
|
4537
|
+
if (!n || typeof n !== "object" || typeof n.type !== "string") return;
|
|
4538
|
+
const chunk = inChunk || n.type === "Property" && !n.computed && n.key?.type === "Identifier" && n.key.name === "transformPageChunk";
|
|
4539
|
+
if (chunk && n.type === "CallExpression" && n.callee?.type === "MemberExpression" && !n.callee.computed) {
|
|
4540
|
+
const method = n.callee.property?.name;
|
|
4541
|
+
const arg = unwrapTs(n.arguments?.[0]);
|
|
4542
|
+
const token = arg?.type === "Literal" && typeof arg.value === "string" ? arg.value : arg?.type === "Identifier" ? consts.get(arg.name) : void 0;
|
|
4543
|
+
if ((method === "replace" || method === "replaceAll") && token) out.add(token);
|
|
4544
|
+
}
|
|
4545
|
+
for (const key of Object.keys(n)) if (!WALK_IGNORED_KEYS.has(key)) visit(n[key], chunk);
|
|
4546
|
+
};
|
|
4547
|
+
visit(program, false);
|
|
4548
|
+
return [...out];
|
|
4549
|
+
}
|
|
4550
|
+
/** Whether `node` calls `name` outside nested functions. */
|
|
4551
|
+
function callsOutsideFunctions(node, name) {
|
|
4552
|
+
if (Array.isArray(node)) return node.some((n) => callsOutsideFunctions(n, name));
|
|
4553
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string") return false;
|
|
4554
|
+
if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === name) return true;
|
|
4555
|
+
return Object.keys(node).some((key) => !WALK_IGNORED_KEYS.has(key) && callsOutsideFunctions(node[key], name));
|
|
4556
|
+
}
|
|
4557
|
+
/**
|
|
4558
|
+
* The request paths the exported `handle` answers on every request, before calling `resolve`: an `if` on
|
|
4559
|
+
* the path alone (`pathname === '/x'`, `pathname.startsWith('/x')`, or an `||` of those, with `pathname`
|
|
4560
|
+
* read from the `event` parameter, directly or through a `const`) whose body returns or throws, among the
|
|
4561
|
+
* statements before the first one that calls `resolve`. A test that also reads anything else, a `sequence(…)`
|
|
4562
|
+
* handle and a guard in another function are not read.
|
|
4563
|
+
*/
|
|
4564
|
+
function handleAnsweredPaths(program, exits) {
|
|
4565
|
+
let handle;
|
|
4566
|
+
forEachNamedExport(program, (name, value) => {
|
|
4567
|
+
if (name === "handle" && isFunctionNode(value)) handle = value;
|
|
4568
|
+
return handle !== void 0;
|
|
4569
|
+
});
|
|
4570
|
+
const param = handle?.params?.[0];
|
|
4571
|
+
if (handle?.body?.type !== "BlockStatement" || param?.type !== "ObjectPattern") return [];
|
|
4572
|
+
const local = (key) => {
|
|
4573
|
+
const p = param.properties?.find((q) => q.type === "Property" && q.key?.type === "Identifier" && q.key.name === key);
|
|
4574
|
+
return p?.value?.type === "Identifier" ? p.value.name : void 0;
|
|
4575
|
+
};
|
|
4576
|
+
const event = local("event");
|
|
4577
|
+
const resolve = local("resolve");
|
|
4578
|
+
if (!event || !resolve) return [];
|
|
4579
|
+
const isUrl = (n) => n?.type === "MemberExpression" && !n.computed && n.property?.name === "url" && n.object?.type === "Identifier" && n.object.name === event;
|
|
4580
|
+
const aliases = /* @__PURE__ */ new Set();
|
|
4581
|
+
const isPath = (n) => {
|
|
4582
|
+
const e = unwrapTs(n);
|
|
4583
|
+
if (e?.type === "Identifier") return aliases.has(e.name);
|
|
4584
|
+
return e?.type === "MemberExpression" && !e.computed && e.property?.name === "pathname" && isUrl(e.object);
|
|
4585
|
+
};
|
|
4586
|
+
const literal = (n) => n?.type === "Literal" && typeof n.value === "string" ? n.value : void 0;
|
|
4587
|
+
const conds = (t) => {
|
|
4588
|
+
if (t?.type === "LogicalExpression" && t.operator === "||") {
|
|
4589
|
+
const l = conds(t.left);
|
|
4590
|
+
const r = conds(t.right);
|
|
4591
|
+
return l && r ? [...l, ...r] : void 0;
|
|
4592
|
+
}
|
|
4593
|
+
if (t?.type === "BinaryExpression" && (t.operator === "===" || t.operator === "==")) {
|
|
4594
|
+
const v = isPath(t.left) ? literal(t.right) : isPath(t.right) ? literal(t.left) : void 0;
|
|
4595
|
+
return v === void 0 ? void 0 : [{
|
|
4596
|
+
op: "eq",
|
|
4597
|
+
v
|
|
4598
|
+
}];
|
|
4599
|
+
}
|
|
4600
|
+
const callee = t?.type === "CallExpression" ? t.callee : void 0;
|
|
4601
|
+
const v = literal(t?.arguments?.[0]);
|
|
4602
|
+
return callee?.type === "MemberExpression" && callee.property?.name === "startsWith" && isPath(callee.object) && v !== void 0 ? [{
|
|
4603
|
+
op: "starts",
|
|
4604
|
+
v
|
|
4605
|
+
}] : void 0;
|
|
4606
|
+
};
|
|
4607
|
+
const exitsAlways = (s) => {
|
|
4608
|
+
if (s?.type === "BlockStatement") return s.body.length > 0 && exitsAlways(s.body[s.body.length - 1]);
|
|
4609
|
+
if (s?.type === "ReturnStatement" || s?.type === "ThrowStatement") return true;
|
|
4610
|
+
const call = s?.type === "ExpressionStatement" ? unwrapTs(s.expression) : void 0;
|
|
4611
|
+
return call?.type === "CallExpression" && call.callee?.type === "Identifier" && exits.has(call.callee.name);
|
|
4612
|
+
};
|
|
4613
|
+
const out = [];
|
|
4614
|
+
for (const stmt of handle.body.body) {
|
|
4615
|
+
if (callsOutsideFunctions(stmt, resolve)) break;
|
|
4616
|
+
if (stmt.type === "VariableDeclaration" && stmt.kind === "const") for (const d of stmt.declarations ?? []) {
|
|
4617
|
+
if (d.id?.type === "Identifier" && isPath(d.init)) aliases.add(d.id.name);
|
|
4618
|
+
if (d.id?.type === "ObjectPattern" && isUrl(unwrapTs(d.init))) {
|
|
4619
|
+
const p = d.id.properties?.find((q) => q.type === "Property" && q.key?.name === "pathname");
|
|
4620
|
+
if (p?.value?.type === "Identifier") aliases.add(p.value.name);
|
|
4621
|
+
}
|
|
4622
|
+
}
|
|
4623
|
+
if (stmt.type !== "IfStatement" || !exitsAlways(stmt.consequent)) continue;
|
|
4624
|
+
const c = conds(stmt.test);
|
|
4625
|
+
if (c) out.push(...c);
|
|
4626
|
+
}
|
|
4627
|
+
return out;
|
|
4628
|
+
}
|
|
4629
|
+
/** Whether `node` holds a `return` outside nested functions. */
|
|
4630
|
+
function containsReturn(node) {
|
|
4631
|
+
if (Array.isArray(node)) return node.some(containsReturn);
|
|
4632
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string" || isFunctionNode(node)) return false;
|
|
4633
|
+
if (node.type === "ReturnStatement") return true;
|
|
4634
|
+
return Object.keys(node).some((key) => !WALK_IGNORED_KEYS.has(key) && containsReturn(node[key]));
|
|
4635
|
+
}
|
|
4636
|
+
/**
|
|
4637
|
+
* Whether the exported `load` redirects or errors on every call: every path through its body reaches a
|
|
4638
|
+
* `redirect(…)` / `error(…)` (thrown or not) before any `return` — through both arms of an `if`/`else`,
|
|
4639
|
+
* through a `try` whose `catch` redirects or rethrows, and through a call to a top-level function
|
|
4640
|
+
* of the same file that itself always redirects. A redirect under an `if` without an `else`, in a
|
|
4641
|
+
* loop, or in a function declared inside `load` does not count.
|
|
4642
|
+
*/
|
|
4643
|
+
function loadNeverRenders(program, locals, building = /* @__PURE__ */ new Set()) {
|
|
4644
|
+
const load = locals.size > 0 ? findLoadFunction(program) : void 0;
|
|
4645
|
+
return !!load?.body && alwaysExits(program, load, locals, building);
|
|
4646
|
+
}
|
|
4647
|
+
/**
|
|
4648
|
+
* Whether `fn`, a function of `program`, redirects or errors on every call (see `loadNeverRenders`).
|
|
4649
|
+
* `building` holds the names `building` from `$app/environment` is imported as: an `if` on one of them
|
|
4650
|
+
* is read with the test false, as it is on every request.
|
|
4651
|
+
*/
|
|
4652
|
+
function alwaysExits(program, fn, locals, building = /* @__PURE__ */ new Set()) {
|
|
4653
|
+
const topLevel = collectTopLevelBindings(program);
|
|
4654
|
+
const fnRedirects = (fn, seen, observed) => {
|
|
4655
|
+
const shadow = /* @__PURE__ */ new Set();
|
|
4656
|
+
for (const p of fn.params ?? []) addBoundNames(p, shadow);
|
|
4657
|
+
const frame = {
|
|
4658
|
+
seen: /* @__PURE__ */ new Set([...seen, fn]),
|
|
4659
|
+
observed
|
|
4660
|
+
};
|
|
4661
|
+
return fn.body?.type === "BlockStatement" ? always(fn.body.body, shadow, void 0, frame) : isRedirect(fn.body, shadow, frame, observed);
|
|
4662
|
+
};
|
|
4663
|
+
const isRedirect = (expr, shadow, frame, returned = false) => {
|
|
4664
|
+
let e = unwrapTs(expr);
|
|
4665
|
+
const awaited = e?.type === "AwaitExpression";
|
|
4666
|
+
if (awaited) e = unwrapTs(e.argument);
|
|
4667
|
+
if (e?.type !== "CallExpression" || e.callee?.type !== "Identifier" || shadow.has(e.callee.name)) return false;
|
|
4668
|
+
if (locals.has(e.callee.name)) return true;
|
|
4669
|
+
const fn = topLevel.get(e.callee.name);
|
|
4670
|
+
if (!isFunctionNode(fn) || fn.generator || frame.seen.has(fn) || fn.async && !awaited && !returned) return false;
|
|
4671
|
+
return fnRedirects(fn, frame.seen, awaited || returned);
|
|
4672
|
+
};
|
|
4673
|
+
const within = (body, outer, param) => {
|
|
4674
|
+
const own = /* @__PURE__ */ new Set();
|
|
4675
|
+
addBoundNames(param, own);
|
|
4676
|
+
for (const stmt of body ?? []) if (stmt?.type === "VariableDeclaration") for (const d of stmt.declarations ?? []) addBoundNames(d?.id, own);
|
|
4677
|
+
else if ((stmt?.type === "FunctionDeclaration" || stmt?.type === "ClassDeclaration") && stmt.id) addBoundNames(stmt.id, own);
|
|
4678
|
+
return own.size === 0 ? outer : /* @__PURE__ */ new Set([...outer, ...own]);
|
|
4679
|
+
};
|
|
4680
|
+
const redirects = (stmt, shadow, rethrow, frame) => {
|
|
4681
|
+
if (stmt?.type === "ExpressionStatement") return isRedirect(stmt.expression, shadow, frame);
|
|
4682
|
+
if (stmt?.type === "ThrowStatement" || stmt?.type === "ReturnStatement") {
|
|
4683
|
+
if (isRedirect(stmt.argument, shadow, frame, stmt.type === "ReturnStatement" && frame.observed)) return true;
|
|
4684
|
+
return stmt.type === "ThrowStatement" && stmt.argument?.type === "Identifier" && stmt.argument.name === rethrow;
|
|
4685
|
+
}
|
|
4686
|
+
if (stmt?.type === "BlockStatement") return always(stmt.body, shadow, rethrow, frame);
|
|
4687
|
+
if (stmt?.type === "IfStatement") return !!stmt.alternate && redirects(stmt.consequent, shadow, rethrow, frame) && redirects(stmt.alternate, shadow, rethrow, frame);
|
|
4688
|
+
if (stmt?.type === "TryStatement") {
|
|
4689
|
+
const param = stmt.handler?.param?.type === "Identifier" ? stmt.handler.param.name : void 0;
|
|
4690
|
+
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);
|
|
4691
|
+
}
|
|
4692
|
+
return false;
|
|
4693
|
+
};
|
|
4694
|
+
const plainReturn = (node, shadow, frame) => {
|
|
4695
|
+
if (Array.isArray(node)) return node.some((n) => plainReturn(n, shadow, frame));
|
|
4696
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string" || isFunctionNode(node)) return false;
|
|
4697
|
+
if (node.type === "ReturnStatement") return !isRedirect(node.argument, shadow, frame, frame.observed);
|
|
4698
|
+
const scope = node.type === "BlockStatement" ? within(node.body, shadow) : shadow;
|
|
4699
|
+
return Object.keys(node).some((key) => !WALK_IGNORED_KEYS.has(key) && plainReturn(node[key], scope, frame));
|
|
4700
|
+
};
|
|
4701
|
+
const always = (body, outer, rethrow, frame) => {
|
|
4702
|
+
const shadow = within(body, outer);
|
|
4703
|
+
for (let stmt of body ?? []) {
|
|
4704
|
+
const test = stmt?.type === "IfStatement" ? unwrapTs(stmt.test) : void 0;
|
|
4705
|
+
if (test?.type === "Identifier" && building.has(test.name) && !shadow.has(test.name)) {
|
|
4706
|
+
if (!stmt.alternate) continue;
|
|
4707
|
+
stmt = stmt.alternate;
|
|
4708
|
+
}
|
|
4709
|
+
if (redirects(stmt, shadow, rethrow, frame)) return true;
|
|
4710
|
+
if (plainReturn(stmt, shadow, frame)) return false;
|
|
4711
|
+
}
|
|
4712
|
+
return false;
|
|
4713
|
+
};
|
|
4714
|
+
return fnRedirects(fn, /* @__PURE__ */ new Set(), true);
|
|
4715
|
+
}
|
|
4716
|
+
/**
|
|
4717
|
+
* The binding a load's leading flag guard reads: a top-level `if (!FLAG) <exit>` (or `!NS.FLAG`) before
|
|
4718
|
+
* any `return`, where `FLAG` is imported from a repo-local module. When that export is the literal
|
|
4719
|
+
* `false`, every request takes the exit; `collectKitModuleFacts` reads the module to decide.
|
|
4720
|
+
*/
|
|
4721
|
+
function leadingFlagGuard(program, exits, imported) {
|
|
4722
|
+
const load = exits.size > 0 ? findLoadFunction(program) : void 0;
|
|
4723
|
+
if (load?.body?.type !== "BlockStatement") return void 0;
|
|
4724
|
+
const local = /* @__PURE__ */ new Set();
|
|
4725
|
+
for (const param of load.params ?? []) addBoundNames(param, local);
|
|
4726
|
+
for (const stmt of load.body.body) if (stmt?.type === "VariableDeclaration") for (const d of stmt.declarations ?? []) addBoundNames(d?.id, local);
|
|
4727
|
+
else if ((stmt?.type === "FunctionDeclaration" || stmt?.type === "ClassDeclaration") && stmt.id) local.add(stmt.id.name);
|
|
4728
|
+
const exitCall = (e) => {
|
|
4729
|
+
const x = unwrapTs(e);
|
|
4730
|
+
return x?.type === "CallExpression" && x.callee?.type === "Identifier" && exits.has(x.callee.name) && !local.has(x.callee.name);
|
|
4731
|
+
};
|
|
4732
|
+
const exitsFirst = (stmt) => {
|
|
4733
|
+
if (stmt?.type === "BlockStatement") {
|
|
4734
|
+
for (const inner of stmt.body) {
|
|
4735
|
+
if (exitsFirst(inner)) return true;
|
|
4736
|
+
if (containsReturn(inner)) return false;
|
|
4737
|
+
}
|
|
4738
|
+
return false;
|
|
4739
|
+
}
|
|
4740
|
+
return stmt?.type === "ExpressionStatement" && exitCall(stmt.expression) || (stmt?.type === "ThrowStatement" || stmt?.type === "ReturnStatement") && exitCall(stmt.argument);
|
|
4741
|
+
};
|
|
4742
|
+
for (const stmt of load.body.body) {
|
|
4743
|
+
const test = stmt?.type === "IfStatement" && exitsFirst(stmt.consequent) ? unwrapTs(stmt.test) : void 0;
|
|
4744
|
+
if (test?.type === "UnaryExpression" && test.operator === "!") {
|
|
4745
|
+
const arg = unwrapTs(test.argument);
|
|
4746
|
+
const root = arg?.type === "MemberExpression" && !arg.computed ? unwrapTs(arg.object) : arg;
|
|
4747
|
+
const member = arg !== root && arg.property?.type === "Identifier" ? arg.property.name : void 0;
|
|
4748
|
+
const from = root?.type === "Identifier" && !local.has(root.name) && (arg === root || member) ? imported(root.name) : void 0;
|
|
4749
|
+
if (from) return {
|
|
4750
|
+
...from,
|
|
4751
|
+
...member ? { member } : {}
|
|
4752
|
+
};
|
|
4753
|
+
}
|
|
4754
|
+
if (containsReturn(stmt)) return void 0;
|
|
4755
|
+
}
|
|
4756
|
+
}
|
|
4757
|
+
/** The imported function `export const load = factory(…)` calls, for `collectKitModuleFacts` to read. */
|
|
4758
|
+
function loadFactory(program, imported) {
|
|
4759
|
+
let call;
|
|
4760
|
+
forEachNamedExport(program, (name, value) => {
|
|
4761
|
+
if (name !== "load") return void 0;
|
|
4762
|
+
call = unwrapTs(value);
|
|
4763
|
+
return true;
|
|
4764
|
+
});
|
|
4765
|
+
return call?.type === "CallExpression" && call.callee?.type === "Identifier" ? imported(call.callee.name) : void 0;
|
|
4766
|
+
}
|
|
4767
|
+
/**
|
|
4768
|
+
* The exported functions of a module that return a function which redirects or errors on every call
|
|
4769
|
+
* (`createRedirect = (to) => () => redirect(307, to)`), read like a `load` that does.
|
|
4770
|
+
*/
|
|
4771
|
+
function parseExitFactoryExports(source, filename) {
|
|
4772
|
+
const { program } = parseModuleProgram(source, filename);
|
|
4773
|
+
const exits = collectNamedImportAliases(program, "@sveltejs/kit", EXIT_NAMES);
|
|
4774
|
+
const names = /* @__PURE__ */ new Set();
|
|
4775
|
+
if (exits.size === 0) return names;
|
|
4776
|
+
forEachNamedExport(program, (name, value) => {
|
|
4777
|
+
if (!isFunctionNode(value)) return void 0;
|
|
4778
|
+
const first = (value.body?.type === "BlockStatement" ? value.body.body : [{
|
|
4779
|
+
type: "ReturnStatement",
|
|
4780
|
+
argument: value.body
|
|
4781
|
+
}]).find((stmt) => containsReturn(stmt));
|
|
4782
|
+
const returned = first?.type === "ReturnStatement" ? first.argument : void 0;
|
|
4783
|
+
if (isFunctionNode(returned) && alwaysExits(program, returned, exits)) names.add(name);
|
|
4784
|
+
});
|
|
4785
|
+
return names;
|
|
4786
|
+
}
|
|
4787
|
+
/** The exports of a module that are the literal `false`, as `NAME` or `NAME.member` (an object literal's own property). */
|
|
4788
|
+
function parseFalseExports(source, filename) {
|
|
4789
|
+
return collectFalseExports(parseModuleProgram(source, filename).program);
|
|
4790
|
+
}
|
|
4791
|
+
/** `parseFalseExports` over a parsed module. */
|
|
4792
|
+
function collectFalseExports(program) {
|
|
4793
|
+
const names = /* @__PURE__ */ new Set();
|
|
4794
|
+
for (const stmt of program?.body ?? []) {
|
|
4795
|
+
if (stmt?.type !== "ExportNamedDeclaration" || stmt.declaration?.type !== "VariableDeclaration") continue;
|
|
4796
|
+
if (stmt.declaration.kind !== "const") continue;
|
|
4797
|
+
for (const d of stmt.declaration.declarations ?? []) {
|
|
4798
|
+
const init = unwrapTs(d?.init);
|
|
4799
|
+
if (d?.id?.type !== "Identifier") continue;
|
|
4800
|
+
if (init?.type === "Literal" && init.value === false) names.add(d.id.name);
|
|
4801
|
+
if (init?.type === "ObjectExpression") {
|
|
4802
|
+
const members = /* @__PURE__ */ new Map();
|
|
4803
|
+
for (const p of init.properties ?? []) {
|
|
4804
|
+
const v = unwrapTs(p?.value);
|
|
4805
|
+
if (p?.type !== "Property" || p.computed || p.key?.type !== "Identifier") members.clear();
|
|
4806
|
+
else members.set(p.key.name, v?.type === "Literal" && v.value === false);
|
|
4807
|
+
}
|
|
4808
|
+
for (const [key, isFalse] of members) if (isFalse) names.add(`${d.id.name}.${key}`);
|
|
4809
|
+
}
|
|
4810
|
+
}
|
|
4811
|
+
}
|
|
4812
|
+
return names;
|
|
4813
|
+
}
|
|
4814
|
+
/**
|
|
4815
|
+
* Whether the exported `load` sends a request without a session away before rendering: a top-level
|
|
4816
|
+
* `if` whose test is only negated reads of the request's `locals` (`!locals.user`,
|
|
4817
|
+
* `!(await locals.auth())`, or a `const` taken from one earlier in the body) and whose branch
|
|
4818
|
+
* redirects or errors 401/403. A test that also reads anything else — the URL, params — gates only
|
|
4819
|
+
* some requests and does not count; neither does a gate after a `return`.
|
|
4820
|
+
*/
|
|
4821
|
+
function loadGatesOnLocals(program) {
|
|
4822
|
+
const redirects = collectNamedImportAliases(program, "@sveltejs/kit", REDIRECT_NAMES);
|
|
4823
|
+
const errors = collectNamedImportAliases(program, "@sveltejs/kit", ERROR_NAMES);
|
|
4824
|
+
const load = redirects.size + errors.size > 0 ? findLoadFunction(program) : void 0;
|
|
4825
|
+
if (load?.body?.type !== "BlockStatement") return false;
|
|
4826
|
+
const param = load.params?.[0];
|
|
4827
|
+
const event = param?.type === "Identifier" ? param.name : void 0;
|
|
4828
|
+
const derived = /* @__PURE__ */ new Set();
|
|
4829
|
+
if (param?.type === "ObjectPattern") {
|
|
4830
|
+
for (const p of param.properties ?? []) if (p?.type === "Property" && (p.key?.name ?? p.key?.value) === "locals") addBoundNames(p.value, derived);
|
|
4831
|
+
}
|
|
4832
|
+
const fromLocals = (expr) => {
|
|
4833
|
+
let cur = unwrapTs(expr);
|
|
4834
|
+
let prev;
|
|
4835
|
+
for (;;) if (cur?.type === "AwaitExpression" || cur?.type === "ChainExpression") cur = unwrapTs(cur.argument ?? cur.expression);
|
|
4836
|
+
else if (cur?.type === "CallExpression" && (cur.arguments ?? []).length === 0) cur = unwrapTs(cur.callee);
|
|
4837
|
+
else if (cur?.type === "MemberExpression" && !cur.computed) [prev, cur] = [cur, unwrapTs(cur.object)];
|
|
4838
|
+
else break;
|
|
4839
|
+
if (cur?.type !== "Identifier") return false;
|
|
4840
|
+
return derived.has(cur.name) || cur.name === event && prev?.computed === false && prev.property?.name === "locals";
|
|
4841
|
+
};
|
|
4842
|
+
const negated = (test) => {
|
|
4843
|
+
const t = unwrapTs(test);
|
|
4844
|
+
if (t?.type === "LogicalExpression") return t.operator === "||" && negated(t.left) && negated(t.right);
|
|
4845
|
+
return t?.type === "UnaryExpression" && t.operator === "!" && fromLocals(t.argument);
|
|
4846
|
+
};
|
|
4847
|
+
const exitCall = (expr) => {
|
|
4848
|
+
const e = unwrapTs(expr);
|
|
4849
|
+
if (e?.type !== "CallExpression" || e.callee?.type !== "Identifier") return false;
|
|
4850
|
+
if (redirects.has(e.callee.name)) return true;
|
|
4851
|
+
if (!errors.has(e.callee.name)) return false;
|
|
4852
|
+
const status = e.arguments?.[0];
|
|
4853
|
+
return status?.type === "Literal" && (status.value === 401 || status.value === 403);
|
|
4854
|
+
};
|
|
4855
|
+
const exitsNow = (stmt) => {
|
|
4856
|
+
if (stmt?.type !== "BlockStatement") return stmt?.type === "ExpressionStatement" && exitCall(stmt.expression) || (stmt?.type === "ThrowStatement" || stmt?.type === "ReturnStatement") && exitCall(stmt.argument);
|
|
4857
|
+
for (const inner of stmt.body) {
|
|
4858
|
+
if (exitsNow(inner)) return true;
|
|
4859
|
+
if (containsReturn(inner)) return false;
|
|
4860
|
+
}
|
|
4861
|
+
return false;
|
|
4862
|
+
};
|
|
4863
|
+
for (const stmt of load.body.body) {
|
|
4864
|
+
if (stmt?.type === "IfStatement" && negated(stmt.test) && exitsNow(stmt.consequent)) return true;
|
|
4865
|
+
if (containsReturn(stmt)) return false;
|
|
4866
|
+
if (stmt?.type === "VariableDeclaration" && stmt.kind === "const") {
|
|
4867
|
+
for (const d of stmt.declarations ?? []) if (d?.init && fromLocals(d.init)) addBoundNames(d.id, derived);
|
|
4868
|
+
}
|
|
4869
|
+
}
|
|
4870
|
+
return false;
|
|
4871
|
+
}
|
|
4872
|
+
/**
|
|
3829
4873
|
* Whether `await`'s argument is a `parent()` / `<x>.parent()` call (Kit's parent-load
|
|
3830
4874
|
* step, exempt from performance/load-waterfall and performance/sequential-awaits). Any `<expr>.parent()` member call matches — over-broad
|
|
3831
4875
|
* in the false-negative direction only, which is the conservative side.
|
|
@@ -3845,6 +4889,7 @@ const BODY_METHODS = /* @__PURE__ */ new Set([
|
|
|
3845
4889
|
"formData",
|
|
3846
4890
|
"bytes"
|
|
3847
4891
|
]);
|
|
4892
|
+
const PROMISE_COMBINATORS = /* @__PURE__ */ new Set(["all", "allSettled"]);
|
|
3848
4893
|
/**
|
|
3849
4894
|
* Whether `await`'s argument is a response-body read (`res.json()`, `res.text()`, …):
|
|
3850
4895
|
* parsing an already-received body costs no extra round trip, so it is not a
|
|
@@ -3853,9 +4898,32 @@ const BODY_METHODS = /* @__PURE__ */ new Set([
|
|
|
3853
4898
|
*/
|
|
3854
4899
|
function isBodyParseCall(arg) {
|
|
3855
4900
|
const e = unwrapTs(arg);
|
|
3856
|
-
if (e?.type !== "CallExpression"
|
|
4901
|
+
if (e?.type !== "CallExpression") return false;
|
|
3857
4902
|
const callee = e.callee;
|
|
3858
|
-
|
|
4903
|
+
if (callee?.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return false;
|
|
4904
|
+
if (BODY_METHODS.has(callee.property.name)) return !e.arguments?.length;
|
|
4905
|
+
const list = e.arguments?.length === 1 ? unwrapTs(e.arguments[0]) : void 0;
|
|
4906
|
+
return callee.object.type === "Identifier" && callee.object.name === "Promise" && PROMISE_COMBINATORS.has(callee.property.name) && list?.type === "ArrayExpression" && list.elements.length > 0 && list.elements.every((el) => el && isBodyParseCall(el));
|
|
4907
|
+
}
|
|
4908
|
+
const WORK_STARTING = /* @__PURE__ */ new Set([
|
|
4909
|
+
"CallExpression",
|
|
4910
|
+
"NewExpression",
|
|
4911
|
+
"ImportExpression",
|
|
4912
|
+
"TaggedTemplateExpression"
|
|
4913
|
+
]);
|
|
4914
|
+
/** Whether an await argument starts work (a call, tagged template, `new`, or `import()`), as opposed to awaiting an existing promise. */
|
|
4915
|
+
function startsWork(arg) {
|
|
4916
|
+
let e = unwrapTs(arg);
|
|
4917
|
+
if (e?.type === "ChainExpression") e = e.expression;
|
|
4918
|
+
return WORK_STARTING.has(e?.type);
|
|
4919
|
+
}
|
|
4920
|
+
/** Whether evaluating `node` may start work: a work-starting expression anywhere outside a nested function. */
|
|
4921
|
+
function containsWork(node) {
|
|
4922
|
+
if (Array.isArray(node)) return node.some(containsWork);
|
|
4923
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string") return false;
|
|
4924
|
+
if (isFunctionNode(node)) return false;
|
|
4925
|
+
if (WORK_STARTING.has(node.type)) return true;
|
|
4926
|
+
return Object.keys(node).some((key) => !WALK_IGNORED_KEYS.has(key) && containsWork(node[key]));
|
|
3859
4927
|
}
|
|
3860
4928
|
/**
|
|
3861
4929
|
* Whether the expression references any tainted name. Threads nested-function
|
|
@@ -3889,15 +4957,36 @@ function refsTainted(node, tainted) {
|
|
|
3889
4957
|
return hit;
|
|
3890
4958
|
}
|
|
3891
4959
|
/**
|
|
4960
|
+
* Awaits that only run when a tainted value says so — `a ?? await x`, `t ? await x : y` — and so
|
|
4961
|
+
* cannot start before the earlier await that decides them.
|
|
4962
|
+
*/
|
|
4963
|
+
function guardedAwaits(node, tainted, out = /* @__PURE__ */ new Set()) {
|
|
4964
|
+
if (Array.isArray(node)) {
|
|
4965
|
+
for (const child of node) guardedAwaits(child, tainted, out);
|
|
4966
|
+
return out;
|
|
4967
|
+
}
|
|
4968
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string" || isFunctionNode(node)) return out;
|
|
4969
|
+
if (node.type === "LogicalExpression" && refsTainted(node.left, tainted)) for (const a of collectAwaits(node.right)) out.add(a);
|
|
4970
|
+
else if (node.type === "ConditionalExpression" && refsTainted(node.test, tainted)) for (const a of collectAwaits([node.consequent, node.alternate])) out.add(a);
|
|
4971
|
+
for (const key of Object.keys(node)) {
|
|
4972
|
+
if (WALK_IGNORED_KEYS.has(key)) continue;
|
|
4973
|
+
guardedAwaits(node[key], tainted, out);
|
|
4974
|
+
}
|
|
4975
|
+
return out;
|
|
4976
|
+
}
|
|
4977
|
+
/**
|
|
3892
4978
|
* performance/load-waterfall, performance/sequential-awaits — forward-taint analysis of the exported `load`'s straight-line
|
|
3893
4979
|
* statements (direct `try` blocks inlined; `if`/loops/`switch` are not classified
|
|
3894
4980
|
* but still propagate taint from their assignments; nested functions are never
|
|
3895
4981
|
* entered). One await site per statement; a site whose awaits' argument subtrees
|
|
3896
4982
|
* reference an earlier site's bindings (transitively, through intermediate consts
|
|
3897
|
-
* and assignments — member-expression targets taint their root object)
|
|
3898
|
-
*
|
|
3899
|
-
*
|
|
3900
|
-
*
|
|
4983
|
+
* and assignments — member-expression targets taint their root object), or that a
|
|
4984
|
+
* tainted `??`/`&&`/`||`/`?:` guard decides (`guardedAwaits`), is
|
|
4985
|
+
* dependent, anchored at the first dependent await — unless it starts no work and its
|
|
4986
|
+
* promise came straight from an await result (`await deferred.state` after
|
|
4987
|
+
* `await parent()`); otherwise independent when a prior site exists, unless every
|
|
4988
|
+
* await merely resumes an already-created promise (a bare identifier argument starts
|
|
4989
|
+
* no request). `await parent()` and
|
|
3901
4990
|
* response-body reads are never sites, but their bindings taint. Lines are
|
|
3902
4991
|
* returned in ORIGINAL-source coordinates (the −1 wrap shift is applied here).
|
|
3903
4992
|
*/
|
|
@@ -3911,12 +5000,60 @@ function collectLoadWaterfalls(program, wrapped) {
|
|
|
3911
5000
|
};
|
|
3912
5001
|
const line = (start) => Math.max(0, lineOf(wrapped, start) - 1);
|
|
3913
5002
|
const tainted = /* @__PURE__ */ new Set();
|
|
5003
|
+
const ownBindings = /* @__PURE__ */ new Set();
|
|
5004
|
+
const collectOwn = (body) => {
|
|
5005
|
+
for (const stmt of body ?? []) {
|
|
5006
|
+
if (stmt?.type === "TryStatement") collectOwn(stmt.block?.body);
|
|
5007
|
+
if (stmt?.type !== "VariableDeclaration") continue;
|
|
5008
|
+
for (const d of stmt.declarations ?? []) {
|
|
5009
|
+
let init = unwrapTs(d?.init);
|
|
5010
|
+
if (init?.type === "AwaitExpression") init = unwrapTs(init.argument);
|
|
5011
|
+
if (init?.type === "CallExpression" || init?.type === "NewExpression") addBoundNames(d?.id, ownBindings);
|
|
5012
|
+
}
|
|
5013
|
+
}
|
|
5014
|
+
};
|
|
5015
|
+
collectOwn(load.body.body);
|
|
5016
|
+
const syncTainted = /* @__PURE__ */ new Set();
|
|
3914
5017
|
let sawAwaitSite = false;
|
|
3915
|
-
const
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
5018
|
+
const taintTarget = (target, rhs) => {
|
|
5019
|
+
const awaited = collectAwaits(rhs).length > 0;
|
|
5020
|
+
if (!awaited && !refsTainted(rhs, tainted)) return;
|
|
5021
|
+
const names = /* @__PURE__ */ new Set();
|
|
5022
|
+
if (target?.type === "MemberExpression") {
|
|
5023
|
+
const root = rootObjectName(target);
|
|
5024
|
+
if (root) names.add(root);
|
|
5025
|
+
} else addBoundNames(target, names);
|
|
5026
|
+
for (const name of names) {
|
|
5027
|
+
tainted.add(name);
|
|
5028
|
+
if (!awaited && (containsWork(rhs) || refsTainted(rhs, syncTainted))) syncTainted.add(name);
|
|
5029
|
+
}
|
|
5030
|
+
};
|
|
5031
|
+
const taintMutation = (call) => {
|
|
5032
|
+
const callee = unwrapTs(call?.callee);
|
|
5033
|
+
if (call?.type !== "CallExpression" || callee?.type !== "MemberExpression" || callee.computed) return;
|
|
5034
|
+
if (!MUTATING_METHODS.has(callee.property?.name) || !(call.arguments ?? []).some((a) => refsTainted(a, tainted))) return;
|
|
5035
|
+
const root = rootObjectName(callee);
|
|
5036
|
+
if (root) tainted.add(root);
|
|
5037
|
+
};
|
|
5038
|
+
const taintLoopFill = (call) => {
|
|
5039
|
+
const callee = unwrapTs(call?.callee);
|
|
5040
|
+
if (call?.type !== "CallExpression" || callee?.type !== "MemberExpression" || callee.computed) return;
|
|
5041
|
+
if (!ITERATING_METHODS.has(callee.property?.name) || !refsTainted(callee.object, tainted)) return;
|
|
5042
|
+
const fill = (node) => {
|
|
5043
|
+
if (Array.isArray(node)) return node.forEach(fill);
|
|
5044
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string") return;
|
|
5045
|
+
if (isFunctionNode(node)) return;
|
|
5046
|
+
const c = node.type === "CallExpression" ? unwrapTs(node.callee) : void 0;
|
|
5047
|
+
if (c?.type === "MemberExpression" && !c.computed && MUTATING_METHODS.has(c.property?.name)) {
|
|
5048
|
+
const root = rootObjectName(c);
|
|
5049
|
+
if (root) tainted.add(root);
|
|
5050
|
+
}
|
|
5051
|
+
if (c?.type === "MemberExpression" && !c.computed && ITERATING_METHODS.has(c.property?.name)) {
|
|
5052
|
+
for (const arg of node.arguments ?? []) if (isFunctionNode(arg)) fill(arg.body);
|
|
5053
|
+
}
|
|
5054
|
+
for (const key of Object.keys(node)) if (!WALK_IGNORED_KEYS.has(key)) fill(node[key]);
|
|
5055
|
+
};
|
|
5056
|
+
for (const arg of call.arguments ?? []) if (isFunctionNode(arg)) fill(arg.body);
|
|
3920
5057
|
};
|
|
3921
5058
|
const taintOnly = (node) => {
|
|
3922
5059
|
if (Array.isArray(node)) {
|
|
@@ -3925,10 +5062,19 @@ function collectLoadWaterfalls(program, wrapped) {
|
|
|
3925
5062
|
}
|
|
3926
5063
|
if (!node || typeof node !== "object" || typeof node.type !== "string") return;
|
|
3927
5064
|
if (isFunctionNode(node)) return;
|
|
3928
|
-
if (node.type === "AssignmentExpression")
|
|
3929
|
-
|
|
5065
|
+
if (node.type === "AssignmentExpression") taintTarget(node.left, node.right);
|
|
5066
|
+
else if (node.type === "CallExpression") {
|
|
5067
|
+
taintMutation(node);
|
|
5068
|
+
taintLoopFill(node);
|
|
5069
|
+
} else if ((node.type === "ForOfStatement" || node.type === "ForInStatement") && refsTainted(node.right, tainted)) {
|
|
5070
|
+
const names = /* @__PURE__ */ new Set();
|
|
5071
|
+
addBoundNames(node.left?.type === "VariableDeclaration" ? node.left.declarations?.[0]?.id : node.left, names);
|
|
5072
|
+
for (const name of names) tainted.add(name);
|
|
3930
5073
|
} else if (node.type === "VariableDeclaration") {
|
|
3931
|
-
for (const d of node.declarations ?? []) if (d?.id && d.init
|
|
5074
|
+
for (const d of node.declarations ?? []) if (d?.id && d.init) {
|
|
5075
|
+
taintTarget(d.id, d.init);
|
|
5076
|
+
taintLoopFill(unwrapTs(d.init));
|
|
5077
|
+
}
|
|
3932
5078
|
}
|
|
3933
5079
|
for (const key of Object.keys(node)) {
|
|
3934
5080
|
if (WALK_IGNORED_KEYS.has(key)) continue;
|
|
@@ -3947,12 +5093,13 @@ function collectLoadWaterfalls(program, wrapped) {
|
|
|
3947
5093
|
if (stmt.type === "VariableDeclaration" || stmt.type === "ExpressionStatement" || stmt.type === "ReturnStatement") {
|
|
3948
5094
|
const sites = collectAwaits(stmt).filter((a) => !isParentCall(a.argument) && !isBodyParseCall(a.argument));
|
|
3949
5095
|
if (sites.length > 0) {
|
|
3950
|
-
const
|
|
5096
|
+
const guarded = guardedAwaits(stmt, tainted);
|
|
5097
|
+
const dependent = sites.filter((a) => (guarded.has(a) || refsTainted(a.argument, tainted)) && (startsWork(a.argument) || refsTainted(a.argument, syncTainted)));
|
|
3951
5098
|
if (dependent.length > 0) {
|
|
3952
5099
|
const anchor = dependent.reduce((m, a) => a.start < m.start ? a : m);
|
|
3953
5100
|
dependentLines.push(line(anchor.start));
|
|
3954
5101
|
} else if (sawAwaitSite) {
|
|
3955
|
-
const workSites = sites.filter((a) =>
|
|
5102
|
+
const workSites = sites.filter((a) => startsWork(a.argument));
|
|
3956
5103
|
if (workSites.length > 0) {
|
|
3957
5104
|
const anchor = workSites.reduce((m, a) => a.start < m.start ? a : m);
|
|
3958
5105
|
independentLines.push(line(anchor.start));
|
|
@@ -3960,15 +5107,22 @@ function collectLoadWaterfalls(program, wrapped) {
|
|
|
3960
5107
|
}
|
|
3961
5108
|
sawAwaitSite = true;
|
|
3962
5109
|
}
|
|
3963
|
-
if (stmt.type === "VariableDeclaration")
|
|
3964
|
-
if (
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
5110
|
+
if (stmt.type === "VariableDeclaration") {
|
|
5111
|
+
for (const d of stmt.declarations ?? []) if (d?.id && d.init) {
|
|
5112
|
+
taintTarget(d.id, d.init);
|
|
5113
|
+
taintLoopFill(unwrapTs(d.init));
|
|
5114
|
+
}
|
|
5115
|
+
} else if (stmt.type === "ExpressionStatement") {
|
|
3968
5116
|
const expr = unwrapTs(stmt.expression);
|
|
3969
|
-
if (expr?.type === "AssignmentExpression")
|
|
3970
|
-
|
|
5117
|
+
if (expr?.type === "AssignmentExpression") taintTarget(expr.left, expr.right);
|
|
5118
|
+
else {
|
|
5119
|
+
taintMutation(expr);
|
|
5120
|
+
taintLoopFill(expr);
|
|
3971
5121
|
}
|
|
5122
|
+
const call = expr?.type === "AwaitExpression" ? unwrapTs(expr.argument) : void 0;
|
|
5123
|
+
const callee = call?.type === "CallExpression" ? unwrapTs(call.callee) : void 0;
|
|
5124
|
+
const owner = callee?.type === "MemberExpression" && !callee.computed ? unwrapTs(callee.object) : void 0;
|
|
5125
|
+
if (owner?.type === "Identifier" && ownBindings.has(owner.name)) tainted.add(owner.name);
|
|
3972
5126
|
}
|
|
3973
5127
|
} else taintOnly(stmt);
|
|
3974
5128
|
}
|
|
@@ -4005,23 +5159,19 @@ function walkKit(node, handlerFns, startupFns, visit, shadowed = /* @__PURE__ */
|
|
|
4005
5159
|
}
|
|
4006
5160
|
}
|
|
4007
5161
|
/**
|
|
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.
|
|
5162
|
+
* Normalize a posix path, resolving `.` and `..` segments — string-only, no I/O. `..` segments
|
|
5163
|
+
* that climb above the root are kept as a leading `../` run (`../../plugins/x`).
|
|
4013
5164
|
*/
|
|
4014
5165
|
function normalizePosix(path) {
|
|
4015
5166
|
const out = [];
|
|
4016
5167
|
for (const seg of path.split("/")) {
|
|
4017
5168
|
if (seg === "" || seg === ".") continue;
|
|
4018
|
-
if (seg === "..")
|
|
4019
|
-
|
|
4020
|
-
out.pop();
|
|
4021
|
-
} else out.push(seg);
|
|
5169
|
+
if (seg === ".." && out.length > 0 && out[out.length - 1] !== "..") out.pop();
|
|
5170
|
+
else out.push(seg);
|
|
4022
5171
|
}
|
|
4023
5172
|
return out.join("/");
|
|
4024
5173
|
}
|
|
5174
|
+
const escapesRoot = (path) => path === ".." || path.startsWith("../");
|
|
4025
5175
|
/**
|
|
4026
5176
|
* What `resolveRepoLocalPath` assumes when no config was read: SvelteKit's own `$lib`, at the
|
|
4027
5177
|
* default `src/lib`. This single entry is the whole of this analyzer's pre-alias behaviour.
|
|
@@ -4048,8 +5198,12 @@ function aliasMatches(entry, spec) {
|
|
|
4048
5198
|
* be a project-local module: the caller's `aliases` list decides which non-relative
|
|
4049
5199
|
* specifiers resolve, defaulting to `$lib` → `src/lib`; `./`/`../` resolve against the
|
|
4050
5200
|
* 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
|
-
*
|
|
5201
|
+
* be resolved to a project-local path at all). A path above the project root (a leading `../`
|
|
5202
|
+
* run) resolves only inside a directory a `kit.alias` value itself names above the root
|
|
5203
|
+
* (`$plugins: '../../plugins'` in a monorepo app): the user's config says the app builds from
|
|
5204
|
+
* there, so it is read like `src/` — likewise inside a workspace package's directory an entry
|
|
5205
|
+
* names as its `root` — and anywhere else above the root is undefined, as nothing says the app
|
|
5206
|
+
* reaches it. Also undefined when a
|
|
4053
5207
|
* matched alias's value is itself absolute (e.g. `/opt/shared/src`, or a posixified Windows
|
|
4054
5208
|
* drive-letter path like `C:/shared/src`): an absolute target is outside the analyzed project by
|
|
4055
5209
|
* definition, and without this check `normalizePosix` would quietly drop the leading empty
|
|
@@ -4058,21 +5212,27 @@ function aliasMatches(entry, spec) {
|
|
|
4058
5212
|
* Exported from the package's public barrel because `architecture/private-scope-import`
|
|
4059
5213
|
* and `architecture/route-component-import` (inside `packages/core`) both need resolution
|
|
4060
5214
|
* that is not restricted to runes modules, unlike `resolveRunesModuleSpecifier` — and
|
|
4061
|
-
* because
|
|
5215
|
+
* because component resolution (`packages/cli/src/providers/source/resolve.ts`), which
|
|
4062
5216
|
* drives transitive `<head>`/heading resolution, delegates its alias/`$lib`/relative
|
|
4063
5217
|
* mapping here too, rather than duplicating it. This is the single site for every
|
|
4064
5218
|
* repo-local specifier resolution in the repo.
|
|
4065
5219
|
*/
|
|
4066
5220
|
function resolveRepoLocalPath(spec, importerFile, aliases = DEFAULT_KIT_ALIASES) {
|
|
4067
5221
|
let path;
|
|
4068
|
-
if (spec.startsWith("./") || spec.startsWith("../")) path = `${importerFile.split("/").slice(0, -1).join("/")}/${spec}`;
|
|
5222
|
+
if (spec === "." || spec === ".." || spec.startsWith("./") || spec.startsWith("../")) path = `${importerFile.split("/").slice(0, -1).join("/")}/${spec}`;
|
|
4069
5223
|
else {
|
|
4070
5224
|
const entry = aliases.find((a) => aliasMatches(a, spec));
|
|
4071
5225
|
if (entry?.replacement == null) return void 0;
|
|
4072
5226
|
if (entry.replacement.startsWith("/") || /^[A-Za-z]:\//.test(entry.replacement)) return void 0;
|
|
4073
|
-
path = entry.replacement + spec.slice(entry.find.length);
|
|
5227
|
+
path = entry.replacement + spec.slice(entry.find.length) + (entry.suffix ?? "");
|
|
4074
5228
|
}
|
|
4075
|
-
|
|
5229
|
+
const normalized = normalizePosix(path);
|
|
5230
|
+
if (!escapesRoot(normalized)) return normalized;
|
|
5231
|
+
return aliases.some((a) => {
|
|
5232
|
+
const named = a.root ?? a.replacement;
|
|
5233
|
+
const dir = named === null ? "" : normalizePosix(named);
|
|
5234
|
+
return escapesRoot(dir) && (normalized === dir || normalized.startsWith(`${dir}/`));
|
|
5235
|
+
}) ? normalized : void 0;
|
|
4076
5236
|
}
|
|
4077
5237
|
/**
|
|
4078
5238
|
* Resolve an import specifier to a repo-relative `.svelte.ts`/`.svelte.js` path, or
|
|
@@ -4080,7 +5240,7 @@ function resolveRepoLocalPath(spec, importerFile, aliases = DEFAULT_KIT_ALIASES)
|
|
|
4080
5240
|
* `aliases` (defaulting to `$lib` → `src/lib` when omitted), so a project's declared
|
|
4081
5241
|
* `kit.alias`/`kit.files.lib` resolve here exactly as they do at rule time; `./`/`../`
|
|
4082
5242
|
* resolve against the importing file's directory; bare packages, unmatched aliases, and
|
|
4083
|
-
* a
|
|
5243
|
+
* a path above the project root outside every `kit.alias` directory are skipped. An
|
|
4084
5244
|
* extensionless `…/x.svelte` specifier canonicalises to `….svelte.ts` (security/shared-state-import also
|
|
4085
5245
|
* tries the `.js` sibling when matching).
|
|
4086
5246
|
*/
|
|
@@ -4115,8 +5275,8 @@ function libServerRoot(aliases) {
|
|
|
4115
5275
|
* an equivalent relative path, resolving to exactly that directory — importing the
|
|
4116
5276
|
* index file rather than a named submodule) is exempt too. A specifier that
|
|
4117
5277
|
* 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
|
-
* `
|
|
5278
|
+
* a relative specifier whose `..` segments escape the project root (outside every
|
|
5279
|
+
* `kit.alias` directory, see `resolveRepoLocalPath`) — is conservatively NOT local state: we can't see that file, so
|
|
4120
5280
|
* we don't flag writes to it. Installed packages (drizzle, redis, @vercel/kv, …) are
|
|
4121
5281
|
* excluded: `.set()`/`.update()` on those is persistence, not shared-module-state
|
|
4122
5282
|
* mutation. When the `$lib` entry is opaque (`libServerRoot` returns undefined — an unreadable
|
|
@@ -4125,13 +5285,9 @@ function libServerRoot(aliases) {
|
|
|
4125
5285
|
* risks a false positive in this default-on security rule, and staying silent only costs a
|
|
4126
5286
|
* missed finding.
|
|
4127
5287
|
*/
|
|
4128
|
-
/** The resolved repo path
|
|
4129
|
-
function
|
|
4130
|
-
|
|
4131
|
-
if (serverRoot === void 0) return void 0;
|
|
4132
|
-
const path = resolveRepoLocalPath(spec, importerFile, aliases);
|
|
4133
|
-
if (path === void 0) return void 0;
|
|
4134
|
-
return path === serverRoot || path.startsWith(`${serverRoot}/`) ? path : void 0;
|
|
5288
|
+
/** The resolved repo path of a repo-local `spec`, or undefined when it is a package or the `$lib` root is opaque. */
|
|
5289
|
+
function localModulePath(spec, importerFile, aliases) {
|
|
5290
|
+
return libServerRoot(aliases) === void 0 ? void 0 : resolveRepoLocalPath(spec, importerFile, aliases);
|
|
4135
5291
|
}
|
|
4136
5292
|
function isLocalStateSpecifier(spec, importerFile, aliases) {
|
|
4137
5293
|
const serverRoot = libServerRoot(aliases);
|
|
@@ -4147,15 +5303,62 @@ const IN_MEMORY_CTORS = /* @__PURE__ */ new Set([
|
|
|
4147
5303
|
"WeakMap",
|
|
4148
5304
|
"WeakSet"
|
|
4149
5305
|
]);
|
|
4150
|
-
|
|
4151
|
-
|
|
5306
|
+
const isContainerCtor = (n) => n?.type === "NewExpression" && n.callee?.type === "Identifier" && IN_MEMORY_CTORS.has(n.callee.name);
|
|
5307
|
+
/**
|
|
5308
|
+
* Whether an initializer positively identifies an in-memory container. An object literal with a
|
|
5309
|
+
* spread is a composite of values this parse cannot see — typically a facade over clients and
|
|
5310
|
+
* imported modules (`{ ...prismaModels, ...handlers }`) — so it counts only when one of its own
|
|
5311
|
+
* properties is itself a container. One whose properties are all functions (`{ get, set }` of the
|
|
5312
|
+
* module's own functions) holds no data: `.set()` on it calls its own method.
|
|
5313
|
+
*/
|
|
5314
|
+
function isInMemoryInit(init, fns = /* @__PURE__ */ new Set()) {
|
|
4152
5315
|
if (!init) return false;
|
|
4153
|
-
if (init.type === "
|
|
5316
|
+
if (init.type === "ArrayExpression") return true;
|
|
5317
|
+
if (init.type === "ObjectExpression") {
|
|
5318
|
+
const props = init.properties ?? [];
|
|
5319
|
+
const isFn = (p) => p?.type === "Property" && (p.method || p.value?.type === "FunctionExpression" || p.value?.type === "ArrowFunctionExpression" || p.value?.type === "Identifier" && fns.has(p.value.name));
|
|
5320
|
+
if (props.length > 0 && props.every(isFn)) return false;
|
|
5321
|
+
if (!props.some((p) => p?.type === "SpreadElement")) return true;
|
|
5322
|
+
return props.some((p) => p?.type === "Property" && isInMemoryInit(p.value, fns));
|
|
5323
|
+
}
|
|
4154
5324
|
return init.type === "NewExpression" && init.callee?.type === "Identifier" && IN_MEMORY_CTORS.has(init.callee.name);
|
|
4155
5325
|
}
|
|
4156
5326
|
/**
|
|
5327
|
+
* The exported bindings of a module that are a persistence or API client: `new C()` of a class imported
|
|
5328
|
+
* from an installed package, or of a class this module declares that neither extends another class nor
|
|
5329
|
+
* keeps a `Map`/`Set`/`WeakMap`/`WeakSet` field; or a call into an installed package (`drizzle(…)`,
|
|
5330
|
+
* `createClient(…)`). A write through one of them reaches a database or service, not module state.
|
|
5331
|
+
*/
|
|
5332
|
+
function parseClientExports(source, filename, aliases) {
|
|
5333
|
+
const names = /* @__PURE__ */ new Set();
|
|
5334
|
+
const { program } = parseModuleProgram(source, filename);
|
|
5335
|
+
const fromPackage = /* @__PURE__ */ new Set();
|
|
5336
|
+
for (const stmt of program?.body ?? []) {
|
|
5337
|
+
const src = stmt?.type === "ImportDeclaration" ? String(stmt.source?.value ?? "") : "";
|
|
5338
|
+
if (!(!src || /^[./$~#]|^@\//.test(src) || resolveRepoLocalPath(src, filename, aliases) !== void 0) && !/^svelte|store/i.test(src)) for (const sp of stmt.specifiers ?? []) fromPackage.add(sp.local.name);
|
|
5339
|
+
}
|
|
5340
|
+
const plainClasses = /* @__PURE__ */ new Set();
|
|
5341
|
+
for (const stmt of program?.body ?? []) {
|
|
5342
|
+
const decl = unwrapExport(stmt);
|
|
5343
|
+
if (decl?.type !== "ClassDeclaration" || !decl.id || decl.superClass) continue;
|
|
5344
|
+
if (!(decl.body?.body ?? []).some((m) => isContainerCtor(m?.value) || m?.kind === "constructor" && m.value?.body?.body?.some((st) => st?.type === "ExpressionStatement" && isContainerCtor(st.expression?.right)))) plainClasses.add(decl.id.name);
|
|
5345
|
+
}
|
|
5346
|
+
for (const stmt of program?.body ?? []) {
|
|
5347
|
+
if (stmt?.type !== "ExportNamedDeclaration" || stmt.declaration?.type !== "VariableDeclaration") continue;
|
|
5348
|
+
for (const d of stmt.declaration.declarations ?? []) {
|
|
5349
|
+
let init = unwrapTs(d?.init);
|
|
5350
|
+
if (init?.type === "AwaitExpression") init = unwrapTs(init.argument);
|
|
5351
|
+
if (d?.id?.type !== "Identifier") continue;
|
|
5352
|
+
const ctor = init?.type === "NewExpression" && init.callee?.type === "Identifier" ? init.callee.name : void 0;
|
|
5353
|
+
if (ctor !== void 0 && (fromPackage.has(ctor) || plainClasses.has(ctor))) names.add(d.id.name);
|
|
5354
|
+
if (init?.type === "CallExpression" && fromPackage.has(rootObjectName(init.callee) ?? "")) names.add(d.id.name);
|
|
5355
|
+
}
|
|
5356
|
+
}
|
|
5357
|
+
return names;
|
|
5358
|
+
}
|
|
5359
|
+
/**
|
|
4157
5360
|
* 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
|
|
5361
|
+
* or array literal (a spread literal only as `isInMemoryInit` allows), or `new Map`/`Set`/`WeakMap`/`WeakSet`. Used to arbitrate a `.set()` call on
|
|
4159
5362
|
* an import from under the `$lib` server root, where the call shape alone cannot separate a
|
|
4160
5363
|
* hand-rolled store from a database client.
|
|
4161
5364
|
*
|
|
@@ -4167,11 +5370,16 @@ function isInMemoryInit(init) {
|
|
|
4167
5370
|
function parseInMemoryExports(source, filename) {
|
|
4168
5371
|
const names = /* @__PURE__ */ new Set();
|
|
4169
5372
|
const { program } = parseModuleProgram(source, filename);
|
|
5373
|
+
const fns = /* @__PURE__ */ new Set();
|
|
5374
|
+
for (const stmt of program?.body ?? []) {
|
|
5375
|
+
const decl = unwrapExport(stmt);
|
|
5376
|
+
if (decl?.type === "FunctionDeclaration" && decl.id) fns.add(decl.id.name);
|
|
5377
|
+
}
|
|
4170
5378
|
for (const stmt of program?.body ?? []) {
|
|
4171
5379
|
if (stmt?.type !== "ExportNamedDeclaration" || !stmt.declaration) continue;
|
|
4172
5380
|
const decl = stmt.declaration;
|
|
4173
5381
|
if (decl.type !== "VariableDeclaration") continue;
|
|
4174
|
-
for (const d of decl.declarations ?? []) if (d?.id?.type === "Identifier" && isInMemoryInit(d.init)) names.add(d.id.name);
|
|
5382
|
+
for (const d of decl.declarations ?? []) if (d?.id?.type === "Identifier" && isInMemoryInit(d.init, fns)) names.add(d.id.name);
|
|
4175
5383
|
}
|
|
4176
5384
|
return names;
|
|
4177
5385
|
}
|
|
@@ -4187,6 +5395,7 @@ function parseKitModuleFacts(source, filename, aliases) {
|
|
|
4187
5395
|
const importedStateWrites = [];
|
|
4188
5396
|
const importedStateWritesOutsideHandlers = [];
|
|
4189
5397
|
const pendingServerStoreWrites = [];
|
|
5398
|
+
const storeWriteTargets = [];
|
|
4190
5399
|
const runesModuleImports = [];
|
|
4191
5400
|
const lifecycleCalls = [];
|
|
4192
5401
|
const browserGlobalRefs = [];
|
|
@@ -4204,6 +5413,12 @@ function parseKitModuleFacts(source, filename, aliases) {
|
|
|
4204
5413
|
const line = (start) => Math.max(0, lineOf(wrapped, start) - 1);
|
|
4205
5414
|
const importedSpecifiers = /* @__PURE__ */ new Map();
|
|
4206
5415
|
const importedNames = /* @__PURE__ */ new Map();
|
|
5416
|
+
const namespaceImports = /* @__PURE__ */ new Set();
|
|
5417
|
+
const localFunctions = /* @__PURE__ */ new Set();
|
|
5418
|
+
walkEstree(program, (n) => {
|
|
5419
|
+
if (n.type === "FunctionDeclaration" && n.id?.name) localFunctions.add(n.id.name);
|
|
5420
|
+
if (n.type === "VariableDeclarator" && n.id?.type === "Identifier" && n.init && isFunctionNode(unwrapTs(n.init))) localFunctions.add(n.id.name);
|
|
5421
|
+
});
|
|
4207
5422
|
for (const stmt of program.body ?? []) {
|
|
4208
5423
|
if (stmt?.type !== "ImportDeclaration" || stmt.importKind === "type") continue;
|
|
4209
5424
|
const spec = typeof stmt.source?.value === "string" ? stmt.source.value : "";
|
|
@@ -4212,6 +5427,7 @@ function parseKitModuleFacts(source, filename, aliases) {
|
|
|
4212
5427
|
if (s?.importKind === "type" || s?.local?.type !== "Identifier") continue;
|
|
4213
5428
|
names.push(s.local.name);
|
|
4214
5429
|
importedSpecifiers.set(s.local.name, spec);
|
|
5430
|
+
if (s.type === "ImportNamespaceSpecifier") namespaceImports.add(s.local.name);
|
|
4215
5431
|
importedNames.set(s.local.name, s.type === "ImportSpecifier" && s.imported?.type === "Identifier" ? s.imported.name : s.local.name);
|
|
4216
5432
|
}
|
|
4217
5433
|
if (names.length === 0) continue;
|
|
@@ -4234,7 +5450,7 @@ function parseKitModuleFacts(source, filename, aliases) {
|
|
|
4234
5450
|
const ssrOptOut = findFalseOptOut(program, wrapped, "ssr");
|
|
4235
5451
|
const csrOptOut = findFalseOptOut(program, wrapped, "csr");
|
|
4236
5452
|
const waterfalls = collectLoadWaterfalls(program, wrapped);
|
|
4237
|
-
if (!ssrOptOut) {
|
|
5453
|
+
if (!ssrOptOut?.literal) {
|
|
4238
5454
|
const shiftLine = (l) => Math.max(0, l - 1);
|
|
4239
5455
|
const browserImports = collectBrowserGuardImports(program);
|
|
4240
5456
|
const guards = /* @__PURE__ */ new Set([...browserImports, ...collectDerivedGuardBindings(program, browserImports)]);
|
|
@@ -4266,10 +5482,12 @@ function parseKitModuleFacts(source, filename, aliases) {
|
|
|
4266
5482
|
scanFn(fn, false);
|
|
4267
5483
|
}
|
|
4268
5484
|
}
|
|
5485
|
+
const browserOnly = browserOnlyRanges(program, collectBrowserGuardImports(program));
|
|
5486
|
+
const reachedOnlyByBrowser = (at) => browserOnly.some(([from, to]) => at >= from && at < to);
|
|
4269
5487
|
walkKit(program, handlerFns, startupFns, (n, shadowed, inFunction, inHandler, inStartup) => {
|
|
4270
5488
|
if (inFunction && !inStartup) {
|
|
4271
5489
|
const flagLet = (name) => {
|
|
4272
|
-
if (name && !shadowed.has(name) && moduleLets.has(name)) moduleStateReassignments.push({
|
|
5490
|
+
if (name && !shadowed.has(name) && moduleLets.has(name) && !reachedOnlyByBrowser(n.start)) moduleStateReassignments.push({
|
|
4273
5491
|
name,
|
|
4274
5492
|
line: line(n.start),
|
|
4275
5493
|
inHandler
|
|
@@ -4309,23 +5527,26 @@ function parseKitModuleFacts(source, filename, aliases) {
|
|
|
4309
5527
|
};
|
|
4310
5528
|
} else if (n.type === "CallExpression" && n.callee?.type === "MemberExpression") {
|
|
4311
5529
|
const method = n.callee.property?.type === "Identifier" ? n.callee.property.name : void 0;
|
|
4312
|
-
|
|
5530
|
+
const nsFunction = n.callee.object?.type === "Identifier" && namespaceImports.has(n.callee.object.name);
|
|
5531
|
+
const updater = method !== "update" || isUpdaterArgument(n.arguments?.[0], localFunctions);
|
|
5532
|
+
if ((method === "set" || method === "update") && !nsFunction && updater) {
|
|
4313
5533
|
const r = importedRoot(n.callee.object);
|
|
4314
5534
|
const spec = r ? importedSpecifiers.get(r) : void 0;
|
|
4315
5535
|
if (r && spec !== void 0) {
|
|
4316
|
-
|
|
5536
|
+
const resolved = localModulePath(spec, filename, aliases);
|
|
5537
|
+
const target = {
|
|
4317
5538
|
name: r,
|
|
4318
|
-
|
|
5539
|
+
imported: importedNames.get(r) ?? r,
|
|
5540
|
+
resolved,
|
|
5541
|
+
line: line(n.start)
|
|
4319
5542
|
};
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
if (resolved !== void 0 && inHandler) pendingServerStoreWrites.push({
|
|
5543
|
+
if (isLocalStateSpecifier(spec, filename, aliases)) {
|
|
5544
|
+
write = {
|
|
4323
5545
|
name: r,
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
}
|
|
5546
|
+
via: "set-call"
|
|
5547
|
+
};
|
|
5548
|
+
if (inHandler) storeWriteTargets.push(target);
|
|
5549
|
+
} else if (resolved !== void 0 && inHandler) pendingServerStoreWrites.push(target);
|
|
4329
5550
|
}
|
|
4330
5551
|
}
|
|
4331
5552
|
} else if (n.type === "AssignmentExpression" && (n.left?.type === "ObjectPattern" || n.left?.type === "ArrayPattern")) {
|
|
@@ -4346,6 +5567,7 @@ function parseKitModuleFacts(source, filename, aliases) {
|
|
|
4346
5567
|
};
|
|
4347
5568
|
scanPatternTargets(n.left);
|
|
4348
5569
|
}
|
|
5570
|
+
if (write && reachedOnlyByBrowser(n.start)) write = void 0;
|
|
4349
5571
|
if (write) {
|
|
4350
5572
|
if (inHandler) importedStateWrites.push({
|
|
4351
5573
|
...write,
|
|
@@ -4377,17 +5599,51 @@ function parseKitModuleFacts(source, filename, aliases) {
|
|
|
4377
5599
|
line: line(call.start)
|
|
4378
5600
|
});
|
|
4379
5601
|
}
|
|
5602
|
+
const exitLocals = collectNamedImportAliases(program, "@sveltejs/kit", EXIT_NAMES);
|
|
5603
|
+
const neverRenders = loadNeverRenders(program, exitLocals);
|
|
5604
|
+
const building = collectNamedImportAliases(program, "$app/environment", BUILDING_NAMES);
|
|
5605
|
+
const hooks = /(^|\/)hooks\.server\.(ts|js)$/.test(filename);
|
|
5606
|
+
const answered = hooks ? handleAnsweredPaths(program, exitLocals) : [];
|
|
5607
|
+
const chunkTokens = hooks ? pageChunkTokens(program) : [];
|
|
5608
|
+
const flagGuard = leadingFlagGuard(program, exitLocals, (local) => {
|
|
5609
|
+
const spec = namespaceImports.has(local) ? void 0 : importedSpecifiers.get(local);
|
|
5610
|
+
const resolved = spec === void 0 ? void 0 : localModulePath(spec, filename, aliases);
|
|
5611
|
+
return resolved === void 0 ? void 0 : {
|
|
5612
|
+
resolved,
|
|
5613
|
+
name: importedNames.get(local) ?? local
|
|
5614
|
+
};
|
|
5615
|
+
});
|
|
5616
|
+
const factory = loadFactory(program, (local) => {
|
|
5617
|
+
const spec = namespaceImports.has(local) ? void 0 : importedSpecifiers.get(local);
|
|
5618
|
+
const resolved = spec === void 0 ? void 0 : localModulePath(spec, filename, aliases);
|
|
5619
|
+
return resolved === void 0 ? void 0 : {
|
|
5620
|
+
resolved,
|
|
5621
|
+
name: importedNames.get(local) ?? local
|
|
5622
|
+
};
|
|
5623
|
+
});
|
|
4380
5624
|
return {
|
|
4381
5625
|
moduleStateReassignments: byLine(moduleStateReassignments),
|
|
4382
5626
|
importedStateWrites: byLine(importedStateWrites),
|
|
4383
5627
|
importedStateWritesOutsideHandlers: byLine(importedStateWritesOutsideHandlers),
|
|
4384
5628
|
pendingServerStoreWrites: byLine(pendingServerStoreWrites),
|
|
5629
|
+
...storeWriteTargets.length > 0 ? { storeWriteTargets } : {},
|
|
4385
5630
|
runesModuleImports: byLine(runesModuleImports),
|
|
4386
5631
|
lifecycleCalls: byLine(lifecycleCalls),
|
|
4387
5632
|
browserGlobalRefs: byLine(browserGlobalRefs),
|
|
4388
5633
|
basePathLinks: byLine(basePathLinks),
|
|
4389
5634
|
...ssrOptOut ? { ssrDisabled: { line: Math.max(0, ssrOptOut.line - 1) } } : {},
|
|
5635
|
+
...!ssrOptOut && exportsName(program, "ssr") ? { ssrEnabled: true } : {},
|
|
5636
|
+
...!ssrOptOut && exportsTrue(program, "ssr") ? { ssrTrue: true } : {},
|
|
4390
5637
|
...csrOptOut ? { csrDisabled: { line: Math.max(0, csrOptOut.line - 1) } } : {},
|
|
5638
|
+
...!csrOptOut && exportsName(program, "csr") ? { csrEnabled: true } : {},
|
|
5639
|
+
...neverRenders ? { loadNeverRenders: true } : {},
|
|
5640
|
+
...!neverRenders && building.size > 0 && loadNeverRenders(program, exitLocals, building) ? { loadNeverRendersOutsideBuild: true } : {},
|
|
5641
|
+
...exportsName(program, "prerender") ? { prerenderExported: true } : {},
|
|
5642
|
+
...answered.length > 0 ? { answeredPaths: answered } : {},
|
|
5643
|
+
...chunkTokens.length > 0 ? { pageChunkTokens: chunkTokens } : {},
|
|
5644
|
+
...flagGuard ? { pendingFlagGuard: flagGuard } : {},
|
|
5645
|
+
...factory ? { pendingLoadFactory: factory } : {},
|
|
5646
|
+
...loadGatesOnLocals(program) ? { loadGated: true } : {},
|
|
4391
5647
|
...waterfalls.dependentLines.length > 0 || waterfalls.independentLines.length > 0 ? { loadWaterfalls: waterfalls } : {},
|
|
4392
5648
|
suppressions
|
|
4393
5649
|
};
|
|
@@ -5609,8 +6865,8 @@ function isRouteEntry(path) {
|
|
|
5609
6865
|
/** The route entries this component imports, with the line each import sits on. */
|
|
5610
6866
|
function routeEntryImports(c, ctx) {
|
|
5611
6867
|
const out = [];
|
|
5612
|
-
for (const { source, line, type } of c.importSpans ?? []) {
|
|
5613
|
-
if (type) continue;
|
|
6868
|
+
for (const { source, line, type, named } of c.importSpans ?? []) {
|
|
6869
|
+
if (type || named) continue;
|
|
5614
6870
|
const target = resolveRepoLocalPath(source, c.file, ctx.project.kitAliases);
|
|
5615
6871
|
if (target !== void 0 && isRouteEntry(target)) out.push({
|
|
5616
6872
|
line,
|
|
@@ -5859,8 +7115,8 @@ const MESSAGE$1 = "Sequential dependent awaits in a universal load create a clie
|
|
|
5859
7115
|
/**
|
|
5860
7116
|
* performance/load-waterfall — dependent await chains in universal loads. Server loads are exempt:
|
|
5861
7117
|
* a dependent chain cannot be parallelized, and on the server there is no better
|
|
5862
|
-
* placement to suggest. csr = false
|
|
5863
|
-
* runtime the universal load only runs during SSR.
|
|
7118
|
+
* placement to suggest. Loads on a route with `csr = false` (its own or a layout's) are exempt
|
|
7119
|
+
* too — without a client runtime the universal load only runs during SSR.
|
|
5864
7120
|
*/
|
|
5865
7121
|
const performanceLoadWaterfall = kitModuleRule({
|
|
5866
7122
|
id: "performance/load-waterfall",
|
|
@@ -5875,7 +7131,7 @@ const performanceLoadWaterfall = kitModuleRule({
|
|
|
5875
7131
|
snippet: "// +page.server.ts — same chain, server-side hops\nexport async function load({ fetch }) {\n const user = await fetch(`/api/user`).then((r) => r.json());\n const posts = await fetch(`/api/posts/${user.id}`).then((r) => r.json());\n return { user, posts };\n}",
|
|
5876
7132
|
lang: "ts"
|
|
5877
7133
|
},
|
|
5878
|
-
applies: (m) => m.kind === "universal" &&
|
|
7134
|
+
applies: (m, ctx) => m.kind === "universal" && (m.loadWaterfalls?.dependentLines.length ?? 0) > 0 && !universalNeverCsr(m, ctx),
|
|
5879
7135
|
bad: (m) => m.loadWaterfalls.dependentLines.map((line) => ({
|
|
5880
7136
|
line,
|
|
5881
7137
|
message: MESSAGE$1
|
|
@@ -5956,223 +7212,6 @@ const performanceIframeLoading = componentRule({
|
|
|
5956
7212
|
}))
|
|
5957
7213
|
});
|
|
5958
7214
|
//#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
7215
|
//#region src/rules/a11y/invalid-role.ts
|
|
6177
7216
|
const a11yInvalidRole = componentRule({
|
|
6178
7217
|
id: "a11y/invalid-role",
|
|
@@ -6765,7 +7804,7 @@ const a11yPatternTitle = componentRule({
|
|
|
6765
7804
|
//#endregion
|
|
6766
7805
|
//#region src/html-spec/generated.ts
|
|
6767
7806
|
/*!
|
|
6768
|
-
* HTML spec data projected from @markuplint/html-spec@5.0.
|
|
7807
|
+
* HTML spec data projected from @markuplint/html-spec@5.0.1 — https://github.com/markuplint/markuplint
|
|
6769
7808
|
*
|
|
6770
7809
|
* MIT License
|
|
6771
7810
|
*
|
|
@@ -6872,7 +7911,7 @@ const ELEMENT_FACT_OVERRIDES = {
|
|
|
6872
7911
|
};
|
|
6873
7912
|
/**
|
|
6874
7913
|
* 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
|
|
7914
|
+
* (`resolveRole`); an expression role, or a spread or `use:` action with no literal role, makes the role unknowable
|
|
6876
7915
|
* and yields `undefined`. Otherwise the element's implicit role — never one role but the set of
|
|
6877
7916
|
* candidates the dataset's conditional outcomes leave open, since the selectors that decide between
|
|
6878
7917
|
* them are not evaluated (design 2026-08-19-aria-role-table-rules, "Which role an element has").
|
|
@@ -6887,7 +7926,7 @@ function roleCandidates(e) {
|
|
|
6887
7926
|
namingProhibited: false
|
|
6888
7927
|
} : void 0;
|
|
6889
7928
|
}
|
|
6890
|
-
if (e.hasSpread) return void 0;
|
|
7929
|
+
if (e.hasSpread || e.hasAction) return void 0;
|
|
6891
7930
|
const el = Object.hasOwn(HTML_SPEC.elements, e.tag) ? HTML_SPEC.elements[e.tag] : void 0;
|
|
6892
7931
|
if (!el) return void 0;
|
|
6893
7932
|
const aria = (Object.hasOwn(ELEMENT_FACT_OVERRIDES, e.tag) ? ELEMENT_FACT_OVERRIDES[e.tag] : void 0) ?? el.aria;
|
|
@@ -7454,7 +8493,12 @@ function surplusRule(spec) {
|
|
|
7454
8493
|
first ??= reps[0];
|
|
7455
8494
|
for (let i = 1; i < reps.length; i++) {
|
|
7456
8495
|
surplus = true;
|
|
7457
|
-
|
|
8496
|
+
const finding = result(route.route, PENALIZED$1, reps[i], spec.message(key, i, reps.length, reps[0]));
|
|
8497
|
+
const recommendation = spec.recommendationFor?.(reps[i], reps[0]);
|
|
8498
|
+
out.push(recommendation ? {
|
|
8499
|
+
...finding,
|
|
8500
|
+
recommendation
|
|
8501
|
+
} : finding);
|
|
7458
8502
|
}
|
|
7459
8503
|
}
|
|
7460
8504
|
if (first && !surplus) out.push(result(route.route, PASS, {
|
|
@@ -7632,6 +8676,7 @@ const a11yIdDuplication = surplusRule({
|
|
|
7632
8676
|
recommendation: "Every id in a route should be unique.",
|
|
7633
8677
|
map: (route) => Object.entries(route.ids).sort(([, a], [, b]) => a[0].file.localeCompare(b[0].file) || a[0].line - b[0].line),
|
|
7634
8678
|
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}"`,
|
|
8679
|
+
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
8680
|
passMessage: "No duplicate ids"
|
|
7636
8681
|
});
|
|
7637
8682
|
//#endregion
|
|
@@ -8152,8 +9197,29 @@ function buildJsonReport(results, config, meta, ruleIds, examined, skipped) {
|
|
|
8152
9197
|
};
|
|
8153
9198
|
}
|
|
8154
9199
|
/** Render results as the documented JSON report string (design §7). */
|
|
8155
|
-
function formatJsonReport(
|
|
8156
|
-
return
|
|
9200
|
+
function formatJsonReport(...args) {
|
|
9201
|
+
return [...formatJsonReportChunks(...args)].join("");
|
|
9202
|
+
}
|
|
9203
|
+
/**
|
|
9204
|
+
* `formatJsonReport`'s text in pieces, one route at a time: a large prerendered site's report is
|
|
9205
|
+
* longer than the longest string V8 can hold.
|
|
9206
|
+
*/
|
|
9207
|
+
function* formatJsonReportChunks(results, config, meta, ruleIds, examined, skipped) {
|
|
9208
|
+
const report = buildJsonReport(results, config, meta, ruleIds, examined, skipped);
|
|
9209
|
+
const marker = JSON.stringify("\0routes");
|
|
9210
|
+
const text = JSON.stringify({
|
|
9211
|
+
...report,
|
|
9212
|
+
routes: "\0routes"
|
|
9213
|
+
}, null, 2);
|
|
9214
|
+
const at = text.indexOf(marker);
|
|
9215
|
+
yield text.slice(0, at);
|
|
9216
|
+
if (report.routes.length === 0) yield "[]";
|
|
9217
|
+
else {
|
|
9218
|
+
yield "[\n";
|
|
9219
|
+
for (const [i, route] of report.routes.entries()) yield `${i > 0 ? ",\n" : ""} ${JSON.stringify(route, null, 2).replaceAll("\n", "\n ")}`;
|
|
9220
|
+
yield "\n ]";
|
|
9221
|
+
}
|
|
9222
|
+
yield text.slice(at + marker.length);
|
|
8157
9223
|
}
|
|
8158
9224
|
//#endregion
|
|
8159
9225
|
//#region src/reporter/sanitize.ts
|
|
@@ -8299,4 +9365,4 @@ function formatMarkdownReport(results, config, meta) {
|
|
|
8299
9365
|
return lines.join("\n");
|
|
8300
9366
|
}
|
|
8301
9367
|
//#endregion
|
|
8302
|
-
export {
|
|
9368
|
+
export { performanceLoadWaterfall as $, shouldSkipRangeCheck as $n, securityJavascriptUrl as $t, a11yPatternTitle as A, splitTokens as An, seoTitlePresence as Ar, parseModuleProgram as At, a11yPlaceholderLabelOption as B, seoJsonLdDateFormat as Bn, defineConfig as Br, attrTextOf as Bt, a11yPermittedContents as C, IDREF_ATTRS as Cn, seoCanonicalUrl as Cr, collectBrowserGuardImports as Ct, a11yDeprecatedAttr as D, foldOccurrences as Dn, layoutNeverRenders as Dr, collectSuppressions as Dt, a11yDisallowedAriaProps as E, decodeFragmentId as En, seoDescriptionPresence as Er, collectProgramBindings as Et, a11yPositiveTabindex as F, seoCharset as Fn, docsUrlFor as Fr, walkEstree as Ft, a11yInteractiveNesting as G, seoViewport as Gn, parseSvelte as Gt, a11yLabelHasControl as H, seoJsonLdDeprecatedType as Hn, attrValueOf as Ht, isInteractiveContainer as I, seoDescriptionLength as In, isPenalized as Ir, walkEvalScope as It, a11yUnknownAriaAttribute as J, seoTwitterCard as Jn, architecturePropCount as Jt, a11yInvalidAriaValue as K, seoOgUrl as Kn, textFromNodes as Kt, isInteractiveElement as L, seoTitleLength as Ln, CATEGORIES as Lr, walkScoped as Lt, a11yNoDuplicateDt as M, resolveRole as Mn, effectiveSeverity as Mr, scopeIntroducedNames as Mt, a11yNoAutofocus as N, seoHreflang as Nn, hasFailureAtOrAbove as Nr, unwrapExport as Nt, a11yDeprecatedElement as O, isTopFragment as On, pageRendersOnlyWhenBuilding as Or, collectSvelteLifecycleImports as Ot, a11yNoAccesskey as P, seoImageAlt as Pn, summarize as Pr, unwrapTs as Pt, performanceSequentialAwaits as Q, resolveRuleOptions as Qn, securityHandlerStateWrite as Qt, literalTabindexValue as R, seoJsonLdRequiredProps as Rn, defaultConfig as Rr, CHILD_NODE_KEYS as Rt, a11yRequiredElement as S, ASIDE_DEMOTING_TAGS as Sn, seoOgImage as Sr, collectBrowserGlobalRefs as St, a11yDeprecatedAria as T, SECTIONING_TAGS as Tn, isClassicScriptType as Tr, collectNamedImportAliases as Tt, a11yAccessibleName as U, seoJsonLdValidity as Un, findAttr as Ut, a11yUseList as V, seoJsonLdRelativeUrl as Vn, attrValue as Vt, a11yAriaHiddenFocus as W, seoSitemapInRobots as Wn, lineOf as Wt, performanceIframeLoading as X, performancePreconnect as Xn, securitySharedStateImport as Xt, a11yInvalidRole as Y, seoIndexability as Yn, architectureComponentSize as Yt, performanceStateRaw as Z, isPlainObject as Zn, securityServerModuleState as Zt, a11yNoMissingIdRef as _, seoHeadingLevelSkip as _n, seoHtmlLang as _r, parseKitModuleFacts as _t, formatJsonReport as a, correctnessOrphanEffect as an, formatFailedRuleWarning as ar, architectureReservedNamePlacement as at, a11yDuplicateLandmark as b, seoSingleH1 as bn, seoJsonLd as br, WALK_IGNORED_KEYS as bt, computeScore as c, correctnessNonreactiveBuiltinState as cn, withFailedRulesOff as cr, architectureUnitEntryFile as ct, messageText as d, correctnessUnmutatedState as dn, performanceLcpImage as dr, collectFalseExports as dt, securityRawHtml as en, validateRuleSetting as er, performanceMinifyDisabled as et, ruleMetaById as f, correctnessEffectAsOnMount as fn, performanceFontPreloadCrossorigin as fr, collectTopLevelBindings as ft, a11yUnverifiedIdRef as g, seoSsrDisabled as gn, performanceImageDimensions as gr, parseInMemoryExports as gt, explainRule as h, correctnessEachKey as hn, performanceImageLoadingHint as hr, parseFalseExports as ht, buildJsonReport as i, correctnessOrphanLifecycle as in, compileOverrides as ir, architectureRouteComponentImport as it, a11yAbbrTitle as j, stripTextDirective as jn, classify as jr, rootObjectName as jt, HTML_SPEC as k, resolveLandmark as kn, routeNeverSsr as kr, matchLifecycleCall as kt, formatGithubReport as l, correctnessStalePropDerivation as ln, withIndexableOff as lr, architecturePrivateScopeImport as lt, allRules as m, correctnessEachIndexKey as mn, performanceResponsiveImage as mr, parseExitFactoryExports as mt, mdEscape as n, correctnessServerBrowserGlobal as nn, applyOverrides as nr, performanceHeavyImport as nt, formatJsonReportChunks as o, correctnessAutoplayMuted as on, selectRules as or, architectureReservedDirectoryNames as ot, severityToSarifLevel as p, correctnessEffectAsDerived as pn, performancePreloadMissingAs as pr, parseClientExports as pt, a11yRequiredAriaProps as q, seoOgDescription as qn, valueFromNodes as qt, terminalSafe as r, correctnessBasePathNavigation as rn, applyRuleSeverities as rr, architectureDocLinkTarget as rt, computeHealth as s, correctnessCheckableBindValue as sn, settingSeverity as sr, architectureDirectoryNaming as st, formatMarkdownReport as t, correctnessInstanceBrowserGlobal as tn, SEARCH_RESULT_RULES as tr, performanceNamespaceImport as tt, SEVERITY_RANK as u, correctnessPropMutation as un, performanceRenderBlockingScript as ur, DEFAULT_KIT_ALIASES as ut, a11yIdDuplication as v, seoDuplicateDescription as vn, seoSitemapXml as vr, resolveRepoLocalPath as vt, a11yDisallowedElement as w, NAMING_ATTRS$1 as wn, hasRel as wr, collectDerivedGuardBindings as wt, a11yDoctype as x, ANCESTRY_DEPENDENT_TAGS as xn, seoOgTitle as xr, addBoundNames as xt, a11yTopLevelLandmark as y, seoDuplicateTitle as yn, seoRobotsTxt as yr, isRootRelativePath as yt, a11yRequireDatetime as z, seoJsonLdPlaceholder as zn, defaultProject as zr, attrText as zt };
|