@goflag/cli 0.1.4 → 0.2.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/README.md +333 -24
- package/dist/{chunk-3777NRM4.js → chunk-6WOWKLXY.js} +762 -203
- package/dist/chunk-6WOWKLXY.js.map +1 -0
- package/dist/cli.js +45 -3
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +548 -1
- package/dist/index.js +1 -1
- package/package.json +6 -5
- package/dist/chunk-3777NRM4.js.map +0 -1
|
@@ -1217,241 +1217,444 @@ function canonicaliseUrl(href, base, options = {}) {
|
|
|
1217
1217
|
return url.toString();
|
|
1218
1218
|
}
|
|
1219
1219
|
|
|
1220
|
+
// src/lib/rules/evaluate.ts
|
|
1221
|
+
function bandFor(value, bands) {
|
|
1222
|
+
const inside = ([min, max]) => value >= min && value <= max;
|
|
1223
|
+
if (inside(bands.ideal)) return { status: "pass", band: "ideal" };
|
|
1224
|
+
if (inside(bands.acceptable)) return { status: "warn", band: "acceptable" };
|
|
1225
|
+
return { status: "warn", band: "poor" };
|
|
1226
|
+
}
|
|
1227
|
+
function evaluateRules(extraction, rules) {
|
|
1228
|
+
const findings = [];
|
|
1229
|
+
const crashes = [];
|
|
1230
|
+
for (const rule of rules) {
|
|
1231
|
+
try {
|
|
1232
|
+
if (rule.kind === "boolean") {
|
|
1233
|
+
const verdict = rule.evaluate(extraction);
|
|
1234
|
+
findings.push({
|
|
1235
|
+
ruleId: rule.id,
|
|
1236
|
+
kind: rule.kind,
|
|
1237
|
+
status: verdict.status,
|
|
1238
|
+
severity: verdict.status === "fail" ? rule.severity : void 0,
|
|
1239
|
+
observed: verdict.observed,
|
|
1240
|
+
expected: rule.expected,
|
|
1241
|
+
message: verdict.message,
|
|
1242
|
+
origin: verdict.origin,
|
|
1243
|
+
rigor: rule.rigor,
|
|
1244
|
+
sources: rule.sources
|
|
1245
|
+
});
|
|
1246
|
+
} else {
|
|
1247
|
+
const verdict = rule.evaluate(extraction);
|
|
1248
|
+
findings.push({
|
|
1249
|
+
ruleId: rule.id,
|
|
1250
|
+
kind: rule.kind,
|
|
1251
|
+
status: verdict.status,
|
|
1252
|
+
band: verdict.band,
|
|
1253
|
+
severity: verdict.status === "warn" && verdict.band && verdict.band !== "ideal" ? rule.severityByBand[verdict.band] : void 0,
|
|
1254
|
+
observed: verdict.observed,
|
|
1255
|
+
expected: rule.expected,
|
|
1256
|
+
message: verdict.message,
|
|
1257
|
+
origin: verdict.origin,
|
|
1258
|
+
rigor: rule.rigor,
|
|
1259
|
+
sources: rule.sources
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
} catch (err) {
|
|
1263
|
+
crashes.push({
|
|
1264
|
+
ruleId: rule.id,
|
|
1265
|
+
message: err instanceof Error ? err.message : String(err)
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
return { findings, crashes };
|
|
1270
|
+
}
|
|
1271
|
+
function findingsToIssues(result, rules) {
|
|
1272
|
+
const byId = new Map(rules.map((rule) => [rule.id, rule]));
|
|
1273
|
+
const issues = [];
|
|
1274
|
+
for (const finding of result.findings) {
|
|
1275
|
+
if (finding.status !== "fail" && finding.status !== "warn") continue;
|
|
1276
|
+
issues.push({
|
|
1277
|
+
ruleId: finding.ruleId,
|
|
1278
|
+
severity: finding.severity ?? "info",
|
|
1279
|
+
message: finding.message ?? `Expected ${finding.expected}.`,
|
|
1280
|
+
origin: finding.origin,
|
|
1281
|
+
fix: byId.get(finding.ruleId)?.fix
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
for (const crash of result.crashes) {
|
|
1285
|
+
issues.push({
|
|
1286
|
+
ruleId: "engine.rule-crashed",
|
|
1287
|
+
severity: "info",
|
|
1288
|
+
message: `Rule \`${crash.ruleId}\` threw: ${crash.message}`
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
return issues;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1220
1294
|
// src/lib/rules/index.ts
|
|
1221
|
-
var
|
|
1222
|
-
var
|
|
1223
|
-
var
|
|
1224
|
-
var
|
|
1295
|
+
var TITLE_IDEAL = [10, 60];
|
|
1296
|
+
var TITLE_ACCEPTABLE = [5, 70];
|
|
1297
|
+
var DESC_IDEAL = [50, 160];
|
|
1298
|
+
var DESC_ACCEPTABLE = [25, 200];
|
|
1225
1299
|
function tokens(value) {
|
|
1226
1300
|
if (!value) return [];
|
|
1227
1301
|
return value.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
1228
1302
|
}
|
|
1229
1303
|
var titleMissing = {
|
|
1230
1304
|
id: "title.missing",
|
|
1305
|
+
kind: "boolean",
|
|
1306
|
+
category: "document",
|
|
1231
1307
|
severity: "error",
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1308
|
+
title: "Every page needs a non-empty `<title>`",
|
|
1309
|
+
why: "The title element is required by the HTML standard and is the primary label search engines, browser tabs, bookmarks, and screen readers use for the page.",
|
|
1310
|
+
rigor: "spec-required",
|
|
1311
|
+
sources: ["whatwg-html-title"],
|
|
1312
|
+
reads: ["document.title"],
|
|
1313
|
+
expected: "a non-empty `<title>` element",
|
|
1314
|
+
relates: ["title.length", "og.title.missing"],
|
|
1315
|
+
fix: {
|
|
1316
|
+
title: "Add a <title> to <head>",
|
|
1317
|
+
snippet: [
|
|
1318
|
+
"// app/\u2026/page.tsx \u2014 App Router owns the <head>; never hand-write the tag.",
|
|
1319
|
+
'export const metadata = { title: "Page name \u2014 Site name" };'
|
|
1320
|
+
].join("\n"),
|
|
1321
|
+
language: "tsx"
|
|
1322
|
+
},
|
|
1323
|
+
evaluate: (ex) => {
|
|
1324
|
+
const observed = ex.document.title?.value?.trim();
|
|
1325
|
+
if (observed) return { status: "pass", observed };
|
|
1326
|
+
return {
|
|
1327
|
+
status: "fail",
|
|
1328
|
+
observed: observed ?? null,
|
|
1237
1329
|
message: "Page is missing a `<title>` element (or it is empty).",
|
|
1238
|
-
origin: { kind: "title" }
|
|
1239
|
-
|
|
1240
|
-
title: "Add a <title> to <head>",
|
|
1241
|
-
snippet: [
|
|
1242
|
-
"// app/\u2026/page.tsx \u2014 App Router owns the <head>; never hand-write the tag.",
|
|
1243
|
-
'export const metadata = { title: "Page name \u2014 Site name" };'
|
|
1244
|
-
].join("\n"),
|
|
1245
|
-
language: "tsx"
|
|
1246
|
-
}
|
|
1247
|
-
});
|
|
1330
|
+
origin: { kind: "title" }
|
|
1331
|
+
};
|
|
1248
1332
|
}
|
|
1249
1333
|
};
|
|
1250
1334
|
var titleLength = {
|
|
1251
1335
|
id: "title.length",
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1336
|
+
kind: "scored",
|
|
1337
|
+
category: "document",
|
|
1338
|
+
title: `Keep \`<title>\` between ${TITLE_IDEAL[0]} and ${TITLE_IDEAL[1]} characters`,
|
|
1339
|
+
why: "Long titles get truncated in search results and short ones waste the one line every result gets. The window is display folklore, not spec \u2014 Google states title length is not a ranking factor.",
|
|
1340
|
+
rigor: "heuristic",
|
|
1341
|
+
sources: ["google-title-link", "moz-title-tag"],
|
|
1342
|
+
reads: ["document.title"],
|
|
1343
|
+
bands: { ideal: TITLE_IDEAL, acceptable: TITLE_ACCEPTABLE },
|
|
1344
|
+
severityByBand: { acceptable: "warning", poor: "warning" },
|
|
1345
|
+
expected: `${TITLE_IDEAL[0]}\u2013${TITLE_IDEAL[1]} characters`,
|
|
1346
|
+
relates: ["title.missing"],
|
|
1347
|
+
evaluate: (ex) => {
|
|
1348
|
+
const value = ex.document.title?.value?.trim();
|
|
1349
|
+
if (!value) return { status: "na", observed: 0 };
|
|
1350
|
+
const { status, band } = bandFor(value.length, {
|
|
1351
|
+
ideal: TITLE_IDEAL,
|
|
1352
|
+
acceptable: TITLE_ACCEPTABLE
|
|
1262
1353
|
});
|
|
1354
|
+
if (status === "pass") return { status, band, observed: value.length };
|
|
1355
|
+
const direction = value.length < TITLE_IDEAL[0] ? "short" : "long";
|
|
1356
|
+
return {
|
|
1357
|
+
status,
|
|
1358
|
+
band,
|
|
1359
|
+
observed: value.length,
|
|
1360
|
+
message: `Title is ${value.length} characters \u2014 ${direction} of the recommended ${TITLE_IDEAL[0]}\u2013${TITLE_IDEAL[1]} window.`,
|
|
1361
|
+
origin: { kind: "title" }
|
|
1362
|
+
};
|
|
1263
1363
|
}
|
|
1264
1364
|
};
|
|
1265
1365
|
var descriptionMissing = {
|
|
1266
1366
|
id: "description.missing",
|
|
1367
|
+
kind: "boolean",
|
|
1368
|
+
category: "meta",
|
|
1267
1369
|
severity: "warning",
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1370
|
+
title: 'Provide a `<meta name="description">` on every indexable page',
|
|
1371
|
+
why: "The description feeds the search-result snippet. Without one, engines improvise from page text \u2014 usually worse than a sentence you wrote.",
|
|
1372
|
+
rigor: "spec-recommended",
|
|
1373
|
+
sources: ["whatwg-html-standard-metadata-names", "google-snippet"],
|
|
1374
|
+
reads: ["meta.description"],
|
|
1375
|
+
expected: 'a non-empty `<meta name="description">`',
|
|
1376
|
+
relates: ["description.length", "og.description.missing"],
|
|
1377
|
+
fix: {
|
|
1378
|
+
title: "Add a meta description",
|
|
1379
|
+
snippet: [
|
|
1380
|
+
"// app/\u2026/page.tsx",
|
|
1381
|
+
"export const metadata = {",
|
|
1382
|
+
' description: "One sentence that promises what this page delivers.",',
|
|
1383
|
+
"};"
|
|
1384
|
+
].join("\n"),
|
|
1385
|
+
language: "tsx"
|
|
1386
|
+
},
|
|
1387
|
+
evaluate: (ex) => {
|
|
1388
|
+
const observed = ex.meta.description?.value?.trim();
|
|
1389
|
+
if (observed) return { status: "pass", observed };
|
|
1390
|
+
return {
|
|
1391
|
+
status: "fail",
|
|
1392
|
+
observed: observed ?? null,
|
|
1273
1393
|
message: 'Page has no `<meta name="description">`.',
|
|
1274
|
-
origin: { kind: "meta", name: "description" }
|
|
1275
|
-
|
|
1276
|
-
title: "Add a meta description",
|
|
1277
|
-
snippet: [
|
|
1278
|
-
"// app/\u2026/page.tsx",
|
|
1279
|
-
"export const metadata = {",
|
|
1280
|
-
' description: "One sentence that promises what this page delivers.",',
|
|
1281
|
-
"};"
|
|
1282
|
-
].join("\n"),
|
|
1283
|
-
language: "tsx"
|
|
1284
|
-
}
|
|
1285
|
-
});
|
|
1394
|
+
origin: { kind: "meta", name: "description" }
|
|
1395
|
+
};
|
|
1286
1396
|
}
|
|
1287
1397
|
};
|
|
1288
1398
|
var descriptionLength = {
|
|
1289
1399
|
id: "description.length",
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1400
|
+
kind: "scored",
|
|
1401
|
+
category: "meta",
|
|
1402
|
+
title: `Keep descriptions between ${DESC_IDEAL[0]} and ${DESC_IDEAL[1]} characters`,
|
|
1403
|
+
why: "Snippets truncate around 160 characters and very short descriptions get replaced by improvised page text. Display folklore, not spec.",
|
|
1404
|
+
rigor: "heuristic",
|
|
1405
|
+
sources: ["google-snippet", "moz-meta-description"],
|
|
1406
|
+
reads: ["meta.description"],
|
|
1407
|
+
bands: { ideal: DESC_IDEAL, acceptable: DESC_ACCEPTABLE },
|
|
1408
|
+
severityByBand: { acceptable: "warning", poor: "warning" },
|
|
1409
|
+
expected: `${DESC_IDEAL[0]}\u2013${DESC_IDEAL[1]} characters`,
|
|
1410
|
+
relates: ["description.missing"],
|
|
1411
|
+
evaluate: (ex) => {
|
|
1412
|
+
const value = ex.meta.description?.value?.trim();
|
|
1413
|
+
if (!value) return { status: "na", observed: 0 };
|
|
1414
|
+
const { status, band } = bandFor(value.length, {
|
|
1415
|
+
ideal: DESC_IDEAL,
|
|
1416
|
+
acceptable: DESC_ACCEPTABLE
|
|
1300
1417
|
});
|
|
1418
|
+
if (status === "pass") return { status, band, observed: value.length };
|
|
1419
|
+
const direction = value.length < DESC_IDEAL[0] ? "short" : "long";
|
|
1420
|
+
return {
|
|
1421
|
+
status,
|
|
1422
|
+
band,
|
|
1423
|
+
observed: value.length,
|
|
1424
|
+
message: `Description is ${value.length} characters \u2014 ${direction} of the recommended ${DESC_IDEAL[0]}\u2013${DESC_IDEAL[1]} window.`,
|
|
1425
|
+
origin: { kind: "meta", name: "description" }
|
|
1426
|
+
};
|
|
1301
1427
|
}
|
|
1302
1428
|
};
|
|
1303
1429
|
var canonicalMissing = {
|
|
1304
1430
|
id: "canonical.missing",
|
|
1431
|
+
kind: "boolean",
|
|
1432
|
+
category: "canonical",
|
|
1305
1433
|
severity: "warning",
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1434
|
+
title: 'Declare a `<link rel="canonical">` so search engines pick the right URL',
|
|
1435
|
+
why: "Without a canonical, engines choose among duplicate URLs (tracking params, http/https, trailing slashes) themselves \u2014 and may split ranking signals across them.",
|
|
1436
|
+
rigor: "vendor-spec",
|
|
1437
|
+
sources: ["ietf-rfc6596", "google-canonicalization"],
|
|
1438
|
+
reads: ["meta.canonical"],
|
|
1439
|
+
expected: 'a `<link rel="canonical">` declaration',
|
|
1440
|
+
relates: ["canonical.absolute"],
|
|
1441
|
+
fix: {
|
|
1442
|
+
title: "Declare the canonical URL",
|
|
1443
|
+
snippet: [
|
|
1444
|
+
"// app/\u2026/page.tsx \u2014 relative canonicals resolve against metadataBase,",
|
|
1445
|
+
"// which defaults to localhost and silently breaks in production.",
|
|
1446
|
+
"export const metadata = {",
|
|
1447
|
+
" metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL!),",
|
|
1448
|
+
' alternates: { canonical: "/the-page" },',
|
|
1449
|
+
"};"
|
|
1450
|
+
].join("\n"),
|
|
1451
|
+
language: "tsx"
|
|
1452
|
+
},
|
|
1453
|
+
evaluate: (ex) => {
|
|
1454
|
+
const observed = ex.meta.canonical?.value;
|
|
1455
|
+
if (observed) return { status: "pass", observed };
|
|
1456
|
+
return {
|
|
1457
|
+
status: "fail",
|
|
1458
|
+
observed: null,
|
|
1310
1459
|
message: 'Page is missing `<link rel="canonical">`.',
|
|
1311
|
-
origin: { kind: "link", rel: "canonical" }
|
|
1312
|
-
|
|
1313
|
-
title: "Declare the canonical URL",
|
|
1314
|
-
snippet: [
|
|
1315
|
-
"// app/\u2026/page.tsx \u2014 relative canonicals resolve against metadataBase,",
|
|
1316
|
-
"// which defaults to localhost and silently breaks in production.",
|
|
1317
|
-
"export const metadata = {",
|
|
1318
|
-
" metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL!),",
|
|
1319
|
-
' alternates: { canonical: "/the-page" },',
|
|
1320
|
-
"};"
|
|
1321
|
-
].join("\n"),
|
|
1322
|
-
language: "tsx"
|
|
1323
|
-
}
|
|
1324
|
-
});
|
|
1460
|
+
origin: { kind: "link", rel: "canonical" }
|
|
1461
|
+
};
|
|
1325
1462
|
}
|
|
1326
1463
|
};
|
|
1327
1464
|
var canonicalAbsolute = {
|
|
1328
1465
|
id: "canonical.absolute",
|
|
1466
|
+
kind: "boolean",
|
|
1467
|
+
category: "canonical",
|
|
1329
1468
|
severity: "error",
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1469
|
+
title: '`rel="canonical"` must be an absolute, fully-qualified URL',
|
|
1470
|
+
why: "Consumers read the literal attribute value, not the browser-resolved URL. A relative canonical means every consumer resolves it against whatever base it assumes \u2014 including the wrong origin.",
|
|
1471
|
+
rigor: "vendor-spec",
|
|
1472
|
+
sources: ["ietf-rfc6596", "google-canonicalization", "whatwg-url"],
|
|
1473
|
+
reads: ["meta.canonical"],
|
|
1474
|
+
expected: "an absolute `http(s)://` canonical URL",
|
|
1475
|
+
relates: ["canonical.missing"],
|
|
1476
|
+
evaluate: (ex) => {
|
|
1477
|
+
const canonical = ex.meta.canonical;
|
|
1478
|
+
const raw = canonical?.raw?.trim();
|
|
1479
|
+
if (!raw) return { status: "na", observed: null };
|
|
1480
|
+
if (/^https?:\/\//i.test(raw)) return { status: "pass", observed: raw };
|
|
1481
|
+
return {
|
|
1482
|
+
status: "fail",
|
|
1483
|
+
observed: raw,
|
|
1484
|
+
message: `Canonical is "${raw}" \u2014 must be an absolute http(s) URL (consumers see the raw value, not the resolved "${canonical?.value ?? raw}").`,
|
|
1338
1485
|
origin: { kind: "link", rel: "canonical" }
|
|
1339
|
-
}
|
|
1486
|
+
};
|
|
1340
1487
|
}
|
|
1341
1488
|
};
|
|
1342
1489
|
var viewportMissing = {
|
|
1343
1490
|
id: "viewport.missing",
|
|
1491
|
+
kind: "boolean",
|
|
1492
|
+
category: "meta",
|
|
1344
1493
|
severity: "warning",
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1494
|
+
title: 'Declare a `<meta name="viewport">` so mobile browsers render at the right scale',
|
|
1495
|
+
why: "Without a viewport declaration mobile browsers render at desktop width and scale down \u2014 unreadable text and a mobile-usability demotion.",
|
|
1496
|
+
rigor: "guideline",
|
|
1497
|
+
sources: ["mdn-viewport", "whatwg-html-standard-metadata-names"],
|
|
1498
|
+
reads: ["meta.viewport"],
|
|
1499
|
+
expected: 'a `<meta name="viewport">` declaration',
|
|
1500
|
+
fix: {
|
|
1501
|
+
title: "Add a viewport meta",
|
|
1502
|
+
snippet: [
|
|
1503
|
+
"// app/layout.tsx \u2014 a dedicated export, not part of `metadata`.",
|
|
1504
|
+
'export const viewport = { width: "device-width", initialScale: 1 };'
|
|
1505
|
+
].join("\n"),
|
|
1506
|
+
language: "tsx"
|
|
1507
|
+
},
|
|
1508
|
+
evaluate: (ex) => {
|
|
1509
|
+
const observed = ex.meta.viewport?.value?.trim();
|
|
1510
|
+
if (observed) return { status: "pass", observed };
|
|
1511
|
+
return {
|
|
1512
|
+
status: "fail",
|
|
1513
|
+
observed: null,
|
|
1350
1514
|
message: 'Page has no `<meta name="viewport">` \u2014 mobile browsers will render at desktop width.',
|
|
1351
|
-
origin: { kind: "meta", name: "viewport" }
|
|
1352
|
-
|
|
1353
|
-
title: "Add a viewport meta",
|
|
1354
|
-
snippet: [
|
|
1355
|
-
"// app/layout.tsx \u2014 a dedicated export, not part of `metadata`.",
|
|
1356
|
-
'export const viewport = { width: "device-width", initialScale: 1 };'
|
|
1357
|
-
].join("\n"),
|
|
1358
|
-
language: "tsx"
|
|
1359
|
-
}
|
|
1360
|
-
});
|
|
1515
|
+
origin: { kind: "meta", name: "viewport" }
|
|
1516
|
+
};
|
|
1361
1517
|
}
|
|
1362
1518
|
};
|
|
1363
1519
|
var ogTitleMissing = {
|
|
1364
1520
|
id: "og.title.missing",
|
|
1521
|
+
kind: "boolean",
|
|
1522
|
+
category: "opengraph",
|
|
1365
1523
|
severity: "warning",
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1524
|
+
title: "Set an explicit `og:title` instead of relying on `<title>` fallback",
|
|
1525
|
+
why: "og:title is one of the four properties the Open Graph protocol requires. Consumers that fall back to `<title>` drag site-name boilerplate into the unfurl.",
|
|
1526
|
+
rigor: "vendor-spec",
|
|
1527
|
+
sources: ["ogp"],
|
|
1528
|
+
reads: ["openGraph.title"],
|
|
1529
|
+
expected: "an explicit `og:title`",
|
|
1530
|
+
relates: ["title.missing", "og.image.missing"],
|
|
1531
|
+
evaluate: (ex) => {
|
|
1532
|
+
const observed = ex.openGraph.title?.value?.trim();
|
|
1533
|
+
if (observed) return { status: "pass", observed };
|
|
1534
|
+
return {
|
|
1535
|
+
status: "fail",
|
|
1536
|
+
observed: null,
|
|
1370
1537
|
message: "Page has no `og:title`; consumers will fall back to `<title>` (or nothing).",
|
|
1371
1538
|
origin: { kind: "meta", property: "og:title" }
|
|
1372
|
-
}
|
|
1539
|
+
};
|
|
1373
1540
|
}
|
|
1374
1541
|
};
|
|
1375
1542
|
var ogDescriptionMissing = {
|
|
1376
1543
|
id: "og.description.missing",
|
|
1544
|
+
kind: "boolean",
|
|
1545
|
+
category: "opengraph",
|
|
1377
1546
|
severity: "info",
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1547
|
+
title: "Set an explicit `og:description` for richer link unfurls",
|
|
1548
|
+
why: "A page that opted into Open Graph but omitted og:description gets unfurls that fall back to the meta description \u2014 or to nothing.",
|
|
1549
|
+
rigor: "vendor-spec",
|
|
1550
|
+
sources: ["ogp", "meta-og-sharing"],
|
|
1551
|
+
reads: ["openGraph.description", "openGraph.title", "openGraph.images", "openGraph.url"],
|
|
1552
|
+
expected: "an `og:description` alongside the other `og:*` tags",
|
|
1553
|
+
relates: ["description.missing"],
|
|
1554
|
+
evaluate: (ex) => {
|
|
1555
|
+
const observed = ex.openGraph.description?.value?.trim();
|
|
1556
|
+
if (observed) return { status: "pass", observed };
|
|
1381
1557
|
const hasOtherOg = Boolean(
|
|
1382
|
-
|
|
1558
|
+
ex.openGraph.title?.value || ex.openGraph.images.length > 0 || ex.openGraph.url?.value
|
|
1383
1559
|
);
|
|
1384
|
-
if (!hasOtherOg) return;
|
|
1385
|
-
return
|
|
1560
|
+
if (!hasOtherOg) return { status: "na", observed: null };
|
|
1561
|
+
return {
|
|
1562
|
+
status: "fail",
|
|
1563
|
+
observed: null,
|
|
1386
1564
|
message: "Page has `og:*` tags but no `og:description`; unfurls will fall back to the meta description (or nothing).",
|
|
1387
1565
|
origin: { kind: "meta", property: "og:description" }
|
|
1388
|
-
}
|
|
1566
|
+
};
|
|
1389
1567
|
}
|
|
1390
1568
|
};
|
|
1391
1569
|
var ogImageMissing = {
|
|
1392
1570
|
id: "og.image.missing",
|
|
1571
|
+
kind: "boolean",
|
|
1572
|
+
category: "opengraph",
|
|
1393
1573
|
severity: "warning",
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1574
|
+
title: "Provide at least one `og:image` so links unfurl with a preview",
|
|
1575
|
+
why: "og:image is one of the four required Open Graph properties, and the one with the most visible payoff: without it, unfurls are text-only or pick a random body image.",
|
|
1576
|
+
rigor: "vendor-spec",
|
|
1577
|
+
sources: ["ogp", "meta-og-sharing"],
|
|
1578
|
+
reads: ["openGraph.images"],
|
|
1579
|
+
expected: "at least one `og:image`",
|
|
1580
|
+
relates: ["og.title.missing"],
|
|
1581
|
+
fix: {
|
|
1582
|
+
title: "Add an og:image",
|
|
1583
|
+
snippet: [
|
|
1584
|
+
"// app/\u2026/page.tsx",
|
|
1585
|
+
"export const metadata = {",
|
|
1586
|
+
' openGraph: { images: [{ url: "/og.png", width: 1200, height: 630 }] },',
|
|
1587
|
+
"};",
|
|
1588
|
+
"// Or generate one per page: app/\u2026/opengraph-image.tsx with ImageResponse."
|
|
1589
|
+
].join("\n"),
|
|
1590
|
+
language: "tsx"
|
|
1591
|
+
},
|
|
1592
|
+
evaluate: (ex) => {
|
|
1593
|
+
if (ex.openGraph.images.length > 0) {
|
|
1594
|
+
return { status: "pass", observed: ex.openGraph.images.map((i) => i.url.value) };
|
|
1595
|
+
}
|
|
1596
|
+
return {
|
|
1597
|
+
status: "fail",
|
|
1598
|
+
observed: [],
|
|
1398
1599
|
message: "Page has no `og:image`. Link unfurls will fall back to text-only or a random body image.",
|
|
1399
|
-
origin: { kind: "meta", property: "og:image" }
|
|
1400
|
-
|
|
1401
|
-
title: "Add an og:image",
|
|
1402
|
-
snippet: [
|
|
1403
|
-
"// app/\u2026/page.tsx",
|
|
1404
|
-
"export const metadata = {",
|
|
1405
|
-
' openGraph: { images: [{ url: "/og.png", width: 1200, height: 630 }] },',
|
|
1406
|
-
"};",
|
|
1407
|
-
"// Or generate one per page: app/\u2026/opengraph-image.tsx with ImageResponse."
|
|
1408
|
-
].join("\n"),
|
|
1409
|
-
language: "tsx"
|
|
1410
|
-
}
|
|
1411
|
-
});
|
|
1600
|
+
origin: { kind: "meta", property: "og:image" }
|
|
1601
|
+
};
|
|
1412
1602
|
}
|
|
1413
1603
|
};
|
|
1414
1604
|
var robotsConflict = {
|
|
1415
1605
|
id: "robots.conflict",
|
|
1606
|
+
kind: "boolean",
|
|
1607
|
+
category: "robots",
|
|
1416
1608
|
severity: "error",
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
{
|
|
1609
|
+
title: "`robots`, `googlebot`, and `X-Robots-Tag` must not contradict each other",
|
|
1610
|
+
why: "When directives conflict, crawlers resolve them with the most restrictive interpretation \u2014 a stray `noindex` wins over the `index` you meant, and the page silently drops out of search.",
|
|
1611
|
+
rigor: "vendor-spec",
|
|
1612
|
+
sources: ["google-robots-meta"],
|
|
1613
|
+
reads: ["meta.robots", "meta.googlebot", "http.headers"],
|
|
1614
|
+
expected: "consistent indexing directives across meta tags and headers",
|
|
1615
|
+
evaluate: (ex) => {
|
|
1616
|
+
const candidates = [
|
|
1617
|
+
{
|
|
1618
|
+
name: "meta robots",
|
|
1619
|
+
tokens: new Set(tokens(ex.meta.robots?.value)),
|
|
1620
|
+
origin: { kind: "meta", name: "robots" }
|
|
1621
|
+
},
|
|
1622
|
+
{
|
|
1623
|
+
name: "meta googlebot",
|
|
1624
|
+
tokens: new Set(tokens(ex.meta.googlebot?.value)),
|
|
1625
|
+
origin: { kind: "meta", name: "googlebot" }
|
|
1626
|
+
},
|
|
1426
1627
|
{
|
|
1427
1628
|
name: "X-Robots-Tag header",
|
|
1428
|
-
tokens:
|
|
1629
|
+
tokens: new Set(tokens(ex.http.headers["x-robots-tag"])),
|
|
1429
1630
|
origin: { kind: "header", name: "x-robots-tag" }
|
|
1430
1631
|
}
|
|
1431
1632
|
];
|
|
1432
|
-
const
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
const
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
);
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
message: `Conflicting follow directives: ${nofollowers.map((s) => s.name).join(", ")} say \`nofollow\`, ${followers.map((s) => s.name).join(", ")} say \`follow\`.`,
|
|
1450
|
-
origin: nofollowers[0].origin
|
|
1451
|
-
})
|
|
1452
|
-
);
|
|
1633
|
+
const declarations = candidates.filter((d) => d.tokens.size > 0);
|
|
1634
|
+
const observed = Object.fromEntries(declarations.map((d) => [d.name, [...d.tokens].sort()]));
|
|
1635
|
+
if (declarations.length < 2) return { status: "na", observed };
|
|
1636
|
+
const conflicts = [];
|
|
1637
|
+
let origin;
|
|
1638
|
+
for (const [negative, positive] of [
|
|
1639
|
+
["noindex", "index"],
|
|
1640
|
+
["nofollow", "follow"]
|
|
1641
|
+
]) {
|
|
1642
|
+
const sayNo = declarations.filter((d) => d.tokens.has(negative));
|
|
1643
|
+
const sayYes = declarations.filter((d) => d.tokens.has(positive) && !d.tokens.has(negative));
|
|
1644
|
+
if (sayNo.length > 0 && sayYes.length > 0) {
|
|
1645
|
+
conflicts.push(
|
|
1646
|
+
`${sayNo.map((d) => d.name).join(", ")} say \`${negative}\`, ${sayYes.map((d) => d.name).join(", ")} say \`${positive}\``
|
|
1647
|
+
);
|
|
1648
|
+
origin ??= sayNo[0].origin;
|
|
1649
|
+
}
|
|
1453
1650
|
}
|
|
1454
|
-
return
|
|
1651
|
+
if (conflicts.length === 0) return { status: "pass", observed };
|
|
1652
|
+
return {
|
|
1653
|
+
status: "fail",
|
|
1654
|
+
observed,
|
|
1655
|
+
message: `Conflicting robots directives: ${conflicts.join("; ")}.`,
|
|
1656
|
+
origin
|
|
1657
|
+
};
|
|
1455
1658
|
}
|
|
1456
1659
|
};
|
|
1457
1660
|
var RULES = [
|
|
@@ -1468,8 +1671,99 @@ var RULES = [
|
|
|
1468
1671
|
viewportMissing
|
|
1469
1672
|
];
|
|
1470
1673
|
var RULE_BY_ID = new Map(RULES.map((r) => [r.id, r]));
|
|
1471
|
-
|
|
1472
|
-
|
|
1674
|
+
|
|
1675
|
+
// src/lib/rules/extraction/types.ts
|
|
1676
|
+
var EXTRACTION_VERSION = 1;
|
|
1677
|
+
|
|
1678
|
+
// src/lib/rules/extraction/from-page.ts
|
|
1679
|
+
function htmlFact(value, attribute) {
|
|
1680
|
+
if (value === void 0) return void 0;
|
|
1681
|
+
return { value, origin: { kind: "html", attribute }, raw: value };
|
|
1682
|
+
}
|
|
1683
|
+
function documentFromPage(page) {
|
|
1684
|
+
return {
|
|
1685
|
+
title: page.meta.title,
|
|
1686
|
+
lang: htmlFact(page.raw.htmlLang, "lang"),
|
|
1687
|
+
dir: htmlFact(page.raw.htmlDir, "dir"),
|
|
1688
|
+
charset: page.meta.charset,
|
|
1689
|
+
// `<base>` is an element, not an attribute of `<html>`; `TagOrigin` has
|
|
1690
|
+
// no dedicated kind for it, so the origin names the element instead.
|
|
1691
|
+
base: htmlFact(page.raw.baseHref, "base")
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
function openGraphFromPage(page) {
|
|
1695
|
+
const og = page.openGraph;
|
|
1696
|
+
return {
|
|
1697
|
+
title: og.title,
|
|
1698
|
+
type: og.type,
|
|
1699
|
+
url: og.url,
|
|
1700
|
+
description: og.description,
|
|
1701
|
+
siteName: og.siteName,
|
|
1702
|
+
locale: og.locale,
|
|
1703
|
+
localeAlternates: og.localeAlternates,
|
|
1704
|
+
images: og.images,
|
|
1705
|
+
other: og.unknown
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
function linksFromPage(page) {
|
|
1709
|
+
return {
|
|
1710
|
+
hreflang: page.links.alternates,
|
|
1711
|
+
icons: page.links.icons,
|
|
1712
|
+
manifest: page.links.manifest ? { href: page.links.manifest.href } : void 0,
|
|
1713
|
+
feeds: page.links.feeds.map(({ type, href, title }) => ({ type, href, title }))
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1716
|
+
function extractionFromPage(page) {
|
|
1717
|
+
return {
|
|
1718
|
+
extractionVersion: EXTRACTION_VERSION,
|
|
1719
|
+
fetchedAt: page.fetchedAt,
|
|
1720
|
+
http: {
|
|
1721
|
+
requestedUrl: page.fetch.requestedUrl,
|
|
1722
|
+
finalUrl: page.fetch.finalUrl,
|
|
1723
|
+
status: page.fetch.status,
|
|
1724
|
+
headers: page.fetch.headers,
|
|
1725
|
+
redirects: page.fetch.redirectCount,
|
|
1726
|
+
contentType: page.fetch.contentType
|
|
1727
|
+
},
|
|
1728
|
+
rendering: {
|
|
1729
|
+
mode: page.extractor.mode,
|
|
1730
|
+
escalated: page.extractor.escalated,
|
|
1731
|
+
escalationReason: page.extractor.escalationReason
|
|
1732
|
+
},
|
|
1733
|
+
document: documentFromPage(page),
|
|
1734
|
+
meta: {
|
|
1735
|
+
description: page.meta.description,
|
|
1736
|
+
viewport: page.meta.viewport,
|
|
1737
|
+
robots: page.meta.robots,
|
|
1738
|
+
googlebot: page.meta.googlebot,
|
|
1739
|
+
canonical: page.meta.canonical,
|
|
1740
|
+
themeColor: page.meta.themeColor,
|
|
1741
|
+
colorScheme: page.meta.colorScheme,
|
|
1742
|
+
referrer: page.meta.referrer,
|
|
1743
|
+
generator: page.meta.generator,
|
|
1744
|
+
applicationName: page.meta.applicationName,
|
|
1745
|
+
author: page.meta.author,
|
|
1746
|
+
keywords: page.meta.keywords
|
|
1747
|
+
},
|
|
1748
|
+
openGraph: openGraphFromPage(page),
|
|
1749
|
+
twitter: {
|
|
1750
|
+
card: page.twitter.card,
|
|
1751
|
+
site: page.twitter.site,
|
|
1752
|
+
creator: page.twitter.creator,
|
|
1753
|
+
title: page.twitter.title,
|
|
1754
|
+
description: page.twitter.description,
|
|
1755
|
+
image: page.twitter.image,
|
|
1756
|
+
imageAlt: page.twitter.imageAlt
|
|
1757
|
+
},
|
|
1758
|
+
links: linksFromPage(page),
|
|
1759
|
+
jsonLd: page.jsonLd.map(({ index, types, data, parseError, raw }) => ({
|
|
1760
|
+
index,
|
|
1761
|
+
types,
|
|
1762
|
+
data,
|
|
1763
|
+
parseError,
|
|
1764
|
+
raw
|
|
1765
|
+
}))
|
|
1766
|
+
};
|
|
1473
1767
|
}
|
|
1474
1768
|
|
|
1475
1769
|
// src/lib/core/lint.ts
|
|
@@ -1478,37 +1772,6 @@ var SEVERITY_RANK = {
|
|
|
1478
1772
|
warning: 1,
|
|
1479
1773
|
info: 2
|
|
1480
1774
|
};
|
|
1481
|
-
function lint(page, rules = RULES) {
|
|
1482
|
-
const issues = [];
|
|
1483
|
-
for (const rule of rules) {
|
|
1484
|
-
if (rule.appliesTo && !rule.appliesTo(page)) continue;
|
|
1485
|
-
let result;
|
|
1486
|
-
try {
|
|
1487
|
-
result = rule.check({
|
|
1488
|
-
page,
|
|
1489
|
-
issue: (input) => ({
|
|
1490
|
-
ruleId: rule.id,
|
|
1491
|
-
severity: input.severity ?? rule.severity,
|
|
1492
|
-
message: input.message,
|
|
1493
|
-
origin: input.origin,
|
|
1494
|
-
fix: input.fix,
|
|
1495
|
-
suggestion: input.suggestion
|
|
1496
|
-
})
|
|
1497
|
-
});
|
|
1498
|
-
} catch (err) {
|
|
1499
|
-
issues.push({
|
|
1500
|
-
ruleId: "engine.rule-crashed",
|
|
1501
|
-
severity: "info",
|
|
1502
|
-
message: `Rule \`${rule.id}\` threw: ${err instanceof Error ? err.message : String(err)}`
|
|
1503
|
-
});
|
|
1504
|
-
continue;
|
|
1505
|
-
}
|
|
1506
|
-
if (!result) continue;
|
|
1507
|
-
if (Array.isArray(result)) issues.push(...result);
|
|
1508
|
-
else issues.push(result);
|
|
1509
|
-
}
|
|
1510
|
-
return sortIssues(issues);
|
|
1511
|
-
}
|
|
1512
1775
|
function sortIssues(issues) {
|
|
1513
1776
|
return [...issues].sort((a, b) => {
|
|
1514
1777
|
const sev = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
|
|
@@ -1519,6 +1782,9 @@ function sortIssues(issues) {
|
|
|
1519
1782
|
|
|
1520
1783
|
// src/lib/core/i18n.ts
|
|
1521
1784
|
var BCP47_LOOSE = /^[a-z]{2,3}(-[a-z]{2}|-\d{3})?$/i;
|
|
1785
|
+
function localeIdentity(tag) {
|
|
1786
|
+
return tag.trim().toLowerCase();
|
|
1787
|
+
}
|
|
1522
1788
|
function looksLikeLocaleSegment(segment) {
|
|
1523
1789
|
return BCP47_LOOSE.test(segment);
|
|
1524
1790
|
}
|
|
@@ -1560,7 +1826,7 @@ function buildI18nMatrix(pages, options = {}) {
|
|
|
1560
1826
|
continue;
|
|
1561
1827
|
}
|
|
1562
1828
|
const { route } = splitRoute(altUrl.pathname);
|
|
1563
|
-
const locale = alt.isXDefault ? "x-default" : alt.hreflang;
|
|
1829
|
+
const locale = alt.isXDefault ? "x-default" : localeIdentity(alt.hreflang);
|
|
1564
1830
|
record(route || selfRoute, locale, altUrl.toString());
|
|
1565
1831
|
}
|
|
1566
1832
|
}
|
|
@@ -1594,14 +1860,15 @@ function reciprocityIssues(pages) {
|
|
|
1594
1860
|
const url = page.fetch.finalUrl;
|
|
1595
1861
|
const seenLocalesOnPage = /* @__PURE__ */ new Set();
|
|
1596
1862
|
for (const alt of page.links.alternates) {
|
|
1597
|
-
const
|
|
1863
|
+
const raw = alt.isXDefault ? "x-default" : alt.hreflang;
|
|
1864
|
+
const locale = alt.isXDefault ? "x-default" : localeIdentity(alt.hreflang);
|
|
1598
1865
|
seenLocalesOnPage.add(locale);
|
|
1599
1866
|
if (!isValidLocale(locale)) {
|
|
1600
1867
|
issues.push({
|
|
1601
1868
|
code: "locale.invalid",
|
|
1602
1869
|
url,
|
|
1603
|
-
locale,
|
|
1604
|
-
message: `\`hreflang="${
|
|
1870
|
+
locale: raw,
|
|
1871
|
+
message: `\`hreflang="${raw}"\` is not a valid BCP 47 tag.`
|
|
1605
1872
|
});
|
|
1606
1873
|
continue;
|
|
1607
1874
|
}
|
|
@@ -1660,7 +1927,7 @@ function dedupeBy(items, key) {
|
|
|
1660
1927
|
function splitRoute(pathname) {
|
|
1661
1928
|
const segments = pathname.split("/").filter(Boolean);
|
|
1662
1929
|
if (segments.length > 0 && looksLikeLocaleSegment(segments[0])) {
|
|
1663
|
-
const locale = segments[0];
|
|
1930
|
+
const locale = localeIdentity(segments[0]);
|
|
1664
1931
|
const rest = segments.slice(1).join("/");
|
|
1665
1932
|
return { route: rest ? `/${rest}` : "/", locale };
|
|
1666
1933
|
}
|
|
@@ -2222,6 +2489,168 @@ function relaxTlsIfRequested2(enabled) {
|
|
|
2222
2489
|
};
|
|
2223
2490
|
}
|
|
2224
2491
|
|
|
2492
|
+
// src/lib/rules/advisory.ts
|
|
2493
|
+
function resolveEvidencePath(extraction, path) {
|
|
2494
|
+
let current = extraction;
|
|
2495
|
+
for (const segment of path.split(".")) {
|
|
2496
|
+
if (current === null || current === void 0) return null;
|
|
2497
|
+
if (typeof current !== "object") return null;
|
|
2498
|
+
current = current[segment];
|
|
2499
|
+
}
|
|
2500
|
+
return current ?? null;
|
|
2501
|
+
}
|
|
2502
|
+
function evidenceFor(extraction, paths) {
|
|
2503
|
+
const bundle = {};
|
|
2504
|
+
for (const path of paths) bundle[path] = resolveEvidencePath(extraction, path);
|
|
2505
|
+
return bundle;
|
|
2506
|
+
}
|
|
2507
|
+
function collectAdvisories(extraction, rules) {
|
|
2508
|
+
return rules.filter((rule) => rule.appliesTo?.(extraction) ?? true).map((rule) => ({
|
|
2509
|
+
ruleId: rule.id,
|
|
2510
|
+
kind: "prose",
|
|
2511
|
+
prose: rule.prose,
|
|
2512
|
+
rigor: rule.rigor,
|
|
2513
|
+
sources: rule.sources,
|
|
2514
|
+
evidence: evidenceFor(extraction, rule.reads),
|
|
2515
|
+
verdict: "needs-judgment"
|
|
2516
|
+
}));
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
// src/lib/rules/profiles/index.ts
|
|
2520
|
+
var DEFAULT_PROFILE = "default";
|
|
2521
|
+
var PROFILES = {
|
|
2522
|
+
default: {
|
|
2523
|
+
name: "default",
|
|
2524
|
+
description: "rule severities exactly as authored"
|
|
2525
|
+
},
|
|
2526
|
+
strict: {
|
|
2527
|
+
name: "strict",
|
|
2528
|
+
description: "every spec-backed rule fails the build",
|
|
2529
|
+
// The line is drawn at "is there a document that says so", not at "does
|
|
2530
|
+
// it feel important". Heuristics stay warnings even here — promoting
|
|
2531
|
+
// SERP-length folklore to an error would be exactly the dishonesty the
|
|
2532
|
+
// rigor axis exists to prevent.
|
|
2533
|
+
byRigor: {
|
|
2534
|
+
"spec-required": { severity: "error" },
|
|
2535
|
+
"spec-recommended": { severity: "error" },
|
|
2536
|
+
"vendor-spec": { severity: "error" },
|
|
2537
|
+
guideline: { severity: "warning" },
|
|
2538
|
+
heuristic: { severity: "warning" }
|
|
2539
|
+
}
|
|
2540
|
+
},
|
|
2541
|
+
"spec-only": {
|
|
2542
|
+
name: "spec-only",
|
|
2543
|
+
description: "heuristic rules switched off entirely",
|
|
2544
|
+
// Not a severity change: a heuristic finding is switched off, so it is
|
|
2545
|
+
// absent from the conformance view rather than sitting there as a
|
|
2546
|
+
// suspiciously quiet `pass`.
|
|
2547
|
+
byRigor: {
|
|
2548
|
+
heuristic: { enabled: false }
|
|
2549
|
+
}
|
|
2550
|
+
},
|
|
2551
|
+
marketing: {
|
|
2552
|
+
name: "marketing",
|
|
2553
|
+
description: "snippet and unfurl metadata gaps are errors",
|
|
2554
|
+
// The recommended/optional distinction inverts for a page whose job is
|
|
2555
|
+
// to be shared: a missing description or preview image costs a click,
|
|
2556
|
+
// whatever the spec calls it.
|
|
2557
|
+
rules: {
|
|
2558
|
+
"description.missing": { severity: "error" },
|
|
2559
|
+
"description.length": { severity: "warning" },
|
|
2560
|
+
"og.title.missing": { severity: "error" },
|
|
2561
|
+
"og.image.missing": { severity: "error" },
|
|
2562
|
+
"og.description.missing": { severity: "warning" }
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
};
|
|
2566
|
+
var PROFILE_NAMES = Object.keys(PROFILES);
|
|
2567
|
+
function resolveProfile(name = DEFAULT_PROFILE) {
|
|
2568
|
+
const profile = PROFILES[name];
|
|
2569
|
+
if (!profile) {
|
|
2570
|
+
throw new Error(`unknown profile "${name}" \u2014 expected one of: ${PROFILE_NAMES.join(", ")}`);
|
|
2571
|
+
}
|
|
2572
|
+
return profile;
|
|
2573
|
+
}
|
|
2574
|
+
function overrideFor(rule, profile) {
|
|
2575
|
+
return { ...profile.byRigor?.[rule.rigor], ...profile.rules?.[rule.id] };
|
|
2576
|
+
}
|
|
2577
|
+
function withSeverity(rule, severity) {
|
|
2578
|
+
if (rule.kind === "boolean") return { ...rule, severity };
|
|
2579
|
+
return { ...rule, severityByBand: { acceptable: severity, poor: severity } };
|
|
2580
|
+
}
|
|
2581
|
+
function applyProfile(rules, profile) {
|
|
2582
|
+
const effective = [];
|
|
2583
|
+
for (const rule of rules) {
|
|
2584
|
+
const override = overrideFor(rule, profile);
|
|
2585
|
+
if (override.enabled === false) continue;
|
|
2586
|
+
effective.push(override.severity ? withSeverity(rule, override.severity) : rule);
|
|
2587
|
+
}
|
|
2588
|
+
return effective;
|
|
2589
|
+
}
|
|
2590
|
+
function rulesForProfile(name, rules = RULES) {
|
|
2591
|
+
return applyProfile(rules, resolveProfile(name));
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
// src/lib/rules/prose.ts
|
|
2595
|
+
var titleDescriptive = {
|
|
2596
|
+
id: "title.descriptive",
|
|
2597
|
+
kind: "prose",
|
|
2598
|
+
category: "document",
|
|
2599
|
+
title: "The `<title>` describes this specific page",
|
|
2600
|
+
why: "A title that repeats the site name, or describes the section rather than the page, gives a searcher no way to tell two results apart \u2014 and gives Google a reason to rewrite it into something you did not choose.",
|
|
2601
|
+
rigor: "guideline",
|
|
2602
|
+
sources: ["google-title-link", "bing-webmaster-guidelines"],
|
|
2603
|
+
reads: ["document.title", "meta.description", "openGraph.title", "http.finalUrl"],
|
|
2604
|
+
relates: ["title.missing", "title.length"],
|
|
2605
|
+
appliesTo: (ex) => Boolean(ex.document.title?.value?.trim()),
|
|
2606
|
+
prose: "Does the title describe what is on THIS page specifically \u2014 not the site, not the section \u2014 and would it be distinguishable from the other pages of this site in a list of search results?"
|
|
2607
|
+
};
|
|
2608
|
+
var descriptionAccurate = {
|
|
2609
|
+
id: "description.accurate",
|
|
2610
|
+
kind: "prose",
|
|
2611
|
+
category: "meta",
|
|
2612
|
+
title: "The meta description summarizes this page truthfully",
|
|
2613
|
+
why: "The description is the one sentence you get to write in a search result. A boilerplate line repeated site-wide, or one that promises something the page does not deliver, gets replaced by improvised page text \u2014 or gets the click and loses the visitor.",
|
|
2614
|
+
rigor: "guideline",
|
|
2615
|
+
sources: ["google-snippet", "moz-meta-description"],
|
|
2616
|
+
reads: ["meta.description", "document.title", "http.finalUrl"],
|
|
2617
|
+
relates: ["description.missing", "description.length"],
|
|
2618
|
+
appliesTo: (ex) => Boolean(ex.meta.description?.value?.trim()),
|
|
2619
|
+
prose: "Does the description accurately summarize this page's content, and is it written for this page rather than copied across the site?"
|
|
2620
|
+
};
|
|
2621
|
+
var langMatchesContent = {
|
|
2622
|
+
id: "lang.matches-content",
|
|
2623
|
+
kind: "prose",
|
|
2624
|
+
category: "i18n",
|
|
2625
|
+
title: "The declared `lang` is the language the page is actually written in",
|
|
2626
|
+
why: "A wrong `lang` is worse than a missing one: screen readers switch to the wrong pronunciation rules, browsers offer to translate a page that is already in the reader's language, and search engines file the page under the wrong audience. Nothing in the markup contradicts it, so no mechanical check can catch it.",
|
|
2627
|
+
rigor: "spec-required",
|
|
2628
|
+
sources: ["whatwg-html-lang", "w3c-i18n-language-tags"],
|
|
2629
|
+
reads: ["document.lang", "document.title", "meta.description", "openGraph.locale"],
|
|
2630
|
+
appliesTo: (ex) => Boolean(ex.document.lang?.value?.trim()),
|
|
2631
|
+
prose: "Is the text of this page actually written in the language its `lang` attribute declares?"
|
|
2632
|
+
};
|
|
2633
|
+
var ogImageRepresentative = {
|
|
2634
|
+
id: "og.image.representative",
|
|
2635
|
+
kind: "prose",
|
|
2636
|
+
category: "opengraph",
|
|
2637
|
+
title: "The `og:image` represents this page and survives the unfurl crop",
|
|
2638
|
+
why: "The preview image is the whole payload of a shared link. A generic site-wide banner, or artwork whose subject sits outside the ~1.91:1 crop, is present enough to pass every mechanical check and still communicate nothing.",
|
|
2639
|
+
rigor: "guideline",
|
|
2640
|
+
sources: ["ogp", "meta-og-sharing"],
|
|
2641
|
+
reads: ["openGraph.images", "openGraph.title", "document.title", "http.finalUrl"],
|
|
2642
|
+
relates: ["og.image.missing"],
|
|
2643
|
+
appliesTo: (ex) => ex.openGraph.images.length > 0,
|
|
2644
|
+
prose: "Does the og:image represent what this page is about, rather than being a site-wide default, and does its subject survive being cropped to the 1.91:1 aspect ratio consumers render?"
|
|
2645
|
+
};
|
|
2646
|
+
var PROSE_RULES = [
|
|
2647
|
+
descriptionAccurate,
|
|
2648
|
+
langMatchesContent,
|
|
2649
|
+
ogImageRepresentative,
|
|
2650
|
+
titleDescriptive
|
|
2651
|
+
];
|
|
2652
|
+
var PROSE_RULE_BY_ID = new Map(PROSE_RULES.map((r) => [r.id, r]));
|
|
2653
|
+
|
|
2225
2654
|
// src/lib/core/net/fetch-url.ts
|
|
2226
2655
|
var DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
|
2227
2656
|
var DEFAULT_TIMEOUT_MS = 8e3;
|
|
@@ -2858,6 +3287,38 @@ function normalizeInputUrl(input) {
|
|
|
2858
3287
|
return { ok: true, url: candidate };
|
|
2859
3288
|
}
|
|
2860
3289
|
|
|
3290
|
+
// src/report/conformance.ts
|
|
3291
|
+
function buildConformance(rules, rows) {
|
|
3292
|
+
const totals = new Map(
|
|
3293
|
+
rules.map((rule) => [rule.id, { pass: 0, fail: 0, warn: 0, na: 0, crashed: 0 }])
|
|
3294
|
+
);
|
|
3295
|
+
const pages = rows.map((row) => {
|
|
3296
|
+
const statuses = {};
|
|
3297
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3298
|
+
for (const finding of row.findings) {
|
|
3299
|
+
const tally = totals.get(finding.ruleId);
|
|
3300
|
+
if (!tally) continue;
|
|
3301
|
+
statuses[finding.ruleId] = finding.status;
|
|
3302
|
+
tally[finding.status] += 1;
|
|
3303
|
+
seen.add(finding.ruleId);
|
|
3304
|
+
}
|
|
3305
|
+
for (const rule of rules) if (!seen.has(rule.id)) totals.get(rule.id).crashed += 1;
|
|
3306
|
+
return { url: row.pageUrl, statuses };
|
|
3307
|
+
});
|
|
3308
|
+
return {
|
|
3309
|
+
rules: rules.map((rule) => ({
|
|
3310
|
+
ruleId: rule.id,
|
|
3311
|
+
kind: rule.kind,
|
|
3312
|
+
title: rule.title,
|
|
3313
|
+
rigor: rule.rigor,
|
|
3314
|
+
sources: rule.sources,
|
|
3315
|
+
expected: rule.expected,
|
|
3316
|
+
totals: totals.get(rule.id)
|
|
3317
|
+
})),
|
|
3318
|
+
pages
|
|
3319
|
+
};
|
|
3320
|
+
}
|
|
3321
|
+
|
|
2861
3322
|
// src/report/build.ts
|
|
2862
3323
|
function isOkStatus(status) {
|
|
2863
3324
|
return status >= 200 && status < 300;
|
|
@@ -2933,6 +3394,8 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
2933
3394
|
const depth = options.depth ?? 2;
|
|
2934
3395
|
const maxPages = options.maxPages ?? 200;
|
|
2935
3396
|
const checkExternal = options.checkExternal !== false;
|
|
3397
|
+
const profile = options.profile ?? DEFAULT_PROFILE;
|
|
3398
|
+
const rules = rulesForProfile(profile);
|
|
2936
3399
|
const discovery = options.noSitemap ? void 0 : await discoverSitemap(entry, {
|
|
2937
3400
|
signal: options.signal,
|
|
2938
3401
|
timeoutMs: options.timeoutMs,
|
|
@@ -2977,20 +3440,37 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
2977
3440
|
status: p.fetch.status
|
|
2978
3441
|
}));
|
|
2979
3442
|
const seoIssues = [];
|
|
3443
|
+
const conformanceRows = [];
|
|
3444
|
+
const advisories = [];
|
|
3445
|
+
const ruleById = new Map(rules.map((rule) => [rule.id, rule]));
|
|
2980
3446
|
for (const page of htmlPages) {
|
|
2981
3447
|
const pageUrl = page.fetch.finalUrl;
|
|
3448
|
+
const extraction = extractionFromPage(page);
|
|
3449
|
+
const evaluation = evaluateRules(extraction, rules);
|
|
3450
|
+
if (options.conformance) {
|
|
3451
|
+
conformanceRows.push({ pageUrl, findings: evaluation.findings });
|
|
3452
|
+
}
|
|
3453
|
+
if (options.advisories) {
|
|
3454
|
+
for (const advisory of collectAdvisories(extraction, PROSE_RULES)) {
|
|
3455
|
+
advisories.push({
|
|
3456
|
+
...advisory,
|
|
3457
|
+
id: fingerprint("advisory", advisory.ruleId, routeKey(pageUrl)),
|
|
3458
|
+
pageUrl
|
|
3459
|
+
});
|
|
3460
|
+
}
|
|
3461
|
+
}
|
|
2982
3462
|
const occurrence = /* @__PURE__ */ new Map();
|
|
2983
|
-
for (const issue of
|
|
3463
|
+
for (const issue of sortIssues(findingsToIssues(evaluation, rules))) {
|
|
2984
3464
|
const n = occurrence.get(issue.ruleId) ?? 0;
|
|
2985
3465
|
occurrence.set(issue.ruleId, n + 1);
|
|
2986
|
-
const rule =
|
|
3466
|
+
const rule = ruleById.get(issue.ruleId);
|
|
2987
3467
|
seoIssues.push({
|
|
2988
3468
|
id: fingerprint("seo", issue.ruleId, routeKey(pageUrl), String(n)),
|
|
2989
3469
|
pageUrl,
|
|
2990
3470
|
ruleId: issue.ruleId,
|
|
2991
3471
|
severity: issue.severity,
|
|
2992
3472
|
message: issue.message,
|
|
2993
|
-
why: rule?.
|
|
3473
|
+
why: rule?.title,
|
|
2994
3474
|
fix: issue.fix?.snippet
|
|
2995
3475
|
});
|
|
2996
3476
|
}
|
|
@@ -3104,6 +3584,7 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
3104
3584
|
return {
|
|
3105
3585
|
url: entry,
|
|
3106
3586
|
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3587
|
+
profile,
|
|
3107
3588
|
summary: {
|
|
3108
3589
|
brokenLinks: brokenCount,
|
|
3109
3590
|
missingTranslations,
|
|
@@ -3128,6 +3609,12 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
3128
3609
|
missingTranslations: { holes, reciprocity },
|
|
3129
3610
|
seoIssues,
|
|
3130
3611
|
siteIssues,
|
|
3612
|
+
// Both sections are omitted entirely when not asked for, rather than
|
|
3613
|
+
// emitted empty: an empty `advisories: []` would read as "no prose rule
|
|
3614
|
+
// has anything to ask about this site", which is a claim goflag has not
|
|
3615
|
+
// made.
|
|
3616
|
+
...options.conformance ? { conformance: buildConformance(rules, conformanceRows) } : {},
|
|
3617
|
+
...options.advisories ? { advisories } : {},
|
|
3131
3618
|
diagnostics: {
|
|
3132
3619
|
pagesCrawled: pages.length,
|
|
3133
3620
|
pagesScanned: linkReport.pagesScanned,
|
|
@@ -3206,7 +3693,10 @@ function renderTerminal(report, options = {}) {
|
|
|
3206
3693
|
lines.push(
|
|
3207
3694
|
`${c(verdictColor(report.summary.verdict), VERDICT_FLAG[report.summary.verdict].toUpperCase())} ` + c(
|
|
3208
3695
|
ANSI.dim,
|
|
3209
|
-
`${report.diagnostics.pagesCrawled} pages crawled, ${report.diagnostics.pagesScanned} scanned`
|
|
3696
|
+
`${report.diagnostics.pagesCrawled} pages crawled, ${report.diagnostics.pagesScanned} scanned` + // Named only when it is not the default. A run under `spec-only`
|
|
3697
|
+
// that prints "0 SEO issues" without saying so reads as a clean
|
|
3698
|
+
// site rather than a narrowed one.
|
|
3699
|
+
(report.profile === DEFAULT_PROFILE ? "" : `, profile ${report.profile}`)
|
|
3210
3700
|
)
|
|
3211
3701
|
);
|
|
3212
3702
|
lines.push("");
|
|
@@ -3276,6 +3766,36 @@ function renderTerminal(report, options = {}) {
|
|
|
3276
3766
|
lines.push(...renderIssuesByPage(report.siteIssues, c));
|
|
3277
3767
|
lines.push("");
|
|
3278
3768
|
}
|
|
3769
|
+
if (report.conformance) {
|
|
3770
|
+
lines.push(c(ANSI.bold, "Conformance"));
|
|
3771
|
+
const judged = report.conformance.pages.length;
|
|
3772
|
+
lines.push(c(ANSI.dim, ` ${judged} page${judged === 1 ? "" : "s"} judged \xD7 every rule`));
|
|
3773
|
+
for (const rule of report.conformance.rules) {
|
|
3774
|
+
const { pass, fail, warn, na, crashed } = rule.totals;
|
|
3775
|
+
const tally = [
|
|
3776
|
+
fail > 0 ? c(ANSI.red, `${fail} fail`) : null,
|
|
3777
|
+
warn > 0 ? c(ANSI.yellow, `${warn} warn`) : null,
|
|
3778
|
+
pass > 0 ? c(ANSI.green, `${pass} pass`) : null,
|
|
3779
|
+
na > 0 ? c(ANSI.dim, `${na} n/a`) : null,
|
|
3780
|
+
crashed > 0 ? c(ANSI.red, `${crashed} crashed`) : null
|
|
3781
|
+
].filter(Boolean);
|
|
3782
|
+
lines.push(
|
|
3783
|
+
` ${c(ANSI.cyan, rule.ruleId.padEnd(24))} ${tally.join(c(ANSI.dim, " \xB7 "))}` + c(ANSI.dim, ` [${rule.rigor}]`)
|
|
3784
|
+
);
|
|
3785
|
+
}
|
|
3786
|
+
lines.push("");
|
|
3787
|
+
}
|
|
3788
|
+
if (report.advisories && report.advisories.length > 0) {
|
|
3789
|
+
lines.push(c(ANSI.bold, "Needs judgment"));
|
|
3790
|
+
lines.push(c(ANSI.dim, " goflag states these; it will not guess the answer."));
|
|
3791
|
+
for (const [ruleId, group] of groupBy([...report.advisories], (a) => a.ruleId)) {
|
|
3792
|
+
lines.push(
|
|
3793
|
+
` ${c(ANSI.cyan, ruleId)} ${c(ANSI.dim, `(${group.length} page${group.length === 1 ? "" : "s"})`)}`
|
|
3794
|
+
);
|
|
3795
|
+
lines.push(` ${group[0].prose}`);
|
|
3796
|
+
}
|
|
3797
|
+
lines.push("");
|
|
3798
|
+
}
|
|
3279
3799
|
if (report.summary.verdict === "green") {
|
|
3280
3800
|
lines.push(c(ANSI.green, "No problems found."));
|
|
3281
3801
|
lines.push("");
|
|
@@ -3346,7 +3866,9 @@ function renderSummaryTerminal(summary, options = {}) {
|
|
|
3346
3866
|
lines.push(
|
|
3347
3867
|
`${c(verdictColor2(summary.verdict), VERDICT_FLAG2[summary.verdict])} ` + c(
|
|
3348
3868
|
ANSI2.dim,
|
|
3349
|
-
`${summary.totals.pagesCrawled} pages crawled, ${summary.totals.pagesScanned} scanned`
|
|
3869
|
+
`${summary.totals.pagesCrawled} pages crawled, ${summary.totals.pagesScanned} scanned` + // Same reasoning as `renderTerminal`: a narrowed run must say so,
|
|
3870
|
+
// or its low finding count reads as a clean site.
|
|
3871
|
+
(summary.profile === DEFAULT_PROFILE ? "" : `, profile ${summary.profile}`)
|
|
3350
3872
|
)
|
|
3351
3873
|
);
|
|
3352
3874
|
lines.push("");
|
|
@@ -3401,6 +3923,37 @@ function renderSummaryTerminal(summary, options = {}) {
|
|
|
3401
3923
|
lines.push(...rollupRows(summary.siteIssues, c));
|
|
3402
3924
|
lines.push("");
|
|
3403
3925
|
}
|
|
3926
|
+
if (summary.conformance) {
|
|
3927
|
+
lines.push(c(ANSI2.bold, "Conformance"));
|
|
3928
|
+
for (const rule of summary.conformance.rules) {
|
|
3929
|
+
const { pass, fail, warn, na, crashed } = rule.totals;
|
|
3930
|
+
const tally = [
|
|
3931
|
+
fail > 0 ? c(ANSI2.red, `${fail} fail`) : null,
|
|
3932
|
+
warn > 0 ? c(ANSI2.yellow, `${warn} warn`) : null,
|
|
3933
|
+
pass > 0 ? c(ANSI2.green, `${pass} pass`) : null,
|
|
3934
|
+
na > 0 ? c(ANSI2.dim, `${na} n/a`) : null,
|
|
3935
|
+
crashed > 0 ? c(ANSI2.red, `${crashed} crashed`) : null
|
|
3936
|
+
].filter(Boolean);
|
|
3937
|
+
lines.push(` ${c(ANSI2.cyan, rule.ruleId.padEnd(24))} ${tally.join(c(ANSI2.dim, " \xB7 "))}`);
|
|
3938
|
+
}
|
|
3939
|
+
lines.push("");
|
|
3940
|
+
}
|
|
3941
|
+
if (summary.advisories && summary.advisories.length > 0) {
|
|
3942
|
+
lines.push(c(ANSI2.bold, "Needs judgment"));
|
|
3943
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
3944
|
+
for (const advisory of summary.advisories) {
|
|
3945
|
+
const entry = byRule.get(advisory.ruleId);
|
|
3946
|
+
if (entry) entry.pages += 1;
|
|
3947
|
+
else byRule.set(advisory.ruleId, { prose: advisory.prose, pages: 1 });
|
|
3948
|
+
}
|
|
3949
|
+
for (const [ruleId, { prose, pages }] of byRule) {
|
|
3950
|
+
lines.push(
|
|
3951
|
+
` ${c(ANSI2.cyan, ruleId)} ${c(ANSI2.dim, `(${pages} page${pages === 1 ? "" : "s"})`)}`
|
|
3952
|
+
);
|
|
3953
|
+
lines.push(` ${prose}`);
|
|
3954
|
+
}
|
|
3955
|
+
lines.push("");
|
|
3956
|
+
}
|
|
3404
3957
|
if (summary.verdict === "green") {
|
|
3405
3958
|
lines.push(c(ANSI2.green, "No problems found."));
|
|
3406
3959
|
lines.push("");
|
|
@@ -3446,6 +3999,7 @@ function summarize(report) {
|
|
|
3446
3999
|
return {
|
|
3447
4000
|
url: report.url,
|
|
3448
4001
|
finishedAt: report.finishedAt,
|
|
4002
|
+
profile: report.profile,
|
|
3449
4003
|
verdict: report.summary.verdict,
|
|
3450
4004
|
totals: {
|
|
3451
4005
|
brokenLinks: report.summary.brokenLinks,
|
|
@@ -3465,6 +4019,8 @@ function summarize(report) {
|
|
|
3465
4019
|
},
|
|
3466
4020
|
seoIssues: rollupByRule(report.seoIssues),
|
|
3467
4021
|
siteIssues: rollupByRule(report.siteIssues),
|
|
4022
|
+
...report.conformance ? { conformance: { rules: report.conformance.rules } } : {},
|
|
4023
|
+
...report.advisories ? { advisories: report.advisories } : {},
|
|
3468
4024
|
truncated: report.diagnostics.truncated,
|
|
3469
4025
|
warnings: report.diagnostics.warnings
|
|
3470
4026
|
};
|
|
@@ -3704,6 +4260,9 @@ var Logger = class {
|
|
|
3704
4260
|
};
|
|
3705
4261
|
|
|
3706
4262
|
export {
|
|
4263
|
+
DEFAULT_PROFILE,
|
|
4264
|
+
PROFILES,
|
|
4265
|
+
PROFILE_NAMES,
|
|
3707
4266
|
routeKey,
|
|
3708
4267
|
targetKey,
|
|
3709
4268
|
fingerprint,
|
|
@@ -3716,4 +4275,4 @@ export {
|
|
|
3716
4275
|
summarize,
|
|
3717
4276
|
Logger
|
|
3718
4277
|
};
|
|
3719
|
-
//# sourceMappingURL=chunk-
|
|
4278
|
+
//# sourceMappingURL=chunk-6WOWKLXY.js.map
|