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.
- package/LICENSE +13 -0
- package/README.md +160 -0
- package/dist/cli.js +71 -0
- package/dist/context.js +43 -0
- package/dist/crawlers.js +86 -0
- package/dist/index.js +25 -0
- package/dist/license.js +448 -0
- package/dist/meta.js +28 -0
- package/dist/net.js +128 -0
- package/dist/server.js +17 -0
- package/dist/state.js +81 -0
- package/dist/tools/account/account.js +64 -0
- package/dist/tools/account/cli.js +21 -0
- package/dist/tools/account/index.js +48 -0
- package/dist/tools/account/sync.js +142 -0
- package/dist/tools/account/upsell.js +290 -0
- package/dist/tools/crawl/audit/analyze.js +719 -0
- package/dist/tools/crawl/audit/bots.js +114 -0
- package/dist/tools/crawl/audit/checks.js +126 -0
- package/dist/tools/crawl/audit/html.js +382 -0
- package/dist/tools/crawl/audit/index.js +364 -0
- package/dist/tools/crawl/audit/model.js +1 -0
- package/dist/tools/crawl/audit/report.js +199 -0
- package/dist/tools/crawl/audit/robots.js +199 -0
- package/dist/tools/crawl/audit/sitemap.js +147 -0
- package/dist/tools/crawl/cli.js +106 -0
- package/dist/tools/crawl/common/advice.js +61 -0
- package/dist/tools/crawl/common/findings.js +57 -0
- package/dist/tools/crawl/common/routes.js +80 -0
- package/dist/tools/crawl/common/text.js +89 -0
- package/dist/tools/crawl/common/urls.js +64 -0
- package/dist/tools/crawl/index.js +226 -0
- package/dist/tools/crawl/indexnow.js +271 -0
- package/dist/tools/crawl/keywords.js +178 -0
- package/dist/tools/crawl/scan/framework.js +645 -0
- package/dist/tools/crawl/scan/index.js +175 -0
- package/dist/tools/crawl/scan/project.js +180 -0
- package/dist/tools/crawl/scan/routes.js +354 -0
- package/dist/tools/crawl/scan/signals.js +414 -0
- package/dist/tools/method/cli.js +42 -0
- package/dist/tools/method/core/brief.js +302 -0
- package/dist/tools/method/core/extract.js +435 -0
- package/dist/tools/method/core/generate.js +504 -0
- package/dist/tools/method/core/lint.js +556 -0
- package/dist/tools/method/core/plan.js +405 -0
- package/dist/tools/method/core/prompt.js +150 -0
- package/dist/tools/method/core/questions.js +196 -0
- package/dist/tools/method/core/report.js +182 -0
- package/dist/tools/method/core/research-run.js +148 -0
- package/dist/tools/method/core/research.js +448 -0
- package/dist/tools/method/core/snippets.js +292 -0
- package/dist/tools/method/core/start.js +277 -0
- package/dist/tools/method/data/anatomy.js +141 -0
- package/dist/tools/method/data/appendices.js +202 -0
- package/dist/tools/method/data/cloudflare.js +192 -0
- package/dist/tools/method/data/evidence.js +137 -0
- package/dist/tools/method/data/geo-jobs.js +128 -0
- package/dist/tools/method/data/index.js +19 -0
- package/dist/tools/method/data/landing.js +67 -0
- package/dist/tools/method/data/lessons.js +127 -0
- package/dist/tools/method/data/measurement.js +142 -0
- package/dist/tools/method/data/method.js +141 -0
- package/dist/tools/method/data/offsite.js +148 -0
- package/dist/tools/method/data/pages.js +167 -0
- package/dist/tools/method/data/prompts.js +633 -0
- package/dist/tools/method/data/publish.js +113 -0
- package/dist/tools/method/data/render.js +117 -0
- package/dist/tools/method/data/research.js +131 -0
- package/dist/tools/method/data/roadmap.js +105 -0
- package/dist/tools/method/data/traps.js +74 -0
- package/dist/tools/method/data/verify.js +88 -0
- package/dist/tools/method/data/viral-list.js +39 -0
- package/dist/tools/method/index.js +209 -0
- package/dist/tools/method/lib/calls.js +53 -0
- package/dist/tools/method/lib/framework.js +525 -0
- package/dist/tools/method/lib/l10n.js +17 -0
- package/dist/tools/method/lib/routes.js +118 -0
- package/dist/tools/method/lib/state.js +454 -0
- package/dist/tools/method/lib/text.js +165 -0
- package/dist/tools/method/mcp/prompts.js +79 -0
- package/dist/tools/method/mcp/resources.js +50 -0
- package/dist/tools/visibility/analyze.js +463 -0
- package/dist/tools/visibility/cli.js +86 -0
- package/dist/tools/visibility/estimate.js +105 -0
- package/dist/tools/visibility/index.js +108 -0
- package/dist/tools/visibility/judge.js +156 -0
- package/dist/tools/visibility/manual.js +275 -0
- package/dist/tools/visibility/models.js +277 -0
- package/dist/tools/visibility/monitoring.js +165 -0
- package/dist/tools/visibility/openrouter.js +449 -0
- package/dist/tools/visibility/render.js +295 -0
- package/dist/tools/visibility/run.js +684 -0
- package/dist/tools/visibility/summary.js +164 -0
- package/dist/types.js +3 -0
- package/dist/util.js +92 -0
- package/package.json +54 -0
- package/server.json +34 -0
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
// Framework, builder, rendering verdict, hosting/preview patterns and static dir for katman_scan_code.
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { CSR_ACCEPT, JS_RENDER_NOTE, LOVABLE_CAVEAT, csrFixFor } from "../common/advice.js";
|
|
5
|
+
import { escapeRe } from "../common/text.js";
|
|
6
|
+
import { codeFiles, first, has, hasDir, list, text } from "./project.js";
|
|
7
|
+
const PRIORITY = [
|
|
8
|
+
"next", "nuxt", "@sveltejs/kit", "astro", "@remix-run/react", "@remix-run/node", "@react-router/dev", "@tanstack/react-start",
|
|
9
|
+
"@tanstack/start", "@tanstack/solid-start", "gatsby", "@solidjs/start", "solid-start", "@builder.io/qwik-city", "@angular/core",
|
|
10
|
+
"@docusaurus/core", "vitepress", "vuepress", "@11ty/eleventy", "vike", "vite-plugin-ssr", "waku", "react-scripts", "vite", "react",
|
|
11
|
+
"vue", "svelte", "solid-js", "preact", "express", "hono", "fastify", "koa", "itty-router", "wrangler",
|
|
12
|
+
];
|
|
13
|
+
function cleanVersion(v) {
|
|
14
|
+
if (!v)
|
|
15
|
+
return undefined;
|
|
16
|
+
const m = v.match(/\d+(\.\d+){0,2}/);
|
|
17
|
+
return m ? m[0] : undefined;
|
|
18
|
+
}
|
|
19
|
+
export function readPackages(p) {
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const rel of p.files.keys()) {
|
|
22
|
+
if (!/(^|\/)package\.json$/.test(rel) || rel.split("/").length > 4)
|
|
23
|
+
continue;
|
|
24
|
+
try {
|
|
25
|
+
const j = JSON.parse(text(p, rel));
|
|
26
|
+
const deps = { ...j.dependencies, ...j.devDependencies, ...j.peerDependencies };
|
|
27
|
+
out.push({
|
|
28
|
+
rel,
|
|
29
|
+
dir: path.posix.dirname(rel) === "." ? "" : path.posix.dirname(rel),
|
|
30
|
+
name: typeof j.name === "string" ? j.name : undefined,
|
|
31
|
+
deps,
|
|
32
|
+
scripts: j.scripts ?? {},
|
|
33
|
+
homepage: typeof j.homepage === "string" ? j.homepage : undefined,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* invalid package.json */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
function rank(pkg) {
|
|
43
|
+
const i = PRIORITY.findIndex((d) => d in pkg.deps);
|
|
44
|
+
return i < 0 ? 999 : i;
|
|
45
|
+
}
|
|
46
|
+
export function choosePrimary(pkgs) {
|
|
47
|
+
if (!pkgs.length)
|
|
48
|
+
return undefined;
|
|
49
|
+
const rootPkg = pkgs.find((x) => x.dir === "");
|
|
50
|
+
if (rootPkg && rank(rootPkg) < 999)
|
|
51
|
+
return rootPkg;
|
|
52
|
+
const pref = /^(apps\/(web|site|www|frontend|marketing|app)|web|site|www|frontend|client|app|marketing|landing)$/;
|
|
53
|
+
return [...pkgs].sort((a, b) => rank(a) - rank(b) || Number(!pref.test(b.dir)) - Number(!pref.test(a.dir)) || a.dir.length - b.dir.length)[0] ?? rootPkg;
|
|
54
|
+
}
|
|
55
|
+
function F(id, severity, gate, prompt, title, detail, extra = {}) {
|
|
56
|
+
return { id, title, severity, gate, prompt, detail, ...extra };
|
|
57
|
+
}
|
|
58
|
+
/** Content between the SPA mount point's tags, e.g. <div id="root"></div>. */
|
|
59
|
+
export function spaRootInfo(html) {
|
|
60
|
+
const m = html.match(/<div[^>]*\bid\s*=\s*["'](root|app|__next|svelte|main|q-app)["'][^>]*>([\s\S]*?)<\/div>/i);
|
|
61
|
+
if (!m)
|
|
62
|
+
return undefined;
|
|
63
|
+
const inner = m[2].replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
64
|
+
const words = inner ? inner.split(" ").length : 0;
|
|
65
|
+
return { empty: words <= 3, words, id: m[1] };
|
|
66
|
+
}
|
|
67
|
+
export function detect(p) {
|
|
68
|
+
const pkgs = readPackages(p);
|
|
69
|
+
const pkg = choosePrimary(pkgs);
|
|
70
|
+
const appDir = pkg?.dir ?? "";
|
|
71
|
+
const pre = appDir ? `${appDir}/` : "";
|
|
72
|
+
const J = (rel) => pre + rel;
|
|
73
|
+
const deps = pkg?.deps ?? {};
|
|
74
|
+
const hasDep = (d) => d in deps;
|
|
75
|
+
const ver = (d) => cleanVersion(deps[d]);
|
|
76
|
+
const ev = [];
|
|
77
|
+
const findings = [];
|
|
78
|
+
let fw = { name: "unknown" };
|
|
79
|
+
let verdict = "unknown";
|
|
80
|
+
let conf = "low";
|
|
81
|
+
const cfgOf = (names) => {
|
|
82
|
+
const rel = first(p, names.map(J));
|
|
83
|
+
return { rel, t: rel ? text(p, rel) : "" };
|
|
84
|
+
};
|
|
85
|
+
const reAt = (s) => new RegExp(`^${escapeRe(pre)}${s}`);
|
|
86
|
+
const spa = {};
|
|
87
|
+
// Builder signatures
|
|
88
|
+
const readme = text(p, first(p, [J("README.md"), "README.md", J("readme.md")]) ?? "");
|
|
89
|
+
const indexRel = first(p, [J("index.html"), J("public/index.html"), J("src/index.html")]);
|
|
90
|
+
const indexHtml = indexRel ? text(p, indexRel) : "";
|
|
91
|
+
let builder;
|
|
92
|
+
if (hasDep("lovable-tagger") || /lovable\.dev|lovable\.app|lovable project/i.test(readme) || /content=["']Lovable|lovable\.dev\/opengraph|gpteng\.co/i.test(indexHtml))
|
|
93
|
+
builder = "lovable";
|
|
94
|
+
else if (hasDir(p, ".bolt") || p.seenDirNames.has(".bolt") || /bolt\.new/i.test(readme))
|
|
95
|
+
builder = "bolt";
|
|
96
|
+
else if (/v0\.(dev|app)|v0 by vercel/i.test(readme) || codeFiles(p).slice(0, 400).some((f) => /v0 by Vercel|https:\/\/v0\.(dev|app)\/t\//.test(f.text.slice(0, 3000))))
|
|
97
|
+
builder = "v0";
|
|
98
|
+
else if (existsSync(path.join(p.root, ".replit")) || has(p, "replit.nix") || has(p, "replit.md"))
|
|
99
|
+
builder = "replit";
|
|
100
|
+
else if (hasDir(p, ".idx") || p.seenDirNames.has(".idx"))
|
|
101
|
+
builder = "firebase-studio";
|
|
102
|
+
const prerenderDeps = ["vite-react-ssg", "vite-prerender-plugin", "vite-plugin-prerender", "@prerenderer/rollup-plugin", "@prerenderer/renderer-puppeteer", "vite-ssg", "react-snap", "vite-plugin-ssr", "prerender-spa-plugin"];
|
|
103
|
+
const prer = prerenderDeps.filter(hasDep);
|
|
104
|
+
if (hasDep("next")) {
|
|
105
|
+
const { rel, t } = cfgOf(["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"]);
|
|
106
|
+
const appPages = list(p, reAt(String.raw `(src/)?app/(.+/)?page\.(tsx|ts|jsx|js|mdx|md)$`));
|
|
107
|
+
const pagesPages = list(p, reAt(String.raw `(src/)?pages/.+\.(tsx|ts|jsx|js|mdx|md)$`)).filter((f) => !/\/pages\/(api\/|_app\.|_document\.|_error\.)/.test(f));
|
|
108
|
+
const router = appPages.length && pagesPages.length ? "app+pages" : appPages.length ? "app" : pagesPages.length ? "pages" : undefined;
|
|
109
|
+
fw = { name: "next", version: ver("next"), router };
|
|
110
|
+
if (/output\s*:\s*['"`]export['"`]/.test(t)) {
|
|
111
|
+
verdict = "ssg";
|
|
112
|
+
conf = "high";
|
|
113
|
+
ev.push(`${rel}: output: "export" (static HTML export)`);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
verdict = "hybrid";
|
|
117
|
+
conf = "high";
|
|
118
|
+
ev.push(`Next.js ${ver("next") ?? ""} (${router ?? "no"} router) renders pages on the server (at build time or per request)`);
|
|
119
|
+
if (/output\s*:\s*['"`]standalone['"`]/.test(t))
|
|
120
|
+
ev.push(`${rel}: output: "standalone" (Node server)`);
|
|
121
|
+
}
|
|
122
|
+
nextClientChecks(p, [...appPages, ...pagesPages], findings, router);
|
|
123
|
+
}
|
|
124
|
+
else if (hasDep("nuxt") || hasDep("nuxt3")) {
|
|
125
|
+
const { rel, t } = cfgOf(["nuxt.config.ts", "nuxt.config.js", "nuxt.config.mjs"]);
|
|
126
|
+
fw = { name: "nuxt", version: ver("nuxt") ?? ver("nuxt3") };
|
|
127
|
+
conf = "high";
|
|
128
|
+
if (/\bssr\s*:\s*false/.test(t) || /mode\s*:\s*['"]spa['"]/.test(t)) {
|
|
129
|
+
verdict = "csr";
|
|
130
|
+
ev.push(`${rel}: ssr: false (single-page app)`);
|
|
131
|
+
}
|
|
132
|
+
else if (/\btarget\s*:\s*['"]static['"]/.test(t) || (/nuxt\s+generate/.test(pkg?.scripts.generate ?? "") && /generate/.test(pkg?.scripts.build ?? ""))) {
|
|
133
|
+
verdict = "ssg";
|
|
134
|
+
ev.push("nuxt generate / target: static (pre-rendered HTML)");
|
|
135
|
+
}
|
|
136
|
+
else if (/prerender/.test(t)) {
|
|
137
|
+
verdict = "hybrid";
|
|
138
|
+
ev.push(`${rel}: prerender rules (routeRules / nitro.prerender) + server rendering`);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
verdict = "ssr";
|
|
142
|
+
ev.push("Nuxt renders on the server by default (ssr: true)");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else if (hasDep("@sveltejs/kit")) {
|
|
146
|
+
const { rel, t } = cfgOf(["svelte.config.js", "svelte.config.mjs", "svelte.config.ts"]);
|
|
147
|
+
const adapter = t.match(/@sveltejs\/adapter-([a-z-]+)/)?.[1] ?? Object.keys(deps).find((d) => d.startsWith("@sveltejs/adapter-"))?.slice(18);
|
|
148
|
+
fw = { name: "sveltekit", version: ver("@sveltejs/kit") };
|
|
149
|
+
const layouts = list(p, reAt(String.raw `src/routes/\+layout(\.server)?\.(js|ts)$`)).map((f) => text(p, f)).join("\n");
|
|
150
|
+
const ssrFalse = /export\s+const\s+ssr\s*=\s*false/.test(layouts);
|
|
151
|
+
const prerender = /export\s+const\s+prerender\s*=\s*(true|['"]auto['"])/.test(layouts);
|
|
152
|
+
conf = "high";
|
|
153
|
+
if (ssrFalse) {
|
|
154
|
+
verdict = "csr";
|
|
155
|
+
ev.push("src/routes/+layout: export const ssr = false (client-only)");
|
|
156
|
+
}
|
|
157
|
+
else if (adapter === "static") {
|
|
158
|
+
if (prerender) {
|
|
159
|
+
verdict = "ssg";
|
|
160
|
+
ev.push("adapter-static + prerender = true (static HTML)");
|
|
161
|
+
}
|
|
162
|
+
else if (/fallback\s*:/.test(t)) {
|
|
163
|
+
verdict = "csr";
|
|
164
|
+
conf = "medium";
|
|
165
|
+
ev.push(`${rel}: adapter-static with a fallback page and no prerender (SPA mode)`);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
verdict = "ssg";
|
|
169
|
+
conf = "medium";
|
|
170
|
+
ev.push("adapter-static (static HTML)");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
verdict = prerender ? "hybrid" : "ssr";
|
|
175
|
+
ev.push(`SvelteKit ${adapter ? `adapter-${adapter}` : ""} renders on the server${prerender ? " (+ prerendered routes)" : ""}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
else if (hasDep("astro")) {
|
|
179
|
+
const { rel, t } = cfgOf(["astro.config.mjs", "astro.config.ts", "astro.config.js", "astro.config.cjs", "astro.config.mts"]);
|
|
180
|
+
const output = t.match(/output\s*:\s*['"](static|server|hybrid)['"]/)?.[1] ?? "static";
|
|
181
|
+
const adapter = t.match(/@astrojs\/(cloudflare|vercel|netlify|node|deno)/)?.[1];
|
|
182
|
+
fw = { name: "astro", version: ver("astro") };
|
|
183
|
+
const pages = list(p, reAt(String.raw `src/pages/.+\.(astro|md|mdx)$`));
|
|
184
|
+
const preTrue = pages.filter((f) => /export\s+const\s+prerender\s*=\s*true/.test(text(p, f)));
|
|
185
|
+
const preFalse = pages.filter((f) => /export\s+const\s+prerender\s*=\s*false/.test(text(p, f)));
|
|
186
|
+
conf = "high";
|
|
187
|
+
if (output === "server")
|
|
188
|
+
verdict = preTrue.length ? "hybrid" : "ssr";
|
|
189
|
+
else if (output === "hybrid")
|
|
190
|
+
verdict = "hybrid";
|
|
191
|
+
else
|
|
192
|
+
verdict = preFalse.length ? "hybrid" : "ssg";
|
|
193
|
+
ev.push(`${rel ?? "astro"}: output ${output}${adapter ? ` with @astrojs/${adapter}` : ""} — HTML is rendered ${verdict === "ssg" ? "at build time" : "on the server"}`);
|
|
194
|
+
const clientOnly = codeFiles(p).filter((f) => /\.astro$/.test(f.rel) && /client:only/.test(f.text));
|
|
195
|
+
if (clientOnly.length) {
|
|
196
|
+
findings.push(F("scan-astro-client-only", "medium", "read", "P2", `${clientOnly.length} Astro file${clientOnly.length === 1 ? " uses" : "s use"} client:only components`, `client:only islands render nothing on the server; their text is invisible to AI crawlers. ${JS_RENDER_NOTE}`, { files: clientOnly.map((f) => f.rel), fix: "Use client:load/client:visible (server-rendered + hydrated) for anything with text; keep client:only for pure widgets.", accept: CSR_ACCEPT }));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
else if (Object.keys(deps).some((d) => d.startsWith("@remix-run/"))) {
|
|
200
|
+
fw = { name: "remix", version: ver("@remix-run/react") ?? ver("@remix-run/node") };
|
|
201
|
+
const { rel, t } = cfgOf(["vite.config.ts", "vite.config.js", "vite.config.mjs"]);
|
|
202
|
+
conf = "high";
|
|
203
|
+
if (/remix\s*\(\s*\{[\s\S]*?ssr\s*:\s*false/.test(t)) {
|
|
204
|
+
verdict = "csr";
|
|
205
|
+
ev.push(`${rel}: remix({ ssr: false }) (SPA mode)`);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
verdict = "ssr";
|
|
209
|
+
ev.push("Remix renders on the server");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else if (hasDep("@react-router/dev")) {
|
|
213
|
+
fw = { name: "react-router", version: ver("react-router") ?? ver("@react-router/dev") };
|
|
214
|
+
const { rel, t } = cfgOf(["react-router.config.ts", "react-router.config.js", "react-router.config.mjs"]);
|
|
215
|
+
const ssrFalse = /ssr\s*:\s*false/.test(t);
|
|
216
|
+
const prerender = /prerender\s*:/.test(t);
|
|
217
|
+
conf = "high";
|
|
218
|
+
if (ssrFalse && prerender) {
|
|
219
|
+
verdict = "ssg";
|
|
220
|
+
ev.push(`${rel}: ssr: false + prerender (static HTML for the listed routes)`);
|
|
221
|
+
}
|
|
222
|
+
else if (ssrFalse) {
|
|
223
|
+
verdict = "csr";
|
|
224
|
+
ev.push(`${rel}: ssr: false without prerender (SPA mode)`);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
verdict = prerender ? "hybrid" : "ssr";
|
|
228
|
+
ev.push(`React Router framework mode renders on the server${prerender ? " (+ prerendered routes)" : ""}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
else if (hasDep("@tanstack/react-start") || hasDep("@tanstack/start") || hasDep("@tanstack/solid-start")) {
|
|
232
|
+
fw = { name: "tanstack-start", version: ver("@tanstack/react-start") ?? ver("@tanstack/start") ?? ver("@tanstack/solid-start") };
|
|
233
|
+
const { rel, t } = cfgOf(["vite.config.ts", "vite.config.js", "app.config.ts", "app.config.js"]);
|
|
234
|
+
conf = "high";
|
|
235
|
+
if (/spa\s*:\s*\{[^}]*enabled\s*:\s*true/.test(t)) {
|
|
236
|
+
verdict = "csr";
|
|
237
|
+
conf = "medium";
|
|
238
|
+
ev.push(`${rel}: TanStack Start SPA mode`);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
verdict = /prerender\s*:\s*\{[^}]*enabled\s*:\s*true/.test(t) ? "hybrid" : "ssr";
|
|
242
|
+
ev.push(`TanStack Start renders on the server${verdict === "hybrid" ? " (+ prerender)" : ""}${builder === "lovable" ? " (Lovable apps since 2026-05-13 use it)" : ""}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else if (hasDep("gatsby")) {
|
|
246
|
+
fw = { name: "gatsby", version: ver("gatsby") };
|
|
247
|
+
verdict = "ssg";
|
|
248
|
+
conf = "high";
|
|
249
|
+
ev.push("Gatsby builds static HTML");
|
|
250
|
+
}
|
|
251
|
+
else if (hasDep("@solidjs/start") || hasDep("solid-start")) {
|
|
252
|
+
fw = { name: "solid-start", version: ver("@solidjs/start") ?? ver("solid-start") };
|
|
253
|
+
const { rel, t } = cfgOf(["app.config.ts", "app.config.js", "vite.config.ts"]);
|
|
254
|
+
conf = "high";
|
|
255
|
+
verdict = /ssr\s*:\s*false/.test(t) ? "csr" : "ssr";
|
|
256
|
+
ev.push(verdict === "csr" ? `${rel}: ssr: false` : "SolidStart renders on the server");
|
|
257
|
+
}
|
|
258
|
+
else if (hasDep("@builder.io/qwik-city") || hasDep("@builder.io/qwik")) {
|
|
259
|
+
fw = { name: "qwik", version: ver("@builder.io/qwik") };
|
|
260
|
+
const isStatic = codeFiles(p).some((f) => /qwik-city\/adapters\/static/.test(f.text));
|
|
261
|
+
verdict = isStatic ? "ssg" : "ssr";
|
|
262
|
+
conf = "high";
|
|
263
|
+
ev.push(isStatic ? "Qwik City static adapter" : "Qwik City renders on the server");
|
|
264
|
+
}
|
|
265
|
+
else if (hasDep("@angular/core")) {
|
|
266
|
+
fw = { name: "angular", version: ver("@angular/core") };
|
|
267
|
+
const ng = text(p, first(p, [J("angular.json")]) ?? "");
|
|
268
|
+
if (hasDep("@angular/ssr") || hasDep("@nguniversal/express-engine")) {
|
|
269
|
+
conf = "high";
|
|
270
|
+
if (/"outputMode"\s*:\s*"static"/.test(ng) || /"prerender"\s*:\s*true/.test(ng)) {
|
|
271
|
+
verdict = /"outputMode"\s*:\s*"server"/.test(ng) ? "hybrid" : "ssg";
|
|
272
|
+
}
|
|
273
|
+
else
|
|
274
|
+
verdict = "ssr";
|
|
275
|
+
ev.push(`@angular/ssr present (${verdict})`);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
verdict = "csr";
|
|
279
|
+
conf = "high";
|
|
280
|
+
ev.push("Angular without @angular/ssr renders in the browser");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
else if (hasDep("@docusaurus/core") || hasDep("vitepress") || hasDep("vuepress") || hasDep("@11ty/eleventy")) {
|
|
284
|
+
const n = hasDep("@docusaurus/core") ? "docusaurus" : hasDep("vitepress") ? "vitepress" : hasDep("vuepress") ? "vuepress" : "eleventy";
|
|
285
|
+
fw = { name: n, version: ver(n === "eleventy" ? "@11ty/eleventy" : n === "docusaurus" ? "@docusaurus/core" : n) };
|
|
286
|
+
verdict = "ssg";
|
|
287
|
+
conf = "high";
|
|
288
|
+
ev.push(`${n} builds static HTML`);
|
|
289
|
+
}
|
|
290
|
+
else if (hasDep("vike") || hasDep("vite-plugin-ssr") || hasDep("waku")) {
|
|
291
|
+
fw = { name: hasDep("waku") ? "waku" : "vike", version: ver("vike") ?? ver("vite-plugin-ssr") ?? ver("waku") };
|
|
292
|
+
const pre2 = codeFiles(p).some((f) => /prerender\s*:\s*(true|\{)/.test(f.text) && /\+config|vite\.config/.test(f.rel));
|
|
293
|
+
verdict = pre2 ? "ssg" : "ssr";
|
|
294
|
+
conf = "medium";
|
|
295
|
+
ev.push(`${fw.name} renders HTML on the server${pre2 ? " / prerenders" : ""}`);
|
|
296
|
+
}
|
|
297
|
+
else if (hasDep("react-scripts")) {
|
|
298
|
+
fw = { name: "cra", version: ver("react-scripts") };
|
|
299
|
+
if (prer.includes("react-snap") || /react-snap/.test(pkg?.scripts.postbuild ?? "")) {
|
|
300
|
+
verdict = "ssg";
|
|
301
|
+
conf = "medium";
|
|
302
|
+
ev.push("Create React App + react-snap (prerendered at build; react-snap is unmaintained)");
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
verdict = "csr";
|
|
306
|
+
conf = "high";
|
|
307
|
+
ev.push("Create React App renders everything in the browser (CRA is deprecated since 2025-02-14)");
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else if (hasDep("vite") || has(p, J("vite.config.ts")) || has(p, J("vite.config.js"))) {
|
|
311
|
+
const name = hasDep("react") || hasDep("preact") ? "vite-react" : hasDep("vue") ? "vite-vue" : hasDep("svelte") ? "vite-svelte" : hasDep("solid-js") ? "vite-solid" : "vite";
|
|
312
|
+
fw = { name, version: ver("vite") };
|
|
313
|
+
if (prer.length) {
|
|
314
|
+
verdict = "ssg";
|
|
315
|
+
conf = "medium";
|
|
316
|
+
ev.push(`${prer.join(", ")}: public routes are prerendered at build time (verify the output HTML has the text)`);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
verdict = "csr";
|
|
320
|
+
conf = "high";
|
|
321
|
+
ev.push(`package.json: vite ${ver("vite") ?? ""}${name === "vite" ? "" : ` + ${name.slice(5)}`} without an SSR framework or prerender plugin`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
else if (has(p, "hugo.toml") || has(p, "hugo.yaml") || has(p, "hugo.json") || ((has(p, "config.toml") || has(p, "config.yaml")) && (hasDir(p, "layouts") || hasDir(p, "themes")) && hasDir(p, "content"))) {
|
|
325
|
+
fw = { name: "hugo" };
|
|
326
|
+
verdict = "ssg";
|
|
327
|
+
conf = "high";
|
|
328
|
+
ev.push("Hugo builds static HTML");
|
|
329
|
+
}
|
|
330
|
+
else if (has(p, "_config.yml") && (/jekyll/i.test(text(p, "Gemfile")) || hasDir(p, "_layouts") || hasDir(p, "_posts"))) {
|
|
331
|
+
fw = { name: "jekyll" };
|
|
332
|
+
verdict = "ssg";
|
|
333
|
+
conf = "high";
|
|
334
|
+
ev.push("Jekyll builds static HTML");
|
|
335
|
+
}
|
|
336
|
+
else if (has(p, ".eleventy.js") || list(p, /^eleventy\.config\.(js|mjs|cjs)$/).length) {
|
|
337
|
+
fw = { name: "eleventy" };
|
|
338
|
+
verdict = "ssg";
|
|
339
|
+
conf = "high";
|
|
340
|
+
ev.push("Eleventy builds static HTML");
|
|
341
|
+
}
|
|
342
|
+
// SPA shell evidence (index.html) for client-rendered stacks
|
|
343
|
+
if (indexHtml) {
|
|
344
|
+
spa.indexHtml = indexRel;
|
|
345
|
+
const info = spaRootInfo(indexHtml);
|
|
346
|
+
if (info) {
|
|
347
|
+
spa.rootEmpty = info.empty;
|
|
348
|
+
if (verdict === "csr" && info.empty)
|
|
349
|
+
ev.push(`${indexRel}: <div id="${info.id}"></div> is empty — the page text is drawn by JavaScript`);
|
|
350
|
+
}
|
|
351
|
+
const defaults = [];
|
|
352
|
+
if (/<title>\s*(Vite \+ (React|Vue|Svelte|Preact|TS|React \+ TS)[^<]*|Vite App|React App|lovable-project|Lovable App)\s*<\/title>/i.test(indexHtml))
|
|
353
|
+
defaults.push(`default <title>: ${indexHtml.match(/<title>([^<]*)<\/title>/i)?.[1]}`);
|
|
354
|
+
if (/content=["']Lovable Generated Project["']/i.test(indexHtml))
|
|
355
|
+
defaults.push("meta description \"Lovable Generated Project\"");
|
|
356
|
+
if (/lovable\.dev\/opengraph-image/i.test(indexHtml))
|
|
357
|
+
defaults.push("og:image from lovable.dev");
|
|
358
|
+
if (/name=["']author["'][^>]*content=["']Lovable/i.test(indexHtml))
|
|
359
|
+
defaults.push("meta author \"Lovable\"");
|
|
360
|
+
if (defaults.length) {
|
|
361
|
+
findings.push(F("scan-default-meta", "high", "quote", "P3", "index.html still has the builder's default meta tags", `${indexRel}: ${defaults.join("; ")}. Every URL of a single-page app serves these tags to crawlers that don't run JavaScript.`, { files: [indexRel], fix: "Replace them with the brand's title and one-sentence description, your own 1200×630 og:image, and generate per-page tags (P3).", accept: "No default builder tags remain; each public page has its own title and description in the server HTML" }));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// Custom server (Workers / Express / Hono …) rendering HTML in code
|
|
365
|
+
const wr = wranglerInfo(p, appDir);
|
|
366
|
+
if (fw.name === "unknown") {
|
|
367
|
+
const serverDeps = ["express", "hono", "fastify", "koa", "itty-router", "@hono/node-server", "h3", "elysia"].filter(hasDep);
|
|
368
|
+
const htmlFiles = codeFiles(p).filter((f) => /\.(m?[jt]s|[jt]sx|cjs)$/.test(f.rel) && (/<!doctype html/i.test(f.text) || /\bc\.html\(|res\.render\(|['"]content-type['"]\s*:\s*['"]text\/html/i.test(f.text)));
|
|
369
|
+
// Pages prebuilt into the static folder by a build script (static site generator of its own) vs HTML built per request.
|
|
370
|
+
const assets = wr.assetsDir ?? "public";
|
|
371
|
+
const prebuilt = list(p, new RegExp(`^${escapeRe(assets)}/.+\\.html$`)).filter((f) => !/(^|\/)(404|admin|app)\.html$/.test(f));
|
|
372
|
+
const builder = htmlFiles.find((f) => /(^|\/)(build|generate|prerender|render|gen)[-_.]?(pages|site|static|html)?[^/]*\.(m?[jt]s|cjs)$/i.test(f.rel) && /writeFile|outputFile/.test(f.text) && /\.html/.test(f.text));
|
|
373
|
+
if ((serverDeps.length || wr.rel) && builder && prebuilt.length >= 3) {
|
|
374
|
+
fw = { name: "custom-server", router: wr.rel ? "cloudflare-worker" : serverDeps[0] };
|
|
375
|
+
verdict = "ssg";
|
|
376
|
+
conf = "high";
|
|
377
|
+
ev.push(`${prebuilt.length} pages are prebuilt into ${assets}/ by ${builder.rel} and served as static files${wr.rel ? ` by a Cloudflare Worker (${wr.rel})` : ""}`);
|
|
378
|
+
}
|
|
379
|
+
else if ((serverDeps.length || wr.rel) && htmlFiles.length) {
|
|
380
|
+
fw = { name: "custom-server", router: wr.rel ? "cloudflare-worker" : serverDeps[0] };
|
|
381
|
+
verdict = "ssr";
|
|
382
|
+
conf = "medium";
|
|
383
|
+
ev.push(`${wr.rel ? `Cloudflare Worker (${wr.rel}${wr.main ? `, main ${wr.main}` : ""})` : serverDeps.join(", ")} builds HTML in code: ${htmlFiles.slice(0, 3).map((f) => f.rel).join(", ")}`);
|
|
384
|
+
}
|
|
385
|
+
else if (wr.assetsDir || !pkg) {
|
|
386
|
+
const idx = first(p, [wr.assetsDir ? `${wr.assetsDir}/index.html` : "", "index.html", "public/index.html", "src/index.html"].filter(Boolean));
|
|
387
|
+
const html = idx ? text(p, idx) : "";
|
|
388
|
+
const info = html ? spaRootInfo(html) : undefined;
|
|
389
|
+
const htmlCount = list(p, /\.html?$/).filter((f) => !/(^|\/)(node_modules|dist|build)\//.test(f)).length;
|
|
390
|
+
if (idx && info?.empty && /<script/i.test(html)) {
|
|
391
|
+
fw = { name: "static-html" };
|
|
392
|
+
verdict = "csr";
|
|
393
|
+
conf = "medium";
|
|
394
|
+
ev.push(`${idx}: empty #${info.id} mount point and scripts — a built single-page app`);
|
|
395
|
+
}
|
|
396
|
+
else if (idx || htmlCount) {
|
|
397
|
+
fw = { name: "static-html" };
|
|
398
|
+
verdict = "static-html";
|
|
399
|
+
conf = "high";
|
|
400
|
+
ev.push(`${htmlCount} HTML file${htmlCount === 1 ? "" : "s"} served as-is`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (verdict === "unknown")
|
|
405
|
+
ev.push("No known framework or HTML entry point found");
|
|
406
|
+
if (verdict === "csr") {
|
|
407
|
+
findings.unshift(F("scan-csr", "critical", "read", "P2", "Page text is rendered in the browser (client-side rendering)", `${JS_RENDER_NOTE} Evidence: ${ev.join("; ")}.${builder === "lovable" ? ` ${LOVABLE_CAVEAT}` : ""}`, { files: [indexRel, pkg?.rel].filter((x) => !!x), fix: csrFixFor(fw.name, fw.router), accept: CSR_ACCEPT }));
|
|
408
|
+
}
|
|
409
|
+
const hosting = detectHosting(p, pkg, builder, wr, findings);
|
|
410
|
+
const domains = collectDomains(p, pkg, wr);
|
|
411
|
+
if (domains.length)
|
|
412
|
+
hosting.evidence.push(`custom domain: ${domains.join(", ")}`);
|
|
413
|
+
fw.builder = builder;
|
|
414
|
+
return {
|
|
415
|
+
appDir,
|
|
416
|
+
pkg,
|
|
417
|
+
framework: fw,
|
|
418
|
+
rendering: { verdict, confidence: conf, evidence: ev },
|
|
419
|
+
hosting,
|
|
420
|
+
staticDir: staticDirFor(fw.name, appDir, wr.assetsDir, p),
|
|
421
|
+
domains,
|
|
422
|
+
spa: { ...spa, spaFallback: spaFallback(p, wr) },
|
|
423
|
+
findings,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
function nextClientChecks(p, pageFiles, findings, router) {
|
|
427
|
+
const clientFetch = pageFiles.filter((f) => {
|
|
428
|
+
const t = text(p, f);
|
|
429
|
+
return /^\s*['"]use client['"]/m.test(t.slice(0, 400)) && /useEffect\s*\(/.test(t) && /\b(fetch\(|supabase|axios|useQuery|useSWR|getDocs|firebase|trpc|\.from\(['"])/.test(t);
|
|
430
|
+
});
|
|
431
|
+
if (clientFetch.length) {
|
|
432
|
+
findings.push(F("scan-client-fetch", "medium", "read", "P2", `${clientFetch.length} page${clientFetch.length === 1 ? " fetches its" : "s fetch their"} content in the browser`, `"use client" pages that load text in useEffect ship an empty frame to crawlers. ${JS_RENDER_NOTE}`, { files: clientFetch.slice(0, 10), fix: csrFixFor("next", router), accept: CSR_ACCEPT }));
|
|
433
|
+
}
|
|
434
|
+
const ssrFalse = [];
|
|
435
|
+
const guards = [];
|
|
436
|
+
for (const f of codeFiles(p)) {
|
|
437
|
+
const t = f.text;
|
|
438
|
+
if (/dynamic\s*\([\s\S]{0,200}?ssr\s*:\s*false/.test(t))
|
|
439
|
+
ssrFalse.push(f.rel);
|
|
440
|
+
if (/(layout|_app|providers?|root)\.(tsx|jsx|ts|js)$/i.test(f.rel) && /if\s*\(\s*!\s*(mounted|isMounted|hasMounted|isClient|hydrated|isHydrated)\s*\)\s*\{?\s*return\s+(null|<>\s*<\/>)/.test(t))
|
|
441
|
+
guards.push(f.rel);
|
|
442
|
+
}
|
|
443
|
+
if (guards.length) {
|
|
444
|
+
findings.push(F("scan-mounted-guard", "high", "read", "P2", "A layout/provider renders nothing until the browser mounts", `\`if (!mounted) return null\` in ${guards.join(", ")} makes every page under it empty in the server HTML. ${JS_RENDER_NOTE}`, { files: guards, fix: "Render children on the server; guard only the browser-only widget (theme toggle etc.), not the whole tree.", accept: CSR_ACCEPT }));
|
|
445
|
+
}
|
|
446
|
+
if (ssrFalse.length) {
|
|
447
|
+
findings.push(F("scan-ssr-false", "low", "read", "P2", `${ssrFalse.length} file${ssrFalse.length === 1 ? " loads" : "s load"} components with ssr: false`, "Fine for widgets (maps, editors); any text inside them is missing from the server HTML.", { files: ssrFalse.slice(0, 10), fix: "Keep ssr:false only for non-text widgets.", accept: CSR_ACCEPT }));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function wranglerInfo(p, appDir) {
|
|
451
|
+
const pre = appDir ? `${appDir}/` : "";
|
|
452
|
+
const rel = first(p, ["wrangler.jsonc", "wrangler.json", "wrangler.toml"].flatMap((n) => [pre + n, n]));
|
|
453
|
+
if (!rel)
|
|
454
|
+
return { domains: [] };
|
|
455
|
+
const t = text(p, rel).replace(/^\s*\/\/.*$/gm, "");
|
|
456
|
+
const str = (k) => t.match(new RegExp(`["']?${k}["']?\\s*[:=]\\s*["']([^"']+)["']`))?.[1];
|
|
457
|
+
const bool = (k) => {
|
|
458
|
+
const m = t.match(new RegExp(`["']?${k}["']?\\s*[:=]\\s*(true|false)`));
|
|
459
|
+
return m ? m[1] === "true" : undefined;
|
|
460
|
+
};
|
|
461
|
+
const assets = t.match(/["']?assets["']?\s*[:=]\s*\{[^}]*?["']?directory["']?\s*[:=]\s*["']([^"']+)["']/)?.[1] ?? t.match(/\[assets\][^[]*?directory\s*=\s*["']([^"']+)["']/)?.[1] ?? t.match(/bucket\s*[:=]\s*["']([^"']+)["']/)?.[1];
|
|
462
|
+
const domains = [...t.matchAll(/["']?pattern["']?\s*[:=]\s*["']([^"'/*]+?)(?:\/\*)?["']/g)].map((m) => m[1]).filter((d) => /\./.test(d) && !/workers\.dev$/.test(d));
|
|
463
|
+
return {
|
|
464
|
+
rel,
|
|
465
|
+
name: str("name"),
|
|
466
|
+
main: str("main"),
|
|
467
|
+
pagesOut: str("pages_build_output_dir"),
|
|
468
|
+
assetsDir: assets ? path.posix.normalize(pre + assets.replace(/^\.\//, "")).replace(/\/$/, "") : undefined,
|
|
469
|
+
workersDev: bool("workers_dev"),
|
|
470
|
+
previewUrls: bool("preview_urls"),
|
|
471
|
+
notFound: str("not_found_handling"),
|
|
472
|
+
domains,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
function spaFallback(p, wr) {
|
|
476
|
+
if (wr.notFound === "single-page-application")
|
|
477
|
+
return `${wr.rel}: not_found_handling "single-page-application"`;
|
|
478
|
+
for (const rel of list(p, /(^|\/)_redirects$/)) {
|
|
479
|
+
if (/^\s*\/\*\s+\/index\.html\s+200/m.test(text(p, rel)))
|
|
480
|
+
return `${rel}: /* /index.html 200`;
|
|
481
|
+
}
|
|
482
|
+
const v = text(p, first(p, ["vercel.json"]) ?? "");
|
|
483
|
+
if (/"source"\s*:\s*"\/\(\.\*\)"[^}]*"destination"\s*:\s*"\/(index\.html)?"/.test(v))
|
|
484
|
+
return "vercel.json: rewrite (.*) → /index.html";
|
|
485
|
+
const n = text(p, first(p, ["netlify.toml"]) ?? "");
|
|
486
|
+
if (/from\s*=\s*["']\/\*["'][\s\S]{0,80}to\s*=\s*["']\/index\.html["'][\s\S]{0,40}status\s*=\s*200/.test(n))
|
|
487
|
+
return "netlify.toml: /* → /index.html 200";
|
|
488
|
+
const fb = text(p, first(p, ["firebase.json"]) ?? "");
|
|
489
|
+
if (/"source"\s*:\s*"\*\*"[^}]*"destination"\s*:\s*"\/index\.html"/.test(fb))
|
|
490
|
+
return "firebase.json: ** → /index.html";
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
493
|
+
function detectHosting(p, pkg, builder, wr, findings) {
|
|
494
|
+
const evidence = [];
|
|
495
|
+
const patterns = [];
|
|
496
|
+
let provider;
|
|
497
|
+
const name = (wr.name ?? pkg?.name ?? path.basename(p.root)).replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
498
|
+
const deps = pkg?.deps ?? {};
|
|
499
|
+
const adapter = (re) => Object.keys(deps).some((d) => re.test(d));
|
|
500
|
+
let fixText = "";
|
|
501
|
+
let severity = "medium";
|
|
502
|
+
if (wr.rel || adapter(/^@astrojs\/cloudflare$|^@sveltejs\/adapter-cloudflare|^@opennextjs\/cloudflare$|^@cloudflare\/next-on-pages$/)) {
|
|
503
|
+
provider = "cloudflare";
|
|
504
|
+
if (wr.rel)
|
|
505
|
+
evidence.push(`${wr.rel}${wr.name ? ` (name: ${wr.name})` : ""}`);
|
|
506
|
+
if (wr.pagesOut || adapter(/^@cloudflare\/next-on-pages$/)) {
|
|
507
|
+
patterns.push(`${name}.pages.dev`, `*.${name}.pages.dev`);
|
|
508
|
+
fixText = `Redirect ${name}.pages.dev to the custom domain (a Bulk Redirect or a check on the host in a Pages Function), or send X-Robots-Tag: noindex there. Preview deployments already get noindex.`;
|
|
509
|
+
}
|
|
510
|
+
else if (wr.rel) {
|
|
511
|
+
if (wr.workersDev !== false)
|
|
512
|
+
patterns.push(`${name}.<your-subdomain>.workers.dev`);
|
|
513
|
+
if (wr.previewUrls !== false)
|
|
514
|
+
patterns.push(`<version>-${name}.<your-subdomain>.workers.dev`);
|
|
515
|
+
if (wr.workersDev === false && wr.previewUrls === false)
|
|
516
|
+
evidence.push("workers_dev = false and preview_urls = false: no public *.workers.dev copy");
|
|
517
|
+
else if (wr.workersDev === false)
|
|
518
|
+
evidence.push("workers_dev = false");
|
|
519
|
+
fixText = `Set "workers_dev": false (and "preview_urls": false) in ${wr.rel} once the custom domain works, or have the Worker 301 any *.workers.dev host to the main domain.`;
|
|
520
|
+
const mentions = codeFiles(p).some((f) => /workers\.dev/.test(f.text));
|
|
521
|
+
if (mentions && patterns.length) {
|
|
522
|
+
severity = "low";
|
|
523
|
+
evidence.push("the code mentions workers.dev (a host redirect may already exist)");
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (!provider && (has(p, "vercel.json") || p.seenDirNames.has(".vercel") || adapter(/^@astrojs\/vercel$|^@sveltejs\/adapter-vercel$|^@vercel\/remix$/))) {
|
|
528
|
+
provider = "vercel";
|
|
529
|
+
evidence.push(has(p, "vercel.json") ? "vercel.json" : ".vercel/ folder or Vercel adapter");
|
|
530
|
+
patterns.push(`${name}.vercel.app`, `${name}-*.vercel.app`);
|
|
531
|
+
fixText = `In Vercel → Settings → Domains, make ${name}.vercel.app redirect (308) to the main domain; keep Deployment Protection on previews (they are noindex by default).`;
|
|
532
|
+
}
|
|
533
|
+
if (!provider && (has(p, "netlify.toml") || adapter(/^@astrojs\/netlify$|^@sveltejs\/adapter-netlify$|^@netlify\//) || builder === "bolt")) {
|
|
534
|
+
provider = "netlify";
|
|
535
|
+
evidence.push(has(p, "netlify.toml") ? "netlify.toml" : builder === "bolt" ? "Bolt deploys to Netlify" : "Netlify adapter");
|
|
536
|
+
patterns.push(`${name}.netlify.app`, `deploy-preview-*--${name}.netlify.app`);
|
|
537
|
+
fixText = `Add a domain-level redirect: https://${name}.netlify.app/* https://<your-domain>/:splat 301! (Netlify sets the primary domain automatically once one is added).`;
|
|
538
|
+
}
|
|
539
|
+
if (!provider && has(p, "firebase.json")) {
|
|
540
|
+
provider = "firebase";
|
|
541
|
+
const proj = text(p, ".firebaserc").match(/"default"\s*:\s*"([^"]+)"/)?.[1] ?? name;
|
|
542
|
+
evidence.push("firebase.json");
|
|
543
|
+
patterns.push(`${proj}.web.app`, `${proj}.firebaseapp.com`);
|
|
544
|
+
fixText = `Redirect ${proj}.web.app and ${proj}.firebaseapp.com to the custom domain (Hosting → custom domain → redirect).`;
|
|
545
|
+
}
|
|
546
|
+
if (builder === "lovable") {
|
|
547
|
+
provider ??= "lovable";
|
|
548
|
+
evidence.push("Lovable project");
|
|
549
|
+
patterns.push(`${name}.lovable.app`, "*.lovableproject.com");
|
|
550
|
+
fixText ||= "In Lovable → Project settings → Domains, connect the custom domain and make it primary so the lovable.app address redirects to it.";
|
|
551
|
+
}
|
|
552
|
+
if (builder === "replit") {
|
|
553
|
+
provider ??= "replit";
|
|
554
|
+
evidence.push("Replit project (.replit)");
|
|
555
|
+
patterns.push(`${name}.replit.app`, "*.replit.dev");
|
|
556
|
+
fixText ||= "Link the custom domain in Replit Deployments and redirect the replit.app address to it (or send noindex there).";
|
|
557
|
+
}
|
|
558
|
+
const others = [
|
|
559
|
+
["render.yaml", "render", [`${name}.onrender.com`]],
|
|
560
|
+
["fly.toml", "fly", [`${name}.fly.dev`]],
|
|
561
|
+
["railway.json", "railway", ["*.up.railway.app"]],
|
|
562
|
+
["railway.toml", "railway", ["*.up.railway.app"]],
|
|
563
|
+
["amplify.yml", "aws-amplify", ["*.amplifyapp.com"]],
|
|
564
|
+
["staticwebapp.config.json", "azure-static-web-apps", ["*.azurestaticapps.net"]],
|
|
565
|
+
];
|
|
566
|
+
for (const [file, prov, pats] of others) {
|
|
567
|
+
if (!provider && has(p, file)) {
|
|
568
|
+
provider = prov;
|
|
569
|
+
evidence.push(file);
|
|
570
|
+
patterns.push(...pats);
|
|
571
|
+
fixText = "Redirect the platform's default address to the custom domain (301) or send noindex there.";
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (!provider && (has(p, "CNAME") || list(p, /^\.github\/workflows\/.+\.ya?ml$/).some((f) => /actions\/deploy-pages|peaceiris\/actions-gh-pages/.test(text(p, f))))) {
|
|
575
|
+
provider = "github-pages";
|
|
576
|
+
evidence.push(has(p, "CNAME") ? "CNAME file" : "GitHub Pages workflow");
|
|
577
|
+
patterns.push("<user>.github.io");
|
|
578
|
+
fixText = "GitHub Pages redirects <user>.github.io to the custom domain automatically when CNAME is set; make sure it is.";
|
|
579
|
+
}
|
|
580
|
+
const hostRedirect = codeFiles(p).find((f) => {
|
|
581
|
+
const t = f.text;
|
|
582
|
+
const m = t.match(/\b(?:url|u|request\.url|new URL\([^)]*\))\.(?:host|hostname)\s*!==?\s*[^\n]{1,80}/);
|
|
583
|
+
return !!m && /redirect\(|Response\.redirect|\b30[178]\b/.test(t.slice(m.index, m.index + 400));
|
|
584
|
+
});
|
|
585
|
+
if (hostRedirect && patterns.length) {
|
|
586
|
+
severity = "info";
|
|
587
|
+
evidence.push(`${hostRedirect.rel} redirects requests on other hosts to the canonical host (verify: katman_audit_site preview_urls)`);
|
|
588
|
+
}
|
|
589
|
+
if (patterns.length) {
|
|
590
|
+
findings.push(F("scan-preview-urls", severity, "read", "P11", `Preview/default addresses may serve an indexable copy (${provider})`, `${patterns.join(", ")}. If they stay open after the custom domain is connected, Google sees two copies of the site and may pick the wrong canonical; robots.txt alone doesn't fix it. ${evidence.join("; ")}.`, { fix: fixText, accept: "Each preview/default address 301-redirects to the main domain or answers with noindex (katman_audit_site with preview_urls passes)" }));
|
|
591
|
+
}
|
|
592
|
+
return { provider, previewPatterns: patterns, evidence };
|
|
593
|
+
}
|
|
594
|
+
function staticDirFor(framework, appDir, assetsDir, p) {
|
|
595
|
+
const pre = appDir ? `${appDir}/` : "";
|
|
596
|
+
if (assetsDir)
|
|
597
|
+
return assetsDir;
|
|
598
|
+
switch (framework) {
|
|
599
|
+
case "sveltekit":
|
|
600
|
+
case "hugo":
|
|
601
|
+
case "gatsby":
|
|
602
|
+
case "docusaurus":
|
|
603
|
+
return `${pre}static`;
|
|
604
|
+
case "jekyll":
|
|
605
|
+
case "eleventy":
|
|
606
|
+
return appDir || ".";
|
|
607
|
+
case "vitepress":
|
|
608
|
+
return `${pre}docs/public`;
|
|
609
|
+
case "nuxt":
|
|
610
|
+
return hasDir(p, `${pre}public`) || !hasDir(p, `${pre}static`) ? `${pre}public` : `${pre}static`;
|
|
611
|
+
case "static-html":
|
|
612
|
+
return hasDir(p, `${pre}public`) && has(p, `${pre}public/index.html`) ? `${pre}public` : appDir || ".";
|
|
613
|
+
case "unknown":
|
|
614
|
+
return hasDir(p, `${pre}public`) ? `${pre}public` : hasDir(p, `${pre}static`) ? `${pre}static` : undefined;
|
|
615
|
+
default:
|
|
616
|
+
return `${pre}public`;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
function collectDomains(p, pkg, wr) {
|
|
620
|
+
const out = new Set(wr.domains);
|
|
621
|
+
const cname = text(p, "CNAME").trim();
|
|
622
|
+
if (cname && /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(cname))
|
|
623
|
+
out.add(cname);
|
|
624
|
+
if (pkg?.homepage && /^https?:\/\//.test(pkg.homepage)) {
|
|
625
|
+
try {
|
|
626
|
+
out.add(new URL(pkg.homepage).hostname);
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
/* ignore */
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
for (const rel of list(p, /(^|\/)(astro\.config\.[mc]?[jt]s|next-sitemap\.config\.[mc]?js|nuxt\.config\.[jt]s|svelte\.config\.js|gatsby-config\.[jt]s|docusaurus\.config\.[jt]s)$/)) {
|
|
633
|
+
for (const m of text(p, rel).matchAll(/(?:site|siteUrl|url)\s*:\s*['"`](https?:\/\/[^'"`/]+)/g)) {
|
|
634
|
+
try {
|
|
635
|
+
const h = new URL(m[1]).hostname;
|
|
636
|
+
if (!/localhost|example\.com|127\.0\.0\.1/.test(h))
|
|
637
|
+
out.add(h);
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
/* ignore */
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return [...out].filter((d) => !/^www\./.test(d) || !out.has(d.slice(4)));
|
|
645
|
+
}
|