@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.
@@ -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 TITLE_MIN = 10;
1222
- var TITLE_MAX = 60;
1223
- var DESC_MIN = 50;
1224
- var DESC_MAX = 160;
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
- summary: "Every page needs a non-empty `<title>`",
1233
- check: ({ page, issue }) => {
1234
- const t = page.meta.title?.value?.trim();
1235
- if (t && t.length > 0) return;
1236
- return issue({
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
- fix: {
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
- severity: "warning",
1253
- summary: `Keep \`<title>\` between ${TITLE_MIN} and ${TITLE_MAX} characters`,
1254
- check: ({ page, issue }) => {
1255
- const t = page.meta.title?.value?.trim();
1256
- if (!t) return;
1257
- if (t.length >= TITLE_MIN && t.length <= TITLE_MAX) return;
1258
- const direction = t.length < TITLE_MIN ? "short" : "long";
1259
- return issue({
1260
- message: `Title is ${t.length} characters \u2014 ${direction} of the recommended ${TITLE_MIN}\u2013${TITLE_MAX} window.`,
1261
- origin: { kind: "title" }
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
- summary: 'Provide a `<meta name="description">` on every indexable page',
1269
- check: ({ page, issue }) => {
1270
- const d = page.meta.description?.value?.trim();
1271
- if (d && d.length > 0) return;
1272
- return issue({
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
- fix: {
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
- severity: "warning",
1291
- summary: `Keep descriptions between ${DESC_MIN} and ${DESC_MAX} characters`,
1292
- check: ({ page, issue }) => {
1293
- const d = page.meta.description?.value?.trim();
1294
- if (!d) return;
1295
- if (d.length >= DESC_MIN && d.length <= DESC_MAX) return;
1296
- const direction = d.length < DESC_MIN ? "short" : "long";
1297
- return issue({
1298
- message: `Description is ${d.length} characters \u2014 ${direction} of the recommended ${DESC_MIN}\u2013${DESC_MAX} window.`,
1299
- origin: { kind: "meta", name: "description" }
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
- summary: 'Declare a `<link rel="canonical">` so search engines pick the right URL',
1307
- check: ({ page, issue }) => {
1308
- if (page.links.canonical) return;
1309
- return issue({
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
- fix: {
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
- summary: '`rel="canonical"` must be an absolute, fully-qualified URL',
1331
- check: ({ page, issue }) => {
1332
- const link = page.raw.links.find((l) => l.rel.toLowerCase() === "canonical");
1333
- const raw = link?.href?.trim();
1334
- if (!raw) return;
1335
- if (/^https?:\/\//i.test(raw)) return;
1336
- return issue({
1337
- message: `Canonical is "${raw}" \u2014 must be an absolute http(s) URL (consumers see the raw value, not the resolved "${page.links.canonical ?? raw}").`,
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
- summary: 'Declare a `<meta name="viewport">` so mobile browsers render at the right scale',
1346
- check: ({ page, issue }) => {
1347
- const v = page.meta.viewport?.value?.trim();
1348
- if (v && v.length > 0) return;
1349
- return issue({
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
- fix: {
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
- summary: "Set an explicit `og:title` instead of relying on `<title>` fallback",
1367
- check: ({ page, issue }) => {
1368
- if (page.openGraph.title?.value?.trim()) return;
1369
- return issue({
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
- summary: "Set an explicit `og:description` for richer link unfurls",
1379
- check: ({ page, issue }) => {
1380
- if (page.openGraph.description?.value?.trim()) return;
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
- page.openGraph.title?.value || page.openGraph.images.length > 0 || page.openGraph.url?.value
1558
+ ex.openGraph.title?.value || ex.openGraph.images.length > 0 || ex.openGraph.url?.value
1383
1559
  );
1384
- if (!hasOtherOg) return;
1385
- return issue({
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
- summary: "Provide at least one `og:image` so links unfurl with a preview",
1395
- check: ({ page, issue }) => {
1396
- if (page.openGraph.images.length > 0) return;
1397
- return issue({
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
- fix: {
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
- summary: "`robots`, `googlebot`, and `X-Robots-Tag` must not contradict each other",
1418
- check: ({ page, issue }) => {
1419
- const issues = [];
1420
- const metaRobots = new Set(tokens(page.meta.robots?.value));
1421
- const metaGoogle = new Set(tokens(page.meta.googlebot?.value));
1422
- const header = new Set(tokens(page.fetch.headers["x-robots-tag"]));
1423
- const allSources = [
1424
- { name: "meta robots", tokens: metaRobots, origin: { kind: "meta", name: "robots" } },
1425
- { name: "meta googlebot", tokens: metaGoogle, origin: { kind: "meta", name: "googlebot" } },
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: header,
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 sources = allSources.filter((s) => s.tokens.size > 0);
1433
- if (sources.length < 2) return [];
1434
- const indexers = sources.filter((s) => s.tokens.has("index") && !s.tokens.has("noindex"));
1435
- const noindexers = sources.filter((s) => s.tokens.has("noindex"));
1436
- if (indexers.length > 0 && noindexers.length > 0) {
1437
- issues.push(
1438
- issue({
1439
- message: `Conflicting indexing directives: ${noindexers.map((s) => s.name).join(", ")} say \`noindex\`, ${indexers.map((s) => s.name).join(", ")} say \`index\`.`,
1440
- origin: noindexers[0].origin
1441
- })
1442
- );
1443
- }
1444
- const followers = sources.filter((s) => s.tokens.has("follow") && !s.tokens.has("nofollow"));
1445
- const nofollowers = sources.filter((s) => s.tokens.has("nofollow"));
1446
- if (followers.length > 0 && nofollowers.length > 0) {
1447
- issues.push(
1448
- issue({
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 issues;
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
- function getRule(id) {
1472
- return RULE_BY_ID.get(id);
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 lint(page)) {
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 = getRule(issue.ruleId);
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?.summary,
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-3777NRM4.js.map
4274
+ //# sourceMappingURL=chunk-GFM3YN5N.js.map