Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions app/(app)/projects/[id]/stats/auto-install.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function AutoInstall({
}: AutoInstallProps) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [rootPath, setRootPath] = useState("");
const [submitting, setSubmitting] = useState<string | null>(null);
const [result, setResult] = useState<{ repo: string; result: InstallResult } | null>(null);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -77,6 +78,7 @@ export function AutoInstall({
owner,
repo: name,
installation_id: repo.installation_id,
root_path: rootPath.trim() || undefined,
}),
},
);
Expand Down Expand Up @@ -172,6 +174,23 @@ export function AutoInstall({
)}
</div>

<details className="mt-2 text-sm">
<summary className="cursor-pointer text-xs text-[var(--color-muted)]">
Subdirectory (optional, for monorepos)
</summary>
<input
type="text"
value={rootPath}
onChange={(e) => setRootPath(e.target.value)}
placeholder="apps/web"
className="mt-2 w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-[var(--color-muted)]">
Leave blank to auto-detect. Set if your site lives in a
monorepo subdir like <code>apps/web</code>.
</p>
</details>

<ul className="mt-3 max-h-72 overflow-y-auto divide-y divide-[var(--color-border)] rounded-md border border-[var(--color-border)]">
{filtered.length === 0 ? (
<li className="p-3 text-sm text-[var(--color-muted)]">
Expand Down
4 changes: 4 additions & 0 deletions app/api/projects/[id]/github/install-tracker/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ const bodySchema = z.object({
owner: z.string().min(1),
repo: z.string().min(1),
installation_id: z.number().int().positive(),
/** Optional subdirectory inside the repo where the app lives,
* e.g. "apps/web" or "sites/sh1pt.com". */
root_path: z.string().max(500).optional(),
});

export async function POST(
Expand Down Expand Up @@ -94,6 +97,7 @@ export async function POST(
owner: body.owner,
repo: body.repo,
projectId,
rootPath: body.root_path,
});
await finalize({
status: result.status,
Expand Down
75 changes: 64 additions & 11 deletions lib/github/install-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,35 @@ const CANDIDATES: string[] = [

const BRANCH_PREFIX = "crawlproof/install-stats-tracker";

function snippetFor(projectId: string): string {
function rawScriptTag(projectId: string): string {
return `<script data-site="${projectId}" src="${env.siteUrl.replace(/\/$/, "")}/stats.js" async></script>`;
}

function nextScriptTag(projectId: string): string {
return `<Script data-site="${projectId}" src="${env.siteUrl.replace(/\/$/, "")}/stats.js" strategy="afterInteractive" />`;
}

/**
* Choose the right snippet shape for a target file. For Next.js TSX/JSX
* layouts we use the <Script> component (next/script) — idiomatic and
* avoids hydration warnings about an inline <script>. Everything else
* gets a plain HTML <script> tag.
*/
function snippetForPath(projectId: string, path: string): string {
return /\.(tsx|jsx)$/.test(path)
? nextScriptTag(projectId)
: rawScriptTag(projectId);
}

/**
* Return content with the snippet inserted before the first </body>, or
* null if there's no </body> in the file.
* null if there's no </body> in the file. Adds `import Script from "next/script"`
* at the top of TSX/JSX files when needed.
*/
function injectBeforeBodyClose(
content: string,
snippet: string,
path: string,
): string | null {
const re = /<\/body>/i;
const match = content.match(re);
Expand All @@ -80,7 +98,36 @@ function injectBeforeBodyClose(
const prefix = content.slice(0, idx);
const lineStart = prefix.lastIndexOf("\n") + 1;
const indent = prefix.slice(lineStart).match(/^\s*/)?.[0] ?? "";
return `${prefix}${indent} ${snippet}\n${indent}${content.slice(idx)}`;
let updated = `${prefix}${indent} ${snippet}\n${indent}${content.slice(idx)}`;

// For Next.js layouts: add `import Script from "next/script"` if it
// isn't already imported. Skip if the file already pulls Script in
// from any source.
if (/\.(tsx|jsx)$/.test(path) && /<Script\b/.test(snippet)) {
if (!/from\s+["']next\/script["']/.test(updated)) {
updated = addNextScriptImport(updated);
}
}
return updated;
}

/**
* Insert `import Script from "next/script";` after the last top-level
* import statement. Falls back to the top of the file if none.
*/
function addNextScriptImport(content: string): string {
const importLine = `import Script from "next/script";\n`;
// Find the line index of the LAST line that starts with `import `.
const lines = content.split("\n");
let lastImportLine = -1;
for (let i = 0; i < lines.length; i++) {
if (/^import\b/.test(lines[i])) lastImportLine = i;
}
if (lastImportLine === -1) {
return importLine + content;
}
lines.splice(lastImportLine + 1, 0, importLine.replace(/\n$/, ""));
return lines.join("\n");
}

/** Strip leading/trailing slashes so we can confidently join with "/". */
Expand All @@ -96,7 +143,6 @@ export async function installTracker(input: InstallInput): Promise<InstallResult
repo: input.repo,
});
const base = repoMeta.default_branch;
const snippet = snippetFor(input.projectId);
const projectIdMarker = `data-site="${input.projectId}"`;

const root = normalizeRoot(input.rootPath);
Expand Down Expand Up @@ -195,8 +241,10 @@ export async function installTracker(input: InstallInput): Promise<InstallResult
);
}

// 2. Compute the new content.
const updated = injectBeforeBodyClose(target.content, snippet);
// Compute the new content. Snippet shape (Next.js <Script> vs raw
// <script>) depends on the target file extension.
const snippet = snippetForPath(input.projectId, target.path);
const updated = injectBeforeBodyClose(target.content, snippet, target.path);
if (!updated) {
throw new Error(
`Could not locate </body> in ${target.path} after second pass.`,
Expand Down Expand Up @@ -234,7 +282,7 @@ export async function installTracker(input: InstallInput): Promise<InstallResult
head: branch,
base,
title: "Add CrawlProof stats tracker",
body: prBody(input.projectId, target.path),
body: prBody(input.projectId, target.path, snippet),
});

return {
Expand All @@ -247,16 +295,21 @@ export async function installTracker(input: InstallInput): Promise<InstallResult
};
}

function prBody(projectId: string, path: string): string {
function prBody(projectId: string, path: string, snippet: string): string {
const isNextScript = /<Script\b/.test(snippet);
void projectId;
const importNote = isNextScript
? "\n\nThe diff also imports `Script` from `next/script` if it wasn't already imported.\n"
: "";
return `This PR adds the [CrawlProof](https://crawlproof.com) stats tracker to your site.

**What it does:** counts pageviews by source — AI engine referrals (ChatGPT, Perplexity, Claude, Gemini…) and AI crawler hits (GPTBot, ClaudeBot, PerplexityBot…). No cookies. No PII. Rolls up to a daily counter on the CrawlProof Stats tab for your project.

**What changed:** one line added to \`${path}\`, just before \`</body>\`:

\`\`\`html
<script data-site="${projectId}" src="${env.siteUrl}/stats.js" async></script>
\`\`\`
\`\`\`${isNextScript ? "tsx" : "html"}
${snippet}
\`\`\`${importNote}

**Docs:** ${env.siteUrl}/docs/stats-tracker
**Disable:** flip the tracker off on your CrawlProof project Stats tab and the script becomes a no-op (or remove this line).`;
Expand Down
Loading