@goflag/cli 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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];
@@ -1519,6 +1782,9 @@ function sortIssues(issues) {
1519
1782
 
1520
1783
  // src/lib/core/i18n.ts
1521
1784
  var BCP47_LOOSE = /^[a-z]{2,3}(-[a-z]{2}|-\d{3})?$/i;
1785
+ function localeIdentity(tag) {
1786
+ return tag.trim().toLowerCase();
1787
+ }
1522
1788
  function looksLikeLocaleSegment(segment) {
1523
1789
  return BCP47_LOOSE.test(segment);
1524
1790
  }
@@ -1560,7 +1826,7 @@ function buildI18nMatrix(pages, options = {}) {
1560
1826
  continue;
1561
1827
  }
1562
1828
  const { route } = splitRoute(altUrl.pathname);
1563
- const locale = alt.isXDefault ? "x-default" : alt.hreflang;
1829
+ const locale = alt.isXDefault ? "x-default" : localeIdentity(alt.hreflang);
1564
1830
  record(route || selfRoute, locale, altUrl.toString());
1565
1831
  }
1566
1832
  }
@@ -1594,14 +1860,15 @@ function reciprocityIssues(pages) {
1594
1860
  const url = page.fetch.finalUrl;
1595
1861
  const seenLocalesOnPage = /* @__PURE__ */ new Set();
1596
1862
  for (const alt of page.links.alternates) {
1597
- const locale = alt.isXDefault ? "x-default" : alt.hreflang;
1863
+ const raw = alt.isXDefault ? "x-default" : alt.hreflang;
1864
+ const locale = alt.isXDefault ? "x-default" : localeIdentity(alt.hreflang);
1598
1865
  seenLocalesOnPage.add(locale);
1599
1866
  if (!isValidLocale(locale)) {
1600
1867
  issues.push({
1601
1868
  code: "locale.invalid",
1602
1869
  url,
1603
- locale,
1604
- message: `\`hreflang="${locale}"\` is not a valid BCP 47 tag.`
1870
+ locale: raw,
1871
+ message: `\`hreflang="${raw}"\` is not a valid BCP 47 tag.`
1605
1872
  });
1606
1873
  continue;
1607
1874
  }
@@ -1660,7 +1927,7 @@ function dedupeBy(items, key) {
1660
1927
  function splitRoute(pathname) {
1661
1928
  const segments = pathname.split("/").filter(Boolean);
1662
1929
  if (segments.length > 0 && looksLikeLocaleSegment(segments[0])) {
1663
- const locale = segments[0];
1930
+ const locale = localeIdentity(segments[0]);
1664
1931
  const rest = segments.slice(1).join("/");
1665
1932
  return { route: rest ? `/${rest}` : "/", locale };
1666
1933
  }
@@ -2222,6 +2489,168 @@ function relaxTlsIfRequested2(enabled) {
2222
2489
  };
2223
2490
  }
2224
2491
 
2492
+ // src/lib/rules/advisory.ts
2493
+ function resolveEvidencePath(extraction, path) {
2494
+ let current = extraction;
2495
+ for (const segment of path.split(".")) {
2496
+ if (current === null || current === void 0) return null;
2497
+ if (typeof current !== "object") return null;
2498
+ current = current[segment];
2499
+ }
2500
+ return current ?? null;
2501
+ }
2502
+ function evidenceFor(extraction, paths) {
2503
+ const bundle = {};
2504
+ for (const path of paths) bundle[path] = resolveEvidencePath(extraction, path);
2505
+ return bundle;
2506
+ }
2507
+ function collectAdvisories(extraction, rules) {
2508
+ return rules.filter((rule) => rule.appliesTo?.(extraction) ?? true).map((rule) => ({
2509
+ ruleId: rule.id,
2510
+ kind: "prose",
2511
+ prose: rule.prose,
2512
+ rigor: rule.rigor,
2513
+ sources: rule.sources,
2514
+ evidence: evidenceFor(extraction, rule.reads),
2515
+ verdict: "needs-judgment"
2516
+ }));
2517
+ }
2518
+
2519
+ // src/lib/rules/profiles/index.ts
2520
+ var DEFAULT_PROFILE = "default";
2521
+ var PROFILES = {
2522
+ default: {
2523
+ name: "default",
2524
+ description: "rule severities exactly as authored"
2525
+ },
2526
+ strict: {
2527
+ name: "strict",
2528
+ description: "every spec-backed rule fails the build",
2529
+ // The line is drawn at "is there a document that says so", not at "does
2530
+ // it feel important". Heuristics stay warnings even here — promoting
2531
+ // SERP-length folklore to an error would be exactly the dishonesty the
2532
+ // rigor axis exists to prevent.
2533
+ byRigor: {
2534
+ "spec-required": { severity: "error" },
2535
+ "spec-recommended": { severity: "error" },
2536
+ "vendor-spec": { severity: "error" },
2537
+ guideline: { severity: "warning" },
2538
+ heuristic: { severity: "warning" }
2539
+ }
2540
+ },
2541
+ "spec-only": {
2542
+ name: "spec-only",
2543
+ description: "heuristic rules switched off entirely",
2544
+ // Not a severity change: a heuristic finding is switched off, so it is
2545
+ // absent from the conformance view rather than sitting there as a
2546
+ // suspiciously quiet `pass`.
2547
+ byRigor: {
2548
+ heuristic: { enabled: false }
2549
+ }
2550
+ },
2551
+ marketing: {
2552
+ name: "marketing",
2553
+ description: "snippet and unfurl metadata gaps are errors",
2554
+ // The recommended/optional distinction inverts for a page whose job is
2555
+ // to be shared: a missing description or preview image costs a click,
2556
+ // whatever the spec calls it.
2557
+ rules: {
2558
+ "description.missing": { severity: "error" },
2559
+ "description.length": { severity: "warning" },
2560
+ "og.title.missing": { severity: "error" },
2561
+ "og.image.missing": { severity: "error" },
2562
+ "og.description.missing": { severity: "warning" }
2563
+ }
2564
+ }
2565
+ };
2566
+ var PROFILE_NAMES = Object.keys(PROFILES);
2567
+ function resolveProfile(name = DEFAULT_PROFILE) {
2568
+ const profile = PROFILES[name];
2569
+ if (!profile) {
2570
+ throw new Error(`unknown profile "${name}" \u2014 expected one of: ${PROFILE_NAMES.join(", ")}`);
2571
+ }
2572
+ return profile;
2573
+ }
2574
+ function overrideFor(rule, profile) {
2575
+ return { ...profile.byRigor?.[rule.rigor], ...profile.rules?.[rule.id] };
2576
+ }
2577
+ function withSeverity(rule, severity) {
2578
+ if (rule.kind === "boolean") return { ...rule, severity };
2579
+ return { ...rule, severityByBand: { acceptable: severity, poor: severity } };
2580
+ }
2581
+ function applyProfile(rules, profile) {
2582
+ const effective = [];
2583
+ for (const rule of rules) {
2584
+ const override = overrideFor(rule, profile);
2585
+ if (override.enabled === false) continue;
2586
+ effective.push(override.severity ? withSeverity(rule, override.severity) : rule);
2587
+ }
2588
+ return effective;
2589
+ }
2590
+ function rulesForProfile(name, rules = RULES) {
2591
+ return applyProfile(rules, resolveProfile(name));
2592
+ }
2593
+
2594
+ // src/lib/rules/prose.ts
2595
+ var titleDescriptive = {
2596
+ id: "title.descriptive",
2597
+ kind: "prose",
2598
+ category: "document",
2599
+ title: "The `<title>` describes this specific page",
2600
+ why: "A title that repeats the site name, or describes the section rather than the page, gives a searcher no way to tell two results apart \u2014 and gives Google a reason to rewrite it into something you did not choose.",
2601
+ rigor: "guideline",
2602
+ sources: ["google-title-link", "bing-webmaster-guidelines"],
2603
+ reads: ["document.title", "meta.description", "openGraph.title", "http.finalUrl"],
2604
+ relates: ["title.missing", "title.length"],
2605
+ appliesTo: (ex) => Boolean(ex.document.title?.value?.trim()),
2606
+ prose: "Does the title describe what is on THIS page specifically \u2014 not the site, not the section \u2014 and would it be distinguishable from the other pages of this site in a list of search results?"
2607
+ };
2608
+ var descriptionAccurate = {
2609
+ id: "description.accurate",
2610
+ kind: "prose",
2611
+ category: "meta",
2612
+ title: "The meta description summarizes this page truthfully",
2613
+ why: "The description is the one sentence you get to write in a search result. A boilerplate line repeated site-wide, or one that promises something the page does not deliver, gets replaced by improvised page text \u2014 or gets the click and loses the visitor.",
2614
+ rigor: "guideline",
2615
+ sources: ["google-snippet", "moz-meta-description"],
2616
+ reads: ["meta.description", "document.title", "http.finalUrl"],
2617
+ relates: ["description.missing", "description.length"],
2618
+ appliesTo: (ex) => Boolean(ex.meta.description?.value?.trim()),
2619
+ prose: "Does the description accurately summarize this page's content, and is it written for this page rather than copied across the site?"
2620
+ };
2621
+ var langMatchesContent = {
2622
+ id: "lang.matches-content",
2623
+ kind: "prose",
2624
+ category: "i18n",
2625
+ title: "The declared `lang` is the language the page is actually written in",
2626
+ why: "A wrong `lang` is worse than a missing one: screen readers switch to the wrong pronunciation rules, browsers offer to translate a page that is already in the reader's language, and search engines file the page under the wrong audience. Nothing in the markup contradicts it, so no mechanical check can catch it.",
2627
+ rigor: "spec-required",
2628
+ sources: ["whatwg-html-lang", "w3c-i18n-language-tags"],
2629
+ reads: ["document.lang", "document.title", "meta.description", "openGraph.locale"],
2630
+ appliesTo: (ex) => Boolean(ex.document.lang?.value?.trim()),
2631
+ prose: "Is the text of this page actually written in the language its `lang` attribute declares?"
2632
+ };
2633
+ var ogImageRepresentative = {
2634
+ id: "og.image.representative",
2635
+ kind: "prose",
2636
+ category: "opengraph",
2637
+ title: "The `og:image` represents this page and survives the unfurl crop",
2638
+ why: "The preview image is the whole payload of a shared link. A generic site-wide banner, or artwork whose subject sits outside the ~1.91:1 crop, is present enough to pass every mechanical check and still communicate nothing.",
2639
+ rigor: "guideline",
2640
+ sources: ["ogp", "meta-og-sharing"],
2641
+ reads: ["openGraph.images", "openGraph.title", "document.title", "http.finalUrl"],
2642
+ relates: ["og.image.missing"],
2643
+ appliesTo: (ex) => ex.openGraph.images.length > 0,
2644
+ prose: "Does the og:image represent what this page is about, rather than being a site-wide default, and does its subject survive being cropped to the 1.91:1 aspect ratio consumers render?"
2645
+ };
2646
+ var PROSE_RULES = [
2647
+ descriptionAccurate,
2648
+ langMatchesContent,
2649
+ ogImageRepresentative,
2650
+ titleDescriptive
2651
+ ];
2652
+ var PROSE_RULE_BY_ID = new Map(PROSE_RULES.map((r) => [r.id, r]));
2653
+
2225
2654
  // src/lib/core/net/fetch-url.ts
2226
2655
  var DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
2227
2656
  var DEFAULT_TIMEOUT_MS = 8e3;
@@ -2858,6 +3287,38 @@ function normalizeInputUrl(input) {
2858
3287
  return { ok: true, url: candidate };
2859
3288
  }
2860
3289
 
3290
+ // src/report/conformance.ts
3291
+ function buildConformance(rules, rows) {
3292
+ const totals = new Map(
3293
+ rules.map((rule) => [rule.id, { pass: 0, fail: 0, warn: 0, na: 0, crashed: 0 }])
3294
+ );
3295
+ const pages = rows.map((row) => {
3296
+ const statuses = {};
3297
+ const seen = /* @__PURE__ */ new Set();
3298
+ for (const finding of row.findings) {
3299
+ const tally = totals.get(finding.ruleId);
3300
+ if (!tally) continue;
3301
+ statuses[finding.ruleId] = finding.status;
3302
+ tally[finding.status] += 1;
3303
+ seen.add(finding.ruleId);
3304
+ }
3305
+ for (const rule of rules) if (!seen.has(rule.id)) totals.get(rule.id).crashed += 1;
3306
+ return { url: row.pageUrl, statuses };
3307
+ });
3308
+ return {
3309
+ rules: rules.map((rule) => ({
3310
+ ruleId: rule.id,
3311
+ kind: rule.kind,
3312
+ title: rule.title,
3313
+ rigor: rule.rigor,
3314
+ sources: rule.sources,
3315
+ expected: rule.expected,
3316
+ totals: totals.get(rule.id)
3317
+ })),
3318
+ pages
3319
+ };
3320
+ }
3321
+
2861
3322
  // src/report/build.ts
2862
3323
  function isOkStatus(status) {
2863
3324
  return status >= 200 && status < 300;
@@ -2933,6 +3394,8 @@ async function runAudit(inputUrl, options = {}) {
2933
3394
  const depth = options.depth ?? 2;
2934
3395
  const maxPages = options.maxPages ?? 200;
2935
3396
  const checkExternal = options.checkExternal !== false;
3397
+ const profile = options.profile ?? DEFAULT_PROFILE;
3398
+ const rules = rulesForProfile(profile);
2936
3399
  const discovery = options.noSitemap ? void 0 : await discoverSitemap(entry, {
2937
3400
  signal: options.signal,
2938
3401
  timeoutMs: options.timeoutMs,
@@ -2977,20 +3440,37 @@ async function runAudit(inputUrl, options = {}) {
2977
3440
  status: p.fetch.status
2978
3441
  }));
2979
3442
  const seoIssues = [];
3443
+ const conformanceRows = [];
3444
+ const advisories = [];
3445
+ const ruleById = new Map(rules.map((rule) => [rule.id, rule]));
2980
3446
  for (const page of htmlPages) {
2981
3447
  const pageUrl = page.fetch.finalUrl;
3448
+ const extraction = extractionFromPage(page);
3449
+ const evaluation = evaluateRules(extraction, rules);
3450
+ if (options.conformance) {
3451
+ conformanceRows.push({ pageUrl, findings: evaluation.findings });
3452
+ }
3453
+ if (options.advisories) {
3454
+ for (const advisory of collectAdvisories(extraction, PROSE_RULES)) {
3455
+ advisories.push({
3456
+ ...advisory,
3457
+ id: fingerprint("advisory", advisory.ruleId, routeKey(pageUrl)),
3458
+ pageUrl
3459
+ });
3460
+ }
3461
+ }
2982
3462
  const occurrence = /* @__PURE__ */ new Map();
2983
- for (const issue of lint(page)) {
3463
+ for (const issue of sortIssues(findingsToIssues(evaluation, rules))) {
2984
3464
  const n = occurrence.get(issue.ruleId) ?? 0;
2985
3465
  occurrence.set(issue.ruleId, n + 1);
2986
- const rule = getRule(issue.ruleId);
3466
+ const rule = ruleById.get(issue.ruleId);
2987
3467
  seoIssues.push({
2988
3468
  id: fingerprint("seo", issue.ruleId, routeKey(pageUrl), String(n)),
2989
3469
  pageUrl,
2990
3470
  ruleId: issue.ruleId,
2991
3471
  severity: issue.severity,
2992
3472
  message: issue.message,
2993
- why: rule?.summary,
3473
+ why: rule?.title,
2994
3474
  fix: issue.fix?.snippet
2995
3475
  });
2996
3476
  }
@@ -3104,6 +3584,7 @@ async function runAudit(inputUrl, options = {}) {
3104
3584
  return {
3105
3585
  url: entry,
3106
3586
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
3587
+ profile,
3107
3588
  summary: {
3108
3589
  brokenLinks: brokenCount,
3109
3590
  missingTranslations,
@@ -3128,6 +3609,12 @@ async function runAudit(inputUrl, options = {}) {
3128
3609
  missingTranslations: { holes, reciprocity },
3129
3610
  seoIssues,
3130
3611
  siteIssues,
3612
+ // Both sections are omitted entirely when not asked for, rather than
3613
+ // emitted empty: an empty `advisories: []` would read as "no prose rule
3614
+ // has anything to ask about this site", which is a claim goflag has not
3615
+ // made.
3616
+ ...options.conformance ? { conformance: buildConformance(rules, conformanceRows) } : {},
3617
+ ...options.advisories ? { advisories } : {},
3131
3618
  diagnostics: {
3132
3619
  pagesCrawled: pages.length,
3133
3620
  pagesScanned: linkReport.pagesScanned,
@@ -3206,7 +3693,10 @@ function renderTerminal(report, options = {}) {
3206
3693
  lines.push(
3207
3694
  `${c(verdictColor(report.summary.verdict), VERDICT_FLAG[report.summary.verdict].toUpperCase())} ` + c(
3208
3695
  ANSI.dim,
3209
- `${report.diagnostics.pagesCrawled} pages crawled, ${report.diagnostics.pagesScanned} scanned`
3696
+ `${report.diagnostics.pagesCrawled} pages crawled, ${report.diagnostics.pagesScanned} scanned` + // Named only when it is not the default. A run under `spec-only`
3697
+ // that prints "0 SEO issues" without saying so reads as a clean
3698
+ // site rather than a narrowed one.
3699
+ (report.profile === DEFAULT_PROFILE ? "" : `, profile ${report.profile}`)
3210
3700
  )
3211
3701
  );
3212
3702
  lines.push("");
@@ -3276,6 +3766,36 @@ function renderTerminal(report, options = {}) {
3276
3766
  lines.push(...renderIssuesByPage(report.siteIssues, c));
3277
3767
  lines.push("");
3278
3768
  }
3769
+ if (report.conformance) {
3770
+ lines.push(c(ANSI.bold, "Conformance"));
3771
+ const judged = report.conformance.pages.length;
3772
+ lines.push(c(ANSI.dim, ` ${judged} page${judged === 1 ? "" : "s"} judged \xD7 every rule`));
3773
+ for (const rule of report.conformance.rules) {
3774
+ const { pass, fail, warn, na, crashed } = rule.totals;
3775
+ const tally = [
3776
+ fail > 0 ? c(ANSI.red, `${fail} fail`) : null,
3777
+ warn > 0 ? c(ANSI.yellow, `${warn} warn`) : null,
3778
+ pass > 0 ? c(ANSI.green, `${pass} pass`) : null,
3779
+ na > 0 ? c(ANSI.dim, `${na} n/a`) : null,
3780
+ crashed > 0 ? c(ANSI.red, `${crashed} crashed`) : null
3781
+ ].filter(Boolean);
3782
+ lines.push(
3783
+ ` ${c(ANSI.cyan, rule.ruleId.padEnd(24))} ${tally.join(c(ANSI.dim, " \xB7 "))}` + c(ANSI.dim, ` [${rule.rigor}]`)
3784
+ );
3785
+ }
3786
+ lines.push("");
3787
+ }
3788
+ if (report.advisories && report.advisories.length > 0) {
3789
+ lines.push(c(ANSI.bold, "Needs judgment"));
3790
+ lines.push(c(ANSI.dim, " goflag states these; it will not guess the answer."));
3791
+ for (const [ruleId, group] of groupBy([...report.advisories], (a) => a.ruleId)) {
3792
+ lines.push(
3793
+ ` ${c(ANSI.cyan, ruleId)} ${c(ANSI.dim, `(${group.length} page${group.length === 1 ? "" : "s"})`)}`
3794
+ );
3795
+ lines.push(` ${group[0].prose}`);
3796
+ }
3797
+ lines.push("");
3798
+ }
3279
3799
  if (report.summary.verdict === "green") {
3280
3800
  lines.push(c(ANSI.green, "No problems found."));
3281
3801
  lines.push("");
@@ -3346,7 +3866,9 @@ function renderSummaryTerminal(summary, options = {}) {
3346
3866
  lines.push(
3347
3867
  `${c(verdictColor2(summary.verdict), VERDICT_FLAG2[summary.verdict])} ` + c(
3348
3868
  ANSI2.dim,
3349
- `${summary.totals.pagesCrawled} pages crawled, ${summary.totals.pagesScanned} scanned`
3869
+ `${summary.totals.pagesCrawled} pages crawled, ${summary.totals.pagesScanned} scanned` + // Same reasoning as `renderTerminal`: a narrowed run must say so,
3870
+ // or its low finding count reads as a clean site.
3871
+ (summary.profile === DEFAULT_PROFILE ? "" : `, profile ${summary.profile}`)
3350
3872
  )
3351
3873
  );
3352
3874
  lines.push("");
@@ -3401,6 +3923,37 @@ function renderSummaryTerminal(summary, options = {}) {
3401
3923
  lines.push(...rollupRows(summary.siteIssues, c));
3402
3924
  lines.push("");
3403
3925
  }
3926
+ if (summary.conformance) {
3927
+ lines.push(c(ANSI2.bold, "Conformance"));
3928
+ for (const rule of summary.conformance.rules) {
3929
+ const { pass, fail, warn, na, crashed } = rule.totals;
3930
+ const tally = [
3931
+ fail > 0 ? c(ANSI2.red, `${fail} fail`) : null,
3932
+ warn > 0 ? c(ANSI2.yellow, `${warn} warn`) : null,
3933
+ pass > 0 ? c(ANSI2.green, `${pass} pass`) : null,
3934
+ na > 0 ? c(ANSI2.dim, `${na} n/a`) : null,
3935
+ crashed > 0 ? c(ANSI2.red, `${crashed} crashed`) : null
3936
+ ].filter(Boolean);
3937
+ lines.push(` ${c(ANSI2.cyan, rule.ruleId.padEnd(24))} ${tally.join(c(ANSI2.dim, " \xB7 "))}`);
3938
+ }
3939
+ lines.push("");
3940
+ }
3941
+ if (summary.advisories && summary.advisories.length > 0) {
3942
+ lines.push(c(ANSI2.bold, "Needs judgment"));
3943
+ const byRule = /* @__PURE__ */ new Map();
3944
+ for (const advisory of summary.advisories) {
3945
+ const entry = byRule.get(advisory.ruleId);
3946
+ if (entry) entry.pages += 1;
3947
+ else byRule.set(advisory.ruleId, { prose: advisory.prose, pages: 1 });
3948
+ }
3949
+ for (const [ruleId, { prose, pages }] of byRule) {
3950
+ lines.push(
3951
+ ` ${c(ANSI2.cyan, ruleId)} ${c(ANSI2.dim, `(${pages} page${pages === 1 ? "" : "s"})`)}`
3952
+ );
3953
+ lines.push(` ${prose}`);
3954
+ }
3955
+ lines.push("");
3956
+ }
3404
3957
  if (summary.verdict === "green") {
3405
3958
  lines.push(c(ANSI2.green, "No problems found."));
3406
3959
  lines.push("");
@@ -3446,6 +3999,7 @@ function summarize(report) {
3446
3999
  return {
3447
4000
  url: report.url,
3448
4001
  finishedAt: report.finishedAt,
4002
+ profile: report.profile,
3449
4003
  verdict: report.summary.verdict,
3450
4004
  totals: {
3451
4005
  brokenLinks: report.summary.brokenLinks,
@@ -3465,6 +4019,8 @@ function summarize(report) {
3465
4019
  },
3466
4020
  seoIssues: rollupByRule(report.seoIssues),
3467
4021
  siteIssues: rollupByRule(report.siteIssues),
4022
+ ...report.conformance ? { conformance: { rules: report.conformance.rules } } : {},
4023
+ ...report.advisories ? { advisories: report.advisories } : {},
3468
4024
  truncated: report.diagnostics.truncated,
3469
4025
  warnings: report.diagnostics.warnings
3470
4026
  };
@@ -3704,6 +4260,9 @@ var Logger = class {
3704
4260
  };
3705
4261
 
3706
4262
  export {
4263
+ DEFAULT_PROFILE,
4264
+ PROFILES,
4265
+ PROFILE_NAMES,
3707
4266
  routeKey,
3708
4267
  targetKey,
3709
4268
  fingerprint,
@@ -3716,4 +4275,4 @@ export {
3716
4275
  summarize,
3717
4276
  Logger
3718
4277
  };
3719
- //# sourceMappingURL=chunk-3777NRM4.js.map
4278
+ //# sourceMappingURL=chunk-6WOWKLXY.js.map