@svelte-vitals/core 0.16.0 → 0.19.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 +104 -3
- package/dist/index.js +580 -22
- package/package.json +5 -2
package/dist/index.d.ts
CHANGED
|
@@ -55,7 +55,7 @@ interface Result {
|
|
|
55
55
|
line?: number;
|
|
56
56
|
}
|
|
57
57
|
type Scope = 'route' | 'project' | 'component';
|
|
58
|
-
type Category = 'seo' | 'performance' | 'correctness' | 'security';
|
|
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. */
|
|
@@ -216,13 +216,22 @@ interface EffectFact {
|
|
|
216
216
|
line: number;
|
|
217
217
|
/** True when the effect body only assigns to `$state` variables (the "use $derived" smell). */
|
|
218
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;
|
|
219
221
|
}
|
|
220
222
|
/** A flagged source position in a component (e.g. an `{@html}` tag or a `javascript:` URL). */
|
|
221
223
|
interface SourceSpan {
|
|
222
224
|
/** 1-based source line, or 0 if unknown. */
|
|
223
225
|
line: number;
|
|
224
226
|
}
|
|
225
|
-
/**
|
|
227
|
+
/** An inline `svelte-vitals-disable-next-line` directive found in the component's source (issue #92). */
|
|
228
|
+
interface SuppressionDirective {
|
|
229
|
+
/** 1-based line the directive suppresses (the line immediately after the comment). */
|
|
230
|
+
line: number;
|
|
231
|
+
/** Rule ids suppressed on that line; undefined = suppress every rule on that line. */
|
|
232
|
+
ruleIds?: string[];
|
|
233
|
+
}
|
|
234
|
+
/** Reactivity/correctness + security + architecture facts parsed from one `.svelte` component. */
|
|
226
235
|
interface ComponentFacts {
|
|
227
236
|
/** Source file the component came from. */
|
|
228
237
|
file: string;
|
|
@@ -232,8 +241,74 @@ interface ComponentFacts {
|
|
|
232
241
|
htmlTags: SourceSpan[];
|
|
233
242
|
/** Element attributes with a literal `javascript:` URL (Security SEC002). */
|
|
234
243
|
javascriptUrls: SourceSpan[];
|
|
244
|
+
/** Source line count of the component file (Architecture ARCH001). */
|
|
245
|
+
loc: number;
|
|
246
|
+
/** Named props destructured from `$props()`; 0 when unknowable (rest / non-destructured) (Architecture ARCH002). */
|
|
247
|
+
propCount: number;
|
|
248
|
+
/** Module specifiers of every `import` in the instance + module scripts (Bundle PERF009). */
|
|
249
|
+
imports: string[];
|
|
250
|
+
/** Value `import * as X from '<bare pkg>'` namespace imports (type-only excluded) — Bundle PERF010. */
|
|
251
|
+
namespaceImports: {
|
|
252
|
+
source: string;
|
|
253
|
+
line: number;
|
|
254
|
+
}[];
|
|
255
|
+
/** `$state` declarations never written or escaped anywhere in the component — candidates for const (CORRECT004). */
|
|
256
|
+
constableStates: {
|
|
257
|
+
name: string;
|
|
258
|
+
line: number;
|
|
259
|
+
}[];
|
|
260
|
+
/** Inline `svelte-vitals-disable-next-line` directives found in this file's source — component-rule escape hatch (issue #92). Optional: absent is equivalent to no directives, so existing external constructors of `ComponentFacts` are unaffected. */
|
|
261
|
+
suppressions?: SuppressionDirective[];
|
|
235
262
|
}
|
|
236
263
|
|
|
264
|
+
/** Parse a component's reactivity/correctness + security + architecture facts (CLI/static + vite build mode). */
|
|
265
|
+
declare function parseComponentFacts(source: string, filename: string): {
|
|
266
|
+
eachBlocks: EachBlockFact[];
|
|
267
|
+
effects: EffectFact[];
|
|
268
|
+
htmlTags: SourceSpan[];
|
|
269
|
+
javascriptUrls: SourceSpan[];
|
|
270
|
+
loc: number;
|
|
271
|
+
propCount: number;
|
|
272
|
+
imports: string[];
|
|
273
|
+
namespaceImports: {
|
|
274
|
+
source: string;
|
|
275
|
+
line: number;
|
|
276
|
+
}[];
|
|
277
|
+
constableStates: {
|
|
278
|
+
name: string;
|
|
279
|
+
line: number;
|
|
280
|
+
}[];
|
|
281
|
+
suppressions: SuppressionDirective[];
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
type Node = any;
|
|
285
|
+
/**
|
|
286
|
+
* All keys that can bear child nodes in a Svelte AST node.
|
|
287
|
+
* Covers if/each/await blocks (pending/then/catch/fallback) as well as
|
|
288
|
+
* the standard fragment, nodes, consequent, alternate, and body keys.
|
|
289
|
+
*/
|
|
290
|
+
declare const CHILD_NODE_KEYS: string[];
|
|
291
|
+
/**
|
|
292
|
+
* Determine a value's kind from a list of child/text nodes (design §4, §11):
|
|
293
|
+
* - any ExpressionTag present → 'dynamic' (e.g. {data.title}); we do NOT
|
|
294
|
+
* follow the expression — that would turn this into runtime analysis.
|
|
295
|
+
* - non-whitespace Text only → 'static'
|
|
296
|
+
* - empty / whitespace only → 'absent'
|
|
297
|
+
*/
|
|
298
|
+
declare function valueFromNodes(nodes: Node[]): Value;
|
|
299
|
+
/** The literal text of a node list when fully static (no ExpressionTag), else undefined. */
|
|
300
|
+
declare function textFromNodes(nodes: Node[]): string | undefined;
|
|
301
|
+
/** Static string of an attribute (e.g. name="description"), or undefined if dynamic/absent. */
|
|
302
|
+
declare function attrText(attributes: Node[], name: string): string | undefined;
|
|
303
|
+
/** Value kind of an attribute's content (e.g. the `content` of a <meta>). */
|
|
304
|
+
declare function attrValue(attributes: Node[], name: string): Value;
|
|
305
|
+
declare function lineOf(source: string, offset: unknown): number;
|
|
306
|
+
declare function findAttr(attributes: Node[], name: string): Node | undefined;
|
|
307
|
+
/** Value kind of a single attribute (e.g. a component prop). */
|
|
308
|
+
declare function attrValueOf(attr: Node): Value;
|
|
309
|
+
/** Literal static text of a single attribute node (e.g. a component prop), or undefined if dynamic/absent. */
|
|
310
|
+
declare function attrTextOf(attr: Node): string | undefined;
|
|
311
|
+
|
|
237
312
|
/**
|
|
238
313
|
* Source-file locations that satisfy the project-scope rules, shared by every
|
|
239
314
|
* mode so the static (CLI) and rendered (plugin) collectors never drift. This
|
|
@@ -404,10 +479,20 @@ declare const seo030HeadingOrder: Rule;
|
|
|
404
479
|
|
|
405
480
|
declare const correct001EachKey: Rule;
|
|
406
481
|
declare const correct002EffectDerived: Rule;
|
|
482
|
+
declare const correct003EffectAsOnMount: Rule;
|
|
483
|
+
|
|
484
|
+
declare const correct004UnmutatedState: Rule;
|
|
407
485
|
|
|
408
486
|
declare const sec001Html: Rule;
|
|
409
487
|
declare const sec002JavascriptUrl: Rule;
|
|
410
488
|
|
|
489
|
+
declare const arch001ComponentSize: Rule;
|
|
490
|
+
declare const arch002PropCount: Rule;
|
|
491
|
+
|
|
492
|
+
declare const perf009HeavyImport: Rule;
|
|
493
|
+
|
|
494
|
+
declare const perf010NamespaceImport: Rule;
|
|
495
|
+
|
|
411
496
|
declare const allRules: Rule[];
|
|
412
497
|
|
|
413
498
|
interface RuleInfo {
|
|
@@ -498,10 +583,26 @@ declare function summarize(results: Result[], config: Config): Summary;
|
|
|
498
583
|
/** Whether the run should fail the build/CI per the minimum failing severity. */
|
|
499
584
|
declare function hasFailureAtOrAbove(summary: Summary, min: Severity): boolean;
|
|
500
585
|
|
|
586
|
+
/** String decorators for the console reporter. Injected so core stays pure/dep-free. */
|
|
587
|
+
interface Palette {
|
|
588
|
+
bold: (s: string) => string;
|
|
589
|
+
dim: (s: string) => string;
|
|
590
|
+
red: (s: string) => string;
|
|
591
|
+
yellow: (s: string) => string;
|
|
592
|
+
green: (s: string) => string;
|
|
593
|
+
cyan: (s: string) => string;
|
|
594
|
+
}
|
|
595
|
+
/** Default: no decoration (identity) — output is byte-identical to plain text. */
|
|
596
|
+
declare const noColorPalette: Palette;
|
|
597
|
+
/** Green ≥ 90, yellow ≥ 70, red otherwise — for a 0–100 score. */
|
|
598
|
+
declare function scoreColor(p: Palette, score: number): (s: string) => string;
|
|
599
|
+
|
|
501
600
|
interface ConsoleReportOptions {
|
|
502
601
|
byRoute?: boolean;
|
|
503
602
|
/** Mode label shown in the header (default 'static mode'). */
|
|
504
603
|
mode?: string;
|
|
604
|
+
/** Color decorators; defaults to no color. */
|
|
605
|
+
palette?: Palette;
|
|
505
606
|
}
|
|
506
607
|
/**
|
|
507
608
|
* Render results as a console report string (design §7). Pure: returns a string,
|
|
@@ -616,4 +717,4 @@ declare function selectRules(rules: Rule[], config: Config): Rule[];
|
|
|
616
717
|
/** Apply per-rule severity overrides to results (design §6). */
|
|
617
718
|
declare function applyRuleSeverities(results: Result[], config: Config): Result[];
|
|
618
719
|
|
|
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 };
|
|
720
|
+
export { BAND_COLOR, CHILD_NODE_KEYS, 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 SuppressionDirective, type TreatDynamicAs, type Value, allRules, applyRuleSeverities, arch001ComponentSize, arch002PropCount, attrText, attrTextOf, attrValue, attrValueOf, buildHtmlDocument, buildJsonReport, classify, computeHealth, computeScore, correct001EachKey, correct002EffectDerived, correct003EffectAsOnMount, correct004UnmutatedState, defaultConfig, defaultProject, defineConfig, docsUrlFor, effectiveSeverity, escapeHtml, explainRule, findAttr, formatAgentReport, formatConsoleReport, formatGithubReport, formatHtmlReport, formatJsonReport, formatSarifReport, hasFailureAtOrAbove, headTagRule, imageRule, isPenalized, lineOf, linkRule, noColorPalette, parseComponentFacts, 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, textFromNodes, valueFromNodes };
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,408 @@ function defineConfig(config = {}) {
|
|
|
14
14
|
return { ...defaultConfig, ...config };
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// src/component-parse.ts
|
|
18
|
+
import { parse } from "svelte/compiler";
|
|
19
|
+
|
|
20
|
+
// src/svelte-ast.ts
|
|
21
|
+
var CHILD_NODE_KEYS = [
|
|
22
|
+
"fragment",
|
|
23
|
+
"nodes",
|
|
24
|
+
"consequent",
|
|
25
|
+
"alternate",
|
|
26
|
+
"body",
|
|
27
|
+
"pending",
|
|
28
|
+
"then",
|
|
29
|
+
"catch",
|
|
30
|
+
"fallback"
|
|
31
|
+
];
|
|
32
|
+
function valueFromNodes(nodes) {
|
|
33
|
+
if (!Array.isArray(nodes)) return "absent";
|
|
34
|
+
if (nodes.some((n) => n?.type === "ExpressionTag")) return "dynamic";
|
|
35
|
+
const text = nodes.filter((n) => n?.type === "Text").map((n) => String(n.data ?? "")).join("");
|
|
36
|
+
return text.trim().length > 0 ? "static" : "absent";
|
|
37
|
+
}
|
|
38
|
+
function textFromNodes(nodes) {
|
|
39
|
+
if (!Array.isArray(nodes) || nodes.some((n) => n?.type === "ExpressionTag")) return void 0;
|
|
40
|
+
const text = nodes.filter((n) => n?.type === "Text").map((n) => String(n.data ?? "")).join("");
|
|
41
|
+
return text.trim().length > 0 ? text : void 0;
|
|
42
|
+
}
|
|
43
|
+
function attrText(attributes, name) {
|
|
44
|
+
const attr = findAttr(attributes, name);
|
|
45
|
+
if (!attr) return void 0;
|
|
46
|
+
const v = attr.value;
|
|
47
|
+
if (v === true) return "";
|
|
48
|
+
if (Array.isArray(v)) {
|
|
49
|
+
if (v.some((n) => n?.type === "ExpressionTag")) return void 0;
|
|
50
|
+
return v.filter((n) => n?.type === "Text").map((n) => String(n.data ?? "")).join("");
|
|
51
|
+
}
|
|
52
|
+
return void 0;
|
|
53
|
+
}
|
|
54
|
+
function attrValue(attributes, name) {
|
|
55
|
+
const attr = findAttr(attributes, name);
|
|
56
|
+
if (!attr) return "absent";
|
|
57
|
+
const v = attr.value;
|
|
58
|
+
if (v === true) return "absent";
|
|
59
|
+
if (Array.isArray(v)) return valueFromNodes(v);
|
|
60
|
+
if (v && v.type === "ExpressionTag") return "dynamic";
|
|
61
|
+
return "absent";
|
|
62
|
+
}
|
|
63
|
+
function lineOf(source, offset) {
|
|
64
|
+
if (typeof offset !== "number" || offset < 0) return 0;
|
|
65
|
+
let line = 1;
|
|
66
|
+
const end = Math.min(offset, source.length);
|
|
67
|
+
for (let i = 0; i < end; i++) if (source[i] === "\n") line++;
|
|
68
|
+
return line;
|
|
69
|
+
}
|
|
70
|
+
function findAttr(attributes, name) {
|
|
71
|
+
if (!Array.isArray(attributes)) return void 0;
|
|
72
|
+
return attributes.find((a) => a?.type === "Attribute" && a.name === name);
|
|
73
|
+
}
|
|
74
|
+
function attrValueOf(attr) {
|
|
75
|
+
const v = attr?.value;
|
|
76
|
+
if (v === true) return "absent";
|
|
77
|
+
if (Array.isArray(v)) return valueFromNodes(v);
|
|
78
|
+
if (v && v.type === "ExpressionTag") return "dynamic";
|
|
79
|
+
return "absent";
|
|
80
|
+
}
|
|
81
|
+
function attrTextOf(attr) {
|
|
82
|
+
const v = attr?.value;
|
|
83
|
+
if (!Array.isArray(v) || v.some((n) => n?.type === "ExpressionTag")) return void 0;
|
|
84
|
+
const text = v.filter((n) => n?.type === "Text").map((n) => String(n.data ?? "")).join("");
|
|
85
|
+
return text.trim().length > 0 ? text : void 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/component-parse.ts
|
|
89
|
+
function isConstantListEach(node) {
|
|
90
|
+
const expr = node?.expression;
|
|
91
|
+
return expr?.type === "ArrayExpression" && Array.isArray(expr.elements) && !expr.elements.some((el) => el?.type === "SpreadElement");
|
|
92
|
+
}
|
|
93
|
+
function collectEachBlocks(node, source, acc) {
|
|
94
|
+
if (Array.isArray(node)) {
|
|
95
|
+
for (const child of node) collectEachBlocks(child, source, acc);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (!node || typeof node !== "object") return;
|
|
99
|
+
if (node.type === "EachBlock" && !isConstantListEach(node)) {
|
|
100
|
+
acc.push({ hasKey: node.key != null, line: lineOf(source, node.start) });
|
|
101
|
+
}
|
|
102
|
+
for (const key of CHILD_NODE_KEYS) {
|
|
103
|
+
if (key in node) collectEachBlocks(node[key], source, acc);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function walkEstree(node, visit) {
|
|
107
|
+
if (Array.isArray(node)) {
|
|
108
|
+
for (const child of node) walkEstree(child, visit);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string") return;
|
|
112
|
+
visit(node);
|
|
113
|
+
for (const key of Object.keys(node)) {
|
|
114
|
+
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
|
|
115
|
+
walkEstree(node[key], visit);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function isEffectCall(node) {
|
|
119
|
+
const c = node?.callee;
|
|
120
|
+
if (c?.type === "Identifier") return c.name === "$effect";
|
|
121
|
+
if (c?.type === "MemberExpression" && c.object?.type === "Identifier" && c.object.name === "$effect") {
|
|
122
|
+
return c.property?.type === "Identifier" && c.property.name === "pre";
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
function isStateDeclaration(node) {
|
|
127
|
+
const c = node?.callee;
|
|
128
|
+
if (c?.type === "Identifier") return c.name === "$state";
|
|
129
|
+
if (c?.type === "MemberExpression" && c.object?.type === "Identifier" && c.object.name === "$state") {
|
|
130
|
+
return c.property?.type === "Identifier" && (c.property.name === "raw" || c.property.name === "frozen");
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
function bodyOnlyAssignsState(fn, stateNames) {
|
|
135
|
+
const isStateAssign = (expr) => expr?.type === "AssignmentExpression" && expr.operator === "=" && expr.left?.type === "Identifier" && stateNames.has(expr.left.name);
|
|
136
|
+
const body = fn?.body;
|
|
137
|
+
if (!body) return false;
|
|
138
|
+
if (body.type !== "BlockStatement") return isStateAssign(body);
|
|
139
|
+
if (body.body.length === 0) return false;
|
|
140
|
+
return body.body.every((s) => s?.type === "ExpressionStatement" && isStateAssign(s.expression));
|
|
141
|
+
}
|
|
142
|
+
function isDerivedDeclaration(node) {
|
|
143
|
+
const c = node?.callee;
|
|
144
|
+
if (c?.type === "Identifier") return c.name === "$derived";
|
|
145
|
+
if (c?.type === "MemberExpression" && c.object?.type === "Identifier" && c.object.name === "$derived") {
|
|
146
|
+
return c.property?.type === "Identifier" && c.property.name === "by";
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
function addBoundNames(id, acc) {
|
|
151
|
+
if (!id) return;
|
|
152
|
+
switch (id.type) {
|
|
153
|
+
case "Identifier":
|
|
154
|
+
acc.add(id.name);
|
|
155
|
+
break;
|
|
156
|
+
case "ObjectPattern":
|
|
157
|
+
for (const p of id.properties ?? []) {
|
|
158
|
+
if (p?.type === "Property") addBoundNames(p.value, acc);
|
|
159
|
+
else if (p?.type === "RestElement") addBoundNames(p.argument, acc);
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
case "ArrayPattern":
|
|
163
|
+
for (const el of id.elements ?? []) addBoundNames(el, acc);
|
|
164
|
+
break;
|
|
165
|
+
case "AssignmentPattern":
|
|
166
|
+
addBoundNames(id.left, acc);
|
|
167
|
+
break;
|
|
168
|
+
case "RestElement":
|
|
169
|
+
addBoundNames(id.argument, acc);
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function rootObjectName(node) {
|
|
174
|
+
let cur = node;
|
|
175
|
+
while (cur?.type === "MemberExpression") cur = cur.object;
|
|
176
|
+
return cur?.type === "Identifier" ? cur.name : void 0;
|
|
177
|
+
}
|
|
178
|
+
function collectStateWrites(root, stateNames, acc) {
|
|
179
|
+
walkEstree(root, (n) => {
|
|
180
|
+
if (n?.type === "AssignmentExpression") {
|
|
181
|
+
if (n.left?.type === "Identifier" && stateNames.has(n.left.name)) acc.add(n.left.name);
|
|
182
|
+
else if (n.left?.type === "MemberExpression") {
|
|
183
|
+
const r = rootObjectName(n.left);
|
|
184
|
+
if (r && stateNames.has(r)) acc.add(r);
|
|
185
|
+
} else if (n.left?.type === "ObjectPattern" || n.left?.type === "ArrayPattern") {
|
|
186
|
+
const bound = /* @__PURE__ */ new Set();
|
|
187
|
+
addBoundNames(n.left, bound);
|
|
188
|
+
for (const name of bound) if (stateNames.has(name)) acc.add(name);
|
|
189
|
+
}
|
|
190
|
+
} else if (n?.type === "UpdateExpression") {
|
|
191
|
+
const r = rootObjectName(n.argument);
|
|
192
|
+
if (r && stateNames.has(r)) acc.add(r);
|
|
193
|
+
} else if (n?.type === "UnaryExpression" && n.operator === "delete") {
|
|
194
|
+
const r = rootObjectName(n.argument);
|
|
195
|
+
if (r && stateNames.has(r)) acc.add(r);
|
|
196
|
+
} else if (n?.type === "CallExpression") {
|
|
197
|
+
if (n.callee?.type === "MemberExpression") {
|
|
198
|
+
const r = rootObjectName(n.callee);
|
|
199
|
+
if (r && stateNames.has(r)) acc.add(r);
|
|
200
|
+
}
|
|
201
|
+
for (const a of n.arguments ?? []) {
|
|
202
|
+
const arg = a?.type === "SpreadElement" ? a.argument : a;
|
|
203
|
+
const r = rootObjectName(arg);
|
|
204
|
+
if (r && stateNames.has(r)) acc.add(r);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
var COMPONENT_LIKE_TYPES = /* @__PURE__ */ new Set(["Component", "SvelteComponent", "SvelteSelf"]);
|
|
210
|
+
function collectTemplateEscapes(node, stateNames, acc) {
|
|
211
|
+
if (Array.isArray(node)) {
|
|
212
|
+
for (const c of node) collectTemplateEscapes(c, stateNames, acc);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string") return;
|
|
216
|
+
if (Array.isArray(node.attributes)) {
|
|
217
|
+
for (const attr of node.attributes) {
|
|
218
|
+
if (attr?.type === "BindDirective") {
|
|
219
|
+
const r = rootObjectName(attr.expression);
|
|
220
|
+
if (r && stateNames.has(r)) acc.add(r);
|
|
221
|
+
} else if (COMPONENT_LIKE_TYPES.has(node.type)) {
|
|
222
|
+
walkEstree(attr, (m) => {
|
|
223
|
+
if (m?.type === "Identifier" && stateNames.has(m.name)) acc.add(m.name);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
for (const key of CHILD_NODE_KEYS) {
|
|
229
|
+
if (key in node) collectTemplateEscapes(node[key], stateNames, acc);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
var RUNE_NAMES = /* @__PURE__ */ new Set(["$state", "$derived", "$effect", "$props", "$bindable", "$inspect", "$host"]);
|
|
233
|
+
function bodyReadsReactive(fn, reactiveNames) {
|
|
234
|
+
let reads = false;
|
|
235
|
+
const IGNORED_KEYS = /* @__PURE__ */ new Set(["type", "start", "end", "loc", "range"]);
|
|
236
|
+
const visit = (n) => {
|
|
237
|
+
if (reads || !n) return;
|
|
238
|
+
if (Array.isArray(n)) {
|
|
239
|
+
for (const c of n) visit(c);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (typeof n !== "object" || typeof n.type !== "string") return;
|
|
243
|
+
if (n.type === "Identifier") {
|
|
244
|
+
if (reactiveNames.has(n.name) || n.name.startsWith("$") && !RUNE_NAMES.has(n.name)) reads = true;
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (n.type === "CallExpression" && n.callee?.type === "Identifier") {
|
|
248
|
+
reads = true;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (n.type === "MemberExpression") {
|
|
252
|
+
visit(n.object);
|
|
253
|
+
if (n.computed) visit(n.property);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (n.type === "Property") {
|
|
257
|
+
if (n.computed) visit(n.key);
|
|
258
|
+
visit(n.value);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
for (const key of Object.keys(n)) {
|
|
262
|
+
if (!IGNORED_KEYS.has(key)) visit(n[key]);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
visit(fn.body);
|
|
266
|
+
return reads;
|
|
267
|
+
}
|
|
268
|
+
function bodyIsEmpty(fn) {
|
|
269
|
+
const body = fn?.body;
|
|
270
|
+
if (!body) return true;
|
|
271
|
+
if (body.type === "BlockStatement") return (body.body ?? []).length === 0;
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
var URL_ATTRS = ["href", "src", "action", "formaction"];
|
|
275
|
+
function collectSecurityFacts(node, source, htmlTags, jsUrls) {
|
|
276
|
+
if (Array.isArray(node)) {
|
|
277
|
+
for (const child of node) collectSecurityFacts(child, source, htmlTags, jsUrls);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (!node || typeof node !== "object") return;
|
|
281
|
+
if (node.type === "HtmlTag") htmlTags.push({ line: lineOf(source, node.start) });
|
|
282
|
+
if ((node.type === "RegularElement" || node.type === "SvelteElement") && Array.isArray(node.attributes)) {
|
|
283
|
+
for (const name of URL_ATTRS) {
|
|
284
|
+
const attr = findAttr(node.attributes, name);
|
|
285
|
+
if (!attr) continue;
|
|
286
|
+
const value = attrTextOf(attr);
|
|
287
|
+
if (value !== void 0 && /^\s*javascript:/i.test(value)) {
|
|
288
|
+
jsUrls.push({ line: lineOf(source, attr.start ?? node.start) });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
for (const key of CHILD_NODE_KEYS) {
|
|
293
|
+
if (key in node) collectSecurityFacts(node[key], source, htmlTags, jsUrls);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function isPropsCall(node) {
|
|
297
|
+
return node?.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "$props";
|
|
298
|
+
}
|
|
299
|
+
function countProps(program) {
|
|
300
|
+
let count = 0;
|
|
301
|
+
let seen = 0;
|
|
302
|
+
let uncountable = false;
|
|
303
|
+
walkEstree(program, (n) => {
|
|
304
|
+
if (n.type !== "VariableDeclarator" || !n.init || !isPropsCall(n.init)) return;
|
|
305
|
+
seen++;
|
|
306
|
+
const props = n.id?.type === "ObjectPattern" ? n.id.properties : void 0;
|
|
307
|
+
if (!Array.isArray(props) || props.some((p) => p?.type === "RestElement")) {
|
|
308
|
+
uncountable = true;
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
count = props.filter((p) => p?.type === "Property").length;
|
|
312
|
+
});
|
|
313
|
+
return uncountable || seen > 1 ? 0 : count;
|
|
314
|
+
}
|
|
315
|
+
function countLines(source) {
|
|
316
|
+
if (source.length === 0) return 0;
|
|
317
|
+
return source.split("\n").length - (source.endsWith("\n") ? 1 : 0);
|
|
318
|
+
}
|
|
319
|
+
function collectImportSources(program, acc) {
|
|
320
|
+
walkEstree(program, (n) => {
|
|
321
|
+
if (n.type === "ImportDeclaration" && typeof n.source?.value === "string") acc.push(n.source.value);
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
function isBareSpecifier(s) {
|
|
325
|
+
return !/^[./$#]/.test(s);
|
|
326
|
+
}
|
|
327
|
+
function collectNamespaceImports(program, source, acc) {
|
|
328
|
+
walkEstree(program, (n) => {
|
|
329
|
+
if (n.type !== "ImportDeclaration" || n.importKind === "type") return;
|
|
330
|
+
const spec = n.source?.value;
|
|
331
|
+
if (typeof spec !== "string" || !isBareSpecifier(spec)) return;
|
|
332
|
+
if (Array.isArray(n.specifiers) && n.specifiers.some((s) => s?.type === "ImportNamespaceSpecifier")) {
|
|
333
|
+
acc.push({ source: spec, line: lineOf(source, n.start) });
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
var JS_DIRECTIVE = /^\s*\/\/\s*svelte-vitals-disable-next-line(?:\s+([A-Za-z]+\d+(?:\s*,\s*[A-Za-z]+\d+)*))?\s*$/;
|
|
338
|
+
var HTML_DIRECTIVE = /^\s*<!--\s*svelte-vitals-disable-next-line(?:\s+([A-Za-z]+\d+(?:\s*,\s*[A-Za-z]+\d+)*))?\s*-->\s*$/;
|
|
339
|
+
function collectSuppressions(source) {
|
|
340
|
+
const out = [];
|
|
341
|
+
const lines = source.split("\n");
|
|
342
|
+
lines.forEach((line, i) => {
|
|
343
|
+
const m = JS_DIRECTIVE.exec(line) ?? HTML_DIRECTIVE.exec(line);
|
|
344
|
+
if (!m) return;
|
|
345
|
+
const ruleIds = m[1]?.split(",").map((s) => s.trim().toUpperCase());
|
|
346
|
+
out.push({ line: i + 2, ruleIds });
|
|
347
|
+
});
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
function parseComponentFacts(source, filename) {
|
|
351
|
+
const ast = parse(source, { modern: true, filename });
|
|
352
|
+
const eachBlocks = [];
|
|
353
|
+
collectEachBlocks(ast.fragment ?? ast, source, eachBlocks);
|
|
354
|
+
const htmlTags = [];
|
|
355
|
+
const javascriptUrls = [];
|
|
356
|
+
collectSecurityFacts(ast.fragment ?? ast, source, htmlTags, javascriptUrls);
|
|
357
|
+
const loc = countLines(source);
|
|
358
|
+
const suppressions = collectSuppressions(source);
|
|
359
|
+
const imports = [];
|
|
360
|
+
const namespaceImports = [];
|
|
361
|
+
if (ast.module?.content) {
|
|
362
|
+
collectImportSources(ast.module.content, imports);
|
|
363
|
+
collectNamespaceImports(ast.module.content, source, namespaceImports);
|
|
364
|
+
}
|
|
365
|
+
const effects = [];
|
|
366
|
+
const constableStates = [];
|
|
367
|
+
let propCount = 0;
|
|
368
|
+
const program = ast.instance?.content;
|
|
369
|
+
if (program) {
|
|
370
|
+
collectImportSources(program, imports);
|
|
371
|
+
collectNamespaceImports(program, source, namespaceImports);
|
|
372
|
+
propCount = countProps(program);
|
|
373
|
+
const stateNames = /* @__PURE__ */ new Set();
|
|
374
|
+
const reactiveNames = /* @__PURE__ */ new Set();
|
|
375
|
+
const stateDecls = [];
|
|
376
|
+
walkEstree(program, (n) => {
|
|
377
|
+
if (n.type !== "VariableDeclarator" || !n.init) return;
|
|
378
|
+
if (isStateDeclaration(n.init) && n.id?.type === "Identifier") {
|
|
379
|
+
stateNames.add(n.id.name);
|
|
380
|
+
stateDecls.push({ name: n.id.name, line: lineOf(source, n.start) });
|
|
381
|
+
}
|
|
382
|
+
if (isStateDeclaration(n.init) || isDerivedDeclaration(n.init) || isPropsCall(n.init))
|
|
383
|
+
addBoundNames(n.id, reactiveNames);
|
|
384
|
+
});
|
|
385
|
+
walkEstree(program, (n) => {
|
|
386
|
+
if (n.type !== "CallExpression" || !isEffectCall(n)) return;
|
|
387
|
+
const fn = n.arguments?.[0];
|
|
388
|
+
const isFn = fn?.type === "ArrowFunctionExpression" || fn?.type === "FunctionExpression";
|
|
389
|
+
effects.push({
|
|
390
|
+
line: lineOf(source, n.start),
|
|
391
|
+
assignsOnlyState: isFn ? bodyOnlyAssignsState(fn, stateNames) : false,
|
|
392
|
+
mountOnly: isFn ? !bodyIsEmpty(fn) && !bodyReadsReactive(fn, reactiveNames) : false
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
const writtenOrEscaped = /* @__PURE__ */ new Set();
|
|
396
|
+
collectStateWrites(program, stateNames, writtenOrEscaped);
|
|
397
|
+
if (ast.fragment) {
|
|
398
|
+
collectStateWrites(ast.fragment, stateNames, writtenOrEscaped);
|
|
399
|
+
collectTemplateEscapes(ast.fragment, stateNames, writtenOrEscaped);
|
|
400
|
+
}
|
|
401
|
+
for (const d of stateDecls) {
|
|
402
|
+
if (!writtenOrEscaped.has(d.name)) constableStates.push(d);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
eachBlocks,
|
|
407
|
+
effects,
|
|
408
|
+
htmlTags,
|
|
409
|
+
javascriptUrls,
|
|
410
|
+
loc,
|
|
411
|
+
propCount,
|
|
412
|
+
imports,
|
|
413
|
+
namespaceImports,
|
|
414
|
+
constableStates,
|
|
415
|
+
suppressions
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
17
419
|
// src/project-paths.ts
|
|
18
420
|
var ROBOTS_SOURCE_PATHS = [
|
|
19
421
|
"static/robots.txt",
|
|
@@ -1438,6 +1840,9 @@ var seo030HeadingOrder = {
|
|
|
1438
1840
|
// src/rules/component-rule.ts
|
|
1439
1841
|
var PENALIZED2 = { presence: "none", value: "absent" };
|
|
1440
1842
|
var PASS2 = { presence: "own", value: "static" };
|
|
1843
|
+
function isSuppressed(c, ruleId, line) {
|
|
1844
|
+
return (c.suppressions ?? []).some((s) => s.line === line && (!s.ruleIds || s.ruleIds.includes(ruleId)));
|
|
1845
|
+
}
|
|
1441
1846
|
function componentRule(opts) {
|
|
1442
1847
|
const docsUrl7 = docsUrlFor(opts.id);
|
|
1443
1848
|
const severity = opts.severity ?? "warning";
|
|
@@ -1452,7 +1857,7 @@ function componentRule(opts) {
|
|
|
1452
1857
|
const out = [];
|
|
1453
1858
|
for (const c of ctx.components ?? []) {
|
|
1454
1859
|
if (!opts.applies(c)) continue;
|
|
1455
|
-
const bad = opts.bad(c);
|
|
1860
|
+
const bad = opts.bad(c).filter((b) => !(b.line > 0 && isSuppressed(c, opts.id, b.line)));
|
|
1456
1861
|
if (bad.length === 0) {
|
|
1457
1862
|
out.push({
|
|
1458
1863
|
id: opts.id,
|
|
@@ -1507,6 +1912,32 @@ var correct002EffectDerived = componentRule({
|
|
|
1507
1912
|
applies: (c) => c.effects.length > 0,
|
|
1508
1913
|
bad: (c) => c.effects.filter((e) => e.assignsOnlyState).map((e) => ({ line: e.line, message: "$effect only assigns state \u2014 use $derived instead" }))
|
|
1509
1914
|
});
|
|
1915
|
+
var correct003EffectAsOnMount = componentRule({
|
|
1916
|
+
id: "CORRECT003",
|
|
1917
|
+
title: "Effect used as onMount",
|
|
1918
|
+
category: "correctness",
|
|
1919
|
+
label: "$effect usage",
|
|
1920
|
+
recommendation: "Move mount-time side effects to onMount (import { onMount } from 'svelte'); reserve $effect for logic that reacts to $state/$derived/$props.",
|
|
1921
|
+
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.",
|
|
1922
|
+
applies: (c) => c.effects.length > 0,
|
|
1923
|
+
bad: (c) => c.effects.filter((e) => e.mountOnly).map((e) => ({ line: e.line, message: "$effect reads no reactive value \u2014 use onMount instead" }))
|
|
1924
|
+
});
|
|
1925
|
+
|
|
1926
|
+
// src/rules/correctness/correct004-unmutated-state.ts
|
|
1927
|
+
var correct004UnmutatedState = componentRule({
|
|
1928
|
+
id: "CORRECT004",
|
|
1929
|
+
title: "Unmutated $state",
|
|
1930
|
+
category: "correctness",
|
|
1931
|
+
severity: "info",
|
|
1932
|
+
label: "$state usage",
|
|
1933
|
+
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.",
|
|
1934
|
+
rationale: "A $state that is never mutated pays for reactivity (deep proxying, tracking) it never uses; const (or $state.raw) is clearer and cheaper.",
|
|
1935
|
+
applies: (c) => c.constableStates.length > 0,
|
|
1936
|
+
bad: (c) => c.constableStates.map((s) => ({
|
|
1937
|
+
line: s.line,
|
|
1938
|
+
message: `$state "${s.name}" is never mutated \u2014 use const (or $state.raw if you only reassign it)`
|
|
1939
|
+
}))
|
|
1940
|
+
});
|
|
1510
1941
|
|
|
1511
1942
|
// src/rules/security/sec001-002.ts
|
|
1512
1943
|
var sec001Html = componentRule({
|
|
@@ -1530,6 +1961,83 @@ var sec002JavascriptUrl = componentRule({
|
|
|
1530
1961
|
bad: (c) => c.javascriptUrls.map((u) => ({ line: u.line, message: "javascript: URL in an attribute" }))
|
|
1531
1962
|
});
|
|
1532
1963
|
|
|
1964
|
+
// src/rules/architecture/arch001-002.ts
|
|
1965
|
+
var MAX_LOC = 400;
|
|
1966
|
+
var MAX_PROPS = 10;
|
|
1967
|
+
var arch001ComponentSize = componentRule({
|
|
1968
|
+
id: "ARCH001",
|
|
1969
|
+
title: "Component size",
|
|
1970
|
+
category: "architecture",
|
|
1971
|
+
severity: "info",
|
|
1972
|
+
label: "Component size",
|
|
1973
|
+
recommendation: `Split components over ${MAX_LOC} lines into smaller, focused pieces.`,
|
|
1974
|
+
rationale: "A very large component is hard to read, test, and reuse, and is a common sign that several responsibilities should be split out.",
|
|
1975
|
+
applies: (c) => c.loc > 0,
|
|
1976
|
+
// skip unanalyzable files (loc 0 = read/parse failure), don't PASS them
|
|
1977
|
+
bad: (c) => c.loc > MAX_LOC ? [{ line: 1, message: `Component is ${c.loc} lines (over ${MAX_LOC})` }] : []
|
|
1978
|
+
});
|
|
1979
|
+
var arch002PropCount = componentRule({
|
|
1980
|
+
id: "ARCH002",
|
|
1981
|
+
title: "Prop count",
|
|
1982
|
+
category: "architecture",
|
|
1983
|
+
severity: "info",
|
|
1984
|
+
label: "Prop count",
|
|
1985
|
+
recommendation: `Group related props into an object, or split the component, when it takes more than ${MAX_PROPS} props.`,
|
|
1986
|
+
rationale: "A component taking many props is usually doing too much; grouping or splitting keeps its API understandable.",
|
|
1987
|
+
applies: (c) => c.propCount > 0,
|
|
1988
|
+
// only components whose props we could count
|
|
1989
|
+
bad: (c) => c.propCount > MAX_PROPS ? [{ line: 1, message: `Component takes ${c.propCount} props (over ${MAX_PROPS})` }] : []
|
|
1990
|
+
});
|
|
1991
|
+
|
|
1992
|
+
// src/rules/performance/perf009-heavy-import.ts
|
|
1993
|
+
var HEAVY_PACKAGES = {
|
|
1994
|
+
lodash: "import a submodule (lodash/debounce) or use lodash-es for tree-shaking",
|
|
1995
|
+
moment: "use a lighter date library (date-fns or dayjs) \u2014 moment is large and not tree-shakeable"
|
|
1996
|
+
};
|
|
1997
|
+
var perf009HeavyImport = componentRule({
|
|
1998
|
+
id: "PERF009",
|
|
1999
|
+
title: "Heavy dependency import",
|
|
2000
|
+
category: "performance",
|
|
2001
|
+
severity: "info",
|
|
2002
|
+
label: "No heavy imports",
|
|
2003
|
+
recommendation: "Import a submodule or switch to a lighter, tree-shakeable alternative.",
|
|
2004
|
+
rationale: "Importing a large, non-tree-shakeable package pulls its whole weight into the bundle even when only a fraction is used, slowing load.",
|
|
2005
|
+
applies: (c) => c.imports.length > 0,
|
|
2006
|
+
bad: (c) => {
|
|
2007
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2008
|
+
const out = [];
|
|
2009
|
+
for (const src of c.imports) {
|
|
2010
|
+
if (!Object.hasOwn(HEAVY_PACKAGES, src) || seen.has(src)) continue;
|
|
2011
|
+
seen.add(src);
|
|
2012
|
+
out.push({ line: 0, message: `Heavy import "${src}" \u2014 ${HEAVY_PACKAGES[src]}` });
|
|
2013
|
+
}
|
|
2014
|
+
return out;
|
|
2015
|
+
}
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
// src/rules/performance/perf010-namespace-import.ts
|
|
2019
|
+
var perf010NamespaceImport = componentRule({
|
|
2020
|
+
id: "PERF010",
|
|
2021
|
+
title: "Namespace import",
|
|
2022
|
+
category: "performance",
|
|
2023
|
+
severity: "info",
|
|
2024
|
+
label: "No namespace imports",
|
|
2025
|
+
recommendation: "Use named imports (import { x } from 'pkg') instead of import * as \u2014 a namespace import keeps the whole module in the bundle.",
|
|
2026
|
+
rationale: "A namespace import (import * as X) forces the bundler to retain the entire module, so unused exports cannot be tree-shaken out.",
|
|
2027
|
+
applies: (c) => c.namespaceImports.length > 0,
|
|
2028
|
+
bad: (c) => {
|
|
2029
|
+
const minLine = /* @__PURE__ */ new Map();
|
|
2030
|
+
for (const ns of c.namespaceImports) {
|
|
2031
|
+
const prev = minLine.get(ns.source);
|
|
2032
|
+
if (prev === void 0 || ns.line < prev) minLine.set(ns.source, ns.line);
|
|
2033
|
+
}
|
|
2034
|
+
return [...minLine.entries()].sort((a, b) => a[1] - b[1]).map(([source, line]) => ({
|
|
2035
|
+
line,
|
|
2036
|
+
message: `Namespace import "* as \u2026 from '${source}'" \u2014 prefer named imports so the bundler can tree-shake`
|
|
2037
|
+
}));
|
|
2038
|
+
}
|
|
2039
|
+
});
|
|
2040
|
+
|
|
1533
2041
|
// src/rules/index.ts
|
|
1534
2042
|
var allRules = [
|
|
1535
2043
|
seo001Title,
|
|
@@ -1572,8 +2080,14 @@ var allRules = [
|
|
|
1572
2080
|
seo030HeadingOrder,
|
|
1573
2081
|
correct001EachKey,
|
|
1574
2082
|
correct002EffectDerived,
|
|
2083
|
+
correct003EffectAsOnMount,
|
|
2084
|
+
correct004UnmutatedState,
|
|
1575
2085
|
sec001Html,
|
|
1576
|
-
sec002JavascriptUrl
|
|
2086
|
+
sec002JavascriptUrl,
|
|
2087
|
+
arch001ComponentSize,
|
|
2088
|
+
arch002PropCount,
|
|
2089
|
+
perf009HeavyImport,
|
|
2090
|
+
perf010NamespaceImport
|
|
1577
2091
|
];
|
|
1578
2092
|
function explainRule(id) {
|
|
1579
2093
|
const target = id.toUpperCase();
|
|
@@ -1700,6 +2214,21 @@ function computeHealth(results, config) {
|
|
|
1700
2214
|
return { health, categories, weights };
|
|
1701
2215
|
}
|
|
1702
2216
|
|
|
2217
|
+
// src/reporter/palette.ts
|
|
2218
|
+
var noColorPalette = {
|
|
2219
|
+
bold: (s) => s,
|
|
2220
|
+
dim: (s) => s,
|
|
2221
|
+
red: (s) => s,
|
|
2222
|
+
yellow: (s) => s,
|
|
2223
|
+
green: (s) => s,
|
|
2224
|
+
cyan: (s) => s
|
|
2225
|
+
};
|
|
2226
|
+
function scoreColor(p, score) {
|
|
2227
|
+
if (score >= 90) return p.green;
|
|
2228
|
+
if (score >= 70) return p.yellow;
|
|
2229
|
+
return p.red;
|
|
2230
|
+
}
|
|
2231
|
+
|
|
1703
2232
|
// src/reporter/console.ts
|
|
1704
2233
|
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";
|
|
1705
2234
|
var SEVERITY_TITLE = {
|
|
@@ -1711,66 +2240,77 @@ var CATEGORY_LABEL = {
|
|
|
1711
2240
|
seo: "SEO",
|
|
1712
2241
|
performance: "Performance",
|
|
1713
2242
|
correctness: "Correctness",
|
|
1714
|
-
security: "Security"
|
|
2243
|
+
security: "Security",
|
|
2244
|
+
architecture: "Architecture"
|
|
1715
2245
|
};
|
|
1716
|
-
var CATEGORY_ORDER = ["seo", "performance", "correctness", "security"];
|
|
1717
|
-
function scoreLine(label, { score, scoreModel }) {
|
|
2246
|
+
var CATEGORY_ORDER = ["seo", "performance", "correctness", "security", "architecture"];
|
|
2247
|
+
function scoreLine(p, label, { score, scoreModel }) {
|
|
1718
2248
|
const parts = [`route avg ${scoreModel.routeAverage}`];
|
|
1719
2249
|
if (scoreModel.sitePenalty > 0) parts.push(`site \u2212${scoreModel.sitePenalty}`);
|
|
1720
2250
|
if (scoreModel.criticalCap !== null) parts.push(`capped at ${scoreModel.criticalCap}: critical present`);
|
|
1721
|
-
return `${label} Score: ${score}/100 (${parts.join(" \xB7 ")})`;
|
|
2251
|
+
return `${label} Score: ${scoreColor(p, score)(`${score}/100`)} ${p.dim(`(${parts.join(" \xB7 ")})`)}`;
|
|
1722
2252
|
}
|
|
1723
|
-
function byRouteTree(results, config) {
|
|
2253
|
+
function byRouteTree(p, results, config) {
|
|
1724
2254
|
const routes = /* @__PURE__ */ new Map();
|
|
1725
2255
|
for (const r of results) {
|
|
1726
2256
|
if (r.route === void 0) continue;
|
|
1727
2257
|
if (!routes.has(r.route)) routes.set(r.route, []);
|
|
1728
2258
|
routes.get(r.route).push(r);
|
|
1729
2259
|
}
|
|
1730
|
-
const lines = ["By route", RULE];
|
|
2260
|
+
const lines = [p.bold("By route"), p.dim(RULE)];
|
|
1731
2261
|
for (const [route, rs] of [...routes.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
1732
2262
|
const { score } = computeScore(rs, config, { applyCriticalCap: false });
|
|
1733
|
-
lines.push(`${route.padEnd(28)} ${score}`);
|
|
2263
|
+
lines.push(`${route.padEnd(28)} ${scoreColor(p, score)(`${score}`)}`);
|
|
1734
2264
|
for (const r of rs.filter((x) => classify(x, config) === "fail")) {
|
|
1735
|
-
lines.push(` \u2717 ${r.id} ${r.message}`);
|
|
2265
|
+
lines.push(` ${p.red("\u2717")} ${r.id} ${r.message}`);
|
|
1736
2266
|
}
|
|
1737
2267
|
}
|
|
1738
2268
|
lines.push("");
|
|
1739
2269
|
return lines;
|
|
1740
2270
|
}
|
|
1741
2271
|
function formatConsoleReport(results, config, options = {}) {
|
|
2272
|
+
const p = options.palette ?? noColorPalette;
|
|
1742
2273
|
const summary = summarize(results, config);
|
|
1743
2274
|
const { health, categories: byCat } = computeHealth(results, config);
|
|
1744
2275
|
const present2 = CATEGORY_ORDER.filter((c) => byCat[c] !== void 0);
|
|
1745
|
-
const header = [
|
|
2276
|
+
const header = [
|
|
2277
|
+
p.bold(`Svelte Vitals \xB7 ${options.mode ?? "static mode"}`),
|
|
2278
|
+
"",
|
|
2279
|
+
`${p.bold("Health:")} ${scoreColor(p, health)(`${health}/100`)}`
|
|
2280
|
+
];
|
|
1746
2281
|
for (const c of present2) {
|
|
1747
|
-
header.push(scoreLine(CATEGORY_LABEL[c] ?? c, byCat[c]));
|
|
2282
|
+
header.push(scoreLine(p, CATEGORY_LABEL[c] ?? c, byCat[c]));
|
|
1748
2283
|
}
|
|
1749
2284
|
const lines = [...header, ""];
|
|
2285
|
+
const SEVERITY_COLOR = {
|
|
2286
|
+
critical: (s) => p.red(p.bold(s)),
|
|
2287
|
+
warning: (s) => p.yellow(p.bold(s)),
|
|
2288
|
+
info: (s) => p.dim(s)
|
|
2289
|
+
};
|
|
1750
2290
|
const failures = results.filter((r) => classify(r, config) === "fail");
|
|
1751
2291
|
for (const severity of ["critical", "warning", "info"]) {
|
|
1752
2292
|
const bucket = failures.filter((r) => effectiveSeverity(r, config) === severity);
|
|
1753
2293
|
if (bucket.length === 0) continue;
|
|
1754
|
-
lines.push(`${SEVERITY_TITLE[severity]} (${bucket.length})
|
|
2294
|
+
lines.push(SEVERITY_COLOR[severity](`${SEVERITY_TITLE[severity]} (${bucket.length})`), p.dim(RULE));
|
|
1755
2295
|
for (const r of bucket) {
|
|
1756
|
-
lines.push(
|
|
1757
|
-
if (r.route) lines.push(` ${r.route}`);
|
|
1758
|
-
if (r.location) lines.push(` ${r.location}${r.line ? `:${r.line}` : ""}`);
|
|
2296
|
+
lines.push(`${p.red("\u2717")} ${r.id} ${r.message}`);
|
|
2297
|
+
if (r.route) lines.push(p.dim(` ${r.route}`));
|
|
2298
|
+
if (r.location) lines.push(p.dim(` ${r.location}${r.line ? `:${r.line}` : ""}`));
|
|
1759
2299
|
}
|
|
1760
2300
|
lines.push("");
|
|
1761
2301
|
}
|
|
1762
2302
|
const passed = results.filter((r) => classify(r, config) !== "fail");
|
|
1763
2303
|
if (passed.length > 0) {
|
|
1764
|
-
lines.push(`Passed (${passed.length})
|
|
2304
|
+
lines.push(p.bold(`Passed (${passed.length})`), p.dim(RULE));
|
|
1765
2305
|
for (const r of passed) {
|
|
1766
|
-
const marker = classify(r, config) === "dynamic" ? " \u21AF dynamic" : "";
|
|
2306
|
+
const marker = classify(r, config) === "dynamic" ? p.cyan(" \u21AF dynamic") : "";
|
|
1767
2307
|
const route = r.route ? ` ${r.route}` : "";
|
|
1768
|
-
lines.push(
|
|
2308
|
+
lines.push(`${p.green("\u2713")} ${r.id} ${r.message}${marker}${route}`);
|
|
1769
2309
|
}
|
|
1770
2310
|
lines.push("");
|
|
1771
2311
|
}
|
|
1772
|
-
if (options.byRoute) lines.push(...byRouteTree(results, config));
|
|
1773
|
-
if (summary.dynamic > 0) lines.push("\u21AF = set dynamically (verified at runtime).");
|
|
2312
|
+
if (options.byRoute) lines.push(...byRouteTree(p, results, config));
|
|
2313
|
+
if (summary.dynamic > 0) lines.push(p.dim("\u21AF = set dynamically (verified at runtime)."));
|
|
1774
2314
|
return lines.join("\n").replace(/\n+$/, "\n");
|
|
1775
2315
|
}
|
|
1776
2316
|
|
|
@@ -2157,10 +2697,17 @@ function applyRuleSeverities(results, config) {
|
|
|
2157
2697
|
}
|
|
2158
2698
|
export {
|
|
2159
2699
|
BAND_COLOR,
|
|
2700
|
+
CHILD_NODE_KEYS,
|
|
2160
2701
|
ROBOTS_SOURCE_PATHS,
|
|
2161
2702
|
SITEMAP_SOURCE_PATHS,
|
|
2162
2703
|
allRules,
|
|
2163
2704
|
applyRuleSeverities,
|
|
2705
|
+
arch001ComponentSize,
|
|
2706
|
+
arch002PropCount,
|
|
2707
|
+
attrText,
|
|
2708
|
+
attrTextOf,
|
|
2709
|
+
attrValue,
|
|
2710
|
+
attrValueOf,
|
|
2164
2711
|
buildHtmlDocument,
|
|
2165
2712
|
buildJsonReport,
|
|
2166
2713
|
classify,
|
|
@@ -2168,6 +2715,8 @@ export {
|
|
|
2168
2715
|
computeScore,
|
|
2169
2716
|
correct001EachKey,
|
|
2170
2717
|
correct002EffectDerived,
|
|
2718
|
+
correct003EffectAsOnMount,
|
|
2719
|
+
correct004UnmutatedState,
|
|
2171
2720
|
defaultConfig,
|
|
2172
2721
|
defaultProject,
|
|
2173
2722
|
defineConfig,
|
|
@@ -2175,6 +2724,7 @@ export {
|
|
|
2175
2724
|
effectiveSeverity,
|
|
2176
2725
|
escapeHtml,
|
|
2177
2726
|
explainRule,
|
|
2727
|
+
findAttr,
|
|
2178
2728
|
formatAgentReport,
|
|
2179
2729
|
formatConsoleReport,
|
|
2180
2730
|
formatGithubReport,
|
|
@@ -2185,7 +2735,10 @@ export {
|
|
|
2185
2735
|
headTagRule,
|
|
2186
2736
|
imageRule,
|
|
2187
2737
|
isPenalized,
|
|
2738
|
+
lineOf,
|
|
2188
2739
|
linkRule,
|
|
2740
|
+
noColorPalette,
|
|
2741
|
+
parseComponentFacts,
|
|
2189
2742
|
perf001ImageDimensions,
|
|
2190
2743
|
perf002ImageLoading,
|
|
2191
2744
|
perf003PreloadAs,
|
|
@@ -2194,9 +2747,12 @@ export {
|
|
|
2194
2747
|
perf006ResponsiveImage,
|
|
2195
2748
|
perf007RenderBlockingScript,
|
|
2196
2749
|
perf008Preconnect,
|
|
2750
|
+
perf009HeavyImport,
|
|
2751
|
+
perf010NamespaceImport,
|
|
2197
2752
|
runRules,
|
|
2198
2753
|
safeHref,
|
|
2199
2754
|
scoreBand,
|
|
2755
|
+
scoreColor,
|
|
2200
2756
|
scoresByCategory,
|
|
2201
2757
|
sec001Html,
|
|
2202
2758
|
sec002JavascriptUrl,
|
|
@@ -2231,5 +2787,7 @@ export {
|
|
|
2231
2787
|
seo028TitleUnique,
|
|
2232
2788
|
seo029DescriptionUnique,
|
|
2233
2789
|
seo030HeadingOrder,
|
|
2234
|
-
summarize
|
|
2790
|
+
summarize,
|
|
2791
|
+
textFromNodes,
|
|
2792
|
+
valueFromNodes
|
|
2235
2793
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svelte-vitals/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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,9 +21,12 @@
|
|
|
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
|
+
"dependencies": {
|
|
28
|
+
"svelte": "^5.56.4"
|
|
29
|
+
},
|
|
27
30
|
"exports": {
|
|
28
31
|
".": {
|
|
29
32
|
"types": "./dist/index.d.ts",
|