@pseolint/core 0.2.1 → 0.3.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.
Files changed (68) hide show
  1. package/README.md +84 -15
  2. package/dist/ai/prompt.d.ts +1 -1
  3. package/dist/ai/prompt.d.ts.map +1 -1
  4. package/dist/ai/prompt.js +13 -1
  5. package/dist/ai/prompt.js.map +1 -1
  6. package/dist/auditor.d.ts.map +1 -1
  7. package/dist/auditor.js +197 -63
  8. package/dist/auditor.js.map +1 -1
  9. package/dist/cache.d.ts.map +1 -1
  10. package/dist/cache.js +38 -2
  11. package/dist/cache.js.map +1 -1
  12. package/dist/formatters/console.d.ts +9 -0
  13. package/dist/formatters/console.d.ts.map +1 -1
  14. package/dist/formatters/console.js +53 -0
  15. package/dist/formatters/console.js.map +1 -1
  16. package/dist/formatters/html.d.ts.map +1 -1
  17. package/dist/formatters/html.js +363 -135
  18. package/dist/formatters/html.js.map +1 -1
  19. package/dist/index.d.ts +10 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +9 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/rule-references.d.ts.map +1 -1
  24. package/dist/rule-references.js +8 -0
  25. package/dist/rule-references.js.map +1 -1
  26. package/dist/rules/aeo/answer-first.d.ts +18 -0
  27. package/dist/rules/aeo/answer-first.d.ts.map +1 -0
  28. package/dist/rules/aeo/answer-first.js +191 -0
  29. package/dist/rules/aeo/answer-first.js.map +1 -0
  30. package/dist/rules/aeo/citable-facts.d.ts +9 -0
  31. package/dist/rules/aeo/citable-facts.d.ts.map +1 -0
  32. package/dist/rules/aeo/citable-facts.js +90 -0
  33. package/dist/rules/aeo/citable-facts.js.map +1 -0
  34. package/dist/rules/aeo/content-modularity.d.ts +11 -0
  35. package/dist/rules/aeo/content-modularity.d.ts.map +1 -0
  36. package/dist/rules/aeo/content-modularity.js +107 -0
  37. package/dist/rules/aeo/content-modularity.js.map +1 -0
  38. package/dist/rules/aeo/crawler-access.d.ts +25 -0
  39. package/dist/rules/aeo/crawler-access.d.ts.map +1 -0
  40. package/dist/rules/aeo/crawler-access.js +116 -0
  41. package/dist/rules/aeo/crawler-access.js.map +1 -0
  42. package/dist/rules/aeo/faq-coverage.d.ts +9 -0
  43. package/dist/rules/aeo/faq-coverage.d.ts.map +1 -0
  44. package/dist/rules/aeo/faq-coverage.js +71 -0
  45. package/dist/rules/aeo/faq-coverage.js.map +1 -0
  46. package/dist/rules/aeo/freshness-signals.d.ts +9 -0
  47. package/dist/rules/aeo/freshness-signals.d.ts.map +1 -0
  48. package/dist/rules/aeo/freshness-signals.js +109 -0
  49. package/dist/rules/aeo/freshness-signals.js.map +1 -0
  50. package/dist/rules/aeo/llms-txt.d.ts +24 -0
  51. package/dist/rules/aeo/llms-txt.d.ts.map +1 -0
  52. package/dist/rules/aeo/llms-txt.js +93 -0
  53. package/dist/rules/aeo/llms-txt.js.map +1 -0
  54. package/dist/rules/aeo/non-replicable-value.d.ts +9 -0
  55. package/dist/rules/aeo/non-replicable-value.d.ts.map +1 -0
  56. package/dist/rules/aeo/non-replicable-value.js +95 -0
  57. package/dist/rules/aeo/non-replicable-value.js.map +1 -0
  58. package/dist/rules/scope.d.ts +12 -0
  59. package/dist/rules/scope.d.ts.map +1 -0
  60. package/dist/rules/scope.js +66 -0
  61. package/dist/rules/scope.js.map +1 -0
  62. package/dist/rules/tech/robots-sitemap-presence.d.ts +16 -0
  63. package/dist/rules/tech/robots-sitemap-presence.d.ts.map +1 -1
  64. package/dist/rules/tech/robots-sitemap-presence.js +26 -2
  65. package/dist/rules/tech/robots-sitemap-presence.js.map +1 -1
  66. package/dist/types.d.ts +29 -0
  67. package/dist/types.d.ts.map +1 -1
  68. package/package.json +91 -66
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Default AI crawler user-agents to check. Ordered by prevalence.
3
+ */
4
+ export const DEFAULT_AI_CRAWLERS = [
5
+ "GPTBot",
6
+ "ChatGPT-User",
7
+ "ClaudeBot",
8
+ "PerplexityBot",
9
+ "Bytespider",
10
+ "Google-Extended",
11
+ "CCBot",
12
+ "Applebot-Extended",
13
+ ];
14
+ /**
15
+ * Parse robots.txt into a map of user-agent -> list of Disallow patterns.
16
+ * User-agent keys are lowercased for case-insensitive lookup.
17
+ * A blank Disallow value is treated as "allow all" and produces an empty array
18
+ * (matches the robots spec: "Disallow: " with no value means no restrictions).
19
+ */
20
+ export function parseRobotsByUserAgent(robotsTxt) {
21
+ const lines = robotsTxt.split(/\r?\n/);
22
+ const result = new Map();
23
+ let currentAgents = [];
24
+ let expectingRules = false;
25
+ for (const raw of lines) {
26
+ const line = raw.trim();
27
+ if (!line || line.startsWith("#"))
28
+ continue;
29
+ if (/^user-agent\s*:/i.test(line)) {
30
+ const ua = line.replace(/^user-agent\s*:\s*/i, "").trim().toLowerCase();
31
+ if (!expectingRules) {
32
+ // Stacking consecutive User-agent lines — they all share the next rule block.
33
+ currentAgents.push(ua);
34
+ }
35
+ else {
36
+ currentAgents = [ua];
37
+ expectingRules = false;
38
+ }
39
+ if (!result.has(ua))
40
+ result.set(ua, []);
41
+ continue;
42
+ }
43
+ if (/^(allow|disallow|crawl-delay|sitemap)\s*:/i.test(line)) {
44
+ expectingRules = true;
45
+ }
46
+ if (/^disallow\s*:/i.test(line)) {
47
+ const value = line.replace(/^disallow\s*:\s*/i, "").trim();
48
+ if (!value)
49
+ continue;
50
+ for (const agent of currentAgents) {
51
+ const bucket = result.get(agent);
52
+ if (bucket)
53
+ bucket.push(value);
54
+ }
55
+ }
56
+ }
57
+ return result;
58
+ }
59
+ /** True if the Disallow list includes a root block (`/`). */
60
+ export function isFullyDisallowed(patterns) {
61
+ if (!patterns)
62
+ return false;
63
+ return patterns.some((p) => p === "/" || p === "/*");
64
+ }
65
+ /**
66
+ * Warn per blocked AI crawler; escalate to error when all configured crawlers are blocked.
67
+ * Wildcard blocks (`User-agent: *` + `Disallow: /`) also count as blocking each named crawler
68
+ * unless the crawler has its own more-permissive block.
69
+ */
70
+ export function crawlerAccessRule(robotsTxtContent, options) {
71
+ if (!robotsTxtContent)
72
+ return [];
73
+ const crawlers = options?.crawlers ?? DEFAULT_AI_CRAWLERS;
74
+ const byAgent = parseRobotsByUserAgent(robotsTxtContent);
75
+ const wildcardBlocked = isFullyDisallowed(byAgent.get("*"));
76
+ const blocked = [];
77
+ for (const crawler of crawlers) {
78
+ const key = crawler.toLowerCase();
79
+ const ownBlock = byAgent.get(key);
80
+ if (ownBlock === undefined) {
81
+ // No explicit block for this agent — it falls back to the wildcard block.
82
+ if (wildcardBlocked)
83
+ blocked.push(crawler);
84
+ continue;
85
+ }
86
+ if (isFullyDisallowed(ownBlock))
87
+ blocked.push(crawler);
88
+ }
89
+ if (blocked.length === 0)
90
+ return [];
91
+ const findings = [];
92
+ const allBlocked = blocked.length === crawlers.length;
93
+ if (allBlocked) {
94
+ findings.push({
95
+ ruleId: "aeo/crawler-access",
96
+ severity: "error",
97
+ message: `robots.txt blocks all ${crawlers.length} configured AI crawlers: ${blocked.join(", ")}.`,
98
+ fix: `Blocking every AI crawler makes your pages invisible to answer engines. ` +
99
+ `Sites uncited in AI Overviews lose ~68% of traffic vs ~12% for cited sites. ` +
100
+ `Remove the Disallow rules for these crawlers unless you have a specific legal or competitive reason to block them.`,
101
+ });
102
+ return findings;
103
+ }
104
+ for (const crawler of blocked) {
105
+ findings.push({
106
+ ruleId: "aeo/crawler-access",
107
+ severity: "warning",
108
+ message: `robots.txt blocks ${crawler}.`,
109
+ fix: `Remove the "Disallow: /" directive for User-agent: ${crawler} in your robots.txt. ` +
110
+ `Blocking ${crawler} removes your pages from its answer engine's citation pool. ` +
111
+ `If selective blocking is intentional (e.g. admin routes only), narrow the Disallow pattern instead of blocking the whole site.`,
112
+ });
113
+ }
114
+ return findings;
115
+ }
116
+ //# sourceMappingURL=crawler-access.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crawler-access.js","sourceRoot":"","sources":["../../../src/rules/aeo/crawler-access.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG;IACjC,QAAQ;IACR,cAAc;IACd,WAAW;IACX,eAAe;IACf,YAAY;IACZ,iBAAiB;IACjB,OAAO;IACP,mBAAmB;CACX,CAAC;AAEX;;;;;GAKG;AACH,MAAM,UAAU,sBAAsB,CAAC,SAAiB;IACtD,MAAM,KAAK,GAAG,SAAS,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IACvC,MAAM,MAAM,GAAG,IAAI,GAAG,EAAoB,CAAC;IAC3C,IAAI,aAAa,GAAa,EAAE,CAAC;IACjC,IAAI,cAAc,GAAG,KAAK,CAAC;IAE3B,KAAK,MAAM,GAAG,IAAI,KAAK,EAAE,CAAC;QACxB,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;QACxB,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,SAAS;QAE5C,IAAI,kBAAkB,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;YAClC,MAAM,EAAE,GAAG,IAAI,CAAC,OAAO,CAAC,qBAAqB,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;YACxE,IAAI,CAAC,cAAc,EAAE,CAAC;gBACpB,8EAA8E;gBAC9E,aAAa,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YACzB,CAAC;iBAAM,CAAC;gBACN,aAAa,GAAG,CAAC,EAAE,CAAC,CAAC;gBACrB,cAAc,GAAG,KAAK,CAAC;YACzB,CAAC;YACD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC;gBAAE,MAAM,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;YACxC,SAAS;QACX,CAAC;QAED,IAAI,4CAA4C,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;YAC5D,cAAc,GAAG,IAAI,CAAC;QACxB,CAAC;QAED,IAAI,gBAAgB,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;YAChC,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,mBAAmB,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;YAC3D,IAAI,CAAC,KAAK;gBAAE,SAAS;YACrB,KAAK,MAAM,KAAK,IAAI,aAAa,EAAE,CAAC;gBAClC,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;gBACjC,IAAI,MAAM;oBAAE,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YACjC,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,6DAA6D;AAC7D,MAAM,UAAU,iBAAiB,CAAC,QAA8B;IAC9D,IAAI,CAAC,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC5B,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,IAAI,CAAC,CAAC;AACvD,CAAC;AAOD;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAC/B,gBAAwB,EACxB,OAA8B;IAE9B,IAAI,CAAC,gBAAgB;QAAE,OAAO,EAAE,CAAC;IAEjC,MAAM,QAAQ,GAAG,OAAO,EAAE,QAAQ,IAAI,mBAAmB,CAAC;IAC1D,MAAM,OAAO,GAAG,sBAAsB,CAAC,gBAAgB,CAAC,CAAC;IACzD,MAAM,eAAe,GAAG,iBAAiB,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;IAE5D,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,MAAM,GAAG,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;QAClC,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAClC,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;YAC3B,0EAA0E;YAC1E,IAAI,eAAe;gBAAE,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YAC3C,SAAS;QACX,CAAC;QACD,IAAI,iBAAiB,CAAC,QAAQ,CAAC;YAAE,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACzD,CAAC;IAED,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAEpC,MAAM,QAAQ,GAAiB,EAAE,CAAC;IAClC,MAAM,UAAU,GAAG,OAAO,CAAC,MAAM,KAAK,QAAQ,CAAC,MAAM,CAAC;IAEtD,IAAI,UAAU,EAAE,CAAC;QACf,QAAQ,CAAC,IAAI,CAAC;YACZ,MAAM,EAAE,oBAAoB;YAC5B,QAAQ,EAAE,OAAO;YACjB,OAAO,EAAE,yBAAyB,QAAQ,CAAC,MAAM,4BAA4B,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG;YAClG,GAAG,EACD,0EAA0E;gBAC1E,8EAA8E;gBAC9E,oHAAoH;SACvH,CAAC,CAAC;QACH,OAAO,QAAQ,CAAC;IAClB,CAAC;IAED,KAAK,MAAM,OAAO,IAAI,OAAO,EAAE,CAAC;QAC9B,QAAQ,CAAC,IAAI,CAAC;YACZ,MAAM,EAAE,oBAAoB;YAC5B,QAAQ,EAAE,SAAS;YACnB,OAAO,EAAE,qBAAqB,OAAO,GAAG;YACxC,GAAG,EACD,sDAAsD,OAAO,uBAAuB;gBACpF,YAAY,OAAO,8DAA8D;gBACjF,gIAAgI;SACnI,CAAC,CAAC;IACL,CAAC;IAED,OAAO,QAAQ,CAAC;AAClB,CAAC"}
@@ -0,0 +1,9 @@
1
+ import type { ParsedPage, RuleResult } from "../../types.js";
2
+ export interface FaqCoverageOptions {
3
+ /** URL substring/glob fragments that signal question intent. */
4
+ questionPatterns?: string[];
5
+ /** Minimum number of question-style headings to trigger the check (default: 2). */
6
+ minQuestionHeadings?: number;
7
+ }
8
+ export declare function faqCoverageRule(pages: ParsedPage[], options?: FaqCoverageOptions): RuleResult[];
9
+ //# sourceMappingURL=faq-coverage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"faq-coverage.d.ts","sourceRoot":"","sources":["../../../src/rules/aeo/faq-coverage.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAE7D,MAAM,WAAW,kBAAkB;IACjC,gEAAgE;IAChE,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B,mFAAmF;IACnF,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AA6CD,wBAAgB,eAAe,CAC7B,KAAK,EAAE,UAAU,EAAE,EACnB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,UAAU,EAAE,CA8Bd"}
@@ -0,0 +1,71 @@
1
+ const DEFAULT_URL_PATTERNS = ["/how-to-", "/what-is-", "/guide-", "-faq", "/faq", "/questions"];
2
+ const QUESTION_STARTERS = /^\s*(how|what|why|when|where|who|can|does|do|is|are|should|which|will|could|would|may)\b/i;
3
+ function isQuestionHeading(heading) {
4
+ const trimmed = heading.trim();
5
+ if (!trimmed)
6
+ return false;
7
+ return trimmed.endsWith("?") || QUESTION_STARTERS.test(trimmed);
8
+ }
9
+ function hasFaqLikeSchema(entries) {
10
+ const stack = [...entries];
11
+ while (stack.length > 0) {
12
+ const node = stack.pop();
13
+ if (node === null || typeof node !== "object")
14
+ continue;
15
+ const obj = node;
16
+ const type = obj["@type"];
17
+ if (type === "FAQPage" || type === "HowTo" || type === "QAPage")
18
+ return true;
19
+ if (Array.isArray(type) && type.some((t) => t === "FAQPage" || t === "HowTo" || t === "QAPage")) {
20
+ return true;
21
+ }
22
+ for (const value of Object.values(obj)) {
23
+ if (Array.isArray(value)) {
24
+ for (const item of value)
25
+ stack.push(item);
26
+ }
27
+ else if (value !== null && typeof value === "object") {
28
+ stack.push(value);
29
+ }
30
+ }
31
+ }
32
+ return false;
33
+ }
34
+ function urlLooksLikeFaq(url, patterns) {
35
+ try {
36
+ const path = new URL(url).pathname.toLowerCase();
37
+ return patterns.some((p) => path.includes(p));
38
+ }
39
+ catch {
40
+ const lower = url.toLowerCase();
41
+ return patterns.some((p) => lower.includes(p));
42
+ }
43
+ }
44
+ export function faqCoverageRule(pages, options) {
45
+ const patterns = options?.questionPatterns ?? DEFAULT_URL_PATTERNS;
46
+ const minQuestions = options?.minQuestionHeadings ?? 2;
47
+ const findings = [];
48
+ for (const page of pages) {
49
+ const questionHeadings = page.headings.h2.filter(isQuestionHeading);
50
+ const urlSignalsFaq = urlLooksLikeFaq(page.url, patterns);
51
+ if (!urlSignalsFaq && questionHeadings.length < minQuestions)
52
+ continue;
53
+ if (hasFaqLikeSchema(page.jsonLd))
54
+ continue;
55
+ const sampleList = questionHeadings.slice(0, 3).map((h) => `"${h.trim()}"`).join(", ");
56
+ const detail = questionHeadings.length > 0
57
+ ? `${questionHeadings.length} question-style heading${questionHeadings.length === 1 ? "" : "s"}${sampleList ? ` (e.g. ${sampleList})` : ""}`
58
+ : `URL path matches an FAQ pattern`;
59
+ findings.push({
60
+ ruleId: "aeo/faq-coverage",
61
+ severity: "info",
62
+ message: `${page.url} contains FAQ-style content (${detail}) but no FAQPage/HowTo JSON-LD.`,
63
+ pageUrl: page.url,
64
+ fix: `Add FAQPage JSON-LD that mirrors the existing Q&A content. For pSEO templates, generate the ` +
65
+ `schema programmatically from the same data source that renders the headings — don't ship identical ` +
66
+ `questions with only the entity name swapped.`,
67
+ });
68
+ }
69
+ return findings;
70
+ }
71
+ //# sourceMappingURL=faq-coverage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"faq-coverage.js","sourceRoot":"","sources":["../../../src/rules/aeo/faq-coverage.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ import type { ParsedPage, RuleResult } from "../../types.js";
2
+ export interface FreshnessOptions {
3
+ /** Flag pages with dateModified older than this many days. Default: 180. */
4
+ maxStaleDays?: number;
5
+ /** Clock override for deterministic testing. Default: Date.now(). */
6
+ now?: () => number;
7
+ }
8
+ export declare function freshnessSignalsRule(pages: ParsedPage[], options?: FreshnessOptions): RuleResult[];
9
+ //# sourceMappingURL=freshness-signals.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"freshness-signals.d.ts","sourceRoot":"","sources":["../../../src/rules/aeo/freshness-signals.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAE7D,MAAM,WAAW,gBAAgB;IAC/B,4EAA4E;IAC5E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,qEAAqE;IACrE,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAkED,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,UAAU,EAAE,EACnB,OAAO,CAAC,EAAE,gBAAgB,GACzB,UAAU,EAAE,CAsDd"}
@@ -0,0 +1,109 @@
1
+ import { load } from "cheerio";
2
+ const DAY_MS = 86_400_000;
3
+ /**
4
+ * Find dateModified-specific signals in JSON-LD (recursive). Keeps `dateModified` and
5
+ * `datePublished` separate so callers can require a true modification signal rather than
6
+ * accept "published once in 2020" as evidence of freshness.
7
+ */
8
+ function findDatesInJsonLd(entries) {
9
+ let modified = null;
10
+ let published = null;
11
+ const stack = [...entries];
12
+ while (stack.length > 0) {
13
+ const node = stack.pop();
14
+ if (node === null || typeof node !== "object")
15
+ continue;
16
+ const obj = node;
17
+ const m = obj["dateModified"];
18
+ if (!modified && typeof m === "string" && m.length > 0)
19
+ modified = m;
20
+ const p = obj["datePublished"] ?? obj["dateCreated"];
21
+ if (!published && typeof p === "string" && p.length > 0)
22
+ published = p;
23
+ for (const value of Object.values(obj)) {
24
+ if (Array.isArray(value)) {
25
+ for (const item of value)
26
+ stack.push(item);
27
+ }
28
+ else if (value !== null && typeof value === "object") {
29
+ stack.push(value);
30
+ }
31
+ }
32
+ }
33
+ return { modified, published };
34
+ }
35
+ /**
36
+ * Find modification-specific meta tags and <time> elements. Uses cheerio so attribute
37
+ * order does not matter (<meta content="..." property="...">` and `<meta property="..."
38
+ * content="...">` both work).
39
+ */
40
+ function findDateModifiedInHtml(html) {
41
+ const $ = load(html);
42
+ const candidates = [
43
+ $('meta[property="article:modified_time"]').attr("content"),
44
+ $('meta[name="last-modified"]').attr("content"),
45
+ $('meta[name="dc.date.modified"]').attr("content"),
46
+ $("time[datetime]").attr("datetime"),
47
+ ];
48
+ for (const value of candidates) {
49
+ if (typeof value === "string" && value.length > 0)
50
+ return value;
51
+ }
52
+ return null;
53
+ }
54
+ function hasVisibleUpdateSignal(contentText) {
55
+ return /\b(last\s+updated|updated\s+on|revised|last\s+modified)\b/i.test(contentText);
56
+ }
57
+ function parseDateSafe(value) {
58
+ if (!value)
59
+ return null;
60
+ const t = Date.parse(value);
61
+ return Number.isFinite(t) ? new Date(t) : null;
62
+ }
63
+ export function freshnessSignalsRule(pages, options) {
64
+ const maxStaleDays = options?.maxStaleDays ?? 180;
65
+ const now = options?.now ? options.now() : Date.now();
66
+ const findings = [];
67
+ for (const page of pages) {
68
+ const { modified: jsonLdModified, published: jsonLdPublished } = findDatesInJsonLd(page.jsonLd);
69
+ const htmlModified = findDateModifiedInHtml(page.html);
70
+ const visibleSignal = hasVisibleUpdateSignal(page.contentText);
71
+ // A true modification signal is `dateModified`, a modification meta tag, or visible
72
+ // "Last updated" text. `datePublished` alone (a page born in 2019 and never touched)
73
+ // is NOT a modification signal — fall through to the no-signal warning.
74
+ const hasModificationSignal = Boolean(jsonLdModified || htmlModified || visibleSignal);
75
+ if (!hasModificationSignal) {
76
+ findings.push({
77
+ ruleId: "aeo/freshness-signals",
78
+ severity: "warning",
79
+ message: `${page.url} has no dateModified signal (no JSON-LD dateModified, no modification meta tag, no visible "Last updated").`,
80
+ pageUrl: page.url,
81
+ fix: `Add a freshness signal so AI engines know the page is current. Three recommended places: ` +
82
+ `(1) dateModified in your JSON-LD schema, ` +
83
+ `(2) a visible "Last updated: YYYY-MM-DD" line in the page content, ` +
84
+ `(3) accurate <lastmod> in your sitemap. ` +
85
+ `For pSEO templates, automate dateModified to update when your underlying data source changes.`,
86
+ });
87
+ continue;
88
+ }
89
+ const best = parseDateSafe(jsonLdModified) ??
90
+ parseDateSafe(htmlModified) ??
91
+ parseDateSafe(jsonLdPublished) ??
92
+ parseDateSafe(page.publishedDate);
93
+ if (best) {
94
+ const ageDays = Math.floor((now - best.getTime()) / DAY_MS);
95
+ if (ageDays > maxStaleDays) {
96
+ findings.push({
97
+ ruleId: "aeo/freshness-signals",
98
+ severity: "info",
99
+ message: `${page.url} was last updated ${ageDays} days ago (threshold: ${maxStaleDays}).`,
100
+ pageUrl: page.url,
101
+ fix: `AI engines prioritize fresh content for citation. If the page is still accurate, ` +
102
+ `refresh the visible date and bump dateModified. If information has changed, update the body accordingly.`,
103
+ });
104
+ }
105
+ }
106
+ }
107
+ return findings;
108
+ }
109
+ //# sourceMappingURL=freshness-signals.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"freshness-signals.js","sourceRoot":"","sources":["../../../src/rules/aeo/freshness-signals.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,24 @@
1
+ import type { RuleResult } from "../../types.js";
2
+ export interface LlmsTxtFetcher {
3
+ (url: string): Promise<string | null>;
4
+ }
5
+ /**
6
+ * Minimal shape check for the emerging llms.txt convention:
7
+ * - First non-empty line is an `# H1` title
8
+ * - At least one `## ` section heading
9
+ * - At least one markdown link line under a section
10
+ * Deliberately lenient: the spec is still evolving, so only reject obvious garbage.
11
+ */
12
+ export declare function validateLlmsTxt(content: string): {
13
+ valid: boolean;
14
+ reason?: string;
15
+ };
16
+ /**
17
+ * Check for /llms.txt at the origin. Site-level rule — runs once, not per page.
18
+ */
19
+ export interface LlmsTxtRuleOptions {
20
+ /** Timeout in ms for the /llms.txt fetch. Default: 10 000. */
21
+ timeoutMs?: number;
22
+ }
23
+ export declare function llmsTxtRule(source: string, fetcherOrOptions?: LlmsTxtFetcher | LlmsTxtRuleOptions): Promise<RuleResult[]>;
24
+ //# sourceMappingURL=llms-txt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"llms-txt.d.ts","sourceRoot":"","sources":["../../../src/rules/aeo/llms-txt.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,MAAM,WAAW,cAAc;IAC7B,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CACvC;AAkBD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAgCpF;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,8DAA8D;IAC9D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAsB,WAAW,CAC/B,MAAM,EAAE,MAAM,EACd,gBAAgB,GAAE,cAAc,GAAG,kBAAuB,GACzD,OAAO,CAAC,UAAU,EAAE,CAAC,CAqCvB"}
@@ -0,0 +1,93 @@
1
+ const DEFAULT_TIMEOUT_MS = 10_000;
2
+ async function defaultFetch(url, timeoutMs = DEFAULT_TIMEOUT_MS) {
3
+ const controller = new AbortController();
4
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
5
+ try {
6
+ const response = await fetch(url, { signal: controller.signal });
7
+ if (!response.ok)
8
+ return null;
9
+ return await response.text();
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ finally {
15
+ clearTimeout(timer);
16
+ }
17
+ }
18
+ /**
19
+ * Minimal shape check for the emerging llms.txt convention:
20
+ * - First non-empty line is an `# H1` title
21
+ * - At least one `## ` section heading
22
+ * - At least one markdown link line under a section
23
+ * Deliberately lenient: the spec is still evolving, so only reject obvious garbage.
24
+ */
25
+ export function validateLlmsTxt(content) {
26
+ const lines = content.split(/\r?\n/);
27
+ let sawTitle = false;
28
+ let sawSection = false;
29
+ let sawLink = false;
30
+ for (const raw of lines) {
31
+ const line = raw.trim();
32
+ if (!line)
33
+ continue;
34
+ if (!sawTitle) {
35
+ if (/^#\s+\S/.test(line)) {
36
+ sawTitle = true;
37
+ }
38
+ else if (line.startsWith("#")) {
39
+ continue;
40
+ }
41
+ else {
42
+ return { valid: false, reason: "file does not start with an `# ` H1 title" };
43
+ }
44
+ continue;
45
+ }
46
+ if (/^##\s+\S/.test(line)) {
47
+ sawSection = true;
48
+ continue;
49
+ }
50
+ if (/^-\s*\[[^\]]+\]\([^)]+\)/.test(line)) {
51
+ sawLink = true;
52
+ }
53
+ }
54
+ if (!sawTitle)
55
+ return { valid: false, reason: "missing `# ` H1 title" };
56
+ if (!sawSection)
57
+ return { valid: false, reason: "no `## ` section headings found" };
58
+ if (!sawLink)
59
+ return { valid: false, reason: "no markdown link entries found under any section" };
60
+ return { valid: true };
61
+ }
62
+ export async function llmsTxtRule(source, fetcherOrOptions = {}) {
63
+ if (!/^https?:\/\//i.test(source))
64
+ return [];
65
+ const origin = new URL(source).origin;
66
+ const llmsUrl = `${origin}/llms.txt`;
67
+ const fetcher = typeof fetcherOrOptions === "function"
68
+ ? fetcherOrOptions
69
+ : (url) => defaultFetch(url, fetcherOrOptions.timeoutMs);
70
+ const text = await fetcher(llmsUrl);
71
+ if (text === null) {
72
+ return [{
73
+ ruleId: "aeo/llms-txt",
74
+ severity: "warning",
75
+ message: `No llms.txt found at ${llmsUrl}.`,
76
+ fix: `Create ${llmsUrl} to guide AI engines toward your most authoritative, citable content. ` +
77
+ `Start with an # H1 title, a blockquote summary, then ## sections listing your key pages as markdown links. ` +
78
+ `See https://llmstxt.org for the emerging convention.`,
79
+ }];
80
+ }
81
+ const check = validateLlmsTxt(text);
82
+ if (!check.valid) {
83
+ return [{
84
+ ruleId: "aeo/llms-txt",
85
+ severity: "warning",
86
+ message: `${llmsUrl} exists but is malformed: ${check.reason}.`,
87
+ fix: `Ensure ${llmsUrl} opens with an "# Site Name" H1, has at least one "## Section" heading, ` +
88
+ `and lists pages as "- [Title](https://...): short description". See https://llmstxt.org.`,
89
+ }];
90
+ }
91
+ return [];
92
+ }
93
+ //# sourceMappingURL=llms-txt.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"llms-txt.js","sourceRoot":"","sources":["../../../src/rules/aeo/llms-txt.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ import type { ParsedPage, RuleResult } from "../../types.js";
2
+ export interface NonReplicableValueOptions {
3
+ /** Extra selectors that count as interactive/non-replicable. */
4
+ interactiveSelectors?: string[];
5
+ /** Severity override. Default: "warning". */
6
+ severity?: "info" | "warning" | "error";
7
+ }
8
+ export declare function nonReplicableValueRule(pages: ParsedPage[], options?: NonReplicableValueOptions): RuleResult[];
9
+ //# sourceMappingURL=non-replicable-value.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"non-replicable-value.d.ts","sourceRoot":"","sources":["../../../src/rules/aeo/non-replicable-value.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAE7D,MAAM,WAAW,yBAAyB;IACxC,gEAAgE;IAChE,oBAAoB,CAAC,EAAE,MAAM,EAAE,CAAC;IAChC,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC;CACzC;AAqED,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,UAAU,EAAE,EACnB,OAAO,CAAC,EAAE,yBAAyB,GAClC,UAAU,EAAE,CA+Bd"}
@@ -0,0 +1,95 @@
1
+ import { load } from "cheerio";
2
+ const DEFAULT_INTERACTIVE_SELECTORS = [
3
+ "form",
4
+ "input:not([type=hidden])",
5
+ "select",
6
+ "textarea",
7
+ "iframe",
8
+ "button[type=submit]",
9
+ "canvas",
10
+ "[data-interactive]",
11
+ "[data-calculator]",
12
+ "[data-tool]",
13
+ "[data-widget]",
14
+ "[x-data]",
15
+ ".calculator",
16
+ ".tool",
17
+ ".checker",
18
+ ".generator",
19
+ ".interactive",
20
+ ".widget",
21
+ ".comparator",
22
+ ];
23
+ const DOWNLOAD_PATTERN = /\.(pdf|docx?|xlsx?|csv|zip|pptx?)(?:$|[?#])/i;
24
+ const GATED_PATTERNS = [
25
+ /\bsign\s*(in|up)\s+to\s+(read|access|view|download|continue)/i,
26
+ /\bpaywall\b/i,
27
+ /\bsubscribe\s+to\s+read\b/i,
28
+ /\blog\s*in\s+to\s+(read|access|view|download|continue)/i,
29
+ ];
30
+ function hasInteractiveElement($, html, extraSelectors) {
31
+ const selectors = [...DEFAULT_INTERACTIVE_SELECTORS, ...extraSelectors];
32
+ for (const sel of selectors) {
33
+ try {
34
+ if ($(sel).length > 0)
35
+ return true;
36
+ }
37
+ catch {
38
+ // invalid selector — skip
39
+ }
40
+ }
41
+ // Framework-generated component markers use hashed attribute names; scan raw HTML for common patterns.
42
+ if (/\bdata-(reactroot|react-[\w-]+|vue-[\w-]+|v-[\w-]+|svelte-[\w-]+)\b/i.test(html))
43
+ return true;
44
+ return false;
45
+ }
46
+ function hasDownloadableAsset($) {
47
+ let found = false;
48
+ $("a[href]").each((_, el) => {
49
+ if (found)
50
+ return false;
51
+ const href = $(el).attr("href") ?? "";
52
+ if (DOWNLOAD_PATTERN.test(href) || /[?&]format=(pdf|docx?|xlsx?|csv)\b/i.test(href)) {
53
+ found = true;
54
+ return false;
55
+ }
56
+ if ($(el).attr("download") !== undefined) {
57
+ found = true;
58
+ return false;
59
+ }
60
+ return undefined;
61
+ });
62
+ return found;
63
+ }
64
+ function hasGatedContent(text) {
65
+ return GATED_PATTERNS.some((re) => re.test(text));
66
+ }
67
+ export function nonReplicableValueRule(pages, options) {
68
+ const extra = options?.interactiveSelectors ?? [];
69
+ const severity = options?.severity ?? "warning";
70
+ const findings = [];
71
+ for (const page of pages) {
72
+ const $ = load(page.html);
73
+ const interactive = hasInteractiveElement($, page.html, extra);
74
+ const downloadable = hasDownloadableAsset($);
75
+ const gated = hasGatedContent(page.contentText);
76
+ if (interactive || downloadable || gated)
77
+ continue;
78
+ findings.push({
79
+ ruleId: "aeo/non-replicable-value",
80
+ severity,
81
+ message: `${page.url} contains only text AI can fully summarize — no interactive element, downloadable asset, or gated content detected.`,
82
+ pageUrl: page.url,
83
+ fix: `Add a non-replicable value so users have a reason to click through instead of accepting the AI summary. Options: ` +
84
+ `(1) an interactive tool specific to this page's topic (calculator, checker, comparator, generator), ` +
85
+ `(2) an interactive checklist users can complete, ` +
86
+ `(3) a downloadable asset (PDF, spreadsheet, dataset) visible above the fold, ` +
87
+ `(4) a live preview / try-it-yourself widget, ` +
88
+ `(5) a gated resource (account required). ` +
89
+ `For single-page apps, ensure interactive elements are present in the server-rendered HTML ` +
90
+ `so crawlers see them — client-only widgets are invisible to this audit AND to most AI crawlers.`,
91
+ });
92
+ }
93
+ return findings;
94
+ }
95
+ //# sourceMappingURL=non-replicable-value.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"non-replicable-value.js","sourceRoot":"","sources":["../../../src/rules/aeo/non-replicable-value.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Declarative scope for each rule ID.
3
+ * - "page": output depends only on a single parsed page.
4
+ * - "corpus": output requires the full set of pages (clustering, cross-page comparisons).
5
+ *
6
+ * Diff-audit dispatch reads this map and skips corpus rules when `state.since` is set.
7
+ */
8
+ export type RuleScope = "page" | "corpus";
9
+ export declare const RULE_SCOPE: Record<string, RuleScope>;
10
+ /** Returns true when the rule may run in diff (page-scoped) mode. Unknown ids default to corpus (safer). */
11
+ export declare function isRuleAllowedInDiff(ruleId: string): boolean;
12
+ //# sourceMappingURL=scope.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope.d.ts","sourceRoot":"","sources":["../../src/rules/scope.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,QAAQ,CAAC;AAE1C,eAAO,MAAM,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAoEhD,CAAC;AAEF,4GAA4G;AAC5G,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAE3D"}