@aexol/spectral 0.9.126 → 0.9.128

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.
Files changed (89) hide show
  1. package/dist/agent/index.d.ts.map +1 -1
  2. package/dist/agent/index.js +5 -0
  3. package/dist/extensions/seo/analyzers/content.d.ts +29 -6
  4. package/dist/extensions/seo/analyzers/content.d.ts.map +1 -1
  5. package/dist/extensions/seo/analyzers/content.js +243 -8
  6. package/dist/extensions/seo/analyzers/crawler.d.ts.map +1 -1
  7. package/dist/extensions/seo/analyzers/crawler.js +133 -7
  8. package/dist/extensions/seo/analyzers/drift.d.ts.map +1 -1
  9. package/dist/extensions/seo/analyzers/drift.js +86 -29
  10. package/dist/extensions/seo/analyzers/images.d.ts +10 -4
  11. package/dist/extensions/seo/analyzers/images.d.ts.map +1 -1
  12. package/dist/extensions/seo/analyzers/images.js +153 -5
  13. package/dist/extensions/seo/analyzers/performance.d.ts +3 -3
  14. package/dist/extensions/seo/analyzers/performance.d.ts.map +1 -1
  15. package/dist/extensions/seo/analyzers/performance.js +77 -6
  16. package/dist/extensions/seo/analyzers/readability.d.ts.map +1 -1
  17. package/dist/extensions/seo/analyzers/readability.js +17 -9
  18. package/dist/extensions/seo/analyzers/schema.d.ts.map +1 -1
  19. package/dist/extensions/seo/analyzers/schema.js +99 -10
  20. package/dist/extensions/seo/analyzers/sitemap.d.ts.map +1 -1
  21. package/dist/extensions/seo/analyzers/sitemap.js +57 -16
  22. package/dist/extensions/seo/analyzers/technical.d.ts +0 -7
  23. package/dist/extensions/seo/analyzers/technical.d.ts.map +1 -1
  24. package/dist/extensions/seo/analyzers/technical.js +408 -37
  25. package/dist/extensions/seo/config.d.ts +2 -0
  26. package/dist/extensions/seo/config.d.ts.map +1 -1
  27. package/dist/extensions/seo/config.js +2 -0
  28. package/dist/extensions/seo/tools/batch.js +1 -1
  29. package/dist/extensions/seo/tools/content.d.ts.map +1 -1
  30. package/dist/extensions/seo/tools/content.js +5 -3
  31. package/dist/extensions/seo/tools/full-audit.d.ts +1 -1
  32. package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -1
  33. package/dist/extensions/seo/tools/full-audit.js +391 -66
  34. package/dist/extensions/seo/tools/helpers.d.ts +15 -4
  35. package/dist/extensions/seo/tools/helpers.d.ts.map +1 -1
  36. package/dist/extensions/seo/tools/helpers.js +57 -6
  37. package/dist/extensions/seo/tools/performance.d.ts.map +1 -1
  38. package/dist/extensions/seo/tools/performance.js +17 -11
  39. package/dist/extensions/seo/tools/report.d.ts.map +1 -1
  40. package/dist/extensions/seo/tools/report.js +513 -47
  41. package/dist/extensions/seo/types.d.ts +295 -3
  42. package/dist/extensions/seo/types.d.ts.map +1 -1
  43. package/dist/extensions/seo/utils/fetcher.d.ts +4 -0
  44. package/dist/extensions/seo/utils/fetcher.d.ts.map +1 -1
  45. package/dist/extensions/seo/utils/fetcher.js +77 -11
  46. package/dist/extensions/seo/utils/parser.d.ts +3 -1
  47. package/dist/extensions/seo/utils/parser.d.ts.map +1 -1
  48. package/dist/extensions/seo/utils/parser.js +105 -25
  49. package/dist/extensions/seo/utils/renderer.d.ts +6 -1
  50. package/dist/extensions/seo/utils/renderer.d.ts.map +1 -1
  51. package/dist/extensions/seo/utils/renderer.js +207 -1
  52. package/dist/extensions/seo/utils/url-safety.d.ts.map +1 -1
  53. package/dist/extensions/seo/utils/url-safety.js +19 -3
  54. package/dist/extensions/web/tools/fetch.js +2 -2
  55. package/dist/memory/tools/compact-context.d.ts.map +1 -1
  56. package/dist/memory/tools/compact-context.js +10 -3
  57. package/dist/sdk/agent-core/agent-loop.d.ts.map +1 -1
  58. package/dist/sdk/agent-core/agent-loop.js +87 -4
  59. package/dist/sdk/agent-core/types.d.ts +1 -0
  60. package/dist/sdk/agent-core/types.d.ts.map +1 -1
  61. package/dist/sdk/ai/utils/validation.d.ts +0 -7
  62. package/dist/sdk/ai/utils/validation.d.ts.map +1 -1
  63. package/dist/sdk/ai/utils/validation.js +21 -1
  64. package/dist/sdk/coding-agent/core/extensions/runner.d.ts.map +1 -1
  65. package/dist/sdk/coding-agent/core/extensions/runner.js +15 -1
  66. package/dist/sdk/coding-agent/core/tools/apply-patch.d.ts +7 -2
  67. package/dist/sdk/coding-agent/core/tools/apply-patch.d.ts.map +1 -1
  68. package/dist/sdk/coding-agent/core/tools/apply-patch.js +219 -41
  69. package/dist/sdk/coding-agent/core/tools/atomic-write.d.ts +30 -0
  70. package/dist/sdk/coding-agent/core/tools/atomic-write.d.ts.map +1 -0
  71. package/dist/sdk/coding-agent/core/tools/atomic-write.js +42 -0
  72. package/dist/sdk/coding-agent/core/tools/edit-diff.d.ts +10 -4
  73. package/dist/sdk/coding-agent/core/tools/edit-diff.d.ts.map +1 -1
  74. package/dist/sdk/coding-agent/core/tools/edit-diff.js +44 -24
  75. package/dist/sdk/coding-agent/core/tools/edit.d.ts +6 -0
  76. package/dist/sdk/coding-agent/core/tools/edit.d.ts.map +1 -1
  77. package/dist/sdk/coding-agent/core/tools/edit.js +31 -5
  78. package/dist/sdk/coding-agent/core/tools/file-mutation-queue.d.ts +9 -3
  79. package/dist/sdk/coding-agent/core/tools/file-mutation-queue.d.ts.map +1 -1
  80. package/dist/sdk/coding-agent/core/tools/file-mutation-queue.js +42 -3
  81. package/dist/sdk/coding-agent/core/tools/read.d.ts.map +1 -1
  82. package/dist/sdk/coding-agent/core/tools/read.js +79 -47
  83. package/dist/sdk/coding-agent/core/tools/truncate.d.ts +4 -2
  84. package/dist/sdk/coding-agent/core/tools/truncate.d.ts.map +1 -1
  85. package/dist/sdk/coding-agent/core/tools/truncate.js +43 -23
  86. package/dist/sdk/coding-agent/core/tools/write.d.ts +5 -8
  87. package/dist/sdk/coding-agent/core/tools/write.d.ts.map +1 -1
  88. package/dist/sdk/coding-agent/core/tools/write.js +29 -11
  89. package/package.json +1 -1
@@ -12,6 +12,7 @@ import Database from "better-sqlite3";
12
12
  import { parsePage } from "../utils/parser.js";
13
13
  import { fetchPage } from "../utils/fetcher.js";
14
14
  import { normalizeUrl, urlHash } from "../utils/url-safety.js";
15
+ const SCHEMA_VERSION = 2;
15
16
  let dbInstance = null;
16
17
  function getDb(config) {
17
18
  if (dbInstance)
@@ -19,27 +20,44 @@ function getDb(config) {
19
20
  mkdirSync(dirname(config.dbPath), { recursive: true });
20
21
  dbInstance = new Database(config.dbPath);
21
22
  dbInstance.pragma("journal_mode = WAL");
23
+ // Create version table and track schema version.
22
24
  dbInstance.exec(`
23
- CREATE TABLE IF NOT EXISTS baselines (
24
- id INTEGER PRIMARY KEY AUTOINCREMENT,
25
- url TEXT NOT NULL,
26
- url_hash TEXT NOT NULL,
27
- timestamp TEXT NOT NULL,
28
- title TEXT,
29
- meta_description TEXT,
30
- canonical TEXT,
31
- robots TEXT,
32
- h1 TEXT,
33
- h2_json TEXT,
34
- h3_json TEXT,
35
- schema_json TEXT,
36
- og_json TEXT,
37
- html_hash TEXT,
38
- schema_hash TEXT,
39
- status_code INTEGER
40
- );
41
- CREATE INDEX IF NOT EXISTS idx_url_hash ON baselines(url_hash);
25
+ CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL);
42
26
  `);
27
+ const currentVersion = dbInstance.prepare("SELECT version FROM schema_version").get();
28
+ if (!currentVersion || currentVersion.version < 1) {
29
+ dbInstance.exec(`
30
+ CREATE TABLE IF NOT EXISTS baselines (
31
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ url TEXT NOT NULL,
33
+ url_hash TEXT NOT NULL,
34
+ timestamp TEXT NOT NULL,
35
+ title TEXT,
36
+ meta_description TEXT,
37
+ canonical TEXT,
38
+ robots TEXT,
39
+ h1 TEXT,
40
+ h2_json TEXT,
41
+ h3_json TEXT,
42
+ schema_json TEXT,
43
+ og_json TEXT,
44
+ html_hash TEXT,
45
+ schema_hash TEXT,
46
+ status_code INTEGER
47
+ );
48
+ CREATE INDEX IF NOT EXISTS idx_url_hash ON baselines(url_hash);
49
+ `);
50
+ dbInstance.prepare("DELETE FROM schema_version").run();
51
+ dbInstance.prepare("INSERT INTO schema_version (version) VALUES (1)").run();
52
+ }
53
+ if (!currentVersion || currentVersion.version < 2) {
54
+ dbInstance.exec(`
55
+ ALTER TABLE baselines ADD COLUMN expires_at TEXT NOT NULL DEFAULT '';
56
+ ALTER TABLE baselines ADD COLUMN headers_json TEXT NOT NULL DEFAULT '{}';
57
+ `);
58
+ dbInstance.prepare("DELETE FROM schema_version").run();
59
+ dbInstance.prepare("INSERT INTO schema_version (version) VALUES (2)").run();
60
+ }
43
61
  return dbInstance;
44
62
  }
45
63
  /** Close the database connection (called on session shutdown). */
@@ -69,19 +87,25 @@ export async function captureBaseline(url, config) {
69
87
  const ogJson = JSON.stringify(pageData.ogTags);
70
88
  const htmlHash = hashContent(fetchResult.content);
71
89
  const schemaHash = hashContent(schemaJson);
90
+ const headersJson = JSON.stringify(fetchResult.headers);
91
+ // Compute expiration (default: 30 days).
92
+ const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
72
93
  const db = getDb(config);
94
+ // Prune expired baselines before inserting.
95
+ db.prepare("DELETE FROM baselines WHERE expires_at < ? AND expires_at != ''").run(new Date().toISOString());
73
96
  const stmt = db.prepare(`
74
97
  INSERT INTO baselines
75
- (url, url_hash, timestamp, title, meta_description, canonical, robots,
76
- h1, h2_json, h3_json, schema_json, og_json, html_hash, schema_hash, status_code)
77
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
98
+ (url, url_hash, timestamp, expires_at, title, meta_description, canonical, robots,
99
+ h1, h2_json, h3_json, schema_json, og_json, html_hash, schema_hash, status_code, headers_json)
100
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
78
101
  `);
79
- const info = stmt.run(normalizeUrl(url), hash, timestamp, pageData.title, pageData.metaDescription, pageData.canonical, pageData.metaRobots, pageData.headings.find((h) => h.level === 1)?.text ?? null, h2Json, h3Json, schemaJson, ogJson, htmlHash, schemaHash, fetchResult.statusCode);
102
+ const info = stmt.run(normalizeUrl(url), hash, timestamp, expiresAt, pageData.title, pageData.metaDescription, pageData.canonical, pageData.metaRobots, pageData.headings.find((h) => h.level === 1)?.text ?? null, h2Json, h3Json, schemaJson, ogJson, htmlHash, schemaHash, fetchResult.statusCode, headersJson);
80
103
  return {
81
104
  id: Number(info.lastInsertRowid),
82
105
  url: normalizeUrl(url),
83
106
  urlHash: hash,
84
107
  timestamp,
108
+ expiresAt,
85
109
  title: pageData.title,
86
110
  metaDescription: pageData.metaDescription,
87
111
  canonical: pageData.canonical,
@@ -94,6 +118,7 @@ export async function captureBaseline(url, config) {
94
118
  htmlHash,
95
119
  schemaHash,
96
120
  statusCode: fetchResult.statusCode,
121
+ headersJson,
97
122
  };
98
123
  }
99
124
  /** Get a baseline by ID or the latest baseline for a URL. */
@@ -113,6 +138,7 @@ function rowToBaseline(row) {
113
138
  url: row.url,
114
139
  urlHash: row.url_hash,
115
140
  timestamp: row.timestamp,
141
+ expiresAt: row.expires_at ?? "",
116
142
  title: row.title ?? null,
117
143
  metaDescription: row.meta_description ?? null,
118
144
  canonical: row.canonical ?? null,
@@ -125,6 +151,7 @@ function rowToBaseline(row) {
125
151
  htmlHash: row.html_hash ?? "",
126
152
  schemaHash: row.schema_hash ?? "",
127
153
  statusCode: row.status_code ?? 0,
154
+ headersJson: row.headers_json ?? "{}",
128
155
  };
129
156
  }
130
157
  /** Compare current page state against a baseline. */
@@ -133,32 +160,52 @@ export async function compareBaseline(url, config, baselineId) {
133
160
  if (!baseline) {
134
161
  throw new Error(`No baseline found for ${url}${baselineId ? ` (id=${baselineId})` : ""}.`);
135
162
  }
163
+ // Check if baseline has expired.
164
+ if (baseline.expiresAt && baseline.expiresAt < new Date().toISOString()) {
165
+ throw new Error(`Baseline has expired (${baseline.expiresAt}). Capture a new baseline.`);
166
+ }
136
167
  const fetchResult = await fetchPage(url, config);
137
168
  const pageData = parsePage(fetchResult.content, fetchResult.finalUrl);
138
169
  const changedFields = [];
139
170
  const details = {};
171
+ const fieldSeverity = {};
140
172
  const comparisons = [
141
- { field: "title", before: baseline.title, after: pageData.title },
142
- { field: "metaDescription", before: baseline.metaDescription, after: pageData.metaDescription },
143
- { field: "canonical", before: baseline.canonical, after: pageData.canonical },
144
- { field: "robots", before: baseline.robots, after: pageData.metaRobots },
145
- { field: "h1", before: baseline.h1, after: pageData.headings.find((h) => h.level === 1)?.text ?? null },
173
+ { field: "title", before: baseline.title, after: pageData.title, severityHint: "high" },
174
+ { field: "metaDescription", before: baseline.metaDescription, after: pageData.metaDescription, severityHint: "medium" },
175
+ { field: "canonical", before: baseline.canonical, after: pageData.canonical, severityHint: "critical" },
176
+ { field: "robots", before: baseline.robots, after: pageData.metaRobots, severityHint: "high" },
177
+ { field: "h1", before: baseline.h1, after: pageData.headings.find((h) => h.level === 1)?.text ?? null, severityHint: "medium" },
146
178
  {
147
179
  field: "h2",
148
180
  before: JSON.parse(baseline.h2Json),
149
181
  after: pageData.headings.filter((h) => h.level === 2).map((h) => h.text),
182
+ severityHint: "low",
150
183
  },
151
184
  {
152
185
  field: "h3",
153
186
  before: JSON.parse(baseline.h3Json),
154
187
  after: pageData.headings.filter((h) => h.level === 3).map((h) => h.text),
188
+ severityHint: "low",
155
189
  },
156
190
  {
157
191
  field: "schema",
158
192
  before: JSON.parse(baseline.schemaJson),
159
193
  after: pageData.schemaBlocks.map((s) => ({ type: s.type, format: s.format })),
194
+ severityHint: "high",
195
+ },
196
+ { field: "statusCode", before: baseline.statusCode, after: fetchResult.statusCode, severityHint: "critical" },
197
+ {
198
+ field: "headers",
199
+ before: JSON.parse(baseline.headersJson || "{}"),
200
+ after: fetchResult.headers,
201
+ severityHint: "medium",
202
+ },
203
+ {
204
+ field: "ogTags",
205
+ before: JSON.parse(baseline.ogJson),
206
+ after: pageData.ogTags,
207
+ severityHint: "low",
160
208
  },
161
- { field: "statusCode", before: baseline.statusCode, after: fetchResult.statusCode },
162
209
  ];
163
210
  for (const cmp of comparisons) {
164
211
  const beforeStr = JSON.stringify(cmp.before);
@@ -166,6 +213,15 @@ export async function compareBaseline(url, config, baselineId) {
166
213
  if (beforeStr !== afterStr) {
167
214
  changedFields.push(cmp.field);
168
215
  details[cmp.field] = { before: cmp.before, after: cmp.after };
216
+ fieldSeverity[cmp.field] = cmp.severityHint;
217
+ // Detect removed schema blocks (all blocks removed).
218
+ if (cmp.field === "schema") {
219
+ const beforeArr = Array.isArray(cmp.before) ? cmp.before : [];
220
+ const afterArr = Array.isArray(cmp.after) ? cmp.after : [];
221
+ if (beforeArr.length > 0 && afterArr.length === 0) {
222
+ fieldSeverity[cmp.field] = "critical";
223
+ }
224
+ }
169
225
  }
170
226
  }
171
227
  // HTML hash comparison.
@@ -192,6 +248,7 @@ export async function compareBaseline(url, config, baselineId) {
192
248
  comparedAt: new Date().toISOString(),
193
249
  changedFields,
194
250
  details,
251
+ fieldSeverity,
195
252
  severity,
196
253
  };
197
254
  }
@@ -2,9 +2,15 @@
2
2
  * Image SEO analyzer.
3
3
  *
4
4
  * Extracts image data from parsed HTML and flags common SEO issues:
5
- * missing alt text, missing dimensions, lazy-loading method, and modern
6
- * format usage.
5
+ * missing alt text, missing dimensions, lazy-loading method, modern
6
+ * format usage, responsive coverage, fetchpriority analysis,
7
+ * decorative vs missing alt distinction, aspect ratio checks,
8
+ * and SVG accessibility.
7
9
  */
8
- import type { ImageAuditResult } from "../types.js";
9
- export declare function auditImages(html: string, url?: string): ImageAuditResult;
10
+ import type { ImageAuditResult, ImageInfo, PageData } from "../types.js";
11
+ import type { SeoConfig } from "../config.js";
12
+ export declare function auditImages(html: string, url?: string, pageData?: PageData): ImageAuditResult;
13
+ export declare function checkImageUrls(images: ImageInfo[], config: SeoConfig): Promise<{
14
+ brokenUrls: string[];
15
+ }>;
10
16
  //# sourceMappingURL=images.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"images.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/images.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,gBAAgB,EAAuB,MAAM,aAAa,CAAC;AAiBzE,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,gBAAgB,CAqCxE"}
1
+ {"version":3,"file":"images.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/images.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,gBAAgB,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACzE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAiB9C,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,QAAQ,GAAG,gBAAgB,CAsK7F;AAED,wBAAsB,cAAc,CAClC,MAAM,EAAE,SAAS,EAAE,EACnB,MAAM,EAAE,SAAS,GAChB,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CAoCnC"}
@@ -12,32 +12,130 @@ function getExtension(src) {
12
12
  return dotIdx >= 0 ? src.slice(dotIdx).toLowerCase() : "";
13
13
  }
14
14
  }
15
- export function auditImages(html, url) {
16
- const pageData = parsePage(html, url);
17
- const images = pageData.images;
15
+ export function auditImages(html, url, pageData) {
16
+ const pd = pageData ?? parsePage(html, url);
17
+ const images = pd.images;
18
18
  let missingAlt = 0;
19
+ let decorativeCount = 0;
19
20
  let missingDimensions = 0;
20
21
  const lazyCounts = { native: 0, dataSrc: 0, dataLazy: 0, none: 0 };
21
22
  let modernFormats = 0;
22
23
  const oversized = [];
24
+ let fetchpriorityHighCount = 0;
25
+ const fetchpriorityIssues = [];
26
+ const ratioIssues = [];
27
+ const dimensionHintIssues = [];
28
+ let decorativeWithAltIssues = 0;
29
+ let svgImgMissingAlt = 0;
30
+ let responsiveImages = 0;
23
31
  for (const img of images) {
24
- if (!img.alt)
32
+ // Distinguish decorative (alt="" empty) from truly missing alt
33
+ if (img.isDecorative) {
34
+ decorativeCount++;
35
+ if (img.alt)
36
+ decorativeWithAltIssues++;
37
+ }
38
+ else if (!img.alt) {
25
39
  missingAlt++;
40
+ }
26
41
  if (!img.width || !img.height)
27
42
  missingDimensions++;
28
43
  lazyCounts[img.lazyMethod]++;
29
44
  const ext = getExtension(img.src);
30
45
  if (MODERN_FORMATS.includes(ext))
31
46
  modernFormats++;
47
+ if (img.isSvg && !img.alt)
48
+ svgImgMissingAlt++;
32
49
  // Flag potential oversized images (large numeric dimensions).
33
50
  const w = img.width ? Number.parseInt(img.width, 10) : 0;
34
51
  const h = img.height ? Number.parseInt(img.height, 10) : 0;
35
52
  if (w > 2000 || h > 2000) {
36
53
  oversized.push(img.src);
37
54
  }
55
+ if (w > 0 && h > 0 && (w / h > 4 || h / w > 4)) {
56
+ dimensionHintIssues.push(`Extreme dimension ratio (${w}x${h}): ${img.src}`);
57
+ }
58
+ if ((!img.width || !img.height) && !img.srcset && !img.isInPicture) {
59
+ dimensionHintIssues.push(`Missing dimension hints (no width/height or srcset): ${img.src}`);
60
+ }
61
+ // fetchpriority analysis
62
+ if (img.fetchpriority === "high") {
63
+ fetchpriorityHighCount++;
64
+ if (w > 0 && h > 0 && (w < 100 || h < 100)) {
65
+ fetchpriorityIssues.push(`fetchpriority="high" on small image (${w}x${h}): ${img.src}`);
66
+ }
67
+ }
68
+ // Aspect ratio checks
69
+ if (w > 0 && h > 0) {
70
+ const ratio = w / h;
71
+ if (ratio < 0.3 || ratio > 3.0) {
72
+ ratioIssues.push(`Extreme aspect ratio (${ratio.toFixed(2)}): ${img.src} (${w}x${h})`);
73
+ }
74
+ }
75
+ // Responsive coverage
76
+ if (img.srcset || img.sizes || img.isInPicture) {
77
+ responsiveImages++;
78
+ }
79
+ }
80
+ // Decoding attribute analysis
81
+ const decodingStats = { async: 0, sync: 0, auto: 0, none: 0 };
82
+ for (const img of images) {
83
+ if (img.decoding === "async")
84
+ decodingStats.async++;
85
+ else if (img.decoding === "sync")
86
+ decodingStats.sync++;
87
+ else if (img.decoding === "auto")
88
+ decodingStats.auto++;
89
+ else
90
+ decodingStats.none++;
91
+ }
92
+ // No LCP image prioritized when many images exist
93
+ if (images.length > 5 && fetchpriorityHighCount === 0) {
94
+ fetchpriorityIssues.push(`No LCP image prioritized with fetchpriority="high"`);
95
+ }
96
+ const responsiveCoverage = images.length > 0
97
+ ? Math.round((responsiveImages / images.length) * 100)
98
+ : 100;
99
+ // Base score (existing formula using only truly missing alt)
100
+ const baseIssueCount = missingAlt + missingDimensions + oversized.length;
101
+ const baseScore = images.length === 0
102
+ ? 100
103
+ : Math.max(0, Math.round(100 - (baseIssueCount / (images.length * 3)) * 100));
104
+ // Adjustments
105
+ let adjustedScore = baseScore;
106
+ // Penalize extreme aspect ratios
107
+ if (ratioIssues.length > 0) {
108
+ adjustedScore = Math.max(0, adjustedScore - Math.min(10, ratioIssues.length * 2));
109
+ }
110
+ // Penalize SVG accessibility issues
111
+ const svgIssues = pd.svgMissingTitle ?? 0;
112
+ if (svgIssues > 0) {
113
+ // Cap SVG penalty at 15 — many inline icon SVGs are intentional
114
+ adjustedScore = Math.max(0, adjustedScore - Math.min(15, svgIssues * 3));
115
+ }
116
+ // Bonus for good responsive coverage
117
+ if (responsiveCoverage > 75) {
118
+ adjustedScore = Math.min(100, adjustedScore + 5);
119
+ }
120
+ // Bonus for proper fetchpriority usage
121
+ if (fetchpriorityHighCount >= 1) {
122
+ adjustedScore = Math.min(100, adjustedScore + 3);
123
+ }
124
+ // Bonus for decoding='async' adoption
125
+ const totalWithDecoding = decodingStats.async + decodingStats.sync + decodingStats.auto + decodingStats.none;
126
+ if (totalWithDecoding > 0 && decodingStats.async / totalWithDecoding > 0.5) {
127
+ adjustedScore = Math.min(100, adjustedScore + 2);
128
+ }
129
+ else if (decodingStats.async > 0) {
130
+ adjustedScore = Math.min(100, adjustedScore + 1);
131
+ }
132
+ // Penalize SVG <img> missing alt
133
+ if (svgImgMissingAlt > 0) {
134
+ adjustedScore = Math.max(0, adjustedScore - Math.min(10, svgImgMissingAlt * 2));
38
135
  }
39
136
  return {
40
- url: url ?? pageData.url,
137
+ url: url ?? pd.url,
138
+ score: adjustedScore,
41
139
  totalImages: images.length,
42
140
  missingAlt,
43
141
  missingDimensions,
@@ -45,5 +143,55 @@ export function auditImages(html, url) {
45
143
  modernFormats,
46
144
  oversized,
47
145
  images,
146
+ pictureCount: pd.pictureCount,
147
+ svgCount: pd.svgCount,
148
+ svgMissingTitle: pd.svgMissingTitle,
149
+ responsiveImages,
150
+ responsiveCoverage,
151
+ fetchpriorityHighCount,
152
+ fetchpriorityIssues: fetchpriorityIssues.length > 0 ? fetchpriorityIssues : undefined,
153
+ decorativeCount,
154
+ ratioIssues: ratioIssues.length > 0 ? ratioIssues : undefined,
155
+ decodingStats,
156
+ svgImgMissingAlt: svgImgMissingAlt > 0 ? svgImgMissingAlt : undefined,
157
+ dimensionHintIssues: dimensionHintIssues.length > 0 ? dimensionHintIssues : undefined,
158
+ decorativeWithAltIssues: decorativeWithAltIssues > 0 ? decorativeWithAltIssues : undefined,
48
159
  };
49
160
  }
161
+ export async function checkImageUrls(images, config) {
162
+ const MAX_IMAGES = 20;
163
+ const CONCURRENCY = 5;
164
+ const TIMEOUT_MS = 5000;
165
+ const brokenUrls = [];
166
+ const targetImages = images.slice(0, MAX_IMAGES);
167
+ for (let i = 0; i < targetImages.length; i += CONCURRENCY) {
168
+ const batch = targetImages.slice(i, i + CONCURRENCY);
169
+ const results = await Promise.allSettled(batch.map(async (img) => {
170
+ const controller = new AbortController();
171
+ const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
172
+ try {
173
+ const res = await fetch(img.src, {
174
+ method: "HEAD",
175
+ signal: controller.signal,
176
+ headers: { "User-Agent": config.userAgent },
177
+ });
178
+ if (res.status < 200 || res.status >= 400) {
179
+ return { url: img.src, broken: true };
180
+ }
181
+ return { url: img.src, broken: false };
182
+ }
183
+ catch {
184
+ return { url: img.src, broken: true };
185
+ }
186
+ finally {
187
+ clearTimeout(timeoutId);
188
+ }
189
+ }));
190
+ for (const result of results) {
191
+ if (result.status === "fulfilled" && result.value.broken) {
192
+ brokenUrls.push(result.value.url);
193
+ }
194
+ }
195
+ }
196
+ return { brokenUrls };
197
+ }
@@ -1,6 +1,6 @@
1
- import type { PerformanceResult, PageWeightResult, RenderBlockingResult, FontAuditResult, FetchResult } from "../types.js";
2
- export declare function calculatePerformanceFromFetch(fetchResult: FetchResult, url: string): PerformanceResult;
3
- export declare function analyzePageWeight(fetchResult: FetchResult, url: string): PageWeightResult;
1
+ import type { MeasuredPerformance, PerformanceResult, PageWeightResult, RenderBlockingResult, FontAuditResult, FetchResult } from "../types.js";
2
+ export declare function calculatePerformanceFromFetch(fetchResult: FetchResult, url: string, measuredPerformance?: MeasuredPerformance): PerformanceResult;
3
+ export declare function analyzePageWeight(fetchResult: FetchResult, url: string, measuredPerformance?: MeasuredPerformance): PageWeightResult;
4
4
  export declare function analyzeRenderBlocking(fetchResult: FetchResult, url: string): RenderBlockingResult;
5
5
  export declare function analyzeFonts(fetchResult: FetchResult, url: string): FontAuditResult;
6
6
  //# sourceMappingURL=performance.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"performance.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/performance.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAS3H,wBAAgB,6BAA6B,CAC3C,WAAW,EAAE,WAAW,EACxB,GAAG,EAAE,MAAM,GACV,iBAAiB,CA0EnB;AAiCD,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,GAAG,gBAAgB,CA6CzF;AAED,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,GAAG,oBAAoB,CAgDjG;AAED,wBAAgB,YAAY,CAAC,WAAW,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,GAAG,eAAe,CAyEnF"}
1
+ {"version":3,"file":"performance.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/performance.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAShJ,wBAAgB,6BAA6B,CAC3C,WAAW,EAAE,WAAW,EACxB,GAAG,EAAE,MAAM,EACX,mBAAmB,CAAC,EAAE,mBAAmB,GACxC,iBAAiB,CA8GnB;AA8CD,wBAAgB,iBAAiB,CAC/B,WAAW,EAAE,WAAW,EACxB,GAAG,EAAE,MAAM,EACX,mBAAmB,CAAC,EAAE,mBAAmB,GACxC,gBAAgB,CA4ElB;AAED,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,GAAG,oBAAoB,CAgDjG;AAED,wBAAgB,YAAY,CAAC,WAAW,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,GAAG,eAAe,CAyEnF"}
@@ -4,10 +4,39 @@ const CLS_GOOD = 0.1;
4
4
  const CLS_POOR = 0.25;
5
5
  const TBT_GOOD = 200;
6
6
  const TBT_POOR = 600;
7
- export function calculatePerformanceFromFetch(fetchResult, url) {
7
+ export function calculatePerformanceFromFetch(fetchResult, url, measuredPerformance) {
8
8
  const content = fetchResult.content;
9
9
  const headers = fetchResult.headers;
10
- // Estimate metrics from available data
10
+ // If real browser measurements are available, use them.
11
+ if (measuredPerformance) {
12
+ const lcp = measuredPerformance.lcp ?? { value: 0, rating: "poor" };
13
+ const cls = measuredPerformance.cls ?? { value: 0, rating: "good" };
14
+ const tbt = measuredPerformance.tbt ?? { value: 0, rating: "good" };
15
+ const fcp = measuredPerformance.fcp ?? { value: 0, rating: "poor" };
16
+ const ttfb = measuredPerformance.ttfb ?? { value: 0, rating: "poor" };
17
+ const score = calculateCWVScore(lcp.rating, cls.rating, tbt.rating);
18
+ const result = {
19
+ url,
20
+ lcp: { value: lcp.value, rating: lcp.rating, element: lcp.element },
21
+ cls: { value: cls.value, rating: cls.rating },
22
+ tbt: { value: tbt.value, rating: tbt.rating },
23
+ fcp: { value: fcp.value, rating: fcp.rating },
24
+ ttfb: { value: ttfb.value, rating: ttfb.rating },
25
+ score,
26
+ source: "lab",
27
+ disclaimer: "Lab data measured in a single synthetic page load. This is not real-user (RUM) field data from Chrome UX Report.",
28
+ navigationTiming: measuredPerformance.navigationTiming,
29
+ longTasks: measuredPerformance.longTasks,
30
+ failedRequests: measuredPerformance.failedRequests,
31
+ layoutShiftCount: measuredPerformance.layoutShiftCount,
32
+ layoutShiftSources: measuredPerformance.layoutShiftSources,
33
+ };
34
+ if (measuredPerformance.navigationTiming) {
35
+ result.navigationTimingRatings = rateNavTiming(measuredPerformance.navigationTiming);
36
+ }
37
+ return result;
38
+ }
39
+ // Heuristic fallback: estimate metrics from HTML structure.
11
40
  const htmlSize = fetchResult.contentLengthBytes;
12
41
  const ttfbEstimate = estimateTTFB(headers);
13
42
  // Count resources to estimate complexity
@@ -71,16 +100,31 @@ export function calculatePerformanceFromFetch(fetchResult, url) {
71
100
  else
72
101
  tbtRating = "poor";
73
102
  // FCP estimate
74
- const fcp = Math.round(ttfbEstimate + 200 + Math.random() * 300);
103
+ const fcpValue = Math.round(ttfbEstimate + 200 + Math.random() * 300);
104
+ const fcpRating = fcpValue < 1800 ? "good" : fcpValue < 3000 ? "needs-improvement" : "poor";
105
+ const ttfbRating = ttfbEstimate < 800 ? "good" : ttfbEstimate < 1800 ? "needs-improvement" : "poor";
75
106
  const score = calculateCWVScore(lcpRating, clsRating, tbtRating);
76
107
  return {
77
108
  url,
78
109
  lcp: { value: lcpEstimate, rating: lcpRating },
79
110
  cls: { value: Number(clsEstimate.toFixed(3)), rating: clsRating },
80
111
  tbt: { value: tbtEstimate, rating: tbtRating },
81
- fcp,
82
- ttfb: ttfbEstimate,
112
+ fcp: { value: fcpValue, rating: fcpRating },
113
+ ttfb: { value: ttfbEstimate, rating: ttfbRating },
83
114
  score,
115
+ source: "heuristic",
116
+ };
117
+ }
118
+ function rateNavTiming(nav) {
119
+ if (!nav)
120
+ return undefined;
121
+ const rate = (v, good, poor) => v < good ? "good" : v < poor ? "needs-improvement" : "poor";
122
+ return {
123
+ dns: { value: nav.dnsMs, rating: rate(nav.dnsMs, 100, 500) },
124
+ tcp: { value: nav.tcpMs, rating: rate(nav.tcpMs, 100, 500) },
125
+ ssl: { value: nav.sslMs, rating: rate(nav.sslMs, 100, 500) },
126
+ ttfb: { value: nav.ttfbMs, rating: rate(nav.ttfbMs, 800, 1800) },
127
+ download: { value: nav.downloadMs, rating: rate(nav.downloadMs, 500, 2000) },
84
128
  };
85
129
  }
86
130
  function estimateTTFB(headers) {
@@ -109,7 +153,34 @@ function calculateCWVScore(lcp, cls, tbt) {
109
153
  score -= 25;
110
154
  return Math.max(0, score);
111
155
  }
112
- export function analyzePageWeight(fetchResult, url) {
156
+ export function analyzePageWeight(fetchResult, url, measuredPerformance) {
157
+ // If real browser measurements are available, use them for resource data.
158
+ if (measuredPerformance && (measuredPerformance.totalTransferKb !== undefined || measuredPerformance.topHeavyResources)) {
159
+ const totalKb = measuredPerformance.totalTransferKb ?? 0;
160
+ const resourceCount = measuredPerformance.resourceCount ?? 0;
161
+ const heavyResources = (measuredPerformance.topHeavyResources ?? []).map((r) => ({
162
+ url: r.url,
163
+ sizeKb: Math.round(r.transferSize / 1024),
164
+ type: r.type,
165
+ }));
166
+ // HTML size still comes from fetch result.
167
+ const htmlKb = Math.round(fetchResult.contentLengthBytes / 1024);
168
+ // Categorize by type from heavy resources list.
169
+ const byType = (type) => heavyResources.filter((r) => r.type === type).reduce((s, r) => s + r.sizeKb, 0);
170
+ return {
171
+ url: fetchResult.finalUrl || url,
172
+ totalKb,
173
+ htmlKb,
174
+ cssKb: byType("link"),
175
+ jsKb: byType("script"),
176
+ imageKb: byType("img"),
177
+ fontKb: byType("font"),
178
+ otherKb: 0,
179
+ resourceCount,
180
+ topHeavyResources: heavyResources,
181
+ };
182
+ }
183
+ // Heuristic fallback.
113
184
  const content = fetchResult.content;
114
185
  const htmlKb = Math.round(fetchResult.contentLengthBytes / 1024);
115
186
  // Estimate CSS size
@@ -1 +1 @@
1
- {"version":3,"file":"readability.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/readability.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAuCrD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,iBAAiB,CAyEhF;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,CAwBtE"}
1
+ {"version":3,"file":"readability.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/readability.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAgDrD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,iBAAiB,CA0DhF;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,CAqBtE"}
@@ -1,6 +1,21 @@
1
+ const ABBREVIATIONS = new Set([
2
+ "mr", "mrs", "ms", "dr", "prof", "sr", "jr", "st", "ave", "blvd",
3
+ "etc", "vs", "approx", "dept", "est", "eg", "ie", "inc", "ltd",
4
+ "co", "corp", "jan", "feb", "mar", "apr", "jun", "jul", "aug",
5
+ "sep", "oct", "nov", "dec",
6
+ ]);
1
7
  function countSentences(text) {
2
- const matches = text.match(/[.!?]+/g);
3
- return matches ? matches.length : 1;
8
+ if (!text)
9
+ return 1;
10
+ let processed = text;
11
+ for (const abbr of ABBREVIATIONS) {
12
+ const escaped = abbr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13
+ const re = new RegExp(`\\b${escaped}\\.`, "gi");
14
+ processed = processed.replace(re, (match) => match.replace(".", "##DOT##"));
15
+ }
16
+ const sentences = processed.split(/(?<=[.!?])\s+(?=[A-Z])/);
17
+ const count = sentences.length;
18
+ return count > 0 ? count : 1;
4
19
  }
5
20
  function countSyllables(word) {
6
21
  const lower = word.toLowerCase().replace(/[^a-z]/g, "");
@@ -16,10 +31,8 @@ function countSyllables(word) {
16
31
  }
17
32
  prevWasVowel = isVowel;
18
33
  }
19
- // Silent e at end
20
34
  if (lower.endsWith("e") && count > 1)
21
35
  count--;
22
- // le at end adds a syllable
23
36
  if (lower.endsWith("le") && lower.length > 3)
24
37
  count++;
25
38
  return Math.max(1, count);
@@ -59,17 +72,12 @@ export function analyzeReadability(text, url) {
59
72
  if (isComplexWord(clean))
60
73
  complexWordCount++;
61
74
  }
62
- // Flesch Reading Ease
63
75
  const fleschReadingEase = Number((206.835 - 1.015 * (wordCount / sentenceCount) - 84.6 * (totalSyllables / wordCount)).toFixed(1));
64
- // Flesch-Kincaid Grade Level
65
76
  const fleschKincaid = Number((0.39 * (wordCount / sentenceCount) + 11.8 * (totalSyllables / wordCount) - 15.59).toFixed(1));
66
- // SMOG Index
67
77
  const smog = complexWordCount > 0
68
78
  ? Number((1.043 * Math.sqrt(complexWordCount * (30 / sentenceCount)) + 3.1291).toFixed(1))
69
79
  : 0;
70
- // Automated Readability Index (ARI)
71
80
  const ari = Number((4.71 * (totalLetters / wordCount) + 0.5 * (wordCount / sentenceCount) - 21.43).toFixed(1));
72
- // Coleman-Liau Index
73
81
  const L = (totalLetters / wordCount) * 100;
74
82
  const S = (sentenceCount / wordCount) * 100;
75
83
  const colemanLiau = Number((0.0588 * L - 0.296 * S - 15.8).toFixed(1));
@@ -1 +1 @@
1
- {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/schema.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAe,MAAM,aAAa,CAAC;AA2FlE,qCAAqC;AACrC,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,iBAAiB,CA2DzE;AAID,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,cAAc,GAAG,eAAe,GAAG,SAAS,GAAG,aAAa,GAAG,SAAS,GAC5E,gBAAgB,GAAG,SAAS,GAAG,QAAQ,GAAG,aAAa,GAAG,wBAAwB,GAClF,aAAa,GAAG,aAAa,CAAC;IAChC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,sDAAsD;AACtD,wBAAgB,cAAc,CAAC,MAAM,EAAE,oBAAoB,GAAG;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CAsMlH"}
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/schema.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAe,MAAM,aAAa,CAAC;AAgKlE,qCAAqC;AACrC,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,iBAAiB,CAmFzE;AAID,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,cAAc,GAAG,eAAe,GAAG,SAAS,GAAG,aAAa,GAAG,SAAS,GAC5E,gBAAgB,GAAG,SAAS,GAAG,QAAQ,GAAG,aAAa,GAAG,wBAAwB,GAClF,aAAa,GAAG,aAAa,CAAC;IAChC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,sDAAsD;AACtD,wBAAgB,cAAc,CAAC,MAAM,EAAE,oBAAoB,GAAG;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CAsMlH"}