katman-mcp 0.1.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 (97) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +160 -0
  3. package/dist/cli.js +71 -0
  4. package/dist/context.js +43 -0
  5. package/dist/crawlers.js +86 -0
  6. package/dist/index.js +25 -0
  7. package/dist/license.js +448 -0
  8. package/dist/meta.js +28 -0
  9. package/dist/net.js +128 -0
  10. package/dist/server.js +17 -0
  11. package/dist/state.js +81 -0
  12. package/dist/tools/account/account.js +64 -0
  13. package/dist/tools/account/cli.js +21 -0
  14. package/dist/tools/account/index.js +48 -0
  15. package/dist/tools/account/sync.js +142 -0
  16. package/dist/tools/account/upsell.js +290 -0
  17. package/dist/tools/crawl/audit/analyze.js +719 -0
  18. package/dist/tools/crawl/audit/bots.js +114 -0
  19. package/dist/tools/crawl/audit/checks.js +126 -0
  20. package/dist/tools/crawl/audit/html.js +382 -0
  21. package/dist/tools/crawl/audit/index.js +364 -0
  22. package/dist/tools/crawl/audit/model.js +1 -0
  23. package/dist/tools/crawl/audit/report.js +199 -0
  24. package/dist/tools/crawl/audit/robots.js +199 -0
  25. package/dist/tools/crawl/audit/sitemap.js +147 -0
  26. package/dist/tools/crawl/cli.js +106 -0
  27. package/dist/tools/crawl/common/advice.js +61 -0
  28. package/dist/tools/crawl/common/findings.js +57 -0
  29. package/dist/tools/crawl/common/routes.js +80 -0
  30. package/dist/tools/crawl/common/text.js +89 -0
  31. package/dist/tools/crawl/common/urls.js +64 -0
  32. package/dist/tools/crawl/index.js +226 -0
  33. package/dist/tools/crawl/indexnow.js +271 -0
  34. package/dist/tools/crawl/keywords.js +178 -0
  35. package/dist/tools/crawl/scan/framework.js +645 -0
  36. package/dist/tools/crawl/scan/index.js +175 -0
  37. package/dist/tools/crawl/scan/project.js +180 -0
  38. package/dist/tools/crawl/scan/routes.js +354 -0
  39. package/dist/tools/crawl/scan/signals.js +414 -0
  40. package/dist/tools/method/cli.js +42 -0
  41. package/dist/tools/method/core/brief.js +302 -0
  42. package/dist/tools/method/core/extract.js +435 -0
  43. package/dist/tools/method/core/generate.js +504 -0
  44. package/dist/tools/method/core/lint.js +556 -0
  45. package/dist/tools/method/core/plan.js +405 -0
  46. package/dist/tools/method/core/prompt.js +150 -0
  47. package/dist/tools/method/core/questions.js +196 -0
  48. package/dist/tools/method/core/report.js +182 -0
  49. package/dist/tools/method/core/research-run.js +148 -0
  50. package/dist/tools/method/core/research.js +448 -0
  51. package/dist/tools/method/core/snippets.js +292 -0
  52. package/dist/tools/method/core/start.js +277 -0
  53. package/dist/tools/method/data/anatomy.js +141 -0
  54. package/dist/tools/method/data/appendices.js +202 -0
  55. package/dist/tools/method/data/cloudflare.js +192 -0
  56. package/dist/tools/method/data/evidence.js +137 -0
  57. package/dist/tools/method/data/geo-jobs.js +128 -0
  58. package/dist/tools/method/data/index.js +19 -0
  59. package/dist/tools/method/data/landing.js +67 -0
  60. package/dist/tools/method/data/lessons.js +127 -0
  61. package/dist/tools/method/data/measurement.js +142 -0
  62. package/dist/tools/method/data/method.js +141 -0
  63. package/dist/tools/method/data/offsite.js +148 -0
  64. package/dist/tools/method/data/pages.js +167 -0
  65. package/dist/tools/method/data/prompts.js +633 -0
  66. package/dist/tools/method/data/publish.js +113 -0
  67. package/dist/tools/method/data/render.js +117 -0
  68. package/dist/tools/method/data/research.js +131 -0
  69. package/dist/tools/method/data/roadmap.js +105 -0
  70. package/dist/tools/method/data/traps.js +74 -0
  71. package/dist/tools/method/data/verify.js +88 -0
  72. package/dist/tools/method/data/viral-list.js +39 -0
  73. package/dist/tools/method/index.js +209 -0
  74. package/dist/tools/method/lib/calls.js +53 -0
  75. package/dist/tools/method/lib/framework.js +525 -0
  76. package/dist/tools/method/lib/l10n.js +17 -0
  77. package/dist/tools/method/lib/routes.js +118 -0
  78. package/dist/tools/method/lib/state.js +454 -0
  79. package/dist/tools/method/lib/text.js +165 -0
  80. package/dist/tools/method/mcp/prompts.js +79 -0
  81. package/dist/tools/method/mcp/resources.js +50 -0
  82. package/dist/tools/visibility/analyze.js +463 -0
  83. package/dist/tools/visibility/cli.js +86 -0
  84. package/dist/tools/visibility/estimate.js +105 -0
  85. package/dist/tools/visibility/index.js +108 -0
  86. package/dist/tools/visibility/judge.js +156 -0
  87. package/dist/tools/visibility/manual.js +275 -0
  88. package/dist/tools/visibility/models.js +277 -0
  89. package/dist/tools/visibility/monitoring.js +165 -0
  90. package/dist/tools/visibility/openrouter.js +449 -0
  91. package/dist/tools/visibility/render.js +295 -0
  92. package/dist/tools/visibility/run.js +684 -0
  93. package/dist/tools/visibility/summary.js +164 -0
  94. package/dist/types.js +3 -0
  95. package/dist/util.js +92 -0
  96. package/package.json +54 -0
  97. package/server.json +34 -0
@@ -0,0 +1,414 @@
1
+ import { crawlerByToken } from "../../../crawlers.js";
2
+ import { classifyPath } from "../common/routes.js";
3
+ import { analyzeRobots } from "../audit/robots.js";
4
+ import { lastmodStats, parseSitemapXml } from "../audit/sitemap.js";
5
+ import { codeFiles, first, has, lineOf, list, text } from "./project.js";
6
+ function F(id, severity, gate, prompt, title, detail, extra = {}) {
7
+ return { id, title, severity, gate, prompt, detail, ...extra };
8
+ }
9
+ function exportedFn(code, names) {
10
+ const re = new RegExp(`export\\s+(?:async\\s+)?(?:function\\s+(?:${names.join("|")})\\b|const\\s+(?:${names.join("|")})\\s*=)`);
11
+ return code.find((f) => /\.(m?[jt]sx?|cjs)$/.test(f.rel) && re.test(f.text))?.rel;
12
+ }
13
+ export function detectFiles(p, det, rs) {
14
+ const pre = det.appDir ? `${det.appDir}/` : "";
15
+ const sd = det.staticDir && det.staticDir !== "." ? `${det.staticDir}/` : det.staticDir === "." ? "" : `${pre}public/`;
16
+ const deps = det.pkg?.deps ?? {};
17
+ const code = codeFiles(p, { includeScripts: true });
18
+ const findings = [];
19
+ const staticFile = (name) => first(p, [`${sd}${name}`, `${pre}public/${name}`, `${pre}static/${name}`, `${pre}${name}`, name]);
20
+ const gen = (res) => {
21
+ for (const re of res) {
22
+ const hit = list(p, re)[0];
23
+ if (hit)
24
+ return hit;
25
+ }
26
+ return undefined;
27
+ };
28
+ const fromRoute = (name, fnNames) => {
29
+ const hit = rs.fileRoutes.get(name);
30
+ if (!hit)
31
+ return undefined;
32
+ return exportedFn(code, fnNames) ?? hit.file;
33
+ };
34
+ // robots.txt
35
+ const staticRobots = staticFile("robots.txt");
36
+ const robotsGen = gen([/(^|\/)(src\/)?app\/robots\.(ts|js|tsx)$/, /(^|\/)src\/pages\/robots\.txt\.(ts|js)$/, /(^|\/)src\/routes\/robots\.txt\/\+server\.(ts|js)$/, /(^|\/)server\/routes\/robots\.txt\.(ts|js)$/, /(^|\/)app\/routes\/(robots\[\.\]txt|\[robots\.txt\])\.(tsx?|jsx?)$/, /(^|\/)pages\/robots\.txt\.(tsx?|jsx?)$/, /(^|\/)app\/robots\.txt\/route\.(ts|js)$/]) ??
37
+ fromRoute("robots.txt", ["robots", "robotsTxt", "getRobots"]) ??
38
+ (["next-sitemap", "@nuxtjs/robots", "nuxt-simple-robots", "astro-robots-txt", "gatsby-plugin-robots-txt"].find((d) => d in deps) ? `dependency: ${["next-sitemap", "@nuxtjs/robots", "nuxt-simple-robots", "astro-robots-txt", "gatsby-plugin-robots-txt"].find((d) => d in deps)}` : undefined) ??
39
+ code.find((f) => /\.(m?[jt]s|cjs)$/.test(f.rel) && /User-agent:\s*[*\w]/.test(f.text) && /Sitemap:/.test(f.text) && !/(^|\/)test/.test(f.rel))?.rel;
40
+ const robots = staticRobots ? { exists: true, path: staticRobots, generated: false } : robotsGen ? { exists: true, path: robotsGen, generated: true } : { exists: false };
41
+ // sitemap
42
+ const staticSitemap = staticFile("sitemap.xml") ?? staticFile("sitemap-index.xml") ?? staticFile("sitemap_index.xml");
43
+ const sitemapDep = ["next-sitemap", "@astrojs/sitemap", "@nuxtjs/sitemap", "nuxt-simple-sitemap", "gatsby-plugin-sitemap", "vite-plugin-sitemap", "svelte-sitemap", "super-sitemap", "sitemap", "react-router-sitemap", "@11ty/eleventy-plugin-sitemap"].find((d) => d in deps);
44
+ const sitemapGen = gen([/(^|\/)(src\/)?app\/sitemap\.(ts|js|tsx)$/, /(^|\/)app\/sitemap\.xml\/route\.(ts|js)$/, /(^|\/)pages\/sitemap\.xml\.(tsx?|jsx?)$/, /(^|\/)src\/pages\/sitemap[^/]*\.xml\.(ts|js)$/, /(^|\/)src\/routes\/sitemap\.xml\/\+server\.(ts|js)$/, /(^|\/)server\/routes\/sitemap\.xml\.(ts|js)$/, /(^|\/)app\/routes\/(sitemap\[\.\]xml|\[sitemap\.xml\])\.(tsx?|jsx?)$/, /(^|\/)(sitemap\.xml|sitemap)\.(njk|liquid|11ty\.js)$/]) ??
45
+ fromRoute("sitemap.xml", ["sitemap", "sitemapXml", "getSitemap"]) ??
46
+ (sitemapDep ? `dependency: ${sitemapDep}` : undefined) ??
47
+ list(p, /(^|\/)scripts\/[^/]*sitemap[^/]*\.(m?[jt]s|cjs|py)$/i)[0] ??
48
+ code.find((f) => /\.(m?[jt]s|cjs)$/.test(f.rel) && /<urlset\b/.test(f.text))?.rel ??
49
+ (det.framework.name === "hugo" ? "hugo (built-in sitemap)" : undefined) ??
50
+ (/jekyll-sitemap/.test(text(p, "_config.yml") + text(p, "Gemfile")) ? "jekyll-sitemap plugin" : undefined);
51
+ const sitemap = staticSitemap ? { exists: true, path: staticSitemap, generated: false } : sitemapGen ? { exists: true, path: sitemapGen, generated: true } : { exists: false };
52
+ const sitemapGenerators = [sitemapGen, ...list(p, /(^|\/)next-sitemap\.config\.[mc]?js$/)].filter((x) => !!x && !x.startsWith("dependency") && has(p, x));
53
+ // llms.txt / llms-full.txt
54
+ const staticLlms = staticFile("llms.txt");
55
+ const llmsGen = gen([/(^|\/)app\/llms\.txt\/route\.(ts|js)$/, /(^|\/)src\/pages\/llms\.txt\.(ts|js)$/, /(^|\/)src\/routes\/llms\.txt\/\+server\.(ts|js)$/, /(^|\/)server\/routes\/llms\.txt\.(ts|js)$/]) ??
56
+ fromRoute("llms.txt", ["llms", "llmsTxt", "getLlms"]) ??
57
+ list(p, /(^|\/)scripts\/[^/]*llms[^/]*\.(m?[jt]s|cjs|py)$/i)[0];
58
+ const llms = staticLlms ? { exists: true, path: staticLlms, generated: false } : llmsGen ? { exists: true, path: llmsGen, generated: true } : { exists: false };
59
+ const staticLlmsFull = staticFile("llms-full.txt");
60
+ const llmsFullGen = gen([/(^|\/)app\/llms-full\.txt\/route\.(ts|js)$/, /(^|\/)src\/pages\/llms-full\.txt\.(ts|js)$/, /(^|\/)src\/routes\/llms-full\.txt\/\+server\.(ts|js)$/]) ??
61
+ fromRoute("llms-full.txt", ["llmsFull", "llmsFullTxt"]);
62
+ const llmsFull = staticLlmsFull ? { exists: true, path: staticLlmsFull, generated: false } : llmsFullGen ? { exists: true, path: llmsFullGen, generated: true } : { exists: false };
63
+ // 404 page
64
+ const nf = gen([/(^|\/)(src\/)?app\/not-found\.(tsx|jsx|js|ts)$/, /(^|\/)(src\/)?pages\/404\.(tsx|jsx|js|ts|astro|md|mdx|vue)$/, /(^|\/)(app\/)?error\.vue$/, /(^|\/)src\/routes\/\+error\.svelte$/, /(^|\/)app\/routes\/\$\.(tsx|jsx)$/, /(^|\/)layouts\/404\.html$/]) ??
65
+ first(p, [`${sd}404.html`, `${pre}404.html`, "404.html", "404.md"]);
66
+ let notFound = nf ? { exists: true, path: nf, generated: !/\.html?$/.test(nf) } : { exists: false };
67
+ if (!nf) {
68
+ const custom = code.find((f) => /\.(m?[jt]sx?|cjs)$/.test(f.rel) &&
69
+ !/(^|\/)(dev[-_.]?server|dev|mock|test)[^/]*\.[a-z]+$/i.test(f.rel) &&
70
+ /(status:\s*404|HttpError\(\s*404|\.status\(\s*404\s*\)|,\s*\{\s*status:\s*404|notFound\(\))/.test(f.text) &&
71
+ /not[\s_-]?found/i.test(f.text));
72
+ if (custom)
73
+ notFound = { exists: true, path: custom.rel, generated: true };
74
+ else if (rs.catchAll.length)
75
+ notFound = { exists: true, path: `${rs.catchAll[0].file}:${rs.catchAll[0].line} (client-side catch-all)`, generated: true };
76
+ }
77
+ // Static-file analysis
78
+ if (staticRobots)
79
+ findings.push(...staticRobotsFindings(staticRobots, text(p, staticRobots)));
80
+ if (staticSitemap) {
81
+ const sm = parseSitemapXml(text(p, staticSitemap));
82
+ const st = lastmodStats(sm.entries);
83
+ if (st.identical) {
84
+ findings.push(F("scan-sitemap-fake-lastmod", "medium", "read", "P4", `${staticSitemap}: every URL has the same lastmod (${st.commonDay})`, "lastmod should move only when a page's words change; one date everywhere says the dates mean nothing (book mistake #1).", { files: [staticSitemap], fix: "Generate the sitemap from page data with a per-page updated date.", accept: "lastmod differs between pages and only moves when a page's text changes" }));
85
+ }
86
+ }
87
+ if (staticLlms && (p.files.get(staticLlms)?.size ?? 0) > 50_000) {
88
+ findings.push(F("scan-llms-too-big", "low", "quote", "P8", `${staticLlms} is ${Math.round((p.files.get(staticLlms)?.size ?? 0) / 1024)} KB`, "Keep llms.txt short: product, pricing, the 10–20 key links; move long lists to llms-full.txt.", { files: [staticLlms], fix: "Trim to the essentials.", accept: "llms.txt under ~50 KB" }));
89
+ }
90
+ // Generated sitemap stamped with the build/request time
91
+ for (const g of sitemapGenerators) {
92
+ const t = text(p, g);
93
+ const stamp = /lastModified\s*:\s*new Date\(\s*\)|lastmod\s*:\s*new Date\(\s*\)|lastmod>\s*\$\{\s*new Date\(\s*\)|lastmod[^\n]{0,60}new Date\(\s*\)\.toISOString\(\)/.test(t);
94
+ const nextSitemapAuto = /next-sitemap\.config/.test(g) && !/autoLastmod\s*:\s*false/.test(t);
95
+ if (stamp || nextSitemapAuto) {
96
+ findings.push(F("scan-sitemap-build-lastmod", "medium", "read", "P4", "The sitemap stamps lastmod with the build/request time", `${g}: ${nextSitemapAuto ? "next-sitemap sets lastmod to the build time unless autoLastmod: false" : "lastmod = new Date()"}. Every deploy claims every page changed today — the book's mistake #1 (and it floods IndexNow).`, { files: [g], fix: "Use each page's real updated date (from content front-matter or the data file); omit lastmod if you don't have one.", accept: "lastmod only changes when a page's text changes" }));
97
+ break;
98
+ }
99
+ }
100
+ return { files: { robots, sitemap, llms, llmsFull, notFound }, staticRobots, staticSitemap, staticLlms, sitemapGenerators, findings };
101
+ }
102
+ function staticRobotsFindings(rel, body) {
103
+ const r = analyzeRobots(body, "https://example.invalid/robots.txt");
104
+ const out = [];
105
+ if (r.blocksAll) {
106
+ out.push(F("scan-robots-blocks-all", "critical", "read", "P4", `${rel} blocks the whole site (Disallow: / for *)`, "Every crawler without its own group is told to stay out.", { files: [rel], fix: "Disallow only app paths (/app/, /admin/, /api/) for *.", accept: "robots.txt has no Disallow: / for *" }));
107
+ }
108
+ const search = r.blockedTokens.filter((t) => ["search", "user", "search+training"].includes(crawlerByToken(t)?.purpose ?? ""));
109
+ if (search.length && !r.blocksAll) {
110
+ out.push(F("scan-robots-ai-search-blocked", "critical", "read", "P4", `${rel} blocks AI search/answer bots: ${search.join(", ")}`, "These bots decide whether you can appear in ChatGPT, Claude and Perplexity answers.", { files: [rel], fix: "Allow them by name (book appendix A).", accept: "OAI-SearchBot, ChatGPT-User, Claude-SearchBot, Claude-User, PerplexityBot, Perplexity-User may fetch /" }));
111
+ }
112
+ const training = r.blockedTokens.filter((t) => !search.includes(t));
113
+ if (training.length && !r.blocksAll) {
114
+ out.push(F("scan-robots-training-blocked", "medium", "read", "P4", `${rel} blocks training bots: ${training.join(", ")}`, "A business decision — make sure it's deliberate. Blocking Google-Extended also removes Gemini grounding.", { files: [rel], fix: "Allow them unless you decided to opt out of training; keep Google-Extended allowed.", accept: "Every Disallow: / for an AI bot is written on purpose" }));
115
+ }
116
+ if (r.contentSignals.length) {
117
+ out.push(F("scan-robots-content-signal", "low", "read", "P4", `${rel} has a Content-Signal: line`, "Lighthouse and Search Console report it as an invalid robots.txt rule (book mistake #9).", { files: [rel], fix: "Write it as a comment: # Content signals: search=yes, ai-input=yes, ai-train=yes", accept: "Lighthouse shows robots.txt is valid" }));
118
+ }
119
+ for (const b of r.barePrefixes.filter((x) => x.pattern.replace(/^\//, "").length <= 2)) {
120
+ out.push(F("scan-robots-short-prefix", "low", "read", "P4", `${rel}: \`Disallow: ${b.pattern}\` is a bare prefix`, `It also blocks every path that starts with "${b.pattern}" (e.g. ${b.pattern}ools).`, { files: [rel], fix: `Use \`Disallow: ${b.pattern}/\`.`, accept: "No bare short prefixes" }));
121
+ }
122
+ if (!r.sitemaps.length) {
123
+ out.push(F("scan-robots-no-sitemap", "low", "read", "P4", `${rel} has no Sitemap line`, "", { files: [rel], fix: "Add `Sitemap: https://<domain>/sitemap.xml` at the end.", accept: "robots.txt ends with a Sitemap: line" }));
124
+ }
125
+ return out;
126
+ }
127
+ // ---------------------------------------------------------------------------------------------------------------
128
+ // Head tags
129
+ export function detectHeadSource(p, det, rs) {
130
+ const code = codeFiles(p);
131
+ const fw = det.framework.name;
132
+ const findings = [];
133
+ const notes = [];
134
+ const central = code.find((f) => /(^|\/)(seo|metadata|site-?config|siteConfig|meta|head|BaseHead|SEO|Seo|Head)(\.config)?\.(ts|js|tsx|jsx|mjs|astro|svelte|vue)$/.test(f.rel) || /(^|\/)seo\//i.test(f.rel));
135
+ const count = (re) => code.filter((f) => re.test(f.text));
136
+ let info = { exists: false };
137
+ let setters = [];
138
+ if (fw === "next") {
139
+ setters = count(/export\s+const\s+metadata\s*[:=]|export\s+(async\s+)?function\s+generateMetadata|from\s+["']next\/head["']|<NextSeo|<DefaultSeo|next-seo/);
140
+ const pages = rs.routes.filter((r) => r.file && /(^|\/)page\.(tsx|jsx|ts|js|mdx)$/.test(r.file) && r.kind === "public" && !r.dynamic);
141
+ const withoutOwn = pages.filter((r) => !/export\s+const\s+metadata|generateMetadata/.test(text(p, r.file)));
142
+ if (pages.length >= 3 && withoutOwn.length / pages.length > 0.5) {
143
+ findings.push(F("scan-pages-inherit-title", "medium", "quote", "P3", `${withoutOwn.length} of ${pages.length} public pages have no own metadata`, "They inherit the root layout's title and description, so several URLs share the same title.", { files: withoutOwn.slice(0, 10).map((r) => r.file), fix: "Export metadata (title ~60 chars, one-sentence description, canonical, openGraph) per page from a single SEO source file.", accept: "Every public page has a unique title and description" }));
144
+ }
145
+ }
146
+ else if (["vite-react", "cra", "static-html"].includes(fw) && det.rendering.verdict === "csr") {
147
+ setters = count(/react-helmet|@unhead\/react|react-head\b|<Helmet\b|useHead\(|document\.title\s*=/);
148
+ }
149
+ else if (fw === "vite-vue" || fw === "nuxt") {
150
+ setters = count(/useHead\(|useSeoMeta\(|vue-meta|@unhead\/vue|@vueuse\/head|definePageMeta\(/);
151
+ }
152
+ else if (fw === "sveltekit" || fw === "vite-svelte") {
153
+ setters = count(/<svelte:head>/);
154
+ }
155
+ else if (fw === "astro") {
156
+ setters = count(/<title>|<SEO\b|astro-seo/).filter((f) => /\.astro$/.test(f.rel));
157
+ }
158
+ else if (fw === "remix" || fw === "react-router") {
159
+ setters = count(/export\s+(const|function)\s+meta\b/);
160
+ }
161
+ else if (fw === "gatsby") {
162
+ setters = count(/export\s+(const|function)\s+Head\b|react-helmet|<Seo\b|<SEO\b/);
163
+ }
164
+ else if (fw === "angular") {
165
+ setters = count(/\bTitle\b[\s\S]{0,200}setTitle\(|\bMeta\b[\s\S]{0,200}updateTag\(/);
166
+ }
167
+ else {
168
+ const staticPrefix = det.staticDir ? `${det.staticDir}/` : "\u0000";
169
+ const generated = fw === "custom-server" && det.rendering.verdict === "ssg";
170
+ setters = count(/<title>|<title>\$\{|<title>\{|document\.title\s*=|<meta\s+name=["']description["']/).filter((f) => !(generated && f.rel.startsWith(staticPrefix) && /\.html?$/.test(f.rel)));
171
+ if (generated) {
172
+ const gen = codeFiles(p, { includeScripts: true }).find((f) => /<title>\$\{/.test(f.text) && /writeFile|outputFile/.test(f.text));
173
+ if (gen)
174
+ setters.unshift(gen);
175
+ }
176
+ }
177
+ if (central) {
178
+ info = { exists: true, path: central.rel, generated: true };
179
+ notes.push(`single SEO source: ${central.rel}`);
180
+ }
181
+ else if (setters.length) {
182
+ info = { exists: true, path: setters[0].rel, generated: !/\.html?$/.test(setters[0].rel) };
183
+ if (setters.length > 5 && fw !== "static-html") {
184
+ findings.push(F("scan-head-scattered", "medium", "quote", "P3", `Titles and descriptions are set in ${setters.length} different files`, "Head tags written by hand in many files get copied and go stale; the assistant's one sentence comes from the description.", { files: setters.slice(0, 10).map((f) => f.rel), fix: "Generate every page's title, description, canonical and og tags from one file (e.g. src/seo/pages.ts) and add a test that fails on duplicates (P3).", accept: "Each page's head comes from one file; the duplicate test passes" }));
185
+ }
186
+ }
187
+ if (!info.exists) {
188
+ const spa = det.rendering.verdict === "csr";
189
+ findings.push(F("scan-head-missing", "high", "quote", "P3", spa ? "Every URL serves index.html's title and description" : "No per-page title/description found", spa
190
+ ? "The single-page app has no per-route head management, so every route shares one title and description (and crawlers without JavaScript would not see per-route tags anyway)."
191
+ : "No code sets page titles or meta descriptions.", { files: det.spa.indexHtml ? [det.spa.indexHtml] : [], fix: "Create one SEO source (title ~60 chars, one-sentence description, canonical, og:*) per page and render it into the server HTML (P3; for SPAs together with P2 prerendering).", accept: "Each public page has a unique title and description in the server HTML" }));
192
+ }
193
+ // canonical / og:image anywhere in the code
194
+ const all = [...codeFiles(p), ...[...p.files.values()].filter((f) => f.text && /\.html?$/.test(f.rel))];
195
+ const anyCanonical = all.some((f) => /rel=["'{]?\s*["']?canonical|canonical\s*[:=]|alternates\s*:\s*\{[^}]*canonical/.test(f.text));
196
+ const anyOg = all.some((f) => /og:image|openGraph\s*:\s*\{|opengraph-image|twitter:image|ogImage/.test(f.text)) || list(p, /(^|\/)opengraph-image\.(png|jpg|tsx|jsx|ts|js)$/).length > 0;
197
+ if (!anyCanonical) {
198
+ findings.push(F("scan-no-canonical", "medium", "read", "P3", "No canonical tags in the code", "Without a self-referencing canonical, Google picks the preferred URL itself (it may pick a preview copy or a www twin).", { fix: "Emit one absolute, self-referencing canonical per page from the SEO source.", accept: "Every page has exactly one absolute canonical to itself" }));
199
+ }
200
+ if (!anyOg) {
201
+ findings.push(F("scan-no-og-image", "medium", "quote", "P3", "No og:image in the code", "Shared links show an empty card; Google also uses og:image as a thumbnail source (the book's katman.pro had 0/40).", { fix: "Add a 1200×630 og:image per page (absolute URL) with og:image:alt, og:title, og:description, og:url and twitter:card.", accept: "Every page has an og:image that opens in the browser" }));
202
+ }
203
+ return { info, findings, notes };
204
+ }
205
+ // ---------------------------------------------------------------------------------------------------------------
206
+ // JSON-LD, i18n, analytics
207
+ export function jsonLdTypes(p) {
208
+ const out = new Set();
209
+ for (const f of p.files.values()) {
210
+ if (!f.text || f.minified || !/\.(m?[jt]sx?|cjs|astro|vue|svelte|html?|json|njk|liquid|php)$/.test(f.rel))
211
+ continue;
212
+ if (/(^|\/)(package(-lock)?|tsconfig|components)\.json$/.test(f.rel))
213
+ continue;
214
+ for (const m of f.text.matchAll(/["']@type["']\s*:\s*["'`]([A-Z][A-Za-z0-9]{2,40})["'`]/g))
215
+ out.add(m[1]);
216
+ }
217
+ return [...out].sort();
218
+ }
219
+ const I18N_LIBS = [
220
+ ["next-intl", "next-intl"], ["next-i18next", "next-i18next"], ["next-translate", "next-translate"], ["react-i18next", "react-i18next"],
221
+ ["i18next", "i18next"], ["@nuxtjs/i18n", "@nuxtjs/i18n"], ["vue-i18n", "vue-i18n"], ["svelte-i18n", "svelte-i18n"],
222
+ ["@inlang/paraglide-js", "paraglide"], ["@inlang/paraglide-sveltekit", "paraglide"], ["astro-i18next", "astro-i18next"],
223
+ ["@lingui/core", "lingui"], ["react-intl", "react-intl"], ["typesafe-i18n", "typesafe-i18n"], ["@angular/localize", "@angular/localize"],
224
+ ];
225
+ const LOCALE = /^[a-z]{2}(-[A-Z]{2}|-[a-z]{2,4})?$/;
226
+ export function detectI18n(p, det) {
227
+ const deps = det.pkg?.deps ?? {};
228
+ const code = codeFiles(p);
229
+ let library = I18N_LIBS.find(([d]) => d in deps)?.[1];
230
+ const locales = new Set();
231
+ for (const f of code.concat([...p.files.values()].filter((x) => x.text && /(^|\/)(next|astro|nuxt)\.config\.[mc]?[jt]s$/.test(x.rel)))) {
232
+ for (const m of f.text.matchAll(/\b(?:LANGS|LOCALES|SITE_LANGS|locales|supportedLngs|SUPPORTED_LOCALES|supportedLocales|SUPPORTED_LANGS)\s*[:=]\s*\[([^\]]{1,600})\]/g)) {
233
+ for (const s of m[1].matchAll(/["'`]([a-zA-Z-]{2,10})["'`]/g))
234
+ if (LOCALE.test(s[1]))
235
+ locales.add(s[1]);
236
+ for (const s of m[1].matchAll(/code\s*:\s*["'`]([a-zA-Z-]{2,10})["'`]/g))
237
+ if (LOCALE.test(s[1]))
238
+ locales.add(s[1]);
239
+ }
240
+ }
241
+ for (const f of p.files.keys()) {
242
+ const m = f.match(/(?:^|\/)(?:locales|messages|i18n|lang|langs|translations|dictionaries)\/([a-z]{2}(?:[-_][A-Za-z]{2})?)(?:\.(?:json|ya?ml|ts|js)|\/)/);
243
+ if (m && LOCALE.test(m[1].replace("_", "-")))
244
+ locales.add(m[1].replace("_", "-"));
245
+ }
246
+ if (!library && code.some((f) => /(^|\/)i18n\.(m?[jt]s)$/.test(f.rel)))
247
+ library = "custom";
248
+ // <html lang>
249
+ let htmlLang;
250
+ for (const f of [...p.files.values()].filter((x) => x.text && /(^|\/)(index\.html|app\.html|_document\.[jt]sx?|layout\.[jt]sx?|root\.[jt]sx|.*Layout\.astro|layout\.astro|nuxt\.config\.[jt]s|shells?\.[jt]s|layout\.m?js)$/.test(x.rel))) {
251
+ const t = f.text;
252
+ const m = t.match(/<[Hh]tml[^>]*\blang\s*=\s*\{?\s*["'`]([a-zA-Z-]{2,10})["'`]/) ?? t.match(/htmlAttrs\s*:\s*\{[^}]*lang\s*:\s*["'`]([a-zA-Z-]{2,10})["'`]/);
253
+ if (m) {
254
+ htmlLang = m[1];
255
+ break;
256
+ }
257
+ if (/<[Hh]tml[^>]*\blang\s*=\s*(\{|["'`]\$\{)/.test(t)) {
258
+ htmlLang = "dynamic";
259
+ break;
260
+ }
261
+ }
262
+ const detected = !!library || locales.size > 1;
263
+ const findings = [];
264
+ if (locales.size > 1) {
265
+ const hreflang = code.some((f) => /hreflang|hrefLang|alternates\s*:\s*\{[\s\S]{0,200}languages|xhtml:link/.test(f.text));
266
+ if (!hreflang) {
267
+ findings.push(F("scan-i18n-no-hreflang", "medium", "read", "P3", `${locales.size} languages but no hreflang in the code`, `Locales: ${[...locales].join(", ")}. Without reciprocal hreflang (plus x-default), search engines can't match each language version to its audience.`, { fix: "Emit hreflang for every translated version (itself included) and x-default, only for pages that really exist in that language.", accept: "Every hreflang target returns 200 and links back" }));
268
+ }
269
+ }
270
+ if (!htmlLang && det.framework.name !== "unknown") {
271
+ findings.push(F("scan-no-html-lang", "low", "read", "P3", "No <html lang> found", "Set the page language on the <html> element.", { fix: "Add lang to <html> (per language on multilingual sites).", accept: "Every page declares its language" }));
272
+ }
273
+ return { i18n: { detected, library, locales: [...locales].sort(), htmlLang }, findings };
274
+ }
275
+ const ANALYTICS = [
276
+ [/googletagmanager\.com\/gtag\/js|gtag\(\s*['"]config['"]|@next\/third-parties\/google|react-ga4|vue-gtag|['"]G-[A-Z0-9]{6,}['"]/, "google-analytics"],
277
+ [/googletagmanager\.com\/gtm\.js|['"]GTM-[A-Z0-9]{4,}['"]/, "gtm"],
278
+ [/plausible\.io\/js|next-plausible|plausible-tracker/, "plausible"],
279
+ [/usefathom\.com|fathom-client/, "fathom"],
280
+ [/posthog-js|posthog\.init|posthog-node/, "posthog"],
281
+ [/cloud\.umami\.is|umami\.is\/script|data-website-id=/, "umami"],
282
+ [/@vercel\/analytics/, "vercel-analytics"],
283
+ [/static\.cloudflareinsights\.com\/beacon/, "cloudflare-web-analytics"],
284
+ [/simpleanalyticscdn\.com/, "simple-analytics"],
285
+ [/mixpanel-browser|mixpanel\.init\(/, "mixpanel"],
286
+ [/@amplitude\/|amplitude\.init\(/, "amplitude"],
287
+ [/@segment\/analytics|analytics\.load\(/, "segment"],
288
+ [/matomo\.js|_paq\.push/, "matomo"],
289
+ [/clarity\.ms|@microsoft\/clarity/, "microsoft-clarity"],
290
+ [/static\.hotjar\.com/, "hotjar"],
291
+ [/connect\.facebook\.net\/[^"']*fbevents|fbq\(\s*['"]init/, "meta-pixel"],
292
+ [/navigator\.sendBeacon\(|\/api\/(collect|track|events?|analytics|beacon|hit)\b/, "custom"],
293
+ ];
294
+ const ANALYTICS_DEPS = {
295
+ "@vercel/analytics": "vercel-analytics", "posthog-js": "posthog", "posthog-node": "posthog", "mixpanel-browser": "mixpanel",
296
+ "@amplitude/analytics-browser": "amplitude", "react-ga4": "google-analytics", "vue-gtag": "google-analytics", "next-plausible": "plausible",
297
+ "plausible-tracker": "plausible", "fathom-client": "fathom", "@segment/analytics-next": "segment", "@microsoft/clarity": "microsoft-clarity",
298
+ "@next/third-parties": "google-analytics", "@umami/node": "umami", "nuxt-gtag": "google-analytics", "@nuxtjs/plausible": "plausible",
299
+ };
300
+ export function detectAnalytics(p, deps = {}) {
301
+ // Client scripts in public/ count here (tracking snippets live there), but not minified bundles.
302
+ const publicJs = [...p.files.values()].filter((f) => f.text && !f.minified && /(^|\/)(public|static)\/.*\.m?js$/.test(f.rel));
303
+ const code = [...codeFiles(p, { includeScripts: true }), ...publicJs].filter((f) => !/\.(md|mdx)$/.test(f.rel));
304
+ const providers = new Set();
305
+ for (const d of Object.keys(deps))
306
+ if (ANALYTICS_DEPS[d])
307
+ providers.add(ANALYTICS_DEPS[d]);
308
+ for (const f of code)
309
+ for (const [re, name] of ANALYTICS)
310
+ if (re.test(f.text))
311
+ providers.add(name);
312
+ if (code.some((f) => /(^|\/)(analytics|track|tracking|telemetry|beacon|metrics|visits?)\.(m?[jt]s|cjs)$/.test(f.rel) && /utm_source|referr|pageview|page_view|session/i.test(f.text))) {
313
+ providers.delete("custom");
314
+ providers.add("first-party");
315
+ }
316
+ const tracksAiChannel = code.some((f) => /chatgpt\.com|chat\.openai\.com|perplexity\.ai|claude\.ai|gemini\.google\.com/.test(f.text) && /referr|utm_source|searchParams|channel/i.test(f.text));
317
+ const botCounting = code.some((f) => /GPTBot|OAI-SearchBot|ClaudeBot|PerplexityBot/.test(f.text) && /user-agent|userAgent|headers\.get\(\s*['"]user-agent/i.test(f.text));
318
+ const findings = [];
319
+ if (!providers.size) {
320
+ findings.push(F("scan-no-analytics", "low", "measure", "P13", "No analytics found", "You can't see visitors from ChatGPT (or anywhere) without some analytics.", { fix: "Add privacy-friendly analytics with an AI channel (referrer chatgpt.com OR utm_source=chatgpt.com) and a human filter (P13).", accept: "The ChatGPT channel also counts visits without a referrer; bot counts come from the server" }));
321
+ }
322
+ if (!tracksAiChannel) {
323
+ findings.push(F("scan-no-ai-channel", "medium", "measure", "P13", "ChatGPT visits aren't tracked as their own channel", "Most ChatGPT visitors send no referrer (79% on the book's example site) but carry utm_source=chatgpt.com; referrer-only analytics files them under \"direct\".", { fix: "Classify a session as AI when the referrer is chatgpt.com/perplexity.ai/claude.ai/gemini.google.com… OR the landing URL has utm_source=chatgpt.com (P13).", accept: "ChatGPT channel counts referrer-less visits; bot counts come from the server" }));
324
+ }
325
+ if (!botCounting) {
326
+ findings.push(F("scan-no-bot-counting", "low", "measure", "P13", "AI bots aren't counted on the server", "Bots don't run analytics JavaScript; only server/edge logs show whether GPTBot, OAI-SearchBot, ClaudeBot or PerplexityBot read your pages.", { fix: "Count requests per bot user agent per day in middleware/the Worker (katman_generate can emit a snippet).", accept: "Daily bot counts per crawler are visible (server-side)" }));
327
+ }
328
+ return { analytics: { providers: [...providers].sort(), tracksAiChannel }, findings, botCounting };
329
+ }
330
+ // ---------------------------------------------------------------------------------------------------------------
331
+ // Links (onClick navigation) and images
332
+ const NAVISH = /(nav|menu|header|footer|sidebar|card|navbar|topbar|layout|hero|tile|grid|list|link|breadcrumb)/i;
333
+ export function detectLinksAndImages(p) {
334
+ const code = codeFiles(p).filter((f) => /\.(jsx|tsx|vue|svelte|astro|html?|js|ts)$/.test(f.rel));
335
+ const findings = [];
336
+ const navHits = [];
337
+ let anchors = 0;
338
+ for (const f of code) {
339
+ const t = f.text;
340
+ anchors += (t.match(/<a\s[^>]*href=|<Link\b|<NavLink\b|<NuxtLink\b|<router-link\b|<RouterLink\b/g) ?? []).length;
341
+ for (const m of t.matchAll(/(?:onClick|@click(?:\.\w+)?|on:click|onclick|\(click\))\s*=\s*[{"']/g)) {
342
+ const window = t.slice(m.index, m.index + 220);
343
+ const nav = window.match(/\b(navigate|router\.(?:push|replace)|history\.push|goto|location\.assign|\$router\.push|window\.location(?:\.href)?\s*=)\s*\(?\s*(["'`]([^"'`]*)["'`])?/);
344
+ if (!nav)
345
+ continue;
346
+ navHits.push({ file: f.rel, line: lineOf(t, m.index), target: nav[3], navish: NAVISH.test(f.rel.split("/").pop() ?? "") });
347
+ }
348
+ }
349
+ const publicHits = navHits.filter((h) => !h.target || (h.target.startsWith("/") && ["public", "legal"].includes(classifyPath(h.target))));
350
+ if (publicHits.length) {
351
+ const inNav = publicHits.filter((h) => h.navish);
352
+ findings.push(F("scan-onclick-nav", inNav.length ? "high" : "medium", "read", "P10", `${publicHits.length} onClick navigation${publicHits.length === 1 ? "" : "s"} to public pages${inNav.length ? ` (${inNav.length} in nav/menu/card components)` : ""}`, `Crawlers follow only <a href> links; a div/button that navigates in onClick is invisible to them (book trap #5). ${anchors} <a>/<Link> elements found for comparison.`, { files: [...new Set(publicHits.map((h) => `${h.file}:${h.line}`))].slice(0, 10), evidence: publicHits.slice(0, 5).map((h) => `${h.file}:${h.line} → ${h.target ?? "(computed)"}`), fix: "Use <a href> / <Link href> for every navigation to a public page (menus, cards, footers); keep onClick only for actions.", accept: "Menus and cards use <a href>; `curl -s https://site/ | grep -o '<a [^>]*href=\"/' | wc -l` ≥ number of menu items" }));
353
+ }
354
+ // Images
355
+ let total = 0;
356
+ let noAlt = 0;
357
+ let noSize = 0;
358
+ const altFiles = new Set();
359
+ const sizeFiles = new Set();
360
+ for (const f of code) {
361
+ for (const m of f.text.matchAll(/<img\b[^>]*>/gi)) {
362
+ total++;
363
+ const tag = m[0];
364
+ if (!/\balt\s*=/.test(tag) && !/\{\s*\.\.\.\w+\s*\}/.test(tag)) {
365
+ noAlt++;
366
+ altFiles.add(`${f.rel}:${lineOf(f.text, m.index)}`);
367
+ }
368
+ if (!/\bwidth\s*=/.test(tag) || !/\bheight\s*=/.test(tag)) {
369
+ noSize++;
370
+ sizeFiles.add(f.rel);
371
+ }
372
+ }
373
+ }
374
+ if (noAlt || noSize > 3) {
375
+ findings.push(F("scan-images", noAlt ? "low" : "info", "quote", "P9", `${noAlt} <img> without alt, ${noSize} without width/height (of ${total})`, "Informative images need a short alt (decorative ones alt=\"\"); width and height prevent layout shift (CLS).", { files: [...altFiles].slice(0, 10), evidence: [...sizeFiles].slice(0, 5).map((f) => `no size: ${f}`), fix: "Add alt to every <img>, width/height (or aspect-ratio), loading=\"lazy\" below the fold; keep images under 200 KB (WebP/AVIF).", accept: "No <img> without alt; no image over 200 KB; CLS < 0.1" }));
376
+ }
377
+ return findings;
378
+ }
379
+ // ---------------------------------------------------------------------------------------------------------------
380
+ // Site-wide noindex and header pitfalls
381
+ export function detectIndexingPitfalls(p) {
382
+ const findings = [];
383
+ const roots = [...p.files.values()].filter((f) => f.text && /(^|\/)(index\.html|app\.html|(src\/)?app\/layout\.[jt]sx?|pages\/_(document|app)\.[jt]sx?|app\/root\.[jt]sx?|nuxt\.config\.[jt]s|src\/layouts\/[^/]*\.astro|layout\.m?js)$/.test(f.rel));
384
+ for (const f of roots) {
385
+ const t = f.text;
386
+ const m = t.match(/<meta[^>]+name=["']robots["'][^>]+content=["'][^"']*noindex|robots\s*:\s*\{[^}]*\bindex\s*:\s*false|robots\s*:\s*["'`]noindex/);
387
+ if (!m)
388
+ continue;
389
+ const before = t.slice(Math.max(0, (m.index ?? 0) - 90), m.index ?? 0);
390
+ if (/\?\s*$|\?\s*['"`(]|&&\s*[('"`<]?\s*$|\$\{[^}]*$|if\s*\(|noindex\s*\?|:\s*$/.test(before))
391
+ continue; // conditional
392
+ findings.push(F("scan-sitewide-noindex", "critical", "read", "P4", `Site-wide noindex in ${f.rel}`, "The root layout / index.html tells search engines not to index any page (often left over from staging).", { files: [`${f.rel}:${lineOf(t, m.index ?? 0)}`], fix: "Remove the noindex from the root; add noindex only to login/checkout/thank-you/search pages.", accept: "Public pages have no noindex (katman_audit_site shows 0 noindex pages in the sitemap)" }));
393
+ }
394
+ const headerFiles = [...p.files.values()].filter((f) => f.text && /(^|\/)(_headers|vercel\.json|netlify\.toml)$/.test(f.rel));
395
+ for (const f of headerFiles) {
396
+ const t = f.text;
397
+ if (/x-robots-tag["']?\s*[:=]\s*["']?[^\n"']*noindex/i.test(t) && /(^\/\*\s*$|"source"\s*:\s*"\/\(\.\*\)"|for\s*=\s*["']\/\*["'])/m.test(t)) {
398
+ findings.push(F("scan-header-noindex", "critical", "read", "P4", `${f.rel} sends X-Robots-Tag: noindex`, "A header rule adds noindex (check whether it applies to all paths on the production domain).", { files: [f.rel], fix: "Scope X-Robots-Tag: noindex to preview hosts or private paths only.", accept: "Production pages answer without X-Robots-Tag: noindex" }));
399
+ }
400
+ if (/no-transform/i.test(t)) {
401
+ findings.push(F("scan-no-transform", "medium", "read", "P14", `${f.rel} sets Cache-Control: no-transform`, "no-transform stops Cloudflare (and other CDNs) from compressing HTML — the book's example shipped pages ~4× larger until it was found (mistake #10).", { files: [f.rel], fix: "Remove no-transform from HTML responses (or compress in your own code).", accept: "curl -s -o /dev/null -D - -H 'Accept-Encoding: br, gzip' https://site/ | grep -i content-encoding shows br or gzip" }));
402
+ }
403
+ }
404
+ const selfCompress = codeFiles(p).some((f) => /CompressionStream|['"]Content-Encoding['"]\s*,\s*[a-z'"]|set\(\s*['"]content-encoding['"]/i.test(f.text));
405
+ for (const f of codeFiles(p)) {
406
+ if (selfCompress)
407
+ break;
408
+ if (/no-transform/.test(f.text) && /cache-control/i.test(f.text) && /\.(m?[jt]s|cjs)$/.test(f.rel)) {
409
+ findings.push(F("scan-no-transform-code", "medium", "read", "P14", `${f.rel} sends Cache-Control: no-transform`, "no-transform can silently disable CDN compression of HTML (the book's mistake #10).", { files: [f.rel], fix: "Drop no-transform for HTML, or compress in the Worker yourself; re-check headers after every fix.", accept: "HTML responses carry Content-Encoding: br or gzip" }));
410
+ break;
411
+ }
412
+ }
413
+ return findings;
414
+ }
@@ -0,0 +1,42 @@
1
+ // CLI commands of the method module: `katman-mcp report` and `katman-mcp plan` (same core as the MCP tools).
2
+ import path from "node:path";
3
+ import { parseArgs } from "../../cli.js";
4
+ import { runReport } from "./core/report.js";
5
+ import { runPlan } from "./core/plan.js";
6
+ function rootOf(flags, positional) {
7
+ const r = typeof flags.root === "string" ? flags.root : positional[0];
8
+ return path.resolve(r ?? process.cwd());
9
+ }
10
+ function langOf(flags) {
11
+ return flags.lang === "tr" || flags.lang === "en" ? flags.lang : undefined;
12
+ }
13
+ export const commands = {
14
+ report: async (args) => {
15
+ const { _, flags } = parseArgs(args);
16
+ try {
17
+ const out = await runReport(rootOf(flags, _), { lang: langOf(flags) });
18
+ console.log(out.text);
19
+ return out.isError ? 1 : 0;
20
+ }
21
+ catch (e) {
22
+ console.error(`katman report: ${e instanceof Error ? e.message : String(e)}`);
23
+ return 1;
24
+ }
25
+ },
26
+ plan: async (args) => {
27
+ const { _, flags } = parseArgs(args);
28
+ try {
29
+ const out = await runPlan(rootOf(flags, _), { lang: langOf(flags) });
30
+ console.log(out.text);
31
+ return out.isError ? 1 : 0;
32
+ }
33
+ catch (e) {
34
+ console.error(`katman plan: ${e instanceof Error ? e.message : String(e)}`);
35
+ return 1;
36
+ }
37
+ },
38
+ };
39
+ export const help = [
40
+ " katman-mcp plan [--root .] [--lang en|tr] build .katman/plan.md: page plan + 30-day task list (keeps your ticks)",
41
+ " katman-mcp report [--root .] [--lang en|tr] write .katman/report.md: research, audit, AI visibility, plan, next 7 days",
42
+ ];