seo 0.17.0 → 0.17.1

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/mcp.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
2
- import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-CkObTzDg.js";
2
+ import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-G8B5TPzB.js";
3
3
  import { createHash, randomUUID, sign } from "node:crypto";
4
4
  import { ZodOptional, z } from "zod";
5
5
  import { arch, homedir, platform, tmpdir, totalmem } from "node:os";
@@ -12190,6 +12190,20 @@ const RULE_DEFINITIONS = [
12190
12190
  suggestedCommands: ["seo audit-page --url <url> --json"]
12191
12191
  }
12192
12192
  },
12193
+ {
12194
+ id: "empty_title",
12195
+ title: "Title empty",
12196
+ category: "metadata",
12197
+ defaultSeverity: "high",
12198
+ whyItMatters: "The page has a <title> element with no text. Search engines treat it like a missing title and write their own headline for the result.",
12199
+ howToFix: "Fill the title element with a descriptive title that names the page topic. An empty title usually means a template field or CMS value is blank.",
12200
+ impactIfIgnored: "The page can look vague in search results, earn lower CTR, and send weaker relevance signals.",
12201
+ howToVerify: "Re-run the crawl and confirm the title element has text.",
12202
+ agentHints: {
12203
+ evidenceFields: ["page.title"],
12204
+ suggestedCommands: ["seo crawl <url> --max-pages 1 --json"]
12205
+ }
12206
+ },
12193
12207
  {
12194
12208
  id: "title_too_wide",
12195
12209
  title: "Title may truncate on some devices",
@@ -12678,6 +12692,16 @@ const RULE_DEFINITIONS = [
12678
12692
  impactIfIgnored: "Search snippets may be generic or less persuasive, especially on pages that already rank.",
12679
12693
  howToVerify: "Re-run the crawl and confirm the page has a meta description."
12680
12694
  },
12695
+ {
12696
+ id: "empty_meta_description",
12697
+ title: "Meta description empty",
12698
+ category: "metadata",
12699
+ defaultSeverity: "medium",
12700
+ whyItMatters: "The page has a meta description tag with no content, so search engines choose their own snippet. An empty tag usually means a template field or CMS value is blank.",
12701
+ howToFix: "Fill the content attribute with a concise, unique description, or fix the template value that leaves it blank.",
12702
+ impactIfIgnored: "Search snippets may be generic or less persuasive, especially on pages that already rank.",
12703
+ howToVerify: "Re-run the crawl and confirm the meta description has content."
12704
+ },
12681
12705
  {
12682
12706
  id: "meta_description_duplicate",
12683
12707
  title: "Duplicate meta description",
@@ -13779,6 +13803,27 @@ var GoogleApiError = class extends SeoError {
13779
13803
  this.status = status;
13780
13804
  }
13781
13805
  };
13806
+ const URL_INSPECTION_TIMEOUT_MS = 6e4;
13807
+ async function withinGoogleTimeout(what, timeoutMs, signal, run) {
13808
+ const controller = new AbortController();
13809
+ const fromCaller = () => controller.abort(signal?.reason);
13810
+ if (signal?.aborted) fromCaller();
13811
+ else signal?.addEventListener("abort", fromCaller, { once: true });
13812
+ let timer;
13813
+ const timedOut = new Promise((_, reject) => {
13814
+ timer = setTimeout(() => {
13815
+ const error = new SeoError("PROVIDER_UNAVAILABLE", `${what} did not answer within ${timeoutMs / 1e3} seconds, so the request was stopped. Try again later.`);
13816
+ reject(error);
13817
+ controller.abort(error);
13818
+ }, timeoutMs);
13819
+ });
13820
+ try {
13821
+ return await Promise.race([run(controller.signal), timedOut]);
13822
+ } finally {
13823
+ clearTimeout(timer);
13824
+ signal?.removeEventListener("abort", fromCaller);
13825
+ }
13826
+ }
13782
13827
  function bearerFetch(token) {
13783
13828
  return async (url, init) => fetch(url, {
13784
13829
  ...init,
@@ -13807,12 +13852,15 @@ async function failure(response, label, subject, options) {
13807
13852
  return new GoogleApiError("PROVIDER_UNAVAILABLE", `${label} answered ${status}. ${detail}`.trim(), status);
13808
13853
  }
13809
13854
  async function googleJson(gfetch, url, input) {
13810
- const response = await gfetch(url, {
13811
- method: input.body === void 0 ? "GET" : "POST",
13812
- body: input.body === void 0 ? void 0 : JSON.stringify(input.body)
13855
+ return withinGoogleTimeout(input.subject ? `${input.label} for ${input.subject}` : input.label, input.timeoutMs ?? 3e4, void 0, async (signal) => {
13856
+ const response = await gfetch(url, {
13857
+ method: input.body === void 0 ? "GET" : "POST",
13858
+ body: input.body === void 0 ? void 0 : JSON.stringify(input.body),
13859
+ signal
13860
+ });
13861
+ if (!response.ok) throw await failure(response, input.label, input.subject, input);
13862
+ return readJson(response, input.label, { maxBytes: input.maxBytes });
13813
13863
  });
13814
- if (!response.ok) throw await failure(response, input.label, input.subject, input);
13815
- return readJson(response, input.label, { maxBytes: input.maxBytes });
13816
13864
  }
13817
13865
  const GSC = "https://www.googleapis.com/webmasters/v3";
13818
13866
  const PAGE_ROWS$1 = 25e3;
@@ -13864,6 +13912,7 @@ async function inspectSearchConsoleUrl(gfetch, site, url, options) {
13864
13912
  ...options,
13865
13913
  label: "URL Inspection",
13866
13914
  subject: url,
13915
+ timeoutMs: URL_INSPECTION_TIMEOUT_MS,
13867
13916
  body: {
13868
13917
  siteUrl: site,
13869
13918
  inspectionUrl: url,
@@ -18567,6 +18616,10 @@ function safeText$1(value) {
18567
18616
  const trimmed = value?.replace(/\s+/g, " ").trim();
18568
18617
  return trimmed ? trimmed : void 0;
18569
18618
  }
18619
+ function tagText(element, read) {
18620
+ if (!element.length) return void 0;
18621
+ return read(element)?.replace(/\s+/g, " ").trim() ?? "";
18622
+ }
18570
18623
  function headerValue$3(headers, name) {
18571
18624
  const target = name.toLowerCase();
18572
18625
  return Object.entries(headers).find(([key]) => key.toLowerCase() === target)?.[1];
@@ -18835,14 +18888,14 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
18835
18888
  html: sanitizedContentHtml($, fetchResult.html, fetchResult.finalUrl, streamedSegments.resolved > 0)
18836
18889
  }, extractor, dependencies);
18837
18890
  const { text, excerpt } = content;
18838
- const title = safeText$1($("title").first().text());
18891
+ const title = tagText($("title").first(), (element) => element.text());
18839
18892
  const crawlerMetaRefresh = extractor === "crawler" ? metaRefresh($, fetchResult.finalUrl) : void 0;
18840
18893
  if (crawlerMetaRefresh && new URL(crawlerMetaRefresh.targetUrl).origin === url.origin) crawlerInternalLinks.add(crawlerMetaRefresh.targetUrl);
18841
18894
  return {
18842
18895
  url: fetchResult.url,
18843
18896
  finalUrl: fetchResult.finalUrl,
18844
18897
  title,
18845
- metaDescription: safeText$1($("meta[name=\"description\"]").attr("content")),
18898
+ metaDescription: tagText($("meta[name=\"description\"]").first(), (element) => element.attr("content")),
18846
18899
  metaRobots,
18847
18900
  xRobotsTag: safeText$1(headerValue$3(fetchResult.headers, "x-robots-tag")),
18848
18901
  canonical: canonicalEvidence.selectedRaw,
@@ -19200,31 +19253,172 @@ function recordHostFetch(input) {
19200
19253
  }
19201
19254
  return snapshotForHost(input.host);
19202
19255
  }
19203
- // packages/core/dist/fetch/page-fetcher/redirects.js
19204
- async function fetchWithRedirectChain(url, signal, fetch = publicHttpFetch) {
19205
- const redirectChain = [];
19206
- let currentUrl = url;
19207
- for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
19208
- const response = await fetch(currentUrl, {
19256
+ const REDIRECT_STATUSES = /* @__PURE__ */ new Set([
19257
+ 301,
19258
+ 302,
19259
+ 303,
19260
+ 307,
19261
+ 308
19262
+ ]);
19263
+ function errorText$1(error) {
19264
+ if (!(error instanceof Error)) return String(error);
19265
+ const cause = error.cause;
19266
+ const detail = typeof cause?.code === "string" ? cause.code : typeof cause?.message === "string" ? cause.message : "";
19267
+ return detail && !error.message.includes(detail) ? `${error.message} (${detail})` : error.message;
19268
+ }
19269
+ function httpUrl(value, base) {
19270
+ try {
19271
+ const url = new URL(value, base);
19272
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
19273
+ url.hash = "";
19274
+ return url.toString();
19275
+ } catch {
19276
+ return null;
19277
+ }
19278
+ }
19279
+ async function traceRedirects(url, options = {}) {
19280
+ const request = options.fetch ?? ((to, init) => fetch(to, init));
19281
+ const { trace, response } = await followRedirects(url, {
19282
+ maxHops: options.maxHops,
19283
+ timeoutMs: options.timeoutMs,
19284
+ onResponse: options.onResponse,
19285
+ request: (to, signal) => request(to, {
19209
19286
  redirect: "manual",
19287
+ headers: {
19288
+ "user-agent": SEO_CRAWLER_USER_AGENT,
19289
+ accept: "text/html,application/xhtml+xml,*/*;q=0.8"
19290
+ },
19210
19291
  signal
19211
- });
19212
- const location = response.headers.get("location");
19213
- if (response.status < 300 || response.status >= 400 || !location) return {
19214
- response,
19215
- redirectChain
19216
- };
19217
- const nextUrl = new URL(location, currentUrl).toString();
19218
- redirectChain.push({
19219
- url: currentUrl,
19220
- status: response.status,
19221
- location: nextUrl
19222
- });
19223
- await response.body?.cancel().catch(() => void 0);
19224
- currentUrl = nextUrl;
19292
+ })
19293
+ });
19294
+ await response?.body?.cancel().catch(() => void 0);
19295
+ return trace;
19296
+ }
19297
+ async function followRedirects(url, options) {
19298
+ const maxHops = Math.min(Math.max(Math.trunc(options.maxHops ?? 10), 0), 10);
19299
+ const timeoutMs = options.timeoutMs ?? 1e4;
19300
+ const steps = [];
19301
+ const end = (final_url, final_status, outcome, error, extra = {}) => ({
19302
+ trace: {
19303
+ url,
19304
+ steps,
19305
+ hops: steps.filter((step) => step.location !== null).length,
19306
+ final_url,
19307
+ final_status,
19308
+ outcome,
19309
+ ...error ? { error } : {}
19310
+ },
19311
+ response: extra.response ?? null,
19312
+ ..."error" in extra ? { error: extra.error } : {}
19313
+ });
19314
+ const start = httpUrl(url);
19315
+ if (!start) return end(url, null, "error", "Not an http or https URL.");
19316
+ let current = start;
19317
+ const seen = /* @__PURE__ */ new Set();
19318
+ while (true) {
19319
+ seen.add(current);
19320
+ const startedAt = Date.now();
19321
+ const hop = new AbortController();
19322
+ let timedOut = false;
19323
+ const timer = setTimeout(() => {
19324
+ timedOut = true;
19325
+ hop.abort();
19326
+ }, timeoutMs);
19327
+ const signal = options.signal ? AbortSignal.any([options.signal, hop.signal]) : hop.signal;
19328
+ try {
19329
+ let response;
19330
+ try {
19331
+ response = await options.request(current, signal);
19332
+ } catch (error) {
19333
+ if (options.signal?.aborted) throw error;
19334
+ return timedOut || error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError") ? end(current, null, "timeout", `No response within ${timeoutMs} ms.`, { error }) : end(current, null, "error", errorText$1(error), { error });
19335
+ }
19336
+ const ms = Date.now() - startedAt;
19337
+ const header = response.headers.get("location");
19338
+ const redirects = REDIRECT_STATUSES.has(response.status) && header !== null;
19339
+ const location = redirects ? httpUrl(header, current) : null;
19340
+ if (options.onResponse) await options.onResponse(response, current);
19341
+ steps.push({
19342
+ url: current,
19343
+ status: response.status,
19344
+ location,
19345
+ ms
19346
+ });
19347
+ if (!redirects) return end(current, response.status, "done", "", { response });
19348
+ await response.body?.cancel().catch(() => void 0);
19349
+ if (!location) return end(current, response.status, "error", `The Location header is not an http or https URL: ${header.slice(0, 200)}`);
19350
+ if (seen.has(location)) return end(current, response.status, "loop");
19351
+ if (steps.length > maxHops) return end(current, response.status, "too_many");
19352
+ current = location;
19353
+ } finally {
19354
+ clearTimeout(timer);
19355
+ }
19356
+ }
19357
+ }
19358
+ // packages/core/dist/fetch/page-fetcher/redirects.js
19359
+ var RedirectChainError = class extends Error {
19360
+ kind;
19361
+ url;
19362
+ redirectChain;
19363
+ constructor(kind, url, redirectChain, message, options) {
19364
+ super(message, options);
19365
+ this.kind = kind;
19366
+ this.url = url;
19367
+ this.redirectChain = redirectChain;
19368
+ this.name = "RedirectChainError";
19225
19369
  }
19226
- throw new Error(`Too many redirects for ${url}`);
19370
+ };
19371
+ async function fetchWithRedirectChain(url, signal, fetch = publicHttpFetch, options = {}) {
19372
+ const followed = await followRedirects(url, {
19373
+ signal,
19374
+ timeoutMs: options.timeoutMs ?? 1e4,
19375
+ request: (to, hopSignal) => fetch(to, {
19376
+ redirect: "manual",
19377
+ signal: hopSignal
19378
+ })
19379
+ });
19380
+ const redirectChain = followed.trace.steps.flatMap(({ url: from, status, location }) => location === null ? [] : [{
19381
+ url: from,
19382
+ status,
19383
+ location
19384
+ }]);
19385
+ const { trace, response } = followed;
19386
+ if (trace.outcome === "done" && response) return {
19387
+ response,
19388
+ redirectChain
19389
+ };
19390
+ const kind = trace.outcome === "loop" || trace.outcome === "too_many" || trace.outcome === "timeout" ? trace.outcome : "network";
19391
+ throw new RedirectChainError(kind, url, redirectChain, kind === "loop" ? `Redirect loop for ${url}: ${trace.final_url} redirects back to a URL already requested.` : kind === "too_many" ? `Too many redirects for ${url}: still redirecting after 10 redirects.` : trace.error ?? "The request failed.", "error" in followed ? { cause: followed.error } : void 0);
19392
+ }
19393
+ function redirectsWillRepeat(error) {
19394
+ return error instanceof RedirectChainError && (error.kind === "loop" || error.kind === "too_many");
19395
+ }
19396
+ // packages/core/dist/fetch/page-fetcher/robots-evidence.js
19397
+ function pageRobotsEvidence(robots) {
19398
+ return {
19399
+ url: robots.url,
19400
+ allowed: robots.allowed,
19401
+ availability: robots.availability,
19402
+ status: robots.status,
19403
+ error: robots.error,
19404
+ matchedLine: robots.matchedLine
19405
+ };
19406
+ }
19407
+ function diagnosticRobotsEvidence(robots) {
19408
+ return {
19409
+ url: robots.url,
19410
+ cache: robots.cache,
19411
+ allowed: robots.allowed,
19412
+ availability: robots.availability,
19413
+ status: robots.status,
19414
+ error: robots.error
19415
+ };
19416
+ }
19417
+ function robotsTxtIsHtml(contentType, text) {
19418
+ const body = text.replace(/^\uFEFF/, "").trimStart();
19419
+ return body.startsWith("<") || /\btext\/html\b/i.test(contentType) && !/^\s*(?:user-agent|allow|disallow|sitemap)\s*:/im.test(body);
19227
19420
  }
19421
+ const ROBOTS_TXT_HTML_ERROR = "robots.txt returned a web page, not a robots.txt file, so its rules are unknown.";
19228
19422
  // packages/core/dist/fetch/page-fetcher/robots.js
19229
19423
  const MAX_ROBOTS_RESPONSE_BYTES = 1048576;
19230
19424
  var RobotsAccessError = class extends Error {
@@ -19244,7 +19438,16 @@ function cacheableStatus(status) {
19244
19438
  return status >= 200 && status < 300 || status >= 400 && status < 500 && status !== 429;
19245
19439
  }
19246
19440
  function resultFromResponse(input) {
19247
- if (input.status >= 200 && input.status < 300) {
19441
+ const ok = input.status >= 200 && input.status < 300;
19442
+ if (ok && robotsTxtIsHtml(input.contentType, input.text)) return {
19443
+ allowed: true,
19444
+ availability: "unreadable",
19445
+ status: input.status,
19446
+ error: ROBOTS_TXT_HTML_ERROR,
19447
+ cache: input.cache,
19448
+ url: input.robotsUrl
19449
+ };
19450
+ if (ok) {
19248
19451
  const parsed = parseRobots$1(input.robotsUrl, input.text);
19249
19452
  const lineNumber = parsed.getMatchingLineNumber?.(input.targetUrl, SEO_CRAWLER_USER_AGENT);
19250
19453
  const matchedLine = lineNumber ? input.text.split(/\r?\n/)[lineNumber - 1]?.trim() : void 0;
@@ -19278,6 +19481,7 @@ function resultFromSource(source, targetUrl) {
19278
19481
  robotsUrl: source.robotsUrl,
19279
19482
  targetUrl,
19280
19483
  status: source.status,
19484
+ contentType: source.contentType ?? "",
19281
19485
  text: source.text,
19282
19486
  cache: source.cache
19283
19487
  });
@@ -19312,7 +19516,8 @@ async function fetchRobotsSource(origin, refresh, options) {
19312
19516
  signal: controller.signal
19313
19517
  });
19314
19518
  const text = await readBoundedResponseText(response, MAX_ROBOTS_RESPONSE_BYTES, "robots.txt response");
19315
- if (options.writeCache !== false && cacheableStatus(response.status)) writeToCache((database) => {
19519
+ const contentType = response.headers.get("content-type") ?? "";
19520
+ if (options.writeCache !== false && cacheableStatus(response.status) && !(response.status >= 200 && response.status < 300 && robotsTxtIsHtml(contentType, text))) writeToCache((database) => {
19316
19521
  database.prepare(`INSERT OR REPLACE INTO http_cache
19317
19522
  (url_hash, url, status, headers_json, body_blob, etag, fetched_at, expires_at)
19318
19523
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(key, robotsUrl, response.status, "{}", Buffer.from(text), null, Date.now(), Date.now() + 864e5);
@@ -19321,6 +19526,7 @@ async function fetchRobotsSource(origin, refresh, options) {
19321
19526
  return {
19322
19527
  robotsUrl,
19323
19528
  status: response.status,
19529
+ contentType,
19324
19530
  text,
19325
19531
  cache: refresh ? "bypass" : "miss"
19326
19532
  };
@@ -19376,27 +19582,6 @@ function createRobotsSession(input = {}) {
19376
19582
  }
19377
19583
  };
19378
19584
  }
19379
- // packages/core/dist/fetch/page-fetcher/robots-evidence.js
19380
- function pageRobotsEvidence(robots) {
19381
- return {
19382
- url: robots.url,
19383
- allowed: robots.allowed,
19384
- availability: robots.availability,
19385
- status: robots.status,
19386
- error: robots.error,
19387
- matchedLine: robots.matchedLine
19388
- };
19389
- }
19390
- function diagnosticRobotsEvidence(robots) {
19391
- return {
19392
- url: robots.url,
19393
- cache: robots.cache,
19394
- allowed: robots.allowed,
19395
- availability: robots.availability,
19396
- status: robots.status,
19397
- error: robots.error
19398
- };
19399
- }
19400
19585
  // packages/core/dist/fetch/page-fetcher/plain.js
19401
19586
  const MAX_PAGE_RESPONSE_BYTES = 5242880;
19402
19587
  function encodePageFetchCacheEvidence(result) {
@@ -19478,8 +19663,9 @@ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, r
19478
19663
  if (signal?.aborted) controller.abort();
19479
19664
  else signal?.addEventListener("abort", abort, { once: true });
19480
19665
  try {
19481
- return await fetchWithRedirectChain(url, controller.signal);
19666
+ return await fetchWithRedirectChain(url, controller.signal, void 0, { timeoutMs });
19482
19667
  } catch (error) {
19668
+ if (redirectsWillRepeat(error)) throw new AbortError(error);
19483
19669
  if (error instanceof Error && /4\d\d/.test(error.message)) throw new AbortError(error);
19484
19670
  throw error;
19485
19671
  } finally {
@@ -20056,8 +20242,9 @@ async function statusProbe(url, opts) {
20056
20242
  if (opts.signal?.aborted) controller.abort();
20057
20243
  else opts.signal?.addEventListener("abort", abort, { once: true });
20058
20244
  try {
20059
- return await fetchWithRedirectChain(url, controller.signal);
20245
+ return await fetchWithRedirectChain(url, controller.signal, void 0, { timeoutMs: opts.timeoutMs });
20060
20246
  } catch (error) {
20247
+ if (redirectsWillRepeat(error)) throw new AbortError(error);
20061
20248
  if (opts.signal?.aborted) throw new AbortError(error instanceof Error ? error : new Error(String(error)));
20062
20249
  throw error;
20063
20250
  } finally {
@@ -20435,38 +20622,40 @@ async function inspectUrl(input) {
20435
20622
  property: input.siteUrl,
20436
20623
  limit: input.quotaLimit
20437
20624
  });
20438
- let response;
20439
- try {
20440
- response = await authedFetch(client, "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect", {
20441
- method: "POST",
20442
- body: JSON.stringify({
20443
- siteUrl: input.siteUrl,
20444
- inspectionUrl,
20445
- ...input.languageCode ? { languageCode: input.languageCode } : {}
20446
- }),
20447
- signal: input.signal
20448
- });
20449
- } catch (error) {
20625
+ return withinGoogleTimeout(`URL Inspection for ${inspectionUrl}`, URL_INSPECTION_TIMEOUT_MS, input.signal, async (signal) => {
20626
+ let response;
20627
+ try {
20628
+ response = await authedFetch(client, "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect", {
20629
+ method: "POST",
20630
+ body: JSON.stringify({
20631
+ siteUrl: input.siteUrl,
20632
+ inspectionUrl,
20633
+ ...input.languageCode ? { languageCode: input.languageCode } : {}
20634
+ }),
20635
+ signal
20636
+ });
20637
+ } catch (error) {
20638
+ finalize(reservation, "consumed");
20639
+ if (error instanceof SeoError) throw error;
20640
+ throw new SeoError("PROVIDER_UNAVAILABLE", `URL Inspection request failed: ${error instanceof Error ? error.message : String(error)}`);
20641
+ }
20642
+ if (response.status === 429) {
20643
+ const now = /* @__PURE__ */ new Date();
20644
+ const blockedUntil = retryAfter$1(response, now);
20645
+ finalize(reservation, "exhausted", blockedUntil, now);
20646
+ throw new UrlInspectionQuotaError({
20647
+ property: input.siteUrl,
20648
+ resetAt: (blockedUntil ?? (reservation ? new Date(reservation.resetAt) : urlInspectionQuotaResetAt(now))).toISOString(),
20649
+ used: reservation ? reservation.used + reservation.count : 0,
20650
+ limit: reservation?.limit ?? 2e3,
20651
+ reason: "provider",
20652
+ requestSent: true
20653
+ });
20654
+ }
20450
20655
  finalize(reservation, "consumed");
20451
- if (error instanceof SeoError) throw error;
20452
- throw new SeoError("PROVIDER_UNAVAILABLE", `URL Inspection request failed: ${error instanceof Error ? error.message : String(error)}`);
20453
- }
20454
- if (response.status === 429) {
20455
- const now = /* @__PURE__ */ new Date();
20456
- const blockedUntil = retryAfter$1(response, now);
20457
- finalize(reservation, "exhausted", blockedUntil, now);
20458
- throw new UrlInspectionQuotaError({
20459
- property: input.siteUrl,
20460
- resetAt: (blockedUntil ?? (reservation ? new Date(reservation.resetAt) : urlInspectionQuotaResetAt(now))).toISOString(),
20461
- used: reservation ? reservation.used + reservation.count : 0,
20462
- limit: reservation?.limit ?? 2e3,
20463
- reason: "provider",
20464
- requestSent: true
20465
- });
20466
- }
20467
- finalize(reservation, "consumed");
20468
- if (!response.ok) throw inspectionResponseError(response, input);
20469
- return await readJson(response, "URL Inspection");
20656
+ if (!response.ok) throw inspectionResponseError(response, input);
20657
+ return await readJson(response, "URL Inspection");
20658
+ });
20470
20659
  }
20471
20660
  // packages/core/dist/gsc/client/search-analytics.js
20472
20661
  function searchDateRange(days) {
@@ -20826,7 +21015,7 @@ function buildRecommendations(report) {
20826
21015
  const page = report.page;
20827
21016
  if (!page.title) recommendations.push({
20828
21017
  principle: "C.2",
20829
- evidenceRef: "The HTML document has no title element.",
21018
+ evidenceRef: page.title === "" ? "The HTML document has an empty title element." : "The HTML document has no title element.",
20830
21019
  action: "Add a concise, descriptive title that identifies this page.",
20831
21020
  effort: "S",
20832
21021
  confidence: "high"
@@ -20855,7 +21044,15 @@ async function auditPage(input, dependencies = defaultDependencies$12) {
20855
21044
  const page = await extractPage(fetched, input.extractor);
20856
21045
  const h1s = page.headings.filter((heading) => heading.level === 1);
20857
21046
  const issues = [];
20858
- if (!page.title) issues.push({
21047
+ if (page.title === "") issues.push({
21048
+ code: "empty_title",
21049
+ title: "Title empty",
21050
+ detail: "The <title> element has no text.",
21051
+ principle: "C.2",
21052
+ evidenceRef: "HTML document has an empty <title> element.",
21053
+ severity: "high"
21054
+ });
21055
+ else if (!page.title) issues.push({
20859
21056
  code: "missing_title",
20860
21057
  title: "Title missing",
20861
21058
  detail: "No <title> element was found.",
@@ -23061,7 +23258,8 @@ function auditCrawlPages(pages, opts = {}) {
23061
23258
  const title = page.title?.trim();
23062
23259
  const titleWidth = title ? estimateSerpTitleWidth(title) : void 0;
23063
23260
  const duplicateTitle = duplicateTitles.get(normalizedMetadata(title) ?? "");
23064
- if (!title) issues.push(issue$1("missing_title", page));
23261
+ if (title === void 0) issues.push(issue$1("missing_title", page));
23262
+ else if (!title) issues.push(issue$1("empty_title", page));
23065
23263
  else if (titleWidth?.status === "over-reference") issues.push(issue$1("title_too_wide", page, `${titleWidth.estimatedPixels}px`, {
23066
23264
  title,
23067
23265
  ...titleWidth
@@ -23073,7 +23271,8 @@ function auditCrawlPages(pages, opts = {}) {
23073
23271
  }));
23074
23272
  const metaDescription = page.metaDescription?.trim();
23075
23273
  const duplicateDescription = duplicateDescriptions.get(normalizedMetadata(metaDescription) ?? "");
23076
- if (!metaDescription) issues.push(issue$1("missing_meta_description", page));
23274
+ if (metaDescription === void 0) issues.push(issue$1("missing_meta_description", page));
23275
+ else if (!metaDescription) issues.push(issue$1("empty_meta_description", page));
23077
23276
  if (metaDescription && duplicateDescription?.urls.has(page.url)) issues.push(issue$1("meta_description_duplicate", page, duplicateDescription.value, {
23078
23277
  metaDescription,
23079
23278
  duplicateCount: duplicateDescription.count,
@@ -23999,13 +24198,15 @@ const crawlAiAvailabilitySchema = z.enum([
23999
24198
  "absent",
24000
24199
  "access-blocked",
24001
24200
  "rate-limited",
24002
- "unreachable"
24201
+ "unreachable",
24202
+ "unreadable"
24003
24203
  ]);
24004
24204
  const crawlFetchErrorKindSchema = z.enum([
24005
24205
  "dns",
24006
24206
  "tls",
24007
24207
  "timeout",
24008
24208
  "redirect-limit",
24209
+ "redirect-loop",
24009
24210
  "too-large",
24010
24211
  "aborted",
24011
24212
  "unknown"
@@ -24178,7 +24379,8 @@ const crawlRobotsEvidenceSchema = z.object({
24178
24379
  "absent",
24179
24380
  "access-blocked",
24180
24381
  "rate-limited",
24181
- "unreachable"
24382
+ "unreachable",
24383
+ "unreadable"
24182
24384
  ]),
24183
24385
  status: z.number().int().optional(),
24184
24386
  error: z.string().optional(),
@@ -24372,7 +24574,8 @@ const crawlPageSnapshotSchema = z.object({
24372
24574
  "absent",
24373
24575
  "access-blocked",
24374
24576
  "rate-limited",
24375
- "unreachable"
24577
+ "unreachable",
24578
+ "unreadable"
24376
24579
  ]),
24377
24580
  status: z.number().int().optional(),
24378
24581
  error: z.string().optional()
@@ -24717,6 +24920,7 @@ const crawlRequestObservationSchema = z.union([
24717
24920
  "tls",
24718
24921
  "timeout",
24719
24922
  "redirect-limit",
24923
+ "redirect-loop",
24720
24924
  "aborted",
24721
24925
  "unknown"
24722
24926
  ]),
@@ -25071,7 +25275,7 @@ function agentReadyIssues(input) {
25071
25275
  const issues = [];
25072
25276
  const blocked = (ai.robotsTxt?.botAccess ?? []).filter((bot) => bot.purpose === "search" && bot.allowed === false);
25073
25277
  if (blocked.length && ai.robotsTxt) issues.push(issue("ai_search_bot_blocked", ai.robotsTxt.url, `robots.txt blocks ${blocked.map((bot) => bot.userAgent).join(", ")} from the start URL.`, { blocked: blocked.map((bot) => bot.userAgent) }));
25074
- if (ai.robotsTxt?.servesHtml) issues.push(issue("robots_txt_html", ai.robotsTxt.url, `/robots.txt returned an HTML page (HTTP ${ai.robotsTxt.status}, ${ai.robotsTxt.contentType}), so it has no rules or sitemap lines.`, {
25278
+ if (ai.robotsTxt?.servesHtml) issues.push(issue("robots_txt_html", ai.robotsTxt.url, `/robots.txt returned an HTML page (HTTP ${ai.robotsTxt.status}, ${ai.robotsTxt.contentType}), not a robots.txt file, so its rules for crawlers are unknown. Google treats it as a file with no valid rules, so the crawl went ahead as Google would.`, {
25075
25279
  status: ai.robotsTxt.status,
25076
25280
  contentType: ai.robotsTxt.contentType
25077
25281
  }));
@@ -25597,6 +25801,12 @@ function fetchFailureKind(error, signal) {
25597
25801
  const code = String(record?.code ?? record?.cause?.code ?? "").toUpperCase();
25598
25802
  const message = error instanceof Error ? error.message : String(error);
25599
25803
  if (signal?.aborted) return "aborted";
25804
+ if (error instanceof RedirectChainError) {
25805
+ if (error.kind === "loop") return "redirect-loop";
25806
+ if (error.kind === "too_many") return "redirect-limit";
25807
+ if (error.kind === "timeout") return "timeout";
25808
+ return error.cause === void 0 ? "unknown" : fetchFailureKind(error.cause, signal);
25809
+ }
25600
25810
  if (/TOO MANY REDIRECTS/i.test(message)) return "redirect-limit";
25601
25811
  if ([
25602
25812
  "ENOTFOUND",
@@ -25989,7 +26199,7 @@ function availabilityOf(status) {
25989
26199
  if (status >= 400 && status < 500) return "absent";
25990
26200
  return "unreachable";
25991
26201
  }
25992
- function errorText$1(error) {
26202
+ function errorText(error) {
25993
26203
  if (!(error instanceof Error)) return String(error);
25994
26204
  const cause = error.cause instanceof Error ? error.cause.message : "";
25995
26205
  return cause && !error.message.includes(cause) ? `${error.message}: ${cause}` : error.message;
@@ -25997,7 +26207,7 @@ function errorText$1(error) {
25997
26207
  function requestError$1(error, timeoutMs, signal) {
25998
26208
  const errorKind = fetchErrorKind(error, signal);
25999
26209
  return {
26000
- error: errorKind === "timeout" ? `No response within ${Math.min(timeoutMs, REQUEST_TIMEOUT_CAP_MS) / 1e3} seconds.` : errorText$1(error),
26210
+ error: errorKind === "timeout" ? `No response within ${Math.min(timeoutMs, REQUEST_TIMEOUT_CAP_MS) / 1e3} seconds.` : errorText(error),
26001
26211
  errorKind
26002
26212
  };
26003
26213
  }
@@ -26135,10 +26345,10 @@ function sitemapUrlsFromRobots(text) {
26135
26345
  async function checkRobotsAiAccess(input) {
26136
26346
  const robotsUrl = new URL("/robots.txt", input.url).toString();
26137
26347
  const result = await get$1(input, robotsUrl);
26138
- const servesHtml = result.availability === "available" && isHtml(result.contentType);
26139
- const availability = servesHtml ? "absent" : result.availability;
26348
+ const servesHtml = result.availability === "available" && robotsTxtIsHtml(result.contentType, result.text);
26349
+ const availability = servesHtml ? "unreadable" : result.availability;
26140
26350
  const exists = availability === "available";
26141
- const unknown = result.availability === "rate-limited" || result.availability === "unreachable";
26351
+ const unknown = availability === "rate-limited" || availability === "unreachable" || availability === "unreadable";
26142
26352
  const declared = exists ? declaredUserAgents(result.text) : /* @__PURE__ */ new Set();
26143
26353
  const parsed = parseRobots(robotsUrl, exists ? result.text : "");
26144
26354
  const ruleHosts = /* @__PURE__ */ new Set([new URL(robotsUrl).host, new URL(result.url).host]);
@@ -26155,7 +26365,7 @@ async function checkRobotsAiAccess(input) {
26155
26365
  ...result.status === void 0 ? {} : { status: result.status },
26156
26366
  ...result.contentType ? { contentType: result.contentType } : {},
26157
26367
  ...servesHtml ? { servesHtml } : {},
26158
- ...unknown ? { error: result.error ?? `robots.txt returned HTTP ${result.status}.` } : {},
26368
+ ...unknown ? { error: servesHtml ? ROBOTS_TXT_HTML_ERROR : result.error ?? `robots.txt returned HTTP ${result.status}.` } : {},
26159
26369
  ...result.errorKind ? { errorKind: result.errorKind } : {},
26160
26370
  sitemapUrls: exists ? sitemapUrlsFromRobots(result.text) : [],
26161
26371
  ...unknown ? {} : { contentSignals: exists ? contentSignalsFromRobots(result.text) : [] },
@@ -26167,7 +26377,7 @@ async function checkRobotsAiAccess(input) {
26167
26377
  coveredByWildcard: declared.has("*")
26168
26378
  }))
26169
26379
  },
26170
- allows: (url) => unknown ? null : isAllowed(url, SEO_CRAWLER_USER_AGENT)
26380
+ allows: (url) => servesHtml ? true : unknown ? null : isAllowed(url, SEO_CRAWLER_USER_AGENT)
26171
26381
  };
26172
26382
  }
26173
26383
  async function checkAgentResources(input) {
@@ -36592,82 +36802,6 @@ async function indexCoverageSignals(input, dependencies = defaultDependencies$1)
36592
36802
  }
36593
36803
  };
36594
36804
  }
36595
- const REDIRECT_STATUSES = /* @__PURE__ */ new Set([
36596
- 301,
36597
- 302,
36598
- 303,
36599
- 307,
36600
- 308
36601
- ]);
36602
- function errorText(error) {
36603
- if (!(error instanceof Error)) return String(error);
36604
- const cause = error.cause;
36605
- const detail = typeof cause?.code === "string" ? cause.code : typeof cause?.message === "string" ? cause.message : "";
36606
- return detail && !error.message.includes(detail) ? `${error.message} (${detail})` : error.message;
36607
- }
36608
- function httpUrl(value, base) {
36609
- try {
36610
- const url = new URL(value, base);
36611
- if (url.protocol !== "http:" && url.protocol !== "https:") return null;
36612
- url.hash = "";
36613
- return url.toString();
36614
- } catch {
36615
- return null;
36616
- }
36617
- }
36618
- async function traceRedirects(url, options = {}) {
36619
- const maxHops = Math.min(Math.max(Math.trunc(options.maxHops ?? 10), 0), 10);
36620
- const timeoutMs = options.timeoutMs ?? 1e4;
36621
- const request = options.fetch ?? ((to, init) => fetch(to, init));
36622
- const steps = [];
36623
- const end = (final_url, final_status, outcome, error) => ({
36624
- url,
36625
- steps,
36626
- hops: steps.filter((step) => step.location !== null).length,
36627
- final_url,
36628
- final_status,
36629
- outcome,
36630
- ...error ? { error } : {}
36631
- });
36632
- const start = httpUrl(url);
36633
- if (!start) return end(url, null, "error", "Not an http or https URL.");
36634
- let current = start;
36635
- const seen = /* @__PURE__ */ new Set();
36636
- while (true) {
36637
- seen.add(current);
36638
- const startedAt = Date.now();
36639
- let response;
36640
- try {
36641
- response = await request(current, {
36642
- redirect: "manual",
36643
- headers: {
36644
- "user-agent": SEO_CRAWLER_USER_AGENT,
36645
- accept: "text/html,application/xhtml+xml,*/*;q=0.8"
36646
- },
36647
- signal: AbortSignal.timeout(timeoutMs)
36648
- });
36649
- } catch (error) {
36650
- return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError") ? end(current, null, "timeout", `No response within ${timeoutMs} ms.`) : end(current, null, "error", errorText(error));
36651
- }
36652
- const ms = Date.now() - startedAt;
36653
- const header = response.headers.get("location");
36654
- const redirects = REDIRECT_STATUSES.has(response.status) && header !== null;
36655
- const location = redirects ? httpUrl(header, current) : null;
36656
- if (options.onResponse) await options.onResponse(response, current);
36657
- await response.body?.cancel().catch(() => void 0);
36658
- steps.push({
36659
- url: current,
36660
- status: response.status,
36661
- location,
36662
- ms
36663
- });
36664
- if (!redirects) return end(current, response.status, "done");
36665
- if (!location) return end(current, response.status, "error", `The Location header is not an http or https URL: ${header.slice(0, 200)}`);
36666
- if (seen.has(location)) return end(current, response.status, "loop");
36667
- if (steps.length > maxHops) return end(current, response.status, "too_many");
36668
- current = location;
36669
- }
36670
- }
36671
36805
  // packages/core/dist/analyze/monitoring/redirect-trace.js
36672
36806
  function redirectIssue(input) {
36673
36807
  const issues = [];
@@ -39800,7 +39934,19 @@ function makeBlock(kind, built, extra = {}) {
39800
39934
  text.pop();
39801
39935
  map.pop();
39802
39936
  }
39803
- if (!text.length) return null;
39937
+ if (!text.length) {
39938
+ if (extra.at === void 0 || kind !== "title" && kind !== "description") return null;
39939
+ map.push(extra.at);
39940
+ return {
39941
+ kind,
39942
+ text: "",
39943
+ norm: "",
39944
+ map,
39945
+ field: extra.field,
39946
+ section: 0,
39947
+ links: []
39948
+ };
39949
+ }
39804
39950
  const joined = text.join("");
39805
39951
  const links = [];
39806
39952
  for (const link of extra.links ?? []) {
@@ -40092,7 +40238,8 @@ var Extractor = class {
40092
40238
  }
40093
40239
  const block = makeBlock(kind, decodeValue(source, at, at + value.length, Boolean(quoted)), {
40094
40240
  field: key,
40095
- level: kind === "heading" ? 1 : void 0
40241
+ level: kind === "heading" ? 1 : void 0,
40242
+ at
40096
40243
  });
40097
40244
  if (block) this.props.push({
40098
40245
  at,
@@ -40415,7 +40562,10 @@ function extractHtml(source, astro) {
40415
40562
  const content = tag.attrs.get("content");
40416
40563
  const value = name ? source.slice(name.start, name.end) : "";
40417
40564
  if (content && /^description$/i.test(value)) {
40418
- const block = makeBlock("description", decodeValue(source, content.start, content.end, false), { field: "description" });
40565
+ const block = makeBlock("description", decodeValue(source, content.start, content.end, false), {
40566
+ field: "description",
40567
+ at: content.start
40568
+ });
40419
40569
  if (block) x.props.push({
40420
40570
  at: content.start,
40421
40571
  block
@@ -40425,7 +40575,10 @@ function extractHtml(source, astro) {
40425
40575
  if (lower === "title" && !tag.closing) {
40426
40576
  const close = source.toLowerCase().indexOf("</title", tag.end);
40427
40577
  const stop = close < 0 ? n : close;
40428
- const block = makeBlock("title", decodeValue(source, tag.end, stop, false), { field: "title" });
40578
+ const block = makeBlock("title", decodeValue(source, tag.end, stop, false), {
40579
+ field: "title",
40580
+ at: tag.end
40581
+ });
40429
40582
  if (block) x.props.push({
40430
40583
  at: tag.end,
40431
40584
  block
@@ -40539,7 +40692,10 @@ function extractMetaLines(source, metaKind) {
40539
40692
  const built = decodeValue(source, from, end, false);
40540
40693
  const value = source.slice(from, end).trim();
40541
40694
  const kind = label ? label[1]?.toLowerCase() : metaKind ? metaKind : value.length <= 70 ? "title" : "description";
40542
- const block = makeBlock(kind, built, { field: kind });
40695
+ const block = makeBlock(kind, built, {
40696
+ field: kind,
40697
+ ...label ? { at: from } : {}
40698
+ });
40543
40699
  if (block) blocks.push(block);
40544
40700
  }
40545
40701
  return finish(x, blocks);
@@ -40693,6 +40849,16 @@ function insideQuotes(text, start, end) {
40693
40849
  if (open <= start && i >= end - 1) return true;
40694
40850
  open = -1;
40695
40851
  }
40852
+ const opener = text.lastIndexOf("'", start);
40853
+ if (opener >= 0 && start - opener <= 80 && (opener === 0 || /[\s([]/.test(text[opener - 1] ?? "")) && /[A-Za-z]/.test(text[opener + 1] ?? "")) {
40854
+ const closer = /'(?![A-Za-z])/g;
40855
+ closer.lastIndex = Math.max(end - 1, opener + 1);
40856
+ const found = closer.exec(text);
40857
+ if (found && found.index - end <= 80) {
40858
+ const between = text.slice(opener + 1, found.index);
40859
+ if (!/'\s/.test(between.slice(0, Math.max(0, start - opener - 1)))) return true;
40860
+ }
40861
+ }
40696
40862
  return false;
40697
40863
  }
40698
40864
  var Context = class {
@@ -40806,7 +40972,12 @@ const PLAIN = {
40806
40972
  seamless: "smooth, simple (or say what does not break)",
40807
40973
  robust: "reliable, strong (or say what it survives)",
40808
40974
  holistic: "whole, complete",
40809
- utilize: "use"
40975
+ utilize: "use",
40976
+ breathtaking: "say what you see",
40977
+ stunning: "say what you see",
40978
+ enchanting: "say what you see",
40979
+ "must-visit": "say why to go",
40980
+ unforgettable: "say what stays with you"
40810
40981
  };
40811
40982
  function plainFor(word) {
40812
40983
  const lower = word.toLowerCase();
@@ -40874,12 +41045,13 @@ const PHRASE_RULES = [
40874
41045
  id: "throat-clearing",
40875
41046
  family: "ai",
40876
41047
  level: "error",
40877
- description: "Openers that announce a point instead of making it: \"Here's the thing\", \"The truth is\", \"Let that sink in\".",
41048
+ description: "Openers that announce a point instead of making it: \"Here's the thing\", \"The truth is\", \"The real question is\", \"Let that sink in\".",
40878
41049
  patterns: [
40879
41050
  /\bhere's the (?:thing|deal|kicker|catch|twist|truth|secret|reality)\b/gi,
40880
41051
  /(?<!\b(?:what|how|whatever|whether|where|why|who|if)\s)\b(?:the (?:uncomfortable |honest |hard |brutal |simple |real )?truth is|the reality is(?: that)?|the thing is|the honest answer(?: is)?)\b/gi,
40881
41052
  /\b(?:let me be (?:clear|honest|blunt)|let's be (?:clear|honest|real)|real talk|make no mistake|let that sink in|read that again)\b/gi,
40882
- /\bfull stop\.\s*$/gim
41053
+ /\bfull stop\.\s*$/gim,
41054
+ /\bthe (?:real|bigger|more interesting|only|big) question (?:now )?is\b|\bthe question now is\b/gi
40883
41055
  ],
40884
41056
  message: "This announces a point instead of making it.",
40885
41057
  fix: cut
@@ -40897,7 +41069,7 @@ const PHRASE_RULES = [
40897
41069
  /\b(?:a problem nobody talks about|the insight everyone's missing|most people don't reali[sz]e)\b/gi
40898
41070
  ],
40899
41071
  message: "It claims a hidden insight instead of showing the finding.",
40900
- fix: "Lead with the finding itself and the number behind it."
41072
+ fix: "Cut the setup and state the finding itself."
40901
41073
  },
40902
41074
  {
40903
41075
  id: "significance-inflation",
@@ -40916,10 +41088,16 @@ const PHRASE_RULES = [
40916
41088
  /\ban? (?:meaningful|significant|major|big|bold|giant|monumental|crucial|pivotal|huge) step (?:towards?|forward)\b/gi,
40917
41089
  /\bwatershed moment\b/gi,
40918
41090
  /\bhas emerged as (?:a|the|one of) (?:leading|key|major|critical|essential|pivotal|prominent|dominant)\b/gi,
40919
- /\bextends? far beyond\b/gi
41091
+ /\bextends? far beyond\b/gi,
41092
+ /\b(?:is|are|was) (?:just|only|merely) the (?:beginning|start)\b/gi,
41093
+ /\bmakes? (?:a|the) (?:real|big|huge|meaningful|lasting|tangible|world of) difference\b/gi,
41094
+ /\b(?:a|another) (?:major|key|significant|important|huge|big|proud) milestone\b/gi,
41095
+ /\bbuilding a better (?:future|world|tomorrow)\b/gi,
41096
+ /\bthe implications are (?:profound|significant|enormous|huge|far-reaching)\b/gi,
41097
+ /\b(?:is|was) an (?:event|experience|attraction) in itself\b/gi
40920
41098
  ],
40921
41099
  message: "It inflates importance without saying what changed.",
40922
- fix: "Name the concrete effect: what changed, for whom, and by how much."
41100
+ fix: "Cut it, or name the concrete effect the text already states. Do not add a number that is not there."
40923
41101
  },
40924
41102
  {
40925
41103
  id: "participle-tail",
@@ -40927,8 +41105,8 @@ const PHRASE_RULES = [
40927
41105
  level: "error",
40928
41106
  description: "A sentence that ends on \", highlighting/underscoring/showcasing ...\" commentary.",
40929
41107
  kinds: PROSE,
40930
- patterns: [/,\s+(?:highlighting|underscoring|emphasi[sz]ing|showcasing|symboli[sz]ing|solidifying|cementing|signall?ing|reinforcing|fostering|cultivating|paving the way|setting the stage|demonstrating|illustrating|reflecting|ensuring|marking|contributing to)\b[^.!?]{0,120}[.!?]?/gi],
40931
- levelFor: (match) => /^,\s+(?:reflecting|ensuring|marking|contributing|demonstrating|illustrating)/i.test(match.text) ? "warning" : void 0,
41108
+ patterns: [/,\s+(?:highlighting|underscoring|emphasi[sz]ing|showcasing|symboli[sz]ing|solidifying|cementing|signall?ing|reinforcing|fostering|cultivating|paving the way|setting the stage|demonstrating|illustrating|reflecting|ensuring|marking|contributing to)\b[^.!?]{0,120}[.!?]?/gi, /,\s+(?:giving (?:users|teams|customers|small teams|every \w+|people) (?:a|an|the|more)\b|freeing (?:you|them|up)\b|making it (?:easy|easier|simple|simpler|effortless|possible) (?:for|to)\b|creating an? (?:[\w-]+ )?(?:experience|panorama|atmosphere|sense)\b|delivering (?:real-time|accurate|predictions|reliable|a seamless|an intuitive)\b)[^.!?]{0,120}[.!?]?/gi],
41109
+ levelFor: (match) => /^,\s+(?:reflecting|ensuring|marking|contributing|demonstrating|illustrating|giving|freeing|making|creating|delivering)/i.test(match.text) ? "warning" : void 0,
40932
41110
  message: "The \", highlighting ...\" tail comments on the sentence instead of adding a fact.",
40933
41111
  fix: "End the sentence at the comma. If the tail holds a real fact, give it its own sentence with a subject and verb."
40934
41112
  },
@@ -40936,7 +41114,7 @@ const PHRASE_RULES = [
40936
41114
  id: "ai-vocabulary",
40937
41115
  family: "ai",
40938
41116
  level: "error",
40939
- description: "Words far more common in AI text than in human writing: delve, tapestry, testament, showcase, meticulous, realm.",
41117
+ description: "Words far more common in AI text than in human writing: delve, tapestry, testament, showcase, meticulous, realm. One alone is a warning; two or more in a piece are errors.",
40940
41118
  patterns: [
40941
41119
  /\bdelv(?:e|es|ed|ing)\b/gi,
40942
41120
  /\b(?:rich )?tapestry\b/gi,
@@ -40959,7 +41137,9 @@ const PHRASE_RULES = [
40959
41137
  /\bbustling\b/gi,
40960
41138
  /\bsynerg(?:y|ies)\b/gi,
40961
41139
  /\brevolutioni[sz](?:e|es|ed|ing)\b/gi,
40962
- /\bnavigat(?:e|ing) the complexit(?:y|ies)\b/gi
41140
+ /\bnavigat(?:e|ing) the complexit(?:y|ies)\b/gi,
41141
+ /\bbreathtaking(?:ly)?\b|\bmust-visit\b|\bunforgettable\b/gi,
41142
+ /\bstunning(?:ly)?\b|\benchanting\b/gi
40963
41143
  ],
40964
41144
  message: (match) => `"${match.text}" is far more common in AI text than in human writing.`,
40965
41145
  fix: (match) => `Use ${plainFor(match.text.replace(/^(?:rich|a) /i, ""))}.`
@@ -41001,7 +41181,7 @@ const PHRASE_RULES = [
41001
41181
  /\bbattle-tested\b|\bbulletproof\b|\bunparalleled\b|\bunmatched\b/gi
41002
41182
  ],
41003
41183
  message: (match) => `"${match.text}" claims quality without showing it.`,
41004
- fix: (match) => `Replace it with the proof: ${plainFor(match.text)}.`
41184
+ fix: (match) => `Cut it, or use what the text already shows (${plainFor(match.text)}). Do not add a claim the text does not make.`
41005
41185
  },
41006
41186
  {
41007
41187
  id: "cliche",
@@ -41035,11 +41215,13 @@ const PHRASE_RULES = [
41035
41215
  /\bpaves? the way\b/gi,
41036
41216
  /\bnorth star\b|\btable stakes\b|\bneedle in a haystack\b/gi,
41037
41217
  /\bnot a rounding error\b/gi,
41218
+ /\bpunch(?:es|ing)? (?:well )?above (?:its|their|his|her) weight\b/gi,
41219
+ /\b(?:thrilled|delighted) to (?:announce|share|introduce|unveil)\b/gi,
41038
41220
  /\bthe (?:whole|entire) point\b/gi,
41039
41221
  /,? which is (?:the (?:whole |real |entire )?point|exactly the point|kind of the point)\b/gi
41040
41222
  ],
41041
41223
  message: (match) => `"${match.text}" is a stock phrase that would fit any page.`,
41042
- fix: "Say the specific thing in literal words, with the number or name that makes it true here."
41224
+ fix: "Say the specific thing in literal words, using a fact the text already gives, or cut it."
41043
41225
  },
41044
41226
  {
41045
41227
  id: "filler",
@@ -41084,14 +41266,19 @@ const PHRASE_RULES = [
41084
41266
  id: "generic-closer",
41085
41267
  family: "ai",
41086
41268
  level: "error",
41087
- description: "Tidy endings: \"In conclusion\", \"the future looks bright\", \"only time will tell\".",
41269
+ description: "Tidy endings: \"In conclusion\", \"the future looks bright\", \"Here's to the next chapter\", \"Let's build something extraordinary\".",
41088
41270
  patterns: [
41089
41271
  /\bthe future (?:of \w+ )?(?:looks bright|is here|is now|starts now|is bright)\b/gi,
41090
41272
  /\bonly time will tell\b|\bone thing is (?:certain|clear)\b/gi,
41091
41273
  /\bexciting times (?:lie )?ahead\b|\bas we move forward\b/gi,
41092
41274
  /(?:^|[.!?]\s)(?:In conclusion|In summary|To sum (?:it )?up|To summari[sz]e|All in all|In closing)\b/gi,
41093
41275
  /\bthe possibilities are endless\b|\bthe rest is history\b/gi,
41094
- /\bbecause (?:you|your team) deserves? (?:better|more|the best)\b/gi
41276
+ /\bbecause (?:you|your team) deserves? (?:better|more|the best)\b/gi,
41277
+ /(?:^|[.!?]\s)Here's to (?:the|what|a|an|our|your|many|more|another|new)\b[^.!?]{0,60}[.!?]/g,
41278
+ /\blet's (?:build|make|create|shape) something (?:extraordinary|remarkable|amazing|great|special|incredible|beautiful|meaningful)\b/gi,
41279
+ /\bwe'd love (?:to hear )?your (?:feedback|thoughts)\b/gi,
41280
+ /\bthe way it (?:should|was meant to) (?:be|work)\b/gi,
41281
+ /(?:^|[.!?]\s)See you (?:there|soon|at the \w+|on the \w+)[.!]/g
41095
41282
  ],
41096
41283
  message: "A stock closing line that adds nothing the page has not said.",
41097
41284
  fix: "End on the next step, or on the last concrete fact."
@@ -41106,6 +41293,7 @@ const PHRASE_RULES = [
41106
41293
  /\bthat's when (?:everything|it all) changed\b/gi,
41107
41294
  /\b(?:then )?something (?:interesting|unexpected) happened\b/gi,
41108
41295
  /\b(?:this|here) is where (?:things|it) gets? (?:interesting|tricky|fun)\b|\bhere's where it gets (?:interesting|tricky)\b/gi,
41296
+ /\b(?:this|that) is where (?:[\w-]+ ){1,3}(?:shines?|excels?|stands? out)\b/gi,
41109
41297
  /\bflipped the script\b|\brewrote the playbook\b|\bchanged the game\b|\bplot twist\b/gi
41110
41298
  ],
41111
41299
  message: "A staged turn that tells the reader to feel surprised.",
@@ -41141,8 +41329,9 @@ const PHRASE_RULES = [
41141
41329
  /\b(?:studies|research|surveys) (?:show|shows|suggest|suggests|indicate|indicates|proves?)\b/gi,
41142
41330
  /\bit is (?:widely|generally|commonly) (?:believed|accepted|known|agreed)\b/gi
41143
41331
  ],
41332
+ skip: (match) => /^[A-Z]\w* [A-Z]/.test(match.text),
41144
41333
  message: "A claim credited to nobody in particular.",
41145
- fix: "Name the source and link it, or cut the claim."
41334
+ fix: "Name the source if the text has one. Otherwise cut the claim or say it as your own view."
41146
41335
  },
41147
41336
  {
41148
41337
  id: "hedge-stack",
@@ -41159,7 +41348,11 @@ const PHRASE_RULES = [
41159
41348
  level: "error",
41160
41349
  kinds: PROSE,
41161
41350
  description: "A label and colon used as a drumroll: \"The best part: it learns.\"",
41162
- patterns: [/(?:^|[.!?]\s+)(?:The|One|My|Here's the|And the|But the)\s+(?:\w+\s+){0,3}(?:part|thing|secret|trick|catch|kicker|truth|twist|takeaway|lesson|upshot|irony|punchline):\s+\S/g],
41351
+ patterns: [
41352
+ /(?:^|[.!?]\s+)(?:The|One|My|Here's the|And the|But the)\s+(?:\w+\s+){0,3}(?:part|thing|secret|trick|catch|kicker|truth|twist|takeaway|lesson|upshot|irony|punchline):\s+\S/g,
41353
+ /\bthe (?:idea|answer|fix|plan|rule|math|process|premise|concept) is simple:/gi,
41354
+ /\bthe (?:results?|answer|choice|difference) (?:was|were|became) (?:clear|obvious|striking)\b/gi
41355
+ ],
41163
41356
  message: "A colon reveal builds suspense the content does not need.",
41164
41357
  fix: "Say it straight: drop the label and keep what follows the colon."
41165
41358
  },
@@ -41167,12 +41360,13 @@ const PHRASE_RULES = [
41167
41360
  id: "copula-avoidance",
41168
41361
  family: "ai",
41169
41362
  level: "error",
41170
- description: "\"Serves as\", \"stands as\", \"boasts\" where \"is\" or \"has\" would do.",
41363
+ description: "\"Serves as\", \"stands as\", \"boasts\", \"represents a significant\" where \"is\" or \"has\" would do.",
41171
41364
  patterns: [
41172
41365
  /\bstands? as (?:a|an|the)\b/gi,
41173
41366
  /\bboasts? (?:a|an|the|over|more|\d)/gi,
41174
41367
  /\bholds? the distinction of\b/gi,
41175
- /\bserves? as (?:a|an|the)\b/gi
41368
+ /\bserves? as (?:a|an|the)\b/gi,
41369
+ /\brepresents? (?:a|an) (?:significant|major|big|huge|real|smart|key|mature|important|meaningful|proven)\b|\brepresents? (?:our|their|its|a) (?:commitment|dedication)\b/gi
41176
41370
  ],
41177
41371
  levelFor: (match) => /^serves?/i.test(match.text) ? "warning" : void 0,
41178
41372
  message: (match) => `"${match.text.trim()}" dodges a plain "is" or "has".`,
@@ -41191,7 +41385,7 @@ const PHRASE_RULES = [
41191
41385
  /\ball-in-one\b/gi,
41192
41386
  /\bin (?:just )?a few (?:clicks|minutes|seconds)\b/gi,
41193
41387
  /\bjoin (?:thousands|millions|hundreds) of (?:happy|satisfied|other)?\b/gi,
41194
- /\bin seconds,? not (?:hours|days|weeks)\b/gi,
41388
+ /\bin (?:seconds|minutes|hours|days),? not (?:minutes|hours|days|weeks|months)\b/gi,
41195
41389
  /\bmore than just an?\b/gi,
41196
41390
  /\bthat's where \w+ comes? in\b/gi,
41197
41391
  /\btrusted by (?:industry leaders|thousands|teams worldwide|the best)\b/gi,
@@ -41199,7 +41393,7 @@ const PHRASE_RULES = [
41199
41393
  ],
41200
41394
  levelFor: (match) => /the power of/i.test(match.text) ? "warning" : void 0,
41201
41395
  message: (match) => `"${match.text}" is a stock landing-page line that fits any product.`,
41202
- fix: "Say what the product does for this reader, with a number or an example."
41396
+ fix: "Cut the phrase, or replace it with a fact the text already gives, such as a number or a feature named elsewhere on the page. Do not add a new claim."
41203
41397
  },
41204
41398
  {
41205
41399
  id: "stranded-negation",
@@ -41214,12 +41408,63 @@ const PHRASE_RULES = [
41214
41408
  ],
41215
41409
  message: "A two-word denial used for rhythm. It makes the reader wait.",
41216
41410
  fix: "Join it to the sentence before, such as \"Nothing technical was holding these pages back.\""
41411
+ },
41412
+ {
41413
+ id: "strawman",
41414
+ family: "ai",
41415
+ level: "error",
41416
+ kinds: PROSE,
41417
+ description: "An answer to an objection nobody raised: \"It isn't perfect.\", \"doesn't have to be stressful\", \"None of this means\", \"This isn't about\".",
41418
+ patterns: [
41419
+ /(?:^|[.!?:]\s+)(?:It|This|That|Nothing|No \w+)(?:'s| is|'s not| isn't| is not| wasn't| was not) (?:not )?perfect\.(?=\s|$)/g,
41420
+ /\b(?:doesn't|does not|don't|do not|needn't|need not) (?:have to |need to )?be (?:so )?(?:stressful|hard|complicated|difficult|painful|expensive|overwhelming|a chore|scary|intimidating|daunting|confusing|a headache)\b/gi,
41421
+ /(?:^|[.!?]\s)None of this means\b/g,
41422
+ /(?:^|[.!?]\s)(?:This|That|It)(?: isn't| is not|'s not) (?:just |really |only )?about\b/g,
41423
+ /\b(?:isn't|is not|ain't) rocket science\b/gi
41424
+ ],
41425
+ message: "It answers a doubt the reader did not raise.",
41426
+ fix: "Cut it, or state the limit or the point plainly as a fact."
41427
+ },
41428
+ {
41429
+ id: "cta-question",
41430
+ family: "ai",
41431
+ level: "error",
41432
+ kinds: [
41433
+ "heading",
41434
+ "paragraph",
41435
+ "prop",
41436
+ "label"
41437
+ ],
41438
+ description: "A sales question used as a heading or closing line: \"Ready to Simplify Your Practice Finances?\", \"Tired of spreadsheets?\".",
41439
+ patterns: [/(?:^|[.!?]\s+)(?:Ready (?:to|for)|Tired of|Struggling (?:to|with)|Sick of|Fed up with)\b[^.!?]{1,80}\?/g],
41440
+ message: "A sales question that asks the reader something only to answer it with a pitch.",
41441
+ fix: "Say the offer or the next step as a plain statement, such as \"Start a free trial\" or the price."
41217
41442
  }
41218
41443
  ];
41444
+ const RIDER = /,\s+(?:allowing|making|leaving|turning|giving|creating|delivering|letting|enabling|helping|offering|providing|bringing|adding|keeping|drifting|tumbling|converting|resulting|saving|freeing|ensuring|reflecting)\s[^.!?;]{0,120}/gi;
41445
+ function riderDensity(ctx) {
41446
+ const rule = PHRASE_RULES.find((entry) => entry.id === "participle-tail");
41447
+ if (ctx.level(rule) === "off") return;
41448
+ const found = [];
41449
+ for (const block of ctx.blocks(PROSE)) {
41450
+ RIDER.lastIndex = 0;
41451
+ for (const match of block.norm.matchAll(RIDER)) {
41452
+ const start = match.index ?? 0;
41453
+ found.push({
41454
+ block,
41455
+ start,
41456
+ end: start + match[0].length
41457
+ });
41458
+ }
41459
+ }
41460
+ if (found.length < 2 || found.length * 250 < ctx.proseWords) return;
41461
+ for (const item of found) ctx.hit(rule, item.block, item.start, item.end, `${found.length} ", -ing" riders in ${ctx.proseWords.toLocaleString("en-US")} words. Tacking a result onto each fact is a common AI habit.`, "End the sentence at the comma. If the rider holds a fact the text needs, give it its own sentence with a subject and verb.", "warning");
41462
+ }
41219
41463
  function quoted(text, start, end) {
41220
41464
  return (text[start - 1] === "\"" || text[start - 1] === "'") && /^[.,;:!?]?["']/.test(text.slice(end, end + 2));
41221
41465
  }
41222
41466
  function runPhraseRules(ctx) {
41467
+ const before = ctx.hits.length;
41223
41468
  for (const rule of PHRASE_RULES) {
41224
41469
  if (ctx.level(rule) === "off") continue;
41225
41470
  for (const block of ctx.blocks(rule.kinds ?? ALL)) {
@@ -41246,6 +41491,9 @@ function runPhraseRules(ctx) {
41246
41491
  }
41247
41492
  }
41248
41493
  }
41494
+ riderDensity(ctx);
41495
+ const vocabulary = ctx.hits.slice(before).filter((hit) => hit.rule === "ai-vocabulary");
41496
+ if (vocabulary.length === 1) for (const hit of vocabulary) hit.level = "warning";
41249
41497
  }
41250
41498
  // packages/core/dist/serp/browser.js
41251
41499
  const SERP_PIXEL_BUDGETS = {
@@ -41400,7 +41648,7 @@ const SEO_RULES = {
41400
41648
  id: "dash",
41401
41649
  family: "copy",
41402
41650
  level: "error",
41403
- description: "Em dashes, en dashes and spaced hyphens used as dashes in copy."
41651
+ description: "Em dashes, en dashes and spaced hyphens used as dashes in copy. A separator in a title, such as \"Pricing - Example\", is left alone."
41404
41652
  },
41405
41653
  titleLength: {
41406
41654
  id: "title-length",
@@ -41439,7 +41687,7 @@ const SEO_RULES = {
41439
41687
  id: "emoji",
41440
41688
  family: "copy",
41441
41689
  level: "warning",
41442
- description: "Emoji at the start of headings or list items."
41690
+ description: "Emoji at the start of headings or list items, and decorative emoji such as 🎉 or 🚀 anywhere."
41443
41691
  },
41444
41692
  exclamation: {
41445
41693
  id: "exclamation",
@@ -41477,6 +41725,7 @@ function dash(ctx) {
41477
41725
  for (const block of ctx.blocks(ALL)) {
41478
41726
  re.lastIndex = 0;
41479
41727
  for (const found of block.norm.matchAll(re)) {
41728
+ if ((block.kind === "title" || block.kind === "label") && /^\s+\S*\s+$/.test(found[0])) continue;
41480
41729
  const start = (found.index ?? 0) + (found[1] ? 1 : 0);
41481
41730
  const end = (found.index ?? 0) + found[0].length;
41482
41731
  const range = Boolean(found[1]);
@@ -41495,11 +41744,15 @@ function lengths(ctx) {
41495
41744
  const length = block.text.length;
41496
41745
  if (length >= low && length <= high) continue;
41497
41746
  const what = title ? "title" : "description";
41747
+ if (!length) {
41748
+ ctx.hit(rule, block, 0, 0, `The ${what} is empty.`, title ? "Write a title that names the page, in 30 to 60 characters." : "Write one or two sentences that say what the page contains and who it is for, in 70 to 160 characters.", "error");
41749
+ continue;
41750
+ }
41498
41751
  const pixels = Math.ceil(serpTextWidth(block.text, what));
41499
41752
  ctx.hit(rule, block, 0, block.text.length, `The ${what} is ${length} characters, about ${pixels} pixels wide. Aim for ${low} to ${high} characters; Google shows about ${SERP_PIXEL_BUDGETS[what].desktop} pixels of a ${what} on desktop.`, length < low ? title ? "Add the words people search for, or one true reason to click, such as \"free\" or \"for any site\"." : "Say what the page contains and who it is for in one or two sentences." : title ? "Cut words after the main topic. Search results cut long titles off at about 60 characters." : "Cut it to one specific sentence. Search results cut descriptions off at about 160 characters.");
41500
41753
  }
41501
41754
  }
41502
- const HEADING_CLICHE = /^(?:a )?(?:deep dive|under the hood|demystifying\b|why (?:it|this|that) matters|a closer look|the ultimate guide|everything you need to know|mastering\b|unlocking\b|the power of\b|the future of\b|what you'll learn|key takeaways|final thoughts|wrapping up|conclusion|in conclusion|tl;?dr|overview|introduction|the road ahead|looking ahead|challenges and (?:legacy|opportunities|future)|future outlook|the bottom line|let's dive in|get started today)/i;
41755
+ const HEADING_CLICHE = /^(?:a )?(?:deep dive|under the hood|demystifying\b|why (?:it|this|that) matters|a closer look|the ultimate guide|everything you need to know|mastering\b|unlocking\b|the power of\b|the future of\b|what you'll learn|key takeaways|final thoughts|wrapping up|conclusion|in conclusion|tl;?dr|overview|introduction|the road ahead|looking ahead|challenges and (?:legacy|opportunities|future)|future outlook|the bottom line|let's dive in|get started today|(?:the )?(?:challenge|solution|results?)$|at a glance$|first impressions$|a note of thanks$)/i;
41503
41756
  function headings(ctx) {
41504
41757
  const list = ctx.blocks(["heading"]);
41505
41758
  const h1s = list.filter((block) => block.level === 1);
@@ -41513,7 +41766,17 @@ function headings(ctx) {
41513
41766
  }
41514
41767
  }
41515
41768
  const EMOJI = /^\p{Extended_Pictographic}/u;
41769
+ const DECORATION = /[\u{1F389}\u{1F680}\u{2728}\u{1F525}\u{1F4A1}\u{1F64C}\u{1F44F}\u{1F4AA}\u{1F3AF}\u{1F449}]/gu;
41516
41770
  function emoji(ctx) {
41771
+ for (const block of ctx.blocks(ALL)) {
41772
+ if (block.kind === "heading" || block.kind === "list") {
41773
+ if (EMOJI.test(block.text)) continue;
41774
+ }
41775
+ for (const found of block.text.matchAll(DECORATION)) {
41776
+ const at = found.index ?? 0;
41777
+ ctx.hit(SEO_RULES.emoji, block, at, at + found[0].length, `A decorative ${found[0]} emoji.`, "Remove it. The words should carry the point.");
41778
+ }
41779
+ }
41517
41780
  for (const block of ctx.blocks(["heading", "list"])) if (EMOJI.test(block.text)) ctx.hit(SEO_RULES.emoji, block, 0, Math.min(block.text.length, 40), `An emoji opens this ${block.kind === "heading" ? "heading" : "list item"}.`, "Remove it. The words should carry the point.");
41518
41781
  }
41519
41782
  function exclamation(ctx) {
@@ -41591,7 +41854,7 @@ function reviews(ctx) {
41591
41854
  PROOF.lastIndex = 0;
41592
41855
  for (const found of block.norm.matchAll(PROOF)) ctx.review("proof", block, found.index ?? 0, (found.index ?? 0) + found[0].length, `Is there a source for "${found[0]}" on the page? Link it or say where the number comes from.`);
41593
41856
  }
41594
- if (block.kind === "paragraph" && block.endsSection && sentences.length === 1 && words$2(block.text).length <= 10) ctx.review("mic-drop", block, 0, block.text.length, "Does this closing line add a fact? If it only restates the paragraph above, cut it.");
41857
+ if (block.kind === "paragraph" && block.endsSection && sentences.length === 1 && words$2(block.text).length <= 10 && words$2(block.text).filter((word) => /[a-z]/i.test(word)).length >= 3 && /[.!?]["')]?$/.test(block.text)) ctx.review("mic-drop", block, 0, block.text.length, "Does this closing line add a fact? If it only restates the paragraph above, cut it.");
41595
41858
  }
41596
41859
  if (ctx.preset === "landing" || ctx.preset === "meta") for (const block of ctx.blocks([
41597
41860
  "title",
@@ -41599,6 +41862,7 @@ function reviews(ctx) {
41599
41862
  "heading"
41600
41863
  ])) {
41601
41864
  if (block.kind === "heading" && block.level !== 1) continue;
41865
+ if (!block.text) continue;
41602
41866
  const later = words$2(block.text).slice(1);
41603
41867
  if (/\d/.test(block.text) || later.some((word) => /^[A-Z]/.test(word))) continue;
41604
41868
  ctx.review("swap-test", block, 0, block.text.length, "Would this line work unchanged on a competitor's page? If so, add the product, number or result only you can claim.");
@@ -41652,7 +41916,7 @@ const STRUCTURE_RULES = {
41652
41916
  id: "pithy-fragment",
41653
41917
  family: "ai",
41654
41918
  level: "error",
41655
- description: "A verbless two-beat fragment: \"Same essay, two titles.\" \"Simple, but powerful.\""
41919
+ description: "Verbless slogan fragments: \"Same essay, two titles.\", \"Every month. No exceptions.\", \"$299 per month. That's it.\""
41656
41920
  },
41657
41921
  stockCloser: {
41658
41922
  id: "stock-closer",
@@ -41676,7 +41940,7 @@ const STRUCTURE_RULES = {
41676
41940
  id: "anaphora",
41677
41941
  family: "ai",
41678
41942
  level: "error",
41679
- description: "Three or more very short sentences in a row that start with \"No\", \"It's\", \"They\", \"We\" or \"You\": \"No cookies. No tracking. No banners.\""
41943
+ description: "Short sentences in a row that start with the same word: two or more \"No ...\" sentences, or three or more starting \"It's\", \"They\", \"We\" or \"You\"."
41680
41944
  },
41681
41945
  vocabularyCluster: {
41682
41946
  id: "ai-vocabulary-cluster",
@@ -41689,7 +41953,7 @@ const STRUCTURE_RULES = {
41689
41953
  family: "ai",
41690
41954
  level: "warning",
41691
41955
  presets: { docs: "off" },
41692
- description: "Lists of three, more often than about one per 100 words, or three abstract nouns in a row."
41956
+ description: "Formula triads: three parallel clauses (\"It is cheap, it is reversible, and it works\"), three items that open with the same word, three adjectives after \"is\", or three abstract nouns. Plain lists of three are left alone."
41693
41957
  },
41694
41958
  inventedLabel: {
41695
41959
  id: "invented-label",
@@ -41703,6 +41967,16 @@ const STRUCTURE_RULES = {
41703
41967
  level: "warning",
41704
41968
  description: "Repeated \"What makes this work is ...\" or \"The problem is that ...\" setups."
41705
41969
  },
41970
+ boldEmphasis: {
41971
+ id: "bold-emphasis",
41972
+ family: "ai",
41973
+ level: "warning",
41974
+ presets: {
41975
+ docs: "off",
41976
+ meta: "off"
41977
+ },
41978
+ description: "Bold used for emphasis more than about once per 100 words of prose."
41979
+ },
41706
41980
  boldLabels: {
41707
41981
  id: "bold-label-bullets",
41708
41982
  family: "ai",
@@ -41761,6 +42035,11 @@ function negativeParallelism(ctx) {
41761
42035
  const from = restate ? end - restate[0].length : end;
41762
42036
  const second = block.text.slice(from, after.end).trim();
41763
42037
  const fix = second ? `Drop the "isn't" half and state the claim: "${second.charAt(0).toUpperCase()}${second.slice(1)}"` : "Drop the \"isn't\" half and state the claim once.";
42038
+ const concession = (/,\s*but\s+(?:[\w']+\s*){1,3}$/i.test(found[0]) || /^(?:while|although|though|even though)\b/i.test(subject)) && kind !== "reveal";
42039
+ if (level === "warning" && firstWords > 12 || concession) {
42040
+ ctx.review("contrast", block, sentence.start, Math.max(after.end, end), "Does this \"isn't X ... it's Y\" correct something a reader would really think? If not, state the claim once.");
42041
+ continue;
42042
+ }
41764
42043
  ctx.hit(rule, block, sentence.start, Math.max(after.end, end), "An \"isn't X, it's Y\" reframe argues with a claim nobody made.", fix, ctx.level(rule) === "off" ? void 0 : level);
41765
42044
  }
41766
42045
  }
@@ -41793,13 +42072,20 @@ function gerund(word) {
41793
42072
  const lower = word.toLowerCase();
41794
42073
  return lower.length > 4 && lower.endsWith("ing") && !NOT_GERUND.has(lower);
41795
42074
  }
42075
+ const NOT_JUST = /,\s+not (?:just|merely|simply) [^,.;:!?]{1,50}[.!?]?$|\bnot (?:just|merely|simply) [^,.;:!?]{1,50},? but\b/i;
41796
42076
  function contrastReframe(ctx) {
41797
42077
  const rule = STRUCTURE_RULES.contrastReframe;
41798
42078
  if (ctx.level(rule) === "off") return;
41799
42079
  for (const block of ctx.blocks([...SENTENCE_KINDS, "heading"])) for (const sentence of ctx.sentences(block)) {
41800
42080
  const text = sentence.text;
42081
+ const just = NOT_JUST.exec(text);
42082
+ if (just && (just[0].startsWith(",") || words$2(text).length <= 16) && !insideQuotes(block.norm, sentence.start + just.index, sentence.start + just.index + just[0].length)) {
42083
+ ctx.hit(rule, block, sentence.start, sentence.end, "A \"not just X\" contrast inflates the claim instead of stating it.", "Say what it is or does in one plain clause, and drop the \"not just\" half.");
42084
+ continue;
42085
+ }
41801
42086
  for (const found of text.matchAll(/,\s+not\s+|\s+rather than\s+/gi)) {
41802
42087
  const at = found.index ?? 0;
42088
+ if (insideQuotes(block.norm, sentence.start + at, sentence.start + at + found[0].length)) continue;
41803
42089
  const rather = /rather/i.test(found[0]);
41804
42090
  const left = text.slice(0, at);
41805
42091
  const rightAll = text.slice(at + found[0].length);
@@ -41844,8 +42130,13 @@ function heresAnnouncement(ctx) {
41844
42130
  if (!/^(?:And |So |But )?Here(?:'s| is| are) (?:the|what|why|how|who|where|when|a|an|my|our|some|two|three|four|five)\b/i.test(text) || /^(?:And |So |But )?Here's the (?:thing|deal|kicker|catch|twist|truth|secret|reality)\b/i.test(text)) continue;
41845
42131
  const count = words$2(text).length;
41846
42132
  const colon = text.endsWith(":");
42133
+ const sentences = ctx.sentences(block);
42134
+ if (sentence === sentences[sentences.length - 1]) {
42135
+ const after = ctx.doc.source.slice((block.map[block.map.length - 1] ?? 0) + 1).replace(/^[^\n]*\n/, "").trimStart();
42136
+ if (/^(?:!\[|<(?:img|figure|picture|video|iframe|blockquote|table)\b|>|\|)/i.test(after)) continue;
42137
+ }
41847
42138
  let level = null;
41848
- if (colon && count <= 4) level = "error";
42139
+ if (colon && count <= 5) level = "error";
41849
42140
  else if (!colon && !text.endsWith("?")) {
41850
42141
  if (count <= 9) level = "error";
41851
42142
  else if (count <= 16) level = "warning";
@@ -41863,9 +42154,29 @@ const TWO_BEAT = [
41863
42154
  const GREETING = /^(?:hi|hello|hey|thanks|thank you|cheers|dear|yes|no|ok|okay|sure|well|oh|example|note|tip|warning|update|step \d)\b/i;
41864
42155
  const LEADING_CLAUSE = /^(?:without|with|for|in|on|at|after|before|if|when|once|since|until|unless|by|from|to|then|also|first|next|finally|instead|otherwise|please)\b/i;
41865
42156
  const MONTH = /\b(?:january|february|march|april|may|june|july|august|september|october|november|december|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/i;
42157
+ const KICKER = /^(?:That's it|That's all|No exceptions|No catch|No strings attached|No surprises|No asterisks|Period|Full stop|Simple as that|It's that simple|That simple|Nothing more|Nothing else)[.!]$/i;
42158
+ function slogan(text) {
42159
+ const list = words$2(text);
42160
+ return /[.!]$/.test(text) && !/^Not\b/.test(text) && list.length >= 1 && list.length <= 4 && !GREETING.test(text) && !MONTH.test(text) && !list.every((word) => /^[A-Z0-9]/.test(word)) && verbless(text);
42161
+ }
42162
+ function fragmentRuns(ctx, rule) {
42163
+ for (const block of ctx.blocks(RHYTHM_KINDS)) {
42164
+ const sentences = ctx.sentences(block);
42165
+ for (let i = 1; i < sentences.length; i++) {
42166
+ const before = sentences[i - 1];
42167
+ const sentence = sentences[i];
42168
+ const kicker = KICKER.test(sentence.text) && words$2(before.text).length <= 8;
42169
+ const pair = slogan(before.text) && slogan(sentence.text);
42170
+ if (!kicker && !pair) continue;
42171
+ ctx.hit(rule, block, before.start, sentence.end, kicker ? `"${sentence.text}" is a kicker added for rhythm after a short line.` : "Two verbless fragments in a row read as a slogan, not a sentence.", "Join them into one plain sentence with a verb, such as \"Books close within 48 hours of month end, every month,\" or cut the kicker.");
42172
+ i++;
42173
+ }
42174
+ }
42175
+ }
41866
42176
  function pithyFragment(ctx) {
41867
42177
  const rule = STRUCTURE_RULES.pithyFragment;
41868
42178
  if (ctx.level(rule) === "off") return;
42179
+ fragmentRuns(ctx, rule);
41869
42180
  for (const block of ctx.blocks(RHYTHM_KINDS)) for (const sentence of ctx.sentences(block)) {
41870
42181
  const text = sentence.text;
41871
42182
  if (!/[.!]$/.test(text)) continue;
@@ -41904,7 +42215,7 @@ const FIGURATIVE_NOUNS = "fights?|battles?|hills?|wars?|bets?|gambles?|races?|ri
41904
42215
  function figurativeWorth(ctx) {
41905
42216
  const rule = STRUCTURE_RULES.figurativeWorth;
41906
42217
  const strong = new RegExp(`\\b(?:${FIGURATIVE_NOUNS}) worth \\w+ing\\b`, "gi");
41907
- const reader = /\bworth (?:noting|mentioning|knowing|highlighting|calling out|pointing out|flagging|a (?:closer |second )?look|your time|the (?:wait|hype|effort|read))\b/gi;
42218
+ const reader = /\bworth (?:noting|mentioning|knowing|highlighting|calling out|pointing out|flagging|a (?:closer |second )?look)\b/gi;
41908
42219
  const any = /\b(\w+) worth (\w+ing)\b/gi;
41909
42220
  for (const block of ctx.blocks([
41910
42221
  ...RHYTHM_KINDS,
@@ -41956,7 +42267,9 @@ function anaphora(ctx) {
41956
42267
  const first = words$2(sentences[i].text)[0]?.toLowerCase();
41957
42268
  let j = i + 1;
41958
42269
  while (j < sentences.length && first && words$2(sentences[j].text)[0]?.toLowerCase() === first) j++;
41959
- if (j - i >= 3 && first && ![
42270
+ const run = sentences.slice(i, j);
42271
+ if (first === "no" && j - i >= 2 && run.every((sentence) => /^No (?!one\b|matter\b|doubt\b|longer\b|way\b|wonder\b)[a-z$\d]/.test(sentence.text) && words$2(sentence.text).length <= 12)) ctx.hit(rule, block, sentences[i].start, sentences[j - 1].end, `${j - i} sentences in a row start with "No", a drumbeat of things the product is not.`, "Say what it is in one sentence, and keep only the one \"no\" a reader needs, such as \"One flat monthly fee with no tiers.\"");
42272
+ else if (j - i >= 3 && first && ![
41960
42273
  "the",
41961
42274
  "a",
41962
42275
  "an",
@@ -41967,7 +42280,7 @@ function anaphora(ctx) {
41967
42280
  "it",
41968
42281
  "this"
41969
42282
  ].includes(first)) {
41970
- if (sentences.slice(i, j).every((sentence) => words$2(sentence.text).length <= 7) && /^(?:it's|no|they|we|you)$/.test(first)) ctx.hit(rule, block, sentences[i].start, sentences[j - 1].end, `${j - i} short sentences in a row start with "${first}".`, "Merge them into one sentence or a short list.");
42283
+ if (run.every((sentence) => words$2(sentence.text).length <= 7) && /^(?:it's|they|we|you)$/.test(first)) ctx.hit(rule, block, sentences[i].start, sentences[j - 1].end, `${j - i} short sentences in a row start with "${first}".`, "Merge them into one sentence or a short list.");
41971
42284
  else ctx.review("anaphora", block, sentences[i].start, sentences[j - 1].end, `${j - i} sentences in a row start with "${first}". Does the repeat help the reader follow a real list, or is it there for rhythm?`);
41972
42285
  }
41973
42286
  i = j;
@@ -42008,30 +42321,66 @@ function vocabularyCluster(ctx) {
42008
42321
  }
42009
42322
  }
42010
42323
  }
42011
- const TRIAD = /\b[\w'-]+(?: [\w'-]+){0,2}, [\w'-]+(?: [\w'-]+){0,2},? and [\w'-]+\b/g;
42324
+ const SERIES = /\b[\w'$-]+(?: [\w'$-]+){0,5}(?:, [\w'$-]+(?: [\w'$-]+){0,5}){1,3},? (?:and|or) [\w'$-]+(?: [\w'$-]+){0,5}/g;
42012
42325
  const ABSTRACT_TRIAD = /\b[a-z]+(?:tion|sion|ity|ness|ment|ence|ance|ism), (?:and )?[a-z]+(?:tion|sion|ity|ness|ment|ence|ance|ism),? and [a-z]+(?:tion|sion|ity|ness|ment|ence|ance|ism)\b/gi;
42326
+ const ECHO = new Set("to our your their my his her its every each more less no it they we you".split(" "));
42327
+ const COPULA = /\b(?:is|are|was|were|'s|feels?|felt|looks?|stays?|remains?|seems?)\s+$/i;
42328
+ function formulaTriad(items, before) {
42329
+ const firsts = items.map((item) => words$2(item)[0]?.toLowerCase() ?? "");
42330
+ const echo = firsts[0] ?? "";
42331
+ if (ECHO.has(echo) && firsts.every((first) => first === echo)) return `Each item opens with "${echo}".`;
42332
+ if (items.every((item) => /^(?:the|their|our|your|its|it|they|we|you|he|she)\b/i.test(item) && words$2(item).length >= 3 && words$2(item).length <= 7 && !verbless(item))) return "Three parallel clauses in a row.";
42333
+ if (items.every((item) => /^[a-z]/.test(item) && words$2(item).length <= 2 && !PREPOSITIONS.has(words$2(item)[0] ?? "")) && COPULA.test(before)) return "A string of adjectives after \"is\".";
42334
+ if (items.every((item) => /^\w+ed\b/i.test(item) && words$2(item).length <= 3)) return "A string of past participles.";
42335
+ return null;
42336
+ }
42013
42337
  function triads(ctx) {
42014
42338
  const rule = STRUCTURE_RULES.triad;
42015
42339
  if (ctx.level(rule) === "off") return;
42016
- const found = [];
42017
42340
  for (const block of ctx.blocks([
42018
42341
  "paragraph",
42019
42342
  "quote",
42020
- "prop"
42343
+ "prop",
42344
+ "list"
42021
42345
  ])) {
42022
- TRIAD.lastIndex = 0;
42023
- for (const match of block.norm.matchAll(TRIAD)) found.push({
42024
- block,
42025
- start: match.index ?? 0,
42026
- end: (match.index ?? 0) + match[0].length
42027
- });
42346
+ const taken = [];
42028
42347
  ABSTRACT_TRIAD.lastIndex = 0;
42029
- for (const match of block.norm.matchAll(ABSTRACT_TRIAD)) ctx.hit(rule, block, match.index ?? 0, (match.index ?? 0) + match[0].length, "Three abstract nouns in a row sound weighty but say little.", "Keep the one that matters and give an example of it.");
42030
- }
42031
- const limit = Math.max(3, Math.ceil(ctx.proseWords / 100));
42032
- if (found.length > limit) {
42033
- const third = found[limit];
42034
- ctx.hit(rule, third.block, third.start, third.end, `${found.length} lists of three in ${ctx.proseWords.toLocaleString("en-US")} words of prose. More than about one per 100 words sounds formulaic.`, "Turn some of them into two items, four items or a sentence that ranks them.");
42348
+ for (const match of block.norm.matchAll(ABSTRACT_TRIAD)) {
42349
+ const start = match.index ?? 0;
42350
+ taken.push([start, start + match[0].length]);
42351
+ ctx.hit(rule, block, start, start + match[0].length, "Three abstract nouns in a row sound weighty but say little.", "Keep the one that matters and give an example of it.");
42352
+ }
42353
+ for (const sentence of ctx.sentences(block)) {
42354
+ SERIES.lastIndex = 0;
42355
+ for (const match of sentence.text.matchAll(SERIES)) {
42356
+ let from = match.index ?? 0;
42357
+ const raw = match[0].split(/,\s*(?:(?:and|or)\s+)?|\s+(?:and|or)\s+(?=[^,]*$)/);
42358
+ if (raw.length < 3) continue;
42359
+ const items = [...raw];
42360
+ const head = raw[0] ?? "";
42361
+ const lead = /^.*\b(?:is|are|was|were|'s|feels?|looks?|stays?|remains?|seems?|of|for|to|with|by|from|on|in|like|including|as)\s+/i.exec(head);
42362
+ if (lead && words$2(head.slice(lead[0].length)).length) {
42363
+ items[0] = head.slice(lead[0].length);
42364
+ from += lead[0].length;
42365
+ }
42366
+ const end = sentence.start + (match.index ?? 0) + match[0].length;
42367
+ const before = block.norm.slice(sentence.start, sentence.start + from);
42368
+ const echo = words$2(raw[1] ?? "")[0]?.toLowerCase() ?? "";
42369
+ const inFirst = new RegExp(`\\b${echo}\\b(?!.*\\b${echo}\\b)`, "i").exec(head);
42370
+ if (ECHO.has(echo) && inFirst && inFirst.index > 0) {
42371
+ items[0] = head.slice(inFirst.index);
42372
+ from = (match.index ?? 0) + inFirst.index;
42373
+ }
42374
+ const plain = formulaTriad(raw, "");
42375
+ const why = plain ?? formulaTriad(items, before);
42376
+ if (!why) continue;
42377
+ if (plain) from = match.index ?? 0;
42378
+ const start = sentence.start + from;
42379
+ if (taken.some(([a, b]) => start < b && end > a)) continue;
42380
+ taken.push([start, end]);
42381
+ ctx.hit(rule, block, start, end, `A list of ${items.length} that reads as a formula. ${why}`, "Keep the items the reader needs. If all of them are needed, write them plainly or as two sentences; if one was added for rhythm, cut it.");
42382
+ }
42383
+ }
42035
42384
  }
42036
42385
  }
42037
42386
  function countedPatterns(ctx) {
@@ -42082,6 +42431,42 @@ function boldLabels(ctx) {
42082
42431
  }
42083
42432
  report();
42084
42433
  }
42434
+ function boldEmphasis(ctx) {
42435
+ const rule = STRUCTURE_RULES.boldEmphasis;
42436
+ if (ctx.level(rule) === "off") return;
42437
+ const markdown = ctx.doc.format === "md" || ctx.doc.format === "mdx";
42438
+ const re = markdown ? /(\*\*|__)(?=\S)[^*_\n]+?\1/g : /<(strong|b)\b[^>]*>[^<]{1,200}<\/\1>/gi;
42439
+ const prose = ctx.blocks([
42440
+ "paragraph",
42441
+ "list",
42442
+ "quote",
42443
+ "prop"
42444
+ ]);
42445
+ const spans = [];
42446
+ for (const found of ctx.doc.source.matchAll(re)) {
42447
+ const open = markdown ? 2 : found[0].indexOf(">") + 1;
42448
+ const from = (found.index ?? 0) + open;
42449
+ const to = (found.index ?? 0) + found[0].length - (markdown ? 2 : found[0].length - found[0].lastIndexOf("<"));
42450
+ for (const block of prose) {
42451
+ const first = block.map[0] ?? 0;
42452
+ const last = block.map[block.map.length - 1] ?? 0;
42453
+ if (from < first || from > last) continue;
42454
+ const start = block.map.indexOf(from);
42455
+ if (start <= 0 && block.kind === "list") break;
42456
+ if (start < 0) break;
42457
+ let end = start;
42458
+ while (end < block.map.length && (block.map[end] ?? 0) < to) end++;
42459
+ spans.push({
42460
+ block,
42461
+ start,
42462
+ end
42463
+ });
42464
+ break;
42465
+ }
42466
+ }
42467
+ if (spans.length < 3 || spans.length * 100 <= ctx.proseWords) return;
42468
+ for (const span of spans) ctx.hit(rule, span.block, span.start, span.end, `${spans.length} bold phrases in ${ctx.proseWords.toLocaleString("en-US")} words of prose. Bold this often stops meaning "important".`, "Remove the bold here. Keep bold, if any, for the one thing a skimming reader must not miss.");
42469
+ }
42085
42470
  function runStructureRules(ctx) {
42086
42471
  negativeParallelism(ctx);
42087
42472
  contrastReframe(ctx);
@@ -42095,6 +42480,7 @@ function runStructureRules(ctx) {
42095
42480
  triads(ctx);
42096
42481
  countedPatterns(ctx);
42097
42482
  boldLabels(ctx);
42483
+ boldEmphasis(ctx);
42098
42484
  }
42099
42485
  // packages/core/dist/lint/types.js
42100
42486
  const LINT_PRESETS = [
@@ -42129,6 +42515,37 @@ const LINT_RULES = [
42129
42515
  })),
42130
42516
  ...Object.values(SEO_RULES)
42131
42517
  ];
42518
+ const LINT_REVIEW_LENSES = {
42519
+ contrast: "A \", not X\" tail that may or may not be a needed correction.",
42520
+ metaphor: "A figure of speech that may not fit, such as a thing that \"waits\" or \"wants\".",
42521
+ evidence: "A superlative with no number or source nearby.",
42522
+ "mic-drop": "A short closing line that may only restate the paragraph.",
42523
+ "swap-test": "A headline or description that could sit on a competitor’s page unchanged.",
42524
+ proof: "A customer count or percentage that needs a source.",
42525
+ staccato: "Three or more very short sentences in a row that may be a drumbeat.",
42526
+ anaphora: "Several sentences in a row that open with the same word, for rhythm."
42527
+ };
42528
+ function lintRulesText(only) {
42529
+ const keep = (category) => !only?.length || only.includes(category);
42530
+ const lines = [];
42531
+ for (const category of LINT_CATEGORIES) {
42532
+ if (!keep(category)) continue;
42533
+ lines.push(`${CATEGORY_NAMES[category]} (${category})`);
42534
+ for (const rule of LINT_RULES) {
42535
+ if (rule.family !== category) continue;
42536
+ const presets = Object.entries(rule.presets ?? {}).map(([preset, level]) => `${preset} ${level}`).join(", ");
42537
+ lines.push(` ${rule.id} ${rule.level}${presets ? ` (${presets})` : ""}`, ` ${rule.description}`);
42538
+ }
42539
+ const lenses = Object.entries(LINT_REVIEW_LENSES).filter(([lens]) => lensCategory(lens) === category);
42540
+ if (lenses.length) {
42541
+ lines.push(" Review lenses (judgement calls, never errors):");
42542
+ for (const [lens, description] of lenses) lines.push(` ${lens}`, ` ${description}`);
42543
+ }
42544
+ lines.push("");
42545
+ }
42546
+ lines.push("Skip a rule with --ignore <id>, or one line with a <!-- seo-lint-ignore <id> --> comment above it.");
42547
+ return lines.join("\n");
42548
+ }
42132
42549
  const AI_RULES = new Set(LINT_RULES.filter((rule) => rule.family === "ai").map((rule) => rule.id));
42133
42550
  const RULE_CATEGORY = new Map(LINT_RULES.map((rule) => [rule.id, rule.family]));
42134
42551
  const LENS_CATEGORY = {
@@ -44231,16 +44648,18 @@ async function analyticsBeside(access, site, range, before, pages) {
44231
44648
  }
44232
44649
  // packages/core/dist/commands/audit.js
44233
44650
  const AUDIT_SECTION = "weekly-audit";
44234
- const AUDIT_SECTION_TEXT = "What the weekly crawl of the site found (broken and redirected internal links, missing or duplicate titles, descriptions and H1s, noindex, canonicals to another page, sitemap URLs that do not return 200, thin pages, tags that JavaScript changes), with what is new, back or resolved since the crawl before. Only for sites added to the weekly site audit beta.";
44651
+ const AUDIT_SECTION_TEXT = "What the weekly crawl of the site found (broken and redirected internal links, missing, empty or duplicate titles and descriptions, missing H1s, noindex for Google, canonicals to another page, sitemap URLs that do not return 200, thin pages, tags that JavaScript changes), with what is new, back or resolved since the crawl before. Only for sites added to the weekly site audit beta.";
44235
44652
  const AUDIT_RULES = {
44236
44653
  link_to_error: "Internal links to a page that returns an error",
44237
44654
  link_to_redirect: "Internal links to a redirect",
44238
44655
  link_failed: "Internal links to a page that could not be fetched",
44239
44656
  title_missing: "Pages with no title",
44657
+ title_empty: "Pages with an empty title",
44240
44658
  title_duplicate: "Titles used by more than one page",
44241
44659
  description_missing: "Pages with no meta description",
44660
+ description_empty: "Pages with an empty meta description",
44242
44661
  h1_missing: "Pages with no H1",
44243
- noindex: "Pages that ask not to be indexed",
44662
+ noindex: "Pages that ask Google not to index them",
44244
44663
  canonical_elsewhere: "Pages whose canonical is another URL",
44245
44664
  sitemap_url_not_ok: "Sitemap URLs that do not return 200",
44246
44665
  sitemap_page_unlinked: "Sitemap pages no crawled page links to (heuristic: the crawl may not reach every page)",
@@ -46131,6 +46550,19 @@ async function pageSource(url, readPage) {
46131
46550
  };
46132
46551
  }
46133
46552
  async function runLint(input, readFiles, readPage) {
46553
+ if (input.rules) {
46554
+ const only = input.only?.length ? input.only : void 0;
46555
+ return {
46556
+ text: lintRulesText(only),
46557
+ data: { rules: LINT_RULES.filter((rule) => !only || only.includes(rule.family)).map(({ id, family, level, presets, description }) => ({
46558
+ id,
46559
+ category: family,
46560
+ level,
46561
+ ...presets ? { presets } : {},
46562
+ description
46563
+ })) }
46564
+ };
46565
+ }
46134
46566
  const hasText = typeof input.text === "string" && input.text.length > 0;
46135
46567
  const hasPaths = Boolean(input.paths?.length);
46136
46568
  const hasUrl = typeof input.url === "string" && input.url.length > 0;
@@ -47627,13 +48059,14 @@ async function runPage(input, deps) {
47627
48059
  deps.progress?.(`Checking ${input.url}: the live page, Google index status, sitemaps and Search Console queries.`);
47628
48060
  const [live, inspection, sitemap, [now, then], markdown] = await Promise.all([
47629
48061
  leg("live page", deps.liveCheck(input.url), (value) => `Live page: ${value.first.status ?? value.first.error ?? "no answer"}${value.hops.length ? ` and ${value.hops.length} redirects` : ""}`),
47630
- leg("Google index status", inspectWithin(deps, input), (value) => value.index ? `Google index status: ${value.index.coverage ?? value.index.verdict ?? "no reason returned"}` : `Google index status: not available (${value.error?.code})`),
48062
+ leg("Google index status", inspectWithin(deps, input), (value) => value.index ? `Google index status: ${value.index.coverage ?? value.index.verdict ?? "no reason returned"}${value.savedAt ? ` (saved from ${value.savedAt})` : ""}` : `Google index status: not available (${value.error?.code})`),
47631
48063
  leg("sitemaps", sitemapUrls(input.site), (value) => `Sitemaps: ${value.urls.length} URLs from ${value.files} ${value.files === 1 ? "file" : "files"}${value.partial ? " (partial)" : ""}`),
47632
48064
  leg("Search Console queries", Promise.all([query(range), query(before)]), ([current]) => `Search Console: ${current.rows.length} queries`),
47633
48065
  full && deps.pageMarkdown ? leg("main content", deps.pageMarkdown(input.url).catch((error) => ({ error: error instanceof Error ? error.message : String(error) })), () => "Main content read") : null
47634
48066
  ]);
47635
48067
  const indexed = inspection.index;
47636
48068
  const indexError = inspection.error;
48069
+ const savedAt = inspection.savedAt;
47637
48070
  const facts = live.first;
47638
48071
  const reason = indexed ? indexingRow({
47639
48072
  url: input.url,
@@ -47680,7 +48113,7 @@ async function runPage(input, deps) {
47680
48113
  title: `${input.url} in ${input.site}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
47681
48114
  notes: [
47682
48115
  `Live page (fetched now):\n${pageFactsTable([facts], null)}${live.hops.length ? `\nRedirects: ${live.hops.map((hop) => `${hop.status} to ${hop.to}`).join(", ")}${live.last ? `, which returns ${live.last.status ?? live.last.error}` : ", then loop or pass 5 hops"}.` : ""}`,
47683
- indexed ? `Google index status:\n${table$1([indexed], Object.keys(indexed), null)}` : indexErrorText(indexError, input, deps),
48116
+ indexed ? `Google index status${savedAt ? ` (from the inspection saved at ${savedAt}, within the last 7 days, so no quota was used; in the CLI, add --refresh to ask Google again)` : ""}:\n${table$1([indexed], Object.keys(indexed), null)}` : indexErrorText(indexError, input, deps),
47684
48117
  ...reason ? [`Google's reason: ${reason.reason}. Label: ${reason.label}. ${reason.why} (Labels are rules over this evidence, not Google's; ${deps.call("index")} explains them.)`] : [],
47685
48118
  ...indexed && /^URL is unknown to Google$/i.test(indexed.coverage ?? "") ? [unknownNext(input, sitemap.urls.includes(input.url), deps)] : [],
47686
48119
  `Query totals: clicks ${sum(now.rows, "clicks")} (was ${sum(then.rows, "clicks")}), impressions ${sum(now.rows, "impressions")} (was ${sum(then.rows, "impressions")}). Search Console hides rare queries, so these can be lower than the page total.`,
@@ -47710,6 +48143,7 @@ async function runPage(input, deps) {
47710
48143
  page: facts,
47711
48144
  index: indexed,
47712
48145
  indexError,
48146
+ indexInspectedAt: savedAt,
47713
48147
  reason,
47714
48148
  totalQueries: rows.length,
47715
48149
  queries: rows.slice(offset, offset + 50),
@@ -47719,6 +48153,14 @@ async function runPage(input, deps) {
47719
48153
  }
47720
48154
  const INSPECT_TIMEOUT_MS = 6e4;
47721
48155
  async function inspectWithin(deps, input) {
48156
+ try {
48157
+ const saved = deps.savedInspections?.read(input.site, [input.url]).get(input.url);
48158
+ if (saved) return {
48159
+ index: saved.status,
48160
+ error: null,
48161
+ savedAt: saved.inspectedAt
48162
+ };
48163
+ } catch {}
47722
48164
  let timer;
47723
48165
  const stop = new AbortController();
47724
48166
  const timeout = new Promise((resolve) => {
@@ -47731,17 +48173,25 @@ async function inspectWithin(deps, input) {
47731
48173
  }, INSPECT_TIMEOUT_MS);
47732
48174
  });
47733
48175
  try {
47734
- return await Promise.race([deps.inspect(input.site, input.url, stop.signal).then((index) => ({
47735
- index,
47736
- error: null
47737
- })), timeout.then((error) => ({
48176
+ return await Promise.race([deps.inspect(input.site, input.url, stop.signal).then((index) => {
48177
+ try {
48178
+ deps.savedInspections?.save(input.site, input.url, index);
48179
+ } catch {}
48180
+ return {
48181
+ index,
48182
+ error: null,
48183
+ savedAt: null
48184
+ };
48185
+ }), timeout.then((error) => ({
47738
48186
  index: null,
47739
- error
48187
+ error,
48188
+ savedAt: null
47740
48189
  }))]);
47741
48190
  } catch (error) {
47742
48191
  const known = commandErrorFrom(error);
47743
48192
  return {
47744
48193
  index: null,
48194
+ savedAt: null,
47745
48195
  error: {
47746
48196
  code: known instanceof CommandError ? known.code : "GOOGLE_ERROR",
47747
48197
  message: known instanceof Error ? known.message : String(known)
@@ -48243,7 +48693,8 @@ const lintInput = {
48243
48693
  meta_kind: z.enum(LINT_META_KINDS).optional().describe("Optional, with the meta preset: title or description, what a line with no \"title:\" or \"description:\" label is. Default: a line up to 70 characters is a title, a longer one a description."),
48244
48694
  ignore_rules: z.array(z.string().min(1).max(60)).max(50).optional().describe("Optional rule ids or review lenses to skip."),
48245
48695
  only: z.array(z.enum(LINT_CATEGORIES)).max(LINT_CATEGORIES.length).optional().describe("Optional categories to check: ai (patterns common in AI-written text), copy (weak copy and house style, such as dashes) or seo (titles, descriptions, headings). Default: all."),
48246
- ignore_phrases: z.array(z.string().min(1).max(200)).max(50).optional().describe("Optional phrases to allow, such as a product name a rule keeps matching.")
48696
+ ignore_phrases: z.array(z.string().min(1).max(200)).max(50).optional().describe("Optional phrases to allow, such as a product name a rule keeps matching."),
48697
+ rules: z.boolean().optional().describe("Optional. true lists every rule with its id, category, level and description instead of checking text; only limits the list, such as only [\"ai\"].")
48247
48698
  };
48248
48699
  const SHARED_TOOLS = [
48249
48700
  tool({
@@ -48520,7 +48971,8 @@ const SHARED_TOOLS = [
48520
48971
  ignoreRules: input.ignore_rules,
48521
48972
  ignorePhrases: input.ignore_phrases,
48522
48973
  only: input.only,
48523
- metaKind: input.meta_kind
48974
+ metaKind: input.meta_kind,
48975
+ rules: input.rules
48524
48976
  }, surface.readFiles, pageMarkdown && ((url) => pageMarkdown(url, 2e5)));
48525
48977
  }
48526
48978
  })