@svelte-vitals/core 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +63 -8
- package/dist/index.js +255 -23
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -54,8 +54,8 @@ interface Result {
|
|
|
54
54
|
/** 1-based source line for element-level findings (e.g. a specific <img>). */
|
|
55
55
|
line?: number;
|
|
56
56
|
}
|
|
57
|
-
type Scope = 'route' | 'project';
|
|
58
|
-
type Category = 'seo' | 'performance';
|
|
57
|
+
type Scope = 'route' | 'project' | 'component';
|
|
58
|
+
type Category = 'seo' | 'performance' | 'correctness' | 'security';
|
|
59
59
|
/** How dynamic (`{data.title}`) values are treated by scoring (design §4, §12). */
|
|
60
60
|
type TreatDynamicAs = 'pass' | 'warn' | 'fail';
|
|
61
61
|
/** Per-rule override: disable, or change severity. */
|
|
@@ -198,6 +198,42 @@ interface ResolvedHeadings {
|
|
|
198
198
|
headings: HeadingInfo[];
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Component-body facts for the Correctness category — the source-analysis boundary
|
|
203
|
+
* (mirrors images.ts / headings.ts). Collected by the static (CLI) provider only;
|
|
204
|
+
* the rendered provider can't see reactivity, so correctness rules no-op there.
|
|
205
|
+
*/
|
|
206
|
+
/** An `{#each}` block in a component template. */
|
|
207
|
+
interface EachBlockFact {
|
|
208
|
+
/** True when the block has a key, e.g. `{#each items as item (item.id)}`. */
|
|
209
|
+
hasKey: boolean;
|
|
210
|
+
/** 1-based source line, or 0 if unknown. */
|
|
211
|
+
line: number;
|
|
212
|
+
}
|
|
213
|
+
/** An `$effect(...)` / `$effect.pre(...)` call in a component's instance script. */
|
|
214
|
+
interface EffectFact {
|
|
215
|
+
/** 1-based source line, or 0 if unknown. */
|
|
216
|
+
line: number;
|
|
217
|
+
/** True when the effect body only assigns to `$state` variables (the "use $derived" smell). */
|
|
218
|
+
assignsOnlyState: boolean;
|
|
219
|
+
}
|
|
220
|
+
/** A flagged source position in a component (e.g. an `{@html}` tag or a `javascript:` URL). */
|
|
221
|
+
interface SourceSpan {
|
|
222
|
+
/** 1-based source line, or 0 if unknown. */
|
|
223
|
+
line: number;
|
|
224
|
+
}
|
|
225
|
+
/** Reactivity/correctness + security facts parsed from one `.svelte` component. */
|
|
226
|
+
interface ComponentFacts {
|
|
227
|
+
/** Source file the component came from. */
|
|
228
|
+
file: string;
|
|
229
|
+
eachBlocks: EachBlockFact[];
|
|
230
|
+
effects: EffectFact[];
|
|
231
|
+
/** `{@html …}` occurrences — raw-HTML render surfaces (Security SEC001). */
|
|
232
|
+
htmlTags: SourceSpan[];
|
|
233
|
+
/** Element attributes with a literal `javascript:` URL (Security SEC002). */
|
|
234
|
+
javascriptUrls: SourceSpan[];
|
|
235
|
+
}
|
|
236
|
+
|
|
201
237
|
/**
|
|
202
238
|
* Source-file locations that satisfy the project-scope rules, shared by every
|
|
203
239
|
* mode so the static (CLI) and rendered (plugin) collectors never drift. This
|
|
@@ -215,6 +251,8 @@ interface RuleContext {
|
|
|
215
251
|
images?: ResolvedImages[];
|
|
216
252
|
/** Per-route page-body headings for SEO027 (absent in modes that don't collect them). */
|
|
217
253
|
headings?: ResolvedHeadings[];
|
|
254
|
+
/** Per-file component-body facts for Correctness rules (static/CLI mode only). */
|
|
255
|
+
components?: ComponentFacts[];
|
|
218
256
|
project: Project;
|
|
219
257
|
config: Config;
|
|
220
258
|
}
|
|
@@ -282,9 +320,9 @@ declare const perf004FontPreloadCrossorigin: Rule;
|
|
|
282
320
|
|
|
283
321
|
/**
|
|
284
322
|
* PERF005 — LCP image not lazy-loaded. Lazy-loading the largest contentful paint
|
|
285
|
-
* image delays it.
|
|
286
|
-
*
|
|
287
|
-
*
|
|
323
|
+
* image delays it. Analysis approximates the LCP as the first <img> in document
|
|
324
|
+
* order for the route; if that image is loading="lazy", flag it. Runs in both
|
|
325
|
+
* static (CLI) and rendered (vite) mode, since both providers collect <img>.
|
|
288
326
|
*/
|
|
289
327
|
declare const perf005LcpImage: Rule;
|
|
290
328
|
|
|
@@ -330,8 +368,8 @@ declare const seo023DescriptionLength: Rule;
|
|
|
330
368
|
declare const seo024Charset: Rule;
|
|
331
369
|
|
|
332
370
|
/**
|
|
333
|
-
* SEO025 — Image alt text. Reuses the <img> collection
|
|
334
|
-
*
|
|
371
|
+
* SEO025 — Image alt text. Reuses the <img> collection from both providers — the
|
|
372
|
+
* static (CLI) source parser and the rendered (vite) HTML parser — like PERF001/002.
|
|
335
373
|
* Presence only: an explicit empty `alt=""` is a valid decorative-image signal and
|
|
336
374
|
* passes; a spread `{...rest}` may supply alt, so it is not flagged.
|
|
337
375
|
*/
|
|
@@ -353,6 +391,23 @@ declare const seo026Hreflang: Rule;
|
|
|
353
391
|
*/
|
|
354
392
|
declare const seo027Heading: Rule;
|
|
355
393
|
|
|
394
|
+
declare const seo028TitleUnique: Rule;
|
|
395
|
+
declare const seo029DescriptionUnique: Rule;
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* SEO030 — Skipped heading level. Walking a route's body headings in document
|
|
399
|
+
* order, a level that jumps more than +1 over the previous heading (e.g. h2 → h4)
|
|
400
|
+
* breaks the outline. The first heading has no predecessor (missing/multiple
|
|
401
|
+
* <h1> stays SEO027's concern). A route with no headings emits nothing.
|
|
402
|
+
*/
|
|
403
|
+
declare const seo030HeadingOrder: Rule;
|
|
404
|
+
|
|
405
|
+
declare const correct001EachKey: Rule;
|
|
406
|
+
declare const correct002EffectDerived: Rule;
|
|
407
|
+
|
|
408
|
+
declare const sec001Html: Rule;
|
|
409
|
+
declare const sec002JavascriptUrl: Rule;
|
|
410
|
+
|
|
356
411
|
declare const allRules: Rule[];
|
|
357
412
|
|
|
358
413
|
interface RuleInfo {
|
|
@@ -561,4 +616,4 @@ declare function selectRules(rules: Rule[], config: Config): Rule[];
|
|
|
561
616
|
/** Apply per-rule severity overrides to results (design §6). */
|
|
562
617
|
declare function applyRuleSeverities(results: Result[], config: Config): Result[];
|
|
563
618
|
|
|
564
|
-
export { BAND_COLOR, type Category, type Classification, type Config, type ConsoleReportOptions, type Detection, type Fix, type HeadProvider, type HeadTag, type HeadingInfo, type HealthResult, type ImageInfo, type JsonReport, type Presence, type Project, ROBOTS_SOURCE_PATHS, type ResolvedHead, type ResolvedHeadings, type ResolvedImages, type Result, type Rule, type RuleContext, type RuleInfo, type RuleSetting, type Runtime, SITEMAP_SOURCE_PATHS, type Scope, type ScoreModel, type ScoreOptions, type ScoreResult, type Severity, type Summary, type TreatDynamicAs, type Value, allRules, applyRuleSeverities, buildHtmlDocument, buildJsonReport, classify, computeHealth, computeScore, defaultConfig, defaultProject, defineConfig, docsUrlFor, effectiveSeverity, escapeHtml, explainRule, formatAgentReport, formatConsoleReport, formatGithubReport, formatHtmlReport, formatJsonReport, formatSarifReport, hasFailureAtOrAbove, headTagRule, imageRule, isPenalized, linkRule, perf001ImageDimensions, perf002ImageLoading, perf003PreloadAs, perf004FontPreloadCrossorigin, perf005LcpImage, perf006ResponsiveImage, perf007RenderBlockingScript, perf008Preconnect, runRules, safeHref, scoreBand, scoresByCategory, selectRules, seo001Title, seo002Description, seo003Canonical, seo004OgImage, seo005OgTitle, seo006Robots, seo007Sitemap, seo008JsonLd, seo009HtmlLang, seo010Indexability, seo011TwitterCard, seo012OgDescription, seo013OgUrl, seo014Viewport, seo015SitemapInRobots, seo016JsonLdValidity, seo017DeprecatedType, seo018RelativeUrl, seo019DateFormat, seo020Placeholder, seo021RequiredProps, seo022TitleLength, seo023DescriptionLength, seo024Charset, seo025ImageAlt, seo026Hreflang, seo027Heading, summarize };
|
|
619
|
+
export { BAND_COLOR, type Category, type Classification, type ComponentFacts, type Config, type ConsoleReportOptions, type Detection, type EachBlockFact, type EffectFact, type Fix, type HeadProvider, type HeadTag, type HeadingInfo, type HealthResult, type ImageInfo, type JsonReport, type Presence, type Project, ROBOTS_SOURCE_PATHS, type ResolvedHead, type ResolvedHeadings, type ResolvedImages, type Result, type Rule, type RuleContext, type RuleInfo, type RuleSetting, type Runtime, SITEMAP_SOURCE_PATHS, type Scope, type ScoreModel, type ScoreOptions, type ScoreResult, type Severity, type SourceSpan, type Summary, type TreatDynamicAs, type Value, allRules, applyRuleSeverities, buildHtmlDocument, buildJsonReport, classify, computeHealth, computeScore, correct001EachKey, correct002EffectDerived, defaultConfig, defaultProject, defineConfig, docsUrlFor, effectiveSeverity, escapeHtml, explainRule, formatAgentReport, formatConsoleReport, formatGithubReport, formatHtmlReport, formatJsonReport, formatSarifReport, hasFailureAtOrAbove, headTagRule, imageRule, isPenalized, linkRule, perf001ImageDimensions, perf002ImageLoading, perf003PreloadAs, perf004FontPreloadCrossorigin, perf005LcpImage, perf006ResponsiveImage, perf007RenderBlockingScript, perf008Preconnect, runRules, safeHref, scoreBand, scoresByCategory, sec001Html, sec002JavascriptUrl, selectRules, seo001Title, seo002Description, seo003Canonical, seo004OgImage, seo005OgTitle, seo006Robots, seo007Sitemap, seo008JsonLd, seo009HtmlLang, seo010Indexability, seo011TwitterCard, seo012OgDescription, seo013OgUrl, seo014Viewport, seo015SitemapInRobots, seo016JsonLdValidity, seo017DeprecatedType, seo018RelativeUrl, seo019DateFormat, seo020Placeholder, seo021RequiredProps, seo022TitleLength, seo023DescriptionLength, seo024Charset, seo025ImageAlt, seo026Hreflang, seo027Heading, seo028TitleUnique, seo029DescriptionUnique, seo030HeadingOrder, summarize };
|
package/dist/index.js
CHANGED
|
@@ -94,7 +94,7 @@ function detect(head, match) {
|
|
|
94
94
|
return tag ? { presence: tag.presence, value: tag.value } : { presence: "none", value: "absent" };
|
|
95
95
|
}
|
|
96
96
|
function headTagRule(opts) {
|
|
97
|
-
const
|
|
97
|
+
const docsUrl7 = docsUrlFor(opts.id);
|
|
98
98
|
return {
|
|
99
99
|
id: opts.id,
|
|
100
100
|
title: opts.title,
|
|
@@ -117,7 +117,7 @@ function headTagRule(opts) {
|
|
|
117
117
|
location: head.file,
|
|
118
118
|
message,
|
|
119
119
|
recommendation: opts.recommendation,
|
|
120
|
-
docsUrl:
|
|
120
|
+
docsUrl: docsUrl7,
|
|
121
121
|
// Copy per finding: opts.fix is a rule-level template shared across all
|
|
122
122
|
// results this rule emits; a fresh object keeps findings independent.
|
|
123
123
|
...opts.fix ? { fix: { ...opts.fix } } : {}
|
|
@@ -296,7 +296,7 @@ var seo009HtmlLang = {
|
|
|
296
296
|
|
|
297
297
|
// src/rules/perf/image-rule.ts
|
|
298
298
|
function imageRule(opts) {
|
|
299
|
-
const
|
|
299
|
+
const docsUrl7 = docsUrlFor(opts.id);
|
|
300
300
|
const category = opts.category ?? "performance";
|
|
301
301
|
return {
|
|
302
302
|
id: opts.id,
|
|
@@ -320,7 +320,7 @@ function imageRule(opts) {
|
|
|
320
320
|
route: route.route,
|
|
321
321
|
message: opts.label,
|
|
322
322
|
recommendation: opts.recommendation,
|
|
323
|
-
docsUrl:
|
|
323
|
+
docsUrl: docsUrl7
|
|
324
324
|
});
|
|
325
325
|
continue;
|
|
326
326
|
}
|
|
@@ -335,7 +335,7 @@ function imageRule(opts) {
|
|
|
335
335
|
...img.line > 0 ? { line: img.line } : {},
|
|
336
336
|
message: `Missing ${opts.label}`,
|
|
337
337
|
recommendation: opts.recommendation,
|
|
338
|
-
docsUrl:
|
|
338
|
+
docsUrl: docsUrl7,
|
|
339
339
|
...opts.fix ? { fix: { ...opts.fix } } : {}
|
|
340
340
|
});
|
|
341
341
|
}
|
|
@@ -391,7 +391,7 @@ var perf006ResponsiveImage = imageRule({
|
|
|
391
391
|
|
|
392
392
|
// src/rules/perf/link-rule.ts
|
|
393
393
|
function linkRule(opts) {
|
|
394
|
-
const
|
|
394
|
+
const docsUrl7 = docsUrlFor(opts.id);
|
|
395
395
|
return {
|
|
396
396
|
id: opts.id,
|
|
397
397
|
title: opts.title,
|
|
@@ -415,7 +415,7 @@ function linkRule(opts) {
|
|
|
415
415
|
route: head.route,
|
|
416
416
|
message: opts.label,
|
|
417
417
|
recommendation: opts.recommendation,
|
|
418
|
-
docsUrl:
|
|
418
|
+
docsUrl: docsUrl7
|
|
419
419
|
});
|
|
420
420
|
continue;
|
|
421
421
|
}
|
|
@@ -432,7 +432,7 @@ function linkRule(opts) {
|
|
|
432
432
|
location: tag.file ?? head.file,
|
|
433
433
|
message: `Missing ${opts.label}`,
|
|
434
434
|
recommendation: opts.recommendation,
|
|
435
|
-
docsUrl:
|
|
435
|
+
docsUrl: docsUrl7,
|
|
436
436
|
...opts.fix ? { fix: { ...opts.fix } } : {}
|
|
437
437
|
});
|
|
438
438
|
}
|
|
@@ -658,7 +658,7 @@ var seo010Indexability = {
|
|
|
658
658
|
rationale: "A noindex directive removes the page from search results; an accidental noindex on a public route silently deindexes it.",
|
|
659
659
|
fix: SEO010_FIX,
|
|
660
660
|
async check(ctx) {
|
|
661
|
-
const
|
|
661
|
+
const docsUrl7 = docsUrlFor("SEO010");
|
|
662
662
|
const out = [];
|
|
663
663
|
for (const head of ctx.heads) {
|
|
664
664
|
const noindexed = head.tags.some((t) => t.kind === "meta" && t.name === "robots" && t.noindex === true);
|
|
@@ -673,7 +673,7 @@ var seo010Indexability = {
|
|
|
673
673
|
location: head.file,
|
|
674
674
|
message: "Route is noindex \u2014 verify this is intentional",
|
|
675
675
|
recommendation: 'If this route should be indexed, remove noindex from its <meta name="robots">.',
|
|
676
|
-
docsUrl:
|
|
676
|
+
docsUrl: docsUrl7,
|
|
677
677
|
fix: { ...SEO010_FIX }
|
|
678
678
|
});
|
|
679
679
|
}
|
|
@@ -928,7 +928,7 @@ var seo016JsonLdValidity = {
|
|
|
928
928
|
lang: "svelte"
|
|
929
929
|
},
|
|
930
930
|
async check(ctx) {
|
|
931
|
-
const
|
|
931
|
+
const docsUrl7 = docsUrlFor("SEO016");
|
|
932
932
|
const out = [];
|
|
933
933
|
for (const head of ctx.heads) {
|
|
934
934
|
for (const tag of jsonldTags(head)) {
|
|
@@ -947,7 +947,7 @@ var seo016JsonLdValidity = {
|
|
|
947
947
|
location: head.file,
|
|
948
948
|
message: problem,
|
|
949
949
|
recommendation: "Make the JSON-LD valid JSON with both @context and @type.",
|
|
950
|
-
docsUrl:
|
|
950
|
+
docsUrl: docsUrl7,
|
|
951
951
|
fix: { ...seo016JsonLdValidity.fix }
|
|
952
952
|
} : {
|
|
953
953
|
id: "SEO016",
|
|
@@ -957,7 +957,7 @@ var seo016JsonLdValidity = {
|
|
|
957
957
|
route: head.route,
|
|
958
958
|
message: "JSON-LD validity",
|
|
959
959
|
recommendation: "Make the JSON-LD valid JSON with both @context and @type.",
|
|
960
|
-
docsUrl:
|
|
960
|
+
docsUrl: docsUrl7
|
|
961
961
|
}
|
|
962
962
|
);
|
|
963
963
|
}
|
|
@@ -966,7 +966,7 @@ var seo016JsonLdValidity = {
|
|
|
966
966
|
}
|
|
967
967
|
};
|
|
968
968
|
function jsonldRule(opts) {
|
|
969
|
-
const
|
|
969
|
+
const docsUrl7 = docsUrlFor(opts.id);
|
|
970
970
|
return {
|
|
971
971
|
id: opts.id,
|
|
972
972
|
title: opts.title,
|
|
@@ -994,7 +994,7 @@ function jsonldRule(opts) {
|
|
|
994
994
|
location: head.file,
|
|
995
995
|
message: problem,
|
|
996
996
|
recommendation: opts.recommendation,
|
|
997
|
-
docsUrl:
|
|
997
|
+
docsUrl: docsUrl7,
|
|
998
998
|
...opts.fix ? { fix: { ...opts.fix } } : {}
|
|
999
999
|
} : {
|
|
1000
1000
|
id: opts.id,
|
|
@@ -1004,7 +1004,7 @@ function jsonldRule(opts) {
|
|
|
1004
1004
|
route: head.route,
|
|
1005
1005
|
message: opts.label,
|
|
1006
1006
|
recommendation: opts.recommendation,
|
|
1007
|
-
docsUrl:
|
|
1007
|
+
docsUrl: docsUrl7
|
|
1008
1008
|
}
|
|
1009
1009
|
);
|
|
1010
1010
|
}
|
|
@@ -1095,15 +1095,18 @@ var seo021RequiredProps = jsonldRule({
|
|
|
1095
1095
|
|
|
1096
1096
|
// src/rules/seo/text-metrics.ts
|
|
1097
1097
|
var segmenter = typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? new Intl.Segmenter() : void 0;
|
|
1098
|
+
function collapseWhitespace(s) {
|
|
1099
|
+
return s.trim().replace(/\s+/g, " ");
|
|
1100
|
+
}
|
|
1098
1101
|
function visibleLength(s) {
|
|
1099
|
-
const collapsed =
|
|
1102
|
+
const collapsed = collapseWhitespace(s);
|
|
1100
1103
|
if (!segmenter) return [...collapsed].length;
|
|
1101
1104
|
return [...segmenter.segment(collapsed)].length;
|
|
1102
1105
|
}
|
|
1103
1106
|
|
|
1104
1107
|
// src/rules/seo/seo022-023.ts
|
|
1105
1108
|
function lengthRule(opts) {
|
|
1106
|
-
const
|
|
1109
|
+
const docsUrl7 = docsUrlFor(opts.id);
|
|
1107
1110
|
return {
|
|
1108
1111
|
id: opts.id,
|
|
1109
1112
|
title: opts.title,
|
|
@@ -1130,7 +1133,7 @@ function lengthRule(opts) {
|
|
|
1130
1133
|
location: tag.file ?? head.file,
|
|
1131
1134
|
message: problem,
|
|
1132
1135
|
recommendation: opts.recommendation,
|
|
1133
|
-
docsUrl:
|
|
1136
|
+
docsUrl: docsUrl7
|
|
1134
1137
|
} : {
|
|
1135
1138
|
id: opts.id,
|
|
1136
1139
|
category: "seo",
|
|
@@ -1139,7 +1142,7 @@ function lengthRule(opts) {
|
|
|
1139
1142
|
route: head.route,
|
|
1140
1143
|
message: opts.label,
|
|
1141
1144
|
recommendation: opts.recommendation,
|
|
1142
|
-
docsUrl:
|
|
1145
|
+
docsUrl: docsUrl7
|
|
1143
1146
|
}
|
|
1144
1147
|
);
|
|
1145
1148
|
}
|
|
@@ -1314,6 +1317,219 @@ var seo027Heading = {
|
|
|
1314
1317
|
}
|
|
1315
1318
|
};
|
|
1316
1319
|
|
|
1320
|
+
// src/rules/seo/seo028-029-uniqueness.ts
|
|
1321
|
+
function uniquenessRule(opts) {
|
|
1322
|
+
const docsUrl7 = docsUrlFor(opts.id);
|
|
1323
|
+
return {
|
|
1324
|
+
id: opts.id,
|
|
1325
|
+
title: opts.title,
|
|
1326
|
+
category: "seo",
|
|
1327
|
+
severity: "warning",
|
|
1328
|
+
scope: "route",
|
|
1329
|
+
rationale: opts.rationale,
|
|
1330
|
+
async check(ctx) {
|
|
1331
|
+
const entries = [];
|
|
1332
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1333
|
+
for (const head of ctx.heads) {
|
|
1334
|
+
const tag = head.tags.find(opts.match);
|
|
1335
|
+
if (!tag || typeof tag.text !== "string") continue;
|
|
1336
|
+
const text = collapseWhitespace(tag.text);
|
|
1337
|
+
if (text.length === 0) continue;
|
|
1338
|
+
entries.push({ route: head.route, file: tag.file ?? head.file, text });
|
|
1339
|
+
counts.set(text, (counts.get(text) ?? 0) + 1);
|
|
1340
|
+
}
|
|
1341
|
+
return entries.map((e) => {
|
|
1342
|
+
const n = counts.get(e.text) ?? 1;
|
|
1343
|
+
return n > 1 ? {
|
|
1344
|
+
id: opts.id,
|
|
1345
|
+
category: "seo",
|
|
1346
|
+
severity: "warning",
|
|
1347
|
+
detection: PENALIZED,
|
|
1348
|
+
route: e.route,
|
|
1349
|
+
location: e.file,
|
|
1350
|
+
message: `${opts.noun} is duplicated across ${n} routes`,
|
|
1351
|
+
recommendation: opts.recommendation,
|
|
1352
|
+
docsUrl: docsUrl7
|
|
1353
|
+
} : {
|
|
1354
|
+
id: opts.id,
|
|
1355
|
+
category: "seo",
|
|
1356
|
+
severity: "warning",
|
|
1357
|
+
detection: PASS,
|
|
1358
|
+
route: e.route,
|
|
1359
|
+
message: opts.label,
|
|
1360
|
+
recommendation: opts.recommendation,
|
|
1361
|
+
docsUrl: docsUrl7
|
|
1362
|
+
};
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
var seo028TitleUnique = uniquenessRule({
|
|
1368
|
+
id: "SEO028",
|
|
1369
|
+
title: "Duplicate title",
|
|
1370
|
+
label: "Unique title",
|
|
1371
|
+
noun: "Title",
|
|
1372
|
+
match: (t) => t.kind === "title",
|
|
1373
|
+
recommendation: "Give each route a unique <title> that describes that page specifically.",
|
|
1374
|
+
rationale: "Duplicate titles across pages make them compete in search results and weaken each page\u2019s relevance signal."
|
|
1375
|
+
});
|
|
1376
|
+
var seo029DescriptionUnique = uniquenessRule({
|
|
1377
|
+
id: "SEO029",
|
|
1378
|
+
title: "Duplicate description",
|
|
1379
|
+
label: "Unique description",
|
|
1380
|
+
noun: "Description",
|
|
1381
|
+
match: (t) => t.kind === "meta" && t.name === "description",
|
|
1382
|
+
recommendation: "Write a unique meta description per route so each search snippet is page-specific.",
|
|
1383
|
+
rationale: "Duplicate meta descriptions give search engines no per-page summary, so they are often ignored or rewritten."
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
// src/rules/seo/seo030-heading-order.ts
|
|
1387
|
+
var docsUrl6 = docsUrlFor("SEO030");
|
|
1388
|
+
var recommendation6 = "Increase heading levels one step at a time (do not jump, e.g. from <h2> straight to <h4>).";
|
|
1389
|
+
var seo030HeadingOrder = {
|
|
1390
|
+
id: "SEO030",
|
|
1391
|
+
title: "Heading order",
|
|
1392
|
+
category: "seo",
|
|
1393
|
+
severity: "info",
|
|
1394
|
+
scope: "route",
|
|
1395
|
+
rationale: "Skipping a heading level breaks the document outline that search engines and assistive tech rely on to understand page structure.",
|
|
1396
|
+
async check(ctx) {
|
|
1397
|
+
const out = [];
|
|
1398
|
+
for (const route of ctx.headings ?? []) {
|
|
1399
|
+
if (route.headings.length === 0) continue;
|
|
1400
|
+
let prev = route.headings[0].level;
|
|
1401
|
+
let skip;
|
|
1402
|
+
for (let i = 1; i < route.headings.length; i++) {
|
|
1403
|
+
const h = route.headings[i];
|
|
1404
|
+
if (h.level > prev + 1) {
|
|
1405
|
+
skip = { level: h.level, prev, line: h.line, file: h.file };
|
|
1406
|
+
break;
|
|
1407
|
+
}
|
|
1408
|
+
prev = h.level;
|
|
1409
|
+
}
|
|
1410
|
+
out.push(
|
|
1411
|
+
skip ? {
|
|
1412
|
+
id: "SEO030",
|
|
1413
|
+
category: "seo",
|
|
1414
|
+
severity: "info",
|
|
1415
|
+
detection: PENALIZED,
|
|
1416
|
+
route: route.route,
|
|
1417
|
+
location: skip.file,
|
|
1418
|
+
...skip.line > 0 ? { line: skip.line } : {},
|
|
1419
|
+
message: `Heading level skipped (<h${skip.prev}> to <h${skip.level}>)`,
|
|
1420
|
+
recommendation: recommendation6,
|
|
1421
|
+
docsUrl: docsUrl6
|
|
1422
|
+
} : {
|
|
1423
|
+
id: "SEO030",
|
|
1424
|
+
category: "seo",
|
|
1425
|
+
severity: "info",
|
|
1426
|
+
detection: PASS,
|
|
1427
|
+
route: route.route,
|
|
1428
|
+
message: "Heading order",
|
|
1429
|
+
recommendation: recommendation6,
|
|
1430
|
+
docsUrl: docsUrl6
|
|
1431
|
+
}
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
return out;
|
|
1435
|
+
}
|
|
1436
|
+
};
|
|
1437
|
+
|
|
1438
|
+
// src/rules/component-rule.ts
|
|
1439
|
+
var PENALIZED2 = { presence: "none", value: "absent" };
|
|
1440
|
+
var PASS2 = { presence: "own", value: "static" };
|
|
1441
|
+
function componentRule(opts) {
|
|
1442
|
+
const docsUrl7 = docsUrlFor(opts.id);
|
|
1443
|
+
const severity = opts.severity ?? "warning";
|
|
1444
|
+
return {
|
|
1445
|
+
id: opts.id,
|
|
1446
|
+
title: opts.title,
|
|
1447
|
+
category: opts.category,
|
|
1448
|
+
severity,
|
|
1449
|
+
scope: "component",
|
|
1450
|
+
rationale: opts.rationale,
|
|
1451
|
+
async check(ctx) {
|
|
1452
|
+
const out = [];
|
|
1453
|
+
for (const c of ctx.components ?? []) {
|
|
1454
|
+
if (!opts.applies(c)) continue;
|
|
1455
|
+
const bad = opts.bad(c);
|
|
1456
|
+
if (bad.length === 0) {
|
|
1457
|
+
out.push({
|
|
1458
|
+
id: opts.id,
|
|
1459
|
+
category: opts.category,
|
|
1460
|
+
severity,
|
|
1461
|
+
detection: PASS2,
|
|
1462
|
+
route: c.file,
|
|
1463
|
+
message: opts.label,
|
|
1464
|
+
recommendation: opts.recommendation,
|
|
1465
|
+
docsUrl: docsUrl7
|
|
1466
|
+
});
|
|
1467
|
+
continue;
|
|
1468
|
+
}
|
|
1469
|
+
for (const b of bad) {
|
|
1470
|
+
out.push({
|
|
1471
|
+
id: opts.id,
|
|
1472
|
+
category: opts.category,
|
|
1473
|
+
severity,
|
|
1474
|
+
detection: PENALIZED2,
|
|
1475
|
+
route: c.file,
|
|
1476
|
+
location: c.file,
|
|
1477
|
+
...b.line > 0 ? { line: b.line } : {},
|
|
1478
|
+
message: b.message,
|
|
1479
|
+
recommendation: opts.recommendation,
|
|
1480
|
+
docsUrl: docsUrl7
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
return out;
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// src/rules/correctness/correct001-002.ts
|
|
1490
|
+
var correct001EachKey = componentRule({
|
|
1491
|
+
id: "CORRECT001",
|
|
1492
|
+
title: "Keyed each block",
|
|
1493
|
+
category: "correctness",
|
|
1494
|
+
label: "Keyed {#each}",
|
|
1495
|
+
recommendation: "Add a key to the {#each} block, e.g. {#each items as item (item.id)}.",
|
|
1496
|
+
rationale: "An unkeyed {#each} destroys and recreates DOM nodes when the list reorders, losing element state/focus and wasting work; a key lets Svelte move nodes instead.",
|
|
1497
|
+
applies: (c) => c.eachBlocks.length > 0,
|
|
1498
|
+
bad: (c) => c.eachBlocks.filter((e) => !e.hasKey).map((e) => ({ line: e.line, message: "{#each} block has no key" }))
|
|
1499
|
+
});
|
|
1500
|
+
var correct002EffectDerived = componentRule({
|
|
1501
|
+
id: "CORRECT002",
|
|
1502
|
+
title: "Effect used to derive state",
|
|
1503
|
+
category: "correctness",
|
|
1504
|
+
label: "$effect usage",
|
|
1505
|
+
recommendation: "Replace the state-syncing $effect with a derived value, e.g. let x = $derived(expr).",
|
|
1506
|
+
rationale: 'An $effect whose body only assigns to $state is the "useEffect \u2192 $effect" anti-pattern: it reruns after render and can cause extra passes or loops. $derived expresses the same dependency declaratively.',
|
|
1507
|
+
applies: (c) => c.effects.length > 0,
|
|
1508
|
+
bad: (c) => c.effects.filter((e) => e.assignsOnlyState).map((e) => ({ line: e.line, message: "$effect only assigns state \u2014 use $derived instead" }))
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
// src/rules/security/sec001-002.ts
|
|
1512
|
+
var sec001Html = componentRule({
|
|
1513
|
+
id: "SEC001",
|
|
1514
|
+
title: "Raw HTML render",
|
|
1515
|
+
category: "security",
|
|
1516
|
+
label: "{@html} usage",
|
|
1517
|
+
recommendation: "Sanitize the value before {@html} (e.g. DOMPurify), or render it as text/markup instead.",
|
|
1518
|
+
rationale: "{@html} renders its value as unescaped HTML; if the value can contain user input and is not sanitized, it is a cross-site-scripting (XSS) vector.",
|
|
1519
|
+
applies: (c) => c.htmlTags.length > 0,
|
|
1520
|
+
bad: (c) => c.htmlTags.map((h) => ({ line: h.line, message: "{@html} renders unescaped HTML \u2014 ensure it is sanitized" }))
|
|
1521
|
+
});
|
|
1522
|
+
var sec002JavascriptUrl = componentRule({
|
|
1523
|
+
id: "SEC002",
|
|
1524
|
+
title: "javascript: URL",
|
|
1525
|
+
category: "security",
|
|
1526
|
+
label: "No javascript: URLs",
|
|
1527
|
+
recommendation: "Use an event handler or a real URL instead of a javascript: URL.",
|
|
1528
|
+
rationale: "A javascript: URL in href/src/action executes arbitrary script on activation \u2014 an XSS / unsafe-navigation vector that also breaks under a strict Content-Security-Policy.",
|
|
1529
|
+
applies: (c) => c.javascriptUrls.length > 0,
|
|
1530
|
+
bad: (c) => c.javascriptUrls.map((u) => ({ line: u.line, message: "javascript: URL in an attribute" }))
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1317
1533
|
// src/rules/index.ts
|
|
1318
1534
|
var allRules = [
|
|
1319
1535
|
seo001Title,
|
|
@@ -1350,7 +1566,14 @@ var allRules = [
|
|
|
1350
1566
|
perf005LcpImage,
|
|
1351
1567
|
perf006ResponsiveImage,
|
|
1352
1568
|
perf007RenderBlockingScript,
|
|
1353
|
-
perf008Preconnect
|
|
1569
|
+
perf008Preconnect,
|
|
1570
|
+
seo028TitleUnique,
|
|
1571
|
+
seo029DescriptionUnique,
|
|
1572
|
+
seo030HeadingOrder,
|
|
1573
|
+
correct001EachKey,
|
|
1574
|
+
correct002EffectDerived,
|
|
1575
|
+
sec001Html,
|
|
1576
|
+
sec002JavascriptUrl
|
|
1354
1577
|
];
|
|
1355
1578
|
function explainRule(id) {
|
|
1356
1579
|
const target = id.toUpperCase();
|
|
@@ -1486,9 +1709,11 @@ var SEVERITY_TITLE = {
|
|
|
1486
1709
|
};
|
|
1487
1710
|
var CATEGORY_LABEL = {
|
|
1488
1711
|
seo: "SEO",
|
|
1489
|
-
performance: "Performance"
|
|
1712
|
+
performance: "Performance",
|
|
1713
|
+
correctness: "Correctness",
|
|
1714
|
+
security: "Security"
|
|
1490
1715
|
};
|
|
1491
|
-
var CATEGORY_ORDER = ["seo", "performance"];
|
|
1716
|
+
var CATEGORY_ORDER = ["seo", "performance", "correctness", "security"];
|
|
1492
1717
|
function scoreLine(label, { score, scoreModel }) {
|
|
1493
1718
|
const parts = [`route avg ${scoreModel.routeAverage}`];
|
|
1494
1719
|
if (scoreModel.sitePenalty > 0) parts.push(`site \u2212${scoreModel.sitePenalty}`);
|
|
@@ -1941,6 +2166,8 @@ export {
|
|
|
1941
2166
|
classify,
|
|
1942
2167
|
computeHealth,
|
|
1943
2168
|
computeScore,
|
|
2169
|
+
correct001EachKey,
|
|
2170
|
+
correct002EffectDerived,
|
|
1944
2171
|
defaultConfig,
|
|
1945
2172
|
defaultProject,
|
|
1946
2173
|
defineConfig,
|
|
@@ -1971,6 +2198,8 @@ export {
|
|
|
1971
2198
|
safeHref,
|
|
1972
2199
|
scoreBand,
|
|
1973
2200
|
scoresByCategory,
|
|
2201
|
+
sec001Html,
|
|
2202
|
+
sec002JavascriptUrl,
|
|
1974
2203
|
selectRules,
|
|
1975
2204
|
seo001Title,
|
|
1976
2205
|
seo002Description,
|
|
@@ -1999,5 +2228,8 @@ export {
|
|
|
1999
2228
|
seo025ImageAlt,
|
|
2000
2229
|
seo026Hreflang,
|
|
2001
2230
|
seo027Heading,
|
|
2231
|
+
seo028TitleUnique,
|
|
2232
|
+
seo029DescriptionUnique,
|
|
2233
|
+
seo030HeadingOrder,
|
|
2002
2234
|
summarize
|
|
2003
2235
|
};
|