@svelte-vitals/core 0.12.0 → 0.14.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 +92 -2
- package/dist/index.js +722 -13
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ interface Project {
|
|
|
21
21
|
hasSitemap: boolean;
|
|
22
22
|
/** <html lang> from app.html: presence 'own' when the attribute exists ('none' otherwise); value 'static' if non-empty, 'absent' if empty. */
|
|
23
23
|
htmlLang: Detection;
|
|
24
|
+
/** Whether the static static/robots.txt references a sitemap (`Sitemap:` line). Undefined for a +server endpoint / absent / unreadable. */
|
|
25
|
+
robotsReferencesSitemap?: boolean;
|
|
24
26
|
}
|
|
25
27
|
declare const defaultProject: Project;
|
|
26
28
|
/** A concrete, agent-actionable remediation for a finding (design §10, issue #18). */
|
|
@@ -111,6 +113,14 @@ interface HeadTag {
|
|
|
111
113
|
hasAs?: boolean;
|
|
112
114
|
/** True when a <link> has a `crossorigin` attribute (presence only; value is irrelevant to the checks). */
|
|
113
115
|
hasCrossorigin?: boolean;
|
|
116
|
+
/** True when a <meta name="robots"> literal content contains `noindex`/`none`. Undefined when dynamic or absent. */
|
|
117
|
+
noindex?: boolean;
|
|
118
|
+
/** Literal `<script type="application/ld+json">` content, set only when the script is static. Undefined when dynamic. */
|
|
119
|
+
jsonld?: string;
|
|
120
|
+
/** Literal visible text of a static <title> or <meta name="description"> content, set only when static. Undefined when dynamic. */
|
|
121
|
+
text?: string;
|
|
122
|
+
/** Literal `hreflang` of a `<link rel="alternate">` (e.g. 'en', 'en-US', 'x-default'). Undefined when dynamic/absent. */
|
|
123
|
+
hreflang?: string;
|
|
114
124
|
/** Where this tag was set relative to the route. Never 'none' (absence = no tag). */
|
|
115
125
|
presence: Exclude<Presence, 'none'>;
|
|
116
126
|
/** Whether the tag's value is static/dynamic/absent (design §4). */
|
|
@@ -148,6 +158,8 @@ interface ImageInfo {
|
|
|
148
158
|
hasWidth: boolean;
|
|
149
159
|
hasHeight: boolean;
|
|
150
160
|
hasLoading: boolean;
|
|
161
|
+
/** True when the <img> has an `alt` attribute at all (incl. empty `alt=""` decorative; SEO025). */
|
|
162
|
+
hasAlt: boolean;
|
|
151
163
|
/** 1-based source line, or 0 if unknown. */
|
|
152
164
|
line: number;
|
|
153
165
|
/** Source file the <img> came from. */
|
|
@@ -159,6 +171,25 @@ interface ResolvedImages {
|
|
|
159
171
|
images: ImageInfo[];
|
|
160
172
|
}
|
|
161
173
|
|
|
174
|
+
/**
|
|
175
|
+
* A normalized page-body heading occurrence — the mode-independent boundary for
|
|
176
|
+
* the heading-hierarchy rule (mirrors images.ts). Both providers collect these
|
|
177
|
+
* so SEO027 never needs to know which mode produced them.
|
|
178
|
+
*/
|
|
179
|
+
interface HeadingInfo {
|
|
180
|
+
/** Heading level 1–6 (the `n` in <hn>). */
|
|
181
|
+
level: number;
|
|
182
|
+
/** 1-based source line, or 0 if unknown (rendered mode does not track lines). */
|
|
183
|
+
line: number;
|
|
184
|
+
/** Source file the heading came from. */
|
|
185
|
+
file: string;
|
|
186
|
+
}
|
|
187
|
+
/** Resolved page-body headings for a single route (page + layout chain). */
|
|
188
|
+
interface ResolvedHeadings {
|
|
189
|
+
route: string;
|
|
190
|
+
headings: HeadingInfo[];
|
|
191
|
+
}
|
|
192
|
+
|
|
162
193
|
/**
|
|
163
194
|
* Source-file locations that satisfy the project-scope rules, shared by every
|
|
164
195
|
* mode so the static (CLI) and rendered (plugin) collectors never drift. This
|
|
@@ -174,6 +205,8 @@ interface RuleContext {
|
|
|
174
205
|
heads: ResolvedHead[];
|
|
175
206
|
/** Per-route <img> elements for Performance rules (absent in modes that don't collect them). */
|
|
176
207
|
images?: ResolvedImages[];
|
|
208
|
+
/** Per-route page-body headings for SEO027 (absent in modes that don't collect them). */
|
|
209
|
+
headings?: ResolvedHeadings[];
|
|
177
210
|
project: Project;
|
|
178
211
|
config: Config;
|
|
179
212
|
}
|
|
@@ -238,6 +271,54 @@ declare const perf002ImageLoading: Rule;
|
|
|
238
271
|
declare const perf003PreloadAs: Rule;
|
|
239
272
|
declare const perf004FontPreloadCrossorigin: Rule;
|
|
240
273
|
|
|
274
|
+
declare const seo010Indexability: Rule;
|
|
275
|
+
declare const seo011TwitterCard: Rule;
|
|
276
|
+
declare const seo012OgDescription: Rule;
|
|
277
|
+
declare const seo013OgUrl: Rule;
|
|
278
|
+
declare const seo014Viewport: Rule;
|
|
279
|
+
declare const seo015SitemapInRobots: Rule;
|
|
280
|
+
|
|
281
|
+
declare const seo016JsonLdValidity: Rule;
|
|
282
|
+
declare const seo017DeprecatedType: Rule;
|
|
283
|
+
declare const seo018RelativeUrl: Rule;
|
|
284
|
+
declare const seo019DateFormat: Rule;
|
|
285
|
+
declare const seo020Placeholder: Rule;
|
|
286
|
+
declare const seo021RequiredProps: Rule;
|
|
287
|
+
|
|
288
|
+
declare const seo022TitleLength: Rule;
|
|
289
|
+
declare const seo023DescriptionLength: Rule;
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* SEO024 — Character encoding. The charset meta lives in `src/app.html`, so it is
|
|
293
|
+
* only visible to rendered analysis (`appliesTo: rendered`), exactly like SEO014
|
|
294
|
+
* (viewport). Static route analysis emits nothing instead of false-flagging it.
|
|
295
|
+
*/
|
|
296
|
+
declare const seo024Charset: Rule;
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* SEO025 — Image alt text. Reuses the <img> collection (CLI/static only; rendered
|
|
300
|
+
* mode does not collect images, so the rule no-ops there, like PERF001/002).
|
|
301
|
+
* Presence only: an explicit empty `alt=""` is a valid decorative-image signal and
|
|
302
|
+
* passes; a spread `{...rest}` may supply alt, so it is not flagged.
|
|
303
|
+
*/
|
|
304
|
+
declare const seo025ImageAlt: Rule;
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* SEO026 — hreflang / x-default validity. Opt-in: a route with no
|
|
308
|
+
* `<link rel="alternate" hreflang>` emits nothing (monolingual sites are never
|
|
309
|
+
* flagged). When alternates exist, every code must be well-formed and a set of
|
|
310
|
+
* two or more must declare an x-default. Works in both modes.
|
|
311
|
+
*/
|
|
312
|
+
declare const seo026Hreflang: Rule;
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* SEO027 — Heading hierarchy (single H1). Reads the per-route page-body headings
|
|
316
|
+
* channel (collected by both providers). Zero <h1> (no primary heading) and two
|
|
317
|
+
* or more (diluted topic) are both flagged; exactly one passes. A route whose
|
|
318
|
+
* headings were not collected (channel unset) emits nothing.
|
|
319
|
+
*/
|
|
320
|
+
declare const seo027Heading: Rule;
|
|
321
|
+
|
|
241
322
|
declare const allRules: Rule[];
|
|
242
323
|
|
|
243
324
|
interface RuleInfo {
|
|
@@ -265,6 +346,13 @@ interface HeadTagRuleOptions {
|
|
|
265
346
|
rationale: string;
|
|
266
347
|
/** Agent-actionable remediation attached to every finding (issue #18). */
|
|
267
348
|
fix?: Fix;
|
|
349
|
+
/**
|
|
350
|
+
* When set, only heads for which this returns true are evaluated; others emit
|
|
351
|
+
* nothing. Use for tags whose canonical location is invisible to a given mode
|
|
352
|
+
* (e.g. viewport lives in app.html → only checkable in rendered mode), so the
|
|
353
|
+
* rule stays silent instead of false-flagging "missing".
|
|
354
|
+
*/
|
|
355
|
+
appliesTo?: (head: ResolvedHead) => boolean;
|
|
268
356
|
}
|
|
269
357
|
/** Build a route-scope rule asserting the presence of a single head tag (design §11). */
|
|
270
358
|
declare function headTagRule(opts: HeadTagRuleOptions): Rule;
|
|
@@ -273,6 +361,8 @@ interface ImageRuleOptions {
|
|
|
273
361
|
id: string;
|
|
274
362
|
title: string;
|
|
275
363
|
severity: Severity;
|
|
364
|
+
/** Vitals category (default 'performance'); SEO025 (alt text) reports under 'seo'. */
|
|
365
|
+
category?: Category;
|
|
276
366
|
/** Noun phrase for messages, e.g. '<img> width/height'. */
|
|
277
367
|
label: string;
|
|
278
368
|
recommendation: string;
|
|
@@ -281,7 +371,7 @@ interface ImageRuleOptions {
|
|
|
281
371
|
/** Returns true when the image satisfies the rule (passes). */
|
|
282
372
|
ok: (img: ImageInfo) => boolean;
|
|
283
373
|
}
|
|
284
|
-
/** Build a route-scoped
|
|
374
|
+
/** Build a route-scoped <img> rule that checks each image against `ok` (issue #10). */
|
|
285
375
|
declare function imageRule(opts: ImageRuleOptions): Rule;
|
|
286
376
|
|
|
287
377
|
interface LinkRuleOptions {
|
|
@@ -437,4 +527,4 @@ declare function selectRules(rules: Rule[], config: Config): Rule[];
|
|
|
437
527
|
/** Apply per-rule severity overrides to results (design §6). */
|
|
438
528
|
declare function applyRuleSeverities(results: Result[], config: Config): Result[];
|
|
439
529
|
|
|
440
|
-
export { BAND_COLOR, type Category, type Classification, type Config, type ConsoleReportOptions, type Detection, type Fix, type HeadProvider, type HeadTag, type HealthResult, type ImageInfo, type JsonReport, type Presence, type Project, ROBOTS_SOURCE_PATHS, type ResolvedHead, 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, runRules, safeHref, scoreBand, scoresByCategory, selectRules, seo001Title, seo002Description, seo003Canonical, seo004OgImage, seo005OgTitle, seo006Robots, seo007Sitemap, seo008JsonLd, seo009HtmlLang, summarize };
|
|
530
|
+
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, 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 };
|
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 docsUrl3 = docsUrlFor(opts.id);
|
|
98
98
|
return {
|
|
99
99
|
id: opts.id,
|
|
100
100
|
title: opts.title,
|
|
@@ -104,7 +104,8 @@ function headTagRule(opts) {
|
|
|
104
104
|
rationale: opts.rationale,
|
|
105
105
|
...opts.fix ? { fix: opts.fix } : {},
|
|
106
106
|
async check(ctx) {
|
|
107
|
-
|
|
107
|
+
const heads = opts.appliesTo ? ctx.heads.filter(opts.appliesTo) : ctx.heads;
|
|
108
|
+
return heads.map((head) => {
|
|
108
109
|
const detection = detect(head, opts.match);
|
|
109
110
|
const message = detection.presence === "none" ? `Missing ${opts.label}` : detection.value === "absent" ? `Empty ${opts.label}` : opts.label;
|
|
110
111
|
return {
|
|
@@ -116,7 +117,7 @@ function headTagRule(opts) {
|
|
|
116
117
|
location: head.file,
|
|
117
118
|
message,
|
|
118
119
|
recommendation: opts.recommendation,
|
|
119
|
-
docsUrl,
|
|
120
|
+
docsUrl: docsUrl3,
|
|
120
121
|
// Copy per finding: opts.fix is a rule-level template shared across all
|
|
121
122
|
// results this rule emits; a fresh object keeps findings independent.
|
|
122
123
|
...opts.fix ? { fix: { ...opts.fix } } : {}
|
|
@@ -295,11 +296,12 @@ var seo009HtmlLang = {
|
|
|
295
296
|
|
|
296
297
|
// src/rules/perf/image-rule.ts
|
|
297
298
|
function imageRule(opts) {
|
|
298
|
-
const
|
|
299
|
+
const docsUrl3 = docsUrlFor(opts.id);
|
|
300
|
+
const category = opts.category ?? "performance";
|
|
299
301
|
return {
|
|
300
302
|
id: opts.id,
|
|
301
303
|
title: opts.title,
|
|
302
|
-
category
|
|
304
|
+
category,
|
|
303
305
|
severity: opts.severity,
|
|
304
306
|
scope: "route",
|
|
305
307
|
rationale: opts.rationale,
|
|
@@ -312,20 +314,20 @@ function imageRule(opts) {
|
|
|
312
314
|
if (bad.length === 0) {
|
|
313
315
|
out.push({
|
|
314
316
|
id: opts.id,
|
|
315
|
-
category
|
|
317
|
+
category,
|
|
316
318
|
severity: opts.severity,
|
|
317
319
|
detection: { presence: "own", value: "static" },
|
|
318
320
|
route: route.route,
|
|
319
321
|
message: opts.label,
|
|
320
322
|
recommendation: opts.recommendation,
|
|
321
|
-
docsUrl
|
|
323
|
+
docsUrl: docsUrl3
|
|
322
324
|
});
|
|
323
325
|
continue;
|
|
324
326
|
}
|
|
325
327
|
for (const img of bad) {
|
|
326
328
|
out.push({
|
|
327
329
|
id: opts.id,
|
|
328
|
-
category
|
|
330
|
+
category,
|
|
329
331
|
severity: opts.severity,
|
|
330
332
|
detection: { presence: "none", value: "absent" },
|
|
331
333
|
route: route.route,
|
|
@@ -333,7 +335,7 @@ function imageRule(opts) {
|
|
|
333
335
|
...img.line > 0 ? { line: img.line } : {},
|
|
334
336
|
message: `Missing ${opts.label}`,
|
|
335
337
|
recommendation: opts.recommendation,
|
|
336
|
-
docsUrl,
|
|
338
|
+
docsUrl: docsUrl3,
|
|
337
339
|
...opts.fix ? { fix: { ...opts.fix } } : {}
|
|
338
340
|
});
|
|
339
341
|
}
|
|
@@ -375,7 +377,7 @@ var perf002ImageLoading = imageRule({
|
|
|
375
377
|
|
|
376
378
|
// src/rules/perf/link-rule.ts
|
|
377
379
|
function linkRule(opts) {
|
|
378
|
-
const
|
|
380
|
+
const docsUrl3 = docsUrlFor(opts.id);
|
|
379
381
|
return {
|
|
380
382
|
id: opts.id,
|
|
381
383
|
title: opts.title,
|
|
@@ -399,7 +401,7 @@ function linkRule(opts) {
|
|
|
399
401
|
route: head.route,
|
|
400
402
|
message: opts.label,
|
|
401
403
|
recommendation: opts.recommendation,
|
|
402
|
-
docsUrl
|
|
404
|
+
docsUrl: docsUrl3
|
|
403
405
|
});
|
|
404
406
|
continue;
|
|
405
407
|
}
|
|
@@ -416,7 +418,7 @@ function linkRule(opts) {
|
|
|
416
418
|
location: tag.file ?? head.file,
|
|
417
419
|
message: `Missing ${opts.label}`,
|
|
418
420
|
recommendation: opts.recommendation,
|
|
419
|
-
docsUrl,
|
|
421
|
+
docsUrl: docsUrl3,
|
|
420
422
|
...opts.fix ? { fix: { ...opts.fix } } : {}
|
|
421
423
|
});
|
|
422
424
|
}
|
|
@@ -458,6 +460,677 @@ var perf004FontPreloadCrossorigin = linkRule({
|
|
|
458
460
|
ok: (t) => t.hasCrossorigin === true
|
|
459
461
|
});
|
|
460
462
|
|
|
463
|
+
// src/rules/seo/seo010-015.ts
|
|
464
|
+
var SEO010_FIX = {
|
|
465
|
+
description: 'If this route should be indexed, drop noindex from its <meta name="robots">.',
|
|
466
|
+
snippet: '<svelte:head>\n <meta name="robots" content="index, follow" />\n</svelte:head>',
|
|
467
|
+
lang: "svelte"
|
|
468
|
+
};
|
|
469
|
+
var seo010Indexability = {
|
|
470
|
+
id: "SEO010",
|
|
471
|
+
title: "Indexability",
|
|
472
|
+
category: "seo",
|
|
473
|
+
severity: "info",
|
|
474
|
+
scope: "route",
|
|
475
|
+
rationale: "A noindex directive removes the page from search results; an accidental noindex on a public route silently deindexes it.",
|
|
476
|
+
fix: SEO010_FIX,
|
|
477
|
+
async check(ctx) {
|
|
478
|
+
const docsUrl3 = docsUrlFor("SEO010");
|
|
479
|
+
const out = [];
|
|
480
|
+
for (const head of ctx.heads) {
|
|
481
|
+
const noindexed = head.tags.some((t) => t.kind === "meta" && t.name === "robots" && t.noindex === true);
|
|
482
|
+
if (!noindexed) continue;
|
|
483
|
+
out.push({
|
|
484
|
+
id: "SEO010",
|
|
485
|
+
category: "seo",
|
|
486
|
+
severity: "info",
|
|
487
|
+
detection: { presence: "none", value: "absent" },
|
|
488
|
+
// surfaced as an issue (isPenalized)
|
|
489
|
+
route: head.route,
|
|
490
|
+
location: head.file,
|
|
491
|
+
message: "Route is noindex \u2014 verify this is intentional",
|
|
492
|
+
recommendation: 'If this route should be indexed, remove noindex from its <meta name="robots">.',
|
|
493
|
+
docsUrl: docsUrl3,
|
|
494
|
+
fix: { ...SEO010_FIX }
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
return out;
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
var seo011TwitterCard = headTagRule({
|
|
501
|
+
id: "SEO011",
|
|
502
|
+
title: "Twitter Card",
|
|
503
|
+
severity: "info",
|
|
504
|
+
match: (t) => t.kind === "meta" && t.name === "twitter:card",
|
|
505
|
+
label: '<meta name="twitter:card">',
|
|
506
|
+
recommendation: 'Add <meta name="twitter:card" content="summary_large_image"> so X/Twitter renders a rich card.',
|
|
507
|
+
rationale: "twitter:card selects how the page renders when shared on X/Twitter; without it the platform falls back to a basic link (Open Graph tags are used as fallbacks for the rest).",
|
|
508
|
+
fix: {
|
|
509
|
+
description: "Add a twitter:card meta tag in <svelte:head>.",
|
|
510
|
+
snippet: '<svelte:head>\n <meta name="twitter:card" content="summary_large_image" />\n</svelte:head>',
|
|
511
|
+
lang: "svelte"
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
var seo012OgDescription = headTagRule({
|
|
515
|
+
id: "SEO012",
|
|
516
|
+
title: "Open Graph description",
|
|
517
|
+
severity: "warning",
|
|
518
|
+
match: (t) => t.kind === "meta" && t.property === "og:description",
|
|
519
|
+
label: '<meta property="og:description">',
|
|
520
|
+
recommendation: 'Add <meta property="og:description">, or set openGraph.description on your meta component.',
|
|
521
|
+
rationale: "og:description is the summary shown under the title in social previews; without it platforms guess or show nothing, lowering click-through.",
|
|
522
|
+
fix: {
|
|
523
|
+
description: "Add an og:description meta tag in <svelte:head>.",
|
|
524
|
+
snippet: '<svelte:head>\n <meta property="og:description" content="A concise page summary." />\n</svelte:head>',
|
|
525
|
+
lang: "svelte"
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
var seo013OgUrl = headTagRule({
|
|
529
|
+
id: "SEO013",
|
|
530
|
+
title: "Open Graph URL",
|
|
531
|
+
severity: "info",
|
|
532
|
+
match: (t) => t.kind === "meta" && t.property === "og:url",
|
|
533
|
+
label: '<meta property="og:url">',
|
|
534
|
+
recommendation: 'Add <meta property="og:url"> with the canonical URL, or set openGraph.url on your meta component.',
|
|
535
|
+
rationale: "og:url tells social platforms the canonical address to attribute shares and likes to, consolidating engagement on one URL.",
|
|
536
|
+
fix: {
|
|
537
|
+
description: "Add an og:url meta tag in <svelte:head>.",
|
|
538
|
+
snippet: '<svelte:head>\n <meta property="og:url" content="https://example.com/this-page" />\n</svelte:head>',
|
|
539
|
+
lang: "svelte"
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
var seo014Viewport = headTagRule({
|
|
543
|
+
id: "SEO014",
|
|
544
|
+
title: "Viewport",
|
|
545
|
+
severity: "warning",
|
|
546
|
+
match: (t) => t.kind === "meta" && t.name === "viewport",
|
|
547
|
+
label: '<meta name="viewport">',
|
|
548
|
+
// Viewport canonically lives in app.html, which static (CLI) mode does not
|
|
549
|
+
// resolve into head tags — only evaluate rendered heads so the rule stays
|
|
550
|
+
// silent there instead of false-flagging "missing" on every route.
|
|
551
|
+
appliesTo: (head) => head.source === "rendered",
|
|
552
|
+
recommendation: 'Add <meta name="viewport" content="width=device-width, initial-scale=1"> (usually in app.html).',
|
|
553
|
+
rationale: "Without a viewport meta tag the page is not mobile-responsive, which Google penalizes under mobile-first indexing.",
|
|
554
|
+
fix: {
|
|
555
|
+
description: "Add the viewport meta tag (typically in src/app.html <head>).",
|
|
556
|
+
snippet: '<meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
557
|
+
lang: "html"
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
var SEO015_FIX = {
|
|
561
|
+
description: "Add a Sitemap: line to static/robots.txt.",
|
|
562
|
+
snippet: "User-agent: *\nAllow: /\n\nSitemap: https://example.com/sitemap.xml",
|
|
563
|
+
lang: "text"
|
|
564
|
+
};
|
|
565
|
+
var seo015SitemapInRobots = {
|
|
566
|
+
id: "SEO015",
|
|
567
|
+
title: "Sitemap referenced in robots.txt",
|
|
568
|
+
category: "seo",
|
|
569
|
+
severity: "info",
|
|
570
|
+
scope: "project",
|
|
571
|
+
rationale: "A Sitemap: line in robots.txt helps crawlers discover your sitemap; without it discovery relies on manual submission.",
|
|
572
|
+
fix: SEO015_FIX,
|
|
573
|
+
async check(ctx) {
|
|
574
|
+
const { hasRobotsTxt, hasSitemap, robotsReferencesSitemap } = ctx.project;
|
|
575
|
+
if (!(hasRobotsTxt && hasSitemap && robotsReferencesSitemap === false)) return [];
|
|
576
|
+
return [
|
|
577
|
+
{
|
|
578
|
+
id: "SEO015",
|
|
579
|
+
category: "seo",
|
|
580
|
+
severity: "info",
|
|
581
|
+
detection: { presence: "none", value: "absent" },
|
|
582
|
+
message: "robots.txt does not reference your sitemap",
|
|
583
|
+
recommendation: "Add a Sitemap: line to static/robots.txt pointing at your sitemap.xml.",
|
|
584
|
+
docsUrl: docsUrlFor("SEO015"),
|
|
585
|
+
fix: { ...SEO015_FIX }
|
|
586
|
+
}
|
|
587
|
+
];
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
// src/rules/seo/jsonld-engine.ts
|
|
592
|
+
function parseJsonLd(raw) {
|
|
593
|
+
let data;
|
|
594
|
+
try {
|
|
595
|
+
data = JSON.parse(raw);
|
|
596
|
+
} catch {
|
|
597
|
+
return { ok: false, nodes: [] };
|
|
598
|
+
}
|
|
599
|
+
const nodes = [];
|
|
600
|
+
const visit = (v) => {
|
|
601
|
+
if (Array.isArray(v)) {
|
|
602
|
+
v.forEach(visit);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (v && typeof v === "object") {
|
|
606
|
+
const o = v;
|
|
607
|
+
nodes.push(o);
|
|
608
|
+
if (Array.isArray(o["@graph"])) o["@graph"].forEach(visit);
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
visit(data);
|
|
612
|
+
return { ok: true, nodes };
|
|
613
|
+
}
|
|
614
|
+
function typeOf(node) {
|
|
615
|
+
const t = node["@type"];
|
|
616
|
+
if (typeof t === "string") return [t];
|
|
617
|
+
if (Array.isArray(t)) return t.filter((x) => typeof x === "string");
|
|
618
|
+
return [];
|
|
619
|
+
}
|
|
620
|
+
function collectValues(nodes, keys) {
|
|
621
|
+
const out = [];
|
|
622
|
+
const walk = (v) => {
|
|
623
|
+
if (Array.isArray(v)) {
|
|
624
|
+
v.forEach(walk);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (v && typeof v === "object") {
|
|
628
|
+
for (const [k, val] of Object.entries(v)) {
|
|
629
|
+
if (keys.has(k) && typeof val === "string") out.push(val);
|
|
630
|
+
else if (keys.has(k) && Array.isArray(val)) {
|
|
631
|
+
for (const e of val) if (typeof e === "string") out.push(e);
|
|
632
|
+
}
|
|
633
|
+
walk(val);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
nodes.forEach(walk);
|
|
638
|
+
return out;
|
|
639
|
+
}
|
|
640
|
+
function nodeStringValues(node) {
|
|
641
|
+
const out = [];
|
|
642
|
+
const walk = (v) => {
|
|
643
|
+
if (typeof v === "string") {
|
|
644
|
+
out.push(v);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (Array.isArray(v)) {
|
|
648
|
+
v.forEach(walk);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (v && typeof v === "object") Object.values(v).forEach(walk);
|
|
652
|
+
};
|
|
653
|
+
walk(node);
|
|
654
|
+
return out;
|
|
655
|
+
}
|
|
656
|
+
function isAbsoluteUrl(s) {
|
|
657
|
+
const str = s.trim();
|
|
658
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(str) || str.startsWith("//");
|
|
659
|
+
}
|
|
660
|
+
function isIso8601(s) {
|
|
661
|
+
const str = s.trim();
|
|
662
|
+
if (!/^\d{4}(-\d{2}(-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?)?)?$/.test(str)) return false;
|
|
663
|
+
const y = Number(str.slice(0, 4));
|
|
664
|
+
const hasMonth = str.length >= 7;
|
|
665
|
+
const hasDay = str.length >= 10;
|
|
666
|
+
const m = hasMonth ? Number(str.slice(5, 7)) : 1;
|
|
667
|
+
if (m < 1 || m > 12) return false;
|
|
668
|
+
if (hasDay) {
|
|
669
|
+
const d = Number(str.slice(8, 10));
|
|
670
|
+
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
671
|
+
if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== d) return false;
|
|
672
|
+
}
|
|
673
|
+
const tm = /^\d{4}-\d{2}-\d{2}T(\d{2}):(\d{2})(?::(\d{2}))?/.exec(str);
|
|
674
|
+
if (tm) {
|
|
675
|
+
const [hh, mm, ss] = [Number(tm[1]), Number(tm[2]), tm[3] !== void 0 ? Number(tm[3]) : 0];
|
|
676
|
+
if (hh > 23 || mm > 59 || ss > 59) return false;
|
|
677
|
+
}
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
var PLACEHOLDER_RES = [
|
|
681
|
+
/lorem ipsum/i,
|
|
682
|
+
/your company/i,
|
|
683
|
+
/your-?domain/i,
|
|
684
|
+
/example company/i,
|
|
685
|
+
/yourcompany/i,
|
|
686
|
+
/your name here/i
|
|
687
|
+
];
|
|
688
|
+
var PLACEHOLDERS = PLACEHOLDER_RES.map((r) => r.source);
|
|
689
|
+
function hasPlaceholder(s) {
|
|
690
|
+
return PLACEHOLDER_RES.some((re) => re.test(s));
|
|
691
|
+
}
|
|
692
|
+
var URL_KEYS = /* @__PURE__ */ new Set(["url", "image", "logo", "sameAs", "contentUrl", "thumbnailUrl"]);
|
|
693
|
+
var DATE_KEYS = /* @__PURE__ */ new Set([
|
|
694
|
+
"datePublished",
|
|
695
|
+
"dateModified",
|
|
696
|
+
"dateCreated",
|
|
697
|
+
"startDate",
|
|
698
|
+
"endDate",
|
|
699
|
+
"uploadDate",
|
|
700
|
+
"validFrom",
|
|
701
|
+
"expires"
|
|
702
|
+
]);
|
|
703
|
+
var DEPRECATED_TYPES = /* @__PURE__ */ new Set(["HowTo", "FAQPage", "ClaimReview"]);
|
|
704
|
+
function hasNonEmpty(node, key) {
|
|
705
|
+
if (!(key in node)) return false;
|
|
706
|
+
const v = node[key];
|
|
707
|
+
if (v === null || v === void 0) return false;
|
|
708
|
+
if (typeof v === "string") return v.trim().length > 0;
|
|
709
|
+
if (Array.isArray(v)) return v.length > 0;
|
|
710
|
+
return true;
|
|
711
|
+
}
|
|
712
|
+
var REQUIRED_PROPS = {
|
|
713
|
+
Article: ["headline"],
|
|
714
|
+
BlogPosting: ["headline"],
|
|
715
|
+
NewsArticle: ["headline"],
|
|
716
|
+
Product: ["name", "offers"],
|
|
717
|
+
BreadcrumbList: ["itemListElement"],
|
|
718
|
+
Organization: ["name", "url"],
|
|
719
|
+
WebSite: ["name", "url"],
|
|
720
|
+
Event: ["name", "startDate", "location"],
|
|
721
|
+
Recipe: ["name", "image", "recipeIngredient", "recipeInstructions"],
|
|
722
|
+
Person: ["name"],
|
|
723
|
+
VideoObject: ["name", "description", "thumbnailUrl", "uploadDate"],
|
|
724
|
+
LocalBusiness: ["name", "address"]
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
// src/rules/seo/detection.ts
|
|
728
|
+
var PENALIZED = { presence: "none", value: "absent" };
|
|
729
|
+
var PASS = { presence: "own", value: "static" };
|
|
730
|
+
|
|
731
|
+
// src/rules/seo/seo016-021.ts
|
|
732
|
+
function jsonldTags(head) {
|
|
733
|
+
return head.tags.filter((t) => t.kind === "jsonld" && typeof t.jsonld === "string");
|
|
734
|
+
}
|
|
735
|
+
var seo016JsonLdValidity = {
|
|
736
|
+
id: "SEO016",
|
|
737
|
+
title: "JSON-LD validity",
|
|
738
|
+
category: "seo",
|
|
739
|
+
severity: "warning",
|
|
740
|
+
scope: "route",
|
|
741
|
+
rationale: "Invalid JSON-LD \u2014 unparseable, or missing @context/@type \u2014 is silently ignored by search engines, so the structured data does nothing.",
|
|
742
|
+
fix: {
|
|
743
|
+
description: "Make the JSON-LD valid: parseable JSON with both @context (schema.org) and @type.",
|
|
744
|
+
snippet: '<svelte:head>\n <script type="application/ld+json">\n {"@context":"https://schema.org","@type":"WebPage","name":"\u2026"}\n </script>\n</svelte:head>',
|
|
745
|
+
lang: "svelte"
|
|
746
|
+
},
|
|
747
|
+
async check(ctx) {
|
|
748
|
+
const docsUrl3 = docsUrlFor("SEO016");
|
|
749
|
+
const out = [];
|
|
750
|
+
for (const head of ctx.heads) {
|
|
751
|
+
for (const tag of jsonldTags(head)) {
|
|
752
|
+
const parsed = parseJsonLd(tag.jsonld);
|
|
753
|
+
let problem;
|
|
754
|
+
if (!parsed.ok) problem = "JSON-LD is not valid JSON";
|
|
755
|
+
else if (!parsed.nodes.some((n) => "@context" in n)) problem = "JSON-LD is missing @context";
|
|
756
|
+
else if (!parsed.nodes.some((n) => typeOf(n).length > 0)) problem = "JSON-LD is missing @type";
|
|
757
|
+
out.push(
|
|
758
|
+
problem ? {
|
|
759
|
+
id: "SEO016",
|
|
760
|
+
category: "seo",
|
|
761
|
+
severity: "warning",
|
|
762
|
+
detection: PENALIZED,
|
|
763
|
+
route: head.route,
|
|
764
|
+
location: head.file,
|
|
765
|
+
message: problem,
|
|
766
|
+
recommendation: "Make the JSON-LD valid JSON with both @context and @type.",
|
|
767
|
+
docsUrl: docsUrl3,
|
|
768
|
+
fix: { ...seo016JsonLdValidity.fix }
|
|
769
|
+
} : {
|
|
770
|
+
id: "SEO016",
|
|
771
|
+
category: "seo",
|
|
772
|
+
severity: "warning",
|
|
773
|
+
detection: PASS,
|
|
774
|
+
route: head.route,
|
|
775
|
+
message: "JSON-LD validity",
|
|
776
|
+
recommendation: "Make the JSON-LD valid JSON with both @context and @type.",
|
|
777
|
+
docsUrl: docsUrl3
|
|
778
|
+
}
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return out;
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
function jsonldRule(opts) {
|
|
786
|
+
const docsUrl3 = docsUrlFor(opts.id);
|
|
787
|
+
return {
|
|
788
|
+
id: opts.id,
|
|
789
|
+
title: opts.title,
|
|
790
|
+
category: "seo",
|
|
791
|
+
severity: opts.severity,
|
|
792
|
+
scope: "route",
|
|
793
|
+
rationale: opts.rationale,
|
|
794
|
+
...opts.fix ? { fix: opts.fix } : {},
|
|
795
|
+
async check(ctx) {
|
|
796
|
+
const out = [];
|
|
797
|
+
for (const head of ctx.heads) {
|
|
798
|
+
for (const tag of jsonldTags(head)) {
|
|
799
|
+
const parsed = parseJsonLd(tag.jsonld);
|
|
800
|
+
if (!parsed.ok) continue;
|
|
801
|
+
if (!parsed.nodes.some((n) => "@context" in n) || !parsed.nodes.some((n) => typeOf(n).length > 0)) continue;
|
|
802
|
+
const problem = opts.problem(parsed.nodes);
|
|
803
|
+
if (problem === false) continue;
|
|
804
|
+
out.push(
|
|
805
|
+
problem ? {
|
|
806
|
+
id: opts.id,
|
|
807
|
+
category: "seo",
|
|
808
|
+
severity: opts.severity,
|
|
809
|
+
detection: PENALIZED,
|
|
810
|
+
route: head.route,
|
|
811
|
+
location: head.file,
|
|
812
|
+
message: problem,
|
|
813
|
+
recommendation: opts.recommendation,
|
|
814
|
+
docsUrl: docsUrl3,
|
|
815
|
+
...opts.fix ? { fix: { ...opts.fix } } : {}
|
|
816
|
+
} : {
|
|
817
|
+
id: opts.id,
|
|
818
|
+
category: "seo",
|
|
819
|
+
severity: opts.severity,
|
|
820
|
+
detection: PASS,
|
|
821
|
+
route: head.route,
|
|
822
|
+
message: opts.label,
|
|
823
|
+
recommendation: opts.recommendation,
|
|
824
|
+
docsUrl: docsUrl3
|
|
825
|
+
}
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return out;
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
var seo017DeprecatedType = jsonldRule({
|
|
834
|
+
id: "SEO017",
|
|
835
|
+
title: "Deprecated structured-data type",
|
|
836
|
+
severity: "info",
|
|
837
|
+
label: "Structured-data type",
|
|
838
|
+
recommendation: "Verify the rich-result status of this @type; Google dropped or restricted some (e.g. HowTo, FAQPage).",
|
|
839
|
+
rationale: "Some schema types no longer produce rich results, so the markup adds weight without the SERP benefit.",
|
|
840
|
+
problem: (nodes) => {
|
|
841
|
+
const dep = nodes.flatMap(typeOf).find((t) => DEPRECATED_TYPES.has(t));
|
|
842
|
+
return dep ? `@type "${dep}" no longer reliably produces a Google rich result` : void 0;
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
var seo018RelativeUrl = jsonldRule({
|
|
846
|
+
id: "SEO018",
|
|
847
|
+
title: "JSON-LD relative URL",
|
|
848
|
+
severity: "warning",
|
|
849
|
+
label: "JSON-LD URLs",
|
|
850
|
+
recommendation: "Use absolute URLs (http/https) for url/@id/image/logo/sameAs/contentUrl/thumbnailUrl in JSON-LD.",
|
|
851
|
+
rationale: "Search engines need absolute URLs in structured data; a relative URL cannot be resolved reliably.",
|
|
852
|
+
fix: {
|
|
853
|
+
description: "Replace relative URLs in JSON-LD with absolute URLs.",
|
|
854
|
+
snippet: '"image": "https://example.com/logo.png"',
|
|
855
|
+
lang: "json"
|
|
856
|
+
},
|
|
857
|
+
problem: (nodes) => {
|
|
858
|
+
const bad = collectValues(nodes, URL_KEYS).find((v) => !isAbsoluteUrl(v));
|
|
859
|
+
return bad ? `Relative URL in JSON-LD: "${bad}" \u2014 use an absolute URL` : void 0;
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
var seo019DateFormat = jsonldRule({
|
|
863
|
+
id: "SEO019",
|
|
864
|
+
title: "JSON-LD date format",
|
|
865
|
+
severity: "info",
|
|
866
|
+
label: "JSON-LD dates",
|
|
867
|
+
recommendation: "Use ISO-8601 dates (e.g. 2026-06-26 or 2026-06-26T10:00:00Z) in JSON-LD.",
|
|
868
|
+
rationale: "Schema.org date properties expect ISO-8601; other formats may be ignored or misparsed.",
|
|
869
|
+
fix: {
|
|
870
|
+
description: "Format JSON-LD date properties as ISO-8601.",
|
|
871
|
+
snippet: '"datePublished": "2026-06-26"',
|
|
872
|
+
lang: "json"
|
|
873
|
+
},
|
|
874
|
+
problem: (nodes) => {
|
|
875
|
+
const bad = collectValues(nodes, DATE_KEYS).find((v) => !isIso8601(v));
|
|
876
|
+
return bad ? `Non-ISO-8601 date in JSON-LD: "${bad}"` : void 0;
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
var seo020Placeholder = jsonldRule({
|
|
880
|
+
id: "SEO020",
|
|
881
|
+
title: "JSON-LD placeholder text",
|
|
882
|
+
severity: "info",
|
|
883
|
+
label: "JSON-LD content",
|
|
884
|
+
recommendation: "Replace placeholder/boilerplate text in JSON-LD with real values.",
|
|
885
|
+
rationale: 'Leftover placeholder text (e.g. "Your Company Name", "lorem ipsum") ships misleading structured data.',
|
|
886
|
+
problem: (nodes) => {
|
|
887
|
+
const bad = nodes.flatMap(nodeStringValues).find(hasPlaceholder);
|
|
888
|
+
return bad ? `Placeholder text in JSON-LD: "${bad}"` : void 0;
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
var seo021RequiredProps = jsonldRule({
|
|
892
|
+
id: "SEO021",
|
|
893
|
+
title: "JSON-LD required properties",
|
|
894
|
+
severity: "warning",
|
|
895
|
+
label: "JSON-LD required properties",
|
|
896
|
+
recommendation: "Add the properties Google requires for this @type's rich result.",
|
|
897
|
+
rationale: "A recognized @type missing its required properties is ineligible for the corresponding rich result.",
|
|
898
|
+
problem: (nodes) => {
|
|
899
|
+
let hasKnownType = false;
|
|
900
|
+
for (const node of nodes) {
|
|
901
|
+
for (const t of typeOf(node)) {
|
|
902
|
+
const required = REQUIRED_PROPS[t];
|
|
903
|
+
if (!required) continue;
|
|
904
|
+
hasKnownType = true;
|
|
905
|
+
const missing = required.filter((p) => !hasNonEmpty(node, p));
|
|
906
|
+
if (missing.length > 0) return `${t} JSON-LD is missing required ${missing.join(", ")}`;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return hasKnownType ? void 0 : false;
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
// src/rules/seo/text-metrics.ts
|
|
914
|
+
var segmenter = typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? new Intl.Segmenter() : void 0;
|
|
915
|
+
function visibleLength(s) {
|
|
916
|
+
const collapsed = s.trim().replace(/\s+/g, " ");
|
|
917
|
+
if (!segmenter) return [...collapsed].length;
|
|
918
|
+
return [...segmenter.segment(collapsed)].length;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// src/rules/seo/seo022-023.ts
|
|
922
|
+
function lengthRule(opts) {
|
|
923
|
+
const docsUrl3 = docsUrlFor(opts.id);
|
|
924
|
+
return {
|
|
925
|
+
id: opts.id,
|
|
926
|
+
title: opts.title,
|
|
927
|
+
category: "seo",
|
|
928
|
+
severity: "info",
|
|
929
|
+
scope: "route",
|
|
930
|
+
rationale: opts.rationale,
|
|
931
|
+
async check(ctx) {
|
|
932
|
+
const out = [];
|
|
933
|
+
for (const head of ctx.heads) {
|
|
934
|
+
const tag = head.tags.find(opts.match);
|
|
935
|
+
if (!tag || typeof tag.text !== "string") continue;
|
|
936
|
+
const len = visibleLength(tag.text);
|
|
937
|
+
let problem;
|
|
938
|
+
if (len < opts.min) problem = `${opts.noun} is too short (${len} chars; aim for ${opts.min}\u2013${opts.max})`;
|
|
939
|
+
else if (len > opts.max) problem = `${opts.noun} is too long (${len} chars; aim for ${opts.min}\u2013${opts.max})`;
|
|
940
|
+
out.push(
|
|
941
|
+
problem ? {
|
|
942
|
+
id: opts.id,
|
|
943
|
+
category: "seo",
|
|
944
|
+
severity: "info",
|
|
945
|
+
detection: PENALIZED,
|
|
946
|
+
route: head.route,
|
|
947
|
+
location: tag.file ?? head.file,
|
|
948
|
+
message: problem,
|
|
949
|
+
recommendation: opts.recommendation,
|
|
950
|
+
docsUrl: docsUrl3
|
|
951
|
+
} : {
|
|
952
|
+
id: opts.id,
|
|
953
|
+
category: "seo",
|
|
954
|
+
severity: "info",
|
|
955
|
+
detection: PASS,
|
|
956
|
+
route: head.route,
|
|
957
|
+
message: opts.label,
|
|
958
|
+
recommendation: opts.recommendation,
|
|
959
|
+
docsUrl: docsUrl3
|
|
960
|
+
}
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
return out;
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
var seo022TitleLength = lengthRule({
|
|
968
|
+
id: "SEO022",
|
|
969
|
+
title: "Title length",
|
|
970
|
+
label: "Title length",
|
|
971
|
+
noun: "Title",
|
|
972
|
+
match: (t) => t.kind === "title",
|
|
973
|
+
min: 30,
|
|
974
|
+
max: 60,
|
|
975
|
+
recommendation: "Aim for a title of 30\u201360 characters so it is not truncated in search results.",
|
|
976
|
+
rationale: "A title that is too short wastes the strongest on-page signal; one that is too long is truncated in the SERP."
|
|
977
|
+
});
|
|
978
|
+
var seo023DescriptionLength = lengthRule({
|
|
979
|
+
id: "SEO023",
|
|
980
|
+
title: "Description length",
|
|
981
|
+
label: "Description length",
|
|
982
|
+
noun: "Description",
|
|
983
|
+
match: (t) => t.kind === "meta" && t.name === "description",
|
|
984
|
+
min: 70,
|
|
985
|
+
max: 160,
|
|
986
|
+
recommendation: "Aim for a meta description of 70\u2013160 characters so it is not truncated in search results.",
|
|
987
|
+
rationale: "A description that is too short under-uses the SERP snippet; one that is too long is truncated by search engines."
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
// src/rules/seo/seo024-charset.ts
|
|
991
|
+
var seo024Charset = headTagRule({
|
|
992
|
+
id: "SEO024",
|
|
993
|
+
title: "Character encoding",
|
|
994
|
+
severity: "warning",
|
|
995
|
+
match: (t) => t.kind === "meta" && t.name === "charset",
|
|
996
|
+
label: "<meta charset>",
|
|
997
|
+
appliesTo: (head) => head.source === "rendered",
|
|
998
|
+
recommendation: 'Add <meta charset="utf-8"> (usually the first line of <head> in src/app.html).',
|
|
999
|
+
rationale: 'Without a declared character encoding the browser must guess, which can render text as mojibake; <meta charset="utf-8"> is the standard declaration.',
|
|
1000
|
+
fix: {
|
|
1001
|
+
description: "Add the charset meta tag (typically the first line of <head> in src/app.html).",
|
|
1002
|
+
snippet: '<meta charset="utf-8" />',
|
|
1003
|
+
lang: "html"
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
// src/rules/seo/seo025-image-alt.ts
|
|
1008
|
+
var seo025ImageAlt = imageRule({
|
|
1009
|
+
id: "SEO025",
|
|
1010
|
+
title: "Image alt text",
|
|
1011
|
+
category: "seo",
|
|
1012
|
+
severity: "warning",
|
|
1013
|
+
label: "<img> alt text",
|
|
1014
|
+
recommendation: 'Add an alt attribute to every <img> (use alt="" only for purely decorative images).',
|
|
1015
|
+
rationale: "An <img> with no alt attribute is invisible to image search and assistive technology; a descriptive alt is an image-SEO signal.",
|
|
1016
|
+
fix: {
|
|
1017
|
+
description: 'Add a descriptive alt attribute to the <img> (or alt="" if purely decorative).',
|
|
1018
|
+
snippet: '<img src="/photo.jpg" width="800" height="600" alt="Description of the image" />',
|
|
1019
|
+
lang: "svelte"
|
|
1020
|
+
},
|
|
1021
|
+
ok: (img) => img.hasAlt
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
// src/rules/seo/seo026-hreflang.ts
|
|
1025
|
+
var docsUrl = docsUrlFor("SEO026");
|
|
1026
|
+
var recommendation = 'Use valid hreflang codes (e.g. "en", "en-US", "x-default") and include an x-default when you have multiple language alternates.';
|
|
1027
|
+
var HREFLANG_RE = /^[a-z]{2,3}(-[a-z]{4})?(-([a-z]{2}|\d{3}))?$/i;
|
|
1028
|
+
function isValidHreflang(v) {
|
|
1029
|
+
return v.toLowerCase() === "x-default" || HREFLANG_RE.test(v);
|
|
1030
|
+
}
|
|
1031
|
+
var seo026Hreflang = {
|
|
1032
|
+
id: "SEO026",
|
|
1033
|
+
title: "hreflang validity",
|
|
1034
|
+
category: "seo",
|
|
1035
|
+
severity: "warning",
|
|
1036
|
+
scope: "route",
|
|
1037
|
+
rationale: "A malformed hreflang code or a missing x-default breaks international targeting, so search engines may serve the wrong language version.",
|
|
1038
|
+
async check(ctx) {
|
|
1039
|
+
const out = [];
|
|
1040
|
+
for (const head of ctx.heads) {
|
|
1041
|
+
const alternates = head.tags.filter(
|
|
1042
|
+
(t) => t.kind === "link" && t.rel === "alternate" && typeof t.hreflang === "string"
|
|
1043
|
+
);
|
|
1044
|
+
if (alternates.length === 0) continue;
|
|
1045
|
+
const values = alternates.map((t) => t.hreflang);
|
|
1046
|
+
const badTag = alternates.find((t) => !isValidHreflang(t.hreflang));
|
|
1047
|
+
let problem;
|
|
1048
|
+
let location = head.file;
|
|
1049
|
+
if (badTag) {
|
|
1050
|
+
problem = `Invalid hreflang value "${badTag.hreflang}"`;
|
|
1051
|
+
location = badTag.file ?? head.file;
|
|
1052
|
+
} else if (values.length >= 2 && !values.some((v) => v.toLowerCase() === "x-default")) {
|
|
1053
|
+
problem = "Multiple hreflang alternates without an x-default";
|
|
1054
|
+
}
|
|
1055
|
+
out.push(
|
|
1056
|
+
problem ? {
|
|
1057
|
+
id: "SEO026",
|
|
1058
|
+
category: "seo",
|
|
1059
|
+
severity: "warning",
|
|
1060
|
+
detection: PENALIZED,
|
|
1061
|
+
route: head.route,
|
|
1062
|
+
location,
|
|
1063
|
+
message: problem,
|
|
1064
|
+
recommendation,
|
|
1065
|
+
docsUrl
|
|
1066
|
+
} : {
|
|
1067
|
+
id: "SEO026",
|
|
1068
|
+
category: "seo",
|
|
1069
|
+
severity: "warning",
|
|
1070
|
+
detection: PASS,
|
|
1071
|
+
route: head.route,
|
|
1072
|
+
message: "hreflang",
|
|
1073
|
+
recommendation,
|
|
1074
|
+
docsUrl
|
|
1075
|
+
}
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
return out;
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
// src/rules/seo/seo027-heading.ts
|
|
1083
|
+
var docsUrl2 = docsUrlFor("SEO027");
|
|
1084
|
+
var recommendation2 = "Use exactly one <h1> per page for its main topic; demote extra top-level headings to <h2>+.";
|
|
1085
|
+
var seo027Heading = {
|
|
1086
|
+
id: "SEO027",
|
|
1087
|
+
title: "Heading hierarchy",
|
|
1088
|
+
category: "seo",
|
|
1089
|
+
severity: "warning",
|
|
1090
|
+
scope: "route",
|
|
1091
|
+
rationale: "Each page should have exactly one <h1> naming its main topic; none leaves the page without a primary heading, and several dilute the topic signal.",
|
|
1092
|
+
async check(ctx) {
|
|
1093
|
+
const out = [];
|
|
1094
|
+
for (const route of ctx.headings ?? []) {
|
|
1095
|
+
const h1 = route.headings.filter((h) => h.level === 1);
|
|
1096
|
+
let problem;
|
|
1097
|
+
let where = {};
|
|
1098
|
+
if (h1.length === 0) {
|
|
1099
|
+
problem = "Missing <h1>";
|
|
1100
|
+
const first = route.headings[0];
|
|
1101
|
+
if (first) where = { location: first.file, ...first.line > 0 ? { line: first.line } : {} };
|
|
1102
|
+
} else if (h1.length > 1) {
|
|
1103
|
+
problem = `Multiple <h1> (${h1.length}); use exactly one`;
|
|
1104
|
+
const extra = h1[1];
|
|
1105
|
+
where = { location: extra.file, ...extra.line > 0 ? { line: extra.line } : {} };
|
|
1106
|
+
}
|
|
1107
|
+
out.push(
|
|
1108
|
+
problem ? {
|
|
1109
|
+
id: "SEO027",
|
|
1110
|
+
category: "seo",
|
|
1111
|
+
severity: "warning",
|
|
1112
|
+
detection: PENALIZED,
|
|
1113
|
+
route: route.route,
|
|
1114
|
+
...where,
|
|
1115
|
+
message: problem,
|
|
1116
|
+
recommendation: recommendation2,
|
|
1117
|
+
docsUrl: docsUrl2
|
|
1118
|
+
} : {
|
|
1119
|
+
id: "SEO027",
|
|
1120
|
+
category: "seo",
|
|
1121
|
+
severity: "warning",
|
|
1122
|
+
detection: PASS,
|
|
1123
|
+
route: route.route,
|
|
1124
|
+
message: "Heading hierarchy",
|
|
1125
|
+
recommendation: recommendation2,
|
|
1126
|
+
docsUrl: docsUrl2
|
|
1127
|
+
}
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
return out;
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
|
|
461
1134
|
// src/rules/index.ts
|
|
462
1135
|
var allRules = [
|
|
463
1136
|
seo001Title,
|
|
@@ -472,7 +1145,25 @@ var allRules = [
|
|
|
472
1145
|
perf001ImageDimensions,
|
|
473
1146
|
perf002ImageLoading,
|
|
474
1147
|
perf003PreloadAs,
|
|
475
|
-
perf004FontPreloadCrossorigin
|
|
1148
|
+
perf004FontPreloadCrossorigin,
|
|
1149
|
+
seo010Indexability,
|
|
1150
|
+
seo011TwitterCard,
|
|
1151
|
+
seo012OgDescription,
|
|
1152
|
+
seo013OgUrl,
|
|
1153
|
+
seo014Viewport,
|
|
1154
|
+
seo015SitemapInRobots,
|
|
1155
|
+
seo016JsonLdValidity,
|
|
1156
|
+
seo017DeprecatedType,
|
|
1157
|
+
seo018RelativeUrl,
|
|
1158
|
+
seo019DateFormat,
|
|
1159
|
+
seo020Placeholder,
|
|
1160
|
+
seo021RequiredProps,
|
|
1161
|
+
seo022TitleLength,
|
|
1162
|
+
seo023DescriptionLength,
|
|
1163
|
+
seo024Charset,
|
|
1164
|
+
seo025ImageAlt,
|
|
1165
|
+
seo026Hreflang,
|
|
1166
|
+
seo027Heading
|
|
476
1167
|
];
|
|
477
1168
|
function explainRule(id) {
|
|
478
1169
|
const target = id.toUpperCase();
|
|
@@ -1099,5 +1790,23 @@ export {
|
|
|
1099
1790
|
seo007Sitemap,
|
|
1100
1791
|
seo008JsonLd,
|
|
1101
1792
|
seo009HtmlLang,
|
|
1793
|
+
seo010Indexability,
|
|
1794
|
+
seo011TwitterCard,
|
|
1795
|
+
seo012OgDescription,
|
|
1796
|
+
seo013OgUrl,
|
|
1797
|
+
seo014Viewport,
|
|
1798
|
+
seo015SitemapInRobots,
|
|
1799
|
+
seo016JsonLdValidity,
|
|
1800
|
+
seo017DeprecatedType,
|
|
1801
|
+
seo018RelativeUrl,
|
|
1802
|
+
seo019DateFormat,
|
|
1803
|
+
seo020Placeholder,
|
|
1804
|
+
seo021RequiredProps,
|
|
1805
|
+
seo022TitleLength,
|
|
1806
|
+
seo023DescriptionLength,
|
|
1807
|
+
seo024Charset,
|
|
1808
|
+
seo025ImageAlt,
|
|
1809
|
+
seo026Hreflang,
|
|
1810
|
+
seo027Heading,
|
|
1102
1811
|
summarize
|
|
1103
1812
|
};
|