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/cli.js +73 -73
- package/dist/cli.js.map +1 -1
- package/dist/{dist-CWH3D29C.js → dist-B9mN7y6S.js} +1 -1
- package/dist/{dist-DzEqoO8B.js → dist-BjB7Lfd6.js} +138 -137
- package/dist/dist-BjB7Lfd6.js.map +1 -0
- package/dist/{esm-CkObTzDg.js → esm-G8B5TPzB.js} +2 -2
- package/dist/{esm-CkObTzDg.js.map → esm-G8B5TPzB.js.map} +1 -1
- package/dist/index.d.ts +222 -79
- package/dist/index.js +701 -243
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +690 -238
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
- package/dist/dist-DzEqoO8B.js.map +0 -1
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-
|
|
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
|
-
|
|
13811
|
-
|
|
13812
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
19204
|
-
|
|
19205
|
-
|
|
19206
|
-
|
|
19207
|
-
|
|
19208
|
-
|
|
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
|
-
|
|
19213
|
-
|
|
19214
|
-
|
|
19215
|
-
|
|
19216
|
-
|
|
19217
|
-
|
|
19218
|
-
|
|
19219
|
-
|
|
19220
|
-
|
|
19221
|
-
|
|
19222
|
-
|
|
19223
|
-
|
|
19224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20439
|
-
|
|
20440
|
-
|
|
20441
|
-
|
|
20442
|
-
|
|
20443
|
-
|
|
20444
|
-
|
|
20445
|
-
|
|
20446
|
-
|
|
20447
|
-
|
|
20448
|
-
|
|
20449
|
-
|
|
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 (
|
|
20452
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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" &&
|
|
26139
|
-
const availability = servesHtml ? "
|
|
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 =
|
|
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)
|
|
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), {
|
|
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), {
|
|
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, {
|
|
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: "
|
|
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: "
|
|
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) => `
|
|
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,
|
|
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\", \"
|
|
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
|
|
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: [
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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 <=
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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))
|
|
42030
|
-
|
|
42031
|
-
|
|
42032
|
-
|
|
42033
|
-
|
|
42034
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
47736
|
-
|
|
47737
|
-
|
|
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
|
})
|