@goflag/cli 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +310 -22
- package/dist/{chunk-3777NRM4.js → chunk-GFM3YN5N.js} +753 -198
- package/dist/chunk-GFM3YN5N.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 +4 -3
- 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];
|
|
@@ -2222,6 +2485,168 @@ function relaxTlsIfRequested2(enabled) {
|
|
|
2222
2485
|
};
|
|
2223
2486
|
}
|
|
2224
2487
|
|
|
2488
|
+
// src/lib/rules/advisory.ts
|
|
2489
|
+
function resolveEvidencePath(extraction, path) {
|
|
2490
|
+
let current = extraction;
|
|
2491
|
+
for (const segment of path.split(".")) {
|
|
2492
|
+
if (current === null || current === void 0) return null;
|
|
2493
|
+
if (typeof current !== "object") return null;
|
|
2494
|
+
current = current[segment];
|
|
2495
|
+
}
|
|
2496
|
+
return current ?? null;
|
|
2497
|
+
}
|
|
2498
|
+
function evidenceFor(extraction, paths) {
|
|
2499
|
+
const bundle = {};
|
|
2500
|
+
for (const path of paths) bundle[path] = resolveEvidencePath(extraction, path);
|
|
2501
|
+
return bundle;
|
|
2502
|
+
}
|
|
2503
|
+
function collectAdvisories(extraction, rules) {
|
|
2504
|
+
return rules.filter((rule) => rule.appliesTo?.(extraction) ?? true).map((rule) => ({
|
|
2505
|
+
ruleId: rule.id,
|
|
2506
|
+
kind: "prose",
|
|
2507
|
+
prose: rule.prose,
|
|
2508
|
+
rigor: rule.rigor,
|
|
2509
|
+
sources: rule.sources,
|
|
2510
|
+
evidence: evidenceFor(extraction, rule.reads),
|
|
2511
|
+
verdict: "needs-judgment"
|
|
2512
|
+
}));
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
// src/lib/rules/profiles/index.ts
|
|
2516
|
+
var DEFAULT_PROFILE = "default";
|
|
2517
|
+
var PROFILES = {
|
|
2518
|
+
default: {
|
|
2519
|
+
name: "default",
|
|
2520
|
+
description: "rule severities exactly as authored"
|
|
2521
|
+
},
|
|
2522
|
+
strict: {
|
|
2523
|
+
name: "strict",
|
|
2524
|
+
description: "every spec-backed rule fails the build",
|
|
2525
|
+
// The line is drawn at "is there a document that says so", not at "does
|
|
2526
|
+
// it feel important". Heuristics stay warnings even here — promoting
|
|
2527
|
+
// SERP-length folklore to an error would be exactly the dishonesty the
|
|
2528
|
+
// rigor axis exists to prevent.
|
|
2529
|
+
byRigor: {
|
|
2530
|
+
"spec-required": { severity: "error" },
|
|
2531
|
+
"spec-recommended": { severity: "error" },
|
|
2532
|
+
"vendor-spec": { severity: "error" },
|
|
2533
|
+
guideline: { severity: "warning" },
|
|
2534
|
+
heuristic: { severity: "warning" }
|
|
2535
|
+
}
|
|
2536
|
+
},
|
|
2537
|
+
"spec-only": {
|
|
2538
|
+
name: "spec-only",
|
|
2539
|
+
description: "heuristic rules switched off entirely",
|
|
2540
|
+
// Not a severity change: a heuristic finding is switched off, so it is
|
|
2541
|
+
// absent from the conformance view rather than sitting there as a
|
|
2542
|
+
// suspiciously quiet `pass`.
|
|
2543
|
+
byRigor: {
|
|
2544
|
+
heuristic: { enabled: false }
|
|
2545
|
+
}
|
|
2546
|
+
},
|
|
2547
|
+
marketing: {
|
|
2548
|
+
name: "marketing",
|
|
2549
|
+
description: "snippet and unfurl metadata gaps are errors",
|
|
2550
|
+
// The recommended/optional distinction inverts for a page whose job is
|
|
2551
|
+
// to be shared: a missing description or preview image costs a click,
|
|
2552
|
+
// whatever the spec calls it.
|
|
2553
|
+
rules: {
|
|
2554
|
+
"description.missing": { severity: "error" },
|
|
2555
|
+
"description.length": { severity: "warning" },
|
|
2556
|
+
"og.title.missing": { severity: "error" },
|
|
2557
|
+
"og.image.missing": { severity: "error" },
|
|
2558
|
+
"og.description.missing": { severity: "warning" }
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
};
|
|
2562
|
+
var PROFILE_NAMES = Object.keys(PROFILES);
|
|
2563
|
+
function resolveProfile(name = DEFAULT_PROFILE) {
|
|
2564
|
+
const profile = PROFILES[name];
|
|
2565
|
+
if (!profile) {
|
|
2566
|
+
throw new Error(`unknown profile "${name}" \u2014 expected one of: ${PROFILE_NAMES.join(", ")}`);
|
|
2567
|
+
}
|
|
2568
|
+
return profile;
|
|
2569
|
+
}
|
|
2570
|
+
function overrideFor(rule, profile) {
|
|
2571
|
+
return { ...profile.byRigor?.[rule.rigor], ...profile.rules?.[rule.id] };
|
|
2572
|
+
}
|
|
2573
|
+
function withSeverity(rule, severity) {
|
|
2574
|
+
if (rule.kind === "boolean") return { ...rule, severity };
|
|
2575
|
+
return { ...rule, severityByBand: { acceptable: severity, poor: severity } };
|
|
2576
|
+
}
|
|
2577
|
+
function applyProfile(rules, profile) {
|
|
2578
|
+
const effective = [];
|
|
2579
|
+
for (const rule of rules) {
|
|
2580
|
+
const override = overrideFor(rule, profile);
|
|
2581
|
+
if (override.enabled === false) continue;
|
|
2582
|
+
effective.push(override.severity ? withSeverity(rule, override.severity) : rule);
|
|
2583
|
+
}
|
|
2584
|
+
return effective;
|
|
2585
|
+
}
|
|
2586
|
+
function rulesForProfile(name, rules = RULES) {
|
|
2587
|
+
return applyProfile(rules, resolveProfile(name));
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
// src/lib/rules/prose.ts
|
|
2591
|
+
var titleDescriptive = {
|
|
2592
|
+
id: "title.descriptive",
|
|
2593
|
+
kind: "prose",
|
|
2594
|
+
category: "document",
|
|
2595
|
+
title: "The `<title>` describes this specific page",
|
|
2596
|
+
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.",
|
|
2597
|
+
rigor: "guideline",
|
|
2598
|
+
sources: ["google-title-link", "bing-webmaster-guidelines"],
|
|
2599
|
+
reads: ["document.title", "meta.description", "openGraph.title", "http.finalUrl"],
|
|
2600
|
+
relates: ["title.missing", "title.length"],
|
|
2601
|
+
appliesTo: (ex) => Boolean(ex.document.title?.value?.trim()),
|
|
2602
|
+
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?"
|
|
2603
|
+
};
|
|
2604
|
+
var descriptionAccurate = {
|
|
2605
|
+
id: "description.accurate",
|
|
2606
|
+
kind: "prose",
|
|
2607
|
+
category: "meta",
|
|
2608
|
+
title: "The meta description summarizes this page truthfully",
|
|
2609
|
+
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.",
|
|
2610
|
+
rigor: "guideline",
|
|
2611
|
+
sources: ["google-snippet", "moz-meta-description"],
|
|
2612
|
+
reads: ["meta.description", "document.title", "http.finalUrl"],
|
|
2613
|
+
relates: ["description.missing", "description.length"],
|
|
2614
|
+
appliesTo: (ex) => Boolean(ex.meta.description?.value?.trim()),
|
|
2615
|
+
prose: "Does the description accurately summarize this page's content, and is it written for this page rather than copied across the site?"
|
|
2616
|
+
};
|
|
2617
|
+
var langMatchesContent = {
|
|
2618
|
+
id: "lang.matches-content",
|
|
2619
|
+
kind: "prose",
|
|
2620
|
+
category: "i18n",
|
|
2621
|
+
title: "The declared `lang` is the language the page is actually written in",
|
|
2622
|
+
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.",
|
|
2623
|
+
rigor: "spec-required",
|
|
2624
|
+
sources: ["whatwg-html-lang", "w3c-i18n-language-tags"],
|
|
2625
|
+
reads: ["document.lang", "document.title", "meta.description", "openGraph.locale"],
|
|
2626
|
+
appliesTo: (ex) => Boolean(ex.document.lang?.value?.trim()),
|
|
2627
|
+
prose: "Is the text of this page actually written in the language its `lang` attribute declares?"
|
|
2628
|
+
};
|
|
2629
|
+
var ogImageRepresentative = {
|
|
2630
|
+
id: "og.image.representative",
|
|
2631
|
+
kind: "prose",
|
|
2632
|
+
category: "opengraph",
|
|
2633
|
+
title: "The `og:image` represents this page and survives the unfurl crop",
|
|
2634
|
+
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.",
|
|
2635
|
+
rigor: "guideline",
|
|
2636
|
+
sources: ["ogp", "meta-og-sharing"],
|
|
2637
|
+
reads: ["openGraph.images", "openGraph.title", "document.title", "http.finalUrl"],
|
|
2638
|
+
relates: ["og.image.missing"],
|
|
2639
|
+
appliesTo: (ex) => ex.openGraph.images.length > 0,
|
|
2640
|
+
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?"
|
|
2641
|
+
};
|
|
2642
|
+
var PROSE_RULES = [
|
|
2643
|
+
descriptionAccurate,
|
|
2644
|
+
langMatchesContent,
|
|
2645
|
+
ogImageRepresentative,
|
|
2646
|
+
titleDescriptive
|
|
2647
|
+
];
|
|
2648
|
+
var PROSE_RULE_BY_ID = new Map(PROSE_RULES.map((r) => [r.id, r]));
|
|
2649
|
+
|
|
2225
2650
|
// src/lib/core/net/fetch-url.ts
|
|
2226
2651
|
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
2652
|
var DEFAULT_TIMEOUT_MS = 8e3;
|
|
@@ -2858,6 +3283,38 @@ function normalizeInputUrl(input) {
|
|
|
2858
3283
|
return { ok: true, url: candidate };
|
|
2859
3284
|
}
|
|
2860
3285
|
|
|
3286
|
+
// src/report/conformance.ts
|
|
3287
|
+
function buildConformance(rules, rows) {
|
|
3288
|
+
const totals = new Map(
|
|
3289
|
+
rules.map((rule) => [rule.id, { pass: 0, fail: 0, warn: 0, na: 0, crashed: 0 }])
|
|
3290
|
+
);
|
|
3291
|
+
const pages = rows.map((row) => {
|
|
3292
|
+
const statuses = {};
|
|
3293
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3294
|
+
for (const finding of row.findings) {
|
|
3295
|
+
const tally = totals.get(finding.ruleId);
|
|
3296
|
+
if (!tally) continue;
|
|
3297
|
+
statuses[finding.ruleId] = finding.status;
|
|
3298
|
+
tally[finding.status] += 1;
|
|
3299
|
+
seen.add(finding.ruleId);
|
|
3300
|
+
}
|
|
3301
|
+
for (const rule of rules) if (!seen.has(rule.id)) totals.get(rule.id).crashed += 1;
|
|
3302
|
+
return { url: row.pageUrl, statuses };
|
|
3303
|
+
});
|
|
3304
|
+
return {
|
|
3305
|
+
rules: rules.map((rule) => ({
|
|
3306
|
+
ruleId: rule.id,
|
|
3307
|
+
kind: rule.kind,
|
|
3308
|
+
title: rule.title,
|
|
3309
|
+
rigor: rule.rigor,
|
|
3310
|
+
sources: rule.sources,
|
|
3311
|
+
expected: rule.expected,
|
|
3312
|
+
totals: totals.get(rule.id)
|
|
3313
|
+
})),
|
|
3314
|
+
pages
|
|
3315
|
+
};
|
|
3316
|
+
}
|
|
3317
|
+
|
|
2861
3318
|
// src/report/build.ts
|
|
2862
3319
|
function isOkStatus(status) {
|
|
2863
3320
|
return status >= 200 && status < 300;
|
|
@@ -2933,6 +3390,8 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
2933
3390
|
const depth = options.depth ?? 2;
|
|
2934
3391
|
const maxPages = options.maxPages ?? 200;
|
|
2935
3392
|
const checkExternal = options.checkExternal !== false;
|
|
3393
|
+
const profile = options.profile ?? DEFAULT_PROFILE;
|
|
3394
|
+
const rules = rulesForProfile(profile);
|
|
2936
3395
|
const discovery = options.noSitemap ? void 0 : await discoverSitemap(entry, {
|
|
2937
3396
|
signal: options.signal,
|
|
2938
3397
|
timeoutMs: options.timeoutMs,
|
|
@@ -2977,20 +3436,37 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
2977
3436
|
status: p.fetch.status
|
|
2978
3437
|
}));
|
|
2979
3438
|
const seoIssues = [];
|
|
3439
|
+
const conformanceRows = [];
|
|
3440
|
+
const advisories = [];
|
|
3441
|
+
const ruleById = new Map(rules.map((rule) => [rule.id, rule]));
|
|
2980
3442
|
for (const page of htmlPages) {
|
|
2981
3443
|
const pageUrl = page.fetch.finalUrl;
|
|
3444
|
+
const extraction = extractionFromPage(page);
|
|
3445
|
+
const evaluation = evaluateRules(extraction, rules);
|
|
3446
|
+
if (options.conformance) {
|
|
3447
|
+
conformanceRows.push({ pageUrl, findings: evaluation.findings });
|
|
3448
|
+
}
|
|
3449
|
+
if (options.advisories) {
|
|
3450
|
+
for (const advisory of collectAdvisories(extraction, PROSE_RULES)) {
|
|
3451
|
+
advisories.push({
|
|
3452
|
+
...advisory,
|
|
3453
|
+
id: fingerprint("advisory", advisory.ruleId, routeKey(pageUrl)),
|
|
3454
|
+
pageUrl
|
|
3455
|
+
});
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
2982
3458
|
const occurrence = /* @__PURE__ */ new Map();
|
|
2983
|
-
for (const issue of
|
|
3459
|
+
for (const issue of sortIssues(findingsToIssues(evaluation, rules))) {
|
|
2984
3460
|
const n = occurrence.get(issue.ruleId) ?? 0;
|
|
2985
3461
|
occurrence.set(issue.ruleId, n + 1);
|
|
2986
|
-
const rule =
|
|
3462
|
+
const rule = ruleById.get(issue.ruleId);
|
|
2987
3463
|
seoIssues.push({
|
|
2988
3464
|
id: fingerprint("seo", issue.ruleId, routeKey(pageUrl), String(n)),
|
|
2989
3465
|
pageUrl,
|
|
2990
3466
|
ruleId: issue.ruleId,
|
|
2991
3467
|
severity: issue.severity,
|
|
2992
3468
|
message: issue.message,
|
|
2993
|
-
why: rule?.
|
|
3469
|
+
why: rule?.title,
|
|
2994
3470
|
fix: issue.fix?.snippet
|
|
2995
3471
|
});
|
|
2996
3472
|
}
|
|
@@ -3104,6 +3580,7 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
3104
3580
|
return {
|
|
3105
3581
|
url: entry,
|
|
3106
3582
|
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3583
|
+
profile,
|
|
3107
3584
|
summary: {
|
|
3108
3585
|
brokenLinks: brokenCount,
|
|
3109
3586
|
missingTranslations,
|
|
@@ -3128,6 +3605,12 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
3128
3605
|
missingTranslations: { holes, reciprocity },
|
|
3129
3606
|
seoIssues,
|
|
3130
3607
|
siteIssues,
|
|
3608
|
+
// Both sections are omitted entirely when not asked for, rather than
|
|
3609
|
+
// emitted empty: an empty `advisories: []` would read as "no prose rule
|
|
3610
|
+
// has anything to ask about this site", which is a claim goflag has not
|
|
3611
|
+
// made.
|
|
3612
|
+
...options.conformance ? { conformance: buildConformance(rules, conformanceRows) } : {},
|
|
3613
|
+
...options.advisories ? { advisories } : {},
|
|
3131
3614
|
diagnostics: {
|
|
3132
3615
|
pagesCrawled: pages.length,
|
|
3133
3616
|
pagesScanned: linkReport.pagesScanned,
|
|
@@ -3206,7 +3689,10 @@ function renderTerminal(report, options = {}) {
|
|
|
3206
3689
|
lines.push(
|
|
3207
3690
|
`${c(verdictColor(report.summary.verdict), VERDICT_FLAG[report.summary.verdict].toUpperCase())} ` + c(
|
|
3208
3691
|
ANSI.dim,
|
|
3209
|
-
`${report.diagnostics.pagesCrawled} pages crawled, ${report.diagnostics.pagesScanned} scanned`
|
|
3692
|
+
`${report.diagnostics.pagesCrawled} pages crawled, ${report.diagnostics.pagesScanned} scanned` + // Named only when it is not the default. A run under `spec-only`
|
|
3693
|
+
// that prints "0 SEO issues" without saying so reads as a clean
|
|
3694
|
+
// site rather than a narrowed one.
|
|
3695
|
+
(report.profile === DEFAULT_PROFILE ? "" : `, profile ${report.profile}`)
|
|
3210
3696
|
)
|
|
3211
3697
|
);
|
|
3212
3698
|
lines.push("");
|
|
@@ -3276,6 +3762,36 @@ function renderTerminal(report, options = {}) {
|
|
|
3276
3762
|
lines.push(...renderIssuesByPage(report.siteIssues, c));
|
|
3277
3763
|
lines.push("");
|
|
3278
3764
|
}
|
|
3765
|
+
if (report.conformance) {
|
|
3766
|
+
lines.push(c(ANSI.bold, "Conformance"));
|
|
3767
|
+
const judged = report.conformance.pages.length;
|
|
3768
|
+
lines.push(c(ANSI.dim, ` ${judged} page${judged === 1 ? "" : "s"} judged \xD7 every rule`));
|
|
3769
|
+
for (const rule of report.conformance.rules) {
|
|
3770
|
+
const { pass, fail, warn, na, crashed } = rule.totals;
|
|
3771
|
+
const tally = [
|
|
3772
|
+
fail > 0 ? c(ANSI.red, `${fail} fail`) : null,
|
|
3773
|
+
warn > 0 ? c(ANSI.yellow, `${warn} warn`) : null,
|
|
3774
|
+
pass > 0 ? c(ANSI.green, `${pass} pass`) : null,
|
|
3775
|
+
na > 0 ? c(ANSI.dim, `${na} n/a`) : null,
|
|
3776
|
+
crashed > 0 ? c(ANSI.red, `${crashed} crashed`) : null
|
|
3777
|
+
].filter(Boolean);
|
|
3778
|
+
lines.push(
|
|
3779
|
+
` ${c(ANSI.cyan, rule.ruleId.padEnd(24))} ${tally.join(c(ANSI.dim, " \xB7 "))}` + c(ANSI.dim, ` [${rule.rigor}]`)
|
|
3780
|
+
);
|
|
3781
|
+
}
|
|
3782
|
+
lines.push("");
|
|
3783
|
+
}
|
|
3784
|
+
if (report.advisories && report.advisories.length > 0) {
|
|
3785
|
+
lines.push(c(ANSI.bold, "Needs judgment"));
|
|
3786
|
+
lines.push(c(ANSI.dim, " goflag states these; it will not guess the answer."));
|
|
3787
|
+
for (const [ruleId, group] of groupBy([...report.advisories], (a) => a.ruleId)) {
|
|
3788
|
+
lines.push(
|
|
3789
|
+
` ${c(ANSI.cyan, ruleId)} ${c(ANSI.dim, `(${group.length} page${group.length === 1 ? "" : "s"})`)}`
|
|
3790
|
+
);
|
|
3791
|
+
lines.push(` ${group[0].prose}`);
|
|
3792
|
+
}
|
|
3793
|
+
lines.push("");
|
|
3794
|
+
}
|
|
3279
3795
|
if (report.summary.verdict === "green") {
|
|
3280
3796
|
lines.push(c(ANSI.green, "No problems found."));
|
|
3281
3797
|
lines.push("");
|
|
@@ -3346,7 +3862,9 @@ function renderSummaryTerminal(summary, options = {}) {
|
|
|
3346
3862
|
lines.push(
|
|
3347
3863
|
`${c(verdictColor2(summary.verdict), VERDICT_FLAG2[summary.verdict])} ` + c(
|
|
3348
3864
|
ANSI2.dim,
|
|
3349
|
-
`${summary.totals.pagesCrawled} pages crawled, ${summary.totals.pagesScanned} scanned`
|
|
3865
|
+
`${summary.totals.pagesCrawled} pages crawled, ${summary.totals.pagesScanned} scanned` + // Same reasoning as `renderTerminal`: a narrowed run must say so,
|
|
3866
|
+
// or its low finding count reads as a clean site.
|
|
3867
|
+
(summary.profile === DEFAULT_PROFILE ? "" : `, profile ${summary.profile}`)
|
|
3350
3868
|
)
|
|
3351
3869
|
);
|
|
3352
3870
|
lines.push("");
|
|
@@ -3401,6 +3919,37 @@ function renderSummaryTerminal(summary, options = {}) {
|
|
|
3401
3919
|
lines.push(...rollupRows(summary.siteIssues, c));
|
|
3402
3920
|
lines.push("");
|
|
3403
3921
|
}
|
|
3922
|
+
if (summary.conformance) {
|
|
3923
|
+
lines.push(c(ANSI2.bold, "Conformance"));
|
|
3924
|
+
for (const rule of summary.conformance.rules) {
|
|
3925
|
+
const { pass, fail, warn, na, crashed } = rule.totals;
|
|
3926
|
+
const tally = [
|
|
3927
|
+
fail > 0 ? c(ANSI2.red, `${fail} fail`) : null,
|
|
3928
|
+
warn > 0 ? c(ANSI2.yellow, `${warn} warn`) : null,
|
|
3929
|
+
pass > 0 ? c(ANSI2.green, `${pass} pass`) : null,
|
|
3930
|
+
na > 0 ? c(ANSI2.dim, `${na} n/a`) : null,
|
|
3931
|
+
crashed > 0 ? c(ANSI2.red, `${crashed} crashed`) : null
|
|
3932
|
+
].filter(Boolean);
|
|
3933
|
+
lines.push(` ${c(ANSI2.cyan, rule.ruleId.padEnd(24))} ${tally.join(c(ANSI2.dim, " \xB7 "))}`);
|
|
3934
|
+
}
|
|
3935
|
+
lines.push("");
|
|
3936
|
+
}
|
|
3937
|
+
if (summary.advisories && summary.advisories.length > 0) {
|
|
3938
|
+
lines.push(c(ANSI2.bold, "Needs judgment"));
|
|
3939
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
3940
|
+
for (const advisory of summary.advisories) {
|
|
3941
|
+
const entry = byRule.get(advisory.ruleId);
|
|
3942
|
+
if (entry) entry.pages += 1;
|
|
3943
|
+
else byRule.set(advisory.ruleId, { prose: advisory.prose, pages: 1 });
|
|
3944
|
+
}
|
|
3945
|
+
for (const [ruleId, { prose, pages }] of byRule) {
|
|
3946
|
+
lines.push(
|
|
3947
|
+
` ${c(ANSI2.cyan, ruleId)} ${c(ANSI2.dim, `(${pages} page${pages === 1 ? "" : "s"})`)}`
|
|
3948
|
+
);
|
|
3949
|
+
lines.push(` ${prose}`);
|
|
3950
|
+
}
|
|
3951
|
+
lines.push("");
|
|
3952
|
+
}
|
|
3404
3953
|
if (summary.verdict === "green") {
|
|
3405
3954
|
lines.push(c(ANSI2.green, "No problems found."));
|
|
3406
3955
|
lines.push("");
|
|
@@ -3446,6 +3995,7 @@ function summarize(report) {
|
|
|
3446
3995
|
return {
|
|
3447
3996
|
url: report.url,
|
|
3448
3997
|
finishedAt: report.finishedAt,
|
|
3998
|
+
profile: report.profile,
|
|
3449
3999
|
verdict: report.summary.verdict,
|
|
3450
4000
|
totals: {
|
|
3451
4001
|
brokenLinks: report.summary.brokenLinks,
|
|
@@ -3465,6 +4015,8 @@ function summarize(report) {
|
|
|
3465
4015
|
},
|
|
3466
4016
|
seoIssues: rollupByRule(report.seoIssues),
|
|
3467
4017
|
siteIssues: rollupByRule(report.siteIssues),
|
|
4018
|
+
...report.conformance ? { conformance: { rules: report.conformance.rules } } : {},
|
|
4019
|
+
...report.advisories ? { advisories: report.advisories } : {},
|
|
3468
4020
|
truncated: report.diagnostics.truncated,
|
|
3469
4021
|
warnings: report.diagnostics.warnings
|
|
3470
4022
|
};
|
|
@@ -3704,6 +4256,9 @@ var Logger = class {
|
|
|
3704
4256
|
};
|
|
3705
4257
|
|
|
3706
4258
|
export {
|
|
4259
|
+
DEFAULT_PROFILE,
|
|
4260
|
+
PROFILES,
|
|
4261
|
+
PROFILE_NAMES,
|
|
3707
4262
|
routeKey,
|
|
3708
4263
|
targetKey,
|
|
3709
4264
|
fingerprint,
|
|
@@ -3716,4 +4271,4 @@ export {
|
|
|
3716
4271
|
summarize,
|
|
3717
4272
|
Logger
|
|
3718
4273
|
};
|
|
3719
|
-
//# sourceMappingURL=chunk-
|
|
4274
|
+
//# sourceMappingURL=chunk-GFM3YN5N.js.map
|