@svelte-vitals/core 0.15.0 → 0.18.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 +107 -8
- package/dist/index.js +415 -40
- package/package.json +2 -2
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' | 'architecture';
|
|
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,60 @@ 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
|
+
/** True when this $effect has a NON-EMPTY body that reads no reactive value and makes no bare call — it never re-runs, so it should be onMount (CORRECT003). */
|
|
220
|
+
mountOnly: boolean;
|
|
221
|
+
}
|
|
222
|
+
/** A flagged source position in a component (e.g. an `{@html}` tag or a `javascript:` URL). */
|
|
223
|
+
interface SourceSpan {
|
|
224
|
+
/** 1-based source line, or 0 if unknown. */
|
|
225
|
+
line: number;
|
|
226
|
+
}
|
|
227
|
+
/** Reactivity/correctness + security + architecture facts parsed from one `.svelte` component. */
|
|
228
|
+
interface ComponentFacts {
|
|
229
|
+
/** Source file the component came from. */
|
|
230
|
+
file: string;
|
|
231
|
+
eachBlocks: EachBlockFact[];
|
|
232
|
+
effects: EffectFact[];
|
|
233
|
+
/** `{@html …}` occurrences — raw-HTML render surfaces (Security SEC001). */
|
|
234
|
+
htmlTags: SourceSpan[];
|
|
235
|
+
/** Element attributes with a literal `javascript:` URL (Security SEC002). */
|
|
236
|
+
javascriptUrls: SourceSpan[];
|
|
237
|
+
/** Source line count of the component file (Architecture ARCH001). */
|
|
238
|
+
loc: number;
|
|
239
|
+
/** Named props destructured from `$props()`; 0 when unknowable (rest / non-destructured) (Architecture ARCH002). */
|
|
240
|
+
propCount: number;
|
|
241
|
+
/** Module specifiers of every `import` in the instance + module scripts (Bundle PERF009). */
|
|
242
|
+
imports: string[];
|
|
243
|
+
/** Value `import * as X from '<bare pkg>'` namespace imports (type-only excluded) — Bundle PERF010. */
|
|
244
|
+
namespaceImports: {
|
|
245
|
+
source: string;
|
|
246
|
+
line: number;
|
|
247
|
+
}[];
|
|
248
|
+
/** `$state` declarations never written or escaped anywhere in the component — candidates for const (CORRECT004). */
|
|
249
|
+
constableStates: {
|
|
250
|
+
name: string;
|
|
251
|
+
line: number;
|
|
252
|
+
}[];
|
|
253
|
+
}
|
|
254
|
+
|
|
201
255
|
/**
|
|
202
256
|
* Source-file locations that satisfy the project-scope rules, shared by every
|
|
203
257
|
* mode so the static (CLI) and rendered (plugin) collectors never drift. This
|
|
@@ -215,6 +269,8 @@ interface RuleContext {
|
|
|
215
269
|
images?: ResolvedImages[];
|
|
216
270
|
/** Per-route page-body headings for SEO027 (absent in modes that don't collect them). */
|
|
217
271
|
headings?: ResolvedHeadings[];
|
|
272
|
+
/** Per-file component-body facts for Correctness rules (static/CLI mode only). */
|
|
273
|
+
components?: ComponentFacts[];
|
|
218
274
|
project: Project;
|
|
219
275
|
config: Config;
|
|
220
276
|
}
|
|
@@ -282,9 +338,9 @@ declare const perf004FontPreloadCrossorigin: Rule;
|
|
|
282
338
|
|
|
283
339
|
/**
|
|
284
340
|
* PERF005 — LCP image not lazy-loaded. Lazy-loading the largest contentful paint
|
|
285
|
-
* image delays it.
|
|
286
|
-
*
|
|
287
|
-
*
|
|
341
|
+
* image delays it. Analysis approximates the LCP as the first <img> in document
|
|
342
|
+
* order for the route; if that image is loading="lazy", flag it. Runs in both
|
|
343
|
+
* static (CLI) and rendered (vite) mode, since both providers collect <img>.
|
|
288
344
|
*/
|
|
289
345
|
declare const perf005LcpImage: Rule;
|
|
290
346
|
|
|
@@ -330,8 +386,8 @@ declare const seo023DescriptionLength: Rule;
|
|
|
330
386
|
declare const seo024Charset: Rule;
|
|
331
387
|
|
|
332
388
|
/**
|
|
333
|
-
* SEO025 — Image alt text. Reuses the <img> collection
|
|
334
|
-
*
|
|
389
|
+
* SEO025 — Image alt text. Reuses the <img> collection from both providers — the
|
|
390
|
+
* static (CLI) source parser and the rendered (vite) HTML parser — like PERF001/002.
|
|
335
391
|
* Presence only: an explicit empty `alt=""` is a valid decorative-image signal and
|
|
336
392
|
* passes; a spread `{...rest}` may supply alt, so it is not flagged.
|
|
337
393
|
*/
|
|
@@ -353,6 +409,33 @@ declare const seo026Hreflang: Rule;
|
|
|
353
409
|
*/
|
|
354
410
|
declare const seo027Heading: Rule;
|
|
355
411
|
|
|
412
|
+
declare const seo028TitleUnique: Rule;
|
|
413
|
+
declare const seo029DescriptionUnique: Rule;
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* SEO030 — Skipped heading level. Walking a route's body headings in document
|
|
417
|
+
* order, a level that jumps more than +1 over the previous heading (e.g. h2 → h4)
|
|
418
|
+
* breaks the outline. The first heading has no predecessor (missing/multiple
|
|
419
|
+
* <h1> stays SEO027's concern). A route with no headings emits nothing.
|
|
420
|
+
*/
|
|
421
|
+
declare const seo030HeadingOrder: Rule;
|
|
422
|
+
|
|
423
|
+
declare const correct001EachKey: Rule;
|
|
424
|
+
declare const correct002EffectDerived: Rule;
|
|
425
|
+
declare const correct003EffectAsOnMount: Rule;
|
|
426
|
+
|
|
427
|
+
declare const correct004UnmutatedState: Rule;
|
|
428
|
+
|
|
429
|
+
declare const sec001Html: Rule;
|
|
430
|
+
declare const sec002JavascriptUrl: Rule;
|
|
431
|
+
|
|
432
|
+
declare const arch001ComponentSize: Rule;
|
|
433
|
+
declare const arch002PropCount: Rule;
|
|
434
|
+
|
|
435
|
+
declare const perf009HeavyImport: Rule;
|
|
436
|
+
|
|
437
|
+
declare const perf010NamespaceImport: Rule;
|
|
438
|
+
|
|
356
439
|
declare const allRules: Rule[];
|
|
357
440
|
|
|
358
441
|
interface RuleInfo {
|
|
@@ -443,10 +526,26 @@ declare function summarize(results: Result[], config: Config): Summary;
|
|
|
443
526
|
/** Whether the run should fail the build/CI per the minimum failing severity. */
|
|
444
527
|
declare function hasFailureAtOrAbove(summary: Summary, min: Severity): boolean;
|
|
445
528
|
|
|
529
|
+
/** String decorators for the console reporter. Injected so core stays pure/dep-free. */
|
|
530
|
+
interface Palette {
|
|
531
|
+
bold: (s: string) => string;
|
|
532
|
+
dim: (s: string) => string;
|
|
533
|
+
red: (s: string) => string;
|
|
534
|
+
yellow: (s: string) => string;
|
|
535
|
+
green: (s: string) => string;
|
|
536
|
+
cyan: (s: string) => string;
|
|
537
|
+
}
|
|
538
|
+
/** Default: no decoration (identity) — output is byte-identical to plain text. */
|
|
539
|
+
declare const noColorPalette: Palette;
|
|
540
|
+
/** Green ≥ 90, yellow ≥ 70, red otherwise — for a 0–100 score. */
|
|
541
|
+
declare function scoreColor(p: Palette, score: number): (s: string) => string;
|
|
542
|
+
|
|
446
543
|
interface ConsoleReportOptions {
|
|
447
544
|
byRoute?: boolean;
|
|
448
545
|
/** Mode label shown in the header (default 'static mode'). */
|
|
449
546
|
mode?: string;
|
|
547
|
+
/** Color decorators; defaults to no color. */
|
|
548
|
+
palette?: Palette;
|
|
450
549
|
}
|
|
451
550
|
/**
|
|
452
551
|
* Render results as a console report string (design §7). Pure: returns a string,
|
|
@@ -561,4 +660,4 @@ declare function selectRules(rules: Rule[], config: Config): Rule[];
|
|
|
561
660
|
/** Apply per-rule severity overrides to results (design §6). */
|
|
562
661
|
declare function applyRuleSeverities(results: Result[], config: Config): Result[];
|
|
563
662
|
|
|
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 };
|
|
663
|
+
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 Palette, 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, arch001ComponentSize, arch002PropCount, buildHtmlDocument, buildJsonReport, classify, computeHealth, computeScore, correct001EachKey, correct002EffectDerived, correct003EffectAsOnMount, correct004UnmutatedState, defaultConfig, defaultProject, defineConfig, docsUrlFor, effectiveSeverity, escapeHtml, explainRule, formatAgentReport, formatConsoleReport, formatGithubReport, formatHtmlReport, formatJsonReport, formatSarifReport, hasFailureAtOrAbove, headTagRule, imageRule, isPenalized, linkRule, noColorPalette, perf001ImageDimensions, perf002ImageLoading, perf003PreloadAs, perf004FontPreloadCrossorigin, perf005LcpImage, perf006ResponsiveImage, perf007RenderBlockingScript, perf008Preconnect, perf009HeavyImport, perf010NamespaceImport, runRules, safeHref, scoreBand, scoreColor, 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,322 @@ 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
|
+
var correct003EffectAsOnMount = componentRule({
|
|
1511
|
+
id: "CORRECT003",
|
|
1512
|
+
title: "Effect used as onMount",
|
|
1513
|
+
category: "correctness",
|
|
1514
|
+
label: "$effect usage",
|
|
1515
|
+
recommendation: "Move mount-time side effects to onMount (import { onMount } from 'svelte'); reserve $effect for logic that reacts to $state/$derived/$props.",
|
|
1516
|
+
rationale: "An $effect that reads no reactive value runs once after mount and never re-runs \u2014 it is an onMount in disguise, which obscures intent and misuses the reactivity system.",
|
|
1517
|
+
applies: (c) => c.effects.length > 0,
|
|
1518
|
+
bad: (c) => c.effects.filter((e) => e.mountOnly).map((e) => ({ line: e.line, message: "$effect reads no reactive value \u2014 use onMount instead" }))
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
// src/rules/correctness/correct004-unmutated-state.ts
|
|
1522
|
+
var correct004UnmutatedState = componentRule({
|
|
1523
|
+
id: "CORRECT004",
|
|
1524
|
+
title: "Unmutated $state",
|
|
1525
|
+
category: "correctness",
|
|
1526
|
+
severity: "info",
|
|
1527
|
+
label: "$state usage",
|
|
1528
|
+
recommendation: "If a value never changes, use const; if you only ever reassign it wholesale (never mutate its properties), use $state.raw to skip deep proxying.",
|
|
1529
|
+
rationale: "A $state that is never mutated pays for reactivity (deep proxying, tracking) it never uses; const (or $state.raw) is clearer and cheaper.",
|
|
1530
|
+
applies: (c) => c.constableStates.length > 0,
|
|
1531
|
+
bad: (c) => c.constableStates.map((s) => ({
|
|
1532
|
+
line: s.line,
|
|
1533
|
+
message: `$state "${s.name}" is never mutated \u2014 use const (or $state.raw if you only reassign it)`
|
|
1534
|
+
}))
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
// src/rules/security/sec001-002.ts
|
|
1538
|
+
var sec001Html = componentRule({
|
|
1539
|
+
id: "SEC001",
|
|
1540
|
+
title: "Raw HTML render",
|
|
1541
|
+
category: "security",
|
|
1542
|
+
label: "{@html} usage",
|
|
1543
|
+
recommendation: "Sanitize the value before {@html} (e.g. DOMPurify), or render it as text/markup instead.",
|
|
1544
|
+
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.",
|
|
1545
|
+
applies: (c) => c.htmlTags.length > 0,
|
|
1546
|
+
bad: (c) => c.htmlTags.map((h) => ({ line: h.line, message: "{@html} renders unescaped HTML \u2014 ensure it is sanitized" }))
|
|
1547
|
+
});
|
|
1548
|
+
var sec002JavascriptUrl = componentRule({
|
|
1549
|
+
id: "SEC002",
|
|
1550
|
+
title: "javascript: URL",
|
|
1551
|
+
category: "security",
|
|
1552
|
+
label: "No javascript: URLs",
|
|
1553
|
+
recommendation: "Use an event handler or a real URL instead of a javascript: URL.",
|
|
1554
|
+
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.",
|
|
1555
|
+
applies: (c) => c.javascriptUrls.length > 0,
|
|
1556
|
+
bad: (c) => c.javascriptUrls.map((u) => ({ line: u.line, message: "javascript: URL in an attribute" }))
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1559
|
+
// src/rules/architecture/arch001-002.ts
|
|
1560
|
+
var MAX_LOC = 400;
|
|
1561
|
+
var MAX_PROPS = 10;
|
|
1562
|
+
var arch001ComponentSize = componentRule({
|
|
1563
|
+
id: "ARCH001",
|
|
1564
|
+
title: "Component size",
|
|
1565
|
+
category: "architecture",
|
|
1566
|
+
severity: "info",
|
|
1567
|
+
label: "Component size",
|
|
1568
|
+
recommendation: `Split components over ${MAX_LOC} lines into smaller, focused pieces.`,
|
|
1569
|
+
rationale: "A very large component is hard to read, test, and reuse, and is a common sign that several responsibilities should be split out.",
|
|
1570
|
+
applies: (c) => c.loc > 0,
|
|
1571
|
+
// skip unanalyzable files (loc 0 = read/parse failure), don't PASS them
|
|
1572
|
+
bad: (c) => c.loc > MAX_LOC ? [{ line: 1, message: `Component is ${c.loc} lines (over ${MAX_LOC})` }] : []
|
|
1573
|
+
});
|
|
1574
|
+
var arch002PropCount = componentRule({
|
|
1575
|
+
id: "ARCH002",
|
|
1576
|
+
title: "Prop count",
|
|
1577
|
+
category: "architecture",
|
|
1578
|
+
severity: "info",
|
|
1579
|
+
label: "Prop count",
|
|
1580
|
+
recommendation: `Group related props into an object, or split the component, when it takes more than ${MAX_PROPS} props.`,
|
|
1581
|
+
rationale: "A component taking many props is usually doing too much; grouping or splitting keeps its API understandable.",
|
|
1582
|
+
applies: (c) => c.propCount > 0,
|
|
1583
|
+
// only components whose props we could count
|
|
1584
|
+
bad: (c) => c.propCount > MAX_PROPS ? [{ line: 1, message: `Component takes ${c.propCount} props (over ${MAX_PROPS})` }] : []
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
// src/rules/performance/perf009-heavy-import.ts
|
|
1588
|
+
var HEAVY_PACKAGES = {
|
|
1589
|
+
lodash: "import a submodule (lodash/debounce) or use lodash-es for tree-shaking",
|
|
1590
|
+
moment: "use a lighter date library (date-fns or dayjs) \u2014 moment is large and not tree-shakeable"
|
|
1591
|
+
};
|
|
1592
|
+
var perf009HeavyImport = componentRule({
|
|
1593
|
+
id: "PERF009",
|
|
1594
|
+
title: "Heavy dependency import",
|
|
1595
|
+
category: "performance",
|
|
1596
|
+
severity: "info",
|
|
1597
|
+
label: "No heavy imports",
|
|
1598
|
+
recommendation: "Import a submodule or switch to a lighter, tree-shakeable alternative.",
|
|
1599
|
+
rationale: "Importing a large, non-tree-shakeable package pulls its whole weight into the bundle even when only a fraction is used, slowing load.",
|
|
1600
|
+
applies: (c) => c.imports.length > 0,
|
|
1601
|
+
bad: (c) => {
|
|
1602
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1603
|
+
const out = [];
|
|
1604
|
+
for (const src of c.imports) {
|
|
1605
|
+
if (!Object.hasOwn(HEAVY_PACKAGES, src) || seen.has(src)) continue;
|
|
1606
|
+
seen.add(src);
|
|
1607
|
+
out.push({ line: 0, message: `Heavy import "${src}" \u2014 ${HEAVY_PACKAGES[src]}` });
|
|
1608
|
+
}
|
|
1609
|
+
return out;
|
|
1610
|
+
}
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
// src/rules/performance/perf010-namespace-import.ts
|
|
1614
|
+
var perf010NamespaceImport = componentRule({
|
|
1615
|
+
id: "PERF010",
|
|
1616
|
+
title: "Namespace import",
|
|
1617
|
+
category: "performance",
|
|
1618
|
+
severity: "info",
|
|
1619
|
+
label: "No namespace imports",
|
|
1620
|
+
recommendation: "Use named imports (import { x } from 'pkg') instead of import * as \u2014 a namespace import keeps the whole module in the bundle.",
|
|
1621
|
+
rationale: "A namespace import (import * as X) forces the bundler to retain the entire module, so unused exports cannot be tree-shaken out.",
|
|
1622
|
+
applies: (c) => c.namespaceImports.length > 0,
|
|
1623
|
+
bad: (c) => {
|
|
1624
|
+
const minLine = /* @__PURE__ */ new Map();
|
|
1625
|
+
for (const ns of c.namespaceImports) {
|
|
1626
|
+
const prev = minLine.get(ns.source);
|
|
1627
|
+
if (prev === void 0 || ns.line < prev) minLine.set(ns.source, ns.line);
|
|
1628
|
+
}
|
|
1629
|
+
return [...minLine.entries()].sort((a, b) => a[1] - b[1]).map(([source, line]) => ({
|
|
1630
|
+
line,
|
|
1631
|
+
message: `Namespace import "* as \u2026 from '${source}'" \u2014 prefer named imports so the bundler can tree-shake`
|
|
1632
|
+
}));
|
|
1633
|
+
}
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1317
1636
|
// src/rules/index.ts
|
|
1318
1637
|
var allRules = [
|
|
1319
1638
|
seo001Title,
|
|
@@ -1350,7 +1669,20 @@ var allRules = [
|
|
|
1350
1669
|
perf005LcpImage,
|
|
1351
1670
|
perf006ResponsiveImage,
|
|
1352
1671
|
perf007RenderBlockingScript,
|
|
1353
|
-
perf008Preconnect
|
|
1672
|
+
perf008Preconnect,
|
|
1673
|
+
seo028TitleUnique,
|
|
1674
|
+
seo029DescriptionUnique,
|
|
1675
|
+
seo030HeadingOrder,
|
|
1676
|
+
correct001EachKey,
|
|
1677
|
+
correct002EffectDerived,
|
|
1678
|
+
correct003EffectAsOnMount,
|
|
1679
|
+
correct004UnmutatedState,
|
|
1680
|
+
sec001Html,
|
|
1681
|
+
sec002JavascriptUrl,
|
|
1682
|
+
arch001ComponentSize,
|
|
1683
|
+
arch002PropCount,
|
|
1684
|
+
perf009HeavyImport,
|
|
1685
|
+
perf010NamespaceImport
|
|
1354
1686
|
];
|
|
1355
1687
|
function explainRule(id) {
|
|
1356
1688
|
const target = id.toUpperCase();
|
|
@@ -1477,6 +1809,21 @@ function computeHealth(results, config) {
|
|
|
1477
1809
|
return { health, categories, weights };
|
|
1478
1810
|
}
|
|
1479
1811
|
|
|
1812
|
+
// src/reporter/palette.ts
|
|
1813
|
+
var noColorPalette = {
|
|
1814
|
+
bold: (s) => s,
|
|
1815
|
+
dim: (s) => s,
|
|
1816
|
+
red: (s) => s,
|
|
1817
|
+
yellow: (s) => s,
|
|
1818
|
+
green: (s) => s,
|
|
1819
|
+
cyan: (s) => s
|
|
1820
|
+
};
|
|
1821
|
+
function scoreColor(p, score) {
|
|
1822
|
+
if (score >= 90) return p.green;
|
|
1823
|
+
if (score >= 70) return p.yellow;
|
|
1824
|
+
return p.red;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1480
1827
|
// src/reporter/console.ts
|
|
1481
1828
|
var RULE = "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500";
|
|
1482
1829
|
var SEVERITY_TITLE = {
|
|
@@ -1486,66 +1833,79 @@ var SEVERITY_TITLE = {
|
|
|
1486
1833
|
};
|
|
1487
1834
|
var CATEGORY_LABEL = {
|
|
1488
1835
|
seo: "SEO",
|
|
1489
|
-
performance: "Performance"
|
|
1836
|
+
performance: "Performance",
|
|
1837
|
+
correctness: "Correctness",
|
|
1838
|
+
security: "Security",
|
|
1839
|
+
architecture: "Architecture"
|
|
1490
1840
|
};
|
|
1491
|
-
var CATEGORY_ORDER = ["seo", "performance"];
|
|
1492
|
-
function scoreLine(label, { score, scoreModel }) {
|
|
1841
|
+
var CATEGORY_ORDER = ["seo", "performance", "correctness", "security", "architecture"];
|
|
1842
|
+
function scoreLine(p, label, { score, scoreModel }) {
|
|
1493
1843
|
const parts = [`route avg ${scoreModel.routeAverage}`];
|
|
1494
1844
|
if (scoreModel.sitePenalty > 0) parts.push(`site \u2212${scoreModel.sitePenalty}`);
|
|
1495
1845
|
if (scoreModel.criticalCap !== null) parts.push(`capped at ${scoreModel.criticalCap}: critical present`);
|
|
1496
|
-
return `${label} Score: ${score}/100 (${parts.join(" \xB7 ")})`;
|
|
1846
|
+
return `${label} Score: ${scoreColor(p, score)(`${score}/100`)} ${p.dim(`(${parts.join(" \xB7 ")})`)}`;
|
|
1497
1847
|
}
|
|
1498
|
-
function byRouteTree(results, config) {
|
|
1848
|
+
function byRouteTree(p, results, config) {
|
|
1499
1849
|
const routes = /* @__PURE__ */ new Map();
|
|
1500
1850
|
for (const r of results) {
|
|
1501
1851
|
if (r.route === void 0) continue;
|
|
1502
1852
|
if (!routes.has(r.route)) routes.set(r.route, []);
|
|
1503
1853
|
routes.get(r.route).push(r);
|
|
1504
1854
|
}
|
|
1505
|
-
const lines = ["By route", RULE];
|
|
1855
|
+
const lines = [p.bold("By route"), p.dim(RULE)];
|
|
1506
1856
|
for (const [route, rs] of [...routes.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
1507
1857
|
const { score } = computeScore(rs, config, { applyCriticalCap: false });
|
|
1508
|
-
lines.push(`${route.padEnd(28)} ${score}`);
|
|
1858
|
+
lines.push(`${route.padEnd(28)} ${scoreColor(p, score)(`${score}`)}`);
|
|
1509
1859
|
for (const r of rs.filter((x) => classify(x, config) === "fail")) {
|
|
1510
|
-
lines.push(` \u2717 ${r.id} ${r.message}`);
|
|
1860
|
+
lines.push(` ${p.red("\u2717")} ${r.id} ${r.message}`);
|
|
1511
1861
|
}
|
|
1512
1862
|
}
|
|
1513
1863
|
lines.push("");
|
|
1514
1864
|
return lines;
|
|
1515
1865
|
}
|
|
1516
1866
|
function formatConsoleReport(results, config, options = {}) {
|
|
1867
|
+
const p = options.palette ?? noColorPalette;
|
|
1517
1868
|
const summary = summarize(results, config);
|
|
1518
1869
|
const { health, categories: byCat } = computeHealth(results, config);
|
|
1519
1870
|
const present2 = CATEGORY_ORDER.filter((c) => byCat[c] !== void 0);
|
|
1520
|
-
const header = [
|
|
1871
|
+
const header = [
|
|
1872
|
+
p.bold(`Svelte Vitals \xB7 ${options.mode ?? "static mode"}`),
|
|
1873
|
+
"",
|
|
1874
|
+
`${p.bold("Health:")} ${scoreColor(p, health)(`${health}/100`)}`
|
|
1875
|
+
];
|
|
1521
1876
|
for (const c of present2) {
|
|
1522
|
-
header.push(scoreLine(CATEGORY_LABEL[c] ?? c, byCat[c]));
|
|
1877
|
+
header.push(scoreLine(p, CATEGORY_LABEL[c] ?? c, byCat[c]));
|
|
1523
1878
|
}
|
|
1524
1879
|
const lines = [...header, ""];
|
|
1880
|
+
const SEVERITY_COLOR = {
|
|
1881
|
+
critical: (s) => p.red(p.bold(s)),
|
|
1882
|
+
warning: (s) => p.yellow(p.bold(s)),
|
|
1883
|
+
info: (s) => p.dim(s)
|
|
1884
|
+
};
|
|
1525
1885
|
const failures = results.filter((r) => classify(r, config) === "fail");
|
|
1526
1886
|
for (const severity of ["critical", "warning", "info"]) {
|
|
1527
1887
|
const bucket = failures.filter((r) => effectiveSeverity(r, config) === severity);
|
|
1528
1888
|
if (bucket.length === 0) continue;
|
|
1529
|
-
lines.push(`${SEVERITY_TITLE[severity]} (${bucket.length})
|
|
1889
|
+
lines.push(SEVERITY_COLOR[severity](`${SEVERITY_TITLE[severity]} (${bucket.length})`), p.dim(RULE));
|
|
1530
1890
|
for (const r of bucket) {
|
|
1531
|
-
lines.push(
|
|
1532
|
-
if (r.route) lines.push(` ${r.route}`);
|
|
1533
|
-
if (r.location) lines.push(` ${r.location}${r.line ? `:${r.line}` : ""}`);
|
|
1891
|
+
lines.push(`${p.red("\u2717")} ${r.id} ${r.message}`);
|
|
1892
|
+
if (r.route) lines.push(p.dim(` ${r.route}`));
|
|
1893
|
+
if (r.location) lines.push(p.dim(` ${r.location}${r.line ? `:${r.line}` : ""}`));
|
|
1534
1894
|
}
|
|
1535
1895
|
lines.push("");
|
|
1536
1896
|
}
|
|
1537
1897
|
const passed = results.filter((r) => classify(r, config) !== "fail");
|
|
1538
1898
|
if (passed.length > 0) {
|
|
1539
|
-
lines.push(`Passed (${passed.length})
|
|
1899
|
+
lines.push(p.bold(`Passed (${passed.length})`), p.dim(RULE));
|
|
1540
1900
|
for (const r of passed) {
|
|
1541
|
-
const marker = classify(r, config) === "dynamic" ? " \u21AF dynamic" : "";
|
|
1901
|
+
const marker = classify(r, config) === "dynamic" ? p.cyan(" \u21AF dynamic") : "";
|
|
1542
1902
|
const route = r.route ? ` ${r.route}` : "";
|
|
1543
|
-
lines.push(
|
|
1903
|
+
lines.push(`${p.green("\u2713")} ${r.id} ${r.message}${marker}${route}`);
|
|
1544
1904
|
}
|
|
1545
1905
|
lines.push("");
|
|
1546
1906
|
}
|
|
1547
|
-
if (options.byRoute) lines.push(...byRouteTree(results, config));
|
|
1548
|
-
if (summary.dynamic > 0) lines.push("\u21AF = set dynamically (verified at runtime).");
|
|
1907
|
+
if (options.byRoute) lines.push(...byRouteTree(p, results, config));
|
|
1908
|
+
if (summary.dynamic > 0) lines.push(p.dim("\u21AF = set dynamically (verified at runtime)."));
|
|
1549
1909
|
return lines.join("\n").replace(/\n+$/, "\n");
|
|
1550
1910
|
}
|
|
1551
1911
|
|
|
@@ -1936,11 +2296,17 @@ export {
|
|
|
1936
2296
|
SITEMAP_SOURCE_PATHS,
|
|
1937
2297
|
allRules,
|
|
1938
2298
|
applyRuleSeverities,
|
|
2299
|
+
arch001ComponentSize,
|
|
2300
|
+
arch002PropCount,
|
|
1939
2301
|
buildHtmlDocument,
|
|
1940
2302
|
buildJsonReport,
|
|
1941
2303
|
classify,
|
|
1942
2304
|
computeHealth,
|
|
1943
2305
|
computeScore,
|
|
2306
|
+
correct001EachKey,
|
|
2307
|
+
correct002EffectDerived,
|
|
2308
|
+
correct003EffectAsOnMount,
|
|
2309
|
+
correct004UnmutatedState,
|
|
1944
2310
|
defaultConfig,
|
|
1945
2311
|
defaultProject,
|
|
1946
2312
|
defineConfig,
|
|
@@ -1959,6 +2325,7 @@ export {
|
|
|
1959
2325
|
imageRule,
|
|
1960
2326
|
isPenalized,
|
|
1961
2327
|
linkRule,
|
|
2328
|
+
noColorPalette,
|
|
1962
2329
|
perf001ImageDimensions,
|
|
1963
2330
|
perf002ImageLoading,
|
|
1964
2331
|
perf003PreloadAs,
|
|
@@ -1967,10 +2334,15 @@ export {
|
|
|
1967
2334
|
perf006ResponsiveImage,
|
|
1968
2335
|
perf007RenderBlockingScript,
|
|
1969
2336
|
perf008Preconnect,
|
|
2337
|
+
perf009HeavyImport,
|
|
2338
|
+
perf010NamespaceImport,
|
|
1970
2339
|
runRules,
|
|
1971
2340
|
safeHref,
|
|
1972
2341
|
scoreBand,
|
|
2342
|
+
scoreColor,
|
|
1973
2343
|
scoresByCategory,
|
|
2344
|
+
sec001Html,
|
|
2345
|
+
sec002JavascriptUrl,
|
|
1974
2346
|
selectRules,
|
|
1975
2347
|
seo001Title,
|
|
1976
2348
|
seo002Description,
|
|
@@ -1999,5 +2371,8 @@ export {
|
|
|
1999
2371
|
seo025ImageAlt,
|
|
2000
2372
|
seo026Hreflang,
|
|
2001
2373
|
seo027Heading,
|
|
2374
|
+
seo028TitleUnique,
|
|
2375
|
+
seo029DescriptionUnique,
|
|
2376
|
+
seo030HeadingOrder,
|
|
2002
2377
|
summarize
|
|
2003
2378
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svelte-vitals/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Shared, runtime-agnostic core for svelte-vitals (types, rule engine, scorer, reporter).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"homepage": "https://github.com/oekazuma/svelte-vitals#readme",
|
|
23
23
|
"engines": {
|
|
24
|
-
"node": ">=18"
|
|
24
|
+
"node": ">=18.20.8"
|
|
25
25
|
},
|
|
26
26
|
"sideEffects": false,
|
|
27
27
|
"exports": {
|